diff --git a/.claude/settings.local.json b/.claude/settings.local.json index fab06b55..839f028f 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -68,7 +68,9 @@ "Bash(node --check /Users/admin/IdeaProjects/fastbee/yack-web/src/api/iot/report.js)", "Bash(sed -i '' 's/direction\\)\\);$/direction\\)\\)\\);/' static_level/StaticLevelAnalyzer.java)", "Bash(sed -i '' 's/strainState, direction\\)\\);/strainState, direction\\)\\)\\);/' strain_gauge/StrainGaugeAnalyzer.java)", - "Bash(pip3 install *)" + "Bash(pip3 install *)", + "mcp__filesystem__read_file", + "mcp__filesystem__edit_file" ] } } diff --git a/docs/fastbee-device-report-engine.md b/docs/fastbee-device-report-engine.md index 5e24bbbc..de2e817f 100644 --- a/docs/fastbee-device-report-engine.md +++ b/docs/fastbee-device-report-engine.md @@ -205,19 +205,28 @@ services: ## 4.3 系统配置(oss_config 插入默认记录) ```sql -INSERT INTO oss_config +-- 先停用云 OSS 配置(存量占位符) +UPDATE `oss_config` SET `status` = '1' WHERE `config_key` IN ('aliyun', 'qiniu'); + +-- 插入 MinIO 默认配置(endpoint 为云服务器地址,供后端 S3 SDK 连接) +INSERT IGNORE INTO `oss_config` (tenant_id, tenant_name, config_key, access_key, secret_key, bucket_name, prefix, - endpoint, domain, is_https, region, access_policy, status, del_flag, create_time) + endpoint, domain, is_https, region, access_policy, status, del_flag, create_by, create_time, update_by, update_time, remark) VALUES -(1, 'admin', 'minio', '', '', 'fastbee', 'report', - 'http://:9000', '', 'N', '', '1', '0', '0', NOW()); +(1, 'admin', 'minio', 'supermeminio', 'minio20260909', 'fastbee', 'report', + 'http://47.1.28.77:9000', '', 'N', '', '0', '0', '0', 'admin', NOW(), 'admin', NOW(), 'MinIO默认存储'); ``` 要点: - `status='0'` 设为默认,启动时 `OssApplicationRunner` 自动装载进 Redis(`sys_oss:default_config`)。 +- `endpoint` 为后端连接 MinIO 的 S3 API 地址,需确保端口 9000 在安全组/防火墙放行;若后端与 MinIO 同机部署,可改为 `http://127.0.0.1:9000`。 +- `domain` 为空时,`getUrl()` 返回 `http:///`;若配置了 CDN 或公网域名,可填入以生成更友好的 URL。 - `PlatformType` 增加 `MINIO(5, "MinIO")`(仅用于管理页展示;实际路由走 `config_key`)。 -- bucket 权限 `access_policy=1`(public)时上传后可直接访问 URL;若不想公网直链,下载统一走后端代理。 +- `access_policy=0`(私有),报告下载统一走后端代理接口(`GET /iot/report/download/{reportNo}`),可校验权限和过期状态。 +- `bucket_name='fastbee'`:需在 MinIO 中提前创建此 bucket。 +- `endpoint` 需根据实际 MinIO 部署地址修改(如 `http://192.168.1.100:9000` 或生产域名)。 +- `bucket_name='fastbee'`:需在 MinIO 中提前创建此 bucket。 ## 4.4 报告文件路径 diff --git a/docs/锚索测力计模版.docx b/docs/锚索测力计模版.docx new file mode 100644 index 00000000..b7d12e13 Binary files /dev/null and b/docs/锚索测力计模版.docx differ diff --git a/fastbee-open-api/src/main/java/com/fastbee/controller/device/DeviceController.java b/fastbee-open-api/src/main/java/com/fastbee/controller/device/DeviceController.java index 4a0ea9ad..4f5a86c3 100644 --- a/fastbee-open-api/src/main/java/com/fastbee/controller/device/DeviceController.java +++ b/fastbee-open-api/src/main/java/com/fastbee/controller/device/DeviceController.java @@ -140,6 +140,25 @@ public class DeviceController extends BaseController { return AjaxResult.success(deviceService.selectUserDeviceStatistic(device)); } + /** + * 查询树形设备列表(用于报表生成等场景,网关设备可展开显示子设备) + */ + @PreAuthorize("@ss.hasAnyPermi('iot:device:list,iot:report:generate')") + @GetMapping("/treeList") + @ApiOperation("查询树形设备列表(网关可展开子设备)") + public AjaxResult treeList(Device device) { + LoginUser loginUser = getLoginUser(); + if (null == loginUser.getDeptId()) { + device.setTenantId(loginUser.getUserId()); + } else { + device.setDeptId(loginUser.getDeptId()); + } + if (Objects.isNull(device.getTenantId())) { + device.setTenantId(loginUser.getUserId()); + } + return AjaxResult.success(deviceService.selectDeviceTreeList(device)); + } + /** * 查询所有设备简短列表 */ diff --git a/fastbee-open-api/src/main/java/com/fastbee/controller/report/ReportController.java b/fastbee-open-api/src/main/java/com/fastbee/controller/report/ReportController.java index 7678e6a7..4e36e30a 100644 --- a/fastbee-open-api/src/main/java/com/fastbee/controller/report/ReportController.java +++ b/fastbee-open-api/src/main/java/com/fastbee/controller/report/ReportController.java @@ -10,9 +10,9 @@ import com.fastbee.common.exception.ServiceException; import com.fastbee.common.utils.StringUtils; import com.fastbee.oss.service.OssClient; import com.fastbee.oss.service.OssFactory; -import com.fastbee.report.api.ReportEngine; import com.fastbee.report.api.ReportRequest; import com.fastbee.report.api.ReportResult; +import com.fastbee.report.executor.ReportAsyncExecutor; import com.fastbee.report.record.ReportRecord; import com.fastbee.report.record.ReportRecordQuery; import com.fastbee.report.record.ReportRecordService; @@ -53,7 +53,7 @@ public class ReportController extends BaseController { "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; @Resource - private ReportEngine reportEngine; + private ReportAsyncExecutor reportAsyncExecutor; @Resource private ReportRecordService reportRecordService; @@ -62,13 +62,34 @@ public class ReportController extends BaseController { private RedisCache redisCache; /** - * 生成并导出报表(V1 同步生成) + * 异步生成报表(V2 异步生成,立即返回报告编号) */ - @ApiOperation("生成并导出报表") + @ApiOperation("异步生成报表") @PreAuthorize("@ss.hasPermi('iot:report:generate')") @PostMapping("/generate") public AjaxResult generate(@RequestBody ReportRequest request) { - ReportResult result = reportEngine.generate(request); + String reportNo = reportAsyncExecutor.submitAsyncTask(request); + return AjaxResult.success("报表生成任务已提交,请稍后查询状态", reportNo); + } + + /** + * 查询报表生成状态 + */ + @ApiOperation("查询报表生成状态") + @PreAuthorize("@ss.hasAnyPermi('iot:report:list,iot:device:list')") + @GetMapping("/status/{reportNo}") + public AjaxResult status(@PathVariable("reportNo") String reportNo) { + ReportRecord record = reportRecordService.findByReportNo(reportNo); + if (record == null) { + throw new ServiceException("报告不存在"); + } + ReportResult result = ReportResult.builder() + .reportNo(record.getReportNo()) + .status(record.getStatus()) + .fileName(record.getFileName()) + .fileUrl(record.getFileUrl()) + .expireTime(record.getExpireTime()) + .build(); return AjaxResult.success(result); } diff --git a/fastbee-report/src/main/java/com/fastbee/report/analysis/PrimaryMetricResolver.java b/fastbee-report/src/main/java/com/fastbee/report/analysis/PrimaryMetricResolver.java new file mode 100644 index 00000000..5f885d3a --- /dev/null +++ b/fastbee-report/src/main/java/com/fastbee/report/analysis/PrimaryMetricResolver.java @@ -0,0 +1,126 @@ +package com.fastbee.report.analysis; + +import com.fastbee.iot.domain.ThingsModel; +import com.fastbee.report.core.ReportContext; +import com.fastbee.report.data.MetricData; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; + +/** + * 主监测指标解析器(统一各 Analyzer 的"主指标"判定口径) + * + *

判定规则(按物模型列表顺序,保证稳定、不依赖时序数据返回顺序):

+ *
    + *
  1. 排除辅助指标(角度/温度/电压/电量/信号/湿度等),辅助量不作为主监测指标;
  2. + *
  3. 优先返回名称或标识符命中核心关键词的物模型;
  4. + *
  5. 否则返回第一个有数据的非辅助指标;
  6. + *
  7. 再否则返回第一个非辅助指标;最后兜底第一个物模型。
  8. + *
+ * + * @author FastBee + * @date 2026-09-10 + */ +@Component +public class PrimaryMetricResolver { + + /** 辅助指标关键词(这些物模型不作为主监测指标) */ + private static final String[] AUXILIARY_KEYWORDS = { + "角度", "angle", "温度", "temperature", "电压", "voltage", + "电量", "battery", "信号", "signal", "湿度", "humidity", + "电压值", "battery_voltage" + }; + + /** + * 解析主监测指标标识符 + * + * @param context 报告上下文(提供物模型列表,顺序稳定) + * @param byIdentity 时序数据分组(用于判断是否有数据) + * @param keywords 核心关键词(如"位移"、"缝宽"、"轴力") + * @return 主监测指标标识符;无物模型时返回 null + */ + public String resolve(ReportContext context, Map> byIdentity, String... keywords) { + return resolve(context, byIdentity, false, keywords); + } + + /** + * 解析主监测指标标识符 + * + * @param allowAuxiliary 是否允许辅助指标(角度/温度等)作为主指标。 + * 倾角计、温度计等设备的主监测指标本身就是"角度/温度",需传 true。 + */ + public String resolve(ReportContext context, Map> byIdentity, + boolean allowAuxiliary, String... keywords) { + List models = context.getThingsModels(); + if (models == null || models.isEmpty()) { + return byIdentity.isEmpty() ? null : byIdentity.keySet().iterator().next(); + } + + String firstWithData = null; + String firstAny = null; + + for (ThingsModel model : models) { + String identity = model.getIdentifier(); + if (identity == null || identity.trim().isEmpty()) { + continue; + } + String name = model.getModelName() == null ? identity : model.getModelName(); + String lower = (name + " " + identity).toLowerCase(); + boolean auxiliary = isAuxiliary(lower); + + // 核心关键词命中优先(倾角计/温度计允许辅助指标作为主指标) + if (!auxiliary || allowAuxiliary) { + for (String keyword : keywords) { + if (lower.contains(keyword.toLowerCase())) { + return identity; + } + } + } + + // 辅助指标不参与"第一个"兜底 + if (auxiliary && !allowAuxiliary) { + continue; + } + + boolean hasData = byIdentity.containsKey(identity); + if (firstAny == null) { + firstAny = identity; + } + if (firstWithData == null && hasData) { + firstWithData = identity; + } + } + + if (firstWithData != null) { + return firstWithData; + } + if (firstAny != null) { + return firstAny; + } + // 全部是辅助指标时,退回第一个物模型 + return models.get(0).getIdentifier(); + } + + /** + * 是否为辅助指标(角度/温度/电压/电量/信号/湿度等) + */ + public boolean isAuxiliary(String nameAndIdentifier) { + for (String keyword : AUXILIARY_KEYWORDS) { + if (nameAndIdentifier.contains(keyword)) { + return true; + } + } + return false; + } + + /** + * 是否需要生成独立图表(辅助指标中仅温度/电压等少量关注项可跳过) + */ + public boolean isChartWorthy(String nameAndIdentifier) { + return !nameAndIdentifier.contains("电量") + && !nameAndIdentifier.contains("battery") + && !nameAndIdentifier.contains("信号") + && !nameAndIdentifier.contains("signal"); + } +} diff --git a/fastbee-report/src/main/java/com/fastbee/report/analyzer/axial_force/AxialForceAnalyzer.java b/fastbee-report/src/main/java/com/fastbee/report/analyzer/axial_force/AxialForceAnalyzer.java index 63b6785a..1efef286 100644 --- a/fastbee-report/src/main/java/com/fastbee/report/analyzer/axial_force/AxialForceAnalyzer.java +++ b/fastbee-report/src/main/java/com/fastbee/report/analyzer/axial_force/AxialForceAnalyzer.java @@ -3,6 +3,7 @@ package com.fastbee.report.analyzer.axial_force; import com.fastbee.common.enums.MonitorCategory; import com.fastbee.common.utils.StringUtils; import com.fastbee.iot.domain.ThingsModel; +import com.fastbee.report.analysis.PrimaryMetricResolver; import com.fastbee.report.analysis.QualityAnalyzer; import com.fastbee.report.analysis.RateCalculator; import com.fastbee.report.analysis.StatisticsCalculator; @@ -11,10 +12,12 @@ import com.fastbee.report.analysis.WarningAnalyzer; import com.fastbee.report.analyzer.ReportAnalyzer; import com.fastbee.report.api.ReportRequest; import com.fastbee.report.chart.ChartGenerator; +import com.fastbee.report.chart.MetricChartBuilder; import com.fastbee.report.core.ReportContext; 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.QualityAnalysis; import com.fastbee.report.model.Statistics; import com.fastbee.report.model.TrendAnalysis; @@ -60,6 +63,12 @@ public class AxialForceAnalyzer implements ReportAnalyzer { @Resource private ChartGenerator chartGenerator; + @Resource + private PrimaryMetricResolver primaryMetricResolver; + + @Resource + private MetricChartBuilder metricChartBuilder; + @Override public boolean supports(MonitorCategory category) { return category == MonitorCategory.AXIAL_FORCE; @@ -68,23 +77,41 @@ public class AxialForceAnalyzer implements ReportAnalyzer { @Override public AnalysisResult analyze(ReportContext context, ReportRequest request) { AnalysisResult result = new AnalysisResult(); + + // 1. 按物模型标识符分组历史数据 Map> byIdentity = context.getMetricDataList().stream() .filter(d -> d.getIdentity() != null) .collect(Collectors.groupingBy(MetricData::getIdentity, LinkedHashMap::new, Collectors.toList())); - if (byIdentity.isEmpty()) { - result.getConclusions().add(new Conclusion(1, "统计期内无有效监测数据,请检查设备上报状态与时间范围。")); + // 2. 获取所有监测物模型(即使没有数据也要显示) + Map allModels = context.getThingsModels().stream() + .filter(m -> m.getIdentifier() != null) + .collect(Collectors.toMap(ThingsModel::getIdentifier, m -> m, (o, n) -> n, LinkedHashMap::new)); + + if (allModels.isEmpty()) { + result.getConclusions().add(new Conclusion(1, "设备未配置监测物模型,请联系管理员配置。")); return result; } - for (Map.Entry> entry : byIdentity.entrySet()) { + // 3. 为每个物模型生成统计数据(包括无数据的物模型) + for (Map.Entry entry : allModels.entrySet()) { String identity = entry.getKey(); - List data = entry.getValue(); - + ThingsModel model = entry.getValue(); + List data = byIdentity.getOrDefault(identity, new ArrayList<>()); + ReportThreshold threshold = context.getThresholds().get(identity); BigDecimal controlValue = threshold == null ? null : threshold.getControlValue(); Statistics statistics = statisticsCalculator.calculate(data); + // 补充物模型信息(确保即使无数据也有 modelName 和 unit) + if (statistics.getModelName() == null || statistics.getModelName().isEmpty()) { + statistics.setModelName(model.getModelName()); + } + if (statistics.getUnit() == null || statistics.getUnit().isEmpty()) { + statistics.setUnit(model.getUnit()); + } + statistics.setIdentifier(identity); + TrendAnalysis trend = trendAnalyzer.analyze(data); QualityAnalysis quality = qualityAnalyzer.analyze(data, context.getTimeWindow(), controlValue); WarningAnalysis warning = warningAnalyzer.analyze( @@ -96,31 +123,14 @@ public class AxialForceAnalyzer implements ReportAnalyzer { result.getWarnings().put(identity, warning); } - String primaryIdentity = resolvePrimaryIdentity(byIdentity, context); + // 统一主指标口径:优先名称/标识符命中关键词的物模型,排除角度/温度/电压等辅助量 + String primaryIdentity = primaryMetricResolver.resolve(context, byIdentity, false, "轴力", "应力", "force", "stress"); + result.setPrimaryIdentifier(primaryIdentity); buildConclusions(result, context, primaryIdentity); buildChart(result, context, primaryIdentity); return result; } - /** - * 主监测指标:优先名称/标识符含"力/stress/压力/拉力",否则取第一个有数据的指标 - */ - private String resolvePrimaryIdentity(Map> byIdentity, ReportContext context) { - String fallback = null; - for (Map.Entry> entry : byIdentity.entrySet()) { - ThingsModel model = findModel(context, entry.getKey()); - String name = model == null ? entry.getKey() : (model.getModelName() == null ? entry.getKey() : model.getModelName()); - String lower = (name + entry.getKey()).toLowerCase(); - if (lower.contains("force") || lower.contains("stress") || lower.contains("轴力") || lower.contains("应力") || lower.contains("压力") || lower.contains("拉力")) { - return entry.getKey(); - } - if (fallback == null && !entry.getValue().isEmpty()) { - fallback = entry.getKey(); - } - } - return fallback == null ? byIdentity.keySet().iterator().next() : fallback; - } - private void buildConclusions(AnalysisResult result, ReportContext context, String primaryIdentity) { Statistics statistics = result.getStatistics().get(primaryIdentity); TrendAnalysis trend = result.getTrends().get(primaryIdentity); @@ -129,6 +139,32 @@ public class AxialForceAnalyzer implements ReportAnalyzer { TimeWindow window = context.getTimeWindow(); String unit = statistics == null ? "" : (statistics.getUnit() == null ? "" : statistics.getUnit()); + // 生成项目概况总结 + String summary = buildSummary(statistics, trend, unit, window); + result.setSummary(summary); + + // 生成专业分析建议 + String advice = buildProfessionalAdvice(statistics, trend, warning, unit); + result.setProfessionalAdvice(advice); + + // 设置安全评估 + if (warning != null) { + result.setSafetyLevel(convertWarningToSafetyLevel(warning.getStatus())); + result.setSafetyDescription(warning.getDescription()); + } + + // 设置控制值 + ReportThreshold threshold = context.getThresholds().get(primaryIdentity); + if (threshold != null && threshold.getControlValue() != null) { + result.setControlValue(threshold.getControlValue()); + result.setControlValueUnit(threshold.getUnit()); + } + + // 组装多测点数据(支持多物模型标识符,如红线/白线/绿线) + List metricPoints = buildMetricPoints(result, context); + result.setMetricPoints(metricPoints); + + // 生成详细结论 List conclusions = new ArrayList<>(); int seq = 1; if (statistics == null || statistics.getCount() == 0) { @@ -151,6 +187,15 @@ public class AxialForceAnalyzer implements ReportAnalyzer { statistics.getMax().toPlainString(), unit, statistics.getMin().toPlainString(), unit, statistics.getAvg().toPlainString(), unit))); + + // 添加控制值对比结论 + if (result.getControlValue() != null && result.getControlValue().compareTo(BigDecimal.ZERO) != 0) { + BigDecimal ratio = statistics.getCurrent().abs().divide(result.getControlValue(), 4, BigDecimal.ROUND_HALF_UP) + .multiply(new BigDecimal("100")); + conclusions.add(new Conclusion(seq++, String.format( + "当前轴力占控制值比例:%.2f%%(控制值:%s%s)。", + ratio.doubleValue(), result.getControlValue().toPlainString(), result.getControlValueUnit()))); + } } if (trend != null && StringUtils.isNotEmpty(trend.getDescription())) { @@ -166,24 +211,137 @@ public class AxialForceAnalyzer implements ReportAnalyzer { result.setConclusions(conclusions); } - private void buildChart(AnalysisResult result, ReportContext context, String primaryIdentity) { - List data = context.getMetricDataList().stream() - .filter(d -> Objects.equals(d.getIdentity(), primaryIdentity)) - .collect(Collectors.toList()); - Statistics statistics = result.getStatistics().get(primaryIdentity); - String metricName = statistics == null ? primaryIdentity : statistics.getModelName(); - String unit = statistics == null ? "" : (statistics.getUnit() == null ? "" : statistics.getUnit()); - byte[] png = chartGenerator.timeSeriesChart("轴力/应力时程曲线", metricName, unit, data); - result.getCharts().put("timeSeriesChart", png); + /** + * 生成项目概况总结 + */ + private String buildSummary(Statistics statistics, TrendAnalysis trend, String unit, TimeWindow window) { + if (statistics == null || statistics.getCount() == 0) { + return "统计期内无有效监测数据。"; + } + + String forceState = (statistics.getCurrent() != null && statistics.getCurrent().compareTo(BigDecimal.ZERO) >= 0) ? "受拉" : "受压"; + String direction = trend == null ? "待分析" : directionText(trend.getDirection()); + BigDecimal rate = rateCalculator.ratePerDay(statistics, + Duration.between(window.getStartTime(), window.getEndTime()).getSeconds()); + + return String.format( + "本期轴力/应力监测数据显示:当前轴力为%s%s,处于%s状态,较初始值变化%s%s,变化速率为%s%s/天,轴力呈%s趋势。" + + "统计期内最大轴力%s%s,最小轴力%s%s,平均轴力%s%s。", + statistics.getCurrent().toPlainString(), unit, + forceState, + statistics.getDelta().toPlainString(), unit, + rate.toPlainString(), unit, + direction, + statistics.getMax().toPlainString(), unit, + statistics.getMin().toPlainString(), unit, + statistics.getAvg().toPlainString(), unit + ); } - private ThingsModel findModel(ReportContext context, String identity) { - if (context.getThingsModels() == null) { - return null; + /** + * 生成专业分析建议 + */ + private String buildProfessionalAdvice(Statistics statistics, TrendAnalysis trend, WarningAnalysis warning, String unit) { + if (statistics == null || statistics.getCount() == 0) { + return "建议检查设备上报状态,确保数据采集正常。"; } - return context.getThingsModels().stream() - .filter(m -> Objects.equals(m.getIdentifier(), identity)) - .findFirst().orElse(null); + + StringBuilder advice = new StringBuilder(); + advice.append("专业分析:"); + + // 基于趋势方向给出建议 + if (trend != null) { + if (TrendAnalysis.DIRECTION_UP.equals(trend.getDirection())) { + advice.append("轴力呈增大趋势,需关注结构受力状态变化。"); + } else if (TrendAnalysis.DIRECTION_DOWN.equals(trend.getDirection())) { + advice.append("轴力呈减小趋势,结构受力状态趋于稳定。"); + } else { + advice.append("轴力变化稳定,结构受力状态正常。"); + } + } + + // 基于受力状态给出建议 + String forceState = (statistics.getCurrent() != null && statistics.getCurrent().compareTo(BigDecimal.ZERO) >= 0) ? "受拉" : "受压"; + if ("受拉".equals(forceState)) { + advice.append("当前结构处于受拉状态,需关注材料抗拉强度。"); + } else { + advice.append("当前结构处于受压状态,需关注材料抗压强度。"); + } + + // 基于预警状态给出建议 + if (warning != null) { + if (WarningAnalysis.STATUS_ALARM.equals(warning.getStatus())) { + advice.append("当前处于报警状态,建议立即采取加固措施,并组织专业人员进行安全评估。"); + } else if (WarningAnalysis.STATUS_WARNING.equals(warning.getStatus())) { + advice.append("当前处于预警状态,建议加密监测频率,准备应急预案。"); + } + } + + return advice.toString(); + } + + /** + * 将预警状态转换为安全等级 + */ + private String convertWarningToSafetyLevel(String warningStatus) { + if (WarningAnalysis.STATUS_ALARM.equals(warningStatus)) { + return "DANGER"; + } else if (WarningAnalysis.STATUS_WARNING.equals(warningStatus)) { + return "WARNING"; + } + return "SAFE"; + } + + /** + * 组装多测点数据(支持多物模型标识符) + */ + private List buildMetricPoints(AnalysisResult result, ReportContext context) { + List points = new ArrayList<>(); + + for (Map.Entry entry : result.getStatistics().entrySet()) { + String identity = entry.getKey(); + Statistics stats = entry.getValue(); + + MetricPointData point = MetricPointData.builder() + .identifier(identity) + .modelName(stats.getModelName()) + .initialValue(stats.getInitial()) + .initialUnit(stats.getUnit()) + .currentValue(stats.getCurrent()) + .currentUnit(stats.getUnit()) + .changeValue(stats.getDelta()) + .changeUnit(stats.getUnit()) + .maxValue(stats.getMax()) + .minValue(stats.getMin()) + .avgValue(stats.getAvg()) + .build(); + + // 设置预警状态 + WarningAnalysis warning = result.getWarnings().get(identity); + if (warning != null) { + point.setWarningStatus(warning.getStatus()); + } + + // 计算变化速率 + TimeWindow window = context.getTimeWindow(); + BigDecimal rate = rateCalculator.ratePerDay(stats, + Duration.between(window.getStartTime(), window.getEndTime()).getSeconds()); + point.setChangeRate(rate); + point.setRateUnit(stats.getUnit() + "/天"); + + points.add(point); + } + + return points; + } + + private void buildChart(AnalysisResult result, ReportContext context, String primaryIdentity) { + // 主指标单图 + 其余指标按单位分组多曲线图(展示所有物模型数据) + Map> byIdentity = context.getMetricDataList().stream() + .filter(d -> d.getIdentity() != null) + .collect(Collectors.groupingBy(MetricData::getIdentity, LinkedHashMap::new, Collectors.toList())); + Map charts = metricChartBuilder.build(result.getStatistics(), primaryIdentity, byIdentity); + result.getCharts().putAll(metricChartBuilder.fillPlaceholders(charts)); } private String statusText(String status) { diff --git a/fastbee-report/src/main/java/com/fastbee/report/analyzer/crack/CrackMeterAnalyzer.java b/fastbee-report/src/main/java/com/fastbee/report/analyzer/crack/CrackMeterAnalyzer.java index 62cc210b..55b32b5f 100644 --- a/fastbee-report/src/main/java/com/fastbee/report/analyzer/crack/CrackMeterAnalyzer.java +++ b/fastbee-report/src/main/java/com/fastbee/report/analyzer/crack/CrackMeterAnalyzer.java @@ -3,6 +3,7 @@ package com.fastbee.report.analyzer.crack; import com.fastbee.common.enums.MonitorCategory; import com.fastbee.common.utils.StringUtils; import com.fastbee.iot.domain.ThingsModel; +import com.fastbee.report.analysis.PrimaryMetricResolver; import com.fastbee.report.analysis.QualityAnalyzer; import com.fastbee.report.analysis.RateCalculator; import com.fastbee.report.analysis.StatisticsCalculator; @@ -11,10 +12,12 @@ import com.fastbee.report.analysis.WarningAnalyzer; import com.fastbee.report.analyzer.ReportAnalyzer; import com.fastbee.report.api.ReportRequest; import com.fastbee.report.chart.ChartGenerator; +import com.fastbee.report.chart.MetricChartBuilder; import com.fastbee.report.core.ReportContext; 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.QualityAnalysis; import com.fastbee.report.model.Statistics; import com.fastbee.report.model.TrendAnalysis; @@ -34,7 +37,7 @@ import java.util.Objects; import java.util.stream.Collectors; /** - * 裂缝计专项分析器(V1:当前/初始宽度、变化量、速率、趋势、预警、时程图) + * 裂缝计专项分析器(当前/初始宽度、变化量、速率、趋势、预警、时程图) * * @author FastBee * @date 2026-08-16 @@ -60,6 +63,12 @@ public class CrackMeterAnalyzer implements ReportAnalyzer { @Resource private ChartGenerator chartGenerator; + @Resource + private PrimaryMetricResolver primaryMetricResolver; + + @Resource + private MetricChartBuilder metricChartBuilder; + @Override public boolean supports(MonitorCategory category) { return category == MonitorCategory.CRACK_METER; @@ -68,22 +77,41 @@ public class CrackMeterAnalyzer implements ReportAnalyzer { @Override public AnalysisResult analyze(ReportContext context, ReportRequest request) { AnalysisResult result = new AnalysisResult(); + + // 1. 按物模型标识符分组历史数据 Map> byIdentity = context.getMetricDataList().stream() .filter(d -> d.getIdentity() != null) .collect(Collectors.groupingBy(MetricData::getIdentity, LinkedHashMap::new, Collectors.toList())); - if (byIdentity.isEmpty()) { - result.getConclusions().add(new Conclusion(1, "统计期内无有效监测数据,请检查设备上报状态与时间范围。")); + // 2. 获取所有监测物模型(即使没有数据也要显示) + Map allModels = context.getThingsModels().stream() + .filter(m -> m.getIdentifier() != null) + .collect(Collectors.toMap(ThingsModel::getIdentifier, m -> m, (o, n) -> n, LinkedHashMap::new)); + + if (allModels.isEmpty()) { + result.getConclusions().add(new Conclusion(1, "设备未配置监测物模型,请联系管理员配置。")); return result; } - for (Map.Entry> entry : byIdentity.entrySet()) { + // 3. 为每个物模型生成统计数据(包括无数据的物模型) + for (Map.Entry entry : allModels.entrySet()) { String identity = entry.getKey(); - List data = entry.getValue(); + ThingsModel model = entry.getValue(); + List data = byIdentity.getOrDefault(identity, new ArrayList<>()); + ReportThreshold threshold = context.getThresholds().get(identity); BigDecimal controlValue = threshold == null ? null : threshold.getControlValue(); Statistics statistics = statisticsCalculator.calculate(data); + // 补充物模型信息(确保即使无数据也有 modelName 和 unit) + if (statistics.getModelName() == null || statistics.getModelName().isEmpty()) { + statistics.setModelName(model.getModelName()); + } + if (statistics.getUnit() == null || statistics.getUnit().isEmpty()) { + statistics.setUnit(model.getUnit()); + } + statistics.setIdentifier(identity); + TrendAnalysis trend = trendAnalyzer.analyze(data); QualityAnalysis quality = qualityAnalyzer.analyze(data, context.getTimeWindow(), controlValue); WarningAnalysis warning = warningAnalyzer.analyze( @@ -95,31 +123,14 @@ public class CrackMeterAnalyzer implements ReportAnalyzer { result.getWarnings().put(identity, warning); } - String primaryIdentity = resolvePrimaryIdentity(byIdentity, context); + // 统一主指标口径:优先名称/标识符命中关键词的物模型,排除角度/温度/电压等辅助量 + String primaryIdentity = primaryMetricResolver.resolve(context, byIdentity, false, "裂缝宽度", "缝宽", "width"); + result.setPrimaryIdentifier(primaryIdentity); buildConclusions(result, context, primaryIdentity); buildChart(result, context, primaryIdentity); return result; } - /** - * 主监测指标:优先名称/标识符含“宽度/缝宽/width”,否则取第一个有数据的指标 - */ - private String resolvePrimaryIdentity(Map> byIdentity, ReportContext context) { - String fallback = null; - for (Map.Entry> entry : byIdentity.entrySet()) { - ThingsModel model = findModel(context, entry.getKey()); - String name = model == null ? entry.getKey() : (model.getModelName() == null ? entry.getKey() : model.getModelName()); - String lower = (name + entry.getKey()).toLowerCase(); - if (lower.contains("width") || lower.contains("缝宽") || lower.contains("宽度")) { - return entry.getKey(); - } - if (fallback == null && !entry.getValue().isEmpty()) { - fallback = entry.getKey(); - } - } - return fallback == null ? byIdentity.keySet().iterator().next() : fallback; - } - private void buildConclusions(AnalysisResult result, ReportContext context, String primaryIdentity) { Statistics statistics = result.getStatistics().get(primaryIdentity); TrendAnalysis trend = result.getTrends().get(primaryIdentity); @@ -128,6 +139,32 @@ public class CrackMeterAnalyzer implements ReportAnalyzer { TimeWindow window = context.getTimeWindow(); String unit = statistics == null ? "" : (statistics.getUnit() == null ? "" : statistics.getUnit()); + // 生成项目概况总结 + String summary = buildSummary(statistics, trend, unit, window); + result.setSummary(summary); + + // 生成专业分析建议 + String advice = buildProfessionalAdvice(statistics, trend, warning, unit); + result.setProfessionalAdvice(advice); + + // 设置安全评估 + if (warning != null) { + result.setSafetyLevel(convertWarningToSafetyLevel(warning.getStatus())); + result.setSafetyDescription(warning.getDescription()); + } + + // 设置控制值 + ReportThreshold threshold = context.getThresholds().get(primaryIdentity); + if (threshold != null && threshold.getControlValue() != null) { + result.setControlValue(threshold.getControlValue()); + result.setControlValueUnit(threshold.getUnit()); + } + + // 组装多测点数据 + List metricPoints = buildMetricPoints(result, context); + result.setMetricPoints(metricPoints); + + // 生成详细结论 List conclusions = new ArrayList<>(); int seq = 1; if (statistics == null || statistics.getCount() == 0) { @@ -149,6 +186,15 @@ public class CrackMeterAnalyzer implements ReportAnalyzer { statistics.getMax().toPlainString(), unit, statistics.getMin().toPlainString(), unit, statistics.getAvg().toPlainString(), unit))); + + // 添加控制值对比结论 + if (result.getControlValue() != null && result.getControlValue().compareTo(BigDecimal.ZERO) != 0) { + BigDecimal ratio = statistics.getCurrent().divide(result.getControlValue(), 4, BigDecimal.ROUND_HALF_UP) + .multiply(new BigDecimal("100")); + conclusions.add(new Conclusion(seq++, String.format( + "当前裂缝宽度占控制值比例:%.2f%%(控制值:%s%s)。", + ratio.doubleValue(), result.getControlValue().toPlainString(), result.getControlValueUnit()))); + } } if (trend != null && StringUtils.isNotEmpty(trend.getDescription())) { @@ -164,26 +210,133 @@ public class CrackMeterAnalyzer implements ReportAnalyzer { result.setConclusions(conclusions); } - private void buildChart(AnalysisResult result, ReportContext context, String primaryIdentity) { - List data = context.getMetricDataList().stream() - .filter(d -> Objects.equals(d.getIdentity(), primaryIdentity)) - .collect(Collectors.toList()); - Statistics statistics = result.getStatistics().get(primaryIdentity); - String metricName = statistics == null ? primaryIdentity : statistics.getModelName(); - String unit = statistics == null ? "" : (statistics.getUnit() == null ? "" : statistics.getUnit()); - byte[] png = chartGenerator.timeSeriesChart("裂缝宽度时程曲线", metricName, unit, data); - // 使用统一键名 timeSeriesChart,兼容所有模板 - result.getCharts().put("timeSeriesChart", png); - result.getCharts().put("crackTrend", png); // 兼容旧版模板 + /** + * 生成项目概况总结 + */ + private String buildSummary(Statistics statistics, TrendAnalysis trend, String unit, TimeWindow window) { + if (statistics == null || statistics.getCount() == 0) { + return "统计期内无有效监测数据。"; + } + + String direction = trend == null ? "待分析" : directionText(trend.getDirection()); + BigDecimal rate = rateCalculator.ratePerDay(statistics, + Duration.between(window.getStartTime(), window.getEndTime()).getSeconds()); + + return String.format( + "本期裂缝宽度监测数据显示:当前裂缝宽度为%s%s,较初始值变化%s%s,变化速率为%s%s/天,裂缝呈%s趋势。" + + "统计期内最大裂缝宽度%s%s,最小裂缝宽度%s%s,平均裂缝宽度%s%s。", + statistics.getCurrent().toPlainString(), unit, + statistics.getDelta().toPlainString(), unit, + rate.toPlainString(), unit, + direction, + statistics.getMax().toPlainString(), unit, + statistics.getMin().toPlainString(), unit, + statistics.getAvg().toPlainString(), unit + ); } - private ThingsModel findModel(ReportContext context, String identity) { - if (context.getThingsModels() == null) { - return null; + /** + * 生成专业分析建议 + */ + private String buildProfessionalAdvice(Statistics statistics, TrendAnalysis trend, WarningAnalysis warning, String unit) { + if (statistics == null || statistics.getCount() == 0) { + return "建议检查设备上报状态,确保数据采集正常。"; } - return context.getThingsModels().stream() - .filter(m -> Objects.equals(m.getIdentifier(), identity)) - .findFirst().orElse(null); + + StringBuilder advice = new StringBuilder(); + advice.append("专业分析:"); + + // 基于趋势方向给出建议 + if (trend != null) { + if (TrendAnalysis.DIRECTION_UP.equals(trend.getDirection())) { + advice.append("裂缝宽度呈增大趋势,建议加强监测频率,关注裂缝发展速率。"); + } else if (TrendAnalysis.DIRECTION_DOWN.equals(trend.getDirection())) { + advice.append("裂缝宽度呈减小趋势,结构状态趋于稳定。"); + } else { + advice.append("裂缝宽度变化稳定,结构状态正常。"); + } + } + + // 基于预警状态给出建议 + if (warning != null) { + if (WarningAnalysis.STATUS_ALARM.equals(warning.getStatus())) { + advice.append("当前处于报警状态,建议立即采取加固措施,并组织专业人员进行安全评估。"); + } else if (WarningAnalysis.STATUS_WARNING.equals(warning.getStatus())) { + advice.append("当前处于预警状态,建议加密监测频率,准备应急预案。"); + } + } + + // 基于变化量给出建议 + BigDecimal absDelta = statistics.getDelta().abs(); + if (absDelta.compareTo(new BigDecimal("1")) > 0) { + advice.append("裂缝变化量较大(>1mm),建议进行详细的结构安全评估。"); + } + + return advice.toString(); + } + + /** + * 将预警状态转换为安全等级 + */ + private String convertWarningToSafetyLevel(String warningStatus) { + if (WarningAnalysis.STATUS_ALARM.equals(warningStatus)) { + return "DANGER"; + } else if (WarningAnalysis.STATUS_WARNING.equals(warningStatus)) { + return "WARNING"; + } + return "SAFE"; + } + + /** + * 组装多测点数据(支持多物模型标识符) + */ + private List buildMetricPoints(AnalysisResult result, ReportContext context) { + List points = new ArrayList<>(); + + for (Map.Entry entry : result.getStatistics().entrySet()) { + String identity = entry.getKey(); + Statistics stats = entry.getValue(); + + MetricPointData point = MetricPointData.builder() + .identifier(identity) + .modelName(stats.getModelName()) + .initialValue(stats.getInitial()) + .initialUnit(stats.getUnit()) + .currentValue(stats.getCurrent()) + .currentUnit(stats.getUnit()) + .changeValue(stats.getDelta()) + .changeUnit(stats.getUnit()) + .maxValue(stats.getMax()) + .minValue(stats.getMin()) + .avgValue(stats.getAvg()) + .build(); + + // 设置预警状态 + WarningAnalysis warning = result.getWarnings().get(identity); + if (warning != null) { + point.setWarningStatus(warning.getStatus()); + } + + // 计算变化速率 + TimeWindow window = context.getTimeWindow(); + BigDecimal rate = rateCalculator.ratePerDay(stats, + Duration.between(window.getStartTime(), window.getEndTime()).getSeconds()); + point.setChangeRate(rate); + point.setRateUnit(stats.getUnit() + "/天"); + + points.add(point); + } + + return points; + } + + private void buildChart(AnalysisResult result, ReportContext context, String primaryIdentity) { + // 主指标单图 + 其余指标按单位分组多曲线图(展示所有物模型数据) + Map> byIdentity = context.getMetricDataList().stream() + .filter(d -> d.getIdentity() != null) + .collect(Collectors.groupingBy(MetricData::getIdentity, LinkedHashMap::new, Collectors.toList())); + Map charts = metricChartBuilder.build(result.getStatistics(), primaryIdentity, byIdentity); + result.getCharts().putAll(metricChartBuilder.fillPlaceholders(charts)); } private String statusText(String status) { diff --git a/fastbee-report/src/main/java/com/fastbee/report/analyzer/deep_displacement/DeepDisplacementAnalyzer.java b/fastbee-report/src/main/java/com/fastbee/report/analyzer/deep_displacement/DeepDisplacementAnalyzer.java index 6ceb54a1..509c5bb5 100644 --- a/fastbee-report/src/main/java/com/fastbee/report/analyzer/deep_displacement/DeepDisplacementAnalyzer.java +++ b/fastbee-report/src/main/java/com/fastbee/report/analyzer/deep_displacement/DeepDisplacementAnalyzer.java @@ -3,6 +3,7 @@ package com.fastbee.report.analyzer.deep_displacement; import com.fastbee.common.enums.MonitorCategory; import com.fastbee.common.utils.StringUtils; import com.fastbee.iot.domain.ThingsModel; +import com.fastbee.report.analysis.PrimaryMetricResolver; import com.fastbee.report.analysis.QualityAnalyzer; import com.fastbee.report.analysis.RateCalculator; import com.fastbee.report.analysis.StatisticsCalculator; @@ -11,10 +12,12 @@ import com.fastbee.report.analysis.WarningAnalyzer; import com.fastbee.report.analyzer.ReportAnalyzer; import com.fastbee.report.api.ReportRequest; import com.fastbee.report.chart.ChartGenerator; +import com.fastbee.report.chart.MetricChartBuilder; import com.fastbee.report.core.ReportContext; 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.QualityAnalysis; import com.fastbee.report.model.Statistics; import com.fastbee.report.model.TrendAnalysis; @@ -60,6 +63,12 @@ public class DeepDisplacementAnalyzer implements ReportAnalyzer { @Resource private ChartGenerator chartGenerator; + @Resource + private PrimaryMetricResolver primaryMetricResolver; + + @Resource + private MetricChartBuilder metricChartBuilder; + @Override public boolean supports(MonitorCategory category) { return category == MonitorCategory.DEEP_DISPLACEMENT; @@ -68,22 +77,41 @@ public class DeepDisplacementAnalyzer implements ReportAnalyzer { @Override public AnalysisResult analyze(ReportContext context, ReportRequest request) { AnalysisResult result = new AnalysisResult(); + + // 1. 按物模型标识符分组历史数据 Map> byIdentity = context.getMetricDataList().stream() .filter(d -> d.getIdentity() != null) .collect(Collectors.groupingBy(MetricData::getIdentity, LinkedHashMap::new, Collectors.toList())); - if (byIdentity.isEmpty()) { - result.getConclusions().add(new Conclusion(1, "统计期内无有效监测数据,请检查设备上报状态与时间范围。")); + // 2. 获取所有监测物模型(即使没有数据也要显示) + Map allModels = context.getThingsModels().stream() + .filter(m -> m.getIdentifier() != null) + .collect(Collectors.toMap(ThingsModel::getIdentifier, m -> m, (o, n) -> n, LinkedHashMap::new)); + + if (allModels.isEmpty()) { + result.getConclusions().add(new Conclusion(1, "设备未配置监测物模型,请联系管理员配置。")); return result; } - for (Map.Entry> entry : byIdentity.entrySet()) { + // 3. 为每个物模型生成统计数据(包括无数据的物模型) + for (Map.Entry entry : allModels.entrySet()) { String identity = entry.getKey(); - List data = entry.getValue(); + ThingsModel model = entry.getValue(); + List data = byIdentity.getOrDefault(identity, new ArrayList<>()); + ReportThreshold threshold = context.getThresholds().get(identity); BigDecimal controlValue = threshold == null ? null : threshold.getControlValue(); Statistics statistics = statisticsCalculator.calculate(data); + // 补充物模型信息(确保即使无数据也有 modelName 和 unit) + if (statistics.getModelName() == null || statistics.getModelName().isEmpty()) { + statistics.setModelName(model.getModelName()); + } + if (statistics.getUnit() == null || statistics.getUnit().isEmpty()) { + statistics.setUnit(model.getUnit()); + } + statistics.setIdentifier(identity); + TrendAnalysis trend = trendAnalyzer.analyze(data); QualityAnalysis quality = qualityAnalyzer.analyze(data, context.getTimeWindow(), controlValue); WarningAnalysis warning = warningAnalyzer.analyze( @@ -95,7 +123,11 @@ public class DeepDisplacementAnalyzer implements ReportAnalyzer { result.getWarnings().put(identity, warning); } - String primaryIdentity = resolvePrimaryIdentity(byIdentity, context); + // 统一主指标口径:按物模型顺序、排除角度/温度/电压等辅助量 + String primaryIdentity = primaryMetricResolver.resolve(context, byIdentity, + "深部位移", "位移", "displacement", "沉降", "settlement"); + result.setPrimaryIdentifier(primaryIdentity); + buildConclusions(result, context, primaryIdentity); buildChart(result, context, primaryIdentity); return result; @@ -103,21 +135,12 @@ public class DeepDisplacementAnalyzer implements ReportAnalyzer { /** * 主监测指标:优先名称/标识符含"位移/displacement/x/y/z",否则取第一个有数据的指标 + * + * @deprecated 由 {@link PrimaryMetricResolver} 统一解析,保留仅为兼容历史调用 */ + @Deprecated private String resolvePrimaryIdentity(Map> byIdentity, ReportContext context) { - String fallback = null; - for (Map.Entry> entry : byIdentity.entrySet()) { - ThingsModel model = findModel(context, entry.getKey()); - String name = model == null ? entry.getKey() : (model.getModelName() == null ? entry.getKey() : model.getModelName()); - String lower = (name + entry.getKey()).toLowerCase(); - if (lower.contains("displacement") || lower.contains("位移") || lower.contains("_x") || lower.contains("_y") || lower.contains("_z")) { - return entry.getKey(); - } - if (fallback == null && !entry.getValue().isEmpty()) { - fallback = entry.getKey(); - } - } - return fallback == null ? byIdentity.keySet().iterator().next() : fallback; + return primaryMetricResolver.resolve(context, byIdentity, "位移", "displacement"); } private void buildConclusions(AnalysisResult result, ReportContext context, String primaryIdentity) { @@ -128,13 +151,39 @@ public class DeepDisplacementAnalyzer implements ReportAnalyzer { TimeWindow window = context.getTimeWindow(); String unit = statistics == null ? "" : (statistics.getUnit() == null ? "" : statistics.getUnit()); + // 生成项目概况总结 + String summary = buildSummary(statistics, trend, unit, window); + result.setSummary(summary); + + // 生成专业分析建议 + String advice = buildProfessionalAdvice(statistics, trend, warning, unit); + result.setProfessionalAdvice(advice); + + // 设置安全评估 + if (warning != null) { + result.setSafetyLevel(convertWarningToSafetyLevel(warning.getStatus())); + result.setSafetyDescription(warning.getDescription()); + } + + // 设置控制值 + ReportThreshold threshold = context.getThresholds().get(primaryIdentity); + if (threshold != null && threshold.getControlValue() != null) { + result.setControlValue(threshold.getControlValue()); + result.setControlValueUnit(threshold.getUnit()); + } + + // 组装多测点数据(支持多物模型标识符,如不同深度的位移测点) + List metricPoints = buildMetricPoints(result, context); + result.setMetricPoints(metricPoints); + + // 生成详细结论 List conclusions = new ArrayList<>(); int seq = 1; if (statistics == null || statistics.getCount() == 0) { conclusions.add(new Conclusion(seq++, "统计期内无有效监测数据,请检查设备上报状态与时间范围。")); } else { conclusions.add(new Conclusion(seq++, String.format( - "统计期内深部水平位移由 %s%s 变化至 %s%s,累计位移量 %s%s,位移方向为%s。", + "统计期内深部位移由 %s%s 变化至 %s%s,累计位移量 %s%s,位移方向为%s。", statistics.getInitial().toPlainString(), unit, statistics.getCurrent().toPlainString(), unit, statistics.getDelta().toPlainString(), unit, @@ -148,6 +197,20 @@ public class DeepDisplacementAnalyzer implements ReportAnalyzer { statistics.getMax().toPlainString(), unit, statistics.getMin().toPlainString(), unit, statistics.getAvg().toPlainString(), unit))); + + // 添加控制值对比结论 + if (result.getControlValue() != null && result.getControlValue().compareTo(BigDecimal.ZERO) != 0) { + BigDecimal ratio = statistics.getDelta().abs().divide(result.getControlValue(), 4, BigDecimal.ROUND_HALF_UP) + .multiply(new BigDecimal("100")); + conclusions.add(new Conclusion(seq++, String.format( + "累计位移占控制值比例:%.2f%%(控制值:%s%s),需关注潜在滑动面发展趋势。", + ratio.doubleValue(), result.getControlValue().toPlainString(), result.getControlValueUnit()))); + } + + // 位移速率评估 + if (rate.compareTo(new BigDecimal("2")) > 0) { + conclusions.add(new Conclusion(seq++, "位移速率较大,需加密监测频次,必要时启动预警机制。")); + } } if (trend != null && StringUtils.isNotEmpty(trend.getDescription())) { @@ -163,24 +226,133 @@ public class DeepDisplacementAnalyzer implements ReportAnalyzer { result.setConclusions(conclusions); } - private void buildChart(AnalysisResult result, ReportContext context, String primaryIdentity) { - List data = context.getMetricDataList().stream() - .filter(d -> Objects.equals(d.getIdentity(), primaryIdentity)) - .collect(Collectors.toList()); - Statistics statistics = result.getStatistics().get(primaryIdentity); - String metricName = statistics == null ? primaryIdentity : statistics.getModelName(); - String unit = statistics == null ? "" : (statistics.getUnit() == null ? "" : statistics.getUnit()); - byte[] png = chartGenerator.timeSeriesChart("深部位移时程曲线", metricName, unit, data); - result.getCharts().put("timeSeriesChart", png); + /** + * 生成项目概况总结 + */ + private String buildSummary(Statistics statistics, TrendAnalysis trend, String unit, TimeWindow window) { + if (statistics == null || statistics.getCount() == 0) { + return "统计期内无有效监测数据。"; + } + + String direction = trend == null ? "待分析" : directionText(trend.getDirection()); + BigDecimal rate = rateCalculator.ratePerDay(statistics, + Duration.between(window.getStartTime(), window.getEndTime()).getSeconds()); + + return String.format( + "本期深部位移监测数据显示:当前位移为%s%s,较初始值累计变化%s%s,位移速率为%s%s/天,位移方向呈%s趋势。" + + "统计期内最大位移%s%s,最小位移%s%s,平均位移%s%s。土体内部变形监测正常。", + statistics.getCurrent().toPlainString(), unit, + statistics.getDelta().toPlainString(), unit, + rate.toPlainString(), unit, + direction, + statistics.getMax().toPlainString(), unit, + statistics.getMin().toPlainString(), unit, + statistics.getAvg().toPlainString(), unit + ); } - private ThingsModel findModel(ReportContext context, String identity) { - if (context.getThingsModels() == null) { - return null; + /** + * 生成专业分析建议 + */ + private String buildProfessionalAdvice(Statistics statistics, TrendAnalysis trend, WarningAnalysis warning, String unit) { + if (statistics == null || statistics.getCount() == 0) { + return "建议检查设备上报状态,确保数据采集正常。"; } - return context.getThingsModels().stream() - .filter(m -> Objects.equals(m.getIdentifier(), identity)) - .findFirst().orElse(null); + + StringBuilder advice = new StringBuilder(); + advice.append("专业分析:"); + + // 基于趋势方向给出建议 + if (trend != null) { + if (TrendAnalysis.DIRECTION_UP.equals(trend.getDirection())) { + advice.append("深部位移呈增大趋势,可能存在潜在滑动面发展,需结合地质资料分析滑移面位置与形态。"); + } else if (TrendAnalysis.DIRECTION_DOWN.equals(trend.getDirection())) { + advice.append("深部位移呈减小趋势,土体变形趋于稳定。"); + } else { + advice.append("深部位移变化稳定,土体内部变形正常。"); + } + } + + // 基于位移量级给出建议 + if (statistics.getDelta().abs().compareTo(new BigDecimal("10")) > 0) { + advice.append("累计位移量较大,建议加密监测频率,结合测斜孔剖面图分析位移沿深度分布规律。"); + } + + // 基于预警状态给出建议 + if (warning != null) { + if (WarningAnalysis.STATUS_ALARM.equals(warning.getStatus())) { + advice.append("当前处于报警状态,建议立即启动应急预案,组织专业人员进行地质灾害评估,必要时采取加固措施。"); + } else if (WarningAnalysis.STATUS_WARNING.equals(warning.getStatus())) { + advice.append("当前处于预警状态,建议加强巡视检查,准备应急物资,制定应急预案。"); + } + } + + return advice.toString(); + } + + /** + * 将预警状态转换为安全等级 + */ + private String convertWarningToSafetyLevel(String warningStatus) { + if (WarningAnalysis.STATUS_ALARM.equals(warningStatus)) { + return "DANGER"; + } else if (WarningAnalysis.STATUS_WARNING.equals(warningStatus)) { + return "WARNING"; + } + return "SAFE"; + } + + /** + * 组装多测点数据(支持多物模型标识符) + */ + private List buildMetricPoints(AnalysisResult result, ReportContext context) { + List points = new ArrayList<>(); + + for (Map.Entry entry : result.getStatistics().entrySet()) { + String identity = entry.getKey(); + Statistics stats = entry.getValue(); + + MetricPointData point = MetricPointData.builder() + .identifier(identity) + .modelName(stats.getModelName()) + .initialValue(stats.getInitial()) + .initialUnit(stats.getUnit()) + .currentValue(stats.getCurrent()) + .currentUnit(stats.getUnit()) + .changeValue(stats.getDelta()) + .changeUnit(stats.getUnit()) + .maxValue(stats.getMax()) + .minValue(stats.getMin()) + .avgValue(stats.getAvg()) + .build(); + + // 设置预警状态 + WarningAnalysis warning = result.getWarnings().get(identity); + if (warning != null) { + point.setWarningStatus(warning.getStatus()); + } + + // 计算变化速率 + TimeWindow window = context.getTimeWindow(); + BigDecimal rate = rateCalculator.ratePerDay(stats, + Duration.between(window.getStartTime(), window.getEndTime()).getSeconds()); + point.setChangeRate(rate); + point.setRateUnit(stats.getUnit() + "/天"); + + points.add(point); + } + + return points; + } + + private void buildChart(AnalysisResult result, ReportContext context, String primaryIdentity) { + // 主指标单图 + 其余指标按单位分组多曲线图(展示所有物模型数据) + Map> byIdentity = context.getMetricDataList().stream() + .filter(d -> d.getIdentity() != null) + .collect(Collectors.groupingBy(MetricData::getIdentity, LinkedHashMap::new, Collectors.toList())); + Map charts = metricChartBuilder.build(result.getStatistics(), primaryIdentity, byIdentity); + // 补齐模板预留图表位(无数据时用空白图),避免模板标签缺失导致渲染异常 + result.getCharts().putAll(metricChartBuilder.fillPlaceholders(charts)); } private String statusText(String status) { diff --git a/fastbee-report/src/main/java/com/fastbee/report/analyzer/inclinometer/InclinometerAnalyzer.java b/fastbee-report/src/main/java/com/fastbee/report/analyzer/inclinometer/InclinometerAnalyzer.java index be080c1f..add00a6b 100644 --- a/fastbee-report/src/main/java/com/fastbee/report/analyzer/inclinometer/InclinometerAnalyzer.java +++ b/fastbee-report/src/main/java/com/fastbee/report/analyzer/inclinometer/InclinometerAnalyzer.java @@ -3,6 +3,7 @@ package com.fastbee.report.analyzer.inclinometer; import com.fastbee.common.enums.MonitorCategory; import com.fastbee.common.utils.StringUtils; import com.fastbee.iot.domain.ThingsModel; +import com.fastbee.report.analysis.PrimaryMetricResolver; import com.fastbee.report.analysis.QualityAnalyzer; import com.fastbee.report.analysis.RateCalculator; import com.fastbee.report.analysis.StatisticsCalculator; @@ -11,10 +12,12 @@ import com.fastbee.report.analysis.WarningAnalyzer; import com.fastbee.report.analyzer.ReportAnalyzer; import com.fastbee.report.api.ReportRequest; import com.fastbee.report.chart.ChartGenerator; +import com.fastbee.report.chart.MetricChartBuilder; import com.fastbee.report.core.ReportContext; 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.QualityAnalysis; import com.fastbee.report.model.Statistics; import com.fastbee.report.model.TrendAnalysis; @@ -60,6 +63,12 @@ public class InclinometerAnalyzer implements ReportAnalyzer { @Resource private ChartGenerator chartGenerator; + @Resource + private PrimaryMetricResolver primaryMetricResolver; + + @Resource + private MetricChartBuilder metricChartBuilder; + @Override public boolean supports(MonitorCategory category) { return category == MonitorCategory.INCLINOMETER; @@ -68,23 +77,41 @@ public class InclinometerAnalyzer implements ReportAnalyzer { @Override public AnalysisResult analyze(ReportContext context, ReportRequest request) { AnalysisResult result = new AnalysisResult(); + + // 1. 按物模型标识符分组历史数据 Map> byIdentity = context.getMetricDataList().stream() .filter(d -> d.getIdentity() != null) .collect(Collectors.groupingBy(MetricData::getIdentity, LinkedHashMap::new, Collectors.toList())); - if (byIdentity.isEmpty()) { - result.getConclusions().add(new Conclusion(1, "统计期内无有效监测数据,请检查设备上报状态与时间范围。")); + // 2. 获取所有监测物模型(即使没有数据也要显示) + Map allModels = context.getThingsModels().stream() + .filter(m -> m.getIdentifier() != null) + .collect(Collectors.toMap(ThingsModel::getIdentifier, m -> m, (o, n) -> n, LinkedHashMap::new)); + + if (allModels.isEmpty()) { + result.getConclusions().add(new Conclusion(1, "设备未配置监测物模型,请联系管理员配置。")); return result; } - for (Map.Entry> entry : byIdentity.entrySet()) { + // 3. 为每个物模型生成统计数据(包括无数据的物模型) + for (Map.Entry entry : allModels.entrySet()) { String identity = entry.getKey(); - List data = entry.getValue(); - + ThingsModel model = entry.getValue(); + List data = byIdentity.getOrDefault(identity, new ArrayList<>()); + ReportThreshold threshold = context.getThresholds().get(identity); BigDecimal controlValue = threshold == null ? null : threshold.getControlValue(); Statistics statistics = statisticsCalculator.calculate(data); + // 补充物模型信息(确保即使无数据也有 modelName 和 unit) + if (statistics.getModelName() == null || statistics.getModelName().isEmpty()) { + statistics.setModelName(model.getModelName()); + } + if (statistics.getUnit() == null || statistics.getUnit().isEmpty()) { + statistics.setUnit(model.getUnit()); + } + statistics.setIdentifier(identity); + TrendAnalysis trend = trendAnalyzer.analyze(data); QualityAnalysis quality = qualityAnalyzer.analyze(data, context.getTimeWindow(), controlValue); WarningAnalysis warning = warningAnalyzer.analyze( @@ -96,31 +123,14 @@ public class InclinometerAnalyzer implements ReportAnalyzer { result.getWarnings().put(identity, warning); } - String primaryIdentity = resolvePrimaryIdentity(byIdentity, context); + // 统一主指标口径:优先名称/标识符命中关键词的物模型,排除角度/温度/电压等辅助量 + String primaryIdentity = primaryMetricResolver.resolve(context, byIdentity, true, "倾角", "角度", "angle", "inclin"); + result.setPrimaryIdentifier(primaryIdentity); buildConclusions(result, context, primaryIdentity); buildChart(result, context, primaryIdentity); return result; } - /** - * 主监测指标:优先名称/标识符含"倾角/incline/angle/°",否则取第一个有数据的指标 - */ - private String resolvePrimaryIdentity(Map> byIdentity, ReportContext context) { - String fallback = null; - for (Map.Entry> entry : byIdentity.entrySet()) { - ThingsModel model = findModel(context, entry.getKey()); - String name = model == null ? entry.getKey() : (model.getModelName() == null ? entry.getKey() : model.getModelName()); - String lower = (name + entry.getKey()).toLowerCase(); - if (lower.contains("incline") || lower.contains("angle") || lower.contains("倾角") || lower.contains("角度")) { - return entry.getKey(); - } - if (fallback == null && !entry.getValue().isEmpty()) { - fallback = entry.getKey(); - } - } - return fallback == null ? byIdentity.keySet().iterator().next() : fallback; - } - private void buildConclusions(AnalysisResult result, ReportContext context, String primaryIdentity) { Statistics statistics = result.getStatistics().get(primaryIdentity); TrendAnalysis trend = result.getTrends().get(primaryIdentity); @@ -129,6 +139,32 @@ public class InclinometerAnalyzer implements ReportAnalyzer { TimeWindow window = context.getTimeWindow(); String unit = statistics == null ? "" : (statistics.getUnit() == null ? "" : statistics.getUnit()); + // 生成项目概况总结 + String summary = buildSummary(statistics, trend, unit, window); + result.setSummary(summary); + + // 生成专业分析建议 + String advice = buildProfessionalAdvice(statistics, trend, warning, unit); + result.setProfessionalAdvice(advice); + + // 设置安全评估 + if (warning != null) { + result.setSafetyLevel(convertWarningToSafetyLevel(warning.getStatus())); + result.setSafetyDescription(warning.getDescription()); + } + + // 设置控制值 + ReportThreshold threshold = context.getThresholds().get(primaryIdentity); + if (threshold != null && threshold.getControlValue() != null) { + result.setControlValue(threshold.getControlValue()); + result.setControlValueUnit(threshold.getUnit()); + } + + // 组装多测点数据(支持多物模型标识符,如X轴/Y轴倾角) + List metricPoints = buildMetricPoints(result, context); + result.setMetricPoints(metricPoints); + + // 生成详细结论 List conclusions = new ArrayList<>(); int seq = 1; if (statistics == null || statistics.getCount() == 0) { @@ -150,6 +186,20 @@ public class InclinometerAnalyzer implements ReportAnalyzer { statistics.getMax().toPlainString(), unit, statistics.getMin().toPlainString(), unit, statistics.getAvg().toPlainString(), unit))); + + // 添加控制值对比结论 + if (result.getControlValue() != null && result.getControlValue().compareTo(BigDecimal.ZERO) != 0) { + BigDecimal ratio = statistics.getCurrent().abs().divide(result.getControlValue(), 4, BigDecimal.ROUND_HALF_UP) + .multiply(new BigDecimal("100")); + conclusions.add(new Conclusion(seq++, String.format( + "当前倾角占控制值比例:%.2f%%(控制值:%s%s),需关注结构倾斜发展趋势。", + ratio.doubleValue(), result.getControlValue().toPlainString(), result.getControlValueUnit()))); + } + + // 倾角安全评估 + if (statistics.getCurrent().abs().compareTo(new BigDecimal("5")) > 0) { + conclusions.add(new Conclusion(seq++, "倾角较大,需评估结构倾斜风险,必要时采取纠偏措施。")); + } } if (trend != null && StringUtils.isNotEmpty(trend.getDescription())) { @@ -165,24 +215,132 @@ public class InclinometerAnalyzer implements ReportAnalyzer { result.setConclusions(conclusions); } - private void buildChart(AnalysisResult result, ReportContext context, String primaryIdentity) { - List data = context.getMetricDataList().stream() - .filter(d -> Objects.equals(d.getIdentity(), primaryIdentity)) - .collect(Collectors.toList()); - Statistics statistics = result.getStatistics().get(primaryIdentity); - String metricName = statistics == null ? primaryIdentity : statistics.getModelName(); - String unit = statistics == null ? "" : (statistics.getUnit() == null ? "" : statistics.getUnit()); - byte[] png = chartGenerator.timeSeriesChart("倾角时程曲线", metricName, unit, data); - result.getCharts().put("timeSeriesChart", png); + /** + * 生成项目概况总结 + */ + private String buildSummary(Statistics statistics, TrendAnalysis trend, String unit, TimeWindow window) { + if (statistics == null || statistics.getCount() == 0) { + return "统计期内无有效监测数据。"; + } + + String direction = trend == null ? "待分析" : directionText(trend.getDirection()); + BigDecimal rate = rateCalculator.ratePerDay(statistics, + Duration.between(window.getStartTime(), window.getEndTime()).getSeconds()); + + return String.format( + "本期倾角监测数据显示:当前倾角为%s%s,较初始值变化%s%s,变化速率为%s%s/天,倾角呈%s趋势。" + + "统计期内最大倾角%s%s,最小倾角%s%s,平均倾角%s%s。结构倾斜监测正常。", + statistics.getCurrent().toPlainString(), unit, + statistics.getDelta().toPlainString(), unit, + rate.toPlainString(), unit, + direction, + statistics.getMax().toPlainString(), unit, + statistics.getMin().toPlainString(), unit, + statistics.getAvg().toPlainString(), unit + ); } - private ThingsModel findModel(ReportContext context, String identity) { - if (context.getThingsModels() == null) { - return null; + /** + * 生成专业分析建议 + */ + private String buildProfessionalAdvice(Statistics statistics, TrendAnalysis trend, WarningAnalysis warning, String unit) { + if (statistics == null || statistics.getCount() == 0) { + return "建议检查设备上报状态,确保数据采集正常。"; } - return context.getThingsModels().stream() - .filter(m -> Objects.equals(m.getIdentifier(), identity)) - .findFirst().orElse(null); + + StringBuilder advice = new StringBuilder(); + advice.append("专业分析:"); + + // 基于趋势方向给出建议 + if (trend != null) { + if (TrendAnalysis.DIRECTION_UP.equals(trend.getDirection())) { + advice.append("倾角呈增大趋势,结构倾斜加剧,需分析倾斜原因与影响。"); + } else if (TrendAnalysis.DIRECTION_DOWN.equals(trend.getDirection())) { + advice.append("倾角呈减小趋势,结构倾斜趋缓或回正。"); + } else { + advice.append("倾角变化稳定,结构倾斜状态正常。"); + } + } + + // 基于倾角量级给出建议 + if (statistics.getCurrent().abs().compareTo(new BigDecimal("3")) > 0) { + advice.append("当前倾角较大,建议结合建筑物使用年限、基础形式等因素综合评估结构安全性。"); + } + + // 基于预警状态给出建议 + if (warning != null) { + if (WarningAnalysis.STATUS_ALARM.equals(warning.getStatus())) { + advice.append("当前处于报警状态,建议立即评估结构安全性,必要时采取纠偏或加固措施。"); + } else if (WarningAnalysis.STATUS_WARNING.equals(warning.getStatus())) { + advice.append("当前处于预警状态,建议加密监测频率,制定纠偏预案。"); + } + } + + return advice.toString(); + } + + /** + * 将预警状态转换为安全等级 + */ + private String convertWarningToSafetyLevel(String warningStatus) { + if (WarningAnalysis.STATUS_ALARM.equals(warningStatus)) { + return "DANGER"; + } else if (WarningAnalysis.STATUS_WARNING.equals(warningStatus)) { + return "WARNING"; + } + return "SAFE"; + } + + /** + * 组装多测点数据(支持多物模型标识符) + */ + private List buildMetricPoints(AnalysisResult result, ReportContext context) { + List points = new ArrayList<>(); + + for (Map.Entry entry : result.getStatistics().entrySet()) { + String identity = entry.getKey(); + Statistics stats = entry.getValue(); + + MetricPointData point = MetricPointData.builder() + .identifier(identity) + .modelName(stats.getModelName()) + .initialValue(stats.getInitial()) + .initialUnit(stats.getUnit()) + .currentValue(stats.getCurrent()) + .currentUnit(stats.getUnit()) + .changeValue(stats.getDelta()) + .changeUnit(stats.getUnit()) + .maxValue(stats.getMax()) + .minValue(stats.getMin()) + .avgValue(stats.getAvg()) + .build(); + + // 设置预警状态 + WarningAnalysis warning = result.getWarnings().get(identity); + if (warning != null) { + point.setWarningStatus(warning.getStatus()); + } + + // 计算变化速率 + TimeWindow window = context.getTimeWindow(); + BigDecimal rate = rateCalculator.ratePerDay(stats, + Duration.between(window.getStartTime(), window.getEndTime()).getSeconds()); + point.setChangeRate(rate); + point.setRateUnit(stats.getUnit() + "/天"); + + points.add(point); + } + + return points; + } + + private void buildChart(AnalysisResult result, ReportContext context, String primaryIdentity) { + // 主指标单图 + 其余指标按单位分组多曲线图(展示所有物模型数据) + Map> byIdentity = context.getMetricDataList().stream() + .filter(d -> d.getIdentity() != null) + .collect(Collectors.groupingBy(MetricData::getIdentity, LinkedHashMap::new, Collectors.toList())); + Map charts = metricChartBuilder.build(result.getStatistics(), primaryIdentity, byIdentity); + result.getCharts().putAll(metricChartBuilder.fillPlaceholders(charts)); } private String statusText(String status) { diff --git a/fastbee-report/src/main/java/com/fastbee/report/analyzer/rain_gauge/RainGaugeAnalyzer.java b/fastbee-report/src/main/java/com/fastbee/report/analyzer/rain_gauge/RainGaugeAnalyzer.java index 97a66b27..d1e5e8dc 100644 --- a/fastbee-report/src/main/java/com/fastbee/report/analyzer/rain_gauge/RainGaugeAnalyzer.java +++ b/fastbee-report/src/main/java/com/fastbee/report/analyzer/rain_gauge/RainGaugeAnalyzer.java @@ -3,6 +3,7 @@ package com.fastbee.report.analyzer.rain_gauge; import com.fastbee.common.enums.MonitorCategory; import com.fastbee.common.utils.StringUtils; import com.fastbee.iot.domain.ThingsModel; +import com.fastbee.report.analysis.PrimaryMetricResolver; import com.fastbee.report.analysis.QualityAnalyzer; import com.fastbee.report.analysis.RateCalculator; import com.fastbee.report.analysis.StatisticsCalculator; @@ -11,10 +12,12 @@ import com.fastbee.report.analysis.WarningAnalyzer; import com.fastbee.report.analyzer.ReportAnalyzer; import com.fastbee.report.api.ReportRequest; import com.fastbee.report.chart.ChartGenerator; +import com.fastbee.report.chart.MetricChartBuilder; import com.fastbee.report.core.ReportContext; 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.QualityAnalysis; import com.fastbee.report.model.Statistics; import com.fastbee.report.model.TrendAnalysis; @@ -60,6 +63,12 @@ public class RainGaugeAnalyzer implements ReportAnalyzer { @Resource private ChartGenerator chartGenerator; + @Resource + private PrimaryMetricResolver primaryMetricResolver; + + @Resource + private MetricChartBuilder metricChartBuilder; + @Override public boolean supports(MonitorCategory category) { return category == MonitorCategory.RAIN_GAUGE; @@ -68,23 +77,41 @@ public class RainGaugeAnalyzer implements ReportAnalyzer { @Override public AnalysisResult analyze(ReportContext context, ReportRequest request) { AnalysisResult result = new AnalysisResult(); + + // 1. 按物模型标识符分组历史数据 Map> byIdentity = context.getMetricDataList().stream() .filter(d -> d.getIdentity() != null) .collect(Collectors.groupingBy(MetricData::getIdentity, LinkedHashMap::new, Collectors.toList())); - if (byIdentity.isEmpty()) { - result.getConclusions().add(new Conclusion(1, "统计期内无有效监测数据,请检查设备上报状态与时间范围。")); + // 2. 获取所有监测物模型(即使没有数据也要显示) + Map allModels = context.getThingsModels().stream() + .filter(m -> m.getIdentifier() != null) + .collect(Collectors.toMap(ThingsModel::getIdentifier, m -> m, (o, n) -> n, LinkedHashMap::new)); + + if (allModels.isEmpty()) { + result.getConclusions().add(new Conclusion(1, "设备未配置监测物模型,请联系管理员配置。")); return result; } - for (Map.Entry> entry : byIdentity.entrySet()) { + // 3. 为每个物模型生成统计数据(包括无数据的物模型) + for (Map.Entry entry : allModels.entrySet()) { String identity = entry.getKey(); - List data = entry.getValue(); - + ThingsModel model = entry.getValue(); + List data = byIdentity.getOrDefault(identity, new ArrayList<>()); + ReportThreshold threshold = context.getThresholds().get(identity); BigDecimal controlValue = threshold == null ? null : threshold.getControlValue(); Statistics statistics = statisticsCalculator.calculate(data); + // 补充物模型信息(确保即使无数据也有 modelName 和 unit) + if (statistics.getModelName() == null || statistics.getModelName().isEmpty()) { + statistics.setModelName(model.getModelName()); + } + if (statistics.getUnit() == null || statistics.getUnit().isEmpty()) { + statistics.setUnit(model.getUnit()); + } + statistics.setIdentifier(identity); + TrendAnalysis trend = trendAnalyzer.analyze(data); QualityAnalysis quality = qualityAnalyzer.analyze(data, context.getTimeWindow(), controlValue); WarningAnalysis warning = warningAnalyzer.analyze( @@ -96,31 +123,14 @@ public class RainGaugeAnalyzer implements ReportAnalyzer { result.getWarnings().put(identity, warning); } - String primaryIdentity = resolvePrimaryIdentity(byIdentity, context); + // 统一主指标口径:优先名称/标识符命中关键词的物模型,排除角度/温度/电压等辅助量 + String primaryIdentity = primaryMetricResolver.resolve(context, byIdentity, false, "雨量", "降雨", "降水量", "rain"); + result.setPrimaryIdentifier(primaryIdentity); buildConclusions(result, context, primaryIdentity); buildChart(result, context, primaryIdentity); return result; } - /** - * 主监测指标:优先名称/标识符含"雨量/rain/precipitation",否则取第一个有数据的指标 - */ - private String resolvePrimaryIdentity(Map> byIdentity, ReportContext context) { - String fallback = null; - for (Map.Entry> entry : byIdentity.entrySet()) { - ThingsModel model = findModel(context, entry.getKey()); - String name = model == null ? entry.getKey() : (model.getModelName() == null ? entry.getKey() : model.getModelName()); - String lower = (name + entry.getKey()).toLowerCase(); - if (lower.contains("rain") || lower.contains("雨量") || lower.contains("precipitation") || lower.contains("降水")) { - return entry.getKey(); - } - if (fallback == null && !entry.getValue().isEmpty()) { - fallback = entry.getKey(); - } - } - return fallback == null ? byIdentity.keySet().iterator().next() : fallback; - } - private void buildConclusions(AnalysisResult result, ReportContext context, String primaryIdentity) { Statistics statistics = result.getStatistics().get(primaryIdentity); TrendAnalysis trend = result.getTrends().get(primaryIdentity); @@ -129,6 +139,32 @@ public class RainGaugeAnalyzer implements ReportAnalyzer { TimeWindow window = context.getTimeWindow(); String unit = statistics == null ? "" : (statistics.getUnit() == null ? "" : statistics.getUnit()); + // 生成项目概况总结 + String summary = buildSummary(statistics, trend, unit, window); + result.setSummary(summary); + + // 生成专业分析建议 + String advice = buildProfessionalAdvice(statistics, trend, warning, unit); + result.setProfessionalAdvice(advice); + + // 设置安全评估 + if (warning != null) { + result.setSafetyLevel(convertWarningToSafetyLevel(warning.getStatus())); + result.setSafetyDescription(warning.getDescription()); + } + + // 设置控制值 + ReportThreshold threshold = context.getThresholds().get(primaryIdentity); + if (threshold != null && threshold.getControlValue() != null) { + result.setControlValue(threshold.getControlValue()); + result.setControlValueUnit(threshold.getUnit()); + } + + // 组装多测点数据(支持多物模型标识符) + List metricPoints = buildMetricPoints(result, context); + result.setMetricPoints(metricPoints); + + // 生成详细结论 List conclusions = new ArrayList<>(); int seq = 1; if (statistics == null || statistics.getCount() == 0) { @@ -149,6 +185,20 @@ public class RainGaugeAnalyzer implements ReportAnalyzer { rate.toPlainString(), unit, statistics.getMax().toPlainString(), unit, statistics.getAvg().toPlainString(), unit))); + + // 添加控制值对比结论 + if (result.getControlValue() != null && result.getControlValue().compareTo(BigDecimal.ZERO) != 0) { + BigDecimal ratio = statistics.getDelta().abs().divide(result.getControlValue(), 4, BigDecimal.ROUND_HALF_UP) + .multiply(new BigDecimal("100")); + conclusions.add(new Conclusion(seq++, String.format( + "累计降雨量占控制值比例:%.2f%%(控制值:%s%s),需关注降雨对边坡稳定性的影响。", + ratio.doubleValue(), result.getControlValue().toPlainString(), result.getControlValueUnit()))); + } + + // 降雨强度评估 + if (statistics.getDelta().abs().compareTo(new BigDecimal("50")) > 0) { + conclusions.add(new Conclusion(seq++, "统计期内降雨量较大,需加强巡视检查,关注排水设施运行状况。")); + } } if (trend != null && StringUtils.isNotEmpty(trend.getDescription())) { @@ -164,24 +214,130 @@ public class RainGaugeAnalyzer implements ReportAnalyzer { result.setConclusions(conclusions); } - private void buildChart(AnalysisResult result, ReportContext context, String primaryIdentity) { - List data = context.getMetricDataList().stream() - .filter(d -> Objects.equals(d.getIdentity(), primaryIdentity)) - .collect(Collectors.toList()); - Statistics statistics = result.getStatistics().get(primaryIdentity); - String metricName = statistics == null ? primaryIdentity : statistics.getModelName(); - String unit = statistics == null ? "" : (statistics.getUnit() == null ? "" : statistics.getUnit()); - byte[] png = chartGenerator.timeSeriesChart("降雨量时程曲线", metricName, unit, data); - result.getCharts().put("timeSeriesChart", png); + /** + * 生成项目概况总结 + */ + private String buildSummary(Statistics statistics, TrendAnalysis trend, String unit, TimeWindow window) { + if (statistics == null || statistics.getCount() == 0) { + return "统计期内无有效监测数据。"; + } + + String direction = trend == null ? "待分析" : directionText(trend.getDirection()); + BigDecimal rate = rateCalculator.ratePerDay(statistics, + Duration.between(window.getStartTime(), window.getEndTime()).getSeconds()); + + return String.format( + "本期降雨监测数据显示:统计期内累计降雨量%s%s,平均日降雨量%s%s/天,降雨呈%s趋势。" + + "统计期内最大单次降雨%s%s,平均降雨强度%s%s。环境气象监测正常。", + statistics.getDelta().abs().toPlainString(), unit, + rate.toPlainString(), unit, + direction, + statistics.getMax().toPlainString(), unit, + statistics.getAvg().toPlainString(), unit + ); } - private ThingsModel findModel(ReportContext context, String identity) { - if (context.getThingsModels() == null) { - return null; + /** + * 生成专业分析建议 + */ + private String buildProfessionalAdvice(Statistics statistics, TrendAnalysis trend, WarningAnalysis warning, String unit) { + if (statistics == null || statistics.getCount() == 0) { + return "建议检查设备上报状态,确保数据采集正常。"; } - return context.getThingsModels().stream() - .filter(m -> Objects.equals(m.getIdentifier(), identity)) - .findFirst().orElse(null); + + StringBuilder advice = new StringBuilder(); + advice.append("专业分析:"); + + // 基于趋势方向给出建议 + if (trend != null) { + if (TrendAnalysis.DIRECTION_UP.equals(trend.getDirection())) { + advice.append("降雨呈增强趋势,需关注降雨对边坡稳定性、地下水位的影响。"); + } else if (TrendAnalysis.DIRECTION_DOWN.equals(trend.getDirection())) { + advice.append("降雨呈减弱趋势,天气转好。"); + } else { + advice.append("降雨强度平稳,气象条件正常。"); + } + } + + // 基于累计降雨量给出建议 + if (statistics.getDelta().abs().compareTo(new BigDecimal("30")) > 0) { + advice.append("统计期内降雨量较大,建议结合地质资料分析降雨对边坡稳定性的影响,必要时加密监测频次。"); + } + + // 基于预警状态给出建议 + if (warning != null) { + if (WarningAnalysis.STATUS_ALARM.equals(warning.getStatus())) { + advice.append("当前处于报警状态,建议立即启动应急预案,加强巡视检查,做好防汛准备。"); + } else if (WarningAnalysis.STATUS_WARNING.equals(warning.getStatus())) { + advice.append("当前处于预警状态,建议加强巡视检查,关注排水设施运行状况,准备应急物资。"); + } + } + + return advice.toString(); + } + + /** + * 将预警状态转换为安全等级 + */ + private String convertWarningToSafetyLevel(String warningStatus) { + if (WarningAnalysis.STATUS_ALARM.equals(warningStatus)) { + return "DANGER"; + } else if (WarningAnalysis.STATUS_WARNING.equals(warningStatus)) { + return "WARNING"; + } + return "SAFE"; + } + + /** + * 组装多测点数据(支持多物模型标识符) + */ + private List buildMetricPoints(AnalysisResult result, ReportContext context) { + List points = new ArrayList<>(); + + for (Map.Entry entry : result.getStatistics().entrySet()) { + String identity = entry.getKey(); + Statistics stats = entry.getValue(); + + MetricPointData point = MetricPointData.builder() + .identifier(identity) + .modelName(stats.getModelName()) + .initialValue(stats.getInitial()) + .initialUnit(stats.getUnit()) + .currentValue(stats.getCurrent()) + .currentUnit(stats.getUnit()) + .changeValue(stats.getDelta()) + .changeUnit(stats.getUnit()) + .maxValue(stats.getMax()) + .minValue(stats.getMin()) + .avgValue(stats.getAvg()) + .build(); + + // 设置预警状态 + WarningAnalysis warning = result.getWarnings().get(identity); + if (warning != null) { + point.setWarningStatus(warning.getStatus()); + } + + // 计算变化速率 + TimeWindow window = context.getTimeWindow(); + BigDecimal rate = rateCalculator.ratePerDay(stats, + Duration.between(window.getStartTime(), window.getEndTime()).getSeconds()); + point.setChangeRate(rate); + point.setRateUnit(stats.getUnit() + "/天"); + + points.add(point); + } + + return points; + } + + private void buildChart(AnalysisResult result, ReportContext context, String primaryIdentity) { + // 主指标单图 + 其余指标按单位分组多曲线图(展示所有物模型数据) + Map> byIdentity = context.getMetricDataList().stream() + .filter(d -> d.getIdentity() != null) + .collect(Collectors.groupingBy(MetricData::getIdentity, LinkedHashMap::new, Collectors.toList())); + Map charts = metricChartBuilder.build(result.getStatistics(), primaryIdentity, byIdentity); + result.getCharts().putAll(metricChartBuilder.fillPlaceholders(charts)); } private String statusText(String status) { diff --git a/fastbee-report/src/main/java/com/fastbee/report/analyzer/ranging_meter/RangingMeterAnalyzer.java b/fastbee-report/src/main/java/com/fastbee/report/analyzer/ranging_meter/RangingMeterAnalyzer.java index fae4f411..7ea9d86d 100644 --- a/fastbee-report/src/main/java/com/fastbee/report/analyzer/ranging_meter/RangingMeterAnalyzer.java +++ b/fastbee-report/src/main/java/com/fastbee/report/analyzer/ranging_meter/RangingMeterAnalyzer.java @@ -3,6 +3,7 @@ package com.fastbee.report.analyzer.ranging_meter; import com.fastbee.common.enums.MonitorCategory; import com.fastbee.common.utils.StringUtils; import com.fastbee.iot.domain.ThingsModel; +import com.fastbee.report.analysis.PrimaryMetricResolver; import com.fastbee.report.analysis.QualityAnalyzer; import com.fastbee.report.analysis.RateCalculator; import com.fastbee.report.analysis.StatisticsCalculator; @@ -11,10 +12,12 @@ import com.fastbee.report.analysis.WarningAnalyzer; import com.fastbee.report.analyzer.ReportAnalyzer; import com.fastbee.report.api.ReportRequest; import com.fastbee.report.chart.ChartGenerator; +import com.fastbee.report.chart.MetricChartBuilder; import com.fastbee.report.core.ReportContext; 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.QualityAnalysis; import com.fastbee.report.model.Statistics; import com.fastbee.report.model.TrendAnalysis; @@ -60,6 +63,12 @@ public class RangingMeterAnalyzer implements ReportAnalyzer { @Resource private ChartGenerator chartGenerator; + @Resource + private PrimaryMetricResolver primaryMetricResolver; + + @Resource + private MetricChartBuilder metricChartBuilder; + @Override public boolean supports(MonitorCategory category) { return category == MonitorCategory.RANGING_METER; @@ -68,23 +77,41 @@ public class RangingMeterAnalyzer implements ReportAnalyzer { @Override public AnalysisResult analyze(ReportContext context, ReportRequest request) { AnalysisResult result = new AnalysisResult(); + + // 1. 按物模型标识符分组历史数据 Map> byIdentity = context.getMetricDataList().stream() .filter(d -> d.getIdentity() != null) .collect(Collectors.groupingBy(MetricData::getIdentity, LinkedHashMap::new, Collectors.toList())); - if (byIdentity.isEmpty()) { - result.getConclusions().add(new Conclusion(1, "统计期内无有效监测数据,请检查设备上报状态与时间范围。")); + // 2. 获取所有监测物模型(即使没有数据也要显示) + Map allModels = context.getThingsModels().stream() + .filter(m -> m.getIdentifier() != null) + .collect(Collectors.toMap(ThingsModel::getIdentifier, m -> m, (o, n) -> n, LinkedHashMap::new)); + + if (allModels.isEmpty()) { + result.getConclusions().add(new Conclusion(1, "设备未配置监测物模型,请联系管理员配置。")); return result; } - for (Map.Entry> entry : byIdentity.entrySet()) { + // 3. 为每个物模型生成统计数据(包括无数据的物模型) + for (Map.Entry entry : allModels.entrySet()) { String identity = entry.getKey(); - List data = entry.getValue(); - + ThingsModel model = entry.getValue(); + List data = byIdentity.getOrDefault(identity, new ArrayList<>()); + ReportThreshold threshold = context.getThresholds().get(identity); BigDecimal controlValue = threshold == null ? null : threshold.getControlValue(); Statistics statistics = statisticsCalculator.calculate(data); + // 补充物模型信息(确保即使无数据也有 modelName 和 unit) + if (statistics.getModelName() == null || statistics.getModelName().isEmpty()) { + statistics.setModelName(model.getModelName()); + } + if (statistics.getUnit() == null || statistics.getUnit().isEmpty()) { + statistics.setUnit(model.getUnit()); + } + statistics.setIdentifier(identity); + TrendAnalysis trend = trendAnalyzer.analyze(data); QualityAnalysis quality = qualityAnalyzer.analyze(data, context.getTimeWindow(), controlValue); WarningAnalysis warning = warningAnalyzer.analyze( @@ -96,31 +123,14 @@ public class RangingMeterAnalyzer implements ReportAnalyzer { result.getWarnings().put(identity, warning); } - String primaryIdentity = resolvePrimaryIdentity(byIdentity, context); + // 统一主指标口径:优先名称/标识符命中关键词的物模型,排除角度/温度/电压等辅助量 + String primaryIdentity = primaryMetricResolver.resolve(context, byIdentity, false, "距离", "位移", "distance"); + result.setPrimaryIdentifier(primaryIdentity); buildConclusions(result, context, primaryIdentity); buildChart(result, context, primaryIdentity); return result; } - /** - * 主监测指标:优先名称/标识符含"距离/distance/range",否则取第一个有数据的指标 - */ - private String resolvePrimaryIdentity(Map> byIdentity, ReportContext context) { - String fallback = null; - for (Map.Entry> entry : byIdentity.entrySet()) { - ThingsModel model = findModel(context, entry.getKey()); - String name = model == null ? entry.getKey() : (model.getModelName() == null ? entry.getKey() : model.getModelName()); - String lower = (name + entry.getKey()).toLowerCase(); - if (lower.contains("distance") || lower.contains("range") || lower.contains("距离") || lower.contains("测距")) { - return entry.getKey(); - } - if (fallback == null && !entry.getValue().isEmpty()) { - fallback = entry.getKey(); - } - } - return fallback == null ? byIdentity.keySet().iterator().next() : fallback; - } - private void buildConclusions(AnalysisResult result, ReportContext context, String primaryIdentity) { Statistics statistics = result.getStatistics().get(primaryIdentity); TrendAnalysis trend = result.getTrends().get(primaryIdentity); @@ -129,6 +139,32 @@ public class RangingMeterAnalyzer implements ReportAnalyzer { TimeWindow window = context.getTimeWindow(); String unit = statistics == null ? "" : (statistics.getUnit() == null ? "" : statistics.getUnit()); + // 生成项目概况总结 + String summary = buildSummary(statistics, trend, unit, window); + result.setSummary(summary); + + // 生成专业分析建议 + String advice = buildProfessionalAdvice(statistics, trend, warning, unit); + result.setProfessionalAdvice(advice); + + // 设置安全评估 + if (warning != null) { + result.setSafetyLevel(convertWarningToSafetyLevel(warning.getStatus())); + result.setSafetyDescription(warning.getDescription()); + } + + // 设置控制值 + ReportThreshold threshold = context.getThresholds().get(primaryIdentity); + if (threshold != null && threshold.getControlValue() != null) { + result.setControlValue(threshold.getControlValue()); + result.setControlValueUnit(threshold.getUnit()); + } + + // 组装多测点数据(支持多物模型标识符) + List metricPoints = buildMetricPoints(result, context); + result.setMetricPoints(metricPoints); + + // 生成详细结论 List conclusions = new ArrayList<>(); int seq = 1; if (statistics == null || statistics.getCount() == 0) { @@ -150,6 +186,15 @@ public class RangingMeterAnalyzer implements ReportAnalyzer { statistics.getMax().toPlainString(), unit, statistics.getMin().toPlainString(), unit, statistics.getAvg().toPlainString(), unit))); + + // 添加控制值对比结论 + if (result.getControlValue() != null && result.getControlValue().compareTo(BigDecimal.ZERO) != 0) { + BigDecimal ratio = statistics.getDelta().abs().divide(result.getControlValue(), 4, BigDecimal.ROUND_HALF_UP) + .multiply(new BigDecimal("100")); + conclusions.add(new Conclusion(seq++, String.format( + "距离变化占控制值比例:%.2f%%(控制值:%s%s),需关注收敛变形趋势。", + ratio.doubleValue(), result.getControlValue().toPlainString(), result.getControlValueUnit()))); + } } if (trend != null && StringUtils.isNotEmpty(trend.getDescription())) { @@ -165,24 +210,132 @@ public class RangingMeterAnalyzer implements ReportAnalyzer { result.setConclusions(conclusions); } - private void buildChart(AnalysisResult result, ReportContext context, String primaryIdentity) { - List data = context.getMetricDataList().stream() - .filter(d -> Objects.equals(d.getIdentity(), primaryIdentity)) - .collect(Collectors.toList()); - Statistics statistics = result.getStatistics().get(primaryIdentity); - String metricName = statistics == null ? primaryIdentity : statistics.getModelName(); - String unit = statistics == null ? "" : (statistics.getUnit() == null ? "" : statistics.getUnit()); - byte[] png = chartGenerator.timeSeriesChart("测距时程曲线", metricName, unit, data); - result.getCharts().put("timeSeriesChart", png); + /** + * 生成项目概况总结 + */ + private String buildSummary(Statistics statistics, TrendAnalysis trend, String unit, TimeWindow window) { + if (statistics == null || statistics.getCount() == 0) { + return "统计期内无有效监测数据。"; + } + + String direction = trend == null ? "待分析" : directionText(trend.getDirection()); + BigDecimal rate = rateCalculator.ratePerDay(statistics, + Duration.between(window.getStartTime(), window.getEndTime()).getSeconds()); + + return String.format( + "本期测距监测数据显示:当前测距为%s%s,较初始值变化%s%s,变化速率为%s%s/天,测距呈%s趋势。" + + "统计期内最大测距%s%s,最小测距%s%s,平均测距%s%s。变形监测正常。", + statistics.getCurrent().toPlainString(), unit, + statistics.getDelta().toPlainString(), unit, + rate.toPlainString(), unit, + direction, + statistics.getMax().toPlainString(), unit, + statistics.getMin().toPlainString(), unit, + statistics.getAvg().toPlainString(), unit + ); } - private ThingsModel findModel(ReportContext context, String identity) { - if (context.getThingsModels() == null) { - return null; + /** + * 生成专业分析建议 + */ + private String buildProfessionalAdvice(Statistics statistics, TrendAnalysis trend, WarningAnalysis warning, String unit) { + if (statistics == null || statistics.getCount() == 0) { + return "建议检查设备上报状态,确保数据采集正常。"; } - return context.getThingsModels().stream() - .filter(m -> Objects.equals(m.getIdentifier(), identity)) - .findFirst().orElse(null); + + StringBuilder advice = new StringBuilder(); + advice.append("专业分析:"); + + // 基于趋势方向给出建议 + if (trend != null) { + if (TrendAnalysis.DIRECTION_UP.equals(trend.getDirection())) { + advice.append("测距呈增大趋势,可能存在收敛变形,需分析变形原因与速率。"); + } else if (TrendAnalysis.DIRECTION_DOWN.equals(trend.getDirection())) { + advice.append("测距呈减小趋势,可能存在挤压变形或测点位移,需结合其他监测数据分析。"); + } else { + advice.append("测距变化稳定,变形正常。"); + } + } + + // 基于变化量级给出建议 + if (statistics.getDelta().abs().compareTo(new BigDecimal("5")) > 0) { + advice.append("距离变化较大,建议分析变形原因,评估结构安全性。"); + } + + // 基于预警状态给出建议 + if (warning != null) { + if (WarningAnalysis.STATUS_ALARM.equals(warning.getStatus())) { + advice.append("当前处于报警状态,建议立即评估结构安全性,必要时采取加固措施。"); + } else if (WarningAnalysis.STATUS_WARNING.equals(warning.getStatus())) { + advice.append("当前处于预警状态,建议加密监测频率,准备应急预案。"); + } + } + + return advice.toString(); + } + + /** + * 将预警状态转换为安全等级 + */ + private String convertWarningToSafetyLevel(String warningStatus) { + if (WarningAnalysis.STATUS_ALARM.equals(warningStatus)) { + return "DANGER"; + } else if (WarningAnalysis.STATUS_WARNING.equals(warningStatus)) { + return "WARNING"; + } + return "SAFE"; + } + + /** + * 组装多测点数据(支持多物模型标识符) + */ + private List buildMetricPoints(AnalysisResult result, ReportContext context) { + List points = new ArrayList<>(); + + for (Map.Entry entry : result.getStatistics().entrySet()) { + String identity = entry.getKey(); + Statistics stats = entry.getValue(); + + MetricPointData point = MetricPointData.builder() + .identifier(identity) + .modelName(stats.getModelName()) + .initialValue(stats.getInitial()) + .initialUnit(stats.getUnit()) + .currentValue(stats.getCurrent()) + .currentUnit(stats.getUnit()) + .changeValue(stats.getDelta()) + .changeUnit(stats.getUnit()) + .maxValue(stats.getMax()) + .minValue(stats.getMin()) + .avgValue(stats.getAvg()) + .build(); + + // 设置预警状态 + WarningAnalysis warning = result.getWarnings().get(identity); + if (warning != null) { + point.setWarningStatus(warning.getStatus()); + } + + // 计算变化速率 + TimeWindow window = context.getTimeWindow(); + BigDecimal rate = rateCalculator.ratePerDay(stats, + Duration.between(window.getStartTime(), window.getEndTime()).getSeconds()); + point.setChangeRate(rate); + point.setRateUnit(stats.getUnit() + "/天"); + + points.add(point); + } + + return points; + } + + private void buildChart(AnalysisResult result, ReportContext context, String primaryIdentity) { + // 主指标单图 + 其余指标按单位分组多曲线图(展示所有物模型数据) + Map> byIdentity = context.getMetricDataList().stream() + .filter(d -> d.getIdentity() != null) + .collect(Collectors.groupingBy(MetricData::getIdentity, LinkedHashMap::new, Collectors.toList())); + Map charts = metricChartBuilder.build(result.getStatistics(), primaryIdentity, byIdentity); + result.getCharts().putAll(metricChartBuilder.fillPlaceholders(charts)); } private String statusText(String status) { diff --git a/fastbee-report/src/main/java/com/fastbee/report/analyzer/static_level/StaticLevelAnalyzer.java b/fastbee-report/src/main/java/com/fastbee/report/analyzer/static_level/StaticLevelAnalyzer.java index f85c5ef1..428b99e6 100644 --- a/fastbee-report/src/main/java/com/fastbee/report/analyzer/static_level/StaticLevelAnalyzer.java +++ b/fastbee-report/src/main/java/com/fastbee/report/analyzer/static_level/StaticLevelAnalyzer.java @@ -3,6 +3,7 @@ package com.fastbee.report.analyzer.static_level; import com.fastbee.common.enums.MonitorCategory; import com.fastbee.common.utils.StringUtils; import com.fastbee.iot.domain.ThingsModel; +import com.fastbee.report.analysis.PrimaryMetricResolver; import com.fastbee.report.analysis.QualityAnalyzer; import com.fastbee.report.analysis.RateCalculator; import com.fastbee.report.analysis.StatisticsCalculator; @@ -11,10 +12,12 @@ import com.fastbee.report.analysis.WarningAnalyzer; import com.fastbee.report.analyzer.ReportAnalyzer; import com.fastbee.report.api.ReportRequest; import com.fastbee.report.chart.ChartGenerator; +import com.fastbee.report.chart.MetricChartBuilder; import com.fastbee.report.core.ReportContext; 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.QualityAnalysis; import com.fastbee.report.model.Statistics; import com.fastbee.report.model.TrendAnalysis; @@ -60,6 +63,12 @@ public class StaticLevelAnalyzer implements ReportAnalyzer { @Resource private ChartGenerator chartGenerator; + @Resource + private PrimaryMetricResolver primaryMetricResolver; + + @Resource + private MetricChartBuilder metricChartBuilder; + @Override public boolean supports(MonitorCategory category) { return category == MonitorCategory.STATIC_LEVEL; @@ -68,23 +77,41 @@ public class StaticLevelAnalyzer implements ReportAnalyzer { @Override public AnalysisResult analyze(ReportContext context, ReportRequest request) { AnalysisResult result = new AnalysisResult(); + + // 1. 按物模型标识符分组历史数据 Map> byIdentity = context.getMetricDataList().stream() .filter(d -> d.getIdentity() != null) .collect(Collectors.groupingBy(MetricData::getIdentity, LinkedHashMap::new, Collectors.toList())); - if (byIdentity.isEmpty()) { - result.getConclusions().add(new Conclusion(1, "统计期内无有效监测数据,请检查设备上报状态与时间范围。")); + // 2. 获取所有监测物模型(即使没有数据也要显示) + Map allModels = context.getThingsModels().stream() + .filter(m -> m.getIdentifier() != null) + .collect(Collectors.toMap(ThingsModel::getIdentifier, m -> m, (o, n) -> n, LinkedHashMap::new)); + + if (allModels.isEmpty()) { + result.getConclusions().add(new Conclusion(1, "设备未配置监测物模型,请联系管理员配置。")); return result; } - for (Map.Entry> entry : byIdentity.entrySet()) { + // 3. 为每个物模型生成统计数据(包括无数据的物模型) + for (Map.Entry entry : allModels.entrySet()) { String identity = entry.getKey(); - List data = entry.getValue(); - + ThingsModel model = entry.getValue(); + List data = byIdentity.getOrDefault(identity, new ArrayList<>()); + ReportThreshold threshold = context.getThresholds().get(identity); BigDecimal controlValue = threshold == null ? null : threshold.getControlValue(); Statistics statistics = statisticsCalculator.calculate(data); + // 补充物模型信息(确保即使无数据也有 modelName 和 unit) + if (statistics.getModelName() == null || statistics.getModelName().isEmpty()) { + statistics.setModelName(model.getModelName()); + } + if (statistics.getUnit() == null || statistics.getUnit().isEmpty()) { + statistics.setUnit(model.getUnit()); + } + statistics.setIdentifier(identity); + TrendAnalysis trend = trendAnalyzer.analyze(data); QualityAnalysis quality = qualityAnalyzer.analyze(data, context.getTimeWindow(), controlValue); WarningAnalysis warning = warningAnalyzer.analyze( @@ -96,31 +123,14 @@ public class StaticLevelAnalyzer implements ReportAnalyzer { result.getWarnings().put(identity, warning); } - String primaryIdentity = resolvePrimaryIdentity(byIdentity, context); + // 统一主指标口径:优先名称/标识符命中关键词的物模型,排除角度/温度/电压等辅助量 + String primaryIdentity = primaryMetricResolver.resolve(context, byIdentity, false, "沉降", "高程", "settlement"); + result.setPrimaryIdentifier(primaryIdentity); buildConclusions(result, context, primaryIdentity); buildChart(result, context, primaryIdentity); return result; } - /** - * 主监测指标:优先名称/标识符含"高程/elevation/level/沉降",否则取第一个有数据的指标 - */ - private String resolvePrimaryIdentity(Map> byIdentity, ReportContext context) { - String fallback = null; - for (Map.Entry> entry : byIdentity.entrySet()) { - ThingsModel model = findModel(context, entry.getKey()); - String name = model == null ? entry.getKey() : (model.getModelName() == null ? entry.getKey() : model.getModelName()); - String lower = (name + entry.getKey()).toLowerCase(); - if (lower.contains("elevation") || lower.contains("level") || lower.contains("高程") || lower.contains("沉降") || lower.contains("水准")) { - return entry.getKey(); - } - if (fallback == null && !entry.getValue().isEmpty()) { - fallback = entry.getKey(); - } - } - return fallback == null ? byIdentity.keySet().iterator().next() : fallback; - } - private void buildConclusions(AnalysisResult result, ReportContext context, String primaryIdentity) { Statistics statistics = result.getStatistics().get(primaryIdentity); TrendAnalysis trend = result.getTrends().get(primaryIdentity); @@ -129,6 +139,32 @@ public class StaticLevelAnalyzer implements ReportAnalyzer { TimeWindow window = context.getTimeWindow(); String unit = statistics == null ? "" : (statistics.getUnit() == null ? "" : statistics.getUnit()); + // 生成项目概况总结 + String summary = buildSummary(statistics, trend, unit, window); + result.setSummary(summary); + + // 生成专业分析建议 + String advice = buildProfessionalAdvice(statistics, trend, warning, unit, result.getStatistics(), window); + result.setProfessionalAdvice(advice); + + // 设置安全评估 + if (warning != null) { + result.setSafetyLevel(convertWarningToSafetyLevel(warning.getStatus())); + result.setSafetyDescription(warning.getDescription()); + } + + // 设置控制值 + ReportThreshold threshold = context.getThresholds().get(primaryIdentity); + if (threshold != null && threshold.getControlValue() != null) { + result.setControlValue(threshold.getControlValue()); + result.setControlValueUnit(threshold.getUnit()); + } + + // 组装多测点数据(支持多测点高程监测) + List metricPoints = buildMetricPoints(result, context); + result.setMetricPoints(metricPoints); + + // 生成详细结论 List conclusions = new ArrayList<>(); int seq = 1; if (statistics == null || statistics.getCount() == 0) { @@ -150,6 +186,22 @@ public class StaticLevelAnalyzer implements ReportAnalyzer { statistics.getMax().toPlainString(), unit, statistics.getMin().toPlainString(), unit, statistics.getAvg().toPlainString(), unit))); + + // 添加控制值对比结论 + if (result.getControlValue() != null && result.getControlValue().compareTo(BigDecimal.ZERO) != 0) { + BigDecimal absDelta = statistics.getDelta().abs(); + BigDecimal ratio = absDelta.divide(result.getControlValue(), 4, BigDecimal.ROUND_HALF_UP) + .multiply(new BigDecimal("100")); + conclusions.add(new Conclusion(seq++, String.format( + "累计沉降量占控制值比例:%.2f%%(控制值:%s%s)。", + ratio.doubleValue(), result.getControlValue().toPlainString(), result.getControlValueUnit()))); + } + + // 不均匀沉降评估(多测点场景) + String differentialSettlement = assessDifferentialSettlement(result.getStatistics()); + if (StringUtils.isNotEmpty(differentialSettlement)) { + conclusions.add(new Conclusion(seq++, differentialSettlement)); + } } if (trend != null && StringUtils.isNotEmpty(trend.getDescription())) { @@ -165,24 +217,235 @@ public class StaticLevelAnalyzer implements ReportAnalyzer { result.setConclusions(conclusions); } - private void buildChart(AnalysisResult result, ReportContext context, String primaryIdentity) { - List data = context.getMetricDataList().stream() - .filter(d -> Objects.equals(d.getIdentity(), primaryIdentity)) - .collect(Collectors.toList()); - Statistics statistics = result.getStatistics().get(primaryIdentity); - String metricName = statistics == null ? primaryIdentity : statistics.getModelName(); - String unit = statistics == null ? "" : (statistics.getUnit() == null ? "" : statistics.getUnit()); - byte[] png = chartGenerator.timeSeriesChart("静力水准时程曲线", metricName, unit, data); - result.getCharts().put("timeSeriesChart", png); + /** + * 生成项目概况总结 + */ + private String buildSummary(Statistics statistics, TrendAnalysis trend, String unit, TimeWindow window) { + if (statistics == null || statistics.getCount() == 0) { + return "统计期内无有效监测数据。"; + } + + String direction = trend == null ? "待分析" : directionText(trend.getDirection()); + BigDecimal rate = rateCalculator.ratePerDay(statistics, + Duration.between(window.getStartTime(), window.getEndTime()).getSeconds()); + + String settlementType = getSettlementType(statistics.getDelta()); + + return String.format( + "本期静力水准监测数据显示:当前高程为%s%s,较初始值变化%s%s(%s),变化速率为%s%s/天,结构呈%s趋势。" + + "统计期内最大沉降量%s%s,最小沉降量%s%s,平均沉降量%s%s。", + statistics.getCurrent().toPlainString(), unit, + statistics.getDelta().toPlainString(), unit, settlementType, + rate.toPlainString(), unit, + direction, + statistics.getMax().toPlainString(), unit, + statistics.getMin().toPlainString(), unit, + statistics.getAvg().toPlainString(), unit + ); } - private ThingsModel findModel(ReportContext context, String identity) { - if (context.getThingsModels() == null) { + /** + * 生成专业分析建议 + */ + private String buildProfessionalAdvice(Statistics statistics, TrendAnalysis trend, WarningAnalysis warning, + String unit, Map allStatistics, TimeWindow window) { + if (statistics == null || statistics.getCount() == 0) { + return "建议检查设备上报状态,确保数据采集正常。"; + } + + StringBuilder advice = new StringBuilder(); + advice.append("专业分析:"); + + // 基于趋势方向给出建议 + if (trend != null) { + if (TrendAnalysis.DIRECTION_UP.equals(trend.getDirection())) { + advice.append("结构呈持续下沉趋势,需关注沉降速率变化及周边环境影响因素。"); + } else if (TrendAnalysis.DIRECTION_DOWN.equals(trend.getDirection())) { + advice.append("结构呈回弹隆起趋势,需关注回弹原因及对结构的影响。"); + } else { + advice.append("沉降变化稳定,结构状态正常。"); + } + } + + // 基于沉降量给出建议 + BigDecimal absDelta = statistics.getDelta().abs(); + if (absDelta.compareTo(new BigDecimal("10")) > 0) { + advice.append("累计沉降量较大(>10mm),建议进行详细的地基稳定性评估。"); + } + + // 基于沉降速率给出建议 + if (window != null) { + BigDecimal rate = rateCalculator.ratePerDay(statistics, + Duration.between(window.getStartTime(), window.getEndTime()).getSeconds()); + if (rate != null && rate.compareTo(new BigDecimal("2")) > 0) { + advice.append(String.format("沉降速率较快(%.2fmm/天),需加密监测频率。", rate.doubleValue())); + } + } + + // 不均匀沉降分析 + if (allStatistics != null && allStatistics.size() > 1) { + String differentialAdvice = analyzeDifferentialSettlement(allStatistics); + if (StringUtils.isNotEmpty(differentialAdvice)) { + advice.append(differentialAdvice); + } + } + + // 基于预警状态给出建议 + if (warning != null) { + if (WarningAnalysis.STATUS_ALARM.equals(warning.getStatus())) { + advice.append("当前处于报警状态,建议立即采取地基加固措施,并组织专业人员进行安全评估。"); + } else if (WarningAnalysis.STATUS_WARNING.equals(warning.getStatus())) { + advice.append("当前处于预警状态,建议加密监测频率,制定应急预案。"); + } + } + + return advice.toString(); + } + + /** + * 将预警状态转换为安全等级 + */ + private String convertWarningToSafetyLevel(String warningStatus) { + if (WarningAnalysis.STATUS_ALARM.equals(warningStatus)) { + return "DANGER"; + } else if (WarningAnalysis.STATUS_WARNING.equals(warningStatus)) { + return "WARNING"; + } + return "SAFE"; + } + + /** + * 组装多测点数据(支持多测点高程监测) + */ + private List buildMetricPoints(AnalysisResult result, ReportContext context) { + List points = new ArrayList<>(); + + for (Map.Entry entry : result.getStatistics().entrySet()) { + String identity = entry.getKey(); + Statistics stats = entry.getValue(); + + MetricPointData point = MetricPointData.builder() + .identifier(identity) + .modelName(stats.getModelName()) + .initialValue(stats.getInitial()) + .initialUnit(stats.getUnit()) + .currentValue(stats.getCurrent()) + .currentUnit(stats.getUnit()) + .changeValue(stats.getDelta()) + .changeUnit(stats.getUnit()) + .maxValue(stats.getMax()) + .minValue(stats.getMin()) + .avgValue(stats.getAvg()) + .build(); + + // 设置预警状态 + WarningAnalysis warning = result.getWarnings().get(identity); + if (warning != null) { + point.setWarningStatus(warning.getStatus()); + } + + // 计算变化速率 + TimeWindow window = context.getTimeWindow(); + BigDecimal rate = rateCalculator.ratePerDay(stats, + Duration.between(window.getStartTime(), window.getEndTime()).getSeconds()); + point.setChangeRate(rate); + point.setRateUnit(stats.getUnit() + "/天"); + + points.add(point); + } + + return points; + } + + /** + * 评估不均匀沉降(多测点场景) + */ + private String assessDifferentialSettlement(Map allStatistics) { + if (allStatistics == null || allStatistics.size() < 2) { return null; } - return context.getThingsModels().stream() - .filter(m -> Objects.equals(m.getIdentifier(), identity)) - .findFirst().orElse(null); + + BigDecimal maxDelta = null; + BigDecimal minDelta = null; + + for (Statistics stats : allStatistics.values()) { + if (stats == null || stats.getDelta() == null) { + continue; + } + if (maxDelta == null || stats.getDelta().abs().compareTo(maxDelta) > 0) { + maxDelta = stats.getDelta().abs(); + } + if (minDelta == null || stats.getDelta().abs().compareTo(minDelta) < 0) { + minDelta = stats.getDelta().abs(); + } + } + + if (maxDelta != null && minDelta != null) { + BigDecimal differential = maxDelta.subtract(minDelta); + if (differential.compareTo(new BigDecimal("5")) > 0) { + return String.format("存在不均匀沉降,测点间最大沉降差为%s%s,建议关注结构整体稳定性。", + differential.toPlainString(), "mm"); + } + } + + return null; + } + + /** + * 分析不均匀沉降(用于专业建议) + */ + private String analyzeDifferentialSettlement(Map allStatistics) { + if (allStatistics == null || allStatistics.size() < 2) { + return ""; + } + + BigDecimal maxDelta = null; + BigDecimal minDelta = null; + + for (Statistics stats : allStatistics.values()) { + if (stats == null || stats.getDelta() == null) { + continue; + } + if (maxDelta == null || stats.getDelta().abs().compareTo(maxDelta) > 0) { + maxDelta = stats.getDelta().abs(); + } + if (minDelta == null || stats.getDelta().abs().compareTo(minDelta) < 0) { + minDelta = stats.getDelta().abs(); + } + } + + if (maxDelta != null && minDelta != null) { + BigDecimal differential = maxDelta.subtract(minDelta); + if (differential.compareTo(new BigDecimal("5")) > 0) { + return "存在明显不均匀沉降现象,需关注对结构整体稳定性的影响。"; + } + } + + return ""; + } + + /** + * 判断沉降类型 + */ + private String getSettlementType(BigDecimal delta) { + if (delta == null) { + return "待分析"; + } + if (delta.compareTo(BigDecimal.ZERO) > 0) { + return "下沉"; + } else if (delta.compareTo(BigDecimal.ZERO) < 0) { + return "隆起"; + } + return "稳定"; + } + + private void buildChart(AnalysisResult result, ReportContext context, String primaryIdentity) { + // 主指标单图 + 其余指标按单位分组多曲线图(展示所有物模型数据) + Map> byIdentity = context.getMetricDataList().stream() + .filter(d -> d.getIdentity() != null) + .collect(Collectors.groupingBy(MetricData::getIdentity, LinkedHashMap::new, Collectors.toList())); + Map charts = metricChartBuilder.build(result.getStatistics(), primaryIdentity, byIdentity); + result.getCharts().putAll(metricChartBuilder.fillPlaceholders(charts)); } private String statusText(String status) { diff --git a/fastbee-report/src/main/java/com/fastbee/report/analyzer/strain_gauge/StrainGaugeAnalyzer.java b/fastbee-report/src/main/java/com/fastbee/report/analyzer/strain_gauge/StrainGaugeAnalyzer.java index c629a472..f702a846 100644 --- a/fastbee-report/src/main/java/com/fastbee/report/analyzer/strain_gauge/StrainGaugeAnalyzer.java +++ b/fastbee-report/src/main/java/com/fastbee/report/analyzer/strain_gauge/StrainGaugeAnalyzer.java @@ -3,6 +3,7 @@ package com.fastbee.report.analyzer.strain_gauge; import com.fastbee.common.enums.MonitorCategory; import com.fastbee.common.utils.StringUtils; import com.fastbee.iot.domain.ThingsModel; +import com.fastbee.report.analysis.PrimaryMetricResolver; import com.fastbee.report.analysis.QualityAnalyzer; import com.fastbee.report.analysis.RateCalculator; import com.fastbee.report.analysis.StatisticsCalculator; @@ -11,10 +12,12 @@ import com.fastbee.report.analysis.WarningAnalyzer; import com.fastbee.report.analyzer.ReportAnalyzer; import com.fastbee.report.api.ReportRequest; import com.fastbee.report.chart.ChartGenerator; +import com.fastbee.report.chart.MetricChartBuilder; import com.fastbee.report.core.ReportContext; 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.QualityAnalysis; import com.fastbee.report.model.Statistics; import com.fastbee.report.model.TrendAnalysis; @@ -60,6 +63,12 @@ public class StrainGaugeAnalyzer implements ReportAnalyzer { @Resource private ChartGenerator chartGenerator; + @Resource + private PrimaryMetricResolver primaryMetricResolver; + + @Resource + private MetricChartBuilder metricChartBuilder; + @Override public boolean supports(MonitorCategory category) { return category == MonitorCategory.STRAIN_GAUGE; @@ -68,23 +77,41 @@ public class StrainGaugeAnalyzer implements ReportAnalyzer { @Override public AnalysisResult analyze(ReportContext context, ReportRequest request) { AnalysisResult result = new AnalysisResult(); + + // 1. 按物模型标识符分组历史数据 Map> byIdentity = context.getMetricDataList().stream() .filter(d -> d.getIdentity() != null) .collect(Collectors.groupingBy(MetricData::getIdentity, LinkedHashMap::new, Collectors.toList())); - if (byIdentity.isEmpty()) { - result.getConclusions().add(new Conclusion(1, "统计期内无有效监测数据,请检查设备上报状态与时间范围。")); + // 2. 获取所有监测物模型(即使没有数据也要显示) + Map allModels = context.getThingsModels().stream() + .filter(m -> m.getIdentifier() != null) + .collect(Collectors.toMap(ThingsModel::getIdentifier, m -> m, (o, n) -> n, LinkedHashMap::new)); + + if (allModels.isEmpty()) { + result.getConclusions().add(new Conclusion(1, "设备未配置监测物模型,请联系管理员配置。")); return result; } - for (Map.Entry> entry : byIdentity.entrySet()) { + // 3. 为每个物模型生成统计数据(包括无数据的物模型) + for (Map.Entry entry : allModels.entrySet()) { String identity = entry.getKey(); - List data = entry.getValue(); - + ThingsModel model = entry.getValue(); + List data = byIdentity.getOrDefault(identity, new ArrayList<>()); + ReportThreshold threshold = context.getThresholds().get(identity); BigDecimal controlValue = threshold == null ? null : threshold.getControlValue(); Statistics statistics = statisticsCalculator.calculate(data); + // 补充物模型信息(确保即使无数据也有 modelName 和 unit) + if (statistics.getModelName() == null || statistics.getModelName().isEmpty()) { + statistics.setModelName(model.getModelName()); + } + if (statistics.getUnit() == null || statistics.getUnit().isEmpty()) { + statistics.setUnit(model.getUnit()); + } + statistics.setIdentifier(identity); + TrendAnalysis trend = trendAnalyzer.analyze(data); QualityAnalysis quality = qualityAnalyzer.analyze(data, context.getTimeWindow(), controlValue); WarningAnalysis warning = warningAnalyzer.analyze( @@ -96,31 +123,14 @@ public class StrainGaugeAnalyzer implements ReportAnalyzer { result.getWarnings().put(identity, warning); } - String primaryIdentity = resolvePrimaryIdentity(byIdentity, context); + // 统一主指标口径:优先名称/标识符命中关键词的物模型,排除角度/温度/电压等辅助量 + String primaryIdentity = primaryMetricResolver.resolve(context, byIdentity, false, "应变", "strain"); + result.setPrimaryIdentifier(primaryIdentity); buildConclusions(result, context, primaryIdentity); buildChart(result, context, primaryIdentity); return result; } - /** - * 主监测指标:优先名称/标识符含"应变/strain/με",否则取第一个有数据的指标 - */ - private String resolvePrimaryIdentity(Map> byIdentity, ReportContext context) { - String fallback = null; - for (Map.Entry> entry : byIdentity.entrySet()) { - ThingsModel model = findModel(context, entry.getKey()); - String name = model == null ? entry.getKey() : (model.getModelName() == null ? entry.getKey() : model.getModelName()); - String lower = (name + entry.getKey()).toLowerCase(); - if (lower.contains("strain") || lower.contains("应变") || lower.contains("με")) { - return entry.getKey(); - } - if (fallback == null && !entry.getValue().isEmpty()) { - fallback = entry.getKey(); - } - } - return fallback == null ? byIdentity.keySet().iterator().next() : fallback; - } - private void buildConclusions(AnalysisResult result, ReportContext context, String primaryIdentity) { Statistics statistics = result.getStatistics().get(primaryIdentity); TrendAnalysis trend = result.getTrends().get(primaryIdentity); @@ -129,6 +139,32 @@ public class StrainGaugeAnalyzer implements ReportAnalyzer { TimeWindow window = context.getTimeWindow(); String unit = statistics == null ? "" : (statistics.getUnit() == null ? "" : statistics.getUnit()); + // 生成项目概况总结 + String summary = buildSummary(statistics, trend, unit, window); + result.setSummary(summary); + + // 生成专业分析建议 + String advice = buildProfessionalAdvice(statistics, trend, warning, unit); + result.setProfessionalAdvice(advice); + + // 设置安全评估 + if (warning != null) { + result.setSafetyLevel(convertWarningToSafetyLevel(warning.getStatus())); + result.setSafetyDescription(warning.getDescription()); + } + + // 设置控制值 + ReportThreshold threshold = context.getThresholds().get(primaryIdentity); + if (threshold != null && threshold.getControlValue() != null) { + result.setControlValue(threshold.getControlValue()); + result.setControlValueUnit(threshold.getUnit()); + } + + // 组装多测点数据(支持多物模型标识符) + List metricPoints = buildMetricPoints(result, context); + result.setMetricPoints(metricPoints); + + // 生成详细结论 List conclusions = new ArrayList<>(); int seq = 1; if (statistics == null || statistics.getCount() == 0) { @@ -151,6 +187,15 @@ public class StrainGaugeAnalyzer implements ReportAnalyzer { statistics.getMax().toPlainString(), unit, statistics.getMin().toPlainString(), unit, statistics.getAvg().toPlainString(), unit))); + + // 添加控制值对比结论 + if (result.getControlValue() != null && result.getControlValue().compareTo(BigDecimal.ZERO) != 0) { + BigDecimal ratio = statistics.getCurrent().abs().divide(result.getControlValue(), 4, BigDecimal.ROUND_HALF_UP) + .multiply(new BigDecimal("100")); + conclusions.add(new Conclusion(seq++, String.format( + "当前应变占控制值比例:%.2f%%(控制值:%s%s),需关注材料强度储备。", + ratio.doubleValue(), result.getControlValue().toPlainString(), result.getControlValueUnit()))); + } } if (trend != null && StringUtils.isNotEmpty(trend.getDescription())) { @@ -166,24 +211,137 @@ public class StrainGaugeAnalyzer implements ReportAnalyzer { result.setConclusions(conclusions); } - private void buildChart(AnalysisResult result, ReportContext context, String primaryIdentity) { - List data = context.getMetricDataList().stream() - .filter(d -> Objects.equals(d.getIdentity(), primaryIdentity)) - .collect(Collectors.toList()); - Statistics statistics = result.getStatistics().get(primaryIdentity); - String metricName = statistics == null ? primaryIdentity : statistics.getModelName(); - String unit = statistics == null ? "" : (statistics.getUnit() == null ? "" : statistics.getUnit()); - byte[] png = chartGenerator.timeSeriesChart("应变时程曲线", metricName, unit, data); - result.getCharts().put("timeSeriesChart", png); + /** + * 生成项目概况总结 + */ + private String buildSummary(Statistics statistics, TrendAnalysis trend, String unit, TimeWindow window) { + if (statistics == null || statistics.getCount() == 0) { + return "统计期内无有效监测数据。"; + } + + String strainState = (statistics.getCurrent() != null && statistics.getCurrent().compareTo(BigDecimal.ZERO) >= 0) ? "拉应变" : "压应变"; + String direction = trend == null ? "待分析" : directionText(trend.getDirection()); + BigDecimal rate = rateCalculator.ratePerDay(statistics, + Duration.between(window.getStartTime(), window.getEndTime()).getSeconds()); + + return String.format( + "本期应变监测数据显示:当前应变为%s%s,处于%s状态,较初始值变化%s%s,应变速率为%s%s/天,应变呈%s趋势。" + + "统计期内最大应变%s%s,最小应变%s%s,平均应变%s%s。结构变形监测正常。", + statistics.getCurrent().toPlainString(), unit, + strainState, + statistics.getDelta().toPlainString(), unit, + rate.toPlainString(), unit, + direction, + statistics.getMax().toPlainString(), unit, + statistics.getMin().toPlainString(), unit, + statistics.getAvg().toPlainString(), unit + ); } - private ThingsModel findModel(ReportContext context, String identity) { - if (context.getThingsModels() == null) { - return null; + /** + * 生成专业分析建议 + */ + private String buildProfessionalAdvice(Statistics statistics, TrendAnalysis trend, WarningAnalysis warning, String unit) { + if (statistics == null || statistics.getCount() == 0) { + return "建议检查设备上报状态,确保数据采集正常。"; } - return context.getThingsModels().stream() - .filter(m -> Objects.equals(m.getIdentifier(), identity)) - .findFirst().orElse(null); + + StringBuilder advice = new StringBuilder(); + advice.append("专业分析:"); + + // 基于应变状态给出建议 + String strainState = (statistics.getCurrent() != null && statistics.getCurrent().compareTo(BigDecimal.ZERO) >= 0) ? "拉应变" : "压应变"; + if ("拉应变".equals(strainState)) { + advice.append("当前结构处于拉应变状态,需关注材料抗拉强度。"); + } else { + advice.append("当前结构处于压应变状态,需关注材料抗压强度和稳定性。"); + } + + // 基于趋势方向给出建议 + if (trend != null) { + if (TrendAnalysis.DIRECTION_UP.equals(trend.getDirection())) { + advice.append("应变呈增大趋势,结构变形加剧,需评估应力重分布影响。"); + } else if (TrendAnalysis.DIRECTION_DOWN.equals(trend.getDirection())) { + advice.append("应变呈减小趋势,结构变形趋于稳定。"); + } else { + advice.append("应变变化稳定,结构变形正常。"); + } + } + + // 基于预警状态给出建议 + if (warning != null) { + if (WarningAnalysis.STATUS_ALARM.equals(warning.getStatus())) { + advice.append("当前处于报警状态,建议立即评估结构安全性,必要时采取加固或卸载措施。"); + } else if (WarningAnalysis.STATUS_WARNING.equals(warning.getStatus())) { + advice.append("当前处于预警状态,建议加密监测频率,准备应急预案。"); + } + } + + return advice.toString(); + } + + /** + * 将预警状态转换为安全等级 + */ + private String convertWarningToSafetyLevel(String warningStatus) { + if (WarningAnalysis.STATUS_ALARM.equals(warningStatus)) { + return "DANGER"; + } else if (WarningAnalysis.STATUS_WARNING.equals(warningStatus)) { + return "WARNING"; + } + return "SAFE"; + } + + /** + * 组装多测点数据(支持多物模型标识符) + */ + private List buildMetricPoints(AnalysisResult result, ReportContext context) { + List points = new ArrayList<>(); + + for (Map.Entry entry : result.getStatistics().entrySet()) { + String identity = entry.getKey(); + Statistics stats = entry.getValue(); + + MetricPointData point = MetricPointData.builder() + .identifier(identity) + .modelName(stats.getModelName()) + .initialValue(stats.getInitial()) + .initialUnit(stats.getUnit()) + .currentValue(stats.getCurrent()) + .currentUnit(stats.getUnit()) + .changeValue(stats.getDelta()) + .changeUnit(stats.getUnit()) + .maxValue(stats.getMax()) + .minValue(stats.getMin()) + .avgValue(stats.getAvg()) + .build(); + + // 设置预警状态 + WarningAnalysis warning = result.getWarnings().get(identity); + if (warning != null) { + point.setWarningStatus(warning.getStatus()); + } + + // 计算变化速率 + TimeWindow window = context.getTimeWindow(); + BigDecimal rate = rateCalculator.ratePerDay(stats, + Duration.between(window.getStartTime(), window.getEndTime()).getSeconds()); + point.setChangeRate(rate); + point.setRateUnit(stats.getUnit() + "/天"); + + points.add(point); + } + + return points; + } + + private void buildChart(AnalysisResult result, ReportContext context, String primaryIdentity) { + // 主指标单图 + 其余指标按单位分组多曲线图(展示所有物模型数据) + Map> byIdentity = context.getMetricDataList().stream() + .filter(d -> d.getIdentity() != null) + .collect(Collectors.groupingBy(MetricData::getIdentity, LinkedHashMap::new, Collectors.toList())); + Map charts = metricChartBuilder.build(result.getStatistics(), primaryIdentity, byIdentity); + result.getCharts().putAll(metricChartBuilder.fillPlaceholders(charts)); } private String statusText(String status) { diff --git a/fastbee-report/src/main/java/com/fastbee/report/analyzer/thermometer/ThermometerAnalyzer.java b/fastbee-report/src/main/java/com/fastbee/report/analyzer/thermometer/ThermometerAnalyzer.java index 80410950..6bcfe950 100644 --- a/fastbee-report/src/main/java/com/fastbee/report/analyzer/thermometer/ThermometerAnalyzer.java +++ b/fastbee-report/src/main/java/com/fastbee/report/analyzer/thermometer/ThermometerAnalyzer.java @@ -3,6 +3,7 @@ package com.fastbee.report.analyzer.thermometer; import com.fastbee.common.enums.MonitorCategory; import com.fastbee.common.utils.StringUtils; import com.fastbee.iot.domain.ThingsModel; +import com.fastbee.report.analysis.PrimaryMetricResolver; import com.fastbee.report.analysis.QualityAnalyzer; import com.fastbee.report.analysis.RateCalculator; import com.fastbee.report.analysis.StatisticsCalculator; @@ -11,10 +12,12 @@ import com.fastbee.report.analysis.WarningAnalyzer; import com.fastbee.report.analyzer.ReportAnalyzer; import com.fastbee.report.api.ReportRequest; import com.fastbee.report.chart.ChartGenerator; +import com.fastbee.report.chart.MetricChartBuilder; import com.fastbee.report.core.ReportContext; 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.QualityAnalysis; import com.fastbee.report.model.Statistics; import com.fastbee.report.model.TrendAnalysis; @@ -60,6 +63,12 @@ public class ThermometerAnalyzer implements ReportAnalyzer { @Resource private ChartGenerator chartGenerator; + @Resource + private PrimaryMetricResolver primaryMetricResolver; + + @Resource + private MetricChartBuilder metricChartBuilder; + @Override public boolean supports(MonitorCategory category) { return category == MonitorCategory.THERMOMETER; @@ -68,23 +77,41 @@ public class ThermometerAnalyzer implements ReportAnalyzer { @Override public AnalysisResult analyze(ReportContext context, ReportRequest request) { AnalysisResult result = new AnalysisResult(); + + // 1. 按物模型标识符分组历史数据 Map> byIdentity = context.getMetricDataList().stream() .filter(d -> d.getIdentity() != null) .collect(Collectors.groupingBy(MetricData::getIdentity, LinkedHashMap::new, Collectors.toList())); - if (byIdentity.isEmpty()) { - result.getConclusions().add(new Conclusion(1, "统计期内无有效监测数据,请检查设备上报状态与时间范围。")); + // 2. 获取所有监测物模型(即使没有数据也要显示) + Map allModels = context.getThingsModels().stream() + .filter(m -> m.getIdentifier() != null) + .collect(Collectors.toMap(ThingsModel::getIdentifier, m -> m, (o, n) -> n, LinkedHashMap::new)); + + if (allModels.isEmpty()) { + result.getConclusions().add(new Conclusion(1, "设备未配置监测物模型,请联系管理员配置。")); return result; } - for (Map.Entry> entry : byIdentity.entrySet()) { + // 3. 为每个物模型生成统计数据(包括无数据的物模型) + for (Map.Entry entry : allModels.entrySet()) { String identity = entry.getKey(); - List data = entry.getValue(); - + ThingsModel model = entry.getValue(); + List data = byIdentity.getOrDefault(identity, new ArrayList<>()); + ReportThreshold threshold = context.getThresholds().get(identity); BigDecimal controlValue = threshold == null ? null : threshold.getControlValue(); Statistics statistics = statisticsCalculator.calculate(data); + // 补充物模型信息(确保即使无数据也有 modelName 和 unit) + if (statistics.getModelName() == null || statistics.getModelName().isEmpty()) { + statistics.setModelName(model.getModelName()); + } + if (statistics.getUnit() == null || statistics.getUnit().isEmpty()) { + statistics.setUnit(model.getUnit()); + } + statistics.setIdentifier(identity); + TrendAnalysis trend = trendAnalyzer.analyze(data); QualityAnalysis quality = qualityAnalyzer.analyze(data, context.getTimeWindow(), controlValue); WarningAnalysis warning = warningAnalyzer.analyze( @@ -96,31 +123,14 @@ public class ThermometerAnalyzer implements ReportAnalyzer { result.getWarnings().put(identity, warning); } - String primaryIdentity = resolvePrimaryIdentity(byIdentity, context); + // 统一主指标口径:优先名称/标识符命中关键词的物模型,排除角度/温度/电压等辅助量 + String primaryIdentity = primaryMetricResolver.resolve(context, byIdentity, true, "温度", "temperature"); + result.setPrimaryIdentifier(primaryIdentity); buildConclusions(result, context, primaryIdentity); buildChart(result, context, primaryIdentity); return result; } - /** - * 主监测指标:优先名称/标识符含"温度/temp/temperature/℃",否则取第一个有数据的指标 - */ - private String resolvePrimaryIdentity(Map> byIdentity, ReportContext context) { - String fallback = null; - for (Map.Entry> entry : byIdentity.entrySet()) { - ThingsModel model = findModel(context, entry.getKey()); - String name = model == null ? entry.getKey() : (model.getModelName() == null ? entry.getKey() : model.getModelName()); - String lower = (name + entry.getKey()).toLowerCase(); - if (lower.contains("temp") || lower.contains("温度") || lower.contains("temperature")) { - return entry.getKey(); - } - if (fallback == null && !entry.getValue().isEmpty()) { - fallback = entry.getKey(); - } - } - return fallback == null ? byIdentity.keySet().iterator().next() : fallback; - } - private void buildConclusions(AnalysisResult result, ReportContext context, String primaryIdentity) { Statistics statistics = result.getStatistics().get(primaryIdentity); TrendAnalysis trend = result.getTrends().get(primaryIdentity); @@ -129,6 +139,32 @@ public class ThermometerAnalyzer implements ReportAnalyzer { TimeWindow window = context.getTimeWindow(); String unit = statistics == null ? "" : (statistics.getUnit() == null ? "" : statistics.getUnit()); + // 生成项目概况总结 + String summary = buildSummary(statistics, trend, unit, window); + result.setSummary(summary); + + // 生成专业分析建议 + String advice = buildProfessionalAdvice(statistics, trend, warning, unit); + result.setProfessionalAdvice(advice); + + // 设置安全评估 + if (warning != null) { + result.setSafetyLevel(convertWarningToSafetyLevel(warning.getStatus())); + result.setSafetyDescription(warning.getDescription()); + } + + // 设置控制值 + ReportThreshold threshold = context.getThresholds().get(primaryIdentity); + if (threshold != null && threshold.getControlValue() != null) { + result.setControlValue(threshold.getControlValue()); + result.setControlValueUnit(threshold.getUnit()); + } + + // 组装多测点数据(支持多物模型标识符) + List metricPoints = buildMetricPoints(result, context); + result.setMetricPoints(metricPoints); + + // 生成详细结论 List conclusions = new ArrayList<>(); int seq = 1; if (statistics == null || statistics.getCount() == 0) { @@ -150,6 +186,21 @@ public class ThermometerAnalyzer implements ReportAnalyzer { statistics.getMax().toPlainString(), unit, statistics.getMin().toPlainString(), unit, statistics.getAvg().toPlainString(), unit))); + + // 添加控制值对比结论 + if (result.getControlValue() != null && result.getControlValue().compareTo(BigDecimal.ZERO) != 0) { + BigDecimal ratio = statistics.getCurrent().abs().divide(result.getControlValue(), 4, BigDecimal.ROUND_HALF_UP) + .multiply(new BigDecimal("100")); + conclusions.add(new Conclusion(seq++, String.format( + "当前温度占控制值比例:%.2f%%(控制值:%s%s),需关注温度对结构的影响。", + ratio.doubleValue(), result.getControlValue().toPlainString(), result.getControlValueUnit()))); + } + + // 温度异常评估 + BigDecimal tempRange = statistics.getMax().subtract(statistics.getMin()); + if (tempRange.compareTo(new BigDecimal("20")) > 0) { + conclusions.add(new Conclusion(seq++, "统计期内温度波动较大,需关注温差对结构变形的影响。")); + } } if (trend != null && StringUtils.isNotEmpty(trend.getDescription())) { @@ -165,24 +216,133 @@ public class ThermometerAnalyzer implements ReportAnalyzer { result.setConclusions(conclusions); } - private void buildChart(AnalysisResult result, ReportContext context, String primaryIdentity) { - List data = context.getMetricDataList().stream() - .filter(d -> Objects.equals(d.getIdentity(), primaryIdentity)) - .collect(Collectors.toList()); - Statistics statistics = result.getStatistics().get(primaryIdentity); - String metricName = statistics == null ? primaryIdentity : statistics.getModelName(); - String unit = statistics == null ? "" : (statistics.getUnit() == null ? "" : statistics.getUnit()); - byte[] png = chartGenerator.timeSeriesChart("温度时程曲线", metricName, unit, data); - result.getCharts().put("timeSeriesChart", png); + /** + * 生成项目概况总结 + */ + private String buildSummary(Statistics statistics, TrendAnalysis trend, String unit, TimeWindow window) { + if (statistics == null || statistics.getCount() == 0) { + return "统计期内无有效监测数据。"; + } + + String direction = trend == null ? "待分析" : directionText(trend.getDirection()); + BigDecimal rate = rateCalculator.ratePerDay(statistics, + Duration.between(window.getStartTime(), window.getEndTime()).getSeconds()); + + return String.format( + "本期温度监测数据显示:当前温度为%s%s,较初始值变化%s%s,温度变化速率为%s%s/天,温度呈%s趋势。" + + "统计期内最高温度%s%s,最低温度%s%s,平均温度%s%s。环境温度监测正常。", + statistics.getCurrent().toPlainString(), unit, + statistics.getDelta().toPlainString(), unit, + rate.toPlainString(), unit, + direction, + statistics.getMax().toPlainString(), unit, + statistics.getMin().toPlainString(), unit, + statistics.getAvg().toPlainString(), unit + ); } - private ThingsModel findModel(ReportContext context, String identity) { - if (context.getThingsModels() == null) { - return null; + /** + * 生成专业分析建议 + */ + private String buildProfessionalAdvice(Statistics statistics, TrendAnalysis trend, WarningAnalysis warning, String unit) { + if (statistics == null || statistics.getCount() == 0) { + return "建议检查设备上报状态,确保数据采集正常。"; } - return context.getThingsModels().stream() - .filter(m -> Objects.equals(m.getIdentifier(), identity)) - .findFirst().orElse(null); + + StringBuilder advice = new StringBuilder(); + advice.append("专业分析:"); + + // 基于趋势方向给出建议 + if (trend != null) { + if (TrendAnalysis.DIRECTION_UP.equals(trend.getDirection())) { + advice.append("温度呈上升趋势,需关注高温对材料性能和结构变形的影响。"); + } else if (TrendAnalysis.DIRECTION_DOWN.equals(trend.getDirection())) { + advice.append("温度呈下降趋势,需关注低温对材料性能和结构变形的影响。"); + } else { + advice.append("温度变化平稳,环境温度正常。"); + } + } + + // 基于温差给出建议 + BigDecimal tempRange = statistics.getMax().subtract(statistics.getMin()); + if (tempRange.compareTo(new BigDecimal("15")) > 0) { + advice.append("统计期内温差较大,需分析温差对结构变形的影响,必要时进行温度修正。"); + } + + // 基于预警状态给出建议 + if (warning != null) { + if (WarningAnalysis.STATUS_ALARM.equals(warning.getStatus())) { + advice.append("当前处于报警状态,建议立即评估温度异常对结构安全的影响,必要时采取保护措施。"); + } else if (WarningAnalysis.STATUS_WARNING.equals(warning.getStatus())) { + advice.append("当前处于预警状态,建议加强温度监测,分析温度异常原因。"); + } + } + + return advice.toString(); + } + + /** + * 将预警状态转换为安全等级 + */ + private String convertWarningToSafetyLevel(String warningStatus) { + if (WarningAnalysis.STATUS_ALARM.equals(warningStatus)) { + return "DANGER"; + } else if (WarningAnalysis.STATUS_WARNING.equals(warningStatus)) { + return "WARNING"; + } + return "SAFE"; + } + + /** + * 组装多测点数据(支持多物模型标识符) + */ + private List buildMetricPoints(AnalysisResult result, ReportContext context) { + List points = new ArrayList<>(); + + for (Map.Entry entry : result.getStatistics().entrySet()) { + String identity = entry.getKey(); + Statistics stats = entry.getValue(); + + MetricPointData point = MetricPointData.builder() + .identifier(identity) + .modelName(stats.getModelName()) + .initialValue(stats.getInitial()) + .initialUnit(stats.getUnit()) + .currentValue(stats.getCurrent()) + .currentUnit(stats.getUnit()) + .changeValue(stats.getDelta()) + .changeUnit(stats.getUnit()) + .maxValue(stats.getMax()) + .minValue(stats.getMin()) + .avgValue(stats.getAvg()) + .build(); + + // 设置预警状态 + WarningAnalysis warning = result.getWarnings().get(identity); + if (warning != null) { + point.setWarningStatus(warning.getStatus()); + } + + // 计算变化速率 + TimeWindow window = context.getTimeWindow(); + BigDecimal rate = rateCalculator.ratePerDay(stats, + Duration.between(window.getStartTime(), window.getEndTime()).getSeconds()); + point.setChangeRate(rate); + point.setRateUnit(stats.getUnit() + "/天"); + + points.add(point); + } + + return points; + } + + private void buildChart(AnalysisResult result, ReportContext context, String primaryIdentity) { + // 主指标单图 + 其余指标按单位分组多曲线图(展示所有物模型数据) + Map> byIdentity = context.getMetricDataList().stream() + .filter(d -> d.getIdentity() != null) + .collect(Collectors.groupingBy(MetricData::getIdentity, LinkedHashMap::new, Collectors.toList())); + Map charts = metricChartBuilder.build(result.getStatistics(), primaryIdentity, byIdentity); + result.getCharts().putAll(metricChartBuilder.fillPlaceholders(charts)); } private String statusText(String status) { diff --git a/fastbee-report/src/main/java/com/fastbee/report/analyzer/vibrating_wire/VibratingWireAnalyzer.java b/fastbee-report/src/main/java/com/fastbee/report/analyzer/vibrating_wire/VibratingWireAnalyzer.java index ed32ad7e..f34b45ef 100644 --- a/fastbee-report/src/main/java/com/fastbee/report/analyzer/vibrating_wire/VibratingWireAnalyzer.java +++ b/fastbee-report/src/main/java/com/fastbee/report/analyzer/vibrating_wire/VibratingWireAnalyzer.java @@ -3,6 +3,7 @@ package com.fastbee.report.analyzer.vibrating_wire; import com.fastbee.common.enums.MonitorCategory; import com.fastbee.common.utils.StringUtils; import com.fastbee.iot.domain.ThingsModel; +import com.fastbee.report.analysis.PrimaryMetricResolver; import com.fastbee.report.analysis.QualityAnalyzer; import com.fastbee.report.analysis.RateCalculator; import com.fastbee.report.analysis.StatisticsCalculator; @@ -11,10 +12,12 @@ import com.fastbee.report.analysis.WarningAnalyzer; import com.fastbee.report.analyzer.ReportAnalyzer; import com.fastbee.report.api.ReportRequest; import com.fastbee.report.chart.ChartGenerator; +import com.fastbee.report.chart.MetricChartBuilder; import com.fastbee.report.core.ReportContext; 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.QualityAnalysis; import com.fastbee.report.model.Statistics; import com.fastbee.report.model.TrendAnalysis; @@ -60,6 +63,12 @@ public class VibratingWireAnalyzer implements ReportAnalyzer { @Resource private ChartGenerator chartGenerator; + @Resource + private PrimaryMetricResolver primaryMetricResolver; + + @Resource + private MetricChartBuilder metricChartBuilder; + @Override public boolean supports(MonitorCategory category) { return category == MonitorCategory.VIBRATING_WIRE; @@ -68,23 +77,41 @@ public class VibratingWireAnalyzer implements ReportAnalyzer { @Override public AnalysisResult analyze(ReportContext context, ReportRequest request) { AnalysisResult result = new AnalysisResult(); + + // 1. 按物模型标识符分组历史数据 Map> byIdentity = context.getMetricDataList().stream() .filter(d -> d.getIdentity() != null) .collect(Collectors.groupingBy(MetricData::getIdentity, LinkedHashMap::new, Collectors.toList())); - if (byIdentity.isEmpty()) { - result.getConclusions().add(new Conclusion(1, "统计期内无有效监测数据,请检查设备上报状态与时间范围。")); + // 2. 获取所有监测物模型(即使没有数据也要显示) + Map allModels = context.getThingsModels().stream() + .filter(m -> m.getIdentifier() != null) + .collect(Collectors.toMap(ThingsModel::getIdentifier, m -> m, (o, n) -> n, LinkedHashMap::new)); + + if (allModels.isEmpty()) { + result.getConclusions().add(new Conclusion(1, "设备未配置监测物模型,请联系管理员配置。")); return result; } - for (Map.Entry> entry : byIdentity.entrySet()) { + // 3. 为每个物模型生成统计数据(包括无数据的物模型) + for (Map.Entry entry : allModels.entrySet()) { String identity = entry.getKey(); - List data = entry.getValue(); - + ThingsModel model = entry.getValue(); + List data = byIdentity.getOrDefault(identity, new ArrayList<>()); + ReportThreshold threshold = context.getThresholds().get(identity); BigDecimal controlValue = threshold == null ? null : threshold.getControlValue(); Statistics statistics = statisticsCalculator.calculate(data); + // 补充物模型信息(确保即使无数据也有 modelName 和 unit) + if (statistics.getModelName() == null || statistics.getModelName().isEmpty()) { + statistics.setModelName(model.getModelName()); + } + if (statistics.getUnit() == null || statistics.getUnit().isEmpty()) { + statistics.setUnit(model.getUnit()); + } + statistics.setIdentifier(identity); + TrendAnalysis trend = trendAnalyzer.analyze(data); QualityAnalysis quality = qualityAnalyzer.analyze(data, context.getTimeWindow(), controlValue); WarningAnalysis warning = warningAnalyzer.analyze( @@ -96,31 +123,14 @@ public class VibratingWireAnalyzer implements ReportAnalyzer { result.getWarnings().put(identity, warning); } - String primaryIdentity = resolvePrimaryIdentity(byIdentity, context); + // 统一主指标口径:优先名称/标识符命中关键词的物模型,排除角度/温度/电压等辅助量 + String primaryIdentity = primaryMetricResolver.resolve(context, byIdentity, false, "频率", "frequency"); + result.setPrimaryIdentifier(primaryIdentity); buildConclusions(result, context, primaryIdentity); buildChart(result, context, primaryIdentity); return result; } - /** - * 主监测指标:优先名称/标识符含"频率/frequency/Hz",否则取第一个有数据的指标 - */ - private String resolvePrimaryIdentity(Map> byIdentity, ReportContext context) { - String fallback = null; - for (Map.Entry> entry : byIdentity.entrySet()) { - ThingsModel model = findModel(context, entry.getKey()); - String name = model == null ? entry.getKey() : (model.getModelName() == null ? entry.getKey() : model.getModelName()); - String lower = (name + entry.getKey()).toLowerCase(); - if (lower.contains("frequency") || lower.contains("频率") || lower.contains("hz")) { - return entry.getKey(); - } - if (fallback == null && !entry.getValue().isEmpty()) { - fallback = entry.getKey(); - } - } - return fallback == null ? byIdentity.keySet().iterator().next() : fallback; - } - private void buildConclusions(AnalysisResult result, ReportContext context, String primaryIdentity) { Statistics statistics = result.getStatistics().get(primaryIdentity); TrendAnalysis trend = result.getTrends().get(primaryIdentity); @@ -129,6 +139,32 @@ public class VibratingWireAnalyzer implements ReportAnalyzer { TimeWindow window = context.getTimeWindow(); String unit = statistics == null ? "" : (statistics.getUnit() == null ? "" : statistics.getUnit()); + // 生成项目概况总结 + String summary = buildSummary(statistics, trend, unit, window); + result.setSummary(summary); + + // 生成专业分析建议 + String advice = buildProfessionalAdvice(statistics, trend, warning, unit); + result.setProfessionalAdvice(advice); + + // 设置安全评估 + if (warning != null) { + result.setSafetyLevel(convertWarningToSafetyLevel(warning.getStatus())); + result.setSafetyDescription(warning.getDescription()); + } + + // 设置控制值 + ReportThreshold threshold = context.getThresholds().get(primaryIdentity); + if (threshold != null && threshold.getControlValue() != null) { + result.setControlValue(threshold.getControlValue()); + result.setControlValueUnit(threshold.getUnit()); + } + + // 组装多测点数据(支持多物模型标识符) + List metricPoints = buildMetricPoints(result, context); + result.setMetricPoints(metricPoints); + + // 生成详细结论 List conclusions = new ArrayList<>(); int seq = 1; if (statistics == null || statistics.getCount() == 0) { @@ -150,6 +186,15 @@ public class VibratingWireAnalyzer implements ReportAnalyzer { statistics.getMax().toPlainString(), unit, statistics.getMin().toPlainString(), unit, statistics.getAvg().toPlainString(), unit))); + + // 添加控制值对比结论 + if (result.getControlValue() != null && result.getControlValue().compareTo(BigDecimal.ZERO) != 0) { + BigDecimal ratio = statistics.getDelta().abs().divide(result.getControlValue(), 4, BigDecimal.ROUND_HALF_UP) + .multiply(new BigDecimal("100")); + conclusions.add(new Conclusion(seq++, String.format( + "频率变化占控制值比例:%.2f%%(控制值:%s%s),需结合温度补偿分析应力状态。", + ratio.doubleValue(), result.getControlValue().toPlainString(), result.getControlValueUnit()))); + } } if (trend != null && StringUtils.isNotEmpty(trend.getDescription())) { @@ -165,24 +210,132 @@ public class VibratingWireAnalyzer implements ReportAnalyzer { result.setConclusions(conclusions); } - private void buildChart(AnalysisResult result, ReportContext context, String primaryIdentity) { - List data = context.getMetricDataList().stream() - .filter(d -> Objects.equals(d.getIdentity(), primaryIdentity)) - .collect(Collectors.toList()); - Statistics statistics = result.getStatistics().get(primaryIdentity); - String metricName = statistics == null ? primaryIdentity : statistics.getModelName(); - String unit = statistics == null ? "" : (statistics.getUnit() == null ? "" : statistics.getUnit()); - byte[] png = chartGenerator.timeSeriesChart("振弦频率时程曲线", metricName, unit, data); - result.getCharts().put("timeSeriesChart", png); + /** + * 生成项目概况总结 + */ + private String buildSummary(Statistics statistics, TrendAnalysis trend, String unit, TimeWindow window) { + if (statistics == null || statistics.getCount() == 0) { + return "统计期内无有效监测数据。"; + } + + String direction = trend == null ? "待分析" : directionText(trend.getDirection()); + BigDecimal rate = rateCalculator.ratePerDay(statistics, + Duration.between(window.getStartTime(), window.getEndTime()).getSeconds()); + + return String.format( + "本期振弦监测数据显示:当前频率为%s%s,较初始值变化%s%s,频率变化速率为%s%s/天,频率呈%s趋势。" + + "统计期内最大频率%s%s,最小频率%s%s,平均频率%s%s。振弦传感器工作正常。", + statistics.getCurrent().toPlainString(), unit, + statistics.getDelta().toPlainString(), unit, + rate.toPlainString(), unit, + direction, + statistics.getMax().toPlainString(), unit, + statistics.getMin().toPlainString(), unit, + statistics.getAvg().toPlainString(), unit + ); } - private ThingsModel findModel(ReportContext context, String identity) { - if (context.getThingsModels() == null) { - return null; + /** + * 生成专业分析建议 + */ + private String buildProfessionalAdvice(Statistics statistics, TrendAnalysis trend, WarningAnalysis warning, String unit) { + if (statistics == null || statistics.getCount() == 0) { + return "建议检查设备上报状态,确保数据采集正常。"; } - return context.getThingsModels().stream() - .filter(m -> Objects.equals(m.getIdentifier(), identity)) - .findFirst().orElse(null); + + StringBuilder advice = new StringBuilder(); + advice.append("专业分析:"); + + // 基于趋势方向给出建议 + if (trend != null) { + if (TrendAnalysis.DIRECTION_UP.equals(trend.getDirection())) { + advice.append("振弦频率呈增大趋势,应力状态发生变化,需结合温度补偿计算实际应力值。"); + } else if (TrendAnalysis.DIRECTION_DOWN.equals(trend.getDirection())) { + advice.append("振弦频率呈减小趋势,结构应力状态趋于松弛。"); + } else { + advice.append("振弦频率变化稳定,结构应力状态正常。"); + } + } + + // 基于频率变化量级给出建议 + if (statistics.getDelta().abs().compareTo(new BigDecimal("50")) > 0) { + advice.append("频率变化较大,建议检查传感器零点漂移,进行温度补偿修正。"); + } + + // 基于预警状态给出建议 + if (warning != null) { + if (WarningAnalysis.STATUS_ALARM.equals(warning.getStatus())) { + advice.append("当前处于报警状态,建议立即校验传感器状态,分析应力超限原因,必要时采取加固措施。"); + } else if (WarningAnalysis.STATUS_WARNING.equals(warning.getStatus())) { + advice.append("当前处于预警状态,建议加密监测频率,准备应急预案。"); + } + } + + return advice.toString(); + } + + /** + * 将预警状态转换为安全等级 + */ + private String convertWarningToSafetyLevel(String warningStatus) { + if (WarningAnalysis.STATUS_ALARM.equals(warningStatus)) { + return "DANGER"; + } else if (WarningAnalysis.STATUS_WARNING.equals(warningStatus)) { + return "WARNING"; + } + return "SAFE"; + } + + /** + * 组装多测点数据(支持多物模型标识符) + */ + private List buildMetricPoints(AnalysisResult result, ReportContext context) { + List points = new ArrayList<>(); + + for (Map.Entry entry : result.getStatistics().entrySet()) { + String identity = entry.getKey(); + Statistics stats = entry.getValue(); + + MetricPointData point = MetricPointData.builder() + .identifier(identity) + .modelName(stats.getModelName()) + .initialValue(stats.getInitial()) + .initialUnit(stats.getUnit()) + .currentValue(stats.getCurrent()) + .currentUnit(stats.getUnit()) + .changeValue(stats.getDelta()) + .changeUnit(stats.getUnit()) + .maxValue(stats.getMax()) + .minValue(stats.getMin()) + .avgValue(stats.getAvg()) + .build(); + + // 设置预警状态 + WarningAnalysis warning = result.getWarnings().get(identity); + if (warning != null) { + point.setWarningStatus(warning.getStatus()); + } + + // 计算变化速率 + TimeWindow window = context.getTimeWindow(); + BigDecimal rate = rateCalculator.ratePerDay(stats, + Duration.between(window.getStartTime(), window.getEndTime()).getSeconds()); + point.setChangeRate(rate); + point.setRateUnit(stats.getUnit() + "/天"); + + points.add(point); + } + + return points; + } + + private void buildChart(AnalysisResult result, ReportContext context, String primaryIdentity) { + // 主指标单图 + 其余指标按单位分组多曲线图(展示所有物模型数据) + Map> byIdentity = context.getMetricDataList().stream() + .filter(d -> d.getIdentity() != null) + .collect(Collectors.groupingBy(MetricData::getIdentity, LinkedHashMap::new, Collectors.toList())); + Map charts = metricChartBuilder.build(result.getStatistics(), primaryIdentity, byIdentity); + result.getCharts().putAll(metricChartBuilder.fillPlaceholders(charts)); } private String statusText(String status) { diff --git a/fastbee-report/src/main/java/com/fastbee/report/analyzer/water_level/WaterLevelAnalyzer.java b/fastbee-report/src/main/java/com/fastbee/report/analyzer/water_level/WaterLevelAnalyzer.java index 56d28a9f..8ff34a65 100644 --- a/fastbee-report/src/main/java/com/fastbee/report/analyzer/water_level/WaterLevelAnalyzer.java +++ b/fastbee-report/src/main/java/com/fastbee/report/analyzer/water_level/WaterLevelAnalyzer.java @@ -3,6 +3,7 @@ package com.fastbee.report.analyzer.water_level; import com.fastbee.common.enums.MonitorCategory; import com.fastbee.common.utils.StringUtils; import com.fastbee.iot.domain.ThingsModel; +import com.fastbee.report.analysis.PrimaryMetricResolver; import com.fastbee.report.analysis.QualityAnalyzer; import com.fastbee.report.analysis.RateCalculator; import com.fastbee.report.analysis.StatisticsCalculator; @@ -11,10 +12,12 @@ import com.fastbee.report.analysis.WarningAnalyzer; import com.fastbee.report.analyzer.ReportAnalyzer; import com.fastbee.report.api.ReportRequest; import com.fastbee.report.chart.ChartGenerator; +import com.fastbee.report.chart.MetricChartBuilder; import com.fastbee.report.core.ReportContext; 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.QualityAnalysis; import com.fastbee.report.model.Statistics; import com.fastbee.report.model.TrendAnalysis; @@ -60,6 +63,12 @@ public class WaterLevelAnalyzer implements ReportAnalyzer { @Resource private ChartGenerator chartGenerator; + @Resource + private PrimaryMetricResolver primaryMetricResolver; + + @Resource + private MetricChartBuilder metricChartBuilder; + @Override public boolean supports(MonitorCategory category) { return category == MonitorCategory.WATER_LEVEL; @@ -68,23 +77,41 @@ public class WaterLevelAnalyzer implements ReportAnalyzer { @Override public AnalysisResult analyze(ReportContext context, ReportRequest request) { AnalysisResult result = new AnalysisResult(); + + // 1. 按物模型标识符分组历史数据 Map> byIdentity = context.getMetricDataList().stream() .filter(d -> d.getIdentity() != null) .collect(Collectors.groupingBy(MetricData::getIdentity, LinkedHashMap::new, Collectors.toList())); - if (byIdentity.isEmpty()) { - result.getConclusions().add(new Conclusion(1, "统计期内无有效监测数据,请检查设备上报状态与时间范围。")); + // 2. 获取所有监测物模型(即使没有数据也要显示) + Map allModels = context.getThingsModels().stream() + .filter(m -> m.getIdentifier() != null) + .collect(Collectors.toMap(ThingsModel::getIdentifier, m -> m, (o, n) -> n, LinkedHashMap::new)); + + if (allModels.isEmpty()) { + result.getConclusions().add(new Conclusion(1, "设备未配置监测物模型,请联系管理员配置。")); return result; } - for (Map.Entry> entry : byIdentity.entrySet()) { + // 3. 为每个物模型生成统计数据(包括无数据的物模型) + for (Map.Entry entry : allModels.entrySet()) { String identity = entry.getKey(); - List data = entry.getValue(); - + ThingsModel model = entry.getValue(); + List data = byIdentity.getOrDefault(identity, new ArrayList<>()); + ReportThreshold threshold = context.getThresholds().get(identity); BigDecimal controlValue = threshold == null ? null : threshold.getControlValue(); Statistics statistics = statisticsCalculator.calculate(data); + // 补充物模型信息(确保即使无数据也有 modelName 和 unit) + if (statistics.getModelName() == null || statistics.getModelName().isEmpty()) { + statistics.setModelName(model.getModelName()); + } + if (statistics.getUnit() == null || statistics.getUnit().isEmpty()) { + statistics.setUnit(model.getUnit()); + } + statistics.setIdentifier(identity); + TrendAnalysis trend = trendAnalyzer.analyze(data); QualityAnalysis quality = qualityAnalyzer.analyze(data, context.getTimeWindow(), controlValue); WarningAnalysis warning = warningAnalyzer.analyze( @@ -96,31 +123,14 @@ public class WaterLevelAnalyzer implements ReportAnalyzer { result.getWarnings().put(identity, warning); } - String primaryIdentity = resolvePrimaryIdentity(byIdentity, context); + // 统一主指标口径:优先名称/标识符命中关键词的物模型,排除角度/温度/电压等辅助量 + String primaryIdentity = primaryMetricResolver.resolve(context, byIdentity, false, "水位", "液位", "water"); + result.setPrimaryIdentifier(primaryIdentity); buildConclusions(result, context, primaryIdentity); buildChart(result, context, primaryIdentity); return result; } - /** - * 主监测指标:优先名称/标识符含"水位/level/water/depth",否则取第一个有数据的指标 - */ - private String resolvePrimaryIdentity(Map> byIdentity, ReportContext context) { - String fallback = null; - for (Map.Entry> entry : byIdentity.entrySet()) { - ThingsModel model = findModel(context, entry.getKey()); - String name = model == null ? entry.getKey() : (model.getModelName() == null ? entry.getKey() : model.getModelName()); - String lower = (name + entry.getKey()).toLowerCase(); - if (lower.contains("level") || lower.contains("water") || lower.contains("水位") || lower.contains("水深") || lower.contains("depth")) { - return entry.getKey(); - } - if (fallback == null && !entry.getValue().isEmpty()) { - fallback = entry.getKey(); - } - } - return fallback == null ? byIdentity.keySet().iterator().next() : fallback; - } - private void buildConclusions(AnalysisResult result, ReportContext context, String primaryIdentity) { Statistics statistics = result.getStatistics().get(primaryIdentity); TrendAnalysis trend = result.getTrends().get(primaryIdentity); @@ -129,6 +139,32 @@ public class WaterLevelAnalyzer implements ReportAnalyzer { TimeWindow window = context.getTimeWindow(); String unit = statistics == null ? "" : (statistics.getUnit() == null ? "" : statistics.getUnit()); + // 生成项目概况总结 + String summary = buildSummary(statistics, trend, unit, window); + result.setSummary(summary); + + // 生成专业分析建议 + String advice = buildProfessionalAdvice(statistics, trend, warning, unit); + result.setProfessionalAdvice(advice); + + // 设置安全评估 + if (warning != null) { + result.setSafetyLevel(convertWarningToSafetyLevel(warning.getStatus())); + result.setSafetyDescription(warning.getDescription()); + } + + // 设置控制值 + ReportThreshold threshold = context.getThresholds().get(primaryIdentity); + if (threshold != null && threshold.getControlValue() != null) { + result.setControlValue(threshold.getControlValue()); + result.setControlValueUnit(threshold.getUnit()); + } + + // 组装多测点数据(支持多物模型标识符) + List metricPoints = buildMetricPoints(result, context); + result.setMetricPoints(metricPoints); + + // 生成详细结论 List conclusions = new ArrayList<>(); int seq = 1; if (statistics == null || statistics.getCount() == 0) { @@ -150,6 +186,15 @@ public class WaterLevelAnalyzer implements ReportAnalyzer { statistics.getMax().toPlainString(), unit, statistics.getMin().toPlainString(), unit, statistics.getAvg().toPlainString(), unit))); + + // 添加控制值对比结论 + if (result.getControlValue() != null && result.getControlValue().compareTo(BigDecimal.ZERO) != 0) { + BigDecimal ratio = statistics.getDelta().abs().divide(result.getControlValue(), 4, BigDecimal.ROUND_HALF_UP) + .multiply(new BigDecimal("100")); + conclusions.add(new Conclusion(seq++, String.format( + "水位变化占控制值比例:%.2f%%(控制值:%s%s),需关注渗流场变化。", + ratio.doubleValue(), result.getControlValue().toPlainString(), result.getControlValueUnit()))); + } } if (trend != null && StringUtils.isNotEmpty(trend.getDescription())) { @@ -165,24 +210,132 @@ public class WaterLevelAnalyzer implements ReportAnalyzer { result.setConclusions(conclusions); } - private void buildChart(AnalysisResult result, ReportContext context, String primaryIdentity) { - List data = context.getMetricDataList().stream() - .filter(d -> Objects.equals(d.getIdentity(), primaryIdentity)) - .collect(Collectors.toList()); - Statistics statistics = result.getStatistics().get(primaryIdentity); - String metricName = statistics == null ? primaryIdentity : statistics.getModelName(); - String unit = statistics == null ? "" : (statistics.getUnit() == null ? "" : statistics.getUnit()); - byte[] png = chartGenerator.timeSeriesChart("水位时程曲线", metricName, unit, data); - result.getCharts().put("timeSeriesChart", png); + /** + * 生成项目概况总结 + */ + private String buildSummary(Statistics statistics, TrendAnalysis trend, String unit, TimeWindow window) { + if (statistics == null || statistics.getCount() == 0) { + return "统计期内无有效监测数据。"; + } + + String direction = trend == null ? "待分析" : directionText(trend.getDirection()); + BigDecimal rate = rateCalculator.ratePerDay(statistics, + Duration.between(window.getStartTime(), window.getEndTime()).getSeconds()); + + return String.format( + "本期水位监测数据显示:当前水位为%s%s,较初始值变化%s%s,水位变化速率为%s%s/天,水位呈%s趋势。" + + "统计期内最高水位%s%s,最低水位%s%s,平均水位%s%s。渗流监测正常。", + statistics.getCurrent().toPlainString(), unit, + statistics.getDelta().toPlainString(), unit, + rate.toPlainString(), unit, + direction, + statistics.getMax().toPlainString(), unit, + statistics.getMin().toPlainString(), unit, + statistics.getAvg().toPlainString(), unit + ); } - private ThingsModel findModel(ReportContext context, String identity) { - if (context.getThingsModels() == null) { - return null; + /** + * 生成专业分析建议 + */ + private String buildProfessionalAdvice(Statistics statistics, TrendAnalysis trend, WarningAnalysis warning, String unit) { + if (statistics == null || statistics.getCount() == 0) { + return "建议检查设备上报状态,确保数据采集正常。"; } - return context.getThingsModels().stream() - .filter(m -> Objects.equals(m.getIdentifier(), identity)) - .findFirst().orElse(null); + + StringBuilder advice = new StringBuilder(); + advice.append("专业分析:"); + + // 基于趋势方向给出建议 + if (trend != null) { + if (TrendAnalysis.DIRECTION_UP.equals(trend.getDirection())) { + advice.append("水位呈上升趋势,需关注浸润线位置变化,评估渗流场稳定性。"); + } else if (TrendAnalysis.DIRECTION_DOWN.equals(trend.getDirection())) { + advice.append("水位呈下降趋势,渗流场趋于稳定。"); + } else { + advice.append("水位变化平稳,渗流场稳定。"); + } + } + + // 基于水位变化量级给出建议 + if (statistics.getDelta().abs().compareTo(new BigDecimal("1")) > 0) { + advice.append("水位变化幅度较大,建议分析水位变化原因(降雨、抽排水等),评估对边坡稳定性的影响。"); + } + + // 基于预警状态给出建议 + if (warning != null) { + if (WarningAnalysis.STATUS_ALARM.equals(warning.getStatus())) { + advice.append("当前处于报警状态,建议立即启动排水措施,组织专业人员进行渗流安全评估。"); + } else if (WarningAnalysis.STATUS_WARNING.equals(warning.getStatus())) { + advice.append("当前处于预警状态,建议加强巡视检查,准备应急排水预案。"); + } + } + + return advice.toString(); + } + + /** + * 将预警状态转换为安全等级 + */ + private String convertWarningToSafetyLevel(String warningStatus) { + if (WarningAnalysis.STATUS_ALARM.equals(warningStatus)) { + return "DANGER"; + } else if (WarningAnalysis.STATUS_WARNING.equals(warningStatus)) { + return "WARNING"; + } + return "SAFE"; + } + + /** + * 组装多测点数据(支持多物模型标识符) + */ + private List buildMetricPoints(AnalysisResult result, ReportContext context) { + List points = new ArrayList<>(); + + for (Map.Entry entry : result.getStatistics().entrySet()) { + String identity = entry.getKey(); + Statistics stats = entry.getValue(); + + MetricPointData point = MetricPointData.builder() + .identifier(identity) + .modelName(stats.getModelName()) + .initialValue(stats.getInitial()) + .initialUnit(stats.getUnit()) + .currentValue(stats.getCurrent()) + .currentUnit(stats.getUnit()) + .changeValue(stats.getDelta()) + .changeUnit(stats.getUnit()) + .maxValue(stats.getMax()) + .minValue(stats.getMin()) + .avgValue(stats.getAvg()) + .build(); + + // 设置预警状态 + WarningAnalysis warning = result.getWarnings().get(identity); + if (warning != null) { + point.setWarningStatus(warning.getStatus()); + } + + // 计算变化速率 + TimeWindow window = context.getTimeWindow(); + BigDecimal rate = rateCalculator.ratePerDay(stats, + Duration.between(window.getStartTime(), window.getEndTime()).getSeconds()); + point.setChangeRate(rate); + point.setRateUnit(stats.getUnit() + "/天"); + + points.add(point); + } + + return points; + } + + private void buildChart(AnalysisResult result, ReportContext context, String primaryIdentity) { + // 主指标单图 + 其余指标按单位分组多曲线图(展示所有物模型数据) + Map> byIdentity = context.getMetricDataList().stream() + .filter(d -> d.getIdentity() != null) + .collect(Collectors.groupingBy(MetricData::getIdentity, LinkedHashMap::new, Collectors.toList())); + Map charts = metricChartBuilder.build(result.getStatistics(), primaryIdentity, byIdentity); + result.getCharts().putAll(metricChartBuilder.fillPlaceholders(charts)); } private String statusText(String status) { diff --git a/fastbee-report/src/main/java/com/fastbee/report/api/ReportEngine.java b/fastbee-report/src/main/java/com/fastbee/report/api/ReportEngine.java deleted file mode 100644 index 71e431f4..00000000 --- a/fastbee-report/src/main/java/com/fastbee/report/api/ReportEngine.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.fastbee.report.api; - -/** - * 报告引擎入口 - * - * @author FastBee - * @date 2026-08-16 - */ -public interface ReportEngine { - - /** - * 生成报告(V1 同步生成,返回报告编号 / 文件地址 / 过期时间) - */ - ReportResult generate(ReportRequest request); -} diff --git a/fastbee-report/src/main/java/com/fastbee/report/api/ReportRequest.java b/fastbee-report/src/main/java/com/fastbee/report/api/ReportRequest.java index b3596115..47eaca6f 100644 --- a/fastbee-report/src/main/java/com/fastbee/report/api/ReportRequest.java +++ b/fastbee-report/src/main/java/com/fastbee/report/api/ReportRequest.java @@ -35,9 +35,9 @@ public class ReportRequest implements Serializable { @ApiModelProperty("周期锚点日期 yyyy-MM-dd(默认今天,仅周期预设时使用)") private String anchorDate; - @ApiModelProperty("模板编码,如 DEVICE_CRACK_METER_V1") + @ApiModelProperty("模板编码,如 DEVICE_CRACK_METER_V2") private String templateCode; - @ApiModelProperty("导出格式(V1 仅支持 WORD)") + @ApiModelProperty("导出格式(目前仅支持 WORD)") private String format; } diff --git a/fastbee-report/src/main/java/com/fastbee/report/chart/ChartGenerator.java b/fastbee-report/src/main/java/com/fastbee/report/chart/ChartGenerator.java index 1f242ab6..615844a0 100644 --- a/fastbee-report/src/main/java/com/fastbee/report/chart/ChartGenerator.java +++ b/fastbee-report/src/main/java/com/fastbee/report/chart/ChartGenerator.java @@ -18,6 +18,7 @@ import java.io.ByteArrayOutputStream; import java.io.IOException; import java.util.Comparator; import java.util.List; +import java.util.Map; /** * Java 图表生成器(JFreeChart → PNG,禁止前端生成图表回传后端) @@ -77,4 +78,58 @@ public class ChartGenerator { } return out.toByteArray(); } + + /** + * 生成多曲线时程图 PNG(同名单位的多指标合并展示) + * + * @param title 图表标题 + * @param unit 纵轴单位 + * @param seriesMap 曲线名 → 时序数据(保持插入顺序) + */ + public byte[] multiSeriesChart(String title, String unit, Map> seriesMap) { + TimeSeriesCollection dataset = new TimeSeriesCollection(); + if (seriesMap != null) { + for (Map.Entry> entry : seriesMap.entrySet()) { + TimeSeries series = new TimeSeries(entry.getKey()); + if (entry.getValue() != null) { + entry.getValue().stream() + .filter(d -> d.getTime() != null && d.getNumericValue() != null) + .sorted(Comparator.comparing(MetricData::getTime)) + .forEach(d -> series.add(new Millisecond(d.getTime()), d.getNumericValue().doubleValue())); + } + dataset.addSeries(series); + } + } + + JFreeChart chart = ChartFactory.createTimeSeriesChart( + title, "时间", unit == null ? "数值" : unit, dataset, + true, true, false); + chart.setBackgroundPaint(Color.WHITE); + chart.getTitle().setFont(CN_TITLE_FONT); + + XYPlot plot = chart.getXYPlot(); + plot.setBackgroundPaint(Color.WHITE); + plot.setDomainGridlinePaint(Color.LIGHT_GRAY); + plot.setRangeGridlinePaint(Color.LIGHT_GRAY); + plot.getDomainAxis().setLabelFont(CN_FONT); + plot.getDomainAxis().setTickLabelFont(new Font("SansSerif", Font.PLAIN, 11)); + plot.getRangeAxis().setLabelFont(CN_FONT); + plot.getRangeAxis().setTickLabelFont(new Font("SansSerif", Font.PLAIN, 11)); + chart.getLegend().setItemFont(CN_FONT); + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + try { + ChartUtils.writeChartAsPNG(out, chart, 900, 360); + } catch (IOException e) { + throw new IllegalStateException("图表生成失败", e); + } + return out.toByteArray(); + } + + /** + * 生成空白占位图(无数据时保持图文排版稳定) + */ + public byte[] blankChart(String title) { + return multiSeriesChart(title, null, new java.util.LinkedHashMap<>()); + } } diff --git a/fastbee-report/src/main/java/com/fastbee/report/chart/MetricChartBuilder.java b/fastbee-report/src/main/java/com/fastbee/report/chart/MetricChartBuilder.java new file mode 100644 index 00000000..eb6f4456 --- /dev/null +++ b/fastbee-report/src/main/java/com/fastbee/report/chart/MetricChartBuilder.java @@ -0,0 +1,108 @@ +package com.fastbee.report.chart; + +import com.fastbee.report.data.MetricData; +import com.fastbee.report.model.Statistics; +import org.springframework.stereotype.Component; + +import javax.annotation.Resource; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 监测指标图表构建器(主指标单图 + 其余指标按单位分组多曲线图) + * + *

约定 charts 的 key:

+ *
    + *
  • {@code timeSeriesChart}:主监测指标时程曲线(与模板兼容)
  • + *
  • {@code chart2} / {@code chart3}:其余指标按单位分组的时程曲线(最多 2 组)
  • + *
+ * + * @author FastBee + * @date 2026-09-10 + */ +@Component +public class MetricChartBuilder { + + /** 附图最大数量(模板预留 chart2 / chart3 两个位置) */ + private static final int MAX_EXTRA_CHARTS = 2; + + @Resource + private ChartGenerator chartGenerator; + + /** + * 构建图表集合 + * + * @param statisticsMap 全部指标统计 + * @param primaryIdentity 主指标标识符 + * @param byIdentity 指标 → 时序数据 + * @return key 为 timeSeriesChart / chart2 / chart3 的 PNG 映射 + */ + public Map build(Map statisticsMap, + String primaryIdentity, + Map> byIdentity) { + Map charts = new LinkedHashMap<>(); + if (statisticsMap == null || statisticsMap.isEmpty()) { + return charts; + } + + // 1. 主监测指标:单独一张图 + Statistics primary = statisticsMap.get(primaryIdentity); + if (primary == null) { + primary = statisticsMap.values().iterator().next(); + primaryIdentity = primary.getIdentifier(); + } + String primaryUnit = primary.getUnit() == null ? "" : primary.getUnit(); + String primaryName = primary.getModelName() == null ? primaryIdentity : primary.getModelName(); + charts.put("timeSeriesChart", chartGenerator.timeSeriesChart( + primaryName + "时程曲线", primaryName, primaryUnit, + byIdentity.getOrDefault(primaryIdentity, new ArrayList<>()))); + + // 2. 其余指标:按单位分组,同单位合并为一张多曲线图 + Map>> groupedByUnit = new LinkedHashMap<>(); + for (Map.Entry entry : statisticsMap.entrySet()) { + String identity = entry.getKey(); + if (identity.equals(primaryIdentity)) { + continue; + } + Statistics statistics = entry.getValue(); + // 无数据的指标不生成图表 + if (statistics == null || statistics.getCount() == 0) { + continue; + } + String unit = statistics.getUnit() == null ? "" : statistics.getUnit(); + String name = statistics.getModelName() == null ? identity : statistics.getModelName(); + groupedByUnit.computeIfAbsent(unit, k -> new LinkedHashMap<>()) + .put(name, byIdentity.getOrDefault(identity, new ArrayList<>())); + } + + int index = 2; + for (Map.Entry>> entry : groupedByUnit.entrySet()) { + if (index > MAX_EXTRA_CHARTS + 1) { + break; + } + String unit = entry.getKey(); + String title = entry.getValue().keySet().iterator().next() + "等指标时程曲线"; + if (entry.getValue().size() == 1) { + title = entry.getValue().keySet().iterator().next() + "时程曲线"; + } + charts.put("chart" + index, + chartGenerator.multiSeriesChart(title, unit, entry.getValue())); + index++; + } + return charts; + } + + /** + * 补齐模板预留的图表位,避免模板标签无对应数据导致渲染异常 + */ + public Map fillPlaceholders(Map charts) { + Map result = new LinkedHashMap<>(charts); + for (int i = 2; i <= MAX_EXTRA_CHARTS + 1; i++) { + String key = "chart" + i; + result.putIfAbsent(key, chartGenerator.blankChart("暂无数据")); + } + return result; + } +} diff --git a/fastbee-report/src/main/java/com/fastbee/report/core/ReportAssembler.java b/fastbee-report/src/main/java/com/fastbee/report/core/ReportAssembler.java index 988b6b00..c513eebe 100644 --- a/fastbee-report/src/main/java/com/fastbee/report/core/ReportAssembler.java +++ b/fastbee-report/src/main/java/com/fastbee/report/core/ReportAssembler.java @@ -8,6 +8,8 @@ import com.fastbee.report.model.ReportModel; import com.fastbee.report.model.Statistics; import org.springframework.stereotype.Component; +import java.util.UUID; + /** * 报告模型装配器:AnalysisResult + 上下文 → ReportModel * @@ -18,18 +20,29 @@ import org.springframework.stereotype.Component; public class ReportAssembler { public ReportModel assemble(ReportContext context, AnalysisResult analysis) { - String templateCode = StringUtils.isNotEmpty(context.getRequest().getTemplateCode()) - ? context.getRequest().getTemplateCode() - : "DEVICE_" + context.getCategory().name() + "_V1"; + // 默认使用专业模版,如未配置 templateCode 则根据监测分类自动选择 + String templateCode = "DEVICE_" + context.getCategory().name() + "_V2"; ReportPeriod period = context.getTimeWindow().getPeriod(); String serialNumber = context.getDevice().getSerialNumber(); if (StringUtils.isEmpty(serialNumber)) { serialNumber = String.valueOf(context.getDevice().getDeviceId()); } - String fileName = serialNumber + "-" + period.getDesc() + "-监测分析报告.docx"; + // 末尾追加随机后缀,避免同一设备同一周期重复导出时文件名相同 + String randomSuffix = UUID.randomUUID().toString().replace("-", "").substring(0, 6); + String fileName = context.getCategory().getDesc() + "-监测分析报告-" + randomSuffix + ".docx"; - Statistics primary = analysis.getStatistics().values().stream().findFirst().orElse(null); + // 主监测指标:优先使用 Analyzer 解析的 primaryIdentifier(与概况/结论/统计指标口径一致) + Statistics primary = null; + if (StringUtils.isNotEmpty(analysis.getPrimaryIdentifier())) { + primary = analysis.getStatistics().get(analysis.getPrimaryIdentifier()); + } + if (primary == null) { + primary = analysis.getStatistics().values().stream().findFirst().orElse(null); + } + + // 添加监测分类描述 + String categoryDesc = context.getCategory().getDesc() + "监测分析报告"; return ReportModel.builder() .reportNo(context.getReportNo()) @@ -43,6 +56,7 @@ public class ReportAssembler { .productName(context.getProduct().getProductName()) .deviceId(context.getDevice().getDeviceId()) .category(context.getCategory()) + .categoryDesc(categoryDesc) // 添加分类描述 .metricName(primary == null ? null : primary.getModelName()) .metricUnit(primary == null ? null : primary.getUnit()) .analysis(analysis) diff --git a/fastbee-report/src/main/java/com/fastbee/report/core/ReportContext.java b/fastbee-report/src/main/java/com/fastbee/report/core/ReportContext.java index b19ca45e..c2b4974e 100644 --- a/fastbee-report/src/main/java/com/fastbee/report/core/ReportContext.java +++ b/fastbee-report/src/main/java/com/fastbee/report/core/ReportContext.java @@ -42,4 +42,10 @@ public class ReportContext { private Map thresholds; private String reportNo; + + /** 创建人用户名(从主线程传递,避免异步线程无法访问 SecurityContext) */ + private String createBy; + + /** 租户ID(从主线程传递) */ + private Long tenantId; } diff --git a/fastbee-report/src/main/java/com/fastbee/report/core/ReportContextBuilder.java b/fastbee-report/src/main/java/com/fastbee/report/core/ReportContextBuilder.java index a375c86c..1dfd5176 100644 --- a/fastbee-report/src/main/java/com/fastbee/report/core/ReportContextBuilder.java +++ b/fastbee-report/src/main/java/com/fastbee/report/core/ReportContextBuilder.java @@ -4,6 +4,8 @@ import com.fastbee.common.enums.MonitorCategory; import com.fastbee.iot.domain.Device; import com.fastbee.iot.domain.Product; import com.fastbee.iot.domain.ThingsModel; +import com.fastbee.common.utils.SecurityUtils; +import com.fastbee.common.core.domain.model.LoginUser; import com.fastbee.iot.mapper.SubGatewayMapper; import com.fastbee.iot.service.IDeviceService; import com.fastbee.iot.service.IProductService; @@ -62,14 +64,14 @@ public class ReportContextBuilder { throw new ReportException("设备不存在: " + request.getDeviceId()); } - // 网关设备特殊处理:使用第一个有监测分类的子设备构建上下文 + // 网关设备不允许直接生成报表:网关是通信中继,真正的监测设备是子设备 Integer deviceType = device.getDeviceType(); if (deviceType != null && deviceType == DEVICE_TYPE_GATEWAY) { - Device subDevice = resolveFirstMonitorSubDevice(request.getDeviceId()); - if (subDevice == null) { - throw new ReportException("网关设备下无可用监测子设备,请选择具体监测设备"); - } - device = subDevice; + List subDeviceIds = subGatewayMapper.selectSubDeviceIdsByGwDeviceId(request.getDeviceId()); + String tip = (subDeviceIds == null || subDeviceIds.isEmpty()) + ? "该网关下未绑定子设备" + : "请选择具体监测设备(网关下有 " + subDeviceIds.size() + " 个子设备)"; + throw new ReportException("网关设备不能直接生成报告。" + tip); } if (device.getProductId() == null) { @@ -110,6 +112,24 @@ public class ReportContextBuilder { context.setMetricDataList(metricDataList); context.setThresholds(thresholds); context.setReportNo(reportNo); + + // 从主线程获取用户信息,传递到异步线程 + String username = null; + Long tenantId = null; + try { + LoginUser loginUser = SecurityUtils.getLoginUser(); + if (loginUser != null) { + username = loginUser.getUsername(); + if (loginUser.getUser() != null && loginUser.getUser().getDept() != null) { + tenantId = loginUser.getUser().getDept().getDeptUserId(); + } + } + } catch (Exception ignored) { + // 非登录态(如后台任务)时保持为 null + } + context.setCreateBy(username); + context.setTenantId(tenantId); + return context; } diff --git a/fastbee-report/src/main/java/com/fastbee/report/core/DefaultReportEngine.java b/fastbee-report/src/main/java/com/fastbee/report/executor/ReportAsyncExecutor.java similarity index 74% rename from fastbee-report/src/main/java/com/fastbee/report/core/DefaultReportEngine.java rename to fastbee-report/src/main/java/com/fastbee/report/executor/ReportAsyncExecutor.java index 6acc9b06..a51c6f81 100644 --- a/fastbee-report/src/main/java/com/fastbee/report/core/DefaultReportEngine.java +++ b/fastbee-report/src/main/java/com/fastbee/report/executor/ReportAsyncExecutor.java @@ -1,4 +1,4 @@ -package com.fastbee.report.core; +package com.fastbee.report.executor; import com.fastbee.common.core.redis.RedisCache; import com.fastbee.common.enums.MonitorCategory; @@ -7,10 +7,11 @@ import com.fastbee.oss.entity.UploadResult; import com.fastbee.oss.service.OssFactory; import com.fastbee.report.analyzer.ReportAnalyzer; import com.fastbee.report.analyzer.ReportAnalyzerFactory; -import com.fastbee.report.api.ReportEngine; import com.fastbee.report.api.ReportRequest; -import com.fastbee.report.api.ReportResult; import com.fastbee.report.category.MonitorCategoryResolver; +import com.fastbee.report.core.ReportAssembler; +import com.fastbee.report.core.ReportContext; +import com.fastbee.report.core.ReportContextBuilder; import com.fastbee.report.exception.ReportException; import com.fastbee.report.model.AnalysisResult; import com.fastbee.report.model.ReportModel; @@ -20,6 +21,7 @@ import com.fastbee.report.record.ReportRecord; import com.fastbee.report.record.ReportRecordService; import com.fastbee.report.renderer.ReportRenderer; import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; import org.springframework.stereotype.Component; import javax.annotation.Resource; @@ -31,14 +33,14 @@ import java.util.Date; import java.util.concurrent.ThreadLocalRandom; /** - * 默认报告引擎:周期/分类解析 → 上下文装配 → 分析 → 渲染 → MinIO 上传 → 落库 + * 报告异步执行器 * * @author FastBee - * @date 2026-08-16 + * @date 2026-09-10 */ @Slf4j @Component -public class DefaultReportEngine implements ReportEngine { +public class ReportAsyncExecutor { private static final String WORD_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; @@ -70,8 +72,16 @@ public class DefaultReportEngine implements ReportEngine { @Resource private RedisCache redisCache; - @Override - public ReportResult generate(ReportRequest request) { + @Resource + private ThreadPoolTaskExecutor threadPoolTaskExecutor; + + /** + * 异步生成报告 + * + * @param request 报告请求 + * @return 报告编号(立即返回) + */ + public String submitAsyncTask(ReportRequest request) { validate(request); TimeWindow window = periodResolver.resolve(request); MonitorCategory category = categoryResolver.resolveByDeviceId(request.getDeviceId()); @@ -81,36 +91,44 @@ public class DefaultReportEngine implements ReportEngine { String reportNo = generateReportNo(); ReportContext context = contextBuilder.build(request, window, category, reportNo); + + // 创建 CREATING 状态记录 ReportRecord record = reportRecordService.createCreating(context); + + // 提交异步任务到线程池 + final ReportContext finalContext = context; + final ReportRequest finalRequest = request; + threadPoolTaskExecutor.execute(() -> asyncGenerate(finalRequest, finalContext, record)); + + return reportNo; + } + + /** + * 异步执行生成逻辑 + */ + private void asyncGenerate(ReportRequest request, ReportContext context, ReportRecord record) { try { - ReportAnalyzer analyzer = analyzerFactory.get(category); + String reportNo = context.getReportNo(); + log.info("开始异步生成报告 reportNo={} deviceId={}", reportNo, request.getDeviceId()); + + ReportAnalyzer analyzer = analyzerFactory.get(context.getCategory()); AnalysisResult analysis = analyzer.analyze(context, request); ReportModel model = reportAssembler.assemble(context, analysis); byte[] docBytes = reportRenderer.render(model); - String path = buildObjectPath(window, request.getDeviceId(), reportNo); + String path = buildObjectPath(request.getDeviceId(), reportNo); UploadResult upload = OssFactory.instance(redisCache) .upload(docBytes, path, WORD_CONTENT_TYPE); Date expireTime = new Date(System.currentTimeMillis() + RETENTION_DAYS * 24L * 3600 * 1000); + reportRecordService.markSuccess(record, model.getFileName(), upload.getUrl(), expireTime); log.info("报告生成成功 reportNo={} fileUrl={}", reportNo, upload.getUrl()); - return ReportResult.builder() - .reportNo(reportNo) - .status(ReportRecord.STATUS_SUCCESS) - .fileName(model.getFileName()) - .fileUrl(upload.getUrl()) - .expireTime(expireTime) - .build(); } catch (Exception e) { - log.error("报告生成失败 reportNo={} deviceId={}", reportNo, request.getDeviceId(), e); + log.error("报告生成失败 reportNo={} deviceId={}", context.getReportNo(), request.getDeviceId(), e); String message = e instanceof ReportException ? e.getMessage() : (e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage()); reportRecordService.markFailed(record, message); - if (e instanceof ReportException) { - throw (ReportException) e; - } - throw new ReportException("报告生成失败: " + message, e); } } @@ -123,7 +141,7 @@ public class DefaultReportEngine implements ReportEngine { } if (StringUtils.isNotEmpty(request.getFormat()) && !"WORD".equalsIgnoreCase(request.getFormat())) { - throw new ReportException("V1 仅支持 WORD 格式导出"); + throw new ReportException("目前仅支持 WORD 格式导出"); } } @@ -132,8 +150,8 @@ public class DefaultReportEngine implements ReportEngine { + "-" + String.format("%04d", ThreadLocalRandom.current().nextInt(10000)); } - private String buildObjectPath(TimeWindow window, Long deviceId, String reportNo) { + private String buildObjectPath(Long deviceId, String reportNo) { LocalDate today = LocalDate.now(ZoneOffset.ofHours(8)); return "report/" + today.format(PATH_DATE) + "/device/" + deviceId + "/" + reportNo + ".docx"; } -} +} \ No newline at end of file diff --git a/fastbee-report/src/main/java/com/fastbee/report/model/AnalysisResult.java b/fastbee-report/src/main/java/com/fastbee/report/model/AnalysisResult.java index 74423ef1..2d524335 100644 --- a/fastbee-report/src/main/java/com/fastbee/report/model/AnalysisResult.java +++ b/fastbee-report/src/main/java/com/fastbee/report/model/AnalysisResult.java @@ -2,6 +2,7 @@ package com.fastbee.report.model; import lombok.Data; +import java.math.BigDecimal; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; @@ -16,6 +17,30 @@ import java.util.Map; @Data public class AnalysisResult { + /** 主监测指标标识符(统一由 Analyzer 解析,ReportAssembler/WordRenderer 共用) */ + private String primaryIdentifier; + + /** 项目概况总结(开头段落,概括关键数据) */ + private String summary; + + /** 专业分析建议(基于设备类型和数据分析的工程建议) */ + private String professionalAdvice; + + /** 安全评估等级(SAFE/WARNING/DANGER/CRITICAL) */ + private String safetyLevel; + + /** 安全评估说明 */ + private String safetyDescription; + + /** 控制值(安全参考基准) */ + private BigDecimal controlValue; + + /** 控制值单位 */ + private String controlValueUnit; + + /** 多指标对比数据(用于详细表格展示) */ + private List metricPoints = new ArrayList<>(); + /** 每个物模型标识符对应的统计指标 */ private Map statistics = new LinkedHashMap<>(); diff --git a/fastbee-report/src/main/java/com/fastbee/report/model/MetricPointData.java b/fastbee-report/src/main/java/com/fastbee/report/model/MetricPointData.java new file mode 100644 index 00000000..0153547f --- /dev/null +++ b/fastbee-report/src/main/java/com/fastbee/report/model/MetricPointData.java @@ -0,0 +1,72 @@ +package com.fastbee.report.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; + +/** + * 测点数据(用于详细表格展示,支持多测点设备) + * + * @author FastBee + * @date 2026-09-09 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class MetricPointData { + + /** 测点编号(如 MS13-1) */ + private String pointNo; + + /** 测点名称(如 红线/白线/绿线) */ + private String pointName; + + /** 物模型标识符 */ + private String identifier; + + /** 物模型名称 */ + private String modelName; + + /** 初始值 */ + private BigDecimal initialValue; + + /** 初始值单位 */ + private String initialUnit; + + /** 当前值 */ + private BigDecimal currentValue; + + /** 当前值单位 */ + private String currentUnit; + + /** 变化量 */ + private BigDecimal changeValue; + + /** 变化量单位 */ + private String changeUnit; + + /** 变化速率 */ + private BigDecimal changeRate; + + /** 变化速率单位 */ + private String rateUnit; + + /** 最大值 */ + private BigDecimal maxValue; + + /** 最小值 */ + private BigDecimal minValue; + + /** 平均值 */ + private BigDecimal avgValue; + + /** 预警状态(NORMAL/WARNING/ALARM) */ + private String warningStatus; + + /** 备注 */ + private String remark; +} \ No newline at end of file diff --git a/fastbee-report/src/main/java/com/fastbee/report/model/ReportModel.java b/fastbee-report/src/main/java/com/fastbee/report/model/ReportModel.java index a96d6f17..4189d497 100644 --- a/fastbee-report/src/main/java/com/fastbee/report/model/ReportModel.java +++ b/fastbee-report/src/main/java/com/fastbee/report/model/ReportModel.java @@ -5,6 +5,7 @@ import com.fastbee.common.enums.ReportPeriod; import lombok.Builder; import lombok.Data; +import java.math.BigDecimal; import java.util.Date; /** @@ -49,5 +50,14 @@ public class ReportModel { /** 主监测指标单位 */ private String metricUnit; + /** 监测分类描述(如"裂缝计监测分析报告") */ + private String categoryDesc; + + /** 控制值(安全参考基准) */ + private BigDecimal controlValue; + + /** 控制值单位 */ + private String controlValueUnit; + private AnalysisResult analysis; } diff --git a/fastbee-report/src/main/java/com/fastbee/report/record/ReportRecordServiceImpl.java b/fastbee-report/src/main/java/com/fastbee/report/record/ReportRecordServiceImpl.java index c0f6da40..6530ebff 100644 --- a/fastbee-report/src/main/java/com/fastbee/report/record/ReportRecordServiceImpl.java +++ b/fastbee-report/src/main/java/com/fastbee/report/record/ReportRecordServiceImpl.java @@ -1,8 +1,6 @@ package com.fastbee.report.record; -import com.fastbee.common.core.domain.model.LoginUser; import com.fastbee.common.utils.DateUtils; -import com.fastbee.common.utils.SecurityUtils; import com.fastbee.report.core.ReportContext; import com.fastbee.report.mapper.ReportRecordMapper; import org.springframework.stereotype.Service; @@ -38,8 +36,8 @@ public class ReportRecordServiceImpl implements ReportRecordService { record.setStartTime(toGmt8Date(context.getTimeWindow().getStartTime())); record.setEndTime(toGmt8Date(context.getTimeWindow().getEndTime())); record.setStatus(ReportRecord.STATUS_CREATING); - record.setTenantId(currentTenantId()); - record.setCreateBy(SecurityUtils.getUsername()); + record.setTenantId(context.getTenantId()); + record.setCreateBy(context.getCreateBy()); record.setCreateTime(now); record.setExpireTime(new Date(now.getTime() + RETENTION_DAYS * 24L * 3600 * 1000)); reportRecordMapper.insert(record); @@ -58,7 +56,8 @@ public class ReportRecordServiceImpl implements ReportRecordService { update.setFileName(fileName); update.setFileUrl(fileUrl); update.setExpireTime(expireTime); - update.setUpdateBy(SecurityUtils.getUsername()); + // 异步线程中无法访问 SecurityContext,使用创建时的用户名 + update.setUpdateBy(record.getCreateBy()); update.setUpdateTime(DateUtils.getNowDate()); reportRecordMapper.updateById(update); } @@ -69,7 +68,8 @@ public class ReportRecordServiceImpl implements ReportRecordService { update.setId(record.getId()); update.setStatus(ReportRecord.STATUS_FAILED); update.setErrorMessage(errorMessage); - update.setUpdateBy(SecurityUtils.getUsername()); + // 异步线程中无法访问 SecurityContext,使用创建时的用户名 + update.setUpdateBy(record.getCreateBy()); update.setUpdateTime(DateUtils.getNowDate()); reportRecordMapper.updateById(update); } @@ -86,17 +86,4 @@ public class ReportRecordServiceImpl implements ReportRecordService { .eq(ReportRecord::getReportNo, reportNo) .last("limit 1")); } - - private Long currentTenantId() { - try { - LoginUser loginUser = SecurityUtils.getLoginUser(); - if (loginUser != null && loginUser.getUser() != null - && loginUser.getUser().getDept() != null) { - return loginUser.getUser().getDept().getDeptUserId(); - } - } catch (Exception ignored) { - // 非登录态(如后台任务)时租户为空 - } - return null; - } } diff --git a/fastbee-report/src/main/java/com/fastbee/report/renderer/WordRenderer.java b/fastbee-report/src/main/java/com/fastbee/report/renderer/WordRenderer.java index c9cab01e..d7469d28 100644 --- a/fastbee-report/src/main/java/com/fastbee/report/renderer/WordRenderer.java +++ b/fastbee-report/src/main/java/com/fastbee/report/renderer/WordRenderer.java @@ -6,14 +6,20 @@ import com.deepoove.poi.data.ByteArrayPictureRenderData; import com.deepoove.poi.data.PictureType; import com.deepoove.poi.data.style.PictureStyle; import com.deepoove.poi.plugin.table.LoopRowTableRenderPolicy; +import com.fastbee.common.utils.StringUtils; import com.fastbee.report.exception.ReportException; import com.fastbee.report.model.AnalysisResult; import com.fastbee.report.model.Conclusion; +import com.fastbee.report.model.MetricPointData; import com.fastbee.report.model.QualityAnalysis; import com.fastbee.report.model.ReportModel; import com.fastbee.report.model.Statistics; import com.fastbee.report.model.TrendAnalysis; import com.fastbee.report.model.WarningAnalysis; +import org.apache.poi.xwpf.usermodel.XWPFDocument; +import org.apache.poi.xwpf.usermodel.XWPFTable; +import org.apache.poi.xwpf.usermodel.XWPFTableCell; +import org.apache.poi.xwpf.usermodel.XWPFTableRow; import org.springframework.stereotype.Component; import javax.annotation.Resource; @@ -21,6 +27,7 @@ import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.math.BigDecimal; +import java.math.RoundingMode; import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.HashMap; @@ -45,12 +52,14 @@ public class WordRenderer implements ReportRenderer { Map data = buildData(model); Configure config = Configure.builder() .bind("conclusions", new LoopRowTableRenderPolicy()) + .bind("metricPoints", new LoopRowTableRenderPolicy()) // 添加多测点数据绑定 .build(); XWPFTemplate tpl = XWPFTemplate.compile(new ByteArrayInputStream(template), config).render(data); try { ByteArrayOutputStream out = new ByteArrayOutputStream(); tpl.write(out); - return out.toByteArray(); + // 后处理:清除循环表格残留的空行,避免排版出现空白行 + return cleanupEmptyTableRows(out.toByteArray()); } catch (IOException e) { throw new ReportException("Word 渲染失败: " + e.getMessage(), e); } finally { @@ -62,10 +71,45 @@ public class WordRenderer implements ReportRenderer { } } + /** + * 清除表格中的空白行(poi-tl 循环表格的标记行渲染后会残留为空行) + */ + private byte[] cleanupEmptyTableRows(byte[] docx) { + try (XWPFDocument document = new XWPFDocument(new ByteArrayInputStream(docx))) { + for (XWPFTable table : document.getTables()) { + List rows = table.getRows(); + for (int i = rows.size() - 1; i >= 0; i--) { + if (isRowBlank(rows.get(i))) { + table.removeRow(i); + } + } + } + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + document.write(buffer); + return buffer.toByteArray(); + } catch (Exception e) { + // 后处理失败时返回原始文档,不影响报告生成 + return docx; + } + } + + /** + * 判断表格行是否完全空白 + */ + private boolean isRowBlank(XWPFTableRow row) { + for (XWPFTableCell cell : row.getTableCells()) { + if (cell.getText() != null && !cell.getText().trim().isEmpty()) { + return false; + } + } + return true; + } + private Map buildData(ReportModel model) { Map data = new HashMap<>(); AnalysisResult analysis = model.getAnalysis() == null ? new AnalysisResult() : model.getAnalysis(); - Statistics primary = analysis.getStatistics().values().stream().findFirst().orElse(null); + // 主监测指标:与概况/结论口径一致(Analyzer 解析的 primaryIdentifier) + Statistics primary = resolvePrimary(analysis); TrendAnalysis trend = primary == null ? null : analysis.getTrends().get(primary.getIdentifier()); QualityAnalysis quality = primary == null ? null : analysis.getQualities().get(primary.getIdentifier()); WarningAnalysis warning = primary == null ? null : analysis.getWarnings().get(primary.getIdentifier()); @@ -95,6 +139,15 @@ public class WordRenderer implements ReportRenderer { statistics.put("totalDelta", str(primary.getTotalDelta())); statistics.put("count", primary.getCount()); statistics.put("unit", primary.getUnit() == null ? "" : primary.getUnit()); + // 带单位的展示值(单位为空时不产生多余空格) + statistics.put("currentWithUnit", withUnit(str(primary.getCurrent()), primary.getUnit())); + statistics.put("initialWithUnit", withUnit(str(primary.getInitial()), primary.getUnit())); + statistics.put("maxWithUnit", withUnit(str(primary.getMax()), primary.getUnit())); + statistics.put("minWithUnit", withUnit(str(primary.getMin()), primary.getUnit())); + statistics.put("avgWithUnit", withUnit(str(primary.getAvg()), primary.getUnit())); + statistics.put("medianWithUnit", withUnit(str(primary.getMedian()), primary.getUnit())); + statistics.put("deltaWithUnit", withUnit(str(primary.getDelta()), primary.getUnit())); + statistics.put("totalDeltaWithUnit", withUnit(str(primary.getTotalDelta()), primary.getUnit())); } else { statistics.put("count", 0); statistics.put("unit", ""); @@ -108,7 +161,7 @@ public class WordRenderer implements ReportRenderer { qualityMap.put("description", quality == null ? "统计期内无有效数据" : quality.getDescription()); Map warningMap = new HashMap<>(); - warningMap.put("status", warning == null ? WarningAnalysis.STATUS_NORMAL : warning.getStatus()); + warningMap.put("status", warning == null ? "正常" : warningStatusText(warning.getStatus())); warningMap.put("description", warning == null ? "统计期内无有效数据" : warning.getDescription()); Map analysisMap = new HashMap<>(); @@ -117,26 +170,68 @@ public class WordRenderer implements ReportRenderer { analysisMap.put("quality", qualityMap); analysisMap.put("warning", warningMap); - Map charts = new HashMap<>(); - // 支持通用图表键名(所有分类使用统一的 timeSeriesChart) - byte[] timeSeriesChart = analysis.getCharts().get("timeSeriesChart"); - if (timeSeriesChart != null) { - ByteArrayPictureRenderData picture = new ByteArrayPictureRenderData(timeSeriesChart, PictureType.PNG); - PictureStyle style = new PictureStyle(); - style.setWidth(640); - style.setHeight(280); - picture.setPictureStyle(style); - charts.put("timeSeriesChart", picture); + // 添加项目概况总结(始终 put,空值兜底) + data.put("summary", StringUtils.isNotEmpty(analysis.getSummary()) + ? analysis.getSummary() : "统计期内无有效监测数据。"); + + // 添加专业分析建议(始终 put,空值兜底) + data.put("professionalAdvice", StringUtils.isNotEmpty(analysis.getProfessionalAdvice()) + ? analysis.getProfessionalAdvice() : "暂无专业分析建议。"); + + // 添加安全评估(始终 put,空值兜底) + data.put("safetyLevel", safetyLevelText(StringUtils.isNotEmpty(analysis.getSafetyLevel()) + ? analysis.getSafetyLevel() : "待评估")); + data.put("safetyDescription", StringUtils.isNotEmpty(analysis.getSafetyDescription()) + ? analysis.getSafetyDescription() : "暂无评估说明。"); + + // 添加控制值(始终 put,空值兜底) + data.put("controlValue", analysis.getControlValue() != null + ? analysis.getControlValue().toPlainString() : "未配置"); + data.put("controlValueUnit", analysis.getControlValueUnit() != null + ? analysis.getControlValueUnit() : ""); + + // 添加多测点数据(始终 put,空列表兜底) + List> metricPointsList = new ArrayList<>(); + if (analysis.getMetricPoints() != null && !analysis.getMetricPoints().isEmpty()) { + for (MetricPointData point : analysis.getMetricPoints()) { + Map pointMap = new HashMap<>(); + pointMap.put("pointNo", point.getPointNo() == null ? "" : point.getPointNo()); + pointMap.put("pointName", point.getPointName() == null ? "" : point.getPointName()); + pointMap.put("identifier", point.getIdentifier()); + pointMap.put("modelName", point.getModelName()); + pointMap.put("initialValue", str(point.getInitialValue())); + pointMap.put("initialUnit", point.getInitialUnit() == null ? "" : point.getInitialUnit()); + pointMap.put("currentValue", str(point.getCurrentValue())); + pointMap.put("currentUnit", point.getCurrentUnit() == null ? "" : point.getCurrentUnit()); + pointMap.put("changeValue", str(point.getChangeValue())); + pointMap.put("changeUnit", point.getChangeUnit() == null ? "" : point.getChangeUnit()); + pointMap.put("changeRate", str(point.getChangeRate())); + pointMap.put("rateUnit", point.getRateUnit() == null ? "" : point.getRateUnit()); + pointMap.put("maxValue", str(point.getMaxValue())); + pointMap.put("minValue", str(point.getMinValue())); + pointMap.put("avgValue", str(point.getAvgValue())); + // 无数据的指标标注"无数据",避免误报为正常 + pointMap.put("warningStatus", point.getCurrentValue() == null + ? "无数据" : warningStatusText(point.getWarningStatus())); + metricPointsList.add(pointMap); + } } - // 兼容旧版裂缝计模板 - byte[] crackTrend = analysis.getCharts().get("crackTrend"); - if (crackTrend != null) { - ByteArrayPictureRenderData picture = new ByteArrayPictureRenderData(crackTrend, PictureType.PNG); + data.put("metricPoints", metricPointsList); + + // 图表:兼容 timeSeriesChart(主指标)/ chart2 / chart3(分组指标)/ crackTrend(旧模板) + Map charts = new HashMap<>(); + for (Map.Entry entry : analysis.getCharts().entrySet()) { + byte[] png = entry.getValue(); + if (png == null || png.length == 0) { + continue; + } + ByteArrayPictureRenderData picture = new ByteArrayPictureRenderData(png, PictureType.PNG); PictureStyle style = new PictureStyle(); - style.setWidth(640); - style.setHeight(280); + // 适配 A4 页面可用宽度(约 15.9cm ≈ 600px) + style.setWidth(600); + style.setHeight(240); picture.setPictureStyle(style); - charts.put("crackTrend", picture); + charts.put(entry.getKey(), picture); } List> conclusions = new ArrayList<>(); @@ -155,7 +250,63 @@ public class WordRenderer implements ReportRenderer { return data; } + /** + * 解析主监测指标统计:优先 primaryIdentifier,兜底第一个 + */ + private Statistics resolvePrimary(AnalysisResult analysis) { + if (StringUtils.isNotEmpty(analysis.getPrimaryIdentifier())) { + Statistics statistics = analysis.getStatistics().get(analysis.getPrimaryIdentifier()); + if (statistics != null) { + return statistics; + } + } + return analysis.getStatistics().values().stream().findFirst().orElse(null); + } + + /** + * 拼接数值与单位(单位为空时只返回数值,避免尾部空格) + */ + private String withUnit(String value, String unit) { + return (unit == null || unit.trim().isEmpty()) ? value : value + " " + unit.trim(); + } + + /** + * 数值格式化:最多保留 6 位小数并去除末尾多余的 0 + * 例:-499.9277470 → -499.927747;3.9844728 → 3.984473;0.000000 → 0 + */ private String str(BigDecimal value) { - return value == null ? "-" : value.toPlainString(); + if (value == null) { + return "-"; + } + BigDecimal formatted = value.setScale(6, RoundingMode.HALF_UP).stripTrailingZeros(); + if (formatted.compareTo(BigDecimal.ZERO) == 0) { + return "0"; + } + return formatted.toPlainString(); + } + + /** 预警状态转中文 */ + private String warningStatusText(String status) { + if (WarningAnalysis.STATUS_ALARM.equals(status)) { + return "报警"; + } + if (WarningAnalysis.STATUS_WARNING.equals(status)) { + return "预警"; + } + return "正常"; + } + + /** 安全等级转中文 */ + private String safetyLevelText(String level) { + if ("DANGER".equals(level)) { + return "危险"; + } + if ("WARNING".equals(level)) { + return "预警"; + } + if ("CRITICAL".equals(level)) { + return "临界"; + } + return "安全"; } } diff --git a/fastbee-report/src/main/resources/templates/DEVICE_AXIAL_FORCE_V1.docx b/fastbee-report/src/main/resources/templates/DEVICE_AXIAL_FORCE_V1.docx deleted file mode 100644 index b4277425..00000000 Binary files a/fastbee-report/src/main/resources/templates/DEVICE_AXIAL_FORCE_V1.docx and /dev/null differ diff --git a/fastbee-report/src/main/resources/templates/DEVICE_AXIAL_FORCE_V2.docx b/fastbee-report/src/main/resources/templates/DEVICE_AXIAL_FORCE_V2.docx new file mode 100644 index 00000000..d88772bb Binary files /dev/null and b/fastbee-report/src/main/resources/templates/DEVICE_AXIAL_FORCE_V2.docx differ diff --git a/fastbee-report/src/main/resources/templates/DEVICE_CRACK_METER_V1.docx b/fastbee-report/src/main/resources/templates/DEVICE_CRACK_METER_V1.docx deleted file mode 100644 index ce93bdf7..00000000 Binary files a/fastbee-report/src/main/resources/templates/DEVICE_CRACK_METER_V1.docx and /dev/null differ diff --git a/fastbee-report/src/main/resources/templates/DEVICE_CRACK_METER_V2.docx b/fastbee-report/src/main/resources/templates/DEVICE_CRACK_METER_V2.docx new file mode 100644 index 00000000..2f4ad757 Binary files /dev/null and b/fastbee-report/src/main/resources/templates/DEVICE_CRACK_METER_V2.docx differ diff --git a/fastbee-report/src/main/resources/templates/DEVICE_DEEP_DISPLACEMENT_V1.docx b/fastbee-report/src/main/resources/templates/DEVICE_DEEP_DISPLACEMENT_V1.docx deleted file mode 100644 index 815eb6bb..00000000 Binary files a/fastbee-report/src/main/resources/templates/DEVICE_DEEP_DISPLACEMENT_V1.docx and /dev/null differ diff --git a/fastbee-report/src/main/resources/templates/DEVICE_DEEP_DISPLACEMENT_V2.docx b/fastbee-report/src/main/resources/templates/DEVICE_DEEP_DISPLACEMENT_V2.docx new file mode 100644 index 00000000..cdda2a9c Binary files /dev/null and b/fastbee-report/src/main/resources/templates/DEVICE_DEEP_DISPLACEMENT_V2.docx differ diff --git a/fastbee-report/src/main/resources/templates/DEVICE_INCLINOMETER_V1.docx b/fastbee-report/src/main/resources/templates/DEVICE_INCLINOMETER_V1.docx deleted file mode 100644 index 199806c3..00000000 Binary files a/fastbee-report/src/main/resources/templates/DEVICE_INCLINOMETER_V1.docx and /dev/null differ diff --git a/fastbee-report/src/main/resources/templates/DEVICE_INCLINOMETER_V2.docx b/fastbee-report/src/main/resources/templates/DEVICE_INCLINOMETER_V2.docx new file mode 100644 index 00000000..1cc11af5 Binary files /dev/null and b/fastbee-report/src/main/resources/templates/DEVICE_INCLINOMETER_V2.docx differ diff --git a/fastbee-report/src/main/resources/templates/DEVICE_RAIN_GAUGE_V1.docx b/fastbee-report/src/main/resources/templates/DEVICE_RAIN_GAUGE_V1.docx deleted file mode 100644 index e0b59eed..00000000 Binary files a/fastbee-report/src/main/resources/templates/DEVICE_RAIN_GAUGE_V1.docx and /dev/null differ diff --git a/fastbee-report/src/main/resources/templates/DEVICE_RAIN_GAUGE_V2.docx b/fastbee-report/src/main/resources/templates/DEVICE_RAIN_GAUGE_V2.docx new file mode 100644 index 00000000..55d8d314 Binary files /dev/null and b/fastbee-report/src/main/resources/templates/DEVICE_RAIN_GAUGE_V2.docx differ diff --git a/fastbee-report/src/main/resources/templates/DEVICE_RANGING_METER_V1.docx b/fastbee-report/src/main/resources/templates/DEVICE_RANGING_METER_V1.docx deleted file mode 100644 index ad6bc3a2..00000000 Binary files a/fastbee-report/src/main/resources/templates/DEVICE_RANGING_METER_V1.docx and /dev/null differ diff --git a/fastbee-report/src/main/resources/templates/DEVICE_RANGING_METER_V2.docx b/fastbee-report/src/main/resources/templates/DEVICE_RANGING_METER_V2.docx new file mode 100644 index 00000000..8b387568 Binary files /dev/null and b/fastbee-report/src/main/resources/templates/DEVICE_RANGING_METER_V2.docx differ diff --git a/fastbee-report/src/main/resources/templates/DEVICE_STATIC_LEVEL_V1.docx b/fastbee-report/src/main/resources/templates/DEVICE_STATIC_LEVEL_V1.docx deleted file mode 100644 index d6a9607f..00000000 Binary files a/fastbee-report/src/main/resources/templates/DEVICE_STATIC_LEVEL_V1.docx and /dev/null differ diff --git a/fastbee-report/src/main/resources/templates/DEVICE_STATIC_LEVEL_V2.docx b/fastbee-report/src/main/resources/templates/DEVICE_STATIC_LEVEL_V2.docx new file mode 100644 index 00000000..b002e934 Binary files /dev/null and b/fastbee-report/src/main/resources/templates/DEVICE_STATIC_LEVEL_V2.docx differ diff --git a/fastbee-report/src/main/resources/templates/DEVICE_STRAIN_GAUGE_V1.docx b/fastbee-report/src/main/resources/templates/DEVICE_STRAIN_GAUGE_V1.docx deleted file mode 100644 index bae0fdbc..00000000 Binary files a/fastbee-report/src/main/resources/templates/DEVICE_STRAIN_GAUGE_V1.docx and /dev/null differ diff --git a/fastbee-report/src/main/resources/templates/DEVICE_STRAIN_GAUGE_V2.docx b/fastbee-report/src/main/resources/templates/DEVICE_STRAIN_GAUGE_V2.docx new file mode 100644 index 00000000..48171537 Binary files /dev/null and b/fastbee-report/src/main/resources/templates/DEVICE_STRAIN_GAUGE_V2.docx differ diff --git a/fastbee-report/src/main/resources/templates/DEVICE_THERMOMETER_V1.docx b/fastbee-report/src/main/resources/templates/DEVICE_THERMOMETER_V1.docx deleted file mode 100644 index 0cb59d75..00000000 Binary files a/fastbee-report/src/main/resources/templates/DEVICE_THERMOMETER_V1.docx and /dev/null differ diff --git a/fastbee-report/src/main/resources/templates/DEVICE_THERMOMETER_V2.docx b/fastbee-report/src/main/resources/templates/DEVICE_THERMOMETER_V2.docx new file mode 100644 index 00000000..c9f5f8ad Binary files /dev/null and b/fastbee-report/src/main/resources/templates/DEVICE_THERMOMETER_V2.docx differ diff --git a/fastbee-report/src/main/resources/templates/DEVICE_VIBRATING_WIRE_V1.docx b/fastbee-report/src/main/resources/templates/DEVICE_VIBRATING_WIRE_V1.docx deleted file mode 100644 index d744600d..00000000 Binary files a/fastbee-report/src/main/resources/templates/DEVICE_VIBRATING_WIRE_V1.docx and /dev/null differ diff --git a/fastbee-report/src/main/resources/templates/DEVICE_VIBRATING_WIRE_V2.docx b/fastbee-report/src/main/resources/templates/DEVICE_VIBRATING_WIRE_V2.docx new file mode 100644 index 00000000..ec8c2d4e Binary files /dev/null and b/fastbee-report/src/main/resources/templates/DEVICE_VIBRATING_WIRE_V2.docx differ diff --git a/fastbee-report/src/main/resources/templates/DEVICE_WATER_LEVEL_V1.docx b/fastbee-report/src/main/resources/templates/DEVICE_WATER_LEVEL_V1.docx deleted file mode 100644 index a0f1a8ab..00000000 Binary files a/fastbee-report/src/main/resources/templates/DEVICE_WATER_LEVEL_V1.docx and /dev/null differ diff --git a/fastbee-report/src/main/resources/templates/DEVICE_WATER_LEVEL_V2.docx b/fastbee-report/src/main/resources/templates/DEVICE_WATER_LEVEL_V2.docx new file mode 100644 index 00000000..d40d5860 Binary files /dev/null and b/fastbee-report/src/main/resources/templates/DEVICE_WATER_LEVEL_V2.docx differ diff --git a/fastbee-report/src/test/java/com/fastbee/report/renderer/WordRendererTest.java b/fastbee-report/src/test/java/com/fastbee/report/renderer/WordRendererTest.java index d7a46e34..dfee0018 100644 --- a/fastbee-report/src/test/java/com/fastbee/report/renderer/WordRendererTest.java +++ b/fastbee-report/src/test/java/com/fastbee/report/renderer/WordRendererTest.java @@ -38,7 +38,7 @@ public class WordRendererTest { @Override public byte[] resolve(String templateCode) { try (java.io.InputStream in = getClass().getClassLoader() - .getResourceAsStream("templates/DEVICE_CRACK_METER_V1.docx")) { + .getResourceAsStream("templates/DEVICE_CRACK_METER_V2.docx")) { return in == null ? null : cn.hutool.core.io.IoUtil.readBytes(in); } catch (Exception e) { throw new RuntimeException(e); diff --git a/fastbee-report/src/test/java/com/fastbee/report/renderer/WordRendererV2TemplateTest.java b/fastbee-report/src/test/java/com/fastbee/report/renderer/WordRendererV2TemplateTest.java new file mode 100644 index 00000000..727b331f --- /dev/null +++ b/fastbee-report/src/test/java/com/fastbee/report/renderer/WordRendererV2TemplateTest.java @@ -0,0 +1,223 @@ +package com.fastbee.report.renderer; + +import com.fastbee.common.enums.MonitorCategory; +import com.fastbee.common.enums.ReportPeriod; +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.QualityAnalysis; +import com.fastbee.report.model.ReportModel; +import com.fastbee.report.model.Statistics; +import com.fastbee.report.model.TrendAnalysis; +import com.fastbee.report.model.WarningAnalysis; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.io.InputStream; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * V2 专业模板渲染冒烟测试:验证循环表格(conclusions/metricPoints)+ 图表 + 概况总结 + * + * @author FastBee + * @date 2026-09-10 + */ +public class WordRendererV2TemplateTest { + + @Test + public void testRenderDeepDisplacementV2() { + WordRenderer renderer = new WordRenderer(); + ReflectionTestUtils.setField(renderer, "templateResolver", new WordTemplateResolver() { + @Override + public byte[] resolve(String templateCode) { + InputStream in = getClass().getClassLoader() + .getResourceAsStream("templates/" + templateCode + ".docx"); + if (in == null) { + throw new IllegalStateException("模板不存在: " + templateCode); + } + return cn.hutool.core.io.IoUtil.readBytes(in); + } + }); + + ReportModel model = buildDeepDisplacementModel(); + byte[] docx = renderer.render(model); + assertNotNull(docx); + assertTrue(docx.length > 0); + String magic = new String(new byte[]{docx[0], docx[1]}, StandardCharsets.ISO_8859_1); + assertTrue("PK".equals(magic), "渲染结果应为有效 docx(zip)"); + + // 输出渲染结果,便于人工检查排版 + try { + java.io.File out = new java.io.File("target/test-output-deep-displacement.docx"); + java.nio.file.Files.write(out.toPath(), docx); + System.out.println("渲染结果已输出: " + out.getAbsolutePath()); + } catch (Exception ignored) { + // 输出失败不影响测试结果 + } + } + + /** 所有 V2 模板均应可渲染(占位符语法校验) */ + @Test + public void testAllV2TemplatesLoadable() { + String[] codes = { + "CRACK_METER_V2", "DEEP_DISPLACEMENT_V2", "AXIAL_FORCE_V2", "STATIC_LEVEL_V2", + "VIBRATING_WIRE_V2", "STRAIN_GAUGE_V2", "WATER_LEVEL_V2", "RANGING_METER_V2", + "INCLINOMETER_V2", "THERMOMETER_V2", "RAIN_GAUGE_V2"}; + for (String suffix : codes) { + InputStream in = getClass().getClassLoader() + .getResourceAsStream("templates/DEVICE_" + suffix + ".docx"); + assertNotNull(in, "模板缺失: DEVICE_" + suffix); + } + } + + private ReportModel buildDeepDisplacementModel() { + // 场景:设备配置了 3 个监测物模型,但只有 Z轴位移 有数据 + // 其他物模型(X轴位移、温度)也应该在报告中显示,标注"无数据" + + // 第一个物模型:Z轴位移(有数据) + Statistics zStats = new Statistics(); + zStats.setIdentifier("zDisplacement"); + zStats.setModelName("Z轴位移"); + zStats.setUnit("mm"); + zStats.setCount(144); + zStats.setCurrent(new BigDecimal("-499.928")); + zStats.setInitial(new BigDecimal("-499.92792")); + zStats.setMax(new BigDecimal("-499.9272")); + zStats.setMin(new BigDecimal("-499.928")); + zStats.setAvg(new BigDecimal("-499.927747")); + zStats.setMedian(new BigDecimal("-499.927730")); + zStats.setDelta(new BigDecimal("-0.00008")); + zStats.setTotalDelta(new BigDecimal("0.02554")); + + // 第二个物模型:X轴位移(无数据)- 会在报告中显示"无数据" + Statistics xStats = new Statistics(); + xStats.setIdentifier("xDisplacement"); + xStats.setModelName("X轴位移"); + xStats.setUnit("mm"); + xStats.setCount(0); // 无数据 + + // 第三个物模型:温度(无数据)- 会在报告中显示"无数据" + Statistics tempStats = new Statistics(); + tempStats.setIdentifier("temperature"); + tempStats.setModelName("温度"); + tempStats.setUnit("℃"); + tempStats.setCount(0); // 无数据 + + TrendAnalysis trend = new TrendAnalysis(); + trend.setIdentifier("zDisplacement"); + trend.setModelName("Z轴位移"); + trend.setDirection(TrendAnalysis.DIRECTION_STABLE); + trend.setSlope(new BigDecimal("-0.000439")); + trend.setRSquared(new BigDecimal("0.1053")); + trend.setDescription("整体波动较大,无明显趋势,日变化速率 -0.000439,拟合优度 R²=0.1053"); + + QualityAnalysis quality = new QualityAnalysis(); + quality.setTotal(144); + quality.setMissing(0); + quality.setDuplicate(12); + quality.setOutOfRange(0); + quality.setCompletenessRate(new BigDecimal("1.0")); + quality.setDescription("统计期内共 144 条数据,预估缺数 0 条,完整率 100.00%,重复值 12 条"); + + WarningAnalysis warning = new WarningAnalysis(); + warning.setStatus(WarningAnalysis.STATUS_NORMAL); + warning.setDescription("未配置结构化阈值,暂按正常处理"); + + AnalysisResult analysis = new AnalysisResult(); + analysis.setPrimaryIdentifier("zDisplacement"); // 主监测指标 + // 添加所有物模型的统计数据(包括无数据的) + analysis.getStatistics().put("zDisplacement", zStats); + analysis.getStatistics().put("xDisplacement", xStats); + analysis.getStatistics().put("temperature", tempStats); + + analysis.getTrends().put("zDisplacement", trend); + analysis.getQualities().put("zDisplacement", quality); + analysis.getWarnings().put("zDisplacement", warning); + + analysis.setSummary("本期深部位移监测数据显示:当前Z轴位移为-499.928mm,较初始值变化-0.00008mm,位移呈稳定趋势。"); + analysis.setProfessionalAdvice("专业分析:位移变化稳定,结构状态正常。建议保持现有监测频率。"); + analysis.setSafetyLevel("SAFE"); + analysis.setSafetyDescription("监测指标处于正常范围。"); + analysis.setControlValue(new BigDecimal("500")); + analysis.setControlValueUnit("mm"); + + // 添加所有测点数据(包括无数据的) + analysis.getMetricPoints().add(MetricPointData.builder() + .identifier("zDisplacement") + .modelName("Z轴位移") + .initialValue(new BigDecimal("-499.92792")) + .currentValue(new BigDecimal("-499.928")) + .changeValue(new BigDecimal("-0.00008")) + .changeRate(new BigDecimal("-0.000160")) + .maxValue(new BigDecimal("-499.9272")) + .minValue(new BigDecimal("-499.928")) + .avgValue(new BigDecimal("-499.927747")) + .warningStatus("NORMAL") + .build()); + + // X轴位移无数据 + analysis.getMetricPoints().add(MetricPointData.builder() + .identifier("xDisplacement") + .modelName("X轴位移") + .warningStatus("无数据") + .build()); + + // 温度无数据 + analysis.getMetricPoints().add(MetricPointData.builder() + .identifier("temperature") + .modelName("温度") + .warningStatus("无数据") + .build()); + + analysis.getConclusions().add(new Conclusion(1, "统计期内深部位移由 -499.92792 变化至 -499.928,位移方向为稳定。")); + analysis.getConclusions().add(new Conclusion(2, "平均位移速率 -0.000160mm/天。")); + analysis.getConclusions().add(new Conclusion(3, "共监测 3 个物模型:Z轴位移(有数据)、X轴位移(无数据)、温度(无数据)。")); + + List chartData = new ArrayList<>(); + long base = System.currentTimeMillis(); + for (int i = 0; i < 10; i++) { + MetricData point = new MetricData(); + point.setIdentity("zDisplacement"); + point.setModelName("Z轴位移"); + point.setUnit("mm"); + point.setTime(new Date(base + i * 3600_000L)); + point.setValue(String.valueOf(-499.928 + i * 0.001)); + point.setNumericValue(BigDecimal.valueOf(-499.928 + i * 0.001)); + chartData.add(point); + } + byte[] png = new ChartGenerator().timeSeriesChart("深部位移时程曲线", "Z轴位移", "mm", chartData); + analysis.getCharts().put("timeSeriesChart", png); + // 附图:其他指标分组图(模板预留 chart2 / chart3) + java.util.Map> grouped = new java.util.LinkedHashMap<>(); + grouped.put("X轴位移", chartData); + analysis.getCharts().put("chart2", new ChartGenerator().multiSeriesChart("其他指标时程曲线", "mm", grouped)); + analysis.getCharts().put("chart3", new ChartGenerator().blankChart("暂无数据")); + + return ReportModel.builder() + .reportNo("REPORT-20260910090000-0001") + .templateCode("DEVICE_DEEP_DISPLACEMENT_V2") // 注意:模板文件名是 DEVICE_DEEP_DISPLACEMENT_V2.docx + .fileName("D1903E4N060G-自定义-监测分析报告.docx") + .periodDesc("2026-09-09 20:58:18 ~ 2026-09-10 08:58:18") + .period(ReportPeriod.CUSTOM) + .generatedAt(new Date()) + .deviceName("固定式测斜仪_1") + .serialNumber("D1903E4N060G") + .productName("固定式测斜仪") + .deviceId(20001L) + .category(MonitorCategory.DEEP_DISPLACEMENT) + .categoryDesc("深部位移监测分析报告") + .metricName("Z轴位移") + .metricUnit("mm") + .analysis(analysis) + .build(); + } +} \ No newline at end of file diff --git a/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/mapper/DeviceMapper.java b/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/mapper/DeviceMapper.java index 798c181b..59c7c707 100644 --- a/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/mapper/DeviceMapper.java +++ b/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/mapper/DeviceMapper.java @@ -400,6 +400,15 @@ public interface DeviceMapper extends BaseMapperX { */ List selectDeviceListByDeviceIds(@Param("deviceIdList") List deviceIdList); + /** + * 树形设备列表专用轻量查询:只查树所需字段,复用简短列表的权限过滤, + * 避开 is_owner/sub_device_count 逐行关联子查询 + * + * @param device 查询条件 + * @return 设备简短列表(仅含树所需字段) + */ + List selectDeviceTreeBaseList(Device device); + /** * 根据监控设备channelId获取设备 * diff --git a/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/mapper/SubGatewayMapper.java b/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/mapper/SubGatewayMapper.java index 4dc5d5a0..0e7b7ca4 100644 --- a/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/mapper/SubGatewayMapper.java +++ b/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/mapper/SubGatewayMapper.java @@ -118,4 +118,12 @@ public interface SubGatewayMapper { * @return 子设备ID列表 */ List selectSubDeviceIdsByGwDeviceId(Long gwDeviceId); + + /** + * 根据网关设备ID列表批量查询网关与子设备关联(一次查询替代逐网关循环查询) + * + * @param gwDeviceIdList 网关设备ID列表 + * @return 网关与子设备关联集合 + */ + List selectSubGatewayListByGwDeviceIds(@Param("gwDeviceIdList") List gwDeviceIdList); } diff --git a/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/model/DeviceTreeVO.java b/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/model/DeviceTreeVO.java new file mode 100644 index 00000000..59a09f17 --- /dev/null +++ b/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/model/DeviceTreeVO.java @@ -0,0 +1,78 @@ +package com.fastbee.iot.model; + +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; + +import java.util.List; + +/** + * 树形设备VO(用于设备选择器,网关可展开显示子设备) + * + * @author FastBee + * @date 2026-09-09 + */ +@Data +@ApiModel(value = "DeviceTreeVO", description = "树形设备VO") +public class DeviceTreeVO { + + /** + * 设备ID + */ + @ApiModelProperty("设备ID") + private Long deviceId; + + /** + * 设备名称 + */ + @ApiModelProperty("设备名称") + private String deviceName; + + /** + * 设备序列号 + */ + @ApiModelProperty("设备序列号") + private String serialNumber; + + /** + * 产品ID + */ + @ApiModelProperty("产品ID") + private Long productId; + + /** + * 产品名称 + */ + @ApiModelProperty("产品名称") + private String productName; + + /** + * 设备类型(1=直连设备,2=网关设备,3=摄像头) + */ + @ApiModelProperty("设备类型(1=直连设备,2=网关设备,3=摄像头)") + private Integer deviceType; + + /** + * 设备状态(1-未激活,2-禁用,3-在线,4-离线) + */ + @ApiModelProperty("设备状态(1-未激活,2-禁用,3-在线,4-离线)") + private Integer status; + + /** + * 是否为叶子节点(网关=false,其他=true) + */ + @ApiModelProperty("是否为叶子节点(网关=false,其他=true)") + private Boolean isLeaf; + + /** + * 子设备列表(仅网关设备有子设备) + */ + @ApiModelProperty("子设备列表") + private List children; + + /** + * 设备完整路径名称(如:网关A / 子设备A-1) + */ + @ApiModelProperty("设备完整路径名称") + private String fullPath; +} \ No newline at end of file diff --git a/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/service/IDeviceService.java b/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/service/IDeviceService.java index dc4d0c69..c103324f 100644 --- a/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/service/IDeviceService.java +++ b/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/service/IDeviceService.java @@ -432,4 +432,12 @@ public interface IDeviceService { public int updateDeviceBySerialNumber(Device device); + /** + * 查询树形设备列表(网关设备可展开显示子设备) + * + * @param device 查询条件 + * @return 树形设备列表 + */ + List selectDeviceTreeList(Device device); + } diff --git a/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/service/impl/DeviceServiceImpl.java b/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/service/impl/DeviceServiceImpl.java index cec4da61..25b7c26c 100644 --- a/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/service/impl/DeviceServiceImpl.java +++ b/fastbee-service/fastbee-iot-service/src/main/java/com/fastbee/iot/service/impl/DeviceServiceImpl.java @@ -87,6 +87,10 @@ import static com.fastbee.common.utils.SecurityUtils.isAdmin; public class DeviceServiceImpl implements IDeviceService { private static final Logger log = LoggerFactory.getLogger(DeviceServiceImpl.class); + /** + * SQL IN 子句分批大小(防止过长 IN 列表) + */ + private static final int SQL_IN_BATCH_SIZE = 1000; @Value("${server.broker.port}") private Long brokerPort; @Autowired @@ -146,6 +150,8 @@ public class DeviceServiceImpl implements IDeviceService { @Autowired private DeviceMapper deviceMapper; @Resource + private ProductMapper productMapper; + @Resource private PlatformDeviceMappingMapper platformDeviceMappingMapper; @Autowired private DeviceUserMapper deviceUserMapper; @@ -2374,4 +2380,149 @@ public class DeviceServiceImpl implements IDeviceService { return deviceMapper.selectSerialNumbersByProductId(productId); } + /** + * 查询树形设备列表(网关设备可展开显示子设备) + * + * @param device 查询条件 + * @return 树形设备列表 + */ + @Override + public List selectDeviceTreeList(Device device) { + long startTime = System.currentTimeMillis(); + // 1. 轻量查询所有设备(包含网关和普通设备),只取树所需字段,避开逐行关联子查询 + List allDevices = deviceMapper.selectDeviceTreeBaseList(device); + + // 2. 收集网关设备ID,一次批量查询所有网关-子设备关系(替代逐网关循环查询) + List gatewayIds = allDevices.stream() + .filter(d -> d.getDeviceType() != null && d.getDeviceType() == 2) + .map(DeviceShortOutput::getDeviceId) + .collect(Collectors.toList()); + + Map> gatewaySubDeviceMap = new HashMap<>(); + Set allSubDeviceIds = new HashSet<>(); + for (List batch : partitionList(gatewayIds, SQL_IN_BATCH_SIZE)) { + for (SubGateway relation : subGatewayMapper.selectSubGatewayListByGwDeviceIds(batch)) { + gatewaySubDeviceMap.computeIfAbsent(relation.getGwDeviceId(), k -> new ArrayList<>()) + .add(relation.getSubDeviceId()); + allSubDeviceIds.add(relation.getSubDeviceId()); + } + } + + // 3. 批量查询子设备信息(轻量查询只查网关/直连设备,子设备需单独查)+ 一次性批量查询产品名称 + Map subDeviceMap = new HashMap<>(); + if (!allSubDeviceIds.isEmpty()) { + List subDevices = new ArrayList<>(); + for (List batch : partitionList(new ArrayList<>(allSubDeviceIds), SQL_IN_BATCH_SIZE)) { + subDevices.addAll(deviceMapper.selectDeviceListByDeviceIds(batch)); + } + subDeviceMap = buildSubDeviceShortMap(subDevices); + } + + // 4. 合并设备映射:网关/直连设备 + 子设备 + Map deviceMap = allDevices.stream() + .collect(Collectors.toMap(DeviceShortOutput::getDeviceId, d -> d, (a, b) -> a)); + deviceMap.putAll(subDeviceMap); // 添加子设备 + + // 5. 构建树形结构(用 Set 判断子设备归属,避免嵌套遍历扫描) + List treeList = new ArrayList<>(); + for (DeviceShortOutput d : allDevices) { + Integer deviceType = d.getDeviceType(); + + // 网关设备 + if (deviceType != null && deviceType == 2) { + DeviceTreeVO gatewayNode = buildDeviceTreeVO(d, false); + + // 添加子设备 + List subDeviceIds = gatewaySubDeviceMap.get(d.getDeviceId()); + if (subDeviceIds != null && !subDeviceIds.isEmpty()) { + List children = new ArrayList<>(); + for (Long subId : subDeviceIds) { + DeviceShortOutput subDevice = deviceMap.get(subId); + if (subDevice != null) { + DeviceTreeVO childNode = buildDeviceTreeVO(subDevice, true); + childNode.setFullPath(d.getDeviceName() + " / " + (subDevice.getDeviceName() != null ? subDevice.getDeviceName() : subDevice.getSerialNumber())); + children.add(childNode); + } + } + gatewayNode.setChildren(children); + } + treeList.add(gatewayNode); + } + // 直连设备或摄像头(不在网关下) + else if (deviceType == null || deviceType == 1 || deviceType == 3) { + if (!allSubDeviceIds.contains(d.getDeviceId())) { + DeviceTreeVO node = buildDeviceTreeVO(d, true); + treeList.add(node); + } + } + } + log.info("树形列表统计时间:{}", System.currentTimeMillis() - startTime); + + return treeList; + } + + /** + * 子设备列表转 DeviceShortOutput 映射,产品名称一次批量查询(替代逐设备查询产品) + */ + private Map buildSubDeviceShortMap(List subDevices) { + Set productIds = subDevices.stream() + .map(Device::getProductId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + Map productNameMap = new HashMap<>(); + for (List batch : partitionList(new ArrayList<>(productIds), SQL_IN_BATCH_SIZE)) { + for (Product product : productMapper.selectProductListByProductIds(batch)) { + productNameMap.put(product.getProductId(), product.getProductName()); + } + } + + Map subDeviceMap = new HashMap<>(); + for (Device sub : subDevices) { + DeviceShortOutput shortOutput = new DeviceShortOutput(); + shortOutput.setDeviceId(sub.getDeviceId()); + shortOutput.setDeviceName(sub.getDeviceName()); + shortOutput.setSerialNumber(sub.getSerialNumber()); + shortOutput.setProductId(sub.getProductId()); + shortOutput.setDeviceType(sub.getDeviceType()); + shortOutput.setStatus(sub.getStatus()); + if (sub.getProductId() != null) { + shortOutput.setProductName(productNameMap.get(sub.getProductId())); + } + subDeviceMap.put(sub.getDeviceId(), shortOutput); + } + return subDeviceMap; + } + + /** + * 将列表按固定大小切分,用于控制 SQL IN 子句长度 + */ + private static List> partitionList(List list, int size) { + List> parts = new ArrayList<>(); + for (int i = 0; i < list.size(); i += size) { + parts.add(new ArrayList<>(list.subList(i, Math.min(i + size, list.size())))); + } + return parts; + } + + /** + * 构建DeviceTreeVO对象 + */ + private DeviceTreeVO buildDeviceTreeVO(DeviceShortOutput d, boolean isLeaf) { + DeviceTreeVO vo = new DeviceTreeVO(); + vo.setDeviceId(d.getDeviceId()); + vo.setDeviceName(d.getDeviceName()); + vo.setSerialNumber(d.getSerialNumber()); + vo.setProductId(d.getProductId()); + vo.setProductName(d.getProductName()); + vo.setDeviceType(d.getDeviceType()); + vo.setStatus(d.getStatus()); + vo.setIsLeaf(isLeaf); + if (d.getDeviceName() != null) { + vo.setFullPath(d.getDeviceName()); + } else { + vo.setFullPath(d.getSerialNumber()); + } + return vo; + } + } diff --git a/fastbee-service/fastbee-iot-service/src/main/resources/mapper/iot/DeviceMapper.xml b/fastbee-service/fastbee-iot-service/src/main/resources/mapper/iot/DeviceMapper.xml index a6ba4d5a..5d756484 100644 --- a/fastbee-service/fastbee-iot-service/src/main/resources/mapper/iot/DeviceMapper.xml +++ b/fastbee-service/fastbee-iot-service/src/main/resources/mapper/iot/DeviceMapper.xml @@ -419,18 +419,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" update iot_device set things_model_value=#{stringValue} where device_id = #{deviceId} - + select d.device_id, d.device_name, p.product_id, p.product_name,p.device_type, + d.tenant_id, d.tenant_name, d.serial_number,d.gw_dev_code, + d.firmware_version, d.status,d.rssi,d.is_shadow,d.is_simulate ,d.location_way, + d.things_model_value, d.active_time,d.create_time, if(null = d.img_url or '' = d.img_url, p.img_url, d.img_url) as img_url, + d.longitude, d.latitude, + case + when (select count(device_id) from iot_device_share du where du.device_id = d.device_id and du.user_id = #{tenantId}) then 0 + else 1 + end as is_owner, + (select count(1) from iot_device d1 where d1.gw_dev_code = d.serial_number) as sub_device_count, + p.protocol_code,p.transport,p.guid,d.project_id + + + + + + +