|
@@ -0,0 +1,597 @@
|
|
|
|
|
+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.IndicatorDefinitionMapper;
|
|
|
|
|
+import com.etotem.cfc.mapper.IndicatorValueMapper;
|
|
|
|
|
+import org.slf4j.Logger;
|
|
|
|
|
+import org.slf4j.LoggerFactory;
|
|
|
|
|
+import org.springframework.beans.factory.annotation.Value;
|
|
|
|
|
+import org.springframework.stereotype.Service;
|
|
|
|
|
+
|
|
|
|
|
+import javax.annotation.Resource;
|
|
|
|
|
+import java.math.BigDecimal;
|
|
|
|
|
+import java.math.RoundingMode;
|
|
|
|
|
+import java.util.ArrayList;
|
|
|
|
|
+import java.util.Collection;
|
|
|
|
|
+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.regex.Matcher;
|
|
|
|
|
+import java.util.regex.Pattern;
|
|
|
|
|
+import java.util.stream.Collectors;
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 指标变化计算底座(DS-2026-10-25-01 · T01)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>纯计算服务:不写库、不改数据。所有"上一期查找 / 参考范围解析 / 变化判定"都收口在这里,
|
|
|
|
|
+ * 后续接口层(报告详情 / 时间轴 / 变化排名 / 速查 / 双期对比)统一调用,避免各处各算一套。</p>
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>上一期定义(严格对齐 PRD §6.5):
|
|
|
|
|
+ * 同 subjectId ∧ 同 sourceType ∧ numeric_value 非空 ∧ report_date 严格小于本期 ∧ 取日期最大的一期。
|
|
|
|
|
+ * 跨报告类型不比较;report_date 为空的期不参与。</p>
|
|
|
|
|
+ */
|
|
|
|
|
+@Service
|
|
|
|
|
+public class IndicatorChangeService {
|
|
|
|
|
+
|
|
|
|
|
+ private static final Logger log = LoggerFactory.getLogger(IndicatorChangeService.class);
|
|
|
|
|
+
|
|
|
|
|
+ @Resource
|
|
|
|
|
+ private IndicatorValueMapper valueMapper;
|
|
|
|
|
+
|
|
|
|
|
+ @Resource
|
|
|
|
|
+ private IndicatorDefinitionMapper definitionMapper;
|
|
|
|
|
+
|
|
|
|
|
+ /** 基本持平阈值(PRD §6.3 / D5):|Δ%| < 2% 且状态未变 → 判持平 */
|
|
|
|
|
+ public static final double DEFAULT_FLAT_THRESHOLD_PCT = 2.0;
|
|
|
|
|
+
|
|
|
|
|
+ /** 微小基数护栏默认值(参考范围无法解析时使用) */
|
|
|
|
|
+ public static final BigDecimal DEFAULT_FLOOR = new BigDecimal("0.1");
|
|
|
|
|
+
|
|
|
|
|
+ /** 微小基数护栏下限(防止 refHigh 极小时护栏退化为 0) */
|
|
|
|
|
+ public static final BigDecimal MIN_FLOOR = new BigDecimal("0.0001");
|
|
|
|
|
+
|
|
|
|
|
+ /** 持平阈值(可配置:app.indicator.flat-threshold-pct) */
|
|
|
|
|
+ @Value("${app.indicator.flat-threshold-pct:2.0}")
|
|
|
|
|
+ private double flatThresholdPct = DEFAULT_FLAT_THRESHOLD_PCT;
|
|
|
|
|
+
|
|
|
|
|
+ /** trendCode 五值枚举 */
|
|
|
|
|
+ public static final String TREND_IMPROVED = "improved";
|
|
|
|
|
+ public static final String TREND_WORSENED = "worsened";
|
|
|
|
|
+ public static final String TREND_FLAT = "flat";
|
|
|
|
|
+ public static final String TREND_FIRST_RECORD = "first_record";
|
|
|
|
|
+ public static final String TREND_PENDING_BACKFILL = "pending_backfill";
|
|
|
|
|
+
|
|
|
|
|
+ /** movement 六值枚举 */
|
|
|
|
|
+ public static final String MOVEMENT_UP = "up";
|
|
|
|
|
+ public static final String MOVEMENT_DOWN = "down";
|
|
|
|
|
+ public static final String MOVEMENT_FLAT = "flat";
|
|
|
|
|
+ public static final String MOVEMENT_CHANGED = "changed";
|
|
|
|
|
+ public static final String MOVEMENT_UNCHANGED = "unchanged";
|
|
|
|
|
+ public static final String MOVEMENT_NONE = "none";
|
|
|
|
|
+
|
|
|
|
|
+ /** 排序比较器:report_date 降序,NULL 恒排最后 */
|
|
|
|
|
+ public static final Comparator<IndicatorValue> NULLS_LAST_DESC =
|
|
|
|
|
+ Comparator.comparing(IndicatorValue::getReportDate,
|
|
|
|
|
+ Comparator.nullsLast(Comparator.reverseOrder()))
|
|
|
|
|
+ .thenComparing(IndicatorValue::getId, Comparator.nullsLast(Comparator.reverseOrder()));
|
|
|
|
|
+
|
|
|
|
|
+ private static final Pattern RANGE_PATTERN =
|
|
|
|
|
+ Pattern.compile("^(-?\\d+(?:\\.\\d+)?)?\\s*[-~]\\s*(-?\\d+(?:\\.\\d+)?)?$");
|
|
|
|
|
+ private static final Pattern LT_PATTERN = Pattern.compile("^[<\\u2264]\\s*(-?\\d+(?:\\.\\d+)?)$");
|
|
|
|
|
+ private static final Pattern GT_PATTERN = Pattern.compile("^[>\\u2265]\\s*(-?\\d+(?:\\.\\d+)?)$");
|
|
|
|
|
+ private static final Pattern SINGLE_PATTERN = Pattern.compile("^(-?\\d+(?:\\.\\d+)?)$");
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== 上一期查找 ====================
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 上一期查找(单指标)。走 idx_subject_def_date(subject_id, definition_id, report_date) 索引。
|
|
|
|
|
+ *
|
|
|
|
|
+ * @return 上一期观测;无则 null(调用方走「首次记录 / 历史整理中」)
|
|
|
|
|
+ */
|
|
|
|
|
+ public IndicatorValue resolvePrevious(Long subjectId, Long definitionId, String sourceType,
|
|
|
|
|
+ java.sql.Date currentReportDate) {
|
|
|
|
|
+ if (subjectId == null || definitionId == null || currentReportDate == null) {
|
|
|
|
|
+ return null; // 本期日期未知 → 无法确定"更早",不猜
|
|
|
|
|
+ }
|
|
|
|
|
+ List<IndicatorValue> rows = valueMapper.selectList(new LambdaQueryWrapper<IndicatorValue>()
|
|
|
|
|
+ .eq(IndicatorValue::getSubjectId, subjectId)
|
|
|
|
|
+ .eq(IndicatorValue::getDefinitionId, definitionId)
|
|
|
|
|
+ .eq(sourceType != null && !sourceType.trim().isEmpty(),
|
|
|
|
|
+ IndicatorValue::getSourceType, sourceType)
|
|
|
|
|
+ .isNotNull(IndicatorValue::getReportDate) // null 日期不参与
|
|
|
|
|
+ .isNotNull(IndicatorValue::getNumericValue) // 有有效数值
|
|
|
|
|
+ .lt(IndicatorValue::getReportDate, currentReportDate) // 严格小于
|
|
|
|
|
+ .orderByDesc(IndicatorValue::getReportDate)
|
|
|
|
|
+ .last("LIMIT 1"));
|
|
|
|
|
+ return rows == null || rows.isEmpty() ? null : rows.get(0);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 批量上一期查找(主力入口,严禁 N+1)。
|
|
|
|
|
+ * 一次性拉该 (subjectId, sourceType) 的全部历史观测,内存分组后逐指标取最晚一期。
|
|
|
|
|
+ *
|
|
|
|
|
+ * @return definitionId → 上一期观测
|
|
|
|
|
+ */
|
|
|
|
|
+ public Map<Long, IndicatorValue> batchResolve(Long subjectId, String sourceType,
|
|
|
|
|
+ java.sql.Date currentReportDate) {
|
|
|
|
|
+ if (subjectId == null || currentReportDate == null) {
|
|
|
|
|
+ return Collections.emptyMap();
|
|
|
|
|
+ }
|
|
|
|
|
+ List<IndicatorValue> all = valueMapper.selectList(new LambdaQueryWrapper<IndicatorValue>()
|
|
|
|
|
+ .eq(IndicatorValue::getSubjectId, subjectId)
|
|
|
|
|
+ .eq(sourceType != null && !sourceType.trim().isEmpty(),
|
|
|
|
|
+ IndicatorValue::getSourceType, sourceType)
|
|
|
|
|
+ .isNotNull(IndicatorValue::getReportDate)
|
|
|
|
|
+ .isNotNull(IndicatorValue::getNumericValue)
|
|
|
|
|
+ .lt(IndicatorValue::getReportDate, currentReportDate)
|
|
|
|
|
+ .orderByAsc(IndicatorValue::getReportDate)); // 升序,便于取最后一个
|
|
|
|
|
+ if (all == null || all.isEmpty()) {
|
|
|
|
|
+ return Collections.emptyMap();
|
|
|
|
|
+ }
|
|
|
|
|
+ Map<Long, List<IndicatorValue>> byDef = all.stream()
|
|
|
|
|
+ .filter(v -> v.getDefinitionId() != null)
|
|
|
|
|
+ .collect(Collectors.groupingBy(IndicatorValue::getDefinitionId));
|
|
|
|
|
+ Map<Long, IndicatorValue> previous = new HashMap<>();
|
|
|
|
|
+ for (Map.Entry<Long, List<IndicatorValue>> e : byDef.entrySet()) {
|
|
|
|
|
+ List<IndicatorValue> sorted = e.getValue(); // 已按 report_date 升序
|
|
|
|
|
+ previous.put(e.getKey(), sorted.get(sorted.size() - 1));
|
|
|
|
|
+ }
|
|
|
|
|
+ return previous;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 批量上一期查找(限定指标集合版本)。仍是 1 次查询,内存过滤 defIds。
|
|
|
|
|
+ */
|
|
|
|
|
+ public Map<Long, IndicatorValue> batchResolve(Long subjectId, String sourceType,
|
|
|
|
|
+ Collection<Long> definitionIds,
|
|
|
|
|
+ java.sql.Date currentReportDate) {
|
|
|
|
|
+ Map<Long, IndicatorValue> all = batchResolve(subjectId, sourceType, currentReportDate);
|
|
|
|
|
+ if (definitionIds == null || definitionIds.isEmpty()) {
|
|
|
|
|
+ return all;
|
|
|
|
|
+ }
|
|
|
|
|
+ Map<Long, IndicatorValue> filtered = new HashMap<>();
|
|
|
|
|
+ for (Long defId : definitionIds) {
|
|
|
|
|
+ IndicatorValue v = all.get(defId);
|
|
|
|
|
+ if (v != null) {
|
|
|
|
|
+ filtered.put(defId, v);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return filtered;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== 跨期跳比 ====================
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * skipGaps:两期之间「该成员有报告、但该指标无有效值」的期数。
|
|
|
|
|
+ */
|
|
|
|
|
+ public long computeSkipGaps(Long subjectId, String sourceType, Long definitionId,
|
|
|
|
|
+ java.sql.Date fromDate, java.sql.Date toDate) {
|
|
|
|
|
+ if (subjectId == null || definitionId == null || fromDate == null || toDate == null) {
|
|
|
|
|
+ return 0L;
|
|
|
|
|
+ }
|
|
|
|
|
+ List<java.sql.Date> allPeriods = valueMapper.selectList(new LambdaQueryWrapper<IndicatorValue>()
|
|
|
|
|
+ .eq(IndicatorValue::getSubjectId, subjectId)
|
|
|
|
|
+ .eq(IndicatorValue::getSourceType, sourceType)
|
|
|
|
|
+ .isNotNull(IndicatorValue::getReportDate)
|
|
|
|
|
+ .select(IndicatorValue::getReportDate))
|
|
|
|
|
+ .stream().map(IndicatorValue::getReportDate).distinct().sorted()
|
|
|
|
|
+ .collect(Collectors.toList());
|
|
|
|
|
+ long reportsBetween = allPeriods.stream()
|
|
|
|
|
+ .filter(d -> d.after(fromDate) && d.before(toDate)).count();
|
|
|
|
|
+ long obsBetween = valueMapper.selectList(new LambdaQueryWrapper<IndicatorValue>()
|
|
|
|
|
+ .eq(IndicatorValue::getSubjectId, subjectId)
|
|
|
|
|
+ .eq(IndicatorValue::getSourceType, sourceType)
|
|
|
|
|
+ .eq(IndicatorValue::getDefinitionId, definitionId)
|
|
|
|
|
+ .isNotNull(IndicatorValue::getNumericValue)
|
|
|
|
|
+ .isNotNull(IndicatorValue::getReportDate)
|
|
|
|
|
+ .gt(IndicatorValue::getReportDate, fromDate)
|
|
|
|
|
+ .lt(IndicatorValue::getReportDate, toDate)
|
|
|
|
|
+ .select(IndicatorValue::getReportDate))
|
|
|
|
|
+ .stream().map(IndicatorValue::getReportDate).distinct().count();
|
|
|
|
|
+ return Math.max(0L, reportsBetween - obsBetween);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== 参考范围解析与判定 ====================
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 解析参考范围。解析失败返回 parsed=false(调用方回落 neutral)。
|
|
|
|
|
+ * 支持:0.00-3.37 / 0.00~3.37 / <5.0 / >1.0 / 3.37 及全角变体。
|
|
|
|
|
+ */
|
|
|
|
|
+ public RefRange parseRefRange(String refRange) {
|
|
|
|
|
+ if (refRange == null || refRange.trim().isEmpty()) {
|
|
|
|
|
+ return RefRange.unparsed();
|
|
|
|
|
+ }
|
|
|
|
|
+ String s = normalize(refRange);
|
|
|
|
|
+ Matcher m;
|
|
|
|
|
+ if ((m = RANGE_PATTERN.matcher(s)).matches()) {
|
|
|
|
|
+ BigDecimal low = m.group(1) != null ? new BigDecimal(m.group(1)) : null;
|
|
|
|
|
+ BigDecimal high = m.group(2) != null ? new BigDecimal(m.group(2)) : null;
|
|
|
|
|
+ if (low == null && high == null) {
|
|
|
|
|
+ return RefRange.unparsed();
|
|
|
|
|
+ }
|
|
|
|
|
+ if (low != null && high != null && low.compareTo(high) > 0) {
|
|
|
|
|
+ return RefRange.unparsed(); // 倒挂 = 脏数据
|
|
|
|
|
+ }
|
|
|
|
|
+ return RefRange.of(low, high);
|
|
|
|
|
+ }
|
|
|
|
|
+ if ((m = LT_PATTERN.matcher(s)).matches()) {
|
|
|
|
|
+ return RefRange.of(null, new BigDecimal(m.group(1)));
|
|
|
|
|
+ }
|
|
|
|
|
+ if ((m = GT_PATTERN.matcher(s)).matches()) {
|
|
|
|
|
+ return RefRange.of(new BigDecimal(m.group(1)), null);
|
|
|
|
|
+ }
|
|
|
|
|
+ if ((m = SINGLE_PATTERN.matcher(s)).matches()) {
|
|
|
|
|
+ return RefRange.of(null, new BigDecimal(m.group(1)));
|
|
|
|
|
+ }
|
|
|
|
|
+ return RefRange.unparsed();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 全角/异形分隔符归一化 */
|
|
|
|
|
+ private String normalize(String s) {
|
|
|
|
|
+ return s.replace('-', '-').replace('—', '-').replace('–', '-')
|
|
|
|
|
+ .replace('〜', '~').replace('~', '~')
|
|
|
|
|
+ .replace('<', '<').replace('>', '>')
|
|
|
|
|
+ .replace("-", "-")
|
|
|
|
|
+ .replaceAll("\\s+", "")
|
|
|
|
|
+ .trim();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * in_range_is_better 判定(修 PRD S10:不再恒判中性)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * @return "improved" | "worsened" | "flat"
|
|
|
|
|
+ */
|
|
|
|
|
+ public String judgeInRange(BigDecimal before, BigDecimal after, RefRange ref) {
|
|
|
|
|
+ if (ref == null || !ref.isParsed() || before == null || after == null) {
|
|
|
|
|
+ return TREND_FLAT; // 解析失败 → 回落中性
|
|
|
|
|
+ }
|
|
|
|
|
+ boolean b = ref.inRange(before);
|
|
|
|
|
+ boolean a = ref.inRange(after);
|
|
|
|
|
+ if (!b && a) {
|
|
|
|
|
+ return TREND_IMPROVED; // 区外 → 区内
|
|
|
|
|
+ }
|
|
|
|
|
+ if (b && !a) {
|
|
|
|
|
+ return TREND_WORSENED; // 区内 → 区外
|
|
|
|
|
+ }
|
|
|
|
|
+ if (b && a) {
|
|
|
|
|
+ return TREND_FLAT; // 始终在区内
|
|
|
|
|
+ }
|
|
|
|
|
+ // 始终在区外:按"与区间边界的距离变化"判定(靠近 = 改善,远离 = 恶化)
|
|
|
|
|
+ BigDecimal db = ref.distance(before);
|
|
|
|
|
+ BigDecimal da = ref.distance(after);
|
|
|
|
|
+ BigDecimal eps = db.abs().multiply(BigDecimal.valueOf(flatThresholdPct / 100.0));
|
|
|
|
|
+ if (eps.compareTo(BigDecimal.valueOf(0.0001)) < 0) {
|
|
|
|
|
+ eps = BigDecimal.valueOf(0.0001); // 防止 eps 为 0 导致恒判持平
|
|
|
|
|
+ }
|
|
|
|
|
+ if (da.subtract(db).abs().compareTo(eps) <= 0) {
|
|
|
|
|
+ return TREND_FLAT; // 位移在阈值内 = 无实质变化
|
|
|
|
|
+ }
|
|
|
|
|
+ return da.compareTo(db) < 0 ? TREND_IMPROVED : TREND_WORSENED;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== 变化计算主流程 ====================
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 变化计算唯一入口(两期都有值时)。
|
|
|
|
|
+ */
|
|
|
|
|
+ public ChangeResult computeChange(IndicatorDefinition def, IndicatorValue before, IndicatorValue after) {
|
|
|
|
|
+ ChangeResult r = new ChangeResult();
|
|
|
|
|
+
|
|
|
|
|
+ // ---- 0. 可比性前置 ----
|
|
|
|
|
+ r.unitMismatch = before != null && after != null
|
|
|
|
|
+ && !Objects.equals(normUnit(before.getUnit()), normUnit(after.getUnit()));
|
|
|
|
|
+ String valueType = def != null ? def.getValueType() : null;
|
|
|
|
|
+
|
|
|
|
|
+ // 非数值指标:不算 Δ,只判有无变化
|
|
|
|
|
+ if (!"numeric".equals(valueType) || before == null || after == null
|
|
|
|
|
+ || before.getNumericValue() == null || after.getNumericValue() == null) {
|
|
|
|
|
+ r.comparable = false;
|
|
|
|
|
+ r.delta = null;
|
|
|
|
|
+ r.deltaPct = null;
|
|
|
|
|
+ if (before != null && after != null) {
|
|
|
|
|
+ boolean same = Objects.equals(before.getValue(), after.getValue());
|
|
|
|
|
+ r.movement = same ? MOVEMENT_UNCHANGED : MOVEMENT_CHANGED;
|
|
|
|
|
+ r.trendLabel = same ? "无变化" : "有变化";
|
|
|
|
|
+ } else {
|
|
|
|
|
+ r.movement = MOVEMENT_CHANGED;
|
|
|
|
|
+ r.trendLabel = "有变化";
|
|
|
|
|
+ }
|
|
|
|
|
+ r.trendCode = TREND_FLAT; // 非数值永不标改善/恶化(合规红线)
|
|
|
|
|
+ r.skipGaps = 0L;
|
|
|
|
|
+ return r;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ---- 1. Δ 与 movement ----
|
|
|
|
|
+ BigDecimal bv = before.getNumericValue();
|
|
|
|
|
+ BigDecimal av = after.getNumericValue();
|
|
|
|
|
+ r.delta = av.subtract(bv).setScale(4, RoundingMode.HALF_UP);
|
|
|
|
|
+ r.movement = movementOf(r.delta);
|
|
|
|
|
+
|
|
|
|
|
+ // 参考范围:优先该期快照,回落字典当前值
|
|
|
|
|
+ RefRange ref = parseRefRange(firstNonEmpty(after.getRefRangeSnapshot(),
|
|
|
|
|
+ before.getRefRangeSnapshot(),
|
|
|
|
|
+ def != null ? def.getRefRange() : null));
|
|
|
|
|
+ // 微小基数护栏:|before| < floor 时 Δ% 不具临床意义(护栏只压百分比,不压 Δ 绝对值)
|
|
|
|
|
+ BigDecimal floor = percentageFloor(ref);
|
|
|
|
|
+ boolean tinyBase = bv.abs().compareTo(floor) < 0;
|
|
|
|
|
+
|
|
|
|
|
+ r.deltaPct = (bv.compareTo(BigDecimal.ZERO) != 0 && !r.unitMismatch && !tinyBase)
|
|
|
|
|
+ ? r.delta.multiply(BigDecimal.valueOf(100))
|
|
|
|
|
+ .divide(bv, 1, RoundingMode.HALF_UP)
|
|
|
|
|
+ : null; // 基数为 0 / 单位不一致 / 微小基数 → 百分比不适用
|
|
|
|
|
+ r.comparable = true;
|
|
|
|
|
+
|
|
|
|
|
+ // ---- 2. 基本持平阈值(|Δ%| < 2% 且状态未变)----
|
|
|
|
|
+ boolean statusChanged = !Objects.equals(norm(before.getStatus()), norm(after.getStatus()));
|
|
|
|
|
+ boolean belowThreshold = r.deltaPct == null
|
|
|
|
|
+ || r.deltaPct.abs().compareTo(BigDecimal.valueOf(flatThresholdPct)) < 0;
|
|
|
|
|
+ if (belowThreshold && !statusChanged) {
|
|
|
|
|
+ r.trendCode = TREND_FLAT;
|
|
|
|
|
+ r.trendLabel = "基本持平";
|
|
|
|
|
+ return r; // 箭头 movement 保留 ↑↓,词判"基本持平"
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ---- 3. 按 goodDirection 判定 ----
|
|
|
|
|
+ String gd = def != null && def.getGoodDirection() != null ? def.getGoodDirection() : "neutral";
|
|
|
|
|
+ String code;
|
|
|
|
|
+ if ("higher_is_better".equals(gd)) {
|
|
|
|
|
+ code = MOVEMENT_UP.equals(r.movement) ? TREND_IMPROVED
|
|
|
|
|
+ : (MOVEMENT_DOWN.equals(r.movement) ? TREND_WORSENED : TREND_FLAT);
|
|
|
|
|
+ } else if ("lower_is_better".equals(gd)) {
|
|
|
|
|
+ code = MOVEMENT_DOWN.equals(r.movement) ? TREND_IMPROVED
|
|
|
|
|
+ : (MOVEMENT_UP.equals(r.movement) ? TREND_WORSENED : TREND_FLAT);
|
|
|
|
|
+ } else if ("in_range_is_better".equals(gd)) {
|
|
|
|
|
+ code = judgeInRange(bv, av, ref);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ code = TREND_FLAT; // neutral / 未知:保留箭头,不判好坏
|
|
|
|
|
+ }
|
|
|
|
|
+ r.trendCode = code;
|
|
|
|
|
+ r.trendLabel = TREND_IMPROVED.equals(code) ? "改善"
|
|
|
|
|
+ : (TREND_WORSENED.equals(code) ? "恶化" : "基本持平");
|
|
|
|
|
+ return r;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 变化计算入口(含"无上一期"两档)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * @param olderReportCount 同成员同类型的更早报告份数:0 → 首次记录;>0 → 历史整理中
|
|
|
|
|
+ */
|
|
|
|
|
+ public ChangeResult computeChange(IndicatorDefinition def, IndicatorValue before, IndicatorValue after,
|
|
|
|
|
+ Integer olderReportCount) {
|
|
|
|
|
+ if (before == null || after == null) {
|
|
|
|
|
+ boolean pending = olderReportCount != null && olderReportCount > 0;
|
|
|
|
|
+ ChangeResult r = new ChangeResult();
|
|
|
|
|
+ r.comparable = false;
|
|
|
|
|
+ r.delta = null;
|
|
|
|
|
+ r.deltaPct = null;
|
|
|
|
|
+ r.movement = MOVEMENT_NONE;
|
|
|
|
|
+ r.trendCode = pending ? TREND_PENDING_BACKFILL : TREND_FIRST_RECORD;
|
|
|
|
|
+ r.trendLabel = pending ? "历史数据整理中,暂无法对比" : "首次记录 · 暂无对比";
|
|
|
|
|
+ r.skipGaps = 0L;
|
|
|
|
|
+ return r;
|
|
|
|
|
+ }
|
|
|
|
|
+ return computeChange(def, before, after);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** Δ → movement */
|
|
|
|
|
+ public String movementOf(BigDecimal delta) {
|
|
|
|
|
+ if (delta == null) {
|
|
|
|
|
+ return MOVEMENT_NONE;
|
|
|
|
|
+ }
|
|
|
|
|
+ int c = delta.compareTo(BigDecimal.ZERO);
|
|
|
|
|
+ return c > 0 ? MOVEMENT_UP : (c < 0 ? MOVEMENT_DOWN : MOVEMENT_FLAT);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 微小基数护栏:以满量程 refHigh 的 1% 为基准(参考范围下界通常为 0,用下界会让护栏失效)。
|
|
|
|
|
+ */
|
|
|
|
|
+ public BigDecimal percentageFloor(RefRange ref) {
|
|
|
|
|
+ if (ref != null && ref.isParsed()) {
|
|
|
|
|
+ if (ref.getHigh() != null) {
|
|
|
|
|
+ return max(ref.getHigh().multiply(BigDecimal.valueOf(0.01)), MIN_FLOOR);
|
|
|
|
|
+ }
|
|
|
|
|
+ if (ref.getLow() != null) {
|
|
|
|
|
+ return max(ref.getLow().multiply(BigDecimal.valueOf(0.1)), MIN_FLOOR);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return DEFAULT_FLOOR;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 排序键:|Δ%| 降序;deltaPct 为 null 的恒定排最后(由调用方处理 null) */
|
|
|
|
|
+ public BigDecimal rankKeyOf(ChangeResult r) {
|
|
|
|
|
+ return r == null || r.getDeltaPct() == null ? null : r.getDeltaPct().abs();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 图表形态建议值(后端单点定义,前端照做) */
|
|
|
|
|
+ public String chartModeOf(int pointsCount, String granularity) {
|
|
|
|
|
+ if (pointsCount <= 0) {
|
|
|
|
|
+ return "empty";
|
|
|
|
|
+ }
|
|
|
|
|
+ if (pointsCount == 1) {
|
|
|
|
|
+ return "single";
|
|
|
|
|
+ }
|
|
|
|
|
+ if (pointsCount == 2) {
|
|
|
|
|
+ return "pair";
|
|
|
|
|
+ }
|
|
|
|
|
+ if (pointsCount >= 20) {
|
|
|
|
|
+ return "line_aggregated";
|
|
|
|
|
+ }
|
|
|
|
|
+ return "line";
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 按 yyyy-MM 聚合取组内末值(保留原始 reportDate 与 dateSource,不伪造月首/月末) */
|
|
|
|
|
+ public List<IndicatorValue> aggregateByMonth(List<IndicatorValue> points) {
|
|
|
|
|
+ if (points == null || points.isEmpty()) {
|
|
|
|
|
+ return new ArrayList<>();
|
|
|
|
|
+ }
|
|
|
|
|
+ Map<String, IndicatorValue> byMonth = new LinkedHashMap<>();
|
|
|
|
|
+ List<IndicatorValue> sorted = new ArrayList<>(points);
|
|
|
|
|
+ sorted.sort(NULLS_LAST_DESC);
|
|
|
|
|
+ Collections.reverse(sorted); // 升序遍历,后写的覆盖前面的 → 取组内最晚一期
|
|
|
|
|
+ for (IndicatorValue v : sorted) {
|
|
|
|
|
+ Date d = v.getReportDate();
|
|
|
|
|
+ if (d == null) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ String ym = new java.text.SimpleDateFormat("yyyy-MM").format(d);
|
|
|
|
|
+ byMonth.put(ym, v);
|
|
|
|
|
+ }
|
|
|
|
|
+ List<IndicatorValue> out = new ArrayList<>(byMonth.values());
|
|
|
|
|
+ out.sort(NULLS_LAST_DESC);
|
|
|
|
|
+ return out;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 读路径统一取启用中的指标定义(Q17 强制):只取 status=1,
|
|
|
|
|
+ * 拿不到的观测由调用方剔除,不降级为"指标"+id 的假名称。
|
|
|
|
|
+ */
|
|
|
|
|
+ public Map<Long, IndicatorDefinition> loadActiveDefinitions(Collection<Long> defIds) {
|
|
|
|
|
+ if (defIds == null || defIds.isEmpty()) {
|
|
|
|
|
+ return Collections.emptyMap();
|
|
|
|
|
+ }
|
|
|
|
|
+ List<IndicatorDefinition> defs = definitionMapper.selectList(
|
|
|
|
|
+ new LambdaQueryWrapper<IndicatorDefinition>()
|
|
|
|
|
+ .in(IndicatorDefinition::getId, defIds)
|
|
|
|
|
+ .eq(IndicatorDefinition::getStatus, 1));
|
|
|
|
|
+ if (defs == null || defs.isEmpty()) {
|
|
|
|
|
+ return Collections.emptyMap();
|
|
|
|
|
+ }
|
|
|
|
|
+ return defs.stream().collect(Collectors.toMap(IndicatorDefinition::getId, d -> d, (a, b) -> a));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== 工具 ====================
|
|
|
|
|
+
|
|
|
|
|
+ private static String firstNonEmpty(String... values) {
|
|
|
|
|
+ for (String v : values) {
|
|
|
|
|
+ if (v != null && !v.trim().isEmpty()) {
|
|
|
|
|
+ return v;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static String norm(String s) {
|
|
|
|
|
+ return s == null ? null : s.trim();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 单位归一化(去空格 + 全角括号/乘号统一) */
|
|
|
|
|
+ private static String normUnit(String s) {
|
|
|
|
|
+ if (s == null) {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ return s.replaceAll("\\s+", "")
|
|
|
|
|
+ .replace('(', '(').replace(')', ')')
|
|
|
|
|
+ .replace('×', 'x').replace('*', '*');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static BigDecimal max(BigDecimal a, BigDecimal b) {
|
|
|
|
|
+ return a.compareTo(b) >= 0 ? a : b;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== 内部类 ====================
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 变化计算结果。字段名与接口出参保持一致,便于 T02 直接组装。
|
|
|
|
|
+ */
|
|
|
|
|
+ public static class ChangeResult {
|
|
|
|
|
+ private BigDecimal delta;
|
|
|
|
|
+ private BigDecimal deltaPct;
|
|
|
|
|
+ private String movement;
|
|
|
|
|
+ private String trendLabel;
|
|
|
|
|
+ private String trendCode;
|
|
|
|
|
+ private long skipGaps;
|
|
|
|
|
+ private boolean unitMismatch;
|
|
|
|
|
+ private boolean comparable;
|
|
|
|
|
+
|
|
|
|
|
+ public BigDecimal getDelta() { return delta; }
|
|
|
|
|
+ public void setDelta(BigDecimal delta) { this.delta = delta; }
|
|
|
|
|
+ public BigDecimal getDeltaPct() { return deltaPct; }
|
|
|
|
|
+ public void setDeltaPct(BigDecimal deltaPct) { this.deltaPct = deltaPct; }
|
|
|
|
|
+ public String getMovement() { return movement; }
|
|
|
|
|
+ public void setMovement(String movement) { this.movement = movement; }
|
|
|
|
|
+ public String getTrendLabel() { return trendLabel; }
|
|
|
|
|
+ public void setTrendLabel(String trendLabel) { this.trendLabel = trendLabel; }
|
|
|
|
|
+ public String getTrendCode() { return trendCode; }
|
|
|
|
|
+ public void setTrendCode(String trendCode) { this.trendCode = trendCode; }
|
|
|
|
|
+ public long getSkipGaps() { return skipGaps; }
|
|
|
|
|
+ public void setSkipGaps(long skipGaps) { this.skipGaps = skipGaps; }
|
|
|
|
|
+ public boolean isUnitMismatch() { return unitMismatch; }
|
|
|
|
|
+ public void setUnitMismatch(boolean unitMismatch) { this.unitMismatch = unitMismatch; }
|
|
|
|
|
+ public boolean isComparable() { return comparable; }
|
|
|
|
|
+ public void setComparable(boolean comparable) { this.comparable = comparable; }
|
|
|
|
|
+
|
|
|
|
|
+ /** 组装成接口出参(字段顺序固定,便于前端与测试断言) */
|
|
|
|
|
+ public Map<String, Object> toMap() {
|
|
|
|
|
+ Map<String, Object> m = new LinkedHashMap<>();
|
|
|
|
|
+ m.put("delta", delta);
|
|
|
|
|
+ m.put("deltaPct", deltaPct);
|
|
|
|
|
+ m.put("movement", movement);
|
|
|
|
|
+ m.put("trendLabel", trendLabel);
|
|
|
|
|
+ m.put("trendCode", trendCode);
|
|
|
|
|
+ m.put("skipGaps", skipGaps);
|
|
|
|
|
+ m.put("unitMismatch", unitMismatch);
|
|
|
|
|
+ m.put("comparable", comparable);
|
|
|
|
|
+ return m;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 参考范围(解析结果)。parsed=false 表示无法解析,判定回落 neutral。
|
|
|
|
|
+ */
|
|
|
|
|
+ public static class RefRange {
|
|
|
|
|
+ private final BigDecimal low;
|
|
|
|
|
+ private final BigDecimal high;
|
|
|
|
|
+ private final boolean parsed;
|
|
|
|
|
+
|
|
|
|
|
+ private RefRange(BigDecimal low, BigDecimal high, boolean parsed) {
|
|
|
|
|
+ this.low = low;
|
|
|
|
|
+ this.high = high;
|
|
|
|
|
+ this.parsed = parsed;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public static RefRange of(BigDecimal low, BigDecimal high) {
|
|
|
|
|
+ return new RefRange(low, high, true);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public static RefRange unparsed() {
|
|
|
|
|
+ return new RefRange(null, null, false);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public BigDecimal getLow() { return low; }
|
|
|
|
|
+ public BigDecimal getHigh() { return high; }
|
|
|
|
|
+ public boolean isParsed() { return parsed; }
|
|
|
|
|
+
|
|
|
|
|
+ /** 是否在范围内(区间内为 true;单边区间只比较存在的一侧) */
|
|
|
|
|
+ public boolean inRange(BigDecimal v) {
|
|
|
|
|
+ if (v == null) {
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+ return (low == null || v.compareTo(low) >= 0)
|
|
|
|
|
+ && (high == null || v.compareTo(high) <= 0);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 到区间边界的距离(区内为 0,区外为正) */
|
|
|
|
|
+ public BigDecimal distance(BigDecimal v) {
|
|
|
|
|
+ if (v == null) {
|
|
|
|
|
+ return BigDecimal.ZERO;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (inRange(v)) {
|
|
|
|
|
+ return BigDecimal.ZERO;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (low != null && v.compareTo(low) < 0) {
|
|
|
|
|
+ return low.subtract(v);
|
|
|
|
|
+ }
|
|
|
|
|
+ if (high != null && v.compareTo(high) > 0) {
|
|
|
|
|
+ return v.subtract(high);
|
|
|
|
|
+ }
|
|
|
|
|
+ return BigDecimal.ZERO;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|