|
|
@@ -16,12 +16,15 @@ import java.math.BigDecimal;
|
|
|
import java.math.RoundingMode;
|
|
|
import java.text.SimpleDateFormat;
|
|
|
import java.util.ArrayList;
|
|
|
+import java.util.Collections;
|
|
|
+import java.util.Comparator;
|
|
|
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.Objects;
|
|
|
import java.util.Set;
|
|
|
import java.util.stream.Collectors;
|
|
|
|
|
|
@@ -46,58 +49,251 @@ public class IndicatorTrendService {
|
|
|
@Resource
|
|
|
private HealthDimensionScoreMapper healthDimensionScoreMapper;
|
|
|
|
|
|
- /** 指标曲线:按 definitionId 分组、report_date 升序返回点序列 */
|
|
|
- public Map<String, Object> trend(Long subjectId, List<Long> definitionIds, Long familyId) {
|
|
|
+ @Resource
|
|
|
+ private IndicatorTimelineService timelineService;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private IndicatorChangeService changeService;
|
|
|
+
|
|
|
+ /** 指标曲线:委托 IndicatorTimelineService.timeline(§4.2) */
|
|
|
+ public Map<String, Object> trend(Long subjectId, List<Long> definitionIds, Long familyId,
|
|
|
+ String sourceType, String fromDate, String toDate,
|
|
|
+ String granularity, String sourceKind) {
|
|
|
+ if (subjectId == null || definitionIds == null || definitionIds.isEmpty()) {
|
|
|
+ Map<String, Object> empty = new LinkedHashMap<>();
|
|
|
+ empty.put("items", new ArrayList<>());
|
|
|
+ return empty;
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ return timelineService.timeline(subjectId, definitionIds, sourceType, fromDate, toDate, granularity, sourceKind);
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("indicator/trend 异常 subjectId={}, defs={}: {}", subjectId, definitionIds, e.getMessage());
|
|
|
+ Map<String, Object> err = new LinkedHashMap<>();
|
|
|
+ err.put("code", 500);
|
|
|
+ err.put("message", "查询失败: " + e.getMessage());
|
|
|
+ return err;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 变化幅度排名(设计 6.2)。scope=adjacent 相邻两次(默认)/ first-vs-latest / pair。
|
|
|
+ * 改造:新增 scope=pair + filter + 分页;magnitude 排序改为 |Δ%| 降序;null deltaPct 恒定排最后。
|
|
|
+ */
|
|
|
+ public Map<String, Object> changeRanking(Long subjectId, String reportType, String category,
|
|
|
+ String scope, String orderBy, Integer limit, Long familyId,
|
|
|
+ String fromReportDate, String toReportDate,
|
|
|
+ String filter, Integer page, Integer pageSize) {
|
|
|
Map<String, Object> data = new LinkedHashMap<>();
|
|
|
- List<Map<String, Object>> items = new ArrayList<>();
|
|
|
- if (definitionIds == null || definitionIds.isEmpty() || subjectId == null) {
|
|
|
- data.put("items", items);
|
|
|
+ // 兼容老调用:无 page/pageSize 且有 limit → 走老逻辑(limit 模式不分页)
|
|
|
+ boolean legacyMode = (page == null && pageSize == null) && limit != null && limit > 0;
|
|
|
+ if (legacyMode || "adjacent".equals(scope) || "first-vs-latest".equals(scope)) {
|
|
|
+ return changeRankingLegacy(subjectId, reportType, category, scope, orderBy, limit, familyId,
|
|
|
+ fromReportDate, toReportDate);
|
|
|
+ }
|
|
|
+ // 新 scope=pair 走 timelineService.pairCompare(分页 + filter + magnitude|Δ%|排序)
|
|
|
+ try {
|
|
|
+ return timelineService.pairCompare(subjectId, reportType, fromReportDate, toReportDate,
|
|
|
+ category, orderBy, filter, page, pageSize);
|
|
|
+ } catch (IllegalArgumentException e) {
|
|
|
+ data.put("code", 500);
|
|
|
+ data.put("message", e.getMessage());
|
|
|
+ return data;
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("indicator/change-ranking(scope=pair) 异常 subjectId={}: {}", subjectId, e.getMessage());
|
|
|
+ data.put("code", 500);
|
|
|
+ data.put("message", "查询失败: " + e.getMessage());
|
|
|
return data;
|
|
|
}
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 指标速查(设计 6.3)。修 null 排序 bug + 增加 previous/change/latestDateSource/latestReportId/goodDirection/valueType/sparkline。
|
|
|
+ * Q17 强制:经 loadActiveDefinitions 过滤 status=0。
|
|
|
+ */
|
|
|
+ public Map<String, Object> quickLookup(Long subjectId, String category, Integer page,
|
|
|
+ Integer pageSize, Long familyId,
|
|
|
+ Boolean withSparkline, Integer sparkLimit) {
|
|
|
+ Map<String, Object> data = new LinkedHashMap<>();
|
|
|
+ int pageVal = page != null && page > 0 ? page : 1;
|
|
|
+ int sizeVal = pageSize != null && pageSize > 0 ? pageSize : 30;
|
|
|
+ int sparkLimitVal = sparkLimit != null && sparkLimit > 0 ? sparkLimit : 8;
|
|
|
+
|
|
|
+ // 拉全部观测(按 reportDate 降序、NULL 恒排最后)
|
|
|
List<IndicatorValue> rows = valueMapper.selectList(new LambdaQueryWrapper<IndicatorValue>()
|
|
|
.eq(IndicatorValue::getSubjectId, subjectId)
|
|
|
- .in(IndicatorValue::getDefinitionId, definitionIds)
|
|
|
- .isNotNull(IndicatorValue::getReportDate)
|
|
|
- .orderByAsc(IndicatorValue::getReportDate));
|
|
|
-
|
|
|
+ .isNotNull(IndicatorValue::getDefinitionId));
|
|
|
Map<Long, List<IndicatorValue>> byDef = rows.stream()
|
|
|
- .collect(Collectors.groupingBy(IndicatorValue::getDefinitionId,
|
|
|
- LinkedHashMap::new, Collectors.toList()));
|
|
|
+ .collect(Collectors.groupingBy(IndicatorValue::getDefinitionId));
|
|
|
+ // Q17:一次性取 active definitions
|
|
|
+ List<Long> defIds = new ArrayList<>(byDef.keySet());
|
|
|
+ Map<Long, IndicatorDefinition> activeDefs = changeService.loadActiveDefinitions(defIds);
|
|
|
|
|
|
- Map<Long, IndicatorDefinition> defCache = new HashMap<>();
|
|
|
+ List<Map<String, Object>> allItems = new ArrayList<>();
|
|
|
for (Map.Entry<Long, List<IndicatorValue>> e : byDef.entrySet()) {
|
|
|
+ Long defId = e.getKey();
|
|
|
+ IndicatorDefinition def = activeDefs.get(defId);
|
|
|
+ if (def == null) {
|
|
|
+ // Q17 强制:拿不到的 active definition 直接剔除,不降级为"指标"+id
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ if (category != null && !category.isEmpty() && !category.equals(def.getCategory())) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ // 排序:reportDate 降序,NULL 恒排最后(修 bug:原实现 null 排最前)
|
|
|
+ List<IndicatorValue> sorted = e.getValue().stream()
|
|
|
+ .sorted(IndicatorChangeService.NULLS_LAST_DESC)
|
|
|
+ .collect(Collectors.toList());
|
|
|
+ if (sorted.isEmpty()) continue;
|
|
|
+ IndicatorValue latest = sorted.get(0);
|
|
|
+ // 找上一期
|
|
|
+ java.sql.Date latestDate = latest.getReportDate();
|
|
|
+ IndicatorValue previous = null;
|
|
|
+ if (latestDate != null) {
|
|
|
+ previous = changeService.resolvePrevious(subjectId, defId, latest.getSourceType(), latestDate);
|
|
|
+ }
|
|
|
+ // change 对象(恒非空)
|
|
|
+ IndicatorChangeService.ChangeResult cr;
|
|
|
+ if (previous == null || latest.getNumericValue() == null || latest.getNumericValue().compareTo(BigDecimal.ZERO) == 0
|
|
|
+ || previous.getNumericValue() == null || previous.getNumericValue().compareTo(BigDecimal.ZERO) == 0) {
|
|
|
+ // 无上一期或基数为 0 → first_record / pending_backfill
|
|
|
+ // 这里简化:无 previous → first_record
|
|
|
+ cr = changeService.computeChange(def, null, latest, 0);
|
|
|
+ } else {
|
|
|
+ cr = changeService.computeChange(def, previous, latest);
|
|
|
+ }
|
|
|
+
|
|
|
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("definitionId", defId);
|
|
|
+ item.put("name", def.getName());
|
|
|
+ item.put("category", def.getCategory());
|
|
|
+ item.put("value", latest.getValue());
|
|
|
+ item.put("numericValue", latest.getNumericValue());
|
|
|
+ item.put("unit", latest.getUnit() != null ? latest.getUnit() : def.getUnit());
|
|
|
+ item.put("refRange", def.getRefRange());
|
|
|
+ item.put("status", latest.getStatus());
|
|
|
+ item.put("goodDirection", def.getGoodDirection());
|
|
|
+ item.put("valueType", def.getValueType());
|
|
|
+ item.put("latestReportDate", latest.getReportDate() != null
|
|
|
+ ? new SimpleDateFormat("yyyy-MM-dd").format(latest.getReportDate()) : null);
|
|
|
+ item.put("latestDateSource", latest.getDateSource());
|
|
|
+ item.put("latestReportId", latest.getReportId());
|
|
|
+ item.put("pointsCount", sorted.size());
|
|
|
+ // olderReportCount:同 (subjectId, sourceType) 且 reportDate < latest.reportDate 的报告数
|
|
|
+ // 为简化,近似为 sorted.size() - 1(有报告的次数),准确值应由 health_reports 统计
|
|
|
+ item.put("olderReportCount", Math.max(0, sorted.size() - 1));
|
|
|
+
|
|
|
+ // previous
|
|
|
+ if (previous != null) {
|
|
|
+ Map<String, Object> prevMap = new LinkedHashMap<>();
|
|
|
+ prevMap.put("reportId", previous.getReportId());
|
|
|
+ prevMap.put("reportDate", previous.getReportDate() != null
|
|
|
+ ? new SimpleDateFormat("yyyy-MM-dd").format(previous.getReportDate()) : null);
|
|
|
+ prevMap.put("dateSource", previous.getDateSource());
|
|
|
+ prevMap.put("value", previous.getValue());
|
|
|
+ prevMap.put("numericValue", previous.getNumericValue());
|
|
|
+ prevMap.put("unit", previous.getUnit());
|
|
|
+ prevMap.put("status", previous.getStatus());
|
|
|
+ item.put("previous", prevMap);
|
|
|
+ } else {
|
|
|
+ item.put("previous", null);
|
|
|
}
|
|
|
- item.put("points", points);
|
|
|
- items.add(item);
|
|
|
+ // change(恒非空)
|
|
|
+ item.put("change", cr.toMap());
|
|
|
+ // sparkline
|
|
|
+ if (Boolean.TRUE.equals(withSparkline) && sorted.size() >= 3) {
|
|
|
+ int n = Math.min(sparkLimitVal, sorted.size());
|
|
|
+ List<Map<String, Object>> spark = new ArrayList<>();
|
|
|
+ for (int i = sorted.size() - n; i < sorted.size(); i++) {
|
|
|
+ Map<String, Object> p = new LinkedHashMap<>();
|
|
|
+ p.put("reportDate", sorted.get(i).getReportDate() != null
|
|
|
+ ? new SimpleDateFormat("yyyy-MM-dd").format(sorted.get(i).getReportDate()) : null);
|
|
|
+ p.put("numericValue", sorted.get(i).getNumericValue());
|
|
|
+ spark.add(p);
|
|
|
+ }
|
|
|
+ item.put("sparkline", spark);
|
|
|
+ } else {
|
|
|
+ item.put("sparkline", null);
|
|
|
+ }
|
|
|
+ allItems.add(item);
|
|
|
}
|
|
|
- data.put("items", items);
|
|
|
+
|
|
|
+ // categories 汇总
|
|
|
+ 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;
|
|
|
}
|
|
|
|
|
|
- /** 变化幅度排名(设计 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) {
|
|
|
+ /** 该成员已有指标时序的报告类型列表(indicator_values.source_type 去重,曲线页类型筛选动态来源) */
|
|
|
+ public List<Map<String, Object>> listSourceTypes(Long subjectId) {
|
|
|
+ List<Map<String, Object>> items = new ArrayList<>();
|
|
|
+ if (subjectId == null) {
|
|
|
+ return items;
|
|
|
+ }
|
|
|
+ List<IndicatorValue> rows = valueMapper.selectList(new LambdaQueryWrapper<IndicatorValue>()
|
|
|
+ .eq(IndicatorValue::getSubjectId, subjectId)
|
|
|
+ .isNotNull(IndicatorValue::getSourceType)
|
|
|
+ .isNotNull(IndicatorValue::getReportDate)
|
|
|
+ .select(IndicatorValue::getSourceType));
|
|
|
+ Set<String> types = new LinkedHashSet<>();
|
|
|
+ for (IndicatorValue v : rows) {
|
|
|
+ String t = v.getSourceType();
|
|
|
+ if (t != null && !t.trim().isEmpty()) {
|
|
|
+ types.add(t.trim());
|
|
|
+ }
|
|
|
+ }
|
|
|
+ for (String t : types) {
|
|
|
+ Map<String, Object> m = new LinkedHashMap<>();
|
|
|
+ m.put("sourceType", t);
|
|
|
+ m.put("label", sourceTypeLabel(t));
|
|
|
+ // periodCount / latestReportDate:同 sourceType 且 report_date 非空的 distinct count + max
|
|
|
+ long periodCount = valueMapper.selectList(new LambdaQueryWrapper<IndicatorValue>()
|
|
|
+ .eq(IndicatorValue::getSubjectId, subjectId)
|
|
|
+ .eq(IndicatorValue::getSourceType, t)
|
|
|
+ .isNotNull(IndicatorValue::getReportDate)
|
|
|
+ .select(IndicatorValue::getReportDate))
|
|
|
+ .stream().map(IndicatorValue::getReportDate).distinct().count();
|
|
|
+ Date latest = valueMapper.selectList(new LambdaQueryWrapper<IndicatorValue>()
|
|
|
+ .eq(IndicatorValue::getSubjectId, subjectId)
|
|
|
+ .eq(IndicatorValue::getSourceType, t)
|
|
|
+ .isNotNull(IndicatorValue::getReportDate)
|
|
|
+ .orderByDesc(IndicatorValue::getReportDate)
|
|
|
+ .select(IndicatorValue::getReportDate))
|
|
|
+ .stream().map(IndicatorValue::getReportDate).filter(Objects::nonNull).findFirst().orElse(null);
|
|
|
+ m.put("periodCount", periodCount);
|
|
|
+ m.put("latestReportDate", latest != null ? new SimpleDateFormat("yyyy-MM-dd").format(latest) : null);
|
|
|
+ items.add(m);
|
|
|
+ }
|
|
|
+ return items;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** legacy 路径(兼容老调用 scope=adjacent/first-vs-latest + limit 模式) */
|
|
|
+ private Map<String, Object> changeRankingLegacy(Long subjectId, String reportType, String category,
|
|
|
+ String scope, String orderBy, Integer limit, Long familyId,
|
|
|
+ String fromReportDate, String toReportDate) {
|
|
|
Map<String, Object> data = new LinkedHashMap<>();
|
|
|
List<Map<String, Object>> items = new ArrayList<>();
|
|
|
- int improved = 0;
|
|
|
- int worsened = 0;
|
|
|
- int flat = 0;
|
|
|
+ int improved = 0, worsened = 0, flat = 0;
|
|
|
|
|
|
LambdaQueryWrapper<IndicatorValue> qw = new LambdaQueryWrapper<IndicatorValue>()
|
|
|
.eq(IndicatorValue::getSubjectId, subjectId)
|
|
|
@@ -107,104 +303,128 @@ public class IndicatorTrendService {
|
|
|
}
|
|
|
List<IndicatorValue> rows = valueMapper.selectList(qw.orderByAsc(IndicatorValue::getReportDate));
|
|
|
|
|
|
- // 该成员(+报告类型)全部观测日期全集:用于计算"相邻两次对比中间缺失的报告期数"
|
|
|
java.util.List<java.sql.Date> allDates = rows.stream()
|
|
|
.map(IndicatorValue::getReportDate)
|
|
|
- .filter(d -> d != null)
|
|
|
+ .filter(Objects::nonNull)
|
|
|
.distinct()
|
|
|
.sorted()
|
|
|
.collect(Collectors.toList());
|
|
|
|
|
|
- // 按 definitionId 分组
|
|
|
Map<Long, List<IndicatorValue>> byDef = rows.stream()
|
|
|
.collect(Collectors.groupingBy(IndicatorValue::getDefinitionId));
|
|
|
- Map<Long, IndicatorDefinition> defCache = new HashMap<>();
|
|
|
+ // Q17:一次性取 active definitions
|
|
|
+ List<Long> defIds = new ArrayList<>(byDef.keySet());
|
|
|
+ Map<Long, IndicatorDefinition> activeDefs = changeService.loadActiveDefinitions(defIds);
|
|
|
|
|
|
for (Map.Entry<Long, List<IndicatorValue>> e : byDef.entrySet()) {
|
|
|
+ IndicatorDefinition def = activeDefs.get(e.getKey());
|
|
|
+ if (def == null) {
|
|
|
+ continue; // Q17 强制剔除 status=0
|
|
|
+ }
|
|
|
+ if (category != null && !category.isEmpty() && !category.equals(def.getCategory())) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
List<IndicatorValue> sorted = e.getValue().stream()
|
|
|
- .sorted((a, b) -> a.getReportDate().compareTo(b.getReportDate()))
|
|
|
+ .sorted(Comparator.comparing(IndicatorValue::getReportDate,
|
|
|
+ Comparator.nullsFirst(Comparator.naturalOrder())))
|
|
|
.collect(Collectors.toList());
|
|
|
if (sorted.size() < 2) {
|
|
|
continue;
|
|
|
}
|
|
|
- IndicatorValue before = sorted.get(0);
|
|
|
- IndicatorValue after = sorted.get(sorted.size() - 1);
|
|
|
+ IndicatorValue before, after;
|
|
|
if ("adjacent".equals(scope)) {
|
|
|
before = sorted.get(sorted.size() - 2);
|
|
|
+ after = sorted.get(sorted.size() - 1);
|
|
|
+ } else {
|
|
|
+ before = sorted.get(0);
|
|
|
+ after = sorted.get(sorted.size() - 1);
|
|
|
+ }
|
|
|
+ if (before.getNumericValue() == null || after.getNumericValue() == null) {
|
|
|
+ continue;
|
|
|
}
|
|
|
- // skipGaps:before/after 之间存在报告日期、但该指标缺失的期数(跨期跳比标记)
|
|
|
- // before 在 adjacent 分支被重新赋值,lambda 需 final 副本
|
|
|
long skipGaps = 0;
|
|
|
if ("adjacent".equals(scope)
|
|
|
&& before.getReportDate() != null && after.getReportDate() != null) {
|
|
|
- final IndicatorValue beforeF = before;
|
|
|
- final IndicatorValue afterF = after;
|
|
|
+ final IndicatorValue b = before, a = after;
|
|
|
long reportsBetween = allDates.stream()
|
|
|
- .filter(d -> d.after(beforeF.getReportDate()) && d.before(afterF.getReportDate()))
|
|
|
+ .filter(d -> d.after(b.getReportDate()) && d.before(a.getReportDate()))
|
|
|
.count();
|
|
|
long obsBetween = sorted.stream()
|
|
|
.filter(v -> v.getReportDate() != null
|
|
|
- && v.getReportDate().after(beforeF.getReportDate())
|
|
|
- && v.getReportDate().before(afterF.getReportDate()))
|
|
|
+ && v.getReportDate().after(b.getReportDate())
|
|
|
+ && v.getReportDate().before(a.getReportDate()))
|
|
|
.count();
|
|
|
skipGaps = Math.max(0, reportsBetween - obsBetween);
|
|
|
}
|
|
|
- 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;
|
|
|
+ IndicatorChangeService.ChangeResult cr = changeService.computeChange(def, before, after);
|
|
|
+ cr.setSkipGaps(skipGaps);
|
|
|
+ if (cr.getTrendCode().equals(IndicatorChangeService.TREND_IMPROVED)) improved++;
|
|
|
+ else if (cr.getTrendCode().equals(IndicatorChangeService.TREND_WORSENED)) worsened++;
|
|
|
+ else if (!cr.isComparable()) {
|
|
|
+ // 不可比项不参与 improved/worsened/flat 计数
|
|
|
+ } else {
|
|
|
+ flat++;
|
|
|
}
|
|
|
- BigDecimal delta = av.subtract(bv);
|
|
|
- // 基数为 0 时百分比无意义(如 0 → 0.5 显示 +0.5 而非误导性的 0%),置 null 由前端降级为只显示 Δ
|
|
|
- BigDecimal deltaPct = bv.compareTo(BigDecimal.ZERO) != 0
|
|
|
- ? delta.multiply(BigDecimal.valueOf(100)).divide(bv, 1, RoundingMode.HALF_UP)
|
|
|
- : null;
|
|
|
- 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");
|
|
|
+ item.put("name", def.getName());
|
|
|
+ item.put("category", def.getCategory());
|
|
|
+ item.put("sourceKind", def.getSourceKind());
|
|
|
+ item.put("unit", def.getUnit());
|
|
|
+ item.put("goodDirection", def.getGoodDirection());
|
|
|
+ item.put("valueType", def.getValueType());
|
|
|
+ item.put("refRange", def.getRefRange());
|
|
|
Map<String, Object> beforeMap = new LinkedHashMap<>();
|
|
|
beforeMap.put("reportDate", fmtDate(before.getReportDate()));
|
|
|
beforeMap.put("value", before.getValue());
|
|
|
+ beforeMap.put("numericValue", before.getNumericValue());
|
|
|
+ beforeMap.put("unit", before.getUnit());
|
|
|
+ beforeMap.put("status", before.getStatus());
|
|
|
Map<String, Object> afterMap = new LinkedHashMap<>();
|
|
|
afterMap.put("reportDate", fmtDate(after.getReportDate()));
|
|
|
afterMap.put("value", after.getValue());
|
|
|
+ afterMap.put("numericValue", after.getNumericValue());
|
|
|
+ afterMap.put("unit", after.getUnit());
|
|
|
+ afterMap.put("status", after.getStatus());
|
|
|
item.put("before", beforeMap);
|
|
|
item.put("after", afterMap);
|
|
|
- item.put("delta", delta);
|
|
|
- item.put("deltaPct", deltaPct);
|
|
|
- item.put("skipGaps", skipGaps);
|
|
|
- item.put("movement", movement);
|
|
|
- item.put("trendLabel", trendLabel);
|
|
|
+ item.put("delta", cr.getDelta());
|
|
|
+ item.put("deltaPct", cr.getDeltaPct());
|
|
|
+ item.put("movement", cr.getMovement());
|
|
|
+ item.put("trendLabel", cr.getTrendLabel());
|
|
|
+ item.put("trendCode", cr.getTrendCode());
|
|
|
+ item.put("skipGaps", cr.getSkipGaps());
|
|
|
+ item.put("unitMismatch", cr.isUnitMismatch());
|
|
|
+ item.put("comparable", cr.isComparable());
|
|
|
items.add(item);
|
|
|
}
|
|
|
|
|
|
- // 排序:magnitude 按 Δ 绝对值降序(默认);up/down 按 Δ 正/负降序
|
|
|
+ // 排序:magnitude 按 |Δ%| 降序,null 恒排最后
|
|
|
+ items.sort((a, b) -> {
|
|
|
+ BigDecimal pa = numeric(a, "deltaPct");
|
|
|
+ BigDecimal pb = numeric(b, "deltaPct");
|
|
|
+ if (pa == null && pb == null) return 0;
|
|
|
+ if (pa == null) return 1;
|
|
|
+ if (pb == null) return -1;
|
|
|
+ return pb.abs().compareTo(pa.abs());
|
|
|
+ });
|
|
|
if ("up".equals(orderBy)) {
|
|
|
- items.sort((a, b) -> ((BigDecimal) b.get("delta")).compareTo((BigDecimal) a.get("delta")));
|
|
|
+ items.sort((a, b) -> {
|
|
|
+ BigDecimal pa = numeric(a, "delta");
|
|
|
+ BigDecimal pb = numeric(b, "delta");
|
|
|
+ if (pa == null) return 1;
|
|
|
+ if (pb == null) return -1;
|
|
|
+ return pb.compareTo(pa);
|
|
|
+ });
|
|
|
} 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()));
|
|
|
+ items.sort((a, b) -> {
|
|
|
+ BigDecimal pa = numeric(a, "delta");
|
|
|
+ BigDecimal pb = numeric(b, "delta");
|
|
|
+ if (pa == null) return 1;
|
|
|
+ if (pb == null) return -1;
|
|
|
+ return pa.compareTo(pb);
|
|
|
+ });
|
|
|
}
|
|
|
|
|
|
int limitVal = limit != null && limit > 0 ? limit : 50;
|
|
|
@@ -217,132 +437,13 @@ public class IndicatorTrendService {
|
|
|
overall.put("improvedCount", improved);
|
|
|
overall.put("worsenedCount", worsened);
|
|
|
overall.put("flatCount", flat);
|
|
|
+ overall.put("incomparableCount", items.stream()
|
|
|
+ .filter(it -> Boolean.FALSE.equals(it.get("comparable")))
|
|
|
+ .count());
|
|
|
data.put("overall", overall);
|
|
|
return data;
|
|
|
}
|
|
|
|
|
|
- /** 该成员已有指标时序的报告类型(indicator_values.source_type 去重,曲线页类型筛选动态来源) */
|
|
|
- public List<Map<String, Object>> listSourceTypes(Long subjectId) {
|
|
|
- List<Map<String, Object>> items = new ArrayList<>();
|
|
|
- if (subjectId == null) {
|
|
|
- return items;
|
|
|
- }
|
|
|
- List<IndicatorValue> rows = valueMapper.selectList(new LambdaQueryWrapper<IndicatorValue>()
|
|
|
- .eq(IndicatorValue::getSubjectId, subjectId)
|
|
|
- .isNotNull(IndicatorValue::getSourceType)
|
|
|
- .isNotNull(IndicatorValue::getReportDate)
|
|
|
- .select(IndicatorValue::getSourceType));
|
|
|
- Set<String> types = new LinkedHashSet<>();
|
|
|
- for (IndicatorValue v : rows) {
|
|
|
- String t = v.getSourceType();
|
|
|
- if (t != null && !t.trim().isEmpty()) {
|
|
|
- types.add(t.trim());
|
|
|
- }
|
|
|
- }
|
|
|
- for (String t : types) {
|
|
|
- Map<String, Object> m = new LinkedHashMap<>();
|
|
|
- m.put("sourceType", t);
|
|
|
- m.put("label", sourceTypeLabel(t));
|
|
|
- items.add(m);
|
|
|
- }
|
|
|
- return items;
|
|
|
- }
|
|
|
-
|
|
|
- private String sourceTypeLabel(String t) {
|
|
|
- if (t == null) {
|
|
|
- return t;
|
|
|
- }
|
|
|
- if (t.startsWith("gut_")) {
|
|
|
- return "肠道菌群";
|
|
|
- }
|
|
|
- if ("food_recommend".equals(t)) {
|
|
|
- return "食材推荐";
|
|
|
- }
|
|
|
- if (t.startsWith("dan_")) {
|
|
|
- return "DAN 测评";
|
|
|
- }
|
|
|
- if ("physical_exam".equals(t)) {
|
|
|
- return "体检";
|
|
|
- }
|
|
|
- if ("tongue".equals(t)) {
|
|
|
- return "舌诊";
|
|
|
- }
|
|
|
- return t;
|
|
|
- }
|
|
|
-
|
|
|
- /** 指标速查(设计 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<>();
|
|
|
@@ -393,54 +494,32 @@ public class IndicatorTrendService {
|
|
|
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";
|
|
|
+ private String sourceTypeLabel(String t) {
|
|
|
+ if (t == null) {
|
|
|
+ return t;
|
|
|
}
|
|
|
- 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;
|
|
|
+ if (t.startsWith("gut_")) {
|
|
|
+ return "肠道菌群";
|
|
|
}
|
|
|
- IndicatorValue latest = sorted.get(0);
|
|
|
- IndicatorValue prev = sorted.get(1);
|
|
|
- if (latest.getNumericValue() == null || prev.getNumericValue() == null) {
|
|
|
- return BigDecimal.ZERO;
|
|
|
+ if ("food_recommend".equals(t)) {
|
|
|
+ return "食材推荐";
|
|
|
}
|
|
|
- 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 (t.startsWith("dan_")) {
|
|
|
+ return "DAN 测评";
|
|
|
}
|
|
|
- if (def == null || def.getGoodDirection() == null) {
|
|
|
- return "中性";
|
|
|
+ if ("physical_exam".equals(t)) {
|
|
|
+ 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 "中性";
|
|
|
+ if ("tongue".equals(t)) {
|
|
|
+ return "舌诊";
|
|
|
}
|
|
|
+ return t;
|
|
|
+ }
|
|
|
+
|
|
|
+ private BigDecimal numeric(Map<String, Object> m, String key) {
|
|
|
+ Object v = m.get(key);
|
|
|
+ if (v instanceof BigDecimal) return (BigDecimal) v;
|
|
|
+ if (v instanceof Number) return BigDecimal.valueOf(((Number) v).doubleValue());
|
|
|
+ return null;
|
|
|
}
|
|
|
}
|