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)」列表
+ *
+ * 三种导出口径:
+ *
+ * - 设备分组导出(groupId):取分组下全部设备,断面 = 分组名,表格展示断面列;
+ * - 网关导出(deviceId 指向网关设备):取该网关下与其子设备同产品的监测设备,
+ * 网关是通信中继不是测点,必须展开为子设备才有物理量可分析;
+ * - 直连设备多选(deviceIds):直接使用传入的设备列表,无断面。
+ *
+ *
+ * @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!+v5IrDa-v*%mD(XL%}Bp8;{xlIGhH4{1FxXB?WX5g
zqx5>1vjPF37fToQA_%}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`66b~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
zSAvA41G#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
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
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!a