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