diff --git a/docs/fastbee-device-report-engine.md b/docs/fastbee-device-report-engine.md index 406b2540..e8ac67da 100644 --- a/docs/fastbee-device-report-engine.md +++ b/docs/fastbee-device-report-engine.md @@ -173,6 +173,37 @@ fastbee-report/ - 模板:`DEVICE_CRACK_METER_V2.docx` 等,按 `template_code`(`DEVICE_${CATEGORY}_V2`)从 classpath 的 `templates/` 目录加载(`DefaultWordTemplateResolver`)。模板与渲染逻辑强耦合,**不入库**,随代码版本发布。 - 变量:`{{report.no}}`、`{{report.periodDesc}}`、`{{device.name}}`、`{{device.serialNumber}}`、`{{analysis.statistics.max}}`、`{{analysis.statistics.delta}}`、`{{analysis.trend.description}}`、`{{analysis.warning.status}}`、循环表格 `{{conclusions}}`、图片 `{{charts.crackTrend}}` 等;模板不直接绑定数据库字段。 +## 3.8 主监测指标(模板里的"监测指标") + +`{{device.metricName}}` 的值来自 `AnalysisResult.primaryIdentifier` → `Statistics.modelName` +(即 `iot_things_model.model_name` 的中文名),由 `PrimaryMetricResolver.resolve` 判定, +优先级从高到低: + +1. **用户显式指定** —— `iot_things_threshold.is_primary` 为真的物模型直接当选, + 不受辅助量黑名单与关键词限制。级别口径与阈值数值**同一套**(逐标识符 `device > product`): + 设备级配了该物模型就以设备行为准(设备行没勾、产品行勾了也不作数),设备没配才继承产品行的勾选。 + 多个标记项同时有数据时,取物模型列表顺序最靠前的(与关键词命中同口径); +2. 名称/标识符命中 `Analyzer#primaryMetricKeywords()` 的物模型,同类中优先该时段有数据的; +3. 第一个有数据的非辅助指标; +4. 第一个非辅助指标;最后兜底物模型列表首项。 + +**为什么需要第 1 条**:关键词表硬编码在 Java 里,物模型命名不规范时第 2 条整体落空、静默退化为第 3/4 条 +——等于把 `ORDER BY model_order DESC` 的排序结果当主指标。现场实例:轴力计产品因物模型叫"锚力"/"压力"、 +关键词只有"轴力/应力",把 GPS"经度"(`model_order` 更靠前)选成了监测指标。 + +配置入口:`yack-web` 阈值配置页 `views/iot/components/threshold-config.vue` 的「主监测指标」开关, +与阈值同表同行落库,无需额外表。 + +**读取方式**:不单独查询,直接复用 `ThingsThresholdProvider#find`(`selectByPriority`)逐物模型查回的 +阈值行里的 `is_primary` —— 级别口径天然与阈值数值一致,不会出现"标记说它是主指标、阈值数值却取设备行" +的自相矛盾状态。提取逻辑在 `ReportContextBuilder#resolveExplicitPrimary`(包级静态,可单测)。 + +设备页可见性:本设备未配置该物模型 → 开关旁显示「继承」;本设备已配置但未勾选、而产品级勾了 → +显示「覆盖产品」并提示报告不会用它作主指标(设备优先口径下该勾选确实失效,必须显式告知)。 + +> 注意此处**不是并集**:同一个物模型两级都配置时,设备行的 `is_primary` 单独决定结果; +> 不同物模型各自独立判断(互不影响)。 + --- # 4. 存储方案:MinIO(关键决策) diff --git a/fastbee-report/src/main/java/com/fastbee/report/analysis/PrimaryMetricResolver.java b/fastbee-report/src/main/java/com/fastbee/report/analysis/PrimaryMetricResolver.java index 5f885d3a..5a86b4d8 100644 --- a/fastbee-report/src/main/java/com/fastbee/report/analysis/PrimaryMetricResolver.java +++ b/fastbee-report/src/main/java/com/fastbee/report/analysis/PrimaryMetricResolver.java @@ -5,6 +5,7 @@ import com.fastbee.report.core.ReportContext; import com.fastbee.report.data.MetricData; import org.springframework.stereotype.Component; +import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -13,12 +14,30 @@ import java.util.Map; * *

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

*
    - *
  1. 排除辅助指标(角度/温度/电压/电量/信号/湿度等),辅助量不作为主监测指标;
  2. - *
  3. 优先返回名称或标识符命中核心关键词的物模型;
  4. + *
  5. 用户显式指定优先:{@code iot_things_threshold.is_primary=1} 的物模型直接当选, + * 不受辅助量黑名单与关键词限制。级别口径与阈值数值同一套(逐标识符 device > product): + * 设备级配了该物模型就以设备行为准,设备没配才继承产品级的勾选; + * 多个标记项同时有数据时,取物模型列表顺序最靠前的(与关键词命中同口径);
  6. + *
  7. 排除辅助指标(角度/温度/电压/电量/信号/湿度/GPS 定位等),辅助量不作为主监测指标;
  8. + *
  9. 优先返回名称或标识符命中核心关键词的物模型;同类命中者中优先取该时段有数据的;
  10. *
  11. 否则返回第一个有数据的非辅助指标;
  12. *
  13. 再否则返回第一个非辅助指标;最后兜底第一个物模型。
  14. *
* + *

为什么"有数据优先"是必须的:同一产品常存在多个同名物模型(如振弦式轴力计的 + * {@code MSJ_KN} / {@code KN} 都叫"压力"),若关键词命中即返回,会选中未上报的那一个, + * 使主指标统计为空。

+ * + *

为什么关键词匹配是决定性的:遍历顺序由物模型查询排序决定 + * ({@code ThingsModelMapper.xml#selectThingsModelList} 为 {@code ORDER BY model_order DESC}), + * 关键词全部落空时会退化为"按排序取第一个非辅助量"——等于把排序结果当主指标。 + * 现场曾有轴力计产品因物模型命名为"压力"、而关键词表只有"轴力/应力",最终把 GPS"经度" + * 选成了监测指标。接入新设备类型时务必核对关键词与现场物模型命名。

+ * + *

显式指定与关键词推断的关系:关键词表是硬编码在 Java 里的,物模型命名不规范时 + * 静默退化为兜底排序,这让现场无法自救。显式指定把最终决定权交回配置人员, + * 关键词推断降级为「未配置时的零配置默认」,保证存量几百个产品不改配置行为不变。

+ * * @author FastBee * @date 2026-09-10 */ @@ -29,15 +48,19 @@ public class PrimaryMetricResolver { private static final String[] AUXILIARY_KEYWORDS = { "角度", "angle", "温度", "temperature", "电压", "voltage", "电量", "battery", "信号", "signal", "湿度", "humidity", - "电压值", "battery_voltage" + "电压值", "battery_voltage", + // 采集仪自带的 GPS 定位量:描述设备位置,不是被监测的物理量 + "经度", "longitude", "纬度", "latitude" }; /** - * 解析主监测指标标识符 + * 解析主监测指标标识符(单值口径,不允许辅助指标作主指标) * - * @param context 报告上下文(提供物模型列表,顺序稳定) + *

保留向后兼容:等价于 {@link #resolveAll} 后取列表第一个。

+ * + * @param context 报告上下文(提供物模型列表,顺序稳定) * @param byIdentity 时序数据分组(用于判断是否有数据) - * @param keywords 核心关键词(如"位移"、"缝宽"、"轴力") + * @param keywords 核心关键词(如"位移"、"缝宽"、"轴力") * @return 主监测指标标识符;无物模型时返回 null */ public String resolve(ReportContext context, Map> byIdentity, String... keywords) { @@ -45,18 +68,42 @@ public class PrimaryMetricResolver { } /** - * 解析主监测指标标识符 + * 解析主监测指标标识符(单值口径) * * @param allowAuxiliary 是否允许辅助指标(角度/温度等)作为主指标。 * 倾角计、温度计等设备的主监测指标本身就是"角度/温度",需传 true。 */ public String resolve(ReportContext context, Map> byIdentity, boolean allowAuxiliary, String... keywords) { + List all = resolveAll(context, byIdentity, allowAuxiliary, keywords); + return all == null || all.isEmpty() ? null : all.get(0); + } + + /** + * 解析全部主监测指标标识符(多值口径,保持物模型列表顺序) + * + *

与 {@link #resolve} 的判定规则一致,区别在于:

+ *
    + *
  • 用户显式勾选({@code is_primary=1})时,收集全部命中的标识符, + * 而不是只取列表最靠前的一个 —— 静力水准仪等设备可同时把「水位」「水温」都设为主指标;
  • + *
  • 关键词命中 / 兜底分支仍只返回一个标识符(单元素列表), + * 因为零配置时无法推断多个主指标,保持存量产品行为不变。
  • + *
+ * + * @return 主监测指标标识符列表(有序);无物模型时返回空列表 + */ + public List resolveAll(ReportContext context, Map> byIdentity, + boolean allowAuxiliary, String... keywords) { List models = context.getThingsModels(); if (models == null || models.isEmpty()) { - return byIdentity.isEmpty() ? null : byIdentity.keySet().iterator().next(); + return byIdentity.isEmpty() ? new ArrayList<>() : new ArrayList<>(byIdentity.keySet()); } + // 显式指定集合(有序):用户意志优先,收集全部命中项 + List explicitAll = new ArrayList<>(); + // 兜底候选:关键词命中 / 第一个有数据 / 第一个非辅助 + String keywordHitWithData = null; + String keywordHit = null; String firstWithData = null; String firstAny = null; @@ -68,22 +115,34 @@ public class PrimaryMetricResolver { String name = model.getModelName() == null ? identity : model.getModelName(); String lower = (name + " " + identity).toLowerCase(); boolean auxiliary = isAuxiliary(lower); + // 倾角计/温度计允许辅助指标作为主指标 + boolean eligibleAsPrimary = !auxiliary || allowAuxiliary; + boolean hasData = byIdentity.containsKey(identity); - // 核心关键词命中优先(倾角计/温度计允许辅助指标作为主指标) - if (!auxiliary || allowAuxiliary) { - for (String keyword : keywords) { - if (lower.contains(keyword.toLowerCase())) { - return identity; - } + // 用户显式指定最高优先:不受辅助量黑名单与关键词限制,显式即用户意志 + // (用户若明确把"电压"标成主指标,就应当被尊重,而不是被辅助量规则否决) + // 多选场景:收集全部,且按「有数据优先」保证主指标列表稳定、不受数据时序影响 + if (context.isPrimaryMetric(identity)) { + if (hasData) { + explicitAll.add(identity); + } + } + + // 核心关键词命中优先(兜底路径用) + if (eligibleAsPrimary && matchesKeyword(lower, keywords)) { + if (keywordHit == null) { + keywordHit = identity; + } + if (keywordHitWithData == null && hasData) { + keywordHitWithData = identity; } } // 辅助指标不参与"第一个"兜底 - if (auxiliary && !allowAuxiliary) { + if (!eligibleAsPrimary) { continue; } - boolean hasData = byIdentity.containsKey(identity); if (firstAny == null) { firstAny = identity; } @@ -92,18 +151,69 @@ public class PrimaryMetricResolver { } } + // ① 用户显式指定:全部命中项;若都无数据,退回「全部显式项(不要求有数据)」 + if (!explicitAll.isEmpty()) { + return explicitAll; + } + List explicitAny = collectExplicitAny(models, context); + if (!explicitAny.isEmpty()) { + return explicitAny; + } + // ② 关键词命中;同类命中者中优先该时段有数据的同名物模型 + if (keywordHitWithData != null) { + return single(keywordHitWithData); + } + if (keywordHit != null) { + return single(keywordHit); + } if (firstWithData != null) { - return firstWithData; + return single(firstWithData); } if (firstAny != null) { - return firstAny; + return single(firstAny); } // 全部是辅助指标时,退回第一个物模型 - return models.get(0).getIdentifier(); + return single(models.get(0).getIdentifier()); + } + + /** 收集全部显式勾选的主指标(不要求有数据,用于显式项全无数据时的兜底) */ + private List collectExplicitAny(List models, ReportContext context) { + List result = new ArrayList<>(); + for (ThingsModel model : models) { + String identity = model.getIdentifier(); + if (identity != null && context.isPrimaryMetric(identity) && !result.contains(identity)) { + result.add(identity); + } + } + return result; + } + + private List single(String identity) { + List result = new ArrayList<>(1); + if (identity != null) { + result.add(identity); + } + return result; } /** - * 是否为辅助指标(角度/温度/电压/电量/信号/湿度等) + * 名称或标识符是否命中任一核心关键词 + */ + private boolean matchesKeyword(String nameAndIdentifier, String[] keywords) { + if (keywords == null || keywords.length == 0) { + return false; + } + for (String keyword : keywords) { + if (keyword != null && !keyword.isEmpty() + && nameAndIdentifier.contains(keyword.toLowerCase())) { + return true; + } + } + return false; + } + + /** + * 是否为辅助指标(角度/温度/电压/电量/信号/湿度/GPS 定位等) */ public boolean isAuxiliary(String nameAndIdentifier) { for (String keyword : AUXILIARY_KEYWORDS) { @@ -113,14 +223,4 @@ public class PrimaryMetricResolver { } return false; } - - /** - * 是否需要生成独立图表(辅助指标中仅温度/电压等少量关注项可跳过) - */ - public boolean isChartWorthy(String nameAndIdentifier) { - return !nameAndIdentifier.contains("电量") - && !nameAndIdentifier.contains("battery") - && !nameAndIdentifier.contains("信号") - && !nameAndIdentifier.contains("signal"); - } } diff --git a/fastbee-report/src/main/java/com/fastbee/report/analysis/ValueFormatter.java b/fastbee-report/src/main/java/com/fastbee/report/analysis/ValueFormatter.java index 3cb8f805..dba84a7a 100644 --- a/fastbee-report/src/main/java/com/fastbee/report/analysis/ValueFormatter.java +++ b/fastbee-report/src/main/java/com/fastbee/report/analysis/ValueFormatter.java @@ -1,6 +1,10 @@ package com.fastbee.report.analysis; +import com.alibaba.fastjson2.JSON; import com.fastbee.common.enums.MonitorCategory; +import com.fastbee.common.utils.StringUtils; +import com.fastbee.iot.domain.ThingsModel; +import com.fastbee.iot.model.ThingsModelItem.Datatype; import org.springframework.stereotype.Component; import java.math.BigDecimal; @@ -91,6 +95,38 @@ public class ValueFormatter { return (fmt != null) ? fmt.defaultUnit : ""; } + /** + * 读取物模型配置的单位 + * + *

不要用 {@code ThingsModel.getUnit()}:单位实际存放在 + * {@code iot_things_model.specs} JSON 的 {@code unit} 字段中,而 {@code ThingsModel#unit} + * 在表里没有对应列、查询语句也不映射它,实体 getter 取到的永远是 null + * ——这会让主指标单位被类别默认值顶替(把"经度"标成 kN),辅助量单位则整体丢失。

+ * + * @param model 物模型 + * @return 单位;未配置或 specs 解析失败返回 null + */ + public static String unitOf(ThingsModel model) { + if (model == null) { + return null; + } + if (StringUtils.isNotEmpty(model.getUnit())) { + return model.getUnit().trim(); + } + String specs = model.getSpecs(); + if (StringUtils.isEmpty(specs)) { + return null; + } + try { + Datatype datatype = JSON.parseObject(specs, Datatype.class); + String unit = datatype == null ? null : datatype.getUnit(); + return StringUtils.isEmpty(unit) ? null : unit.trim(); + } catch (Exception e) { + // specs 非标准结构时按"未配置单位"处理,不影响报告生成 + return null; + } + } + /** * 带单位的显示值 * diff --git a/fastbee-report/src/main/java/com/fastbee/report/analyzer/AbstractDeviceAnalyzer.java b/fastbee-report/src/main/java/com/fastbee/report/analyzer/AbstractDeviceAnalyzer.java index 7ef51950..dc8b9c97 100644 --- a/fastbee-report/src/main/java/com/fastbee/report/analyzer/AbstractDeviceAnalyzer.java +++ b/fastbee-report/src/main/java/com/fastbee/report/analyzer/AbstractDeviceAnalyzer.java @@ -32,8 +32,10 @@ import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.stream.Collectors; /** @@ -281,7 +283,7 @@ public abstract class AbstractDeviceAnalyzer implements ReportAnalyzer { BigDecimal controlValue = threshold == null ? null : threshold.getControlValue(); Statistics statistics = statisticsCalculator.calculate(data); - backfillModelInfo(statistics, model, context.getCategory()); + backfillModelInfo(statistics, model); TrendAnalysis trend = trendAnalyzer.analyze(data); // 越界检测按「相对初始值的累计变化量」判定,不能直接拿原始读数比控制值 @@ -302,14 +304,19 @@ public abstract class AbstractDeviceAnalyzer implements ReportAnalyzer { } // 4. 主指标(关键词匹配 + 辅助量过滤) - String primaryIdentity = primaryMetricResolver.resolve( + // 多主指标:用户可显式勾选多个 is_primary=1 物模型(如水位 + 水温),全部收集; + // primaryIdentifier 保留为「主导主指标」,用于单值章节(概况/结论/安全等级/控制值/主图)。 + List primaryIdentifiers = primaryMetricResolver.resolveAll( context, byIdentity, allowAuxiliaryAsPrimary(), primaryMetricKeywords()); + result.setPrimaryIdentifiers(primaryIdentifiers); + String primaryIdentity = primaryIdentifiers == null || primaryIdentifiers.isEmpty() + ? null : primaryIdentifiers.get(0); result.setPrimaryIdentifier(primaryIdentity); // 5. 组装测点数据(主指标排第一,辅助量作为影响量保留) result.setMetricPoints(buildMetricPointsFiltered(result, context, dataByIdentity, statisticsDurationSeconds)); - // 6. 生成结论 + // 6. 生成结论(以主导主指标为准,保证概况/结论/安全等级口径一致) buildConclusions(result, context, primaryIdentity); // 7. 领域特定结论 @@ -401,15 +408,17 @@ public abstract class AbstractDeviceAnalyzer implements ReportAnalyzer { } /** - * 回填物模型信息 + 单位兜底 + * 回填物模型信息 + * + *

单位只取物模型实配值,不在这里做类别兜底:兜底必须知道"该物模型是不是主指标" + * (水温、设备电压、GPS 经度等辅助量不能被标成主类别单位 mm/kN), + * 该判断由 {@link #resolveMetricUnit(Statistics, ReportContext, boolean)} 负责。

*/ - protected void backfillModelInfo(Statistics statistics, ThingsModel model, MonitorCategory category) { + protected void backfillModelInfo(Statistics statistics, ThingsModel model) { if (statistics.getModelName() == null || statistics.getModelName().isEmpty()) { statistics.setModelName(model.getModelName()); } - // 单位兜底:物模型优先,否则按类别默认 - String resolvedUnit = valueFormatter.resolveUnit(model.getUnit(), category); - statistics.setUnit(resolvedUnit); + statistics.setUnit(ValueFormatter.unitOf(model)); statistics.setIdentifier(model.getIdentifier()); } @@ -424,8 +433,9 @@ public abstract class AbstractDeviceAnalyzer implements ReportAnalyzer { Map> dataByIdentity, long durationSeconds) { List all = new ArrayList<>(); - String primary = result.getPrimaryIdentifier(); - MetricPointData primaryPoint = null; + Set primarySet = primaryIdentifierSet(result); + // 多主指标:每个主指标都排在前,均标记 primary=true(各出完整四列) + List primaryPoints = new ArrayList<>(); for (Map.Entry entry : result.getStatistics().entrySet()) { String identity = entry.getKey(); @@ -437,32 +447,48 @@ public abstract class AbstractDeviceAnalyzer implements ReportAnalyzer { continue; } + boolean primary = primarySet.contains(identity); MetricPointData point = buildMetricPoint(stats, result.getWarnings().get(identity), context, dataByIdentity == null ? null : dataByIdentity.get(identity), durationSeconds, - identity.equals(primary)); + primary); // 主监测指标标记(温度等辅助量为影响量,primary=false) - point.setPrimary(identity.equals(primary)); - if (identity.equals(primary)) { - primaryPoint = point; + point.setPrimary(primary); + if (primary) { + primaryPoints.add(point); } else { all.add(point); } } - // 主指标排第一 + // 主指标排第一(按 primaryIdentifiers 顺序),辅助量随后 List filtered = new ArrayList<>(); - if (primaryPoint != null) { - filtered.add(primaryPoint); + List primaryIdentifiers = result.getPrimaryIdentifiers(); + if (primaryIdentifiers != null) { + for (String id : primaryIdentifiers) { + primaryPoints.stream() + .filter(p -> id.equals(p.getIdentifier())) + .forEach(filtered::add); + } + } + // 兜底:未被 primaryIdentifiers 覆盖的主指标点(理论上不会发生)也保留 + for (MetricPointData p : primaryPoints) { + if (!filtered.contains(p)) { + filtered.add(p); + } } filtered.addAll(all); return filtered; } - /** - * 判断是否为辅助指标 - */ - protected boolean isAuxiliaryMetric(String modelName, String identifier) { - return primaryMetricResolver.isAuxiliary(modelName + " " + identifier); + /** 主指标标识符集合(多主指标),空则退化为单值 primaryIdentifier */ + private Set primaryIdentifierSet(AnalysisResult result) { + Set set = new LinkedHashSet<>(); + if (result.getPrimaryIdentifiers() != null && !result.getPrimaryIdentifiers().isEmpty()) { + set.addAll(result.getPrimaryIdentifiers()); + } else if (StringUtils.isNotEmpty(result.getPrimaryIdentifier())) { + set.add(result.getPrimaryIdentifier()); + } + return set; } /** @@ -530,9 +556,9 @@ public abstract class AbstractDeviceAnalyzer implements ReportAnalyzer { /** * 解析物模型展示单位 * - *

{@link #backfillModelInfo} 已把空单位按报告主类别兜底写入了 {@code Statistics.unit}, - * 因此这里必须回到物模型原始配置判断,否则水温、设备电压、角度等辅助量会被错标成主类别单位 - * (如静力水准的 mm)。

+ *

物模型单位存放在 {@code iot_things_model.specs} JSON 的 {@code unit} 字段里, + * 实体 getter {@code ThingsModel.getUnit()} 对应列在表中并不存在(恒为 null), + * 必须走 {@link ValueFormatter#unitOf(ThingsModel)}。

* *
    *
  • 物模型配置了单位:直接使用;
  • @@ -541,10 +567,9 @@ public abstract class AbstractDeviceAnalyzer implements ReportAnalyzer { *
*/ private String resolveMetricUnit(Statistics stats, ReportContext context, boolean primaryMetric) { - ThingsModel model = findModelByIdentifier(context, stats.getIdentifier()); - String modelUnit = model == null ? null : model.getUnit(); - if (modelUnit != null && !modelUnit.trim().isEmpty()) { - return modelUnit.trim(); + String modelUnit = ValueFormatter.unitOf(findModelByIdentifier(context, stats.getIdentifier())); + if (StringUtils.isNotEmpty(modelUnit)) { + return modelUnit; } if (!primaryMetric) { return ""; @@ -680,6 +705,10 @@ public abstract class AbstractDeviceAnalyzer implements ReportAnalyzer { .collect(Collectors.groupingBy(MetricData::getIdentity, LinkedHashMap::new, Collectors.toList())); Map charts = metricChartBuilder.build(result.getStatistics(), primaryIdentity, byIdentity); result.getCharts().putAll(metricChartBuilder.fillPlaceholders(charts)); + + // 多主指标:每个主指标各出一组三张曲线(时程/累计变化量/变化速率) + result.setMetricChartGroups(metricChartBuilder.buildGroups( + result.getStatistics(), result.getPrimaryIdentifiers(), byIdentity)); } /** diff --git a/fastbee-report/src/main/java/com/fastbee/report/analyzer/axial_force/AxialForceAnalyzer.java b/fastbee-report/src/main/java/com/fastbee/report/analyzer/axial_force/AxialForceAnalyzer.java index fce8d49b..b73ee9c4 100644 --- a/fastbee-report/src/main/java/com/fastbee/report/analyzer/axial_force/AxialForceAnalyzer.java +++ b/fastbee-report/src/main/java/com/fastbee/report/analyzer/axial_force/AxialForceAnalyzer.java @@ -26,7 +26,15 @@ public class AxialForceAnalyzer extends AbstractDeviceAnalyzer { @Override protected String[] primaryMetricKeywords() { - return new String[]{"轴力", "应力", "force", "stress"}; + // 该类别的力值物模型(设备级,各设备自行定义)命名与标识符都不统一, + // 现场实测同一产品下并存三种形态: + // 设备 536:MSJ_KN「压力」 设备 565:KN「压力」 设备 742:KN「锚力」 + // 因此关键词必须同时覆盖"名称"和"标识符"两个维度,且不能枚举具体写法: + // "力" 覆盖全部中文命名(轴力/应力/压力/锚力/锚索力…); + // "kn" 覆盖千牛这一单位量纲的标识符(KN / MSJ_KN / kN…)。 + // 产品内的干扰量(频率CH1..4、电压、温度、GPS 经度/纬度)两个维度都不命中, + // 且辅助量在 PrimaryMetricResolver 里已被排除,不会误选。 + return new String[]{"力", "kn", "force", "stress"}; } @Override diff --git a/fastbee-report/src/main/java/com/fastbee/report/chart/MetricChartBuilder.java b/fastbee-report/src/main/java/com/fastbee/report/chart/MetricChartBuilder.java index aee87568..294838ed 100644 --- a/fastbee-report/src/main/java/com/fastbee/report/chart/MetricChartBuilder.java +++ b/fastbee-report/src/main/java/com/fastbee/report/chart/MetricChartBuilder.java @@ -81,6 +81,59 @@ public class MetricChartBuilder { return charts; } + /** + * 构建多主指标的图表组(每个主指标一组三张曲线,有序) + * + *

与 {@link #build} 的区别:主指标可能有多个({@code primaryIdentifiers}), + * 每个主指标各出一组「时程曲线 + 累计变化量 + 变化速率」,图标题带指标名区分。

+ * + * @param statisticsMap 全部指标统计(key = 物模型标识符) + * @param primaryIdentifiers 主指标标识符列表(有序,全部主指标) + * @param byIdentity 指标 → 时序数据 + * @return 有序图表组列表(与 primaryIdentifiers 对齐,仅含可出图的主指标) + */ + public java.util.List buildGroups( + Map statisticsMap, + java.util.List primaryIdentifiers, + Map> byIdentity) { + java.util.List groups = new java.util.ArrayList<>(); + if (statisticsMap == null || statisticsMap.isEmpty()) { + return groups; + } + java.util.List ids = (primaryIdentifiers == null || primaryIdentifiers.isEmpty()) + ? java.util.Collections.singletonList(statisticsMap.keySet().iterator().next()) + : primaryIdentifiers; + + for (String identifier : ids) { + Statistics stats = statisticsMap.get(identifier); + if (stats == null) { + continue; + } + String name = stats.getModelName() == null ? identifier : stats.getModelName(); + String unit = stats.getUnit() == null ? "" : stats.getUnit(); + List series = sortedSeries(byIdentity.getOrDefault(identifier, new ArrayList<>())); + if (series.isEmpty()) { + continue; + } + + com.fastbee.report.model.MetricChartGroup group = new com.fastbee.report.model.MetricChartGroup(); + group.setIdentifier(identifier); + group.setName(name); + group.setUnit(unit); + group.setTimeSeries(chartGenerator.timeSeriesChart( + name + "时程曲线", name, unit, series)); + + BigDecimal initial = series.get(0).getNumericValue(); + group.setCumulative(chartGenerator.timeSeriesChart( + name + "累计变化量曲线", name + "累计变化量", unit, cumulativeSeries(series, initial))); + group.setRate(chartGenerator.timeSeriesChart( + name + "变化速率曲线", name + "变化速率", + unit + "/d", rateCalculator.segmentRateSeries(series))); + groups.add(group); + } + return groups; + } + private List sortedSeries(List data) { List sorted = new ArrayList<>(); if (data == null) { diff --git a/fastbee-report/src/main/java/com/fastbee/report/core/MultiDeviceAnalysisMerger.java b/fastbee-report/src/main/java/com/fastbee/report/core/MultiDeviceAnalysisMerger.java index ebedde50..0598c62f 100644 --- a/fastbee-report/src/main/java/com/fastbee/report/core/MultiDeviceAnalysisMerger.java +++ b/fastbee-report/src/main/java/com/fastbee/report/core/MultiDeviceAnalysisMerger.java @@ -5,6 +5,7 @@ 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.MetricChartGroup; import com.fastbee.report.model.MetricPointData; import com.fastbee.report.model.Statistics; import com.fastbee.report.model.WarningAnalysis; @@ -104,6 +105,8 @@ public class MultiDeviceAnalysisMerger { AnalysisResult merged = new AnalysisResult(); merged.setPrimaryIdentifier(base.getPrimaryIdentifier()); + // 多主指标:沿用最不利测点的主指标集合(同一产品各测点物模型一致,主指标集合也应一致) + merged.setPrimaryIdentifiers(base.getPrimaryIdentifiers()); merged.setSummary(base.getSummary()); merged.setProfessionalAdvice(base.getProfessionalAdvice()); merged.setSafetyLevel(base.getSafetyLevel()); @@ -121,9 +124,79 @@ public class MultiDeviceAnalysisMerger { merged.setConclusions(buildConclusions(base, valid)); merged.getCharts().putAll(base.getCharts()); merged.getCharts().putAll(buildMultiPointCharts(valid, base.getPrimaryIdentifier())); + // 多主指标图表组:每个主指标一组(多测点时各测点一条曲线) + merged.setMetricChartGroups(buildMultiPointGroups(valid, base)); return appendSkipped(merged, skippedPoints, totalPoints); } + /** + * 构建多主指标的图表组(多测点:每个主指标一组,组内各测点一条曲线) + * + *

与单设备 {@code MetricChartBuilder#buildGroups} 的差异在于「测点维度」: + * 多测点时组内三张图(时程/累计变化量/变化速率)各是多曲线(每测点一条), + * 以便横向对比各测点是否同步发展。主指标维度则完整保留(每个主指标一组)。

+ */ + private List buildMultiPointGroups(List analyses, AnalysisResult base) { + List groups = new ArrayList<>(); + List primaryIdentifiers = base.getPrimaryIdentifiers(); + if (primaryIdentifiers == null || primaryIdentifiers.isEmpty()) { + return groups; + } + for (String identifier : primaryIdentifiers) { + // 各测点该主指标的时序数据(label = 测点标识,value = 序列) + Map> seriesMap = new LinkedHashMap<>(); + String unit = null; + String name = identifier; + for (DeviceAnalysis item : analyses) { + List series = seriesOf(item, identifier); + if (series.isEmpty()) { + continue; + } + seriesMap.put(pointLabel(item), series); + if (unit == null) { + unit = series.get(0).getUnit(); + } + Statistics stats = item.getResult().getStatistics().get(identifier); + if (stats != null && stats.getModelName() != null) { + name = stats.getModelName(); + } + } + if (seriesMap.isEmpty()) { + continue; + } + String unitText = unit == null ? "" : unit; + + MetricChartGroup group = new MetricChartGroup(); + group.setIdentifier(identifier); + group.setName(name); + group.setUnit(unitText); + group.setTimeSeries(chartGenerator.multiSeriesChart( + name + "时程曲线", unitText, seriesMap)); + + // 累计变化量:各测点相对各自初始值 + Map> cumulativeMap = new LinkedHashMap<>(); + for (Map.Entry> entry : seriesMap.entrySet()) { + List s = entry.getValue(); + if (!s.isEmpty() && s.get(0).getNumericValue() != null) { + cumulativeMap.put(entry.getKey(), toCumulative(s, s.get(0).getNumericValue())); + } + } + group.setCumulative(chartGenerator.multiSeriesChart( + name + "累计变化量曲线", unitText, cumulativeMap)); + + // 变化速率:各测点分段速率 + Map> rateMap = new LinkedHashMap<>(); + for (Map.Entry> entry : seriesMap.entrySet()) { + rateMap.put(entry.getKey(), rateCalculator.segmentRateSeries(entry.getValue())); + } + group.setRate(chartGenerator.multiSeriesChart( + name + "变化速率曲线", unitText + "/d", rateMap)); + + groups.add(group); + } + return groups; + } + /** * 把"统计期内无数据、未纳入分析"的测点写入结论。 * diff --git a/fastbee-report/src/main/java/com/fastbee/report/core/ReportAssembler.java b/fastbee-report/src/main/java/com/fastbee/report/core/ReportAssembler.java index 4753aab5..c3d469bf 100644 --- a/fastbee-report/src/main/java/com/fastbee/report/core/ReportAssembler.java +++ b/fastbee-report/src/main/java/com/fastbee/report/core/ReportAssembler.java @@ -54,6 +54,9 @@ public class ReportAssembler { primary = analysis.getStatistics().values().stream().findFirst().orElse(null); } + // 多主指标:收集全部主指标名称(水位、水温…),供头部「监测指标」字段并列展示 + java.util.List metricNames = collectMetricNames(analysis, primary); + // 添加监测分类描述 String categoryDesc = context.getCategory().getDesc() + "监测分析报告"; @@ -71,8 +74,31 @@ public class ReportAssembler { .category(context.getCategory()) .categoryDesc(categoryDesc) // 添加分类描述 .metricName(primary == null ? null : primary.getModelName()) + .metricNames(metricNames) .metricUnit(primary == null ? null : primary.getUnit()) .analysis(analysis) .build(); } + + /** + * 收集全部主监测指标名称(有序) + * + *

多主指标(primaryIdentifiers 非空且 >1)时返回全部名称;否则退化为单值 metricName。

+ */ + private java.util.List collectMetricNames(AnalysisResult analysis, Statistics primary) { + java.util.List names = new java.util.ArrayList<>(); + java.util.List ids = analysis.getPrimaryIdentifiers(); + if (ids != null && !ids.isEmpty()) { + for (String id : ids) { + Statistics s = analysis.getStatistics().get(id); + if (s != null && StringUtils.isNotEmpty(s.getModelName())) { + names.add(s.getModelName()); + } + } + } + if (names.isEmpty() && primary != null && StringUtils.isNotEmpty(primary.getModelName())) { + names.add(primary.getModelName()); + } + return names; + } } diff --git a/fastbee-report/src/main/java/com/fastbee/report/core/ReportContext.java b/fastbee-report/src/main/java/com/fastbee/report/core/ReportContext.java index b6f3d607..d45a0dbe 100644 --- a/fastbee-report/src/main/java/com/fastbee/report/core/ReportContext.java +++ b/fastbee-report/src/main/java/com/fastbee/report/core/ReportContext.java @@ -12,6 +12,7 @@ import lombok.Data; import java.util.List; import java.util.Map; +import java.util.Set; /** * 报告上下文(设备/产品/物模型/时序数据/阈值一次性装配) @@ -49,6 +50,18 @@ public class ReportContext { /** 阈值(key = 物模型标识符) */ private Map thresholds; + /** + * 用户显式指定为主监测指标的物模型标识符(来自 iot_things_threshold.is_primary) + * + *

级别口径与阈值数值同一套(逐标识符 {@code device > product}):设备级配了该物模型就以 + * 设备行为准(设备行没勾、产品行勾了也不作数),设备没配才继承产品行的勾选。 + * 由 {@code ReportContextBuilder#resolveExplicitPrimary} 从 {@link #thresholds} 提取。

+ * + *

为空/为 null 表示用户未配置,主指标回退到 {@code PrimaryMetricResolver} 的关键词推断。 + * 非空时优先级高于关键词推断。

+ */ + private Set explicitPrimaryIdentifiers; + private String reportNo; /** 创建人用户名(从主线程传递,避免异步线程无法访问 SecurityContext) */ @@ -56,4 +69,13 @@ public class ReportContext { /** 租户ID(从主线程传递) */ private Long tenantId; + + /** + * 该物模型是否被用户显式指定为主监测指标 + */ + public boolean isPrimaryMetric(String identifier) { + return identifier != null + && explicitPrimaryIdentifiers != null + && explicitPrimaryIdentifiers.contains(identifier); + } } diff --git a/fastbee-report/src/main/java/com/fastbee/report/core/ReportContextBuilder.java b/fastbee-report/src/main/java/com/fastbee/report/core/ReportContextBuilder.java index 26fc3857..64f346dc 100644 --- a/fastbee-report/src/main/java/com/fastbee/report/core/ReportContextBuilder.java +++ b/fastbee-report/src/main/java/com/fastbee/report/core/ReportContextBuilder.java @@ -20,7 +20,9 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import javax.annotation.Resource; +import java.util.Collections; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Objects; @@ -91,6 +93,8 @@ public class ReportContextBuilder { modelQuery.setType(1); modelQuery.setIsMonitor(1); List models = thingsModelService.selectThingsModelList(modelQuery); + // 同一产品下各设备可能各自定义设备级物模型(device_id>0),先收窄到本设备的 + models = filterByDevice(models, deviceId); // 前端勾选的物模型:只分析所选物理量;未勾选(空)表示分析产品下全部监测物模型 models = filterByIdentifiers(models, request.getIdentifiers(), deviceId); @@ -119,6 +123,15 @@ public class ReportContextBuilder { } } + // 用户显式指定的主监测指标(阈值配置页「主监测指标」开关) + // + // 逐标识符解析,口径与阈值数值完全一致(selectByPriority 的 device > product): + // 设备配了该物模型 → 以设备行为准(哪怕设备行没勾、产品行勾了,也不作数) + // 设备没配该物模型 → 继承产品行的勾选 + // 因此直接从上面按物模型查回来的 thresholds 里取,不能再单独查一次"全部标记"—— + // 那样会把产品级的勾选强加到"设备已自行配置"的物模型上,与阈值数值口径打架。 + Set explicitPrimaryIdentifiers = resolveExplicitPrimary(thresholds); + ReportContext context = new ReportContext(); context.setRequest(request); context.setTimeWindow(window); @@ -129,6 +142,7 @@ public class ReportContextBuilder { context.setMetricDataList(metricDataList); context.setBaselineMetricDataList(baselineMetricDataList); context.setThresholds(thresholds); + context.setExplicitPrimaryIdentifiers(explicitPrimaryIdentifiers); context.setReportNo(reportNo); // 从主线程获取用户信息,传递到异步线程 @@ -151,6 +165,54 @@ public class ReportContextBuilder { return context; } + /** + * 从「按物模型查回的阈值行」里取出用户显式勾选的监控指标标识符 + * + *

{@code thresholds} 的每个 value 都是 {@code ThingsThresholdProvider#find} 的结果, + * 即 `selectByPriority` 的 {@code device > product} 逐标识符解析: + * 设备级有行就返回设备级行,否则返回产品级行。所以这里直接读 {@code is_primary} + * 就是用户要的口径:设备配了以设备为准,设备没配就继承产品。

+ * + *

关键:不能另起一条"查全部标记行"的查询。那样会把产品级的勾选强加到 + * 「设备已自行配置(设备行存在但没勾)」的物模型上,出现"标记说它是主指标、 + * 阈值数值却取的是设备行"的自相矛盾状态。

+ */ + static Set resolveExplicitPrimary(Map thresholds) { + if (thresholds == null || thresholds.isEmpty()) { + return Collections.emptySet(); + } + return thresholds.entrySet().stream() + .filter(entry -> Boolean.TRUE.equals(entry.getValue().getIsPrimary())) + .map(Map.Entry::getKey) + .collect(Collectors.toCollection(LinkedHashSet::new)); + } + + /** + * 收窄到"产品级物模型 + 本设备的设备级物模型" + * + *

物模型查询只按 {@code productId} 过滤(不带 deviceId),因此会带出同一产品下 + * 其他设备自行定义的设备级物模型({@code iot_things_model.device_id > 0})。 + * 这些物模型的命名与单位都可能与本设备不同 —— 现场实例:同一产品的力值量, + * 设备 536 叫"压力"({@code MSJ_KN})、设备 742 叫"锚力"({@code KN})。

+ * + *

不收窄会带来两个后果:① 候选集被别的设备的私有变量稀释,主指标关键词匹配更易落空; + * ② 若两个设备用了相同 identifier,{@code AbstractDeviceAnalyzer#getAllModels} 的 + * {@code Collectors.toMap} 会由后者覆盖前者,报告里的名称/单位会张冠李戴。

+ * + *

{@code device_id} 为空或 0 视为产品级物模型,任何设备都适用,予以保留。

+ */ + List filterByDevice(List models, Long deviceId) { + if (models == null || models.isEmpty()) { + return models; + } + return models.stream() + .filter(model -> { + Long owner = model.getDeviceId(); + return owner == null || owner == 0L || owner.equals(deviceId); + }) + .collect(Collectors.toList()); + } + /** * 按请求指定的物模型标识符过滤监测物模型 * diff --git a/fastbee-report/src/main/java/com/fastbee/report/data/ReportDataProvider.java b/fastbee-report/src/main/java/com/fastbee/report/data/ReportDataProvider.java index 809612e4..af8c510b 100644 --- a/fastbee-report/src/main/java/com/fastbee/report/data/ReportDataProvider.java +++ b/fastbee-report/src/main/java/com/fastbee/report/data/ReportDataProvider.java @@ -6,6 +6,7 @@ import com.fastbee.iot.domain.DeviceLog; import com.fastbee.iot.domain.ThingsModel; import com.fastbee.iot.model.HistoryModel; import com.fastbee.iot.service.IDeviceLogService; +import com.fastbee.report.analysis.ValueFormatter; import com.fastbee.report.period.TimeWindow; import org.springframework.stereotype.Component; @@ -72,7 +73,8 @@ public class ReportDataProvider { ThingsModel model = modelMap.get(history.getIdentity()); if (model != null) { data.setModelName(model.getModelName()); - data.setUnit(model.getUnit()); + // 单位存在 specs JSON 中,实体 getter 取不到(见 ValueFormatter#unitOf) + data.setUnit(ValueFormatter.unitOf(model)); } result.add(data); } diff --git a/fastbee-report/src/main/java/com/fastbee/report/mapper/ThingsThresholdMapper.java b/fastbee-report/src/main/java/com/fastbee/report/mapper/ThingsThresholdMapper.java index 33c73bca..fc4b40c9 100644 --- a/fastbee-report/src/main/java/com/fastbee/report/mapper/ThingsThresholdMapper.java +++ b/fastbee-report/src/main/java/com/fastbee/report/mapper/ThingsThresholdMapper.java @@ -14,6 +14,9 @@ public interface ThingsThresholdMapper extends BaseMapper { /** * 按优先级查询阈值:device_id > product_id > 全局默认(product_id=0) + * + *

逐物模型(标识符)解析,返回的整行里同时带出 {@code is_primary}(主监测指标标记)。 + * 主指标的级别口径与阈值数值完全一致:设备级有行即以设备行为准,否则继承产品级。

*/ ThingsThreshold selectByPriority(@Param("deviceId") Long deviceId, @Param("productId") Long productId, diff --git a/fastbee-report/src/main/java/com/fastbee/report/model/AnalysisResult.java b/fastbee-report/src/main/java/com/fastbee/report/model/AnalysisResult.java index 2d524335..a2eaca72 100644 --- a/fastbee-report/src/main/java/com/fastbee/report/model/AnalysisResult.java +++ b/fastbee-report/src/main/java/com/fastbee/report/model/AnalysisResult.java @@ -17,9 +17,26 @@ import java.util.Map; @Data public class AnalysisResult { - /** 主监测指标标识符(统一由 Analyzer 解析,ReportAssembler/WordRenderer 共用) */ + /** + * 主监测指标标识符(统一由 Analyzer 解析,ReportAssembler/WordRenderer 共用) + * + *

单值语义:在多个主指标中代表「主导主指标」——用于单值章节 + * (项目概况 / 结论 / 安全等级 / 控制值 / 时程曲线主图)。取值口径: + * 预警等级最高者优先,同等级取变化量绝对值最大者(与多测点「最不利测点」一致)。

+ */ private String primaryIdentifier; + /** + * 全部主监测指标标识符(有序,来自 PrimaryMetricResolver 的多主指标解析) + * + *

用户可显式勾选多个 {@code is_primary=1} 物模型(如静力水准仪的「水位」与「水温」), + * 它们应并列作为报告的监测指标,而非只取一个。头部「监测指标」字段与测点表 + * 据此渲染多个主指标(各出完整四列),而单值章节仍以 {@link #primaryIdentifier} 为主。

+ * + *

为空列表/null 时视为未解析,兼容旧逻辑回退到 {@link #primaryIdentifier}。

+ */ + private List primaryIdentifiers; + /** 项目概况总结(开头段落,概括关键数据) */ private String summary; @@ -58,4 +75,13 @@ public class AnalysisResult { /** 图表数据:key = 模板占位符(如 crackTrend),value = PNG byte[] */ private Map charts = new LinkedHashMap<>(); + + /** + * 每个主监测指标的一组图表(有序,与 primaryIdentifiers 对齐) + * + *

多主指标时每个主指标各出一组三张曲线(时程/累计变化量/变化速率), + * 由 {@code MetricChartBuilder} 生成、{@code WordRenderer} 用图片列表循环渲染。 + * 单主指标时列表仅一个元素,行为与旧版一致。

+ */ + private List metricChartGroups = new ArrayList<>(); } diff --git a/fastbee-report/src/main/java/com/fastbee/report/model/MetricChartGroup.java b/fastbee-report/src/main/java/com/fastbee/report/model/MetricChartGroup.java new file mode 100644 index 00000000..a82ed3c7 --- /dev/null +++ b/fastbee-report/src/main/java/com/fastbee/report/model/MetricChartGroup.java @@ -0,0 +1,34 @@ +package com.fastbee.report.model; + +import lombok.Data; + +/** + * 一个主监测指标的一组图表(时程曲线 + 累计变化量 + 变化速率) + * + *

多主指标报告按主指标分组生成图表:每个主指标各出一组三张曲线, + * 图标题带指标名区分(如「水位时程曲线」「水温时程曲线」)。

+ * + * @author FastBee + * @date 2026-09-26 + */ +@Data +public class MetricChartGroup { + + /** 主指标标识符 */ + private String identifier; + + /** 主指标名称(物模型中文名,用于图标题) */ + private String name; + + /** 单位(用于纵轴标注) */ + private String unit; + + /** 时程曲线 PNG */ + private byte[] timeSeries; + + /** 累计变化量曲线 PNG */ + private byte[] cumulative; + + /** 变化速率曲线 PNG */ + private byte[] rate; +} diff --git a/fastbee-report/src/main/java/com/fastbee/report/model/ReportModel.java b/fastbee-report/src/main/java/com/fastbee/report/model/ReportModel.java index 4189d497..3df55646 100644 --- a/fastbee-report/src/main/java/com/fastbee/report/model/ReportModel.java +++ b/fastbee-report/src/main/java/com/fastbee/report/model/ReportModel.java @@ -47,6 +47,14 @@ public class ReportModel { /** 主监测指标(裂缝宽度)名称 */ private String metricName; + /** + * 全部主监测指标名称(有序,与 AnalysisResult.primaryIdentifiers 对应) + * + *

单主指标时与 {@link #metricName} 等价(单元素列表);多主指标时包含所有主指标名。 + * 头部「监测指标」字段据此渲染多个名称。为空时回退 {@link #metricName}。

+ */ + private java.util.List metricNames; + /** 主监测指标单位 */ private String metricUnit; diff --git a/fastbee-report/src/main/java/com/fastbee/report/renderer/WordRenderer.java b/fastbee-report/src/main/java/com/fastbee/report/renderer/WordRenderer.java index 0c4768f0..4daf5a87 100644 --- a/fastbee-report/src/main/java/com/fastbee/report/renderer/WordRenderer.java +++ b/fastbee-report/src/main/java/com/fastbee/report/renderer/WordRenderer.java @@ -17,6 +17,7 @@ import com.fastbee.report.analysis.ValueFormatter; import com.fastbee.report.exception.ReportException; import com.fastbee.report.model.AnalysisResult; import com.fastbee.report.model.Conclusion; +import com.fastbee.report.model.MetricChartGroup; import com.fastbee.report.model.MetricPointData; import com.fastbee.report.model.QualityAnalysis; import com.fastbee.report.model.ReportModel; @@ -50,6 +51,7 @@ import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Set; /** * Word 渲染器(poi-tl 模板渲染 + JFreeChart PNG 嵌入,禁止查库) @@ -250,7 +252,7 @@ public class WordRenderer implements ReportRenderer { device.put("name", model.getDeviceName()); device.put("serialNumber", model.getSerialNumber()); device.put("productName", model.getProductName()); - device.put("metricName", model.getMetricName()); + device.put("metricName", metricNameText(model)); device.put("metricUnit", model.getMetricUnit() == null ? "" : model.getMetricUnit()); Map statistics = new HashMap<>(); @@ -370,6 +372,9 @@ public class WordRenderer implements ReportRenderer { charts.put(entry.getKey(), picture); } + // 多主指标图表组:每个主指标一组三张曲线(时程/累计变化量/变化速率),模板区块循环渲染 + List> metricCharts = buildMetricCharts(analysis); + List> conclusions = new ArrayList<>(); for (Conclusion conclusion : analysis.getConclusions()) { Map item = new HashMap<>(); @@ -382,10 +387,60 @@ public class WordRenderer implements ReportRenderer { data.put("device", device); data.put("analysis", analysisMap); data.put("charts", charts); + data.put("metricCharts", metricCharts); data.put("conclusions", conclusions); return data; } + /** + * 构建多主指标图表组数据(模板 {{?metricCharts}} 区块循环消费) + * + *

每个主指标一个 Map,含 {@code timeSeries} / {@code cumulative} / {@code rate} + * 三个图片(对应模板里 {{@timeSeries}} / {{@cumulative}} / {{@rate}})。

+ * + *

兜底:{@code metricChartGroups} 为空(如旧链路未生成)时回退到 + * {@code charts} 里的 timeSeriesChart/chart2/chart3 单组,保证兼容。

+ */ + private List> buildMetricCharts(AnalysisResult analysis) { + List> result = new ArrayList<>(); + List groups = analysis.getMetricChartGroups(); + if (groups != null && !groups.isEmpty()) { + for (MetricChartGroup group : groups) { + Map item = new HashMap<>(); + item.put("timeSeries", picture(group.getTimeSeries())); + item.put("cumulative", picture(group.getCumulative())); + item.put("rate", picture(group.getRate())); + result.add(item); + } + return result; + } + // 兜底:从 charts Map 拼一组(主导主指标),保证旧模板/旧数据不报错 + byte[] timeSeries = analysis.getCharts() == null ? null : analysis.getCharts().get("timeSeriesChart"); + byte[] cumulative = analysis.getCharts() == null ? null : analysis.getCharts().get("chart2"); + byte[] rate = analysis.getCharts() == null ? null : analysis.getCharts().get("chart3"); + if (timeSeries != null || cumulative != null || rate != null) { + Map item = new HashMap<>(); + item.put("timeSeries", picture(timeSeries)); + item.put("cumulative", picture(cumulative)); + item.put("rate", picture(rate)); + result.add(item); + } + return result; + } + + /** 字节数组 → 图片渲染数据(统一尺寸 600x240,适配 A4) */ + private ByteArrayPictureRenderData picture(byte[] png) { + if (png == null || png.length == 0) { + return null; + } + ByteArrayPictureRenderData picture = new ByteArrayPictureRenderData(png, PictureType.PNG); + PictureStyle style = new PictureStyle(); + style.setWidth(600); + style.setHeight(240); + picture.setPictureStyle(style); + return picture; + } + /** * 解析主监测指标统计:优先 primaryIdentifier,兜底第一个 */ @@ -399,6 +454,26 @@ public class WordRenderer implements ReportRenderer { return analysis.getStatistics().values().stream().findFirst().orElse(null); } + /** + * 头部「监测指标」字段文本:多主指标并列,用「、」连接;单指标退回原值 + */ + private String metricNameText(ReportModel model) { + List names = model.getMetricNames(); + if (names != null && !names.isEmpty()) { + // 去重且保序,避免同名主指标重复显示 + List dedup = new ArrayList<>(); + for (String name : names) { + if (name != null && !name.trim().isEmpty() && !dedup.contains(name.trim())) { + dedup.add(name.trim()); + } + } + if (!dedup.isEmpty()) { + return String.join("、", dedup); + } + } + return model.getMetricName() == null ? "" : model.getMetricName(); + } + /** * 构建动态列测点表(模板占位 {{metricTable}}) * @@ -432,9 +507,19 @@ public class WordRenderer implements ReportRenderer { // 2. 列顺序:主指标优先,其余按首次出现顺序(全测点统一,便于横向对比) List orderedIdentifiers = new ArrayList<>(); + Set primarySet = new java.util.LinkedHashSet<>(); + if (analysis.getPrimaryIdentifiers() != null && !analysis.getPrimaryIdentifiers().isEmpty()) { + for (String id : analysis.getPrimaryIdentifiers()) { + if (id != null && !orderedIdentifiers.contains(id)) { + orderedIdentifiers.add(id); + primarySet.add(id); + } + } + } String primaryIdentifier = analysis.getPrimaryIdentifier(); - if (primaryIdentifier != null) { + if (primaryIdentifier != null && !orderedIdentifiers.contains(primaryIdentifier)) { orderedIdentifiers.add(primaryIdentifier); + primarySet.add(primaryIdentifier); } for (MetricPointData point : points) { if (point.getIdentifier() != null && !orderedIdentifiers.contains(point.getIdentifier())) { @@ -456,7 +541,7 @@ public class WordRenderer implements ReportRenderer { MetricPointData sample = findSample(points, identifier); String name = sample == null ? identifier : nvl(sample.getModelName()); String unit = sample == null ? "" : nvl(sample.getCurrentUnit()); - boolean primary = identifier.equals(primaryIdentifier); + boolean primary = primarySet.contains(identifier); if (primary) { header.add(name + "·初始值" + unitSuffix(unit)); header.add(name + "·本次值" + unitSuffix(unit)); @@ -494,14 +579,14 @@ public class WordRenderer implements ReportRenderer { MetricPointData point = byIdentifier.get(identifier); if (point == null) { row.add("-"); - if (identifier.equals(primaryIdentifier)) { + if (primarySet.contains(identifier)) { row.add("-"); row.add("-"); row.add("-"); } continue; } - if (identifier.equals(primaryIdentifier)) { + if (primarySet.contains(identifier)) { row.add(strForTable(point.getInitialValue(), category)); row.add(strForTable(point.getCurrentValue(), category)); row.add(strForTable(point.getChangeValue(), category)); diff --git a/fastbee-report/src/main/java/com/fastbee/report/threshold/DbThingsThresholdProvider.java b/fastbee-report/src/main/java/com/fastbee/report/threshold/DbThingsThresholdProvider.java index 5ab9cab1..81191911 100644 --- a/fastbee-report/src/main/java/com/fastbee/report/threshold/DbThingsThresholdProvider.java +++ b/fastbee-report/src/main/java/com/fastbee/report/threshold/DbThingsThresholdProvider.java @@ -1,6 +1,5 @@ package com.fastbee.report.threshold; -import com.fastbee.common.utils.StringUtils; import com.fastbee.report.mapper.ThingsThresholdMapper; import org.springframework.stereotype.Service; diff --git a/fastbee-report/src/main/java/com/fastbee/report/threshold/ThingsThreshold.java b/fastbee-report/src/main/java/com/fastbee/report/threshold/ThingsThreshold.java index b5dfdcec..c8180ca0 100644 --- a/fastbee-report/src/main/java/com/fastbee/report/threshold/ThingsThreshold.java +++ b/fastbee-report/src/main/java/com/fastbee/report/threshold/ThingsThreshold.java @@ -50,6 +50,19 @@ public class ThingsThreshold { private String unit; + /** + * 是否主监测指标(1=是,0/NULL=否,由关键词推断) + * + *

报告里的「监测指标」默认由 {@code PrimaryMetricResolver} 按关键词推断, + * 命名不规范时会误判(现场曾把 GPS「经度」选成轴力计的监测指标)。 + * 这里提供显式指定的能力:被标记的物模型,优先级高于任何关键词推断。

+ * + *

级别口径与阈值数值**完全一致**({@code selectByPriority} 的 device > product, + * 逐标识符解析):某个物模型设备级配了就以设备行为准 —— 设备行没勾、产品行勾了也不作数; + * 设备级没配才继承产品行的勾选。

+ */ + private Boolean isPrimary; + @TableField(fill = FieldFill.INSERT) private String createBy; diff --git a/fastbee-report/src/main/java/com/fastbee/report/threshold/ThingsThresholdProvider.java b/fastbee-report/src/main/java/com/fastbee/report/threshold/ThingsThresholdProvider.java index bbb4d61c..375f9898 100644 --- a/fastbee-report/src/main/java/com/fastbee/report/threshold/ThingsThresholdProvider.java +++ b/fastbee-report/src/main/java/com/fastbee/report/threshold/ThingsThresholdProvider.java @@ -10,6 +10,9 @@ public interface ThingsThresholdProvider { /** * 查询指定设备 + 物模型标识符的结构化阈值(device > product > 全局) + * + *

返回行的 {@code isPrimary} 即"该物模型被用户显式指定为主监测指标"。 + * 级别口径与阈值数值同一套:设备级有行即以设备行为准,设备没配就继承产品级的勾选。

*/ ThingsThreshold find(Long deviceId, Long productId, String categoryCode, String metricIdentifier); } diff --git a/fastbee-report/src/main/resources/mapper/report/ThingsThresholdMapper.xml b/fastbee-report/src/main/resources/mapper/report/ThingsThresholdMapper.xml index e9141f05..980ee9e2 100644 --- a/fastbee-report/src/main/resources/mapper/report/ThingsThresholdMapper.xml +++ b/fastbee-report/src/main/resources/mapper/report/ThingsThresholdMapper.xml @@ -4,11 +4,13 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> - +