Browse Source

feat(indicator): IndicatorTrendService 曲线/变化排名/速查(含维度评分小节)

iwt 2 days ago
parent
commit
cd30ddb26e

+ 370 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/IndicatorTrendService.java

@@ -0,0 +1,370 @@
+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.FiveDimensionScoreMapper;
+import com.etotem.cfc.mapper.HealthDimensionScoreMapper;
+import com.etotem.cfc.mapper.IndicatorDefinitionMapper;
+import com.etotem.cfc.mapper.IndicatorValueMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/**
+ * 指标视图服务:trend(曲线)/ change-ranking(变化排名)/ quick-lookup(速查)。
+ * 只读 indicator_values;五维/七维评分不写时序,quick-lookup API 层单独合入 dimensionScores 小节。
+ */
+@Service
+public class IndicatorTrendService {
+
+    private static final Logger log = LoggerFactory.getLogger(IndicatorTrendService.class);
+
+    @Resource
+    private IndicatorValueMapper valueMapper;
+
+    @Resource
+    private IndicatorDefinitionMapper definitionMapper;
+
+    @Resource
+    private FiveDimensionScoreMapper fiveDimensionScoreMapper;
+
+    @Resource
+    private HealthDimensionScoreMapper healthDimensionScoreMapper;
+
+    /** 指标曲线:按 definitionId 分组、report_date 升序返回点序列 */
+    public Map<String, Object> trend(Long subjectId, List<Long> definitionIds, Long familyId) {
+        Map<String, Object> data = new LinkedHashMap<>();
+        List<Map<String, Object>> items = new ArrayList<>();
+        if (definitionIds == null || definitionIds.isEmpty() || subjectId == null) {
+            data.put("items", items);
+            return data;
+        }
+        List<IndicatorValue> rows = valueMapper.selectList(new LambdaQueryWrapper<IndicatorValue>()
+                .eq(IndicatorValue::getSubjectId, subjectId)
+                .in(IndicatorValue::getDefinitionId, definitionIds)
+                .isNotNull(IndicatorValue::getReportDate)
+                .orderByAsc(IndicatorValue::getReportDate));
+
+        Map<Long, List<IndicatorValue>> byDef = rows.stream()
+                .collect(Collectors.groupingBy(IndicatorValue::getDefinitionId,
+                        LinkedHashMap::new, Collectors.toList()));
+
+        Map<Long, IndicatorDefinition> defCache = new HashMap<>();
+        for (Map.Entry<Long, List<IndicatorValue>> e : byDef.entrySet()) {
+            Map<String, Object> item = new LinkedHashMap<>();
+            item.put("definitionId", e.getKey());
+            IndicatorDefinition def = defCache.computeIfAbsent(e.getKey(),
+                    id -> definitionMapper.selectById(id));
+            item.put("name", def != null ? def.getName() : "指标" + e.getKey());
+            item.put("unit", def != null ? def.getUnit() : null);
+            List<Map<String, Object>> points = new ArrayList<>();
+            for (IndicatorValue v : e.getValue()) {
+                Map<String, Object> p = new LinkedHashMap<>();
+                p.put("reportDate", v.getReportDate() != null
+                        ? new SimpleDateFormat("yyyy-MM-dd").format(v.getReportDate()) : null);
+                p.put("value", v.getValue());
+                p.put("numericValue", v.getNumericValue());
+                p.put("unit", v.getUnit());
+                p.put("status", v.getStatus());
+                points.add(p);
+            }
+            item.put("points", points);
+            items.add(item);
+        }
+        data.put("items", items);
+        return data;
+    }
+
+    /** 变化幅度排名(设计 6.2)。scope=adjacent 相邻两次(默认)/ first-vs-latest 首尾对比。 */
+    public Map<String, Object> changeRanking(Long subjectId, String reportType, String category,
+                                             String scope, String orderBy, Integer limit, Long familyId) {
+        Map<String, Object> data = new LinkedHashMap<>();
+        List<Map<String, Object>> items = new ArrayList<>();
+        int improved = 0;
+        int worsened = 0;
+        int flat = 0;
+
+        LambdaQueryWrapper<IndicatorValue> qw = new LambdaQueryWrapper<IndicatorValue>()
+                .eq(IndicatorValue::getSubjectId, subjectId)
+                .isNotNull(IndicatorValue::getReportDate);
+        if (reportType != null && !reportType.isEmpty()) {
+            qw.eq(IndicatorValue::getSourceType, reportType);
+        }
+        List<IndicatorValue> rows = valueMapper.selectList(qw.orderByAsc(IndicatorValue::getReportDate));
+
+        // 按 definitionId 分组
+        Map<Long, List<IndicatorValue>> byDef = rows.stream()
+                .collect(Collectors.groupingBy(IndicatorValue::getDefinitionId));
+        Map<Long, IndicatorDefinition> defCache = new HashMap<>();
+
+        for (Map.Entry<Long, List<IndicatorValue>> e : byDef.entrySet()) {
+            List<IndicatorValue> sorted = e.getValue().stream()
+                    .sorted((a, b) -> a.getReportDate().compareTo(b.getReportDate()))
+                    .collect(Collectors.toList());
+            if (sorted.size() < 2) {
+                continue;
+            }
+            IndicatorValue before = sorted.get(0);
+            IndicatorValue after = sorted.get(sorted.size() - 1);
+            if ("adjacent".equals(scope)) {
+                before = sorted.get(sorted.size() - 2);
+            }
+            IndicatorDefinition def = defCache.computeIfAbsent(e.getKey(),
+                    id -> definitionMapper.selectById(id));
+            String defCategory = def != null ? def.getCategory() : null;
+            if (category != null && !category.isEmpty() && !category.equals(defCategory)) {
+                continue;
+            }
+            BigDecimal bv = before.getNumericValue();
+            BigDecimal av = after.getNumericValue();
+            if (bv == null || av == null) {
+                continue;
+            }
+            BigDecimal delta = av.subtract(bv);
+            BigDecimal deltaPct = bv.compareTo(BigDecimal.ZERO) != 0
+                    ? delta.multiply(BigDecimal.valueOf(100)).divide(bv, 1, RoundingMode.HALF_UP)
+                    : BigDecimal.ZERO;
+            String movement = "flat";
+            if (delta.compareTo(BigDecimal.ZERO) > 0) movement = "up";
+            else if (delta.compareTo(BigDecimal.ZERO) < 0) movement = "down";
+
+            String trendLabel = trendLabel(def, movement, delta);
+            if ("改善".equals(trendLabel)) improved++;
+            else if ("恶化".equals(trendLabel)) worsened++;
+            else flat++;
+
+            Map<String, Object> item = new LinkedHashMap<>();
+            item.put("definitionId", e.getKey());
+            item.put("name", def != null ? def.getName() : "指标" + e.getKey());
+            item.put("category", defCategory);
+            item.put("sourceKind", def != null ? def.getSourceKind() : null);
+            item.put("unit", def != null ? def.getUnit() : null);
+            item.put("goodDirection", def != null ? def.getGoodDirection() : "neutral");
+            Map<String, Object> beforeMap = new LinkedHashMap<>();
+            beforeMap.put("reportDate", fmtDate(before.getReportDate()));
+            beforeMap.put("value", before.getValue());
+            Map<String, Object> afterMap = new LinkedHashMap<>();
+            afterMap.put("reportDate", fmtDate(after.getReportDate()));
+            afterMap.put("value", after.getValue());
+            item.put("before", beforeMap);
+            item.put("after", afterMap);
+            item.put("delta", delta);
+            item.put("deltaPct", deltaPct);
+            item.put("movement", movement);
+            item.put("trendLabel", trendLabel);
+            items.add(item);
+        }
+
+        // 排序:magnitude 按 Δ 绝对值降序(默认);up/down 按 Δ 正/负降序
+        if ("up".equals(orderBy)) {
+            items.sort((a, b) -> ((BigDecimal) b.get("delta")).compareTo((BigDecimal) a.get("delta")));
+        } else if ("down".equals(orderBy)) {
+            items.sort((a, b) -> ((BigDecimal) a.get("delta")).compareTo((BigDecimal) b.get("delta")));
+        } else {
+            items.sort((a, b) -> ((BigDecimal) b.get("delta")).abs()
+                    .compareTo(((BigDecimal) a.get("delta")).abs()));
+        }
+
+        int limitVal = limit != null && limit > 0 ? limit : 50;
+        if (items.size() > limitVal) {
+            items = new ArrayList<>(items.subList(0, limitVal));
+        }
+
+        data.put("items", items);
+        Map<String, Object> overall = new LinkedHashMap<>();
+        overall.put("improvedCount", improved);
+        overall.put("worsenedCount", worsened);
+        overall.put("flatCount", flat);
+        data.put("overall", overall);
+        return data;
+    }
+
+    /** 指标速查(设计 6.3):分类分页 + 五维/七维评分小节 */
+    public Map<String, Object> quickLookup(Long subjectId, String category, Integer page,
+                                           Integer pageSize, Long familyId) {
+        Map<String, Object> data = new LinkedHashMap<>();
+        int pageVal = page != null && page > 0 ? page : 1;
+        int sizeVal = pageSize != null && pageSize > 0 ? pageSize : 30;
+
+        // 该成员全部观测(按时序取最新一条)
+        List<IndicatorValue> rows = valueMapper.selectList(new LambdaQueryWrapper<IndicatorValue>()
+                .eq(IndicatorValue::getSubjectId, subjectId)
+                .isNotNull(IndicatorValue::getDefinitionId));
+        Map<Long, List<IndicatorValue>> byDef = rows.stream()
+                .collect(Collectors.groupingBy(IndicatorValue::getDefinitionId));
+        Map<Long, IndicatorDefinition> defCache = new HashMap<>();
+        List<Map<String, Object>> allItems = new ArrayList<>();
+
+        for (Map.Entry<Long, List<IndicatorValue>> e : byDef.entrySet()) {
+            IndicatorDefinition def = defCache.computeIfAbsent(e.getKey(),
+                    id -> definitionMapper.selectById(id));
+            if (def == null) {
+                continue;
+            }
+            if (category != null && !category.isEmpty() && !category.equals(def.getCategory())) {
+                continue;
+            }
+            List<IndicatorValue> sorted = e.getValue().stream()
+                    .sorted((a, b) -> a.getReportDate() == null ? -1
+                            : (b.getReportDate() == null ? 1 : b.getReportDate().compareTo(a.getReportDate())))
+                    .collect(Collectors.toList());
+            IndicatorValue latest = sorted.get(0);
+            Map<String, Object> item = new LinkedHashMap<>();
+            item.put("definitionId", e.getKey());
+            item.put("name", def.getName());
+            item.put("category", def.getCategory());
+            item.put("value", latest.getValue());
+            item.put("unit", latest.getUnit() != null ? latest.getUnit() : def.getUnit());
+            item.put("refRange", def.getRefRange());
+            item.put("status", latest.getStatus());
+            item.put("trend", trendOf(sorted));
+            item.put("trendLabel", trendLabel(def, trendOf(sorted), deltaOf(sorted)));
+            item.put("latestReportDate", latest.getReportDate() != null
+                    ? new SimpleDateFormat("yyyy-MM-dd").format(latest.getReportDate()) : null);
+            item.put("pointsCount", sorted.size());
+            allItems.add(item);
+        }
+
+        // categories 汇总(含 total)
+        List<Map<String, Object>> categories = new ArrayList<>();
+        Map<String, Long> catCount = allItems.stream()
+                .collect(Collectors.groupingBy(i -> (String) i.get("category"), Collectors.counting()));
+        catCount.forEach((name, total) -> {
+            Map<String, Object> c = new LinkedHashMap<>();
+            c.put("name", name);
+            c.put("total", total);
+            categories.add(c);
+        });
+
+        // 分页
+        int fromIndex = Math.min((pageVal - 1) * sizeVal, allItems.size());
+        int toIndex = Math.min(fromIndex + sizeVal, allItems.size());
+        List<Map<String, Object>> pageItems = allItems.isEmpty()
+                ? new ArrayList<>()
+                : new ArrayList<>(allItems.subList(fromIndex, toIndex));
+
+        data.put("categories", categories);
+        data.put("items", pageItems);
+        data.put("page", pageVal);
+        data.put("pageSize", sizeVal);
+        data.put("total", allItems.size());
+        data.put("dimensionScores", loadDimensionScores(subjectId));
+        return data;
+    }
+
+    /** 五维/七维评分小节(不写 indicator_values,API 层 join 原表) */
+    private List<Map<String, Object>> loadDimensionScores(Long subjectId) {
+        List<Map<String, Object>> result = new ArrayList<>();
+        // 五维:five_dimension_scores 按 memberId + assessedAt 最新
+        try {
+            result.addAll(fiveDimensionScoreMapper.selectList(
+                    new LambdaQueryWrapper<com.etotem.cfc.entity.FiveDimensionScore>()
+                            .eq(com.etotem.cfc.entity.FiveDimensionScore::getMemberId, subjectId)
+                            .orderByDesc(com.etotem.cfc.entity.FiveDimensionScore::getAssessedAt)
+                            .last("LIMIT 10"))
+                    .stream().map(s -> {
+                        Map<String, Object> m = new LinkedHashMap<>();
+                        m.put("dimensionCode", s.getDimensionCode());
+                        m.put("score", s.getScore());
+                        m.put("assessedAt", s.getAssessedAt() != null
+                                ? new SimpleDateFormat("yyyy-MM-dd").format(s.getAssessedAt()) : null);
+                        m.put("source", "five_dimension");
+                        return m;
+                    }).collect(Collectors.toList()));
+        } catch (Exception e) {
+            log.warn("加载五维评分失败 subjectId={}: {}", subjectId, e.getMessage());
+        }
+        // 七维:health_dimension_scores 按 memberId + assessDate 最新
+        try {
+            result.addAll(healthDimensionScoreMapper.selectList(
+                    new LambdaQueryWrapper<com.etotem.cfc.entity.HealthDimensionScore>()
+                            .eq(com.etotem.cfc.entity.HealthDimensionScore::getMemberId, subjectId)
+                            .orderByDesc(com.etotem.cfc.entity.HealthDimensionScore::getAssessDate)
+                            .last("LIMIT 10"))
+                    .stream().map(s -> {
+                        Map<String, Object> m = new LinkedHashMap<>();
+                        m.put("dimensionCode", s.getDimension());
+                        m.put("score", s.getScore());
+                        m.put("assessedAt", s.getAssessDate() != null
+                                ? new SimpleDateFormat("yyyy-MM-dd").format(s.getAssessDate()) : null);
+                        m.put("source", "health_dimension");
+                        return m;
+                    }).collect(Collectors.toList()));
+        } catch (Exception e) {
+            log.warn("加载七维评分失败 subjectId={}: {}", subjectId, e.getMessage());
+        }
+        return result;
+    }
+
+    // ---------- 工具 ----------
+
+    private String fmtDate(Date d) {
+        return d != null ? new SimpleDateFormat("yyyy-MM-dd").format(d) : null;
+    }
+
+    /** 序列趋势:末尾两条比较 */
+    private String trendOf(List<IndicatorValue> sorted) {
+        if (sorted.size() < 2) {
+            return "flat";
+        }
+        IndicatorValue latest = sorted.get(0);
+        IndicatorValue prev = sorted.get(1);
+        if (latest.getNumericValue() == null || prev.getNumericValue() == null) {
+            return "flat";
+        }
+        int cmp = latest.getNumericValue().compareTo(prev.getNumericValue());
+        return cmp > 0 ? "up" : (cmp < 0 ? "down" : "flat");
+    }
+
+    private BigDecimal deltaOf(List<IndicatorValue> sorted) {
+        if (sorted.size() < 2) {
+            return BigDecimal.ZERO;
+        }
+        IndicatorValue latest = sorted.get(0);
+        IndicatorValue prev = sorted.get(1);
+        if (latest.getNumericValue() == null || prev.getNumericValue() == null) {
+            return BigDecimal.ZERO;
+        }
+        return latest.getNumericValue().subtract(prev.getNumericValue());
+    }
+
+    /**
+     * 变化着色/标签规则(设计第 8 节):
+     * good_direction × 数值变化方向 → 改善/恶化/中性。
+     */
+    private String trendLabel(IndicatorDefinition def, String movement, BigDecimal delta) {
+        if ("flat".equals(movement) || delta == null || delta.compareTo(BigDecimal.ZERO) == 0) {
+            return "中性";
+        }
+        if (def == null || def.getGoodDirection() == null) {
+            return "中性";
+        }
+        boolean up = "up".equals(movement);
+        switch (def.getGoodDirection()) {
+            case "higher_is_better":
+                return up ? "改善" : "恶化";
+            case "lower_is_better":
+                return up ? "恶化" : "改善";
+            case "in_range_is_better":
+                // 一期简化:无 refRange 解析,回落为中性
+                return "中性";
+            default:
+                return "中性";
+        }
+    }
+}

+ 100 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/IndicatorTrendServiceTest.java

@@ -0,0 +1,100 @@
+package com.etotem.cfc.service;
+
+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.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.math.BigDecimal;
+import java.sql.Date;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+class IndicatorTrendServiceTest {
+
+    private IndicatorTrendService service;
+    private IndicatorValueMapper valueMapper;
+    private IndicatorDefinitionMapper definitionMapper;
+
+    @BeforeEach
+    void setUp() {
+        service = new IndicatorTrendService();
+        valueMapper = mock(IndicatorValueMapper.class);
+        ReflectionTestUtils.setField(service, "valueMapper", valueMapper);
+        // 计划测试漏加:实现均调用 definitionMapper.selectById,必须补 mock
+        definitionMapper = mock(IndicatorDefinitionMapper.class);
+        ReflectionTestUtils.setField(service, "definitionMapper", definitionMapper);
+        IndicatorDefinition def = new IndicatorDefinition();
+        def.setName("双歧杆菌属");
+        def.setUnit("%");
+        def.setCategory("肠道菌群");
+        def.setGoodDirection("higher_is_better");
+        when(definitionMapper.selectById(any())).thenReturn(def);
+    }
+
+    private IndicatorValue value(Long defId, String date, String val) {
+        IndicatorValue v = new IndicatorValue();
+        v.setDefinitionId(defId);
+        v.setReportDate(Date.valueOf(date));
+        v.setValue(val);
+        v.setNumericValue(new BigDecimal(val));
+        return v;
+    }
+
+    @Test
+    void trendReturnsAscendingPoints() {
+        List<IndicatorValue> rows = new ArrayList<>();
+        rows.add(value(1L, "2026-08-15", "10"));
+        rows.add(value(1L, "2026-10-01", "12.8"));
+        when(valueMapper.selectList(any())).thenReturn(rows);
+
+        Map<String, Object> data = service.trend(3L, Arrays.asList(1L), 2L);
+        List<?> items = (List<?>) data.get("items");
+        assertEquals(1, items.size());
+        Map<?, ?> item = (Map<?, ?>) items.get(0);
+        assertEquals("1", String.valueOf(item.get("definitionId")));
+        List<?> points = (List<?>) item.get("points");
+        assertEquals(2, points.size());
+        Map<?, ?> first = (Map<?, ?>) points.get(0);
+        assertEquals("2026-08-15", first.get("reportDate"));
+    }
+
+    @Test
+    void changeRankingAdjacentComputesDeltaAndTrendLabel() {
+        // 双歧杆菌属 higher_is_better:升幅 → trendLabel=改善
+        List<IndicatorValue> rows = new ArrayList<>();
+        rows.add(value(1L, "2026-08-15", "10"));
+        rows.add(value(1L, "2026-10-01", "12.8"));
+        when(valueMapper.selectList(any())).thenReturn(rows);
+
+        java.util.Map<String, Object> data = service.changeRanking(3L, null, null, "adjacent", "magnitude", 50, 2L);
+        List<?> items = (List<?>) data.get("items");
+        assertEquals(1, items.size());
+        Map<?, ?> item = (Map<?, ?>) items.get(0);
+        assertEquals("up", item.get("movement"));
+        assertEquals("改善", item.get("trendLabel"));
+        Map<?, ?> overall = (Map<?, ?>) data.get("overall");
+        assertEquals(1, ((Number) overall.get("improvedCount")).intValue());
+    }
+
+    @Test
+    void quickLookupReturnsItemsAndDimensions() {
+        List<IndicatorValue> rows = new ArrayList<>();
+        rows.add(value(1L, "2026-10-01", "12.8"));
+        when(valueMapper.selectList(any())).thenReturn(rows);
+
+        Map<String, Object> data = service.quickLookup(3L, null, 1, 30, 2L);
+        assertTrue(data.containsKey("items"));
+        assertTrue(data.containsKey("categories"));
+        assertEquals(1, ((List<?>) data.get("items")).size());
+    }
+}