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;
*
*
判定规则(按物模型列表顺序,保证稳定、不依赖时序数据返回顺序):
*
- * - 排除辅助指标(角度/温度/电压/电量/信号/湿度等),辅助量不作为主监测指标;
- * - 优先返回名称或标识符命中核心关键词的物模型;
+ * - 用户显式指定优先:{@code iot_things_threshold.is_primary=1} 的物模型直接当选,
+ * 不受辅助量黑名单与关键词限制。级别口径与阈值数值同一套(逐标识符 device > product):
+ * 设备级配了该物模型就以设备行为准,设备没配才继承产品级的勾选;
+ * 多个标记项同时有数据时,取物模型列表顺序最靠前的(与关键词命中同口径);
+ * - 排除辅助指标(角度/温度/电压/电量/信号/湿度/GPS 定位等),辅助量不作为主监测指标;
+ * - 优先返回名称或标识符命中核心关键词的物模型;同类命中者中优先取该时段有数据的;
* - 否则返回第一个有数据的非辅助指标;
* - 再否则返回第一个非辅助指标;最后兜底第一个物模型。
*
*
+ * 为什么"有数据优先"是必须的:同一产品常存在多个同名物模型(如振弦式轴力计的
+ * {@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