diff --git a/fastbee-admin/src/main/resources/application.yml b/fastbee-admin/src/main/resources/application.yml index 42130837..4ea2e8cf 100644 --- a/fastbee-admin/src/main/resources/application.yml +++ b/fastbee-admin/src/main/resources/application.yml @@ -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 + diff --git a/fastbee-report/src/main/java/com/fastbee/report/analyzer/AbstractDeviceAnalyzer.java b/fastbee-report/src/main/java/com/fastbee/report/analyzer/AbstractDeviceAnalyzer.java index dc8b9c97..fa23ee3d 100644 --- a/fastbee-report/src/main/java/com/fastbee/report/analyzer/AbstractDeviceAnalyzer.java +++ b/fastbee-report/src/main/java/com/fastbee/report/analyzer/AbstractDeviceAnalyzer.java @@ -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}),供精简版报告出表用 + * + *

严格取 {@link ReportContext#getExplicitPrimaryIdentifiers()},不做任何推断兜底: + * 用户没勾就是空列表,精简版测点表据此输出"未配置主监测指标"提示行,而不是偷偷用 + * 关键词推断的指标顶替 —— 否则报告里会出现用户从未配置、也没预期看到的列。

+ * + *

顺序:沿用 {@code primaryIdentifiers} 的物模型顺序(Resolver 收集显式项时即按此序), + * 保证与完整版报告的列顺序一致,便于两份报告横向对照。

+ * + *

不过滤「统计期内无数据」的显式项:该列保留并显示 {@code -},让缺测可见; + * 若在此处剔除,报告会把「配了但没上报」伪装成「没配」。

+ */ + private List resolveExplicitPrimary(ReportContext context, List primaryIdentifiers) { + Set explicit = context.getExplicitPrimaryIdentifiers(); + if (explicit == null || explicit.isEmpty()) { + return new ArrayList<>(); + } + List 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(或无效) */ diff --git a/fastbee-report/src/main/java/com/fastbee/report/api/ReportRequest.java b/fastbee-report/src/main/java/com/fastbee/report/api/ReportRequest.java index 64543de9..47fa6045 100644 --- a/fastbee-report/src/main/java/com/fastbee/report/api/ReportRequest.java +++ b/fastbee-report/src/main/java/com/fastbee/report/api/ReportRequest.java @@ -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; } diff --git a/fastbee-report/src/main/java/com/fastbee/report/config/ReportProperties.java b/fastbee-report/src/main/java/com/fastbee/report/config/ReportProperties.java new file mode 100644 index 00000000..3d4d7413 --- /dev/null +++ b/fastbee-report/src/main/java/com/fastbee/report/config/ReportProperties.java @@ -0,0 +1,73 @@ +package com.fastbee.report.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * 报告导出配置 + * + *

只控制「用哪一套模板」,全局一刀切。

+ * + *

模板版式(2026-10-04 起只有两种,不要按字面值硬判断):

+ *
    + *
  • V2:完整版,按监测分类取 {@code DEVICE_{CATEGORY}_V2},共 10 节;
  • + *
  • DCPReport:DCP 平台专用的三节版式(项目概况 / 测点数据 / 时程曲线), + * 测点表只输出 {@code iot_things_threshold.is_primary=1} 的主监测指标。 + * 全分类共用一份 {@code templates/DCPReport.docx},首行标题用 + * {@code {{device.categoryDesc}}} 占位,因此不再按分类拆分。
  • + *
+ * + *

为什么叫 DCPReport:这套三节版式是 DCP 平台(平台实时数据推送,见 + * {@code com.fastbee.iot.platform.adapter.dcp})要求交付的报告格式,命名与用途对齐, + * 便于日后回溯「为什么报告有两套版式」。注意与推送侧的 DCP 适配器区分:本常量属 + * 报告导出,彼处属数据推送,两者是不同业务线。

+ * + *

为什么不按分类/产品白名单:报告模板是现场交付物,格式要求由业主一次性约定, + * 不会一部分产品要三节版、一部分要完整版。全局开关让「改配置即全量生效」, + * 也避免白名单长期无人维护、导致部分设备悄悄导出错版本。

+ * + * @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"; + + /** + * 报告模板编码 + * + *

取值:空 / {@code V2} → 完整版;{@code DCPReport} → DCP 三节版式。

+ */ + private String templateCode; + + /** + * 是否启用 DCP 三节版式(只出项目概况、isPrimary 测点表、时程曲线) + * + *

唯一判定入口。装配器与渲染层都只依赖这个方法,不各自比对模板编码字符串 —— + * 早期版本把「是否只出主指标」硬绑在 {@code templateCode == "DEVICE_SUMMARY"} 上, + * 一旦模板改名,判定会静默失效(不报错,辅助量列又冒出来),是必须避免的耦合。

+ */ + public boolean isDcpReportMode() { + return DCP_REPORT_TEMPLATE_CODE.equalsIgnoreCase(trimmed()); + } + + /** + * 实际生效的模板编码 + * + *

用于 {@code ReportModel.templateCode} 落值与日志;非 DCP 版式返回 {@code V2}, + * 由调用方拼分类前缀得到具体文件名。

+ */ + public String trimmed() { + return templateCode == null ? "" : templateCode.trim(); + } +} diff --git a/fastbee-report/src/main/java/com/fastbee/report/core/MultiDeviceAnalysisMerger.java b/fastbee-report/src/main/java/com/fastbee/report/core/MultiDeviceAnalysisMerger.java index 0598c62f..95846e97 100644 --- a/fastbee-report/src/main/java/com/fastbee/report/core/MultiDeviceAnalysisMerger.java +++ b/fastbee-report/src/main/java/com/fastbee/report/core/MultiDeviceAnalysisMerger.java @@ -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()); diff --git a/fastbee-report/src/main/java/com/fastbee/report/core/ReportAssembler.java b/fastbee-report/src/main/java/com/fastbee/report/core/ReportAssembler.java index c3d469bf..2dfde331 100644 --- a/fastbee-report/src/main/java/com/fastbee/report/core/ReportAssembler.java +++ b/fastbee-report/src/main/java/com/fastbee/report/core/ReportAssembler.java @@ -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 参与分析的测点数(>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 metricNames = collectMetricNames(analysis, primary); + List 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(); } + /** + * 解析报告模板编码 + * + *

取值优先级:

+ *
    + *
  1. 配置 {@code report.template-code=DCPReport} → DCP 平台三节版式, + * 全分类共用一份 {@code templates/DCPReport.docx},不拼分类后缀;
  2. + *
  3. 配空或配成 {@code V2} → 按监测分类取完整版 {@code DEVICE_{CATEGORY}_V2}。
  4. + *
+ * + *

为什么不读 {@code request.templateCode}:那是历史字段,从未被真正使用过 + * (此前本行是硬编码),前端也从未传值。放开它等于给外部一个可任意指定 classpath + * 模板文件名的入口,而报告模板与渲染策略强耦合(动态表列、图表位、poi-tl 策略绑定), + * 传错编码会直接渲染失败或产出错版式。格式是现场交付物,由运维改配置决定即可。

+ */ + 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; + } + /** * 收集全部主监测指标名称(有序) * *

多主指标(primaryIdentifiers 非空且 >1)时返回全部名称;否则退化为单值 metricName。

*/ - private java.util.List collectMetricNames(AnalysisResult analysis, Statistics primary) { - java.util.List names = new java.util.ArrayList<>(); - java.util.List ids = analysis.getPrimaryIdentifiers(); + private List collectMetricNames(AnalysisResult analysis, Statistics primary) { + List names = new java.util.ArrayList<>(); + List ids = analysis.getPrimaryIdentifiers(); if (ids != null && !ids.isEmpty()) { for (String id : ids) { Statistics s = analysis.getStatistics().get(id); diff --git a/fastbee-report/src/main/java/com/fastbee/report/model/AnalysisResult.java b/fastbee-report/src/main/java/com/fastbee/report/model/AnalysisResult.java index a2eaca72..8d635097 100644 --- a/fastbee-report/src/main/java/com/fastbee/report/model/AnalysisResult.java +++ b/fastbee-report/src/main/java/com/fastbee/report/model/AnalysisResult.java @@ -37,6 +37,24 @@ public class AnalysisResult { */ private List primaryIdentifiers; + /** + * 用户显式勾选的主监测指标标识符(有序,来自 {@code iot_things_threshold.is_primary}) + * + *

与 {@link #primaryIdentifiers} 的区别,这是两个不同口径,不可互相替代:

+ *
    + *
  • {@code primaryIdentifiers} 是三级漏斗的产物(isPrimary → 关键词推断 → 排序兜底), + * 零配置也能给出一个值;
  • + *
  • {@code explicitPrimaryIdentifiers} 只包含用户在阈值配置页真真切切勾选为 + * {@code is_primary=1} 的物模型,没配就是空,不做任何推断。
  • + *
+ * + *

精简版报告({@code report.template-code=DCPReport})的测点表只输出本集合中的物模型, + * 因此必须与用户配置严格一致 —— 用关键词推断的结果冒充实配会输出用户没勾的指标。

+ * + *

多测点归并时取代表测点(最不利测点)的集合,与 {@link #primaryIdentifiers} 同源同口径。

+ */ + private List explicitPrimaryIdentifiers; + /** 项目概况总结(开头段落,概括关键数据) */ private String summary; diff --git a/fastbee-report/src/main/java/com/fastbee/report/model/ReportModel.java b/fastbee-report/src/main/java/com/fastbee/report/model/ReportModel.java index 3df55646..22559891 100644 --- a/fastbee-report/src/main/java/com/fastbee/report/model/ReportModel.java +++ b/fastbee-report/src/main/java/com/fastbee/report/model/ReportModel.java @@ -61,6 +61,18 @@ public class ReportModel { /** 监测分类描述(如"裂缝计监测分析报告") */ private String categoryDesc; + /** + * 测点表是否只输出主监测指标(DCP 三节版式) + * + *

为 true 时,测点表列只由 {@link AnalysisResult#getExplicitPrimaryIdentifiers()} + * ({@code iot_things_threshold.is_primary=1})生成,非主监测指标(温度、电压、GPS…) + * 整列不出现;该集合为空时表体输出"未配置主监测指标"提示行。

+ * + *

为 false(完整版 V2)时沿用原口径:主指标出完整四列,其余物模型合并为 + * 一列「本次值(变化量)」保留影响量,用于分析辅助物理量的影响。

+ */ + private boolean primaryOnly; + /** 控制值(安全参考基准) */ private BigDecimal controlValue; diff --git a/fastbee-report/src/main/java/com/fastbee/report/renderer/WordRenderer.java b/fastbee-report/src/main/java/com/fastbee/report/renderer/WordRenderer.java index 4daf5a87..e11ebcdf 100644 --- a/fastbee-report/src/main/java/com/fastbee/report/renderer/WordRenderer.java +++ b/fastbee-report/src/main/java/com/fastbee/report/renderer/WordRenderer.java @@ -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 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 charts = new HashMap<>(); @@ -487,8 +490,13 @@ public class WordRenderer implements ReportRenderer { * *

数值精度与正文一致,按监测类别取 {@link ValueFormatter} 配置(静力水准 2 位), * 避免 6 位小数把列撑宽导致内容折行。

+ * + * @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 points = analysis.getMetricPoints() == null ? new ArrayList<>() : analysis.getMetricPoints(); if (points.isEmpty()) { @@ -506,27 +514,53 @@ public class WordRenderer implements ReportRenderer { } // 2. 列顺序:主指标优先,其余按首次出现顺序(全测点统一,便于横向对比) + // 精简版:只保留用户显式勾选(is_primary=1)的主监测指标,口径与配置严格一致 List orderedIdentifiers = new ArrayList<>(); Set primarySet = new java.util.LinkedHashSet<>(); - if (analysis.getPrimaryIdentifiers() != null && !analysis.getPrimaryIdentifiers().isEmpty()) { - for (String id : analysis.getPrimaryIdentifiers()) { + if (primaryOnly) { + List 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); } } - } - String primaryIdentifier = analysis.getPrimaryIdentifier(); - if (primaryIdentifier != null && !orderedIdentifiers.contains(primaryIdentifier)) { - orderedIdentifiers.add(primaryIdentifier); - primarySet.add(primaryIdentifier); - } - for (MetricPointData point : points) { - if (point.getIdentifier() != null && !orderedIdentifiers.contains(point.getIdentifier())) { - orderedIdentifiers.add(point.getIdentifier()); + } else { + if (analysis.getPrimaryIdentifiers() != null && !analysis.getPrimaryIdentifiers().isEmpty()) { + for (String id : analysis.getPrimaryIdentifiers()) { + if (id != null && !orderedIdentifiers.contains(id)) { + orderedIdentifiers.add(id); + primarySet.add(id); + } + } + } + String primaryIdentifier = analysis.getPrimaryIdentifier(); + if (primaryIdentifier != null && !orderedIdentifiers.contains(primaryIdentifier)) { + orderedIdentifiers.add(primaryIdentifier); + primarySet.add(primaryIdentifier); + } + for (MetricPointData point : points) { + if (point.getIdentifier() != null && !orderedIdentifiers.contains(point.getIdentifier())) { + 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()); diff --git a/fastbee-report/src/main/resources/templates/DCPReport.docx b/fastbee-report/src/main/resources/templates/DCPReport.docx new file mode 100644 index 00000000..d2f13ad2 Binary files /dev/null and b/fastbee-report/src/main/resources/templates/DCPReport.docx differ diff --git a/fastbee-report/src/test/java/com/fastbee/report/renderer/WordRendererDcpReportTemplateTest.java b/fastbee-report/src/test/java/com/fastbee/report/renderer/WordRendererDcpReportTemplateTest.java new file mode 100644 index 00000000..316c532a --- /dev/null +++ b/fastbee-report/src/test/java/com/fastbee/report/renderer/WordRendererDcpReportTemplateTest.java @@ -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})渲染测试 + * + *

覆盖三件事:① DCP 模板可渲染且只含三节;② 测点表只出 {@code is_primary=1} 的列, + * 辅助量整列不出现;③ 未勾选主监测指标时输出提示行而非回退到关键词推断结果。

+ * + * @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} 时辅助量仍按「本次值(变化量)」合并列出现 + * + *

这是本次改造最危险的回归点 —— DCP 版式过滤逻辑不能误伤完整版原有行为。

+ */ + @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 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 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 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; + } +}