Commit 5ed2e888 by luoqi

feat(recall): ⑤g 排除闸 —— 诊所已排未来回访则本轮不召

业务可用性测试 Issue「超百天自动加急覆盖回访计划」:
  [被测者]「超过百天他就会给你加急自动升了一个紧急,而且他设置过的回访的话他实际是不记得」

## 先纠两个事实

1. 阈值是 **90 天**不是 100 天(urgency_level:>90 紧急 / 30-90 高 / <30 中),
   而且它不只是标签 —— 急迫性 ×0.4 是综合分权重最大的一块。
2. 「覆盖了回访计划」不准确:PAC **从没看过** task_date,不是覆盖是没接。
   (回访表本身早就进召回了 —— gapWhere 里的外院已治疗闸读 rv.result;
    只是一直没用 task_date 这一列。)

## 口径:排到哪天挡到哪天

  AND NOT EXISTS (SELECT 1 FROM patient_return_visits rv
                  WHERE rv.patient_id = p.id AND rv.task_date > now()::date)

跟 ⑤b(未来预约)同一个道理,注释原话就够用:「召回目的 = 让客服建预约。
患者已经有未来预约 → 客服不需要再 push」。回访是同构的,只是接触形式换成电话/微信。

三个设计选择,都是被生产数据推翻假设之后定的:

· **不设窗口上限。** 原本想拍 N=60 天,查完发现理由不成立:在池患者的未来排程
  99.65% 在一年内(2026 年 25,105 / 2027 年 5,271 / 2028 年以后共 50 条,最远 2033-11-12)。
  固定窗口反而会在第 N+1 天把"诊所排了 N+30 天回访"的人放回池,制造重复触达。
  那 50 条脏数据不值得引一个新常量 —— 真要防该在摄入侧校 task_date 范围。

· **不为"排了不做"加兜底。** 全库到期任务执行率 74.3%(118.1 万 / 159.1 万),
  确实有 1/4 烂尾。但闸的条件是 task_date > today,任务日一过闸自动失效、患者当天回池 ——
  SQL 每轮重算就是自愈机制,不需要解除逻辑。

· **放 scenario 不放 gapWhere。** 这是本次唯一的架构判断:
    gapWhere(召回 + 画像共享)= 【临床事实】层 —— 已治疗/患者拒绝/外院已做 → 缺口真没了
    scenario WHERE(仅召回)   = 【运营时机】层 —— 缺口还在,只是现在不该打电话(⑤b/⑤f)
  未来回访显然是后者:排了个电话不代表那颗缺牙补上了。放进 gapWhere 会让画像因为
  "排了个回访"就说这人没有潜在治疗 —— 那才是口径分叉。

## 生产影响(上线前实测)

  在池 plan            236,508
  ⑤g 将挡下             30,426  (12.9%)
    其中 urgent         17,263  ← 正是一线抱怨的"明明排了回访还催我"
  未来排程到期分布      ≤30天 8,724 / 31-90天 8,502 / 91-180天 9,407 / >180天 3,793

被挡住的 plan 会被 supersede(0 命中 → closeStaleActivePlan),到期是新版本 —— 已确认
可接受:数据都有记录,跟踪得到;分配逻辑将来单独做。contactAttempts 归零暂不处理。

## 顺带修一处过期注释

plan-engine.service.ts:70 还写着「关闭 active(非 assigned)plan / assigned 不动」,
但代码从批量路径修复那次起就已含 assigned(:145 条件是 active OR assigned,
:261 注释也写明是有意改的)。注释改成与代码一致。

## 本地验证

- 477 单测通过(新增 8 例:闸形状 / 只认未到期 / 患者级 / 无窗口上限 /
  ⑤b⑤f 未被替换 / gapWhere 不含未来 task_date / gapWhere 仍保留外院闸 / 画像 SQL 不含本闸)
  测试用递归摊平 Prisma.Sql 的方式断言 —— 嵌套片段在 tagged template 里只是绑定参数,
  不摊开断言会假通过(第一版就踩了这个,gapWhere 内容根本没被检查到)。
- service / web tsc 干净
- 真库跑引擎:
    吕学文 1921711(有 2026-10-09 未来回访)→ plansClosed=1,plan 转 superseded 出池
    李石明 1873810(无未来回访)            → 不受影响,仍 active
    把 2026-10-09 改成昨天再跑 → plansCreated=1,v2 active 回池(自愈方向验证),随后还原

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
parent f634f274
......@@ -67,8 +67,10 @@ export class PlanEngineService {
}
// ⭐ 缺口1 修复:本轮 0 命中 = 该患者已无任何活信号(治完疗/诊断消失/被排除闸过滤)。
// 旧版直接 return → 患者遗留的 active plan 永远残留在召回池(治完疗该结案却一直 active)。
// 现在主动 supersede 关闭它那条 active(非 assigned)plan,让它退出召回池。
// - assigned 不动(客服正在跟进,关掉会打断)
// 现在主动 supersede 关闭它最新那条在池(active / assigned)plan,让它退出召回池。
// - assigned **一起关**(见 closeStaleActivePlan 与批量路径注释):信号都没了,
// 留在客服手上只会让他按已失效的理由去跟进。此前注释写"assigned 不动"是过期的,
// 代码从批量路径修复那次起就已含 assigned。
// - superseded/completed/abandoned 已是终态,无需处理
if (hits.length === 0) {
const closed = await this.closeStaleActivePlan(scope, input.patientId);
......
......@@ -321,6 +321,7 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
// ║ ┌─ ⑤a 排除闸:NOT EXISTS 同类 actual 治疗(牙位级 W4 末升级)─┐ ║
// ║ ┌─ ⑤c 排除:同牙位拔除(W4 末加,临床:拔了就终结)────────────┐ ║
// ║ ┌─ ⑤b 排除:患者已有未来预约 ────────────────────────────────┐ ║
// ║ ┌─ ⑤g 排除:诊所已排未来回访(2026-07 加)───────────────────┐ ║
// ║ │ tx.type = 'treatment_record' AND tx.kind = 'actual' │ ║
// ║ │ tx.status IN ('active','fulfilled') (完成的 actual 是 fulfilled)│ ║
// ║ │ tx.content->>'category' = ANY(resolverCats) │ ║
......@@ -395,7 +396,7 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
-- 数据验证 → 全口场景 resolvedTeeth(看治疗)已覆盖在治患者(K05 挡669/K07 挡73,⑤d 独占仅0~1
-- 且那1个无近期预约=stalled本就该召);牙位场景 ⑤d 按科目(非牙位)误排"同科目别牙在治、这颗没治"
-- (沈静芳 34;43 修复 被 25;26 修复预约连带排除)。⑤d 既冗余又有害 → 去掉,全口/牙位统一靠
-- resolvedTeeth(治疗) + ⑤b(未来预约) + ⑤f(近期到诊)。"已进入链"的细粒度留 W5+ 治疗链内召回。
-- resolvedTeeth(治疗) + ⑤b(未来预约) + ⑤f(近期到诊) + ⑤g(未来回访)。"已进入链"的细粒度留 W5+ 治疗链内召回。
-- (⑤e 同牙位替代治疗 种植/冠桥 已折进上面 resolved_teeth 的 implant/prosthodontic 分支 — 诊断后做了的从 remaining 减掉)
AND NOT EXISTS ( -- ⑤f 就诊冷静:近 N 天到过诊 → 别催刚来过的人(防打扰)
-- 患者级、与具体信号无关:最近一次到诊(encounter/emr)在 N 天内 → 本轮不召。
......@@ -407,6 +408,24 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
AND vis.occurred_at IS NOT NULL
AND vis.occurred_at > ${this.daysAgo(scope.now, POST_VISIT_COOLDOWN_DAYS)}::timestamptz
)
AND NOT EXISTS ( -- ⑤g 排除:诊所已排未来回访
-- 同 ⑤b 的道理:召回的产物就是"让客服去联系一次";诊所已经排好了这次联系,
-- PAC 再产一条就是重复触达(一线反馈里 duplicate_contact 本就是既有反馈项)。
-- 患者级、不看牙位/类目 —— 回访任务本身就是患者级的,宿主不按牙位排。
--
-- 到期自愈:条件是 task_date > 今天,任务日一过本闸自动失效,下一轮照常召回。
-- 所以"排了不做"(全库到期任务执行率 74.3%)不需要额外兜底 —— 烂尾的到点就放行。
-- 也不设窗口上限:在池患者的未来排程 99.65% 在一年内(2028 年以后仅 50 条),
-- 拍一个 N 天窗口反而会在第 N+1 天把"诊所排了 N+30 天回访"的人放回池,制造重复触达。
--
-- ⚠️ 位置刻意放在这里(scenario)而不是 gapWhere:gapWhere 是召回与画像**共享**的
-- 【临床事实】层(已治疗/患者拒绝/外院已做 → 缺口真的没了);本闸是【运营时机】层
-- (缺口还在,只是现在不该打电话),同 ⑤b/⑤f。放进 gapWhere 会让画像因为"排了个回访"
-- 就说这人没有潜在治疗 —— 那是两套口径。
SELECT 1 FROM patient_return_visits rv
WHERE rv.patient_id = p.id
AND rv.task_date > ${scope.now}::date
)
`;
// ⭐ 同 patient 同 sub_scenario 的多 sig 按 tooth-overlap 合并(union-find)
......
import { Prisma } from '@prisma/client';
import { lookupDxTreatment } from '@pac/types';
import { TreatmentInitiationRecallScenario } from '../src/modules/plan/engine/scenarios/treatment-initiation-recall.scenario';
import { PotentialTreatmentSelector } from '../src/modules/clinical-gap/potential-treatment.selector';
import {
buildGapCore,
GAP_FLAGS_BY_PRIMARY,
GAP_PRIMARY_GROUPS,
} from '../src/modules/clinical-gap/potential-treatment-gap.sql';
import type { PrismaService } from '../src/prisma/prisma.service';
/**
* ⑤g 排除闸:诊所已排未来回访 → 本轮不召。
*
* 由来(2026-07 业务可用性测试):
* 「超过百天他就会给你加急自动升了一个紧急,而且他设置过的回访的话他实际是不记得」
* 生产量级:在池 236,508 条 plan 里,30,426(12.9%)患者已有未到期回访排程;
* 其中急迫档 urgent 占 17,263 —— 正是一线说的"明明排了回访还催我"。
*
* 口径三条,本文件逐条锁住:
* ① 患者级 + 只认未到期(task_date > 今天)—— 到期自愈,不需要解除逻辑;
* "排了不做"(全库到期任务执行率 74.3%)靠这条自然放行,不另设兜底。
* ② 不设窗口上限 —— 在池未来排程 99.65% 在一年内,拍 N 天窗口反而会在第 N+1 天
* 把"诊所排了 N+30 天回访"的人放回池,制造重复触达。
* ③ ⭐ 必须在 scenario,**不能**进 gapWhere。
* gapWhere 是召回与画像共享的【临床事实】层(已治疗/患者拒绝/外院已做 → 缺口没了);
* 本闸是【运营时机】层(缺口还在,只是现在不该打电话),同 ⑤b 未来预约 / ⑤f 就诊冷静。
* 放错层会让画像因为"排了个回访"就说这人没有潜在治疗 —— 两套口径,正是要避免的分叉。
*/
/// 鸭子类型判 Prisma.Sql —— `Prisma.Sql` 在运行时不是可 instanceof 的构造器
type SqlLike = { strings: string[]; values: unknown[] };
const isSql = (v: unknown): v is SqlLike =>
!!v && typeof v === 'object' && Array.isArray((v as SqlLike).strings) && Array.isArray((v as SqlLike).values);
/// 把 Prisma.Sql 片段(含嵌套)摊平成可断言的文本。
/// 必须递归展开:嵌套片段(gapWhere / lateralJoin …)在外层 tagged template 里只是一个
/// 绑定参数,不摊开就看不见内容,断言会假通过。
function flatten(frag: Prisma.Sql | SqlLike | unknown): string {
if (!isSql(frag)) return ' ? ';
return frag.strings
.map((s, i) => s + (i < frag.values.length ? flatten(frag.values[i]) : ''))
.join('');
}
/// 捕获 $queryRaw 的完整 SQL 形状(静态段 + 递归摊平的嵌套片段;绑定值占位成 ` ? `)。
/// 返回 [] 让调用方走空结果短路,不需要再 mock 后续查询。
function makeSqlCapturingPrisma(): { prisma: PrismaService; sql: () => string } {
const captured: string[] = [];
const queryRaw = (strings: TemplateStringsArray, ...values: unknown[]) => {
const parts = Array.isArray(strings?.raw) ? strings.raw : [String(strings)];
captured.push(parts.map((s, i) => s + (i < values.length ? flatten(values[i]) : '')).join(''));
return Promise.resolve([]);
};
const prisma = { $queryRaw: queryRaw } as unknown as PrismaService;
return { prisma, sql: () => captured.join('\n/* --- next query --- */\n') };
}
describe('⑤g 未来回访排除闸 —— 召回 SQL', () => {
let sqlText: string;
/// ⑤g 闸自身的子查询文本(整条 SQL 里还有别的 rv.* 条件,断言必须收窄到闸内)
let gate: string;
beforeAll(async () => {
const { prisma, sql } = makeSqlCapturingPrisma();
const scenario = new TreatmentInitiationRecallScenario(prisma);
await scenario.selectHits({
hostId: '00000000-0000-0000-0000-000000000001',
tenantId: 't1',
now: new Date('2026-07-26T00:00:00Z'),
});
sqlText = sql();
// 同一条 SQL 里有两个 `SELECT 1 FROM patient_return_visits rv` 子查询:
// 外院已治疗闸(gapWhere,读 rv.result)和 ⑤g(读 rv.task_date 未来)。
// 取后者 —— 判据是它比的是 `::date`(跟今天比),外院那条比的是诊断日。
const subs = [...sqlText.matchAll(/SELECT 1 FROM patient_return_visits rv[\s\S]{0,240}?\)/g)].map((m) => m[0]);
gate = subs.find((s) => /task_date\s*>\s*\?\s*::date/.test(s)) ?? '';
expect(subs.length).toBeGreaterThanOrEqual(2); // 外院闸 + ⑤g 都在
expect(gate).not.toBe(''); // 抓不到 ⑤g 就别往下断言了,免得后面全部假通过
});
test('⭐ 闸存在,且是 NOT EXISTS 子查询(不是 JOIN,别把行数放大)', () => {
expect(sqlText).toContain('patient_return_visits');
expect(sqlText).toMatch(/NOT EXISTS \([\s\S]*?SELECT 1 FROM patient_return_visits rv/);
expect(sqlText).not.toMatch(/JOIN patient_return_visits/);
});
test('⭐ 只认未到期 —— 必须是 task_date > now::date,不能是 >= 或 <', () => {
// 绑定参数被占位成 ` ? `,故断言写成 `rv.task_date > ? ::date`。
// ⚠️ 只在 ⑤g 闸内断言:同一条 SQL 里外院闸也有 `rv.task_date >= 诊断日`,那是另一回事。
expect(gate).toMatch(/rv\.task_date\s*>\s*\?\s*::date/);
expect(gate).not.toMatch(/rv\.task_date\s*>=/);
expect(gate).not.toMatch(/rv\.task_date\s*<\s*\?/);
});
test('患者级:按 patient_id 关联,不掺牙位 / 类目条件', () => {
expect(gate).toContain('rv.patient_id = p.id');
expect(gate).not.toContain('tooth');
expect(gate).not.toContain('category');
});
test('⭐ 不设窗口上限(闸内只有一个 task_date 比较,没有 now + N 天的第二个条件)', () => {
expect(gate.match(/task_date/g)?.length).toBe(1);
expect(gate).not.toMatch(/interval|days'/i);
});
test('⑤b(未来预约)/ ⑤f(就诊冷静)仍在 —— ⑤g 是新增不是替换', () => {
expect(sqlText).toContain('appointment_record');
expect(sqlText).toMatch(/vis\.type IN \('encounter_record', 'emr_record'\)/);
});
});
describe('⭐ 分层纪律:⑤g 属于「运营时机」层,不得下沉到召回+画像共享的 gapWhere', () => {
const gap = buildGapCore({
rule: lookupDxTreatment('K08')!,
cfgFlags: GAP_FLAGS_BY_PRIMARY.K08 ?? {},
allCodes: [...GAP_PRIMARY_GROUPS.K08!.dxCodes, ...GAP_PRIMARY_GROUPS.K08!.recCodes],
resolverCats: ['implant', 'prosthodontic'],
});
const gapWhereText = flatten(gap.gapWhere);
test('⭐ gapWhere 里没有「与今天比的未来 task_date」—— 那是 ⑤g,不属于这一层', () => {
// ⚠️ 注意 gapWhere 里**本来就有** task_date:外院已治疗闸拿它跟**诊断日**比方向
// (rv.task_date >= COALESCE(sig.occurred_at, sig.planned_for)),那是临床事实判定。
// 要禁的是 ⑤g 那种跟**今天**比的未来判定(rv.task_date > $now::date)。
expect(gapWhereText).not.toMatch(/task_date\s*>\s*\?\s*::date/);
expect(gapWhereText.match(/task_date/g)?.length).toBe(1);
});
test('gapWhere 里**保留**外院已治疗闸 —— 它读 rv.result(临床事实),跟 ⑤g 分工不同', () => {
expect(gapWhereText).toContain('patient_return_visits');
expect(gapWhereText).toContain('rv.result');
expect(gapWhereText).toMatch(/rv\.task_date\s*>=\s*COALESCE\(sig\.occurred_at/);
});
test('画像 selector 跑出来的 SQL 不含未来回访闸', async () => {
const { prisma, sql } = makeSqlCapturingPrisma();
const selector = new PotentialTreatmentSelector(prisma);
await selector.selectForPatient({
hostId: '00000000-0000-0000-0000-000000000001',
tenantId: 't1',
patientId: '00000000-0000-0000-0000-0000000000aa',
now: new Date('2026-07-26T00:00:00Z'),
activeCodes: new Set(['K08', 'K07', 'K02']), // 不给码会被剪枝跳过,SQL 都不生成
});
const text = sql();
expect(text.length).toBeGreaterThan(0); // 确认真跑了 SQL,不是被剪枝跳过
// 外院闸的 task_date(跟诊断日比)允许出现;⑤g 那种跟今天比的未来判定不允许
expect(text).not.toMatch(/rv\.task_date\s*>\s*\?\s*::date/);
});
});
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