ソースを参照

feat(indicator): ReportDateExtractor 真实报告日期提取+历史回填(幂等分批)

iwt 3 日 前
親
コミット
a4a475c3e4

+ 145 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ReportDateExtractor.java

@@ -0,0 +1,145 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.HealthReport;
+import com.etotem.cfc.mapper.HealthReportMapper;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.List;
+
+/**
+ * 真实报告日期提取 + 历史回填。
+ * PDF 解析阶段将「报告日期/采样日期」写入 payload.reportDate 后,
+ * 入库逻辑(HealthReportService 既有 reportDate 修正)自动生效;
+ * 本类只负责:(1) 从 payload 提取真实日期(供回填/校验);
+ * (2) 扫描旧报告幂等回填 report_date(无法提取置 NULL,不伪装成上传时间)。
+ */
+@Service
+public class ReportDateExtractor {
+
+    private static final Logger log = LoggerFactory.getLogger(ReportDateExtractor.class);
+
+    @Resource
+    private HealthReportMapper healthReportMapper;
+
+    @Resource
+    private ObjectMapper objectMapper = new ObjectMapper();
+
+    /**
+     * 从 payload JSON 中提取真实报告日期。
+     * 取值顺序:summary.reportDate → 顶层 reportDate → indicators[].collectionDate(取第一个非空)。
+     * 支持 yyyy-MM-dd / yyyy/MM/dd;无法提取返回 null。
+     */
+    public java.sql.Date extractReportDate(String payloadJson) {
+        if (payloadJson == null || payloadJson.isEmpty()) {
+            return null;
+        }
+        try {
+            JsonNode root = objectMapper.readTree(payloadJson);
+            String dateStr = null;
+            JsonNode summary = root.get("summary");
+            if (summary != null && summary.isObject() && summary.hasNonNull("reportDate")) {
+                dateStr = summary.get("reportDate").asText();
+            }
+            if (dateStr == null || dateStr.trim().isEmpty()) {
+                if (root.hasNonNull("reportDate")) {
+                    dateStr = root.get("reportDate").asText();
+                }
+            }
+            java.sql.Date parsed = parseDate(dateStr);
+            if (parsed != null) {
+                return parsed;
+            }
+            // 回退:indicators[].collectionDate 取第一个非空
+            JsonNode indicators = root.get("indicators");
+            if (indicators != null && indicators.isArray()) {
+                for (JsonNode ind : indicators) {
+                    if (ind.hasNonNull("collectionDate")) {
+                        java.sql.Date d = parseDate(ind.get("collectionDate").asText());
+                        if (d != null) {
+                            return d;
+                        }
+                    }
+                }
+            }
+            return null;
+        } catch (Exception e) {
+            log.warn("ReportDateExtractor 解析 payload 失败: {}", e.getMessage());
+            return null;
+        }
+    }
+
+    private java.sql.Date parseDate(String dateStr) {
+        if (dateStr == null || dateStr.trim().isEmpty()) {
+            return null;
+        }
+        String s = dateStr.trim();
+        try {
+            SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
+            Date d = sdf.parse(s);
+            return new java.sql.Date(d.getTime());
+        } catch (Exception e) {
+            try {
+                SimpleDateFormat sdf = new SimpleDateFormat("yyyy/MM/dd");
+                Date d = sdf.parse(s);
+                return new java.sql.Date(d.getTime());
+            } catch (Exception e2) {
+                return null;
+            }
+        }
+    }
+
+    /**
+     * 历史回填:扫描 report_date 缺失或疑似上传时间的旧报告(且 payload 含 reportDate 字段),
+     * 提取真实日期并回填;无法提取置 NULL。分批(500/批)、幂等可重跑。
+     *
+     * @return 本轮处理的总行数
+     */
+    public int backfillHistoricalReports() {
+        int total = 0;
+        while (true) {
+            List<HealthReport> batch = healthReportMapper.selectList(
+                    new LambdaQueryWrapper<HealthReport>()
+                            .isNull(HealthReport::getReportDate)
+                            .like(HealthReport::getPayloadJson, "reportDate")
+                            .last("LIMIT 500"));
+            if (batch.isEmpty()) {
+                break;
+            }
+            for (HealthReport report : batch) {
+                try {
+                    java.sql.Date realDate = extractReportDate(report.getPayloadJson());
+                    if (realDate != null) {
+                        report.setReportDate(realDate);
+                        healthReportMapper.updateById(report);
+                        log.info("回填报告真实日期 reportId={}, reportDate={}", report.getId(), realDate);
+                    } else {
+                        // 置 NULL 并清 payload 中可提取性:无法提取的不再反复处理
+                        report.setReportDate(null);
+                        healthReportMapper.updateById(report);
+                        log.info("报告日期无法提取,置 NULL reportId={}", report.getId());
+                    }
+                    total++;
+                } catch (Exception e) {
+                    log.warn("回填报告日期失败 reportId={}: {}", report.getId(), e.getMessage());
+                }
+            }
+        }
+        log.info("ReportDateExtractor 历史回填完成,共处理 {} 行", total);
+        return total;
+    }
+
+    /** 每日 03:10 自动回填(幂等可重跑,连跑两次结果一致) */
+    @Scheduled(cron = "0 10 3 * * ?")
+    public void scheduledBackfill() {
+        backfillHistoricalReports();
+    }
+}

+ 52 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/ReportDateExtractorTest.java

@@ -0,0 +1,52 @@
+package com.etotem.cfc.service;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.text.SimpleDateFormat;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class ReportDateExtractorTest {
+
+    private ReportDateExtractor newExtractor() {
+        ReportDateExtractor e = new ReportDateExtractor();
+        // 不注入 mapper,仅测纯提取逻辑
+        return e;
+    }
+
+    @Test
+    void extractFromNestedSummary() throws Exception {
+        ReportDateExtractor e = newExtractor();
+        String json = "{\"summary\":{\"reportDate\":\"2026-08-15\",\"personName\":\"小李\"},\"indicators\":[]}";
+        java.sql.Date d = e.extractReportDate(json);
+        assertNotNull(d);
+        assertEquals("2026-08-15", new SimpleDateFormat("yyyy-MM-dd").format(d));
+    }
+
+    @Test
+    void extractFromFlatPayload() throws Exception {
+        ReportDateExtractor e = newExtractor();
+        String json = "{\"reportDate\":\"2026/08/15\"}";
+        java.sql.Date d = e.extractReportDate(json);
+        assertNotNull(d);
+        assertEquals("2026-08-15", new SimpleDateFormat("yyyy-MM-dd").format(d));
+    }
+
+    @Test
+    void extractReturnsNullWhenMissing() {
+        ReportDateExtractor e = newExtractor();
+        assertNull(e.extractReportDate("{\"summary\":{\"personName\":\"x\"}}"));
+        assertNull(e.extractReportDate(null));
+        assertNull(e.extractReportDate("not json"));
+    }
+
+    @Test
+    void extractFallsBackToIndicatorCollectionDate() throws Exception {
+        ReportDateExtractor e = newExtractor();
+        String json = "{\"summary\":{},\"indicators\":[{\"collectionDate\":\"2026-09-01\"}]}";
+        java.sql.Date d = e.extractReportDate(json);
+        assertNotNull(d);
+        assertEquals("2026-09-01", new SimpleDateFormat("yyyy-MM-dd").format(d));
+    }
+}