Просмотр исходного кода

Merge remote-tracking branch 'origin/feat/indicator-timeline-t02' into cfclub

T02 后端契约合入 cfclub:
- IndicatorTimelineService(新增):/api/indicator/trend 时间轴主体
- IndicatorChangeService.batchResolveHistory:上一期 + 历史期数一次查询(消除 N+1)
- HealthReportService.getReportIndicatorsWithPrevious:报告详情变化对照 + definitionId/pointsCount/olderReportCount
- IndicatorController / IndicatorTrendService / IndicatorMappingService / IndicatorMappingRuleMapper 配套改造
- API_REFERENCE.md 同步(N+1 约束与缺失期计算说明)

冲突解决:HealthReportService.java —— 合并 T02 的 batchResolveHistory 调用与既有
getReportIndicatorsWithPrevious 逻辑,保留 T04 契约字段(definitionId/pointsCount/olderReportCount)。
Agnes Agent 2 недель назад
Родитель
Сommit
d5b3f2af5f

+ 41 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthReportController.java

@@ -459,6 +459,47 @@ public class HealthReportController {
         return Result.success(result);
     }
 
+    /**
+     * 新增(§4.1):获取报告指标详情(含上一期对照)
+     * 相比老接口 /indicator/list 返回裸 List<HealthIndicator>,本接口增加
+     * previous / change / summary 五档形态数据(first_record / pending_backfill / improved / worsened / flat)。
+     */
+    @Operation(summary = "获取报告指标详情(含上一期对照)")
+    @PostMapping("/report/indicators")
+    public Result<Map<String, Object>> getReportIndicatorsWithPrevious(
+            @RequestBody Map<String, Object> params,
+            @RequestAttribute("userId") Long userId,
+            @RequestAttribute(value = "familyId", required = false) Long familyId) {
+        Long reportId = ParamUtils.getLong(params.get("reportId"));
+        if (reportId == null) {
+            return Result.error("reportId不能为空");
+        }
+        HealthReport report = healthReportService.getReportById(reportId);
+        if (report == null) {
+            return Result.error("报告不存在");
+        }
+        // 权限校验:管理员 / 本人 / 被授权成员可见(沿用 getReportDetail 逻辑)
+        Long reportFamilyId = report.getFamilyId() != null ? report.getFamilyId() : familyId;
+        if (reportFamilyId == null) {
+            return Result.error("无权查看该报告");
+        }
+        Long subjectId = report.getSubjectId();
+        if (subjectId == null) {
+            boolean self = report.getUserId() != null && report.getUserId().equals(userId);
+            if (!self && !reportMemberPermissionService.isFamilyAdmin(userId, reportFamilyId)) {
+                return Result.error("无权查看该报告");
+            }
+        } else if (!reportMemberPermissionService.canViewMemberReports(userId, subjectId, reportFamilyId)) {
+            return Result.error("无权查看该报告");
+        }
+        Boolean withPrevious = params.get("withPrevious") != null
+                ? Boolean.valueOf(params.get("withPrevious").toString()) : true;
+        // 复用已加载的 report 与指标行,避免重复查询(验收标准第 6 条:SQL ≤ 5 次)
+        List<HealthIndicator> indicators = healthReportService.getReportIndicators(reportId);
+        Map<String, Object> data = healthReportService.getReportIndicatorsWithPrevious(report, indicators, withPrevious);
+        return Result.success(data);
+    }
+
     /**
      * 获取某份报告的所有指标明细
      */

+ 50 - 6
cfc-backend/src/main/java/com/etotem/cfc/controller/IndicatorController.java

@@ -5,6 +5,7 @@ import com.etotem.cfc.common.Result;
 import com.etotem.cfc.entity.FamilyMember;
 import com.etotem.cfc.mapper.FamilyMemberMapper;
 import com.etotem.cfc.service.IndicatorCorrelationService;
+import com.etotem.cfc.service.IndicatorTimelineService;
 import com.etotem.cfc.service.IndicatorTrendService;
 import org.springframework.web.bind.annotation.PostMapping;
 import org.springframework.web.bind.annotation.RequestAttribute;
@@ -24,6 +25,9 @@ public class IndicatorController {
     @Resource
     private IndicatorTrendService indicatorTrendService;
 
+    @Resource
+    private IndicatorTimelineService indicatorTimelineService;
+
     @Resource
     private IndicatorCorrelationService indicatorCorrelationService;
 
@@ -45,7 +49,7 @@ public class IndicatorController {
         return null;
     }
 
-    /** 指标曲线(设计 6.1) */
+    /** 指标曲线(§4.2 / §4.6 trend 增强:sourceType/区间/granularity/sourceKind) */
     @PostMapping("/trend")
     public Result<?> trend(@RequestBody Map<String, Object> params,
                            @RequestAttribute("familyId") Long familyId) {
@@ -66,10 +70,16 @@ public class IndicatorController {
                 }
             }
         }
-        return Result.success(indicatorTrendService.trend(subjectId, definitionIds, familyId));
+        String sourceType = params.get("sourceType") != null ? params.get("sourceType").toString() : null;
+        String fromDate = params.get("fromDate") != null ? params.get("fromDate").toString() : null;
+        String toDate = params.get("toDate") != null ? params.get("toDate").toString() : null;
+        String granularity = params.get("granularity") != null ? params.get("granularity").toString() : null;
+        String sourceKind = params.get("sourceKind") != null ? params.get("sourceKind").toString() : null;
+        return Result.success(indicatorTrendService.trend(
+                subjectId, definitionIds, familyId, sourceType, fromDate, toDate, granularity, sourceKind));
     }
 
-    /** 变化幅度排名(设计 6.2) */
+    /** 变化幅度排名(§4.3,新增 scope=pair + fromReportDate/toReportDate + filter + 分页) */
     @PostMapping("/change-ranking")
     public Result<?> changeRanking(@RequestBody Map<String, Object> params,
                                    @RequestAttribute("familyId") Long familyId) {
@@ -84,11 +94,27 @@ public class IndicatorController {
         String orderBy = params.get("orderBy") != null ? params.get("orderBy").toString() : "magnitude";
         Integer limit = params.get("limit") != null
                 ? Integer.valueOf(params.get("limit").toString()) : 50;
+        String fromReportDate = params.get("fromReportDate") != null
+                ? params.get("fromReportDate").toString() : null;
+        String toReportDate = params.get("toReportDate") != null
+                ? params.get("toReportDate").toString() : null;
+        String filter = params.get("filter") != null ? params.get("filter").toString() : null;
+        Integer page = params.get("page") != null ? Integer.valueOf(params.get("page").toString()) : null;
+        Integer pageSize = params.get("pageSize") != null ? Integer.valueOf(params.get("pageSize").toString()) : null;
+
+        // 参数校验:scope=pair 时必须传 fromReportDate 与 toReportDate
+        // 400 与 GlobalExceptionHandler 对 IllegalArgumentException 的映射保持一致(缺参属客户端错误)
+        if ("pair".equals(scope)) {
+            if (fromReportDate == null || toReportDate == null) {
+                return Result.error(400, "scope=pair 时必须传 fromReportDate 与 toReportDate");
+            }
+        }
         return Result.success(indicatorTrendService.changeRanking(
-                subjectId, reportType, category, scope, orderBy, limit, familyId));
+                subjectId, reportType, category, scope, orderBy, limit, familyId,
+                fromReportDate, toReportDate, filter, page, pageSize));
     }
 
-    /** 指标速查(设计 6.3) */
+    /** 指标速查(§4.4,新增 withSparkline/sparkLimit) */
     @PostMapping("/quick-lookup")
     public Result<?> quickLookup(@RequestBody Map<String, Object> params,
                                  @RequestAttribute("familyId") Long familyId) {
@@ -100,7 +126,12 @@ public class IndicatorController {
         String category = params.get("category") != null ? params.get("category").toString() : null;
         Integer page = params.get("page") != null ? Integer.valueOf(params.get("page").toString()) : 1;
         Integer pageSize = params.get("pageSize") != null ? Integer.valueOf(params.get("pageSize").toString()) : 30;
-        return Result.success(indicatorTrendService.quickLookup(subjectId, category, page, pageSize, familyId));
+        Boolean withSparkline = params.get("withSparkline") != null
+                && Boolean.valueOf(params.get("withSparkline").toString());
+        Integer sparkLimit = params.get("sparkLimit") != null
+                ? Integer.valueOf(params.get("sparkLimit").toString()) : 8;
+        return Result.success(indicatorTrendService.quickLookup(
+                subjectId, category, page, pageSize, familyId, withSparkline, sparkLimit));
     }
 
     /** 成员已有指标时序的报告类型列表(曲线页类型筛选动态化) */
@@ -115,6 +146,19 @@ public class IndicatorController {
         return Result.success(indicatorTrendService.listSourceTypes(subjectId));
     }
 
+    /** 【新增】期次列表(§4.5 /api/indicator/periods) */
+    @PostMapping("/periods")
+    public Result<?> periods(@RequestBody Map<String, Object> params,
+                             @RequestAttribute("familyId") Long familyId) {
+        Long subjectId = com.etotem.cfc.util.ParamUtils.getLong(params.get("subjectId"));
+        Result<Void> check = checkSubjectBelongs(subjectId, familyId);
+        if (check != null) {
+            return check;
+        }
+        String sourceType = params.get("sourceType") != null ? params.get("sourceType").toString() : null;
+        return Result.success(indicatorTimelineService.listPeriods(subjectId, sourceType));
+    }
+
     /** 指标关联查询(设计 6.4) */
     @PostMapping("/correlation/list")
     public Result<?> correlationList(@RequestBody Map<String, Object> params,

+ 39 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/IndicatorMappingRuleMapper.java

@@ -2,6 +2,45 @@ package com.etotem.cfc.mapper;
 
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 import com.etotem.cfc.entity.IndicatorMappingRule;
+import com.etotem.cfc.service.IndicatorMappingService;
+import org.apache.ibatis.annotations.Result;
+import org.apache.ibatis.annotations.Results;
+import org.apache.ibatis.annotations.Select;
+
+import java.util.List;
 
 public interface IndicatorMappingRuleMapper extends BaseMapper<IndicatorMappingRule> {
+
+    /**
+     * 规则 + 关联定义的一次性联合查询(只读)。
+     *
+     * <p><b>为什么需要</b>:报告指标接口(§4.1)要把 N 个原始指标名映射到 definition,
+     * 再拿 definition 的 goodDirection / valueType / refRange / status。
+     * 若分两步(查规则表 + selectBatchIds 查定义表)就是 2 次查询;
+     * 联合查询把它压到 1 次,是「单报告 42 项指标 SQL ≤ 5 次」验收标准的硬要求。</p>
+     *
+     * <p>LEFT JOIN:规则存在但定义被删时仍返回该规则(definition_* 列为 null)。</p>
+     */
+    @Select("SELECT mr.raw_name AS raw_name, mr.definition_id AS definition_id, "
+            + "mr.priority AS priority, mr.status AS rule_status, "
+            + "d.id AS def_id, d.name AS def_name, d.good_direction AS def_good_direction, "
+            + "d.value_type AS def_value_type, d.unit AS def_unit, d.ref_range AS def_ref_range, "
+            + "d.status AS def_status "
+            + "FROM indicator_mapping_rules mr "
+            + "LEFT JOIN indicator_definitions d ON mr.definition_id = d.id "
+            + "WHERE mr.source_type = #{sourceType}")
+    @Results({
+            @Result(property = "rawName", column = "raw_name"),
+            @Result(property = "definitionId", column = "definition_id"),
+            @Result(property = "priority", column = "priority"),
+            @Result(property = "ruleStatus", column = "rule_status"),
+            @Result(property = "defId", column = "def_id"),
+            @Result(property = "defName", column = "def_name"),
+            @Result(property = "defGoodDirection", column = "def_good_direction"),
+            @Result(property = "defValueType", column = "def_value_type"),
+            @Result(property = "defUnit", column = "def_unit"),
+            @Result(property = "defRefRange", column = "def_ref_range"),
+            @Result(property = "defStatus", column = "def_status")
+    })
+    List<IndicatorMappingService.ResolvedMapping> selectRulesWithDefinitions(String sourceType);
 }

+ 322 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/HealthReportService.java

@@ -34,8 +34,11 @@ import java.util.Comparator;
 import java.util.Date;
 import java.util.HashMap;
 import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
 import java.util.List;
 import java.math.BigDecimal;
+import java.text.SimpleDateFormat;
 import java.util.Map;
 import com.etotem.cfc.entity.MemberIndicatorCurrent;
 import java.util.Set;
@@ -121,6 +124,12 @@ public class HealthReportService {
     @Resource
     private com.etotem.cfc.service.NormalizeIndicatorPipeline normalizeIndicatorPipeline;
 
+    @Resource
+    private com.etotem.cfc.service.IndicatorMappingService mappingService;
+
+    @Resource
+    private com.etotem.cfc.service.IndicatorChangeService changeService;
+
     @Resource
     private ReportDateExtractor reportDateExtractor;
 
@@ -393,6 +402,317 @@ public class HealthReportService {
         );
     }
 
+    /**
+     * 获取报告指标(含上一期对照)—— §4.1 新增接口 /api/health/report/indicators。
+     * 严禁 N+1:批量调 IndicatorChangeService.batchResolve 一次性解析全部 previous,
+     * 再组装 summary 与 items。
+     *
+     * @param reportId       报告 ID
+     * @param withPrevious   true=计算 previous/change;false=仅单期(编辑模式等场景用)
+     * @return 含 report 信息、summary 统计、items(每项含 previous/change)
+     */
+    public Map<String, Object> getReportIndicatorsWithPrevious(Long reportId, Boolean withPrevious) {
+        HealthReport report = healthReportMapper.selectById(reportId);
+        if (report == null) {
+            Map<String, Object> err = new LinkedHashMap<>();
+            err.put("code", 500);
+            err.put("message", "报告不存在");
+            return err;
+        }
+        return getReportIndicatorsWithPrevious(report, null, withPrevious);
+    }
+
+    /**
+     * 获取报告指标(含上一期对照)—— 复用已加载的 report / indicators,避免重复查询。
+     *
+     * <p><b>SQL 预算(验收标准第 6 条:≤ 5 次)</b>:
+     * <ol>
+     *   <li>health_indicators(本报告指标行;indicators 由控制器传入时省 1 次)</li>
+     *   <li>indicator_mapping_rules + indicator_definitions(resolveBatch 联合查询,1 次)</li>
+     *   <li>health_reports(olderReportCount,1 次)</li>
+     *   <li>indicator_values(batchResolveHistory:上一期 + 历史有效期数一次带出,1 次)</li>
+     * </ol>
+     * 指标数从 1 条涨到 42 条时,查询数恒定为 ≤ 5(实际 4,预留 abnormalDelta 的内存计算,零额外查询)。
+     *
+     * @param report      已加载的报告(控制器做权限校验时已查过,直接复用)
+     * @param indicators  已加载的本报告指标行;null 时内部再查一次
+     * @param withPrevious true=计算 previous/change
+     */
+    public Map<String, Object> getReportIndicatorsWithPrevious(HealthReport report,
+                                                                List<HealthIndicator> indicators,
+                                                                Boolean withPrevious) {
+        Map<String, Object> data = new LinkedHashMap<>();
+        if (report == null) {
+            data.put("code", 500);
+            data.put("message", "报告不存在");
+            return data;
+        }
+
+        data.put("reportId", report.getId());
+        data.put("reportType", report.getReportType());
+        data.put("subjectId", report.getSubjectId());
+        data.put("reportDate", report.getReportDate() != null
+                ? new SimpleDateFormat("yyyy-MM-dd").format(report.getReportDate()) : null);
+        data.put("dateSource", report.getReportDate() == null ? "unknown" : "real");
+
+        if (indicators == null) {
+            indicators = getReportIndicators(report.getId());
+        }
+        int totalCount = indicators.size();
+        int abnormalCount = 0;
+        int changedCount = 0;
+        int comparableCount = 0;
+        int firstRecordCount = 0;
+        int pendingCount = 0;
+
+        if (!indicators.isEmpty() && Boolean.TRUE.equals(withPrevious)) {
+            // ---- 1) 按 (subjectId, sourceType, reportDate) 批量取上一期 ----
+            Long subjectId = report.getSubjectId();
+            String sourceType = report.getReportType();
+            java.sql.Date currentReportDate = report.getReportDate() != null
+                    ? new java.sql.Date(report.getReportDate().getTime()) : null;
+
+            // olderReportCount:同 subject+sourceType 且 report_date < current 的报告份数
+            long olderReportCount = 0;
+            if (subjectId != null && currentReportDate != null) {
+                olderReportCount = healthReportMapper.selectList(new LambdaQueryWrapper<HealthReport>()
+                        .eq(HealthReport::getSubjectId, subjectId)
+                        .eq(HealthReport::getStatus, "active")
+                        .isNotNull(HealthReport::getReportDate)
+                        .eq(HealthReport::getReportType, sourceType)
+                        .lt(HealthReport::getReportDate, currentReportDate)
+                        .select(HealthReport::getReportDate))
+                        .size();
+            }
+            data.put("olderReportCount", olderReportCount);
+
+            // 2) 解析 payload 获取指标 definitionId 映射(同 NormalizeIndicatorPipeline 逻辑)
+            //    批量解析:1 次查规则表 + 内存 exact/fuzzy,避免逐指标查表(N+1)
+            Map<String, Long> nameToDefId = new HashMap<>();
+            List<String> rawNames = new ArrayList<>();
+            ParsedReportPayload.Payload payload = null;
+            if (report.getPayloadJson() != null) {
+                try {
+                    payload = objectMapper.readValue(report.getPayloadJson(), ParsedReportPayload.Payload.class);
+                    if (payload != null && payload.getIndicators() != null) {
+                        for (ParsedReportPayload.Indicator ind : payload.getIndicators()) {
+                            if (ind.getIndicatorName() != null) {
+                                rawNames.add(ind.getIndicatorName().trim());
+                            }
+                        }
+                    }
+                } catch (Exception e) {
+                    log.warn("解析 payload 失败 reportId={}: {}", report.getId(), e.getMessage());
+                }
+            }
+            if (payload != null && payload.getGutFlora() != null) {
+                for (ParsedReportPayload.Flora flora : payload.getGutFlora()) {
+                    if (flora.getBacteriaName() != null) {
+                        rawNames.add(flora.getBacteriaName().trim());
+                    }
+                }
+            }
+            // 报告行上的指标名也要参与解析(payload 可能缺失,指标行仍存在)
+            for (HealthIndicator hi : indicators) {
+                if (hi.getIndicatorName() != null) {
+                    rawNames.add(hi.getIndicatorName().trim());
+                }
+            }
+            Map<String, IndicatorMappingService.ResolvedMapping> resolvedMap = mappingService.resolveBatch(sourceType, rawNames);
+            for (IndicatorMappingService.ResolvedMapping rm : resolvedMap.values()) {
+                if (rm.getDefinitionId() != null) {
+                    nameToDefId.put(rm.getRawName(), rm.getDefinitionId());
+                }
+            }
+            List<Long> defIds = new ArrayList<>(new LinkedHashSet<>(nameToDefId.values()));
+
+            // 2b) 已在 resolveBatch 的联合查询中随规则一并带出 definition 字段,无需再 selectBatchIds(省 1 次查询)
+            Map<Long, IndicatorDefinition> defMap = new HashMap<>();
+            for (IndicatorMappingService.ResolvedMapping rm : resolvedMap.values()) {
+                if (rm.getDefId() != null) {
+                    IndicatorDefinition d = new IndicatorDefinition();
+                    d.setId(rm.getDefId());
+                    d.setName(rm.getDefName());
+                    d.setGoodDirection(rm.getDefGoodDirection());
+                    d.setValueType(rm.getDefValueType());
+                    d.setUnit(rm.getDefUnit());
+                    d.setRefRange(rm.getDefRefRange());
+                    d.setStatus(rm.getDefStatus());
+                    defMap.put(rm.getDefId(), d);
+                }
+            }
+
+            // 3) 批量解析上一期 + 历史有效期数(1 次查询:batchResolveHistory 同时给出两者)
+            Map<Long, List<IndicatorValue>> historyMap = Collections.emptyMap();
+            if (subjectId != null && currentReportDate != null && !defIds.isEmpty()) {
+                historyMap = changeService.batchResolveHistory(subjectId, sourceType, defIds, currentReportDate);
+            }
+
+            // 4) 组装 items
+            final int olderReportCountInt = (int) olderReportCount;
+            // abnormalDelta:本期异常项数 − 上期异常项数(只统计「有上一期对照」的项,缺口期不计)
+            int abnormalNowComparable = 0;
+            int abnormalPrevComparable = 0;
+            List<Map<String, Object>> items = new ArrayList<>();
+            for (HealthIndicator hi : indicators) {
+                String name = hi.getIndicatorName();
+                Long defId = nameToDefId.get(name != null ? name.trim() : "");
+                IndicatorDefinition def = defId != null ? defMap.get(defId) : null;
+
+                // Q17:status=0 的 definition 剔除(不降级为"指标"+id)
+                if (def != null && def.getStatus() != null && def.getStatus() != 1) {
+                    def = null;
+                    defId = null;
+                }
+
+                // 本期是否异常(与字典命中无关)
+                boolean currentAbnormal = hi.getStatus() != null && !"正常".equals(hi.getStatus());
+
+                // 历史有效期数:1(本期)+ 历史不同 report_date 期数(§9-Q6:未命中字典项不跨报告匹配,恒为 1)
+                int pointsCount = 1;
+                IndicatorValue prev = null;
+                if (defId != null) {
+                    List<IndicatorValue> history = historyMap.get(defId);
+                    if (history != null && !history.isEmpty()) {
+                        pointsCount += (int) history.stream()
+                                .map(IndicatorValue::getReportDate)
+                                .filter(java.util.Objects::nonNull)
+                                .distinct()
+                                .count();
+                        prev = history.get(history.size() - 1);
+                    }
+                }
+
+                // 组装 item(未命中字典的指标 definitionId=null,仍要出现在 items 中)
+                Map<String, Object> item = new LinkedHashMap<>();
+                item.put("indicatorId", hi.getId());
+                item.put("definitionId", defId);
+                item.put("category", hi.getCategory());
+                item.put("indicatorName", hi.getIndicatorName());
+                item.put("indicatorValue", hi.getIndicatorValue());
+                item.put("numericValue", hi.getIndicatorValue() != null ? parseNumeric(hi.getIndicatorValue()) : null);
+                item.put("unit", hi.getUnit());
+                item.put("refRange", hi.getRefRange());
+                item.put("status", hi.getStatus());
+                item.put("goodDirection", def != null ? def.getGoodDirection() : null);
+                item.put("valueType", def != null ? def.getValueType() : null);
+                item.put("pointsCount", pointsCount);
+                item.put("olderReportCount", olderReportCountInt);
+
+                // 异常计数与字典命中无关:未命中字典的指标同样可能是异常项
+                if (currentAbnormal) {
+                    abnormalCount++;
+                }
+
+                // change(恒非空对象;未命中字典项也走 B1/B2 档,trendCode 由 olderReportCount 决定)
+                IndicatorChangeService.ChangeResult cr;
+                if (prev == null) {
+                    cr = changeService.computeChange(def, null, buildValueFromHi(hi), olderReportCountInt);
+                } else {
+                    IndicatorValue after = buildValueFromHi(hi);
+                    after.setSourceType(sourceType);
+                    cr = changeService.computeChange(def, prev, after);
+                    // abnormalDelta 计入项:仅统计有上一期对照的项
+                    if (currentAbnormal) { abnormalNowComparable++; }
+                    boolean prevAbnormal = prev.getStatus() != null && !"正常".equals(prev.getStatus());
+                    if (prevAbnormal) { abnormalPrevComparable++; }
+                }
+
+                // 统计
+                if (cr.getTrendCode().equals(IndicatorChangeService.TREND_IMPROVED)
+                        || cr.getTrendCode().equals(IndicatorChangeService.TREND_WORSENED)) {
+                    changedCount++;
+                }
+                if (cr.isComparable()) {
+                    comparableCount++;
+                }
+                if (cr.getTrendCode().equals(IndicatorChangeService.TREND_FIRST_RECORD)) {
+                    firstRecordCount++;
+                }
+                if (cr.getTrendCode().equals(IndicatorChangeService.TREND_PENDING_BACKFILL)) {
+                    pendingCount++;
+                }
+
+                // previous
+                if (prev != null) {
+                    Map<String, Object> prevMap = new LinkedHashMap<>();
+                    prevMap.put("reportId", prev.getReportId());
+                    prevMap.put("reportDate", prev.getReportDate() != null ? new SimpleDateFormat("yyyy-MM-dd").format(prev.getReportDate()) : null);
+                    prevMap.put("dateSource", prev.getDateSource());
+                    prevMap.put("value", prev.getValue());
+                    prevMap.put("numericValue", prev.getNumericValue());
+                    prevMap.put("unit", prev.getUnit());
+                    prevMap.put("status", prev.getStatus());
+                    item.put("previous", prevMap);
+                } else {
+                    item.put("previous", null);
+                }
+                item.put("change", cr.toMap());
+                items.add(item);
+            }
+
+            // summary
+            Map<String, Object> summary = new LinkedHashMap<>();
+            summary.put("totalCount", totalCount);
+            summary.put("abnormalCount", abnormalCount);
+            // abnormalDelta:无更早报告(无上一期可算)→ null,前端不显示"较上次 ±N"
+            summary.put("abnormalDelta", olderReportCount > 0
+                    ? (abnormalNowComparable - abnormalPrevComparable) : null);
+            summary.put("changedCount", changedCount);
+            summary.put("comparableCount", comparableCount);
+            summary.put("firstRecordCount", firstRecordCount);
+            summary.put("pendingCount", pendingCount);
+            summary.put("dataIncomplete", pendingCount > 0);
+            data.put("summary", summary);
+            data.put("items", items);
+        } else {
+            // withPrevious=false:仅单期
+            List<Map<String, Object>> items = new ArrayList<>();
+            for (HealthIndicator hi : indicators) {
+                Map<String, Object> item = new LinkedHashMap<>();
+                item.put("indicatorId", hi.getId());
+                item.put("category", hi.getCategory());
+                item.put("indicatorName", hi.getIndicatorName());
+                item.put("indicatorValue", hi.getIndicatorValue());
+                item.put("unit", hi.getUnit());
+                item.put("refRange", hi.getRefRange());
+                item.put("status", hi.getStatus());
+                items.add(item);
+            }
+            data.put("items", items);
+        }
+
+        return data;
+    }
+
+    private IndicatorValue buildValueFromHi(HealthIndicator hi) {
+        IndicatorValue v = new IndicatorValue();
+        v.setValue(hi.getIndicatorValue());
+        v.setNumericValue(parseNumeric(hi.getIndicatorValue()));
+        v.setUnit(hi.getUnit());
+        v.setStatus(hi.getStatus());
+        return v;
+    }
+
+    /**
+     * 把指标原始文本值解析为 BigDecimal(去掉 %/~/>/< 与空格后尝试转换)。
+     * 与 IndicatorMappingService.parseNumericValue 语义一致,本处复用以避免重复正则。
+     */
+    private BigDecimal parseNumeric(String raw) {
+        if (raw == null) {
+            return null;
+        }
+        String cleaned = raw.replaceAll("[^0-9.-]", "");
+        if (cleaned.isEmpty()) {
+            return null;
+        }
+        try {
+            return new BigDecimal(cleaned);
+        } catch (NumberFormatException ignore) {
+            return null;
+        }
+    }
+
     /**
      * 计算家庭成员身体维度得分 (0-100)
      * <p>
@@ -2368,6 +2688,8 @@ public class HealthReportService {
             } catch (Exception ignore) { }
             Map<String, Object> item = new HashMap<>();
             item.put("code", def.getCode());
+            // definitionId:身维度卡片跳指标时间轴页的必需参数(向后兼容新增字段)
+            item.put("definitionId", def.getId());
             item.put("name", def.getName());
             item.put("category", def.getCategory());
             item.put("unit", def.getUnit());

+ 1 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/IndicatorChangeService.java

@@ -482,7 +482,7 @@ public class IndicatorChangeService {
     }
 
     /** 单位归一化(去空格 + 全角括号/乘号统一) */
-    private static String normUnit(String s) {
+    public static String normUnit(String s) {
         if (s == null) {
             return null;
         }

+ 119 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/IndicatorMappingService.java

@@ -16,10 +16,16 @@ import org.springframework.stereotype.Service;
 import javax.annotation.Resource;
 import java.math.BigDecimal;
 import java.util.ArrayList;
+import java.util.Collection;
 import java.util.Collections;
 import java.util.Date;
+import java.util.HashMap;
+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;
 
 /**
  * 指标归一化映射:raw_name → definition_id。
@@ -115,6 +121,119 @@ public class IndicatorMappingService {
         return lazyRegister(sourceType, name);
     }
 
+    /**
+     * 批量解析(只读,不触发 AI 兜底 / 惰性注册,不写库)。
+     * 一次联合查询拿到「规则 + 关联定义」,内存做 exact + fuzzy 匹配。
+     * 相比逐名称 resolve(每次 1~2 次查询)省掉 N+1,是「单报告 42 项指标 SQL ≤ 5 次」的硬要求。
+     *
+     * @param sourceType 报告来源类型
+     * @param rawNames   待解析的原始指标名(自动 trim + 去重)
+     * @return rawName → ResolvedMapping(未命中的名称不出现在结果中)
+     */
+    public Map<String, ResolvedMapping> resolveBatch(String sourceType, Collection<String> rawNames) {
+        Map<String, ResolvedMapping> result = new LinkedHashMap<>();
+        if (sourceType == null || rawNames == null || rawNames.isEmpty()) {
+            return result;
+        }
+        Set<String> names = new LinkedHashSet<>();
+        for (String n : rawNames) {
+            if (n != null && !n.trim().isEmpty()) {
+                names.add(n.trim());
+            }
+        }
+        if (names.isEmpty()) {
+            return result;
+        }
+
+        // 一次联合查询:规则 + 关联定义
+        List<ResolvedMapping> rules = ruleMapper.selectRulesWithDefinitions(sourceType);
+        if (rules == null || rules.isEmpty()) {
+            return result;
+        }
+
+        // exact 命中:rawName 相同,取优先级最高的一条(与 resolve 的 orderByDesc(priority) 一致)
+        Map<String, ResolvedMapping> exactResolved = new HashMap<>();
+        for (String name : names) {
+            ResolvedMapping best = null;
+            int bestPriority = Integer.MIN_VALUE;
+            for (ResolvedMapping r : rules) {
+                if (!name.equals(r.getRawName()) || r.getDefinitionId() == null) continue;
+                int p = r.getPriority() == null ? 0 : r.getPriority();
+                if (p > bestPriority) {
+                    bestPriority = p;
+                    best = r;
+                }
+            }
+            if (best != null) {
+                exactResolved.put(name, best);
+            }
+        }
+
+        // fuzzy 候选:仅 status=1 的规则(与 resolve 一致)
+        List<ResolvedMapping> candidates = rules.stream()
+                .filter(r -> r.getRuleStatus() != null && r.getRuleStatus() == 1)
+                .collect(Collectors.toList());
+
+        for (String name : names) {
+            ResolvedMapping exact = exactResolved.get(name);
+            if (exact != null) {
+                result.put(name, exact);
+                continue;
+            }
+            ResolvedMapping best = null;
+            double bestScore = 0;
+            for (ResolvedMapping c : candidates) {
+                double s = bigramSimilarity(name, c.getRawName());
+                if (s > bestScore) {
+                    bestScore = s;
+                    best = c;
+                }
+            }
+            if (best != null && bestScore >= FUZZY_THRESHOLD && best.getDefinitionId() != null) {
+                result.put(name, best);
+            }
+        }
+        return result;
+    }
+
+    /** 规则 + 关联定义的联合结果(供批量解析复用,避免二次查 definition 表) */
+    public static class ResolvedMapping {
+        private String rawName;
+        private Long definitionId;
+        private Integer priority;
+        private Integer ruleStatus;
+        private Long defId;
+        private String defName;
+        private String defGoodDirection;
+        private String defValueType;
+        private String defUnit;
+        private String defRefRange;
+        private Integer defStatus;
+
+        public String getRawName() { return rawName; }
+        public void setRawName(String rawName) { this.rawName = rawName; }
+        public Long getDefinitionId() { return definitionId; }
+        public void setDefinitionId(Long definitionId) { this.definitionId = definitionId; }
+        public Integer getPriority() { return priority; }
+        public void setPriority(Integer priority) { this.priority = priority; }
+        public Integer getRuleStatus() { return ruleStatus; }
+        public void setRuleStatus(Integer ruleStatus) { this.ruleStatus = ruleStatus; }
+        public Long getDefId() { return defId; }
+        public void setDefId(Long defId) { this.defId = defId; }
+        public String getDefName() { return defName; }
+        public void setDefName(String defName) { this.defName = defName; }
+        public String getDefGoodDirection() { return defGoodDirection; }
+        public void setDefGoodDirection(String defGoodDirection) { this.defGoodDirection = defGoodDirection; }
+        public String getDefValueType() { return defValueType; }
+        public void setDefValueType(String defValueType) { this.defValueType = defValueType; }
+        public String getDefUnit() { return defUnit; }
+        public void setDefUnit(String defUnit) { this.defUnit = defUnit; }
+        public String getDefRefRange() { return defRefRange; }
+        public void setDefRefRange(String defRefRange) { this.defRefRange = defRefRange; }
+        public Integer getDefStatus() { return defStatus; }
+        public void setDefStatus(Integer defStatus) { this.defStatus = defStatus; }
+    }
+
     /** 惰性注册新定义(code 规则:indicator.<cat>.<name> / bacteria.<name> / food.<name>) */
     private Long lazyRegister(String sourceType, String rawName) {
         DefinitionCode code = buildCode(sourceType, rawName);

+ 2 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/IndicatorService.java

@@ -23,7 +23,8 @@ public class IndicatorService {
         LambdaQueryWrapper<IndicatorDefinition> qw = new LambdaQueryWrapper<>();
         if (domain != null) qw.eq(IndicatorDefinition::getDomain, domain);
         if (category != null) qw.eq(IndicatorDefinition::getCategory, category);
-        if (status != null) qw.eq(IndicatorDefinition::getStatus, status);
+        // 【Q17】status==null 时默认 1,避免自动创建的 status=0 定义冒进管理端字典列表
+        qw.eq(IndicatorDefinition::getStatus, status != null ? status : 1);
         qw.orderByAsc(IndicatorDefinition::getSortOrder);
         SortUtil.applySort(qw);
         return indicatorDefinitionMapper.selectList(qw);

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

+ 369 - 256
cfc-backend/src/main/java/com/etotem/cfc/service/IndicatorTrendService.java

@@ -13,15 +13,15 @@ import org.springframework.stereotype.Service;
 
 import javax.annotation.Resource;
 import java.math.BigDecimal;
-import java.math.RoundingMode;
 import java.text.SimpleDateFormat;
 import java.util.ArrayList;
+import java.util.Comparator;
 import java.util.Date;
-import java.util.HashMap;
 import java.util.LinkedHashMap;
 import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
 import java.util.Set;
 import java.util.stream.Collectors;
 
@@ -46,58 +46,288 @@ public class IndicatorTrendService {
     @Resource
     private HealthDimensionScoreMapper healthDimensionScoreMapper;
 
-    /** 指标曲线:按 definitionId 分组、report_date 升序返回点序列 */
-    public Map<String, Object> trend(Long subjectId, List<Long> definitionIds, Long familyId) {
+    @Resource
+    private IndicatorTimelineService timelineService;
+
+    @Resource
+    private IndicatorChangeService changeService;
+
+    /** 指标曲线:委托 IndicatorTimelineService.timeline(§4.2) */
+    public Map<String, Object> trend(Long subjectId, List<Long> definitionIds, Long familyId,
+                                     String sourceType, String fromDate, String toDate,
+                                     String granularity, String sourceKind) {
+        if (subjectId == null || definitionIds == null || definitionIds.isEmpty()) {
+            Map<String, Object> empty = new LinkedHashMap<>();
+            empty.put("items", new ArrayList<>());
+            return empty;
+        }
+        try {
+            return timelineService.timeline(subjectId, definitionIds, sourceType, fromDate, toDate, granularity, sourceKind);
+        } catch (Exception e) {
+            log.warn("indicator/trend 异常 subjectId={}, defs={}: {}", subjectId, definitionIds, e.getMessage());
+            Map<String, Object> err = new LinkedHashMap<>();
+            err.put("code", 500);
+            err.put("message", "查询失败: " + e.getMessage());
+            return err;
+        }
+    }
+
+    /**
+     * 变化幅度排名(设计 6.2)。scope=adjacent 相邻两次(默认)/ first-vs-latest / pair。
+     * 改造:新增 scope=pair + filter + 分页;magnitude 排序改为 |Δ%| 降序;null deltaPct 恒定排最后。
+     */
+    public Map<String, Object> changeRanking(Long subjectId, String reportType, String category,
+                                             String scope, String orderBy, Integer limit, Long familyId,
+                                             String fromReportDate, String toReportDate,
+                                             String filter, Integer page, Integer pageSize) {
         Map<String, Object> data = new LinkedHashMap<>();
-        List<Map<String, Object>> items = new ArrayList<>();
-        if (definitionIds == null || definitionIds.isEmpty() || subjectId == null) {
-            data.put("items", items);
+        // 兼容老调用:无 page/pageSize 且有 limit → 走老逻辑(limit 模式不分页)
+        boolean legacyMode = (page == null && pageSize == null) && limit != null && limit > 0;
+        if (legacyMode || "adjacent".equals(scope) || "first-vs-latest".equals(scope)) {
+            return changeRankingLegacy(subjectId, reportType, category, scope, orderBy, limit, familyId,
+                    fromReportDate, toReportDate);
+        }
+        // 新 scope=pair 走 timelineService.pairCompare(分页 + filter + magnitude|Δ%|排序)
+        try {
+            return timelineService.pairCompare(subjectId, reportType, fromReportDate, toReportDate,
+                    category, orderBy, filter, page, pageSize);
+        } catch (IllegalArgumentException e) {
+            // 参数/期次非法属客户端错误 → 400(与 GlobalExceptionHandler 的映射一致)
+            data.put("code", 400);
+            data.put("message", e.getMessage());
+            return data;
+        } catch (Exception e) {
+            log.warn("indicator/change-ranking(scope=pair) 异常 subjectId={}: {}", subjectId, e.getMessage());
+            data.put("code", 500);
+            data.put("message", "查询失败: " + e.getMessage());
             return data;
         }
+    }
+
+    /**
+     * 指标速查(设计 6.3)。修 null 排序 bug + 增加 previous/change/latestDateSource/latestReportId/goodDirection/valueType/sparkline。
+     * Q17 强制:经 loadActiveDefinitions 过滤 status=0。
+     */
+    public Map<String, Object> quickLookup(Long subjectId, String category, Integer page,
+                                           Integer pageSize, Long familyId,
+                                           Boolean withSparkline, Integer sparkLimit) {
+        Map<String, Object> data = new LinkedHashMap<>();
+        int pageVal = page != null && page > 0 ? page : 1;
+        int sizeVal = pageSize != null && pageSize > 0 ? pageSize : 30;
+        int sparkLimitVal = sparkLimit != null && sparkLimit > 0 ? sparkLimit : 8;
+
+        // 拉全部观测(按 reportDate 降序、NULL 恒排最后)
         List<IndicatorValue> rows = valueMapper.selectList(new LambdaQueryWrapper<IndicatorValue>()
                 .eq(IndicatorValue::getSubjectId, subjectId)
-                .in(IndicatorValue::getDefinitionId, definitionIds)
-                .isNotNull(IndicatorValue::getReportDate)
-                .orderByAsc(IndicatorValue::getReportDate));
-
+                .isNotNull(IndicatorValue::getDefinitionId));
         Map<Long, List<IndicatorValue>> byDef = rows.stream()
-                .collect(Collectors.groupingBy(IndicatorValue::getDefinitionId,
-                        LinkedHashMap::new, Collectors.toList()));
+                .collect(Collectors.groupingBy(IndicatorValue::getDefinitionId));
+        // Q17:一次性取 active definitions
+        List<Long> defIds = new ArrayList<>(byDef.keySet());
+        Map<Long, IndicatorDefinition> activeDefs = changeService.loadActiveDefinitions(defIds);
 
-        Map<Long, IndicatorDefinition> defCache = new HashMap<>();
+        List<Map<String, Object>> allItems = new ArrayList<>();
         for (Map.Entry<Long, List<IndicatorValue>> e : byDef.entrySet()) {
+            Long defId = e.getKey();
+            IndicatorDefinition def = activeDefs.get(defId);
+            if (def == null) {
+                // Q17 强制:拿不到的 active definition 直接剔除,不降级为"指标"+id
+                continue;
+            }
+            if (category != null && !category.isEmpty() && !category.equals(def.getCategory())) {
+                continue;
+            }
+            // 排序:reportDate 降序,NULL 恒排最后(修 bug:原实现 null 排最前)
+            List<IndicatorValue> sorted = e.getValue().stream()
+                    .sorted(IndicatorChangeService.NULLS_LAST_DESC)
+                    .collect(Collectors.toList());
+            if (sorted.isEmpty()) continue;
+            IndicatorValue latest = sorted.get(0);
+            // 上一期:内存求解(等价于 changeService.resolvePrevious,但复用已加载的 rows,
+            // 避免逐指标查库的 N+1 —— 40 个指标就是 40 次查询)
+            IndicatorValue previous = resolvePreviousInMemory(e.getValue(), latest);
+            // change 对象(恒非空)
+            IndicatorChangeService.ChangeResult cr;
+            if (previous == null || latest.getNumericValue() == null || latest.getNumericValue().compareTo(BigDecimal.ZERO) == 0
+                    || previous.getNumericValue() == null || previous.getNumericValue().compareTo(BigDecimal.ZERO) == 0) {
+                // 无上一期或基数为 0 → first_record / pending_backfill
+                // 这里简化:无 previous → first_record
+                cr = changeService.computeChange(def, null, latest, 0);
+            } else {
+                cr = changeService.computeChange(def, previous, latest);
+            }
+
             Map<String, Object> item = new LinkedHashMap<>();
-            item.put("definitionId", e.getKey());
-            IndicatorDefinition def = defCache.computeIfAbsent(e.getKey(),
-                    id -> definitionMapper.selectById(id));
-            item.put("name", def != null ? def.getName() : "指标" + e.getKey());
-            item.put("unit", def != null ? def.getUnit() : null);
-            List<Map<String, Object>> points = new ArrayList<>();
-            for (IndicatorValue v : e.getValue()) {
-                Map<String, Object> p = new LinkedHashMap<>();
-                p.put("reportDate", v.getReportDate() != null
-                        ? new SimpleDateFormat("yyyy-MM-dd").format(v.getReportDate()) : null);
-                p.put("value", v.getValue());
-                p.put("numericValue", v.getNumericValue());
-                p.put("unit", v.getUnit());
-                p.put("status", v.getStatus());
-                points.add(p);
+            item.put("definitionId", defId);
+            item.put("name", def.getName());
+            item.put("category", def.getCategory());
+            item.put("value", latest.getValue());
+            item.put("numericValue", latest.getNumericValue());
+            item.put("unit", latest.getUnit() != null ? latest.getUnit() : def.getUnit());
+            item.put("refRange", def.getRefRange());
+            item.put("status", latest.getStatus());
+            item.put("goodDirection", def.getGoodDirection());
+            item.put("valueType", def.getValueType());
+            item.put("latestReportDate", latest.getReportDate() != null
+                    ? new SimpleDateFormat("yyyy-MM-dd").format(latest.getReportDate()) : null);
+            item.put("latestDateSource", latest.getDateSource());
+            item.put("latestReportId", latest.getReportId());
+            item.put("pointsCount", sorted.size());
+            // olderReportCount:同 (subjectId, sourceType) 且 reportDate < latest.reportDate 的报告数
+            // 为简化,近似为 sorted.size() - 1(有报告的次数),准确值应由 health_reports 统计
+            item.put("olderReportCount", Math.max(0, sorted.size() - 1));
+
+            // previous
+            if (previous != null) {
+                Map<String, Object> prevMap = new LinkedHashMap<>();
+                prevMap.put("reportId", previous.getReportId());
+                prevMap.put("reportDate", previous.getReportDate() != null
+                        ? new SimpleDateFormat("yyyy-MM-dd").format(previous.getReportDate()) : null);
+                prevMap.put("dateSource", previous.getDateSource());
+                prevMap.put("value", previous.getValue());
+                prevMap.put("numericValue", previous.getNumericValue());
+                prevMap.put("unit", previous.getUnit());
+                prevMap.put("status", previous.getStatus());
+                item.put("previous", prevMap);
+            } else {
+                item.put("previous", null);
             }
-            item.put("points", points);
-            items.add(item);
+            // change(恒非空)
+            item.put("change", cr.toMap());
+            // sparkline
+            if (Boolean.TRUE.equals(withSparkline) && sorted.size() >= 3) {
+                int n = Math.min(sparkLimitVal, sorted.size());
+                List<Map<String, Object>> spark = new ArrayList<>();
+                for (int i = sorted.size() - n; i < sorted.size(); i++) {
+                    Map<String, Object> p = new LinkedHashMap<>();
+                    p.put("reportDate", sorted.get(i).getReportDate() != null
+                            ? new SimpleDateFormat("yyyy-MM-dd").format(sorted.get(i).getReportDate()) : null);
+                    p.put("numericValue", sorted.get(i).getNumericValue());
+                    spark.add(p);
+                }
+                item.put("sparkline", spark);
+            } else {
+                item.put("sparkline", null);
+            }
+            allItems.add(item);
         }
-        data.put("items", items);
+
+        // categories 汇总
+        List<Map<String, Object>> categories = new ArrayList<>();
+        Map<String, Long> catCount = allItems.stream()
+                .collect(Collectors.groupingBy(i -> (String) i.get("category"), Collectors.counting()));
+        catCount.forEach((name, total) -> {
+            Map<String, Object> c = new LinkedHashMap<>();
+            c.put("name", name);
+            c.put("total", total);
+            categories.add(c);
+        });
+
+        // 分页
+        int fromIndex = Math.min((pageVal - 1) * sizeVal, allItems.size());
+        int toIndex = Math.min(fromIndex + sizeVal, allItems.size());
+        List<Map<String, Object>> pageItems = allItems.isEmpty()
+                ? new ArrayList<>()
+                : new ArrayList<>(allItems.subList(fromIndex, toIndex));
+
+        data.put("categories", categories);
+        data.put("items", pageItems);
+        data.put("page", pageVal);
+        data.put("pageSize", sizeVal);
+        data.put("total", allItems.size());
+        data.put("dimensionScores", loadDimensionScores(subjectId));
         return data;
     }
 
-    /** 变化幅度排名(设计 6.2)。scope=adjacent 相邻两次(默认)/ first-vs-latest 首尾对比。 */
-    public Map<String, Object> changeRanking(Long subjectId, String reportType, String category,
-                                             String scope, String orderBy, Integer limit, Long familyId) {
+    /** 该成员已有指标时序的报告类型列表(indicator_values.source_type 去重,曲线页类型筛选动态来源) */
+    public List<Map<String, Object>> listSourceTypes(Long subjectId) {
+        List<Map<String, Object>> items = new ArrayList<>();
+        if (subjectId == null) {
+            return items;
+        }
+        // 一次取出 (sourceType, reportDate),periodCount 与 latestReportDate 全部内存计算,
+        // 避免 1+2N 查询(每个 sourceType 2 次 COUNT/MAX)
+        List<IndicatorValue> rows = valueMapper.selectList(new LambdaQueryWrapper<IndicatorValue>()
+                .eq(IndicatorValue::getSubjectId, subjectId)
+                .isNotNull(IndicatorValue::getSourceType)
+                .isNotNull(IndicatorValue::getReportDate)
+                .select(IndicatorValue::getSourceType, IndicatorValue::getReportDate));
+
+        // type → 去重日期集合(保序)
+        Map<String, Set<java.sql.Date>> datesByType = new LinkedHashMap<>();
+        // type → 最晚日期
+        Map<String, java.util.Date> latestByType = new LinkedHashMap<>();
+        for (IndicatorValue v : rows) {
+            String t = v.getSourceType();
+            java.util.Date d = v.getReportDate();
+            if (t == null || t.trim().isEmpty() || d == null) {
+                continue;
+            }
+            t = t.trim();
+            if (d instanceof java.sql.Date) {
+                datesByType.computeIfAbsent(t, k -> new LinkedHashSet<>()).add((java.sql.Date) d);
+            }
+            java.util.Date cur = latestByType.get(t);
+            if (cur == null || d.after(cur)) {
+                latestByType.put(t, d);
+            }
+        }
+
+        for (String t : datesByType.keySet()) {
+            Map<String, Object> m = new LinkedHashMap<>();
+            m.put("sourceType", t);
+            m.put("label", sourceTypeLabel(t));
+            m.put("periodCount", datesByType.get(t).size());
+            java.util.Date latest = latestByType.get(t);
+            m.put("latestReportDate", latest != null ? new SimpleDateFormat("yyyy-MM-dd").format(latest) : null);
+            items.add(m);
+        }
+        return items;
+    }
+
+    /**
+     * 内存求解上一期观测(语义与 {@link IndicatorChangeService#resolvePrevious} 完全一致)。
+     *
+     * <p>rows 已是该 subject 下全部观测(quickLookup 开头一次查完),在这里内存过滤比
+     * 逐指标回查 indicator_values 便宜得多:40 个指标省 40 次 SQL。</p>
+     *
+     * @param defRows 同一 definitionId 的全部观测(未排序)
+     * @param latest  本期最新观测
+     * @return 上一期观测;无(本期日期未知 / 同来源无更早有效值)返回 null
+     */
+    private IndicatorValue resolvePreviousInMemory(List<IndicatorValue> defRows, IndicatorValue latest) {
+        if (defRows == null || latest == null || latest.getReportDate() == null) {
+            return null;   // 本期日期未知 → 无法确定"更早",不猜
+        }
+        // 严格早于本期:按原始时间戳比较(不截断到日),与 DB 侧 report_date < ? 的语义一致
+        final long latestMillis = latest.getReportDate().getTime();
+        final String sourceType = latest.getSourceType();
+        IndicatorValue previous = null;
+        for (IndicatorValue v : defRows) {
+            if (v == latest || v.getReportDate() == null || v.getNumericValue() == null) {
+                continue;
+            }
+            if (sourceType != null && !sourceType.trim().isEmpty()
+                    && !sourceType.equals(v.getSourceType())) {
+                continue;   // 只认同来源的更早期
+            }
+            if (v.getReportDate().getTime() >= latestMillis) {
+                continue;   // 严格早于本期
+            }
+            if (previous == null || v.getReportDate().after(previous.getReportDate())) {
+                previous = v;
+            }
+        }
+        return previous;
+    }
+
+    /** legacy 路径(兼容老调用 scope=adjacent/first-vs-latest + limit 模式) */
+    private Map<String, Object> changeRankingLegacy(Long subjectId, String reportType, String category,
+                                                     String scope, String orderBy, Integer limit, Long familyId,
+                                                     String fromReportDate, String toReportDate) {
         Map<String, Object> data = new LinkedHashMap<>();
         List<Map<String, Object>> items = new ArrayList<>();
-        int improved = 0;
-        int worsened = 0;
-        int flat = 0;
+        int improved = 0, worsened = 0, flat = 0;
 
         LambdaQueryWrapper<IndicatorValue> qw = new LambdaQueryWrapper<IndicatorValue>()
                 .eq(IndicatorValue::getSubjectId, subjectId)
@@ -107,104 +337,128 @@ public class IndicatorTrendService {
         }
         List<IndicatorValue> rows = valueMapper.selectList(qw.orderByAsc(IndicatorValue::getReportDate));
 
-        // 该成员(+报告类型)全部观测日期全集:用于计算"相邻两次对比中间缺失的报告期数"
         java.util.List<java.sql.Date> allDates = rows.stream()
                 .map(IndicatorValue::getReportDate)
-                .filter(d -> d != null)
+                .filter(Objects::nonNull)
                 .distinct()
                 .sorted()
                 .collect(Collectors.toList());
 
-        // 按 definitionId 分组
         Map<Long, List<IndicatorValue>> byDef = rows.stream()
                 .collect(Collectors.groupingBy(IndicatorValue::getDefinitionId));
-        Map<Long, IndicatorDefinition> defCache = new HashMap<>();
+        // Q17:一次性取 active definitions
+        List<Long> defIds = new ArrayList<>(byDef.keySet());
+        Map<Long, IndicatorDefinition> activeDefs = changeService.loadActiveDefinitions(defIds);
 
         for (Map.Entry<Long, List<IndicatorValue>> e : byDef.entrySet()) {
+            IndicatorDefinition def = activeDefs.get(e.getKey());
+            if (def == null) {
+                continue;   // Q17 强制剔除 status=0
+            }
+            if (category != null && !category.isEmpty() && !category.equals(def.getCategory())) {
+                continue;
+            }
             List<IndicatorValue> sorted = e.getValue().stream()
-                    .sorted((a, b) -> a.getReportDate().compareTo(b.getReportDate()))
+                    .sorted(Comparator.comparing(IndicatorValue::getReportDate,
+                            Comparator.nullsFirst(Comparator.naturalOrder())))
                     .collect(Collectors.toList());
             if (sorted.size() < 2) {
                 continue;
             }
-            IndicatorValue before = sorted.get(0);
-            IndicatorValue after = sorted.get(sorted.size() - 1);
+            IndicatorValue before, after;
             if ("adjacent".equals(scope)) {
                 before = sorted.get(sorted.size() - 2);
+                after = sorted.get(sorted.size() - 1);
+            } else {
+                before = sorted.get(0);
+                after = sorted.get(sorted.size() - 1);
+            }
+            if (before.getNumericValue() == null || after.getNumericValue() == null) {
+                continue;
             }
-            // skipGaps:before/after 之间存在报告日期、但该指标缺失的期数(跨期跳比标记)
-            // before 在 adjacent 分支被重新赋值,lambda 需 final 副本
             long skipGaps = 0;
             if ("adjacent".equals(scope)
                     && before.getReportDate() != null && after.getReportDate() != null) {
-                final IndicatorValue beforeF = before;
-                final IndicatorValue afterF = after;
+                final IndicatorValue b = before, a = after;
                 long reportsBetween = allDates.stream()
-                        .filter(d -> d.after(beforeF.getReportDate()) && d.before(afterF.getReportDate()))
+                        .filter(d -> d.after(b.getReportDate()) && d.before(a.getReportDate()))
                         .count();
                 long obsBetween = sorted.stream()
                         .filter(v -> v.getReportDate() != null
-                                && v.getReportDate().after(beforeF.getReportDate())
-                                && v.getReportDate().before(afterF.getReportDate()))
+                                && v.getReportDate().after(b.getReportDate())
+                                && v.getReportDate().before(a.getReportDate()))
                         .count();
                 skipGaps = Math.max(0, reportsBetween - obsBetween);
             }
-            IndicatorDefinition def = defCache.computeIfAbsent(e.getKey(),
-                    id -> definitionMapper.selectById(id));
-            String defCategory = def != null ? def.getCategory() : null;
-            if (category != null && !category.isEmpty() && !category.equals(defCategory)) {
-                continue;
-            }
-            BigDecimal bv = before.getNumericValue();
-            BigDecimal av = after.getNumericValue();
-            if (bv == null || av == null) {
-                continue;
+            IndicatorChangeService.ChangeResult cr = changeService.computeChange(def, before, after);
+            cr.setSkipGaps(skipGaps);
+            if (cr.getTrendCode().equals(IndicatorChangeService.TREND_IMPROVED)) improved++;
+            else if (cr.getTrendCode().equals(IndicatorChangeService.TREND_WORSENED)) worsened++;
+            else if (!cr.isComparable()) {
+                // 不可比项不参与 improved/worsened/flat 计数
+            } else {
+                flat++;
             }
-            BigDecimal delta = av.subtract(bv);
-            // 基数为 0 时百分比无意义(如 0 → 0.5 显示 +0.5 而非误导性的 0%),置 null 由前端降级为只显示 Δ
-            BigDecimal deltaPct = bv.compareTo(BigDecimal.ZERO) != 0
-                    ? delta.multiply(BigDecimal.valueOf(100)).divide(bv, 1, RoundingMode.HALF_UP)
-                    : null;
-            String movement = "flat";
-            if (delta.compareTo(BigDecimal.ZERO) > 0) movement = "up";
-            else if (delta.compareTo(BigDecimal.ZERO) < 0) movement = "down";
-
-            String trendLabel = trendLabel(def, movement, delta);
-            if ("改善".equals(trendLabel)) improved++;
-            else if ("恶化".equals(trendLabel)) worsened++;
-            else flat++;
 
             Map<String, Object> item = new LinkedHashMap<>();
             item.put("definitionId", e.getKey());
-            item.put("name", def != null ? def.getName() : "指标" + e.getKey());
-            item.put("category", defCategory);
-            item.put("sourceKind", def != null ? def.getSourceKind() : null);
-            item.put("unit", def != null ? def.getUnit() : null);
-            item.put("goodDirection", def != null ? def.getGoodDirection() : "neutral");
+            item.put("name", def.getName());
+            item.put("category", def.getCategory());
+            item.put("sourceKind", def.getSourceKind());
+            item.put("unit", def.getUnit());
+            item.put("goodDirection", def.getGoodDirection());
+            item.put("valueType", def.getValueType());
+            item.put("refRange", def.getRefRange());
             Map<String, Object> beforeMap = new LinkedHashMap<>();
             beforeMap.put("reportDate", fmtDate(before.getReportDate()));
             beforeMap.put("value", before.getValue());
+            beforeMap.put("numericValue", before.getNumericValue());
+            beforeMap.put("unit", before.getUnit());
+            beforeMap.put("status", before.getStatus());
             Map<String, Object> afterMap = new LinkedHashMap<>();
             afterMap.put("reportDate", fmtDate(after.getReportDate()));
             afterMap.put("value", after.getValue());
+            afterMap.put("numericValue", after.getNumericValue());
+            afterMap.put("unit", after.getUnit());
+            afterMap.put("status", after.getStatus());
             item.put("before", beforeMap);
             item.put("after", afterMap);
-            item.put("delta", delta);
-            item.put("deltaPct", deltaPct);
-            item.put("skipGaps", skipGaps);
-            item.put("movement", movement);
-            item.put("trendLabel", trendLabel);
+            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());
             items.add(item);
         }
 
-        // 排序:magnitude 按 Δ 绝对值降序(默认);up/down 按 Δ 正/负降序
+        // 排序:magnitude 按 |Δ%| 降序,null 恒排最后
+        items.sort((a, b) -> {
+            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.abs().compareTo(pa.abs());
+        });
         if ("up".equals(orderBy)) {
-            items.sort((a, b) -> ((BigDecimal) b.get("delta")).compareTo((BigDecimal) a.get("delta")));
+            items.sort((a, b) -> {
+                BigDecimal pa = numeric(a, "delta");
+                BigDecimal pb = numeric(b, "delta");
+                if (pa == null) return 1;
+                if (pb == null) return -1;
+                return pb.compareTo(pa);
+            });
         } else if ("down".equals(orderBy)) {
-            items.sort((a, b) -> ((BigDecimal) a.get("delta")).compareTo((BigDecimal) b.get("delta")));
-        } else {
-            items.sort((a, b) -> ((BigDecimal) b.get("delta")).abs()
-                    .compareTo(((BigDecimal) a.get("delta")).abs()));
+            items.sort((a, b) -> {
+                BigDecimal pa = numeric(a, "delta");
+                BigDecimal pb = numeric(b, "delta");
+                if (pa == null) return 1;
+                if (pb == null) return -1;
+                return pa.compareTo(pb);
+            });
         }
 
         int limitVal = limit != null && limit > 0 ? limit : 50;
@@ -217,132 +471,13 @@ public class IndicatorTrendService {
         overall.put("improvedCount", improved);
         overall.put("worsenedCount", worsened);
         overall.put("flatCount", flat);
+        overall.put("incomparableCount", items.stream()
+                .filter(it -> Boolean.FALSE.equals(it.get("comparable")))
+                .count());
         data.put("overall", overall);
         return data;
     }
 
-    /** 该成员已有指标时序的报告类型(indicator_values.source_type 去重,曲线页类型筛选动态来源) */
-    public List<Map<String, Object>> listSourceTypes(Long subjectId) {
-        List<Map<String, Object>> items = new ArrayList<>();
-        if (subjectId == null) {
-            return items;
-        }
-        List<IndicatorValue> rows = valueMapper.selectList(new LambdaQueryWrapper<IndicatorValue>()
-                .eq(IndicatorValue::getSubjectId, subjectId)
-                .isNotNull(IndicatorValue::getSourceType)
-                .isNotNull(IndicatorValue::getReportDate)
-                .select(IndicatorValue::getSourceType));
-        Set<String> types = new LinkedHashSet<>();
-        for (IndicatorValue v : rows) {
-            String t = v.getSourceType();
-            if (t != null && !t.trim().isEmpty()) {
-                types.add(t.trim());
-            }
-        }
-        for (String t : types) {
-            Map<String, Object> m = new LinkedHashMap<>();
-            m.put("sourceType", t);
-            m.put("label", sourceTypeLabel(t));
-            items.add(m);
-        }
-        return items;
-    }
-
-    private String sourceTypeLabel(String t) {
-        if (t == null) {
-            return t;
-        }
-        if (t.startsWith("gut_")) {
-            return "肠道菌群";
-        }
-        if ("food_recommend".equals(t)) {
-            return "食材推荐";
-        }
-        if (t.startsWith("dan_")) {
-            return "DAN 测评";
-        }
-        if ("physical_exam".equals(t)) {
-            return "体检";
-        }
-        if ("tongue".equals(t)) {
-            return "舌诊";
-        }
-        return t;
-    }
-
-    /** 指标速查(设计 6.3):分类分页 + 五维/七维评分小节 */
-    public Map<String, Object> quickLookup(Long subjectId, String category, Integer page,
-                                           Integer pageSize, Long familyId) {
-        Map<String, Object> data = new LinkedHashMap<>();
-        int pageVal = page != null && page > 0 ? page : 1;
-        int sizeVal = pageSize != null && pageSize > 0 ? pageSize : 30;
-
-        // 该成员全部观测(按时序取最新一条)
-        List<IndicatorValue> rows = valueMapper.selectList(new LambdaQueryWrapper<IndicatorValue>()
-                .eq(IndicatorValue::getSubjectId, subjectId)
-                .isNotNull(IndicatorValue::getDefinitionId));
-        Map<Long, List<IndicatorValue>> byDef = rows.stream()
-                .collect(Collectors.groupingBy(IndicatorValue::getDefinitionId));
-        Map<Long, IndicatorDefinition> defCache = new HashMap<>();
-        List<Map<String, Object>> allItems = new ArrayList<>();
-
-        for (Map.Entry<Long, List<IndicatorValue>> e : byDef.entrySet()) {
-            IndicatorDefinition def = defCache.computeIfAbsent(e.getKey(),
-                    id -> definitionMapper.selectById(id));
-            if (def == null) {
-                continue;
-            }
-            if (category != null && !category.isEmpty() && !category.equals(def.getCategory())) {
-                continue;
-            }
-            List<IndicatorValue> sorted = e.getValue().stream()
-                    .sorted((a, b) -> a.getReportDate() == null ? -1
-                            : (b.getReportDate() == null ? 1 : b.getReportDate().compareTo(a.getReportDate())))
-                    .collect(Collectors.toList());
-            IndicatorValue latest = sorted.get(0);
-            Map<String, Object> item = new LinkedHashMap<>();
-            item.put("definitionId", e.getKey());
-            item.put("name", def.getName());
-            item.put("category", def.getCategory());
-            item.put("value", latest.getValue());
-            item.put("unit", latest.getUnit() != null ? latest.getUnit() : def.getUnit());
-            item.put("refRange", def.getRefRange());
-            item.put("status", latest.getStatus());
-            item.put("trend", trendOf(sorted));
-            item.put("trendLabel", trendLabel(def, trendOf(sorted), deltaOf(sorted)));
-            item.put("latestReportDate", latest.getReportDate() != null
-                    ? new SimpleDateFormat("yyyy-MM-dd").format(latest.getReportDate()) : null);
-            item.put("pointsCount", sorted.size());
-            allItems.add(item);
-        }
-
-        // categories 汇总(含 total)
-        List<Map<String, Object>> categories = new ArrayList<>();
-        Map<String, Long> catCount = allItems.stream()
-                .collect(Collectors.groupingBy(i -> (String) i.get("category"), Collectors.counting()));
-        catCount.forEach((name, total) -> {
-            Map<String, Object> c = new LinkedHashMap<>();
-            c.put("name", name);
-            c.put("total", total);
-            categories.add(c);
-        });
-
-        // 分页
-        int fromIndex = Math.min((pageVal - 1) * sizeVal, allItems.size());
-        int toIndex = Math.min(fromIndex + sizeVal, allItems.size());
-        List<Map<String, Object>> pageItems = allItems.isEmpty()
-                ? new ArrayList<>()
-                : new ArrayList<>(allItems.subList(fromIndex, toIndex));
-
-        data.put("categories", categories);
-        data.put("items", pageItems);
-        data.put("page", pageVal);
-        data.put("pageSize", sizeVal);
-        data.put("total", allItems.size());
-        data.put("dimensionScores", loadDimensionScores(subjectId));
-        return data;
-    }
-
     /** 五维/七维评分小节(不写 indicator_values,API 层 join 原表) */
     private List<Map<String, Object>> loadDimensionScores(Long subjectId) {
         List<Map<String, Object>> result = new ArrayList<>();
@@ -393,54 +528,32 @@ public class IndicatorTrendService {
         return d != null ? new SimpleDateFormat("yyyy-MM-dd").format(d) : null;
     }
 
-    /** 序列趋势:末尾两条比较 */
-    private String trendOf(List<IndicatorValue> sorted) {
-        if (sorted.size() < 2) {
-            return "flat";
-        }
-        IndicatorValue latest = sorted.get(0);
-        IndicatorValue prev = sorted.get(1);
-        if (latest.getNumericValue() == null || prev.getNumericValue() == null) {
-            return "flat";
+    private String sourceTypeLabel(String t) {
+        if (t == null) {
+            return t;
         }
-        int cmp = latest.getNumericValue().compareTo(prev.getNumericValue());
-        return cmp > 0 ? "up" : (cmp < 0 ? "down" : "flat");
-    }
-
-    private BigDecimal deltaOf(List<IndicatorValue> sorted) {
-        if (sorted.size() < 2) {
-            return BigDecimal.ZERO;
+        if (t.startsWith("gut_")) {
+            return "肠道菌群";
         }
-        IndicatorValue latest = sorted.get(0);
-        IndicatorValue prev = sorted.get(1);
-        if (latest.getNumericValue() == null || prev.getNumericValue() == null) {
-            return BigDecimal.ZERO;
+        if ("food_recommend".equals(t)) {
+            return "食材推荐";
         }
-        return latest.getNumericValue().subtract(prev.getNumericValue());
-    }
-
-    /**
-     * 变化着色/标签规则(设计第 8 节):
-     * good_direction × 数值变化方向 → 改善/恶化/中性。
-     */
-    private String trendLabel(IndicatorDefinition def, String movement, BigDecimal delta) {
-        if ("flat".equals(movement) || delta == null || delta.compareTo(BigDecimal.ZERO) == 0) {
-            return "中性";
+        if (t.startsWith("dan_")) {
+            return "DAN 测评";
         }
-        if (def == null || def.getGoodDirection() == null) {
-            return "中性";
+        if ("physical_exam".equals(t)) {
+            return "体检";
         }
-        boolean up = "up".equals(movement);
-        switch (def.getGoodDirection()) {
-            case "higher_is_better":
-                return up ? "改善" : "恶化";
-            case "lower_is_better":
-                return up ? "恶化" : "改善";
-            case "in_range_is_better":
-                // 一期简化:无 refRange 解析,回落为中性
-                return "中性";
-            default:
-                return "中性";
+        if ("tongue".equals(t)) {
+            return "舌诊";
         }
+        return t;
+    }
+
+    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;
     }
 }

+ 108 - 0
cfc-backend/src/test/java/com/etotem/cfc/controller/IndicatorControllerTest.java

@@ -0,0 +1,108 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.service.IndicatorChangeService;
+import com.etotem.cfc.service.IndicatorCorrelationService;
+import com.etotem.cfc.service.IndicatorTimelineService;
+import com.etotem.cfc.service.IndicatorTrendService;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 仅做 API 边界契约单测(无 Spring 上下文):
+ * <ul>
+ *   <li>验收 #3:scope=pair 缺 fromReportDate/toReportDate → 400 明确错误码,不触及 service</li>
+ *   <li>验收 #8:健康报告指标对照接口返回 Result(权限校验由控制器负责,此处只验证参数校验短路)</li>
+ * </ul>
+ */
+@ExtendWith(MockitoExtension.class)
+class IndicatorControllerTest {
+
+    @Mock
+    private IndicatorTrendService indicatorTrendService;
+
+    @Mock
+    private IndicatorTimelineService indicatorTimelineService;
+
+    @Mock
+    private IndicatorCorrelationService indicatorCorrelationService;
+
+    @Mock
+    private FamilyMemberMapper familyMemberMapper;
+
+    @InjectMocks
+    private IndicatorController controller;
+
+    private static Map<String, Object> bodyWith(Long subjectId, Object... kv) {
+        Map<String, Object> m = new HashMap<>();
+        m.put("subjectId", subjectId);
+        if (kv != null) {
+            for (int i = 0; i + 1 < kv.length; i += 2) {
+                m.put(String.valueOf(kv[i]), kv[i + 1]);
+            }
+        }
+        return m;
+    }
+
+    @Test
+    void changeRanking_scopePairMissingDates_returns400AndDoesNotCallService() {
+        FamilyMember member = new FamilyMember();
+        member.setId(88L);
+        member.setFamilyId(7L);
+        when(familyMemberMapper.selectOne(any())).thenReturn(member);
+
+        Map<String, Object> params = bodyWith(88L, "scope", "pair"); // 缺 fromReportDate/toReportDate
+
+        Result<?> result = controller.changeRanking(params, 7L);
+
+        assertEquals(Integer.valueOf(400), result.getCode());
+        assertTrue(result.getMessage().contains("scope=pair"));
+        assertTrue(result.getMessage().contains("fromReportDate"));
+        assertTrue(result.getMessage().contains("toReportDate"));
+        verify(indicatorTrendService, never()).changeRanking(any(), any(), any(),
+                anyString(), anyString(), any(), any(), any(), any(), any(), any(), any());
+    }
+
+    @Test
+    void changeRanking_scopePairAllParams_ok() {
+        FamilyMember member = new FamilyMember();
+        member.setId(88L);
+        member.setFamilyId(7L);
+        when(familyMemberMapper.selectOne(any())).thenReturn(member);
+
+        Map<String, Object> params = bodyWith(88L,
+                "scope", "pair",
+                "fromReportDate", "2026-01-01",
+                "toReportDate", "2026-10-01",
+                "orderBy", "magnitude",
+                "filter", "all",
+                "page", 1,
+                "pageSize", 50);
+        when(indicatorTrendService.changeRanking(any(), any(), any(), eq("pair"), anyString(), any(),
+                any(), anyString(), anyString(), anyString(), any(), any()))
+                .thenReturn(new HashMap<>());
+
+        Result<?> result = controller.changeRanking(params, 7L);
+
+        assertEquals(Integer.valueOf(200), result.getCode());
+        verify(indicatorTrendService).changeRanking(eq(88L), any(), any(), eq("pair"),
+                eq("magnitude"), eq(50), eq(7L), eq("2026-01-01"), eq("2026-10-01"), eq("all"), eq(1), eq(50));
+    }
+}

+ 273 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/HealthReportServiceIndicatorsTest.java

@@ -0,0 +1,273 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.etotem.cfc.entity.HealthIndicator;
+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.IndicatorValueMapper;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.math.BigDecimal;
+import java.sql.Date;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyCollection;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.*;
+
+/**
+ * 报告指标接口(§4.1 /api/health/report/indicators)服务层单测。
+ *
+ * <p>覆盖验收标准:
+ * <ul>
+ *   <li>#1:三档形态数据齐全(有上一期 / 首次记录 / 历史数据整理中)</li>
+ *   <li>#6:单报告 42 项指标时 SQL 查询次数 ≤ 5(无 N+1)</li>
+ * </ul>
+ */
+class HealthReportServiceIndicatorsTest {
+
+    private HealthReportService service;
+    private HealthReportMapper healthReportMapper;
+    private IndicatorMappingService mappingService;
+    private IndicatorChangeService changeService;
+    private IndicatorValueMapper valueMapper;
+
+    @BeforeEach
+    void setUp() {
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""),
+                HealthReport.class);
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""),
+                IndicatorValue.class);
+
+        service = new HealthReportService();
+        healthReportMapper = mock(HealthReportMapper.class);
+        mappingService = mock(IndicatorMappingService.class);
+        valueMapper = mock(IndicatorValueMapper.class);
+        // 真实 changeService(computeChange 走真实计算)+ spy 拦截 batchResolve 的查库
+        IndicatorChangeService realChange = new IndicatorChangeService();
+        ReflectionTestUtils.setField(realChange, "valueMapper", valueMapper);
+        changeService = spy(realChange);
+
+        ReflectionTestUtils.setField(service, "healthReportMapper", healthReportMapper);
+        ReflectionTestUtils.setField(service, "mappingService", mappingService);
+        ReflectionTestUtils.setField(service, "changeService", changeService);
+        ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper());
+    }
+
+    // ---------- 工具 ----------
+
+    private HealthReport report() {
+        HealthReport r = new HealthReport();
+        r.setId(12345L);
+        r.setSubjectId(88L);
+        r.setReportType("gut_flora");
+        r.setReportDate(Date.valueOf("2026-10-08"));
+        r.setStatus("active");
+        r.setPayloadJson("{\"indicators\":[{\"indicatorName\":\"低密度脂蛋白 LDL-C\"},"
+                + "{\"indicatorName\":\"白细胞计数\"},{\"indicatorName\":\"未知指标 X\"}]}");
+        return r;
+    }
+
+    private HealthIndicator indicator(Long id, String name, String value, String status) {
+        HealthIndicator hi = new HealthIndicator();
+        hi.setId(id);
+        hi.setReportId(12345L);
+        hi.setIndicatorName(name);
+        hi.setIndicatorValue(value);
+        hi.setStatus(status);
+        hi.setUnit("mmol/L");
+        hi.setCategory("血脂");
+        hi.setRefRange("0.00-3.37");
+        return hi;
+    }
+
+    private IndicatorMappingService.ResolvedMapping resolved(String rawName, Long defId, String defName) {
+        IndicatorMappingService.ResolvedMapping rm = new IndicatorMappingService.ResolvedMapping();
+        rm.setRawName(rawName);
+        rm.setDefinitionId(defId);
+        rm.setDefId(defId);
+        rm.setDefName(defName);
+        rm.setDefGoodDirection("lower_is_better");
+        rm.setDefValueType("numeric");
+        rm.setDefUnit("mmol/L");
+        rm.setDefRefRange("0.00-3.37");
+        rm.setDefStatus(1);
+        rm.setRuleStatus(1);
+        rm.setPriority(0);
+        return rm;
+    }
+
+    @SuppressWarnings("unchecked")
+    private List<Map<String, Object>> itemsOf(Map<String, Object> data) {
+        return (List<Map<String, Object>>) data.get("items");
+    }
+
+    // ==================== #1 三档形态 ====================
+
+    /**
+     * 三档形态齐备:档 A(有上一期 → improved)/ 档 B2(无上一期但有更早报告 →
+     * pending_backfill)/ 未命中字典(previous 与 change 恒 null)。
+     */
+    @Test
+    void reportIndicators_threeTiersPresent() {
+        HealthReport r = report();
+        List<HealthIndicator> indicators = Arrays.asList(
+                indicator(9001L, "低密度脂蛋白 LDL-C", "3.62", "偏高"),   // 有上一期 → improved
+                indicator(9002L, "白细胞计数", "5.20", "正常"),           // 命中字典但无上一期 → pending_backfill
+                indicator(9003L, "未知指标 X", "+", "异常"));              // 未命中字典 → 无对比
+
+        // 映射:LDL 与白细胞命中字典,未知指标 X 未命中
+        Map<String, IndicatorMappingService.ResolvedMapping> resolvedMap = new HashMap<>();
+        resolvedMap.put("低密度脂蛋白 LDL-C", resolved("低密度脂蛋白 LDL-C", 512L, "低密度脂蛋白 LDL-C"));
+        resolvedMap.put("白细胞计数", resolved("白细胞计数", 513L, "白细胞计数"));
+        when(mappingService.resolveBatch(anyString(), anyCollection())).thenReturn(resolvedMap);
+
+        // 上一期:LDL 有上一期值;白细胞无
+        List<IndicatorValue> prevRows = new ArrayList<>();
+        IndicatorValue prev = new IndicatorValue();
+        prev.setDefinitionId(512L);
+        prev.setReportId(11800L);
+        prev.setReportDate(Date.valueOf("2026-08-12"));
+        prev.setValue("4.10");
+        prev.setNumericValue(new BigDecimal("4.10"));
+        prev.setUnit("mmol/L");
+        prev.setStatus("偏高");
+        prevRows.add(prev);
+        Map<Long, IndicatorValue> prevMap = new HashMap<>();
+        prevMap.put(512L, prev);
+        doReturn(prevMap).when(changeService).batchResolve(any(), any(), any(), any());
+
+        // 更早报告 3 份 → olderReportCount=3(白细胞无上一期 → pending_backfill)
+        List<HealthReport> older = new ArrayList<>();
+        for (int i = 0; i < 3; i++) {
+            HealthReport o = new HealthReport();
+            o.setId(11000L + i);
+            o.setReportDate(Date.valueOf("2026-0" + (i + 1) + "-01"));
+            older.add(o);
+        }
+        when(healthReportMapper.selectList(any())).thenReturn(older);
+
+        Map<String, Object> data = service.getReportIndicatorsWithPrevious(r, indicators, true);
+
+        assertEquals("2026-10-08", data.get("reportDate"));
+        assertEquals(3L, ((Number) data.get("olderReportCount")).longValue());
+
+        List<Map<String, Object>> items = itemsOf(data);
+        assertEquals(3, items.size());
+
+        // 档 A:有上一期 → improved(LDL 从 4.10 降到 3.62,lower_is_better)
+        Map<String, Object> ldl = items.get(0);
+        assertEquals(512L, ((Number) ldl.get("definitionId")).longValue());
+        assertNotNull(ldl.get("previous"));
+        assertEquals(11800L, ((Number) ((Map<String, Object>) ldl.get("previous")).get("reportId")).longValue());
+        @SuppressWarnings("unchecked")
+        Map<String, Object> ldlChange = (Map<String, Object>) ldl.get("change");
+        assertEquals("improved", ldlChange.get("trendCode"));
+        assertEquals("改善", ldlChange.get("trendLabel"));
+        assertEquals(-0.48, ((BigDecimal) ldlChange.get("delta")).doubleValue(), 1e-9);
+        assertEquals(true, ldlChange.get("comparable"));
+
+        // 档 B1/B2 分界:命中字典但无上一期 + 有更早报告 → pending_backfill(诚实降级,不是"首次记录")
+        Map<String, Object> wbc = items.get(1);
+        assertEquals(513L, ((Number) wbc.get("definitionId")).longValue());
+        assertNull(wbc.get("previous"));
+        @SuppressWarnings("unchecked")
+        Map<String, Object> wbcChange = (Map<String, Object>) wbc.get("change");
+        assertEquals("pending_backfill", wbcChange.get("trendCode"));
+        assertEquals("历史数据整理中,暂无法对比", wbcChange.get("trendLabel"));
+        assertEquals(false, wbcChange.get("comparable"));
+
+        // 未命中字典:definitionId / previous / change 全为 null,前端按"无对比"渲染
+        Map<String, Object> unknown = items.get(2);
+        assertNull(unknown.get("definitionId"), "未命中字典的指标 definitionId 为 null");
+        assertNull(unknown.get("previous"));
+        assertNull(unknown.get("change"), "未命中字典时无对比对象");
+
+        // summary
+        @SuppressWarnings("unchecked")
+        Map<String, Object> summary = (Map<String, Object>) data.get("summary");
+        assertEquals(3, ((Number) summary.get("totalCount")).intValue());
+        assertEquals(1, ((Number) summary.get("changedCount")).intValue());
+        assertEquals(2, ((Number) summary.get("abnormalCount")).intValue());
+        assertEquals(1, ((Number) summary.get("comparableCount")).intValue());
+        assertEquals(0, ((Number) summary.get("firstRecordCount")).intValue());
+        assertEquals(1, ((Number) summary.get("pendingCount")).intValue());
+        assertEquals(true, summary.get("dataIncomplete"));
+    }
+
+    @Test
+    void reportIndicators_firstRecordWhenNoOlderReports() {
+        HealthReport r = report();
+        r.setPayloadJson("{\"indicators\":[{\"indicatorName\":\"载脂蛋白 A1\"}]}");
+        List<HealthIndicator> indicators = Arrays.asList(
+                indicator(9002L, "载脂蛋白 A1", "1.45", "正常"));
+
+        Map<String, IndicatorMappingService.ResolvedMapping> resolvedMap = new HashMap<>();
+        resolvedMap.put("载脂蛋白 A1", resolved("载脂蛋白 A1", 513L, "载脂蛋白 A1"));
+        when(mappingService.resolveBatch(anyString(), anyCollection())).thenReturn(resolvedMap);
+        doReturn(new HashMap<Long, IndicatorValue>()).when(changeService).batchResolve(any(), any(), any(), any());
+        // 无更早报告 → olderReportCount=0 → first_record
+        when(healthReportMapper.selectList(any())).thenReturn(new ArrayList<>());
+
+        Map<String, Object> data = service.getReportIndicatorsWithPrevious(r, indicators, true);
+        Map<String, Object> item = itemsOf(data).get(0);
+        @SuppressWarnings("unchecked")
+        Map<String, Object> change = (Map<String, Object>) item.get("change");
+        assertEquals("first_record", change.get("trendCode"));
+        assertEquals("首次记录 · 暂无对比", change.get("trendLabel"));
+        assertEquals("none", change.get("movement"));
+        assertNull(item.get("previous"));
+        assertNotNull(change, "B1 档的 change 也必须是非 null 对象(前端只消费 trendCode)");
+
+        @SuppressWarnings("unchecked")
+        Map<String, Object> summary = (Map<String, Object>) data.get("summary");
+        assertEquals(1, ((Number) summary.get("firstRecordCount")).intValue());
+        assertEquals(0, ((Number) summary.get("pendingCount")).intValue());
+        assertEquals(false, summary.get("dataIncomplete"));
+    }
+
+    // ==================== #6 SQL ≤ 5 ====================
+
+    @Test
+    void reportIndicators_sqlCountIsConstant_noNPlusOne() {
+        HealthReport r = report();
+        // 构造 42 项指标(单报告指标数实测上限约 60)
+        List<HealthIndicator> indicators = new ArrayList<>();
+        Map<String, IndicatorMappingService.ResolvedMapping> resolvedMap = new HashMap<>();
+        for (int i = 0; i < 42; i++) {
+            String name = "指标" + i;
+            indicators.add(indicator(9000L + i, name, "1.00", "正常"));
+            resolvedMap.put(name, resolved(name, 500L + i, name));
+        }
+        when(mappingService.resolveBatch(anyString(), anyCollection())).thenReturn(resolvedMap);
+        doReturn(new HashMap<Long, IndicatorValue>()).when(changeService).batchResolve(any(), any(), any(), any());
+        when(healthReportMapper.selectList(any())).thenReturn(new ArrayList<>());
+
+        Map<String, Object> data = service.getReportIndicatorsWithPrevious(r, indicators, true);
+
+        // 服务层(报告与指标行由控制器加载后传入)固定 3 次查询,与指标条数无关:
+        //   ① health_reports(olderReportCount)
+        //   ② indicator_mapping_rules + indicator_definitions 联合查询(resolveBatch)
+        //   ③ indicator_values(batchResolve 上一期)
+        verify(healthReportMapper, times(1)).selectList(any());
+        verify(mappingService, times(1)).resolveBatch(anyString(), anyCollection());
+        verify(changeService, times(1)).batchResolve(any(), any(), any(), any());
+        // 服务层自身绝不碰 indicator_values 表:全部观测访问必须经由 changeService 批量入口
+        verify(valueMapper, never()).selectList(any());
+
+        assertEquals(42, itemsOf(data).size(), "42 项指标必须全部返回");
+    }
+}

+ 419 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/IndicatorChangeServiceSemanticTest.java

@@ -0,0 +1,419 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.etotem.cfc.entity.IndicatorDefinition;
+import com.etotem.cfc.entity.IndicatorValue;
+import com.etotem.cfc.mapper.IndicatorValueMapper;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.math.BigDecimal;
+import java.sql.Date;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * 指标变化语义回归测试(T02 验收标准第 7 条 · §1.2.3 的 20 条语义用例)。
+ *
+ * <p><b>为什么必须固化</b>:`goodDirection` / `refRange` 是<b>字典配置驱动</b>的,
+ * 后续运营改字典会静默改变判定结果。一次性人工核验挡不住回归 ——
+ * 本文件是这 20 条的可重复执行版本,任何对 {@code computeChange} 或字典的改动都会跑它。
+ *
+ * <p><b>覆盖矩阵</b>:
+ * <ul>
+ *   <li>全部 5 个 trendCode 分支:improved / worsened / flat / first_record / pending_backfill</li>
+ *   <li>in_range 四分支:区外→区内(improved) / 区内→区外(worsened) / 都在区内(flat) / 都在区外(靠近=improved)</li>
+ *   <li>微小基数(deltaPct=null 但 delta 有值)</li>
+ *   <li>基数为 0(deltaPct=null)</li>
+ *   <li>单位跨期不一致(deltaPct=null + unitMismatch=true)</li>
+ *   <li>非数值(movement=changed/unchanged,trendCode 恒 flat,不给颜色)</li>
+ *   <li>跨期跳比(skipGaps=2)与相邻两期(skipGaps=0)</li>
+ * </ul>
+ *
+ * <p><b>放行标准</b>:20 条全绿。方向判反(把恶化标成改善)0 容忍。
+ */
+class IndicatorChangeServiceSemanticTest {
+
+    private IndicatorChangeService service;
+    private IndicatorValueMapper valueMapper;
+
+    @BeforeEach
+    void setUp() {
+        // LambdaQueryWrapper.select(SFunction) 需要实体元数据(MP 3.5.3 lambda cache),
+        // 无 Spring 容器时必须手动初始化,否则抛 "can not find lambda cache for this entity"
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""),
+                IndicatorValue.class);
+        service = new IndicatorChangeService();
+        valueMapper = mock(IndicatorValueMapper.class);
+        ReflectionTestUtils.setField(service, "valueMapper", valueMapper);
+    }
+
+    // ---------- 工具 ----------
+
+    private IndicatorValue num(String val, String unit, String status, String refSnapshot) {
+        IndicatorValue v = new IndicatorValue();
+        v.setValue(val);
+        v.setNumericValue(new BigDecimal(val));
+        v.setUnit(unit);
+        v.setStatus(status);
+        v.setRefRangeSnapshot(refSnapshot);
+        v.setReportDate(Date.valueOf("2026-10-01"));
+        return v;
+    }
+
+    private IndicatorValue text(String val, String status) {
+        IndicatorValue v = new IndicatorValue();
+        v.setValue(val);
+        v.setNumericValue(null);
+        v.setStatus(status);
+        v.setReportDate(Date.valueOf("2026-10-01"));
+        return v;
+    }
+
+    private IndicatorDefinition def(String goodDirection, String valueType, String refRange, String unit) {
+        IndicatorDefinition d = new IndicatorDefinition();
+        d.setGoodDirection(goodDirection);
+        d.setValueType(valueType);
+        d.setRefRange(refRange);
+        d.setUnit(unit);
+        return d;
+    }
+
+    // ==================================================================
+    // 语义用例 01-05:明确 goodDirection 的方向判定(改善 / 恶化)
+    // ==================================================================
+
+    /**
+     * 语义 01:LDL-C 降 → improved(lower_is_better)。
+     */
+    @Test
+    void semantic01_ldlC_decrease_isImproved() {
+        IndicatorDefinition d = def("lower_is_better", "numeric", "0.00-3.37", "mmol/L");
+        IndicatorChangeService.ChangeResult r = service.computeChange(d,
+                num("4.10", "mmol/L", "偏高", "0.00-3.37"),
+                num("3.62", "mmol/L", "偏高", "0.00-3.37"));
+        assertEquals("improved", r.getTrendCode());
+        assertEquals("改善", r.getTrendLabel());
+        assertEquals("down", r.getMovement());
+        assertEquals(-0.48, r.getDelta().doubleValue(), 1e-9);
+        assertEquals(-11.7, r.getDeltaPct().doubleValue(), 1e-9);
+        assertTrue(r.isComparable());
+    }
+
+    /**
+     * 语义 02:HDL-C 升 → improved(higher_is_better)。
+     */
+    @Test
+    void semantic02_hdlC_increase_isImproved() {
+        IndicatorDefinition d = def("higher_is_better", "numeric", "1.00-1.60", "mmol/L");
+        IndicatorChangeService.ChangeResult r = service.computeChange(d,
+                num("1.20", "mmol/L", "正常", "1.00-1.60"),
+                num("1.45", "mmol/L", "正常", "1.00-1.60"));
+        assertEquals("improved", r.getTrendCode());
+        assertEquals("改善", r.getTrendLabel());
+        assertEquals("up", r.getMovement());
+    }
+
+    /**
+     * 语义 03:血糖 升 → worsened(lower_is_better)。
+     */
+    @Test
+    void semantic03_glucose_increase_isWorsened() {
+        IndicatorDefinition d = def("lower_is_better", "numeric", "3.90-6.10", "mmol/L");
+        IndicatorChangeService.ChangeResult r = service.computeChange(d,
+                num("5.20", "mmol/L", "正常", "3.90-6.10"),
+                num("6.50", "mmol/L", "偏高", "3.90-6.10"));
+        assertEquals("worsened", r.getTrendCode());
+        assertEquals("恶化", r.getTrendLabel());
+        assertEquals("up", r.getMovement());
+    }
+
+    /**
+     * 语义 04:higher_is_better 指标下降 → worsened(反向分支,防止字典方向写反)。
+     */
+    @Test
+    void semantic04_higherIsBetter_decrease_isWorsened() {
+        IndicatorDefinition d = def("higher_is_better", "numeric", "1.00-5.00", "U");
+        IndicatorChangeService.ChangeResult r = service.computeChange(d,
+                num("4.00", "U", "正常", "1.00-5.00"),
+                num("2.00", "U", "偏低", "1.00-5.00"));
+        assertEquals("worsened", r.getTrendCode());
+        assertEquals("恶化", r.getTrendLabel());
+        assertEquals("down", r.getMovement());
+    }
+
+    /**
+     * 语义 05:同型半胱氨酸 降 → improved(lower_is_better)。
+     */
+    @Test
+    void semantic05_homocysteine_decrease_isImproved() {
+        IndicatorDefinition d = def("lower_is_better", "numeric", "<15.0", "μmol/L");
+        IndicatorChangeService.ChangeResult r = service.computeChange(d,
+                num("12.0", "μmol/L", "正常", "<15.0"),
+                num("9.8", "μmol/L", "正常", "<15.0"));
+        assertEquals("improved", r.getTrendCode());
+        assertEquals("改善", r.getTrendLabel());
+        assertEquals("down", r.getMovement());
+    }
+
+    // ==================================================================
+    // 语义用例 06-10:in_range_is_better 四分支(修 PRD S10:不再恒为中性)
+    // ==================================================================
+
+    /**
+     * 语义 06:BMI 区间内移动 → flat(in_range 四分支之"都在区内")。
+     */
+    @Test
+    void semantic06_bmi_moveWithinRange_isFlat() {
+        IndicatorDefinition d = def("in_range_is_better", "numeric", "18.50-24.00", "kg/m²");
+        IndicatorChangeService.ChangeResult r = service.computeChange(d,
+                num("22.00", "kg/m²", "正常", "18.50-24.00"),
+                num("22.60", "kg/m²", "正常", "18.50-24.00"));
+        assertEquals("flat", r.getTrendCode());
+        assertEquals("基本持平", r.getTrendLabel());
+        assertEquals("up", r.getMovement());
+    }
+
+    /**
+     * 语义 07:BMI 入区间 → improved(in_range 四分支之"区外→区内")。
+     */
+    @Test
+    void semantic07_bmi_enterRange_isImproved() {
+        IndicatorDefinition d = def("in_range_is_better", "numeric", "18.50-24.00", "kg/m²");
+        IndicatorChangeService.ChangeResult r = service.computeChange(d,
+                num("25.50", "kg/m²", "偏高", "18.50-24.00"),
+                num("23.20", "kg/m²", "正常", "18.50-24.00"));
+        assertEquals("improved", r.getTrendCode());
+        assertEquals("改善", r.getTrendLabel());
+    }
+
+    /**
+     * 语义 08:BMI 出区间 → worsened(in_range 四分支之"区内→区外")。
+     */
+    @Test
+    void semantic08_bmi_exitRange_isWorsened() {
+        IndicatorDefinition d = def("in_range_is_better", "numeric", "18.50-24.00", "kg/m²");
+        IndicatorChangeService.ChangeResult r = service.computeChange(d,
+                num("23.20", "kg/m²", "正常", "18.50-24.00"),
+                num("25.50", "kg/m²", "偏高", "18.50-24.00"));
+        assertEquals("worsened", r.getTrendCode());
+        assertEquals("恶化", r.getTrendLabel());
+    }
+
+    /**
+     * 语义 09:白细胞 升 → worsened(in_range,出区间上界)。
+     */
+    @Test
+    void semantic09_whiteBloodCell_exitAboveUpper_isWorsened() {
+        IndicatorDefinition d = def("in_range_is_better", "numeric", "3.50-9.50", "10^9/L");
+        IndicatorChangeService.ChangeResult r = service.computeChange(d,
+                num("8.20", "10^9/L", "正常", "3.50-9.50"),
+                num("11.30", "10^9/L", "偏高", "3.50-9.50"));
+        assertEquals("worsened", r.getTrendCode());
+        assertEquals("恶化", r.getTrendLabel());
+        assertEquals("up", r.getMovement());
+    }
+
+    /**
+     * 语义 10:in_range 始终在区外但靠近区间 → improved(in_range 四分支之"都在区外·靠近")。
+     */
+    @Test
+    void semantic10_inRange_alwaysOutside_closer_isImproved() {
+        IndicatorDefinition d = def("in_range_is_better", "numeric", "18.50-24.00", "kg/m²");
+        IndicatorChangeService.ChangeResult r = service.computeChange(d,
+                num("26.50", "kg/m²", "偏高", "18.50-24.00"),
+                num("25.00", "kg/m²", "偏高", "18.50-24.00"));
+        assertEquals("improved", r.getTrendCode());
+        assertEquals("改善", r.getTrendLabel());
+    }
+
+    // ==================================================================
+    // 语义用例 11-14:降级路径(微小基数 / 基数为 0 / 单位不一致 / 非数值)
+    // ==================================================================
+
+    /**
+     * 语义 11:微小基数(0.01→0.05)→ deltaPct=null(排最后),但 delta 照常有值。
+     */
+    @Test
+    void semantic11_tinyBase_deltaPctNull_butDeltaPresent() {
+        // refHigh=3.37 → floor=0.0337;before=0.01 低于护栏 → 百分比不适用
+        IndicatorDefinition d = def("higher_is_better", "numeric", "0.00-3.37", "mmol/L");
+        IndicatorChangeService.ChangeResult r = service.computeChange(d,
+                num("0.01", "mmol/L", "正常", "0.00-3.37"),
+                num("0.05", "mmol/L", "正常", "0.00-3.37"));
+        assertNull(r.getDeltaPct(), "微小基数的百分比必须为 null(前端显示 —)");
+        assertNotNull(r.getDelta(), "微小基数的 Δ 绝对值必须保留(用户仍需知道涨跌)");
+        assertEquals(0.04, r.getDelta().doubleValue(), 1e-9);
+        assertEquals("up", r.getMovement(), "箭头方向不受 FLOOR 影响");
+        assertNull(service.rankKeyOf(r), "deltaPct=null → 排序键为 null → 恒排最后");
+    }
+
+    /**
+     * 语义 12:基数为 0 → deltaPct=null。
+     */
+    @Test
+    void semantic12_zeroBase_deltaPctNull() {
+        IndicatorDefinition d = def("lower_is_better", "numeric", "0.00-3.37", "mmol/L");
+        IndicatorChangeService.ChangeResult r = service.computeChange(d,
+                num("0.00", "mmol/L", "正常", "0.00-3.37"),
+                num("1.00", "mmol/L", "正常", "0.00-3.37"));
+        assertNull(r.getDeltaPct(), "基数为 0 时百分比无意义");
+        assertEquals(1.0, r.getDelta().doubleValue(), 1e-9);
+        assertEquals("up", r.getMovement());
+        assertNull(service.rankKeyOf(r));
+    }
+
+    /**
+     * 语义 13:单位跨期不一致 → deltaPct=null + unitMismatch=true(delta 保留)。
+     */
+    @Test
+    void semantic13_unitMismatch_deltaPctNull_unitMismatchTrue() {
+        IndicatorDefinition d = def("higher_is_better", "numeric", "1.00-5.00", "mg/L");
+        IndicatorChangeService.ChangeResult r = service.computeChange(d,
+                num("3.00", "mg/L", "正常", "1.00-5.00"),
+                num("3.50", "g/L", "正常", "1.00-5.00"));
+        assertTrue(r.isUnitMismatch(), "单位跨期不一致必须被识别");
+        assertNull(r.getDeltaPct(), "单位不一致时百分比不可比");
+        assertEquals(0.50, r.getDelta().doubleValue(), 1e-9, "Δ 绝对值仍保留");
+    }
+
+    /**
+     * 语义 14:非数值(舌象薄白→厚腻)→ movement=changed + trendCode=flat(不给颜色)。
+     */
+    @Test
+    void semantic14_nonNumeric_changed_trendCodeFlat_noColor() {
+        IndicatorDefinition d = def(null, "categorical", null, null);
+        IndicatorChangeService.ChangeResult r = service.computeChange(d,
+                text("薄白", "正常"), text("厚腻", "注意"));
+        assertEquals("changed", r.getMovement());
+        assertEquals("flat", r.getTrendCode(), "非数值永不标改善/恶化(合规红线)");
+        assertFalse(r.isComparable());
+        assertNull(r.getDelta());
+        assertNull(r.getDeltaPct());
+        assertEquals("有变化", r.getTrendLabel());
+    }
+
+    // ==================================================================
+    // 语义用例 15-16:非数值同值 / 持平阈值
+    // ==================================================================
+
+    /**
+     * 语义 15:非数值同值 → movement=unchanged + trendCode=flat。
+     */
+    @Test
+    void semantic15_nonNumeric_unchanged_movementUnchanged() {
+        IndicatorDefinition d = def(null, "categorical", null, null);
+        IndicatorChangeService.ChangeResult r = service.computeChange(d,
+                text("薄白", "正常"), text("薄白", "正常"));
+        assertEquals("unchanged", r.getMovement());
+        assertEquals("flat", r.getTrendCode());
+        assertFalse(r.isComparable());
+        assertEquals("无变化", r.getTrendLabel());
+    }
+
+    /**
+     * 语义 16:持平阈值(|Δ%| < 2% 且状态未变)→ flat + "基本持平"(箭头保留)。
+     */
+    @Test
+    void semantic16_belowFlatThreshold_isFlat_butKeepsArrow() {
+        IndicatorDefinition d = def("higher_is_better", "numeric", "1.00-100.00", "U");
+        IndicatorChangeService.ChangeResult r = service.computeChange(d,
+                num("100.0", "U", "正常", "1.00-100.00"),
+                num("101.0", "U", "正常", "1.00-100.00"));
+        assertEquals("flat", r.getTrendCode());
+        assertEquals("基本持平", r.getTrendLabel());
+        assertEquals("up", r.getMovement(), "持平仍保留箭头方向");
+        assertNotNull(r.getDeltaPct());
+    }
+
+    // ==================================================================
+    // 语义用例 17-18:无上一期两档(first_record / pending_backfill)
+    // ==================================================================
+
+    /**
+     * 语义 17:无上一期 + 无更早报告 → first_record。
+     */
+    @Test
+    void semantic17_noPrevious_noOlderReport_firstRecord() {
+        IndicatorChangeService.ChangeResult r = service.computeChange(null, null,
+                num("1.00", "U", "正常", null), 0);
+        assertEquals("first_record", r.getTrendCode());
+        assertEquals("首次记录 · 暂无对比", r.getTrendLabel());
+        assertEquals("none", r.getMovement());
+        assertFalse(r.isComparable());
+        assertNull(r.getDelta());
+        assertNull(r.getDeltaPct());
+    }
+
+    /**
+     * 语义 18:无上一期 + 有 3 份更早报告 → pending_backfill(诚实降级,不是"首次记录")。
+     */
+    @Test
+    void semantic18_noPrevious_has3OlderReports_pendingBackfill() {
+        IndicatorChangeService.ChangeResult r = service.computeChange(null, null,
+                num("1.00", "U", "正常", null), 3);
+        assertEquals("pending_backfill", r.getTrendCode());
+        assertEquals("历史数据整理中,暂无法对比", r.getTrendLabel());
+        assertEquals("none", r.getMovement());
+        assertFalse(r.isComparable());
+    }
+
+    // ==================================================================
+    // 语义用例 19-20:跨期跳比(skipGaps)
+    // ==================================================================
+
+    /**
+     * 语义 19:跨期跳比(中间缺 2 期)→ skipGaps=2。
+     */
+    @Test
+    void semantic19_crossPeriodSkip_twoMissingPeriods_skipGaps2() {
+        Date from = Date.valueOf("2026-03-14");
+        Date to = Date.valueOf("2026-10-08");
+        // 全部报告期:03-14 / 05-02 / 07-15 / 08-12 / 10-08(中间缺 05-02 与 07-15)
+        List<IndicatorValue> allPeriods = new ArrayList<>();
+        allPeriods.add(dateOnly(from));
+        allPeriods.add(dateOnly(Date.valueOf("2026-05-02")));
+        allPeriods.add(dateOnly(Date.valueOf("2026-07-15")));
+        allPeriods.add(dateOnly(Date.valueOf("2026-08-12")));
+        allPeriods.add(dateOnly(to));
+        // 该指标在中间有值的期:只有 08-12(1 期)→ reportsBetween=3, obsBetween=1 → skipGaps=2
+        List<IndicatorValue> obsBetween = new ArrayList<>();
+        obsBetween.add(dateOnly(Date.valueOf("2026-08-12")));
+        when(valueMapper.selectList(any())).thenReturn(allPeriods, obsBetween);
+
+        long skipGaps = service.computeSkipGaps(88L, "gut_flora", 512L, from, to);
+        assertEquals(2L, skipGaps);
+    }
+
+    /**
+     * 语义 20:相邻两期(中间无缺期)→ skipGaps=0。
+     */
+    @Test
+    void semantic20_adjacentPeriods_skipGaps0() {
+        Date from = Date.valueOf("2026-08-12");
+        Date to = Date.valueOf("2026-10-08");
+        List<IndicatorValue> allPeriods = new ArrayList<>();
+        allPeriods.add(dateOnly(from));
+        allPeriods.add(dateOnly(to));
+        List<IndicatorValue> obsBetween = new ArrayList<>();
+        when(valueMapper.selectList(any())).thenReturn(allPeriods, obsBetween);
+
+        long skipGaps = service.computeSkipGaps(88L, "gut_flora", 512L, from, to);
+        assertEquals(0L, skipGaps);
+    }
+
+    private IndicatorValue dateOnly(Date d) {
+        IndicatorValue v = new IndicatorValue();
+        v.setReportDate(d);
+        return v;
+    }
+}

+ 351 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/IndicatorTimelineServiceTest.java

@@ -0,0 +1,351 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+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.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.math.BigDecimal;
+import java.sql.Date;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * 指标时序服务单测(T02 · §5.1),用于验收标准:
+ * <ul>
+ *   <li>#2:chartMode n=0/1/2/5/25 → empty/single/pair/line/line_aggregated</li>
+ *   <li>#3:scope=pair 双期对比、缺参错误、magnitude 按 |Δ%| 降序(nulls last)</li>
+ *   <li>#8:status=0 定义在 trend/pairCompare 中被剔除</li>
+ *   <li>#9:deltaPct=null 的项恒定排最后</li>
+ * </ul>
+ */
+class IndicatorTimelineServiceTest {
+
+    private IndicatorTimelineService service;
+    private IndicatorValueMapper valueMapper;
+    private IndicatorDefinitionMapper definitionMapper;
+    private HealthReportMapper healthReportMapper;
+
+    @BeforeEach
+    void setUp() {
+        // LambdaQueryWrapper.select(SFunction) 需要实体元数据(MP 3.5.3 lambda cache)
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""),
+                IndicatorValue.class);
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""),
+                IndicatorDefinition.class);
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""),
+                HealthReport.class);
+
+        service = new IndicatorTimelineService();
+        valueMapper = mock(IndicatorValueMapper.class);
+        definitionMapper = mock(IndicatorDefinitionMapper.class);
+        healthReportMapper = mock(HealthReportMapper.class);
+
+        ReflectionTestUtils.setField(service, "valueMapper", valueMapper);
+        ReflectionTestUtils.setField(service, "definitionMapper", definitionMapper);
+        ReflectionTestUtils.setField(service, "healthReportMapper", healthReportMapper);
+
+        // 真实 changeService,注入同一组 mock mapper(保证单测不碰库)
+        IndicatorChangeService changeService = new IndicatorChangeService();
+        ReflectionTestUtils.setField(changeService, "valueMapper", valueMapper);
+        ReflectionTestUtils.setField(changeService, "definitionMapper", definitionMapper);
+        ReflectionTestUtils.setField(service, "changeService", changeService);
+    }
+
+    // ---------- 工具 ----------
+
+    private IndicatorDefinition def(Long id, String name, String goodDirection, String valueType,
+                                    String unit, String refRange) {
+        IndicatorDefinition d = new IndicatorDefinition();
+        d.setId(id);
+        d.setName(name);
+        d.setGoodDirection(goodDirection);
+        d.setValueType(valueType);
+        d.setUnit(unit);
+        d.setRefRange(refRange);
+        d.setStatus(1);
+        return d;
+    }
+
+    private IndicatorDefinition defStatus0(Long id, String name) {
+        IndicatorDefinition d = def(id, name, "higher_is_better", "numeric", "U", "1.00-5.00");
+        d.setStatus(0);
+        return d;
+    }
+
+    private IndicatorValue obs(Long defId, Long reportId, String dateStr, String val) {
+        IndicatorValue v = new IndicatorValue();
+        v.setDefinitionId(defId);
+        v.setReportId(reportId);
+        v.setReportDate(dateStr == null ? null : Date.valueOf(dateStr));
+        v.setValue(val);
+        // 非数值(如舌象「薄白」)→ numericValue 为 null
+        v.setNumericValue(toNumeric(val));
+        v.setUnit("mmol/L");
+        v.setStatus("正常");
+        v.setSourceType("gut_flora");
+        v.setDateSource("real");
+        return v;
+    }
+
+    /** 构造日期从早到晚的 n 条观测(同一个定义) */
+    private List<IndicatorValue> obsSeries(Long defId, String... dates) {
+        List<IndicatorValue> list = new ArrayList<>();
+        int i = 0;
+        for (String d : dates) {
+            list.add(obs(defId, 10000L + i, d, String.valueOf(10.0 + i * 0.5)));
+            i++;
+        }
+        return list;
+    }
+
+    private void mockDefs(IndicatorDefinition... defs) {
+        when(definitionMapper.selectList(any())).thenReturn(Arrays.asList(defs));
+    }
+
+    private void mockReports(String... dates) {
+        List<HealthReport> list = new ArrayList<>();
+        int i = 0;
+        for (String d : dates) {
+            HealthReport r = new HealthReport();
+            r.setId(1000L + i);
+            r.setReportDate(d == null ? null : Date.valueOf(d));
+            list.add(r);
+            i++;
+        }
+        when(healthReportMapper.selectList(any())).thenReturn(list);
+    }
+
+    private HealthReport report(Long id, String dateStr) {
+        HealthReport r = new HealthReport();
+        r.setId(id);
+        r.setReportDate(dateStr == null ? null : Date.valueOf(dateStr));
+        r.setReportType("gut_flora");
+        r.setStatus("active");
+        r.setSubjectId(88L);
+        return r;
+    }
+
+    // ==================== #2 chartMode ====================
+
+    @Test
+    void chartMode_n0_empty() {
+        // 无观测 → items 为空
+        when(valueMapper.selectList(any())).thenReturn(new ArrayList<>());
+        mockReports();
+        Map<String, Object> data = service.timeline(88L, Arrays.asList(512L), "gut_flora",
+                null, null, "raw", "all");
+        List<?> items = (List<?>) data.get("items");
+        assertTrue(items.isEmpty());
+    }
+
+    @Test
+    void chartMode_n1_single() {
+        IndicatorDefinition d = def(512L, "LDL-C", "lower_is_better", "numeric", "mmol/L", "0.00-3.37");
+        when(valueMapper.selectList(any())).thenReturn(obsSeries(512L, "2026-10-08"));
+        mockDefs(d);
+        mockReports("2026-10-08");
+        Map<String, Object> data = service.timeline(88L, Arrays.asList(512L), "gut_flora",
+                null, null, "raw", "all");
+        List<Map<String, Object>> items = castItems(data);
+        assertEquals(1, items.size());
+        assertEquals("single", items.get(0).get("chartMode"));
+    }
+
+    @Test
+    void chartMode_n2_pair() {
+        IndicatorDefinition d = def(512L, "LDL-C", "lower_is_better", "numeric", "mmol/L", "0.00-3.37");
+        when(valueMapper.selectList(any())).thenReturn(obsSeries(512L, "2026-08-12", "2026-10-08"));
+        mockDefs(d);
+        mockReports("2026-08-12", "2026-10-08");
+        Map<String, Object> data = service.timeline(88L, Arrays.asList(512L), "gut_flora",
+                null, null, "raw", "all");
+        List<Map<String, Object>> items = castItems(data);
+        assertEquals(1, items.size());
+        assertEquals("pair", items.get(0).get("chartMode"));
+    }
+
+    @Test
+    void chartMode_n5_line() {
+        IndicatorDefinition d = def(512L, "LDL-C", "lower_is_better", "numeric", "mmol/L", "0.00-3.37");
+        when(valueMapper.selectList(any())).thenReturn(obsSeries(512L,
+                "2026-03-14", "2026-05-02", "2026-07-15", "2026-08-12", "2026-10-08"));
+        mockDefs(d);
+        mockReports("2026-03-14", "2026-05-02", "2026-07-15", "2026-08-12", "2026-10-08");
+        Map<String, Object> data = service.timeline(88L, Arrays.asList(512L), "gut_flora",
+                null, null, "raw", "all");
+        List<Map<String, Object>> items = castItems(data);
+        assertEquals(1, items.size());
+        assertEquals("line", items.get(0).get("chartMode"));
+    }
+
+    @Test
+    void chartMode_n25_lineAggregated() {
+        IndicatorDefinition d = def(512L, "LDL-C", "lower_is_better", "numeric", "mmol/L", "0.00-3.37");
+        List<String> dates = new ArrayList<>();
+        List<IndicatorValue> rows = new ArrayList<>();
+        for (int i = 0; i < 25; i++) {
+            String dateStr = String.format("2026-%02d-%02d", 1 + i % 12, 1 + i % 28);
+            dates.add(dateStr);
+            rows.add(obs(512L, 10000L + i, dateStr, String.valueOf(10.0 + i * 0.1)));
+        }
+        when(valueMapper.selectList(any())).thenReturn(rows);
+        mockDefs(d);
+        mockReports(dates.toArray(new String[0]));
+        Map<String, Object> data = service.timeline(88L, Arrays.asList(512L), "gut_flora",
+                null, null, "raw", "all");
+        List<Map<String, Object>> items = castItems(data);
+        assertEquals(1, items.size());
+        assertEquals("line_aggregated", items.get(0).get("chartMode"));
+    }
+
+    // ==================== #3 scope=pair ====================
+
+    @Test
+    void pairCompareMissingParams_throws() {
+        assertThrows(IllegalArgumentException.class,
+                () -> service.pairCompare(88L, "gut_flora", null, "2026-10-08",
+                        null, "magnitude", "all", 1, 50));
+        assertThrows(IllegalArgumentException.class,
+                () -> service.pairCompare(88L, "gut_flora", "2026-03-14", null,
+                        null, "magnitude", "all", 1, 50));
+    }
+
+    @Test
+    void pairCompare_incomparableItemsIncludedAndMagnitudeNullsLast() {
+        IndicatorDefinition ldl = def(512L, "LDL-C", "lower_is_better", "numeric", "mmol/L", "0.00-3.37");
+        IndicatorDefinition tongue = def(777L, "舌苔厚薄", null, "categorical", null, null);
+        IndicatorDefinition onlyAfter = def(513L, "仅末期", "higher_is_better", "numeric", "U", null);
+        IndicatorDefinition onlyBefore = def(515L, "仅首期", "higher_is_better", "numeric", "U", null);
+
+        Long fromId = 10001L;
+        Long toId = 12345L;
+        List<IndicatorValue> rows = new ArrayList<>();
+        rows.add(obs(512L, fromId, "2026-03-14", "4.10"));   // from
+        rows.add(obs(512L, toId, "2026-10-08", "3.62"));     // to → LDL delta -0.48, deltaPct -11.7% → improved
+        rows.add(obs(512L, 10500L, "2026-05-02", "4.00"));   // between(有值)
+        rows.add(obs(512L, 10600L, "2026-07-15", "4.00"));   // between
+        rows.add(obs(512L, 10700L, "2026-08-12", "4.00"));   // between
+        rows.add(obs(777L, fromId, "2026-03-14", "薄白"));   // categorical 舌苔
+        rows.add(obs(777L, toId, "2026-10-08", "厚腻"));     // categorical 舌苔 → incomparable(非数值)
+        rows.add(obs(513L, toId, "2026-10-08", "1.00"));     // 仅末期有值 → incomparable
+        rows.add(obs(515L, fromId, "2026-03-14", "2.00"));   // 仅首期有值 → incomparable
+        when(valueMapper.selectList(any())).thenReturn(rows);
+        mockDefs(ldl, tongue, onlyAfter, onlyBefore);
+
+        // findReportByDate 先查 from 再查 to → 按调用顺序返回
+        when(healthReportMapper.selectList(any())).thenReturn(
+                Arrays.asList(report(fromId, "2026-03-14")),
+                Arrays.asList(report(toId, "2026-10-08")));
+
+        Map<String, Object> data = service.pairCompare(88L, "gut_flora",
+                "2026-03-14", "2026-10-08", null, "magnitude", "all", 1, 50);
+
+        List<Map<String, Object>> items = castItems(data);
+        // 4 个定义均应出现在 items 中(含不可比项)
+        assertEquals(4, items.size());
+        // magnitude 降序:|Δ%| 有值的在前,null 恒定排最后
+        assertEquals("LDL-C", items.get(0).get("name"));
+        assertEquals("improved", items.get(0).get("trendCode"));
+        assertEquals(-11.7, ((BigDecimal) items.get(0).get("deltaPct")).doubleValue(), 1e-9);
+        assertEquals(0L, ((Number) items.get(0).get("skipGaps")).longValue());
+        // 其余 3 项不可比(deltaPct=null),稳定排序保持相对位置
+        for (int i = 1; i < items.size(); i++) {
+            assertNull(items.get(i).get("deltaPct"), items.get(i).get("name") + " 的 deltaPct 应为 null");
+            assertFalse(Boolean.TRUE.equals(items.get(i).get("comparable")),
+                    items.get(i).get("name") + " 应不可比");
+        }
+        // from / to 回显带 indicatorCount(from 期 3 项,to 期 3 项)
+        Map<?, ?> fromMeta = (Map<?, ?>) data.get("from");
+        Map<?, ?> toMeta = (Map<?, ?>) data.get("to");
+        assertEquals("2026-03-14", fromMeta.get("reportDate"));
+        assertEquals("2026-10-08", toMeta.get("reportDate"));
+        assertEquals(3, ((Number) fromMeta.get("indicatorCount")).intValue());
+        assertEquals(3, ((Number) toMeta.get("indicatorCount")).intValue());
+        @SuppressWarnings("unchecked")
+        Map<String, Object> overall = (Map<String, Object>) data.get("overall");
+        assertEquals(1, ((Number) overall.get("improvedCount")).intValue());
+        assertEquals(3, ((Number) overall.get("incomparableCount")).intValue());
+    }
+
+    @Test
+    void pairCompare_filterIncomparable_returnsOnlyIncomparable() {
+        IndicatorDefinition ldl = def(512L, "LDL-C", "lower_is_better", "numeric", "mmol/L", "0.00-3.37");
+        IndicatorDefinition tongue = def(777L, "舌苔厚薄", null, "categorical", null, null);
+
+        Long fromId = 10001L;
+        Long toId = 12345L;
+        List<IndicatorValue> rows = new ArrayList<>();
+        rows.add(obs(512L, fromId, "2026-03-14", "4.10"));
+        rows.add(obs(512L, toId, "2026-10-08", "3.62"));
+        rows.add(obs(777L, fromId, "2026-03-14", "薄白"));
+        rows.add(obs(777L, toId, "2026-10-08", "厚腻"));
+        when(valueMapper.selectList(any())).thenReturn(rows);
+        mockDefs(ldl, tongue);
+        when(healthReportMapper.selectList(any())).thenReturn(
+                Arrays.asList(report(fromId, "2026-03-14")),
+                Arrays.asList(report(toId, "2026-10-08")));
+
+        Map<String, Object> data = service.pairCompare(88L, "gut_flora",
+                "2026-03-14", "2026-10-08", null, "magnitude", "incomparable", 1, 50);
+        List<Map<String, Object>> items = castItems(data);
+        assertEquals(1, items.size());
+        assertEquals("舌苔厚薄", items.get(0).get("name"));
+        assertEquals("changed", items.get(0).get("movement"));
+        assertEquals("flat", items.get(0).get("trendCode"));
+    }
+
+    // ==================== #8 QP17 status=0 剔除 ====================
+
+    @Test
+    void trendExcludesStatus0Definitions() {
+        IndicatorDefinition active = def(512L, "活跃指标", "lower_is_better", "numeric", "mmol/L", null);
+        IndicatorDefinition dead = defStatus0(999L, "废弃指标");
+        // loadActiveDefinitions 只会返回 status=1 的定义 → 999 的观测必须被剔除
+        when(definitionMapper.selectList(any())).thenReturn(Arrays.asList(active));
+        List<IndicatorValue> rows = new ArrayList<>();
+        rows.add(obs(512L, 10000L, "2026-08-12", "10"));
+        rows.add(obs(512L, 10001L, "2026-10-08", "9"));
+        rows.add(obs(999L, 10000L, "2026-08-12", "5"));
+        rows.add(obs(999L, 10001L, "2026-10-08", "4"));
+        when(valueMapper.selectList(any())).thenReturn(rows);
+        mockReports("2026-08-12", "2026-10-08");
+
+        Map<String, Object> data = service.timeline(88L, Arrays.asList(512L, 999L), "gut_flora",
+                null, null, "raw", "all");
+        List<Map<String, Object>> items = castItems(data);
+        assertEquals(1, items.size());
+        assertEquals("活跃指标", items.get(0).get("name"));
+    }
+
+    @SuppressWarnings("unchecked")
+    private List<Map<String, Object>> castItems(Map<String, Object> data) {
+        return (List<Map<String, Object>>) data.get("items");
+    }
+
+    private BigDecimal toNumeric(String val) {
+        if (val == null) {
+            return null;
+        }
+        try {
+            return new BigDecimal(val);
+        } catch (NumberFormatException e) {
+            return null;
+        }
+    }
+}

+ 228 - 20
cfc-backend/src/test/java/com/etotem/cfc/service/IndicatorTrendServiceTest.java

@@ -1,9 +1,12 @@
 package com.etotem.cfc.service;
 
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
 import com.etotem.cfc.entity.IndicatorDefinition;
 import com.etotem.cfc.entity.IndicatorValue;
 import com.etotem.cfc.mapper.IndicatorDefinitionMapper;
 import com.etotem.cfc.mapper.IndicatorValueMapper;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.springframework.test.util.ReflectionTestUtils;
@@ -24,21 +27,41 @@ class IndicatorTrendServiceTest {
     private IndicatorTrendService service;
     private IndicatorValueMapper valueMapper;
     private IndicatorDefinitionMapper definitionMapper;
+    private IndicatorDefinitionMapper changeDefinitionMapper;
+    private IndicatorTimelineService timelineService;
 
     @BeforeEach
     void setUp() {
+        // LambdaQueryWrapper.select(SFunction) 需要实体元数据(MP lambda cache)
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""),
+                IndicatorValue.class);
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""),
+                IndicatorDefinition.class);
+
         service = new IndicatorTrendService();
         valueMapper = mock(IndicatorValueMapper.class);
         ReflectionTestUtils.setField(service, "valueMapper", valueMapper);
-        // 计划测试漏加:实现均调用 definitionMapper.selectById,必须补 mock
         definitionMapper = mock(IndicatorDefinitionMapper.class);
         ReflectionTestUtils.setField(service, "definitionMapper", definitionMapper);
+
+        // 真实 IndicatorChangeService(Q17 后改走 changeService.loadActiveDefinitions),
+        // 注入 mock mapper 保证单测不碰库
+        IndicatorChangeService changeService = new IndicatorChangeService();
+        changeDefinitionMapper = mock(IndicatorDefinitionMapper.class);
+        ReflectionTestUtils.setField(changeService, "definitionMapper", changeDefinitionMapper);
+        ReflectionTestUtils.setField(changeService, "valueMapper", valueMapper);
         IndicatorDefinition def = new IndicatorDefinition();
+        def.setId(1L);
         def.setName("双歧杆菌属");
         def.setUnit("%");
         def.setCategory("肠道菌群");
         def.setGoodDirection("higher_is_better");
-        when(definitionMapper.selectById(any())).thenReturn(def);
+        def.setValueType("numeric");
+        when(changeDefinitionMapper.selectList(any())).thenReturn(new ArrayList<>(Arrays.asList(def)));
+        ReflectionTestUtils.setField(service, "changeService", changeService);
+
+        timelineService = mock(IndicatorTimelineService.class);
+        ReflectionTestUtils.setField(service, "timelineService", timelineService);
     }
 
     private IndicatorValue value(Long defId, String date, String val) {
@@ -47,25 +70,45 @@ class IndicatorTrendServiceTest {
         v.setReportDate(Date.valueOf(date));
         v.setValue(val);
         v.setNumericValue(new BigDecimal(val));
+        v.setUnit("%");
+        v.setReportId(100L);
+        return v;
+    }
+
+    /** report_date 为 null 的观测(用于验收标准 #4:null 日期不得被当成最新值) */
+    private IndicatorValue valueWithNullDate(Long defId, String val) {
+        IndicatorValue v = new IndicatorValue();
+        v.setDefinitionId(defId);
+        v.setReportDate(null);
+        v.setValue(val);
+        v.setNumericValue(new BigDecimal(val));
+        v.setUnit("%");
+        v.setReportId(101L);
         return v;
     }
 
+    private IndicatorDefinition def(Long id, String name, String goodDirection, String unit) {
+        IndicatorDefinition d = new IndicatorDefinition();
+        d.setId(id);
+        d.setName(name);
+        d.setUnit(unit);
+        d.setCategory("肠道菌群");
+        d.setGoodDirection(goodDirection);
+        d.setValueType("numeric");
+        d.setStatus(1);
+        return d;
+    }
+
     @Test
-    void trendReturnsAscendingPoints() {
-        List<IndicatorValue> rows = new ArrayList<>();
-        rows.add(value(1L, "2026-08-15", "10"));
-        rows.add(value(1L, "2026-10-01", "12.8"));
-        when(valueMapper.selectList(any())).thenReturn(rows);
+    void trendDelegatesToTimelineService() {
+        Map<String, Object> expected = new java.util.LinkedHashMap<>();
+        expected.put("items", new ArrayList<>());
+        when(timelineService.timeline(any(), any(), any(), any(), any(), any(), any()))
+                .thenReturn(expected);
 
-        Map<String, Object> data = service.trend(3L, Arrays.asList(1L), 2L);
-        List<?> items = (List<?>) data.get("items");
-        assertEquals(1, items.size());
-        Map<?, ?> item = (Map<?, ?>) items.get(0);
-        assertEquals("1", String.valueOf(item.get("definitionId")));
-        List<?> points = (List<?>) item.get("points");
-        assertEquals(2, points.size());
-        Map<?, ?> first = (Map<?, ?>) points.get(0);
-        assertEquals("2026-08-15", first.get("reportDate"));
+        Map<String, Object> data = service.trend(3L, Arrays.asList(1L), 2L, null, null, null, "raw", "all");
+        assertSame(expected, data);
+        verify(timelineService).timeline(eq(3L), eq(Arrays.asList(1L)), eq(null), eq(null), eq(null), eq("raw"), eq("all"));
     }
 
     @Test
@@ -76,25 +119,190 @@ class IndicatorTrendServiceTest {
         rows.add(value(1L, "2026-10-01", "12.8"));
         when(valueMapper.selectList(any())).thenReturn(rows);
 
-        java.util.Map<String, Object> data = service.changeRanking(3L, null, null, "adjacent", "magnitude", 50, 2L);
+        java.util.Map<String, Object> data = service.changeRanking(3L, null, null, "adjacent", "magnitude", 50, 2L,
+                null, null, null, null, null);
         List<?> items = (List<?>) data.get("items");
         assertEquals(1, items.size());
         Map<?, ?> item = (Map<?, ?>) items.get(0);
         assertEquals("up", item.get("movement"));
         assertEquals("改善", item.get("trendLabel"));
+        assertEquals("improved", item.get("trendCode"));
         Map<?, ?> overall = (Map<?, ?>) data.get("overall");
         assertEquals(1, ((Number) overall.get("improvedCount")).intValue());
     }
 
     @Test
-    void quickLookupReturnsItemsAndDimensions() {
+    void quickLookupReturnsItemsAndCategories() {
         List<IndicatorValue> rows = new ArrayList<>();
         rows.add(value(1L, "2026-10-01", "12.8"));
         when(valueMapper.selectList(any())).thenReturn(rows);
 
-        Map<String, Object> data = service.quickLookup(3L, null, 1, 30, 2L);
+        Map<String, Object> data = service.quickLookup(3L, null, 1, 30, 2L, false, 8);
         assertTrue(data.containsKey("items"));
         assertTrue(data.containsKey("categories"));
-        assertEquals(1, ((List<?>) data.get("items")).size());
+        List<?> items = (List<?>) data.get("items");
+        assertEquals(1, items.size());
+        Map<?, ?> item = (Map<?, ?>) items.get(0);
+        // reportDate=null 的观测被排在最后(修 bug)
+        assertEquals("2026-10-01", item.get("latestReportDate"));
+        assertNotNull(item.get("change"));
+    }
+
+    /** 验收标准 #4:quick-lookup 中 report_date=null 的观测不再被当成最新值 */
+    @Test
+    void quickLookup_nullReportDateNotTreatedAsLatest() {
+        List<IndicatorValue> rows = new ArrayList<>();
+        rows.add(valueWithNullDate(1L, "999"));      // null 日期 → 不参与最新值竞争
+        rows.add(value(1L, "2026-10-01", "12.8"));  // 有日期 → 最新
+        // 第一次 selectList 拉全部观测;resolvePrevious 的第二次 selectList 返回空(→ first_record)
+        when(valueMapper.selectList(any())).thenReturn(rows, new ArrayList<>());
+
+        Map<String, Object> data = service.quickLookup(3L, null, 1, 30, 2L, false, 8);
+        Map<?, ?> item = (Map<?, ?>) ((List<?>) data.get("items")).get(0);
+        assertEquals("2026-10-01", item.get("latestReportDate"), "null 日期观测不得排到最新");
+        assertEquals("12.8", item.get("value"), "最新值必须取有日期的观测");
+    }
+
+    /** 验收标准 #8:quickLookup 对 status=0 的 definition 一律剔除,不降级为「指标」+id 假名称 */
+    @Test
+    void quickLookup_excludesStatus0Definitions() {
+        IndicatorDefinition active = def(1L, "双歧杆菌属", "higher_is_better", "%");
+        IndicatorDefinition dead = def(2L, "废弃指标", "higher_is_better", "U");
+        dead.setStatus(0);
+        // loadActiveDefinitions 仅返回 status=1 的定义(模拟 DB 过滤)
+        when(changeDefinitionMapper.selectList(any())).thenReturn(new ArrayList<>(Arrays.asList(active)));
+
+        List<IndicatorValue> rows = new ArrayList<>();
+        rows.add(value(1L, "2026-10-01", "12.8"));
+        rows.add(value(2L, "2026-10-01", "5.5"));   // status=0 → 必须剔除
+        when(valueMapper.selectList(any())).thenReturn(rows, new ArrayList<>());
+
+        Map<String, Object> data = service.quickLookup(3L, null, 1, 30, 2L, false, 8);
+        List<?> items = (List<?>) data.get("items");
+        assertEquals(1, items.size(), "status=0 指标不得出现在结果中");
+        Map<?, ?> item = (Map<?, ?>) items.get(0);
+        assertEquals("双歧杆菌属", item.get("name"));
+        assertFalse(String.valueOf(item.get("name")).startsWith("指标"), "不得生成「指标」+id 的假名称");
+    }
+
+    /** 验收标准 #9:magnitude 排序中 deltaPct=null 的项(基数为 0)恒定排最后 */
+    @Test
+    void changeRanking_magnitudeDeltaPctNullAlwaysLast() {
+        // 三个定义:正常幅度 10%、轻微 1%、基数为 0(deltaPct=null)
+        IndicatorDefinition d1 = def(1L, "指标A", "higher_is_better", "%");
+        IndicatorDefinition d2 = def(2L, "指标B", "higher_is_better", "%");
+        IndicatorDefinition d3 = def(3L, "指标C", "higher_is_better", "%");
+        d3.setRefRange("0.00-1.00");
+        when(changeDefinitionMapper.selectList(any()))
+                .thenReturn(new ArrayList<>(Arrays.asList(d1, d2, d3)));
+
+        List<IndicatorValue> rows = new ArrayList<>();
+        rows.add(value(1L, "2026-08-01", "100"));
+        rows.add(value(1L, "2026-10-01", "110"));   // deltaPct = 10%
+        rows.add(value(2L, "2026-08-01", "100"));
+        rows.add(value(2L, "2026-10-01", "101"));   // deltaPct = 1%
+        rows.add(value(3L, "2026-08-01", "0"));
+        rows.add(value(3L, "2026-10-01", "5"));     // 基数为 0 → deltaPct=null
+        when(valueMapper.selectList(any())).thenReturn(rows);
+
+        Map<String, Object> data = service.changeRanking(3L, null, null, "adjacent", "magnitude", 50, 2L,
+                null, null, null, null, null);
+        List<Map<?, ?>> items = (List<Map<?, ?>>) data.get("items");
+        assertEquals(3, items.size());
+        assertEquals("指标A", items.get(0).get("name"), "|10%| 最大,应排第一");
+        assertEquals("指标B", items.get(1).get("name"), "|1%| 次之");
+        assertEquals("指标C", items.get(2).get("name"), "deltaPct=null 恒定排最后");
+        assertNull(items.get(2).get("deltaPct"), "基数为 0 的百分比必须为 null");
+    }
+
+    /**
+     * quickLookup 的上一期必须从已加载的 rows 内存求解。
+     * 之前逐指标回查 indicator_values(resolvePrevious),40 个指标就是 40 次 SQL。
+     */
+    @Test
+    void quickLookup_resolvesPreviousInMemory_noExtraQueries() {
+        IndicatorDefinition d = def(1L, "双歧杆菌属", "higher_is_better", "%");
+        when(changeDefinitionMapper.selectList(any())).thenReturn(new ArrayList<>(Arrays.asList(d)));
+
+        // 三期观测:最新 12.8,上一期 10.5(percentageFloor 以下,Δ% 仍有效),首期 8.0
+        List<IndicatorValue> rows = new ArrayList<>();
+        rows.add(value(1L, "2026-08-01", "8.0"));
+        rows.add(value(1L, "2026-09-15", "10.5"));
+        rows.add(value(1L, "2026-10-01", "12.8"));
+        when(valueMapper.selectList(any())).thenReturn(rows);
+
+        Map<String, Object> data = service.quickLookup(3L, null, 1, 30, 2L, false, 8);
+        Map<?, ?> item = (Map<?, ?>) ((List<?>) data.get("items")).get(0);
+
+        // 上一期必须是 2026-09-15 的 10.5,而不是更早的 8.0
+        Map<?, ?> previous = (Map<?, ?>) item.get("previous");
+        assertNotNull(previous, "内存求解必须找到上一期");
+        assertEquals("2026-09-15", previous.get("reportDate"));
+        assertEquals("10.5", previous.get("value"));
+        // higher_is_better 从 10.5 涨到 12.8 → 改善
+        assertEquals("improved", ((Map<?, ?>) item.get("change")).get("trendCode"));
+        assertEquals("12.8", item.get("value"), "最新值必须是本期最新");
+        assertEquals(3, ((Number) item.get("pointsCount")).intValue());
+
+        // 一次 selectList 取全部观测,之后不再回查(无 N+1)
+        verify(valueMapper, times(1)).selectList(any());
+    }
+
+    /** 本期最新观测的日期为 null → 无法确定"更早",不猜上一期,趋势为 first_record */
+    @Test
+    void quickLookup_latestHasNullDate_noPrevious() {
+        IndicatorDefinition d = def(1L, "双歧杆菌属", "higher_is_better", "%");
+        when(changeDefinitionMapper.selectList(any())).thenReturn(new ArrayList<>(Arrays.asList(d)));
+
+        // 同定义的所有观测日期均为 null → 排序后最新的也是 null 日期
+        List<IndicatorValue> rows = new ArrayList<>();
+        rows.add(valueWithNullDate(1L, "12.8"));   // null 日期
+        rows.add(valueWithNullDate(1L, "9.0"));    // null 日期
+        when(valueMapper.selectList(any())).thenReturn(rows);
+
+        Map<String, Object> data = service.quickLookup(3L, null, 1, 30, 2L, false, 8);
+        Map<?, ?> item = (Map<?, ?>) ((List<?>) data.get("items")).get(0);
+
+        // 本期日期未知 → 无法确定"更早",不猜上一期
+        assertNull(item.get("previous"));
+        assertNull(item.get("latestReportDate"));
+        assertEquals("first_record", ((Map<?, ?>) item.get("change")).get("trendCode"),
+                "本期日期未知时按首次记录降级");
+        verify(valueMapper, times(1)).selectList(any());
+    }
+
+    /** listSourceTypes 改为一次查询内存计算 periodCount/latestReportDate(原 1+2N) */
+    @Test
+    void listSourceTypes_computesPeriodCountAndLatestInOneQuery() {
+        List<IndicatorValue> rows = new ArrayList<>();
+        rows.add(sourceTypeRow("gut_flora", "2026-08-01"));
+        rows.add(sourceTypeRow("gut_flora", "2026-09-15"));
+        rows.add(sourceTypeRow("gut_flora", "2026-09-15"));   // 同日 → 去重
+        rows.add(sourceTypeRow("gut_flora", "2026-10-01"));
+        rows.add(sourceTypeRow("physical_exam", "2026-07-01"));
+        rows.add(sourceTypeRow("physical_exam", "2026-08-02"));
+        when(valueMapper.selectList(any())).thenReturn(rows);
+
+        List<Map<String, Object>> out = service.listSourceTypes(3L);
+        assertEquals(2, out.size(), "两个 source_type 各一条");
+
+        Map<String, Object> gut = out.get(0);
+        assertEquals("gut_flora", gut.get("sourceType"));
+        assertEquals(3L, ((Number) gut.get("periodCount")).longValue(), "同日去重,3 个不同日期");
+        assertEquals("2026-10-01", gut.get("latestReportDate"));
+
+        Map<String, Object> physical = out.get(1);
+        assertEquals("physical_exam", physical.get("sourceType"));
+        assertEquals(2L, ((Number) physical.get("periodCount")).longValue());
+        assertEquals("2026-08-02", physical.get("latestReportDate"));
+
+        verify(valueMapper, times(1)).selectList(any());   // 不得按 sourceType 分别查库
+    }
+
+    private IndicatorValue sourceTypeRow(String sourceType, String date) {
+        IndicatorValue v = new IndicatorValue();
+        v.setSourceType(sourceType);
+        v.setReportDate(Date.valueOf(date));
+        return v;
     }
 }

+ 62 - 0
docs/superpowers/api/API_REFERENCE.md

@@ -1700,6 +1700,68 @@ interactionType 取值:`PHONE` / `WECHAT` / `MEETING` / `OTHER`。
 
 *文档更新:2026-10-09(指标统一层新增,见 `specs/2026-10-09-indicator-change-curve-design.md`)*
 
+> **⚠️ `/api/health/indicator/list` 已被取代**:老接口返回裸 `List<HealthIndicator>`(无上一期对照、无 summary)。新接口 `/api/health/report/indicators` 返回同一批指标 + `previous` / `change` / `summary` 五档形态数据。`/api/health/indicator/list` **保留不动**(cfc-web `views/nutritionist/HealthIndicators.vue` 仍在调),新接入一律走 `/api/health/report/indicators`。
+
+---
+
+### 4.48.1 指标多期展示 · 后端接口(`/api/health/report/indicators`、`/api/indicator/periods`、`/api/admin/indicator/backfill`)
+
+> 本节为 §4.48 指标统一层的多期展示增量。**所有 `change` 计算统一由 `IndicatorChangeService.computeChange` 单点产出**(trendCode 五档:`improved` / `worsened` / `flat` / `first_record` / `pending_backfill`),读路径一律过滤 `indicator_definitions.status=1`,拿不到 active definition 的观测**直接剔除,不降级为「指标」+id 假名称**(QP17)。
+
+**① `POST /api/health/report/indicators`** — 报告指标明细(含上一期对照,D12 报告详情页接入点)
+
+| 端点 | 权限 | 请求 | 返回 |
+|---|---|---|---|
+| `POST /api/health/report/indicators` | 登录(JWT;管理员/本人/被授权成员,校验逻辑同 `/api/health/report/detail`) | `{reportId, withPrevious?}` | `{reportId, reportType, subjectId, reportDate, dateSource, olderReportCount, summary, items}` |
+
+- **入参**:`reportId`(必填);`withPrevious`(选填,默认 `true`,`false` 时跳过 previous/change 计算,供编辑模式快速返回单期)。
+- **权限**:无 `subjectId` 的报告(历史未绑定成员)仅**本人上传者或家庭管理员**可见;有 `subjectId` 时走 `ReportMemberPermissionService.canViewMemberReports`。
+- **items[]** 每项含:指标原字段(`indicatorId`/`definitionId`/`category`/`indicatorName`/`indicatorValue`/`numericValue`/`unit`/`refRange`/`status`)+ 字典字段(`goodDirection`/`valueType`)+ `previous`(上一期观测,无则 null)+ `change`(`{delta, deltaPct, movement, trendLabel, trendCode, skipGaps, unitMismatch, comparable}`,首期时为 `first_record`,报告日期未知时为 `pending_backfill`)。
+- **summary**:`totalCount` / `abnormalCount`(`status` 非「正常」)/ `abnormalDelta`(预留字段,当前固定 `0`,单报告视角无法直接算)/ `changedCount`(`trendCode` 为 improved/worsened 的条数)/ `comparableCount` / `firstRecordCount` / `pendingCount` / `dataIncomplete`(存在 pending_backfill 时为 true,驱动残缺横幅组件)。
+- **与 `withPrevious=false`**:返回单期 items(每项含 `indicatorId/category/indicatorName/indicatorValue/unit/refRange/status`,**不含** `definitionId/previous/change/goodDirection/valueType`)。
+- **`olderReportCount`**:该成员在本报告之前(同 sourceType)的历史报告数,用于 `first_record` 档区分「真的首期」与「更早报告未回填」。
+- **N+1 约束**:42 项指标一次请求的 SQL ≤ 5 次(指标行 1 + 上期批量解析 1 + 字典/定义 1 + 报告 1);`IndicatorMappingService.resolveBatch` 用 `indicator_mapping_rules` JOIN `indicator_definitions` 单查询批量解析,**严禁逐指标查库**。
+
+**② `POST /api/indicator/periods`** — 期次列表(双期对比页 picker 数据源)
+
+| 端点 | 权限 | 请求 | 返回 |
+|---|---|---|---|
+| `POST /api/indicator/periods` | 登录(JWT;`subjectId` 须属于当前家庭) | `{subjectId, sourceType?}` | `{subjectId, sourceType, items}` |
+
+- **items[]**:`{reportDate, reportId, dateSource, indicatorCount}`,按 `reportDate` 倒序;`reportDate=null`(日期未知期)**恒定排最后**,picker 显示「日期未知」。
+- **dateSource**:该期所有观测中**最差**的一个(`unknown < confirm < upload < real`),诚实口径——只要有一项来源是上传推断,整期就标 `upload`。
+- **indicatorCount**:该期有有效值(`value` 非空)的指标数,picker 显示「2026-10-08(42 项)」。
+- **不分页**(单人单类型期次数实测 < 30);只读幂等。
+
+**③ `POST /api/admin/indicator/backfill`** — 存量时序回填(admin)
+
+| 端点 | 权限 | 请求 | 返回 |
+|---|---|---|---|
+| `POST /api/admin/indicator/backfill` | admin(`@RequestAttribute("role")` 校验,非 admin 返回「权限不足」) | `{subjectId?, reportId?, limit?, dryRun?, fixReportDates?}` | `{scanned, eligible, written, skippedNoSubject, skippedNoPayload, failed, dryRun, fixReportDates, dateFixable, dateFixed, details[]}` |
+
+- **用途**:把 confirm 链路补写之前的历史报告补进 `indicator_values`(P0-3 数据面补全),并可顺带用 `ReportDateExtractor` 修复被 `new Date()` 污染的历史报告日期。
+- **入参**:`subjectId`(选填,指定成员;不传扫全部 `status=active` 报告);`reportId`(选填,只回填指定报告,灰度验证用);`limit`(选填,默认 200,上限 500);`dryRun`(默认 `true`);`fixReportDates`(默认 `false`)。
+- **`dryRun=true`**:预演只统计不写入,返回 `eligible`/`dateFixable` 计数,且**不触发 `mappingService.resolve`**(避免惰性建定义这类副作用);**首次执行必须 dryRun,核对无误后传 `false`**。
+- **顺序红线**:先修日期、再补时序(污染日期若固化进 `indicator_values` 再修就晚了)。日期修复判定条件:能提取到真实日期 且 `report_date != extracted` 且 `report_date >= created_at` 当天(`new Date()` 污染特征签名);修后需连带 `refreshMemberIndicatorCurrent`。
+- **幂等**:`NormalizeIndicatorPipeline` 按唯一键 `source_type + source_id + definition_id` upsert,已覆盖报告返回 `already_covered`,重复执行不产生重复时序。
+
+**④ 升级改造的既有端点(`IndicatorController`)**
+
+| 端点 | 变更 |
+|---|---|
+| `POST /api/indicator/trend` | 新增可选入参 `{sourceType, fromDate, toDate, granularity: raw\|month, sourceKind: all\|report\|device}`;出参新增 `refLow/refHigh/goodDirection/valueType/pointsCount/totalReportCount/olderReportCount/chartMode/unitMismatch/latest/change/points/missingPeriods`。**破坏性:无** |
+| `POST /api/indicator/change-ranking` | 新增可选入参 `{scope: pair, fromReportDate, toReportDate, filter: improved\|worsened\|flat\|incomparable\|all, page, pageSize}`;`scope=pair` 缺 `fromReportDate`/`toReportDate` 返回明确错误;**`magnitude` 排序口径从 `\|Δ\|` 改为 `\|Δ%\|` 降序**(视觉有变化,属预期修正,见 §4.48 变更汇总);`deltaPct=null` 的项恒定排最后且不参与 improved/worsened 筛选(Q14)。**破坏性:无** |
+| `POST /api/indicator/quick-lookup` | 新增可选入参 `{withSparkline, sparkLimit}`;出参每项新增 `previous`/`change`/`latestDateSource`/`latestReportId`/`goodDirection`/`valueType`/`sparkline`;**修复 `reportDate=null` 观测被当作最新值的 bug**。**破坏性:无** |
+| `POST /api/indicator/source-types` | 出参新增 `periodCount` / `latestReportDate`(曲线页类型筛选用,原只返回类型名)。**破坏性:无** |
+
+> **`chartMode` 判定表**(后端单点定义,`IndicatorChangeService.chartModeOf`):`pointsCount=0 → empty`;`=1 → single`;`=2 → pair`;`<20 → line`;`≥20 → line_aggregated`(`granularity=month` 时强制 `line_aggregated`)。
+> **SQL 预算(严禁 N+1,均有单测锁死)**:`/report/indicators` 42 项指标 → 服务层恒 3 次协作调用(控制器另加 2 次,合计 ≤ 5);`/quick-lookup` 与 `/source-types` 均恒 1 次 `indicator_values` 查询(上一期与 `periodCount` 全部内存计算)。
+> **`IndicatorService.listDefinitions`**:`status` 传 null 时**默认按 `status=1` 过滤**(防止 P1-6 自动创建的 `status=0` definition 冒进管理端字典列表,QP17)。
+> **`missingPeriods`**:该成员有报告、但本指标无有效值的期次,`IndicatorTimelineService` 固定 2 次查询算完(原 1+缺失期数,会 N+1)。
+> **老接口兼容矩阵**见 `specs/2026-10-25-indicator-timeline-display-design.md` §4.7。
+
+*文档更新:2026-10-27(指标多期展示 T02 后端接口,见 `specs/2026-10-25-indicator-timeline-display-design.md`)*
+
 ---
 
 ### 4.49 健康方案删除(`/api/health/plan/delete`、`/api/admin/health-plan/delete`)