Explorar o código

feat(T02): 新增 IndicatorTimelineService 指标时序服务(timeline/listPeriods/pairCompare)

指标多期展示 T02 后端接口层 · §5.1 时序服务:

- timeline():按 definitionId 分组输出 points / latest / change / missingPeriods,
  refLow/refHigh(definition.refRange + indicator_values.refRange_snapshot 快照优先),
  pointsCount / totalReportCount / olderReportCount / chartMode / unitMismatch。
  支持 sourceType / fromDate / toDate / granularity(raw|month) / sourceKind(all|report|device)。
- listPeriods():期次列表(dateSource 取该期最差来源,reportDate=null 恒定排最后),
  供双期对比页 picker 使用;2 次查询完成,不分页。
- pairCompare():双期对比(scope=pair),含 sameDateWarning / from / to 元信息、
  skipGaps(内存计算,零额外查询)、improved/worsened/flat/incomparable 汇总、
  filter + orderBy + 分页。
- computeMissingPeriods 重写为 2 次查询(原来 1+缺失期数,N+1),
  并用 Map<Date, row> 修掉 Date 与 String 比较恒不相等导致行内容丢失的问题。
- 读路径全部经 IndicatorChangeService.loadActiveDefinitions()(status=1 过滤),
  拿不到 definition 的观测直接剔除,不降级为"指标"+id(QP17)。
Sisyphus hai 2 semanas
pai
achega
35349905da

+ 817 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/IndicatorTimelineService.java

@@ -0,0 +1,817 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.HealthReport;
+import com.etotem.cfc.entity.IndicatorDefinition;
+import com.etotem.cfc.entity.IndicatorValue;
+import com.etotem.cfc.mapper.HealthReportMapper;
+import com.etotem.cfc.mapper.IndicatorDefinitionMapper;
+import com.etotem.cfc.mapper.IndicatorValueMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.math.BigDecimal;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.stream.Collectors;
+
+/**
+ * 指标时序服务(T02 · §5.1)。
+ * 组合层:读取 indicator_values / health_reports,委托 IndicatorChangeService 做变化计算。
+ */
+@Service
+public class IndicatorTimelineService {
+
+    private static final Logger log = LoggerFactory.getLogger(IndicatorTimelineService.class);
+
+    /** 日期来源质量排序(数值越小越"差") */
+    private static final Map<String, Integer> DATE_SOURCE_QUALITY = new LinkedHashMap<>();
+    static {
+        DATE_SOURCE_QUALITY.put("unknown", 0);
+        DATE_SOURCE_QUALITY.put("confirm", 1);
+        DATE_SOURCE_QUALITY.put("upload", 2);
+        DATE_SOURCE_QUALITY.put("real", 3);
+    }
+
+    @Resource
+    private IndicatorValueMapper valueMapper;
+
+    @Resource
+    private IndicatorDefinitionMapper definitionMapper;
+
+    @Resource
+    private HealthReportMapper healthReportMapper;
+
+    @Resource
+    private IndicatorChangeService changeService;
+
+    /**
+     * 指标时间轴(§4.2)。
+     *
+     * @param subjectId   必填
+     * @param definitionIds 必填
+     * @param sourceType  可选;null = 全部来源
+     * @param fromDate    可选;闭区间
+     * @param toDate      可选;闭区间
+     * @param granularity raw / month
+     * @param sourceKind  all / report(排除 body_fat_scale)/ device(只要 body_fat_scale)
+     * @return { items: [...] }
+     */
+    public Map<String, Object> timeline(Long subjectId, List<Long> definitionIds,
+                                        String sourceType, String fromDate, String toDate,
+                                        String granularity, String sourceKind) {
+        Map<String, Object> data = new LinkedHashMap<>();
+        List<Map<String, Object>> items = new ArrayList<>();
+
+        if (subjectId == null || definitionIds == null || definitionIds.isEmpty()) {
+            data.put("items", items);
+            return data;
+        }
+        // 参数校验
+        if ("month".equals(granularity)) {
+            // 合法
+        } else if (granularity != null && !granularity.isEmpty() && !"raw".equals(granularity)) {
+            throw new IllegalArgumentException("granularity 取值非法");
+        }
+        if (!"all".equals(sourceKind) && !"report".equals(sourceKind) && !"device".equals(sourceKind)) {
+            sourceKind = "all";
+        }
+
+        // ---- 1. 拉取观测 ----
+        LambdaQueryWrapper<IndicatorValue> qw = new LambdaQueryWrapper<IndicatorValue>()
+                .eq(IndicatorValue::getSubjectId, subjectId)
+                .in(IndicatorValue::getDefinitionId, definitionIds)
+                .isNotNull(IndicatorValue::getReportDate)
+                .orderByAsc(IndicatorValue::getReportDate);
+        if (sourceType != null && !sourceType.trim().isEmpty()) {
+            qw.eq(IndicatorValue::getSourceType, sourceType);
+        }
+        if (sourceKind != null) {
+            if ("report".equals(sourceKind)) {
+                qw.ne(IndicatorValue::getSourceType, "body_fat_scale");
+            } else if ("device".equals(sourceKind)) {
+                qw.eq(IndicatorValue::getSourceType, "body_fat_scale");
+            }
+        }
+        List<IndicatorValue> rows = valueMapper.selectList(qw);
+        if (rows == null || rows.isEmpty()) {
+            data.put("items", items);
+            return data;
+        }
+
+        // 按 definitionId 分组(保留原始顺序)
+        Map<Long, List<IndicatorValue>> byDef = rows.stream()
+                .collect(Collectors.groupingBy(IndicatorValue::getDefinitionId,
+                        LinkedHashMap::new, Collectors.toList()));
+
+        // ---- 2. 获取 active definitions ----
+        List<Long> defIds = new ArrayList<>(byDef.keySet());
+        Map<Long, IndicatorDefinition> defs = changeService.loadActiveDefinitions(defIds);
+
+        // ---- 3. 统计 totalReportCount / olderReportCount(一次查询,内存计算两项)----
+        // 设计 §4.2:一份 COUNT 查询同时得 total 与 older,避免两次 COUNT。
+        java.sql.Date latestObsDate = null;
+        for (IndicatorValue v : rows) {
+            if (v.getReportDate() != null) {
+                if (latestObsDate == null || v.getReportDate().after(latestObsDate)) {
+                    latestObsDate = v.getReportDate();
+                }
+            }
+        }
+        final java.util.Date latestObsDateFinal = latestObsDate;
+        Long totalReportCount = null;
+        Long olderReportCount = null;
+        if (latestObsDateFinal != null) {
+            // 一次拉全部报告日期,内存算 total / older(总量小,单人单类型 < 100)
+            List<java.util.Date> reportDates = healthReportMapper.selectList(new LambdaQueryWrapper<HealthReport>()
+                            .eq(HealthReport::getSubjectId, subjectId)
+                            .eq(HealthReport::getStatus, "active")
+                            .isNotNull(HealthReport::getReportDate)
+                            .eq(sourceType != null && !sourceType.trim().isEmpty(),
+                                    HealthReport::getReportType, sourceType)
+                            .select(HealthReport::getReportDate))
+                    .stream().map(HealthReport::getReportDate).filter(Objects::nonNull)
+                    .collect(Collectors.toList());
+            totalReportCount = (long) reportDates.size();
+            olderReportCount = reportDates.stream()
+                    .filter(d -> toSqlDate(d).before(latestObsDateFinal)).count();
+        }
+
+        // ---- 4. 按 definition 组装 ----
+        for (Map.Entry<Long, List<IndicatorValue>> e : byDef.entrySet()) {
+            Long defId = e.getKey();
+            IndicatorDefinition def = defs.get(defId);
+            if (def == null) {
+                // Q17 强制:拿不到的 active definition 直接剔除,不降级
+                continue;
+            }
+            List<IndicatorValue> pointsRaw = e.getValue();
+            if (pointsRaw == null || pointsRaw.isEmpty()) {
+                continue;
+            }
+
+            // 过滤:无 numeric_value 的点是否纳入 chart? 保留所有点(非数值也能显示),pointsCount 计数包含
+            int pointsCount = pointsRaw.size();
+            // 计算 unitMismatch:points 中与 latest.unit 不同的单位
+            boolean unitMismatch = false;
+            String latestUnit = null;
+            for (int i = pointsRaw.size() - 1; i >= 0; i--) {
+                String u = pointsRaw.get(i).getUnit();
+                if (u != null && !u.trim().isEmpty()) {
+                    latestUnit = u.trim();
+                    break;
+                }
+            }
+            if (latestUnit != null) {
+                for (IndicatorValue v : pointsRaw) {
+                    String vu = v.getUnit();
+                    if (vu != null && !changeService.normUnit(vu).equals(changeService.normUnit(latestUnit))) {
+                        unitMismatch = true;
+                        break;
+                    }
+                }
+            }
+
+            // 按 reportDate 升序排列(与源数据一致)
+            List<IndicatorValue> points = new ArrayList<>(pointsRaw);
+            points.sort(IndicatorChangeService.NULLS_LAST_DESC.reversed()); // 升序
+
+            // 聚合:n>=20 时按月聚合
+            String gran = granularity != null ? granularity.trim() : "raw";
+            boolean needAggregate = points.size() >= 20;
+            if (needAggregate) {
+                points = changeService.aggregateByMonth(points);
+            }
+
+            // missingPeriods:该 (subjectId, sourceType) 下报告日期存在、但本指标无有效值的日期
+            List<Map<String, Object>> missingPeriods = computeMissingPeriods(subjectId, sourceType, points);
+
+            // refLow / refHigh
+            double refLow = Double.NaN;
+            double refHigh = Double.NaN;
+            String refRangeStr = null;
+            if (def.getRefRange() != null && !def.getRefRange().trim().isEmpty()) {
+                IndicatorChangeService.RefRange ref = changeService.parseRefRange(def.getRefRange());
+                if (ref.isParsed()) {
+                    if (ref.getLow() != null) refLow = ref.getLow().doubleValue();
+                    if (ref.getHigh() != null) refHigh = ref.getHigh().doubleValue();
+                    refRangeStr = def.getRefRange();
+                }
+            }
+            // 如果有 refRangeSnapshot,优先用快照
+            IndicatorValue lastWithValue = null;
+            for (int i = points.size() - 1; i >= 0; i--) {
+                if (points.get(i).getValue() != null && !points.get(i).getValue().trim().isEmpty()) {
+                    lastWithValue = points.get(i);
+                    break;
+                }
+            }
+            String effectiveRefRange = null;
+            if (lastWithValue != null) {
+                String snap = lastWithValue.getRefRangeSnapshot();
+                if (snap != null && !snap.trim().isEmpty()) {
+                    IndicatorChangeService.RefRange r = changeService.parseRefRange(snap);
+                    if (r.isParsed()) {
+                        if (r.getLow() != null) refLow = r.getLow().doubleValue();
+                        if (r.getHigh() != null) refHigh = r.getHigh().doubleValue();
+                        effectiveRefRange = snap;
+                    }
+                }
+            }
+            if (effectiveRefRange == null && refRangeStr != null) {
+                effectiveRefRange = refRangeStr;
+                // 重新算 refLow/refHigh
+                IndicatorChangeService.RefRange r = changeService.parseRefRange(refRangeStr);
+                if (r.isParsed()) {
+                    if (r.getLow() != null) refLow = r.getLow().doubleValue();
+                    if (r.getHigh() != null) refHigh = r.getHigh().doubleValue();
+                }
+            }
+
+            // chartMode
+            String chartMode = changeService.chartModeOf(pointsCount, gran);
+
+            // 组装 points 与 latest / change
+            List<Map<String, Object>> pointsOut = new ArrayList<>();
+            for (IndicatorValue v : points) {
+                Map<String, Object> p = new LinkedHashMap<>();
+                p.put("reportId", v.getReportId());
+                p.put("reportDate", v.getReportDate() != null ? fmt(v.getReportDate()) : null);
+                p.put("dateSource", v.getDateSource());
+                p.put("value", v.getValue());
+                p.put("numericValue", v.getNumericValue());
+                p.put("unit", v.getUnit());
+                p.put("status", v.getStatus());
+                p.put("sourceType", v.getSourceType());
+                pointsOut.add(p);
+            }
+
+            // latest:points 末元素
+            Map<String, Object> latest = pointsOut.isEmpty() ? null : pointsOut.get(pointsOut.size() - 1);
+
+            // change
+            Map<String, Object> change = buildChange(def, points);
+
+            Map<String, Object> item = new LinkedHashMap<>();
+            item.put("definitionId", defId);
+            item.put("name", def.getName());
+            item.put("unit", def.getUnit());
+            item.put("refRange", effectiveRefRange);
+            item.put("refLow", Double.isNaN(refLow) ? null : refLow);
+            item.put("refHigh", Double.isNaN(refHigh) ? null : refHigh);
+            item.put("goodDirection", def.getGoodDirection());
+            item.put("valueType", def.getValueType());
+            item.put("pointsCount", pointsCount);
+            item.put("totalReportCount", totalReportCount);
+            item.put("olderReportCount", olderReportCount);
+            item.put("chartMode", chartMode);
+            item.put("unitMismatch", unitMismatch);
+            item.put("latest", latest);
+            item.put("change", change);
+            item.put("points", pointsOut);
+            item.put("missingPeriods", missingPeriods);
+            items.add(item);
+        }
+
+        data.put("items", items);
+        return data;
+    }
+
+    /**
+     * 期次列表(§4.5)。供 P1-1 双期对比页 picker 使用。
+     *
+     * @return { subjectId, sourceType, items: [{ reportDate, reportId, dateSource, indicatorCount }] }
+     */
+    public Map<String, Object> listPeriods(Long subjectId, String sourceType) {
+        Map<String, Object> data = new LinkedHashMap<>();
+        data.put("subjectId", subjectId);
+        data.put("sourceType", sourceType);
+        List<Map<String, Object>> items = new ArrayList<>();
+
+        if (subjectId == null) {
+            data.put("items", items);
+            return data;
+        }
+
+        // 1) 拉 health_reports(指定 sourceType 时按 report_type 过滤)
+        LambdaQueryWrapper<HealthReport> rq = new LambdaQueryWrapper<HealthReport>()
+                .eq(HealthReport::getSubjectId, subjectId)
+                .eq(HealthReport::getStatus, "active")
+                .orderByDesc(HealthReport::getReportDate)
+                .orderByDesc(HealthReport::getId);
+        if (sourceType != null && !sourceType.trim().isEmpty()) {
+            rq.eq(HealthReport::getReportType, sourceType);
+        }
+        List<HealthReport> reports = healthReportMapper.selectList(rq);
+        if (reports.isEmpty()) {
+            data.put("items", items);
+            return data;
+        }
+
+        // 2) 拉该 subject 下所有 indicator_values,按 (sourceType, sourceId) 分组计数
+        //    sourceId = reportId(health_indicators 不在 indicator_values 中)
+        List<IndicatorValue> allObs = valueMapper.selectList(new LambdaQueryWrapper<IndicatorValue>()
+                .eq(IndicatorValue::getSubjectId, subjectId)
+                .eq(sourceType != null && !sourceType.trim().isEmpty(),
+                        IndicatorValue::getSourceType, sourceType)
+                .select(IndicatorValue::getSourceType, IndicatorValue::getSourceId,
+                        IndicatorValue::getReportDate, IndicatorValue::getDateSource));
+
+        // 按 sourceId 分组:{sourceId -> { count, worstDateSource }}
+        Map<Long, PeriodMeta> metaMap = new HashMap<>();
+        for (IndicatorValue v : allObs) {
+            if (v.getSourceId() == null) continue;
+            Long sid = v.getSourceId();
+            PeriodMeta m = metaMap.get(sid);
+            if (m == null) {
+                m = new PeriodMeta();
+                metaMap.put(sid, m);
+            }
+            m.count++;
+            if (v.getDateSource() != null) {
+                Integer quality = DATE_SOURCE_QUALITY.get(v.getDateSource());
+                if (quality != null && (m.worstQuality == null || quality < m.worstQuality)) {
+                    m.worstQuality = quality;
+                    m.worstDateSource = v.getDateSource();
+                }
+            }
+        }
+
+        // 3) 每个 report 组装一条
+        for (HealthReport r : reports) {
+            Map<String, Object> row = new LinkedHashMap<>();
+            row.put("reportDate", r.getReportDate() != null ? fmt(r.getReportDate()) : null);
+            row.put("reportId", r.getId());
+            row.put("dateSource", r.getReportDate() == null ? "unknown" : "real");
+            PeriodMeta m = metaMap.get(r.getId());
+            int indicatorCount = m != null ? m.count : 0;
+            String dateSource;
+            if (m != null && m.worstDateSource != null) {
+                dateSource = m.worstDateSource;
+            } else {
+                Object ds = row.get("dateSource");
+                dateSource = ds != null ? ds.toString() : "unknown";
+            }
+            row.put("dateSource", dateSource);
+            row.put("indicatorCount", indicatorCount);
+            items.add(row);
+        }
+        // 日期未知期恒定排最后
+        items.sort((a, b) -> {
+            String da = (String) a.get("reportDate");
+            String db = (String) b.get("reportDate");
+            if (da == null && db == null) return 0;
+            if (da == null) return 1;
+            if (db == null) return -1;
+            return db.compareTo(da);
+        });
+        data.put("items", items);
+        return data;
+    }
+
+    /**
+     * 双期对比(§4.3 scope=pair)。
+     */
+    public Map<String, Object> pairCompare(Long subjectId, String sourceType,
+                                            String fromReportDate, String toReportDate,
+                                            String category, String orderBy,
+                                            String filter, Integer page, Integer pageSize) {
+        Map<String, Object> data = new LinkedHashMap<>();
+
+        // 校验
+        if (fromReportDate == null || toReportDate == null) {
+            throw new IllegalArgumentException("scope=pair 时必须传 fromReportDate 与 toReportDate");
+        }
+        java.sql.Date fromDate = parseDate(fromReportDate);
+        java.sql.Date toDate = parseDate(toReportDate);
+        if (fromDate == null || toDate == null) {
+            throw new IllegalArgumentException("指定的期次不存在");
+        }
+        if (fromDate.after(toDate)) {
+            throw new IllegalArgumentException("fromReportDate 必须早于或等于 toReportDate");
+        }
+
+        // 找 from/to 的报告
+        HealthReport fromReport = findReportByDate(subjectId, sourceType, fromDate);
+        HealthReport toReport = findReportByDate(subjectId, sourceType, toDate);
+        if (fromReport == null || toReport == null) {
+            throw new IllegalArgumentException("指定的期次不存在");
+        }
+        boolean sameDateWarning = fromDate.equals(toDate);
+
+        data.put("sameDateWarning", sameDateWarning);
+
+        // 拉观测:该 subject + sourceType + reportDate 为 from/to 之间的全部
+        LambdaQueryWrapper<IndicatorValue> qw = new LambdaQueryWrapper<IndicatorValue>()
+                .eq(IndicatorValue::getSubjectId, subjectId)
+                .isNotNull(IndicatorValue::getReportDate)
+                .ge(IndicatorValue::getReportDate, fromDate)
+                .le(IndicatorValue::getReportDate, toDate);
+        if (sourceType != null && !sourceType.trim().isEmpty()) {
+            qw.eq(IndicatorValue::getSourceType, sourceType);
+        }
+        List<IndicatorValue> rows = valueMapper.selectList(qw.orderByAsc(IndicatorValue::getReportDate));
+
+        // from / to 期次元信息(含 indicatorCount)
+        data.put("from", buildPeriodMeta(fromReport, countByReportId(rows, fromReport.getId())));
+        data.put("to", buildPeriodMeta(toReport, countByReportId(rows, toReport.getId())));
+
+        // 按 definitionId 分组
+        Map<Long, List<IndicatorValue>> byDef = rows.stream()
+                .filter(v -> v.getDefinitionId() != null)
+                .collect(Collectors.groupingBy(IndicatorValue::getDefinitionId));
+
+        // active definitions
+        List<Long> defIds = new ArrayList<>(byDef.keySet());
+        Map<Long, IndicatorDefinition> defs = changeService.loadActiveDefinitions(defIds);
+
+        // 组装 before/after 观测映射
+        Map<Long, IndicatorValue> beforeMap = new HashMap<>();
+        Map<Long, IndicatorValue> afterMap = new HashMap<>();
+        for (IndicatorValue v : rows) {
+            if (v.getReportId() == null) {
+                continue;
+            }
+            if (v.getReportId().equals(fromReport.getId())) {
+                beforeMap.put(v.getDefinitionId(), v);
+            }
+            if (v.getReportId().equals(toReport.getId())) {
+                afterMap.put(v.getDefinitionId(), v);
+            }
+        }
+
+        // skipGaps 基数:该 (subjectId, sourceType) 在 from..to 之间的全部观测期数(内存计算,零额外查询)
+        // 语义与 IndicatorChangeService.computeSkipGaps 一致:
+        //   skipGaps = 两期之间「有观测但该指标无数值」的期数
+        final long reportsBetween = countPeriodsBetween(rows, fromDate, toDate);
+        // 用于 overall 统计
+        int improved = 0, worsened = 0, flat = 0, incomparable = 0;
+        List<Map<String, Object>> allItems = new ArrayList<>();
+
+        for (Map.Entry<Long, List<IndicatorValue>> e : byDef.entrySet()) {
+            Long defId = e.getKey();
+            IndicatorDefinition def = defs.get(defId);
+            if (def == null) continue; // Q17 强制剔除 status=0
+            if (category != null && !category.isEmpty() && !category.equals(def.getCategory())) {
+                continue;
+            }
+            IndicatorValue before = beforeMap.get(defId);
+            IndicatorValue after = afterMap.get(defId);
+
+            Map<String, Object> item = new LinkedHashMap<>();
+            item.put("definitionId", defId);
+            item.put("name", def.getName());
+            item.put("category", def.getCategory());
+            item.put("sourceKind", def.getSourceKind());
+            item.put("unit", def.getUnit());
+            item.put("refRange", def.getRefRange());
+            item.put("goodDirection", def.getGoodDirection());
+            item.put("valueType", def.getValueType());
+            item.put("before", before != null ? buildObsMeta(before) : null);
+            item.put("after", after != null ? buildObsMeta(after) : null);
+
+            // 仅一期有值 → 不可比,但必须出现在 items 中(filter=incomparable 才有意义)
+            if (before == null || after == null) {
+                incomparable++;
+                item.put("delta", null);
+                item.put("deltaPct", null);
+                item.put("movement", IndicatorChangeService.MOVEMENT_NONE);
+                item.put("trendLabel", "无法比较");
+                item.put("trendCode", IndicatorChangeService.TREND_FLAT);
+                item.put("skipGaps", 0L);
+                item.put("unitMismatch", false);
+                item.put("comparable", false);
+                allItems.add(item);
+                continue;
+            }
+
+            // skipGaps:内存计算(避免每指标 2 次查询的 N+1)
+            long skipGaps = Math.max(0L, reportsBetween - countObsBetween(e.getValue(), fromDate, toDate));
+
+            // 非数值 → 不可比(trendCode 恒 flat,不给颜色)
+            if (!"numeric".equals(def.getValueType())
+                    || before.getNumericValue() == null || after.getNumericValue() == null) {
+                incomparable++;
+                item.put("delta", null);
+                item.put("deltaPct", null);
+                String movement = before.getValue() != null && after.getValue() != null
+                        && before.getValue().equals(after.getValue())
+                        ? IndicatorChangeService.MOVEMENT_UNCHANGED : IndicatorChangeService.MOVEMENT_CHANGED;
+                item.put("movement", movement);
+                item.put("trendLabel", IndicatorChangeService.MOVEMENT_UNCHANGED.equals(movement)
+                        ? "无变化" : "有变化");
+                item.put("trendCode", IndicatorChangeService.TREND_FLAT);
+                item.put("skipGaps", skipGaps);
+                item.put("unitMismatch", false);
+                item.put("comparable", false);
+                allItems.add(item);
+                continue;
+            }
+
+            // 计算 change
+            IndicatorChangeService.ChangeResult cr = changeService.computeChange(def, before, after);
+            cr.setSkipGaps(skipGaps);
+            // 单位不一致 → 百分比不可比,delta 保留
+            if (cr.isUnitMismatch()) {
+                incomparable++;
+                item.put("delta", cr.getDelta());
+                item.put("deltaPct", null);
+                item.put("movement", cr.getMovement());
+                item.put("trendLabel", "无法比较");
+                item.put("trendCode", IndicatorChangeService.TREND_FLAT);
+                item.put("skipGaps", skipGaps);
+                item.put("unitMismatch", true);
+                item.put("comparable", false);
+                allItems.add(item);
+                continue;
+            }
+            // 统计
+            if (cr.getTrendCode().equals(IndicatorChangeService.TREND_IMPROVED)) improved++;
+            else if (cr.getTrendCode().equals(IndicatorChangeService.TREND_WORSENED)) worsened++;
+            else if (!cr.isComparable()) incomparable++;
+            else flat++;
+
+            item.put("delta", cr.getDelta());
+            item.put("deltaPct", cr.getDeltaPct());
+            item.put("movement", cr.getMovement());
+            item.put("trendLabel", cr.getTrendLabel());
+            item.put("trendCode", cr.getTrendCode());
+            item.put("skipGaps", cr.getSkipGaps());
+            item.put("unitMismatch", cr.isUnitMismatch());
+            item.put("comparable", cr.isComparable());
+            allItems.add(item);
+        }
+
+        // 排序
+        sortItems(allItems, orderBy);
+
+        // filter: improved / worsened / flat / incomparable / all
+        if (filter != null && !"all".equals(filter)) {
+            final String f = filter;
+            if ("improved".equals(f)) {
+                allItems.removeIf(it -> !IndicatorChangeService.TREND_IMPROVED.equals(it.get("trendCode")));
+            } else if ("worsened".equals(f)) {
+                allItems.removeIf(it -> !IndicatorChangeService.TREND_WORSENED.equals(it.get("trendCode")));
+            } else if ("flat".equals(f)) {
+                allItems.removeIf(it -> !(IndicatorChangeService.TREND_FLAT.equals(it.get("trendCode"))
+                        && Boolean.TRUE.equals(it.get("comparable"))));
+            } else if ("incomparable".equals(f)) {
+                allItems.removeIf(it -> !Boolean.FALSE.equals(it.get("comparable")));
+            }
+        }
+
+        int total = allItems.size();
+        // 分页
+        int pageVal = page != null && page > 0 ? page : 1;
+        int sizeVal = pageSize != null && pageSize > 0 ? pageSize : 50;
+        int fromIdx = Math.min((pageVal - 1) * sizeVal, total);
+        int toIdx = Math.min(fromIdx + sizeVal, total);
+        List<Map<String, Object>> paged = allItems.isEmpty()
+                ? new ArrayList<>()
+                : new ArrayList<>(allItems.subList(fromIdx, toIdx));
+
+        data.put("overall", buildOverall(improved, worsened, flat, incomparable));
+        data.put("items", paged);
+        data.put("page", pageVal);
+        data.put("pageSize", sizeVal);
+        data.put("total", total);
+        return data;
+    }
+
+    // ---------- 内部工具 ----------
+
+    /** (内存计算)某份报告下有指标的条数(用于 from/to 的 indicatorCount) */
+    private int countByReportId(List<IndicatorValue> rows, Long reportId) {
+        if (rows == null || reportId == null) return 0;
+        return (int) rows.stream()
+                .filter(v -> reportId.equals(v.getReportId()))
+                .filter(v -> v.getDefinitionId() != null)
+                .count();
+    }
+
+    /** (内存计算,无额外查询)该 (subjectId, sourceType) 在 fromDate..toDate 之间(严格区间)的全部观测期数 */
+    private long countPeriodsBetween(List<IndicatorValue> rows, java.sql.Date fromDate, java.sql.Date toDate) {
+        if (rows == null) return 0L;
+        return rows.stream()
+                .map(IndicatorValue::getReportDate)
+                .filter(Objects::nonNull)
+                .distinct()
+                .filter(d -> d.after(fromDate) && d.before(toDate))
+                .count();
+    }
+
+    /** (内存计算,无额外查询)指定定义在 fromDate..toDate 之间(严格区间)的有效值观测期数 */
+    private long countObsBetween(List<IndicatorValue> defPoints, java.sql.Date fromDate, java.sql.Date toDate) {
+        if (defPoints == null) return 0L;
+        return defPoints.stream()
+                .filter(v -> v.getNumericValue() != null && v.getReportDate() != null)
+                .map(IndicatorValue::getReportDate)
+                .distinct()
+                .filter(d -> d.after(fromDate) && d.before(toDate))
+                .count();
+    }
+
+    private Map<String, Object> buildChange(IndicatorDefinition def, List<IndicatorValue> points) {
+        // change = 最新 vs 上一期(相邻)
+        if (points == null || points.size() < 2) {
+            // 无上一期可对比,走 first_record / pending_backfill 取决于 olderReportCount
+            // 这里先返回 null,调用方若需要更精细应传 olderReportCount
+            return null;
+        }
+        IndicatorValue after = points.get(points.size() - 1);
+        IndicatorValue before = points.get(points.size() - 2);
+        return changeService.computeChange(def, before, after).toMap();
+    }
+
+    private Map<String, Object> buildPeriodMeta(HealthReport r, int indicatorCount) {
+        Map<String, Object> m = new LinkedHashMap<>();
+        m.put("reportDate", r.getReportDate() != null ? fmt(r.getReportDate()) : null);
+        m.put("reportId", r.getId());
+        m.put("dateSource", r.getReportDate() == null ? "unknown" : "real");
+        m.put("indicatorCount", indicatorCount);
+        return m;
+    }
+
+    private Map<String, Object> buildObsMeta(IndicatorValue v) {
+        Map<String, Object> m = new LinkedHashMap<>();
+        m.put("reportId", v.getReportId());
+        m.put("reportDate", v.getReportDate() != null ? fmt(v.getReportDate()) : null);
+        m.put("dateSource", v.getDateSource());
+        m.put("value", v.getValue());
+        m.put("numericValue", v.getNumericValue());
+        m.put("unit", v.getUnit());
+        m.put("status", v.getStatus());
+        return m;
+    }
+
+    private Map<String, Object> buildOverall(int improved, int worsened, int flat, int incomparable) {
+        Map<String, Object> o = new LinkedHashMap<>();
+        o.put("comparableCount", improved + worsened + flat);
+        o.put("improvedCount", improved);
+        o.put("worsenedCount", worsened);
+        o.put("flatCount", flat);
+        o.put("incomparableCount", incomparable);
+        return o;
+    }
+
+    /** 计算缺失期(2 次查询,N+1 优化版) */
+    private List<Map<String, Object>> computeMissingPeriods(Long subjectId, String sourceType,
+                                                              List<IndicatorValue> points) {
+        if (points == null || points.isEmpty()) {
+            return Collections.emptyList();
+        }
+        // 该 (subjectId, sourceType) 的全部有值观测日期集合(distinct reportDate)
+        List<java.sql.Date> allObsDates = valueMapper.selectList(new LambdaQueryWrapper<IndicatorValue>()
+                        .eq(IndicatorValue::getSubjectId, subjectId)
+                        .eq(sourceType != null && !sourceType.trim().isEmpty(),
+                                IndicatorValue::getSourceType, sourceType)
+                        .isNotNull(IndicatorValue::getValue)
+                        .select(IndicatorValue::getReportDate))
+                .stream().map(IndicatorValue::getReportDate).filter(Objects::nonNull)
+                .distinct().sorted().collect(Collectors.toList());
+
+        // 本指标已观测日期集合
+        java.util.Set<java.sql.Date> obsDates = points.stream()
+                .map(IndicatorValue::getReportDate)
+                .filter(Objects::nonNull)
+                .collect(Collectors.toSet());
+
+        // 按日期建索引:每个 date 的 reportId + dateSource
+        // 用 Map<Date, row> 而非 List,避免 Date 与 String 比较恒不相等导致行内容丢失
+        Map<java.sql.Date, Map<String, Object>> dateRowMap = new java.util.LinkedHashMap<>();
+        if (!allObsDates.isEmpty()) {
+            List<IndicatorValue> dayObs = valueMapper.selectList(new LambdaQueryWrapper<IndicatorValue>()
+                            .eq(IndicatorValue::getSubjectId, subjectId)
+                            .eq(sourceType != null && !sourceType.trim().isEmpty(),
+                                    IndicatorValue::getSourceType, sourceType)
+                            .isNotNull(IndicatorValue::getValue)
+                            .in(IndicatorValue::getReportDate, allObsDates)
+                            .select(IndicatorValue::getReportDate, IndicatorValue::getReportId,
+                                    IndicatorValue::getDateSource));
+            Map<java.sql.Date, IndicatorValue> firstPerDate = new java.util.LinkedHashMap<>();
+            for (IndicatorValue v : dayObs) {
+                java.sql.Date d = v.getReportDate();
+                if (d != null && !firstPerDate.containsKey(d)) {
+                    firstPerDate.put(d, v);
+                }
+            }
+            for (java.sql.Date d : allObsDates) {
+                IndicatorValue v = firstPerDate.get(d);
+                Map<String, Object> row = new LinkedHashMap<>();
+                row.put("reportDate", fmt(d));
+                row.put("reportId", v != null ? v.getReportId() : null);
+                row.put("dateSource", v != null ? v.getDateSource() : "unknown");
+                dateRowMap.put(d, row);
+            }
+        }
+
+        List<Map<String, Object>> missing = new ArrayList<>();
+        for (java.sql.Date d : allObsDates) {
+            if (!obsDates.contains(d)) {
+                Map<String, Object> row = dateRowMap.get(d);
+                if (row != null) {
+                    missing.add(row);
+                }
+            }
+        }
+        return missing;
+    }
+
+    private HealthReport findReportByDate(Long subjectId, String sourceType, java.sql.Date date) {
+        LambdaQueryWrapper<HealthReport> qw = new LambdaQueryWrapper<HealthReport>()
+                .eq(HealthReport::getSubjectId, subjectId)
+                .eq(HealthReport::getStatus, "active")
+                .eq(HealthReport::getReportDate, date)
+                .orderByDesc(HealthReport::getId)
+                .last("LIMIT 1");
+        if (sourceType != null && !sourceType.trim().isEmpty()) {
+            qw.eq(HealthReport::getReportType, sourceType);
+        }
+        List<HealthReport> list = healthReportMapper.selectList(qw);
+        return list.isEmpty() ? null : list.get(0);
+    }
+
+    private void sortItems(List<Map<String, Object>> items, String orderBy) {
+        final String order = orderBy != null ? orderBy : "magnitude";
+        if ("magnitude".equals(order)) {
+            items.sort((a, b) -> {
+                BigDecimal pa = numeric(a, "deltaPct");
+                BigDecimal pb = numeric(b, "deltaPct");
+                // deltaPct=null 恒定排最后
+                if (pa == null && pb == null) return 0;
+                if (pa == null) return 1;
+                if (pb == null) return -1;
+                return pb.abs().compareTo(pa.abs());
+            });
+        } else if ("improved".equals(order)) {
+            items.sort((a, b) -> {
+                Boolean ca = Boolean.TRUE.equals(a.get("comparable"));
+                Boolean cb = Boolean.TRUE.equals(b.get("comparable"));
+                if (!ca && cb) return 1;
+                if (ca && !cb) return -1;
+                BigDecimal pa = numeric(a, "deltaPct");
+                BigDecimal pb = numeric(b, "deltaPct");
+                if (pa == null && pb == null) return 0;
+                if (pa == null) return 1;
+                if (pb == null) return -1;
+                return pb.compareTo(pa);   // 改善幅度大的在前(deltaPct 降序)
+            });
+        } else if ("worsened".equals(order)) {
+            items.sort((a, b) -> {
+                Boolean ca = Boolean.TRUE.equals(a.get("comparable"));
+                Boolean cb = Boolean.TRUE.equals(b.get("comparable"));
+                if (!ca && cb) return 1;
+                if (ca && !cb) return -1;
+                BigDecimal pa = numeric(a, "deltaPct");
+                BigDecimal pb = numeric(b, "deltaPct");
+                if (pa == null && pb == null) return 0;
+                if (pa == null) return 1;
+                if (pb == null) return -1;
+                return pa.compareTo(pb);   // 恶化幅度大的在前(deltaPct 升序,即负数大的在前)
+            });
+        }
+    }
+
+    private BigDecimal numeric(Map<String, Object> m, String key) {
+        Object v = m.get(key);
+        if (v instanceof BigDecimal) return (BigDecimal) v;
+        if (v instanceof Number) return BigDecimal.valueOf(((Number) v).doubleValue());
+        return null;
+    }
+
+    private String fmt(Date d) {
+        return d != null ? new SimpleDateFormat("yyyy-MM-dd").format(d) : null;
+    }
+
+    private java.sql.Date parseDate(String s) {
+        if (s == null || s.trim().isEmpty()) return null;
+        try {
+            java.util.Date d = new SimpleDateFormat("yyyy-MM-dd").parse(s.trim());
+            return d != null ? new java.sql.Date(d.getTime()) : null;
+        } catch (Exception e) {
+            return null;
+        }
+    }
+
+    /** java.util.Date → java.sql.Date(截断到日) */
+    private java.sql.Date toSqlDate(java.util.Date d) {
+        if (d == null) return null;
+        return new java.sql.Date(d.getTime());
+    }
+
+    private static class PeriodMeta {
+        Integer count;
+        Integer worstQuality;
+        String worstDateSource;
+    }
+}