Commit 4d9c6f13 by luoqi

fix(召回): 残根/残冠归 K03 —— K08.3 被截成 K08 后误判"缺失牙未启动修复"

王丽芳 TS0M013040 牙42 是残根(医生计划残根拔除术),召回却给出"邀约启动缺失牙
修复(种植/桥/义齿)",分配矩阵也落在「种植治疗」列。

根因:源 ICD 用 K08.3 牙根残留(医学上没标错),std_code 截前 3 位后跟 K08.1
后天缺失挤进同一个 K08。而医生没填 std_code 时按中文名映射本来就落 K03
(全库 97,970 条)—— 填了码的这 932 条反而归错,少数派拉回多数派。

K03 的 categories 含 surgical、目标文案本就写着"残根/残冠 → 充填/嵌体/拔除
修复",POTENTIAL_LABEL_RULES 的 K03 + EXTRACTION_NAME_KEYWORDS → extraction
(拔牙治疗)也正等着这批名字。

 带反向闸:复合诊断("牙列缺损,残根"等 325 条)两个病挤在一个 name,
翻 K03 会丢掉缺牙那一半 → 保持 K08。

落点选 reconcileDiagnosisCode 而非 manifest recode:源里同概念已有 3 个码变体
(K08.300x002/K08.302/K08.300),精确匹配必漏;按(K大类+诊断名)判更稳,且共享层
将来对 FRIDAY 同样生效(今天 FRIDAY 无残根诊断,影响面为零)。
源码不丢 —— transaction.rawPayload 原样留着 stdCode。

影响面(生产实测):932 条诊断事实 / 776 位患者 / 149 条在池召回理由,
全部从 implant(种植治疗)列挪到 extraction(拔牙治疗)列,其中 6 条已分配、
会走 staleRows 收尾补 auto_release。上线需 reparse --subject-type=diagnosis。

测试:新增 22 条,全量 1465 条通过;type-check 17 个错为 main 既有,本次引入 0 个。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent 57528b58
/**
* reconcileDiagnosisCode — 宿主 std_code 错标 / 截码失真的纠偏规则。
*
* 两条规则各有一个**反向闸**,测试重点全在闸上 —— 闸失效不会报错,
* 只会让一批患者悄悄换场景(K08 缺牙 base 60 ↔ K03 牙体损伤 base 35),
* 表现为矩阵某一列莫名多/少几百格。
*
* 跑:
* pnpm test -- reconcile-diagnosis-code
*/
import { reconcileDiagnosisCode } from '@pac/types';
describe('reconcileDiagnosisCode', () => {
describe('K00 → K08(后天缺失被错标成发育障碍)', () => {
it.each(['牙齿缺少', '牙缺失', '缺牙', '后天性牙齿缺失'])('%s → K08', (name) => {
expect(reconcileDiagnosisCode('K00', name)).toBe('K08');
});
it('带「先天」→ 保持 K00(真发育障碍)', () => {
expect(reconcileDiagnosisCode('K00', '先天性牙齿缺失')).toBe('K00');
expect(reconcileDiagnosisCode('K00', '先天缺牙')).toBe('K00');
});
it('其它 K00 不动', () => {
expect(reconcileDiagnosisCode('K00', '乳牙滞留')).toBe('K00');
expect(reconcileDiagnosisCode('K00', '多生牙')).toBe('K00');
});
});
describe('K08 → K03(K08.3 牙根残留被截成 K08 缺牙)', () => {
// 生产实测:名字恰为这几个的 932 条应翻 K03
it.each(['残根', '残冠', '残根残冠', '残根/残冠'])('%s → K03', (name) => {
expect(reconcileDiagnosisCode('K08', name)).toBe('K03');
});
it('无法保留 / 不能保留 → K03', () => {
expect(reconcileDiagnosisCode('K08', '无法保留')).toBe('K03');
expect(reconcileDiagnosisCode('K08', '患牙不能保留')).toBe('K03');
});
// ⛔ 反向闸:复合诊断(生产 325 条)两个病挤在一个 name,翻 K03 会丢掉缺牙那一半
it.each([
'牙列缺损,残根',
'缺失,残根',
'残根,牙列缺损',
'牙缺失;#44残根',
'牙缺失, 残根残冠',
'16,12,26缺失,17,11,21,22,23,25,27残根',
])('复合诊断「%s」→ 保持 K08', (name) => {
expect(reconcileDiagnosisCode('K08', name)).toBe('K08');
});
it('纯缺牙类 K08 不动', () => {
expect(reconcileDiagnosisCode('K08', '后天性牙齿缺失')).toBe('K08');
expect(reconcileDiagnosisCode('K08', '牙列缺损')).toBe('K08');
expect(reconcileDiagnosisCode('K08', '牙齿缺少')).toBe('K08');
});
it('残根本来就是 K03 的不受影响(医生没填 std_code 的多数派)', () => {
expect(reconcileDiagnosisCode('K03', '残根')).toBe('K03');
expect(reconcileDiagnosisCode('K03', '残冠')).toBe('K03');
});
});
describe('边界', () => {
it('code 为 null → null', () => {
expect(reconcileDiagnosisCode(null, '残根')).toBeNull();
});
it('name 为 null → 原样返回,不误翻', () => {
expect(reconcileDiagnosisCode('K08', null)).toBe('K08');
expect(reconcileDiagnosisCode('K00', null)).toBe('K00');
});
it('无关码不动', () => {
expect(reconcileDiagnosisCode('K05', '慢性牙周炎')).toBe('K05');
expect(reconcileDiagnosisCode('K02', '龋病')).toBe('K02');
});
});
});
......@@ -303,10 +303,25 @@ export const EXTERNAL_TREATMENT_VISIT_NEG_RE =
/// "牙齿缺少/缺失/缺牙"也错标成 K00(全库 1044 例)→ 冒出"萌出异常"误召(关平 BJ0U007377 牙25:
/// 残根拔除后缺失,宿主标 K00 → 误召萌出处置)。区分符=「先天」:有先天 → 真 K00(发育障碍),
/// 无先天的缺牙义 → 后天缺失 = K08。萌出/多生牙等其它 K00 不动。
///
/// 残根/残冠纠偏(2026-08 王丽芳 TS0M013040 牙42):源 ICD 用 **K08.3 牙根残留**(医学上没标错),
/// 但 std_code 截前 3 位后跟 K08.1 后天缺失挤进同一个 K08 → 目标文案变成"邀约启动缺失牙修复
/// (种植/桥/义齿)",而临床路径是**拔除**或根管+桩核冠。K03 的 categories 含 surgical、
/// 目标文案本就写着"残根/残冠 → 充填/嵌体/拔除修复",且 POTENTIAL_LABEL_RULES 的
/// `K03 + EXTRACTION_NAME_KEYWORDS → extraction(拔牙治疗)` 正等着这批名字 —— 归 K03 才对得上。
/// 口径一致性:医生**没填** std_code 时按中文名映射本来就落 K03(全库 97,970 条);
/// 填了 K08.3xx 的这 932 条反而归错,本规则把少数派拉回多数派。
/// ⛔ 区分符=「缺失/缺牙/缺损」:复合诊断("牙列缺损,残根"/"牙缺失;#44残根",全库 325 条)
/// 两个病挤在一个 name 里,翻 K03 会丢掉缺牙那一半 → 保持 K08(基线分更高,且 K08 的已解决
/// 判定是结构家族宽集、含拔除,患者去拔了照样解除)。那是源头把多诊断挤成一行的问题,不在此修。
/// 源码不丢:transaction.rawPayload 原样留着 stdCode(K08.300x002 / K08.302 / K08.300),随时可倒查。
export function reconcileDiagnosisCode(code: string | null, nameZh: string | null): string | null {
if (code === 'K00' && nameZh && /缺(牙|失|少)/.test(nameZh) && !/先天/.test(nameZh)) {
return 'K08';
}
if (code === 'K08' && nameZh && /残根|残冠|无法保留|不能保留/.test(nameZh) && !/缺失|缺牙|缺损/.test(nameZh)) {
return 'K03';
}
return code;
}
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment