package cn.lihu.jh.module.ecg.service.his;
|
|
import cn.lihu.jh.module.ecg.enums.HisCheckCodeEnum;
|
import cn.lihu.jh.module.ecg.enums.HisCheckNameEnum;
|
import lombok.extern.slf4j.Slf4j;
|
import org.springframework.stereotype.Component;
|
import org.springframework.util.StringUtils;
|
|
import java.util.Arrays;
|
import java.util.List;
|
import java.util.Optional;
|
|
/**
|
* 判定一条 HIS 报文是否为「心电业务单」。
|
*
|
* <h3>为什么需要它</h3>
|
* {@code S0405}(申请单状态更新)是**全院通用**服务,接口文档定义为
|
* {@code S0405|S0405HIS|S040501HIS},不区分科室。医院会把**所有检查科室**
|
* (CT、MRI、DR、超声、骨密度…)的检查状态一并推送给心电系统。
|
*
|
* <p>实测 2026-09-17 日志:S0405 共 1225 次,其中仅 3.5% 的申请单号能在
|
* 心电库中找到。其余为放射/超声等科室的单,例如:
|
* <pre>
|
* 检查项目:胸部CT平扫、冠脉CTA、颅脑MRI平扫、彩超肝胆胰脾、骨密度…
|
* 诊室 :10号机房(CT)、急诊CT室、15号机房(MRI)、产科超声1、急诊B超…
|
* </pre>
|
* 若不过滤,会对这些单执行"查不到 → 插入新记录",向 appointment 表灌入
|
* 大量无关数据(实测单日逾千条)。
|
*
|
* <h3>判定方式(三重,任一命中即为心电单)</h3>
|
* <ol>
|
* <li><b>A. 申请单号后缀</b>:心电单由 {@code S0201ECG} 创建,编号带固定后缀
|
* {@value #ECG_APPLY_NO_SUFFIX_1101} / {@value #ECG_APPLY_NO_SUFFIX_6401}
|
* (实测样例 {@code 8826091720001101}、{@code 8826081120716401})。
|
* 与放射科的纯数字流水号(如 {@code 2126091700001})形态明显不同。</li>
|
* <li><b>B. 检查类型</b>:HIS 检查代码({@code jcxmcode} / 报文的
|
* {@code component2...code.code})能匹配 {@link HisCheckCodeEnum},
|
* 或检查名称能匹配 {@link HisCheckNameEnum}(体检患者按名称匹配)。</li>
|
* <li><b>C. 执行科室</b>:HIS 执行科室代码在配置白名单内。
|
* 该白名单来自配置项 {@code ecg.his.ecg-dept-codes}(逗号分隔)。
|
* <b>未配置时本项不参与判定</b>,避免误判。</li>
|
* </ol>
|
*
|
* <h3>保守策略</h3>
|
* 当三项均无法判定时,返回 {@code false}(视为非心电单)并记录一条 DEBUG 日志。
|
* 这是刻意的保守选择:宁可漏收(有日志可查、可补),也不向业务表灌入无关数据。
|
*
|
* @author 心电排队装机系统
|
*/
|
@Slf4j
|
@Component
|
public class HisEcgFilter {
|
|
/** 心电申请单号后缀之一(对应 check_type=1100 常规心电图-心电向量图 系列) */
|
public static final String ECG_APPLY_NO_SUFFIX_1101 = "1101";
|
/** 心电申请单号后缀之二(对应 check_type=500 动脉硬化检测 系列) */
|
public static final String ECG_APPLY_NO_SUFFIX_6401 = "6401";
|
|
/** 心电申请单号后缀白名单 */
|
private static final List<String> ECG_APPLY_NO_SUFFIXES = Arrays.asList(
|
ECG_APPLY_NO_SUFFIX_1101, ECG_APPLY_NO_SUFFIX_6401);
|
|
/**
|
* 心电检查项目名称关键字(**辅助信号,不是唯一依据**)。
|
* <p>
|
* <b>⚠️ 重要更正</b>:早期版本注释曾声称"本院全部 15 个心电项目名称均含「心电」",
|
* 这是**错误的**。核对 {@code check_type} 表后有 **4 个心电类型不含"心电"**:
|
* <pre>
|
* 300 动态血压 库中 9083 条
|
* 500 动脉硬化检测 库中 11205 条
|
* 600 食道电生理 库中 60 条
|
* 900 心室晚电位 库中 0 条
|
* </pre>
|
* 因此**绝不能**用"含心电"作为唯一判定依据,否则这 2 万多条真实心电记录会被丢弃。
|
* <p>
|
* 本关键字仅作为**白名单之外的补充**({@link #ECG_NAME_WHITELIST} 未收录的
|
* 长尾名称变体,如"十二导联心电检查")。真正的权威依据是:
|
* ① 检查代码({@link HisCheckCodeEnum})、② 名称白名单、③ 非心电排除词。
|
*/
|
private static final String ECG_NAME_KEYWORD = "心电";
|
|
/**
|
* 心电检查项目名称白名单。
|
* <p>
|
* 两个来源合并:
|
* <ol>
|
* <li>{@code check_type} 表的 15 个权威名称;</li>
|
* <li>{@code HisCheckNameEnum} 中 HIS 实际使用的名称变体(如"24小时动态心电图"、
|
* "24小时动态心电监测"、"24小时动态血压监测")。</li>
|
* </ol>
|
* 用**去空白后的完整名称精确匹配**,避免"含心电"这类不严谨的模糊判断。
|
* <p>
|
* 注意:300/500/600/900 这四类名称本身不含"心电",靠本白名单与检查代码共同兜住。
|
*/
|
private static final List<String> ECG_NAME_WHITELIST = Arrays.asList(
|
// —— check_type 表 15 个权威名称(前缀匹配,可覆盖带前后缀的变体)——
|
"常规心电图",
|
"动态心电",
|
"动态血压",
|
"平板运动心电",
|
"动脉硬化检测",
|
"食道电生理",
|
"心电图药物负荷试验",
|
"频谱心电图",
|
"心室晚电位",
|
"床边心电图(常规+频谱)",
|
"常规心电图-心电向量图",
|
"动态心电图+血压",
|
"(GCP)心电图",
|
"常规心电图18导-心电向量图",
|
"床边心电图18导(常规+频谱)",
|
// —— HIS 实际使用的名称变体(来自 HisCheckNameEnum 与生产日志)——
|
"24小时动态心电图",
|
"24小时动态心电监测",
|
"24小时动态血压监测",
|
"心电图运动试验", // 实测 S050401 出现,对应 check_type=400
|
"运动心电图",
|
"运动试验", // 心电图运动试验的短名(用户提出)
|
"平板运动", // 覆盖"平板运动试验"/"平板运动心电图"等变体
|
"活动平板试验",
|
"心电向量图",
|
"床边心电图",
|
"频谱心电",
|
"食道调搏",
|
"经食道心房调搏",
|
"动脉硬化", // check_type=500 的短名(表内为"动脉硬化检测")
|
"踝臂指数",
|
"ABI"
|
);
|
|
/**
|
* 明确的**非心电**排除词。
|
* <p>
|
* 用于在"名称既不在白名单、也不含心电关键字"时,快速判定为其他科室检查。
|
* 这些词来自实测生产日志中大量出现的影像/超声/内镜/功能检查项目
|
* (如"彩超肝胆胰脾"、"胸部CT平扫"、"无痛胃镜"、"骨密度")。
|
* <p>
|
* <b>刻意保守</b>:只收录与心电绝无交集的强特征词,避免误伤。例如
|
* "彩超胎儿心脏"含"心脏"但属超声科,用"彩超"排除;而不收录孤立的"心脏",
|
* 以免误伤可能的心电项目。
|
*/
|
private static final List<String> NON_ECG_EXCLUDE_KEYWORDS = Arrays.asList(
|
// 超声类
|
"彩超", "B超", "超声", "多普勒超声",
|
// 放射影像类
|
"CT", "MRI", "MR平扫", "DR", "CR", "X线", "摄片", "正侧位", "正斜位", "正位", "侧位",
|
"平扫", "增强", "CTA", "MRA", "DWI", "三维重建", "重建", "造影", "钼靶",
|
"机房", "放射", "核磁", "磁共振",
|
// 内镜类
|
"胃镜", "肠镜", "支气管镜", "喉镜", "鼻咽镜", "内镜", "内窥镜",
|
// 其他功能检查(非心电)
|
"肺功能", "肺通气", "肺弥散", "骨密度", "肌电图", "脑电图", "脑血流",
|
"呼气", "激发试验", "舒张试验", "弹性测定", "弹性成像", "量表",
|
"骨龄", "KUB", "排粪造影",
|
// 病理/检验类
|
"活检", "穿刺", "病理", "标本"
|
);
|
|
/**
|
* HIS 执行科室代码白名单(配置项 {@code ecg.his.ecg-dept-codes},逗号分隔)。
|
* <p>
|
* 说明:实测库中查不到 HIS 执行科室代码(如 {@code YJ001})与本地科室的映射表,
|
* 因此这里做成可配置项。未配置时「依据 C」不参与判定,不影响 A/B 的过滤效果。
|
* 待院方提供心电执行科室代码后,在 infra_config 增加该配置即可生效。
|
*/
|
private volatile List<String> ecgDeptCodes = java.util.Collections.emptyList();
|
|
/** 供外部(配置刷新)注入科室白名单 */
|
public void setEcgDeptCodes(List<String> codes) {
|
this.ecgDeptCodes = codes == null ? java.util.Collections.emptyList() : codes;
|
}
|
|
/**
|
* 判定结果:是否为心电业务单,以及命中的依据(便于排查)。
|
*/
|
public static class Result {
|
private final boolean ecg;
|
private final String reason;
|
|
Result(boolean ecg, String reason) {
|
this.ecg = ecg;
|
this.reason = reason;
|
}
|
|
public boolean isEcg() {
|
return ecg;
|
}
|
|
public String getReason() {
|
return reason;
|
}
|
}
|
|
/**
|
* 综合判定(三重)。
|
*
|
* @param applyNo 申请单号,可为空
|
* @param hisCheckCode HIS 检查项目代码(如 {@code Y1202065}),可为空
|
* @param hisCheckName HIS 检查项目名称(如 {@code 床边心电图(常规+频谱)}),可为空
|
* @param hisDeptCode HIS 执行科室代码,可为空
|
* @return 判定结果
|
*/
|
public Result judge(String applyNo, String hisCheckCode, String hisCheckName, String hisDeptCode) {
|
// 依据 A:申请单号后缀
|
if (matchApplyNoSuffix(applyNo)) {
|
return new Result(true, "A:申请单号后缀");
|
}
|
// 依据 B:检查类型(代码优先,其次名称)
|
if (matchCheckCode(hisCheckCode)) {
|
return new Result(true, "B:检查项目代码=" + hisCheckCode);
|
}
|
if (matchCheckName(hisCheckName)) {
|
return new Result(true, "B:检查项目名称=" + hisCheckName);
|
}
|
// 依据 C:执行科室(需配置白名单,未配置时不参与)
|
if (matchDeptCode(hisDeptCode)) {
|
return new Result(true, "C:执行科室=" + hisDeptCode);
|
}
|
return new Result(false, "未命中任何心电判定依据");
|
}
|
|
/**
|
* 综合判定(含 ZDY 检查项目,用于 {@code S050401}/{@code S050501})。
|
* <p>
|
* 这两个服务的检查项目不在 {@code component2.observationRequest.code},
|
* 而在 {@code actAppointment.ZDY.jcxmcode} / {@code ZDY.jcxmname}(逗号分隔多值)。
|
*
|
* @param zdy 报文中的 {@code ZDY} 节点,可为空
|
*/
|
public Result judgeWithZdy(String applyNo, String hisDeptCode, Object zdy) {
|
// 依据 A:申请单号后缀
|
if (matchApplyNoSuffix(applyNo)) {
|
return new Result(true, "A:申请单号后缀");
|
}
|
// 依据 B:ZDY 中的检查项目(代码 + 名称,支持逗号分隔多值)
|
String jcxmCode = extractZdyValue(zdy, "jcxmcode");
|
String jcxmName = extractZdyValue(zdy, "jcxmname");
|
if (matchCheckItems(jcxmCode, jcxmName)) {
|
return new Result(true, "B:ZDY检查项目 code=" + jcxmCode + " name=" + jcxmName);
|
}
|
// 依据 C:执行科室
|
if (matchDeptCode(hisDeptCode)) {
|
return new Result(true, "C:执行科室=" + hisDeptCode);
|
}
|
return new Result(false, "未命中任何心电判定依据");
|
}
|
|
/** 取 ZDY 中的某个文本字段 */
|
private String extractZdyValue(Object zdy, String key) {
|
if (!(zdy instanceof java.util.Map)) {
|
return null;
|
}
|
Object v = ((java.util.Map<?, ?>) zdy).get(key);
|
return v == null ? null : v.toString();
|
}
|
|
/** 依据 A:申请单号是否以心电后缀结尾 */
|
public boolean matchApplyNoSuffix(String applyNo) {
|
if (!StringUtils.hasText(applyNo)) {
|
return false;
|
}
|
String trimmed = applyNo.trim();
|
for (String suffix : ECG_APPLY_NO_SUFFIXES) {
|
if (trimmed.endsWith(suffix)) {
|
return true;
|
}
|
}
|
return false;
|
}
|
|
/**
|
* 依据 B-1:HIS 检查项目代码是否属心电。
|
* <p>
|
* HIS 报文中该值形如 {@code Y1202065},而 {@link HisCheckCodeEnum} 的 code 为
|
* 去掉前缀 {@code Y} 后的数字({@code 1202065})。原代码使用
|
* {@code checkType.substring(1)} 做截断,这里做同样兼容并补长度校验,
|
* 同时容忍报文本身就带或不带 {@code Y} 前缀。
|
*/
|
public boolean matchCheckCode(String hisCheckCode) {
|
if (!StringUtils.hasText(hisCheckCode)) {
|
return false;
|
}
|
String raw = hisCheckCode.trim().replaceAll("\\s+", "");
|
// 原逻辑:substring(1) 去掉首字符。此处同时尝试原值与去首字符,兼容两种报文
|
String[] candidates = raw.length() > 1
|
? new String[]{raw, raw.substring(1)}
|
: new String[]{raw};
|
for (String candidate : candidates) {
|
if (HisCheckCodeEnum.getByCode(candidate) != null) {
|
return true;
|
}
|
}
|
return false;
|
}
|
|
/**
|
* 依据 B-2:HIS 检查项目名称是否属心电。
|
*
|
* <p>判定顺序(**先白名单、后关键字、再用排除词挡住**):
|
* <ol>
|
* <li><b>名称白名单精确匹配</b>({@link ECG_NAME_WHITELIST} 或
|
* {@link HisCheckNameEnum})—— 覆盖"动态血压""动脉硬化检测"
|
* "食道电生理""心室晚电位"等**不含"心电"**的心电项目;</li>
|
* <li><b>含"心电"关键字</b> —— 覆盖白名单未收录的名称变体
|
* (如"十二导联心电检查");</li>
|
* <li><b>命中非心电排除词</b>({@link NON_ECG_EXCLUDE_KEYWORDS})——
|
* 明确判为其他科室检查,直接返回 false。
|
* 这一步很关键:若某名称同时命中"心电"与排除词(异常情况),
|
* 说明报文有歧义,宁可交给排除词判否,也不误收。</li>
|
* </ol>
|
*
|
* <p><b>为什么不只用"含心电"</b>:本院 {@code check_type} 表的 15 个心电类型中,
|
* 有 4 个不含"心电"(300 动态血压、500 动脉硬化检测、600 食道电生理、
|
* 900 心室晚电位),库中合计 2 万余条真实记录。仅靠关键字会**丢弃这些真实心电单**。
|
*/
|
public boolean matchCheckName(String hisCheckName) {
|
if (!StringUtils.hasText(hisCheckName)) {
|
return false;
|
}
|
String normalized = hisCheckName.trim().replaceAll("\\s+", "");
|
|
// 1) 非心电排除词优先判否(防止歧义名称被后面的关键字误收)
|
for (String kw : NON_ECG_EXCLUDE_KEYWORDS) {
|
if (normalized.contains(kw)) {
|
return false;
|
}
|
}
|
|
// 2) 名称白名单匹配
|
// 采用**前缀匹配**而非全等:HIS 的名称常带前后缀变体
|
// (如"24小时动态血压监测"= 前缀"动态血压" + "监测";
|
// "动态心电图+血压"以"动态心电"开头),前缀匹配可覆盖长尾变体。
|
// 安全前提:排除词已在第 1 步先行拦截,不会出现"彩超…"被前缀误收。
|
if (HisCheckNameEnum.getByName(normalized) != null) {
|
return true;
|
}
|
for (String name : ECG_NAME_WHITELIST) {
|
if (normalized.startsWith(name)) {
|
return true;
|
}
|
}
|
|
// 3) 关键字兜底(白名单之外的长尾名称变体)
|
return normalized.contains(ECG_NAME_KEYWORD);
|
}
|
|
/**
|
* 依据 B(多值版):报文中的检查项目代码/名称可能是**逗号分隔的多个项目**。
|
* <p>
|
* 实测 {@code S050401}/{@code S050501} 的 {@code ZDY.jcxmcode}/{@code ZDY.jcxmname}
|
* 形如:
|
* <pre>
|
* jcxmcode=1393,1506 jcxmname=右肘正侧位,左肩关节正侧位
|
* jcxmcode=35672306,35672504,... jcxmname=彩超右下肢动脉,彩超右下肢静脉,...
|
* </pre>
|
* 只要**任一**项目属心电即判定为心电单。
|
*
|
* @param hisCheckCodes 逗号分隔的检查项目代码,可为空
|
* @param hisCheckNames 逗号分隔的检查项目名称,可为空
|
* @return 是否命中心电检查项目
|
*/
|
public boolean matchCheckItems(String hisCheckCodes, String hisCheckNames) {
|
return matchAny(hisCheckCodes, this::matchCheckCode)
|
|| matchAny(hisCheckNames, this::matchCheckName);
|
}
|
|
/** 将分隔符拆开的值逐个交给判定函数,任一命中即返回 true */
|
private boolean matchAny(String joined, java.util.function.Predicate<String> matcher) {
|
if (!StringUtils.hasText(joined)) {
|
return false;
|
}
|
for (String part : joined.split("[,,;;]")) {
|
if (matcher.test(part.trim())) {
|
return true;
|
}
|
}
|
return false;
|
}
|
|
/**
|
* 依据 B(S050401/S050501 专用):从 {@code ZDY.jcxmcode} / {@code ZDY.jcxmname}
|
* 综合判定是否属心电检查。
|
*/
|
public boolean matchZdyCheckItems(Object zdy) {
|
if (!(zdy instanceof java.util.Map)) {
|
return false;
|
}
|
java.util.Map<?, ?> m = (java.util.Map<?, ?>) zdy;
|
Object code = m.get("jcxmcode");
|
Object name = m.get("jcxmname");
|
return matchCheckItems(code == null ? null : code.toString(),
|
name == null ? null : name.toString());
|
}
|
|
/** 依据 C:执行科室代码是否在白名单内(白名单为空时视为不参与判定) */
|
public boolean matchDeptCode(String hisDeptCode) {
|
if (ecgDeptCodes.isEmpty() || !StringUtils.hasText(hisDeptCode)) {
|
return false;
|
}
|
String trimmed = hisDeptCode.trim();
|
return ecgDeptCodes.stream().anyMatch(c -> c.equalsIgnoreCase(trimmed));
|
}
|
|
/**
|
* 便捷方法:仅判定,并在非心电单时输出可排查的 DEBUG 日志。
|
*
|
* @param source 调用来源标识,用于日志定位(如 {@code S0405})
|
*/
|
public boolean isEcgOrder(String source, String applyNo, String hisCheckCode,
|
String hisCheckName, String hisDeptCode) {
|
Result r = judge(applyNo, hisCheckCode, hisCheckName, hisDeptCode);
|
if (!r.isEcg()) {
|
// 用 INFO 级:生产日志级别为 INFO,若用 DEBUG 则跳过记录完全不可见,
|
// 曾因此漏掉了 S050401/S050501 混入非心电单的问题(无法从日志发现)。
|
log.info("[{}][非心电业务单,跳过处理] applyNo={} 检查代码={} 项目名称={} 执行科室={} 原因={}",
|
source, applyNo, hisCheckCode, hisCheckName, hisDeptCode, r.getReason());
|
}
|
return r.isEcg();
|
}
|
|
/**
|
* 便捷方法(含 ZDY 检查项目):用于 {@code S050401}/{@code S050501}。
|
* <p>
|
* 非心电单时输出 INFO 级日志(这两个服务此前**未做任何过滤**,导致放射/超声单
|
* 被当作心电申请单采集:实测 S050401 有 109/256、S050501 有 4/19 属非心电)。
|
*
|
* @param zdy 报文中的 {@code ZDY} 节点
|
*/
|
public boolean isEcgOrderWithZdy(String source, String applyNo, String hisDeptCode, Object zdy) {
|
Result r = judgeWithZdy(applyNo, hisDeptCode, zdy);
|
if (!r.isEcg()) {
|
Object name = zdy instanceof java.util.Map ? ((java.util.Map<?, ?>) zdy).get("jcxmname") : null;
|
Object room = zdy instanceof java.util.Map ? ((java.util.Map<?, ?>) zdy).get("roomname") : null;
|
log.info("[{}][非心电业务单,跳过处理] applyNo={} 检查项目={} 房间={} 执行科室={} 原因={}",
|
source, applyNo, name, room, hisDeptCode, r.getReason());
|
}
|
return r.isEcg();
|
}
|
|
/** 从形如 {@code component2={observationRequest={...code={code=Y1202065,...}}}} 的结构中取检查项目代码 */
|
@SuppressWarnings("unchecked")
|
public static String extractCheckCode(Object component2) {
|
return Optional.ofNullable(component2)
|
.filter(v -> v instanceof java.util.Map)
|
.map(v -> (java.util.Map<String, Object>) v)
|
.map(m -> (java.util.Map<String, Object>) m.get("observationRequest"))
|
.map(or -> (java.util.Map<String, Object>) or.get("code"))
|
.map(code -> {
|
Object c = code.get("code");
|
return c == null ? null : c.toString();
|
})
|
.orElse(null);
|
}
|
}
|