reportEngine:多设备一起生成报告

This commit is contained in:
zhugongping 2026-09-22 21:30:47 +08:00
parent 95d465ec7d
commit 32613c73db
44 changed files with 1554 additions and 325 deletions

View File

@ -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) |

View File

@ -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;

View File

@ -21,7 +21,13 @@ import java.util.List;
@Component
public class QualityAnalyzer {
public QualityAnalysis analyze(List<MetricData> data, TimeWindow window, BigDecimal controlValue) {
/**
* @param controlValue 控制值(阈值)
* @param initialValue 初始值;越界判定按「相对初始值的累计变化量」而非原始读数,
* 否则水位高程、频率这类绝对量读数会被误判为越界
*/
public QualityAnalysis analyze(List<MetricData> 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++;
}
}

View File

@ -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;
/**
* 变化速率计算器(单位/天)
*
* <p>提供三种口径,报告与预警判定统一以<b>回归斜率</b>为主口径:</p>
* <ul>
* <li>{@link #regressionRatePerDay}:一元线性回归斜率(单位/天)。最小二乘拟合,
* 单点异常不会带偏整期速率,抗噪性最好 —— <b>预警判定与表格主速率取此值</b>;
* 有效点不足 3 个时返回 null(此时无统计意义,不得回退为 0,否则会把"算不出"当成"没变化")。</li>
* <li>{@link #averageRatePerDay}:本期平均速率 =(当前值 − 初始值)/ 天数。受首尾单点影响大,
* 作为参考口径并列展示。</li>
* <li>{@link #maxSegmentRatePerDay}:最大分段速率 = 相邻观测点中日变化速率的最大值。
* 用于识别短期突变,专业上用于判断是否存在瞬时加速。</li>
* </ul>
*
* @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<MetricData> data) {
List<double[]> 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<MetricData> data) {
List<MetricData> 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;
}
/**
* 解析主口径速率:优先回归斜率,算不出时回退本期平均速率
*
* <p>回归斜率为 null(点数不足)时不能返回 0——0 会被引擎判成"无速率"从而漏判。
* 此时用平均速率兜底,保证预警判定永远有一个可用口径。</p>
*/
public BigDecimal resolvePrimaryRate(List<MetricData> data, Statistics statistics, long durationSeconds) {
BigDecimal regression = regressionRatePerDay(data);
if (regression != null) {
return regression;
}
return ratePerDay(statistics, durationSeconds);
}
/**
* 分段速率序列(单位/天):相邻观测点的日变化速率,用于绘制变化速率曲线
*
* <p>首点无前值,不产出;相邻点时间间隔为 0 的点直接跳过,避免除零制造无穷速率。</p>
*/
public List<MetricData> segmentRateSeries(List<MetricData> data) {
List<MetricData> series = new ArrayList<>();
List<MetricData> 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<double[]> toPoints(List<MetricData> data) {
List<double[]> 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<MetricData> sorted(List<MetricData> data) {
List<MetricData> sorted = new ArrayList<>(data == null ? new ArrayList<>() : data);
sorted.sort(Comparator.comparing(MetricData::getTime, Comparator.nullsLast(Comparator.naturalOrder())));
return sorted;
}
}

View File

@ -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<MetricData> sorted) {
for (MetricData point : sorted) {
if (point.getNumericValue() != null && point.getTime() != null) {
return point.getTime();
}
}
return null;
}
/**
* 本次观测时间:窗口内末条「有数值」数据的上报时间(对应 current)
*/
private Date lastValidTime(List<MetricData> 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<BigDecimal> values) {
List<BigDecimal> sorted = new ArrayList<>(values);
sorted.sort(BigDecimal::compareTo);

View File

@ -13,6 +13,15 @@ import java.math.BigDecimal;
* 取较严重等级。某一维度未配置阈值时该维度不参与判定;两个维度都不可判定时结果为
* {@link WarningAnalysis#STATUS_UNKNOWN},不得回退为「正常」。</p>
*
* <p>三档阈值与报告预警色的对应关系(与数据平台预警配置一致):</p>
* <ul>
* <li>预警值 warningValue → 黄色 {@link WarningAnalysis#STATUS_WARNING}</li>
* <li>报警值 alarmValue → 橙色 {@link WarningAnalysis#STATUS_ALARM}</li>
* <li>控制值 controlValue → 红色 {@link WarningAnalysis#STATUS_CONTROL}</li>
* </ul>
*
* <p>变化速率主口径为回归斜率,由调用方计算后传入。</p>
*
* @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) {

View File

@ -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();
}
/**
* 按三档预警色给出分级处置建议
*
* <p>红(控制值)/橙(报警值)/黄(预警值)对应不同的处置强度;未配置阈值时明确提示,
* 不生成任何"安全"结论。</p>
*/
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<String, ThingsModel> 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<String, List<MetricData>> 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 {
}
/**
* 组装测点数据(过滤辅助指标 + 主指标排第一)
* 组装测点数据(主指标排第一,其余物模型作为影响量保留)
*
* <p>物理量的完整性优先:温度等辅助量不再被丢弃——振弦式等设备的读数受温度影响,
* 报告需要这些量做影响分析(详见各 Analyzer 的领域结论)。它们仅标记
* {@code primary=false},不主导报告结论。</p>
*/
protected List<MetricPointData> buildMetricPointsFiltered(AnalysisResult result, ReportContext context) {
protected List<MetricPointData> buildMetricPointsFiltered(AnalysisResult result, ReportContext context,
Map<String, List<MetricData>> dataByIdentity,
long durationSeconds) {
List<MetricPointData> all = new ArrayList<>();
String primary = result.getPrimaryIdentifier();
MetricPointData primaryPoint = null;
@ -394,13 +430,6 @@ public abstract class AbstractDeviceAnalyzer implements ReportAnalyzer {
for (Map.Entry<String, Statistics> 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<MetricData> 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 "待评估";

View File

@ -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("当前达到黄色预警(预警值),建议加密监测频率,制定应急预案。");
}
}

View File

@ -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("当前达到黄色预警(预警值),建议加强巡视检查,准备裂缝修补材料。");
}
}

View File

@ -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();

View File

@ -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("当前达到黄色预警(预警值),建议准备防汛物资,制定应急预案。");
}
}

View File

@ -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();

View File

@ -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("当前达到黄色预警(预警值),建议加密监测频率。");
}
}

View File

@ -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("当前达到黄色预警(预警值),建议加强巡视检查,准备应急排水预案。");
}
}

View File

@ -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<Long> deviceIds;
@ApiModelProperty("设备分组ID(按 iot_group 导出,报告表格展示断面列,与 deviceId/deviceIds 三选一)")
private Long groupId;
@ApiModelProperty("统计开始时间 yyyy-MM-dd HH:mm:ss(与 period 二选一)")
private String startTime;

View File

@ -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;
/**
* 监测指标图表构建器(主指标单图 + 其余指标按单位分组多曲线图)
* 监测指标图表构建器(主指标时程曲线 + 累计变化量曲线 + 变化速率曲线)
*
* <p>约定 charts 的 key:</p>
* <ul>
* <li>{@code timeSeriesChart}:主监测指标时程曲线(与模板兼容)</li>
* <li>{@code chart2} / {@code chart3}:其余指标按单位分组的时程曲线(最多 2 组)</li>
* <li>{@code timeSeriesChart}:主监测指标时程曲线(原始读数,与模板兼容)</li>
* <li>{@code chart2}:主监测指标<b>累计变化量</b>曲线(相对初始值)——判断变形/受力发展趋势的核心依据</li>
* <li>{@code chart3}:主监测指标<b>变化速率</b>曲线(单位/天)——判断是否出现加速变化的依据</li>
* </ul>
*
* <p>多测点报告时 chart2 / chart3 由 {@link com.fastbee.report.core.MultiDeviceAnalysisMerger}
* 覆盖为各测点的多曲线版本。</p>
*
* @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<String, LinkedHashMap<String, List<MetricData>>> groupedByUnit = new LinkedHashMap<>();
for (Map.Entry<String, Statistics> 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<String, LinkedHashMap<String, List<MetricData>>> 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<MetricData> 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<MetricData> sortedSeries(List<MetricData> data) {
List<MetricData> 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<MetricData> cumulativeSeries(List<MetricData> series, BigDecimal initial) {
List<MetricData> 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;
}
/**
* 补齐模板预留的图表位,避免模板标签无对应数据导致渲染异常
*/

View File

@ -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;
/**
* 多测点(多设备)分析结果归并器
*
* <p>一台设备(iot_device)= 一个测点。多设备报告把各测点的分析结果归并成一份报告:</p>
* <ul>
* <li>单值章节(统计指标 / 趋势 / 预警)取<b>最不利测点</b>为代表:先看预警等级,
* 同等级时取主指标变化量绝对值最大的测点 —— 报告不能拿最平稳的测点代表整体安全状态;</li>
* <li>明细表(metricPoints)保留全部测点 × 全部物模型,供按物理量逐测点对比;</li>
* <li>图表:各测点主指标的<b>累计变化量曲线</b>与<b>变化速率曲线</b>(多曲线同图),
* 这是判断各测点是否同步发展、是否存在异常单点的核心依据;</li>
* <li>结论:在代表测点结论之后追加多测点横向对比结论。</li>
* </ul>
*
* @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<DeviceAnalysis> analyses) {
return merge(analyses, null, analyses == null ? 0 : analyses.size());
}
/**
* 归并多个测点的分析结果
*
* @param skippedPoints 统计期内无有效数据、未纳入分析的测点标识
* @param totalPoints 本次请求纳入的测点总数(有效 + 跳过)
*/
public AnalysisResult merge(List<DeviceAnalysis> analyses, List<String> skippedPoints, int totalPoints) {
if (analyses == null || analyses.isEmpty()) {
throw new IllegalStateException("无有效测点分析结果");
}
List<DeviceAnalysis> 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);
}
/**
* 把"统计期内无数据、未纳入分析"的测点写入结论。
*
* <p>不静默丢弃:否则用户勾选 5 个测点、报告只有 1 个测点时无法判断是漏导出还是设备没数据。</p>
*/
private AnalysisResult appendSkipped(AnalysisResult result, List<String> 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<DeviceAnalysis> 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<MetricPointData> buildMetricPoints(List<DeviceAnalysis> analyses) {
List<MetricPointData> 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<Conclusion> buildConclusions(AnalysisResult base, List<DeviceAnalysis> analyses) {
List<Conclusion> 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<String, List<String>> 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<String, List<String>> 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<String, byte[]> buildMultiPointCharts(List<DeviceAnalysis> analyses, String primaryIdentifier) {
Map<String, byte[]> charts = new LinkedHashMap<>();
Map<String, List<MetricData>> cumulativeSeries = new LinkedHashMap<>();
Map<String, List<MetricData>> rateSeries = new LinkedHashMap<>();
String unit = null;
for (DeviceAnalysis item : analyses) {
List<MetricData> 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<MetricData> toCumulative(List<MetricData> series, BigDecimal initial) {
List<MetricData> 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<MetricData> seriesOf(DeviceAnalysis item, String primaryIdentifier) {
List<MetricData> series = new ArrayList<>();
List<MetricData> 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 "正常";
}
}

View File

@ -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 参与分析的测点数(&gt;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())

View File

@ -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);
}
/**
* 构建指定设备的报告上下文
*
* <p>多测点报告时每台设备单独构建一次:物模型、时序数据、阈值都以设备维度装配,
* 分析器按设备分析,再由 {@link MultiDeviceAnalysisMerger} 归并。</p>
*/
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<Long> 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) {

View File

@ -0,0 +1,26 @@
package com.fastbee.report.core;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 报告生成目标(一个目标 = 一个测点 = iot_device)
*
* <p>{@link #sectionName} 为断面:仅按设备分组(iot_group)导出时才有值,
* 用于报告表格的断面列;其它导出口径下为 null,表格不展示该列。</p>
*
* @author FastBee
* @date 2026-09-22
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ReportTarget {
/** 设备ID(测点) */
private Long deviceId;
/** 断面(设备分组名);非分组导出为 null */
private String sectionName;
}

View File

@ -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)」列表
*
* <p>三种导出口径:</p>
* <ol>
* <li><b>设备分组导出</b>(groupId):取分组下全部设备,断面 = 分组名,表格展示断面列;</li>
* <li><b>网关导出</b>(deviceId 指向网关设备):取该网关下与其子设备<b>同产品</b>的监测设备,
* 网关是通信中继不是测点,必须展开为子设备才有物理量可分析;</li>
* <li><b>直连设备多选</b>(deviceIds):直接使用传入的设备列表,无断面。</li>
* </ol>
*
* @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<ReportTarget> resolve(ReportRequest request) {
// 1. 按设备分组导出:断面 = 分组名
if (request.getGroupId() != null && request.getGroupId() > 0) {
Group group = new Group();
group.setGroupId(request.getGroupId());
List<Group> groups = groupMapper.selectGroupList(group);
String sectionName = (groups == null || groups.isEmpty()) ? null : groups.get(0).getGroupName();
List<ReportTarget> 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<ReportTarget> 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));
}
/**
* 网关设备:展开为其下子设备,且只保留与首个监测子设备<b>同产品</b>的设备
*
* <p>报告按物模型(物理量)分析,不同产品的物模型与量纲不同,混在一起无法对比,
* 因此只取同产品子设备;若该网关下存在多种产品,按第一个子设备的产品为准并提示其余未纳入。</p>
*/
private List<ReportTarget> resolveGatewaySubDevices(Long gatewayDeviceId) {
List<Long> subDeviceIds = subGatewayMapper.selectSubDeviceIdsByGwDeviceId(gatewayDeviceId);
if (subDeviceIds == null || subDeviceIds.isEmpty()) {
throw new ReportException("该网关下未绑定子设备,无法生成报告");
}
Long productId = null;
List<ReportTarget> 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<Long> distinct(List<?> rawIds) {
Set<Long> 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;
}
}
}

View File

@ -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<ReportTarget> 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<ReportTarget> 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<ReportTarget> 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<MultiDeviceAnalysisMerger.DeviceAnalysis> analyses = new ArrayList<>();
// 统计期内无数据的测点:不静默丢弃,要在结论中写明,避免报告看起来像"只导出了 1 个测点"
List<String> 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("请选择时间范围或报告周期");
}

View File

@ -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> {
/**
* 查询最新启用的模板
*/
ReportTemplate selectByCode(@Param("templateCode") String templateCode);
}

View File

@ -6,9 +6,13 @@ import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
import java.util.Date;
/**
* 测点数据(用于详细表格展示,支持多测点设备)
* 测点数据(报告明细表一行)
*
* <p>测点即 iot_device:多设备报告时一台设备一行,一行对应一个物模型(物理量)的分析结果,
* 表格列按导出设备的物模型动态生成。</p>
*
* @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;
/** 变化速率 */
/**
* 变化速率(主口径):一元线性回归斜率,单位/天
*
* <p>报告展示与预警判定统一取此值,避免同一份报告出现两套速率口径。</p>
*/
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;
}

View File

@ -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;

View File

@ -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;

View File

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

View File

@ -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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> charts = new HashMap<>();
@ -264,8 +284,183 @@ public class WordRenderer implements ReportRenderer {
}
/**
* 拼接数值与单位(单位为空时只返回数值,避免尾部空格)
* 构建动态列测点表(模板占位 {{metricTable}})
*
* <p>列由导出测点实际配置的<b>物模型(物理量)</b>动态生成,一行一个测点(iot_device):</p>
* <ul>
* <li>断面:仅按设备分组(iot_group)导出时存在,否则不生成该列;</li>
* <li>主监测指标:初始值 / 本次值 / 变化量 / 变化速率(完整四列,主导报告结论);</li>
* <li>其余物模型(如温度等影响量):本次值 / 变化量,保留物理量完整度;</li>
* <li>初始观测时间、监测结论:以主指标为准,结论取该测点所有物模型中的最严重等级。</li>
* </ul>
*/
private TableRenderData buildMetricTable(AnalysisResult analysis) {
List<MetricPointData> points = analysis.getMetricPoints() == null
? new ArrayList<>() : analysis.getMetricPoints();
if (points.isEmpty()) {
return Tables.ofA4Width()
.addRow(Rows.of("统计期内无有效测点数据").center().textFontSize(9).create())
.create();
}
// 1. 按测点(设备)分组,保持测点顺序
Map<String, List<MetricPointData>> 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<String> 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<String> 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<String, List<MetricPointData>> entry : byPoint.entrySet()) {
List<MetricPointData> rowPoints = entry.getValue();
Map<String, MetricPointData> byIdentifier = new LinkedHashMap<>();
for (MetricPointData point : rowPoints) {
byIdentifier.put(point.getIdentifier(), point);
}
MetricPointData first = rowPoints.get(0);
List<String> 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<MetricPointData> points, String identifier) {
for (MetricPointData point : points) {
if (identifier.equals(point.getIdentifier())) {
return point;
}
}
return null;
}
/** 初始观测时间:主指标优先,缺失时取该测点第一个有时间的物模型 */
private Date primaryTime(List<MetricPointData> 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<MetricPointData> 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 "待评估";

View File

@ -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;
}

View File

@ -1,18 +0,0 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.fastbee.report.mapper.ReportTemplateMapper">
<select id="selectByCode" resultType="com.fastbee.report.template.ReportTemplate">
select id, template_code, template_name, monitor_category_code, version,
file_path, status, del_flag, create_by, create_time, update_by, update_time
from report_template
where template_code = #{templateCode}
and status = '0'
and del_flag = '0'
order by version desc
limit 1
</select>
</mapper>

View File

@ -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;
/**
* 多测点归并测试:重点验证"统计期内无数据的测点"不会被静默丢弃
*
* <p>场景来自真实反馈:勾选 5 个测点导出,报告只有 1 个测点,用户无法判断是漏导出还是设备没数据。
* 现要求在结论中列明未纳入分析的测点及数量。</p>
*
* @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);
}
}

View File

@ -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 倒推清理)

View File

@ -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) {