Commit cadbe1d6 by luoqi

feat: lifecycle linear_then_crown — 根管闭环额外要冠保护

临床背景:
  根管治疗后牙变脆(髓腔抽空 + 牙体含水量下降),不戴冠保护 2-3 年内
  ~30% 概率牙冠折裂(literature: Caplan & Weintraub 1997 等)
  所以"根管完成 + 牙体充填" 不算真闭环,必须 + "冠修复" 才算完整治疗

改动:
  1. canonical-codes.ts:
     - TreatmentLifecycle 加 requiresCrownProtection?: boolean
     - 新加 lifecycle = 'linear_then_crown' (maxStage=5, 需冠保护)
     - endodontic.lifecycle 'linear' → 'linear_then_crown'
  2. chain-composer:
     - 新加 hasCrownProtection(bucketTooth, byType, anchor) helper
       检查同牙位 prosthodontic actual,treat_stages 含 crown_restoration/post_core
       fallback subtype.includes('冠'/'桩核')
     - S5 闸口加 crownOk 条件:lifecycle.requiresCrownProtection 时必须满足
     - buildStageNodes 透 crownOk 给 S5 节点 → hint "待冠保护(...防牙冠折裂)"

验证(杨光宗 27 K04):
  改前:closed stage=5(根管+充填+复诊 触发 S5 → 误闭环)
  改后:ongoing stage=4 → S5 "未闭环 · 待冠保护(根管后建议戴冠,防牙冠折裂)"

副作用:其他做了根管但没戴冠的患者也会从 closed → ongoing
  → 客服可视化看到缺口,适合补做"根管后冠修复召回"scenario(future)

不影响 SQL recall(scenario 仍按 treatment_initiation_recall ⑤a actual overlap 排除,
K04 27 有 endodontic actual 仍被排除,不会重新进召回池;
真正"该补冠"的召回是另一个 scenario,后续加)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
parent a57a58a8
...@@ -256,18 +256,23 @@ function inferChainStage( ...@@ -256,18 +256,23 @@ function inferChainStage(
const s4Hits = s3AnchorTime ? collectS4Facts(category, byType, s3AnchorTime) : []; const s4Hits = s3AnchorTime ? collectS4Facts(category, byType, s3AnchorTime) : [];
const s4Earliest = earliest(s4Hits); const s4Earliest = earliest(s4Hits);
// ─ S5 信号 ─ closed 条件(W3 末调整 — 不再要求 allSatisfied) // ─ S5 信号 ─ closed 条件(W3 末调整 — 不再要求 allSatisfied;W4 末加 requiresCrownProtection)
// 1. S3 满足 minSteps(s3Reached) — 例:种植 minSteps=2 要植入+修复都做才可能 closed; // 1. S3 满足 minSteps(s3Reached) — 例:种植 minSteps=2 要植入+修复都做才可能 closed;
// 单步类(充填/拔除/洁牙)minSteps=1 做一次即可。**不再要求 milestone 全 steps 都做** // 单步类(充填/拔除/洁牙)minSteps=1 做一次即可。**不再要求 milestone 全 steps 都做**
// (preventive 字典 ['洁牙','涂氟','封闭'] 只做洁牙也算 closed,涂氟/封闭非必做)
// 2. S4 至少 1 命中(术后随访 / 复查 encounter / planned review / *_REVIEW_RECOMMENDED) // 2. S4 至少 1 命中(术后随访 / 复查 encounter / planned review / *_REVIEW_RECOMMENDED)
// 3. lifecycle.maxStage = 5(lifelong_maintenance 直接卡死 stage 4) // 3. lifecycle.maxStage = 5(lifelong_maintenance 直接卡死 stage 4)
// 4. 无 S3 之后的 refund 同 patient(简化:patient 级,跨 category 也判 — 后续接 host order_id 后细化) // 4. 无 S3 之后的 refund 同 patient(简化:patient 级,跨 category 也判)
// 5. 无 S3 之后同位置反弹诊断(同 code + 牙位 overlap > 0 视为反弹) // 5. 无 S3 之后同位置反弹诊断(同 code + 牙位 overlap > 0 视为反弹)
// 6. ⭐ W4 末:lifecycle.requiresCrownProtection → 同牙位有 prosthodontic actual(冠/桩核)
// 临床:根管后牙变脆 ~30% 折裂率,没戴冠不算真闭环
// 杨光宗 27 K04 根管完成但没冠 → 应该 ongoing 提示客服回访做冠
const crownOk = !lifecycle.requiresCrownProtection
|| hasCrownProtection(bucket.tooth, byType, s3AnchorTime);
const s5Eligible = const s5Eligible =
s3Reached && s3Reached &&
s4Hits.length > 0 && s4Hits.length > 0 &&
maxAllowedStage === 5 && maxAllowedStage === 5 &&
crownOk &&
!hasPostS3Refund(byType, s3AnchorTime) && !hasPostS3Refund(byType, s3AnchorTime) &&
!hasPostS3Relapse(category, byType, s3AnchorTime); !hasPostS3Relapse(category, byType, s3AnchorTime);
...@@ -360,6 +365,7 @@ function inferChainStage( ...@@ -360,6 +365,7 @@ function inferChainStage(
s4Hits, s4Hits,
byType, byType,
doctorMap, doctorMap,
crownOk,
}), }),
}; };
} }
...@@ -808,6 +814,35 @@ function hasPostS3Relapse( ...@@ -808,6 +814,35 @@ function hasPostS3Relapse(
}); });
} }
/// W4 末:同牙位是否有 prosthodontic actual(冠/桩核)— lifecycle.requiresCrownProtection 用
/// 杨光宗 27 K04 根管完成但没冠 → false → S5 不闭环 → ongoing 提示客服
/// 时间窗:any time(S3 之前还是之后都算,host 偶尔有"先做冠后补根管"反常顺序)
function hasCrownProtection(
bucketTooth: string,
byType: FactsByType,
_s3AnchorTime: Date | null,
): boolean {
if (!bucketTooth || bucketTooth === '*whole') return true; // 全口/无牙位的(实际很少 endo case)— 跳过这个 check
for (const tx of byType.treatment) {
if (tx.kind !== FactKind.ACTUAL) continue;
const c = tx.content as Record<string, unknown>;
if (String(c.category ?? '') !== 'prosthodontic') continue;
const stages = c.treat_stages;
const subtype = String(c.subtype ?? '');
// ① 优先 treat_stages:含 crown_restoration / post_core 算冠保护
const hasCrownStage = Array.isArray(stages)
&& (stages.includes('crown_restoration') || stages.includes('post_core'));
// ② fallback 词根:subtype 含"冠" 或 "桩核"(prosthodontic category 内的冠相关动作)
const hasCrownSubtype = !hasCrownStage && (subtype.includes('冠') || subtype.includes('桩核'));
if (!hasCrownStage && !hasCrownSubtype) continue;
// 牙位 overlap check(prosthodontic 冠通常带具体牙位)
const txTooth = String(c.tooth_position ?? '');
if (!txTooth) continue; // 没牙位的修复不算特定牙位的冠
if (toothOverlap(bucketTooth, txTooth)) return true;
}
return false;
}
// ───────────────────────────────────────────── // ─────────────────────────────────────────────
// timeline nodes 构造 // timeline nodes 构造
// ───────────────────────────────────────────── // ─────────────────────────────────────────────
...@@ -816,7 +851,7 @@ function buildStageNodes(opts: { ...@@ -816,7 +851,7 @@ function buildStageNodes(opts: {
currentStage: 1 | 2 | 3 | 4 | 5; currentStage: 1 | 2 | 3 | 4 | 5;
status: ChainStatus; status: ChainStatus;
category: string; category: string;
lifecycle: { maxStage: 4 | 5; noteZh: string; expectedSpanMonths?: number }; lifecycle: { maxStage: 4 | 5; noteZh: string; expectedSpanMonths?: number; requiresCrownProtection?: boolean };
milestone: TreatmentMilestone | undefined; milestone: TreatmentMilestone | undefined;
s1Earliest: ChainComposeInputFact | null; s1Earliest: ChainComposeInputFact | null;
s2Earliest: ChainComposeInputFact | null; s2Earliest: ChainComposeInputFact | null;
...@@ -828,6 +863,7 @@ function buildStageNodes(opts: { ...@@ -828,6 +863,7 @@ function buildStageNodes(opts: {
s4Hits: ChainComposeInputFact[]; s4Hits: ChainComposeInputFact[];
byType: FactsByType; byType: FactsByType;
doctorMap: Map<string, string>; doctorMap: Map<string, string>;
crownOk: boolean; // W4 末:lifecycle.requiresCrownProtection 满足与否(inferChainStage 算过)
}): ChainNode[] { }): ChainNode[] {
const { const {
currentStage, currentStage,
...@@ -845,6 +881,7 @@ function buildStageNodes(opts: { ...@@ -845,6 +881,7 @@ function buildStageNodes(opts: {
s4Hits, s4Hits,
byType, byType,
doctorMap, doctorMap,
crownOk,
} = opts; } = opts;
const reached = (s: number) => currentStage >= s; const reached = (s: number) => currentStage >= s;
const cur = (s: number) => currentStage === s; const cur = (s: number) => currentStage === s;
...@@ -1048,10 +1085,15 @@ function buildStageNodes(opts: { ...@@ -1048,10 +1085,15 @@ function buildStageNodes(opts: {
// 未闭环:提示还差什么 // 未闭环:提示还差什么
n.title = '未闭环'; n.title = '未闭环';
if (milestone && matchedSteps.matched.length < milestone.steps.length) { // W4 末:requiresCrownProtection 但无冠 — 显式提示客服(根管后建议戴冠)
if (lifecycle.requiresCrownProtection && s3Reached && !crownOk) {
n.hint = '待冠保护(根管后建议戴冠,防牙冠折裂)';
} else if (milestone && matchedSteps.matched.length < milestone.steps.length) {
const missing = milestone.steps.filter((s) => !matchedSteps.matched.includes(s)); const missing = milestone.steps.filter((s) => !matchedSteps.matched.includes(s));
if (missing.length > 0) { if (missing.length > 0) {
n.hint = `待 ${missing.map(shortLabel).join(' / ')}`; const stepLabelFn = (step: string): string =>
PACTreatmentStep[step as PACTreatmentStepKey] ?? step;
n.hint = `待 ${missing.map(stepLabelFn).map(shortLabel).join(' / ')}`;
} }
} else if (currentStage === 3) { } else if (currentStage === 3) {
n.hint = '待术后复查'; n.hint = '待术后复查';
......
...@@ -261,13 +261,19 @@ export interface TreatmentLifecycle { ...@@ -261,13 +261,19 @@ export interface TreatmentLifecycle {
expectedSpanMonths?: number; expectedSpanMonths?: number;
/// 给 UI 提示 + 业务方调字典时用 /// 给 UI 提示 + 业务方调字典时用
noteZh: string; noteZh: string;
/// W4 末:闭环额外要求同牙位有"冠保护"(prosthodontic actual:冠/桩核)
/// 临床:根管后牙变脆,2-3 年内不戴冠 ~30% 折裂率 — 没戴冠不算真闭环
/// chain-composer S5 闸口会读这个 flag 加判
requiresCrownProtection?: boolean;
} }
export const TreatmentLifecycles = { export const TreatmentLifecycles = {
/// 一次治疗 + 一次复查即可闭环(充填 / 修复 / 拔除 / 美容 / 儿牙) /// 一次治疗 + 一次复查即可闭环(充填 / 修复 / 拔除 / 美容 / 儿牙)
one_shot: { maxStage: 5, expectedSpanMonths: 3, noteZh: '一次性治疗' }, one_shot: { maxStage: 5, expectedSpanMonths: 3, noteZh: '一次性治疗' },
/// 多步治疗 + 复查即可闭环(种植 / 根管) /// 多步治疗 + 复查即可闭环(种植 — implant 自带 crown_placement step,不需要额外要求)
linear: { maxStage: 5, expectedSpanMonths: 6, noteZh: '多步线性治疗' }, linear: { maxStage: 5, expectedSpanMonths: 6, noteZh: '多步线性治疗' },
/// 多步治疗 + 必须冠保护(根管 — endodontic 完成后必须 prosthodontic 冠修复才算闭环)
linear_then_crown: { maxStage: 5, expectedSpanMonths: 6, noteZh: '多步治疗 + 冠保护', requiresCrownProtection: true },
/// 长周期治疗(正畸 2 年);S3 后 12+ 月平稳无新调整 → closed /// 长周期治疗(正畸 2 年);S3 后 12+ 月平稳无新调整 → closed
long_term: { maxStage: 5, expectedSpanMonths: 24, noteZh: '长周期治疗' }, long_term: { maxStage: 5, expectedSpanMonths: 24, noteZh: '长周期治疗' },
/// 周期性治疗(预防/年度洁牙),每年 1 次为常态 /// 周期性治疗(预防/年度洁牙),每年 1 次为常态
...@@ -342,7 +348,8 @@ export interface TreatmentMilestone { ...@@ -342,7 +348,8 @@ export interface TreatmentMilestone {
export const TreatmentMilestones = { export const TreatmentMilestones = {
implant: { steps: ['implant_placement', 'crown_placement'], minSteps: 2, lifecycle: 'linear' }, implant: { steps: ['implant_placement', 'crown_placement'], minSteps: 2, lifecycle: 'linear' },
// endodontic 3 步 + minSteps=2(开髓 + 根充至少);patient 真做了开髓+根备+根充才算完整 // endodontic 3 步 + minSteps=2(开髓 + 根充至少);patient 真做了开髓+根备+根充才算完整
endodontic: { steps: ['pulp_extirpation', 'canal_preparation', 'canal_filling'], minSteps: 2, lifecycle: 'linear' }, // lifecycle=linear_then_crown:闭环额外要求 prosthodontic 冠保护(临床:根管后不戴冠折裂率 ~30%)
endodontic: { steps: ['pulp_extirpation', 'canal_preparation', 'canal_filling'], minSteps: 2, lifecycle: 'linear_then_crown' },
orthodontic: { steps: ['bracket_placement', 'retainer'], minSteps: 1, lifecycle: 'long_term' }, orthodontic: { steps: ['bracket_placement', 'retainer'], minSteps: 1, lifecycle: 'long_term' },
periodontic: { steps: ['supragingival_scaling', 'subgingival_scaling', 'periodontal_maintenance'], minSteps: 1, lifecycle: 'lifelong_maintenance' }, periodontic: { steps: ['supragingival_scaling', 'subgingival_scaling', 'periodontal_maintenance'], minSteps: 1, lifecycle: 'lifelong_maintenance' },
restorative: { steps: ['composite_filling', 'inlay'], minSteps: 1, lifecycle: 'one_shot' }, restorative: { steps: ['composite_filling', 'inlay'], minSteps: 1, lifecycle: 'one_shot' },
......
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