20260910:导出报告优化v2版本
This commit is contained in:
parent
05f1f84df2
commit
0fdcf8dad7
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 报告文件路径
|
||||
|
||||
|
||||
BIN
docs/锚索测力计模版.docx
Normal file
BIN
docs/锚索测力计模版.docx
Normal file
Binary file not shown.
@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询所有设备简短列表
|
||||
*/
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -1,15 +0,0 @@
|
||||
package com.fastbee.report.api;
|
||||
|
||||
/**
|
||||
* 报告引擎入口
|
||||
*
|
||||
* @author FastBee
|
||||
* @date 2026-08-16
|
||||
*/
|
||||
public interface ReportEngine {
|
||||
|
||||
/**
|
||||
* 生成报告(V1 同步生成,返回报告编号 / 文件地址 / 过期时间)
|
||||
*/
|
||||
ReportResult generate(ReportRequest request);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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<>());
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -42,4 +42,10 @@ public class ReportContext {
|
||||
private Map<String, ReportThreshold> thresholds;
|
||||
|
||||
private String reportNo;
|
||||
|
||||
/** 创建人用户名(从主线程传递,避免异步线程无法访问 SecurityContext) */
|
||||
private String createBy;
|
||||
|
||||
/** 租户ID(从主线程传递) */
|
||||
private Long tenantId;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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,7 +150,7 @@ 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";
|
||||
}
|
||||
@ -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<>();
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 "安全";
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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);
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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获取设备
|
||||
*
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -432,4 +432,12 @@ public interface IDeviceService {
|
||||
|
||||
public int updateDeviceBySerialNumber(Device device);
|
||||
|
||||
/**
|
||||
* 查询树形设备列表(网关设备可展开显示子设备)
|
||||
*
|
||||
* @param device 查询条件
|
||||
* @return 树形设备列表
|
||||
*/
|
||||
List<DeviceTreeVO> selectDeviceTreeList(Device device);
|
||||
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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
|
||||
|
||||
316
generate_professional_templates.py
Normal file
316
generate_professional_templates.py
Normal 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()
|
||||
@ -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 下新增)
|
||||
|
||||
@ -210,3 +210,12 @@ export function listThingsModel(query) {
|
||||
params: query,
|
||||
});
|
||||
}
|
||||
|
||||
// 查询树形设备列表(网关可展开子设备)
|
||||
export function listDeviceTree(query) {
|
||||
return request({
|
||||
url: '/iot/device/treeList',
|
||||
method: 'get',
|
||||
params: query,
|
||||
});
|
||||
}
|
||||
|
||||
1
yack-web/src/assets/icons/svg/WORD.svg
Normal file
1
yack-web/src/assets/icons/svg/WORD.svg
Normal 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 |
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user