From 32613c73db8990acd5ebc17c5417c34425b0fdb2 Mon Sep 17 00:00:00 2001 From: zhugongping Date: Tue, 22 Sep 2026 21:30:47 +0800 Subject: [PATCH] =?UTF-8?q?reportEngine=EF=BC=9A=E5=A4=9A=E8=AE=BE?= =?UTF-8?q?=E5=A4=87=E4=B8=80=E8=B5=B7=E7=94=9F=E6=88=90=E6=8A=A5=E5=91=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/fastbee-device-report-engine.md | 3 +- .../fastbee/common/enums/MonitorCategory.java | 50 ++- .../report/analysis/QualityAnalyzer.java | 19 +- .../report/analysis/RateCalculator.java | 155 ++++++- .../report/analysis/StatisticsCalculator.java | 28 ++ .../report/analysis/WarningAnalyzer.java | 39 +- .../analyzer/AbstractDeviceAnalyzer.java | 117 ++++-- .../axial_force/AxialForceAnalyzer.java | 8 +- .../analyzer/crack/CrackMeterAnalyzer.java | 8 +- .../DeepDisplacementAnalyzer.java | 6 +- .../rain_gauge/RainGaugeAnalyzer.java | 8 +- .../static_level/StaticLevelAnalyzer.java | 6 +- .../vibrating_wire/VibratingWireAnalyzer.java | 8 +- .../water_level/WaterLevelAnalyzer.java | 8 +- .../com/fastbee/report/api/ReportRequest.java | 9 +- .../report/chart/MetricChartBuilder.java | 86 ++-- .../core/MultiDeviceAnalysisMerger.java | 385 ++++++++++++++++++ .../fastbee/report/core/ReportAssembler.java | 17 +- .../report/core/ReportContextBuilder.java | 31 +- .../com/fastbee/report/core/ReportTarget.java | 26 ++ .../report/core/ReportTargetResolver.java | 175 ++++++++ .../report/executor/ReportAsyncExecutor.java | 117 ++++-- .../report/mapper/ReportTemplateMapper.java | 19 - .../fastbee/report/model/MetricPointData.java | 43 +- .../com/fastbee/report/model/Statistics.java | 9 + .../fastbee/report/model/WarningAnalysis.java | 22 + .../renderer/DefaultWordTemplateResolver.java | 25 +- .../fastbee/report/renderer/WordRenderer.java | 229 ++++++++++- .../report/template/ReportTemplate.java | 45 -- .../mapper/report/ReportTemplateMapper.xml | 18 - .../templates/DEVICE_AXIAL_FORCE_V2.docx | Bin 38075 -> 37920 bytes .../templates/DEVICE_CRACK_METER_V2.docx | Bin 38075 -> 37916 bytes .../DEVICE_DEEP_DISPLACEMENT_V2.docx | Bin 38076 -> 37920 bytes .../templates/DEVICE_INCLINOMETER_V2.docx | Bin 38076 -> 37916 bytes .../templates/DEVICE_RAIN_GAUGE_V2.docx | Bin 38075 -> 37915 bytes .../templates/DEVICE_RANGING_METER_V2.docx | Bin 38075 -> 37915 bytes .../templates/DEVICE_STATIC_LEVEL_V2.docx | Bin 38078 -> 37921 bytes .../templates/DEVICE_STRAIN_GAUGE_V2.docx | Bin 38075 -> 37917 bytes .../templates/DEVICE_THERMOMETER_V2.docx | Bin 38073 -> 37916 bytes .../templates/DEVICE_VIBRATING_WIRE_V2.docx | Bin 38073 -> 37913 bytes .../templates/DEVICE_WATER_LEVEL_V2.docx | Bin 38075 -> 37916 bytes .../core/MultiDeviceAnalysisMergerTest.java | 97 +++++ sql/fastbee-report.sql | 38 +- .../components/DeviceHistoryTablePanel.vue | 25 +- 44 files changed, 1554 insertions(+), 325 deletions(-) create mode 100644 fastbee-report/src/main/java/com/fastbee/report/core/MultiDeviceAnalysisMerger.java create mode 100644 fastbee-report/src/main/java/com/fastbee/report/core/ReportTarget.java create mode 100644 fastbee-report/src/main/java/com/fastbee/report/core/ReportTargetResolver.java delete mode 100644 fastbee-report/src/main/java/com/fastbee/report/mapper/ReportTemplateMapper.java delete mode 100644 fastbee-report/src/main/java/com/fastbee/report/template/ReportTemplate.java delete mode 100644 fastbee-report/src/main/resources/mapper/report/ReportTemplateMapper.xml create mode 100644 fastbee-report/src/test/java/com/fastbee/report/core/MultiDeviceAnalysisMergerTest.java diff --git a/docs/fastbee-device-report-engine.md b/docs/fastbee-device-report-engine.md index 95dbd4d2..406b2540 100644 --- a/docs/fastbee-device-report-engine.md +++ b/docs/fastbee-device-report-engine.md @@ -170,7 +170,7 @@ fastbee-report/ ## 3.7 Word 渲染 - 新增依赖:`poi-tl 1.10.4`(兼容 POI 4.1.2)、`jfreechart 1.5.4`;禁止再引入 docx4j。 -- 模板:`DEVICE_CRACK_METER_V1.docx` 等,按 `template_code + category` 定位(`report_template` 表)。 +- 模板:`DEVICE_CRACK_METER_V2.docx` 等,按 `template_code`(`DEVICE_${CATEGORY}_V2`)从 classpath 的 `templates/` 目录加载(`DefaultWordTemplateResolver`)。模板与渲染逻辑强耦合,**不入库**,随代码版本发布。 - 变量:`{{report.no}}`、`{{report.periodDesc}}`、`{{device.name}}`、`{{device.serialNumber}}`、`{{analysis.statistics.max}}`、`{{analysis.statistics.delta}}`、`{{analysis.trend.description}}`、`{{analysis.warning.status}}`、循环表格 `{{conclusions}}`、图片 `{{charts.crackTrend}}` 等;模板不直接绑定数据库字段。 --- @@ -363,7 +363,6 @@ POST /iot/report/generate |---|---| | `iot_product` 加列 | `monitor_category_code VARCHAR(64)` 可空;存量按产品名回填(如 `UPDATE iot_product SET monitor_category_code='CRACK_METER' WHERE product_name LIKE '%裂缝%'`) | | `iot_things_threshold` | 物模型阈值表(product/device 级:预警/报警/控制值 + 速率双控) | -| `report_template` | Word 模板表(template_code + version 唯一) | | `report_record` | 报告记录表(含 `expire_time`) | | `oss_config` 插入 | minio 默认配置记录(见 4.3) | diff --git a/fastbee-common/src/main/java/com/fastbee/common/enums/MonitorCategory.java b/fastbee-common/src/main/java/com/fastbee/common/enums/MonitorCategory.java index fb6b13c3..59a5b5b8 100644 --- a/fastbee-common/src/main/java/com/fastbee/common/enums/MonitorCategory.java +++ b/fastbee-common/src/main/java/com/fastbee/common/enums/MonitorCategory.java @@ -16,29 +16,53 @@ import lombok.Getter; @AllArgsConstructor public enum MonitorCategory { - /** 裂缝计 */ + /** + * 裂缝计 + */ CRACK_METER("CRACK_METER", "裂缝计"), - /** 深部位移 */ - DEEP_DISPLACEMENT("DEEP_DISPLACEMENT", "深部位移"), - /** 轴力/应力 */ + /** + * 深部位移 + */ + DEEP_DISPLACEMENT("DEEP_DISPLACEMENT", "深部位移/深部倾角"), + /** + * 轴力/应力 + */ AXIAL_FORCE("AXIAL_FORCE", "轴力/应力"), - /** 静力水准 */ + /** + * 静力水准 + */ STATIC_LEVEL("STATIC_LEVEL", "静力水准"), - /** 振弦 */ + /** + * 振弦 + */ VIBRATING_WIRE("VIBRATING_WIRE", "振弦"), - /** 应变计 */ + /** + * 应变计 + */ STRAIN_GAUGE("STRAIN_GAUGE", "应变计"), - /** 水位计 */ + /** + * 水位计 + */ WATER_LEVEL("WATER_LEVEL", "水位计"), - /** 测距仪 */ + /** + * 测距仪 + */ RANGING_METER("RANGING_METER", "测距仪"), - /** 倾角计 */ + /** + * 倾角计 + */ INCLINOMETER("INCLINOMETER", "倾角计"), - /** 温度计 */ + /** + * 温度计 + */ THERMOMETER("THERMOMETER", "温度计"), - /** 雨量计 */ + /** + * 雨量计 + */ RAIN_GAUGE("RAIN_GAUGE", "雨量计"), - /** 未配置监测分类 */ + /** + * 未配置监测分类 + */ UNKNOWN("UNKNOWN", "未配置监测分类"); private final String code; diff --git a/fastbee-report/src/main/java/com/fastbee/report/analysis/QualityAnalyzer.java b/fastbee-report/src/main/java/com/fastbee/report/analysis/QualityAnalyzer.java index 3401cce2..53d796c2 100644 --- a/fastbee-report/src/main/java/com/fastbee/report/analysis/QualityAnalyzer.java +++ b/fastbee-report/src/main/java/com/fastbee/report/analysis/QualityAnalyzer.java @@ -21,7 +21,13 @@ import java.util.List; @Component public class QualityAnalyzer { - public QualityAnalysis analyze(List data, TimeWindow window, BigDecimal controlValue) { + /** + * @param controlValue 控制值(阈值) + * @param initialValue 初始值;越界判定按「相对初始值的累计变化量」而非原始读数, + * 否则水位高程、频率这类绝对量读数会被误判为越界 + */ + public QualityAnalysis analyze(List data, TimeWindow window, BigDecimal controlValue, + BigDecimal initialValue) { QualityAnalysis result = new QualityAnalysis(); if (data == null || data.isEmpty()) { result.setTotal(0); @@ -50,12 +56,15 @@ public class QualityAnalyzer { } result.setDuplicate(duplicate); - // 越界检测(超出控制值) + // 越界检测:累计变化量(相对初始值)超过控制值的点数 int outOfRange = 0; - if (controlValue != null) { + if (controlValue != null && initialValue != null) { for (MetricData point : sorted) { - if (point.getNumericValue() != null - && point.getNumericValue().abs().compareTo(controlValue) > 0) { + if (point.getNumericValue() == null) { + continue; + } + BigDecimal cumulative = point.getNumericValue().subtract(initialValue); + if (cumulative.abs().compareTo(controlValue) > 0) { outOfRange++; } } diff --git a/fastbee-report/src/main/java/com/fastbee/report/analysis/RateCalculator.java b/fastbee-report/src/main/java/com/fastbee/report/analysis/RateCalculator.java index 5e6e9e02..858dff66 100644 --- a/fastbee-report/src/main/java/com/fastbee/report/analysis/RateCalculator.java +++ b/fastbee-report/src/main/java/com/fastbee/report/analysis/RateCalculator.java @@ -1,22 +1,39 @@ package com.fastbee.report.analysis; +import com.fastbee.report.data.MetricData; import com.fastbee.report.model.Statistics; import org.springframework.stereotype.Component; import java.math.BigDecimal; import java.math.RoundingMode; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; /** * 变化速率计算器(单位/天) * + *

提供三种口径,报告与预警判定统一以回归斜率为主口径:

+ *
    + *
  • {@link #regressionRatePerDay}:一元线性回归斜率(单位/天)。最小二乘拟合, + * 单点异常不会带偏整期速率,抗噪性最好 —— 预警判定与表格主速率取此值; + * 有效点不足 3 个时返回 null(此时无统计意义,不得回退为 0,否则会把"算不出"当成"没变化")。
  • + *
  • {@link #averageRatePerDay}:本期平均速率 =(当前值 − 初始值)/ 天数。受首尾单点影响大, + * 作为参考口径并列展示。
  • + *
  • {@link #maxSegmentRatePerDay}:最大分段速率 = 相邻观测点中日变化速率的最大值。 + * 用于识别短期突变,专业上用于判断是否存在瞬时加速。
  • + *
+ * * @author FastBee * @date 2026-08-16 */ @Component public class RateCalculator { + private static final int SCALE = 6; + /** - * 平均变化速率 = (当前值 - 初始值) / 天数 + * 本期平均速率 = (当前值 - 初始值) / 天数 */ public BigDecimal ratePerDay(Statistics statistics, long durationSeconds) { if (statistics == null || statistics.getCurrent() == null || statistics.getInitial() == null) { @@ -27,6 +44,140 @@ public class RateCalculator { return BigDecimal.ZERO; } return statistics.getCurrent().subtract(statistics.getInitial()) - .divide(BigDecimal.valueOf(days), 6, RoundingMode.HALF_UP); + .divide(BigDecimal.valueOf(days), SCALE, RoundingMode.HALF_UP); + } + + /** 语义化别名:本期平均速率 */ + public BigDecimal averageRatePerDay(Statistics statistics, long durationSeconds) { + return ratePerDay(statistics, durationSeconds); + } + + /** + * 回归斜率速率(单位/天):最小二乘拟合得到的日均变化速率 + * + * @param data 时序数据(内部按时间升序处理) + * @return 回归斜率;有效点不足 3 个或时间跨度退化为 0 时返回 null + */ + public BigDecimal regressionRatePerDay(List data) { + List points = toPoints(data); + if (points.size() < 3) { + return null; + } + double sumX = 0, sumY = 0; + for (double[] p : points) { + sumX += p[0]; + sumY += p[1]; + } + int n = points.size(); + double meanX = sumX / n; + double meanY = sumY / n; + double sxx = 0, sxy = 0; + for (double[] p : points) { + sxx += (p[0] - meanX) * (p[0] - meanX); + sxy += (p[0] - meanX) * (p[1] - meanY); + } + if (sxx == 0) { + // 所有点落在同一时刻,无法拟合斜率 + return null; + } + // x 单位秒 → 换算成每天 + return BigDecimal.valueOf(sxy / sxx * 86400).setScale(SCALE, RoundingMode.HALF_UP); + } + + /** + * 最大分段速率(单位/天):相邻观测点中日变化速率绝对值的最大值,带变化方向符号 + * + * @param data 时序数据 + * @return 最大分段速率;数据点不足 2 个或相邻点时间相同(无法换算)时返回 null + */ + public BigDecimal maxSegmentRatePerDay(List data) { + List sorted = sorted(data); + BigDecimal max = null; + for (int i = 1; i < sorted.size(); i++) { + MetricData prev = sorted.get(i - 1); + MetricData curr = sorted.get(i); + if (prev.getNumericValue() == null || curr.getNumericValue() == null + || prev.getTime() == null || curr.getTime() == null) { + continue; + } + long millis = curr.getTime().getTime() - prev.getTime().getTime(); + if (millis <= 0) { + continue; + } + double days = millis / 86400000.0; + BigDecimal segment = curr.getNumericValue().subtract(prev.getNumericValue()) + .divide(BigDecimal.valueOf(days), SCALE, RoundingMode.HALF_UP); + if (max == null || segment.abs().compareTo(max.abs()) > 0) { + max = segment; + } + } + return max; + } + + /** + * 解析主口径速率:优先回归斜率,算不出时回退本期平均速率 + * + *

回归斜率为 null(点数不足)时不能返回 0——0 会被引擎判成"无速率"从而漏判。 + * 此时用平均速率兜底,保证预警判定永远有一个可用口径。

+ */ + public BigDecimal resolvePrimaryRate(List data, Statistics statistics, long durationSeconds) { + BigDecimal regression = regressionRatePerDay(data); + if (regression != null) { + return regression; + } + return ratePerDay(statistics, durationSeconds); + } + + /** + * 分段速率序列(单位/天):相邻观测点的日变化速率,用于绘制变化速率曲线 + * + *

首点无前值,不产出;相邻点时间间隔为 0 的点直接跳过,避免除零制造无穷速率。

+ */ + public List segmentRateSeries(List data) { + List series = new ArrayList<>(); + List sorted = sorted(data); + for (int i = 1; i < sorted.size(); i++) { + MetricData prev = sorted.get(i - 1); + MetricData curr = sorted.get(i); + if (prev.getNumericValue() == null || curr.getNumericValue() == null + || prev.getTime() == null || curr.getTime() == null) { + continue; + } + long millis = curr.getTime().getTime() - prev.getTime().getTime(); + if (millis <= 0) { + continue; + } + double days = millis / 86400000.0; + BigDecimal rate = curr.getNumericValue().subtract(prev.getNumericValue()) + .divide(BigDecimal.valueOf(days), SCALE, RoundingMode.HALF_UP); + MetricData point = new MetricData(); + point.setIdentity(curr.getIdentity()); + point.setModelName(curr.getModelName()); + point.setUnit(curr.getUnit()); + point.setTime(curr.getTime()); + point.setNumericValue(rate); + point.setValue(rate.toPlainString()); + series.add(point); + } + return series; + } + + private List toPoints(List data) { + List points = new ArrayList<>(); + if (data == null) { + return points; + } + for (MetricData point : sorted(data)) { + if (point.getTime() != null && point.getNumericValue() != null) { + points.add(new double[]{point.getTime().getTime() / 1000.0, point.getNumericValue().doubleValue()}); + } + } + return points; + } + + private List sorted(List data) { + List sorted = new ArrayList<>(data == null ? new ArrayList<>() : data); + sorted.sort(Comparator.comparing(MetricData::getTime, Comparator.nullsLast(Comparator.naturalOrder()))); + return sorted; } } diff --git a/fastbee-report/src/main/java/com/fastbee/report/analysis/StatisticsCalculator.java b/fastbee-report/src/main/java/com/fastbee/report/analysis/StatisticsCalculator.java index e7aabdca..2ef866b5 100644 --- a/fastbee-report/src/main/java/com/fastbee/report/analysis/StatisticsCalculator.java +++ b/fastbee-report/src/main/java/com/fastbee/report/analysis/StatisticsCalculator.java @@ -8,6 +8,7 @@ import java.math.BigDecimal; import java.math.RoundingMode; import java.util.ArrayList; import java.util.Comparator; +import java.util.Date; import java.util.List; /** @@ -60,6 +61,8 @@ public class StatisticsCalculator { statistics.setCurrent(current); statistics.setInitial(initial); + statistics.setInitialTime(firstValidTime(sorted)); + statistics.setCurrentTime(lastValidTime(sorted)); statistics.setMax(max); statistics.setMin(min); statistics.setAvg(avg); @@ -69,6 +72,31 @@ public class StatisticsCalculator { return statistics; } + /** + * 初始观测时间:窗口内首条「有数值」数据的上报时间(对应 initial) + */ + private Date firstValidTime(List sorted) { + for (MetricData point : sorted) { + if (point.getNumericValue() != null && point.getTime() != null) { + return point.getTime(); + } + } + return null; + } + + /** + * 本次观测时间:窗口内末条「有数值」数据的上报时间(对应 current) + */ + private Date lastValidTime(List sorted) { + for (int i = sorted.size() - 1; i >= 0; i--) { + MetricData point = sorted.get(i); + if (point.getNumericValue() != null && point.getTime() != null) { + return point.getTime(); + } + } + return null; + } + private BigDecimal median(List values) { List sorted = new ArrayList<>(values); sorted.sort(BigDecimal::compareTo); diff --git a/fastbee-report/src/main/java/com/fastbee/report/analysis/WarningAnalyzer.java b/fastbee-report/src/main/java/com/fastbee/report/analysis/WarningAnalyzer.java index b3425c84..86930125 100644 --- a/fastbee-report/src/main/java/com/fastbee/report/analysis/WarningAnalyzer.java +++ b/fastbee-report/src/main/java/com/fastbee/report/analysis/WarningAnalyzer.java @@ -13,6 +13,15 @@ import java.math.BigDecimal; * 取较严重等级。某一维度未配置阈值时该维度不参与判定;两个维度都不可判定时结果为 * {@link WarningAnalysis#STATUS_UNKNOWN},不得回退为「正常」。

* + *

三档阈值与报告预警色的对应关系(与数据平台预警配置一致):

+ *
    + *
  • 预警值 warningValue → 黄色 {@link WarningAnalysis#STATUS_WARNING}
  • + *
  • 报警值 alarmValue → 橙色 {@link WarningAnalysis#STATUS_ALARM}
  • + *
  • 控制值 controlValue → 红色 {@link WarningAnalysis#STATUS_CONTROL}
  • + *
+ * + *

变化速率主口径为回归斜率,由调用方计算后传入。

+ * * @author FastBee * @date 2026-08-16 */ @@ -50,39 +59,39 @@ public class WarningAnalyzer { if (!noCumulative && currentValue != null) { BigDecimal abs = currentValue.abs(); if (threshold.getControlValue() != null && abs.compareTo(threshold.getControlValue()) >= 0) { - cumulativeStatus = WarningAnalysis.STATUS_ALARM; - cumulativeDesc = String.format("当前值 %s 达到/超过控制值 %s,处于报警状态", + cumulativeStatus = WarningAnalysis.STATUS_CONTROL; + cumulativeDesc = String.format("累计变化量 %s 达到/超过控制值 %s,达到红色预警", currentValue.toPlainString(), threshold.getControlValue().toPlainString()); } else if (threshold.getAlarmValue() != null && abs.compareTo(threshold.getAlarmValue()) >= 0) { cumulativeStatus = WarningAnalysis.STATUS_ALARM; - cumulativeDesc = String.format("当前值 %s 达到/超过报警值 %s,处于报警状态", + cumulativeDesc = String.format("累计变化量 %s 达到/超过报警值 %s,达到橙色预警", currentValue.toPlainString(), threshold.getAlarmValue().toPlainString()); } else if (threshold.getWarningValue() != null && abs.compareTo(threshold.getWarningValue()) >= 0) { cumulativeStatus = WarningAnalysis.STATUS_WARNING; - cumulativeDesc = String.format("当前值 %s 达到/超过预警值 %s,处于预警状态", + cumulativeDesc = String.format("累计变化量 %s 达到/超过预警值 %s,达到黄色预警", currentValue.toPlainString(), threshold.getWarningValue().toPlainString()); } else { cumulativeStatus = WarningAnalysis.STATUS_NORMAL; - cumulativeDesc = "累计值处于正常范围"; + cumulativeDesc = "累计变化量处于正常范围"; } } - // 维度二:变化速率(单位/天) + // 维度二:变化速率(单位/天,主口径为回归斜率) String rateStatus = null; String rateDesc = null; if (!noRate && changeRate != null) { BigDecimal absRate = changeRate.abs(); if (threshold.getControlRate() != null && absRate.compareTo(threshold.getControlRate()) >= 0) { - rateStatus = WarningAnalysis.STATUS_ALARM; - rateDesc = String.format("变化速率 %s 达到/超过速率控制值 %s,处于报警状态", + rateStatus = WarningAnalysis.STATUS_CONTROL; + rateDesc = String.format("变化速率 %s 达到/超过速率控制值 %s,达到红色预警", absRate.toPlainString(), threshold.getControlRate().toPlainString()); } else if (threshold.getAlarmRate() != null && absRate.compareTo(threshold.getAlarmRate()) >= 0) { rateStatus = WarningAnalysis.STATUS_ALARM; - rateDesc = String.format("变化速率 %s 达到/超过速率报警值 %s,处于报警状态", + rateDesc = String.format("变化速率 %s 达到/超过速率报警值 %s,达到橙色预警", absRate.toPlainString(), threshold.getAlarmRate().toPlainString()); } else if (threshold.getWarningRate() != null && absRate.compareTo(threshold.getWarningRate()) >= 0) { rateStatus = WarningAnalysis.STATUS_WARNING; - rateDesc = String.format("变化速率 %s 达到/超过速率预警值 %s,处于预警状态", + rateDesc = String.format("变化速率 %s 达到/超过速率预警值 %s,达到黄色预警", absRate.toPlainString(), threshold.getWarningRate().toPlainString()); } else { rateStatus = WarningAnalysis.STATUS_NORMAL; @@ -118,7 +127,7 @@ public class WarningAnalyzer { return result; } - /** 取更严重等级:ALARM > WARNING > NORMAL;null 表示该维度未参与判定 */ + /** 取更严重等级:红色 > 橙色 > 黄色 > 正常;null 表示该维度未参与判定 */ private String worse(String a, String b) { if (a == null) { return b; @@ -126,13 +135,7 @@ public class WarningAnalyzer { if (b == null) { return a; } - if (WarningAnalysis.STATUS_ALARM.equals(a) || WarningAnalysis.STATUS_ALARM.equals(b)) { - return WarningAnalysis.STATUS_ALARM; - } - if (WarningAnalysis.STATUS_WARNING.equals(a) || WarningAnalysis.STATUS_WARNING.equals(b)) { - return WarningAnalysis.STATUS_WARNING; - } - return WarningAnalysis.STATUS_NORMAL; + return WarningAnalysis.severity(a) >= WarningAnalysis.severity(b) ? a : b; } private boolean isAllNull(BigDecimal... values) { 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 ab780d37..6a75b38e 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 @@ -57,8 +57,10 @@ public abstract class AbstractDeviceAnalyzer implements ReportAnalyzer { /** 安全等级:安全 */ public static final String SAFETY_SAFE = "SAFE"; - /** 安全等级:预警 */ + /** 安全等级:预警(黄色) */ public static final String SAFETY_WARNING = "WARNING"; + /** 安全等级:临界(橙色) */ + public static final String SAFETY_CRITICAL = "CRITICAL"; /** 安全等级:危险 */ public static final String SAFETY_DANGER = "DANGER"; /** 安全等级:未配置阈值,无法评估(区别于 SAFE,避免"未评估"被当成"安全") */ @@ -184,18 +186,37 @@ public abstract class AbstractDeviceAnalyzer implements ReportAnalyzer { } } - // 基于预警状态给出建议 - if (warning != null) { - if (WarningAnalysis.STATUS_ALARM.equals(warning.getStatus())) { - advice.append("当前处于报警状态,建议立即启动应急预案,组织专业人员进行安全评估。"); - } else if (WarningAnalysis.STATUS_WARNING.equals(warning.getStatus())) { - advice.append("当前处于预警状态,建议加强巡视检查,制定应急预案。"); - } - } + // 基于预警状态给出建议(三档预警色分级处置) + advice.append(alertAdvice(warning)); return advice.toString(); } + /** + * 按三档预警色给出分级处置建议 + * + *

红(控制值)/橙(报警值)/黄(预警值)对应不同的处置强度;未配置阈值时明确提示, + * 不生成任何"安全"结论。

+ */ + protected String alertAdvice(WarningAnalysis warning) { + if (warning == null) { + return ""; + } + if (WarningAnalysis.STATUS_CONTROL.equals(warning.getStatus())) { + return "当前达到红色预警(控制值),建议立即启动应急预案,停止该部位相关作业,组织专业人员现场评估并采取处置措施。"; + } + if (WarningAnalysis.STATUS_ALARM.equals(warning.getStatus())) { + return "当前达到橙色预警(报警值),建议加密监测频次,组织专业人员进行安全复核并准备处置方案。"; + } + if (WarningAnalysis.STATUS_WARNING.equals(warning.getStatus())) { + return "当前达到黄色预警(预警值),建议加强巡视检查,持续关注变化趋势。"; + } + if (WarningAnalysis.STATUS_UNKNOWN.equals(warning.getStatus())) { + return "尚未配置有效预警阈值,无法给出处置建议,请在数据平台配置预警值/报警值/控制值后重新生成。"; + } + return ""; + } + /** * 添加领域特定结论(如静力水准的不均匀沉降、轴力的拉压状态) * 默认空实现,子类按需覆盖 @@ -232,6 +253,15 @@ public abstract class AbstractDeviceAnalyzer implements ReportAnalyzer { // 2. 获取所有监测物模型 Map allModels = getAllModels(context); + // 统计窗口时长(秒):速率计算与数据质量共用 + TimeWindow timeWindow = context.getTimeWindow(); + long statisticsDurationSeconds = (timeWindow != null && timeWindow.getStartTime() != null + && timeWindow.getEndTime() != null) + ? Duration.between(timeWindow.getStartTime(), timeWindow.getEndTime()).getSeconds() + : 0L; + // 参与统计的实际数据序列(相对测量换算后的),供速率多口径计算复用 + Map> dataByIdentity = new LinkedHashMap<>(); + if (allModels.isEmpty()) { result.getConclusions().add(new Conclusion(1, "设备未配置监测物模型,请联系管理员配置。")); return result; @@ -254,14 +284,13 @@ public abstract class AbstractDeviceAnalyzer implements ReportAnalyzer { backfillModelInfo(statistics, model, context.getCategory()); TrendAnalysis trend = trendAnalyzer.analyze(data); - QualityAnalysis quality = qualityAnalyzer.analyze(data, context.getTimeWindow(), controlValue); + // 越界检测按「相对初始值的累计变化量」判定,不能直接拿原始读数比控制值 + QualityAnalysis quality = qualityAnalyzer.analyze(data, context.getTimeWindow(), controlValue, + statistics.getInitial()); // 双控:变化速率(单位/天)与累计量一并参与安全判定 - TimeWindow window = context.getTimeWindow(); - long durationSeconds = (window != null && window.getStartTime() != null && window.getEndTime() != null) - ? Duration.between(window.getStartTime(), window.getEndTime()).getSeconds() - : 0L; - BigDecimal changeRate = rateCalculator.ratePerDay(statistics, durationSeconds); + // 主口径统一为回归斜率,避免同一份报告出现「趋势用斜率、预警用首尾差」两套速率 + BigDecimal changeRate = rateCalculator.resolvePrimaryRate(data, statistics, statisticsDurationSeconds); WarningAnalysis warning = warningAnalyzer.analyze( identity, statistics.getModelName(), statistics.getCurrent(), changeRate, threshold); @@ -269,6 +298,7 @@ public abstract class AbstractDeviceAnalyzer implements ReportAnalyzer { result.getTrends().put(identity, trend); result.getQualities().put(identity, quality); result.getWarnings().put(identity, warning); + dataByIdentity.put(identity, data); } // 4. 主指标(关键词匹配 + 辅助量过滤) @@ -276,8 +306,8 @@ public abstract class AbstractDeviceAnalyzer implements ReportAnalyzer { context, byIdentity, allowAuxiliaryAsPrimary(), primaryMetricKeywords()); result.setPrimaryIdentifier(primaryIdentity); - // 5. 组装测点数据(过滤辅助量、排序) - result.setMetricPoints(buildMetricPointsFiltered(result, context)); + // 5. 组装测点数据(主指标排第一,辅助量作为影响量保留) + result.setMetricPoints(buildMetricPointsFiltered(result, context, dataByIdentity, statisticsDurationSeconds)); // 6. 生成结论 buildConclusions(result, context, primaryIdentity); @@ -384,9 +414,15 @@ public abstract class AbstractDeviceAnalyzer implements ReportAnalyzer { } /** - * 组装测点数据(过滤辅助指标 + 主指标排第一) + * 组装测点数据(主指标排第一,其余物模型作为影响量保留) + * + *

物理量的完整性优先:温度等辅助量不再被丢弃——振弦式等设备的读数受温度影响, + * 报告需要这些量做影响分析(详见各 Analyzer 的领域结论)。它们仅标记 + * {@code primary=false},不主导报告结论。

*/ - protected List buildMetricPointsFiltered(AnalysisResult result, ReportContext context) { + protected List buildMetricPointsFiltered(AnalysisResult result, ReportContext context, + Map> dataByIdentity, + long durationSeconds) { List all = new ArrayList<>(); String primary = result.getPrimaryIdentifier(); MetricPointData primaryPoint = null; @@ -394,13 +430,6 @@ public abstract class AbstractDeviceAnalyzer implements ReportAnalyzer { for (Map.Entry entry : result.getStatistics().entrySet()) { String identity = entry.getKey(); Statistics stats = entry.getValue(); - - // 过滤辅助指标(角度/温度/电压/电量/信号/湿度) - if (!allowAuxiliaryAsPrimary() && isAuxiliaryMetric(stats.getModelName(), identity)) { - continue; - } - - // 过滤无数据/全 0 行 if (stats.getCurrent() == null || stats.getCount() == 0) { continue; } @@ -408,7 +437,10 @@ public abstract class AbstractDeviceAnalyzer implements ReportAnalyzer { continue; } - MetricPointData point = buildMetricPoint(stats, result.getWarnings().get(identity), context); + MetricPointData point = buildMetricPoint(stats, result.getWarnings().get(identity), context, + dataByIdentity == null ? null : dataByIdentity.get(identity), durationSeconds); + // 主监测指标标记(温度等辅助量为影响量,primary=false) + point.setPrimary(identity.equals(primary)); if (identity.equals(primary)) { primaryPoint = point; } else { @@ -444,25 +476,34 @@ public abstract class AbstractDeviceAnalyzer implements ReportAnalyzer { /** * 构建单个测点数据(使用 ValueFormatter 格式化) + * + * @param data 该物模型的时序数据(可能为相对换算后的序列),用于多口径速率计算 + * @param durationSeconds 统计窗口时长(秒) */ - protected MetricPointData buildMetricPoint(Statistics stats, WarningAnalysis warning, ReportContext context) { + protected MetricPointData buildMetricPoint(Statistics stats, WarningAnalysis warning, ReportContext context, + List data, long durationSeconds) { String resolvedUnit = valueFormatter.resolveUnit(stats.getUnit(), context.getCategory()); String rateUnit = valueFormatter.rateUnit(resolvedUnit); - TimeWindow window = context.getTimeWindow(); - BigDecimal rate = rateCalculator.ratePerDay(stats, - Duration.between(window.getStartTime(), window.getEndTime()).getSeconds()); + // 主口径:回归斜率;参考口径:本期平均速率、最大分段速率 + BigDecimal rate = rateCalculator.resolvePrimaryRate(data, stats, durationSeconds); + BigDecimal averageRate = rateCalculator.ratePerDay(stats, durationSeconds); + BigDecimal maxSegmentRate = rateCalculator.maxSegmentRatePerDay(data); return MetricPointData.builder() .identifier(stats.getIdentifier()) .modelName(stats.getModelName()) .initialValue(stats.getInitial()) .initialUnit(resolvedUnit) + .initialTime(stats.getInitialTime()) .currentValue(stats.getCurrent()) .currentUnit(resolvedUnit) + .currentTime(stats.getCurrentTime()) .changeValue(stats.getDelta()) .changeUnit(resolvedUnit) .changeRate(rate) + .averageRate(averageRate) + .maxSegmentRate(maxSegmentRate) .rateUnit(rateUnit) .maxValue(stats.getMax()) .minValue(stats.getMin()) @@ -590,9 +631,12 @@ public abstract class AbstractDeviceAnalyzer implements ReportAnalyzer { * 将预警状态转换为安全等级 */ protected String convertWarningToSafetyLevel(String warningStatus) { - if (WarningAnalysis.STATUS_ALARM.equals(warningStatus)) { + if (WarningAnalysis.STATUS_CONTROL.equals(warningStatus)) { return SAFETY_DANGER; } + if (WarningAnalysis.STATUS_ALARM.equals(warningStatus)) { + return SAFETY_CRITICAL; + } if (WarningAnalysis.STATUS_WARNING.equals(warningStatus)) { return SAFETY_WARNING; } @@ -604,14 +648,17 @@ public abstract class AbstractDeviceAnalyzer implements ReportAnalyzer { } /** - * 预警状态转中文 + * 预警状态转中文(三档预警色:黄/橙/红) */ protected String statusText(String status) { + if (WarningAnalysis.STATUS_CONTROL.equals(status)) { + return "红色预警"; + } if (WarningAnalysis.STATUS_ALARM.equals(status)) { - return "报警"; + return "橙色预警"; } if (WarningAnalysis.STATUS_WARNING.equals(status)) { - return "预警"; + return "黄色预警"; } if (WarningAnalysis.STATUS_UNKNOWN.equals(status)) { return "待评估"; diff --git a/fastbee-report/src/main/java/com/fastbee/report/analyzer/axial_force/AxialForceAnalyzer.java b/fastbee-report/src/main/java/com/fastbee/report/analyzer/axial_force/AxialForceAnalyzer.java index 55864208..fce8d49b 100644 --- a/fastbee-report/src/main/java/com/fastbee/report/analyzer/axial_force/AxialForceAnalyzer.java +++ b/fastbee-report/src/main/java/com/fastbee/report/analyzer/axial_force/AxialForceAnalyzer.java @@ -96,10 +96,12 @@ public class AxialForceAnalyzer extends AbstractDeviceAnalyzer { } if (warning != null) { - if (WarningAnalysis.STATUS_ALARM.equals(warning.getStatus())) { - advice.append("当前处于报警状态,建议立即采取加固措施,组织专业人员进行结构安全评估。"); + if (WarningAnalysis.STATUS_CONTROL.equals(warning.getStatus())) { + advice.append("当前达到红色预警(控制值),建议立即停止该部位作业并采取加固措施,组织专业人员进行结构安全评估。"); + } else if (WarningAnalysis.STATUS_ALARM.equals(warning.getStatus())) { + advice.append("当前达到橙色预警(报警值),建议立即采取加固措施,组织专业人员进行结构安全评估。"); } else if (WarningAnalysis.STATUS_WARNING.equals(warning.getStatus())) { - advice.append("当前处于预警状态,建议加密监测频率,制定应急预案。"); + advice.append("当前达到黄色预警(预警值),建议加密监测频率,制定应急预案。"); } } diff --git a/fastbee-report/src/main/java/com/fastbee/report/analyzer/crack/CrackMeterAnalyzer.java b/fastbee-report/src/main/java/com/fastbee/report/analyzer/crack/CrackMeterAnalyzer.java index 974d9d14..48c83ce5 100644 --- a/fastbee-report/src/main/java/com/fastbee/report/analyzer/crack/CrackMeterAnalyzer.java +++ b/fastbee-report/src/main/java/com/fastbee/report/analyzer/crack/CrackMeterAnalyzer.java @@ -98,10 +98,12 @@ public class CrackMeterAnalyzer extends AbstractDeviceAnalyzer { } if (warning != null) { - if (WarningAnalysis.STATUS_ALARM.equals(warning.getStatus())) { - advice.append("当前处于报警状态,建议立即对裂缝进行封闭处理,组织专业人员进行结构安全评估。"); + if (WarningAnalysis.STATUS_CONTROL.equals(warning.getStatus())) { + advice.append("当前达到红色预警(控制值),建议立即对裂缝进行封闭处理并停止该部位作业,组织专业人员进行结构安全评估。"); + } else if (WarningAnalysis.STATUS_ALARM.equals(warning.getStatus())) { + advice.append("当前达到橙色预警(报警值),建议立即对裂缝进行封闭处理,组织专业人员进行结构安全评估。"); } else if (WarningAnalysis.STATUS_WARNING.equals(warning.getStatus())) { - advice.append("当前处于预警状态,建议加强巡视检查,准备裂缝修补材料。"); + advice.append("当前达到黄色预警(预警值),建议加强巡视检查,准备裂缝修补材料。"); } } diff --git a/fastbee-report/src/main/java/com/fastbee/report/analyzer/deep_displacement/DeepDisplacementAnalyzer.java b/fastbee-report/src/main/java/com/fastbee/report/analyzer/deep_displacement/DeepDisplacementAnalyzer.java index cbbfbb99..16e48ecf 100644 --- a/fastbee-report/src/main/java/com/fastbee/report/analyzer/deep_displacement/DeepDisplacementAnalyzer.java +++ b/fastbee-report/src/main/java/com/fastbee/report/analyzer/deep_displacement/DeepDisplacementAnalyzer.java @@ -123,10 +123,12 @@ public class DeepDisplacementAnalyzer extends AbstractDeviceAnalyzer { // 阈值状态说明 if (warning != null && WarningAnalysis.STATUS_UNKNOWN.equals(warning.getStatus())) { advice.append("【提示】尚未配置控制值,建议根据设计文件在系统中设置位移控制值,以便自动评估安全等级。"); + } else if (warning != null && WarningAnalysis.STATUS_CONTROL.equals(warning.getStatus())) { + advice.append("当前达到红色预警(控制值),建议立即停止该部位作业、启动应急预案,组织专业人员进行地质灾害评估并采取加固措施。"); } else if (warning != null && WarningAnalysis.STATUS_ALARM.equals(warning.getStatus())) { - advice.append("当前处于报警状态,建议立即启动应急预案,组织专业人员进行地质灾害评估,必要时采取加固措施。"); + advice.append("当前达到橙色预警(报警值),建议立即启动应急预案,组织专业人员进行地质灾害评估,必要时采取加固措施。"); } else if (warning != null && WarningAnalysis.STATUS_WARNING.equals(warning.getStatus())) { - advice.append("当前处于预警状态,建议加强巡视检查,准备应急物资,制定应急预案。"); + advice.append("当前达到黄色预警(预警值),建议加强巡视检查,准备应急物资,制定应急预案。"); } return advice.toString(); diff --git a/fastbee-report/src/main/java/com/fastbee/report/analyzer/rain_gauge/RainGaugeAnalyzer.java b/fastbee-report/src/main/java/com/fastbee/report/analyzer/rain_gauge/RainGaugeAnalyzer.java index c1d1795d..a94bb95b 100644 --- a/fastbee-report/src/main/java/com/fastbee/report/analyzer/rain_gauge/RainGaugeAnalyzer.java +++ b/fastbee-report/src/main/java/com/fastbee/report/analyzer/rain_gauge/RainGaugeAnalyzer.java @@ -92,10 +92,12 @@ public class RainGaugeAnalyzer extends AbstractDeviceAnalyzer { } if (warning != null) { - if (WarningAnalysis.STATUS_ALARM.equals(warning.getStatus())) { - advice.append("当前处于报警状态,建议立即启动防汛应急预案,加强排水措施。"); + if (WarningAnalysis.STATUS_CONTROL.equals(warning.getStatus())) { + advice.append("当前达到红色预警(控制值),建议立即启动防汛应急预案并暂停现场作业,加强排水措施。"); + } else if (WarningAnalysis.STATUS_ALARM.equals(warning.getStatus())) { + advice.append("当前达到橙色预警(报警值),建议立即启动防汛应急预案,加强排水措施。"); } else if (WarningAnalysis.STATUS_WARNING.equals(warning.getStatus())) { - advice.append("当前处于预警状态,建议准备防汛物资,制定应急预案。"); + advice.append("当前达到黄色预警(预警值),建议准备防汛物资,制定应急预案。"); } } diff --git a/fastbee-report/src/main/java/com/fastbee/report/analyzer/static_level/StaticLevelAnalyzer.java b/fastbee-report/src/main/java/com/fastbee/report/analyzer/static_level/StaticLevelAnalyzer.java index fa88c2bd..b388becb 100644 --- a/fastbee-report/src/main/java/com/fastbee/report/analyzer/static_level/StaticLevelAnalyzer.java +++ b/fastbee-report/src/main/java/com/fastbee/report/analyzer/static_level/StaticLevelAnalyzer.java @@ -152,10 +152,12 @@ public class StaticLevelAnalyzer extends AbstractDeviceAnalyzer { // 阈值状态说明 if (warning != null && WarningAnalysis.STATUS_UNKNOWN.equals(warning.getStatus())) { advice.append("【提示】尚未配置控制值,建议根据设计文件在系统中设置沉降控制值,以便自动评估安全等级。"); + } else if (warning != null && WarningAnalysis.STATUS_CONTROL.equals(warning.getStatus())) { + advice.append("当前达到红色预警(控制值),建议立即停止该部位作业并采取地基加固措施,组织专业人员进行安全评估。"); } else if (warning != null && WarningAnalysis.STATUS_ALARM.equals(warning.getStatus())) { - advice.append("当前处于报警状态,建议立即采取地基加固措施,组织专业人员进行安全评估。"); + advice.append("当前达到橙色预警(报警值),建议立即采取地基加固措施,组织专业人员进行安全评估。"); } else if (warning != null && WarningAnalysis.STATUS_WARNING.equals(warning.getStatus())) { - advice.append("当前处于预警状态,建议加密监测频率,制定应急预案。"); + advice.append("当前达到黄色预警(预警值),建议加密监测频率,制定应急预案。"); } return advice.toString(); diff --git a/fastbee-report/src/main/java/com/fastbee/report/analyzer/vibrating_wire/VibratingWireAnalyzer.java b/fastbee-report/src/main/java/com/fastbee/report/analyzer/vibrating_wire/VibratingWireAnalyzer.java index 5bd75668..0bfa6f8a 100644 --- a/fastbee-report/src/main/java/com/fastbee/report/analyzer/vibrating_wire/VibratingWireAnalyzer.java +++ b/fastbee-report/src/main/java/com/fastbee/report/analyzer/vibrating_wire/VibratingWireAnalyzer.java @@ -53,10 +53,12 @@ public class VibratingWireAnalyzer extends AbstractDeviceAnalyzer { } if (warning != null) { - if (WarningAnalysis.STATUS_ALARM.equals(warning.getStatus())) { - advice.append("当前处于报警状态,建议立即检查传感器工作状态。"); + if (WarningAnalysis.STATUS_CONTROL.equals(warning.getStatus())) { + advice.append("当前达到红色预警(控制值),建议立即停止该部位作业,核查传感器与结构受力状态。"); + } else if (WarningAnalysis.STATUS_ALARM.equals(warning.getStatus())) { + advice.append("当前达到橙色预警(报警值),建议加密监测频率并立即检查传感器工作状态。"); } else if (WarningAnalysis.STATUS_WARNING.equals(warning.getStatus())) { - advice.append("当前处于预警状态,建议加密监测频率。"); + advice.append("当前达到黄色预警(预警值),建议加密监测频率。"); } } diff --git a/fastbee-report/src/main/java/com/fastbee/report/analyzer/water_level/WaterLevelAnalyzer.java b/fastbee-report/src/main/java/com/fastbee/report/analyzer/water_level/WaterLevelAnalyzer.java index d2ded26e..6143e18d 100644 --- a/fastbee-report/src/main/java/com/fastbee/report/analyzer/water_level/WaterLevelAnalyzer.java +++ b/fastbee-report/src/main/java/com/fastbee/report/analyzer/water_level/WaterLevelAnalyzer.java @@ -99,10 +99,12 @@ public class WaterLevelAnalyzer extends AbstractDeviceAnalyzer { } if (warning != null) { - if (WarningAnalysis.STATUS_ALARM.equals(warning.getStatus())) { - advice.append("当前处于报警状态,建议立即启动排水措施,组织专业人员进行渗流安全评估。"); + if (WarningAnalysis.STATUS_CONTROL.equals(warning.getStatus())) { + advice.append("当前达到红色预警(控制值),建议立即启动应急排水并停止该部位作业,组织专业人员进行渗流安全评估。"); + } else if (WarningAnalysis.STATUS_ALARM.equals(warning.getStatus())) { + advice.append("当前达到橙色预警(报警值),建议立即启动排水措施,组织专业人员进行渗流安全评估。"); } else if (WarningAnalysis.STATUS_WARNING.equals(warning.getStatus())) { - advice.append("当前处于预警状态,建议加强巡视检查,准备应急排水预案。"); + advice.append("当前达到黄色预警(预警值),建议加强巡视检查,准备应急排水预案。"); } } 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 47eaca6f..ddc17c98 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 @@ -5,6 +5,7 @@ import io.swagger.annotations.ApiModelProperty; import lombok.Data; import java.io.Serializable; +import java.util.List; /** * 报告生成请求 @@ -20,9 +21,15 @@ public class ReportRequest implements Serializable { private static final long serialVersionUID = 1L; - @ApiModelProperty(value = "设备ID", required = true) + @ApiModelProperty(value = "设备ID(单设备导出必填;传网关设备ID时自动展开为其下同产品子设备)") private Long deviceId; + @ApiModelProperty("设备ID列表(直连设备多选导出,与 deviceId/groupId 三选一)") + private List deviceIds; + + @ApiModelProperty("设备分组ID(按 iot_group 导出,报告表格展示断面列,与 deviceId/deviceIds 三选一)") + private Long groupId; + @ApiModelProperty("统计开始时间 yyyy-MM-dd HH:mm:ss(与 period 二选一)") private String startTime; diff --git a/fastbee-report/src/main/java/com/fastbee/report/chart/MetricChartBuilder.java b/fastbee-report/src/main/java/com/fastbee/report/chart/MetricChartBuilder.java index eb6f4456..aee87568 100644 --- a/fastbee-report/src/main/java/com/fastbee/report/chart/MetricChartBuilder.java +++ b/fastbee-report/src/main/java/com/fastbee/report/chart/MetricChartBuilder.java @@ -5,20 +5,25 @@ import com.fastbee.report.model.Statistics; import org.springframework.stereotype.Component; import javax.annotation.Resource; +import java.math.BigDecimal; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; /** - * 监测指标图表构建器(主指标单图 + 其余指标按单位分组多曲线图) + * 监测指标图表构建器(主指标时程曲线 + 累计变化量曲线 + 变化速率曲线) * *

约定 charts 的 key:

*
    - *
  • {@code timeSeriesChart}:主监测指标时程曲线(与模板兼容)
  • - *
  • {@code chart2} / {@code chart3}:其余指标按单位分组的时程曲线(最多 2 组)
  • + *
  • {@code timeSeriesChart}:主监测指标时程曲线(原始读数,与模板兼容)
  • + *
  • {@code chart2}:主监测指标累计变化量曲线(相对初始值)——判断变形/受力发展趋势的核心依据
  • + *
  • {@code chart3}:主监测指标变化速率曲线(单位/天)——判断是否出现加速变化的依据
  • *
* + *

多测点报告时 chart2 / chart3 由 {@link com.fastbee.report.core.MultiDeviceAnalysisMerger} + * 覆盖为各测点的多曲线版本。

+ * * @author FastBee * @date 2026-09-10 */ @@ -31,6 +36,9 @@ public class MetricChartBuilder { @Resource private ChartGenerator chartGenerator; + @Resource + private com.fastbee.report.analysis.RateCalculator rateCalculator; + /** * 构建图表集合 * @@ -59,41 +67,51 @@ public class MetricChartBuilder { primaryName + "时程曲线", primaryName, primaryUnit, byIdentity.getOrDefault(primaryIdentity, new ArrayList<>()))); - // 2. 其余指标:按单位分组,同单位合并为一张多曲线图 - Map>> groupedByUnit = new LinkedHashMap<>(); - for (Map.Entry entry : statisticsMap.entrySet()) { - String identity = entry.getKey(); - if (identity.equals(primaryIdentity)) { - continue; - } - Statistics statistics = entry.getValue(); - // 无数据的指标不生成图表 - if (statistics == null || statistics.getCount() == 0) { - continue; - } - String unit = statistics.getUnit() == null ? "" : statistics.getUnit(); - String name = statistics.getModelName() == null ? identity : statistics.getModelName(); - groupedByUnit.computeIfAbsent(unit, k -> new LinkedHashMap<>()) - .put(name, byIdentity.getOrDefault(identity, new ArrayList<>())); - } - - int index = 2; - for (Map.Entry>> entry : groupedByUnit.entrySet()) { - if (index > MAX_EXTRA_CHARTS + 1) { - break; - } - String unit = entry.getKey(); - String title = entry.getValue().keySet().iterator().next() + "等指标时程曲线"; - if (entry.getValue().size() == 1) { - title = entry.getValue().keySet().iterator().next() + "时程曲线"; - } - charts.put("chart" + index, - chartGenerator.multiSeriesChart(title, unit, entry.getValue())); - index++; + // 2. 累计变化量曲线 + 3. 变化速率曲线(报告必须给出这两个维度的曲线) + List primarySeries = sortedSeries(byIdentity.getOrDefault(primaryIdentity, new ArrayList<>())); + if (!primarySeries.isEmpty()) { + BigDecimal initial = primarySeries.get(0).getNumericValue(); + charts.put("chart2", chartGenerator.timeSeriesChart( + primaryName + "累计变化量曲线", primaryName + "累计变化量", primaryUnit, + cumulativeSeries(primarySeries, initial))); + charts.put("chart3", chartGenerator.timeSeriesChart( + primaryName + "变化速率曲线", primaryName + "变化速率", + primaryUnit + "/d", rateCalculator.segmentRateSeries(primarySeries))); } return charts; } + private List sortedSeries(List data) { + List sorted = new ArrayList<>(); + if (data == null) { + return sorted; + } + data.stream() + .filter(d -> d.getNumericValue() != null && d.getTime() != null) + .sorted(java.util.Comparator.comparing(MetricData::getTime)) + .forEach(sorted::add); + return sorted; + } + + /** 累计变化量序列 = 各点读数 − 初始值 */ + private List cumulativeSeries(List series, BigDecimal initial) { + List result = new ArrayList<>(); + if (initial == null) { + return result; + } + for (MetricData point : series) { + MetricData item = new MetricData(); + item.setIdentity(point.getIdentity()); + item.setModelName(point.getModelName()); + item.setUnit(point.getUnit()); + item.setTime(point.getTime()); + item.setNumericValue(point.getNumericValue().subtract(initial)); + item.setValue(item.getNumericValue().toPlainString()); + result.add(item); + } + return result; + } + /** * 补齐模板预留的图表位,避免模板标签无对应数据导致渲染异常 */ 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 new file mode 100644 index 00000000..ebedde50 --- /dev/null +++ b/fastbee-report/src/main/java/com/fastbee/report/core/MultiDeviceAnalysisMerger.java @@ -0,0 +1,385 @@ +package com.fastbee.report.core; + +import com.fastbee.report.analysis.RateCalculator; +import com.fastbee.report.chart.ChartGenerator; +import com.fastbee.report.data.MetricData; +import com.fastbee.report.model.AnalysisResult; +import com.fastbee.report.model.Conclusion; +import com.fastbee.report.model.MetricPointData; +import com.fastbee.report.model.Statistics; +import com.fastbee.report.model.WarningAnalysis; +import org.springframework.stereotype.Component; + +import javax.annotation.Resource; +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * 多测点(多设备)分析结果归并器 + * + *

一台设备(iot_device)= 一个测点。多设备报告把各测点的分析结果归并成一份报告:

+ *
    + *
  • 单值章节(统计指标 / 趋势 / 预警)取最不利测点为代表:先看预警等级, + * 同等级时取主指标变化量绝对值最大的测点 —— 报告不能拿最平稳的测点代表整体安全状态;
  • + *
  • 明细表(metricPoints)保留全部测点 × 全部物模型,供按物理量逐测点对比;
  • + *
  • 图表:各测点主指标的累计变化量曲线与变化速率曲线(多曲线同图), + * 这是判断各测点是否同步发展、是否存在异常单点的核心依据;
  • + *
  • 结论:在代表测点结论之后追加多测点横向对比结论。
  • + *
+ * + * @author FastBee + * @date 2026-09-22 + */ +@Component +public class MultiDeviceAnalysisMerger { + + @Resource + private RateCalculator rateCalculator; + + @Resource + private ChartGenerator chartGenerator; + + /** 单个测点的分析结果 */ + public static class DeviceAnalysis { + private final ReportTarget target; + private final ReportContext context; + private final AnalysisResult result; + + public DeviceAnalysis(ReportTarget target, ReportContext context, AnalysisResult result) { + this.target = target; + this.context = context; + this.result = result; + } + + public ReportTarget getTarget() { + return target; + } + + public ReportContext getContext() { + return context; + } + + public AnalysisResult getResult() { + return result; + } + } + + /** + * 归并多个测点的分析结果 + */ + public AnalysisResult merge(List analyses) { + return merge(analyses, null, analyses == null ? 0 : analyses.size()); + } + + /** + * 归并多个测点的分析结果 + * + * @param skippedPoints 统计期内无有效数据、未纳入分析的测点标识 + * @param totalPoints 本次请求纳入的测点总数(有效 + 跳过) + */ + public AnalysisResult merge(List analyses, List skippedPoints, int totalPoints) { + if (analyses == null || analyses.isEmpty()) { + throw new IllegalStateException("无有效测点分析结果"); + } + List valid = new ArrayList<>(); + for (DeviceAnalysis item : analyses) { + if (item != null && item.getResult() != null && !item.getResult().getStatistics().isEmpty()) { + valid.add(item); + } + } + if (valid.isEmpty()) { + throw new IllegalStateException("所选测点在统计期内均无有效监测数据"); + } + if (valid.size() == 1) { + return appendSkipped(decorateSingle(valid.get(0)), skippedPoints, totalPoints); + } + + DeviceAnalysis worst = worstCase(valid); + AnalysisResult base = worst.getResult(); + + AnalysisResult merged = new AnalysisResult(); + merged.setPrimaryIdentifier(base.getPrimaryIdentifier()); + merged.setSummary(base.getSummary()); + merged.setProfessionalAdvice(base.getProfessionalAdvice()); + merged.setSafetyLevel(base.getSafetyLevel()); + merged.setSafetyDescription(base.getSafetyDescription()); + merged.setControlValue(base.getControlValue()); + merged.setControlValueUnit(base.getControlValueUnit()); + // 单值章节取代表测点(最不利) + merged.getStatistics().putAll(base.getStatistics()); + merged.getTrends().putAll(base.getTrends()); + merged.getQualities().putAll(base.getQualities()); + merged.getWarnings().putAll(base.getWarnings()); + + // 明细:全部测点 × 全部物模型 + merged.setMetricPoints(buildMetricPoints(valid)); + merged.setConclusions(buildConclusions(base, valid)); + merged.getCharts().putAll(base.getCharts()); + merged.getCharts().putAll(buildMultiPointCharts(valid, base.getPrimaryIdentifier())); + return appendSkipped(merged, skippedPoints, totalPoints); + } + + /** + * 把"统计期内无数据、未纳入分析"的测点写入结论。 + * + *

不静默丢弃:否则用户勾选 5 个测点、报告只有 1 个测点时无法判断是漏导出还是设备没数据。

+ */ + private AnalysisResult appendSkipped(AnalysisResult result, List skippedPoints, int totalPoints) { + if (skippedPoints == null || skippedPoints.isEmpty()) { + return result; + } + int seq = result.getConclusions().size() + 1; + result.getConclusions().add(new Conclusion(seq, String.format( + "本次所选 %d 个测点中,%d 个在统计时段内无有效监测数据(设备未上报或该时段无采集记录)," + + "未纳入本次分析:%s。请核实上述测点的采集与上报状态。", + totalPoints, skippedPoints.size(), String.join("、", skippedPoints)))); + return result; + } + + /** 单设备时也要把测点标识(设备编号/名称)补上,保证明细表可追溯 */ + private AnalysisResult decorateSingle(DeviceAnalysis item) { + AnalysisResult result = item.getResult(); + for (MetricPointData point : result.getMetricPoints()) { + point.setDeviceId(item.getTarget().getDeviceId()); + point.setSectionName(item.getTarget().getSectionName()); + if (point.getPointNo() == null && item.getContext().getDevice() != null) { + point.setPointNo(item.getContext().getDevice().getSerialNumber()); + } + if (point.getPointName() == null && item.getContext().getDevice() != null) { + point.setPointName(item.getContext().getDevice().getDeviceName()); + } + } + return result; + } + + /** + * 选出最不利测点:预警等级最严重优先,其次主指标变化量绝对值最大 + */ + private DeviceAnalysis worstCase(List analyses) { + DeviceAnalysis worst = null; + int worstSeverity = Integer.MIN_VALUE; + BigDecimal worstDelta = null; + for (DeviceAnalysis item : analyses) { + AnalysisResult result = item.getResult(); + int severity = severityOf(result); + BigDecimal delta = primaryDeltaAbs(result); + if (worst == null || severity > worstSeverity + || (severity == worstSeverity && compareAbs(delta, worstDelta) > 0)) { + worst = item; + worstSeverity = severity; + worstDelta = delta; + } + } + return worst; + } + + private int severityOf(AnalysisResult result) { + String primary = result.getPrimaryIdentifier(); + WarningAnalysis warning = primary == null ? null : result.getWarnings().get(primary); + if (warning == null) { + return -1; + } + return WarningAnalysis.severity(warning.getStatus()); + } + + private BigDecimal primaryDeltaAbs(AnalysisResult result) { + String primary = result.getPrimaryIdentifier(); + Statistics statistics = primary == null ? null : result.getStatistics().get(primary); + if (statistics == null || statistics.getDelta() == null) { + return BigDecimal.ZERO; + } + return statistics.getDelta().abs(); + } + + private int compareAbs(BigDecimal a, BigDecimal b) { + BigDecimal left = a == null ? BigDecimal.ZERO : a.abs(); + BigDecimal right = b == null ? BigDecimal.ZERO : b.abs(); + return left.compareTo(right); + } + + /** 明细行:测点标识 + 断面 + 物模型分析结果 */ + private List buildMetricPoints(List analyses) { + List points = new ArrayList<>(); + for (DeviceAnalysis item : analyses) { + for (MetricPointData point : item.getResult().getMetricPoints()) { + point.setDeviceId(item.getTarget().getDeviceId()); + point.setSectionName(item.getTarget().getSectionName()); + if (point.getPointNo() == null && item.getContext().getDevice() != null) { + point.setPointNo(item.getContext().getDevice().getSerialNumber()); + } + if (point.getPointName() == null && item.getContext().getDevice() != null) { + point.setPointName(item.getContext().getDevice().getDeviceName()); + } + points.add(point); + } + } + return points; + } + + /** + * 多测点横向对比结论:极值定位、速率极值、预警测点清单 + */ + private List buildConclusions(AnalysisResult base, List analyses) { + List conclusions = new ArrayList<>(base.getConclusions()); + int seq = conclusions.size() + 1; + + String primary = base.getPrimaryIdentifier(); + DeviceAnalysis maxDelta = null; + DeviceAnalysis maxRate = null; + BigDecimal deltaMax = null; + BigDecimal rateMax = null; + Map> byLevel = new LinkedHashMap<>(); + + for (DeviceAnalysis item : analyses) { + AnalysisResult result = item.getResult(); + Statistics statistics = primary == null ? null : result.getStatistics().get(primary); + WarningAnalysis warning = primary == null ? null : result.getWarnings().get(primary); + String name = pointLabel(item); + + if (statistics != null && statistics.getDelta() != null + && (deltaMax == null || statistics.getDelta().abs().compareTo(deltaMax) > 0)) { + deltaMax = statistics.getDelta().abs(); + maxDelta = item; + } + MetricPointData primaryPoint = findPrimaryPoint(result); + if (primaryPoint != null && primaryPoint.getChangeRate() != null + && (rateMax == null || primaryPoint.getChangeRate().abs().compareTo(rateMax) > 0)) { + rateMax = primaryPoint.getChangeRate().abs(); + maxRate = item; + } + if (warning != null && !WarningAnalysis.STATUS_NORMAL.equals(warning.getStatus()) + && !WarningAnalysis.STATUS_UNKNOWN.equals(warning.getStatus())) { + byLevel.computeIfAbsent(levelText(warning.getStatus()), k -> new ArrayList<>()).add(name); + } + } + + conclusions.add(new Conclusion(seq++, String.format( + "本次共纳入 %d 个测点参与分析,单值统计取最不利测点(%s)为代表。", + analyses.size(), maxDelta == null ? "-" : pointLabel(maxDelta)))); + + if (maxDelta != null && deltaMax != null) { + conclusions.add(new Conclusion(seq++, String.format( + "累计变化量最大的测点为 %s,变化量 %s。", pointLabel(maxDelta), deltaMax.toPlainString()))); + } + if (maxRate != null && rateMax != null) { + conclusions.add(new Conclusion(seq++, String.format( + "变化速率最大的测点为 %s,速率 %s(回归斜率口径)。", pointLabel(maxRate), rateMax.toPlainString()))); + } + for (Map.Entry> entry : byLevel.entrySet()) { + conclusions.add(new Conclusion(seq++, String.format( + "达到%s的测点共 %d 个:%s。", entry.getKey(), entry.getValue().size(), + String.join("、", entry.getValue())))); + } + if (byLevel.isEmpty()) { + conclusions.add(new Conclusion(seq, "全部测点均未达到黄色及以上预警。")); + } + return conclusions; + } + + /** + * 多测点曲线:累计变化量 + 变化速率(各测点一条曲线) + * + * @return key = chart2(累计变化量)/ chart3(变化速率),复用模板预留的两个图位 + */ + private Map buildMultiPointCharts(List analyses, String primaryIdentifier) { + Map charts = new LinkedHashMap<>(); + Map> cumulativeSeries = new LinkedHashMap<>(); + Map> rateSeries = new LinkedHashMap<>(); + String unit = null; + + for (DeviceAnalysis item : analyses) { + List series = seriesOf(item, primaryIdentifier); + if (series.isEmpty()) { + continue; + } + String label = pointLabel(item); + if (unit == null && !series.isEmpty()) { + unit = series.get(0).getUnit(); + } + BigDecimal initial = series.get(0).getNumericValue(); + cumulativeSeries.put(label, toCumulative(series, initial)); + rateSeries.put(label, rateCalculator.segmentRateSeries(series)); + } + if (cumulativeSeries.isEmpty()) { + return charts; + } + String unitText = unit == null ? "" : unit; + charts.put("chart2", chartGenerator.multiSeriesChart( + "各测点累计变化量曲线", unitText, cumulativeSeries)); + charts.put("chart3", chartGenerator.multiSeriesChart( + "各测点变化速率曲线", unitText + "/d", rateSeries)); + return charts; + } + + /** 累计变化量序列 = 各点读数 − 初始值 */ + private List toCumulative(List series, BigDecimal initial) { + List result = new ArrayList<>(); + if (initial == null) { + return result; + } + for (MetricData point : series) { + if (point.getNumericValue() == null || point.getTime() == null) { + continue; + } + MetricData item = new MetricData(); + item.setIdentity(point.getIdentity()); + item.setModelName(point.getModelName()); + item.setUnit(point.getUnit()); + item.setTime(point.getTime()); + item.setNumericValue(point.getNumericValue().subtract(initial)); + item.setValue(item.getNumericValue().toPlainString()); + result.add(item); + } + return result; + } + + /** 取该测点主指标的时序数据(按时间升序,过滤空值) */ + private List seriesOf(DeviceAnalysis item, String primaryIdentifier) { + List series = new ArrayList<>(); + List all = item.getContext().getMetricDataList(); + if (all == null || primaryIdentifier == null) { + return series; + } + all.stream() + .filter(d -> primaryIdentifier.equals(d.getIdentity()) + && d.getNumericValue() != null && d.getTime() != null) + .sorted(Comparator.comparing(MetricData::getTime)) + .forEach(series::add); + return series; + } + + private MetricPointData findPrimaryPoint(AnalysisResult result) { + String primary = result.getPrimaryIdentifier(); + return result.getMetricPoints().stream() + .filter(p -> Objects.equals(p.getIdentifier(), primary)) + .findFirst() + .orElse(result.getMetricPoints().isEmpty() ? null : result.getMetricPoints().get(0)); + } + + private String pointLabel(DeviceAnalysis item) { + if (item.getContext().getDevice() == null) { + return String.valueOf(item.getTarget().getDeviceId()); + } + String name = item.getContext().getDevice().getDeviceName(); + return name == null || name.trim().isEmpty() + ? String.valueOf(item.getTarget().getDeviceId()) : name; + } + + private String levelText(String status) { + if (WarningAnalysis.STATUS_CONTROL.equals(status)) { + return "红色预警"; + } + if (WarningAnalysis.STATUS_ALARM.equals(status)) { + return "橙色预警"; + } + if (WarningAnalysis.STATUS_WARNING.equals(status)) { + return "黄色预警"; + } + return "正常"; + } +} 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 c513eebe..4753aab5 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 @@ -20,6 +20,13 @@ import java.util.UUID; public class ReportAssembler { public ReportModel assemble(ReportContext context, AnalysisResult analysis) { + return assemble(context, analysis, 1); + } + + /** + * @param pointCount 参与分析的测点数(>1 时为多测点报告,设备信息展示测点规模) + */ + public ReportModel assemble(ReportContext context, AnalysisResult analysis, int pointCount) { // 默认使用专业模版,如未配置 templateCode 则根据监测分类自动选择 String templateCode = "DEVICE_" + context.getCategory().name() + "_V2"; @@ -30,7 +37,13 @@ public class ReportAssembler { } // 末尾追加随机后缀,避免同一设备同一周期重复导出时文件名相同 String randomSuffix = UUID.randomUUID().toString().replace("-", "").substring(0, 6); - String fileName = context.getCategory().getDesc() + "-监测分析报告-" + randomSuffix + ".docx"; + // 多测点报告:文件名与设备信息体现测点规模,避免看起来像单设备报告 + boolean multi = pointCount > 1; + String fileName = context.getCategory().getDesc() + "-监测分析报告-" + + (multi ? pointCount + "测点-" : "") + randomSuffix + ".docx"; + String deviceLabel = multi + ? context.getProduct().getProductName() + "(" + pointCount + "个测点)" + : context.getDevice().getDeviceName(); // 主监测指标:优先使用 Analyzer 解析的 primaryIdentifier(与概况/结论/统计指标口径一致) Statistics primary = null; @@ -51,7 +64,7 @@ public class ReportAssembler { .periodDesc(context.getTimeWindow().getDescription()) .period(period) .generatedAt(DateUtils.getNowDate()) - .deviceName(context.getDevice().getDeviceName()) + .deviceName(deviceLabel) .serialNumber(serialNumber) .productName(context.getProduct().getProductName()) .deviceId(context.getDevice().getDeviceId()) diff --git a/fastbee-report/src/main/java/com/fastbee/report/core/ReportContextBuilder.java b/fastbee-report/src/main/java/com/fastbee/report/core/ReportContextBuilder.java index dd6dac9f..df2c9881 100644 --- a/fastbee-report/src/main/java/com/fastbee/report/core/ReportContextBuilder.java +++ b/fastbee-report/src/main/java/com/fastbee/report/core/ReportContextBuilder.java @@ -58,24 +58,27 @@ public class ReportContextBuilder { @Resource private SubGatewayMapper subGatewayMapper; + /** + * 构建报告上下文(按请求中的设备) + */ public ReportContext build(ReportRequest request, TimeWindow window, MonitorCategory category, String reportNo) { - Device device = deviceService.selectDeviceByDeviceId(request.getDeviceId()); + return buildForDevice(request.getDeviceId(), request, window, category, reportNo); + } + + /** + * 构建指定设备的报告上下文 + * + *

多测点报告时每台设备单独构建一次:物模型、时序数据、阈值都以设备维度装配, + * 分析器按设备分析,再由 {@link MultiDeviceAnalysisMerger} 归并。

+ */ + public ReportContext buildForDevice(Long deviceId, ReportRequest request, TimeWindow window, + MonitorCategory category, String reportNo) { + Device device = deviceService.selectDeviceByDeviceId(deviceId); if (device == null) { - throw new ReportException("设备不存在: " + request.getDeviceId()); + throw new ReportException("设备不存在: " + deviceId); } - - // 网关设备不允许直接生成报表:网关是通信中继,真正的监测设备是子设备 - Integer deviceType = device.getDeviceType(); - if (deviceType != null && deviceType == DEVICE_TYPE_GATEWAY) { - List subDeviceIds = subGatewayMapper.selectSubDeviceIdsByGwDeviceId(request.getDeviceId()); - String tip = (subDeviceIds == null || subDeviceIds.isEmpty()) - ? "该网关下未绑定子设备" - : "请选择具体监测设备(网关下有 " + subDeviceIds.size() + " 个子设备)"; - throw new ReportException("网关设备不能直接生成报告。" + tip); - } - if (device.getProductId() == null) { - throw new ReportException("设备未绑定产品,无法生成报告"); + throw new ReportException("设备未绑定产品,无法生成报告: " + deviceId); } Product product = productService.selectProductByProductId(device.getProductId()); if (product == null) { diff --git a/fastbee-report/src/main/java/com/fastbee/report/core/ReportTarget.java b/fastbee-report/src/main/java/com/fastbee/report/core/ReportTarget.java new file mode 100644 index 00000000..8d7c7a2f --- /dev/null +++ b/fastbee-report/src/main/java/com/fastbee/report/core/ReportTarget.java @@ -0,0 +1,26 @@ +package com.fastbee.report.core; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 报告生成目标(一个目标 = 一个测点 = iot_device) + * + *

{@link #sectionName} 为断面:仅按设备分组(iot_group)导出时才有值, + * 用于报告表格的断面列;其它导出口径下为 null,表格不展示该列。

+ * + * @author FastBee + * @date 2026-09-22 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ReportTarget { + + /** 设备ID(测点) */ + private Long deviceId; + + /** 断面(设备分组名);非分组导出为 null */ + private String sectionName; +} diff --git a/fastbee-report/src/main/java/com/fastbee/report/core/ReportTargetResolver.java b/fastbee-report/src/main/java/com/fastbee/report/core/ReportTargetResolver.java new file mode 100644 index 00000000..e57987e3 --- /dev/null +++ b/fastbee-report/src/main/java/com/fastbee/report/core/ReportTargetResolver.java @@ -0,0 +1,175 @@ +package com.fastbee.report.core; + +import com.fastbee.common.utils.StringUtils; +import com.fastbee.iot.domain.Device; +import com.fastbee.iot.domain.Group; +import com.fastbee.iot.mapper.GroupMapper; +import com.fastbee.iot.mapper.SubGatewayMapper; +import com.fastbee.iot.service.IDeviceService; +import com.fastbee.report.api.ReportRequest; +import com.fastbee.report.exception.ReportException; +import org.springframework.stereotype.Component; + +import javax.annotation.Resource; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +/** + * 报告生成目标解析器:把请求解析为「测点(iot_device)」列表 + * + *

三种导出口径:

+ *
    + *
  1. 设备分组导出(groupId):取分组下全部设备,断面 = 分组名,表格展示断面列;
  2. + *
  3. 网关导出(deviceId 指向网关设备):取该网关下与其子设备同产品的监测设备, + * 网关是通信中继不是测点,必须展开为子设备才有物理量可分析;
  4. + *
  5. 直连设备多选(deviceIds):直接使用传入的设备列表,无断面。
  6. + *
+ * + * @author FastBee + * @date 2026-09-22 + */ +@Component +public class ReportTargetResolver { + + /** 设备类型:网关设备 */ + private static final int DEVICE_TYPE_GATEWAY = 2; + + @Resource + private IDeviceService deviceService; + + @Resource + private GroupMapper groupMapper; + + @Resource + private SubGatewayMapper subGatewayMapper; + + /** + * 解析报告目标设备列表(去重、保序) + */ + public List resolve(ReportRequest request) { + // 1. 按设备分组导出:断面 = 分组名 + if (request.getGroupId() != null && request.getGroupId() > 0) { + Group group = new Group(); + group.setGroupId(request.getGroupId()); + List groups = groupMapper.selectGroupList(group); + String sectionName = (groups == null || groups.isEmpty()) ? null : groups.get(0).getGroupName(); + List targets = new ArrayList<>(); + for (Long deviceId : distinct(groupMapper.selectDeviceIdsByGroupId(request.getGroupId()))) { + targets.add(new ReportTarget(deviceId, sectionName)); + } + if (targets.isEmpty()) { + throw new ReportException("该设备分组下没有设备,无法生成报告"); + } + return targets; + } + + // 2. 直连设备多选 + if (request.getDeviceIds() != null && !request.getDeviceIds().isEmpty()) { + List targets = new ArrayList<>(); + for (Long deviceId : distinct(request.getDeviceIds())) { + if (deviceId != null) { + targets.add(new ReportTarget(deviceId, null)); + } + } + if (targets.isEmpty()) { + throw new ReportException("请选择要导出报告的设备"); + } + return targets; + } + + // 3. 单设备:网关设备展开为同产品子设备 + Long deviceId = request.getDeviceId(); + if (deviceId == null) { + throw new ReportException("请选择设备"); + } + Device device = deviceService.selectDeviceByDeviceId(deviceId); + if (device == null) { + throw new ReportException("设备不存在: " + deviceId); + } + Integer deviceType = device.getDeviceType(); + if (deviceType != null && deviceType == DEVICE_TYPE_GATEWAY) { + return resolveGatewaySubDevices(deviceId); + } + return Collections.singletonList(new ReportTarget(deviceId, null)); + } + + /** + * 网关设备:展开为其下子设备,且只保留与首个监测子设备同产品的设备 + * + *

报告按物模型(物理量)分析,不同产品的物模型与量纲不同,混在一起无法对比, + * 因此只取同产品子设备;若该网关下存在多种产品,按第一个子设备的产品为准并提示其余未纳入。

+ */ + private List resolveGatewaySubDevices(Long gatewayDeviceId) { + List subDeviceIds = subGatewayMapper.selectSubDeviceIdsByGwDeviceId(gatewayDeviceId); + if (subDeviceIds == null || subDeviceIds.isEmpty()) { + throw new ReportException("该网关下未绑定子设备,无法生成报告"); + } + Long productId = null; + List targets = new ArrayList<>(); + int skipped = 0; + for (Long subDeviceId : distinct(subDeviceIds)) { + Device subDevice = deviceService.selectDeviceByDeviceId(subDeviceId); + if (subDevice == null || subDevice.getProductId() == null) { + continue; + } + // 子设备中的网关(级联)不参与分析 + if (subDevice.getDeviceType() != null && subDevice.getDeviceType() == DEVICE_TYPE_GATEWAY) { + continue; + } + if (productId == null) { + productId = subDevice.getProductId(); + } + if (!productId.equals(subDevice.getProductId())) { + skipped++; + continue; + } + targets.add(new ReportTarget(subDeviceId, null)); + } + if (targets.isEmpty()) { + throw new ReportException("该网关下没有可分析的监测子设备"); + } + if (skipped > 0) { + // 多产品混挂:明确提示,避免用户以为全部子设备都被纳入 + throw new ReportException("该网关下存在 " + skipped + " 个不同产品的子设备," + + "报告按同产品分析,请分开选择产品或改用设备分组导出"); + } + return targets; + } + + private List distinct(List rawIds) { + Set set = new LinkedHashSet<>(); + if (rawIds == null) { + return new ArrayList<>(set); + } + for (Object item : rawIds) { + Long id = toLong(item); + if (id != null && id > 0) { + set.add(id); + } + } + return new ArrayList<>(set); + } + + /** + * 分组查询返回的是 IdOutput 对象,这里统一取 id 字段,避免强依赖具体类型 + */ + private Long toLong(Object item) { + if (item instanceof Number) { + return ((Number) item).longValue(); + } + if (item == null) { + return null; + } + try { + Object id = item.getClass().getMethod("getId").invoke(item); + return id instanceof Number ? ((Number) id).longValue() : null; + } catch (Exception ignored) { + // 非 IdOutput 形态(如直接是 Long)时按字符串解析 + String text = String.valueOf(item); + return StringUtils.isNotEmpty(text) ? Long.parseLong(text) : null; + } + } +} diff --git a/fastbee-report/src/main/java/com/fastbee/report/executor/ReportAsyncExecutor.java b/fastbee-report/src/main/java/com/fastbee/report/executor/ReportAsyncExecutor.java index 1f674f33..148c12bd 100644 --- a/fastbee-report/src/main/java/com/fastbee/report/executor/ReportAsyncExecutor.java +++ b/fastbee-report/src/main/java/com/fastbee/report/executor/ReportAsyncExecutor.java @@ -12,6 +12,9 @@ import com.fastbee.report.category.MonitorCategoryResolver; import com.fastbee.report.core.ReportAssembler; import com.fastbee.report.core.ReportContext; import com.fastbee.report.core.ReportContextBuilder; +import com.fastbee.report.core.ReportTarget; +import com.fastbee.report.core.ReportTargetResolver; +import com.fastbee.report.core.MultiDeviceAnalysisMerger; import com.fastbee.report.exception.ReportException; import com.fastbee.report.model.AnalysisResult; import com.fastbee.report.model.ReportModel; @@ -24,9 +27,12 @@ import com.fastbee.report.model.Statistics; import com.fastbee.report.renderer.ReportRenderer; import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; import javax.annotation.Resource; +import java.util.ArrayList; import java.util.List; import java.util.Map; import java.time.LocalDate; @@ -56,6 +62,12 @@ public class ReportAsyncExecutor { @Resource private MonitorCategoryResolver categoryResolver; + @Resource + private ReportTargetResolver targetResolver; + + @Resource + private MultiDeviceAnalysisMerger merger; + @Resource private ReportContextBuilder contextBuilder; @@ -85,57 +97,96 @@ public class ReportAsyncExecutor { */ public String submitAsyncTask(ReportRequest request) { validate(request); - TimeWindow window = periodResolver.resolve(request); - MonitorCategory category = categoryResolver.resolveByDeviceId(request.getDeviceId()); + List targets = targetResolver.resolve(request); + if (targets.isEmpty()) { + throw new ReportException("未解析到任何可分析的测点设备"); + } + + // 监测分类以首个测点为准(多测点必须同产品,分类一致) + MonitorCategory category = categoryResolver.resolveByDeviceId(targets.get(0).getDeviceId()); if (category == MonitorCategory.UNKNOWN) { throw new ReportException("设备所属产品未配置监测分类(iot_product.monitor_category_code),请联系管理员配置后重试"); } + TimeWindow window = periodResolver.resolve(request); String reportNo = generateReportNo(); - ReportContext context = contextBuilder.build(request, window, category, reportNo); - // 创建 CREATING 状态记录 - ReportRecord record = reportRecordService.createCreating(context); + // 报告记录以首个测点为锚(单设备时即原语义) + // 注意:deviceIds / groupId 导出口径下 request.deviceId 可能为空,必须用解析出的首个测点 + ReportContext anchorContext = contextBuilder.buildForDevice( + targets.get(0).getDeviceId(), request, window, category, reportNo); + ReportRecord record = reportRecordService.createCreating(anchorContext); // 提交异步任务到线程池 - final ReportContext finalContext = context; final ReportRequest finalRequest = request; - threadPoolTaskExecutor.execute(() -> asyncGenerate(finalRequest, finalContext, record)); + final List finalTargets = targets; + // 主线程 SecurityContext 必须显式传递:异步线程拿不到登录态, + // 否则 SecurityUtils.getLanguage() 回落 en-US 导致物模型名取 en_us 列(多数为空), + // 且 ReportContext.createBy / tenantId 会丢失。 + SecurityContext parentContext = SecurityContextHolder.getContext(); + threadPoolTaskExecutor.execute(() -> { + SecurityContextHolder.setContext(parentContext); + try { + asyncGenerate(finalRequest, finalTargets, window, category, reportNo, record); + } finally { + SecurityContextHolder.clearContext(); + } + }); return reportNo; } /** - * 异步执行生成逻辑 + * 异步执行生成逻辑(支持多测点:网关子设备 / 直连多选 / 设备分组) */ - private void asyncGenerate(ReportRequest request, ReportContext context, ReportRecord record) { + private void asyncGenerate(ReportRequest request, List targets, TimeWindow window, + MonitorCategory category, String reportNo, ReportRecord record) { try { - String reportNo = context.getReportNo(); - log.info("开始异步生成报告 reportNo={} deviceId={}", reportNo, request.getDeviceId()); + log.info("开始异步生成报告 reportNo={} 测点数={}", reportNo, targets.size()); - ReportAnalyzer analyzer = analyzerFactory.get(context.getCategory()); - AnalysisResult analysis = analyzer.analyze(context, request); + List analyses = new ArrayList<>(); + // 统计期内无数据的测点:不静默丢弃,要在结论中写明,避免报告看起来像"只导出了 1 个测点" + List skippedPoints = new ArrayList<>(); + ReportContext firstContext = null; + for (ReportTarget target : targets) { + ReportContext context = contextBuilder.buildForDevice(target.getDeviceId(), request, + window, category, reportNo); + if (firstContext == null) { + firstContext = context; + } + ReportAnalyzer analyzer = analyzerFactory.get(category); + AnalysisResult analysis = analyzer.analyze(context, request); + // 单测点无有效数据时跳过(多测点报告允许部分测点无数据,但至少要有 1 个有效) + if (isNoEffectiveData(context, analysis)) { + log.warn("测点无有效数据,已跳过 reportNo={} deviceId={}", reportNo, target.getDeviceId()); + skippedPoints.add(describePoint(context, target.getDeviceId())); + continue; + } + analyses.add(new MultiDeviceAnalysisMerger.DeviceAnalysis(target, context, analysis)); + } - // 无有效监测数据时终止生成,避免导出空白文档 - if (isNoEffectiveData(context, analysis)) { - log.warn("报告生成中止:时间范围内无有效监测数据 reportNo={} deviceId={}", - reportNo, request.getDeviceId()); - reportRecordService.markFailed(record, "所选时间范围内无有效监测数据,无法生成报告"); + if (analyses.isEmpty()) { + log.warn("报告生成中止:全部测点在时间范围内无有效监测数据 reportNo={} 测点={}", + reportNo, String.join("、", skippedPoints)); + reportRecordService.markFailed(record, "所选时间范围内无有效监测数据,无法生成报告(测点:" + + String.join("、", skippedPoints) + ")"); return; } - ReportModel model = reportAssembler.assemble(context, analysis); + AnalysisResult analysis = merger.merge(analyses, skippedPoints, targets.size()); + ReportModel model = reportAssembler.assemble(firstContext, analysis, analyses.size()); byte[] docBytes = reportRenderer.render(model); - String path = buildObjectPath(request.getDeviceId(), reportNo); + String path = buildObjectPath(firstContext.getDevice().getDeviceId(), reportNo); UploadResult upload = OssFactory.instance(redisCache) .upload(docBytes, path, WORD_CONTENT_TYPE); reportRecordService.markSuccess(record, model.getFileName(), upload.getUrl()); - log.info("报告生成成功 reportNo={} fileUrl={}", reportNo, upload.getUrl()); + log.info("报告生成成功 reportNo={} 有效测点={} 无数据跳过={} fileUrl={}", + reportNo, analyses.size(), skippedPoints.size(), upload.getUrl()); } catch (Exception e) { - log.error("报告生成失败 reportNo={} deviceId={}", context.getReportNo(), request.getDeviceId(), e); + log.error("报告生成失败 reportNo={}", reportNo, e); String message = e instanceof ReportException ? e.getMessage() : (e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage()); reportRecordService.markFailed(record, message); @@ -160,10 +211,30 @@ public class ReportAsyncExecutor { return statisticsMap.values().stream().noneMatch(s -> s.getCount() > 0); } + /** + * 测点标识:设备名称(设备编号),用于结论中列明未纳入分析的测点 + */ + private String describePoint(ReportContext context, Long deviceId) { + if (context.getDevice() == null) { + return String.valueOf(deviceId); + } + String name = context.getDevice().getDeviceName(); + String serial = context.getDevice().getSerialNumber(); + if (StringUtils.isEmpty(name)) { + return StringUtils.isEmpty(serial) ? String.valueOf(deviceId) : serial; + } + return StringUtils.isEmpty(serial) ? name : name + "(" + serial + ")"; + } + private void validate(ReportRequest request) { - if (request == null || request.getDeviceId() == null) { + if (request == null) { throw new ReportException("请选择设备"); } + boolean hasDeviceIds = request.getDeviceIds() != null && !request.getDeviceIds().isEmpty(); + boolean hasGroupId = request.getGroupId() != null && request.getGroupId() > 0; + if (request.getDeviceId() == null && !hasDeviceIds && !hasGroupId) { + throw new ReportException("请选择设备(deviceId / deviceIds / groupId 三选一)"); + } if (StringUtils.isEmpty(request.getStartTime()) && StringUtils.isEmpty(request.getPeriod())) { throw new ReportException("请选择时间范围或报告周期"); } diff --git a/fastbee-report/src/main/java/com/fastbee/report/mapper/ReportTemplateMapper.java b/fastbee-report/src/main/java/com/fastbee/report/mapper/ReportTemplateMapper.java deleted file mode 100644 index dc14f6c9..00000000 --- a/fastbee-report/src/main/java/com/fastbee/report/mapper/ReportTemplateMapper.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.fastbee.report.mapper; - -import com.baomidou.mybatisplus.core.mapper.BaseMapper; -import com.fastbee.report.template.ReportTemplate; -import org.apache.ibatis.annotations.Param; - -/** - * 报告模板 Mapper - * - * @author FastBee - * @date 2026-08-16 - */ -public interface ReportTemplateMapper extends BaseMapper { - - /** - * 查询最新启用的模板 - */ - ReportTemplate selectByCode(@Param("templateCode") String templateCode); -} diff --git a/fastbee-report/src/main/java/com/fastbee/report/model/MetricPointData.java b/fastbee-report/src/main/java/com/fastbee/report/model/MetricPointData.java index 0153547f..d40f819b 100644 --- a/fastbee-report/src/main/java/com/fastbee/report/model/MetricPointData.java +++ b/fastbee-report/src/main/java/com/fastbee/report/model/MetricPointData.java @@ -6,9 +6,13 @@ import lombok.Data; import lombok.NoArgsConstructor; import java.math.BigDecimal; +import java.util.Date; /** - * 测点数据(用于详细表格展示,支持多测点设备) + * 测点数据(报告明细表一行) + * + *

测点即 iot_device:多设备报告时一台设备一行,一行对应一个物模型(物理量)的分析结果, + * 表格列按导出设备的物模型动态生成。

* * @author FastBee * @date 2026-09-09 @@ -19,10 +23,16 @@ import java.math.BigDecimal; @AllArgsConstructor public class MetricPointData { - /** 测点编号(如 MS13-1) */ + /** 断面(按设备分组导出时取分组名;非分组导出的为 null,表格不展示该列) */ + private String sectionName; + + /** 测点ID(iot_device.device_id) */ + private Long deviceId; + + /** 测点编号(设备编号 serialNumber) */ private String pointNo; - /** 测点名称(如 红线/白线/绿线) */ + /** 测点名称(设备名称) */ private String pointName; /** 物模型标识符 */ @@ -37,21 +47,37 @@ public class MetricPointData { /** 初始值单位 */ private String initialUnit; + /** 初始观测时间(初始值对应数据的上报时间) */ + private Date initialTime; + /** 当前值 */ private BigDecimal currentValue; /** 当前值单位 */ private String currentUnit; - /** 变化量 */ + /** 本次观测时间 */ + private Date currentTime; + + /** 变化量(当前 - 初始) */ private BigDecimal changeValue; /** 变化量单位 */ private String changeUnit; - /** 变化速率 */ + /** + * 变化速率(主口径):一元线性回归斜率,单位/天 + * + *

报告展示与预警判定统一取此值,避免同一份报告出现两套速率口径。

+ */ private BigDecimal changeRate; + /** 本期平均速率 =(当前 - 初始)/ 天数,参考口径 */ + private BigDecimal averageRate; + + /** 最大分段速率(相邻观测点日速率最大值),用于识别短期突变 */ + private BigDecimal maxSegmentRate; + /** 变化速率单位 */ private String rateUnit; @@ -64,9 +90,12 @@ public class MetricPointData { /** 平均值 */ private BigDecimal avgValue; - /** 预警状态(NORMAL/WARNING/ALARM) */ + /** 预警状态(NORMAL/WARNING/ALARM/CONTROL → 正常/黄色/橙色/红色) */ private String warningStatus; + /** 是否为主监测指标(温度等辅助量为 false,作为影响量参与分析但不主导结论) */ + private Boolean primary; + /** 备注 */ private String remark; -} \ No newline at end of file +} diff --git a/fastbee-report/src/main/java/com/fastbee/report/model/Statistics.java b/fastbee-report/src/main/java/com/fastbee/report/model/Statistics.java index 22fd4cf5..b23e4759 100644 --- a/fastbee-report/src/main/java/com/fastbee/report/model/Statistics.java +++ b/fastbee-report/src/main/java/com/fastbee/report/model/Statistics.java @@ -5,6 +5,7 @@ import io.swagger.annotations.ApiModelProperty; import lombok.Data; import java.math.BigDecimal; +import java.util.Date; /** * 统计指标(当前/初始/极值/均值/中位/变化量/累计变化) @@ -34,6 +35,14 @@ public class Statistics { @ApiModelProperty("初始值(最早)") private BigDecimal initial; + /** 初始值对应观测时间(统计窗口内首条有效数据时间),报告「初始观测时间」列取此值 */ + @ApiModelProperty("初始观测时间") + private Date initialTime; + + /** 当前值对应观测时间(统计窗口内末条有效数据时间) */ + @ApiModelProperty("本次观测时间") + private Date currentTime; + @ApiModelProperty("最大值") private BigDecimal max; diff --git a/fastbee-report/src/main/java/com/fastbee/report/model/WarningAnalysis.java b/fastbee-report/src/main/java/com/fastbee/report/model/WarningAnalysis.java index e52a6b31..b8653cea 100644 --- a/fastbee-report/src/main/java/com/fastbee/report/model/WarningAnalysis.java +++ b/fastbee-report/src/main/java/com/fastbee/report/model/WarningAnalysis.java @@ -16,12 +16,34 @@ import java.math.BigDecimal; @ApiModel(value = "WarningAnalysis", description = "预警分析") public class WarningAnalysis { + /** 正常(未达预警值) */ public static final String STATUS_NORMAL = "NORMAL"; + /** 黄色预警:达到预警值 warningValue */ public static final String STATUS_WARNING = "WARNING"; + /** 橙色预警:达到报警值 alarmValue */ public static final String STATUS_ALARM = "ALARM"; + /** 红色预警:达到控制值 controlValue */ + public static final String STATUS_CONTROL = "CONTROL"; /** 未配置阈值,无法评估 */ public static final String STATUS_UNKNOWN = "UNKNOWN"; + /** 严重度排序(越大越严重),用于取较严重等级 */ + public static int severity(String status) { + if (STATUS_CONTROL.equals(status)) { + return 3; + } + if (STATUS_ALARM.equals(status)) { + return 2; + } + if (STATUS_WARNING.equals(status)) { + return 1; + } + if (STATUS_NORMAL.equals(status)) { + return 0; + } + return -1; + } + @ApiModelProperty("物模型标识符") private String identifier; diff --git a/fastbee-report/src/main/java/com/fastbee/report/renderer/DefaultWordTemplateResolver.java b/fastbee-report/src/main/java/com/fastbee/report/renderer/DefaultWordTemplateResolver.java index 09480e9c..c5354086 100644 --- a/fastbee-report/src/main/java/com/fastbee/report/renderer/DefaultWordTemplateResolver.java +++ b/fastbee-report/src/main/java/com/fastbee/report/renderer/DefaultWordTemplateResolver.java @@ -3,15 +3,16 @@ package com.fastbee.report.renderer; import cn.hutool.core.io.IoUtil; import com.fastbee.common.utils.StringUtils; import com.fastbee.report.exception.ReportException; -import com.fastbee.report.mapper.ReportTemplateMapper; -import com.fastbee.report.template.ReportTemplate; import org.springframework.stereotype.Service; -import javax.annotation.Resource; import java.io.InputStream; /** - * 默认 Word 模板解析器:report_template 表(file_path)优先,classpath 兜底 + * 默认 Word 模板解析器:从 classpath 加载模板 + * + *

模板({@code templates/DEVICE_${CATEGORY}_V2.docx})是代码资产,随版本一起发布。 + * 报告模板的占位符与渲染逻辑强耦合(动态表列、图表位、策略绑定都在代码里), + * 模板变更必然伴随代码变更,因此不再从数据库读取模板路径。

* * @author FastBee * @date 2026-08-16 @@ -19,26 +20,16 @@ import java.io.InputStream; @Service public class DefaultWordTemplateResolver implements WordTemplateResolver { - @Resource - private ReportTemplateMapper reportTemplateMapper; - @Override public byte[] resolve(String templateCode) { if (StringUtils.isEmpty(templateCode)) { throw new ReportException("报告模板编码为空"); } - ReportTemplate template = reportTemplateMapper.selectByCode(templateCode); - if (template != null && StringUtils.isNotEmpty(template.getFilePath())) { - byte[] fromClasspath = loadClasspath(template.getFilePath()); - if (fromClasspath != null) { - return fromClasspath; - } - } - byte[] fallback = loadClasspath("templates/" + templateCode + ".docx"); - if (fallback == null) { + byte[] template = loadClasspath("templates/" + templateCode + ".docx"); + if (template == null) { throw new ReportException("报告模板不存在: " + templateCode); } - return fallback; + return template; } private byte[] loadClasspath(String path) { 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 dc4069f2..d3eb17d9 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 @@ -4,8 +4,13 @@ import com.deepoove.poi.XWPFTemplate; import com.deepoove.poi.config.Configure; import com.deepoove.poi.data.ByteArrayPictureRenderData; import com.deepoove.poi.data.PictureType; +import com.deepoove.poi.data.RowRenderData; +import com.deepoove.poi.data.TableRenderData; +import com.deepoove.poi.data.Tables; +import com.deepoove.poi.data.Rows; import com.deepoove.poi.data.style.PictureStyle; import com.deepoove.poi.plugin.table.LoopRowTableRenderPolicy; +import com.deepoove.poi.policy.TableRenderPolicy; import com.fastbee.common.utils.StringUtils; import com.fastbee.report.exception.ReportException; import com.fastbee.report.model.AnalysisResult; @@ -30,9 +35,12 @@ import java.math.BigDecimal; import java.math.RoundingMode; import java.text.SimpleDateFormat; import java.util.ArrayList; +import java.util.Date; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Map; /** * Word 渲染器(poi-tl 模板渲染 + JFreeChart PNG 嵌入,禁止查库) @@ -52,7 +60,9 @@ public class WordRenderer implements ReportRenderer { Map data = buildData(model); Configure config = Configure.builder() .bind("conclusions", new LoopRowTableRenderPolicy()) - .bind("metricPoints", new LoopRowTableRenderPolicy()) // 添加多测点数据绑定 + .bind("metricPoints", new LoopRowTableRenderPolicy()) // 兼容仍使用固定表格的旧模板 + // 动态列测点表:列由物模型决定,必须绑定表格渲染策略,否则会被当纯文本输出 + .bind("metricTable", new TableRenderPolicy()) .build(); XWPFTemplate tpl = XWPFTemplate.compile(new ByteArrayInputStream(template), config).render(data); try { @@ -161,7 +171,7 @@ public class WordRenderer implements ReportRenderer { qualityMap.put("description", quality == null ? "统计期内无有效数据" : quality.getDescription()); Map warningMap = new HashMap<>(); - warningMap.put("status", warning == null ? "正常" : warningStatusText(warning.getStatus())); + warningMap.put("status", warning == null ? "正常" : warningLevelText(warning.getStatus())); warningMap.put("description", warning == null ? "统计期内无有效数据" : warning.getDescription()); Map analysisMap = new HashMap<>(); @@ -195,21 +205,29 @@ public class WordRenderer implements ReportRenderer { if (analysis.getMetricPoints() != null && !analysis.getMetricPoints().isEmpty()) { for (MetricPointData point : analysis.getMetricPoints()) { Map pointMap = new HashMap<>(); + pointMap.put("sectionName", point.getSectionName() == null ? "" : point.getSectionName()); + pointMap.put("deviceId", point.getDeviceId()); pointMap.put("pointNo", point.getPointNo() == null ? "" : point.getPointNo()); pointMap.put("pointName", point.getPointName() == null ? "" : point.getPointName()); pointMap.put("identifier", point.getIdentifier()); pointMap.put("modelName", point.getModelName()); pointMap.put("initialValue", str(point.getInitialValue())); pointMap.put("initialUnit", point.getInitialUnit() == null ? "" : point.getInitialUnit()); + pointMap.put("initialTime", fmt(point.getInitialTime())); pointMap.put("currentValue", str(point.getCurrentValue())); pointMap.put("currentUnit", point.getCurrentUnit() == null ? "" : point.getCurrentUnit()); + pointMap.put("currentTime", fmt(point.getCurrentTime())); pointMap.put("changeValue", str(point.getChangeValue())); pointMap.put("changeUnit", point.getChangeUnit() == null ? "" : point.getChangeUnit()); + // 主口径速率:回归斜率 pointMap.put("changeRate", str(point.getChangeRate())); + pointMap.put("averageRate", str(point.getAverageRate())); + pointMap.put("maxSegmentRate", str(point.getMaxSegmentRate())); pointMap.put("rateUnit", point.getRateUnit() == null ? "" : point.getRateUnit()); pointMap.put("maxValue", str(point.getMaxValue())); pointMap.put("minValue", str(point.getMinValue())); pointMap.put("avgValue", str(point.getAvgValue())); + pointMap.put("primary", point.getPrimary() != null && point.getPrimary()); // 无数据的指标标注"无数据",避免误报为正常 pointMap.put("warningStatus", point.getCurrentValue() == null ? "无数据" : warningStatusText(point.getWarningStatus())); @@ -217,6 +235,8 @@ public class WordRenderer implements ReportRenderer { } } data.put("metricPoints", metricPointsList); + // 动态列测点表:列由导出设备的物模型(物理量)决定,一行一个测点(iot_device) + data.put("metricTable", buildMetricTable(analysis)); // 图表:兼容 timeSeriesChart(主指标)/ chart2 / chart3(分组指标)/ crackTrend(旧模板) Map charts = new HashMap<>(); @@ -264,8 +284,183 @@ public class WordRenderer implements ReportRenderer { } /** - * 拼接数值与单位(单位为空时只返回数值,避免尾部空格) + * 构建动态列测点表(模板占位 {{metricTable}}) + * + *

列由导出测点实际配置的物模型(物理量)动态生成,一行一个测点(iot_device):

+ *
    + *
  • 断面:仅按设备分组(iot_group)导出时存在,否则不生成该列;
  • + *
  • 主监测指标:初始值 / 本次值 / 变化量 / 变化速率(完整四列,主导报告结论);
  • + *
  • 其余物模型(如温度等影响量):本次值 / 变化量,保留物理量完整度;
  • + *
  • 初始观测时间、监测结论:以主指标为准,结论取该测点所有物模型中的最严重等级。
  • + *
*/ + private TableRenderData buildMetricTable(AnalysisResult analysis) { + List points = analysis.getMetricPoints() == null + ? new ArrayList<>() : analysis.getMetricPoints(); + if (points.isEmpty()) { + return Tables.ofA4Width() + .addRow(Rows.of("统计期内无有效测点数据").center().textFontSize(9).create()) + .create(); + } + + // 1. 按测点(设备)分组,保持测点顺序 + Map> byPoint = new LinkedHashMap<>(); + for (MetricPointData point : points) { + String key = point.getDeviceId() != null + ? "D" + point.getDeviceId() : "P" + point.getPointNo(); + byPoint.computeIfAbsent(key, k -> new ArrayList<>()).add(point); + } + + // 2. 列顺序:主指标优先,其余按首次出现顺序(全测点统一,便于横向对比) + List orderedIdentifiers = new ArrayList<>(); + String primaryIdentifier = analysis.getPrimaryIdentifier(); + if (primaryIdentifier != null) { + orderedIdentifiers.add(primaryIdentifier); + } + for (MetricPointData point : points) { + if (point.getIdentifier() != null && !orderedIdentifiers.contains(point.getIdentifier())) { + orderedIdentifiers.add(point.getIdentifier()); + } + } + + boolean hasSection = points.stream() + .anyMatch(p -> p.getSectionName() != null && !p.getSectionName().trim().isEmpty()); + + // 3. 表头 + List header = new ArrayList<>(); + if (hasSection) { + header.add("断面"); + } + header.add("测点编号"); + header.add("测点名称"); + for (String identifier : orderedIdentifiers) { + MetricPointData sample = findSample(points, identifier); + String name = sample == null ? identifier : nvl(sample.getModelName()); + String unit = sample == null ? "" : nvl(sample.getCurrentUnit()); + boolean primary = identifier.equals(primaryIdentifier); + if (primary) { + header.add(name + "·初始值" + unitSuffix(unit)); + header.add(name + "·本次值" + unitSuffix(unit)); + header.add(name + "·变化量" + unitSuffix(unit)); + header.add(name + "·变化速率" + unitSuffix(sample == null ? "" : nvl(sample.getRateUnit()))); + } else { + header.add(name + "·本次值" + unitSuffix(unit)); + header.add(name + "·变化量" + unitSuffix(unit)); + } + } + header.add("初始观测时间"); + header.add("监测结论"); + + Tables.TableBuilder builder = Tables.ofA4Width().center(); + builder.addRow(Rows.of(header.toArray(new String[0])) + .bgColor("DCE6F1").textBold().center().textFontSize(9).create()); + + // 4. 数据行 + for (Map.Entry> entry : byPoint.entrySet()) { + List rowPoints = entry.getValue(); + Map byIdentifier = new LinkedHashMap<>(); + for (MetricPointData point : rowPoints) { + byIdentifier.put(point.getIdentifier(), point); + } + MetricPointData first = rowPoints.get(0); + List row = new ArrayList<>(); + if (hasSection) { + row.add(nvl(first.getSectionName())); + } + row.add(nvl(first.getPointNo())); + row.add(nvl(first.getPointName())); + + for (String identifier : orderedIdentifiers) { + MetricPointData point = byIdentifier.get(identifier); + if (point == null) { + row.add("-"); + if (identifier.equals(primaryIdentifier)) { + row.add("-"); + row.add("-"); + row.add("-"); + } else { + row.add("-"); + } + continue; + } + if (identifier.equals(primaryIdentifier)) { + row.add(str(point.getInitialValue())); + row.add(str(point.getCurrentValue())); + row.add(str(point.getChangeValue())); + row.add(str(point.getChangeRate())); + } else { + row.add(str(point.getCurrentValue())); + row.add(str(point.getChangeValue())); + } + } + row.add(fmt(primaryTime(rowPoints, primaryIdentifier))); + row.add(worstStatusText(rowPoints, primaryIdentifier)); + builder.addRow(Rows.of(row.toArray(new String[0])).center().textFontSize(9).create()); + } + return builder.create(); + } + + private MetricPointData findSample(List points, String identifier) { + for (MetricPointData point : points) { + if (identifier.equals(point.getIdentifier())) { + return point; + } + } + return null; + } + + /** 初始观测时间:主指标优先,缺失时取该测点第一个有时间的物模型 */ + private Date primaryTime(List rowPoints, String primaryIdentifier) { + for (MetricPointData point : rowPoints) { + if (point.getIdentifier() != null && point.getIdentifier().equals(primaryIdentifier) + && point.getInitialTime() != null) { + return point.getInitialTime(); + } + } + for (MetricPointData point : rowPoints) { + if (point.getInitialTime() != null) { + return point.getInitialTime(); + } + } + return null; + } + + /** 监测结论取该测点全部物模型中最严重的预警等级 */ + private String worstStatusText(List rowPoints, String primaryIdentifier) { + String worst = null; + for (MetricPointData point : rowPoints) { + String status = point.getWarningStatus(); + if (status == null) { + continue; + } + if (worst == null || WarningAnalysis.severity(status) > WarningAnalysis.severity(worst)) { + worst = status; + } + } + // 主指标无数据时整体标注"无数据",避免缺测被当成正常 + boolean primaryMissing = rowPoints.stream() + .noneMatch(p -> p.getIdentifier() != null && p.getIdentifier().equals(primaryIdentifier) + && p.getCurrentValue() != null); + if (primaryMissing) { + return "无数据"; + } + return worst == null ? "待评估" : warningStatusText(worst); + } + + private String nvl(String value) { + return value == null ? "" : value; + } + + private String unitSuffix(String unit) { + return unit == null || unit.trim().isEmpty() ? "" : "(" + unit.trim() + ")"; + } + + /** 时间格式化:yyyy-MM-dd HH:mm:ss,空值返回 "-" */ + private String fmt(Date date) { + return date == null ? "-" : new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(date); + } + + /** 拼接数值与单位(单位为空时只返回数值,避免尾部空格) */ private String withUnit(String value, String unit) { return (unit == null || unit.trim().isEmpty()) ? value : value + " " + unit.trim(); } @@ -285,13 +480,35 @@ public class WordRenderer implements ReportRenderer { return formatted.toPlainString(); } - /** 预警状态转中文 */ + /** 预警状态转中文(表格「监测结论」列:正常/黄色/橙色/红色) */ private String warningStatusText(String status) { + if (WarningAnalysis.STATUS_CONTROL.equals(status)) { + return "红色"; + } if (WarningAnalysis.STATUS_ALARM.equals(status)) { - return "报警"; + return "橙色"; } if (WarningAnalysis.STATUS_WARNING.equals(status)) { - return "预警"; + return "黄色"; + } + if (WarningAnalysis.STATUS_UNKNOWN.equals(status)) { + return "待评估"; + } + return "正常"; + } + + /** + * 预警状态转完整描述(正文「预警状态」章节:正常/黄色预警/橙色预警/红色预警) + */ + private String warningLevelText(String status) { + if (WarningAnalysis.STATUS_CONTROL.equals(status)) { + return "红色预警"; + } + if (WarningAnalysis.STATUS_ALARM.equals(status)) { + return "橙色预警"; + } + if (WarningAnalysis.STATUS_WARNING.equals(status)) { + return "黄色预警"; } if (WarningAnalysis.STATUS_UNKNOWN.equals(status)) { return "待评估"; diff --git a/fastbee-report/src/main/java/com/fastbee/report/template/ReportTemplate.java b/fastbee-report/src/main/java/com/fastbee/report/template/ReportTemplate.java deleted file mode 100644 index 34ee3d8d..00000000 --- a/fastbee-report/src/main/java/com/fastbee/report/template/ReportTemplate.java +++ /dev/null @@ -1,45 +0,0 @@ -package com.fastbee.report.template; - -import com.baomidou.mybatisplus.annotation.TableName; -import com.fasterxml.jackson.annotation.JsonFormat; -import lombok.Data; - -import java.util.Date; - -/** - * 报告模板(report_template) - * - * @author FastBee - * @date 2026-08-16 - */ -@Data -@TableName("report_template") -public class ReportTemplate { - - private Long id; - - private String templateCode; - - private String templateName; - - private String monitorCategoryCode; - - private Integer version; - - /** classpath 相对路径或 MinIO 路径 */ - private String filePath; - - private String status; - - private String delFlag; - - private String createBy; - - @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") - private Date createTime; - - private String updateBy; - - @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") - private Date updateTime; -} diff --git a/fastbee-report/src/main/resources/mapper/report/ReportTemplateMapper.xml b/fastbee-report/src/main/resources/mapper/report/ReportTemplateMapper.xml deleted file mode 100644 index 147ce0cd..00000000 --- a/fastbee-report/src/main/resources/mapper/report/ReportTemplateMapper.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - diff --git a/fastbee-report/src/main/resources/templates/DEVICE_AXIAL_FORCE_V2.docx b/fastbee-report/src/main/resources/templates/DEVICE_AXIAL_FORCE_V2.docx index c11e5d75650d1658837f636bed981950cbcce2d5..7f5565f3e07058950e456644f14d649e60eb52fd 100644 GIT binary patch delta 2333 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 SfF7~zU``Svb^en-^ZyHLdnwTX delta 2562 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 diff --git a/fastbee-report/src/main/resources/templates/DEVICE_CRACK_METER_V2.docx b/fastbee-report/src/main/resources/templates/DEVICE_CRACK_METER_V2.docx index afab2c14720acaa0d03ed47bc3ef1c552dced7ea..5897a8996641fe0623c39304b8658769021f0e9b 100644 GIT binary patch delta 2329 zcmZ8jc{J2*8=e`mjdkq%k|q1ZhsZV~qCq1jS*H-D#aJU`NMyXW@iW>?Ci$=j!K`s4fVbDncu*K=R@x&OTG#$rg;3kVw;MMuvA0)d!7?s=M3Z0%sW zW2_zqi=B|~5b_C$qvwOLAGe(H(5+@LMi2AK3r6EnXr7m=qnKUcwpWB3?sAgm?u6)!$cT4h^~x{^ABk+2J{Z? zg^?))u+b?Y&FdjX;`wZkb(*!)@YYqyBe`uz$4JEFPT`Q)0NlOSSjwiyP;}5lRG!{C z%O#$Ldioq0$r874*6Q@Or_!2kI=y1j6=x@YxNms0Q-ID)mZs>L8CPAQyNftC8~@M* zrp&>)ba`|C)}ZA4`^>(Yw>LlQShS*HxR_l6vlB5k!!hI>Do)e3yP1XEsxY3;vu`zH zU#)zrp$2N z77VW!L$d;OHRFOa)>Wy2IgyE%49kqDY_R0$>7DDt$7hfi3hTNzZal1Z#|Te6tvyZX z8jn?(;R+?Hj9yf<$eW}a&IHTi4I75;N#DH=Nfx@;A;>|DVGZ1xktdGm`)FnN`AB(v zq-RkT>8*?4iKI+-X6DVuyge|UelD%Q4)qQE$ zFZbUt-lZVytS3m+nexx-v{Ab9;gegMcm$JY_p4wSY*`~DsP)IXl@D$rL!h*|tD8xq zq|!A(ZT1{;Z((m&@~}=)T$}LxO$NIlBKEy$HQwA|bg3-jCiZOM)&vvPl1{m=hoy`! zOAS43`K_903fr-hNl_=QQKb12!#V{`DFJ2V&Zr0ux=8UHr6;n`y>BS$$f$6}BUbV{ zN)kD4CB5{xAM4Vtav0|x(A>K|34LnUFreqk8(pTTzQ~{YSD#-qj!+J>!QK12=06)H zd?*CVtL8j7qlWmd*HFkJf5n$Wtx(g{v>r~k*rTYX!8?4>_p9Zb9=x-~`0|wFV5nQ0 z{mN+{gY9w{^g87_k>B8mm!+v5IrDa1vjPF37fToQA_%}nKI_l+z*e+k@vzz=4|&sT^SdV&mkc-5Ej-9BNlnXiaIt`0 zxA1Ypn8{+~ch(fHzI1%C9Dy>X6v!+wIGL0U<{}L9n}jak3w)5X;xpSDaO=$7kB)I6 zyk&8X{S}kIZ4b9ySIH|Hc-Pb1KJ_vC*`&Q}ZT$!KvlP=#GQq)nWx%1WJ1LNxx^>Oz z9d<2wI(&SVgZ0_2)dgXBI##P_o{1N3%=0$~B%as>imf<&s<5yd&I8Mc`P3dvz>_*A zIT%YlhQTtu4OPn+Uc=$Hd#j3VNsJwHUI7f%N=LzrB9lgqb;!|%ouU=oW`C-?$wp5L z3iUP6C6u@0L5Uma28Cdv^CgNNF(E^rIrjys=}~FB zaHEaN9fB^fvzz7u%Yx1+X2FDYiFe<|Ry*PZRg;#w<}KAC-SJNt1^CNxC#-C7u8O7{`##(pp54Fl3w!C?qL}{|8ga|*<9d?Ea) zaIi7(l_W+R;Y=2Wa2o&v1|oo2GV2-IePCdMZJ`5!mYG2yu0J!!7!H2q4*~_^ece?3 zfRB90gFKgaC;YR@#?piZECJK&4316h1{5QcVQBVg-SM M6i$YM#NYe>3r=nw1ONa4 delta 2483 zcmZ8jc{tQ<7oN?|Ok^1*OJm6zDau$2*&>PTOO!;GG{VgAo5@mYF#IT4ig=^!3E5gG zWI`cpsBCF0jqNohUXAbluIu~Gbv@U8pL6c>JkNjU&MtyAJc0?>TEP**5D0`D5^+tr zQlJG2-^Qw5sMHQwfu-*dDnb;7+-|w^X%?$iazP;1j)E1OV!)J}>uHRcX!N*eUJu{N z&AsXc^^{t>Y-HM?uE*h^FaInD{V zpr4|X>G9}GpMiW-VKq5`HgHEIpZNBH%L6i##% z%o;I+K;}BPx@^lGrkTlLEGLlBV!}lgv$$6)BX)CI9N6EB;6I-dKbTERXTC{mePHTM zt=H^YKVp(~3hDKaA~H{r&B2P2tbpp$`Sy)QUMo_W^dT}*-Tmi0OOoZ(rIy%6LhQ|rvhAy)9Vs88 z^YsHSN*4m1F}D&2)+O>pMb;>?sv7Y$;9y-;)Z5JD{kO>v0qi{ z6W}sZGHB>gQ)UqPmu0(cUE>~XT8^xlKr7LiIsG&>E$KiGGl-8LOBVWYfcNOd}HY8jEqGZYVgdF~(58KtVAW9wKe)Q%}T#@C!^OmAq_G31b4Ma9(*9oV(^}ka4Qm{Seb_UDSpAZA6=b&FC$~; znJ8AMu$6lE>bSDCpJMP+{??|X%-?0p&-96Sv;HwLZ-G^h(sK$*G3zgtE&@PZXYEOK zNA>BfB?RYulPF0b^7&b{^6EDvH*f_$y`iT;);1HAEa2BL5x4u6YbO_5%R*E(Tm4O; z$Zrn22Lxypyq4|KP8Kk>XpFk>>gQRNg4c}C-hLM29w?RBc5eCE{@(i~4OV;QCDWGj zyZwKW5D-0hNtVm3!IY+N_J7X56vwx5NR&YK;5L;oS-L z8W%ES77fb{H!5qM_^7L{;R`1_OU(p-5xH4h%i`4ap3$I@5IuGOh5c&^^bpCiYU=*E zq5Eo6NE9x@Bu_?*dnRg8RG_#I6No65V9pTk|h zxh^01P(y;zi`{jZ3L->|bQf2kHoc7|J#{|We%OB;P*_Bap9M$M_$tceI$4=X1c!qr_N@n*C;WSgv!@cXhwX7-`w>rOCTpm>(XGXqX=-r4 zmlfqJr-cpET3&>IK(#z<&u%x3$t6*=ZaTk1_hZiIS^MVOlZ`~I-i7oiO>6jCzagdQ zm=65p?SFl2B6>h48(-{Da)ZaFeVW1K_qIGr8f54+o@=Q~zc1e=u|;fNU;Ll_Uo|ao zacAAzq3G&tCm>@Oq{SvFcm|wJfC%*+O=Tnv#;%x<);RwVP(S7)8%1K**bQWy@QzeI zK4WM2Wdf6fEqR)3z{b^NZ8q+w>9H}8ru+XkqlR{X-TF?$z#@54&{k zD#w1HX*=r%hf=ZZY;!v+1SLQw8 WZK2&wuscB<lN7B%w4z6ot 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 diff --git a/fastbee-report/src/main/resources/templates/DEVICE_INCLINOMETER_V2.docx b/fastbee-report/src/main/resources/templates/DEVICE_INCLINOMETER_V2.docx index 75162741cb134464550caf33043d3cf069ac8c9a..1ffe4296cfcc045cb1577d1ad7c784e92213ce4f 100644 GIT binary patch delta 2323 zcmZ8jc{tSV8lEK#8p|;Dj2J{;_Ca%H##d7k@u-uJ)zse25`E{4GEu+Rg-AP@)!^7~8s zDZCX7-NVWuu=GCp2FchbDF;L#e0wDiz#S@r-~xfvFhB*TD1OP^{hW}caO{L9rJL85 z$}5nDI4_dvQF6Wqp~~;RJ#cRxUEgO#9?v;hPzrHAWpYw9NdAIT5Aw8~ofxr~)#0U9aNb&j9 zB%#jeAJv$|BY0>MkJgo@)TCGoNxqx0Aut4xDG>bSLTKIbt9o{Ioe^52 z9Ye2*=QgDTwey=b&(Y=xt7yx0#8l|;SZ5fWkdI~xEaF8O2qdO9H8@jjsXX--QtF|V zKG#6*5N5^q{E7$noBBxkSAHhlvW6WQ3M~%f>t7&b5@iE+d4wC9HH0HD>XWvFCKY zIC~Fg^lY>)Zp`Hk-?d+r27<5zq|hR+ipRS|-HF37rMy(3BVyex1!3d=^Eqa9YiOy%s5j9Zs> zC-FI1u>~q{xCJO7K-9hUGaeN@d{=yXSI+pEpILmxDD*K)j@eBNA0GP5+V0#K2tiR4 zaIWtOV@(xcs zOVSe$W-MY4n~mVmGk^AaUTjs_PVozC=w6)Q&2?pT8+s$nOR<{Mv!Zu;LK^(>

A= zn*~)`OC=&$Q=VtLt!i8t)c0%5=;>M2rI+twEV5`_0zRZ2V*S7s5%Y*jrTWM=XU7}D z<^Bk4z;f>~+M6wxMoz=_&UUkPu6PGm)qbP)N|(kiIHzk8M_UYW#_i6Tq5)irZFG*W z&hRrvvp7#Q)mwK(0b|!oj)LG7KUj)ycCHZeoz5!yb}K25kB?0cHZajRLWI@2zO$;j zPE8vf-9%@-dP22&a(3#iW2r-}T|Xp?6oJLQhBH#!@=qiXETS>FU*>m>1{WJL7MXUV z#kFETl-@Ug>OfptT0MPF{oJtStUsv(j>I*PCPG>}shPd*%^ zSVmc8&>G*!zXXSPfy7J}i;}g^a*91Xs8nU&g=p!?Hv6M;*u9Lk3OF&`piDt|~k4n6J`gEJK4J2Eem#fP46CyuZ_TsPu}$ z`=@oZy531nW)wDUb~{cV`gLLSTct`bsW_Zf|F0W4_J@=E^Z+~uORm~*rkvFh#2T3I zYG3PE%**iB~P-FLbKAUrTh%M3X7bG{{nV)ojlR!KjBkb;V?w|)&Fx?==7Sq z{%m02YD=%DqSr7p5wjZg@+fn7$GWUR%eLc1*iWjX%h+HUrJk)4p-%SN~U|9Am{%`e;F! z-zm48L^{=sfRS>A$kpslP zw;}v<2;ft|SBf;dMUW~95i$n)jirDy)PwAlD^-#wF%$#}54z!_8Ui#?MP+_Zn{%5! z2LgjY&-d={edC`)0N;oJR3b%yp8xcZ?u{&n1iYez0bXE#qy!)d)&aBuH2VRMy+{p+ z1Z3G!?p_1~7{D=hv;as#z(k;)D8X-s1w(|P|6OeWwRB0a7+{rtm@N!WmxNT_27*Z9 OKw-K7=Ogm(|NI+IeI|+k delta 2484 zcmZ8jc{tQv8=j3MJ6W16%}0nLMcKC(*(PhU6DeEv8tV@)LS$xsmSReZvSmrutnrz5 zEZJvdiDVZV%OK*__}=fjzVBSux$gUZp8GlH{C93bKCHd~#%f{+NAN%(5GDvF`dm3{ zD-?c+6$4PABeDTYJ0eL4UKrb9%iix7Q{TH|5JIbtP;GuSomFJ^a;%O>(RIWEUJ>OKxR34 z5!KIT;xHYK!z&9SA}5Zg3J45lgAhG zeILEMeF?YHJ%0(2x|1hahTJan%Jt+5zLSuE;p62*?eUIlTpfG!Fn4IDL;Th2w6QRs zu|{oQpwG)dyFovcQZv~(#oglPTC3n;nM`AJYlIl7MF!s98?TQ#{+GvT&O0h76*(o+ zoj1}q_}Yd7BWB}kOt>ZL{c=^<{8`rgGK`3?E>fp`m`kOh-l)PKP-sGa^3L*fN~roH zR^3>pLJ{;r%RpA;T$ZVW=@Ys69A+kotd~sNKoHu>dM+bkHt0!5&9hp;GX0`8Lzi9Q zfBZcug8+FfuP%n#89zyS;j*~(MZjpM8U5=4px21-Mh5q51OvsF$m`%Ml&&uT=~MLo9faO zhp~zbA;$R2$2;rF9VWgcNM9%{Yn%J&44?nepEvP*B0XhhtUs(vj{`Oms-t=$VLh|K ztbBjn-WwPlvGZtbr!ZCKmu;PPj=C5F5J=0Ooiz0fxGPh(yr5ikY9!9haiXeZ#U|}e z)Ged04V=Z{Oz%W3G39BkJH%}}K*wcPh~mJaxt=S#%nJ%=Mb@yz;$aLR;quL^P=x*x{D7Xxhk;Ry5A59WWPK zetYgXL!0+=$68E+qALEHex!Gi%R?JENdsI{iBldaHiWarLR3$et7PD$MK&8N47E9g z@pJpx^VE9lZGURqn(nC6*i!z`YF)}`h28_#XW?ha7OO)BZg{zPb^;!tSt*C@ZuoEB zYLvY0)VuH5Lcz{ukvl^4x`^^55vL0mVDP&qc>0_>Alj*CV%em0v@lsI98&dx!u&U< zO23p>QM3$Hro7gNWmDTmtEI^7qi4h%j`3+5{`{nSQ(sS4LcjX?p6j?Zo8-caRCck2 z%7$Z=b4y{u9J>8M`6v#+?9Rk?dmU}FWlfhq*ea)8L&Cip`1!@ALD+0Wz)d6TxXhO0 z^6=6S*1vS}WgqMK6beK*8vr+LZJTbSeJ7AXDP4^qFG*vsiR;1(94548COw8rrHy>$zf)+^^QUeAE9BBG!i15iSqs6P zt?}R`b&~jVvBchbaS2VzvQ0tJwm8<lAxSDMoP+C;o6v%$3oHnC0S;}#=8UNg z?mh&$*jrq6AO^5^pZefQUOW-pU+sODH}jQ3gm3ruq~*gQ^Pr^Wx;>4yJ~Uz+vw>GJ z=TF{r=Zx=hKtHYZ2nG0v!%99}wh!)?QZ@47;wEzuVRwAz?8Q}Ycr|?xWO8rFQME7e zwBN6O;(l+LyjuF%_TcSm6DH5V-c9}BuPcxk>W;vIuMoh6Sa_d5A6?;cf6@*Q$7d-q zD3qwywtCx*jutP6OJzhXEkwK;QM-c7l>fKrRdKcck71GMf~mkAN!hf@#%Y4^n3u)Y z=(%;qH=m9L`ulpd1ynoD5Q=}XdVZU8(0&Pr*U2tah~aw= zl39W0t;8<R_0B5@H*=BpEdw`b`66&#b~Fi+l!Cl`yabZViy-3lO4=hC0_<(hXnciANFpaK$0 zHcu8O#J2w`3Ans)qv$3lHv!vC`KH*G8ogac3BZuz0;JRpAIl$X#gGo5{}TWzW?9{j z{(Cd_P8oU>5K+G;Pbav*B{-b`7?~qac`z16ulx~4WBg4()jx;mcru1YZ?MJk99hNU zo*WG~Cr}yamiH-Yblgl)pyLIs3LU+%%Kx7;s<0R7twk&n7R&}7!<`fUokUC@zZ%Pt zfj~+u86fP35$2y237r8=a64#_MU1c5y8@pqL807dY8 zBEL_Yx0x}AKp~K-!~HuD{j(yWT*2UD3@<2!Kblv@xZtOiY UnkRxi2?8KvDhHzu_V;`K2Bm72k^lez diff --git a/fastbee-report/src/main/resources/templates/DEVICE_RAIN_GAUGE_V2.docx b/fastbee-report/src/main/resources/templates/DEVICE_RAIN_GAUGE_V2.docx index 128f827293d073081cea21dea11cfdcfb08d181c..a456e759684375f5711543700073892ff71e389e 100644 GIT binary patch delta 2328 zcmZ8jc{tSj8~qylGK?jRk*sm424in5g(AszkuA%$%T~$~CNVO&F}`Grl5HA8)*B%r zDr-&Im+TB#1}P@D%Xs?Z_j{k`^PF=&?|Gm1&+|4ugX9t*P;(PTCIJ8d*Z|K$wJK;k z1LFbKd}0tgBHNIRBa+M{2;n|xIYBw6gn3y3Kv5N}WD&xwIyhMI8w*5#amIIXnx%0H zaF|0H}RvJqBNlu+zu z|6*73^Nj)h`bN~}1y|3#?QP0>^!CeoL{n)7~`oi<{;0Hq}}xR}=gcz5OQt9BqSl_6!=u$-@bZqXa3e zOQIFY;&lX5sijd29(~eRTI_GbJXz3%*|Mu_h3NwKhsv@mZ*xDDFm~KYzF2q~{daQW z10=62zX%$I#c>OprKup#^?CF%I+n5vUy_7Y>0k;JEG$4jPA}ZY$$9-zGEkCUR}>U1upeMV_}@YfXX?f?J25z};=a$12qFv#IHyRl~4V;i$nTRB0%0 z>f#GxTW_nahlCUOZpAOzP<@)W;Hjk>3T87zk+6eD?yg$Yq>XR))$hklk?aQIZC(p`MjmoZ-+@wg% zTrhpI(N++tV5IbJ%&AFJhlu*@Set#R++!rDseg_o_EbB>?bZ34<%Y#pOB$VvO?}n; zes&X6Tv+Y*#*acl5yHYn?HCZVTEz8pHni1T;TcMKULa#FU~R+O*(Xg=4_&gGSC*NT z%hQ=7-I_D=j+l0tXgN1vP5QIJ90_D5{&*d$q`su=i{*wwBv%K*s>`-$1}|tf75e%< zg$r)uW!@g+{?QZpj%TIHPH^Ih;Rr-_LbqYldUEZ050&z1Z^2N$VjL5%ANqwuCq6Q( z_Cn{1+a%Fs2jYp*RlGZRk=MV7OVI#s>0Ov66PKx(bk%br{7E3LW#?Uv7^fUHp{92z zK1O;nTEkoWF}EC5s~&chym<27B`sx$@yog&T@fmx=Hzo{jvJAaIBPn1W+TO52IN}= zN!SOM$Mu0uEsbb2uK{M#nxF(T8eO&RCwNLE8nXUD;{ZA(Q>DxQ9pgi0sSt z&#SAvA41G#l(hcnvYtQwD|&RhrlKd25IWQRGa}DIBB4*~F*9aHsxE4wVx}!{<^yG9 ze)DS*d8tWTqxO|?;|dnqkQo$NXX4Brj}~VMEkk8_P=?M<4Z}zGkG&ks&^0u>8Td*C zWWQIBm@N}ubC6+Vo83o|U6Imk^3q;^;H zw(3N;+j^a4T?WZf*QS*|y!gFES0|AtPUuB?5IeNnmR zo~-z?>+syBr=~Y7y|2<&83%8VP(9s4Dp7_J+!8beLeu_89!Yf9>V8zT62z3~?q5p~38Z5WZQJPYDx{>st zZP5)-r4z+lD~xm^7LGjfm`9{S=s&ZOJ1mC;9FHBKV{P0Hy-|)8II_Np&pH}*PNXu^ zEmo;IbWBfGqhoI>nvPufv;Uvs)_5g)3x`KQhM`~yUjD>k&RaYv94|luK!r6kz;kdQ z{I0tU2H-kgj6MQOLqPa+!9HD4&^V1t?r^M7_kwc^BLFP20|4*unFGAb5E}vjp@BEt zkvGAXG$HXr^OF6%6~F)h{~Wx-Bjk78We5%fYttm@OGXZs6o!L4Y2tJV2N4WvpeiU$ zSB?h?35J13*1QA73O0bJ={*XFfB;yq2`d7Mr*qN2km(2pA8&MJb2-{{tsxJ&FJT delta 2489 zcmZ8jc{tQ<8=V=lFGCW>HeRwsNJgY4OCp7NONeN}yq4^Pv5kF83_okDWh7gcl4uCo zw<%*LM79`WidSZ2$;b5d$M@aW^<3wi=RWs!|9PJW&meC~A^cY6Z0sT+5QqyD9IaBr z{|?NyhcC!r>EGlBByxkH z?hM?%T_~PZvK<#-NlIZ)cZrh!&c}#xZA5SW2x|7<^bK$Pzce+4=|*%lW<6tv3BYkUCDp39)Ab$1kE<8gf_z(#fiI6~3cB}}up=AU%2 zqUR}EX9PaDX0nN?E#7Ay_hLS0|LRB-B^<=p_GFqju&JBmcY<%nh~h`5=!Bt-chQQ3 z%C<|r*#pLnr;03vtmbp#TP&u^Ytx^beW5EHnUS(JPF%4MfK2;5ktz z!guRf>p9#DI6p>_{?PYBPKRRjY`5Q?;R*@5K))wAHw?7cyU;+4;_h~r6hiCE=^xh{ zM3<;urL|K6B3u(+TOCv)`V#bc{P_O$&AHfBq`CUViKj{xd`Gox1q@DlcJC+sOIj%M z9=}N(SDCEEVk_C6LW7|kQMnom1w33z5AwM-FgObvJ6cY}S4?j2>qiYz)vGZB^qAuH zkhzFEYuP)&l*T~HFUHfRf1Q@7$-!wo^$KU5Is(Nid%xaHxtGjgQvY0`(|J?Lgu8^X zeAHtnXYxt7KbK8aYp{}oeO-(;(lS4Z8+Kx4t}pz+x{6X8St?;R)lJwGuH3p19J^Z{ z?u#Qe2a=ncLVdPS(9%95HsO|hz1CEpjoqM=OVn54G)JhN7Tm!0QS=pVsM~sP6_sgD zIn1PYB!J~Ewbgn(r@RW3TU%36U2TYX57Hxs{bbUDzTDQWKOwy)6dTEiK#>e*h_34D zw#W$kiOE`oD6DE#+fYqqIsCls4L&#;=eM<)vGb%DB;JO;l#n8;aDy$&zHjr=R?JSKSzv?pDh^xJh z+rX}mTHJ^)XSWWmcK9^OGn`l~%Q-rZwT-?O>8j(_wE6BlYTjY<|c*+ z?F$hx!!C=SmhU@<#LApDO|u_99U5Qc zPGh)c#X@Efq$&qu`$Opq1o@V1N{x|Po=9=GlTG!|lwlLQgGz>x5%4siOW zJMpCU&-(tCTUXr-jj%;78s9L(%R!I2=%YoaY8lN?k7rDG1uZl2c1{bkEnJ^eb9hKY zh~{LbqQ*bp>!v)q*Z4iKm1^^|_X#o@G+Gq+rEcG`l4E+=@m1z%GaOBJ}G~XNyu{AqyqGVS8c-wR0I*;;Vex{(p zBBhCwLh}fdftn0qp2vqXO-OQalZzyjS48!`Lyyc#pP_N z2QAlKFm15$`LGb{<(qC6tx=4u7#Nro6{UBT*O4BZYSmf2+&)r*6gFB7xcj4P=~-%) zW%e~QLISz9U+-iDm9C06Rg7mVbtDzj3c5;uXW~<6R4M-Df#JrU*J>tWH~Zaa9}@G?t_rzh6JE z-lG-w!S;zr)W-5$W?p$C)7v+ta_2}57R{6pjMejjV7_$)aRhzF4BhUazWLOQr%H{H z=NhS5yGeQT46i$(l+W9xuXbm$R4}>a@~&~-EsJ5m<5fQ zmp+_0OzGd1N{kRgoSQd3Cc!?(TY>7o)mi;niC6m)tQf*xW_v3hu+U7<^iY|<+ppY6 zwW&TLD#3g+I8CsxymjuPk0HV-4Gq1_GxN@oG_3#|tlU=BrEW|f?A!(Y&;5Tf$M5$0 z+@l2h)LBVD2I-~EBKUwMHWrBpR{!m(#^NEYAEPh^$36k{V<;>XiDj@F{Ba_`t@81? zzlYx@tsY=mUZkC2;g2)~3)S%YEcC>m`u~_whu37a7V$7humHf3t|GG^L{O0m8HV}x$4FU=Mnc2fg z@Lw1ZD9GE-S=}Fy$v7;#Z(g`IcNqi*fnM#s{gm{lBEdpfKqNzswL~`q1~Cc&u4c%x zBsK#ErUF?R@+?K%QzT&Izt){SRRV|uYOJ0a0E6Vm0g`bNKqSD&`bq;};Ci4OfU^{7 V&+vCLFqk9`9LR)n7~}W%{|6KFnyvr< diff --git a/fastbee-report/src/main/resources/templates/DEVICE_RANGING_METER_V2.docx b/fastbee-report/src/main/resources/templates/DEVICE_RANGING_METER_V2.docx index 939c0ba7ea27c48c434e532d0304ed759d488350..f19f2e23b15e687d9f24f85e0b9884b502a7bdb9 100644 GIT binary patch delta 2328 zcmZ8jc{J2*8=ehW8#_%HDvaJjV^j#)m(P+CvPG54N!Aj^Z(b(bSB8*@!9--p z5^1tTwqBy1Nl+4 zYlJ(%&|Rz>0?Y4_HOSw4BpoIN5!r3IBNiV@i}Ha$8W@1iD@|B-bv-X`DS@BzB)t-} z%NB%_#N4E$M%6}ZJ6Rng`jLJ*meuH?D>xM}s`9y`DEv*kYubEZqxW(S$z2lcwXNx= z%;{;RPY$8^E%v>9QTRZ6KYFOF%#JXpTebq+O4*D&M!uD(bd7Pc^8i&?@v5q`saX^A z!`q&s_{YMEuL#5k5MczJ`9NVX*;sDs;OmA8=bldkmldnp?gz)757_T7gZ@WJ^pVef zk*m}Ui3F5wjTz6PXn~a?kJ&1voD`!a6;<@F1W5~2b(NsZX~T{x9XYIRzKFz%ru^Od zRjm_hIwMN$%33jg0^)KSMTY}C5(;|rww~IGYmiM2cyAMooQjK;N9Fs(sJ+8A)d|Mr z05<4dc+)I1eUW+eX4A=P*tn2|OUXZ_6E~$5?b*bdv*?$!)q2NZJv19t5%fji%2z*H zY@F?iGO{1*n3R9B`jt_WGHG;F=1xfaAtTe$PCq2AYRI_u#P?NKs^5{uh2^j~$~4Vo zIJv%TIh7DrUwj7#^DJhqzp)yc-*~>}N9;jlRWal&5foxcmuOg*v651UTrB_OpL?po$Jn5^BVdP(4pfi<>2HmEh;#wT3ug-k zGgm^Sa#cFqvKF-F)xa=vlmkrdMA@T~h|)PGK_FiaV8)`3S~#Ou1>_+}pPNtsYz zi>g;37+mALuRObdWc zL$FKljSoC03PdzlW3`AP>Tcf|KMnm@Ev0$Au5ie^8d_0eXKb&$EMw=ovs*!pa>}D> zj;2ws+7gBO`ea7dna23wv7O*C^Nex={h_g0)<^W<(jt0f_;gTck5f9TSv%p$kRdC) z)~J%FOz1+xkQ-Xm(n!BDFe)J$)( zvZC?KrbDWIv_#quudlgdbe2BVrLcN+^IL+yRCQRtbCT%d>ep44^@~!MDJ>@g|D!eU z62>WZrIhtqf!ZCa1|LRYJ!CL$CbxPyZZzH^m(m0GxxJml9Jn@X9#e;6#$7nBmqQJ; z0h}Eoupwxa&jn$NZBVwDZF6PMxfsKcY_Gtf^Yn1R=o~_g+9h`7gUm zhuBe?gbM3u=Ei^Jmi?p@!6mb zLouv@45O$IMvjqN@_N{gX;^GQKQzIjNyHHb zIj|)DjTVH{7qNqp2sp_=Ay{_w*06ZcTC8D!KLz|)_vyq`(UWdL@Qg>B%N2Cwo&1Tz z`vx~sFqn0((3iXKr0FSg@%s4MOm9PYMa$y_y1x)!g(w&DWzxscSh?}pv>dplP zbJ}}7RW6TwOvG@apB)|C=F8jI8q||zkNnNL=;dYJAmB#8i##2~s*c$+X!9on?M~O5 zth{=sq)Wu(wBh*TFBAQ`(&t>0%iAO>g|pi9FY*ySS zZO(YvMHcMy(h{}Nii&Ob@OVkm#;ADn&*jIVC*wTwr0>YVfBpMIybi&Zq5Z_lwRbJk z@NEaZXQnrN0I6E&HS8mXPF@eZL~*h4McTiB*BN|1d|swbvL`aM_Q_g?ffj86V^#i|1LBo7}JydRrQw%ykcjxg6;V~@miN860n!1oc%0yZqT+G zgkZQtBWe-KCGxTAdmj7iSrG0G`^EQfVgn*0*0}n}R3cqvyTn2UGfjS6O<;VltzxzS> zQ$(-{utJjOj=-}K5HSOw&)^_nnJuLHdyHye=Gh7bf#&!@AkjZFyGR7ziUfgh*l-{9 z2%t4v8u{Bie`)4C2n+(%@1EbEkUvEPhs6MlY$fiJ@!ciVSYRU?$(6DI0;~;SfWutn zwyV^E7+}v@xU2YpM&KB?w*Vj@pd_F<=>UKv3vsX1$q2ACU``Rs`NfHSS*)sN!b$+ODEg{1Q(`3mA zW6LhX86sI{Y$1|SM_f(!{BiI5`Fuam^L*du{k(s^Pc8vMDTnZzU*>>{flBtu~kfy36dPotp`ByJEWS`Vnt`WJxIR@d#=b-vC3nLk99c@bl_6GK zm28C;7`~awA*QsP!#d~7s)DXKH^EAdrN8XSy!@I&*{HA!>>dM4e?POVS8GAO{Yp!P%;~ztQ=(YB!Q}t*g6(|5zvhLJGR5=Z zfxXuQO`WbRdd4U4sztTIFZLEoy_G7s--Fc_KW2uu3ijZ+?N-x# zZ3^}*Cz}R0Ui3Zwgs6J2cu-eWoxpk$?g-ho@p(90Q7PFmPPpZYU3Iwr=X00VrPgwt zA9M%Y_S=yS!I>2`F~K-*Vo(ipkw+Ej7Q@x-S5#f-%xRd6^=#dY@)9wYmZvTS#~u(L zJitvhhYvK9$hdhuq4GWqhe&IoCRMue_{Qu~L9H63H$^*EM3x5eq^Pi3YtCTPL^o$F+dbsj2yOuN_oU$Q&+rn)_s}16QjRxF_f&fd_RblvcERbG3M~-&5L@+-DGoJQ zA#To%E=->vS$8bu;b){|Qya!CzBH_A`wmI&bWfzkUB$eyQ+#@& ztaOH3Nwych8H?VK9zs<`r;rydsmbPeU!DEG8f!%@X?pSq z6B?1isu-d-AcTx)fdm;L8jjk$|L;J?>JqJDE; zZ+TxgrXbC5rWzI>YwR>U&C`~-UxQM|xmKApB)(0AKuE%+c)Ox98sbns*5#k z(ayJj&yY0bKEFDXO!l>lGSEay7=I0gDRA4hJA1fA6WyvmkPE#V{BV?@+SR?AG8spi zCgg*8yRTuoW41w`;yUv~O+HS9ijg)j-MUYPTs!L5gM8bEjV$inC#l=VYt1clQpZ62^YX>B%&gsjf#2w&6^#{QsQ z-ulx`mA+^wg$~njV^*y1TS>TkZ^)v;f~q$!5IE$_7bEtlGgTDR(8}=%${MfNRFRH7 zW~^}+L85&aFQ2+rtA1cN8MbedrP&{`pQQzg-TZoD*$*aygD#g4mLkcgf@YlX5AeB~ zU{n>lx$}nuy{1*yCgihJev23ly0}!#;(H4jzoWmQFbz)n z8KK$La+}LN{mY}+QDTntvvc{0o|b?!Q{+RWw*vD~zkDOz zuI4mKoJAR&EpdEt-SDiRK59D6Q^@M*T)Xq#*2}0o$8Yulm76Vc$p<|DbN|Ty1r^`l8#;FV3UYo zB=}Sy2z38$zzyYl00Mtp>Zf_hfnfy#gFtnM?`KN-ry{|^A%G}ehP^}s4~Jkv0b9Hj zTZZ7_U^;y@(8$NowK;NW_I2uQQl W(4oOH2^dT~0dQstaT%ol-2We<5S7&c diff --git a/fastbee-report/src/main/resources/templates/DEVICE_STATIC_LEVEL_V2.docx b/fastbee-report/src/main/resources/templates/DEVICE_STATIC_LEVEL_V2.docx index 45e1c9dc92dfe275dc3882872151d8b2852b3652..f93f707f4818642aa95b9636f8b2b23a0d778dcd 100644 GIT binary patch delta 2385 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 diff --git a/fastbee-report/src/main/resources/templates/DEVICE_STRAIN_GAUGE_V2.docx b/fastbee-report/src/main/resources/templates/DEVICE_STRAIN_GAUGE_V2.docx index e40c94ba81adc0016af92f5df74ed9a88525495e..7bcd20a9327330a0e463d6a63076bcaf8b75d5f6 100644 GIT binary patch delta 2330 zcmZ8jX*ksD8~)9VY%`b|yF>O!vTxZXO&p1AktHdnu^dH+8VMmZ{LBnZl*kq)`w_Ag z6S9nbqJ`{*u_x&;u0H(#&vm`mec$hMKiBi&{cyjv1<;IqDAzd)7z-}|0PKKQwq_|; z3k0@{<$VybeXy>9IWfKu4>9bZLL!aP5-KNz>sNBDK@q11g&$tzeD^L&Ki*U|=ycD$dBqan5{TfcMt3P>-On6YB#3RMj!4 zQE-#`HYwmmFyFbwDUvuDQV@>_Nv&P-U=fBYS9x=faDA*QwDg<`mr>+$z2wt-Hqfto zcK0FP7H9pVwiIMXRp(tE6h<*DLre?vNyCV!fuJq4J*!&)w!cVPt5)ByK~mdat0L<= z4x{UtQ8a%@w5e@oIlO|V{z_l@q>lRoR?ll=_sjTY#eLaLBN?#?RBy^m2F>v>G5ACM zUoy*PzE9(tlRdPk%5rpgvYzR zni;Jb|9hv4a<+XV?Upm7h&;?GrDR#$I*e3TzEzeh2xeeaj%p7$G%TkJEV_+djK=0Q zZOZAnRYq#4nmkS9z*aAfl-F`zlaf=VjYXjDVo+!a`9^Pl)9u2cg%hL_?I8i&Cl?&Q z>0DV)B#UzXMybYMI~$NTiotciF)m50rZoFJ41WzGS!};WHWZ}P)l4Vob09IC{0*uu z8E&x&2{SLnx3+G=)0V!CY*73Qdf8nsij`tbi!bWqvb-MWNjy(gmBI@wls zeM+fRNm-+|$=cVh$$;r<27uFf*7b-($k?e7edA8%G>QMm%nKux+&DVBW)e(Fi5g$T zAMTY!%$Q{C)weQou{3xb?-uc(Z^ja)X+s)Z@aE0Iizl+0^aN4dhOe^&c z=kr?_vzC2nc?l!}Ykuuvl}EZCix{%ukL!RERN_x~Sk}lf%h;>uZ-+V*752-G_Dd5r z>&OnuzKGIGu&ww80{lINon}`r`7?^@(Nd)>_*ICAd=T`AtaxR-z8_)%n+hy`EV8GzW7a%PI;BCiTsuHHN)e zfF@s?J;BqQkXw@JQQQ=LM>xb*dSlTl#F*4KA1H`KUZF^&z<#;qves8~-ezrgvJ{xO zb4p>tZ)JXcl`eE)-Z56A^TROJ)<$t0=9RP35t(LC?C)BHTWD9*4xBDVUZ&1pFi;sg zw9@vZfP6}vs6x*+3+9o4fG-C-7bDDz1Lj_9STRriF9P+u-;yFzQ zH}1LZtc`8r{uT@!%4#x_!vtr3oCY77nCrG61Yut$cSr}kUhXgsdgJXu{=U>T|G zi(9Ixsm{LKwNfxwb7eADHpVZgcO+k%Vf zygTI;=yMyqIS3cIPWjAojr3X!amwh}7yDr4jy~LC#N{yW4f~m8IF6b*wdJwao2s_q zP`Q2eNUOt{(P<}cr0kZLXR`zvne)r$B}bP*8FYg{VOm1nH?2ojI#s5N z?Q{>_h&#cW$k%yG{XDt?*3L2BjR-6^zNxPWcc7W4N>#3Z*jjS+Q2grUXNGY+>v`Na z5oWi0ttF!wwt54-i}3rV5)SmMvr4@vy`VzV*8~ z$Bzt_%IaztI4887KG=7CSuNb~GoQ#p_7=*xh~bv^5eNKFz?MgIWA^iu!PB9{jM@f6 zKMf{P4w{ECiCC!8zUSO6Jd}Ae5x&m4N5Ij015B)lT4y$jqj~qOZ(`E+huz{C2bh-g zcmpP;;x(E09?5AP+tbyD_dmJ$Gz*c||PcE1RJ zDgt5#E+&gHN02EZP+mi@%kVGoObSA7Z!Fu;%&h?i0Mi@*aPZH}E=EA20|6k||GKBr z4X`0aKzz?U<1~35fB-<%?%Df>{HX}YjS#RRMUuH>WOvE)P;fm(oGFDs5r{UZ0ZKE~ z#a*QWhJgFlj9tY6)_{u4-Yh5rg+_yQ(ZZm3DuQ{Xlqv!d0xeP{n94s@1X>XX`o#-@ N&r^}CxexdD{|C-2I^qBT delta 2479 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 Ndlnzt1>(<#{|AS|ip>B3 diff --git a/fastbee-report/src/main/resources/templates/DEVICE_THERMOMETER_V2.docx b/fastbee-report/src/main/resources/templates/DEVICE_THERMOMETER_V2.docx index 5871ec50b4e889f77fbd0f97f695cc1cecb239aa..d39a56c837c79cb69848c5f5e73e318d1fceeffa 100644 GIT binary patch delta 2430 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 delta 2481 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 Y3cGfN<`03+7#Tp|(E$NN%Fp}!1M-rGbpQYW diff --git a/fastbee-report/src/main/resources/templates/DEVICE_VIBRATING_WIRE_V2.docx b/fastbee-report/src/main/resources/templates/DEVICE_VIBRATING_WIRE_V2.docx index aa023dc84ac4059d6378035b90cab10312446579..b22e8a693ac058c9b82c7c438b12b545bddff4b6 100644 GIT binary patch delta 2427 zcmZ8j3pCU1AOCHW<~A&In^>B1DTI;x(B<#eT$)=(E9LeIjiO?NNfg_!TvJ4$(W}=g z2{n;Q*I^_gd*_lyOT0y^cg?^5IsKn=p7Z&9zt89SJm2%2=bZ0zrw&?D3svxPhe@db z000NVt1NFQJb=K&*whQrULl{M=T}IclqwVQV577eRj_A|?;2N? z)X`A-6FCAyPKk~TRxKnkgL{xBihGt{mP}*Yo_pkvluK3DL;bhox5>roh4}TLuHDM} za19Qh$=_lwL-?L0xOmYO?~FH|mA{!>T;gD#vSd4IopMRbfm0J*sfAS`SGBFi^c zT-b(Y9Dza&VL>Y*2JTIjmPbVlrU?{81(sUVUnb((J-gievZdwX6{KT&%G{1`eNg;b zHOEU2mWW3uRx5eLpT-#m+zzX2Hq1U= zgY{eP8c8tTc&gcZjecvK{;0H%{HG&ov1e)PbtnC^$((y)i zt(Oz3(}0vrWj5PsOq}$naTS{j*G;|CGr4gM_6-G=M)3>dN3Rn#*VW2eWpU7#p zX7F8whc54Iu4&h&L!h1>0afWHA;Z32WlqZE*=^{2HxMuDilEs#fAen@Ieu?~A`Lr;_9anZ6BgNtc+RNn3m)e^}h!&cgx5juP zh@m`KW+`znerHLn*1F?|mzNqv%Z>S3-egZ|b{?iHy!y4()rM#acZ~6;x^Gg9RCIV+ zAwMjOE4vF_d#L8lAyr1^2{#$OMYWAVPFGcE{E~94X!yzax3O+^<@p-9D|IJD+5rG9Szhj zKJjk8>bj}!j@9kGJ~IbInx9oJA(>Bg;64Z1?3OpZ*eBXxr5>iCsx*%(jc(8{ckVWI zm`p4r1@yd^4%h$Sx@)|(tPhWKd@pn1kB!s=V>v>?}VxSOV2HWQ0(UP0pau9w9J8-{I zLT=eG7S37B#^Dy?;~u5j`n9kyBfB5G6d2nuQQcgkohgQ(df^NxYRqRPm8}W9h;G{9 z_=xh=)AQ59!=x()rtOD`$=&xZS&Hh7Ot$yg3PVzx*tJUA!pmPkg;~@g2a13EcdPiR z&mRtd<>zvyF4%8n-Eh4-ljV7*DDK2f_fQ#{9Y*>n)4ce=^nmTypvI60)xtgRxSc?W zy>4NCkRT!jeQQMXn3&l;dN4RoWX>U*h$}W;HwX<64M?Zt&e7jqO&Q~jcdVb~jeiS^ z|DLY3)bLL(zhlDOfEt=_uz_tLwAY(ZVELj(Pkw;;2l8B=M~;TwX$#N>{UkVkY&+{S$}lNra&M~*v{s^zXlQZK$sv7zo_S>ANp z^XxML-}j*LG5^`&ml*wWa(@`ooVE}ZR2=Ae0OQrAL@}Mk`l>e(A14%AyACYNay#+7 z)T|WK+0(>&!&8V|QxiO)Oy{}0FVdrONsnps`ahhM>b}3KAu#KT6IeIFH?+b{Jz9pVNEq;wONNf`L7eiMfTDGpB?1uutvMc}~e<{S6 z2B}O2KQk~A(K#pG=7-V%aMut3P``;RPJkazsDbXFyrlFX5DnQ39szYFQ5lGauF3@2 znHX>ol$SW&yzAllPVBHxT$B25PTw=ZmMl$B``n7t=5uH$iUz)-semE>O~r^)jXB^S Zr!`c)+*gUq`FRJ*09kyqm-Bx{{{l(pC{+Le delta 2588 zcmZ8jc{tQ-8~)A0*hSWc!5mALL$({6 z1FpnsR0_91V7vHu0D{^he?xEY5jtE7DzbZYw!MG-O$7n~$X4J(UTN~o8Esxp$~zh zC)@7vv~|BaP1oQlf|(Tkl;BGXyMTQ`6ng)g{k2Lx)nF}I*VY_U(3i)pvpx%s;qNQb zbZO6V7`XQCwJLjA%m+i&Vf)*R#PgCfT5J$>X@}fuMQPfsELnR1F65s=9m!_gUj8$+ z?VjygqS9YD~9ah3sb)u1C= zR4|wERN#oZ_u>6>i*$2a%RBf3P}6G_otQe+l7@@zLa|?2>th@4BEeM8qM}(G{{bOaqB<;`k4&o+E!Rb-nGJ+Ltk#PbHB@!#VgU=0$$&8Nfs;l$+ zc>zJwP1wLZTvzX-WtVnL(OiWFFTW!FCHR;1qMP*`)gYT`qqoibI(fR|#Lp%5f=xPF zsEH`tccRe}0T?=Q~X_XpXtQo`_ z*$#hdqZ+1vR;6gHWoB&B^KO6^k8#Ir?+fuk=#x5!k3$M(Pn()hmUZ!J#~A8N!NPN6 z3)-te?opSkeQMPrr_wYkOJ{v1s9qP-dV>1)Z&fDhSt#1y{<_khw`_NzcUmA0-2y#7 zT3G90GiP9OeVHe;GP@Ob;)v0)6BV;ZwaN9i!UnnRjRNm0-Sw96lL+d3y7_ID!eWC+ zVsk;Gmr6dRre{@CT1U6=y0|+3L;r{ctdpv(`pU3msX+}^2bxZs?!z|Ahw}^-9*d*c zM;rQNXKsZBj766i2}VWcAU=&2pu9FK_YsOVh${LHvM&%W4x8f_$&2QV zxW=T~+)Dsn-dYLNQ_j_~K z*Xr?goXY(P&6>b)>*|^gWvh&S1=j5BXTclm;LzA9*9UiS2XQf30}4s`SL$rPu#cZ? z?^JUtQ|@n>^9_*CzQp>Z!LV1O!is%fTF<-<9x2gs{*U3p?I@{mVd+IyIXN=tjkB`b zi!(tYG23<9OEDMy+kH+i6c2he~En}l&B=~ zW#wR*{WwK9BGB{oCiQy28ac;h!iR_xM_m=oEIJ8G21l^t#WT~}vvn=d!{;0ciY}h-&pS#sQ#A~>u=Rw`Q2se5; zFK!b3D<0^DUYc)kRYTZO8>Xa6v%#wBkg^@t#SaFg!;;+%`EqR5?Jmy;#|3ua>N3N* zc96}F)L3C#OVlazuFwc}vFETK(Z;4b;i z0etCB2i;z-t|^jCSzw8{`Uy?m@Gkff$3D{wjx0J~l)oDD_;>*^HhIwrp!^>_2}DBbS)M?>%EcIl|U3KYIuE!|(r? zEgZD>*}IB%>2T)&d5aK3F1Z!{5ymB?D>{3c@~aFeH)BOw=lw=Nlei%+ipQ^W4+Ik= z_M~WP#$It_(lQTMQkiVd#g*j4T-0P7zGJL56EJo zxzQ#Q34IU`D#a^;MxYSasjA-j_|&e$`Q4smesIc701qS_0As*Cr<))WT15qQsS@C` h|E79&Q&<|i7==i3{I0q1sK)|&Ax`WG@&g6RMN diff --git a/fastbee-report/src/main/resources/templates/DEVICE_WATER_LEVEL_V2.docx b/fastbee-report/src/main/resources/templates/DEVICE_WATER_LEVEL_V2.docx index 065054a7fbf6eef8fb6e64e9e8ab144d08f24899..83a8d2c1d6072c72a16240bab89b51a328f81d88 100644 GIT binary patch delta 2329 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 diff --git a/fastbee-report/src/test/java/com/fastbee/report/core/MultiDeviceAnalysisMergerTest.java b/fastbee-report/src/test/java/com/fastbee/report/core/MultiDeviceAnalysisMergerTest.java new file mode 100644 index 00000000..e1475a89 --- /dev/null +++ b/fastbee-report/src/test/java/com/fastbee/report/core/MultiDeviceAnalysisMergerTest.java @@ -0,0 +1,97 @@ +package com.fastbee.report.core; + +import com.fastbee.iot.domain.Device; +import com.fastbee.report.model.AnalysisResult; +import com.fastbee.report.model.MetricPointData; +import com.fastbee.report.model.Statistics; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 多测点归并测试:重点验证"统计期内无数据的测点"不会被静默丢弃 + * + *

场景来自真实反馈:勾选 5 个测点导出,报告只有 1 个测点,用户无法判断是漏导出还是设备没数据。 + * 现要求在结论中列明未纳入分析的测点及数量。

+ * + * @author FastBee + * @date 2026-09-22 + */ +public class MultiDeviceAnalysisMergerTest { + + private static final String PRIMARY = "Dis_x"; + + @Test + public void testSinglePointWithSkippedPoints() { + AnalysisResult merged = new MultiDeviceAnalysisMerger().merge( + Collections.singletonList(analysis(171L, "固定式测斜仪_1", "D1903E4N060G", new BigDecimal("-7.46"))), + Arrays.asList("固定式测斜仪_2(D18IMHD6YVFQ)", "固定式测斜仪_3(D1490T9908QV)", + "固定式测斜仪_4(D127VZVKDVZ9)", "固定式测斜仪_5(D15XKPE8D5RM)"), + 5); + + assertTrue(merged.getConclusions().size() >= 1, "应追加无数据测点结论"); + String text = merged.getConclusions().get(merged.getConclusions().size() - 1).getText(); + assertTrue(text.contains("5"), "结论应包含所选测点总数: " + text); + assertTrue(text.contains("4"), "结论应包含未纳入分析的测点数: " + text); + assertTrue(text.contains("固定式测斜仪_2(D18IMHD6YVFQ)"), "结论应列出具体测点: " + text); + // 单测点分支下,明细行仍要补上测点标识 + assertEquals(Long.valueOf(171L), merged.getMetricPoints().get(0).getDeviceId()); + assertEquals("D1903E4N060G", merged.getMetricPoints().get(0).getPointNo()); + } + + @Test + public void testNoSkippedPoints() { + AnalysisResult merged = new MultiDeviceAnalysisMerger().merge( + Arrays.asList(analysis(171L, "固定式测斜仪_1", "D1903E4N060G", new BigDecimal("-7.46")), + analysis(173L, "固定式测斜仪_3", "D1490T9908QV", new BigDecimal("-3.20"))), + Collections.emptyList(), + 2); + + String all = merged.getConclusions().stream() + .map(c -> c.getText() == null ? "" : c.getText()) + .reduce("", String::concat); + assertFalse(all.contains("未纳入本次分析"), "无跳过测点时不应出现该结论: " + all); + assertEquals(2, merged.getMetricPoints().size(), "明细应保留两个测点"); + } + + private MultiDeviceAnalysisMerger.DeviceAnalysis analysis(Long deviceId, String name, + String serial, BigDecimal current) { + Device device = new Device(); + device.setDeviceId(deviceId); + device.setDeviceName(name); + device.setSerialNumber(serial); + + Statistics statistics = new Statistics(); + statistics.setIdentifier(PRIMARY); + statistics.setModelName("X轴位移"); + statistics.setUnit("mm"); + statistics.setCount(10); + statistics.setInitial(new BigDecimal("-7.44")); + statistics.setCurrent(current); + statistics.setDelta(current.subtract(new BigDecimal("-7.44"))); + + MetricPointData point = new MetricPointData(); + point.setIdentifier(PRIMARY); + point.setModelName("X轴位移"); + point.setCurrentValue(current); + point.setChangeValue(statistics.getDelta()); + point.setPrimary(true); + + AnalysisResult result = new AnalysisResult(); + result.setPrimaryIdentifier(PRIMARY); + result.getStatistics().put(PRIMARY, statistics); + result.setMetricPoints(Collections.singletonList(point)); + + ReportContext context = new ReportContext(); + context.setDevice(device); + return new MultiDeviceAnalysisMerger.DeviceAnalysis( + new ReportTarget(deviceId, null), context, result); + } +} diff --git a/sql/fastbee-report.sql b/sql/fastbee-report.sql index 61221dbd..78bdf81b 100644 --- a/sql/fastbee-report.sql +++ b/sql/fastbee-report.sql @@ -3,7 +3,7 @@ -- 创建日期: 2026-08-16 -- 说明: -- 1. iot_product 增加监测分类编码列 --- 2. 新增 iot_things_threshold / report_template / report_record 三张表 +-- 2. 新增 iot_things_threshold / report_record 两张表(Word 模板不入库,随代码发布) -- 3. oss_config 插入 MinIO 默认配置(存储决策:未购买云 OSS,统一使用自建 MinIO) -- 4. 历史报表菜单与按钮权限 -- 注意: 存量库执行前请先备份;增量脚本可重复执行(使用 / INSERT IGNORE) @@ -85,39 +85,11 @@ CREATE TABLE `iot_things_threshold` ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci ROW_FORMAT=DYNAMIC COMMENT='报告结构化阈值'; -- ------------------------------------------------------------ --- 2.2 Word 模板表 +-- 2.2 Word 模板(不再建 report_template 表) +-- 模板作为代码资产放在 fastbee-report/src/main/resources/templates/ +-- 下(DEVICE_${CATEGORY}_V2.docx),由 DefaultWordTemplateResolver +-- 从 classpath 直接加载,随版本发布,不需要数据库表。 -- ------------------------------------------------------------ -CREATE TABLE `report_template` ( - `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键', - `template_code` varchar(64) COLLATE utf8_unicode_ci NOT NULL COMMENT '模板编码,如 DEVICE_CRACK_METER_V1', - `template_name` varchar(128) COLLATE utf8_unicode_ci DEFAULT NULL COMMENT '模板名称', - `monitor_category_code` varchar(64) COLLATE utf8_unicode_ci DEFAULT NULL COMMENT '监测分类编码', - `version` int(11) NOT NULL DEFAULT 1 COMMENT '版本号', - `file_path` varchar(512) COLLATE utf8_unicode_ci DEFAULT NULL COMMENT '模板文件路径(classpath 或 MinIO 路径)', - `status` char(1) COLLATE utf8_unicode_ci NOT NULL DEFAULT '0' COMMENT '状态(0=启用 1=停用)', - `del_flag` char(1) COLLATE utf8_unicode_ci NOT NULL DEFAULT '0' COMMENT '删除标志(0代表存在 2代表删除)', - `create_by` varchar(64) COLLATE utf8_unicode_ci DEFAULT '' COMMENT '创建者', - `create_time` datetime DEFAULT NULL COMMENT '创建时间', - `update_by` varchar(64) COLLATE utf8_unicode_ci DEFAULT '' COMMENT '更新者', - `update_time` datetime DEFAULT NULL COMMENT '更新时间', - PRIMARY KEY (`id`) USING BTREE, - UNIQUE KEY `uk_template_code_version` (`template_code`, `version`) USING BTREE -) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci ROW_FORMAT=DYNAMIC COMMENT='报告模板表'; - -INSERT IGNORE INTO `report_template` -(template_code, template_name, monitor_category_code, version, file_path, status, del_flag, create_by, create_time) -VALUES -('DEVICE_CRACK_METER_V1', '裂缝计监测分析报告V1', 'CRACK_METER', 1, 'templates/DEVICE_CRACK_METER_V1.docx', '0', '0', 'admin', NOW()), -('DEVICE_DEEP_DISPLACEMENT_V1', '深部位移监测分析报告V1', 'DEEP_DISPLACEMENT', 1, 'templates/DEVICE_DEEP_DISPLACEMENT_V1.docx', '0', '0', 'admin', NOW()), -('DEVICE_AXIAL_FORCE_V1', '轴力应力监测分析报告V1', 'AXIAL_FORCE', 1, 'templates/DEVICE_AXIAL_FORCE_V1.docx', '0', '0', 'admin', NOW()), -('DEVICE_STATIC_LEVEL_V1', '静力水准监测分析报告V1', 'STATIC_LEVEL', 1, 'templates/DEVICE_STATIC_LEVEL_V1.docx', '0', '0', 'admin', NOW()), -('DEVICE_VIBRATING_WIRE_V1', '振弦监测分析报告V1', 'VIBRATING_WIRE', 1, 'templates/DEVICE_VIBRATING_WIRE_V1.docx', '0', '0', 'admin', NOW()), -('DEVICE_STRAIN_GAUGE_V1', '应变计监测分析报告V1', 'STRAIN_GAUGE', 1, 'templates/DEVICE_STRAIN_GAUGE_V1.docx', '0', '0', 'admin', NOW()), -('DEVICE_WATER_LEVEL_V1', '水位计监测分析报告V1', 'WATER_LEVEL', 1, 'templates/DEVICE_WATER_LEVEL_V1.docx', '0', '0', 'admin', NOW()), -('DEVICE_RANGING_METER_V1', '测距仪监测分析报告V1', 'RANGING_METER', 1, 'templates/DEVICE_RANGING_METER_V1.docx', '0', '0', 'admin', NOW()), -('DEVICE_INCLINOMETER_V1', '倾角计监测分析报告V1', 'INCLINOMETER', 1, 'templates/DEVICE_INCLINOMETER_V1.docx', '0', '0', 'admin', NOW()), -('DEVICE_THERMOMETER_V1', '温度计监测分析报告V1', 'THERMOMETER', 1, 'templates/DEVICE_THERMOMETER_V1.docx', '0', '0', 'admin', NOW()), -('DEVICE_RAIN_GAUGE_V1', '雨量计监测分析报告V1', 'RAIN_GAUGE', 1, 'templates/DEVICE_RAIN_GAUGE_V1.docx', '0', '0', 'admin', NOW()); -- ------------------------------------------------------------ -- 2.3 报告生成记录表(保留期由 LogCleanTask#cleanLogs(days) 统一管理,按 create_time 倒推清理) diff --git a/yack-web/src/views/dataCenter/components/DeviceHistoryTablePanel.vue b/yack-web/src/views/dataCenter/components/DeviceHistoryTablePanel.vue index 0cbc0275..0ffff2cf 100644 --- a/yack-web/src/views/dataCenter/components/DeviceHistoryTablePanel.vue +++ b/yack-web/src/views/dataCenter/components/DeviceHistoryTablePanel.vue @@ -476,23 +476,24 @@ export default { this.$message.warning('请选择时间范围'); return; } - // 网关设备需选定单个子设备后才能生成报告(网关本身不可生成) - if (this.isGatewayLike) { - const subIds = this.gwQueryParams.subDeviceIds; - if (Array.isArray(subIds) && subIds.length === 1) { - reportDeviceId = subIds[0]; - } else { - this.$message.warning('网关设备请选择单个子设备后生成报告'); - return; - } - } - this.reportLoading = true; + // 网关设备:支持多子设备一起出报告(后端按同产品校验); + // 未勾选子设备时传网关 deviceId,由后端自动展开为其下同产品子设备 const payload = { - deviceId: reportDeviceId, startTime: moment(range[0]).format('YYYY-MM-DD HH:mm:ss'), endTime: moment(range[1]).format('YYYY-MM-DD HH:mm:ss'), format: 'WORD', }; + if (this.isGatewayLike) { + const subIds = this.gwQueryParams.subDeviceIds; + if (Array.isArray(subIds) && subIds.length > 0) { + payload.deviceIds = subIds; + } else { + payload.deviceId = this.device.deviceId; + } + } else { + payload.deviceId = reportDeviceId; + } + this.reportLoading = true; generateDeviceReport(payload) .then((res) => { if (res.code === 200) {