فهرست منبع

feat(T01): 新增 IndicatorChangeService 变化计算底座 + 单测

- resolvePrevious/batchResolve:上一期查找(1 次查询,禁 N+1)
- parseRefRange/judgeInRange:参考范围解析与 in_range_is_better 四分支
- computeChange:Δ/Δ%、微小基数护栏(refHigh×1%)、2% 持平阈值、五值 trendCode
- 单测 23 例覆盖解析/判定/护栏/单位/非数值/无上一期两档
Sisyphus 2 هفته پیش
والد
کامیت
1d4370a340

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

@@ -0,0 +1,597 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+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.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.util.ArrayList;
+import java.util.Collection;
+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.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+
+/**
+ * 指标变化计算底座(DS-2026-10-25-01 · T01)。
+ *
+ * <p>纯计算服务:不写库、不改数据。所有"上一期查找 / 参考范围解析 / 变化判定"都收口在这里,
+ * 后续接口层(报告详情 / 时间轴 / 变化排名 / 速查 / 双期对比)统一调用,避免各处各算一套。</p>
+ *
+ * <p>上一期定义(严格对齐 PRD §6.5):
+ * 同 subjectId ∧ 同 sourceType ∧ numeric_value 非空 ∧ report_date 严格小于本期 ∧ 取日期最大的一期。
+ * 跨报告类型不比较;report_date 为空的期不参与。</p>
+ */
+@Service
+public class IndicatorChangeService {
+
+    private static final Logger log = LoggerFactory.getLogger(IndicatorChangeService.class);
+
+    @Resource
+    private IndicatorValueMapper valueMapper;
+
+    @Resource
+    private IndicatorDefinitionMapper definitionMapper;
+
+    /** 基本持平阈值(PRD §6.3 / D5):|Δ%| < 2% 且状态未变 → 判持平 */
+    public static final double DEFAULT_FLAT_THRESHOLD_PCT = 2.0;
+
+    /** 微小基数护栏默认值(参考范围无法解析时使用) */
+    public static final BigDecimal DEFAULT_FLOOR = new BigDecimal("0.1");
+
+    /** 微小基数护栏下限(防止 refHigh 极小时护栏退化为 0) */
+    public static final BigDecimal MIN_FLOOR = new BigDecimal("0.0001");
+
+    /** 持平阈值(可配置:app.indicator.flat-threshold-pct) */
+    @Value("${app.indicator.flat-threshold-pct:2.0}")
+    private double flatThresholdPct = DEFAULT_FLAT_THRESHOLD_PCT;
+
+    /** trendCode 五值枚举 */
+    public static final String TREND_IMPROVED = "improved";
+    public static final String TREND_WORSENED = "worsened";
+    public static final String TREND_FLAT = "flat";
+    public static final String TREND_FIRST_RECORD = "first_record";
+    public static final String TREND_PENDING_BACKFILL = "pending_backfill";
+
+    /** movement 六值枚举 */
+    public static final String MOVEMENT_UP = "up";
+    public static final String MOVEMENT_DOWN = "down";
+    public static final String MOVEMENT_FLAT = "flat";
+    public static final String MOVEMENT_CHANGED = "changed";
+    public static final String MOVEMENT_UNCHANGED = "unchanged";
+    public static final String MOVEMENT_NONE = "none";
+
+    /** 排序比较器:report_date 降序,NULL 恒排最后 */
+    public static final Comparator<IndicatorValue> NULLS_LAST_DESC =
+            Comparator.comparing(IndicatorValue::getReportDate,
+                            Comparator.nullsLast(Comparator.reverseOrder()))
+                    .thenComparing(IndicatorValue::getId, Comparator.nullsLast(Comparator.reverseOrder()));
+
+    private static final Pattern RANGE_PATTERN =
+            Pattern.compile("^(-?\\d+(?:\\.\\d+)?)?\\s*[-~]\\s*(-?\\d+(?:\\.\\d+)?)?$");
+    private static final Pattern LT_PATTERN = Pattern.compile("^[<\\u2264]\\s*(-?\\d+(?:\\.\\d+)?)$");
+    private static final Pattern GT_PATTERN = Pattern.compile("^[>\\u2265]\\s*(-?\\d+(?:\\.\\d+)?)$");
+    private static final Pattern SINGLE_PATTERN = Pattern.compile("^(-?\\d+(?:\\.\\d+)?)$");
+
+    // ==================== 上一期查找 ====================
+
+    /**
+     * 上一期查找(单指标)。走 idx_subject_def_date(subject_id, definition_id, report_date) 索引。
+     *
+     * @return 上一期观测;无则 null(调用方走「首次记录 / 历史整理中」)
+     */
+    public IndicatorValue resolvePrevious(Long subjectId, Long definitionId, String sourceType,
+                                          java.sql.Date currentReportDate) {
+        if (subjectId == null || definitionId == null || currentReportDate == null) {
+            return null;   // 本期日期未知 → 无法确定"更早",不猜
+        }
+        List<IndicatorValue> rows = valueMapper.selectList(new LambdaQueryWrapper<IndicatorValue>()
+                .eq(IndicatorValue::getSubjectId, subjectId)
+                .eq(IndicatorValue::getDefinitionId, definitionId)
+                .eq(sourceType != null && !sourceType.trim().isEmpty(),
+                        IndicatorValue::getSourceType, sourceType)
+                .isNotNull(IndicatorValue::getReportDate)      // null 日期不参与
+                .isNotNull(IndicatorValue::getNumericValue)    // 有有效数值
+                .lt(IndicatorValue::getReportDate, currentReportDate)  // 严格小于
+                .orderByDesc(IndicatorValue::getReportDate)
+                .last("LIMIT 1"));
+        return rows == null || rows.isEmpty() ? null : rows.get(0);
+    }
+
+    /**
+     * 批量上一期查找(主力入口,严禁 N+1)。
+     * 一次性拉该 (subjectId, sourceType) 的全部历史观测,内存分组后逐指标取最晚一期。
+     *
+     * @return definitionId → 上一期观测
+     */
+    public Map<Long, IndicatorValue> batchResolve(Long subjectId, String sourceType,
+                                                   java.sql.Date currentReportDate) {
+        if (subjectId == null || currentReportDate == null) {
+            return Collections.emptyMap();
+        }
+        List<IndicatorValue> all = valueMapper.selectList(new LambdaQueryWrapper<IndicatorValue>()
+                .eq(IndicatorValue::getSubjectId, subjectId)
+                .eq(sourceType != null && !sourceType.trim().isEmpty(),
+                        IndicatorValue::getSourceType, sourceType)
+                .isNotNull(IndicatorValue::getReportDate)
+                .isNotNull(IndicatorValue::getNumericValue)
+                .lt(IndicatorValue::getReportDate, currentReportDate)
+                .orderByAsc(IndicatorValue::getReportDate));   // 升序,便于取最后一个
+        if (all == null || all.isEmpty()) {
+            return Collections.emptyMap();
+        }
+        Map<Long, List<IndicatorValue>> byDef = all.stream()
+                .filter(v -> v.getDefinitionId() != null)
+                .collect(Collectors.groupingBy(IndicatorValue::getDefinitionId));
+        Map<Long, IndicatorValue> previous = new HashMap<>();
+        for (Map.Entry<Long, List<IndicatorValue>> e : byDef.entrySet()) {
+            List<IndicatorValue> sorted = e.getValue();   // 已按 report_date 升序
+            previous.put(e.getKey(), sorted.get(sorted.size() - 1));
+        }
+        return previous;
+    }
+
+    /**
+     * 批量上一期查找(限定指标集合版本)。仍是 1 次查询,内存过滤 defIds。
+     */
+    public Map<Long, IndicatorValue> batchResolve(Long subjectId, String sourceType,
+                                                   Collection<Long> definitionIds,
+                                                   java.sql.Date currentReportDate) {
+        Map<Long, IndicatorValue> all = batchResolve(subjectId, sourceType, currentReportDate);
+        if (definitionIds == null || definitionIds.isEmpty()) {
+            return all;
+        }
+        Map<Long, IndicatorValue> filtered = new HashMap<>();
+        for (Long defId : definitionIds) {
+            IndicatorValue v = all.get(defId);
+            if (v != null) {
+                filtered.put(defId, v);
+            }
+        }
+        return filtered;
+    }
+
+    // ==================== 跨期跳比 ====================
+
+    /**
+     * skipGaps:两期之间「该成员有报告、但该指标无有效值」的期数。
+     */
+    public long computeSkipGaps(Long subjectId, String sourceType, Long definitionId,
+                                java.sql.Date fromDate, java.sql.Date toDate) {
+        if (subjectId == null || definitionId == null || fromDate == null || toDate == null) {
+            return 0L;
+        }
+        List<java.sql.Date> allPeriods = valueMapper.selectList(new LambdaQueryWrapper<IndicatorValue>()
+                        .eq(IndicatorValue::getSubjectId, subjectId)
+                        .eq(IndicatorValue::getSourceType, sourceType)
+                        .isNotNull(IndicatorValue::getReportDate)
+                        .select(IndicatorValue::getReportDate))
+                .stream().map(IndicatorValue::getReportDate).distinct().sorted()
+                .collect(Collectors.toList());
+        long reportsBetween = allPeriods.stream()
+                .filter(d -> d.after(fromDate) && d.before(toDate)).count();
+        long obsBetween = valueMapper.selectList(new LambdaQueryWrapper<IndicatorValue>()
+                        .eq(IndicatorValue::getSubjectId, subjectId)
+                        .eq(IndicatorValue::getSourceType, sourceType)
+                        .eq(IndicatorValue::getDefinitionId, definitionId)
+                        .isNotNull(IndicatorValue::getNumericValue)
+                        .isNotNull(IndicatorValue::getReportDate)
+                        .gt(IndicatorValue::getReportDate, fromDate)
+                        .lt(IndicatorValue::getReportDate, toDate)
+                        .select(IndicatorValue::getReportDate))
+                .stream().map(IndicatorValue::getReportDate).distinct().count();
+        return Math.max(0L, reportsBetween - obsBetween);
+    }
+
+    // ==================== 参考范围解析与判定 ====================
+
+    /**
+     * 解析参考范围。解析失败返回 parsed=false(调用方回落 neutral)。
+     * 支持:0.00-3.37 / 0.00~3.37 / <5.0 / >1.0 / 3.37 及全角变体。
+     */
+    public RefRange parseRefRange(String refRange) {
+        if (refRange == null || refRange.trim().isEmpty()) {
+            return RefRange.unparsed();
+        }
+        String s = normalize(refRange);
+        Matcher m;
+        if ((m = RANGE_PATTERN.matcher(s)).matches()) {
+            BigDecimal low = m.group(1) != null ? new BigDecimal(m.group(1)) : null;
+            BigDecimal high = m.group(2) != null ? new BigDecimal(m.group(2)) : null;
+            if (low == null && high == null) {
+                return RefRange.unparsed();
+            }
+            if (low != null && high != null && low.compareTo(high) > 0) {
+                return RefRange.unparsed();   // 倒挂 = 脏数据
+            }
+            return RefRange.of(low, high);
+        }
+        if ((m = LT_PATTERN.matcher(s)).matches()) {
+            return RefRange.of(null, new BigDecimal(m.group(1)));
+        }
+        if ((m = GT_PATTERN.matcher(s)).matches()) {
+            return RefRange.of(new BigDecimal(m.group(1)), null);
+        }
+        if ((m = SINGLE_PATTERN.matcher(s)).matches()) {
+            return RefRange.of(null, new BigDecimal(m.group(1)));
+        }
+        return RefRange.unparsed();
+    }
+
+    /** 全角/异形分隔符归一化 */
+    private String normalize(String s) {
+        return s.replace('-', '-').replace('—', '-').replace('–', '-')
+                .replace('〜', '~').replace('~', '~')
+                .replace('<', '<').replace('>', '>')
+                .replace("-", "-")
+                .replaceAll("\\s+", "")
+                .trim();
+    }
+
+    /**
+     * in_range_is_better 判定(修 PRD S10:不再恒判中性)。
+     *
+     * @return "improved" | "worsened" | "flat"
+     */
+    public String judgeInRange(BigDecimal before, BigDecimal after, RefRange ref) {
+        if (ref == null || !ref.isParsed() || before == null || after == null) {
+            return TREND_FLAT;   // 解析失败 → 回落中性
+        }
+        boolean b = ref.inRange(before);
+        boolean a = ref.inRange(after);
+        if (!b && a) {
+            return TREND_IMPROVED;   // 区外 → 区内
+        }
+        if (b && !a) {
+            return TREND_WORSENED;   // 区内 → 区外
+        }
+        if (b && a) {
+            return TREND_FLAT;        // 始终在区内
+        }
+        // 始终在区外:按"与区间边界的距离变化"判定(靠近 = 改善,远离 = 恶化)
+        BigDecimal db = ref.distance(before);
+        BigDecimal da = ref.distance(after);
+        BigDecimal eps = db.abs().multiply(BigDecimal.valueOf(flatThresholdPct / 100.0));
+        if (eps.compareTo(BigDecimal.valueOf(0.0001)) < 0) {
+            eps = BigDecimal.valueOf(0.0001);   // 防止 eps 为 0 导致恒判持平
+        }
+        if (da.subtract(db).abs().compareTo(eps) <= 0) {
+            return TREND_FLAT;                   // 位移在阈值内 = 无实质变化
+        }
+        return da.compareTo(db) < 0 ? TREND_IMPROVED : TREND_WORSENED;
+    }
+
+    // ==================== 变化计算主流程 ====================
+
+    /**
+     * 变化计算唯一入口(两期都有值时)。
+     */
+    public ChangeResult computeChange(IndicatorDefinition def, IndicatorValue before, IndicatorValue after) {
+        ChangeResult r = new ChangeResult();
+
+        // ---- 0. 可比性前置 ----
+        r.unitMismatch = before != null && after != null
+                && !Objects.equals(normUnit(before.getUnit()), normUnit(after.getUnit()));
+        String valueType = def != null ? def.getValueType() : null;
+
+        // 非数值指标:不算 Δ,只判有无变化
+        if (!"numeric".equals(valueType) || before == null || after == null
+                || before.getNumericValue() == null || after.getNumericValue() == null) {
+            r.comparable = false;
+            r.delta = null;
+            r.deltaPct = null;
+            if (before != null && after != null) {
+                boolean same = Objects.equals(before.getValue(), after.getValue());
+                r.movement = same ? MOVEMENT_UNCHANGED : MOVEMENT_CHANGED;
+                r.trendLabel = same ? "无变化" : "有变化";
+            } else {
+                r.movement = MOVEMENT_CHANGED;
+                r.trendLabel = "有变化";
+            }
+            r.trendCode = TREND_FLAT;   // 非数值永不标改善/恶化(合规红线)
+            r.skipGaps = 0L;
+            return r;
+        }
+
+        // ---- 1. Δ 与 movement ----
+        BigDecimal bv = before.getNumericValue();
+        BigDecimal av = after.getNumericValue();
+        r.delta = av.subtract(bv).setScale(4, RoundingMode.HALF_UP);
+        r.movement = movementOf(r.delta);
+
+        // 参考范围:优先该期快照,回落字典当前值
+        RefRange ref = parseRefRange(firstNonEmpty(after.getRefRangeSnapshot(),
+                before.getRefRangeSnapshot(),
+                def != null ? def.getRefRange() : null));
+        // 微小基数护栏:|before| < floor 时 Δ% 不具临床意义(护栏只压百分比,不压 Δ 绝对值)
+        BigDecimal floor = percentageFloor(ref);
+        boolean tinyBase = bv.abs().compareTo(floor) < 0;
+
+        r.deltaPct = (bv.compareTo(BigDecimal.ZERO) != 0 && !r.unitMismatch && !tinyBase)
+                ? r.delta.multiply(BigDecimal.valueOf(100))
+                        .divide(bv, 1, RoundingMode.HALF_UP)
+                : null;   // 基数为 0 / 单位不一致 / 微小基数 → 百分比不适用
+        r.comparable = true;
+
+        // ---- 2. 基本持平阈值(|Δ%| < 2% 且状态未变)----
+        boolean statusChanged = !Objects.equals(norm(before.getStatus()), norm(after.getStatus()));
+        boolean belowThreshold = r.deltaPct == null
+                || r.deltaPct.abs().compareTo(BigDecimal.valueOf(flatThresholdPct)) < 0;
+        if (belowThreshold && !statusChanged) {
+            r.trendCode = TREND_FLAT;
+            r.trendLabel = "基本持平";
+            return r;   // 箭头 movement 保留 ↑↓,词判"基本持平"
+        }
+
+        // ---- 3. 按 goodDirection 判定 ----
+        String gd = def != null && def.getGoodDirection() != null ? def.getGoodDirection() : "neutral";
+        String code;
+        if ("higher_is_better".equals(gd)) {
+            code = MOVEMENT_UP.equals(r.movement) ? TREND_IMPROVED
+                    : (MOVEMENT_DOWN.equals(r.movement) ? TREND_WORSENED : TREND_FLAT);
+        } else if ("lower_is_better".equals(gd)) {
+            code = MOVEMENT_DOWN.equals(r.movement) ? TREND_IMPROVED
+                    : (MOVEMENT_UP.equals(r.movement) ? TREND_WORSENED : TREND_FLAT);
+        } else if ("in_range_is_better".equals(gd)) {
+            code = judgeInRange(bv, av, ref);
+        } else {
+            code = TREND_FLAT;   // neutral / 未知:保留箭头,不判好坏
+        }
+        r.trendCode = code;
+        r.trendLabel = TREND_IMPROVED.equals(code) ? "改善"
+                : (TREND_WORSENED.equals(code) ? "恶化" : "基本持平");
+        return r;
+    }
+
+    /**
+     * 变化计算入口(含"无上一期"两档)。
+     *
+     * @param olderReportCount 同成员同类型的更早报告份数:0 → 首次记录;>0 → 历史整理中
+     */
+    public ChangeResult computeChange(IndicatorDefinition def, IndicatorValue before, IndicatorValue after,
+                                      Integer olderReportCount) {
+        if (before == null || after == null) {
+            boolean pending = olderReportCount != null && olderReportCount > 0;
+            ChangeResult r = new ChangeResult();
+            r.comparable = false;
+            r.delta = null;
+            r.deltaPct = null;
+            r.movement = MOVEMENT_NONE;
+            r.trendCode = pending ? TREND_PENDING_BACKFILL : TREND_FIRST_RECORD;
+            r.trendLabel = pending ? "历史数据整理中,暂无法对比" : "首次记录 · 暂无对比";
+            r.skipGaps = 0L;
+            return r;
+        }
+        return computeChange(def, before, after);
+    }
+
+    /** Δ → movement */
+    public String movementOf(BigDecimal delta) {
+        if (delta == null) {
+            return MOVEMENT_NONE;
+        }
+        int c = delta.compareTo(BigDecimal.ZERO);
+        return c > 0 ? MOVEMENT_UP : (c < 0 ? MOVEMENT_DOWN : MOVEMENT_FLAT);
+    }
+
+    /**
+     * 微小基数护栏:以满量程 refHigh 的 1% 为基准(参考范围下界通常为 0,用下界会让护栏失效)。
+     */
+    public BigDecimal percentageFloor(RefRange ref) {
+        if (ref != null && ref.isParsed()) {
+            if (ref.getHigh() != null) {
+                return max(ref.getHigh().multiply(BigDecimal.valueOf(0.01)), MIN_FLOOR);
+            }
+            if (ref.getLow() != null) {
+                return max(ref.getLow().multiply(BigDecimal.valueOf(0.1)), MIN_FLOOR);
+            }
+        }
+        return DEFAULT_FLOOR;
+    }
+
+    /** 排序键:|Δ%| 降序;deltaPct 为 null 的恒定排最后(由调用方处理 null) */
+    public BigDecimal rankKeyOf(ChangeResult r) {
+        return r == null || r.getDeltaPct() == null ? null : r.getDeltaPct().abs();
+    }
+
+    /** 图表形态建议值(后端单点定义,前端照做) */
+    public String chartModeOf(int pointsCount, String granularity) {
+        if (pointsCount <= 0) {
+            return "empty";
+        }
+        if (pointsCount == 1) {
+            return "single";
+        }
+        if (pointsCount == 2) {
+            return "pair";
+        }
+        if (pointsCount >= 20) {
+            return "line_aggregated";
+        }
+        return "line";
+    }
+
+    /** 按 yyyy-MM 聚合取组内末值(保留原始 reportDate 与 dateSource,不伪造月首/月末) */
+    public List<IndicatorValue> aggregateByMonth(List<IndicatorValue> points) {
+        if (points == null || points.isEmpty()) {
+            return new ArrayList<>();
+        }
+        Map<String, IndicatorValue> byMonth = new LinkedHashMap<>();
+        List<IndicatorValue> sorted = new ArrayList<>(points);
+        sorted.sort(NULLS_LAST_DESC);
+        Collections.reverse(sorted);   // 升序遍历,后写的覆盖前面的 → 取组内最晚一期
+        for (IndicatorValue v : sorted) {
+            Date d = v.getReportDate();
+            if (d == null) {
+                continue;
+            }
+            String ym = new java.text.SimpleDateFormat("yyyy-MM").format(d);
+            byMonth.put(ym, v);
+        }
+        List<IndicatorValue> out = new ArrayList<>(byMonth.values());
+        out.sort(NULLS_LAST_DESC);
+        return out;
+    }
+
+    /**
+     * 读路径统一取启用中的指标定义(Q17 强制):只取 status=1,
+     * 拿不到的观测由调用方剔除,不降级为"指标"+id 的假名称。
+     */
+    public Map<Long, IndicatorDefinition> loadActiveDefinitions(Collection<Long> defIds) {
+        if (defIds == null || defIds.isEmpty()) {
+            return Collections.emptyMap();
+        }
+        List<IndicatorDefinition> defs = definitionMapper.selectList(
+                new LambdaQueryWrapper<IndicatorDefinition>()
+                        .in(IndicatorDefinition::getId, defIds)
+                        .eq(IndicatorDefinition::getStatus, 1));
+        if (defs == null || defs.isEmpty()) {
+            return Collections.emptyMap();
+        }
+        return defs.stream().collect(Collectors.toMap(IndicatorDefinition::getId, d -> d, (a, b) -> a));
+    }
+
+    // ==================== 工具 ====================
+
+    private static String firstNonEmpty(String... values) {
+        for (String v : values) {
+            if (v != null && !v.trim().isEmpty()) {
+                return v;
+            }
+        }
+        return null;
+    }
+
+    private static String norm(String s) {
+        return s == null ? null : s.trim();
+    }
+
+    /** 单位归一化(去空格 + 全角括号/乘号统一) */
+    private static String normUnit(String s) {
+        if (s == null) {
+            return null;
+        }
+        return s.replaceAll("\\s+", "")
+                .replace('(', '(').replace(')', ')')
+                .replace('×', 'x').replace('*', '*');
+    }
+
+    private static BigDecimal max(BigDecimal a, BigDecimal b) {
+        return a.compareTo(b) >= 0 ? a : b;
+    }
+
+    // ==================== 内部类 ====================
+
+    /**
+     * 变化计算结果。字段名与接口出参保持一致,便于 T02 直接组装。
+     */
+    public static class ChangeResult {
+        private BigDecimal delta;
+        private BigDecimal deltaPct;
+        private String movement;
+        private String trendLabel;
+        private String trendCode;
+        private long skipGaps;
+        private boolean unitMismatch;
+        private boolean comparable;
+
+        public BigDecimal getDelta() { return delta; }
+        public void setDelta(BigDecimal delta) { this.delta = delta; }
+        public BigDecimal getDeltaPct() { return deltaPct; }
+        public void setDeltaPct(BigDecimal deltaPct) { this.deltaPct = deltaPct; }
+        public String getMovement() { return movement; }
+        public void setMovement(String movement) { this.movement = movement; }
+        public String getTrendLabel() { return trendLabel; }
+        public void setTrendLabel(String trendLabel) { this.trendLabel = trendLabel; }
+        public String getTrendCode() { return trendCode; }
+        public void setTrendCode(String trendCode) { this.trendCode = trendCode; }
+        public long getSkipGaps() { return skipGaps; }
+        public void setSkipGaps(long skipGaps) { this.skipGaps = skipGaps; }
+        public boolean isUnitMismatch() { return unitMismatch; }
+        public void setUnitMismatch(boolean unitMismatch) { this.unitMismatch = unitMismatch; }
+        public boolean isComparable() { return comparable; }
+        public void setComparable(boolean comparable) { this.comparable = comparable; }
+
+        /** 组装成接口出参(字段顺序固定,便于前端与测试断言) */
+        public Map<String, Object> toMap() {
+            Map<String, Object> m = new LinkedHashMap<>();
+            m.put("delta", delta);
+            m.put("deltaPct", deltaPct);
+            m.put("movement", movement);
+            m.put("trendLabel", trendLabel);
+            m.put("trendCode", trendCode);
+            m.put("skipGaps", skipGaps);
+            m.put("unitMismatch", unitMismatch);
+            m.put("comparable", comparable);
+            return m;
+        }
+    }
+
+    /**
+     * 参考范围(解析结果)。parsed=false 表示无法解析,判定回落 neutral。
+     */
+    public static class RefRange {
+        private final BigDecimal low;
+        private final BigDecimal high;
+        private final boolean parsed;
+
+        private RefRange(BigDecimal low, BigDecimal high, boolean parsed) {
+            this.low = low;
+            this.high = high;
+            this.parsed = parsed;
+        }
+
+        public static RefRange of(BigDecimal low, BigDecimal high) {
+            return new RefRange(low, high, true);
+        }
+
+        public static RefRange unparsed() {
+            return new RefRange(null, null, false);
+        }
+
+        public BigDecimal getLow() { return low; }
+        public BigDecimal getHigh() { return high; }
+        public boolean isParsed() { return parsed; }
+
+        /** 是否在范围内(区间内为 true;单边区间只比较存在的一侧) */
+        public boolean inRange(BigDecimal v) {
+            if (v == null) {
+                return false;
+            }
+            return (low == null || v.compareTo(low) >= 0)
+                    && (high == null || v.compareTo(high) <= 0);
+        }
+
+        /** 到区间边界的距离(区内为 0,区外为正) */
+        public BigDecimal distance(BigDecimal v) {
+            if (v == null) {
+                return BigDecimal.ZERO;
+            }
+            if (inRange(v)) {
+                return BigDecimal.ZERO;
+            }
+            if (low != null && v.compareTo(low) < 0) {
+                return low.subtract(v);
+            }
+            if (high != null && v.compareTo(high) > 0) {
+                return v.subtract(high);
+            }
+            return BigDecimal.ZERO;
+        }
+    }
+}

+ 279 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/IndicatorChangeServiceTest.java

@@ -0,0 +1,279 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.entity.IndicatorDefinition;
+import com.etotem.cfc.entity.IndicatorValue;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.math.BigDecimal;
+import java.sql.Date;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * 指标变化计算底座单测(T01 验收标准第 4 条)。
+ * 覆盖:refRange 五种格式解析、in_range 四分支、基数为 0、单位不一致、非数值、微小基数护栏。
+ */
+class IndicatorChangeServiceTest {
+
+    private IndicatorChangeService service;
+
+    @BeforeEach
+    void setUp() {
+        service = new IndicatorChangeService();
+    }
+
+    private IndicatorValue value(String num, String unit, String status, String refSnapshot) {
+        IndicatorValue v = new IndicatorValue();
+        v.setValue(num);
+        if (num != null) {
+            try {
+                v.setNumericValue(new BigDecimal(num));
+            } catch (NumberFormatException ignore) {
+                // 非数值型("阳性"等):numericValue 保持 null
+            }
+        }
+        v.setUnit(unit);
+        v.setStatus(status);
+        v.setRefRangeSnapshot(refSnapshot);
+        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;
+    }
+
+    // ==================== refRange 解析 ====================
+
+    @Test
+    void parseRefRange_rangeForms_parsed() {
+        for (String s : new String[]{"0.00-3.37", "0.00~3.37", "0.00–3.37", "0.00—3.37", "  0.00 - 3.37 "}) {
+            IndicatorChangeService.RefRange r = service.parseRefRange(s);
+            assertTrue(r.isParsed(), s);
+            assertEquals(0.0, r.getLow().doubleValue(), 1e-9, s);
+            assertEquals(3.37, r.getHigh().doubleValue(), 1e-9, s);
+        }
+    }
+
+    @Test
+    void parseRefRange_ltForm_highOnly() {
+        for (String s : new String[]{"<5.0", "<5.0", "≤5.0"}) {
+            IndicatorChangeService.RefRange r = service.parseRefRange(s);
+            assertTrue(r.isParsed(), s);
+            assertNull(r.getLow(), s);
+            assertEquals(5.0, r.getHigh().doubleValue(), 1e-9, s);
+        }
+    }
+
+    @Test
+    void parseRefRange_gtForm_lowOnly() {
+        for (String s : new String[]{">1.0", ">1.0", "≥1.0"}) {
+            IndicatorChangeService.RefRange r = service.parseRefRange(s);
+            assertTrue(r.isParsed(), s);
+            assertEquals(1.0, r.getLow().doubleValue(), 1e-9, s);
+            assertNull(r.getHigh(), s);
+        }
+    }
+
+    @Test
+    void parseRefRange_singleValue_highOnly() {
+        IndicatorChangeService.RefRange r = service.parseRefRange("3.37");
+        assertTrue(r.isParsed());
+        assertNull(r.getLow());
+        assertEquals(3.37, r.getHigh().doubleValue(), 1e-9);
+    }
+
+    @Test
+    void parseRefRange_garbage_returnsUnparsed() {
+        for (String s : new String[]{null, "", "  ", "阴性", "见报告", "3.37-1.00", "abc"}) {
+            assertFalse(service.parseRefRange(s).isParsed(), String.valueOf(s));
+        }
+    }
+
+    @Test
+    void refRange_inRangeAndDistance_singleSided() {
+        IndicatorChangeService.RefRange r = service.parseRefRange("<5.0");
+        assertTrue(r.inRange(new BigDecimal("4.9")));
+        assertFalse(r.inRange(new BigDecimal("5.1")));
+        assertEquals(0.1, r.distance(new BigDecimal("5.1")).doubleValue(), 1e-9);
+        assertEquals(0.0, r.distance(new BigDecimal("5.0")).doubleValue(), 1e-9);
+    }
+
+    // ==================== in_range_is_better 四分支 ====================
+
+    @Test
+    void judgeInRange_outToIn_isImproved() {
+        IndicatorChangeService.RefRange r = service.parseRefRange("3.37-5.80");
+        assertEquals("improved", service.judgeInRange(new BigDecimal("6.00"), new BigDecimal("4.00"), r));
+    }
+
+    @Test
+    void judgeInRange_inToOut_isWorsened() {
+        IndicatorChangeService.RefRange r = service.parseRefRange("3.37-5.80");
+        assertEquals("worsened", service.judgeInRange(new BigDecimal("4.00"), new BigDecimal("6.00"), r));
+    }
+
+    @Test
+    void judgeInRange_alwaysIn_isFlat() {
+        IndicatorChangeService.RefRange r = service.parseRefRange("3.37-5.80");
+        assertEquals("flat", service.judgeInRange(new BigDecimal("4.00"), new BigDecimal("4.20"), r));
+    }
+
+    @Test
+    void judgeInRange_alwaysOut_closerIsImproved_fartherIsWorsened() {
+        IndicatorChangeService.RefRange r = service.parseRefRange("3.37-5.80");
+        // 始终在区外:靠近上界 = 改善,远离 = 恶化
+        assertEquals("improved", service.judgeInRange(new BigDecimal("7.00"), new BigDecimal("6.20"), r));
+        assertEquals("worsened", service.judgeInRange(new BigDecimal("6.20"), new BigDecimal("7.00"), r));
+        // 位移在阈值(距离的 2%)内 = 持平(0.001 < 0.2×2%=0.004)
+        assertEquals("flat", service.judgeInRange(new BigDecimal("6.000"), new BigDecimal("6.001"), r));
+    }
+
+    @Test
+    void judgeInRange_unparsed_fallsBackNeutral() {
+        assertEquals("flat", service.judgeInRange(new BigDecimal("9"), new BigDecimal("1"),
+                service.parseRefRange("阴性")));
+    }
+
+    @Test
+    void inRangeIsBetter_noLongerConstantNeutral() {
+        IndicatorDefinition d = def("in_range_is_better", "numeric", "3.37-5.80", "mmol/L");
+        IndicatorChangeService.ChangeResult r = service.computeChange(d,
+                value("6.00", "mmol/L", "偏高", null), value("4.50", "mmol/L", "正常", null));
+        assertEquals("improved", r.getTrendCode());
+    }
+
+    // ==================== computeChange ====================
+
+    @Test
+    void computeChange_normal_increase_downIsBetter() {
+        IndicatorDefinition d = def("lower_is_better", "numeric", "0.00-3.37", "mmol/L");
+        IndicatorChangeService.ChangeResult r = service.computeChange(d,
+                value("2.00", "mmol/L", "正常", "0.00-3.37"), value("2.54", "mmol/L", "正常", "0.00-3.37"));
+        assertEquals("up", r.getMovement());
+        assertEquals(0.54, r.getDelta().doubleValue(), 1e-9);
+        assertEquals(27.0, r.getDeltaPct().doubleValue(), 1e-9);
+        assertEquals("worsened", r.getTrendCode());
+        assertTrue(r.isComparable());
+    }
+
+    @Test
+    void computeChange_zeroBase_deltaAbsPresent_pctNull() {
+        IndicatorDefinition d = def("lower_is_better", "numeric", "0.00-3.37", "mmol/L");
+        IndicatorChangeService.ChangeResult r = service.computeChange(d,
+                value("0.00", "mmol/L", "正常", null), value("1.00", "mmol/L", "正常", null));
+        assertEquals(1.0, r.getDelta().doubleValue(), 1e-9);   // Δ 绝对值照常输出
+        assertNull(r.getDeltaPct());                            // 基数为 0 → 不出百分比
+        assertEquals("up", r.getMovement());
+    }
+
+    @Test
+    void computeChange_unitMismatch_noPct_butDeltaKept() {
+        IndicatorDefinition d = def("higher_is_better", "numeric", "1.0-5.0", "mg/L");
+        IndicatorChangeService.ChangeResult r = service.computeChange(d,
+                value("3.00", "mg/L", "正常", null), value("3.50", "g/L", "正常", null));
+        assertTrue(r.isUnitMismatch());
+        assertNull(r.getDeltaPct());
+        assertEquals(0.50, r.getDelta().doubleValue(), 1e-9);
+    }
+
+    @Test
+    void computeChange_tinyBase_floorSuppressesPctOnly() {
+        // 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,
+                value("0.01", "mmol/L", "正常", "0.00-3.37"), value("0.05", "mmol/L", "正常", "0.00-3.37"));
+        assertNull(r.getDeltaPct());
+        assertEquals(0.04, r.getDelta().doubleValue(), 1e-9);
+        assertEquals("up", r.getMovement());
+    }
+
+    @Test
+    void computeChange_flatThreshold_twoPercent() {
+        IndicatorDefinition d = def("higher_is_better", "numeric", "1.0-100.0", "U");
+        // 100 → 101 只有 +1%:判基本持平,但箭头仍是 up
+        IndicatorChangeService.ChangeResult r = service.computeChange(d,
+                value("100.0", "U", "正常", null), value("101.0", "U", "正常", null));
+        assertEquals("flat", r.getTrendCode());
+        assertEquals("基本持平", r.getTrendLabel());
+        assertEquals("up", r.getMovement());
+    }
+
+    @Test
+    void computeChange_nonNumeric_onlyChangedJudgement() {
+        IndicatorDefinition d = def(null, "text", null, null);
+        IndicatorChangeService.ChangeResult r = service.computeChange(d,
+                value("阳性", null, "偏高", null), value("阴性", null, "正常", null));
+        assertFalse(r.isComparable());
+        assertNull(r.getDelta());
+        assertNull(r.getDeltaPct());
+        assertEquals("changed", r.getMovement());
+        assertEquals("有变化", r.getTrendLabel());
+        assertEquals("flat", r.getTrendCode());
+
+        IndicatorChangeService.ChangeResult same = service.computeChange(d,
+                value("阴性", null, "正常", null), value("阴性", null, "正常", null));
+        assertEquals("unchanged", same.getMovement());
+        assertEquals("无变化", same.getTrendLabel());
+    }
+
+    @Test
+    void computeChange_neutralDirection_keepsArrow_noGoodOrBad() {
+        IndicatorDefinition d = def("neutral", "numeric", "1.0-5.0", "mg/L");
+        IndicatorChangeService.ChangeResult r = service.computeChange(d,
+                value("1.00", "mg/L", "正常", null), value("2.00", "mg/L", "正常", null));
+        assertEquals("flat", r.getTrendCode());
+        assertEquals("up", r.getMovement());
+    }
+
+    @Test
+    void computeChange_noPrevious_returnsFiveValueCodes() {
+        IndicatorChangeService.ChangeResult first = service.computeChange(null, null,
+                value("1.00", "U", "正常", null), 0);
+        assertEquals("first_record", first.getTrendCode());
+        assertEquals("首次记录 · 暂无对比", first.getTrendLabel());
+        assertEquals("none", first.getMovement());
+        assertFalse(first.isComparable());
+
+        IndicatorChangeService.ChangeResult pending = service.computeChange(null, null,
+                value("1.00", "U", "正常", null), 3);
+        assertEquals("pending_backfill", pending.getTrendCode());
+        assertEquals("历史数据整理中,暂无法对比", pending.getTrendLabel());
+    }
+
+    @Test
+    void computeChange_refRangeSnapshotOverridesDictionary() {
+        // 字典 refRange 空,快照为 0.00-3.37 → 用快照解析护栏
+        IndicatorDefinition d = def("lower_is_better", "numeric", null, "mmol/L");
+        IndicatorChangeService.ChangeResult r = service.computeChange(d,
+                value("0.01", "mmol/L", "正常", "0.00-3.37"), value("0.05", "mmol/L", "正常", "0.00-3.37"));
+        assertNull(r.getDeltaPct());
+    }
+
+    @Test
+    void rankKey_nullWhenNoPct() {
+        IndicatorDefinition d = def("higher_is_better", "numeric", "0.00-3.37", "mmol/L");
+        IndicatorChangeService.ChangeResult r = service.computeChange(d,
+                value("0.01", "mmol/L", "正常", "0.00-3.37"), value("0.05", "mmol/L", "正常", "0.00-3.37"));
+        assertNull(service.rankKeyOf(r));
+    }
+
+    // ==================== chartMode ====================
+
+    @Test
+    void chartModeOf_fiveModes() {
+        assertEquals("empty", service.chartModeOf(0, "raw"));
+        assertEquals("single", service.chartModeOf(1, "raw"));
+        assertEquals("pair", service.chartModeOf(2, "raw"));
+        assertEquals("line", service.chartModeOf(5, "raw"));
+        assertEquals("line_aggregated", service.chartModeOf(25, "raw"));
+        // n < 20 时忽略 granularity(设计 §4.2 chartMode 判定表)
+        assertEquals("line", service.chartModeOf(5, "month"));
+    }
+}