|
@@ -210,6 +210,13 @@ public class ReportCollectService {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
harmReportPayloadValid(payload);
|
|
harmReportPayloadValid(payload);
|
|
|
|
|
+ // 采集指标完整性校验(report_type_registry.expected_indicators 定义驱动)
|
|
|
|
|
+ String validationTypeId = (!"unknown".equals(typeId)) ? typeId : draft.getReportType();
|
|
|
|
|
+ try {
|
|
|
|
|
+ runCollectValidation(draftId, validationTypeId, payload);
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.warn("采集指标校验失败 draftId={}, typeId={}", draftId, validationTypeId, e);
|
|
|
|
|
+ }
|
|
|
healthReportDraftService.updateParseMethod(draftId, method);
|
|
healthReportDraftService.updateParseMethod(draftId, method);
|
|
|
healthReportDraftService.updatePayload(draftId, objectMapper.writeValueAsString(payload));
|
|
healthReportDraftService.updatePayload(draftId, objectMapper.writeValueAsString(payload));
|
|
|
|
|
|
|
@@ -291,6 +298,9 @@ public class ReportCollectService {
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ // 采集指标完整性校验(expected_indicators 定义驱动)
|
|
|
|
|
+ runCollectValidation(draftId, registry.getTypeId(), parsed);
|
|
|
|
|
+
|
|
|
// 草稿闭环:reportType 置为识别类型(前端按 dan_* 跳转)+ completed
|
|
// 草稿闭环:reportType 置为识别类型(前端按 dan_* 跳转)+ completed
|
|
|
healthReportDraftService.updateReportType(draftId, registry.getTypeId());
|
|
healthReportDraftService.updateReportType(draftId, registry.getTypeId());
|
|
|
healthReportDraftService.markCollectCompleted(draftId, upload.getId());
|
|
healthReportDraftService.markCollectCompleted(draftId, upload.getId());
|
|
@@ -938,4 +948,298 @@ public class ReportCollectService {
|
|
|
headers.set("Authorization", "Basic " + token);
|
|
headers.set("Authorization", "Basic " + token);
|
|
|
return headers;
|
|
return headers;
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+ // ======================== 采集指标完整性校验(report_type_registry.expected_indicators 定义驱动) ========================
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 健康(GUT/混检)管道校验入口:汇总 payload 中全部可采集指标名称后按定义比对
|
|
|
|
|
+ */
|
|
|
|
|
+ private void runCollectValidation(Long draftId, String typeId, ParsedReportPayload.Payload payload) {
|
|
|
|
|
+ runCollectValidation(draftId, typeId, collectHealthNames(payload));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * DAN 管道校验入口:汇总解析 items 的 name/code 后按定义比对
|
|
|
|
|
+ */
|
|
|
|
|
+ private void runCollectValidation(Long draftId, String typeId, DanReportParseService.DanParsedReport parsed) {
|
|
|
|
|
+ Set<String> names = new HashSet<>();
|
|
|
|
|
+ if (parsed != null && parsed.getItems() != null) {
|
|
|
|
|
+ for (DanReportParseService.DataItem item : parsed.getItems()) {
|
|
|
|
|
+ addCollectName(names, item.getName());
|
|
|
|
|
+ addCollectName(names, item.getCode());
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ runCollectValidation(draftId, typeId, names);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 采集完整性校验:按 report_type_registry.expected_indicators 定义,校验本次采集结果是否覆盖应采集指标项。
|
|
|
|
|
+ * 该类型无指标项定义(expected_indicators 为空 / 类型未注册)时跳过,不影响采集主流程。
|
|
|
|
|
+ * 结果 JSON(typeId/expected/matched/pass/missing)写入草稿 collect_validation,缺失项记入 WARN 日志。
|
|
|
|
|
+ */
|
|
|
|
|
+ private void runCollectValidation(Long draftId, String typeId, Set<String> collectedNames) {
|
|
|
|
|
+ if (draftId == null || typeId == null || collectedNames == null || collectedNames.isEmpty()) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ List<String> expected = loadExpectedIndicators(typeId);
|
|
|
|
|
+ if (expected == null || expected.isEmpty()) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ List<String> missing = new ArrayList<>();
|
|
|
|
|
+ int matched = 0;
|
|
|
|
|
+ for (String exp : expected) {
|
|
|
|
|
+ if (indicatorMatches(exp, collectedNames)) {
|
|
|
|
|
+ matched++;
|
|
|
|
|
+ } else {
|
|
|
|
|
+ missing.add(exp);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ Map<String, Object> result = new LinkedHashMap<>();
|
|
|
|
|
+ result.put("typeId", typeId);
|
|
|
|
|
+ result.put("expected", expected.size());
|
|
|
|
|
+ result.put("matched", matched);
|
|
|
|
|
+ result.put("pass", missing.isEmpty());
|
|
|
|
|
+ result.put("missing", missing.size() > 20 ? missing.subList(0, 20) : missing);
|
|
|
|
|
+ try {
|
|
|
|
|
+ healthReportDraftService.updateCollectValidation(draftId, objectMapper.writeValueAsString(result));
|
|
|
|
|
+ if (missing.isEmpty()) {
|
|
|
|
|
+ log.info("采集校验通过 draftId={}, typeId={}, expected={}", draftId, typeId, expected.size());
|
|
|
|
|
+ } else {
|
|
|
|
|
+ log.warn("采集校验未通过 draftId={}, typeId={}, matched={}/{}, 缺失[{}]",
|
|
|
|
|
+ draftId, typeId, matched, expected.size(), String.join(", ", missing));
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.warn("记录采集校验结果失败 draftId={}, typeId={}", draftId, typeId, e);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 读取类型指标项定义(report_type_registry.expected_indicators JSON 数组);未定义返回 null */
|
|
|
|
|
+ private List<String> loadExpectedIndicators(String typeId) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ ReportTypeRegistry registry = reportTypeRegistryMapper.selectOne(
|
|
|
|
|
+ new LambdaQueryWrapper<ReportTypeRegistry>()
|
|
|
|
|
+ .eq(ReportTypeRegistry::getTypeId, typeId)
|
|
|
|
|
+ .last("LIMIT 1"));
|
|
|
|
|
+ if (registry == null || registry.getExpectedIndicators() == null
|
|
|
|
|
+ || registry.getExpectedIndicators().isEmpty()) {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ return objectMapper.readValue(registry.getExpectedIndicators(),
|
|
|
|
|
+ new TypeReference<List<String>>() {});
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.warn("读取类型指标项定义失败 typeId={}", typeId, e);
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 健康管道:汇总 payload 中全部可采集指标名称(含区块信号),用于与定义比对 */
|
|
|
|
|
+ private Set<String> collectHealthNames(ParsedReportPayload.Payload payload) {
|
|
|
|
|
+ Set<String> names = new HashSet<>();
|
|
|
|
|
+ if (payload == null) {
|
|
|
|
|
+ return names;
|
|
|
|
|
+ }
|
|
|
|
|
+ ParsedReportPayload.Summary s = payload.getSummary();
|
|
|
|
|
+ if (s != null) {
|
|
|
|
|
+ addMappedName(names, "健康总分", s.getOverallScore());
|
|
|
|
|
+ addMappedName(names, "菌群健康", s.getGutHealthScore());
|
|
|
|
|
+ addMappedName(names, "慢病控制", s.getChronicDiseaseScore());
|
|
|
|
|
+ addMappedName(names, "营养均衡", s.getNutritionScore());
|
|
|
|
|
+ addMappedName(names, "肠道菌群平衡", s.getBalanceScore());
|
|
|
|
|
+ addMappedName(names, "菌群多样性", s.getDiversityScore());
|
|
|
|
|
+ addMappedName(names, "有益菌", s.getBeneficialScore());
|
|
|
|
|
+ addMappedName(names, "有害菌", s.getHarmfulScore());
|
|
|
|
|
+ addMappedName(names, "核心菌属", s.getCoreGenusScore());
|
|
|
|
|
+ addMappedName(names, "肠道预测年龄", s.getGutAge());
|
|
|
|
|
+ addMappedName(names, "肠型", s.getGutType());
|
|
|
|
|
+ addMappedName(names, "菌群多样性", s.getShannonIndex());
|
|
|
|
|
+ }
|
|
|
|
|
+ if (payload.getIndicators() != null) {
|
|
|
|
|
+ for (ParsedReportPayload.Indicator ind : payload.getIndicators()) {
|
|
|
|
|
+ addCollectName(names, ind.getIndicatorName());
|
|
|
|
|
+ addCollectName(names, ind.getCategory());
|
|
|
|
|
+ addHealthBlockSignals(names, ind.getIndicatorName(), ind.getCategory());
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ addFloraNames(names, payload.getGutFlora());
|
|
|
|
|
+ addFloraNames(names, payload.getProbioticSpecies());
|
|
|
|
|
+ addFloraNames(names, payload.getPathogenGenus());
|
|
|
|
|
+ addFloraNames(names, payload.getPathogenDetection());
|
|
|
|
|
+ addFloraNames(names, payload.getTaxonomyClass());
|
|
|
|
|
+ addFloraNames(names, payload.getTaxonomyOrder());
|
|
|
|
|
+ addFloraNames(names, payload.getTaxonomyFamily());
|
|
|
|
|
+ addFloraNames(names, payload.getTaxonomyGenus());
|
|
|
|
|
+ addFloraNames(names, payload.getTaxonomySpecies());
|
|
|
|
|
+ if (hasFlora(payload)) {
|
|
|
|
|
+ names.add("菌群检出详细列表");
|
|
|
|
|
+ }
|
|
|
|
|
+ if (payload.getDiseaseRisks() != null && !payload.getDiseaseRisks().isEmpty()) {
|
|
|
|
|
+ names.add("疾病风险评估");
|
|
|
|
|
+ for (ParsedReportPayload.DiseaseRisk dr : payload.getDiseaseRisks()) {
|
|
|
|
|
+ addCollectName(names, dr.getDiseaseName());
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if (payload.getFoods() != null && !payload.getFoods().isEmpty()) {
|
|
|
|
|
+ names.add("个体化食物推荐表");
|
|
|
|
|
+ for (ParsedReportPayload.FoodItem f : payload.getFoods()) {
|
|
|
|
|
+ addCollectName(names, f.getName());
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return names;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private boolean hasFlora(ParsedReportPayload.Payload payload) {
|
|
|
|
|
+ return payload != null && (isNotEmpty(payload.getGutFlora()) || isNotEmpty(payload.getProbioticSpecies())
|
|
|
|
|
+ || isNotEmpty(payload.getPathogenGenus()) || isNotEmpty(payload.getPathogenDetection())
|
|
|
|
|
+ || isNotEmpty(payload.getTaxonomyClass()) || isNotEmpty(payload.getTaxonomyOrder())
|
|
|
|
|
+ || isNotEmpty(payload.getTaxonomyFamily()) || isNotEmpty(payload.getTaxonomyGenus())
|
|
|
|
|
+ || isNotEmpty(payload.getTaxonomySpecies()));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private boolean isNotEmpty(List<?> list) {
|
|
|
|
|
+ return list != null && !list.isEmpty();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private void addFloraNames(Set<String> names, List<ParsedReportPayload.Flora> flora) {
|
|
|
|
|
+ if (flora == null) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ for (ParsedReportPayload.Flora f : flora) {
|
|
|
|
|
+ addCollectName(names, f.getBacteriaName());
|
|
|
|
|
+ addCollectName(names, f.getCategory());
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 菌群报告区块归类:指标名/分类命中关键词 → 记入对应区块信号(容忍「区块名 vs 条目名」表述差异,如 短链脂肪酸 vs 丁酸) */
|
|
|
|
|
+ private static final String[][] HEALTH_BLOCK_KEYWORDS = {
|
|
|
|
|
+ {"主要营养评估", "碳水化合物", "蛋白质", "脂肪", "纤维素", "乳制品"},
|
|
|
|
|
+ {"氨基酸评估", "氨基酸"},
|
|
|
|
|
+ {"维生素评估", "维生素"},
|
|
|
|
|
+ {"微量元素评估", "微量元素", "铁", "锌"},
|
|
|
|
|
+ {"主要消化道致病菌", "致病菌", "幽门螺杆菌"},
|
|
|
|
|
+ {"抗生素风险评估", "抗生素"},
|
|
|
|
|
+ {"肠道屏障及代谢物", "屏障", "渗透"},
|
|
|
|
|
+ {"短链脂肪酸", "短链脂肪酸", "丁酸", "乙酸", "丙酸"},
|
|
|
|
|
+ {"神经递质及激素", "神经递质", "血清素", "多巴胺", "5-羟色胺", "激素"},
|
|
|
|
|
+ {"肠道微生物健康指数", "微生物健康指数"},
|
|
|
|
|
+ {"肠道菌群平衡评估", "菌群平衡"},
|
|
|
|
|
+ {"肠道黏膜屏障", "黏膜屏障"},
|
|
|
|
|
+ {"肠道菌群多样性", "菌群多样性"},
|
|
|
|
|
+ {"肠道菌群表型评估", "菌群表型", "表型评估"},
|
|
|
|
|
+ {"肠道抗炎能力", "抗炎"},
|
|
|
|
|
+ {"肠道免疫力", "免疫"},
|
|
|
|
|
+ {"肠道膳食纤维需求", "膳食纤维"},
|
|
|
|
|
+ {"短链脂肪酸合成能力", "短链脂肪酸合成"},
|
|
|
|
|
+ {"核心菌属综合评分", "核心菌属综合评分", "核心菌属评分"},
|
|
|
|
|
+ {"有益菌综合评分", "有益菌综合评分", "有益菌评分"},
|
|
|
|
|
+ {"有害菌综合评分", "有害菌综合评分", "有害菌评分"},
|
|
|
|
|
+ {"毒性物质清除能力评估", "毒性", "清除能力", "毒素"},
|
|
|
|
|
+ {"胃肠道感染病原体评估", "病原体", "感染"},
|
|
|
|
|
+ {"趣味肠菌评估", "趣味", "人格", "认知功能", "职场心理"}
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ /** 指标名/分类文本命中区块关键词 → 记入该区块信号名 */
|
|
|
|
|
+ private void addHealthBlockSignals(Set<String> names, String... sources) {
|
|
|
|
|
+ for (String src : sources) {
|
|
|
|
|
+ if (src == null) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ for (String[] pair : HEALTH_BLOCK_KEYWORDS) {
|
|
|
|
|
+ for (int k = 1; k < pair.length; k++) {
|
|
|
|
|
+ if (src.contains(pair[k])) {
|
|
|
|
|
+ names.add(pair[0]);
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 名称归一化:去除后缀差异(_pct/_score/百分位/得分 等),中文取核心短语;结果为空则保留原串 */
|
|
|
|
|
+ private String normalizeIndicatorName(String s) {
|
|
|
|
|
+ if (s == null) {
|
|
|
|
|
+ return "";
|
|
|
|
|
+ }
|
|
|
|
|
+ String t = s.trim();
|
|
|
|
|
+ t = t.replaceFirst("(_pct|_score|_小时|_分钟|_min|_max)\\s*$", "");
|
|
|
|
|
+ t = t.replaceFirst("(百分位|得分|评估|指数|综合评分|评分)\\s*$", "");
|
|
|
|
|
+ if (t.isEmpty()) {
|
|
|
|
|
+ t = s.trim();
|
|
|
|
|
+ }
|
|
|
|
|
+ return t.toLowerCase(Locale.ROOT);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 提取中文二元组(用于容忍 表面方法与策略 vs 表面策略 类表述差异) */
|
|
|
|
|
+ private Set<String> cjkBigrams(String s) {
|
|
|
|
|
+ if (s == null || s.isEmpty()) {
|
|
|
|
|
+ return Collections.emptySet();
|
|
|
|
|
+ }
|
|
|
|
|
+ String cjk = s.replaceAll("[^\\u4e00-\\u9fa5]", "");
|
|
|
|
|
+ Set<String> set = new HashSet<>();
|
|
|
|
|
+ if (cjk.length() == 1) {
|
|
|
|
|
+ set.add(cjk);
|
|
|
|
|
+ return set;
|
|
|
|
|
+ }
|
|
|
|
|
+ for (int i = 0; i + 1 < cjk.length(); i++) {
|
|
|
|
|
+ set.add(cjk.substring(i, i + 2));
|
|
|
|
|
+ }
|
|
|
|
|
+ return set;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 指标匹配:归一化后相等 / 互相包含 / 中文二元组重叠 >= 50% 即视为命中 */
|
|
|
|
|
+ private boolean indicatorMatches(String expected, Set<String> collectedNames) {
|
|
|
|
|
+ if (expected == null || collectedNames == null || collectedNames.isEmpty()) {
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+ String normExp = normalizeIndicatorName(expected);
|
|
|
|
|
+ if (normExp.isEmpty()) {
|
|
|
|
|
+ return true;
|
|
|
|
|
+ }
|
|
|
|
|
+ for (String name : collectedNames) {
|
|
|
|
|
+ String norm = normalizeIndicatorName(name);
|
|
|
|
|
+ if (norm.isEmpty()) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (normExp.equals(norm) || normExp.contains(norm) || norm.contains(normExp)) {
|
|
|
|
|
+ return true;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (cjkBigramOverlap(normExp, norm)) {
|
|
|
|
|
+ return true;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private boolean cjkBigramOverlap(String a, String b) {
|
|
|
|
|
+ Set<String> bigramsA = cjkBigrams(a);
|
|
|
|
|
+ Set<String> bigramsB = cjkBigrams(b);
|
|
|
|
|
+ if (bigramsA.isEmpty() || bigramsB.isEmpty()) {
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+ Set<String> smaller = bigramsA.size() <= bigramsB.size() ? bigramsA : bigramsB;
|
|
|
|
|
+ Set<String> larger = bigramsA.size() <= bigramsB.size() ? bigramsB : bigramsA;
|
|
|
|
|
+ int hit = 0;
|
|
|
|
|
+ for (String bg : smaller) {
|
|
|
|
|
+ if (larger.contains(bg)) {
|
|
|
|
|
+ hit++;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return (double) hit / smaller.size() >= 0.5;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private void addCollectName(Set<String> names, String raw) {
|
|
|
|
|
+ if (raw != null && !raw.trim().isEmpty()) {
|
|
|
|
|
+ names.add(raw.trim());
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 字段值非空信号:字段有值(非 null 非空白)时,把映射名计入已采集集合 */
|
|
|
|
|
+ private void addMappedName(Set<String> names, String mappedName, Object value) {
|
|
|
|
|
+ if (value == null) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ String v = String.valueOf(value).trim();
|
|
|
|
|
+ if (!v.isEmpty()) {
|
|
|
|
|
+ names.add(mappedName);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|