|
|
@@ -0,0 +1,817 @@
|
|
|
+package com.etotem.cfc.service;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|
|
+import com.etotem.cfc.entity.HealthReport;
|
|
|
+import com.etotem.cfc.entity.IndicatorDefinition;
|
|
|
+import com.etotem.cfc.entity.IndicatorValue;
|
|
|
+import com.etotem.cfc.mapper.HealthReportMapper;
|
|
|
+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.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.List;
|
|
|
+import java.util.Map;
|
|
|
+import java.util.Objects;
|
|
|
+import java.util.stream.Collectors;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 指标时序服务(T02 · §5.1)。
|
|
|
+ * 组合层:读取 indicator_values / health_reports,委托 IndicatorChangeService 做变化计算。
|
|
|
+ */
|
|
|
+@Service
|
|
|
+public class IndicatorTimelineService {
|
|
|
+
|
|
|
+ private static final Logger log = LoggerFactory.getLogger(IndicatorTimelineService.class);
|
|
|
+
|
|
|
+ /** 日期来源质量排序(数值越小越"差") */
|
|
|
+ private static final Map<String, Integer> DATE_SOURCE_QUALITY = new LinkedHashMap<>();
|
|
|
+ static {
|
|
|
+ DATE_SOURCE_QUALITY.put("unknown", 0);
|
|
|
+ DATE_SOURCE_QUALITY.put("confirm", 1);
|
|
|
+ DATE_SOURCE_QUALITY.put("upload", 2);
|
|
|
+ DATE_SOURCE_QUALITY.put("real", 3);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private IndicatorValueMapper valueMapper;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private IndicatorDefinitionMapper definitionMapper;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private HealthReportMapper healthReportMapper;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private IndicatorChangeService changeService;
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 指标时间轴(§4.2)。
|
|
|
+ *
|
|
|
+ * @param subjectId 必填
|
|
|
+ * @param definitionIds 必填
|
|
|
+ * @param sourceType 可选;null = 全部来源
|
|
|
+ * @param fromDate 可选;闭区间
|
|
|
+ * @param toDate 可选;闭区间
|
|
|
+ * @param granularity raw / month
|
|
|
+ * @param sourceKind all / report(排除 body_fat_scale)/ device(只要 body_fat_scale)
|
|
|
+ * @return { items: [...] }
|
|
|
+ */
|
|
|
+ public Map<String, Object> timeline(Long subjectId, List<Long> definitionIds,
|
|
|
+ String sourceType, String fromDate, String toDate,
|
|
|
+ String granularity, String sourceKind) {
|
|
|
+ Map<String, Object> data = new LinkedHashMap<>();
|
|
|
+ List<Map<String, Object>> items = new ArrayList<>();
|
|
|
+
|
|
|
+ if (subjectId == null || definitionIds == null || definitionIds.isEmpty()) {
|
|
|
+ data.put("items", items);
|
|
|
+ return data;
|
|
|
+ }
|
|
|
+ // 参数校验
|
|
|
+ if ("month".equals(granularity)) {
|
|
|
+ // 合法
|
|
|
+ } else if (granularity != null && !granularity.isEmpty() && !"raw".equals(granularity)) {
|
|
|
+ throw new IllegalArgumentException("granularity 取值非法");
|
|
|
+ }
|
|
|
+ if (!"all".equals(sourceKind) && !"report".equals(sourceKind) && !"device".equals(sourceKind)) {
|
|
|
+ sourceKind = "all";
|
|
|
+ }
|
|
|
+
|
|
|
+ // ---- 1. 拉取观测 ----
|
|
|
+ LambdaQueryWrapper<IndicatorValue> qw = new LambdaQueryWrapper<IndicatorValue>()
|
|
|
+ .eq(IndicatorValue::getSubjectId, subjectId)
|
|
|
+ .in(IndicatorValue::getDefinitionId, definitionIds)
|
|
|
+ .isNotNull(IndicatorValue::getReportDate)
|
|
|
+ .orderByAsc(IndicatorValue::getReportDate);
|
|
|
+ if (sourceType != null && !sourceType.trim().isEmpty()) {
|
|
|
+ qw.eq(IndicatorValue::getSourceType, sourceType);
|
|
|
+ }
|
|
|
+ if (sourceKind != null) {
|
|
|
+ if ("report".equals(sourceKind)) {
|
|
|
+ qw.ne(IndicatorValue::getSourceType, "body_fat_scale");
|
|
|
+ } else if ("device".equals(sourceKind)) {
|
|
|
+ qw.eq(IndicatorValue::getSourceType, "body_fat_scale");
|
|
|
+ }
|
|
|
+ }
|
|
|
+ List<IndicatorValue> rows = valueMapper.selectList(qw);
|
|
|
+ if (rows == null || rows.isEmpty()) {
|
|
|
+ data.put("items", items);
|
|
|
+ return data;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 按 definitionId 分组(保留原始顺序)
|
|
|
+ Map<Long, List<IndicatorValue>> byDef = rows.stream()
|
|
|
+ .collect(Collectors.groupingBy(IndicatorValue::getDefinitionId,
|
|
|
+ LinkedHashMap::new, Collectors.toList()));
|
|
|
+
|
|
|
+ // ---- 2. 获取 active definitions ----
|
|
|
+ List<Long> defIds = new ArrayList<>(byDef.keySet());
|
|
|
+ Map<Long, IndicatorDefinition> defs = changeService.loadActiveDefinitions(defIds);
|
|
|
+
|
|
|
+ // ---- 3. 统计 totalReportCount / olderReportCount(一次查询,内存计算两项)----
|
|
|
+ // 设计 §4.2:一份 COUNT 查询同时得 total 与 older,避免两次 COUNT。
|
|
|
+ java.sql.Date latestObsDate = null;
|
|
|
+ for (IndicatorValue v : rows) {
|
|
|
+ if (v.getReportDate() != null) {
|
|
|
+ if (latestObsDate == null || v.getReportDate().after(latestObsDate)) {
|
|
|
+ latestObsDate = v.getReportDate();
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ final java.util.Date latestObsDateFinal = latestObsDate;
|
|
|
+ Long totalReportCount = null;
|
|
|
+ Long olderReportCount = null;
|
|
|
+ if (latestObsDateFinal != null) {
|
|
|
+ // 一次拉全部报告日期,内存算 total / older(总量小,单人单类型 < 100)
|
|
|
+ List<java.util.Date> reportDates = healthReportMapper.selectList(new LambdaQueryWrapper<HealthReport>()
|
|
|
+ .eq(HealthReport::getSubjectId, subjectId)
|
|
|
+ .eq(HealthReport::getStatus, "active")
|
|
|
+ .isNotNull(HealthReport::getReportDate)
|
|
|
+ .eq(sourceType != null && !sourceType.trim().isEmpty(),
|
|
|
+ HealthReport::getReportType, sourceType)
|
|
|
+ .select(HealthReport::getReportDate))
|
|
|
+ .stream().map(HealthReport::getReportDate).filter(Objects::nonNull)
|
|
|
+ .collect(Collectors.toList());
|
|
|
+ totalReportCount = (long) reportDates.size();
|
|
|
+ olderReportCount = reportDates.stream()
|
|
|
+ .filter(d -> toSqlDate(d).before(latestObsDateFinal)).count();
|
|
|
+ }
|
|
|
+
|
|
|
+ // ---- 4. 按 definition 组装 ----
|
|
|
+ for (Map.Entry<Long, List<IndicatorValue>> e : byDef.entrySet()) {
|
|
|
+ Long defId = e.getKey();
|
|
|
+ IndicatorDefinition def = defs.get(defId);
|
|
|
+ if (def == null) {
|
|
|
+ // Q17 强制:拿不到的 active definition 直接剔除,不降级
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ List<IndicatorValue> pointsRaw = e.getValue();
|
|
|
+ if (pointsRaw == null || pointsRaw.isEmpty()) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 过滤:无 numeric_value 的点是否纳入 chart? 保留所有点(非数值也能显示),pointsCount 计数包含
|
|
|
+ int pointsCount = pointsRaw.size();
|
|
|
+ // 计算 unitMismatch:points 中与 latest.unit 不同的单位
|
|
|
+ boolean unitMismatch = false;
|
|
|
+ String latestUnit = null;
|
|
|
+ for (int i = pointsRaw.size() - 1; i >= 0; i--) {
|
|
|
+ String u = pointsRaw.get(i).getUnit();
|
|
|
+ if (u != null && !u.trim().isEmpty()) {
|
|
|
+ latestUnit = u.trim();
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (latestUnit != null) {
|
|
|
+ for (IndicatorValue v : pointsRaw) {
|
|
|
+ String vu = v.getUnit();
|
|
|
+ if (vu != null && !changeService.normUnit(vu).equals(changeService.normUnit(latestUnit))) {
|
|
|
+ unitMismatch = true;
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 按 reportDate 升序排列(与源数据一致)
|
|
|
+ List<IndicatorValue> points = new ArrayList<>(pointsRaw);
|
|
|
+ points.sort(IndicatorChangeService.NULLS_LAST_DESC.reversed()); // 升序
|
|
|
+
|
|
|
+ // 聚合:n>=20 时按月聚合
|
|
|
+ String gran = granularity != null ? granularity.trim() : "raw";
|
|
|
+ boolean needAggregate = points.size() >= 20;
|
|
|
+ if (needAggregate) {
|
|
|
+ points = changeService.aggregateByMonth(points);
|
|
|
+ }
|
|
|
+
|
|
|
+ // missingPeriods:该 (subjectId, sourceType) 下报告日期存在、但本指标无有效值的日期
|
|
|
+ List<Map<String, Object>> missingPeriods = computeMissingPeriods(subjectId, sourceType, points);
|
|
|
+
|
|
|
+ // refLow / refHigh
|
|
|
+ double refLow = Double.NaN;
|
|
|
+ double refHigh = Double.NaN;
|
|
|
+ String refRangeStr = null;
|
|
|
+ if (def.getRefRange() != null && !def.getRefRange().trim().isEmpty()) {
|
|
|
+ IndicatorChangeService.RefRange ref = changeService.parseRefRange(def.getRefRange());
|
|
|
+ if (ref.isParsed()) {
|
|
|
+ if (ref.getLow() != null) refLow = ref.getLow().doubleValue();
|
|
|
+ if (ref.getHigh() != null) refHigh = ref.getHigh().doubleValue();
|
|
|
+ refRangeStr = def.getRefRange();
|
|
|
+ }
|
|
|
+ }
|
|
|
+ // 如果有 refRangeSnapshot,优先用快照
|
|
|
+ IndicatorValue lastWithValue = null;
|
|
|
+ for (int i = points.size() - 1; i >= 0; i--) {
|
|
|
+ if (points.get(i).getValue() != null && !points.get(i).getValue().trim().isEmpty()) {
|
|
|
+ lastWithValue = points.get(i);
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ String effectiveRefRange = null;
|
|
|
+ if (lastWithValue != null) {
|
|
|
+ String snap = lastWithValue.getRefRangeSnapshot();
|
|
|
+ if (snap != null && !snap.trim().isEmpty()) {
|
|
|
+ IndicatorChangeService.RefRange r = changeService.parseRefRange(snap);
|
|
|
+ if (r.isParsed()) {
|
|
|
+ if (r.getLow() != null) refLow = r.getLow().doubleValue();
|
|
|
+ if (r.getHigh() != null) refHigh = r.getHigh().doubleValue();
|
|
|
+ effectiveRefRange = snap;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (effectiveRefRange == null && refRangeStr != null) {
|
|
|
+ effectiveRefRange = refRangeStr;
|
|
|
+ // 重新算 refLow/refHigh
|
|
|
+ IndicatorChangeService.RefRange r = changeService.parseRefRange(refRangeStr);
|
|
|
+ if (r.isParsed()) {
|
|
|
+ if (r.getLow() != null) refLow = r.getLow().doubleValue();
|
|
|
+ if (r.getHigh() != null) refHigh = r.getHigh().doubleValue();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // chartMode
|
|
|
+ String chartMode = changeService.chartModeOf(pointsCount, gran);
|
|
|
+
|
|
|
+ // 组装 points 与 latest / change
|
|
|
+ List<Map<String, Object>> pointsOut = new ArrayList<>();
|
|
|
+ for (IndicatorValue v : points) {
|
|
|
+ Map<String, Object> p = new LinkedHashMap<>();
|
|
|
+ p.put("reportId", v.getReportId());
|
|
|
+ p.put("reportDate", v.getReportDate() != null ? fmt(v.getReportDate()) : null);
|
|
|
+ p.put("dateSource", v.getDateSource());
|
|
|
+ p.put("value", v.getValue());
|
|
|
+ p.put("numericValue", v.getNumericValue());
|
|
|
+ p.put("unit", v.getUnit());
|
|
|
+ p.put("status", v.getStatus());
|
|
|
+ p.put("sourceType", v.getSourceType());
|
|
|
+ pointsOut.add(p);
|
|
|
+ }
|
|
|
+
|
|
|
+ // latest:points 末元素
|
|
|
+ Map<String, Object> latest = pointsOut.isEmpty() ? null : pointsOut.get(pointsOut.size() - 1);
|
|
|
+
|
|
|
+ // change
|
|
|
+ Map<String, Object> change = buildChange(def, points);
|
|
|
+
|
|
|
+ Map<String, Object> item = new LinkedHashMap<>();
|
|
|
+ item.put("definitionId", defId);
|
|
|
+ item.put("name", def.getName());
|
|
|
+ item.put("unit", def.getUnit());
|
|
|
+ item.put("refRange", effectiveRefRange);
|
|
|
+ item.put("refLow", Double.isNaN(refLow) ? null : refLow);
|
|
|
+ item.put("refHigh", Double.isNaN(refHigh) ? null : refHigh);
|
|
|
+ item.put("goodDirection", def.getGoodDirection());
|
|
|
+ item.put("valueType", def.getValueType());
|
|
|
+ item.put("pointsCount", pointsCount);
|
|
|
+ item.put("totalReportCount", totalReportCount);
|
|
|
+ item.put("olderReportCount", olderReportCount);
|
|
|
+ item.put("chartMode", chartMode);
|
|
|
+ item.put("unitMismatch", unitMismatch);
|
|
|
+ item.put("latest", latest);
|
|
|
+ item.put("change", change);
|
|
|
+ item.put("points", pointsOut);
|
|
|
+ item.put("missingPeriods", missingPeriods);
|
|
|
+ items.add(item);
|
|
|
+ }
|
|
|
+
|
|
|
+ data.put("items", items);
|
|
|
+ return data;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 期次列表(§4.5)。供 P1-1 双期对比页 picker 使用。
|
|
|
+ *
|
|
|
+ * @return { subjectId, sourceType, items: [{ reportDate, reportId, dateSource, indicatorCount }] }
|
|
|
+ */
|
|
|
+ public Map<String, Object> listPeriods(Long subjectId, String sourceType) {
|
|
|
+ Map<String, Object> data = new LinkedHashMap<>();
|
|
|
+ data.put("subjectId", subjectId);
|
|
|
+ data.put("sourceType", sourceType);
|
|
|
+ List<Map<String, Object>> items = new ArrayList<>();
|
|
|
+
|
|
|
+ if (subjectId == null) {
|
|
|
+ data.put("items", items);
|
|
|
+ return data;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 1) 拉 health_reports(指定 sourceType 时按 report_type 过滤)
|
|
|
+ LambdaQueryWrapper<HealthReport> rq = new LambdaQueryWrapper<HealthReport>()
|
|
|
+ .eq(HealthReport::getSubjectId, subjectId)
|
|
|
+ .eq(HealthReport::getStatus, "active")
|
|
|
+ .orderByDesc(HealthReport::getReportDate)
|
|
|
+ .orderByDesc(HealthReport::getId);
|
|
|
+ if (sourceType != null && !sourceType.trim().isEmpty()) {
|
|
|
+ rq.eq(HealthReport::getReportType, sourceType);
|
|
|
+ }
|
|
|
+ List<HealthReport> reports = healthReportMapper.selectList(rq);
|
|
|
+ if (reports.isEmpty()) {
|
|
|
+ data.put("items", items);
|
|
|
+ return data;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 2) 拉该 subject 下所有 indicator_values,按 (sourceType, sourceId) 分组计数
|
|
|
+ // sourceId = reportId(health_indicators 不在 indicator_values 中)
|
|
|
+ List<IndicatorValue> allObs = valueMapper.selectList(new LambdaQueryWrapper<IndicatorValue>()
|
|
|
+ .eq(IndicatorValue::getSubjectId, subjectId)
|
|
|
+ .eq(sourceType != null && !sourceType.trim().isEmpty(),
|
|
|
+ IndicatorValue::getSourceType, sourceType)
|
|
|
+ .select(IndicatorValue::getSourceType, IndicatorValue::getSourceId,
|
|
|
+ IndicatorValue::getReportDate, IndicatorValue::getDateSource));
|
|
|
+
|
|
|
+ // 按 sourceId 分组:{sourceId -> { count, worstDateSource }}
|
|
|
+ Map<Long, PeriodMeta> metaMap = new HashMap<>();
|
|
|
+ for (IndicatorValue v : allObs) {
|
|
|
+ if (v.getSourceId() == null) continue;
|
|
|
+ Long sid = v.getSourceId();
|
|
|
+ PeriodMeta m = metaMap.get(sid);
|
|
|
+ if (m == null) {
|
|
|
+ m = new PeriodMeta();
|
|
|
+ metaMap.put(sid, m);
|
|
|
+ }
|
|
|
+ m.count++;
|
|
|
+ if (v.getDateSource() != null) {
|
|
|
+ Integer quality = DATE_SOURCE_QUALITY.get(v.getDateSource());
|
|
|
+ if (quality != null && (m.worstQuality == null || quality < m.worstQuality)) {
|
|
|
+ m.worstQuality = quality;
|
|
|
+ m.worstDateSource = v.getDateSource();
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 3) 每个 report 组装一条
|
|
|
+ for (HealthReport r : reports) {
|
|
|
+ Map<String, Object> row = new LinkedHashMap<>();
|
|
|
+ row.put("reportDate", r.getReportDate() != null ? fmt(r.getReportDate()) : null);
|
|
|
+ row.put("reportId", r.getId());
|
|
|
+ row.put("dateSource", r.getReportDate() == null ? "unknown" : "real");
|
|
|
+ PeriodMeta m = metaMap.get(r.getId());
|
|
|
+ int indicatorCount = m != null ? m.count : 0;
|
|
|
+ String dateSource;
|
|
|
+ if (m != null && m.worstDateSource != null) {
|
|
|
+ dateSource = m.worstDateSource;
|
|
|
+ } else {
|
|
|
+ Object ds = row.get("dateSource");
|
|
|
+ dateSource = ds != null ? ds.toString() : "unknown";
|
|
|
+ }
|
|
|
+ row.put("dateSource", dateSource);
|
|
|
+ row.put("indicatorCount", indicatorCount);
|
|
|
+ items.add(row);
|
|
|
+ }
|
|
|
+ // 日期未知期恒定排最后
|
|
|
+ items.sort((a, b) -> {
|
|
|
+ String da = (String) a.get("reportDate");
|
|
|
+ String db = (String) b.get("reportDate");
|
|
|
+ if (da == null && db == null) return 0;
|
|
|
+ if (da == null) return 1;
|
|
|
+ if (db == null) return -1;
|
|
|
+ return db.compareTo(da);
|
|
|
+ });
|
|
|
+ data.put("items", items);
|
|
|
+ return data;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 双期对比(§4.3 scope=pair)。
|
|
|
+ */
|
|
|
+ public Map<String, Object> pairCompare(Long subjectId, String sourceType,
|
|
|
+ String fromReportDate, String toReportDate,
|
|
|
+ String category, String orderBy,
|
|
|
+ String filter, Integer page, Integer pageSize) {
|
|
|
+ Map<String, Object> data = new LinkedHashMap<>();
|
|
|
+
|
|
|
+ // 校验
|
|
|
+ if (fromReportDate == null || toReportDate == null) {
|
|
|
+ throw new IllegalArgumentException("scope=pair 时必须传 fromReportDate 与 toReportDate");
|
|
|
+ }
|
|
|
+ java.sql.Date fromDate = parseDate(fromReportDate);
|
|
|
+ java.sql.Date toDate = parseDate(toReportDate);
|
|
|
+ if (fromDate == null || toDate == null) {
|
|
|
+ throw new IllegalArgumentException("指定的期次不存在");
|
|
|
+ }
|
|
|
+ if (fromDate.after(toDate)) {
|
|
|
+ throw new IllegalArgumentException("fromReportDate 必须早于或等于 toReportDate");
|
|
|
+ }
|
|
|
+
|
|
|
+ // 找 from/to 的报告
|
|
|
+ HealthReport fromReport = findReportByDate(subjectId, sourceType, fromDate);
|
|
|
+ HealthReport toReport = findReportByDate(subjectId, sourceType, toDate);
|
|
|
+ if (fromReport == null || toReport == null) {
|
|
|
+ throw new IllegalArgumentException("指定的期次不存在");
|
|
|
+ }
|
|
|
+ boolean sameDateWarning = fromDate.equals(toDate);
|
|
|
+
|
|
|
+ data.put("sameDateWarning", sameDateWarning);
|
|
|
+
|
|
|
+ // 拉观测:该 subject + sourceType + reportDate 为 from/to 之间的全部
|
|
|
+ LambdaQueryWrapper<IndicatorValue> qw = new LambdaQueryWrapper<IndicatorValue>()
|
|
|
+ .eq(IndicatorValue::getSubjectId, subjectId)
|
|
|
+ .isNotNull(IndicatorValue::getReportDate)
|
|
|
+ .ge(IndicatorValue::getReportDate, fromDate)
|
|
|
+ .le(IndicatorValue::getReportDate, toDate);
|
|
|
+ if (sourceType != null && !sourceType.trim().isEmpty()) {
|
|
|
+ qw.eq(IndicatorValue::getSourceType, sourceType);
|
|
|
+ }
|
|
|
+ List<IndicatorValue> rows = valueMapper.selectList(qw.orderByAsc(IndicatorValue::getReportDate));
|
|
|
+
|
|
|
+ // from / to 期次元信息(含 indicatorCount)
|
|
|
+ data.put("from", buildPeriodMeta(fromReport, countByReportId(rows, fromReport.getId())));
|
|
|
+ data.put("to", buildPeriodMeta(toReport, countByReportId(rows, toReport.getId())));
|
|
|
+
|
|
|
+ // 按 definitionId 分组
|
|
|
+ Map<Long, List<IndicatorValue>> byDef = rows.stream()
|
|
|
+ .filter(v -> v.getDefinitionId() != null)
|
|
|
+ .collect(Collectors.groupingBy(IndicatorValue::getDefinitionId));
|
|
|
+
|
|
|
+ // active definitions
|
|
|
+ List<Long> defIds = new ArrayList<>(byDef.keySet());
|
|
|
+ Map<Long, IndicatorDefinition> defs = changeService.loadActiveDefinitions(defIds);
|
|
|
+
|
|
|
+ // 组装 before/after 观测映射
|
|
|
+ Map<Long, IndicatorValue> beforeMap = new HashMap<>();
|
|
|
+ Map<Long, IndicatorValue> afterMap = new HashMap<>();
|
|
|
+ for (IndicatorValue v : rows) {
|
|
|
+ if (v.getReportId() == null) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ if (v.getReportId().equals(fromReport.getId())) {
|
|
|
+ beforeMap.put(v.getDefinitionId(), v);
|
|
|
+ }
|
|
|
+ if (v.getReportId().equals(toReport.getId())) {
|
|
|
+ afterMap.put(v.getDefinitionId(), v);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // skipGaps 基数:该 (subjectId, sourceType) 在 from..to 之间的全部观测期数(内存计算,零额外查询)
|
|
|
+ // 语义与 IndicatorChangeService.computeSkipGaps 一致:
|
|
|
+ // skipGaps = 两期之间「有观测但该指标无数值」的期数
|
|
|
+ final long reportsBetween = countPeriodsBetween(rows, fromDate, toDate);
|
|
|
+ // 用于 overall 统计
|
|
|
+ int improved = 0, worsened = 0, flat = 0, incomparable = 0;
|
|
|
+ List<Map<String, Object>> allItems = new ArrayList<>();
|
|
|
+
|
|
|
+ for (Map.Entry<Long, List<IndicatorValue>> e : byDef.entrySet()) {
|
|
|
+ Long defId = e.getKey();
|
|
|
+ IndicatorDefinition def = defs.get(defId);
|
|
|
+ if (def == null) continue; // Q17 强制剔除 status=0
|
|
|
+ if (category != null && !category.isEmpty() && !category.equals(def.getCategory())) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ IndicatorValue before = beforeMap.get(defId);
|
|
|
+ IndicatorValue after = afterMap.get(defId);
|
|
|
+
|
|
|
+ Map<String, Object> item = new LinkedHashMap<>();
|
|
|
+ item.put("definitionId", defId);
|
|
|
+ item.put("name", def.getName());
|
|
|
+ item.put("category", def.getCategory());
|
|
|
+ item.put("sourceKind", def.getSourceKind());
|
|
|
+ item.put("unit", def.getUnit());
|
|
|
+ item.put("refRange", def.getRefRange());
|
|
|
+ item.put("goodDirection", def.getGoodDirection());
|
|
|
+ item.put("valueType", def.getValueType());
|
|
|
+ item.put("before", before != null ? buildObsMeta(before) : null);
|
|
|
+ item.put("after", after != null ? buildObsMeta(after) : null);
|
|
|
+
|
|
|
+ // 仅一期有值 → 不可比,但必须出现在 items 中(filter=incomparable 才有意义)
|
|
|
+ if (before == null || after == null) {
|
|
|
+ incomparable++;
|
|
|
+ item.put("delta", null);
|
|
|
+ item.put("deltaPct", null);
|
|
|
+ item.put("movement", IndicatorChangeService.MOVEMENT_NONE);
|
|
|
+ item.put("trendLabel", "无法比较");
|
|
|
+ item.put("trendCode", IndicatorChangeService.TREND_FLAT);
|
|
|
+ item.put("skipGaps", 0L);
|
|
|
+ item.put("unitMismatch", false);
|
|
|
+ item.put("comparable", false);
|
|
|
+ allItems.add(item);
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ // skipGaps:内存计算(避免每指标 2 次查询的 N+1)
|
|
|
+ long skipGaps = Math.max(0L, reportsBetween - countObsBetween(e.getValue(), fromDate, toDate));
|
|
|
+
|
|
|
+ // 非数值 → 不可比(trendCode 恒 flat,不给颜色)
|
|
|
+ if (!"numeric".equals(def.getValueType())
|
|
|
+ || before.getNumericValue() == null || after.getNumericValue() == null) {
|
|
|
+ incomparable++;
|
|
|
+ item.put("delta", null);
|
|
|
+ item.put("deltaPct", null);
|
|
|
+ String movement = before.getValue() != null && after.getValue() != null
|
|
|
+ && before.getValue().equals(after.getValue())
|
|
|
+ ? IndicatorChangeService.MOVEMENT_UNCHANGED : IndicatorChangeService.MOVEMENT_CHANGED;
|
|
|
+ item.put("movement", movement);
|
|
|
+ item.put("trendLabel", IndicatorChangeService.MOVEMENT_UNCHANGED.equals(movement)
|
|
|
+ ? "无变化" : "有变化");
|
|
|
+ item.put("trendCode", IndicatorChangeService.TREND_FLAT);
|
|
|
+ item.put("skipGaps", skipGaps);
|
|
|
+ item.put("unitMismatch", false);
|
|
|
+ item.put("comparable", false);
|
|
|
+ allItems.add(item);
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 计算 change
|
|
|
+ IndicatorChangeService.ChangeResult cr = changeService.computeChange(def, before, after);
|
|
|
+ cr.setSkipGaps(skipGaps);
|
|
|
+ // 单位不一致 → 百分比不可比,delta 保留
|
|
|
+ if (cr.isUnitMismatch()) {
|
|
|
+ incomparable++;
|
|
|
+ item.put("delta", cr.getDelta());
|
|
|
+ item.put("deltaPct", null);
|
|
|
+ item.put("movement", cr.getMovement());
|
|
|
+ item.put("trendLabel", "无法比较");
|
|
|
+ item.put("trendCode", IndicatorChangeService.TREND_FLAT);
|
|
|
+ item.put("skipGaps", skipGaps);
|
|
|
+ item.put("unitMismatch", true);
|
|
|
+ item.put("comparable", false);
|
|
|
+ allItems.add(item);
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ // 统计
|
|
|
+ if (cr.getTrendCode().equals(IndicatorChangeService.TREND_IMPROVED)) improved++;
|
|
|
+ else if (cr.getTrendCode().equals(IndicatorChangeService.TREND_WORSENED)) worsened++;
|
|
|
+ else if (!cr.isComparable()) incomparable++;
|
|
|
+ else flat++;
|
|
|
+
|
|
|
+ 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());
|
|
|
+ allItems.add(item);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 排序
|
|
|
+ sortItems(allItems, orderBy);
|
|
|
+
|
|
|
+ // filter: improved / worsened / flat / incomparable / all
|
|
|
+ if (filter != null && !"all".equals(filter)) {
|
|
|
+ final String f = filter;
|
|
|
+ if ("improved".equals(f)) {
|
|
|
+ allItems.removeIf(it -> !IndicatorChangeService.TREND_IMPROVED.equals(it.get("trendCode")));
|
|
|
+ } else if ("worsened".equals(f)) {
|
|
|
+ allItems.removeIf(it -> !IndicatorChangeService.TREND_WORSENED.equals(it.get("trendCode")));
|
|
|
+ } else if ("flat".equals(f)) {
|
|
|
+ allItems.removeIf(it -> !(IndicatorChangeService.TREND_FLAT.equals(it.get("trendCode"))
|
|
|
+ && Boolean.TRUE.equals(it.get("comparable"))));
|
|
|
+ } else if ("incomparable".equals(f)) {
|
|
|
+ allItems.removeIf(it -> !Boolean.FALSE.equals(it.get("comparable")));
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ int total = allItems.size();
|
|
|
+ // 分页
|
|
|
+ int pageVal = page != null && page > 0 ? page : 1;
|
|
|
+ int sizeVal = pageSize != null && pageSize > 0 ? pageSize : 50;
|
|
|
+ int fromIdx = Math.min((pageVal - 1) * sizeVal, total);
|
|
|
+ int toIdx = Math.min(fromIdx + sizeVal, total);
|
|
|
+ List<Map<String, Object>> paged = allItems.isEmpty()
|
|
|
+ ? new ArrayList<>()
|
|
|
+ : new ArrayList<>(allItems.subList(fromIdx, toIdx));
|
|
|
+
|
|
|
+ data.put("overall", buildOverall(improved, worsened, flat, incomparable));
|
|
|
+ data.put("items", paged);
|
|
|
+ data.put("page", pageVal);
|
|
|
+ data.put("pageSize", sizeVal);
|
|
|
+ data.put("total", total);
|
|
|
+ return data;
|
|
|
+ }
|
|
|
+
|
|
|
+ // ---------- 内部工具 ----------
|
|
|
+
|
|
|
+ /** (内存计算)某份报告下有指标的条数(用于 from/to 的 indicatorCount) */
|
|
|
+ private int countByReportId(List<IndicatorValue> rows, Long reportId) {
|
|
|
+ if (rows == null || reportId == null) return 0;
|
|
|
+ return (int) rows.stream()
|
|
|
+ .filter(v -> reportId.equals(v.getReportId()))
|
|
|
+ .filter(v -> v.getDefinitionId() != null)
|
|
|
+ .count();
|
|
|
+ }
|
|
|
+
|
|
|
+ /** (内存计算,无额外查询)该 (subjectId, sourceType) 在 fromDate..toDate 之间(严格区间)的全部观测期数 */
|
|
|
+ private long countPeriodsBetween(List<IndicatorValue> rows, java.sql.Date fromDate, java.sql.Date toDate) {
|
|
|
+ if (rows == null) return 0L;
|
|
|
+ return rows.stream()
|
|
|
+ .map(IndicatorValue::getReportDate)
|
|
|
+ .filter(Objects::nonNull)
|
|
|
+ .distinct()
|
|
|
+ .filter(d -> d.after(fromDate) && d.before(toDate))
|
|
|
+ .count();
|
|
|
+ }
|
|
|
+
|
|
|
+ /** (内存计算,无额外查询)指定定义在 fromDate..toDate 之间(严格区间)的有效值观测期数 */
|
|
|
+ private long countObsBetween(List<IndicatorValue> defPoints, java.sql.Date fromDate, java.sql.Date toDate) {
|
|
|
+ if (defPoints == null) return 0L;
|
|
|
+ return defPoints.stream()
|
|
|
+ .filter(v -> v.getNumericValue() != null && v.getReportDate() != null)
|
|
|
+ .map(IndicatorValue::getReportDate)
|
|
|
+ .distinct()
|
|
|
+ .filter(d -> d.after(fromDate) && d.before(toDate))
|
|
|
+ .count();
|
|
|
+ }
|
|
|
+
|
|
|
+ private Map<String, Object> buildChange(IndicatorDefinition def, List<IndicatorValue> points) {
|
|
|
+ // change = 最新 vs 上一期(相邻)
|
|
|
+ if (points == null || points.size() < 2) {
|
|
|
+ // 无上一期可对比,走 first_record / pending_backfill 取决于 olderReportCount
|
|
|
+ // 这里先返回 null,调用方若需要更精细应传 olderReportCount
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ IndicatorValue after = points.get(points.size() - 1);
|
|
|
+ IndicatorValue before = points.get(points.size() - 2);
|
|
|
+ return changeService.computeChange(def, before, after).toMap();
|
|
|
+ }
|
|
|
+
|
|
|
+ private Map<String, Object> buildPeriodMeta(HealthReport r, int indicatorCount) {
|
|
|
+ Map<String, Object> m = new LinkedHashMap<>();
|
|
|
+ m.put("reportDate", r.getReportDate() != null ? fmt(r.getReportDate()) : null);
|
|
|
+ m.put("reportId", r.getId());
|
|
|
+ m.put("dateSource", r.getReportDate() == null ? "unknown" : "real");
|
|
|
+ m.put("indicatorCount", indicatorCount);
|
|
|
+ return m;
|
|
|
+ }
|
|
|
+
|
|
|
+ private Map<String, Object> buildObsMeta(IndicatorValue v) {
|
|
|
+ Map<String, Object> m = new LinkedHashMap<>();
|
|
|
+ m.put("reportId", v.getReportId());
|
|
|
+ m.put("reportDate", v.getReportDate() != null ? fmt(v.getReportDate()) : null);
|
|
|
+ m.put("dateSource", v.getDateSource());
|
|
|
+ m.put("value", v.getValue());
|
|
|
+ m.put("numericValue", v.getNumericValue());
|
|
|
+ m.put("unit", v.getUnit());
|
|
|
+ m.put("status", v.getStatus());
|
|
|
+ return m;
|
|
|
+ }
|
|
|
+
|
|
|
+ private Map<String, Object> buildOverall(int improved, int worsened, int flat, int incomparable) {
|
|
|
+ Map<String, Object> o = new LinkedHashMap<>();
|
|
|
+ o.put("comparableCount", improved + worsened + flat);
|
|
|
+ o.put("improvedCount", improved);
|
|
|
+ o.put("worsenedCount", worsened);
|
|
|
+ o.put("flatCount", flat);
|
|
|
+ o.put("incomparableCount", incomparable);
|
|
|
+ return o;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 计算缺失期(2 次查询,N+1 优化版) */
|
|
|
+ private List<Map<String, Object>> computeMissingPeriods(Long subjectId, String sourceType,
|
|
|
+ List<IndicatorValue> points) {
|
|
|
+ if (points == null || points.isEmpty()) {
|
|
|
+ return Collections.emptyList();
|
|
|
+ }
|
|
|
+ // 该 (subjectId, sourceType) 的全部有值观测日期集合(distinct reportDate)
|
|
|
+ List<java.sql.Date> allObsDates = valueMapper.selectList(new LambdaQueryWrapper<IndicatorValue>()
|
|
|
+ .eq(IndicatorValue::getSubjectId, subjectId)
|
|
|
+ .eq(sourceType != null && !sourceType.trim().isEmpty(),
|
|
|
+ IndicatorValue::getSourceType, sourceType)
|
|
|
+ .isNotNull(IndicatorValue::getValue)
|
|
|
+ .select(IndicatorValue::getReportDate))
|
|
|
+ .stream().map(IndicatorValue::getReportDate).filter(Objects::nonNull)
|
|
|
+ .distinct().sorted().collect(Collectors.toList());
|
|
|
+
|
|
|
+ // 本指标已观测日期集合
|
|
|
+ java.util.Set<java.sql.Date> obsDates = points.stream()
|
|
|
+ .map(IndicatorValue::getReportDate)
|
|
|
+ .filter(Objects::nonNull)
|
|
|
+ .collect(Collectors.toSet());
|
|
|
+
|
|
|
+ // 按日期建索引:每个 date 的 reportId + dateSource
|
|
|
+ // 用 Map<Date, row> 而非 List,避免 Date 与 String 比较恒不相等导致行内容丢失
|
|
|
+ Map<java.sql.Date, Map<String, Object>> dateRowMap = new java.util.LinkedHashMap<>();
|
|
|
+ if (!allObsDates.isEmpty()) {
|
|
|
+ List<IndicatorValue> dayObs = valueMapper.selectList(new LambdaQueryWrapper<IndicatorValue>()
|
|
|
+ .eq(IndicatorValue::getSubjectId, subjectId)
|
|
|
+ .eq(sourceType != null && !sourceType.trim().isEmpty(),
|
|
|
+ IndicatorValue::getSourceType, sourceType)
|
|
|
+ .isNotNull(IndicatorValue::getValue)
|
|
|
+ .in(IndicatorValue::getReportDate, allObsDates)
|
|
|
+ .select(IndicatorValue::getReportDate, IndicatorValue::getReportId,
|
|
|
+ IndicatorValue::getDateSource));
|
|
|
+ Map<java.sql.Date, IndicatorValue> firstPerDate = new java.util.LinkedHashMap<>();
|
|
|
+ for (IndicatorValue v : dayObs) {
|
|
|
+ java.sql.Date d = v.getReportDate();
|
|
|
+ if (d != null && !firstPerDate.containsKey(d)) {
|
|
|
+ firstPerDate.put(d, v);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ for (java.sql.Date d : allObsDates) {
|
|
|
+ IndicatorValue v = firstPerDate.get(d);
|
|
|
+ Map<String, Object> row = new LinkedHashMap<>();
|
|
|
+ row.put("reportDate", fmt(d));
|
|
|
+ row.put("reportId", v != null ? v.getReportId() : null);
|
|
|
+ row.put("dateSource", v != null ? v.getDateSource() : "unknown");
|
|
|
+ dateRowMap.put(d, row);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ List<Map<String, Object>> missing = new ArrayList<>();
|
|
|
+ for (java.sql.Date d : allObsDates) {
|
|
|
+ if (!obsDates.contains(d)) {
|
|
|
+ Map<String, Object> row = dateRowMap.get(d);
|
|
|
+ if (row != null) {
|
|
|
+ missing.add(row);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return missing;
|
|
|
+ }
|
|
|
+
|
|
|
+ private HealthReport findReportByDate(Long subjectId, String sourceType, java.sql.Date date) {
|
|
|
+ LambdaQueryWrapper<HealthReport> qw = new LambdaQueryWrapper<HealthReport>()
|
|
|
+ .eq(HealthReport::getSubjectId, subjectId)
|
|
|
+ .eq(HealthReport::getStatus, "active")
|
|
|
+ .eq(HealthReport::getReportDate, date)
|
|
|
+ .orderByDesc(HealthReport::getId)
|
|
|
+ .last("LIMIT 1");
|
|
|
+ if (sourceType != null && !sourceType.trim().isEmpty()) {
|
|
|
+ qw.eq(HealthReport::getReportType, sourceType);
|
|
|
+ }
|
|
|
+ List<HealthReport> list = healthReportMapper.selectList(qw);
|
|
|
+ return list.isEmpty() ? null : list.get(0);
|
|
|
+ }
|
|
|
+
|
|
|
+ private void sortItems(List<Map<String, Object>> items, String orderBy) {
|
|
|
+ final String order = orderBy != null ? orderBy : "magnitude";
|
|
|
+ if ("magnitude".equals(order)) {
|
|
|
+ items.sort((a, b) -> {
|
|
|
+ BigDecimal pa = numeric(a, "deltaPct");
|
|
|
+ BigDecimal pb = numeric(b, "deltaPct");
|
|
|
+ // deltaPct=null 恒定排最后
|
|
|
+ if (pa == null && pb == null) return 0;
|
|
|
+ if (pa == null) return 1;
|
|
|
+ if (pb == null) return -1;
|
|
|
+ return pb.abs().compareTo(pa.abs());
|
|
|
+ });
|
|
|
+ } else if ("improved".equals(order)) {
|
|
|
+ items.sort((a, b) -> {
|
|
|
+ Boolean ca = Boolean.TRUE.equals(a.get("comparable"));
|
|
|
+ Boolean cb = Boolean.TRUE.equals(b.get("comparable"));
|
|
|
+ if (!ca && cb) return 1;
|
|
|
+ if (ca && !cb) return -1;
|
|
|
+ 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.compareTo(pa); // 改善幅度大的在前(deltaPct 降序)
|
|
|
+ });
|
|
|
+ } else if ("worsened".equals(order)) {
|
|
|
+ items.sort((a, b) -> {
|
|
|
+ Boolean ca = Boolean.TRUE.equals(a.get("comparable"));
|
|
|
+ Boolean cb = Boolean.TRUE.equals(b.get("comparable"));
|
|
|
+ if (!ca && cb) return 1;
|
|
|
+ if (ca && !cb) return -1;
|
|
|
+ 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 pa.compareTo(pb); // 恶化幅度大的在前(deltaPct 升序,即负数大的在前)
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ 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;
|
|
|
+ }
|
|
|
+
|
|
|
+ private String fmt(Date d) {
|
|
|
+ return d != null ? new SimpleDateFormat("yyyy-MM-dd").format(d) : null;
|
|
|
+ }
|
|
|
+
|
|
|
+ private java.sql.Date parseDate(String s) {
|
|
|
+ if (s == null || s.trim().isEmpty()) return null;
|
|
|
+ try {
|
|
|
+ java.util.Date d = new SimpleDateFormat("yyyy-MM-dd").parse(s.trim());
|
|
|
+ return d != null ? new java.sql.Date(d.getTime()) : null;
|
|
|
+ } catch (Exception e) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** java.util.Date → java.sql.Date(截断到日) */
|
|
|
+ private java.sql.Date toSqlDate(java.util.Date d) {
|
|
|
+ if (d == null) return null;
|
|
|
+ return new java.sql.Date(d.getTime());
|
|
|
+ }
|
|
|
+
|
|
|
+ private static class PeriodMeta {
|
|
|
+ Integer count;
|
|
|
+ Integer worstQuality;
|
|
|
+ String worstDateSource;
|
|
|
+ }
|
|
|
+}
|