reportEngine:dcp报告支持

This commit is contained in:
zhugongping 2026-10-05 09:09:10 +08:00
parent 5d8ae0e2f0
commit 694f48acd1
11 changed files with 633 additions and 22 deletions

View File

@ -232,3 +232,13 @@ map:
gaode_ak: 3af85be39e2f28334097781349336157
gaode_secret: d0561ad20142e74f8c59edc5deafdbe4
tianditu_ak: b920e6076a06aa279c974f5f76cf746e
# 设备监测报告导出配置(全局一刀切,test/prod 环境共用)
report:
# 模板编码(取值即 templates/ 下的模板名,V2 为按分类取默认的版本标记):
# 留空 或 V2 → 完整版,按监测分类走 DEVICE_{CATEGORY}_V2(共 10 节)
# DCPReport → DCP 平台专用三节版式(项目概况 / 测点数据 / 时程曲线),
# 测点表只输出 ThingsThreshold.is_primary=1 的主监测指标
# 默认保持完整版(V2):切换版式是交付格式变更,需现场确认后由运维显式改为 DCPReport
template-code: V2

View File

@ -313,6 +313,10 @@ public abstract class AbstractDeviceAnalyzer implements ReportAnalyzer {
? null : primaryIdentifiers.get(0);
result.setPrimaryIdentifier(primaryIdentity);
// 4.1 用户显式勾选的主监测指标(is_primary=1):与上面的推断结果是两个口径,原样透传,
// 供精简版报告的测点表严格按用户配置出列(没勾就是空,不做关键词兜底)
result.setExplicitPrimaryIdentifiers(resolveExplicitPrimary(context, primaryIdentifiers));
// 5. 组装测点数据(主指标排第一,辅助量作为影响量保留)
result.setMetricPoints(buildMetricPointsFiltered(result, context, dataByIdentity, statisticsDurationSeconds));
@ -491,6 +495,41 @@ public abstract class AbstractDeviceAnalyzer implements ReportAnalyzer {
return set;
}
/**
* 解析「用户显式勾选的主监测指标」({@code is_primary=1}),供精简版报告出表用
*
* <p>严格取 {@link ReportContext#getExplicitPrimaryIdentifiers()},<b>不做任何推断兜底</b>:
* 用户没勾就是空列表,精简版测点表据此输出"未配置主监测指标"提示行,而不是偷偷用
* 关键词推断的指标顶替 —— 否则报告里会出现用户从未配置、也没预期看到的列。</p>
*
* <p>顺序:沿用 {@code primaryIdentifiers} 的物模型顺序(Resolver 收集显式项时即按此序),
* 保证与完整版报告的列顺序一致,便于两份报告横向对照。</p>
*
* <p>不过滤「统计期内无数据」的显式项:该列保留并显示 {@code -},让缺测可见;
* 若在此处剔除,报告会把「配了但没上报」伪装成「没配」。</p>
*/
private List<String> resolveExplicitPrimary(ReportContext context, List<String> primaryIdentifiers) {
Set<String> explicit = context.getExplicitPrimaryIdentifiers();
if (explicit == null || explicit.isEmpty()) {
return new ArrayList<>();
}
List<String> ordered = new ArrayList<>();
if (primaryIdentifiers != null) {
for (String id : primaryIdentifiers) {
if (explicit.contains(id) && !ordered.contains(id)) {
ordered.add(id);
}
}
}
// 理论不会走到这里(Resolver 显式分支必然收齐全部勾选项),保底避免静默丢列
for (String id : explicit) {
if (!ordered.contains(id)) {
ordered.add(id);
}
}
return ordered;
}
/**
* 判断统计值是否全为 0(或无效)
*/

View File

@ -47,9 +47,6 @@ public class ReportRequest implements Serializable {
@ApiModelProperty("周期锚点日期 yyyy-MM-dd(默认今天,仅周期预设时使用)")
private String anchorDate;
@ApiModelProperty("模板编码,如 DEVICE_CRACK_METER_V2")
private String templateCode;
@ApiModelProperty("导出格式(目前仅支持 WORD)")
private String format;
}

View File

@ -0,0 +1,73 @@
package com.fastbee.report.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* 报告导出配置
*
* <p>只控制「用哪一套模板」,全局一刀切。</p>
*
* <p><b>模板版式</b>(2026-10-04 起只有两种,不要按字面值硬判断):</p>
* <ul>
* <li><b>V2</b>:完整版,按监测分类取 {@code DEVICE_{CATEGORY}_V2},共 10 节;</li>
* <li><b>DCPReport</b>:DCP 平台专用的三节版式(项目概况 / 测点数据 / 时程曲线),
* 测点表只输出 {@code iot_things_threshold.is_primary=1} 的主监测指标。
* 全分类共用一份 {@code templates/DCPReport.docx},首行标题用
* {@code {{device.categoryDesc}}} 占位,因此不再按分类拆分。</li>
* </ul>
*
* <p><b>为什么叫 DCPReport</b>:这套三节版式是 DCP 平台(平台实时数据推送,见
* {@code com.fastbee.iot.platform.adapter.dcp})要求交付的报告格式,命名与用途对齐,
* 便于日后回溯「为什么报告有两套版式」。注意与推送侧的 DCP 适配器区分:本常量属
* <b>报告导出</b>,彼处属<b>数据推送</b>,两者是不同业务线。</p>
*
* <p><b>为什么不按分类/产品白名单</b>:报告模板是现场交付物,格式要求由业主一次性约定,
* 不会一部分产品要三节版、一部分要完整版。全局开关让「改配置即全量生效」,
* 也避免白名单长期无人维护、导致部分设备悄悄导出错版本。</p>
*
* @author FastBee
* @date 2026-10-04
*/
@Data
@Component
@ConfigurationProperties(prefix = "report")
public class ReportProperties {
/** 完整版模板的版本标记:配置值等于它(或为空)时按监测分类取默认模板 */
public static final String FULL_VERSION_CODE = "V2";
/**
* DCP 平台三节版式的模板编码,与 {@code templates/DCPReport.docx} 文件名严格一致
*/
public static final String DCP_REPORT_TEMPLATE_CODE = "DCPReport";
/**
* 报告模板编码
*
* <p>取值:空 / {@code V2} → 完整版;{@code DCPReport} → DCP 三节版式。</p>
*/
private String templateCode;
/**
* 是否启用 DCP 三节版式(只出项目概况、isPrimary 测点表、时程曲线)
*
* <p>唯一判定入口。装配器与渲染层都只依赖这个方法,不各自比对模板编码字符串 ——
* 早期版本把「是否只出主指标」硬绑在 {@code templateCode == "DEVICE_SUMMARY"} 上,
* 一旦模板改名,判定会静默失效(不报错,辅助量列又冒出来),是必须避免的耦合。</p>
*/
public boolean isDcpReportMode() {
return DCP_REPORT_TEMPLATE_CODE.equalsIgnoreCase(trimmed());
}
/**
* 实际生效的模板编码
*
* <p>用于 {@code ReportModel.templateCode} 落值与日志;非 DCP 版式返回 {@code V2},
* 由调用方拼分类前缀得到具体文件名。</p>
*/
public String trimmed() {
return templateCode == null ? "" : templateCode.trim();
}
}

View File

@ -107,6 +107,9 @@ public class MultiDeviceAnalysisMerger {
merged.setPrimaryIdentifier(base.getPrimaryIdentifier());
// 多主指标:沿用最不利测点的主指标集合(同一产品各测点物模型一致,主指标集合也应一致)
merged.setPrimaryIdentifiers(base.getPrimaryIdentifiers());
// 用户显式勾选的主监测指标(is_primary=1):同样取代表测点,
// 漏传会让精简版多测点报告误判为"未配置主监测指标"而出提示行
merged.setExplicitPrimaryIdentifiers(base.getExplicitPrimaryIdentifiers());
merged.setSummary(base.getSummary());
merged.setProfessionalAdvice(base.getProfessionalAdvice());
merged.setSafetyLevel(base.getSafetyLevel());

View File

@ -3,11 +3,14 @@ package com.fastbee.report.core;
import com.fastbee.common.enums.ReportPeriod;
import com.fastbee.common.utils.DateUtils;
import com.fastbee.common.utils.StringUtils;
import com.fastbee.report.config.ReportProperties;
import com.fastbee.report.model.AnalysisResult;
import com.fastbee.report.model.ReportModel;
import com.fastbee.report.model.Statistics;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.util.List;
import java.util.UUID;
/**
@ -19,6 +22,9 @@ import java.util.UUID;
@Component
public class ReportAssembler {
@Resource
private ReportProperties reportProperties;
public ReportModel assemble(ReportContext context, AnalysisResult analysis) {
return assemble(context, analysis, 1);
}
@ -27,8 +33,8 @@ public class ReportAssembler {
* @param pointCount 参与分析的测点数(&gt;1 时为多测点报告,设备信息展示测点规模)
*/
public ReportModel assemble(ReportContext context, AnalysisResult analysis, int pointCount) {
// 默认使用专业模版,如未配置 templateCode 则根据监测分类自动选择
String templateCode = "DEVICE_" + context.getCategory().name() + "_V2";
// 模板选择:DCP 版式为全局配置(report.template-code),不拼分类后缀,全分类共用一份模板
String templateCode = resolveTemplateCode(context);
ReportPeriod period = context.getTimeWindow().getPeriod();
String serialNumber = context.getDevice().getSerialNumber();
@ -55,7 +61,7 @@ public class ReportAssembler {
}
// 多主指标:收集全部主指标名称(水位、水温…),供头部「监测指标」字段并列展示
java.util.List<String> metricNames = collectMetricNames(analysis, primary);
List<String> metricNames = collectMetricNames(analysis, primary);
// 添加监测分类描述
String categoryDesc = context.getCategory().getDesc() + "监测分析报告";
@ -76,18 +82,42 @@ public class ReportAssembler {
.metricName(primary == null ? null : primary.getModelName())
.metricNames(metricNames)
.metricUnit(primary == null ? null : primary.getUnit())
// DCP 版式:测点表只出 is_primary=1 的主监测指标(未勾选时由渲染层输出提示行)
.primaryOnly(ReportProperties.DCP_REPORT_TEMPLATE_CODE.equals(templateCode))
.analysis(analysis)
.build();
}
/**
* 解析报告模板编码
*
* <p>取值优先级:</p>
* <ol>
* <li><b>配置 {@code report.template-code=DCPReport}</b> → DCP 平台三节版式,
* 全分类共用一份 {@code templates/DCPReport.docx},不拼分类后缀;</li>
* <li>配空或配成 {@code V2} → 按监测分类取完整版 {@code DEVICE_{CATEGORY}_V2}。</li>
* </ol>
*
* <p><b>为什么不读 {@code request.templateCode}</b>:那是历史字段,从未被真正使用过
* (此前本行是硬编码),前端也从未传值。放开它等于给外部一个可任意指定 classpath
* 模板文件名的入口,而报告模板与渲染策略强耦合(动态表列、图表位、poi-tl 策略绑定),
* 传错编码会直接渲染失败或产出错版式。格式是现场交付物,由运维改配置决定即可。</p>
*/
private String resolveTemplateCode(ReportContext context) {
if (reportProperties != null && reportProperties.isDcpReportMode()) {
return ReportProperties.DCP_REPORT_TEMPLATE_CODE;
}
return "DEVICE_" + context.getCategory().name() + "_" + ReportProperties.FULL_VERSION_CODE;
}
/**
* 收集全部主监测指标名称(有序)
*
* <p>多主指标(primaryIdentifiers 非空且 &gt;1)时返回全部名称;否则退化为单值 metricName。</p>
*/
private java.util.List<String> collectMetricNames(AnalysisResult analysis, Statistics primary) {
java.util.List<String> names = new java.util.ArrayList<>();
java.util.List<String> ids = analysis.getPrimaryIdentifiers();
private List<String> collectMetricNames(AnalysisResult analysis, Statistics primary) {
List<String> names = new java.util.ArrayList<>();
List<String> ids = analysis.getPrimaryIdentifiers();
if (ids != null && !ids.isEmpty()) {
for (String id : ids) {
Statistics s = analysis.getStatistics().get(id);

View File

@ -37,6 +37,24 @@ public class AnalysisResult {
*/
private List<String> primaryIdentifiers;
/**
* 用户显式勾选的主监测指标标识符(有序,来自 {@code iot_things_threshold.is_primary})
*
* <p><b>与 {@link #primaryIdentifiers} 的区别,这是两个不同口径,不可互相替代:</b></p>
* <ul>
* <li>{@code primaryIdentifiers} 是三级漏斗的产物(isPrimary → 关键词推断 → 排序兜底),
* 零配置也能给出一个值;</li>
* <li>{@code explicitPrimaryIdentifiers} 只包含用户在阈值配置页真真切切勾选为
* {@code is_primary=1} 的物模型,<b>没配就是空,不做任何推断</b>。</li>
* </ul>
*
* <p>精简版报告({@code report.template-code=DCPReport})的测点表只输出本集合中的物模型,
* 因此必须与用户配置严格一致 —— 用关键词推断的结果冒充实配会输出用户没勾的指标。</p>
*
* <p>多测点归并时取代表测点(最不利测点)的集合,与 {@link #primaryIdentifiers} 同源同口径。</p>
*/
private List<String> explicitPrimaryIdentifiers;
/** 项目概况总结(开头段落,概括关键数据) */
private String summary;

View File

@ -61,6 +61,18 @@ public class ReportModel {
/** 监测分类描述(如"裂缝计监测分析报告") */
private String categoryDesc;
/**
* 测点表是否只输出主监测指标(DCP 三节版式)
*
* <p>为 true 时,测点表列只由 {@link AnalysisResult#getExplicitPrimaryIdentifiers()}
* ({@code iot_things_threshold.is_primary=1})生成,非主监测指标(温度、电压、GPS…)
* <b>整列不出现</b>;该集合为空时表体输出"未配置主监测指标"提示行。</p>
*
* <p>为 false(完整版 V2)时沿用原口径:主指标出完整四列,其余物模型合并为
* 一列「本次值(变化量)」保留影响量,用于分析辅助物理量的影响。</p>
*/
private boolean primaryOnly;
/** 控制值(安全参考基准) */
private BigDecimal controlValue;

View File

@ -254,6 +254,8 @@ public class WordRenderer implements ReportRenderer {
device.put("productName", model.getProductName());
device.put("metricName", metricNameText(model));
device.put("metricUnit", model.getMetricUnit() == null ? "" : model.getMetricUnit());
// 精简版模板首行标题用占位符(单份模板服务全部分类,不硬编码分类名)
device.put("categoryDesc", model.getCategoryDesc() == null ? "" : model.getCategoryDesc());
Map<String, Object> statistics = new HashMap<>();
if (primary != null) {
@ -354,7 +356,8 @@ public class WordRenderer implements ReportRenderer {
}
data.put("metricPoints", metricPointsList);
// 动态列测点表:列由导出设备的物模型(物理量)决定,一行一个测点(iot_device)
data.put("metricTable", buildMetricTable(analysis, model.getCategory()));
// 精简版(primaryOnly)只出 is_primary=1 的主监测指标列,其余物模型整列不生成
data.put("metricTable", buildMetricTable(analysis, model.getCategory(), model.isPrimaryOnly()));
// 图表:兼容 timeSeriesChart(主指标)/ chart2 / chart3(分组指标)/ crackTrend(旧模板)
Map<String, Object> charts = new HashMap<>();
@ -487,8 +490,13 @@ public class WordRenderer implements ReportRenderer {
*
* <p>数值精度与正文一致,按监测类别取 {@link ValueFormatter} 配置(静力水准 2 位),
* 避免 6 位小数把列撑宽导致内容折行。</p>
*
* @param primaryOnly 精简版模式:列只由 {@code explicitPrimaryIdentifiers}(is_primary=1)生成,
* 非主监测指标整列不出现;为空时输出未配置提示行。
* 完整版为 false,沿用「主指标 + 辅助量合并列」原口径。
*/
private TableRenderData buildMetricTable(AnalysisResult analysis, MonitorCategory category) {
private TableRenderData buildMetricTable(AnalysisResult analysis, MonitorCategory category,
boolean primaryOnly) {
List<MetricPointData> points = analysis.getMetricPoints() == null
? new ArrayList<>() : analysis.getMetricPoints();
if (points.isEmpty()) {
@ -506,8 +514,25 @@ public class WordRenderer implements ReportRenderer {
}
// 2. 列顺序:主指标优先,其余按首次出现顺序(全测点统一,便于横向对比)
// 精简版:只保留用户显式勾选(is_primary=1)的主监测指标,口径与配置严格一致
List<String> orderedIdentifiers = new ArrayList<>();
Set<String> primarySet = new java.util.LinkedHashSet<>();
if (primaryOnly) {
List<String> explicit = analysis.getExplicitPrimaryIdentifiers();
if (explicit == null || explicit.isEmpty()) {
// 没有勾选主监测指标:明确提示,不回退到关键词推断结果
return Tables.ofA4Width()
.addRow(Rows.of("未配置主监测指标(请在阈值配置页勾选「主监测指标」后重新导出)")
.center().textFontSize(9).create())
.create();
}
for (String id : explicit) {
if (id != null && !orderedIdentifiers.contains(id)) {
orderedIdentifiers.add(id);
primarySet.add(id);
}
}
} else {
if (analysis.getPrimaryIdentifiers() != null && !analysis.getPrimaryIdentifiers().isEmpty()) {
for (String id : analysis.getPrimaryIdentifiers()) {
if (id != null && !orderedIdentifiers.contains(id)) {
@ -526,6 +551,15 @@ public class WordRenderer implements ReportRenderer {
orderedIdentifiers.add(point.getIdentifier());
}
}
}
// 2b. 表格代表指标:决定尾部「初始观测时间」「监测结论」两列以哪个物模型为准
// 精简版必须用 isPrimary 集合的首个(用户配置口径),
// 不能用 analysis.primaryIdentifier(那是关键词推断值,可能根本不在 isPrimary 集合里,
// 会导致这两列指向用户没勾的物模型)
String primaryIdentifier = primaryOnly
? orderedIdentifiers.get(0)
: analysis.getPrimaryIdentifier();
boolean hasSection = points.stream()
.anyMatch(p -> p.getSectionName() != null && !p.getSectionName().trim().isEmpty());

View File

@ -0,0 +1,395 @@
package com.fastbee.report.renderer;
import com.fastbee.common.enums.MonitorCategory;
import com.fastbee.common.enums.ReportPeriod;
import com.fastbee.report.chart.ChartGenerator;
import com.fastbee.report.config.ReportProperties;
import com.fastbee.report.core.ReportAssembler;
import com.fastbee.report.data.MetricData;
import com.fastbee.report.model.AnalysisResult;
import com.fastbee.report.model.MetricPointData;
import com.fastbee.report.model.QualityAnalysis;
import com.fastbee.report.model.ReportModel;
import com.fastbee.report.model.Statistics;
import com.fastbee.report.model.TrendAnalysis;
import com.fastbee.report.model.WarningAnalysis;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.io.InputStream;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* DCP 平台三节版式报告({@code DCPReport})渲染测试
*
* <p>覆盖三件事:① DCP 模板可渲染且只含三节;② 测点表只出 {@code is_primary=1} 的列,
* 辅助量整列不出现;③ 未勾选主监测指标时输出提示行而非回退到关键词推断结果。</p>
*
* @author FastBee
* @date 2026-10-04
*/
public class WordRendererDcpReportTemplateTest {
/** 固定模板解析器:按编码加载 templates/ 下的真实模板 */
private WordRenderer newRenderer() {
WordRenderer renderer = new WordRenderer();
ReflectionTestUtils.setField(renderer, "templateResolver", new WordTemplateResolver() {
@Override
public byte[] resolve(String templateCode) {
try (InputStream in = getClass().getClassLoader()
.getResourceAsStream("templates/" + templateCode + ".docx")) {
if (in == null) {
throw new IllegalStateException("模板不存在: " + templateCode);
}
return cn.hutool.core.io.IoUtil.readBytes(in);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
});
return renderer;
}
@Test
public void testRenderDcpReportTemplate() {
ReportModel model = buildModel(Arrays.asList("crackWidth"));
byte[] docx = newRenderer().render(model);
assertNotNull(docx);
assertTrue(docx.length > 0);
String magic = new String(new byte[]{docx[0], docx[1]}, StandardCharsets.ISO_8859_1);
assertTrue("PK".equals(magic), "渲染结果应为有效 docx(zip)");
try {
java.io.File out = new java.io.File("target/test-output-dcp-report.docx");
java.nio.file.Files.write(out.toPath(), docx);
System.out.println("DCP 版式渲染结果已输出: " + out.getAbsolutePath());
} catch (Exception ignored) {
// 输出失败不影响断言
}
}
/** DCP 模板只需一份,服务全部监测分类(首行标题用 {{device.categoryDesc}} 占位) */
@Test
public void testDcpReportTemplateExists() {
// 常量必须与 templates/ 下的文件名严格一致:解析器按 templateCode 拼文件名找资源,
// 常量与文件任一侧改名都会在运行时抛「报告模板不存在」,这里提前挡住
assertEquals("DCPReport.docx",
ReportProperties.DCP_REPORT_TEMPLATE_CODE + ".docx",
"常量与模板文件名不一致,改名时需同步");
try (InputStream in = getClass().getClassLoader()
.getResourceAsStream("templates/DCPReport.docx")) {
assertNotNull(in, "DCP 版式模板缺失: DCPReport.docx");
} catch (Exception e) {
throw new RuntimeException(e);
}
}
/** 配置切到 DCPReport 时,装配出的模板编码与 primaryOnly 标记都正确 */
@Test
public void testAssemblerResolvesDcpReportTemplate() {
ReportAssembler assembler = new ReportAssembler();
ReflectionTestUtils.setField(assembler, "reportProperties", dcpReportProperties());
com.fastbee.report.core.ReportContext context = new com.fastbee.report.core.ReportContext();
context.setCategory(MonitorCategory.CRACK_METER);
com.fastbee.iot.domain.Device device = new com.fastbee.iot.domain.Device();
device.setDeviceId(1L);
device.setDeviceName("裂缝计1");
device.setSerialNumber("SN001");
context.setDevice(device);
com.fastbee.iot.domain.Product product = new com.fastbee.iot.domain.Product();
product.setProductId(1L);
product.setProductName("裂缝计");
context.setProduct(product);
com.fastbee.report.period.TimeWindow window = new com.fastbee.report.period.TimeWindow();
window.setPeriod(ReportPeriod.CUSTOM);
context.setTimeWindow(window);
context.setReportNo("REPORT-TEST-0001");
ReportModel model = assembler.assemble(context, buildAnalysis(Arrays.asList("crackWidth")), 1);
assertEquals(ReportProperties.DCP_REPORT_TEMPLATE_CODE, model.getTemplateCode());
assertTrue(model.isPrimaryOnly(), "DCP 版式必须置 primaryOnly,测点表才只出主监测指标");
}
/** 配置切回 V2 时,仍按监测分类取完整版模板,且 primaryOnly=false(原行为不变) */
@Test
public void testAssemblerFallsBackToFullTemplate() {
ReportAssembler assembler = new ReportAssembler();
ReportProperties properties = new ReportProperties();
properties.setTemplateCode(ReportProperties.FULL_VERSION_CODE);
ReflectionTestUtils.setField(assembler, "reportProperties", properties);
com.fastbee.report.core.ReportContext context = new com.fastbee.report.core.ReportContext();
context.setCategory(MonitorCategory.CRACK_METER);
com.fastbee.iot.domain.Device device = new com.fastbee.iot.domain.Device();
device.setDeviceId(1L);
device.setDeviceName("裂缝计1");
context.setDevice(device);
com.fastbee.iot.domain.Product product = new com.fastbee.iot.domain.Product();
product.setProductId(1L);
product.setProductName("裂缝计");
context.setProduct(product);
com.fastbee.report.period.TimeWindow window = new com.fastbee.report.period.TimeWindow();
window.setPeriod(ReportPeriod.CUSTOM);
context.setTimeWindow(window);
context.setReportNo("REPORT-TEST-0002");
ReportModel model = assembler.assemble(context, buildAnalysis(Arrays.asList("crackWidth")), 1);
assertEquals("DEVICE_CRACK_METER_V2", model.getTemplateCode());
assertTrue(!model.isPrimaryOnly(), "完整版必须保持原有口径:主指标 + 辅助量合并列");
}
/** isPrimary 未勾选时,DCP 版式必须渲染出提示行而不是回退到关键词推断的主指标 */
@Test
public void testDcpReportWithoutExplicitPrimaryShowsHint() {
ReportModel model = buildModel(new ArrayList<>());
model.setTemplateCode(ReportProperties.DCP_REPORT_TEMPLATE_CODE);
byte[] docx = newRenderer().render(model);
assertTrue(docx.length > 0, "未配置主监测指标时也应产出可读文档");
}
/**
* 完整版回归:{@code primaryOnly=false} 时辅助量仍按「本次值(变化量)」合并列出现
*
* <p>这是本次改造最危险的回归点 —— DCP 版式过滤逻辑不能误伤完整版原有行为。</p>
*/
@Test
public void testFullVersionKeepsAuxiliaryColumns() throws Exception {
ReportModel model = buildModel(Arrays.asList("crackWidth"));
model.setTemplateCode("DEVICE_CRACK_METER_V2");
model.setPrimaryOnly(false);
byte[] docx = newRenderer().render(model);
assertTrue(docx.length > 0);
String xml = readDocumentXml(docx);
// 主指标出完整四列
assertTrue(xml.contains("裂缝宽度·初始值(mm)"), "完整版主指标应出四列");
// 辅助量仍在(合并列),未被DCP 版式逻辑误删
assertTrue(xml.contains("裂缝温度·本次值(变化量)"), "完整版辅助量合并列必须保留");
assertTrue(xml.contains("设备电压·本次值(变化量)"), "完整版辅助量合并列必须保留");
}
/** DCP 版式回归:辅助量列不得出现 */
@Test
public void testDcpReportDropsAuxiliaryColumns() throws Exception {
ReportModel model = buildModel(Arrays.asList("crackWidth"));
byte[] docx = newRenderer().render(model);
String xml = readDocumentXml(docx);
assertTrue(xml.contains("裂缝宽度·初始值(mm)"), "主指标列应保留");
assertTrue(!xml.contains("裂缝温度·本次值(变化量)"), "DCP 版式不得输出辅助量列");
assertTrue(!xml.contains("设备电压·本次值(变化量)"), "DCP 版式不得输出辅助量列");
// 被裁掉的章节不得残留
assertTrue(!xml.contains("安全等级"), "DCP 版式不应有安全评估章节");
assertTrue(!xml.contains("结论内容"), "DCP 版式不应有结论表格");
}
/** 从 docx 字节中读出 document.xml 文本 */
private String readDocumentXml(byte[] docx) throws Exception {
try (java.util.zip.ZipInputStream zin = new java.util.zip.ZipInputStream(
new java.io.ByteArrayInputStream(docx))) {
java.util.zip.ZipEntry entry;
while ((entry = zin.getNextEntry()) != null) {
if ("word/document.xml".equals(entry.getName())) {
return new String(cn.hutool.core.io.IoUtil.readBytes(zin), StandardCharsets.UTF_8);
}
}
}
throw new IllegalStateException("docx 中未找到 word/document.xml");
}
private ReportProperties dcpReportProperties() {
ReportProperties properties = new ReportProperties();
properties.setTemplateCode(ReportProperties.DCP_REPORT_TEMPLATE_CODE);
return properties;
}
/**
* 构造模型:产品配了 3 个物模型(裂缝宽度=主监测指标、裂缝温度、设备电压),
* 但 {@code explicitPrimaryIdentifiers} 只含用户实际勾选的项。
*/
private ReportModel buildModel(List<String> explicitPrimary) {
AnalysisResult analysis = buildAnalysis(explicitPrimary);
return ReportModel.builder()
.reportNo("REPORT-20261004120000-0001")
.templateCode(ReportProperties.DCP_REPORT_TEMPLATE_CODE)
.fileName("裂缝计-监测分析报告-abc123.docx")
.periodDesc("2026-10-01 00:00:00 ~ 2026-10-04 00:00:00")
.period(ReportPeriod.CUSTOM)
.generatedAt(new Date())
.deviceName("裂缝计_1")
.serialNumber("SN20261004001")
.productName("裂缝计")
.deviceId(20001L)
.category(MonitorCategory.CRACK_METER)
.categoryDesc("裂缝计监测分析报告")
.metricName("裂缝宽度")
.metricNames(Arrays.asList("裂缝宽度"))
.metricUnit("mm")
.primaryOnly(true)
.analysis(analysis)
.build();
}
private AnalysisResult buildAnalysis(List<String> explicitPrimary) {
Statistics crack = new Statistics();
crack.setIdentifier("crackWidth");
crack.setModelName("裂缝宽度");
crack.setUnit("mm");
crack.setCount(144);
crack.setCurrent(new BigDecimal("0.382"));
crack.setInitial(new BigDecimal("0.350"));
crack.setMax(new BigDecimal("0.405"));
crack.setMin(new BigDecimal("0.348"));
crack.setAvg(new BigDecimal("0.371"));
crack.setMedian(new BigDecimal("0.370"));
crack.setDelta(new BigDecimal("0.032"));
crack.setTotalDelta(new BigDecimal("0.057"));
// 辅助量:非主监测指标,DCP 版式下整列不出现
Statistics temp = new Statistics();
temp.setIdentifier("crackTemp");
temp.setModelName("裂缝温度");
temp.setUnit("℃");
temp.setCount(144);
temp.setCurrent(new BigDecimal("18.5"));
temp.setInitial(new BigDecimal("18.1"));
temp.setDelta(new BigDecimal("0.4"));
Statistics voltage = new Statistics();
voltage.setIdentifier("voltage");
voltage.setModelName("设备电压");
voltage.setUnit("V");
voltage.setCount(144);
voltage.setCurrent(new BigDecimal("12.6"));
voltage.setInitial(new BigDecimal("12.5"));
voltage.setDelta(new BigDecimal("0.1"));
AnalysisResult analysis = new AnalysisResult();
analysis.setPrimaryIdentifier("crackWidth");
analysis.setPrimaryIdentifiers(Arrays.asList("crackWidth"));
analysis.setExplicitPrimaryIdentifiers(explicitPrimary);
analysis.getStatistics().put("crackWidth", crack);
analysis.getStatistics().put("crackTemp", temp);
analysis.getStatistics().put("voltage", voltage);
TrendAnalysis trend = new TrendAnalysis();
trend.setIdentifier("crackWidth");
trend.setModelName("裂缝宽度");
trend.setDirection(TrendAnalysis.DIRECTION_UP);
trend.setDescription("整体呈增大趋势,日变化速率 0.0008mm");
analysis.getTrends().put("crackWidth", trend);
QualityAnalysis quality = new QualityAnalysis();
quality.setTotal(144);
quality.setMissing(0);
quality.setCompletenessRate(new BigDecimal("1.0"));
quality.setDescription("统计期内共 144 条数据,完整率 100.00%");
analysis.getQualities().put("crackWidth", quality);
WarningAnalysis warning = new WarningAnalysis();
warning.setIdentifier("crackWidth");
warning.setStatus(WarningAnalysis.STATUS_NORMAL);
warning.setDescription("未超过预警值");
analysis.getWarnings().put("crackWidth", warning);
analysis.setSummary("本期裂缝计监测数据显示:当前裂缝宽度为0.382mm,较初始值变化0.032mm,呈增大趋势。");
analysis.setProfessionalAdvice("建议加强巡视检查。");
analysis.setSafetyLevel("SAFE");
analysis.setSafetyDescription("监测指标处于正常范围。");
analysis.setControlValue(new BigDecimal("1.000"));
analysis.setControlValueUnit("mm");
analysis.getMetricPoints().add(MetricPointData.builder()
.identifier("crackWidth")
.modelName("裂缝宽度")
.deviceId(20001L)
.pointNo("SN20261004001")
.pointName("裂缝计_1")
.initialValue(new BigDecimal("0.350"))
.initialUnit("mm")
.currentValue(new BigDecimal("0.382"))
.currentUnit("mm")
.changeValue(new BigDecimal("0.032"))
.changeUnit("mm")
.changeRate(new BigDecimal("0.0008"))
.rateUnit("mm/d")
.maxValue(new BigDecimal("0.405"))
.minValue(new BigDecimal("0.348"))
.avgValue(new BigDecimal("0.371"))
.primary(true)
.warningStatus(WarningAnalysis.STATUS_NORMAL)
.build());
analysis.getMetricPoints().add(MetricPointData.builder()
.identifier("crackTemp")
.modelName("裂缝温度")
.deviceId(20001L)
.pointNo("SN20261004001")
.pointName("裂缝计_1")
.initialValue(new BigDecimal("18.1"))
.initialUnit("℃")
.currentValue(new BigDecimal("18.5"))
.currentUnit("℃")
.changeValue(new BigDecimal("0.4"))
.changeUnit("℃")
.changeRate(new BigDecimal("0.0133"))
.rateUnit("℃/d")
.primary(false)
.warningStatus(WarningAnalysis.STATUS_NORMAL)
.build());
analysis.getMetricPoints().add(MetricPointData.builder()
.identifier("voltage")
.modelName("设备电压")
.deviceId(20001L)
.pointNo("SN20261004001")
.pointName("裂缝计_1")
.initialValue(new BigDecimal("12.5"))
.initialUnit("V")
.currentValue(new BigDecimal("12.6"))
.currentUnit("V")
.changeValue(new BigDecimal("0.1"))
.changeUnit("V")
.changeRate(new BigDecimal("0.0003"))
.rateUnit("V/d")
.primary(false)
.warningStatus(WarningAnalysis.STATUS_NORMAL)
.build());
List<MetricData> chartData = new ArrayList<>();
long base = System.currentTimeMillis();
for (int i = 0; i < 24; i++) {
MetricData point = new MetricData();
point.setIdentity("crackWidth");
point.setModelName("裂缝宽度");
point.setUnit("mm");
point.setTime(new Date(base + i * 3600_000L));
point.setNumericValue(new BigDecimal("0.350").add(new BigDecimal("0.0013").multiply(new BigDecimal(i))));
point.setValue(point.getNumericValue().toPlainString());
chartData.add(point);
}
byte[] png = new ChartGenerator().timeSeriesChart("裂缝宽度时程曲线", "裂缝宽度", "mm", chartData);
analysis.getCharts().put("timeSeriesChart", png);
com.fastbee.report.model.MetricChartGroup group = new com.fastbee.report.model.MetricChartGroup();
group.setIdentifier("crackWidth");
group.setName("裂缝宽度");
group.setUnit("mm");
group.setTimeSeries(png);
group.setCumulative(png);
group.setRate(png);
analysis.getMetricChartGroups().add(group);
return analysis;
}
}