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

Merge T01: 指标多期展示(DS-2026-10-25-01)

合并 422/423 迁移到 cfclub:
- init() 依次调用 addActivityAudienceColumns()(A2/421) → ensureHealthIndicatorsCollectionDateColumn()
  → migrateIndicatorValuesTimelineColumns()(422) → createBodyFatScaleRecordsTable()(423)
- 复用 cfclub 既有注释/迁移(T28 family_food_overview 等)为权威基线,仅叠加 T01 三个方法定义
- 解决 DatabaseInitializer.java init() 尾部迁移顺序冲突:A2 调用与 T01 调用共存,init() 闭括号保留,方法定义置于其后
Agnes Agent 2 недель назад
Родитель
Сommit
880d18ff58

+ 61 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -3332,6 +3332,13 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
         // 说明:沿用 visible_scope 迁移的 information_schema.COLUMNS 存在性检查范式,
         //      以便用 AFTER visible_to 精确定位列顺序(ensureColumn 不支持列定位)。
         addActivityAudienceColumns();
+        // 迁移422: 指标多期展示(DS-2026-10-25-01)
+        // 顺序红线:先确保 health_indicators.collection_date 物理列存在,才能放开实体映射;
+        //          顺序颠倒会导致缺列环境所有 health_indicators 查询报 Unknown column。
+        ensureHealthIndicatorsCollectionDateColumn();
+        migrateIndicatorValuesTimelineColumns();
+        // 迁移423: 体脂秤测量记录表(sourceId 由 memberId 改为 recordId 的前置表)
+        createBodyFatScaleRecordsTable();
     }
 
     /**
@@ -14165,6 +14172,60 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
 
     // 迁移420: 创建 ppoint_consumption_log(消费支付成功时落一行,按消费时间滚动一年聚合)
     // 迁移420(F-4):建表幂等,包内可见以便单测直接调用
+
+    /**
+     * 迁移422 前置:确保 health_indicators.collection_date 物理列存在。
+     * 放开 HealthIndicator.collectionDate 的 @TableField(exist=false) 之前必须先跑这一步;
+     * 任一环境缺列都会让所有 health_indicators 查询报 Unknown column(报告详情页整体崩溃)。
+     */
+    void ensureHealthIndicatorsCollectionDateColumn() {
+        if (!columnExists("health_indicators", "collection_date")) {
+            ensureColumn("health_indicators", "collection_date",
+                    "DATE DEFAULT NULL COMMENT '指标采集日期(从报告提取的检测日期,非入库时间)'");
+            log.info("迁移422: 已补建 health_indicators.collection_date 列");
+        } else {
+            log.info("迁移422: health_indicators.collection_date 已存在,放开实体映射的前置条件满足");
+        }
+    }
+
+    /**
+     * 迁移422: indicator_values 增加日期来源标记 + 参考范围快照。
+     * date_source 区分采样日/上传日;ref_range_snapshot 保证历史判定可复现(字典改了不影响历史)。
+     */
+    void migrateIndicatorValuesTimelineColumns() {
+        ensureColumn("indicator_values", "date_source",
+                "VARCHAR(16) DEFAULT 'unknown' COMMENT 'report_date 来源: real=采样日/upload=上传日/confirm=确认日/unknown=未知(存量)'");
+        ensureColumn("indicator_values", "ref_range_snapshot",
+                "VARCHAR(100) DEFAULT NULL COMMENT '该期参考范围快照(为空时回落 indicator_definitions.ref_range)'");
+        log.info("迁移422: indicator_values 已补齐日期来源与参考范围快照 2 列");
+    }
+
+    // ==================== 迁移423:体脂秤测量记录表 ====================
+
+    /**
+     * 迁移423: 体脂秤测量记录表(一次测量一条记录)。
+     * 现状 sourceId=memberId + insert 会在第二次测量时撞 uk_source_definition 导致整批保存失败;
+     * 本表提供 recordId 作为 indicator_values.source_id 的新取值语义(服务层改造见 T05)。
+     */
+    void createBodyFatScaleRecordsTable() {
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `body_fat_scale_records` ("
+                    + "`id` BIGINT AUTO_INCREMENT PRIMARY KEY, "
+                    + "`member_id` BIGINT NOT NULL COMMENT '家庭成员ID(family_members.id)', "
+                    + "`family_id` BIGINT NULL COMMENT '所属家庭', "
+                    + "`image_url` VARCHAR(500) NULL COMMENT '体脂秤照片', "
+                    + "`measured_at` DATE NULL COMMENT '测量日期(取不到为NULL,观测 report_date 记为上传日)', "
+                    + "`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, "
+                    + "INDEX `idx_member_date` (`member_id`, `measured_at`) "
+                    + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='体脂秤测量记录'");
+            log.info("迁移423: 已创建 body_fat_scale_records 表");
+        } catch (Exception e) {
+            log.warn("迁移423: 创建 body_fat_scale_records 表失败(可能已存在): {}", e.getMessage());
+        }
+    }
+
+    
+
     void createPpointConsumptionLogTable() {
         try {
             jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `ppoint_consumption_log` ("

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

@@ -40,6 +40,7 @@ import com.etotem.cfc.service.HealthReportService;
 import com.etotem.cfc.service.MemberIndicatorCurrentService;
 import com.etotem.cfc.service.BasicQuotaService;
 import com.etotem.cfc.service.NutritionDeficiencyService;
+import com.etotem.cfc.service.NormalizeIndicatorPipeline;
 import com.etotem.cfc.service.PdfParseService;
 import com.etotem.cfc.service.ReportCollectService;
 import com.etotem.cfc.service.ReportTransferService;
@@ -185,6 +186,9 @@ public class HealthReportController {
     @Resource
     private com.etotem.cfc.service.PurchaseService purchaseService;
 
+    @Resource
+    private NormalizeIndicatorPipeline normalizeIndicatorPipeline;
+
     @Resource
     private MemberIndicatorCurrentService memberIndicatorCurrentService;
 
@@ -1437,6 +1441,13 @@ public class HealthReportController {
 
             HealthReport created = healthReportService.createReport(report, indicators, gutFlora, diseaseRisks);
 
+            // G2(DS-2026-10-25-01):confirm 链路补写统一指标层时序(失败不阻断确认)
+            try {
+                normalizeIndicatorPipeline.processReport(created, payload);
+            } catch (Exception e) {
+                log.warn("confirm 归一化管道写入失败 reportId={}: {}", created.getId(), e.getMessage());
+            }
+
             // 落库 payload 快照 + 创建日志
             healthReportService.savePayloadSnapshot(created.getId(), userId, "create", null, payloadJson);
 

+ 28 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/IndicatorAdminController.java

@@ -3,9 +3,11 @@ package com.etotem.cfc.controller.admin;
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.entity.IndicatorDefinition;
 import com.etotem.cfc.entity.IndicatorValue;
+import com.etotem.cfc.service.HealthReportService;
 import com.etotem.cfc.service.IndicatorService;
 import org.springframework.web.bind.annotation.PostMapping;
 import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestAttribute;
 import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RestController;
 import javax.annotation.Resource;
@@ -20,6 +22,9 @@ public class IndicatorAdminController {
     @Resource
     private IndicatorService indicatorService;
 
+    @Resource
+    private HealthReportService healthReportService;
+
     @PostMapping("/list")
     public Result<?> list(@RequestBody Map<String, Object> params) {
         String domain = (String) params.getOrDefault("domain", null);
@@ -55,4 +60,27 @@ public class IndicatorAdminController {
         List<IndicatorValue> values = indicatorService.listValues(sourceType, sourceId);
         return Result.success(values);
     }
+
+    /**
+     * 存量时序回填(DS-2026-10-25-01 §4.6)。
+     * dryRun=true(默认)只统计不写入,返回 eligible / dateFixable 两个计数;
+     * dryRun=false 才实际写入,且顺序上先修日期再补时序。
+     *
+     * 入参:subjectId? / reportId? / limit(默认200) / dryRun(默认true) / fixReportDates(默认false)
+     */
+    @PostMapping("/backfill")
+    public Result<?> backfill(@RequestBody Map<String, Object> params,
+                              @RequestAttribute(value = "role", required = false) String role) {
+        if (!"admin".equals(role)) {
+            return Result.error("权限不足");
+        }
+        Long subjectId = ParamUtils.getLong(params.get("subjectId"));
+        Long reportId = ParamUtils.getLong(params.get("reportId"));
+        Long limitLong = ParamUtils.getLong(params.get("limit"));
+        Integer limit = limitLong == null ? null : limitLong.intValue();
+        boolean dryRun = !Boolean.FALSE.equals(ParamUtils.getBoolean(params.get("dryRun")));
+        boolean fixReportDates = Boolean.TRUE.equals(ParamUtils.getBoolean(params.get("fixReportDates")));
+        return Result.success(
+                healthReportService.backfillIndicatorValues(subjectId, reportId, limit, dryRun, fixReportDates));
+    }
 }

+ 1 - 3
cfc-backend/src/main/java/com/etotem/cfc/entity/HealthIndicator.java

@@ -1,7 +1,6 @@
 package com.etotem.cfc.entity;
 
 import com.baomidou.mybatisplus.annotation.IdType;
-import com.baomidou.mybatisplus.annotation.TableField;
 import com.baomidou.mybatisplus.annotation.TableId;
 import com.baomidou.mybatisplus.annotation.TableName;
 import lombok.Data;
@@ -47,8 +46,7 @@ public class HealthIndicator implements Serializable {
     /** 排序 */
     private Integer sortOrder;
 
-    /** 指标采集日期(从报告提取的检测日期,非入库时间) — DB列不存在 */
-    @TableField(exist = false)
+    /** 指标采集日期(从报告提取的检测日期,非入库时间) — 迁移422 已确保物理列存在 */
     private Date collectionDate;
 
     private Date createdAt;

+ 8 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/IndicatorValue.java

@@ -22,6 +22,10 @@ public class IndicatorValue {
     private java.sql.Date reportDate;
     private String unit;
     private String status;
+    /** report_date 来源: real/upload/confirm/unknown */
+    private String dateSource;
+    /** 该期参考范围快照(为空时回落 indicator_definitions.ref_range) */
+    private String refRangeSnapshot;
     private Date recordedAt;
 
     public Long getId() { return id; }
@@ -50,6 +54,10 @@ public class IndicatorValue {
     public void setUnit(String unit) { this.unit = unit; }
     public String getStatus() { return status; }
     public void setStatus(String status) { this.status = status; }
+    public String getDateSource() { return dateSource; }
+    public void setDateSource(String dateSource) { this.dateSource = dateSource; }
+    public String getRefRangeSnapshot() { return refRangeSnapshot; }
+    public void setRefRangeSnapshot(String refRangeSnapshot) { this.refRangeSnapshot = refRangeSnapshot; }
     public Date getRecordedAt() { return recordedAt; }
     public void setRecordedAt(Date recordedAt) { this.recordedAt = recordedAt; }
 }

+ 7 - 3
cfc-backend/src/main/java/com/etotem/cfc/mapper/IndicatorValueMapper.java

@@ -12,12 +12,16 @@ public interface IndicatorValueMapper extends BaseMapper<IndicatorValue> {
     /** 幂等写:同 (source_type, source_id, definition_id) 覆盖更新(uk_source_definition) */
     @Insert("INSERT INTO indicator_values " +
             "(source_type, source_id, definition_id, value, numeric_value, remark, " +
-            " subject_id, family_id, report_id, report_date, unit, status, recorded_at) " +
+            " subject_id, family_id, report_id, report_date, unit, status, " +
+            " date_source, ref_range_snapshot, recorded_at) " +
             "VALUES (#{v.sourceType}, #{v.sourceId}, #{v.definitionId}, #{v.value}, #{v.numericValue}, #{v.remark}, " +
-            " #{v.subjectId}, #{v.familyId}, #{v.reportId}, #{v.reportDate}, #{v.unit}, #{v.status}, NOW()) " +
+            " #{v.subjectId}, #{v.familyId}, #{v.reportId}, #{v.reportDate}, #{v.unit}, #{v.status}, " +
+            " #{v.dateSource}, #{v.refRangeSnapshot}, NOW()) " +
             "ON DUPLICATE KEY UPDATE " +
             " value = VALUES(value), numeric_value = VALUES(numeric_value), remark = VALUES(remark), " +
             " subject_id = VALUES(subject_id), family_id = VALUES(family_id), report_id = VALUES(report_id), " +
-            " report_date = VALUES(report_date), unit = VALUES(unit), status = VALUES(status), recorded_at = NOW()")
+            " report_date = VALUES(report_date), unit = VALUES(unit), status = VALUES(status), " +
+            " date_source = VALUES(date_source), ref_range_snapshot = VALUES(ref_range_snapshot), " +
+            " recorded_at = NOW()")
     int upsertMapping(@Param("v") IndicatorValue v);
 }

+ 153 - 2
cfc-backend/src/main/java/com/etotem/cfc/service/HealthReportService.java

@@ -121,6 +121,9 @@ public class HealthReportService {
     @Resource
     private com.etotem.cfc.service.NormalizeIndicatorPipeline normalizeIndicatorPipeline;
 
+    @Resource
+    private ReportDateExtractor reportDateExtractor;
+
     /**
      * 创建健康报告(含指标列表)
      */
@@ -1282,10 +1285,12 @@ public class HealthReportService {
     public void bindReportToMember(Long reportId, Long subjectId) {
         // 使用 JdbcTemplate 直接 UPDATE,避免 MyBatis-Plus NOT_NULL 策略导致 NULL 值不更新
         String sql = "UPDATE health_reports SET subject_id = ? WHERE id = ?";
+        Long previousSubjectId = null;
         // A方案:写库前归一化为 family_members.id 口径
         if (subjectId != null && subjectId > 0) {
             HealthReport rep = healthReportMapper.selectById(reportId);
             if (rep != null) {
+                previousSubjectId = rep.getSubjectId();
                 Long resolved = resolveSubjectToMemberId(subjectId, rep.getFamilyId());
                 if (resolved != null && !resolved.equals(subjectId)) {
                     log.info("subjectId归一化 reportId={}, {} -> {}", reportId, subjectId, resolved);
@@ -1296,9 +1301,155 @@ public class HealthReportService {
         int updatedRows = jdbcTemplate.update(sql, subjectId, reportId);
         if (updatedRows == 0) {
             log.warn("reportId={}, subjectId={} 更新失败 - 报告不存在", reportId, subjectId);
-        } else {
-            log.info("已绑定报告 {} 到被检测人 {}", reportId, subjectId);
+            return;
+        }
+        log.info("已绑定报告 {} 到被检测人 {}", reportId, subjectId);
+
+        // G3(DS-2026-10-25-01):上传时未选成员 → NormalizeIndicatorPipeline 因 subjectId==null 静默跳过,
+        // 仅 UPDATE subject_id 不会补写时序,用户会看到「绑了成员却没有历史」。
+        // 只在「此前无归属」时补跑(即 G3 缺口),失败不阻断绑定;processReport 底层 upsert 幂等。
+        if (previousSubjectId == null && subjectId != null) {
+            try {
+                HealthReport rep = healthReportMapper.selectById(reportId);
+                if (rep != null && rep.getSubjectId() != null && rep.getPayloadJson() != null) {
+                    ParsedReportPayload.Payload payload =
+                            objectMapper.readValue(rep.getPayloadJson(), ParsedReportPayload.Payload.class);
+                    normalizeIndicatorPipeline.processReport(rep, payload);
+                    log.info("绑定成员后补写时序 reportId={}, subjectId={}", reportId, subjectId);
+                }
+            } catch (Exception e) {
+                log.warn("绑定成员后补写时序失败 reportId={}: {}", reportId, e.getMessage());
+            }
+        }
+    }
+
+    /**
+     * 存量时序回填(/api/admin/indicator/backfill 的服务端,DS-2026-10-25-01 §4.6)。
+     *
+     * <p>顺序红线:先做日期修复、再做时序回填(污染日期若先固化进 indicator_values 再修就晚了)。
+     * dryRun=true 时只统计不写入,绝不产生任何副作用(不触发 mappingService.resolve,避免惰性建定义)。</p>
+     *
+     * @return scanned / eligible / written / skippedNoSubject / skippedNoPayload / failed /
+     *         dryRun / fixReportDates / dateFixable / dateFixed / details
+     */
+    public Map<String, Object> backfillIndicatorValues(Long subjectId, Long reportId, Integer limit,
+                                                       boolean dryRun, boolean fixReportDates) {
+        int batchLimit = (limit == null || limit < 1) ? 200 : Math.min(limit, 500);
+        LambdaQueryWrapper<HealthReport> qw = new LambdaQueryWrapper<HealthReport>()
+                .eq(HealthReport::getStatus, "active");
+        if (subjectId != null) {
+            qw.eq(HealthReport::getSubjectId, subjectId);
+        }
+        if (reportId != null) {
+            qw.eq(HealthReport::getId, reportId);
+        }
+        qw.orderByAsc(HealthReport::getId).last("LIMIT " + batchLimit);
+        List<HealthReport> reports = healthReportMapper.selectList(qw);
+
+        int scanned = reports.size();
+        int eligible = 0;
+        int written = 0;
+        int skippedNoSubject = 0;
+        int skippedNoPayload = 0;
+        int failed = 0;
+        int dateFixable = 0;
+        int dateFixed = 0;
+        List<Map<String, Object>> details = new ArrayList<>();
+
+        for (HealthReport r : reports) {
+            // ---- 第 1 步:日期修复判定(幂等:修好后条件不再成立)----
+            try {
+                java.sql.Date extracted = reportDateExtractor.extractReportDate(r.getPayloadJson());
+                if (extracted != null && r.getReportDate() != null
+                        && !toDay(extracted).equals(toDay(r.getReportDate()))
+                        && !r.getReportDate().before(toDay(r.getCreatedAt()))) {
+                    // report_date >= 入库日当天 = new Date() fallback 污染特征 → 修回真实采样日
+                    dateFixable++;
+                    if (!dryRun && fixReportDates) {
+                        String oldDate = r.getReportDate() == null ? null : toDay(r.getReportDate()).toString();
+                        r.setReportDate(extracted);
+                        healthReportMapper.updateById(r);
+                        dateFixed++;
+                        try {
+                            refreshMemberIndicatorCurrent(r.getSubjectId(), r.getId());
+                        } catch (Exception e) {
+                            log.warn("日期修复后刷新指标当前值失败 reportId={}: {}", r.getId(), e.getMessage());
+                        }
+                        log.info("回填日期修复 reportId={}, {} → {}", r.getId(), oldDate, extracted);
+                    }
+                }
+            } catch (Exception e) {
+                log.warn("日期修复判定失败 reportId={}: {}", r.getId(), e.getMessage());
+            }
+
+            // ---- 第 2 步:时序回填判定 ----
+            if (r.getSubjectId() == null) {
+                skippedNoSubject++;
+                details.add(backfillDetail(r, "skip_no_subject"));
+                continue;
+            }
+            if (r.getPayloadJson() == null || r.getPayloadJson().trim().isEmpty()) {
+                skippedNoPayload++;
+                details.add(backfillDetail(r, "skip_no_payload"));
+                continue;
+            }
+            boolean covered = indicatorValueMapper.selectCount(new LambdaQueryWrapper<IndicatorValue>()
+                    .eq(IndicatorValue::getSourceType, r.getReportType())
+                    .eq(IndicatorValue::getSourceId, r.getId())) > 0;
+            if (covered) {
+                details.add(backfillDetail(r, "already_covered"));
+                continue;
+            }
+            eligible++;
+            String status = "would_write";
+            if (!dryRun) {
+                try {
+                    ParsedReportPayload.Payload payload =
+                            objectMapper.readValue(r.getPayloadJson(), ParsedReportPayload.Payload.class);
+                    normalizeIndicatorPipeline.processReport(r, payload);
+                    written++;
+                    status = "written";
+                } catch (Exception e) {
+                    failed++;
+                    status = "failed";
+                    log.warn("时序回填失败 reportId={}: {}", r.getId(), e.getMessage());
+                }
+            }
+            details.add(backfillDetail(r, status));
+        }
+
+        Map<String, Object> out = new HashMap<>();
+        out.put("scanned", scanned);
+        out.put("eligible", eligible);
+        out.put("written", written);
+        out.put("skippedNoSubject", skippedNoSubject);
+        out.put("skippedNoPayload", skippedNoPayload);
+        out.put("failed", failed);
+        out.put("dryRun", dryRun);
+        out.put("fixReportDates", fixReportDates);
+        out.put("dateFixable", dateFixable);
+        out.put("dateFixed", dateFixed);
+        out.put("details", details);
+        log.info("存量时序回填(dryRun={}, fixDates={}) scanned={}, eligible={}, dateFixable={}, 已写 {}",
+                dryRun, fixReportDates, scanned, eligible, dateFixable, written);
+        return out;
+    }
+
+    private Map<String, Object> backfillDetail(HealthReport r, String status) {
+        Map<String, Object> d = new HashMap<>();
+        d.put("reportId", r.getId());
+        d.put("status", status);
+        return d;
+    }
+
+    /** 截断到日(仅比较日期部分) */
+    private java.sql.Date toDay(java.util.Date d) {
+        if (d == null) {
+            return null;
         }
+        return java.sql.Date.valueOf(
+                java.time.Instant.ofEpochMilli(d.getTime())
+                        .atZone(java.time.ZoneId.systemDefault()).toLocalDate());
     }
 
     /**

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

+ 55 - 4
cfc-backend/src/main/java/com/etotem/cfc/service/NormalizeIndicatorPipeline.java

@@ -3,7 +3,9 @@ package com.etotem.cfc.service;
 import com.etotem.cfc.dto.ParsedReportPayload;
 import com.etotem.cfc.entity.FoodRecommendIndex;
 import com.etotem.cfc.entity.HealthReport;
+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;
@@ -31,6 +33,9 @@ public class NormalizeIndicatorPipeline {
     @Resource
     private IndicatorValueMapper valueMapper;
 
+    @Resource
+    private IndicatorDefinitionMapper definitionMapper;
+
     /**
      * 报告指标 + 菌属归一化写入。
      * 调用点:insertReport()(subjectId 块后)/ updateReportFromPayload()(步骤 5 后)。
@@ -49,6 +54,7 @@ public class NormalizeIndicatorPipeline {
         String sourceType = report.getReportType();
         Date reportDate = report.getReportDate() != null
                 ? new Date(report.getReportDate().getTime()) : null;
+        String dateSource = resolveDateSource(report);
 
         int written = 0;
         if (payload != null && payload.getIndicators() != null) {
@@ -60,7 +66,8 @@ public class NormalizeIndicatorPipeline {
                 if (defId == null) {
                     continue;
                 }
-                IndicatorValue v = baseValue(sourceType, reportId, defId, subjectId, familyId, reportDate);
+                IndicatorValue v = baseValue(sourceType, reportId, defId, subjectId, familyId, reportDate,
+                        dateSource, refRangeOf(defId));
                 v.setValue(ind.getIndicatorValue());
                 v.setNumericValue(mappingService.parseNumericValue(ind.getIndicatorValue()));
                 v.setUnit(ind.getUnit());
@@ -78,7 +85,8 @@ public class NormalizeIndicatorPipeline {
                 if (defId == null) {
                     continue;
                 }
-                IndicatorValue v = baseValue(sourceType, reportId, defId, subjectId, familyId, reportDate);
+                IndicatorValue v = baseValue(sourceType, reportId, defId, subjectId, familyId, reportDate,
+                        dateSource, refRangeOf(defId));
                 v.setValue(flora.getBacteriaValue());
                 v.setNumericValue(mappingService.parseNumericValue(flora.getBacteriaValue()));
                 v.setUnit("%");
@@ -106,7 +114,7 @@ public class NormalizeIndicatorPipeline {
             if (defId == null) {
                 continue;
             }
-            IndicatorValue v = baseValue("food_recommend", idx.getId(), defId, userId, null, null);
+            IndicatorValue v = baseValue("food_recommend", idx.getId(), defId, userId, null, null, "unknown", null);
             v.setValue(idx.getIndexScore() != null ? idx.getIndexScore().toString() : null);
             v.setNumericValue(idx.getIndexScore() != null
                     ? BigDecimal.valueOf(idx.getIndexScore()) : null);
@@ -117,7 +125,8 @@ public class NormalizeIndicatorPipeline {
     }
 
     private IndicatorValue baseValue(String sourceType, Long sourceId, Long defId,
-                                     Long subjectId, Long familyId, Date reportDate) {
+                                     Long subjectId, Long familyId, Date reportDate,
+                                     String dateSource, String refRangeSnapshot) {
         IndicatorValue v = new IndicatorValue();
         v.setSourceType(sourceType);
         v.setSourceId(sourceId);
@@ -125,6 +134,48 @@ public class NormalizeIndicatorPipeline {
         v.setSubjectId(subjectId);
         v.setFamilyId(familyId);
         v.setReportDate(reportDate);
+        v.setDateSource(dateSource);
+        v.setRefRangeSnapshot(refRangeSnapshot);
         return v;
     }
+
+    /** 参考范围快照:落库时点字典的值,保证历史判定可复现(失败返回 null,回落字典当前值) */
+    private String refRangeOf(Long defId) {
+        if (defId == null) {
+            return null;
+        }
+        try {
+            IndicatorDefinition def = definitionMapper.selectById(defId);
+            return def != null ? def.getRefRange() : null;
+        } catch (Exception e) {
+            log.warn("读取指标定义参考范围失败 defId={}: {}", defId, e.getMessage());
+            return null;
+        }
+    }
+
+    /**
+     * 日期来源判定(只打标记,不改变 reportDate 取值逻辑)。
+     * real    —— report_date 早于入库日,可判定为从报告内容/元数据提取的真实采样日
+     * upload  —— 日期与入库日相同或更晚(new Date() fallback 的特征),按上传日对待
+     * confirm —— 同上但来源为 DAN 报告(其 reportDate 取自确认入库时间)
+     * unknown —— reportDate 或 createdAt 缺失,无法判断
+     */
+    private String resolveDateSource(HealthReport report) {
+        if (report == null || report.getReportDate() == null || report.getCreatedAt() == null) {
+            return "unknown";
+        }
+        java.sql.Date reportDay = toDay(report.getReportDate());
+        java.sql.Date createdDay = toDay(report.getCreatedAt());
+        if (reportDay.before(createdDay)) {
+            return "real";
+        }
+        return "dan_a2".equals(report.getReportType()) ? "confirm" : "upload";
+    }
+
+    /** 截断到日(避免同一天因时分秒不同被误判为"采样日早于入库日") */
+    private java.sql.Date toDay(java.util.Date d) {
+        return java.sql.Date.valueOf(
+                java.time.Instant.ofEpochMilli(d.getTime())
+                        .atZone(java.time.ZoneId.systemDefault()).toLocalDate());
+    }
 }

+ 13 - 0
cfc-backend/src/main/resources/schema.sql

@@ -2592,6 +2592,8 @@ CREATE TABLE IF NOT EXISTS indicator_values (
     report_date DATE COMMENT '真实报告日期(时序查询键)',
     unit VARCHAR(32) COMMENT '观测单位',
     status VARCHAR(32) COMMENT '观测状态快照(正常/偏高/偏低等)',
+    date_source VARCHAR(16) DEFAULT 'unknown' COMMENT 'report_date 来源: real=报告提取的采样日 / upload=上传日(未识别到采样日) / confirm=确认入库日 / unknown=未知(存量行)',
+    ref_range_snapshot VARCHAR(100) DEFAULT NULL COMMENT '该期参考范围快照(为空时回落到 indicator_definitions.ref_range)',
     recorded_at DATETIME DEFAULT CURRENT_TIMESTAMP,
     UNIQUE KEY uk_source_definition (source_type, source_id, definition_id),
     INDEX idx_source (source_type, source_id),
@@ -2599,6 +2601,17 @@ CREATE TABLE IF NOT EXISTS indicator_values (
     INDEX idx_subject_def_date (subject_id, definition_id, report_date)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='全域指标值';
 
+-- ========== 体脂秤测量记录表(迁移423;一次测量一条记录,支撑 sourceId=recordId 的多期时序) ==========
+CREATE TABLE IF NOT EXISTS body_fat_scale_records (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    member_id BIGINT NOT NULL COMMENT '家庭成员ID(family_members.id)',
+    family_id BIGINT COMMENT '所属家庭',
+    image_url VARCHAR(500) COMMENT '体脂秤照片',
+    measured_at DATE COMMENT '测量日期(取不到为NULL,观测 report_date 记为上传日)',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    INDEX idx_member_date (member_id, measured_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='体脂秤测量记录';
+
 -- ========== 舌诊主记录表 ==========
 CREATE TABLE IF NOT EXISTS tongue_records (
     id BIGINT AUTO_INCREMENT PRIMARY KEY,

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