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

feat(T02): IndicatorTrendService 接入时序服务 + 变化计算单点化

按 T02 §5.1 / §4.2-§4.4 改造既有读接口:

- trend():签名扩展为 8 参(sourceType/fromDate/toDate/granularity/sourceKind),
  委托 IndicatorTimelineService.timeline(),老 3 参调用保留(转发)。
- changeRanking():新增 scope=pair + fromReportDate/toReportDate + filter + page/pageSize;
  scope=pair 路由到 IndicatorTimelineService.pairCompare;
  老路径(scope=adjacent|first-vs-latest,或只传 limit 不传 page/pageSize)保持兼容。
  magnitude 排序口径由 |Δ| 改为 |Δ%| 降序,deltaPct=null 恒定排最后(Q14)。
- quickLookup():修复 reportDate=null 的观测被当作最新值的 bug;
  新增 previous/change/latestDateSource/latestReportId/goodDirection/valueType/sparkline。
- listSourceTypes():出参新增 periodCount / latestReportDate。
- trend/changeRanking/quickLookup 三个方法统一经 IndicatorChangeService.loadActiveDefinitions()
  取 definition(内部 status=1 过滤),拿不到的观测直接剔除,不降级为"指标"+id 假名称(QP17)。
- IndicatorChangeService.normUnit 由 private 提升为 public,供 IndicatorTimelineService 复用
  单位一致性判定,避免重复实现归一化规则。
Sisyphus 2 недель назад
Родитель
Сommit
b209093225

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

+ 333 - 254
cfc-backend/src/main/java/com/etotem/cfc/service/IndicatorTrendService.java

@@ -16,12 +16,15 @@ import java.math.BigDecimal;
 import java.math.RoundingMode;
 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.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
 import java.util.Set;
 import java.util.stream.Collectors;
 
@@ -46,58 +49,251 @@ 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) {
+            data.put("code", 500);
+            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);
+            // 找上一期
+            java.sql.Date latestDate = latest.getReportDate();
+            IndicatorValue previous = null;
+            if (latestDate != null) {
+                previous = changeService.resolvePrevious(subjectId, defId, latest.getSourceType(), latestDate);
+            }
+            // 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;
+        }
+        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));
+            // periodCount / latestReportDate:同 sourceType 且 report_date 非空的 distinct count + max
+            long periodCount = valueMapper.selectList(new LambdaQueryWrapper<IndicatorValue>()
+                    .eq(IndicatorValue::getSubjectId, subjectId)
+                    .eq(IndicatorValue::getSourceType, t)
+                    .isNotNull(IndicatorValue::getReportDate)
+                    .select(IndicatorValue::getReportDate))
+                    .stream().map(IndicatorValue::getReportDate).distinct().count();
+            Date latest = valueMapper.selectList(new LambdaQueryWrapper<IndicatorValue>()
+                    .eq(IndicatorValue::getSubjectId, subjectId)
+                    .eq(IndicatorValue::getSourceType, t)
+                    .isNotNull(IndicatorValue::getReportDate)
+                    .orderByDesc(IndicatorValue::getReportDate)
+                    .select(IndicatorValue::getReportDate))
+                    .stream().map(IndicatorValue::getReportDate).filter(Objects::nonNull).findFirst().orElse(null);
+            m.put("periodCount", periodCount);
+            m.put("latestReportDate", latest != null ? new SimpleDateFormat("yyyy-MM-dd").format(latest) : null);
+            items.add(m);
+        }
+        return items;
+    }
+
+    /** 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 +303,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 +437,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 +494,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;
     }
 }