reportEngine:多设备一起生成报告
This commit is contained in:
parent
95d465ec7d
commit
32613c73db
@ -170,7 +170,7 @@ fastbee-report/
|
|||||||
## 3.7 Word 渲染
|
## 3.7 Word 渲染
|
||||||
|
|
||||||
- 新增依赖:`poi-tl 1.10.4`(兼容 POI 4.1.2)、`jfreechart 1.5.4`;禁止再引入 docx4j。
|
- 新增依赖:`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}}` 等;模板不直接绑定数据库字段。
|
- 变量:`{{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_product` 加列 | `monitor_category_code VARCHAR(64)` 可空;存量按产品名回填(如 `UPDATE iot_product SET monitor_category_code='CRACK_METER' WHERE product_name LIKE '%裂缝%'`) |
|
||||||
| `iot_things_threshold` | 物模型阈值表(product/device 级:预警/报警/控制值 + 速率双控) |
|
| `iot_things_threshold` | 物模型阈值表(product/device 级:预警/报警/控制值 + 速率双控) |
|
||||||
| `report_template` | Word 模板表(template_code + version 唯一) |
|
|
||||||
| `report_record` | 报告记录表(含 `expire_time`) |
|
| `report_record` | 报告记录表(含 `expire_time`) |
|
||||||
| `oss_config` 插入 | minio 默认配置记录(见 4.3) |
|
| `oss_config` 插入 | minio 默认配置记录(见 4.3) |
|
||||||
|
|
||||||
|
|||||||
@ -16,29 +16,53 @@ import lombok.Getter;
|
|||||||
@AllArgsConstructor
|
@AllArgsConstructor
|
||||||
public enum MonitorCategory {
|
public enum MonitorCategory {
|
||||||
|
|
||||||
/** 裂缝计 */
|
/**
|
||||||
|
* 裂缝计
|
||||||
|
*/
|
||||||
CRACK_METER("CRACK_METER", "裂缝计"),
|
CRACK_METER("CRACK_METER", "裂缝计"),
|
||||||
/** 深部位移 */
|
/**
|
||||||
DEEP_DISPLACEMENT("DEEP_DISPLACEMENT", "深部位移"),
|
* 深部位移
|
||||||
/** 轴力/应力 */
|
*/
|
||||||
|
DEEP_DISPLACEMENT("DEEP_DISPLACEMENT", "深部位移/深部倾角"),
|
||||||
|
/**
|
||||||
|
* 轴力/应力
|
||||||
|
*/
|
||||||
AXIAL_FORCE("AXIAL_FORCE", "轴力/应力"),
|
AXIAL_FORCE("AXIAL_FORCE", "轴力/应力"),
|
||||||
/** 静力水准 */
|
/**
|
||||||
|
* 静力水准
|
||||||
|
*/
|
||||||
STATIC_LEVEL("STATIC_LEVEL", "静力水准"),
|
STATIC_LEVEL("STATIC_LEVEL", "静力水准"),
|
||||||
/** 振弦 */
|
/**
|
||||||
|
* 振弦
|
||||||
|
*/
|
||||||
VIBRATING_WIRE("VIBRATING_WIRE", "振弦"),
|
VIBRATING_WIRE("VIBRATING_WIRE", "振弦"),
|
||||||
/** 应变计 */
|
/**
|
||||||
|
* 应变计
|
||||||
|
*/
|
||||||
STRAIN_GAUGE("STRAIN_GAUGE", "应变计"),
|
STRAIN_GAUGE("STRAIN_GAUGE", "应变计"),
|
||||||
/** 水位计 */
|
/**
|
||||||
|
* 水位计
|
||||||
|
*/
|
||||||
WATER_LEVEL("WATER_LEVEL", "水位计"),
|
WATER_LEVEL("WATER_LEVEL", "水位计"),
|
||||||
/** 测距仪 */
|
/**
|
||||||
|
* 测距仪
|
||||||
|
*/
|
||||||
RANGING_METER("RANGING_METER", "测距仪"),
|
RANGING_METER("RANGING_METER", "测距仪"),
|
||||||
/** 倾角计 */
|
/**
|
||||||
|
* 倾角计
|
||||||
|
*/
|
||||||
INCLINOMETER("INCLINOMETER", "倾角计"),
|
INCLINOMETER("INCLINOMETER", "倾角计"),
|
||||||
/** 温度计 */
|
/**
|
||||||
|
* 温度计
|
||||||
|
*/
|
||||||
THERMOMETER("THERMOMETER", "温度计"),
|
THERMOMETER("THERMOMETER", "温度计"),
|
||||||
/** 雨量计 */
|
/**
|
||||||
|
* 雨量计
|
||||||
|
*/
|
||||||
RAIN_GAUGE("RAIN_GAUGE", "雨量计"),
|
RAIN_GAUGE("RAIN_GAUGE", "雨量计"),
|
||||||
/** 未配置监测分类 */
|
/**
|
||||||
|
* 未配置监测分类
|
||||||
|
*/
|
||||||
UNKNOWN("UNKNOWN", "未配置监测分类");
|
UNKNOWN("UNKNOWN", "未配置监测分类");
|
||||||
|
|
||||||
private final String code;
|
private final String code;
|
||||||
|
|||||||
@ -21,7 +21,13 @@ import java.util.List;
|
|||||||
@Component
|
@Component
|
||||||
public class QualityAnalyzer {
|
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();
|
QualityAnalysis result = new QualityAnalysis();
|
||||||
if (data == null || data.isEmpty()) {
|
if (data == null || data.isEmpty()) {
|
||||||
result.setTotal(0);
|
result.setTotal(0);
|
||||||
@ -50,12 +56,15 @@ public class QualityAnalyzer {
|
|||||||
}
|
}
|
||||||
result.setDuplicate(duplicate);
|
result.setDuplicate(duplicate);
|
||||||
|
|
||||||
// 越界检测(超出控制值)
|
// 越界检测:累计变化量(相对初始值)超过控制值的点数
|
||||||
int outOfRange = 0;
|
int outOfRange = 0;
|
||||||
if (controlValue != null) {
|
if (controlValue != null && initialValue != null) {
|
||||||
for (MetricData point : sorted) {
|
for (MetricData point : sorted) {
|
||||||
if (point.getNumericValue() != null
|
if (point.getNumericValue() == null) {
|
||||||
&& point.getNumericValue().abs().compareTo(controlValue) > 0) {
|
continue;
|
||||||
|
}
|
||||||
|
BigDecimal cumulative = point.getNumericValue().subtract(initialValue);
|
||||||
|
if (cumulative.abs().compareTo(controlValue) > 0) {
|
||||||
outOfRange++;
|
outOfRange++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,22 +1,39 @@
|
|||||||
package com.fastbee.report.analysis;
|
package com.fastbee.report.analysis;
|
||||||
|
|
||||||
|
import com.fastbee.report.data.MetricData;
|
||||||
import com.fastbee.report.model.Statistics;
|
import com.fastbee.report.model.Statistics;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.math.RoundingMode;
|
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
|
* @author FastBee
|
||||||
* @date 2026-08-16
|
* @date 2026-08-16
|
||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
public class RateCalculator {
|
public class RateCalculator {
|
||||||
|
|
||||||
|
private static final int SCALE = 6;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 平均变化速率 = (当前值 - 初始值) / 天数
|
* 本期平均速率 = (当前值 - 初始值) / 天数
|
||||||
*/
|
*/
|
||||||
public BigDecimal ratePerDay(Statistics statistics, long durationSeconds) {
|
public BigDecimal ratePerDay(Statistics statistics, long durationSeconds) {
|
||||||
if (statistics == null || statistics.getCurrent() == null || statistics.getInitial() == null) {
|
if (statistics == null || statistics.getCurrent() == null || statistics.getInitial() == null) {
|
||||||
@ -27,6 +44,140 @@ public class RateCalculator {
|
|||||||
return BigDecimal.ZERO;
|
return BigDecimal.ZERO;
|
||||||
}
|
}
|
||||||
return statistics.getCurrent().subtract(statistics.getInitial())
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import java.math.BigDecimal;
|
|||||||
import java.math.RoundingMode;
|
import java.math.RoundingMode;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
|
import java.util.Date;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -60,6 +61,8 @@ public class StatisticsCalculator {
|
|||||||
|
|
||||||
statistics.setCurrent(current);
|
statistics.setCurrent(current);
|
||||||
statistics.setInitial(initial);
|
statistics.setInitial(initial);
|
||||||
|
statistics.setInitialTime(firstValidTime(sorted));
|
||||||
|
statistics.setCurrentTime(lastValidTime(sorted));
|
||||||
statistics.setMax(max);
|
statistics.setMax(max);
|
||||||
statistics.setMin(min);
|
statistics.setMin(min);
|
||||||
statistics.setAvg(avg);
|
statistics.setAvg(avg);
|
||||||
@ -69,6 +72,31 @@ public class StatisticsCalculator {
|
|||||||
return statistics;
|
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) {
|
private BigDecimal median(List<BigDecimal> values) {
|
||||||
List<BigDecimal> sorted = new ArrayList<>(values);
|
List<BigDecimal> sorted = new ArrayList<>(values);
|
||||||
sorted.sort(BigDecimal::compareTo);
|
sorted.sort(BigDecimal::compareTo);
|
||||||
|
|||||||
@ -13,6 +13,15 @@ import java.math.BigDecimal;
|
|||||||
* 取较严重等级。某一维度未配置阈值时该维度不参与判定;两个维度都不可判定时结果为
|
* 取较严重等级。某一维度未配置阈值时该维度不参与判定;两个维度都不可判定时结果为
|
||||||
* {@link WarningAnalysis#STATUS_UNKNOWN},不得回退为「正常」。</p>
|
* {@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
|
* @author FastBee
|
||||||
* @date 2026-08-16
|
* @date 2026-08-16
|
||||||
*/
|
*/
|
||||||
@ -50,39 +59,39 @@ public class WarningAnalyzer {
|
|||||||
if (!noCumulative && currentValue != null) {
|
if (!noCumulative && currentValue != null) {
|
||||||
BigDecimal abs = currentValue.abs();
|
BigDecimal abs = currentValue.abs();
|
||||||
if (threshold.getControlValue() != null && abs.compareTo(threshold.getControlValue()) >= 0) {
|
if (threshold.getControlValue() != null && abs.compareTo(threshold.getControlValue()) >= 0) {
|
||||||
cumulativeStatus = WarningAnalysis.STATUS_ALARM;
|
cumulativeStatus = WarningAnalysis.STATUS_CONTROL;
|
||||||
cumulativeDesc = String.format("当前值 %s 达到/超过控制值 %s,处于报警状态",
|
cumulativeDesc = String.format("累计变化量 %s 达到/超过控制值 %s,达到红色预警",
|
||||||
currentValue.toPlainString(), threshold.getControlValue().toPlainString());
|
currentValue.toPlainString(), threshold.getControlValue().toPlainString());
|
||||||
} else if (threshold.getAlarmValue() != null && abs.compareTo(threshold.getAlarmValue()) >= 0) {
|
} else if (threshold.getAlarmValue() != null && abs.compareTo(threshold.getAlarmValue()) >= 0) {
|
||||||
cumulativeStatus = WarningAnalysis.STATUS_ALARM;
|
cumulativeStatus = WarningAnalysis.STATUS_ALARM;
|
||||||
cumulativeDesc = String.format("当前值 %s 达到/超过报警值 %s,处于报警状态",
|
cumulativeDesc = String.format("累计变化量 %s 达到/超过报警值 %s,达到橙色预警",
|
||||||
currentValue.toPlainString(), threshold.getAlarmValue().toPlainString());
|
currentValue.toPlainString(), threshold.getAlarmValue().toPlainString());
|
||||||
} else if (threshold.getWarningValue() != null && abs.compareTo(threshold.getWarningValue()) >= 0) {
|
} else if (threshold.getWarningValue() != null && abs.compareTo(threshold.getWarningValue()) >= 0) {
|
||||||
cumulativeStatus = WarningAnalysis.STATUS_WARNING;
|
cumulativeStatus = WarningAnalysis.STATUS_WARNING;
|
||||||
cumulativeDesc = String.format("当前值 %s 达到/超过预警值 %s,处于预警状态",
|
cumulativeDesc = String.format("累计变化量 %s 达到/超过预警值 %s,达到黄色预警",
|
||||||
currentValue.toPlainString(), threshold.getWarningValue().toPlainString());
|
currentValue.toPlainString(), threshold.getWarningValue().toPlainString());
|
||||||
} else {
|
} else {
|
||||||
cumulativeStatus = WarningAnalysis.STATUS_NORMAL;
|
cumulativeStatus = WarningAnalysis.STATUS_NORMAL;
|
||||||
cumulativeDesc = "累计值处于正常范围";
|
cumulativeDesc = "累计变化量处于正常范围";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 维度二:变化速率(单位/天)
|
// 维度二:变化速率(单位/天,主口径为回归斜率)
|
||||||
String rateStatus = null;
|
String rateStatus = null;
|
||||||
String rateDesc = null;
|
String rateDesc = null;
|
||||||
if (!noRate && changeRate != null) {
|
if (!noRate && changeRate != null) {
|
||||||
BigDecimal absRate = changeRate.abs();
|
BigDecimal absRate = changeRate.abs();
|
||||||
if (threshold.getControlRate() != null && absRate.compareTo(threshold.getControlRate()) >= 0) {
|
if (threshold.getControlRate() != null && absRate.compareTo(threshold.getControlRate()) >= 0) {
|
||||||
rateStatus = WarningAnalysis.STATUS_ALARM;
|
rateStatus = WarningAnalysis.STATUS_CONTROL;
|
||||||
rateDesc = String.format("变化速率 %s 达到/超过速率控制值 %s,处于报警状态",
|
rateDesc = String.format("变化速率 %s 达到/超过速率控制值 %s,达到红色预警",
|
||||||
absRate.toPlainString(), threshold.getControlRate().toPlainString());
|
absRate.toPlainString(), threshold.getControlRate().toPlainString());
|
||||||
} else if (threshold.getAlarmRate() != null && absRate.compareTo(threshold.getAlarmRate()) >= 0) {
|
} else if (threshold.getAlarmRate() != null && absRate.compareTo(threshold.getAlarmRate()) >= 0) {
|
||||||
rateStatus = WarningAnalysis.STATUS_ALARM;
|
rateStatus = WarningAnalysis.STATUS_ALARM;
|
||||||
rateDesc = String.format("变化速率 %s 达到/超过速率报警值 %s,处于报警状态",
|
rateDesc = String.format("变化速率 %s 达到/超过速率报警值 %s,达到橙色预警",
|
||||||
absRate.toPlainString(), threshold.getAlarmRate().toPlainString());
|
absRate.toPlainString(), threshold.getAlarmRate().toPlainString());
|
||||||
} else if (threshold.getWarningRate() != null && absRate.compareTo(threshold.getWarningRate()) >= 0) {
|
} else if (threshold.getWarningRate() != null && absRate.compareTo(threshold.getWarningRate()) >= 0) {
|
||||||
rateStatus = WarningAnalysis.STATUS_WARNING;
|
rateStatus = WarningAnalysis.STATUS_WARNING;
|
||||||
rateDesc = String.format("变化速率 %s 达到/超过速率预警值 %s,处于预警状态",
|
rateDesc = String.format("变化速率 %s 达到/超过速率预警值 %s,达到黄色预警",
|
||||||
absRate.toPlainString(), threshold.getWarningRate().toPlainString());
|
absRate.toPlainString(), threshold.getWarningRate().toPlainString());
|
||||||
} else {
|
} else {
|
||||||
rateStatus = WarningAnalysis.STATUS_NORMAL;
|
rateStatus = WarningAnalysis.STATUS_NORMAL;
|
||||||
@ -118,7 +127,7 @@ public class WarningAnalyzer {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 取更严重等级:ALARM > WARNING > NORMAL;null 表示该维度未参与判定 */
|
/** 取更严重等级:红色 > 橙色 > 黄色 > 正常;null 表示该维度未参与判定 */
|
||||||
private String worse(String a, String b) {
|
private String worse(String a, String b) {
|
||||||
if (a == null) {
|
if (a == null) {
|
||||||
return b;
|
return b;
|
||||||
@ -126,13 +135,7 @@ public class WarningAnalyzer {
|
|||||||
if (b == null) {
|
if (b == null) {
|
||||||
return a;
|
return a;
|
||||||
}
|
}
|
||||||
if (WarningAnalysis.STATUS_ALARM.equals(a) || WarningAnalysis.STATUS_ALARM.equals(b)) {
|
return WarningAnalysis.severity(a) >= WarningAnalysis.severity(b) ? a : b;
|
||||||
return WarningAnalysis.STATUS_ALARM;
|
|
||||||
}
|
|
||||||
if (WarningAnalysis.STATUS_WARNING.equals(a) || WarningAnalysis.STATUS_WARNING.equals(b)) {
|
|
||||||
return WarningAnalysis.STATUS_WARNING;
|
|
||||||
}
|
|
||||||
return WarningAnalysis.STATUS_NORMAL;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean isAllNull(BigDecimal... values) {
|
private boolean isAllNull(BigDecimal... values) {
|
||||||
|
|||||||
@ -57,8 +57,10 @@ public abstract class AbstractDeviceAnalyzer implements ReportAnalyzer {
|
|||||||
|
|
||||||
/** 安全等级:安全 */
|
/** 安全等级:安全 */
|
||||||
public static final String SAFETY_SAFE = "SAFE";
|
public static final String SAFETY_SAFE = "SAFE";
|
||||||
/** 安全等级:预警 */
|
/** 安全等级:预警(黄色) */
|
||||||
public static final String SAFETY_WARNING = "WARNING";
|
public static final String SAFETY_WARNING = "WARNING";
|
||||||
|
/** 安全等级:临界(橙色) */
|
||||||
|
public static final String SAFETY_CRITICAL = "CRITICAL";
|
||||||
/** 安全等级:危险 */
|
/** 安全等级:危险 */
|
||||||
public static final String SAFETY_DANGER = "DANGER";
|
public static final String SAFETY_DANGER = "DANGER";
|
||||||
/** 安全等级:未配置阈值,无法评估(区别于 SAFE,避免"未评估"被当成"安全") */
|
/** 安全等级:未配置阈值,无法评估(区别于 SAFE,避免"未评估"被当成"安全") */
|
||||||
@ -184,18 +186,37 @@ public abstract class AbstractDeviceAnalyzer implements ReportAnalyzer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 基于预警状态给出建议
|
// 基于预警状态给出建议(三档预警色分级处置)
|
||||||
if (warning != null) {
|
advice.append(alertAdvice(warning));
|
||||||
if (WarningAnalysis.STATUS_ALARM.equals(warning.getStatus())) {
|
|
||||||
advice.append("当前处于报警状态,建议立即启动应急预案,组织专业人员进行安全评估。");
|
|
||||||
} else if (WarningAnalysis.STATUS_WARNING.equals(warning.getStatus())) {
|
|
||||||
advice.append("当前处于预警状态,建议加强巡视检查,制定应急预案。");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return advice.toString();
|
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. 获取所有监测物模型
|
// 2. 获取所有监测物模型
|
||||||
Map<String, ThingsModel> allModels = getAllModels(context);
|
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()) {
|
if (allModels.isEmpty()) {
|
||||||
result.getConclusions().add(new Conclusion(1, "设备未配置监测物模型,请联系管理员配置。"));
|
result.getConclusions().add(new Conclusion(1, "设备未配置监测物模型,请联系管理员配置。"));
|
||||||
return result;
|
return result;
|
||||||
@ -254,14 +284,13 @@ public abstract class AbstractDeviceAnalyzer implements ReportAnalyzer {
|
|||||||
backfillModelInfo(statistics, model, context.getCategory());
|
backfillModelInfo(statistics, model, context.getCategory());
|
||||||
|
|
||||||
TrendAnalysis trend = trendAnalyzer.analyze(data);
|
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)
|
BigDecimal changeRate = rateCalculator.resolvePrimaryRate(data, statistics, statisticsDurationSeconds);
|
||||||
? Duration.between(window.getStartTime(), window.getEndTime()).getSeconds()
|
|
||||||
: 0L;
|
|
||||||
BigDecimal changeRate = rateCalculator.ratePerDay(statistics, durationSeconds);
|
|
||||||
WarningAnalysis warning = warningAnalyzer.analyze(
|
WarningAnalysis warning = warningAnalyzer.analyze(
|
||||||
identity, statistics.getModelName(), statistics.getCurrent(), changeRate, threshold);
|
identity, statistics.getModelName(), statistics.getCurrent(), changeRate, threshold);
|
||||||
|
|
||||||
@ -269,6 +298,7 @@ public abstract class AbstractDeviceAnalyzer implements ReportAnalyzer {
|
|||||||
result.getTrends().put(identity, trend);
|
result.getTrends().put(identity, trend);
|
||||||
result.getQualities().put(identity, quality);
|
result.getQualities().put(identity, quality);
|
||||||
result.getWarnings().put(identity, warning);
|
result.getWarnings().put(identity, warning);
|
||||||
|
dataByIdentity.put(identity, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 主指标(关键词匹配 + 辅助量过滤)
|
// 4. 主指标(关键词匹配 + 辅助量过滤)
|
||||||
@ -276,8 +306,8 @@ public abstract class AbstractDeviceAnalyzer implements ReportAnalyzer {
|
|||||||
context, byIdentity, allowAuxiliaryAsPrimary(), primaryMetricKeywords());
|
context, byIdentity, allowAuxiliaryAsPrimary(), primaryMetricKeywords());
|
||||||
result.setPrimaryIdentifier(primaryIdentity);
|
result.setPrimaryIdentifier(primaryIdentity);
|
||||||
|
|
||||||
// 5. 组装测点数据(过滤辅助量、排序)
|
// 5. 组装测点数据(主指标排第一,辅助量作为影响量保留)
|
||||||
result.setMetricPoints(buildMetricPointsFiltered(result, context));
|
result.setMetricPoints(buildMetricPointsFiltered(result, context, dataByIdentity, statisticsDurationSeconds));
|
||||||
|
|
||||||
// 6. 生成结论
|
// 6. 生成结论
|
||||||
buildConclusions(result, context, primaryIdentity);
|
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<>();
|
List<MetricPointData> all = new ArrayList<>();
|
||||||
String primary = result.getPrimaryIdentifier();
|
String primary = result.getPrimaryIdentifier();
|
||||||
MetricPointData primaryPoint = null;
|
MetricPointData primaryPoint = null;
|
||||||
@ -394,13 +430,6 @@ public abstract class AbstractDeviceAnalyzer implements ReportAnalyzer {
|
|||||||
for (Map.Entry<String, Statistics> entry : result.getStatistics().entrySet()) {
|
for (Map.Entry<String, Statistics> entry : result.getStatistics().entrySet()) {
|
||||||
String identity = entry.getKey();
|
String identity = entry.getKey();
|
||||||
Statistics stats = entry.getValue();
|
Statistics stats = entry.getValue();
|
||||||
|
|
||||||
// 过滤辅助指标(角度/温度/电压/电量/信号/湿度)
|
|
||||||
if (!allowAuxiliaryAsPrimary() && isAuxiliaryMetric(stats.getModelName(), identity)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 过滤无数据/全 0 行
|
|
||||||
if (stats.getCurrent() == null || stats.getCount() == 0) {
|
if (stats.getCurrent() == null || stats.getCount() == 0) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -408,7 +437,10 @@ public abstract class AbstractDeviceAnalyzer implements ReportAnalyzer {
|
|||||||
continue;
|
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)) {
|
if (identity.equals(primary)) {
|
||||||
primaryPoint = point;
|
primaryPoint = point;
|
||||||
} else {
|
} else {
|
||||||
@ -444,25 +476,34 @@ public abstract class AbstractDeviceAnalyzer implements ReportAnalyzer {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 构建单个测点数据(使用 ValueFormatter 格式化)
|
* 构建单个测点数据(使用 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 resolvedUnit = valueFormatter.resolveUnit(stats.getUnit(), context.getCategory());
|
||||||
String rateUnit = valueFormatter.rateUnit(resolvedUnit);
|
String rateUnit = valueFormatter.rateUnit(resolvedUnit);
|
||||||
|
|
||||||
TimeWindow window = context.getTimeWindow();
|
// 主口径:回归斜率;参考口径:本期平均速率、最大分段速率
|
||||||
BigDecimal rate = rateCalculator.ratePerDay(stats,
|
BigDecimal rate = rateCalculator.resolvePrimaryRate(data, stats, durationSeconds);
|
||||||
Duration.between(window.getStartTime(), window.getEndTime()).getSeconds());
|
BigDecimal averageRate = rateCalculator.ratePerDay(stats, durationSeconds);
|
||||||
|
BigDecimal maxSegmentRate = rateCalculator.maxSegmentRatePerDay(data);
|
||||||
|
|
||||||
return MetricPointData.builder()
|
return MetricPointData.builder()
|
||||||
.identifier(stats.getIdentifier())
|
.identifier(stats.getIdentifier())
|
||||||
.modelName(stats.getModelName())
|
.modelName(stats.getModelName())
|
||||||
.initialValue(stats.getInitial())
|
.initialValue(stats.getInitial())
|
||||||
.initialUnit(resolvedUnit)
|
.initialUnit(resolvedUnit)
|
||||||
|
.initialTime(stats.getInitialTime())
|
||||||
.currentValue(stats.getCurrent())
|
.currentValue(stats.getCurrent())
|
||||||
.currentUnit(resolvedUnit)
|
.currentUnit(resolvedUnit)
|
||||||
|
.currentTime(stats.getCurrentTime())
|
||||||
.changeValue(stats.getDelta())
|
.changeValue(stats.getDelta())
|
||||||
.changeUnit(resolvedUnit)
|
.changeUnit(resolvedUnit)
|
||||||
.changeRate(rate)
|
.changeRate(rate)
|
||||||
|
.averageRate(averageRate)
|
||||||
|
.maxSegmentRate(maxSegmentRate)
|
||||||
.rateUnit(rateUnit)
|
.rateUnit(rateUnit)
|
||||||
.maxValue(stats.getMax())
|
.maxValue(stats.getMax())
|
||||||
.minValue(stats.getMin())
|
.minValue(stats.getMin())
|
||||||
@ -590,9 +631,12 @@ public abstract class AbstractDeviceAnalyzer implements ReportAnalyzer {
|
|||||||
* 将预警状态转换为安全等级
|
* 将预警状态转换为安全等级
|
||||||
*/
|
*/
|
||||||
protected String convertWarningToSafetyLevel(String warningStatus) {
|
protected String convertWarningToSafetyLevel(String warningStatus) {
|
||||||
if (WarningAnalysis.STATUS_ALARM.equals(warningStatus)) {
|
if (WarningAnalysis.STATUS_CONTROL.equals(warningStatus)) {
|
||||||
return SAFETY_DANGER;
|
return SAFETY_DANGER;
|
||||||
}
|
}
|
||||||
|
if (WarningAnalysis.STATUS_ALARM.equals(warningStatus)) {
|
||||||
|
return SAFETY_CRITICAL;
|
||||||
|
}
|
||||||
if (WarningAnalysis.STATUS_WARNING.equals(warningStatus)) {
|
if (WarningAnalysis.STATUS_WARNING.equals(warningStatus)) {
|
||||||
return SAFETY_WARNING;
|
return SAFETY_WARNING;
|
||||||
}
|
}
|
||||||
@ -604,14 +648,17 @@ public abstract class AbstractDeviceAnalyzer implements ReportAnalyzer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 预警状态转中文
|
* 预警状态转中文(三档预警色:黄/橙/红)
|
||||||
*/
|
*/
|
||||||
protected String statusText(String status) {
|
protected String statusText(String status) {
|
||||||
|
if (WarningAnalysis.STATUS_CONTROL.equals(status)) {
|
||||||
|
return "红色预警";
|
||||||
|
}
|
||||||
if (WarningAnalysis.STATUS_ALARM.equals(status)) {
|
if (WarningAnalysis.STATUS_ALARM.equals(status)) {
|
||||||
return "报警";
|
return "橙色预警";
|
||||||
}
|
}
|
||||||
if (WarningAnalysis.STATUS_WARNING.equals(status)) {
|
if (WarningAnalysis.STATUS_WARNING.equals(status)) {
|
||||||
return "预警";
|
return "黄色预警";
|
||||||
}
|
}
|
||||||
if (WarningAnalysis.STATUS_UNKNOWN.equals(status)) {
|
if (WarningAnalysis.STATUS_UNKNOWN.equals(status)) {
|
||||||
return "待评估";
|
return "待评估";
|
||||||
|
|||||||
@ -96,10 +96,12 @@ public class AxialForceAnalyzer extends AbstractDeviceAnalyzer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (warning != null) {
|
if (warning != null) {
|
||||||
if (WarningAnalysis.STATUS_ALARM.equals(warning.getStatus())) {
|
if (WarningAnalysis.STATUS_CONTROL.equals(warning.getStatus())) {
|
||||||
advice.append("当前处于报警状态,建议立即采取加固措施,组织专业人员进行结构安全评估。");
|
advice.append("当前达到红色预警(控制值),建议立即停止该部位作业并采取加固措施,组织专业人员进行结构安全评估。");
|
||||||
|
} else if (WarningAnalysis.STATUS_ALARM.equals(warning.getStatus())) {
|
||||||
|
advice.append("当前达到橙色预警(报警值),建议立即采取加固措施,组织专业人员进行结构安全评估。");
|
||||||
} else if (WarningAnalysis.STATUS_WARNING.equals(warning.getStatus())) {
|
} else if (WarningAnalysis.STATUS_WARNING.equals(warning.getStatus())) {
|
||||||
advice.append("当前处于预警状态,建议加密监测频率,制定应急预案。");
|
advice.append("当前达到黄色预警(预警值),建议加密监测频率,制定应急预案。");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -98,10 +98,12 @@ public class CrackMeterAnalyzer extends AbstractDeviceAnalyzer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (warning != null) {
|
if (warning != null) {
|
||||||
if (WarningAnalysis.STATUS_ALARM.equals(warning.getStatus())) {
|
if (WarningAnalysis.STATUS_CONTROL.equals(warning.getStatus())) {
|
||||||
advice.append("当前处于报警状态,建议立即对裂缝进行封闭处理,组织专业人员进行结构安全评估。");
|
advice.append("当前达到红色预警(控制值),建议立即对裂缝进行封闭处理并停止该部位作业,组织专业人员进行结构安全评估。");
|
||||||
|
} else if (WarningAnalysis.STATUS_ALARM.equals(warning.getStatus())) {
|
||||||
|
advice.append("当前达到橙色预警(报警值),建议立即对裂缝进行封闭处理,组织专业人员进行结构安全评估。");
|
||||||
} else if (WarningAnalysis.STATUS_WARNING.equals(warning.getStatus())) {
|
} else if (WarningAnalysis.STATUS_WARNING.equals(warning.getStatus())) {
|
||||||
advice.append("当前处于预警状态,建议加强巡视检查,准备裂缝修补材料。");
|
advice.append("当前达到黄色预警(预警值),建议加强巡视检查,准备裂缝修补材料。");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -123,10 +123,12 @@ public class DeepDisplacementAnalyzer extends AbstractDeviceAnalyzer {
|
|||||||
// 阈值状态说明
|
// 阈值状态说明
|
||||||
if (warning != null && WarningAnalysis.STATUS_UNKNOWN.equals(warning.getStatus())) {
|
if (warning != null && WarningAnalysis.STATUS_UNKNOWN.equals(warning.getStatus())) {
|
||||||
advice.append("【提示】尚未配置控制值,建议根据设计文件在系统中设置位移控制值,以便自动评估安全等级。");
|
advice.append("【提示】尚未配置控制值,建议根据设计文件在系统中设置位移控制值,以便自动评估安全等级。");
|
||||||
|
} else if (warning != null && WarningAnalysis.STATUS_CONTROL.equals(warning.getStatus())) {
|
||||||
|
advice.append("当前达到红色预警(控制值),建议立即停止该部位作业、启动应急预案,组织专业人员进行地质灾害评估并采取加固措施。");
|
||||||
} else if (warning != null && WarningAnalysis.STATUS_ALARM.equals(warning.getStatus())) {
|
} else if (warning != null && WarningAnalysis.STATUS_ALARM.equals(warning.getStatus())) {
|
||||||
advice.append("当前处于报警状态,建议立即启动应急预案,组织专业人员进行地质灾害评估,必要时采取加固措施。");
|
advice.append("当前达到橙色预警(报警值),建议立即启动应急预案,组织专业人员进行地质灾害评估,必要时采取加固措施。");
|
||||||
} else if (warning != null && WarningAnalysis.STATUS_WARNING.equals(warning.getStatus())) {
|
} else if (warning != null && WarningAnalysis.STATUS_WARNING.equals(warning.getStatus())) {
|
||||||
advice.append("当前处于预警状态,建议加强巡视检查,准备应急物资,制定应急预案。");
|
advice.append("当前达到黄色预警(预警值),建议加强巡视检查,准备应急物资,制定应急预案。");
|
||||||
}
|
}
|
||||||
|
|
||||||
return advice.toString();
|
return advice.toString();
|
||||||
|
|||||||
@ -92,10 +92,12 @@ public class RainGaugeAnalyzer extends AbstractDeviceAnalyzer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (warning != null) {
|
if (warning != null) {
|
||||||
if (WarningAnalysis.STATUS_ALARM.equals(warning.getStatus())) {
|
if (WarningAnalysis.STATUS_CONTROL.equals(warning.getStatus())) {
|
||||||
advice.append("当前处于报警状态,建议立即启动防汛应急预案,加强排水措施。");
|
advice.append("当前达到红色预警(控制值),建议立即启动防汛应急预案并暂停现场作业,加强排水措施。");
|
||||||
|
} else if (WarningAnalysis.STATUS_ALARM.equals(warning.getStatus())) {
|
||||||
|
advice.append("当前达到橙色预警(报警值),建议立即启动防汛应急预案,加强排水措施。");
|
||||||
} else if (WarningAnalysis.STATUS_WARNING.equals(warning.getStatus())) {
|
} else if (WarningAnalysis.STATUS_WARNING.equals(warning.getStatus())) {
|
||||||
advice.append("当前处于预警状态,建议准备防汛物资,制定应急预案。");
|
advice.append("当前达到黄色预警(预警值),建议准备防汛物资,制定应急预案。");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -152,10 +152,12 @@ public class StaticLevelAnalyzer extends AbstractDeviceAnalyzer {
|
|||||||
// 阈值状态说明
|
// 阈值状态说明
|
||||||
if (warning != null && WarningAnalysis.STATUS_UNKNOWN.equals(warning.getStatus())) {
|
if (warning != null && WarningAnalysis.STATUS_UNKNOWN.equals(warning.getStatus())) {
|
||||||
advice.append("【提示】尚未配置控制值,建议根据设计文件在系统中设置沉降控制值,以便自动评估安全等级。");
|
advice.append("【提示】尚未配置控制值,建议根据设计文件在系统中设置沉降控制值,以便自动评估安全等级。");
|
||||||
|
} else if (warning != null && WarningAnalysis.STATUS_CONTROL.equals(warning.getStatus())) {
|
||||||
|
advice.append("当前达到红色预警(控制值),建议立即停止该部位作业并采取地基加固措施,组织专业人员进行安全评估。");
|
||||||
} else if (warning != null && WarningAnalysis.STATUS_ALARM.equals(warning.getStatus())) {
|
} else if (warning != null && WarningAnalysis.STATUS_ALARM.equals(warning.getStatus())) {
|
||||||
advice.append("当前处于报警状态,建议立即采取地基加固措施,组织专业人员进行安全评估。");
|
advice.append("当前达到橙色预警(报警值),建议立即采取地基加固措施,组织专业人员进行安全评估。");
|
||||||
} else if (warning != null && WarningAnalysis.STATUS_WARNING.equals(warning.getStatus())) {
|
} else if (warning != null && WarningAnalysis.STATUS_WARNING.equals(warning.getStatus())) {
|
||||||
advice.append("当前处于预警状态,建议加密监测频率,制定应急预案。");
|
advice.append("当前达到黄色预警(预警值),建议加密监测频率,制定应急预案。");
|
||||||
}
|
}
|
||||||
|
|
||||||
return advice.toString();
|
return advice.toString();
|
||||||
|
|||||||
@ -53,10 +53,12 @@ public class VibratingWireAnalyzer extends AbstractDeviceAnalyzer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (warning != null) {
|
if (warning != null) {
|
||||||
if (WarningAnalysis.STATUS_ALARM.equals(warning.getStatus())) {
|
if (WarningAnalysis.STATUS_CONTROL.equals(warning.getStatus())) {
|
||||||
advice.append("当前处于报警状态,建议立即检查传感器工作状态。");
|
advice.append("当前达到红色预警(控制值),建议立即停止该部位作业,核查传感器与结构受力状态。");
|
||||||
|
} else if (WarningAnalysis.STATUS_ALARM.equals(warning.getStatus())) {
|
||||||
|
advice.append("当前达到橙色预警(报警值),建议加密监测频率并立即检查传感器工作状态。");
|
||||||
} else if (WarningAnalysis.STATUS_WARNING.equals(warning.getStatus())) {
|
} else if (WarningAnalysis.STATUS_WARNING.equals(warning.getStatus())) {
|
||||||
advice.append("当前处于预警状态,建议加密监测频率。");
|
advice.append("当前达到黄色预警(预警值),建议加密监测频率。");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -99,10 +99,12 @@ public class WaterLevelAnalyzer extends AbstractDeviceAnalyzer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (warning != null) {
|
if (warning != null) {
|
||||||
if (WarningAnalysis.STATUS_ALARM.equals(warning.getStatus())) {
|
if (WarningAnalysis.STATUS_CONTROL.equals(warning.getStatus())) {
|
||||||
advice.append("当前处于报警状态,建议立即启动排水措施,组织专业人员进行渗流安全评估。");
|
advice.append("当前达到红色预警(控制值),建议立即启动应急排水并停止该部位作业,组织专业人员进行渗流安全评估。");
|
||||||
|
} else if (WarningAnalysis.STATUS_ALARM.equals(warning.getStatus())) {
|
||||||
|
advice.append("当前达到橙色预警(报警值),建议立即启动排水措施,组织专业人员进行渗流安全评估。");
|
||||||
} else if (WarningAnalysis.STATUS_WARNING.equals(warning.getStatus())) {
|
} else if (WarningAnalysis.STATUS_WARNING.equals(warning.getStatus())) {
|
||||||
advice.append("当前处于预警状态,建议加强巡视检查,准备应急排水预案。");
|
advice.append("当前达到黄色预警(预警值),建议加强巡视检查,准备应急排水预案。");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import io.swagger.annotations.ApiModelProperty;
|
|||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
import java.io.Serializable;
|
import java.io.Serializable;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 报告生成请求
|
* 报告生成请求
|
||||||
@ -20,9 +21,15 @@ public class ReportRequest implements Serializable {
|
|||||||
|
|
||||||
private static final long serialVersionUID = 1L;
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
@ApiModelProperty(value = "设备ID", required = true)
|
@ApiModelProperty(value = "设备ID(单设备导出必填;传网关设备ID时自动展开为其下同产品子设备)")
|
||||||
private Long deviceId;
|
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 二选一)")
|
@ApiModelProperty("统计开始时间 yyyy-MM-dd HH:mm:ss(与 period 二选一)")
|
||||||
private String startTime;
|
private String startTime;
|
||||||
|
|
||||||
|
|||||||
@ -5,20 +5,25 @@ import com.fastbee.report.model.Statistics;
|
|||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
import javax.annotation.Resource;
|
import javax.annotation.Resource;
|
||||||
|
import java.math.BigDecimal;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 监测指标图表构建器(主指标单图 + 其余指标按单位分组多曲线图)
|
* 监测指标图表构建器(主指标时程曲线 + 累计变化量曲线 + 变化速率曲线)
|
||||||
*
|
*
|
||||||
* <p>约定 charts 的 key:</p>
|
* <p>约定 charts 的 key:</p>
|
||||||
* <ul>
|
* <ul>
|
||||||
* <li>{@code timeSeriesChart}:主监测指标时程曲线(与模板兼容)</li>
|
* <li>{@code timeSeriesChart}:主监测指标时程曲线(原始读数,与模板兼容)</li>
|
||||||
* <li>{@code chart2} / {@code chart3}:其余指标按单位分组的时程曲线(最多 2 组)</li>
|
* <li>{@code chart2}:主监测指标<b>累计变化量</b>曲线(相对初始值)——判断变形/受力发展趋势的核心依据</li>
|
||||||
|
* <li>{@code chart3}:主监测指标<b>变化速率</b>曲线(单位/天)——判断是否出现加速变化的依据</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
*
|
*
|
||||||
|
* <p>多测点报告时 chart2 / chart3 由 {@link com.fastbee.report.core.MultiDeviceAnalysisMerger}
|
||||||
|
* 覆盖为各测点的多曲线版本。</p>
|
||||||
|
*
|
||||||
* @author FastBee
|
* @author FastBee
|
||||||
* @date 2026-09-10
|
* @date 2026-09-10
|
||||||
*/
|
*/
|
||||||
@ -31,6 +36,9 @@ public class MetricChartBuilder {
|
|||||||
@Resource
|
@Resource
|
||||||
private ChartGenerator chartGenerator;
|
private ChartGenerator chartGenerator;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private com.fastbee.report.analysis.RateCalculator rateCalculator;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 构建图表集合
|
* 构建图表集合
|
||||||
*
|
*
|
||||||
@ -59,41 +67,51 @@ public class MetricChartBuilder {
|
|||||||
primaryName + "时程曲线", primaryName, primaryUnit,
|
primaryName + "时程曲线", primaryName, primaryUnit,
|
||||||
byIdentity.getOrDefault(primaryIdentity, new ArrayList<>())));
|
byIdentity.getOrDefault(primaryIdentity, new ArrayList<>())));
|
||||||
|
|
||||||
// 2. 其余指标:按单位分组,同单位合并为一张多曲线图
|
// 2. 累计变化量曲线 + 3. 变化速率曲线(报告必须给出这两个维度的曲线)
|
||||||
Map<String, LinkedHashMap<String, List<MetricData>>> groupedByUnit = new LinkedHashMap<>();
|
List<MetricData> primarySeries = sortedSeries(byIdentity.getOrDefault(primaryIdentity, new ArrayList<>()));
|
||||||
for (Map.Entry<String, Statistics> entry : statisticsMap.entrySet()) {
|
if (!primarySeries.isEmpty()) {
|
||||||
String identity = entry.getKey();
|
BigDecimal initial = primarySeries.get(0).getNumericValue();
|
||||||
if (identity.equals(primaryIdentity)) {
|
charts.put("chart2", chartGenerator.timeSeriesChart(
|
||||||
continue;
|
primaryName + "累计变化量曲线", primaryName + "累计变化量", primaryUnit,
|
||||||
}
|
cumulativeSeries(primarySeries, initial)));
|
||||||
Statistics statistics = entry.getValue();
|
charts.put("chart3", chartGenerator.timeSeriesChart(
|
||||||
// 无数据的指标不生成图表
|
primaryName + "变化速率曲线", primaryName + "变化速率",
|
||||||
if (statistics == null || statistics.getCount() == 0) {
|
primaryUnit + "/d", rateCalculator.segmentRateSeries(primarySeries)));
|
||||||
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++;
|
|
||||||
}
|
}
|
||||||
return charts;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 补齐模板预留的图表位,避免模板标签无对应数据导致渲染异常
|
* 补齐模板预留的图表位,避免模板标签无对应数据导致渲染异常
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -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 "正常";
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -20,6 +20,13 @@ import java.util.UUID;
|
|||||||
public class ReportAssembler {
|
public class ReportAssembler {
|
||||||
|
|
||||||
public ReportModel assemble(ReportContext context, AnalysisResult analysis) {
|
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 则根据监测分类自动选择
|
// 默认使用专业模版,如未配置 templateCode 则根据监测分类自动选择
|
||||||
String templateCode = "DEVICE_" + context.getCategory().name() + "_V2";
|
String templateCode = "DEVICE_" + context.getCategory().name() + "_V2";
|
||||||
|
|
||||||
@ -30,7 +37,13 @@ public class ReportAssembler {
|
|||||||
}
|
}
|
||||||
// 末尾追加随机后缀,避免同一设备同一周期重复导出时文件名相同
|
// 末尾追加随机后缀,避免同一设备同一周期重复导出时文件名相同
|
||||||
String randomSuffix = UUID.randomUUID().toString().replace("-", "").substring(0, 6);
|
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(与概况/结论/统计指标口径一致)
|
// 主监测指标:优先使用 Analyzer 解析的 primaryIdentifier(与概况/结论/统计指标口径一致)
|
||||||
Statistics primary = null;
|
Statistics primary = null;
|
||||||
@ -51,7 +64,7 @@ public class ReportAssembler {
|
|||||||
.periodDesc(context.getTimeWindow().getDescription())
|
.periodDesc(context.getTimeWindow().getDescription())
|
||||||
.period(period)
|
.period(period)
|
||||||
.generatedAt(DateUtils.getNowDate())
|
.generatedAt(DateUtils.getNowDate())
|
||||||
.deviceName(context.getDevice().getDeviceName())
|
.deviceName(deviceLabel)
|
||||||
.serialNumber(serialNumber)
|
.serialNumber(serialNumber)
|
||||||
.productName(context.getProduct().getProductName())
|
.productName(context.getProduct().getProductName())
|
||||||
.deviceId(context.getDevice().getDeviceId())
|
.deviceId(context.getDevice().getDeviceId())
|
||||||
|
|||||||
@ -58,24 +58,27 @@ public class ReportContextBuilder {
|
|||||||
@Resource
|
@Resource
|
||||||
private SubGatewayMapper subGatewayMapper;
|
private SubGatewayMapper subGatewayMapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建报告上下文(按请求中的设备)
|
||||||
|
*/
|
||||||
public ReportContext build(ReportRequest request, TimeWindow window, MonitorCategory category, String reportNo) {
|
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) {
|
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) {
|
if (device.getProductId() == null) {
|
||||||
throw new ReportException("设备未绑定产品,无法生成报告");
|
throw new ReportException("设备未绑定产品,无法生成报告: " + deviceId);
|
||||||
}
|
}
|
||||||
Product product = productService.selectProductByProductId(device.getProductId());
|
Product product = productService.selectProductByProductId(device.getProductId());
|
||||||
if (product == null) {
|
if (product == null) {
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -12,6 +12,9 @@ import com.fastbee.report.category.MonitorCategoryResolver;
|
|||||||
import com.fastbee.report.core.ReportAssembler;
|
import com.fastbee.report.core.ReportAssembler;
|
||||||
import com.fastbee.report.core.ReportContext;
|
import com.fastbee.report.core.ReportContext;
|
||||||
import com.fastbee.report.core.ReportContextBuilder;
|
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.exception.ReportException;
|
||||||
import com.fastbee.report.model.AnalysisResult;
|
import com.fastbee.report.model.AnalysisResult;
|
||||||
import com.fastbee.report.model.ReportModel;
|
import com.fastbee.report.model.ReportModel;
|
||||||
@ -24,9 +27,12 @@ import com.fastbee.report.model.Statistics;
|
|||||||
import com.fastbee.report.renderer.ReportRenderer;
|
import com.fastbee.report.renderer.ReportRenderer;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
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 org.springframework.stereotype.Component;
|
||||||
|
|
||||||
import javax.annotation.Resource;
|
import javax.annotation.Resource;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
@ -56,6 +62,12 @@ public class ReportAsyncExecutor {
|
|||||||
@Resource
|
@Resource
|
||||||
private MonitorCategoryResolver categoryResolver;
|
private MonitorCategoryResolver categoryResolver;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private ReportTargetResolver targetResolver;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private MultiDeviceAnalysisMerger merger;
|
||||||
|
|
||||||
@Resource
|
@Resource
|
||||||
private ReportContextBuilder contextBuilder;
|
private ReportContextBuilder contextBuilder;
|
||||||
|
|
||||||
@ -85,57 +97,96 @@ public class ReportAsyncExecutor {
|
|||||||
*/
|
*/
|
||||||
public String submitAsyncTask(ReportRequest request) {
|
public String submitAsyncTask(ReportRequest request) {
|
||||||
validate(request);
|
validate(request);
|
||||||
TimeWindow window = periodResolver.resolve(request);
|
List<ReportTarget> targets = targetResolver.resolve(request);
|
||||||
MonitorCategory category = categoryResolver.resolveByDeviceId(request.getDeviceId());
|
if (targets.isEmpty()) {
|
||||||
|
throw new ReportException("未解析到任何可分析的测点设备");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监测分类以首个测点为准(多测点必须同产品,分类一致)
|
||||||
|
MonitorCategory category = categoryResolver.resolveByDeviceId(targets.get(0).getDeviceId());
|
||||||
if (category == MonitorCategory.UNKNOWN) {
|
if (category == MonitorCategory.UNKNOWN) {
|
||||||
throw new ReportException("设备所属产品未配置监测分类(iot_product.monitor_category_code),请联系管理员配置后重试");
|
throw new ReportException("设备所属产品未配置监测分类(iot_product.monitor_category_code),请联系管理员配置后重试");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TimeWindow window = periodResolver.resolve(request);
|
||||||
String reportNo = generateReportNo();
|
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;
|
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;
|
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 {
|
try {
|
||||||
String reportNo = context.getReportNo();
|
log.info("开始异步生成报告 reportNo={} 测点数={}", reportNo, targets.size());
|
||||||
log.info("开始异步生成报告 reportNo={} deviceId={}", reportNo, request.getDeviceId());
|
|
||||||
|
|
||||||
ReportAnalyzer analyzer = analyzerFactory.get(context.getCategory());
|
List<MultiDeviceAnalysisMerger.DeviceAnalysis> analyses = new ArrayList<>();
|
||||||
AnalysisResult analysis = analyzer.analyze(context, request);
|
// 统计期内无数据的测点:不静默丢弃,要在结论中写明,避免报告看起来像"只导出了 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 (analyses.isEmpty()) {
|
||||||
if (isNoEffectiveData(context, analysis)) {
|
log.warn("报告生成中止:全部测点在时间范围内无有效监测数据 reportNo={} 测点={}",
|
||||||
log.warn("报告生成中止:时间范围内无有效监测数据 reportNo={} deviceId={}",
|
reportNo, String.join("、", skippedPoints));
|
||||||
reportNo, request.getDeviceId());
|
reportRecordService.markFailed(record, "所选时间范围内无有效监测数据,无法生成报告(测点:"
|
||||||
reportRecordService.markFailed(record, "所选时间范围内无有效监测数据,无法生成报告");
|
+ String.join("、", skippedPoints) + ")");
|
||||||
return;
|
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);
|
byte[] docBytes = reportRenderer.render(model);
|
||||||
|
|
||||||
String path = buildObjectPath(request.getDeviceId(), reportNo);
|
String path = buildObjectPath(firstContext.getDevice().getDeviceId(), reportNo);
|
||||||
UploadResult upload = OssFactory.instance(redisCache)
|
UploadResult upload = OssFactory.instance(redisCache)
|
||||||
.upload(docBytes, path, WORD_CONTENT_TYPE);
|
.upload(docBytes, path, WORD_CONTENT_TYPE);
|
||||||
|
|
||||||
reportRecordService.markSuccess(record, model.getFileName(), upload.getUrl());
|
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) {
|
} catch (Exception e) {
|
||||||
log.error("报告生成失败 reportNo={} deviceId={}", context.getReportNo(), request.getDeviceId(), e);
|
log.error("报告生成失败 reportNo={}", reportNo, e);
|
||||||
String message = e instanceof ReportException ? e.getMessage()
|
String message = e instanceof ReportException ? e.getMessage()
|
||||||
: (e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage());
|
: (e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage());
|
||||||
reportRecordService.markFailed(record, message);
|
reportRecordService.markFailed(record, message);
|
||||||
@ -160,10 +211,30 @@ public class ReportAsyncExecutor {
|
|||||||
return statisticsMap.values().stream().noneMatch(s -> s.getCount() > 0);
|
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) {
|
private void validate(ReportRequest request) {
|
||||||
if (request == null || request.getDeviceId() == null) {
|
if (request == null) {
|
||||||
throw new ReportException("请选择设备");
|
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())) {
|
if (StringUtils.isEmpty(request.getStartTime()) && StringUtils.isEmpty(request.getPeriod())) {
|
||||||
throw new ReportException("请选择时间范围或报告周期");
|
throw new ReportException("请选择时间范围或报告周期");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
|
||||||
}
|
|
||||||
@ -6,9 +6,13 @@ import lombok.Data;
|
|||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
|
import java.util.Date;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 测点数据(用于详细表格展示,支持多测点设备)
|
* 测点数据(报告明细表一行)
|
||||||
|
*
|
||||||
|
* <p>测点即 iot_device:多设备报告时一台设备一行,一行对应一个物模型(物理量)的分析结果,
|
||||||
|
* 表格列按导出设备的物模型动态生成。</p>
|
||||||
*
|
*
|
||||||
* @author FastBee
|
* @author FastBee
|
||||||
* @date 2026-09-09
|
* @date 2026-09-09
|
||||||
@ -19,10 +23,16 @@ import java.math.BigDecimal;
|
|||||||
@AllArgsConstructor
|
@AllArgsConstructor
|
||||||
public class MetricPointData {
|
public class MetricPointData {
|
||||||
|
|
||||||
/** 测点编号(如 MS13-1) */
|
/** 断面(按设备分组导出时取分组名;非分组导出的为 null,表格不展示该列) */
|
||||||
|
private String sectionName;
|
||||||
|
|
||||||
|
/** 测点ID(iot_device.device_id) */
|
||||||
|
private Long deviceId;
|
||||||
|
|
||||||
|
/** 测点编号(设备编号 serialNumber) */
|
||||||
private String pointNo;
|
private String pointNo;
|
||||||
|
|
||||||
/** 测点名称(如 红线/白线/绿线) */
|
/** 测点名称(设备名称) */
|
||||||
private String pointName;
|
private String pointName;
|
||||||
|
|
||||||
/** 物模型标识符 */
|
/** 物模型标识符 */
|
||||||
@ -37,21 +47,37 @@ public class MetricPointData {
|
|||||||
/** 初始值单位 */
|
/** 初始值单位 */
|
||||||
private String initialUnit;
|
private String initialUnit;
|
||||||
|
|
||||||
|
/** 初始观测时间(初始值对应数据的上报时间) */
|
||||||
|
private Date initialTime;
|
||||||
|
|
||||||
/** 当前值 */
|
/** 当前值 */
|
||||||
private BigDecimal currentValue;
|
private BigDecimal currentValue;
|
||||||
|
|
||||||
/** 当前值单位 */
|
/** 当前值单位 */
|
||||||
private String currentUnit;
|
private String currentUnit;
|
||||||
|
|
||||||
/** 变化量 */
|
/** 本次观测时间 */
|
||||||
|
private Date currentTime;
|
||||||
|
|
||||||
|
/** 变化量(当前 - 初始) */
|
||||||
private BigDecimal changeValue;
|
private BigDecimal changeValue;
|
||||||
|
|
||||||
/** 变化量单位 */
|
/** 变化量单位 */
|
||||||
private String changeUnit;
|
private String changeUnit;
|
||||||
|
|
||||||
/** 变化速率 */
|
/**
|
||||||
|
* 变化速率(主口径):一元线性回归斜率,单位/天
|
||||||
|
*
|
||||||
|
* <p>报告展示与预警判定统一取此值,避免同一份报告出现两套速率口径。</p>
|
||||||
|
*/
|
||||||
private BigDecimal changeRate;
|
private BigDecimal changeRate;
|
||||||
|
|
||||||
|
/** 本期平均速率 =(当前 - 初始)/ 天数,参考口径 */
|
||||||
|
private BigDecimal averageRate;
|
||||||
|
|
||||||
|
/** 最大分段速率(相邻观测点日速率最大值),用于识别短期突变 */
|
||||||
|
private BigDecimal maxSegmentRate;
|
||||||
|
|
||||||
/** 变化速率单位 */
|
/** 变化速率单位 */
|
||||||
private String rateUnit;
|
private String rateUnit;
|
||||||
|
|
||||||
@ -64,9 +90,12 @@ public class MetricPointData {
|
|||||||
/** 平均值 */
|
/** 平均值 */
|
||||||
private BigDecimal avgValue;
|
private BigDecimal avgValue;
|
||||||
|
|
||||||
/** 预警状态(NORMAL/WARNING/ALARM) */
|
/** 预警状态(NORMAL/WARNING/ALARM/CONTROL → 正常/黄色/橙色/红色) */
|
||||||
private String warningStatus;
|
private String warningStatus;
|
||||||
|
|
||||||
|
/** 是否为主监测指标(温度等辅助量为 false,作为影响量参与分析但不主导结论) */
|
||||||
|
private Boolean primary;
|
||||||
|
|
||||||
/** 备注 */
|
/** 备注 */
|
||||||
private String remark;
|
private String remark;
|
||||||
}
|
}
|
||||||
@ -5,6 +5,7 @@ import io.swagger.annotations.ApiModelProperty;
|
|||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
|
import java.util.Date;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 统计指标(当前/初始/极值/均值/中位/变化量/累计变化)
|
* 统计指标(当前/初始/极值/均值/中位/变化量/累计变化)
|
||||||
@ -34,6 +35,14 @@ public class Statistics {
|
|||||||
@ApiModelProperty("初始值(最早)")
|
@ApiModelProperty("初始值(最早)")
|
||||||
private BigDecimal initial;
|
private BigDecimal initial;
|
||||||
|
|
||||||
|
/** 初始值对应观测时间(统计窗口内首条有效数据时间),报告「初始观测时间」列取此值 */
|
||||||
|
@ApiModelProperty("初始观测时间")
|
||||||
|
private Date initialTime;
|
||||||
|
|
||||||
|
/** 当前值对应观测时间(统计窗口内末条有效数据时间) */
|
||||||
|
@ApiModelProperty("本次观测时间")
|
||||||
|
private Date currentTime;
|
||||||
|
|
||||||
@ApiModelProperty("最大值")
|
@ApiModelProperty("最大值")
|
||||||
private BigDecimal max;
|
private BigDecimal max;
|
||||||
|
|
||||||
|
|||||||
@ -16,12 +16,34 @@ import java.math.BigDecimal;
|
|||||||
@ApiModel(value = "WarningAnalysis", description = "预警分析")
|
@ApiModel(value = "WarningAnalysis", description = "预警分析")
|
||||||
public class WarningAnalysis {
|
public class WarningAnalysis {
|
||||||
|
|
||||||
|
/** 正常(未达预警值) */
|
||||||
public static final String STATUS_NORMAL = "NORMAL";
|
public static final String STATUS_NORMAL = "NORMAL";
|
||||||
|
/** 黄色预警:达到预警值 warningValue */
|
||||||
public static final String STATUS_WARNING = "WARNING";
|
public static final String STATUS_WARNING = "WARNING";
|
||||||
|
/** 橙色预警:达到报警值 alarmValue */
|
||||||
public static final String STATUS_ALARM = "ALARM";
|
public static final String STATUS_ALARM = "ALARM";
|
||||||
|
/** 红色预警:达到控制值 controlValue */
|
||||||
|
public static final String STATUS_CONTROL = "CONTROL";
|
||||||
/** 未配置阈值,无法评估 */
|
/** 未配置阈值,无法评估 */
|
||||||
public static final String STATUS_UNKNOWN = "UNKNOWN";
|
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("物模型标识符")
|
@ApiModelProperty("物模型标识符")
|
||||||
private String identifier;
|
private String identifier;
|
||||||
|
|
||||||
|
|||||||
@ -3,15 +3,16 @@ package com.fastbee.report.renderer;
|
|||||||
import cn.hutool.core.io.IoUtil;
|
import cn.hutool.core.io.IoUtil;
|
||||||
import com.fastbee.common.utils.StringUtils;
|
import com.fastbee.common.utils.StringUtils;
|
||||||
import com.fastbee.report.exception.ReportException;
|
import com.fastbee.report.exception.ReportException;
|
||||||
import com.fastbee.report.mapper.ReportTemplateMapper;
|
|
||||||
import com.fastbee.report.template.ReportTemplate;
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import javax.annotation.Resource;
|
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 默认 Word 模板解析器:report_template 表(file_path)优先,classpath 兜底
|
* 默认 Word 模板解析器:从 classpath 加载模板
|
||||||
|
*
|
||||||
|
* <p>模板({@code templates/DEVICE_${CATEGORY}_V2.docx})是代码资产,随版本一起发布。
|
||||||
|
* 报告模板的占位符与渲染逻辑强耦合(动态表列、图表位、策略绑定都在代码里),
|
||||||
|
* 模板变更必然伴随代码变更,因此不再从数据库读取模板路径。</p>
|
||||||
*
|
*
|
||||||
* @author FastBee
|
* @author FastBee
|
||||||
* @date 2026-08-16
|
* @date 2026-08-16
|
||||||
@ -19,26 +20,16 @@ import java.io.InputStream;
|
|||||||
@Service
|
@Service
|
||||||
public class DefaultWordTemplateResolver implements WordTemplateResolver {
|
public class DefaultWordTemplateResolver implements WordTemplateResolver {
|
||||||
|
|
||||||
@Resource
|
|
||||||
private ReportTemplateMapper reportTemplateMapper;
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public byte[] resolve(String templateCode) {
|
public byte[] resolve(String templateCode) {
|
||||||
if (StringUtils.isEmpty(templateCode)) {
|
if (StringUtils.isEmpty(templateCode)) {
|
||||||
throw new ReportException("报告模板编码为空");
|
throw new ReportException("报告模板编码为空");
|
||||||
}
|
}
|
||||||
ReportTemplate template = reportTemplateMapper.selectByCode(templateCode);
|
byte[] template = loadClasspath("templates/" + templateCode + ".docx");
|
||||||
if (template != null && StringUtils.isNotEmpty(template.getFilePath())) {
|
if (template == null) {
|
||||||
byte[] fromClasspath = loadClasspath(template.getFilePath());
|
|
||||||
if (fromClasspath != null) {
|
|
||||||
return fromClasspath;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
byte[] fallback = loadClasspath("templates/" + templateCode + ".docx");
|
|
||||||
if (fallback == null) {
|
|
||||||
throw new ReportException("报告模板不存在: " + templateCode);
|
throw new ReportException("报告模板不存在: " + templateCode);
|
||||||
}
|
}
|
||||||
return fallback;
|
return template;
|
||||||
}
|
}
|
||||||
|
|
||||||
private byte[] loadClasspath(String path) {
|
private byte[] loadClasspath(String path) {
|
||||||
|
|||||||
@ -4,8 +4,13 @@ import com.deepoove.poi.XWPFTemplate;
|
|||||||
import com.deepoove.poi.config.Configure;
|
import com.deepoove.poi.config.Configure;
|
||||||
import com.deepoove.poi.data.ByteArrayPictureRenderData;
|
import com.deepoove.poi.data.ByteArrayPictureRenderData;
|
||||||
import com.deepoove.poi.data.PictureType;
|
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.data.style.PictureStyle;
|
||||||
import com.deepoove.poi.plugin.table.LoopRowTableRenderPolicy;
|
import com.deepoove.poi.plugin.table.LoopRowTableRenderPolicy;
|
||||||
|
import com.deepoove.poi.policy.TableRenderPolicy;
|
||||||
import com.fastbee.common.utils.StringUtils;
|
import com.fastbee.common.utils.StringUtils;
|
||||||
import com.fastbee.report.exception.ReportException;
|
import com.fastbee.report.exception.ReportException;
|
||||||
import com.fastbee.report.model.AnalysisResult;
|
import com.fastbee.report.model.AnalysisResult;
|
||||||
@ -30,9 +35,12 @@ import java.math.BigDecimal;
|
|||||||
import java.math.RoundingMode;
|
import java.math.RoundingMode;
|
||||||
import java.text.SimpleDateFormat;
|
import java.text.SimpleDateFormat;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.Date;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Word 渲染器(poi-tl 模板渲染 + JFreeChart PNG 嵌入,禁止查库)
|
* Word 渲染器(poi-tl 模板渲染 + JFreeChart PNG 嵌入,禁止查库)
|
||||||
@ -52,7 +60,9 @@ public class WordRenderer implements ReportRenderer {
|
|||||||
Map<String, Object> data = buildData(model);
|
Map<String, Object> data = buildData(model);
|
||||||
Configure config = Configure.builder()
|
Configure config = Configure.builder()
|
||||||
.bind("conclusions", new LoopRowTableRenderPolicy())
|
.bind("conclusions", new LoopRowTableRenderPolicy())
|
||||||
.bind("metricPoints", new LoopRowTableRenderPolicy()) // 添加多测点数据绑定
|
.bind("metricPoints", new LoopRowTableRenderPolicy()) // 兼容仍使用固定表格的旧模板
|
||||||
|
// 动态列测点表:列由物模型决定,必须绑定表格渲染策略,否则会被当纯文本输出
|
||||||
|
.bind("metricTable", new TableRenderPolicy())
|
||||||
.build();
|
.build();
|
||||||
XWPFTemplate tpl = XWPFTemplate.compile(new ByteArrayInputStream(template), config).render(data);
|
XWPFTemplate tpl = XWPFTemplate.compile(new ByteArrayInputStream(template), config).render(data);
|
||||||
try {
|
try {
|
||||||
@ -161,7 +171,7 @@ public class WordRenderer implements ReportRenderer {
|
|||||||
qualityMap.put("description", quality == null ? "统计期内无有效数据" : quality.getDescription());
|
qualityMap.put("description", quality == null ? "统计期内无有效数据" : quality.getDescription());
|
||||||
|
|
||||||
Map<String, Object> warningMap = new HashMap<>();
|
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());
|
warningMap.put("description", warning == null ? "统计期内无有效数据" : warning.getDescription());
|
||||||
|
|
||||||
Map<String, Object> analysisMap = new HashMap<>();
|
Map<String, Object> analysisMap = new HashMap<>();
|
||||||
@ -195,21 +205,29 @@ public class WordRenderer implements ReportRenderer {
|
|||||||
if (analysis.getMetricPoints() != null && !analysis.getMetricPoints().isEmpty()) {
|
if (analysis.getMetricPoints() != null && !analysis.getMetricPoints().isEmpty()) {
|
||||||
for (MetricPointData point : analysis.getMetricPoints()) {
|
for (MetricPointData point : analysis.getMetricPoints()) {
|
||||||
Map<String, Object> pointMap = new HashMap<>();
|
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("pointNo", point.getPointNo() == null ? "" : point.getPointNo());
|
||||||
pointMap.put("pointName", point.getPointName() == null ? "" : point.getPointName());
|
pointMap.put("pointName", point.getPointName() == null ? "" : point.getPointName());
|
||||||
pointMap.put("identifier", point.getIdentifier());
|
pointMap.put("identifier", point.getIdentifier());
|
||||||
pointMap.put("modelName", point.getModelName());
|
pointMap.put("modelName", point.getModelName());
|
||||||
pointMap.put("initialValue", str(point.getInitialValue()));
|
pointMap.put("initialValue", str(point.getInitialValue()));
|
||||||
pointMap.put("initialUnit", point.getInitialUnit() == null ? "" : point.getInitialUnit());
|
pointMap.put("initialUnit", point.getInitialUnit() == null ? "" : point.getInitialUnit());
|
||||||
|
pointMap.put("initialTime", fmt(point.getInitialTime()));
|
||||||
pointMap.put("currentValue", str(point.getCurrentValue()));
|
pointMap.put("currentValue", str(point.getCurrentValue()));
|
||||||
pointMap.put("currentUnit", point.getCurrentUnit() == null ? "" : point.getCurrentUnit());
|
pointMap.put("currentUnit", point.getCurrentUnit() == null ? "" : point.getCurrentUnit());
|
||||||
|
pointMap.put("currentTime", fmt(point.getCurrentTime()));
|
||||||
pointMap.put("changeValue", str(point.getChangeValue()));
|
pointMap.put("changeValue", str(point.getChangeValue()));
|
||||||
pointMap.put("changeUnit", point.getChangeUnit() == null ? "" : point.getChangeUnit());
|
pointMap.put("changeUnit", point.getChangeUnit() == null ? "" : point.getChangeUnit());
|
||||||
|
// 主口径速率:回归斜率
|
||||||
pointMap.put("changeRate", str(point.getChangeRate()));
|
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("rateUnit", point.getRateUnit() == null ? "" : point.getRateUnit());
|
||||||
pointMap.put("maxValue", str(point.getMaxValue()));
|
pointMap.put("maxValue", str(point.getMaxValue()));
|
||||||
pointMap.put("minValue", str(point.getMinValue()));
|
pointMap.put("minValue", str(point.getMinValue()));
|
||||||
pointMap.put("avgValue", str(point.getAvgValue()));
|
pointMap.put("avgValue", str(point.getAvgValue()));
|
||||||
|
pointMap.put("primary", point.getPrimary() != null && point.getPrimary());
|
||||||
// 无数据的指标标注"无数据",避免误报为正常
|
// 无数据的指标标注"无数据",避免误报为正常
|
||||||
pointMap.put("warningStatus", point.getCurrentValue() == null
|
pointMap.put("warningStatus", point.getCurrentValue() == null
|
||||||
? "无数据" : warningStatusText(point.getWarningStatus()));
|
? "无数据" : warningStatusText(point.getWarningStatus()));
|
||||||
@ -217,6 +235,8 @@ public class WordRenderer implements ReportRenderer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
data.put("metricPoints", metricPointsList);
|
data.put("metricPoints", metricPointsList);
|
||||||
|
// 动态列测点表:列由导出设备的物模型(物理量)决定,一行一个测点(iot_device)
|
||||||
|
data.put("metricTable", buildMetricTable(analysis));
|
||||||
|
|
||||||
// 图表:兼容 timeSeriesChart(主指标)/ chart2 / chart3(分组指标)/ crackTrend(旧模板)
|
// 图表:兼容 timeSeriesChart(主指标)/ chart2 / chart3(分组指标)/ crackTrend(旧模板)
|
||||||
Map<String, Object> charts = new HashMap<>();
|
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) {
|
private String withUnit(String value, String unit) {
|
||||||
return (unit == null || unit.trim().isEmpty()) ? value : value + " " + unit.trim();
|
return (unit == null || unit.trim().isEmpty()) ? value : value + " " + unit.trim();
|
||||||
}
|
}
|
||||||
@ -285,13 +480,35 @@ public class WordRenderer implements ReportRenderer {
|
|||||||
return formatted.toPlainString();
|
return formatted.toPlainString();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 预警状态转中文 */
|
/** 预警状态转中文(表格「监测结论」列:正常/黄色/橙色/红色) */
|
||||||
private String warningStatusText(String status) {
|
private String warningStatusText(String status) {
|
||||||
|
if (WarningAnalysis.STATUS_CONTROL.equals(status)) {
|
||||||
|
return "红色";
|
||||||
|
}
|
||||||
if (WarningAnalysis.STATUS_ALARM.equals(status)) {
|
if (WarningAnalysis.STATUS_ALARM.equals(status)) {
|
||||||
return "报警";
|
return "橙色";
|
||||||
}
|
}
|
||||||
if (WarningAnalysis.STATUS_WARNING.equals(status)) {
|
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)) {
|
if (WarningAnalysis.STATUS_UNKNOWN.equals(status)) {
|
||||||
return "待评估";
|
return "待评估";
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,7 +3,7 @@
|
|||||||
-- 创建日期: 2026-08-16
|
-- 创建日期: 2026-08-16
|
||||||
-- 说明:
|
-- 说明:
|
||||||
-- 1. iot_product 增加监测分类编码列
|
-- 1. iot_product 增加监测分类编码列
|
||||||
-- 2. 新增 iot_things_threshold / report_template / report_record 三张表
|
-- 2. 新增 iot_things_threshold / report_record 两张表(Word 模板不入库,随代码发布)
|
||||||
-- 3. oss_config 插入 MinIO 默认配置(存储决策:未购买云 OSS,统一使用自建 MinIO)
|
-- 3. oss_config 插入 MinIO 默认配置(存储决策:未购买云 OSS,统一使用自建 MinIO)
|
||||||
-- 4. 历史报表菜单与按钮权限
|
-- 4. 历史报表菜单与按钮权限
|
||||||
-- 注意: 存量库执行前请先备份;增量脚本可重复执行(使用 / INSERT IGNORE)
|
-- 注意: 存量库执行前请先备份;增量脚本可重复执行(使用 / INSERT IGNORE)
|
||||||
@ -85,39 +85,11 @@ CREATE TABLE `iot_things_threshold` (
|
|||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci ROW_FORMAT=DYNAMIC COMMENT='报告结构化阈值';
|
) 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 倒推清理)
|
-- 2.3 报告生成记录表(保留期由 LogCleanTask#cleanLogs(days) 统一管理,按 create_time 倒推清理)
|
||||||
|
|||||||
@ -476,23 +476,24 @@ export default {
|
|||||||
this.$message.warning('请选择时间范围');
|
this.$message.warning('请选择时间范围');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// 网关设备需选定单个子设备后才能生成报告(网关本身不可生成)
|
// 网关设备:支持多子设备一起出报告(后端按同产品校验);
|
||||||
if (this.isGatewayLike) {
|
// 未勾选子设备时传网关 deviceId,由后端自动展开为其下同产品子设备
|
||||||
const subIds = this.gwQueryParams.subDeviceIds;
|
|
||||||
if (Array.isArray(subIds) && subIds.length === 1) {
|
|
||||||
reportDeviceId = subIds[0];
|
|
||||||
} else {
|
|
||||||
this.$message.warning('网关设备请选择单个子设备后生成报告');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.reportLoading = true;
|
|
||||||
const payload = {
|
const payload = {
|
||||||
deviceId: reportDeviceId,
|
|
||||||
startTime: moment(range[0]).format('YYYY-MM-DD HH:mm:ss'),
|
startTime: moment(range[0]).format('YYYY-MM-DD HH:mm:ss'),
|
||||||
endTime: moment(range[1]).format('YYYY-MM-DD HH:mm:ss'),
|
endTime: moment(range[1]).format('YYYY-MM-DD HH:mm:ss'),
|
||||||
format: 'WORD',
|
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)
|
generateDeviceReport(payload)
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
if (res.code === 200) {
|
if (res.code === 200) {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user