Commit d005735f by luoqi

fix(recall): 引擎两缺口 — 0命中关闭遗留plan + wholeMouth全口病牙位忽略

修"正畸/牙周误召"(40393fbe)后暴露的两个独立引擎缺口。

缺口1(主):0 命中时不关闭遗留 active plan
  recomputeForPatient 在 hits.length===0 时直接 return,runAllForHost
  只遍历 hitsByPatient(有命中的患者)→ 患者治完疗变 0 命中后,旧召回
  plan 永远残留召回池(status 一直 active,该结案却不结)。
  修复:
    - recomputeForPatient 0 命中 → closeStaleActivePlan supersede 关闭
      该患者 active(非 assigned)plan。
    - runAllForHost upsert 循环后,扫该 (host,tenant) 全部 status='active'
      plan,凡不在本轮 hitsByPatient 的 supersede 关闭。
    - 纪律:按 tenant 限定;assigned 不动(客服跟进中);终态不动;
      不建后继版本(无信号是终结,非版本演进);循环后扫避免误关本轮新 active。
    - 新增 plansClosed 计数(EngineRunResult + recomputeForPatient 返回 + CLI 日志)。

缺口2(次):scenario 排除/聚类不消费 wholeMouth flag
  K05 标了 wholeMouth(牙周全口病)但 ⑤a 排除 SQL / tooth-overlap 聚类
  都没消费 → K05 诊断带具体牙位(如@38)、牙周治疗在别的牙(@36)时被当
  单牙不重叠 → 误召 perio_no_srp@38。
  修复:wholeMouth 码把"信号牙位"统一替换成 NULL(sigToothExpr)→
    - ⑤a 第一分支"信号无牙位"恒真 → category 级排除
    - ⑤c/⑤e"信号有牙位"守卫恒假 → 单颗拔除/冠桥不误终结全口病
    - 主 SELECT tooth=null → 聚类归 'whole' cluster(sub_key=...@whole)
  K07 经临床确认正畸=全牙弓矫治,同加 wholeMouth(K07 +
  ORTHO_CONSULT_RECOMMENDED),消除残留 3 例 ortho 误召。

测试:plan-engine-stale-close.spec.ts 覆盖
  - 有命中→0命中→plan 被关闭(recomputeForPatient 4 例 + runAllForHost 3 例)
  - wholeMouth flag 标注防漂移(K05/K07/SRP/ORTHO 标,K02/K04/K08 不标)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent 40393fbe
......@@ -49,17 +49,18 @@ async function bootstrap() {
select: { id: true, tenantId: true, externalId: true },
});
logger.log(`▶ 定向重算 ${patients.length} patients(host=${args.host})...`);
let created = 0, failed = 0;
let created = 0, closed = 0, failed = 0;
for (const p of patients) {
try {
const r = await engine.recomputeForPatient({ hostId: host.id, tenantId: p.tenantId, patientId: p.id });
created += r.plansCreated;
closed += r.plansClosed;
} catch (err) {
failed++;
logger.error(` ${p.externalId} FAILED: ${err instanceof Error ? err.message : err}`);
}
}
logger.log(`Done(定向):patients=${patients.length} plansCreated=${created} failed=${failed}`);
logger.log(`Done(定向):patients=${patients.length} plansCreated=${created} plansClosed=${closed} failed=${failed}`);
return;
}
......@@ -86,6 +87,7 @@ async function bootstrap() {
logger.log(` plansUnchanged: ${r.plansUnchanged}`);
logger.log(` plansSkippedAssigned: ${r.plansSkippedAssigned}`);
logger.log(` plansSuppressed: ${r.plansSuppressed}`);
logger.log(` plansClosed: ${r.plansClosed}`);
logger.log(` duration: ${r.durationMs}ms`);
logger.log(`──────────────────────────────────────────────────────`);
}
......
......@@ -43,7 +43,7 @@ export class PlanEngineService {
tenantId: string;
clinicId?: string;
patientId: string;
}): Promise<{ plansCreated: number }> {
}): Promise<{ plansCreated: number; plansClosed: number }> {
// 真·单 patient:scenario SQL 带 patientId 收窄(只扫该患者),只 upsert 这一个 plan。
// (旧版走 runAllForHost 全租户扫 + 写全员 plan,详情页单刷被拖到分钟级 → 现 O(1))
const scope: ScenarioScope = {
......@@ -57,8 +57,15 @@ export class PlanEngineService {
const scHits = await sc.selectHits(scope);
for (const h of scHits) hits.push({ ...h, scenarioKey: sc.key });
}
// 无命中 → 不动(与全量行为一致:不主动关闭旧 plan,stale 清理是独立议题)
if (hits.length === 0) return { plansCreated: 0 };
// ⭐ 缺口1 修复:本轮 0 命中 = 该患者已无任何活信号(治完疗/诊断消失/被排除闸过滤)。
// 旧版直接 return → 患者遗留的 active plan 永远残留在召回池(治完疗该结案却一直 active)。
// 现在主动 supersede 关闭它那条 active(非 assigned)plan,让它退出召回池。
// - assigned 不动(客服正在跟进,关掉会打断)
// - superseded/completed/abandoned 已是终态,无需处理
if (hits.length === 0) {
const closed = await this.closeStaleActivePlan(scope, input.patientId);
return { plansCreated: 0, plansClosed: closed ? 1 : 0 };
}
const log = await this.prisma.planGenerationLog.create({
data: {
......@@ -77,7 +84,8 @@ export class PlanEngineService {
where: { id: log.id },
data: { status: 'success', plansCreated: created, endedAt: new Date() },
});
return { plansCreated: created };
// upsert 路径自身会 supersede 旧版本(版本流),不算"关闭退池";plansClosed 只统计 0 命中关闭。
return { plansCreated: created, plansClosed: 0 };
} catch (err) {
await this.prisma.planGenerationLog.update({
where: { id: log.id },
......@@ -92,6 +100,33 @@ export class PlanEngineService {
}
/**
* ⭐ 缺口1 helper:关闭该患者遗留的 active(非 assigned)plan(本轮 0 命中 → 退出召回池)。
*
* 只关 status='active' 的最新一条:
* - assigned 不动(客服跟进中,关掉会打断)
* - superseded/completed/abandoned 已是终态
* 关闭 = supersede(status='superseded' + supersededAt=now),不创建后继版本
* —— "无信号了"是终结,不是版本演进,故无新版本(版本流纪律:只有"换 reason set"才升版本)。
* 返回是否真的关了一条(给调用方计数)。
*/
private async closeStaleActivePlan(
scope: ScenarioScope,
patientId: string,
): Promise<boolean> {
const latest = await this.prisma.followupPlan.findFirst({
where: { hostId: scope.hostId, tenantId: scope.tenantId, patientId },
orderBy: { version: 'desc' },
select: { id: true, status: true },
});
if (!latest || latest.status !== 'active') return false;
await this.prisma.followupPlan.update({
where: { id: latest.id },
data: { status: 'superseded', supersededAt: scope.now },
});
return true;
}
/**
* 批量跑某 host 全 patient 的召回算法,产 plans。
*/
async runAllForHost(input: {
......@@ -123,6 +158,7 @@ export class PlanEngineService {
let plansUnchanged = 0;
let plansSkippedAssigned = 0;
let plansSuppressed = 0;
let plansClosed = 0;
// 2. 逐 patient 写 plan(每个 patient 一条 PlanGenerationLog,跟 schema 设计一致)
for (const [patientId, hits] of hitsByPatient.entries()) {
......@@ -167,6 +203,31 @@ export class PlanEngineService {
}
}
// 3. ⭐ 缺口1 修复:关闭"有 active plan 但本轮 0 命中"的患者的遗留 plan。
// 旧版只遍历 hitsByPatient(有命中的患者)→ 治完疗变 0 命中的患者,其 active plan 永远残留召回池。
// 现在:跑完 upsert(已产生本轮新 active)后,扫该 (host, tenant) 全部 status='active' plan,
// 凡 patientId 不在本轮 hitsByPatient 的,supersede 关闭(退出召回池)。
// 纪律:
// - 按 tenant 限定(隔离闸)
// - 只关 status='active';assigned 不动(客服跟进中),终态不动
// - 必须在 upsert 循环之后扫:这样本轮 created/superseded 出的新 active(其患者在 hitsByPatient)
// 会被 hitsByPatient.has 命中而跳过,不会误关
// - 不创建后继版本(无信号是终结,非版本演进)
const activePlans = await this.prisma.followupPlan.findMany({
where: { hostId: scope.hostId, tenantId: scope.tenantId, status: 'active' },
select: { id: true, patientId: true },
});
const staleIds = activePlans
.filter((pl) => !hitsByPatient.has(pl.patientId))
.map((pl) => pl.id);
if (staleIds.length > 0) {
const res = await this.prisma.followupPlan.updateMany({
where: { id: { in: staleIds } },
data: { status: 'superseded', supersededAt: now },
});
plansClosed = res.count;
}
return {
scenariosRun: this.scenarios.length,
patientsHit: hitsByPatient.size,
......@@ -175,6 +236,7 @@ export class PlanEngineService {
plansUnchanged,
plansSkippedAssigned,
plansSuppressed,
plansClosed,
durationMs: Date.now() - startedAt.getTime(),
};
}
......@@ -361,5 +423,6 @@ export interface EngineRunResult {
plansUnchanged: number;
plansSkippedAssigned: number;
plansSuppressed: number;
plansClosed: number;
durationMs: number;
}
......@@ -246,6 +246,18 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
? Prisma.empty
: Prisma.sql`AND tx.occurred_at >= COALESCE(sig.occurred_at, sig.planned_for)`;
// ⭐ 缺口2 修复:wholeMouth 码(全口/全牙弓病:牙周 K05 / 正畸 K07)忽略 dx 自带牙位。
// 把"信号的牙位"统一替换成 NULL → 下游一律按"无牙位"处理:
// - 主 SELECT:tooth=null → 聚类归 'whole' cluster(sub_key=...@whole),展示不带单颗牙
// - ⑤a:第一分支"信号无牙位"恒真 → category 级排除(只要做过同类治疗即排,不比牙位)
// - ⑤c / ⑤e:"信号有牙位"守卫恒假 → 不按单牙误排(单颗拔除/单颗冠桥不终结全口病)
// 原因:全口病诊断偶带某颗牙(录入习惯),治疗可能在别的牙,单牙重叠判断会误召
// (709686:K05@38 牙周治疗在 36 → 单牙不重叠 → 误召 perio_no_srp@38)。
// 非 wholeMouth 码(K02 龋/K04 根管/K08 缺牙等)保持按牙位精确匹配,不受影响。
const sigToothExpr = rule.wholeMouth
? Prisma.sql`NULL::text`
: Prisma.sql`sig.content->>'tooth_position'`;
// ╔═════════════════════════════════════════════════════════════════════╗
// ║ 召回 SQL 完整解读(initiation = 潜在治疗新链召回) ║
// ║ ║
......@@ -314,7 +326,7 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
sig.id AS signal_fact_id,
sig.type AS signal_type,
sig.content->>'code' AS signal_code,
sig.content->>'tooth_position' AS tooth,
${sigToothExpr} AS tooth, -- ⭐ wholeMouth 码忽略 dx 牙位(见上 sigToothExpr)
sig.content->>'extracted_by' AS extracted_by,
sig.content->>'confidence' AS confidence,
sig.clinic_id AS clinic_id,
......@@ -348,7 +360,8 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
-- W4 末升级:牙位级 overlap(详见上面 ⑤a 注释 box)
AND (
-- 信号无牙位(全口诊断如 K05)→ patient/category 级排除,跟现状一致
COALESCE(NULLIF(trim(sig.content->>'tooth_position'), ''), '') = ''
-- ⭐ wholeMouth 码 sigToothExpr=NULL → 此条恒真 → 走 category 级排除(忽略 dx 自带牙位)
COALESCE(NULLIF(trim(${sigToothExpr}), ''), '') = ''
-- actual 无牙位 → 仅 periodontic/orthodontic(整牙弓治疗,无牙位是常态:实测
-- 牙周 134:1、正畸 19:1 压倒性无牙位)才视为"全口覆盖"→ 排除该 category 所有信号。
-- surgical/restorative/prosthodontic 等 per-tooth 类目(实测压倒性有牙位)的无牙位 actual
......@@ -366,7 +379,7 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
-- 4. && PG array overlap 操作符,任一元素相同即 true
-- ⚠️ 必须 array_remove '' — 否则两个 array 都含空字符串时 '' = '' 误返 true
OR array_remove(
string_to_array(regexp_replace(sig.content->>'tooth_position', '[^0-9;]+', ';', 'g'), ';'),
string_to_array(regexp_replace(${sigToothExpr}, '[^0-9;]+', ';', 'g'), ';'),
''
) && array_remove(
string_to_array(regexp_replace(tx.content->>'tooth_position', '[^0-9;]+', ';', 'g'), ';'),
......@@ -385,9 +398,10 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
AND surg.kind = 'actual'
AND surg.status IN ('active', 'fulfilled')
AND surg.content->>'category' = 'surgical'
AND COALESCE(NULLIF(trim(sig.content->>'tooth_position'), ''), '') != '' -- 信号必须有牙位
-- ⭐ wholeMouth 码 sigToothExpr=NULL → 此守卫恒假 → ⑤c 不生效(单颗拔除不终结全口病)
AND COALESCE(NULLIF(trim(${sigToothExpr}), ''), '') != '' -- 信号必须有牙位
AND array_remove(
string_to_array(regexp_replace(sig.content->>'tooth_position', '[^0-9;]+', ';', 'g'), ';'),
string_to_array(regexp_replace(${sigToothExpr}, '[^0-9;]+', ';', 'g'), ';'),
''
) && array_remove(
string_to_array(regexp_replace(surg.content->>'tooth_position', '[^0-9;]+', ';', 'g'), ';'),
......@@ -434,10 +448,11 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
AND alt.status IN ('active', 'fulfilled')
AND alt.content->>'category' IN ('implant', 'prosthodontic')
AND alt.occurred_at >= COALESCE(sig.occurred_at, sig.planned_for) -- 诊断之后
AND COALESCE(NULLIF(trim(sig.content->>'tooth_position'), ''), '') != '' -- 信号有牙位
-- ⭐ wholeMouth 码 sigToothExpr=NULL → 此守卫恒假 → ⑤e 不生效(单颗冠桥不终结全口病)
AND COALESCE(NULLIF(trim(${sigToothExpr}), ''), '') != '' -- 信号有牙位
AND COALESCE(NULLIF(trim(alt.content->>'tooth_position'), ''), '') != '' -- actual 有牙位
AND array_remove(
string_to_array(regexp_replace(sig.content->>'tooth_position', '[^0-9;]+', ';', 'g'), ';'),
string_to_array(regexp_replace(${sigToothExpr}, '[^0-9;]+', ';', 'g'), ';'),
''
) && array_remove(
string_to_array(regexp_replace(alt.content->>'tooth_position', '[^0-9;]+', ';', 'g'), ';'),
......
import { lookupDxTreatment } from '@pac/types';
import { PlanEngineService } from '../src/modules/plan/engine/plan-engine.service';
import type { ScenarioHit } from '../src/modules/plan/engine/scenario.interface';
/**
* 缺口1 回归:重算后变成 0 命中的患者,其遗留 active(非 assigned)plan 应被 supersede 关闭(退出召回池)。
* 缺口2 回归:wholeMouth flag(全口/全牙弓病)在 canonical-codes 正确标注 —— SQL 行为靠服务器实测覆盖。
*
* 背景:之前修"正畸/牙周误召"暴露引擎两个独立缺口:
* 缺口1 — 0 命中时不关旧 plan,治完疗该结案的召回 plan 永远残留 active。
* 缺口2 — scenario 排除/聚类不消费 wholeMouth,K05/K07 诊断带牙位时被当单牙误召。
*/
const HOST = 'host-1';
const TENANT = 'tenant-1';
/// 构造一个最小可用的 PrismaService mock(只 mock 引擎用到的方法)。
function makePrismaMock(opts: {
/// closeStaleActivePlan / upsertPlan 的 latest plan 查询返回值
latestPlan?: { id: string; status: string } | null;
/// runAllForHost 关闭闸:status='active' 的全量 plan
activePlans?: Array<{ id: string; patientId: string }>;
}) {
const update = jest.fn().mockResolvedValue({});
const updateMany = jest.fn().mockImplementation(async ({ where }) => ({
count: Array.isArray(where?.id?.in) ? where.id.in.length : 0,
}));
const create = jest.fn().mockResolvedValue({ id: 'new-plan' });
const findFirst = jest.fn().mockResolvedValue(opts.latestPlan ?? null);
const findMany = jest.fn().mockImplementation(async ({ where }) => {
// runAllForHost 关闭闸:status='active'
if (where?.status === 'active') return opts.activePlans ?? [];
// fetchSnoozedSignalKeys:status IN ('completed','abandoned')(冷静期)
return [];
});
const prisma = {
followupPlan: { findFirst, findMany, update, updateMany, create },
persona: { findFirst: jest.fn().mockResolvedValue(null) },
planGenerationLog: {
create: jest.fn().mockResolvedValue({ id: 'log-1' }),
update: jest.fn().mockResolvedValue({}),
},
$transaction: jest
.fn()
.mockImplementation(async (cb: (tx: unknown) => Promise<unknown>) =>
cb({
followupPlan: { update: jest.fn().mockResolvedValue({}), create: jest.fn().mockResolvedValue({}) },
}),
),
};
return { prisma, spies: { update, updateMany, create, findFirst, findMany } };
}
function makeScenario(hits: ScenarioHit[]) {
return {
key: 'treatment_initiation_recall',
selectHits: jest.fn().mockResolvedValue(hits),
};
}
function hit(patientId: string, subKey: string): ScenarioHit {
return {
patientId,
patientExternalId: `ext-${patientId}`,
reason: 'r',
priorityScore: 50,
subKey,
evidence: { factIds: ['f1'] },
};
}
function makeEngine(prisma: unknown, scenario: unknown) {
return new PlanEngineService(
prisma as never,
scenario as never,
);
}
describe('缺口1 — recomputeForPatient 0 命中关闭遗留 active plan', () => {
test('0 命中 + 存在 active plan → supersede 关闭(plansClosed=1)', async () => {
const { prisma, spies } = makePrismaMock({ latestPlan: { id: 'plan-a', status: 'active' } });
const engine = makeEngine(prisma, makeScenario([]));
const res = await engine.recomputeForPatient({ hostId: HOST, tenantId: TENANT, patientId: 'pat-a' });
expect(res).toEqual({ plansCreated: 0, plansClosed: 1 });
expect(spies.update).toHaveBeenCalledTimes(1);
expect(spies.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: 'plan-a' },
data: expect.objectContaining({ status: 'superseded' }),
}),
);
// 0 命中走 early return,不应建 PlanGenerationLog(无 upsert)
expect(prisma.planGenerationLog.create).not.toHaveBeenCalled();
});
test('0 命中 + assigned plan → 不动(客服跟进中,plansClosed=0)', async () => {
const { prisma, spies } = makePrismaMock({ latestPlan: { id: 'plan-a', status: 'assigned' } });
const engine = makeEngine(prisma, makeScenario([]));
const res = await engine.recomputeForPatient({ hostId: HOST, tenantId: TENANT, patientId: 'pat-a' });
expect(res).toEqual({ plansCreated: 0, plansClosed: 0 });
expect(spies.update).not.toHaveBeenCalled();
});
test('0 命中 + 无任何 plan → 无操作(plansClosed=0)', async () => {
const { prisma, spies } = makePrismaMock({ latestPlan: null });
const engine = makeEngine(prisma, makeScenario([]));
const res = await engine.recomputeForPatient({ hostId: HOST, tenantId: TENANT, patientId: 'pat-a' });
expect(res).toEqual({ plansCreated: 0, plansClosed: 0 });
expect(spies.update).not.toHaveBeenCalled();
});
test('0 命中 + 终态 plan(completed)→ 不动(已是终态,plansClosed=0)', async () => {
const { prisma, spies } = makePrismaMock({ latestPlan: { id: 'plan-a', status: 'completed' } });
const engine = makeEngine(prisma, makeScenario([]));
const res = await engine.recomputeForPatient({ hostId: HOST, tenantId: TENANT, patientId: 'pat-a' });
expect(res).toEqual({ plansCreated: 0, plansClosed: 0 });
expect(spies.update).not.toHaveBeenCalled();
});
});
describe('缺口1 — runAllForHost 关闭"有 active plan 但本轮 0 命中"的患者', () => {
test('A 有命中(保留)+ B 无命中(关闭)→ plansClosed=1,只关 B', async () => {
// A 本轮命中;B 没命中但有遗留 active plan
const { prisma, spies } = makePrismaMock({
latestPlan: null, // upsertPlan(A):无既有 → 走 create
activePlans: [
{ id: 'plan-a2', patientId: 'pat-a' }, // A 本轮新产生的 active(在 hitsByPatient → 跳过)
{ id: 'plan-b', patientId: 'pat-b' }, // B 遗留 active(不在 hitsByPatient → 关闭)
],
});
const engine = makeEngine(prisma, makeScenario([hit('pat-a', 'missing_tooth@whole')]));
const res = await engine.runAllForHost({ hostId: HOST, tenantId: TENANT, now: new Date('2026-06-02T00:00:00Z') });
expect(res.plansCreated).toBe(1);
expect(res.plansClosed).toBe(1);
// 只关 B,不关 A 的新 active
expect(spies.updateMany).toHaveBeenCalledTimes(1);
expect(spies.updateMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: { in: ['plan-b'] } },
data: expect.objectContaining({ status: 'superseded' }),
}),
);
});
test('全部患者 0 命中 → 所有遗留 active plan 关闭', async () => {
const { prisma, spies } = makePrismaMock({
activePlans: [
{ id: 'plan-x', patientId: 'pat-x' },
{ id: 'plan-y', patientId: 'pat-y' },
],
});
const engine = makeEngine(prisma, makeScenario([]));
const res = await engine.runAllForHost({ hostId: HOST, tenantId: TENANT, now: new Date('2026-06-02T00:00:00Z') });
expect(res.patientsHit).toBe(0);
expect(res.plansClosed).toBe(2);
expect(spies.updateMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { id: { in: ['plan-x', 'plan-y'] } } }),
);
});
test('无遗留 active plan → 不调 updateMany(plansClosed=0)', async () => {
const { prisma, spies } = makePrismaMock({ activePlans: [] });
const engine = makeEngine(prisma, makeScenario([]));
const res = await engine.runAllForHost({ hostId: HOST, tenantId: TENANT, now: new Date('2026-06-02T00:00:00Z') });
expect(res.plansClosed).toBe(0);
expect(spies.updateMany).not.toHaveBeenCalled();
});
});
describe('缺口2 — wholeMouth flag 标注(单一真理源,防漂移)', () => {
test('全口/全牙弓病码(K05 牙周 / K07 正畸)标 wholeMouth', () => {
expect(lookupDxTreatment('K05')?.wholeMouth).toBe(true);
expect(lookupDxTreatment('K07')?.wholeMouth).toBe(true);
});
test('对应推荐码同标 wholeMouth(SRP / 正畸咨询)', () => {
expect(lookupDxTreatment('SRP_RECOMMENDED')?.wholeMouth).toBe(true);
expect(lookupDxTreatment('ORTHO_CONSULT_RECOMMENDED')?.wholeMouth).toBe(true);
});
test('按牙位精确匹配的码不标 wholeMouth(K02 龋 / K04 根管 / K08 缺牙)', () => {
expect(lookupDxTreatment('K02')?.wholeMouth).toBeUndefined();
expect(lookupDxTreatment('K04')?.wholeMouth).toBeUndefined();
expect(lookupDxTreatment('K08')?.wholeMouth).toBeUndefined();
});
});
......@@ -171,6 +171,14 @@ export interface DxTreatmentRule {
windowDays: number;
urgencyDayThreshold: number;
chainLabel: string;
/// 全口/全牙弓性疾病(牙周 K05、正畸 K07):**召回侧忽略 dx 自带牙位**。
/// 消费方:
/// - chain-composer:空牙位时显示"全口"(否则"未标注牙位")
/// - 召回 scenario(treatment-initiation-recall):排除闸 ⑤a 把信号强制视为无牙位 → category 级
/// 排除;tooth-overlap 聚类归 'whole' cluster(sub_key=...@whole)。忽略 dx 偶带的单颗牙。
/// 原因:全口病的诊断偶带某颗牙(录入习惯),但治疗可能在别的牙,单牙重叠判断会误召
/// (709686:K05@38 牙周治疗在 36 → 单牙不重叠 → 误召 perio_no_srp@38)。
/// 一般跟 excludeIfEverTreated 同时设(全口长疗程病:忽略牙位 + 忽略时间方向)。
wholeMouth?: boolean;
/// 治疗"一次性发起、非按牙位"(全口长疗程/慢性,如正畸 K07、牙周 K05):
/// 召回排除闸 ⑤a **忽略时间方向** —— 只要曾做过同类治疗即排除(不要求治疗晚于诊断)。
......@@ -194,7 +202,10 @@ export const DiagnosisTreatmentMap = {
// 应该被召回去促进真 SRP,而不是被算"已启动"放过。chain-composer 显示 S3 未进行也支持这判定
K05: { categories: ['periodontic'], cooldownDays: 30, windowDays: 120, urgencyDayThreshold: 90, chainLabel: '牙周治疗', wholeMouth: true, excludeIfEverTreated: true },
K06: { categories: ['periodontic', 'surgical'], cooldownDays: 14, windowDays: 120, urgencyDayThreshold: 60, chainLabel: '牙龈/牙槽嵴处置' },
K07: { categories: ['orthodontic'], cooldownDays: 30, windowDays: 365, urgencyDayThreshold: 180, chainLabel: '正畸矫治', excludeIfEverTreated: true },
// K07 wholeMouth:true — 正畸是全牙弓矫治,诊断 fact 偶带某颗牙(录入习惯)无临床意义。
// 召回排除 ⑤a / 聚类按"全口/全牙弓"处理,忽略 dx 自带牙位(否则 dx@38 + 矫治记录@36 单牙不重叠 → 误召)。
// 配合 excludeIfEverTreated(忽略时间方向):曾做过正畸即排除。两 flag 同治"全口长疗程"病。
K07: { categories: ['orthodontic'], cooldownDays: 30, windowDays: 365, urgencyDayThreshold: 180, chainLabel: '正畸矫治', wholeMouth: true, excludeIfEverTreated: true },
K08: { categories: ['implant', 'prosthodontic'], cooldownDays: 30, windowDays: 180, urgencyDayThreshold: 120, chainLabel: '种植修复' },
K09: { categories: ['surgical'], cooldownDays: 14, windowDays: 90, urgencyDayThreshold: 60, chainLabel: '颌骨囊肿摘除' },
// 推荐码 — 同临床类目继承 K0x 配置
......@@ -203,7 +214,7 @@ export const DiagnosisTreatmentMap = {
FILLING_RECOMMENDED: { categories: ['restorative'], cooldownDays: 14, windowDays: 60, urgencyDayThreshold: 45, chainLabel: '龋齿充填' },
SRP_RECOMMENDED: { categories: ['periodontic'], cooldownDays: 30, windowDays: 120, urgencyDayThreshold: 90, chainLabel: '牙周治疗', wholeMouth: true },
EXTRACTION_RECOMMENDED: { categories: ['surgical'], cooldownDays: 7, windowDays: 30, urgencyDayThreshold: 14, chainLabel: '拔除' },
ORTHO_CONSULT_RECOMMENDED: { categories: ['orthodontic'], cooldownDays: 30, windowDays: 365, urgencyDayThreshold: 180, chainLabel: '正畸矫治' },
ORTHO_CONSULT_RECOMMENDED: { categories: ['orthodontic'], cooldownDays: 30, windowDays: 365, urgencyDayThreshold: 180, chainLabel: '正畸矫治', wholeMouth: true },
ANNUAL_REVIEW_RECOMMENDED: { categories: ['periodontic'], cooldownDays: 60, windowDays: 540, urgencyDayThreshold: 365, chainLabel: '年度复查' },
RCT_RECOMMENDED: { categories: ['endodontic'], cooldownDays: 14, windowDays: 60, urgencyDayThreshold: 45, chainLabel: '根管治疗' },
HARD_TISSUE_REPAIR_RECOMMENDED: { categories: ['restorative', 'prosthodontic', 'surgical'], cooldownDays: 14, windowDays: 90, urgencyDayThreshold: 60, chainLabel: '牙体修复' },
......
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