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

fix(T02): 修正报告指标 summary 异常计数漏算 + 强化三档形态与 SQL 预算单测

- HealthReportService.getReportIndicatorsWithPrevious:abnormalCount 的统计从
  "命中字典之后" 提到之前 —— 未命中字典的指标同样可能带异常状态,
  原实现会让 summary.abnormalCount 少于实际异常项数(残缺横幅与异常徽标口径失真)。
- HealthReportServiceIndicatorsTest:
  ① 三档形态用例补齐"命中字典但无上一期且有更早报告 → pending_backfill"这一档
     (此前该档因 definitionId=null 被短路,未真正走到 pending_backfill 分支),
     并新增"未命中字典 → previous/change 恒 null"用例;
 ② summary 断言改用实际契约字段 changedCount(原误写 improvedCount);
 ③ spy 桩改用 doReturn(...).when(...),避免打桩时真实调用 batchResolve 被计入调用次数;
 ④ SQL 预算用例断言服务层恒 3 次协作调用且绝不直接访问 indicator_values,
     验证查询数与指标条数无关(无 N+1)。
Sisyphus 2 недель назад
Родитель
Сommit
70b2a48cdf

+ 5 - 3
cfc-backend/src/main/java/com/etotem/cfc/service/HealthReportService.java

@@ -577,6 +577,11 @@ public class HealthReportService {
                 item.put("valueType", def != null ? def.getValueType() : null);
                 item.put("pointsCount", 1); // 单报告视角下每指标只有 1 期,时间轴视图另有计算
 
+                // 异常计数与字典命中无关:未命中字典的指标同样可能是异常项
+                if (hi.getStatus() != null && !"正常".equals(hi.getStatus())) {
+                    abnormalCount++;
+                }
+
                 if (def == null) {
                     // 未命中字典:previous/change 恒为 null(前端按"无对比"渲染)
                     item.put("previous", null);
@@ -598,9 +603,6 @@ public class HealthReportService {
                 }
 
                 // 统计
-                if (hi.getStatus() != null && !"正常".equals(hi.getStatus())) {
-                    abnormalCount++;
-                }
                 if (cr.getTrendCode().equals(IndicatorChangeService.TREND_IMPROVED)
                         || cr.getTrendCode().equals(IndicatorChangeService.TREND_WORSENED)) {
                     changedCount++;

+ 43 - 22
cfc-backend/src/test/java/com/etotem/cfc/service/HealthReportServiceIndicatorsTest.java

@@ -55,7 +55,7 @@ class HealthReportServiceIndicatorsTest {
         service = new HealthReportService();
         healthReportMapper = mock(HealthReportMapper.class);
         mappingService = mock(IndicatorMappingService.class);
-        IndicatorValueMapper valueMapper = mock(IndicatorValueMapper.class);
+        valueMapper = mock(IndicatorValueMapper.class);
         // 真实 changeService(computeChange 走真实计算)+ spy 拦截 batchResolve 的查库
         IndicatorChangeService realChange = new IndicatorChangeService();
         ReflectionTestUtils.setField(realChange, "valueMapper", valueMapper);
@@ -117,16 +117,22 @@ class HealthReportServiceIndicatorsTest {
 
     // ==================== #1 三档形态 ====================
 
+    /**
+     * 三档形态齐备:档 A(有上一期 → improved)/ 档 B2(无上一期但有更早报告 →
+     * pending_backfill)/ 未命中字典(previous 与 change 恒 null)。
+     */
     @Test
     void reportIndicators_threeTiersPresent() {
         HealthReport r = report();
         List<HealthIndicator> indicators = Arrays.asList(
                 indicator(9001L, "低密度脂蛋白 LDL-C", "3.62", "偏高"),   // 有上一期 → improved
-                indicator(9002L, "白细胞计数", "5.20", "正常"));          // 无上一期 + 有更早报告 → pending_backfill
+                indicator(9002L, "白细胞计数", "5.20", "正常"),           // 命中字典但无上一期 → pending_backfill
+                indicator(9003L, "未知指标 X", "+", "异常"));              // 未命中字典 → 无对比
 
-        // 映射:只有 LDL 命中字典,白细胞未命中(definitionId=null)
+        // 映射:LDL 与白细胞命中字典,未知指标 X 未命中
         Map<String, IndicatorMappingService.ResolvedMapping> resolvedMap = new HashMap<>();
         resolvedMap.put("低密度脂蛋白 LDL-C", resolved("低密度脂蛋白 LDL-C", 512L, "低密度脂蛋白 LDL-C"));
+        resolvedMap.put("白细胞计数", resolved("白细胞计数", 513L, "白细胞计数"));
         when(mappingService.resolveBatch(anyString(), anyCollection())).thenReturn(resolvedMap);
 
         // 上一期:LDL 有上一期值;白细胞无
@@ -140,10 +146,11 @@ class HealthReportServiceIndicatorsTest {
         prev.setUnit("mmol/L");
         prev.setStatus("偏高");
         prevRows.add(prev);
-        when(changeService.batchResolve(any(), any(), any(), any())).thenReturn(
-                new HashMap<Long, IndicatorValue>() {{ put(512L, prev); }});
+        Map<Long, IndicatorValue> prevMap = new HashMap<>();
+        prevMap.put(512L, prev);
+        doReturn(prevMap).when(changeService).batchResolve(any(), any(), any(), any());
 
-        // 更早报告 3 份 → olderReportCount=3(无上一期 → pending_backfill)
+        // 更早报告 3 份 → olderReportCount=3(白细胞无上一期 → pending_backfill)
         List<HealthReport> older = new ArrayList<>();
         for (int i = 0; i < 3; i++) {
             HealthReport o = new HealthReport();
@@ -159,12 +166,13 @@ class HealthReportServiceIndicatorsTest {
         assertEquals(3L, ((Number) data.get("olderReportCount")).longValue());
 
         List<Map<String, Object>> items = itemsOf(data);
-        assertEquals(2, items.size());
+        assertEquals(3, items.size());
 
-        // 档 A:有上一期 → improved
+        // 档 A:有上一期 → improved(LDL 从 4.10 降到 3.62,lower_is_better)
         Map<String, Object> ldl = items.get(0);
         assertEquals(512L, ((Number) ldl.get("definitionId")).longValue());
         assertNotNull(ldl.get("previous"));
+        assertEquals(11800L, ((Number) ((Map<String, Object>) ldl.get("previous")).get("reportId")).longValue());
         @SuppressWarnings("unchecked")
         Map<String, Object> ldlChange = (Map<String, Object>) ldl.get("change");
         assertEquals("improved", ldlChange.get("trendCode"));
@@ -172,18 +180,32 @@ class HealthReportServiceIndicatorsTest {
         assertEquals(-0.48, ((BigDecimal) ldlChange.get("delta")).doubleValue(), 1e-9);
         assertEquals(true, ldlChange.get("comparable"));
 
-        // 档 B2:无上一期 + 有更早报告 → pending_backfill(诚实降级,不是"首次记录")
+        // 档 B1/B2 分界:命中字典但无上一期 + 有更早报告 → pending_backfill(诚实降级,不是"首次记录")
         Map<String, Object> wbc = items.get(1);
-        assertNull(wbc.get("definitionId"), "未命中字典的指标 definitionId 为 null");
+        assertEquals(513L, ((Number) wbc.get("definitionId")).longValue());
         assertNull(wbc.get("previous"));
-        assertNull(wbc.get("change"), "未命中字典时无对比对象");
+        @SuppressWarnings("unchecked")
+        Map<String, Object> wbcChange = (Map<String, Object>) wbc.get("change");
+        assertEquals("pending_backfill", wbcChange.get("trendCode"));
+        assertEquals("历史数据整理中,暂无法对比", wbcChange.get("trendLabel"));
+        assertEquals(false, wbcChange.get("comparable"));
+
+        // 未命中字典:definitionId / previous / change 全为 null,前端按"无对比"渲染
+        Map<String, Object> unknown = items.get(2);
+        assertNull(unknown.get("definitionId"), "未命中字典的指标 definitionId 为 null");
+        assertNull(unknown.get("previous"));
+        assertNull(unknown.get("change"), "未命中字典时无对比对象");
 
         // summary
         @SuppressWarnings("unchecked")
         Map<String, Object> summary = (Map<String, Object>) data.get("summary");
-        assertEquals(2, ((Number) summary.get("totalCount")).intValue());
-        assertEquals(1, ((Number) summary.get("improvedCount")).intValue());
-        assertEquals(1, ((Number) summary.get("abnormalCount")).intValue());
+        assertEquals(3, ((Number) summary.get("totalCount")).intValue());
+        assertEquals(1, ((Number) summary.get("changedCount")).intValue());
+        assertEquals(2, ((Number) summary.get("abnormalCount")).intValue());
+        assertEquals(1, ((Number) summary.get("comparableCount")).intValue());
+        assertEquals(0, ((Number) summary.get("firstRecordCount")).intValue());
+        assertEquals(1, ((Number) summary.get("pendingCount")).intValue());
+        assertEquals(true, summary.get("dataIncomplete"));
     }
 
     @Test
@@ -196,7 +218,7 @@ class HealthReportServiceIndicatorsTest {
         Map<String, IndicatorMappingService.ResolvedMapping> resolvedMap = new HashMap<>();
         resolvedMap.put("载脂蛋白 A1", resolved("载脂蛋白 A1", 513L, "载脂蛋白 A1"));
         when(mappingService.resolveBatch(anyString(), anyCollection())).thenReturn(resolvedMap);
-        when(changeService.batchResolve(any(), any(), any(), any())).thenReturn(new HashMap<>());
+        doReturn(new HashMap<Long, IndicatorValue>()).when(changeService).batchResolve(any(), any(), any(), any());
         // 无更早报告 → olderReportCount=0 → first_record
         when(healthReportMapper.selectList(any())).thenReturn(new ArrayList<>());
 
@@ -231,22 +253,21 @@ class HealthReportServiceIndicatorsTest {
             resolvedMap.put(name, resolved(name, 500L + i, name));
         }
         when(mappingService.resolveBatch(anyString(), anyCollection())).thenReturn(resolvedMap);
-        when(changeService.batchResolve(any(), any(), any(), any())).thenReturn(new HashMap<>());
+        doReturn(new HashMap<Long, IndicatorValue>()).when(changeService).batchResolve(any(), any(), any(), any());
         when(healthReportMapper.selectList(any())).thenReturn(new ArrayList<>());
 
-        service.getReportIndicatorsWithPrevious(r, indicators, true);
+        Map<String, Object> data = service.getReportIndicatorsWithPrevious(r, indicators, true);
 
-        // 服务层(报告与指标行由控制器加载后传入)固定 3 次查询:
+        // 服务层(报告与指标行由控制器加载后传入)固定 3 次查询,与指标条数无关:
         //   ① health_reports(olderReportCount)
         //   ② indicator_mapping_rules + indicator_definitions 联合查询(resolveBatch)
         //   ③ indicator_values(batchResolve 上一期)
         verify(healthReportMapper, times(1)).selectList(any());
         verify(mappingService, times(1)).resolveBatch(anyString(), anyCollection());
         verify(changeService, times(1)).batchResolve(any(), any(), any(), any());
-        // 绝不能逐指标查库:42 项指标只允许 1 次 indicator_values 查询
-        verify(valueMapper, times(1)).selectList(any());
+        // 服务层自身绝不碰 indicator_values 表:全部观测访问必须经由 changeService 批量入口
+        verify(valueMapper, never()).selectList(any());
 
-        assertEquals(42, itemsOf(service.getReportIndicatorsWithPrevious(r, indicators, true)).size(),
-                "42 项指标必须全部返回");
+        assertEquals(42, itemsOf(data).size(), "42 项指标必须全部返回");
     }
 }