yxh
7 天以前 68ff33beff11310e830b0615b5a81f83fad0f683
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
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);
    }
}