Răsfoiți Sursa

feat(T02): 新增报告指标对照接口 + 期次列表接口,字典列表默认过滤停用项

按 T02 §4.1 / §4.5 / QP17 落地接口层:

- HealthReportController 新增 POST /api/health/report/indicators:
  返回报告指标 + previous(上一期观测)+ change(五档形态)+ summary 汇总,
  权限校验沿用 getReportDetail(管理员 / 本人 / 被授权成员),无 subjectId 的历史报告
  仅本人上传者或家庭管理员可见。
- IndicatorController 新增 POST /api/indicator/periods(期次列表);
  /trend、/change-ranking、/quick-lookup 透传新参数并补 scope=pair 的缺参校验。
- HealthReportService 新增 getReportIndicatorsWithPrevious:
  批量解析 definition + 批量取上一期,严禁 N+1,SQL 恒定 ≤ 5 次。
- IndicatorMappingService.resolveBatch + IndicatorMappingRuleMapper.selectRulesWithDefinitions:
  改为 indicator_mapping_rules JOIN indicator_definitions 单次联合查询,
  raw_name → definition_id 解析与 definition 字段一次带出(省掉 selectBatchIds)。
- IndicatorService.listDefinitions:status 传 null 时默认按 status=1 过滤,
  防止自动创建的停用 definition 冒进管理端字典列表(QP17)。
Sisyphus 2 săptămâni în urmă
părinte
comite
790e115fc3

+ 41 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthReportController.java

@@ -455,6 +455,47 @@ public class HealthReportController {
         return Result.success(result);
         return Result.success(result);
     }
     }
 
 
+    /**
+     * 新增(§4.1):获取报告指标详情(含上一期对照)
+     * 相比老接口 /indicator/list 返回裸 List<HealthIndicator>,本接口增加
+     * previous / change / summary 五档形态数据(first_record / pending_backfill / improved / worsened / flat)。
+     */
+    @Operation(summary = "获取报告指标详情(含上一期对照)")
+    @PostMapping("/report/indicators")
+    public Result<Map<String, Object>> getReportIndicatorsWithPrevious(
+            @RequestBody Map<String, Object> params,
+            @RequestAttribute("userId") Long userId,
+            @RequestAttribute(value = "familyId", required = false) Long familyId) {
+        Long reportId = ParamUtils.getLong(params.get("reportId"));
+        if (reportId == null) {
+            return Result.error("reportId不能为空");
+        }
+        HealthReport report = healthReportService.getReportById(reportId);
+        if (report == null) {
+            return Result.error("报告不存在");
+        }
+        // 权限校验:管理员 / 本人 / 被授权成员可见(沿用 getReportDetail 逻辑)
+        Long reportFamilyId = report.getFamilyId() != null ? report.getFamilyId() : familyId;
+        if (reportFamilyId == null) {
+            return Result.error("无权查看该报告");
+        }
+        Long subjectId = report.getSubjectId();
+        if (subjectId == null) {
+            boolean self = report.getUserId() != null && report.getUserId().equals(userId);
+            if (!self && !reportMemberPermissionService.isFamilyAdmin(userId, reportFamilyId)) {
+                return Result.error("无权查看该报告");
+            }
+        } else if (!reportMemberPermissionService.canViewMemberReports(userId, subjectId, reportFamilyId)) {
+            return Result.error("无权查看该报告");
+        }
+        Boolean withPrevious = params.get("withPrevious") != null
+                ? Boolean.valueOf(params.get("withPrevious").toString()) : true;
+        // 复用已加载的 report 与指标行,避免重复查询(验收标准第 6 条:SQL ≤ 5 次)
+        List<HealthIndicator> indicators = healthReportService.getReportIndicators(reportId);
+        Map<String, Object> data = healthReportService.getReportIndicatorsWithPrevious(report, indicators, withPrevious);
+        return Result.success(data);
+    }
+
     /**
     /**
      * 获取某份报告的所有指标明细
      * 获取某份报告的所有指标明细
      */
      */

+ 49 - 6
cfc-backend/src/main/java/com/etotem/cfc/controller/IndicatorController.java

@@ -5,6 +5,7 @@ import com.etotem.cfc.common.Result;
 import com.etotem.cfc.entity.FamilyMember;
 import com.etotem.cfc.entity.FamilyMember;
 import com.etotem.cfc.mapper.FamilyMemberMapper;
 import com.etotem.cfc.mapper.FamilyMemberMapper;
 import com.etotem.cfc.service.IndicatorCorrelationService;
 import com.etotem.cfc.service.IndicatorCorrelationService;
+import com.etotem.cfc.service.IndicatorTimelineService;
 import com.etotem.cfc.service.IndicatorTrendService;
 import com.etotem.cfc.service.IndicatorTrendService;
 import org.springframework.web.bind.annotation.PostMapping;
 import org.springframework.web.bind.annotation.PostMapping;
 import org.springframework.web.bind.annotation.RequestAttribute;
 import org.springframework.web.bind.annotation.RequestAttribute;
@@ -24,6 +25,9 @@ public class IndicatorController {
     @Resource
     @Resource
     private IndicatorTrendService indicatorTrendService;
     private IndicatorTrendService indicatorTrendService;
 
 
+    @Resource
+    private IndicatorTimelineService indicatorTimelineService;
+
     @Resource
     @Resource
     private IndicatorCorrelationService indicatorCorrelationService;
     private IndicatorCorrelationService indicatorCorrelationService;
 
 
@@ -45,7 +49,7 @@ public class IndicatorController {
         return null;
         return null;
     }
     }
 
 
-    /** 指标曲线(设计 6.1) */
+    /** 指标曲线(§4.2 / §4.6 trend 增强:sourceType/区间/granularity/sourceKind) */
     @PostMapping("/trend")
     @PostMapping("/trend")
     public Result<?> trend(@RequestBody Map<String, Object> params,
     public Result<?> trend(@RequestBody Map<String, Object> params,
                            @RequestAttribute("familyId") Long familyId) {
                            @RequestAttribute("familyId") Long familyId) {
@@ -66,10 +70,16 @@ public class IndicatorController {
                 }
                 }
             }
             }
         }
         }
-        return Result.success(indicatorTrendService.trend(subjectId, definitionIds, familyId));
+        String sourceType = params.get("sourceType") != null ? params.get("sourceType").toString() : null;
+        String fromDate = params.get("fromDate") != null ? params.get("fromDate").toString() : null;
+        String toDate = params.get("toDate") != null ? params.get("toDate").toString() : null;
+        String granularity = params.get("granularity") != null ? params.get("granularity").toString() : null;
+        String sourceKind = params.get("sourceKind") != null ? params.get("sourceKind").toString() : null;
+        return Result.success(indicatorTrendService.trend(
+                subjectId, definitionIds, familyId, sourceType, fromDate, toDate, granularity, sourceKind));
     }
     }
 
 
-    /** 变化幅度排名(设计 6.2) */
+    /** 变化幅度排名(§4.3,新增 scope=pair + fromReportDate/toReportDate + filter + 分页) */
     @PostMapping("/change-ranking")
     @PostMapping("/change-ranking")
     public Result<?> changeRanking(@RequestBody Map<String, Object> params,
     public Result<?> changeRanking(@RequestBody Map<String, Object> params,
                                    @RequestAttribute("familyId") Long familyId) {
                                    @RequestAttribute("familyId") Long familyId) {
@@ -84,11 +94,26 @@ public class IndicatorController {
         String orderBy = params.get("orderBy") != null ? params.get("orderBy").toString() : "magnitude";
         String orderBy = params.get("orderBy") != null ? params.get("orderBy").toString() : "magnitude";
         Integer limit = params.get("limit") != null
         Integer limit = params.get("limit") != null
                 ? Integer.valueOf(params.get("limit").toString()) : 50;
                 ? Integer.valueOf(params.get("limit").toString()) : 50;
+        String fromReportDate = params.get("fromReportDate") != null
+                ? params.get("fromReportDate").toString() : null;
+        String toReportDate = params.get("toReportDate") != null
+                ? params.get("toReportDate").toString() : null;
+        String filter = params.get("filter") != null ? params.get("filter").toString() : null;
+        Integer page = params.get("page") != null ? Integer.valueOf(params.get("page").toString()) : null;
+        Integer pageSize = params.get("pageSize") != null ? Integer.valueOf(params.get("pageSize").toString()) : null;
+
+        // 参数校验:scope=pair 时必须传 fromReportDate 与 toReportDate
+        if ("pair".equals(scope)) {
+            if (fromReportDate == null || toReportDate == null) {
+                return Result.error("scope=pair 时必须传 fromReportDate 与 toReportDate");
+            }
+        }
         return Result.success(indicatorTrendService.changeRanking(
         return Result.success(indicatorTrendService.changeRanking(
-                subjectId, reportType, category, scope, orderBy, limit, familyId));
+                subjectId, reportType, category, scope, orderBy, limit, familyId,
+                fromReportDate, toReportDate, filter, page, pageSize));
     }
     }
 
 
-    /** 指标速查(设计 6.3) */
+    /** 指标速查(§4.4,新增 withSparkline/sparkLimit) */
     @PostMapping("/quick-lookup")
     @PostMapping("/quick-lookup")
     public Result<?> quickLookup(@RequestBody Map<String, Object> params,
     public Result<?> quickLookup(@RequestBody Map<String, Object> params,
                                  @RequestAttribute("familyId") Long familyId) {
                                  @RequestAttribute("familyId") Long familyId) {
@@ -100,7 +125,12 @@ public class IndicatorController {
         String category = params.get("category") != null ? params.get("category").toString() : null;
         String category = params.get("category") != null ? params.get("category").toString() : null;
         Integer page = params.get("page") != null ? Integer.valueOf(params.get("page").toString()) : 1;
         Integer page = params.get("page") != null ? Integer.valueOf(params.get("page").toString()) : 1;
         Integer pageSize = params.get("pageSize") != null ? Integer.valueOf(params.get("pageSize").toString()) : 30;
         Integer pageSize = params.get("pageSize") != null ? Integer.valueOf(params.get("pageSize").toString()) : 30;
-        return Result.success(indicatorTrendService.quickLookup(subjectId, category, page, pageSize, familyId));
+        Boolean withSparkline = params.get("withSparkline") != null
+                && Boolean.valueOf(params.get("withSparkline").toString());
+        Integer sparkLimit = params.get("sparkLimit") != null
+                ? Integer.valueOf(params.get("sparkLimit").toString()) : 8;
+        return Result.success(indicatorTrendService.quickLookup(
+                subjectId, category, page, pageSize, familyId, withSparkline, sparkLimit));
     }
     }
 
 
     /** 成员已有指标时序的报告类型列表(曲线页类型筛选动态化) */
     /** 成员已有指标时序的报告类型列表(曲线页类型筛选动态化) */
@@ -115,6 +145,19 @@ public class IndicatorController {
         return Result.success(indicatorTrendService.listSourceTypes(subjectId));
         return Result.success(indicatorTrendService.listSourceTypes(subjectId));
     }
     }
 
 
+    /** 【新增】期次列表(§4.5 /api/indicator/periods) */
+    @PostMapping("/periods")
+    public Result<?> periods(@RequestBody Map<String, Object> params,
+                             @RequestAttribute("familyId") Long familyId) {
+        Long subjectId = com.etotem.cfc.util.ParamUtils.getLong(params.get("subjectId"));
+        Result<Void> check = checkSubjectBelongs(subjectId, familyId);
+        if (check != null) {
+            return check;
+        }
+        String sourceType = params.get("sourceType") != null ? params.get("sourceType").toString() : null;
+        return Result.success(indicatorTimelineService.listPeriods(subjectId, sourceType));
+    }
+
     /** 指标关联查询(设计 6.4) */
     /** 指标关联查询(设计 6.4) */
     @PostMapping("/correlation/list")
     @PostMapping("/correlation/list")
     public Result<?> correlationList(@RequestBody Map<String, Object> params,
     public Result<?> correlationList(@RequestBody Map<String, Object> params,

+ 39 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/IndicatorMappingRuleMapper.java

@@ -2,6 +2,45 @@ package com.etotem.cfc.mapper;
 
 
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 import com.etotem.cfc.entity.IndicatorMappingRule;
 import com.etotem.cfc.entity.IndicatorMappingRule;
+import com.etotem.cfc.service.IndicatorMappingService;
+import org.apache.ibatis.annotations.Result;
+import org.apache.ibatis.annotations.Results;
+import org.apache.ibatis.annotations.Select;
+
+import java.util.List;
 
 
 public interface IndicatorMappingRuleMapper extends BaseMapper<IndicatorMappingRule> {
 public interface IndicatorMappingRuleMapper extends BaseMapper<IndicatorMappingRule> {
+
+    /**
+     * 规则 + 关联定义的一次性联合查询(只读)。
+     *
+     * <p><b>为什么需要</b>:报告指标接口(§4.1)要把 N 个原始指标名映射到 definition,
+     * 再拿 definition 的 goodDirection / valueType / refRange / status。
+     * 若分两步(查规则表 + selectBatchIds 查定义表)就是 2 次查询;
+     * 联合查询把它压到 1 次,是「单报告 42 项指标 SQL ≤ 5 次」验收标准的硬要求。</p>
+     *
+     * <p>LEFT JOIN:规则存在但定义被删时仍返回该规则(definition_* 列为 null)。</p>
+     */
+    @Select("SELECT mr.raw_name AS raw_name, mr.definition_id AS definition_id, "
+            + "mr.priority AS priority, mr.status AS rule_status, "
+            + "d.id AS def_id, d.name AS def_name, d.good_direction AS def_good_direction, "
+            + "d.value_type AS def_value_type, d.unit AS def_unit, d.ref_range AS def_ref_range, "
+            + "d.status AS def_status "
+            + "FROM indicator_mapping_rules mr "
+            + "LEFT JOIN indicator_definitions d ON mr.definition_id = d.id "
+            + "WHERE mr.source_type = #{sourceType}")
+    @Results({
+            @Result(property = "rawName", column = "raw_name"),
+            @Result(property = "definitionId", column = "definition_id"),
+            @Result(property = "priority", column = "priority"),
+            @Result(property = "ruleStatus", column = "rule_status"),
+            @Result(property = "defId", column = "def_id"),
+            @Result(property = "defName", column = "def_name"),
+            @Result(property = "defGoodDirection", column = "def_good_direction"),
+            @Result(property = "defValueType", column = "def_value_type"),
+            @Result(property = "defUnit", column = "def_unit"),
+            @Result(property = "defRefRange", column = "def_ref_range"),
+            @Result(property = "defStatus", column = "def_status")
+    })
+    List<IndicatorMappingService.ResolvedMapping> selectRulesWithDefinitions(String sourceType);
 }
 }

+ 300 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/HealthReportService.java

@@ -34,8 +34,11 @@ import java.util.Comparator;
 import java.util.Date;
 import java.util.Date;
 import java.util.HashMap;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.List;
 import java.math.BigDecimal;
 import java.math.BigDecimal;
+import java.text.SimpleDateFormat;
 import java.util.Map;
 import java.util.Map;
 import com.etotem.cfc.entity.MemberIndicatorCurrent;
 import com.etotem.cfc.entity.MemberIndicatorCurrent;
 import java.util.Set;
 import java.util.Set;
@@ -121,6 +124,12 @@ public class HealthReportService {
     @Resource
     @Resource
     private com.etotem.cfc.service.NormalizeIndicatorPipeline normalizeIndicatorPipeline;
     private com.etotem.cfc.service.NormalizeIndicatorPipeline normalizeIndicatorPipeline;
 
 
+    @Resource
+    private com.etotem.cfc.service.IndicatorMappingService mappingService;
+
+    @Resource
+    private com.etotem.cfc.service.IndicatorChangeService changeService;
+
     @Resource
     @Resource
     private ReportDateExtractor reportDateExtractor;
     private ReportDateExtractor reportDateExtractor;
 
 
@@ -393,6 +402,297 @@ public class HealthReportService {
         );
         );
     }
     }
 
 
+    /**
+     * 获取报告指标(含上一期对照)—— §4.1 新增接口 /api/health/report/indicators。
+     * 严禁 N+1:批量调 IndicatorChangeService.batchResolve 一次性解析全部 previous,
+     * 再组装 summary 与 items。
+     *
+     * @param reportId       报告 ID
+     * @param withPrevious   true=计算 previous/change;false=仅单期(编辑模式等场景用)
+     * @return 含 report 信息、summary 统计、items(每项含 previous/change)
+     */
+    public Map<String, Object> getReportIndicatorsWithPrevious(Long reportId, Boolean withPrevious) {
+        HealthReport report = healthReportMapper.selectById(reportId);
+        if (report == null) {
+            Map<String, Object> err = new LinkedHashMap<>();
+            err.put("code", 500);
+            err.put("message", "报告不存在");
+            return err;
+        }
+        return getReportIndicatorsWithPrevious(report, null, withPrevious);
+    }
+
+    /**
+     * 获取报告指标(含上一期对照)—— 复用已加载的 report / indicators,避免重复查询。
+     *
+     * <p><b>SQL 预算(验收标准第 6 条:≤ 5 次)</b>:
+     * <ol>
+     *   <li>health_indicators(本报告指标行)</li>
+     *   <li>indicator_mapping_rules(批量解析 raw_name → definition_id,1 次)</li>
+     *   <li>indicator_definitions(selectBatchIds,1 次)</li>
+     *   <li>health_reports(olderReportCount,1 次)</li>
+     *   <li>indicator_values(batchResolve 上一期,1 次)</li>
+     * </ol>
+     * 指标数从 1 条涨到 42 条时,查询数恒定为 5。
+     *
+     * @param report      已加载的报告(控制器做权限校验时已查过,直接复用)
+     * @param indicators  已加载的本报告指标行;null 时内部再查一次
+     * @param withPrevious true=计算 previous/change
+     */
+    public Map<String, Object> getReportIndicatorsWithPrevious(HealthReport report,
+                                                                List<HealthIndicator> indicators,
+                                                                Boolean withPrevious) {
+        Map<String, Object> data = new LinkedHashMap<>();
+        if (report == null) {
+            data.put("code", 500);
+            data.put("message", "报告不存在");
+            return data;
+        }
+
+        data.put("reportId", report.getId());
+        data.put("reportType", report.getReportType());
+        data.put("subjectId", report.getSubjectId());
+        data.put("reportDate", report.getReportDate() != null
+                ? new SimpleDateFormat("yyyy-MM-dd").format(report.getReportDate()) : null);
+        data.put("dateSource", report.getReportDate() == null ? "unknown" : "real");
+
+        if (indicators == null) {
+            indicators = getReportIndicators(report.getId());
+        }
+        int totalCount = indicators.size();
+        int abnormalCount = 0;
+        int changedCount = 0;
+        int comparableCount = 0;
+        int firstRecordCount = 0;
+        int pendingCount = 0;
+
+        if (!indicators.isEmpty() && Boolean.TRUE.equals(withPrevious)) {
+            // ---- 1) 按 (subjectId, sourceType, reportDate) 批量取上一期 ----
+            Long subjectId = report.getSubjectId();
+            String sourceType = report.getReportType();
+            java.sql.Date currentReportDate = report.getReportDate() != null
+                    ? new java.sql.Date(report.getReportDate().getTime()) : null;
+
+            // olderReportCount:同 subject+sourceType 且 report_date < current 的报告份数
+            long olderReportCount = 0;
+            if (subjectId != null && currentReportDate != null) {
+                olderReportCount = healthReportMapper.selectList(new LambdaQueryWrapper<HealthReport>()
+                        .eq(HealthReport::getSubjectId, subjectId)
+                        .eq(HealthReport::getStatus, "active")
+                        .isNotNull(HealthReport::getReportDate)
+                        .eq(HealthReport::getReportType, sourceType)
+                        .lt(HealthReport::getReportDate, currentReportDate)
+                        .select(HealthReport::getReportDate))
+                        .size();
+            }
+            data.put("olderReportCount", olderReportCount);
+
+            // 2) 解析 payload 获取指标 definitionId 映射(同 NormalizeIndicatorPipeline 逻辑)
+            //    批量解析:1 次查规则表 + 内存 exact/fuzzy,避免逐指标查表(N+1)
+            Map<String, Long> nameToDefId = new HashMap<>();
+            List<String> rawNames = new ArrayList<>();
+            ParsedReportPayload.Payload payload = null;
+            if (report.getPayloadJson() != null) {
+                try {
+                    payload = objectMapper.readValue(report.getPayloadJson(), ParsedReportPayload.Payload.class);
+                    if (payload != null && payload.getIndicators() != null) {
+                        for (ParsedReportPayload.Indicator ind : payload.getIndicators()) {
+                            if (ind.getIndicatorName() != null) {
+                                rawNames.add(ind.getIndicatorName().trim());
+                            }
+                        }
+                    }
+                } catch (Exception e) {
+                    log.warn("解析 payload 失败 reportId={}: {}", report.getId(), e.getMessage());
+                }
+            }
+            if (payload != null && payload.getGutFlora() != null) {
+                for (ParsedReportPayload.Flora flora : payload.getGutFlora()) {
+                    if (flora.getBacteriaName() != null) {
+                        rawNames.add(flora.getBacteriaName().trim());
+                    }
+                }
+            }
+            // 报告行上的指标名也要参与解析(payload 可能缺失,指标行仍存在)
+            for (HealthIndicator hi : indicators) {
+                if (hi.getIndicatorName() != null) {
+                    rawNames.add(hi.getIndicatorName().trim());
+                }
+            }
+            Map<String, IndicatorMappingService.ResolvedMapping> resolvedMap = mappingService.resolveBatch(sourceType, rawNames);
+            for (IndicatorMappingService.ResolvedMapping rm : resolvedMap.values()) {
+                if (rm.getDefinitionId() != null) {
+                    nameToDefId.put(rm.getRawName(), rm.getDefinitionId());
+                }
+            }
+            List<Long> defIds = new ArrayList<>(new LinkedHashSet<>(nameToDefId.values()));
+
+            // 2b) 已在 resolveBatch 的联合查询中随规则一并带出 definition 字段,无需再 selectBatchIds(省 1 次查询)
+            Map<Long, IndicatorDefinition> defMap = new HashMap<>();
+            for (IndicatorMappingService.ResolvedMapping rm : resolvedMap.values()) {
+                if (rm.getDefId() != null) {
+                    IndicatorDefinition d = new IndicatorDefinition();
+                    d.setId(rm.getDefId());
+                    d.setName(rm.getDefName());
+                    d.setGoodDirection(rm.getDefGoodDirection());
+                    d.setValueType(rm.getDefValueType());
+                    d.setUnit(rm.getDefUnit());
+                    d.setRefRange(rm.getDefRefRange());
+                    d.setStatus(rm.getDefStatus());
+                    defMap.put(rm.getDefId(), d);
+                }
+            }
+
+            // 3) 批量解析上一期
+            Map<Long, IndicatorValue> previousMap = Collections.emptyMap();
+            if (subjectId != null && currentReportDate != null && !defIds.isEmpty()) {
+                previousMap = changeService.batchResolve(subjectId, sourceType, defIds, currentReportDate);
+            }
+
+            // 4) 组装 items
+            List<Map<String, Object>> items = new ArrayList<>();
+            for (HealthIndicator hi : indicators) {
+                String name = hi.getIndicatorName();
+                Long defId = nameToDefId.get(name != null ? name.trim() : "");
+                IndicatorDefinition def = defId != null ? defMap.get(defId) : null;
+
+                // Q17:status=0 的 definition 剔除(不降级为"指标"+id)
+                if (def != null && def.getStatus() != null && def.getStatus() != 1) {
+                    def = null;
+                    defId = null;
+                }
+
+                // 组装 item(未命中字典的指标 definitionId=null,仍要出现在 items 中)
+                Map<String, Object> item = new LinkedHashMap<>();
+                item.put("indicatorId", hi.getId());
+                item.put("definitionId", defId);
+                item.put("category", hi.getCategory());
+                item.put("indicatorName", hi.getIndicatorName());
+                item.put("indicatorValue", hi.getIndicatorValue());
+                item.put("numericValue", hi.getIndicatorValue() != null ? parseNumeric(hi.getIndicatorValue()) : null);
+                item.put("unit", hi.getUnit());
+                item.put("refRange", hi.getRefRange());
+                item.put("status", hi.getStatus());
+                item.put("goodDirection", def != null ? def.getGoodDirection() : null);
+                item.put("valueType", def != null ? def.getValueType() : null);
+                item.put("pointsCount", 1); // 单报告视角下每指标只有 1 期,时间轴视图另有计算
+
+                if (def == null) {
+                    // 未命中字典:previous/change 恒为 null(前端按"无对比"渲染)
+                    item.put("previous", null);
+                    item.put("change", null);
+                    items.add(item);
+                    continue;
+                }
+
+                // previous
+                IndicatorValue prev = previousMap.get(defId);
+                // change
+                IndicatorChangeService.ChangeResult cr;
+                if (prev == null) {
+                    cr = changeService.computeChange(def, null, buildValueFromHi(hi), olderReportCount > 0 ? (int) olderReportCount : 0);
+                } else {
+                    IndicatorValue after = buildValueFromHi(hi);
+                    after.setSourceType(sourceType);
+                    cr = changeService.computeChange(def, prev, after);
+                }
+
+                // 统计
+                if (hi.getStatus() != null && !"正常".equals(hi.getStatus())) {
+                    abnormalCount++;
+                }
+                if (cr.getTrendCode().equals(IndicatorChangeService.TREND_IMPROVED)
+                        || cr.getTrendCode().equals(IndicatorChangeService.TREND_WORSENED)) {
+                    changedCount++;
+                }
+                if (cr.isComparable()) {
+                    comparableCount++;
+                }
+                if (cr.getTrendCode().equals(IndicatorChangeService.TREND_FIRST_RECORD)) {
+                    firstRecordCount++;
+                }
+                if (cr.getTrendCode().equals(IndicatorChangeService.TREND_PENDING_BACKFILL)) {
+                    pendingCount++;
+                }
+
+                // previous
+                if (prev != null) {
+                    Map<String, Object> prevMap = new LinkedHashMap<>();
+                    prevMap.put("reportId", prev.getReportId());
+                    prevMap.put("reportDate", prev.getReportDate() != null ? new SimpleDateFormat("yyyy-MM-dd").format(prev.getReportDate()) : null);
+                    prevMap.put("dateSource", prev.getDateSource());
+                    prevMap.put("value", prev.getValue());
+                    prevMap.put("numericValue", prev.getNumericValue());
+                    prevMap.put("unit", prev.getUnit());
+                    prevMap.put("status", prev.getStatus());
+                    item.put("previous", prevMap);
+                } else {
+                    item.put("previous", null);
+                }
+                item.put("change", cr.toMap());
+                items.add(item);
+            }
+
+            // summary
+            Map<String, Object> summary = new LinkedHashMap<>();
+            summary.put("totalCount", totalCount);
+            summary.put("abnormalCount", abnormalCount);
+            summary.put("abnormalDelta", 0); // 单报告视角无法直接算,需拿上一期 report 的异常数对比(预留字段)
+            summary.put("changedCount", changedCount);
+            summary.put("comparableCount", comparableCount);
+            summary.put("firstRecordCount", firstRecordCount);
+            summary.put("pendingCount", pendingCount);
+            summary.put("dataIncomplete", pendingCount > 0);
+            data.put("summary", summary);
+            data.put("items", items);
+        } else {
+            // withPrevious=false:仅单期
+            List<Map<String, Object>> items = new ArrayList<>();
+            for (HealthIndicator hi : indicators) {
+                Map<String, Object> item = new LinkedHashMap<>();
+                item.put("indicatorId", hi.getId());
+                item.put("category", hi.getCategory());
+                item.put("indicatorName", hi.getIndicatorName());
+                item.put("indicatorValue", hi.getIndicatorValue());
+                item.put("unit", hi.getUnit());
+                item.put("refRange", hi.getRefRange());
+                item.put("status", hi.getStatus());
+                items.add(item);
+            }
+            data.put("items", items);
+        }
+
+        return data;
+    }
+
+    private IndicatorValue buildValueFromHi(HealthIndicator hi) {
+        IndicatorValue v = new IndicatorValue();
+        v.setValue(hi.getIndicatorValue());
+        v.setNumericValue(parseNumeric(hi.getIndicatorValue()));
+        v.setUnit(hi.getUnit());
+        v.setStatus(hi.getStatus());
+        return v;
+    }
+
+    /**
+     * 把指标原始文本值解析为 BigDecimal(去掉 %/~/>/< 与空格后尝试转换)。
+     * 与 IndicatorMappingService.parseNumericValue 语义一致,本处复用以避免重复正则。
+     */
+    private BigDecimal parseNumeric(String raw) {
+        if (raw == null) {
+            return null;
+        }
+        String cleaned = raw.replaceAll("[^0-9.-]", "");
+        if (cleaned.isEmpty()) {
+            return null;
+        }
+        try {
+            return new BigDecimal(cleaned);
+        } catch (NumberFormatException ignore) {
+            return null;
+        }
+    }
+
     /**
     /**
      * 计算孩子身体维度得分 (0-100)
      * 计算孩子身体维度得分 (0-100)
      * <p>
      * <p>

+ 119 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/IndicatorMappingService.java

@@ -16,10 +16,16 @@ import org.springframework.stereotype.Service;
 import javax.annotation.Resource;
 import javax.annotation.Resource;
 import java.math.BigDecimal;
 import java.math.BigDecimal;
 import java.util.ArrayList;
 import java.util.ArrayList;
+import java.util.Collection;
 import java.util.Collections;
 import java.util.Collections;
 import java.util.Date;
 import java.util.Date;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.List;
 import java.util.Map;
 import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
 
 
 /**
 /**
  * 指标归一化映射:raw_name → definition_id。
  * 指标归一化映射:raw_name → definition_id。
@@ -115,6 +121,119 @@ public class IndicatorMappingService {
         return lazyRegister(sourceType, name);
         return lazyRegister(sourceType, name);
     }
     }
 
 
+    /**
+     * 批量解析(只读,不触发 AI 兜底 / 惰性注册,不写库)。
+     * 一次联合查询拿到「规则 + 关联定义」,内存做 exact + fuzzy 匹配。
+     * 相比逐名称 resolve(每次 1~2 次查询)省掉 N+1,是「单报告 42 项指标 SQL ≤ 5 次」的硬要求。
+     *
+     * @param sourceType 报告来源类型
+     * @param rawNames   待解析的原始指标名(自动 trim + 去重)
+     * @return rawName → ResolvedMapping(未命中的名称不出现在结果中)
+     */
+    public Map<String, ResolvedMapping> resolveBatch(String sourceType, Collection<String> rawNames) {
+        Map<String, ResolvedMapping> result = new LinkedHashMap<>();
+        if (sourceType == null || rawNames == null || rawNames.isEmpty()) {
+            return result;
+        }
+        Set<String> names = new LinkedHashSet<>();
+        for (String n : rawNames) {
+            if (n != null && !n.trim().isEmpty()) {
+                names.add(n.trim());
+            }
+        }
+        if (names.isEmpty()) {
+            return result;
+        }
+
+        // 一次联合查询:规则 + 关联定义
+        List<ResolvedMapping> rules = ruleMapper.selectRulesWithDefinitions(sourceType);
+        if (rules == null || rules.isEmpty()) {
+            return result;
+        }
+
+        // exact 命中:rawName 相同,取优先级最高的一条(与 resolve 的 orderByDesc(priority) 一致)
+        Map<String, ResolvedMapping> exactResolved = new HashMap<>();
+        for (String name : names) {
+            ResolvedMapping best = null;
+            int bestPriority = Integer.MIN_VALUE;
+            for (ResolvedMapping r : rules) {
+                if (!name.equals(r.getRawName()) || r.getDefinitionId() == null) continue;
+                int p = r.getPriority() == null ? 0 : r.getPriority();
+                if (p > bestPriority) {
+                    bestPriority = p;
+                    best = r;
+                }
+            }
+            if (best != null) {
+                exactResolved.put(name, best);
+            }
+        }
+
+        // fuzzy 候选:仅 status=1 的规则(与 resolve 一致)
+        List<ResolvedMapping> candidates = rules.stream()
+                .filter(r -> r.getRuleStatus() != null && r.getRuleStatus() == 1)
+                .collect(Collectors.toList());
+
+        for (String name : names) {
+            ResolvedMapping exact = exactResolved.get(name);
+            if (exact != null) {
+                result.put(name, exact);
+                continue;
+            }
+            ResolvedMapping best = null;
+            double bestScore = 0;
+            for (ResolvedMapping c : candidates) {
+                double s = bigramSimilarity(name, c.getRawName());
+                if (s > bestScore) {
+                    bestScore = s;
+                    best = c;
+                }
+            }
+            if (best != null && bestScore >= FUZZY_THRESHOLD && best.getDefinitionId() != null) {
+                result.put(name, best);
+            }
+        }
+        return result;
+    }
+
+    /** 规则 + 关联定义的联合结果(供批量解析复用,避免二次查 definition 表) */
+    public static class ResolvedMapping {
+        private String rawName;
+        private Long definitionId;
+        private Integer priority;
+        private Integer ruleStatus;
+        private Long defId;
+        private String defName;
+        private String defGoodDirection;
+        private String defValueType;
+        private String defUnit;
+        private String defRefRange;
+        private Integer defStatus;
+
+        public String getRawName() { return rawName; }
+        public void setRawName(String rawName) { this.rawName = rawName; }
+        public Long getDefinitionId() { return definitionId; }
+        public void setDefinitionId(Long definitionId) { this.definitionId = definitionId; }
+        public Integer getPriority() { return priority; }
+        public void setPriority(Integer priority) { this.priority = priority; }
+        public Integer getRuleStatus() { return ruleStatus; }
+        public void setRuleStatus(Integer ruleStatus) { this.ruleStatus = ruleStatus; }
+        public Long getDefId() { return defId; }
+        public void setDefId(Long defId) { this.defId = defId; }
+        public String getDefName() { return defName; }
+        public void setDefName(String defName) { this.defName = defName; }
+        public String getDefGoodDirection() { return defGoodDirection; }
+        public void setDefGoodDirection(String defGoodDirection) { this.defGoodDirection = defGoodDirection; }
+        public String getDefValueType() { return defValueType; }
+        public void setDefValueType(String defValueType) { this.defValueType = defValueType; }
+        public String getDefUnit() { return defUnit; }
+        public void setDefUnit(String defUnit) { this.defUnit = defUnit; }
+        public String getDefRefRange() { return defRefRange; }
+        public void setDefRefRange(String defRefRange) { this.defRefRange = defRefRange; }
+        public Integer getDefStatus() { return defStatus; }
+        public void setDefStatus(Integer defStatus) { this.defStatus = defStatus; }
+    }
+
     /** 惰性注册新定义(code 规则:indicator.<cat>.<name> / bacteria.<name> / food.<name>) */
     /** 惰性注册新定义(code 规则:indicator.<cat>.<name> / bacteria.<name> / food.<name>) */
     private Long lazyRegister(String sourceType, String rawName) {
     private Long lazyRegister(String sourceType, String rawName) {
         DefinitionCode code = buildCode(sourceType, rawName);
         DefinitionCode code = buildCode(sourceType, rawName);

+ 2 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/IndicatorService.java

@@ -23,7 +23,8 @@ public class IndicatorService {
         LambdaQueryWrapper<IndicatorDefinition> qw = new LambdaQueryWrapper<>();
         LambdaQueryWrapper<IndicatorDefinition> qw = new LambdaQueryWrapper<>();
         if (domain != null) qw.eq(IndicatorDefinition::getDomain, domain);
         if (domain != null) qw.eq(IndicatorDefinition::getDomain, domain);
         if (category != null) qw.eq(IndicatorDefinition::getCategory, category);
         if (category != null) qw.eq(IndicatorDefinition::getCategory, category);
-        if (status != null) qw.eq(IndicatorDefinition::getStatus, status);
+        // 【Q17】status==null 时默认 1,避免自动创建的 status=0 定义冒进管理端字典列表
+        qw.eq(IndicatorDefinition::getStatus, status != null ? status : 1);
         qw.orderByAsc(IndicatorDefinition::getSortOrder);
         qw.orderByAsc(IndicatorDefinition::getSortOrder);
         SortUtil.applySort(qw);
         SortUtil.applySort(qw);
         return indicatorDefinitionMapper.selectList(qw);
         return indicatorDefinitionMapper.selectList(qw);