20260910:导出报告优化v2版本

This commit is contained in:
zhugongping 2026-09-10 11:47:39 +08:00
parent 05f1f84df2
commit 0fdcf8dad7
68 changed files with 4156 additions and 590 deletions

View File

@ -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"
]
}
}

View File

@ -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', '<AccessKey>', '<SecretKey>', 'fastbee', 'report',
'http://<minio-host>: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://<endpoint>/<bucketName>`;若配置了 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 报告文件路径

Binary file not shown.

View File

@ -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));
}
/**
* 查询所有设备简短列表
*/

View File

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

View File

@ -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 的"主指标"判定口径)
*
* <p>判定规则(按物模型列表顺序,保证稳定、不依赖时序数据返回顺序):</p>
* <ol>
* <li>排除辅助指标(角度/温度/电压/电量/信号/湿度等),辅助量不作为主监测指标;</li>
* <li>优先返回名称或标识符命中核心关键词的物模型;</li>
* <li>否则返回第一个有数据的非辅助指标;</li>
* <li>再否则返回第一个非辅助指标;最后兜底第一个物模型。</li>
* </ol>
*
* @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<String, List<MetricData>> byIdentity, String... keywords) {
return resolve(context, byIdentity, false, keywords);
}
/**
* 解析主监测指标标识符
*
* @param allowAuxiliary 是否允许辅助指标(角度/温度等)作为主指标。
* 倾角计、温度计等设备的主监测指标本身就是"角度/温度",需传 true。
*/
public String resolve(ReportContext context, Map<String, List<MetricData>> byIdentity,
boolean allowAuxiliary, String... keywords) {
List<ThingsModel> 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");
}
}

View File

@ -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<String, List<MetricData>> 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<String, ThingsModel> 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<String, List<MetricData>> entry : byIdentity.entrySet()) {
// 3. 为每个物模型生成统计数据(包括无数据的物模型)
for (Map.Entry<String, ThingsModel> entry : allModels.entrySet()) {
String identity = entry.getKey();
List<MetricData> data = entry.getValue();
ThingsModel model = entry.getValue();
List<MetricData> 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<String, List<MetricData>> byIdentity, ReportContext context) {
String fallback = null;
for (Map.Entry<String, List<MetricData>> 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<MetricPointData> metricPoints = buildMetricPoints(result, context);
result.setMetricPoints(metricPoints);
// 生成详细结论
List<Conclusion> 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<MetricData> 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<MetricPointData> buildMetricPoints(AnalysisResult result, ReportContext context) {
List<MetricPointData> points = new ArrayList<>();
for (Map.Entry<String, Statistics> 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<String, List<MetricData>> byIdentity = context.getMetricDataList().stream()
.filter(d -> d.getIdentity() != null)
.collect(Collectors.groupingBy(MetricData::getIdentity, LinkedHashMap::new, Collectors.toList()));
Map<String, byte[]> charts = metricChartBuilder.build(result.getStatistics(), primaryIdentity, byIdentity);
result.getCharts().putAll(metricChartBuilder.fillPlaceholders(charts));
}
private String statusText(String status) {

View File

@ -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<String, List<MetricData>> 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<String, ThingsModel> 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<String, List<MetricData>> entry : byIdentity.entrySet()) {
// 3. 为每个物模型生成统计数据(包括无数据的物模型)
for (Map.Entry<String, ThingsModel> entry : allModels.entrySet()) {
String identity = entry.getKey();
List<MetricData> data = entry.getValue();
ThingsModel model = entry.getValue();
List<MetricData> 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<String, List<MetricData>> byIdentity, ReportContext context) {
String fallback = null;
for (Map.Entry<String, List<MetricData>> 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<MetricPointData> metricPoints = buildMetricPoints(result, context);
result.setMetricPoints(metricPoints);
// 生成详细结论
List<Conclusion> 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<MetricData> 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<MetricPointData> buildMetricPoints(AnalysisResult result, ReportContext context) {
List<MetricPointData> points = new ArrayList<>();
for (Map.Entry<String, Statistics> 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<String, List<MetricData>> byIdentity = context.getMetricDataList().stream()
.filter(d -> d.getIdentity() != null)
.collect(Collectors.groupingBy(MetricData::getIdentity, LinkedHashMap::new, Collectors.toList()));
Map<String, byte[]> charts = metricChartBuilder.build(result.getStatistics(), primaryIdentity, byIdentity);
result.getCharts().putAll(metricChartBuilder.fillPlaceholders(charts));
}
private String statusText(String status) {

View File

@ -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<String, List<MetricData>> 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<String, ThingsModel> 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<String, List<MetricData>> entry : byIdentity.entrySet()) {
// 3. 为每个物模型生成统计数据(包括无数据的物模型)
for (Map.Entry<String, ThingsModel> entry : allModels.entrySet()) {
String identity = entry.getKey();
List<MetricData> data = entry.getValue();
ThingsModel model = entry.getValue();
List<MetricData> 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<String, List<MetricData>> byIdentity, ReportContext context) {
String fallback = null;
for (Map.Entry<String, List<MetricData>> 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<MetricPointData> metricPoints = buildMetricPoints(result, context);
result.setMetricPoints(metricPoints);
// 生成详细结论
List<Conclusion> 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<MetricData> 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<MetricPointData> buildMetricPoints(AnalysisResult result, ReportContext context) {
List<MetricPointData> points = new ArrayList<>();
for (Map.Entry<String, Statistics> 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<String, List<MetricData>> byIdentity = context.getMetricDataList().stream()
.filter(d -> d.getIdentity() != null)
.collect(Collectors.groupingBy(MetricData::getIdentity, LinkedHashMap::new, Collectors.toList()));
Map<String, byte[]> charts = metricChartBuilder.build(result.getStatistics(), primaryIdentity, byIdentity);
// 补齐模板预留图表位(无数据时用空白图),避免模板标签缺失导致渲染异常
result.getCharts().putAll(metricChartBuilder.fillPlaceholders(charts));
}
private String statusText(String status) {

View File

@ -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<String, List<MetricData>> 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<String, ThingsModel> 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<String, List<MetricData>> entry : byIdentity.entrySet()) {
// 3. 为每个物模型生成统计数据(包括无数据的物模型)
for (Map.Entry<String, ThingsModel> entry : allModels.entrySet()) {
String identity = entry.getKey();
List<MetricData> data = entry.getValue();
ThingsModel model = entry.getValue();
List<MetricData> 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<String, List<MetricData>> byIdentity, ReportContext context) {
String fallback = null;
for (Map.Entry<String, List<MetricData>> 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<MetricPointData> metricPoints = buildMetricPoints(result, context);
result.setMetricPoints(metricPoints);
// 生成详细结论
List<Conclusion> 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<MetricData> 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<MetricPointData> buildMetricPoints(AnalysisResult result, ReportContext context) {
List<MetricPointData> points = new ArrayList<>();
for (Map.Entry<String, Statistics> 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<String, List<MetricData>> byIdentity = context.getMetricDataList().stream()
.filter(d -> d.getIdentity() != null)
.collect(Collectors.groupingBy(MetricData::getIdentity, LinkedHashMap::new, Collectors.toList()));
Map<String, byte[]> charts = metricChartBuilder.build(result.getStatistics(), primaryIdentity, byIdentity);
result.getCharts().putAll(metricChartBuilder.fillPlaceholders(charts));
}
private String statusText(String status) {

View File

@ -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<String, List<MetricData>> 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<String, ThingsModel> 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<String, List<MetricData>> entry : byIdentity.entrySet()) {
// 3. 为每个物模型生成统计数据(包括无数据的物模型)
for (Map.Entry<String, ThingsModel> entry : allModels.entrySet()) {
String identity = entry.getKey();
List<MetricData> data = entry.getValue();
ThingsModel model = entry.getValue();
List<MetricData> 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<String, List<MetricData>> byIdentity, ReportContext context) {
String fallback = null;
for (Map.Entry<String, List<MetricData>> 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<MetricPointData> metricPoints = buildMetricPoints(result, context);
result.setMetricPoints(metricPoints);
// 生成详细结论
List<Conclusion> 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<MetricData> 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<MetricPointData> buildMetricPoints(AnalysisResult result, ReportContext context) {
List<MetricPointData> points = new ArrayList<>();
for (Map.Entry<String, Statistics> 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<String, List<MetricData>> byIdentity = context.getMetricDataList().stream()
.filter(d -> d.getIdentity() != null)
.collect(Collectors.groupingBy(MetricData::getIdentity, LinkedHashMap::new, Collectors.toList()));
Map<String, byte[]> charts = metricChartBuilder.build(result.getStatistics(), primaryIdentity, byIdentity);
result.getCharts().putAll(metricChartBuilder.fillPlaceholders(charts));
}
private String statusText(String status) {

View File

@ -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<String, List<MetricData>> 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<String, ThingsModel> 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<String, List<MetricData>> entry : byIdentity.entrySet()) {
// 3. 为每个物模型生成统计数据(包括无数据的物模型)
for (Map.Entry<String, ThingsModel> entry : allModels.entrySet()) {
String identity = entry.getKey();
List<MetricData> data = entry.getValue();
ThingsModel model = entry.getValue();
List<MetricData> 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<String, List<MetricData>> byIdentity, ReportContext context) {
String fallback = null;
for (Map.Entry<String, List<MetricData>> 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<MetricPointData> metricPoints = buildMetricPoints(result, context);
result.setMetricPoints(metricPoints);
// 生成详细结论
List<Conclusion> 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<MetricData> 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<MetricPointData> buildMetricPoints(AnalysisResult result, ReportContext context) {
List<MetricPointData> points = new ArrayList<>();
for (Map.Entry<String, Statistics> 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<String, List<MetricData>> byIdentity = context.getMetricDataList().stream()
.filter(d -> d.getIdentity() != null)
.collect(Collectors.groupingBy(MetricData::getIdentity, LinkedHashMap::new, Collectors.toList()));
Map<String, byte[]> charts = metricChartBuilder.build(result.getStatistics(), primaryIdentity, byIdentity);
result.getCharts().putAll(metricChartBuilder.fillPlaceholders(charts));
}
private String statusText(String status) {

View File

@ -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<String, List<MetricData>> 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<String, ThingsModel> 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<String, List<MetricData>> entry : byIdentity.entrySet()) {
// 3. 为每个物模型生成统计数据(包括无数据的物模型)
for (Map.Entry<String, ThingsModel> entry : allModels.entrySet()) {
String identity = entry.getKey();
List<MetricData> data = entry.getValue();
ThingsModel model = entry.getValue();
List<MetricData> 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<String, List<MetricData>> byIdentity, ReportContext context) {
String fallback = null;
for (Map.Entry<String, List<MetricData>> 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<MetricPointData> metricPoints = buildMetricPoints(result, context);
result.setMetricPoints(metricPoints);
// 生成详细结论
List<Conclusion> 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<MetricData> 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<String, Statistics> 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<MetricPointData> buildMetricPoints(AnalysisResult result, ReportContext context) {
List<MetricPointData> points = new ArrayList<>();
for (Map.Entry<String, Statistics> 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<String, Statistics> 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<String, Statistics> 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<String, List<MetricData>> byIdentity = context.getMetricDataList().stream()
.filter(d -> d.getIdentity() != null)
.collect(Collectors.groupingBy(MetricData::getIdentity, LinkedHashMap::new, Collectors.toList()));
Map<String, byte[]> charts = metricChartBuilder.build(result.getStatistics(), primaryIdentity, byIdentity);
result.getCharts().putAll(metricChartBuilder.fillPlaceholders(charts));
}
private String statusText(String status) {

View File

@ -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<String, List<MetricData>> 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<String, ThingsModel> 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<String, List<MetricData>> entry : byIdentity.entrySet()) {
// 3. 为每个物模型生成统计数据(包括无数据的物模型)
for (Map.Entry<String, ThingsModel> entry : allModels.entrySet()) {
String identity = entry.getKey();
List<MetricData> data = entry.getValue();
ThingsModel model = entry.getValue();
List<MetricData> 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<String, List<MetricData>> byIdentity, ReportContext context) {
String fallback = null;
for (Map.Entry<String, List<MetricData>> 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<MetricPointData> metricPoints = buildMetricPoints(result, context);
result.setMetricPoints(metricPoints);
// 生成详细结论
List<Conclusion> 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<MetricData> 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<MetricPointData> buildMetricPoints(AnalysisResult result, ReportContext context) {
List<MetricPointData> points = new ArrayList<>();
for (Map.Entry<String, Statistics> 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<String, List<MetricData>> byIdentity = context.getMetricDataList().stream()
.filter(d -> d.getIdentity() != null)
.collect(Collectors.groupingBy(MetricData::getIdentity, LinkedHashMap::new, Collectors.toList()));
Map<String, byte[]> charts = metricChartBuilder.build(result.getStatistics(), primaryIdentity, byIdentity);
result.getCharts().putAll(metricChartBuilder.fillPlaceholders(charts));
}
private String statusText(String status) {

View File

@ -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<String, List<MetricData>> 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<String, ThingsModel> 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<String, List<MetricData>> entry : byIdentity.entrySet()) {
// 3. 为每个物模型生成统计数据(包括无数据的物模型)
for (Map.Entry<String, ThingsModel> entry : allModels.entrySet()) {
String identity = entry.getKey();
List<MetricData> data = entry.getValue();
ThingsModel model = entry.getValue();
List<MetricData> 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<String, List<MetricData>> byIdentity, ReportContext context) {
String fallback = null;
for (Map.Entry<String, List<MetricData>> 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<MetricPointData> metricPoints = buildMetricPoints(result, context);
result.setMetricPoints(metricPoints);
// 生成详细结论
List<Conclusion> 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<MetricData> 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<MetricPointData> buildMetricPoints(AnalysisResult result, ReportContext context) {
List<MetricPointData> points = new ArrayList<>();
for (Map.Entry<String, Statistics> 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<String, List<MetricData>> byIdentity = context.getMetricDataList().stream()
.filter(d -> d.getIdentity() != null)
.collect(Collectors.groupingBy(MetricData::getIdentity, LinkedHashMap::new, Collectors.toList()));
Map<String, byte[]> charts = metricChartBuilder.build(result.getStatistics(), primaryIdentity, byIdentity);
result.getCharts().putAll(metricChartBuilder.fillPlaceholders(charts));
}
private String statusText(String status) {

View File

@ -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<String, List<MetricData>> 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<String, ThingsModel> 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<String, List<MetricData>> entry : byIdentity.entrySet()) {
// 3. 为每个物模型生成统计数据(包括无数据的物模型)
for (Map.Entry<String, ThingsModel> entry : allModels.entrySet()) {
String identity = entry.getKey();
List<MetricData> data = entry.getValue();
ThingsModel model = entry.getValue();
List<MetricData> 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<String, List<MetricData>> byIdentity, ReportContext context) {
String fallback = null;
for (Map.Entry<String, List<MetricData>> 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<MetricPointData> metricPoints = buildMetricPoints(result, context);
result.setMetricPoints(metricPoints);
// 生成详细结论
List<Conclusion> 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<MetricData> 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<MetricPointData> buildMetricPoints(AnalysisResult result, ReportContext context) {
List<MetricPointData> points = new ArrayList<>();
for (Map.Entry<String, Statistics> 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<String, List<MetricData>> byIdentity = context.getMetricDataList().stream()
.filter(d -> d.getIdentity() != null)
.collect(Collectors.groupingBy(MetricData::getIdentity, LinkedHashMap::new, Collectors.toList()));
Map<String, byte[]> charts = metricChartBuilder.build(result.getStatistics(), primaryIdentity, byIdentity);
result.getCharts().putAll(metricChartBuilder.fillPlaceholders(charts));
}
private String statusText(String status) {

View File

@ -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<String, List<MetricData>> 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<String, ThingsModel> 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<String, List<MetricData>> entry : byIdentity.entrySet()) {
// 3. 为每个物模型生成统计数据(包括无数据的物模型)
for (Map.Entry<String, ThingsModel> entry : allModels.entrySet()) {
String identity = entry.getKey();
List<MetricData> data = entry.getValue();
ThingsModel model = entry.getValue();
List<MetricData> 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<String, List<MetricData>> byIdentity, ReportContext context) {
String fallback = null;
for (Map.Entry<String, List<MetricData>> 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<MetricPointData> metricPoints = buildMetricPoints(result, context);
result.setMetricPoints(metricPoints);
// 生成详细结论
List<Conclusion> 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<MetricData> 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<MetricPointData> buildMetricPoints(AnalysisResult result, ReportContext context) {
List<MetricPointData> points = new ArrayList<>();
for (Map.Entry<String, Statistics> 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<String, List<MetricData>> byIdentity = context.getMetricDataList().stream()
.filter(d -> d.getIdentity() != null)
.collect(Collectors.groupingBy(MetricData::getIdentity, LinkedHashMap::new, Collectors.toList()));
Map<String, byte[]> charts = metricChartBuilder.build(result.getStatistics(), primaryIdentity, byIdentity);
result.getCharts().putAll(metricChartBuilder.fillPlaceholders(charts));
}
private String statusText(String status) {

View File

@ -1,15 +0,0 @@
package com.fastbee.report.api;
/**
* 报告引擎入口
*
* @author FastBee
* @date 2026-08-16
*/
public interface ReportEngine {
/**
* 生成报告(V1 同步生成,返回报告编号 / 文件地址 / 过期时间)
*/
ReportResult generate(ReportRequest request);
}

View File

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

View File

@ -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<String, List<MetricData>> seriesMap) {
TimeSeriesCollection dataset = new TimeSeriesCollection();
if (seriesMap != null) {
for (Map.Entry<String, List<MetricData>> 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<>());
}
}

View File

@ -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;
/**
* 监测指标图表构建器(主指标单图 + 其余指标按单位分组多曲线图)
*
* <p>约定 charts 的 key:</p>
* <ul>
* <li>{@code timeSeriesChart}:主监测指标时程曲线(与模板兼容)</li>
* <li>{@code chart2} / {@code chart3}:其余指标按单位分组的时程曲线(最多 2 组)</li>
* </ul>
*
* @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<String, byte[]> build(Map<String, Statistics> statisticsMap,
String primaryIdentity,
Map<String, List<MetricData>> byIdentity) {
Map<String, byte[]> 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<String, LinkedHashMap<String, List<MetricData>>> groupedByUnit = new LinkedHashMap<>();
for (Map.Entry<String, Statistics> entry : statisticsMap.entrySet()) {
String identity = entry.getKey();
if (identity.equals(primaryIdentity)) {
continue;
}
Statistics statistics = entry.getValue();
// 无数据的指标不生成图表
if (statistics == null || statistics.getCount() == 0) {
continue;
}
String unit = statistics.getUnit() == null ? "" : statistics.getUnit();
String name = statistics.getModelName() == null ? identity : statistics.getModelName();
groupedByUnit.computeIfAbsent(unit, k -> new LinkedHashMap<>())
.put(name, byIdentity.getOrDefault(identity, new ArrayList<>()));
}
int index = 2;
for (Map.Entry<String, LinkedHashMap<String, List<MetricData>>> entry : groupedByUnit.entrySet()) {
if (index > MAX_EXTRA_CHARTS + 1) {
break;
}
String unit = entry.getKey();
String title = entry.getValue().keySet().iterator().next() + "等指标时程曲线";
if (entry.getValue().size() == 1) {
title = entry.getValue().keySet().iterator().next() + "时程曲线";
}
charts.put("chart" + index,
chartGenerator.multiSeriesChart(title, unit, entry.getValue()));
index++;
}
return charts;
}
/**
* 补齐模板预留的图表位,避免模板标签无对应数据导致渲染异常
*/
public Map<String, byte[]> fillPlaceholders(Map<String, byte[]> charts) {
Map<String, byte[]> 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;
}
}

View File

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

View File

@ -42,4 +42,10 @@ public class ReportContext {
private Map<String, ReportThreshold> thresholds;
private String reportNo;
/** 创建人用户名(从主线程传递,避免异步线程无法访问 SecurityContext) */
private String createBy;
/** 租户ID(从主线程传递) */
private Long tenantId;
}

View File

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

View File

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

View File

@ -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<MetricPointData> metricPoints = new ArrayList<>();
/** 每个物模型标识符对应的统计指标 */
private Map<String, Statistics> statistics = new LinkedHashMap<>();

View File

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

View File

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

View File

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

View File

@ -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<String, Object> 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<XWPFTableRow> 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<String, Object> buildData(ReportModel model) {
Map<String, Object> 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<String, Object> 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<String, Object> analysisMap = new HashMap<>();
@ -117,26 +170,68 @@ public class WordRenderer implements ReportRenderer {
analysisMap.put("quality", qualityMap);
analysisMap.put("warning", warningMap);
Map<String, Object> 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<Map<String, Object>> metricPointsList = new ArrayList<>();
if (analysis.getMetricPoints() != null && !analysis.getMetricPoints().isEmpty()) {
for (MetricPointData point : analysis.getMetricPoints()) {
Map<String, Object> 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<String, Object> charts = new HashMap<>();
for (Map.Entry<String, byte[]> 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<Map<String, Object>> 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 "安全";
}
}

View File

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

View File

@ -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<MetricData> 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<String, java.util.List<MetricData>> 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();
}
}

View File

@ -400,6 +400,15 @@ public interface DeviceMapper extends BaseMapperX<Device> {
*/
List<Device> selectDeviceListByDeviceIds(@Param("deviceIdList") List<Long> deviceIdList);
/**
* 树形设备列表专用轻量查询:只查树所需字段,复用简短列表的权限过滤,
* 避开 is_owner/sub_device_count 逐行关联子查询
*
* @param device 查询条件
* @return 设备简短列表(仅含树所需字段)
*/
List<DeviceShortOutput> selectDeviceTreeBaseList(Device device);
/**
* 根据监控设备channelId获取设备
*

View File

@ -118,4 +118,12 @@ public interface SubGatewayMapper {
* @return 子设备ID列表
*/
List<Long> selectSubDeviceIdsByGwDeviceId(Long gwDeviceId);
/**
* 根据网关设备ID列表批量查询网关与子设备关联(一次查询替代逐网关循环查询)
*
* @param gwDeviceIdList 网关设备ID列表
* @return 网关与子设备关联集合
*/
List<SubGateway> selectSubGatewayListByGwDeviceIds(@Param("gwDeviceIdList") List<Long> gwDeviceIdList);
}

View File

@ -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<DeviceTreeVO> children;
/**
* 设备完整路径名称(如:网关A / 子设备A-1)
*/
@ApiModelProperty("设备完整路径名称")
private String fullPath;
}

View File

@ -432,4 +432,12 @@ public interface IDeviceService {
public int updateDeviceBySerialNumber(Device device);
/**
* 查询树形设备列表(网关设备可展开显示子设备)
*
* @param device 查询条件
* @return 树形设备列表
*/
List<DeviceTreeVO> selectDeviceTreeList(Device device);
}

View File

@ -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<DeviceTreeVO> selectDeviceTreeList(Device device) {
long startTime = System.currentTimeMillis();
// 1. 轻量查询所有设备(包含网关和普通设备),只取树所需字段,避开逐行关联子查询
List<DeviceShortOutput> allDevices = deviceMapper.selectDeviceTreeBaseList(device);
// 2. 收集网关设备ID,一次批量查询所有网关-子设备关系(替代逐网关循环查询)
List<Long> gatewayIds = allDevices.stream()
.filter(d -> d.getDeviceType() != null && d.getDeviceType() == 2)
.map(DeviceShortOutput::getDeviceId)
.collect(Collectors.toList());
Map<Long, List<Long>> gatewaySubDeviceMap = new HashMap<>();
Set<Long> allSubDeviceIds = new HashSet<>();
for (List<Long> 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<Long, DeviceShortOutput> subDeviceMap = new HashMap<>();
if (!allSubDeviceIds.isEmpty()) {
List<Device> subDevices = new ArrayList<>();
for (List<Long> batch : partitionList(new ArrayList<>(allSubDeviceIds), SQL_IN_BATCH_SIZE)) {
subDevices.addAll(deviceMapper.selectDeviceListByDeviceIds(batch));
}
subDeviceMap = buildSubDeviceShortMap(subDevices);
}
// 4. 合并设备映射:网关/直连设备 + 子设备
Map<Long, DeviceShortOutput> deviceMap = allDevices.stream()
.collect(Collectors.toMap(DeviceShortOutput::getDeviceId, d -> d, (a, b) -> a));
deviceMap.putAll(subDeviceMap); // 添加子设备
// 5. 构建树形结构(用 Set 判断子设备归属,避免嵌套遍历扫描)
List<DeviceTreeVO> treeList = new ArrayList<>();
for (DeviceShortOutput d : allDevices) {
Integer deviceType = d.getDeviceType();
// 网关设备
if (deviceType != null && deviceType == 2) {
DeviceTreeVO gatewayNode = buildDeviceTreeVO(d, false);
// 添加子设备
List<Long> subDeviceIds = gatewaySubDeviceMap.get(d.getDeviceId());
if (subDeviceIds != null && !subDeviceIds.isEmpty()) {
List<DeviceTreeVO> 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<Long, DeviceShortOutput> buildSubDeviceShortMap(List<Device> subDevices) {
Set<Long> productIds = subDevices.stream()
.map(Device::getProductId)
.filter(Objects::nonNull)
.collect(Collectors.toSet());
Map<Long, String> productNameMap = new HashMap<>();
for (List<Long> batch : partitionList(new ArrayList<>(productIds), SQL_IN_BATCH_SIZE)) {
for (Product product : productMapper.selectProductListByProductIds(batch)) {
productNameMap.put(product.getProductId(), product.getProductName());
}
}
Map<Long, DeviceShortOutput> 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 <T> List<List<T>> partitionList(List<T> list, int size) {
List<List<T>> 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;
}
}

View File

@ -419,18 +419,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
update iot_device set things_model_value=#{stringValue} where device_id = #{deviceId}
</update>
<select id="selectDeviceShortList" parameterType="com.fastbee.iot.domain.Device" resultMap="DeviceShortResult">
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
<!-- 设备简短列表的 FROM/WHERE 共享片段(含权限过滤,供 selectDeviceShortList 与 selectDeviceTreeBaseList 复用) -->
<sql id="selectDeviceShortListJoinWhere">
from iot_device d
left join iot_product p on p.product_id=d.product_id
<if test="groupId != null and groupId !=0 "> left join iot_device_group g on g.device_id=d.device_id </if>
@ -468,6 +458,28 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
</where>
group by d.device_id,d.tenant_id
order by d.create_time desc
</sql>
<select id="selectDeviceShortList" parameterType="com.fastbee.iot.domain.Device" resultMap="DeviceShortResult">
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
<include refid="selectDeviceShortListJoinWhere"/>
</select>
<!-- 树形设备列表专用轻量查询:只取树所需字段,避开 is_owner/sub_device_count 逐行关联子查询 -->
<select id="selectDeviceTreeBaseList" parameterType="com.fastbee.iot.domain.Device" resultMap="DeviceShortResult">
select d.device_id, d.device_name, d.serial_number, d.status, d.tenant_id,
p.product_id, p.product_name, p.device_type
<include refid="selectDeviceShortListJoinWhere"/>
</select>
<select id="selectDeviceShortStatistic" parameterType="com.fastbee.iot.domain.Device" resultType="com.fastbee.iot.model.DeviceStatistic">

View File

@ -133,6 +133,15 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
where gw_device_id = #{gwDeviceId}
</select>
<select id="selectSubGatewayListByGwDeviceIds" resultMap="GatewayResult">
select id, gw_device_id, sub_device_id, slave_id
from iot_sub_gateway
where gw_device_id in
<foreach collection="gwDeviceIdList" item="gwDeviceId" open="(" separator="," close=")">
#{gwDeviceId}
</foreach>
</select>
<select id="getIsSelectGateSubDevice" parameterType="com.fastbee.iot.model.gateWay.GateSubDeviceVO"
resultMap="GatewaySubDeviceResult">
select d.device_id,d.serial_number,d.device_name

View File

@ -0,0 +1,316 @@
#!/usr/bin/env python3
"""
专业报告模版生成脚本 V2.2
修复循环表格语法(poi-tl 需要标记行 + 模板行的结构)
"""
from docx import Document
from docx.shared import Pt, Cm
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.enum.table import WD_TABLE_ALIGNMENT
from docx.oxml.ns import qn
from docx.oxml import OxmlElement
import os
def set_font(run, name='宋体', size=12, bold=False):
"""设置字体"""
run.font.name = name
run._element.rPr.rFonts.set(qn('w:eastAsia'), name)
run.font.size = Pt(size)
run.bold = bold
def add_heading(doc, text, size=14):
"""添加章节标题"""
para = doc.add_paragraph()
run = para.add_run(text)
set_font(run, '黑体', size, bold=True)
para.paragraph_format.space_before = Pt(12)
para.paragraph_format.space_after = Pt(6)
return para
def add_body(doc, text, indent=True):
"""添加正文段落"""
para = doc.add_paragraph()
run = para.add_run(text)
set_font(run, '宋体', 12)
if indent:
para.paragraph_format.first_line_indent = Cm(0.74)
return para
def add_label_para(doc, label, value):
"""添加 标签:值 段落"""
para = doc.add_paragraph()
label_run = para.add_run(label)
set_font(label_run, '宋体', 12, bold=True)
value_run = para.add_run(value)
set_font(value_run, '宋体', 12)
return para
def set_cell_background(cell, color):
"""设置单元格背景色"""
shading = OxmlElement('w:shd')
shading.set(qn('w:val'), 'clear')
shading.set(qn('w:fill'), color)
cell._tc.get_or_add_tcPr().append(shading)
def set_cell_vertical_center(cell):
"""单元格内容垂直居中"""
vAlign = OxmlElement('w:vAlign')
vAlign.set(qn('w:val'), 'center')
cell._tc.get_or_add_tcPr().append(vAlign)
def style_header_cell(cell, bg_color='DCE6F1'):
"""表头单元格样式:加粗 + 居中 + 浅蓝底纹 + 垂直居中"""
set_cell_background(cell, bg_color)
set_cell_vertical_center(cell)
cell.paragraphs[0].alignment = WD_ALIGN_PARAGRAPH.CENTER
for run in cell.paragraphs[0].runs:
run.bold = True
def style_body_cell(cell, align=WD_ALIGN_PARAGRAPH.CENTER):
"""数据单元格样式:垂直居中"""
set_cell_vertical_center(cell)
cell.paragraphs[0].alignment = align
def set_table_widths(table, widths_cm):
"""设置表格列宽(厘米)"""
for row in table.rows:
for i, width in enumerate(widths_cm):
if i < len(row.cells):
row.cells[i].width = Cm(width)
def create_professional_template(template_name, device_type, template_dir):
"""创建专业报告模版"""
doc = Document()
# 页面设置:A4 + 标准页边距
section = doc.sections[0]
section.page_width = Cm(21.0)
section.page_height = Cm(29.7)
section.left_margin = Cm(2.54)
section.right_margin = Cm(2.54)
section.top_margin = Cm(2.54)
section.bottom_margin = Cm(2.54)
# 设置文档默认样式:宋体小四、1.5 倍行距
style = doc.styles['Normal']
style.font.name = '宋体'
style._element.rPr.rFonts.set(qn('w:eastAsia'), '宋体')
style.font.size = Pt(12)
style.paragraph_format.line_spacing = 1.5
# ===== 1. 标题 =====
title = doc.add_paragraph()
title.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = title.add_run(f'{device_type}监测分析报告')
set_font(run, '黑体', 18, bold=True)
title.paragraph_format.space_after = Pt(12)
# ===== 2. 基本信息 =====
info_para = doc.add_paragraph()
r1 = info_para.add_run('报告编号:')
set_font(r1, '宋体', 12, bold=True)
r2 = info_para.add_run('{{report.no}}')
set_font(r2, '宋体', 12)
r3 = info_para.add_run(' 生成时间:')
set_font(r3, '宋体', 12, bold=True)
r4 = info_para.add_run('{{report.generatedAt}}')
set_font(r4, '宋体', 12)
add_label_para(doc, '报告周期:', '{{report.periodDesc}}')
add_label_para(doc, '设备信息:', '设备名称 {{device.name}}|设备编号 {{device.serialNumber}}|所属产品 {{device.productName}}')
add_label_para(doc, '监测指标:', '{{device.metricName}}')
add_label_para(doc, '控制值:', '{{controlValue}} {{controlValueUnit}}')
# ===== 3. 项目概况 =====
add_heading(doc, '一、项目概况')
add_body(doc, '{{summary}}')
# ===== 4. 安全评估 =====
add_heading(doc, '二、安全评估')
add_label_para(doc, '安全等级:', '{{safetyLevel}}')
add_label_para(doc, '评估说明:', '{{safetyDescription}}')
# ===== 5. 统计指标 =====
add_heading(doc, '三、统计指标')
table = doc.add_table(rows=10, cols=2)
table.style = 'Table Grid'
table.alignment = WD_TABLE_ALIGNMENT.CENTER
header_cells = table.rows[0].cells
header_cells[0].text = '统计指标'
header_cells[1].text = '数值'
style_header_cell(header_cells[0])
style_header_cell(header_cells[1])
metrics = [
('当前值', '{{analysis.statistics.currentWithUnit}}'),
('初始值', '{{analysis.statistics.initialWithUnit}}'),
('最大值', '{{analysis.statistics.maxWithUnit}}'),
('最小值', '{{analysis.statistics.minWithUnit}}'),
('平均值', '{{analysis.statistics.avgWithUnit}}'),
('中位数', '{{analysis.statistics.medianWithUnit}}'),
('变化量', '{{analysis.statistics.deltaWithUnit}}'),
('累计变化', '{{analysis.statistics.totalDeltaWithUnit}}'),
('数据量', '{{analysis.statistics.count}}'),
]
for i, (label, value) in enumerate(metrics, start=1):
row = table.rows[i]
row.cells[0].text = label
row.cells[1].text = value
style_body_cell(row.cells[0])
style_body_cell(row.cells[1])
set_table_widths(table, [4.5, 11.0])
# ===== 6. 测点数据(循环表格) =====
add_heading(doc, '四、测点数据')
# poi-tl LoopRowTableRenderPolicy 需要的结构:
# 第1行:表头
# 第2行:{{listName}} 标记行(可以是空单元格)
# 第3行:模板行,使用 [field] 语法
points_table = doc.add_table(rows=3, cols=8)
points_table.style = 'Table Grid'
points_table.alignment = WD_TABLE_ALIGNMENT.CENTER
# 表头
points_headers = ['测点', '初始值', '当前值', '变化量', '变化速率', '最大值', '最小值', '预警状态']
points_header_row = points_table.rows[0]
for i, header in enumerate(points_headers):
points_header_row.cells[i].text = header
style_header_cell(points_header_row.cells[i])
# 标记行:{{metricPoints}}
points_marker_row = points_table.rows[1]
points_marker_row.cells[0].text = '{{metricPoints}}'
# 模板行:[field] 语法
points_data_row = points_table.rows[2]
points_data = [
'[modelName]',
'[initialValue]',
'[currentValue]',
'[changeValue]',
'[changeRate]',
'[maxValue]',
'[minValue]',
'[warningStatus]',
]
for i, data in enumerate(points_data):
points_data_row.cells[i].text = data
style_body_cell(points_data_row.cells[i])
set_table_widths(points_table, [2.6, 1.84, 1.84, 1.84, 1.84, 1.84, 1.84, 1.84])
# ===== 7. 趋势分析 =====
add_heading(doc, '五、趋势分析')
add_body(doc, '{{analysis.trend.description}}')
# ===== 8. 数据质量 =====
add_heading(doc, '六、数据质量')
add_body(doc, '{{analysis.quality.description}}')
# ===== 9. 预警状态 =====
add_heading(doc, '七、预警状态')
add_label_para(doc, '状态:', '{{analysis.warning.status}}')
add_body(doc, '{{analysis.warning.description}}', indent=False)
# ===== 10. 时程曲线(主指标 + 分组指标) =====
add_heading(doc, '八、时程曲线')
chart_para1 = doc.add_paragraph()
chart_para1.alignment = WD_ALIGN_PARAGRAPH.CENTER
chart_para1.add_run('{{@charts.timeSeriesChart}}')
chart_para2 = doc.add_paragraph()
chart_para2.alignment = WD_ALIGN_PARAGRAPH.CENTER
chart_para2.add_run('{{@charts.chart2}}')
chart_para3 = doc.add_paragraph()
chart_para3.alignment = WD_ALIGN_PARAGRAPH.CENTER
chart_para3.add_run('{{@charts.chart3}}')
# ===== 11. 专业分析建议 =====
add_heading(doc, '九、专业分析建议')
add_body(doc, '{{professionalAdvice}}')
# ===== 12. 结论(循环表格) =====
add_heading(doc, '十、结论')
# poi-tl LoopRowTableRenderPolicy 结构
conclusion_table = doc.add_table(rows=3, cols=2)
conclusion_table.style = 'Table Grid'
conclusion_table.alignment = WD_TABLE_ALIGNMENT.CENTER
# 表头
conclusion_header_row = conclusion_table.rows[0]
conclusion_header_row.cells[0].text = '序号'
conclusion_header_row.cells[1].text = '结论内容'
style_header_cell(conclusion_header_row.cells[0])
style_header_cell(conclusion_header_row.cells[1])
# 标记行
conclusion_marker_row = conclusion_table.rows[1]
conclusion_marker_row.cells[0].text = '{{conclusions}}'
# 模板行
conclusion_data_row = conclusion_table.rows[2]
conclusion_data_row.cells[0].text = '[seq]'
conclusion_data_row.cells[1].text = '[text]'
style_body_cell(conclusion_data_row.cells[0])
style_body_cell(conclusion_data_row.cells[1], WD_ALIGN_PARAGRAPH.LEFT)
set_table_widths(conclusion_table, [1.5, 14.0])
# 保存文档
output_path = os.path.join(template_dir, f'{template_name}_V2.docx')
doc.save(output_path)
print(f'已生成模版: {output_path}')
return output_path
def main():
"""主函数"""
template_dir = '/Users/admin/IdeaProjects/fastbee/fastbee-report/src/main/resources/templates'
device_types = {
'DEVICE_CRACK_METER': '裂缝计',
'DEVICE_DEEP_DISPLACEMENT': '深部位移',
'DEVICE_AXIAL_FORCE': '轴力/应力',
'DEVICE_STATIC_LEVEL': '静力水准',
'DEVICE_VIBRATING_WIRE': '振弦',
'DEVICE_STRAIN_GAUGE': '应变计',
'DEVICE_WATER_LEVEL': '水位计',
'DEVICE_RANGING_METER': '测距仪',
'DEVICE_INCLINOMETER': '倾角计',
'DEVICE_THERMOMETER': '温度计',
'DEVICE_RAIN_GAUGE': '雨量计',
}
print('开始生成专业报告模版 V2.2(修复循环表格语法)...')
print(f'模版目录: {template_dir}')
print()
for template_name, device_type in device_types.items():
create_professional_template(template_name, device_type, template_dir)
print()
print('所有模版生成完成!')
if __name__ == '__main__':
main()

View File

@ -146,7 +146,9 @@ CREATE TABLE `report_record` (
-- ------------------------------------------------------------
-- 3. oss_config:MinIO 默认配置(未购买云 OSS → 自建 MinIO)
-- 注意:endpoint 需改为实际 MinIO 部署地址;下载统一走后端代理,桶保持私有(access_policy=0)
-- endpoint:后端连接 MinIO 的 S3 API 地址(若后端与 MinIO 同机可改为 http://127.0.0.1:9000)
-- domain:为空时 getUrl() 返回 http://<endpoint>/<bucketName>;可配置 CDN/公网域名生成更友好的 URL
-- 注意:端口 9000 需在安全组/防火墙放行;下载统一走后端代理,桶保持私有(access_policy=0)
-- ------------------------------------------------------------
UPDATE `oss_config` SET `status` = '1' WHERE `config_key` IN ('aliyun', 'qiniu');
@ -155,7 +157,7 @@ INSERT IGNORE INTO `oss_config`
endpoint, domain, is_https, region, access_policy, status, del_flag, create_by, create_time, update_by, update_time, remark)
VALUES
(1, 'admin', 'minio', 'yack-superme', 'yack-minio20260816', 'fastbee', 'report',
'http://127.0.0.1:9000', '', 'N', '', '0', '0', '0', 'admin', NOW(), 'admin', NOW(), 'MinIO默认存储');
'http://47.121.180.229:9000', '', 'N', '', '0', '0', '0', 'admin', NOW(), 'admin', NOW(), 'MinIO默认存储');
-- ------------------------------------------------------------
-- 4. 历史报表菜单与权限(数据中心 3343 下新增)

View File

@ -210,3 +210,12 @@ export function listThingsModel(query) {
params: query,
});
}
// 查询树形设备列表(网关可展开子设备)
export function listDeviceTree(query) {
return request({
url: '/iot/device/treeList',
method: 'get',
params: query,
});
}

View File

@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg class="icon" width="200px" height="200.00px" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M535.119473 0h69.599248v95.247413C729.226717 96.331138 853.614299 93.92286 977.881468 96.331138a40.459078 40.459078 0 0 1 44.914393 45.516463c2.047037 234.566322 0 469.614299 1.204139 703.819379-1.204139 24.082785 2.287865 50.694262-11.318909 72.248354-16.978363 12.041392-38.893697 10.837253-58.761994 12.041392h-349.200376V1023.518344h-72.248354C354.980245 990.886171 177.490122 960.541863 0 928.752587V95.488241C178.33302 63.578551 356.786453 32.511759 535.119473 0z" fill="#707070" /><path d="M604.718721 131.010348H988.598307v761.979304H604.718721v-95.247413h302.479774v-48.165569H604.718721v-59.002822h302.479774v-48.16557H604.718721v-59.002822h302.479774v-48.165569H604.718721v-60.206961h302.479774V428.673565H604.718721v-60.206961h302.479774v-46.96143H604.718721v-59.604892h302.479774V214.336783H604.718721zM240.827846 341.373471c22.156162-1.324553 44.19191-2.287865 66.348071-3.492003 15.533396 80.4365 31.30762 160.632173 48.165569 240.827845 13.125118-82.724365 27.695202-165.087488 41.783632-247.571025 23.239887-0.842897 46.479774-2.167451 69.719661-3.612418-26.370649 115.356538-49.369708 231.796802-78.148636 346.430856-19.386642 10.355597-48.165569 0-71.52587 1.204139C301.034807 596.169332 283.093133 517.779868 269.245532 438.667921c-13.606773 76.944497-31.30762 153.16651-46.841016 229.508937-22.39699-1.204139-44.793979-2.528692-67.311383-4.094073-19.266228-104.760113-42.024459-208.918156-60.206962-313.919097 19.868297-0.963311 39.857008-1.806209 60.206962-2.528693 12.041392 75.860771 25.648166 151.360301 36.124177 227.341487 16.135466-77.907808 32.873001-155.695202 49.610536-233.603011z" fill="#FFFFFF" /></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@ -1365,3 +1365,69 @@
width: 100%;
margin-top: 8px;
}
/* ========== 分析模式操作按钮 ========== */
.analysis-action-btns {
display: grid;
grid-template-columns: 1fr;
gap: 10px;
margin-top: 4px;
}
.action-btn {
appearance: none;
-webkit-appearance: none;
outline: none;
display: flex;
align-items: center;
justify-content: center;
gap: 7px;
height: 36px;
border-radius: 3px;
color: #fff;
font-size: 13px;
font-weight: 600;
letter-spacing: 2px;
cursor: pointer;
transition: all .18s ease;
user-select: none;
i {
font-size: 15px;
}
&:disabled {
opacity: .42;
cursor: not-allowed;
box-shadow: none;
}
&.is-loading {
opacity: .75;
cursor: wait;
}
}
.action-btn--primary {
border: 1px solid rgba(64, 158, 255, .7);
background: linear-gradient(180deg, #409EFF, #337ecc);
box-shadow: 0 0 12px rgba(64, 158, 255, .25), inset 0 0 8px rgba(255, 255, 255, .08);
&:not(:disabled):hover {
border-color: rgba(102, 185, 255, .85);
background: linear-gradient(180deg, #66b1ff, #409EFF);
box-shadow: 0 0 16px rgba(64, 158, 255, .4), inset 0 0 8px rgba(255, 255, 255, .12);
}
}
.action-btn--success {
border: 1px solid rgba(103, 194, 58, .7);
background: linear-gradient(180deg, #67C23A, #529b2e);
box-shadow: 0 0 12px rgba(103, 194, 58, .2), inset 0 0 8px rgba(255, 255, 255, .08);
&:not(:disabled):hover {
border-color: rgba(133, 220, 88, .85);
background: linear-gradient(180deg, #85ce61, #67C23A);
box-shadow: 0 0 16px rgba(103, 194, 58, .35), inset 0 0 8px rgba(255, 255, 255, .12);
}
}

View File

@ -228,16 +228,18 @@
<div class="panel-title">分析条件</div>
<el-form label-position="top" size="mini" class="analysis-form">
<el-form-item label="设备">
<el-select v-model="analysisQuery.deviceId" filterable remote clearable placeholder="请选择设备(支持搜索)"
:remote-method="searchAnalysisDevices" :loading="analysisDeviceSearchLoading"
@visible-change="onAnalysisDeviceVisible" @change="handleAnalysisDeviceChange">
<el-option
v-for="device in analysisDeviceOptions"
:key="device.deviceId"
:label="device.deviceName || device.serialNumber"
:value="device.deviceId"
/>
</el-select>
<el-cascader
v-model="analysisQuery.deviceId"
:options="deviceTreeOptions"
:props="cascaderProps"
:show-all-levels="true"
placeholder="请选择设备"
filterable
clearable
:loading="deviceTreeLoading"
@change="handleDeviceTreeChange"
style="width: 100%;"
/>
</el-form-item>
<el-form-item label="时间范围">
<!-- 预设时间按钮组 -->
@ -304,14 +306,24 @@
@change="handleCustomTimeChange"
/>
</el-form-item>
<el-button type="primary" icon="el-icon-search" :loading="analysisLoading" @click="runAnalysis">查询数据和曲线</el-button>
<el-button
type="success"
icon="el-icon-document"
:loading="reportLoading"
:disabled="!analysisQuery.deviceId || !analysisQuery.timeRange || analysisQuery.timeRange.length < 2"
@click="handleGenerateReport"
>生成并导出报表</el-button>
<div class="analysis-action-btns">
<button type="button" class="action-btn action-btn--primary" :class="{ 'is-loading': analysisLoading }" :disabled="analysisLoading" @click="runAnalysis">
<i v-if="!analysisLoading" class="el-icon-search"></i>
<i v-else class="el-icon-loading"></i>
<span>查询数据和曲线</span>
</button>
<button
type="button"
class="action-btn action-btn--success"
:class="{ 'is-loading': reportLoading }"
:disabled="!analysisQuery.deviceId || !analysisQuery.timeRange || analysisQuery.timeRange.length < 2 || reportLoading"
@click="handleGenerateReport"
>
<i v-if="!reportLoading" class="el-icon-document"></i>
<i v-else class="el-icon-loading"></i>
<span>生成并导出报表</span>
</button>
</div>
</el-form>
</div>
<div class="panel-card analysis-card">
@ -393,7 +405,7 @@ import { getUserProfile } from '@/api/system/user';
import { saveAs } from 'file-saver';
import { generateDeviceReport, downloadReportRecord } from '@/api/iot/report';
import { blobValidate } from '@/utils/ruoyi';
import { listDeviceShort, getDeviceShortStatistic, getDeviceStatistic, listThingsModel } from '@/api/iot/device';
import { listDeviceShort, listDeviceTree, getDeviceShortStatistic, getDeviceStatistic, listThingsModel } from '@/api/iot/device';
import { listAlertLog } from '@/api/iot/alertLog';
import { getDataCenterDeviceHistory } from '@/api/data/center';
import { listProject } from '@/api/iot/project';
@ -448,6 +460,16 @@ export default {
analysisDeviceOptions: [],
selectedAnalysisDevice: null,
analysisDeviceSearchLoading: false,
deviceTreeOptions: [],
deviceTreeLoading: false,
cascaderProps: {
value: 'deviceId',
label: 'deviceName',
children: 'children',
expandTrigger: 'hover',
checkStrictly: true, // 允许选择任意节点,在change中判断是否可选
emitPath: false,
},
selectedProject: null,
projectChart: null,
deviceChart: null,
@ -670,8 +692,10 @@ export default {
this.renderDashboardMap();
} else if (value === 'mapView') {
this.renderSatelliteMap();
} else {
} else if (value === 'analysis') {
this.resizeAnalysisChart();
// 加载树形设备列表
this.loadDeviceTree();
}
});
},
@ -935,6 +959,96 @@ export default {
this.analysisDeviceSearchLoading = false;
});
},
// 加载树形设备列表
loadDeviceTree() {
this.deviceTreeLoading = true;
const query = { showChild: true, entrance: 2 };
if (this.selectedProject) query.projectId = this.selectedProject.id;
listDeviceTree(query)
.then((res) => {
if (res.code === 200) {
this.deviceTreeOptions = this.processDeviceTree(res.data || []);
// 检查网关设备
const gateways = this.deviceTreeOptions.filter(d => !d.isLeaf);
gateways.forEach((g, i) => {
console.log(` 网关${i+1}: ${g.deviceName}, 子设备数量: ${g.children?.length || 0}`);
});
}
})
.catch((error) => {
this.deviceTreeOptions = [];
})
.finally(() => {
this.deviceTreeLoading = false;
});
},
// 处理设备树:保持原始数据结构
processDeviceTree(tree) {
return tree.map(node => {
// 网关节点:保持children,但标记为网关(用于后续判断)
if (!node.isLeaf) {
return {
...node,
label: '🌐 ' + (node.deviceName || node.serialNumber), // 添加网关图标
children: node.children ? this.processDeviceTree(node.children) : []
};
}
// 叶子节点:可选择,显示完整路径
return {
...node,
label: node.fullPath || node.deviceName || node.serialNumber
};
});
},
// 处理树形设备选择变更
handleDeviceTreeChange(deviceId) {
// 查找选中的设备
const selectedDevice = this.findDeviceById(this.deviceTreeOptions, deviceId);
if (!selectedDevice) {
this.$message.error('设备信息异常,请重新选择');
return;
}
// 判断是否是网关设备
if (!selectedDevice.isLeaf) {
this.$message.warning('网关设备不能直接生成报告,请选择具体的监测设备');
this.analysisQuery.deviceId = null;
this.selectedAnalysisDevice = null; // 清空已选设备
this.identifierList = []; // 清空物模型列表
this.analysisLineList = []; // 清空历史数据
return;
}
// 直接使用树中找到的设备对象,而不是在扁平列表中查找
this.selectedAnalysisDevice = selectedDevice;
this.identifierList = [];
this.analysisLineList = [];
// 加载物模型
listThingsModel({ deviceId, pageNum: 1, pageSize: 9999 })
.then((res) => {
if (res.code === 200) this.identifierList = res.rows || [];
})
.catch(() => {
this.identifierList = [];
});
},
// 根据ID查找设备
findDeviceById(tree, deviceId) {
for (const node of tree) {
if (node.deviceId === deviceId) return node;
if (node.children) {
const found = this.findDeviceById(node.children, deviceId);
if (found) return found;
}
}
return null;
},
onAnalysisDeviceVisible(visible) {
if (visible) this.searchAnalysisDevices('');
},
@ -1435,16 +1549,22 @@ export default {
deviceId: device.deviceId,
startTime: moment(this.analysisQuery.timeRange[0]).format('YYYY-MM-DD HH:mm:ss'),
endTime: moment(this.analysisQuery.timeRange[1]).format('YYYY-MM-DD HH:mm:ss'),
templateCode: 'DEVICE_CRACK_METER_V1',
// 不传 templateCode,后端根据设备监测分类自动选择对应模版
format: 'WORD',
})
.then((res) => {
if (res.code === 200) {
const data = res.data || {};
this.$message.success('报表生成成功,可在历史报表中查看');
if (data.reportNo) {
this.downloadReport(data);
}
// 使用弹框提示用户
this.$alert('报表正在后台生成中,生成完成后可在历史报表中下载', '提交成功', {
confirmButtonText: '查看历史报表',
type: 'success',
callback: action => {
if (action === 'confirm') {
// 跳转到历史报表页面
this.$router.push({ path: '/dataCenter/reportHistory' }).catch(() => {});
}
}
});
}
})
.finally(() => {

View File

@ -4,9 +4,18 @@
<el-card style="margin-bottom: 12px">
<el-form :model="queryParams" ref="queryForm" :inline="true" size="mini" label-width="68px" style="margin-bottom: -18px">
<el-form-item label="设备名称" prop="deviceId">
<el-select v-model="queryParams.deviceId" placeholder="请选择设备" filterable @change="handleDeviceChange" style="width: 200px">
<el-option v-for="item in deviceList" :key="item.deviceId" :label="item.deviceName" :value="item.deviceId"></el-option>
</el-select>
<el-cascader
v-model="queryParams.deviceId"
:options="deviceTreeOptions"
:props="cascaderProps"
:show-all-levels="true"
placeholder="请选择设备"
filterable
clearable
:loading="deviceTreeLoading"
@change="handleDeviceTreeChange"
style="width: 280px"
/>
</el-form-item>
<el-form-item label="时间范围" prop="dayDaterange">
<el-date-picker
@ -148,7 +157,7 @@
<script>
import moment from 'moment';
import { listDeviceShort, listThingsModel } from '@/api/iot/device.js';
import { listDeviceShort, listDeviceTree, listThingsModel } from '@/api/iot/device.js';
import { saveAs } from 'file-saver';
import { generateDeviceReport, downloadReportRecord } from '@/api/iot/report';
import { blobValidate } from '@/utils/ruoyi';
@ -171,6 +180,16 @@ export default {
pendingCount: 0,
queried: false,
deviceList: [],
deviceTreeOptions: [],
deviceTreeLoading: false,
cascaderProps: {
value: 'deviceId',
label: 'deviceName',
children: 'children',
expandTrigger: 'hover',
checkStrictly: true, // 允许选择任意节点,在change中判断是否可选
emitPath: false,
},
identifierList: [],
pickerOptions: {
shortcuts: [
@ -205,6 +224,70 @@ export default {
listDeviceShort({ showChild: true, pageNum: 1, pageSize: 9999 }).then((res) => {
if (res.code === 200) this.deviceList = res.rows;
});
// 同时加载树形设备列表
this.loadDeviceTree();
},
// 加载树形设备列表
loadDeviceTree() {
this.deviceTreeLoading = true;
listDeviceTree({ showChild: true })
.then((res) => {
if (res.code === 200) {
this.deviceTreeOptions = this.processDeviceTree(res.data || []);
}
})
.catch((error) => {
console.error('加载设备树失败:', error);
this.deviceTreeOptions = [];
})
.finally(() => {
this.deviceTreeLoading = false;
});
},
// 处理设备树:保持原始数据结构
processDeviceTree(tree) {
return tree.map(node => {
// 网关节点:保持children,但标记为网关(用于后续判断)
if (!node.isLeaf) {
return {
...node,
label: '🌐 ' + (node.deviceName || node.serialNumber), // 添加网关图标
children: node.children ? this.processDeviceTree(node.children) : []
};
}
// 叶子节点:可选择,显示完整路径
return {
...node,
label: node.fullPath || node.deviceName || node.serialNumber
};
});
},
// 处理树形设备选择变更
handleDeviceTreeChange(deviceId) {
console.log('选择的设备ID:', deviceId);
// 查找选中的设备
const selectedDevice = this.findDeviceById(this.deviceTreeOptions, deviceId);
// 判断是否是网关设备
if (selectedDevice && !selectedDevice.isLeaf) {
this.$message.warning('网关设备不能直接生成报告,请选择具体的监测设备');
this.queryParams.deviceId = null;
return;
}
this.handleDeviceChange(deviceId);
},
// 根据ID查找设备
findDeviceById(tree, deviceId) {
for (const node of tree) {
if (node.deviceId === deviceId) return node;
if (node.children) {
const found = this.findDeviceById(node.children, deviceId);
if (found) return found;
}
}
return null;
},
handleDeviceChange(val) {
this.identifierList = [];
@ -246,16 +329,22 @@ export default {
deviceId: this.queryParams.deviceId,
startTime: moment(this.queryParams.dayDaterange[0]).format('YYYY-MM-DD HH:mm:ss'),
endTime: moment(this.queryParams.dayDaterange[1]).format('YYYY-MM-DD HH:mm:ss'),
templateCode: 'DEVICE_CRACK_METER_V1',
// 不传 templateCode,后端根据设备监测分类自动选择对应模版
format: 'WORD',
})
.then((res) => {
if (res.code === 200) {
const data = res.data || {};
this.$message.success('报表生成成功,可在历史报表中查看');
if (data.reportNo) {
this.downloadReport(data);
}
// 使用弹框提示用户
this.$alert('报表正在后台生成中,生成完成后可在历史报表中下载', '提交成功', {
confirmButtonText: '查看历史报表',
type: 'success',
callback: action => {
if (action === 'confirm') {
// 跳转到历史报表页面
this.$router.push({ path: '/dataCenter/reportHistory' }).catch(() => {});
}
}
});
}
})
.finally(() => {