Jelajahi Sumber

fix(T04): 修正 D12§7 Q2 SQL 列名与成员级口径 + periods 接口 listPeriods NPE 空安全

E2E Test Bot 2 minggu lalu
induk
melakukan
a6750d7bee

+ 1 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/IndicatorTimelineService.java

@@ -832,7 +832,7 @@ public class IndicatorTimelineService {
     }
 
     private static class PeriodMeta {
-        Integer count;
+        Integer count = 0;
         Integer worstQuality;
         String worstDateSource;
     }

+ 36 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/IndicatorTimelineServiceTest.java

@@ -344,6 +344,42 @@ class IndicatorTimelineServiceTest {
         assertEquals("活跃指标", items.get(0).get("name"));
     }
 
+    // ==================== listPeriods 空安全(NPE 回归,T04) ====================
+
+    @Test
+    void listPeriods_subjectNull_returnsEmpty() {
+        // subjectId 为 null → 直接返回空 items,不查库、不 NPE
+        Map<String, Object> data = service.listPeriods(null, "gut_flora");
+        assertNull(data.get("subjectId"));
+        assertEquals("gut_flora", data.get("sourceType"));
+        assertTrue(castItems(data).isEmpty());
+    }
+
+    @Test
+    void listPeriods_noReports_returnsEmpty() {
+        when(healthReportMapper.selectList(any())).thenReturn(new ArrayList<>());
+        when(valueMapper.selectList(any())).thenReturn(new ArrayList<>());
+        Map<String, Object> data = service.listPeriods(88L, "gut_flora");
+        assertTrue(castItems(data).isEmpty());
+    }
+
+    @Test
+    void listPeriods_singleReportWithObs_noNpe() {
+        // 复现原 NPE:新 PeriodMeta 的 count 为 null,m.count++ 拆箱抛 NPE
+        List<IndicatorValue> obsList = new ArrayList<>();
+        IndicatorValue v = obs(512L, 1000L, "2026-10-08", "3.62");
+        v.setSourceId(1000L);
+        obsList.add(v);
+        when(valueMapper.selectList(any())).thenReturn(obsList);
+        when(healthReportMapper.selectList(any())).thenReturn(Arrays.asList(report(1000L, "2026-10-08")));
+
+        Map<String, Object> data = service.listPeriods(88L, "gut_flora");
+        List<Map<String, Object>> items = castItems(data);
+        assertEquals(1, items.size());
+        assertEquals(1, ((Number) items.get(0).get("indicatorCount")).intValue());
+        assertEquals("2026-10-08", items.get(0).get("reportDate"));
+    }
+
     @SuppressWarnings("unchecked")
     private List<Map<String, Object>> castItems(Map<String, Object> data) {
         return (List<Map<String, Object>>) data.get("items");

+ 24 - 19
docs/superpowers/specs/2026-10-25-indicator-timeline-display-design.md

@@ -2890,26 +2890,29 @@ FROM (
   WHERE r.status = 'active' AND r.subject_id IS NOT NULL
 ) t;
 
--- Q2:成员可用率(分母:有 >=2 份报告的成员)
+-- Q2:成员可用率
+--   分子 = 在 status=1 定义上有 ≥1 个指标跨 ≥2 个不同 report_date(numeric_value 非空)的成员数
+--   分母 = 有 ≥2 份不同 report_date 的 active 报告的成员数
+-- 口径(主理人已定):判定"多期"以 indicator_values.report_date 为准,不同 report_date 记为不同报告,
+--   与上传时间无关;同一 report_date 的多份报告视为同一期,不重复计数。
+-- 列名修正:indicator_definitions 主键列是 id(无 definition_id 列),join 用 d.id = v.definition_id。
 SELECT
-  SUM(CASE WHEN multi_period_defs > 0 THEN 1 ELSE 0 END) / COUNT(*) AS member_usable_rate
-FROM (
-  SELECT m.subject_id,
-         COUNT(DISTINCT d.definition_id) AS multi_period_defs
-  FROM (
-    SELECT r.subject_id, r.report_type
-    FROM health_reports r
-    WHERE r.status='active' AND r.subject_id IS NOT NULL AND r.report_date IS NOT NULL
-    GROUP BY r.subject_id, r.report_type
-    HAVING COUNT(*) >= 2
-  ) m
-  JOIN indicator_values v ON v.subject_id = m.subject_id AND v.source_type = m.report_type
-  JOIN (SELECT definition_id FROM indicator_definitions WHERE status = 1) d
-       ON d.definition_id = v.definition_id
-  WHERE v.report_date IS NOT NULL AND v.numeric_value IS NOT NULL
-  GROUP BY m.subject_id, v.definition_id
-  HAVING COUNT(DISTINCT v.report_date) >= 2
-) x;
+  (SELECT COUNT(DISTINCT n.subject_id) FROM (
+     SELECT v.subject_id, v.definition_id
+     FROM indicator_values v
+     JOIN indicator_definitions d ON d.id = v.definition_id AND d.status = 1
+     WHERE v.report_date IS NOT NULL AND v.numeric_value IS NOT NULL
+     GROUP BY v.subject_id, v.definition_id
+     HAVING COUNT(DISTINCT v.report_date) >= 2
+   ) n)
+  /
+  (SELECT COUNT(DISTINCT m.subject_id) FROM (
+     SELECT r.subject_id
+     FROM health_reports r
+     WHERE r.status='active' AND r.subject_id IS NOT NULL AND r.report_date IS NOT NULL
+     GROUP BY r.subject_id
+     HAVING COUNT(DISTINCT r.report_date) >= 2
+   ) m) AS member_usable_rate;
 
 -- Q3:单报告逐项核对(替换 :report_id)
 SELECT d.name,
@@ -2922,6 +2925,8 @@ JOIN indicator_definitions d ON d.name = hi.indicator_name AND d.status = 1
 WHERE r.id = :report_id;
 ```
 
+> **口径说明(判定多期)**:Q2 的「多期」一律以 `indicator_values.report_date` 为准,**与上传时间无关**。不同 `report_date` 记为不同报告;同一 `report_date` 的多份报告视为同一期(不重复计数)。`report_date IS NULL` 的观测/报告不参与分子与分母。
+>
 > **为什么 L2 比 L1 更能反映真实体验**:L1 高不代表能用——若每份报告只有 1 期数据,覆盖率可以 100% 但没有任何指标能形成对照。**L2 直接测"有多少成员能真正看到一条变化"**,这才是 P0-1 的价值载体。
 > **若不达标怎么办**:回到 §8.1 继续跑回填;仍不达标则检查 G3(`bindReportToMember` 补写)是否已上线生效 —— G3 补完后补跑一次 `bindReportToMember` 触发的 pipeline 即可。