From 694f48acd175bb2a75276503d43e696f645ce4ec Mon Sep 17 00:00:00 2001 From: zhugongping Date: Mon, 5 Oct 2026 09:09:10 +0800 Subject: [PATCH] =?UTF-8?q?reportEngine=EF=BC=9Adcp=E6=8A=A5=E5=91=8A?= =?UTF-8?q?=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/resources/application.yml | 10 + .../analyzer/AbstractDeviceAnalyzer.java | 39 ++ .../com/fastbee/report/api/ReportRequest.java | 3 - .../report/config/ReportProperties.java | 73 ++++ .../core/MultiDeviceAnalysisMerger.java | 3 + .../fastbee/report/core/ReportAssembler.java | 42 +- .../fastbee/report/model/AnalysisResult.java | 18 + .../com/fastbee/report/model/ReportModel.java | 12 + .../fastbee/report/renderer/WordRenderer.java | 60 ++- .../main/resources/templates/DCPReport.docx | Bin 0 -> 37200 bytes .../WordRendererDcpReportTemplateTest.java | 395 ++++++++++++++++++ 11 files changed, 633 insertions(+), 22 deletions(-) create mode 100644 fastbee-report/src/main/java/com/fastbee/report/config/ReportProperties.java create mode 100644 fastbee-report/src/main/resources/templates/DCPReport.docx create mode 100644 fastbee-report/src/test/java/com/fastbee/report/renderer/WordRendererDcpReportTemplateTest.java 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 0000000000000000000000000000000000000000..d2f13ad2d3075efef6f4f52dad53a05805e9045b GIT binary patch literal 37200 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 Wz?&6Vlz=j#AVVUsOt{$t;sF51a=o?y literal 0 HcmV?d00001 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; + } +}