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

feat(T01): 指标值落库补日期来源/参考范围快照,放开 collection_date 映射

- IndicatorValue 新增 dateSource / refRangeSnapshot 字段
- upsertMapping 的 INSERT 与 ON DUPLICATE 子句同步 2 列
- NormalizeIndicatorPipeline 写入日期来源标记与参考范围快照
- HealthIndicator.collectionDate 移除 @TableField(exist=false)(前置列存在性兜底已在迁移422)
Sisyphus 2 недель назад
Родитель
Сommit
27f2d0de8d

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

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