|
|
@@ -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 "中性";
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|