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; /** * 变化速率计算器(单位/天) * + *

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

+ * + * * @author FastBee * @date 2026-08-16 */ @Component public class RateCalculator { + private static final int SCALE = 6; + /** - * 平均变化速率 = (当前值 - 初始值) / 天数 + * 本期平均速率 = (当前值 - 初始值) / 天数 */ public BigDecimal ratePerDay(Statistics statistics, long durationSeconds) { if (statistics == null || statistics.getCurrent() == null || statistics.getInitial() == null) { @@ -27,6 +44,140 @@ public class RateCalculator { return BigDecimal.ZERO; } return statistics.getCurrent().subtract(statistics.getInitial()) - .divide(BigDecimal.valueOf(days), 6, RoundingMode.HALF_UP); + .divide(BigDecimal.valueOf(days), SCALE, RoundingMode.HALF_UP); + } + + /** 语义化别名:本期平均速率 */ + public BigDecimal averageRatePerDay(Statistics statistics, long durationSeconds) { + return ratePerDay(statistics, durationSeconds); + } + + /** + * 回归斜率速率(单位/天):最小二乘拟合得到的日均变化速率 + * + * @param data 时序数据(内部按时间升序处理) + * @return 回归斜率;有效点不足 3 个或时间跨度退化为 0 时返回 null + */ + public BigDecimal regressionRatePerDay(List data) { + List points = toPoints(data); + if (points.size() < 3) { + return null; + } + double sumX = 0, sumY = 0; + for (double[] p : points) { + sumX += p[0]; + sumY += p[1]; + } + int n = points.size(); + double meanX = sumX / n; + double meanY = sumY / n; + double sxx = 0, sxy = 0; + for (double[] p : points) { + sxx += (p[0] - meanX) * (p[0] - meanX); + sxy += (p[0] - meanX) * (p[1] - meanY); + } + if (sxx == 0) { + // 所有点落在同一时刻,无法拟合斜率 + return null; + } + // x 单位秒 → 换算成每天 + return BigDecimal.valueOf(sxy / sxx * 86400).setScale(SCALE, RoundingMode.HALF_UP); + } + + /** + * 最大分段速率(单位/天):相邻观测点中日变化速率绝对值的最大值,带变化方向符号 + * + * @param data 时序数据 + * @return 最大分段速率;数据点不足 2 个或相邻点时间相同(无法换算)时返回 null + */ + public BigDecimal maxSegmentRatePerDay(List data) { + List sorted = sorted(data); + BigDecimal max = null; + for (int i = 1; i < sorted.size(); i++) { + MetricData prev = sorted.get(i - 1); + MetricData curr = sorted.get(i); + if (prev.getNumericValue() == null || curr.getNumericValue() == null + || prev.getTime() == null || curr.getTime() == null) { + continue; + } + long millis = curr.getTime().getTime() - prev.getTime().getTime(); + if (millis <= 0) { + continue; + } + double days = millis / 86400000.0; + BigDecimal segment = curr.getNumericValue().subtract(prev.getNumericValue()) + .divide(BigDecimal.valueOf(days), SCALE, RoundingMode.HALF_UP); + if (max == null || segment.abs().compareTo(max.abs()) > 0) { + max = segment; + } + } + return max; + } + + /** + * 解析主口径速率:优先回归斜率,算不出时回退本期平均速率 + * + *

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

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

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

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

* + *

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

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

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

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

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

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

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

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

约定 charts 的 key:

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

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

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

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

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

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

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

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

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

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

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

三种导出口径:

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

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

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

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

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

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

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

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

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

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

+ *
    + *
  • 断面:仅按设备分组(iot_group)导出时存在,否则不生成该列;
  • + *
  • 主监测指标:初始值 / 本次值 / 变化量 / 变化速率(完整四列,主导报告结论);
  • + *
  • 其余物模型(如温度等影响量):本次值 / 变化量,保留物理量完整度;
  • + *
  • 初始观测时间、监测结论:以主指标为准,结论取该测点所有物模型中的最严重等级。
  • + *
*/ + private TableRenderData buildMetricTable(AnalysisResult analysis) { + List points = analysis.getMetricPoints() == null + ? new ArrayList<>() : analysis.getMetricPoints(); + if (points.isEmpty()) { + return Tables.ofA4Width() + .addRow(Rows.of("统计期内无有效测点数据").center().textFontSize(9).create()) + .create(); + } + + // 1. 按测点(设备)分组,保持测点顺序 + Map> byPoint = new LinkedHashMap<>(); + for (MetricPointData point : points) { + String key = point.getDeviceId() != null + ? "D" + point.getDeviceId() : "P" + point.getPointNo(); + byPoint.computeIfAbsent(key, k -> new ArrayList<>()).add(point); + } + + // 2. 列顺序:主指标优先,其余按首次出现顺序(全测点统一,便于横向对比) + List orderedIdentifiers = new ArrayList<>(); + String primaryIdentifier = analysis.getPrimaryIdentifier(); + if (primaryIdentifier != null) { + orderedIdentifiers.add(primaryIdentifier); + } + for (MetricPointData point : points) { + if (point.getIdentifier() != null && !orderedIdentifiers.contains(point.getIdentifier())) { + orderedIdentifiers.add(point.getIdentifier()); + } + } + + boolean hasSection = points.stream() + .anyMatch(p -> p.getSectionName() != null && !p.getSectionName().trim().isEmpty()); + + // 3. 表头 + List header = new ArrayList<>(); + if (hasSection) { + header.add("断面"); + } + header.add("测点编号"); + header.add("测点名称"); + for (String identifier : orderedIdentifiers) { + MetricPointData sample = findSample(points, identifier); + String name = sample == null ? identifier : nvl(sample.getModelName()); + String unit = sample == null ? "" : nvl(sample.getCurrentUnit()); + boolean primary = identifier.equals(primaryIdentifier); + if (primary) { + header.add(name + "·初始值" + unitSuffix(unit)); + header.add(name + "·本次值" + unitSuffix(unit)); + header.add(name + "·变化量" + unitSuffix(unit)); + header.add(name + "·变化速率" + unitSuffix(sample == null ? "" : nvl(sample.getRateUnit()))); + } else { + header.add(name + "·本次值" + unitSuffix(unit)); + header.add(name + "·变化量" + unitSuffix(unit)); + } + } + header.add("初始观测时间"); + header.add("监测结论"); + + Tables.TableBuilder builder = Tables.ofA4Width().center(); + builder.addRow(Rows.of(header.toArray(new String[0])) + .bgColor("DCE6F1").textBold().center().textFontSize(9).create()); + + // 4. 数据行 + for (Map.Entry> entry : byPoint.entrySet()) { + List rowPoints = entry.getValue(); + Map byIdentifier = new LinkedHashMap<>(); + for (MetricPointData point : rowPoints) { + byIdentifier.put(point.getIdentifier(), point); + } + MetricPointData first = rowPoints.get(0); + List row = new ArrayList<>(); + if (hasSection) { + row.add(nvl(first.getSectionName())); + } + row.add(nvl(first.getPointNo())); + row.add(nvl(first.getPointName())); + + for (String identifier : orderedIdentifiers) { + MetricPointData point = byIdentifier.get(identifier); + if (point == null) { + row.add("-"); + if (identifier.equals(primaryIdentifier)) { + row.add("-"); + row.add("-"); + row.add("-"); + } else { + row.add("-"); + } + continue; + } + if (identifier.equals(primaryIdentifier)) { + row.add(str(point.getInitialValue())); + row.add(str(point.getCurrentValue())); + row.add(str(point.getChangeValue())); + row.add(str(point.getChangeRate())); + } else { + row.add(str(point.getCurrentValue())); + row.add(str(point.getChangeValue())); + } + } + row.add(fmt(primaryTime(rowPoints, primaryIdentifier))); + row.add(worstStatusText(rowPoints, primaryIdentifier)); + builder.addRow(Rows.of(row.toArray(new String[0])).center().textFontSize(9).create()); + } + return builder.create(); + } + + private MetricPointData findSample(List points, String identifier) { + for (MetricPointData point : points) { + if (identifier.equals(point.getIdentifier())) { + return point; + } + } + return null; + } + + /** 初始观测时间:主指标优先,缺失时取该测点第一个有时间的物模型 */ + private Date primaryTime(List rowPoints, String primaryIdentifier) { + for (MetricPointData point : rowPoints) { + if (point.getIdentifier() != null && point.getIdentifier().equals(primaryIdentifier) + && point.getInitialTime() != null) { + return point.getInitialTime(); + } + } + for (MetricPointData point : rowPoints) { + if (point.getInitialTime() != null) { + return point.getInitialTime(); + } + } + return null; + } + + /** 监测结论取该测点全部物模型中最严重的预警等级 */ + private String worstStatusText(List rowPoints, String primaryIdentifier) { + String worst = null; + for (MetricPointData point : rowPoints) { + String status = point.getWarningStatus(); + if (status == null) { + continue; + } + if (worst == null || WarningAnalysis.severity(status) > WarningAnalysis.severity(worst)) { + worst = status; + } + } + // 主指标无数据时整体标注"无数据",避免缺测被当成正常 + boolean primaryMissing = rowPoints.stream() + .noneMatch(p -> p.getIdentifier() != null && p.getIdentifier().equals(primaryIdentifier) + && p.getCurrentValue() != null); + if (primaryMissing) { + return "无数据"; + } + return worst == null ? "待评估" : warningStatusText(worst); + } + + private String nvl(String value) { + return value == null ? "" : value; + } + + private String unitSuffix(String unit) { + return unit == null || unit.trim().isEmpty() ? "" : "(" + unit.trim() + ")"; + } + + /** 时间格式化:yyyy-MM-dd HH:mm:ss,空值返回 "-" */ + private String fmt(Date date) { + return date == null ? "-" : new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(date); + } + + /** 拼接数值与单位(单位为空时只返回数值,避免尾部空格) */ private String withUnit(String value, String unit) { return (unit == null || unit.trim().isEmpty()) ? value : value + " " + unit.trim(); } @@ -285,13 +480,35 @@ public class WordRenderer implements ReportRenderer { return formatted.toPlainString(); } - /** 预警状态转中文 */ + /** 预警状态转中文(表格「监测结论」列:正常/黄色/橙色/红色) */ private String warningStatusText(String status) { + if (WarningAnalysis.STATUS_CONTROL.equals(status)) { + return "红色"; + } if (WarningAnalysis.STATUS_ALARM.equals(status)) { - return "报警"; + return "橙色"; } if (WarningAnalysis.STATUS_WARNING.equals(status)) { - return "预警"; + return "黄色"; + } + if (WarningAnalysis.STATUS_UNKNOWN.equals(status)) { + return "待评估"; + } + return "正常"; + } + + /** + * 预警状态转完整描述(正文「预警状态」章节:正常/黄色预警/橙色预警/红色预警) + */ + private String warningLevelText(String status) { + if (WarningAnalysis.STATUS_CONTROL.equals(status)) { + return "红色预警"; + } + if (WarningAnalysis.STATUS_ALARM.equals(status)) { + return "橙色预警"; + } + if (WarningAnalysis.STATUS_WARNING.equals(status)) { + return "黄色预警"; } if (WarningAnalysis.STATUS_UNKNOWN.equals(status)) { return "待评估"; diff --git a/fastbee-report/src/main/java/com/fastbee/report/template/ReportTemplate.java b/fastbee-report/src/main/java/com/fastbee/report/template/ReportTemplate.java deleted file mode 100644 index 34ee3d8d..00000000 --- a/fastbee-report/src/main/java/com/fastbee/report/template/ReportTemplate.java +++ /dev/null @@ -1,45 +0,0 @@ -package com.fastbee.report.template; - -import com.baomidou.mybatisplus.annotation.TableName; -import com.fasterxml.jackson.annotation.JsonFormat; -import lombok.Data; - -import java.util.Date; - -/** - * 报告模板(report_template) - * - * @author FastBee - * @date 2026-08-16 - */ -@Data -@TableName("report_template") -public class ReportTemplate { - - private Long id; - - private String templateCode; - - private String templateName; - - private String monitorCategoryCode; - - private Integer version; - - /** classpath 相对路径或 MinIO 路径 */ - private String filePath; - - private String status; - - private String delFlag; - - private String createBy; - - @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") - private Date createTime; - - private String updateBy; - - @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") - private Date updateTime; -} diff --git a/fastbee-report/src/main/resources/mapper/report/ReportTemplateMapper.xml b/fastbee-report/src/main/resources/mapper/report/ReportTemplateMapper.xml deleted file mode 100644 index 147ce0cd..00000000 --- a/fastbee-report/src/main/resources/mapper/report/ReportTemplateMapper.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - diff --git a/fastbee-report/src/main/resources/templates/DEVICE_AXIAL_FORCE_V2.docx b/fastbee-report/src/main/resources/templates/DEVICE_AXIAL_FORCE_V2.docx index 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) {