Commit 7b4f0d93 by luoqi

fix(plan): 跟进诊所归属改为患者最后到诊诊所(与诊断诊所解耦)

业务口径(2026-07):运营按客户最后一次到诊的诊所做跟进分类,谁家最后来过就归谁家客服
——原口径 target_clinic_id = 触发诊断 fact 所在诊所,会把主诊所患者顺路在别处拍片/被诊断
的人错分到那家只去过一次的诊所(反馈案例 BJ0D046171:150+次天使大厦、1次印象城影像AI诊断
→ 却进了印象城列表)。适用全部召回场景,不止影像 AI。

- target_clinic_id = 患者最近一条 encounter/emr(到诊)所在诊所;无到诊记录兜底回退诊断诊所(不置空)
- unchanged 分支(reason 未变)也就地改归属,不升版本 → 普通重算即可修复存量错分
- 批量 prefetch 修 uuid bug:patient_id IN (text 参数) 对 uuid 列在 PG 报类型错,
  批量整体静默退回诊断诊所(修不动);改 = ANY(::uuid[])。本地 2470 患者全量重算后残留错分 0
- 单刷路径 resolveLastVisitClinic 直查

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
parent 07bc2845
......@@ -190,11 +190,8 @@ export class PlanEngineService {
// 一次性 createMany(每患仍一行,审计/监控口径不变,只是写法从 12.5万次 → 几次)。
// 注:selectHits 的全表扫不在本优化内(时间规则随时可改,每轮必须全量重选才正确)。
const patientIds = [...hitsByPatient.keys()];
const { latestByPatient, snoozedByPatient, personaByPatient } = await this.prefetchForBatch(
scope,
patientIds,
now,
);
const { latestByPatient, snoozedByPatient, personaByPatient, lastVisitClinicByPatient } =
await this.prefetchForBatch(scope, patientIds, now);
const EMPTY_SNOOZE = new Map<string, Date>();
const logRows: Prisma.PlanGenerationLogCreateManyInput[] = [];
const concurrency = Math.max(1, Number(process.env.PAC_PLAN_BATCH_CONCURRENCY) || 8);
......@@ -211,6 +208,7 @@ export class PlanEngineService {
latest: latestByPatient.get(patientId) ?? null,
snoozedAnchors: snoozedByPatient.get(patientId) ?? EMPTY_SNOOZE,
personaId: personaByPatient.get(patientId) ?? null,
lastVisitClinicId: lastVisitClinicByPatient.get(patientId) ?? null,
},
});
// 计数:JS 单线程,await 恢复后同步自增,chunk 内并发无交错
......@@ -303,6 +301,7 @@ export class PlanEngineService {
latest: PlanWithReasons | null;
snoozedAnchors: Map<string, Date>;
personaId: string | null;
lastVisitClinicId: string | null;
};
}): Promise<'created' | 'superseded' | 'unchanged' | 'suppressed'> {
const { scope, patientId, hits, prefetched } = input;
......@@ -349,6 +348,20 @@ export class PlanEngineService {
const newPriorityScore = Math.max(...usableHits.map((h) => h.priorityScore));
// head = 优先级最高的 hit(plan 顶层字段:goal / recommendedRole / recommendedAt / recommendedChannel /
// targetClinicId 兜底)。提前算,unchanged 分支也要用它做诊所归属。
const head = [...usableHits].sort((a, b) => b.priorityScore - a.priorityScore)[0]!;
// ⭐ 跟进诊所归属(2026-07 业务口径):运营按【患者最后一次到诊的诊所】做跟进分类,
// 与医务侧的诊断诊所解耦 —— 客户最后去哪家,就归哪家客服跟进。
// (原口径 = 触发诊断 fact 所在诊所 head.targetClinicId,会把"主诊所患者顺路在别处拍片/被诊断"
// 的人错分到那家只去过一次的诊所,业务反馈误导跟进。见 BJ0D046171 案例。)
// 兜底:患者无任何到诊记录(理论罕见,如仅影像无接诊)→ 回退诊断诊所,不置空。
const lastVisitClinicId = prefetched
? prefetched.lastVisitClinicId
: await this.resolveLastVisitClinic(scope, patientId);
const targetClinicId = lastVisitClinicId ?? head.targetClinicId ?? null;
// 比对 active plan 的 reason 是否变化
// ⭐ W3 末修:比对维度 = (scenario, subKey) 二元组集合,**不能只看 scenario**
// 旧 bug:加新 SUB_SCENARIOS(K01/K07/K00 等)后,oldScenarios={treatment_initiation_recall}
......@@ -363,12 +376,13 @@ export class PlanEngineService {
oldSubScenarios.size === newSubScenarios.size &&
[...oldSubScenarios].every((k) => newSubScenarios.has(k));
if (sameSubScenarios) {
// 只刷 priorityScore,不创新版本
if (latest.priorityScore !== newPriorityScore) {
await this.prisma.followupPlan.update({
where: { id: latest.id },
data: { priorityScore: newPriorityScore },
});
// reason 未变 → 不创新版本;但 priorityScore / 跟进诊所可能变(诊所重归属不算 reason 变更,
// 不该升版本,就地改即可 —— 否则存量 plan 永远停在旧诊所,重算修不好)。
const patch: Prisma.FollowupPlanUpdateInput = {};
if (latest.priorityScore !== newPriorityScore) patch.priorityScore = newPriorityScore;
if (latest.targetClinicId !== targetClinicId) patch.targetClinicId = targetClinicId;
if (Object.keys(patch).length > 0) {
await this.prisma.followupPlan.update({ where: { id: latest.id }, data: patch });
}
return 'unchanged';
}
......@@ -379,8 +393,7 @@ export class PlanEngineService {
// ⭐ 若旧版是 assigned:新版本继承分配(客服不丢单、不被抢;只是理由换到最新)。
// contactAttempts 一并带过去 → 频控/熔断计数延续(执行明细行仍挂旧版本,符合版本流语义)。
const carryAssignment = latest?.status === 'assigned';
// head = 优先级最高的 hit(用于 plan 顶层字段:goal / recommendedRole / recommendedAt / recommendedChannel)
const head = [...usableHits].sort((a, b) => b.priorityScore - a.priorityScore)[0]!;
// head / targetClinicId 已在上方(unchanged 分支前)算好,此处直接用。
// 拉对应 patient 的最新 active persona(可空);批量路径用预取值,单刷照旧查
const personaId = prefetched
......@@ -406,7 +419,7 @@ export class PlanEngineService {
tenantId: scope.tenantId,
patientId,
personaId: personaId ?? null,
targetClinicId: head.targetClinicId ?? null,
targetClinicId,
version: nextVersion,
priorityScore: newPriorityScore,
goal: head.goal ?? null,
......@@ -510,10 +523,12 @@ export class PlanEngineService {
latestByPatient: Map<string, PlanWithReasons>;
snoozedByPatient: Map<string, Map<string, Date>>;
personaByPatient: Map<string, string>;
lastVisitClinicByPatient: Map<string, string>;
}> {
const latestByPatient = new Map<string, PlanWithReasons>();
const snoozedByPatient = new Map<string, Map<string, Date>>();
const personaByPatient = new Map<string, string>();
const lastVisitClinicByPatient = new Map<string, string>();
const CHUNK = 2000;
for (let i = 0; i < patientIds.length; i += CHUNK) {
const ids = patientIds.slice(i, i + CHUNK);
......@@ -566,8 +581,46 @@ export class PlanEngineService {
personaByPatient.set(pe.patientId, pe.id);
}
}
// 每患最后一次到诊(encounter/emr)所在诊所 → 跟进归属(见 upsertPlan 说明)。
// DISTINCT ON 取 occurred_at 最新那条;跟"就诊冷静期"用同一 type 口径,语义一致。
const visits = await this.prisma.$queryRaw<
Array<{ patient_id: string; clinic_id: string }>
>`
SELECT DISTINCT ON (patient_id) patient_id, clinic_id
FROM patient_facts
WHERE host_id = ${scope.hostId}::uuid
AND tenant_id = ${scope.tenantId}
AND patient_id = ANY(${ids}::uuid[])
AND type IN ('encounter_record', 'emr_record')
AND occurred_at IS NOT NULL
AND clinic_id IS NOT NULL AND clinic_id <> ''
AND superseded_at IS NULL
ORDER BY patient_id, occurred_at DESC
`;
for (const v of visits) lastVisitClinicByPatient.set(v.patient_id, v.clinic_id);
}
return { latestByPatient, snoozedByPatient, personaByPatient, lastVisitClinicByPatient };
}
return { latestByPatient, snoozedByPatient, personaByPatient };
/// 单刷路径:解析该患者最后一次到诊(encounter/emr)所在诊所(批量路径走 prefetchForBatch)。
private async resolveLastVisitClinic(
scope: ScenarioScope,
patientId: string,
): Promise<string | null> {
const rows = await this.prisma.$queryRaw<Array<{ clinic_id: string }>>`
SELECT clinic_id
FROM patient_facts
WHERE host_id = ${scope.hostId}::uuid
AND tenant_id = ${scope.tenantId}
AND patient_id = ${patientId}::uuid
AND type IN ('encounter_record', 'emr_record')
AND occurred_at IS NOT NULL
AND clinic_id IS NOT NULL AND clinic_id <> ''
AND superseded_at IS NULL
ORDER BY occurred_at DESC
LIMIT 1
`;
return rows[0]?.clinic_id ?? null;
}
/// 子场景 → 生命周期类型(one_shot 短效 / recurring 长效)
......
......@@ -471,7 +471,8 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
recommendedChannel: 'phone',
// recommendedAt 不赋值(留 null):v1 没有"最佳触达时间"算法,全填当天无意义,
// 前端已移除推荐时间展示/排序。待有真实择时逻辑再启用。
// 目标诊所 = 诊断出该未治疗需求的诊所(患者最可能回访的地方)
// 目标诊所兜底 = 诊断出该未治疗需求的诊所;plan 顶层正常口径由 plan-engine 覆盖为
// "患者最后到诊诊所"(运营跟进归属),仅当患者无任何到诊记录时才回退到此值。
targetClinicId: r.clinic_id ?? null,
evidence: {
// cluster 内全部 sig 的 fact_id(lead 在 [0])— 让审计/追溯能看到所有触发依据
......@@ -574,7 +575,8 @@ interface HitRow {
extracted_by: string | null;
confidence: string | null; // pg 转 string,JS Number 转换
code_source: string | null; // std_code/name_map=医生 / image_ai / null — 置信度因子用
clinic_id: string | null; // 触发诊断 fact 的诊所 → plan.targetClinicId
clinic_id: string | null; // 触发诊断 fact 的诊所 → hit.targetClinicId(仅作兜底:患者无到诊记录时用;
// plan.targetClinicId 正常口径 = 患者最后到诊诊所,由 plan-engine 覆盖,见 upsertPlan)
signal_occurred_at: Date;
days_since: number;
// ↓ mergeRowsByToothOverlap 合并后注入(单 sig 时跟 signal_fact_id / signal_type 同步)
......
......@@ -21,6 +21,7 @@ interface Plan {
version: number;
status: string;
priorityScore: number;
targetClinicId?: string | null;
snoozedUntil: Date | null;
reasons: Reason[];
assigneeUserId: string | null;
......@@ -114,6 +115,7 @@ function makeStore(seed: { plans?: Partial<Plan>[]; personas?: { patientId: stri
version: data.version as number,
status: data.status as string,
priorityScore: data.priorityScore as number,
targetClinicId: (data.targetClinicId as string) ?? null,
snoozedUntil: null,
reasons: rdata.map((r) => ({ scenario: r.scenario, subKey: r.subKey ?? null })),
assigneeUserId: (data.assigneeUserId as string) ?? null,
......@@ -157,6 +159,8 @@ function makeStore(seed: { plans?: Partial<Plan>[]; personas?: { patientId: stri
followupPlan,
persona,
planGenerationLog,
// 最后到诊诊所查询(prefetchForBatch):本套件不测归属 → 返空 = 回退兜底 head.targetClinicId
$queryRaw: jest.fn(async (): Promise<Array<{ patient_id: string; clinic_id: string }>> => []),
$transaction: jest.fn(async (cb: (tx: unknown) => Promise<unknown>) =>
cb({ followupPlan: { update: followupPlan.update, create: followupPlan.create } }),
),
......@@ -167,13 +171,14 @@ function makeStore(seed: { plans?: Partial<Plan>[]; personas?: { patientId: stri
function makeScenario(hits: ScenarioHit[]) {
return { key: SCEN, selectHits: jest.fn(async () => hits) };
}
function hit(patientId: string, subKey: string, priorityScore = 50): ScenarioHit {
function hit(patientId: string, subKey: string, priorityScore = 50, targetClinicId?: string): ScenarioHit {
return {
patientId,
patientExternalId: `ext-${patientId}`,
reason: 'r',
priorityScore,
subKey,
targetClinicId: targetClinicId ?? null,
evidence: { factIds: ['f1'] },
} as ScenarioHit;
}
......@@ -326,3 +331,52 @@ describe('runAllForHost 批量路径 — 5 种结局等价', () => {
expect(res.plansClosed).toBe(0);
});
});
describe('跟进诊所归属 = 患者最后到诊诊所(与诊断诊所解耦)', () => {
test('有到诊记录 → plan.targetClinicId 取最后到诊诊所,而非触发诊断诊所', async () => {
const { prisma, plans } = makeStore({});
// 触发诊断落在 clinic-diag(如影像AI/顺路就诊那家);患者最后到诊在 clinic-home(主诊所)
prisma.$queryRaw = jest.fn(async () => [{ patient_id: 'pat-x', clinic_id: 'clinic-home' }]);
await engine(
prisma,
makeScenario([hit('pat-x', 'missing_tooth@whole', 50, 'clinic-diag')]),
).runAllForHost({ hostId: HOST, tenantId: TENANT, now: NOW });
const active = plans.find((p) => p.patientId === 'pat-x' && p.status === 'active')!;
expect(active.targetClinicId).toBe('clinic-home'); // ⭐ 归属跟人走,不跟诊断走
});
test('无任何到诊记录 → 回退触发诊断诊所(不置空)', async () => {
const { prisma, plans } = makeStore({});
prisma.$queryRaw = jest.fn(async () => []); // 查不到到诊
await engine(
prisma,
makeScenario([hit('pat-y', 'missing_tooth@whole', 50, 'clinic-diag')]),
).runAllForHost({ hostId: HOST, tenantId: TENANT, now: NOW });
const active = plans.find((p) => p.patientId === 'pat-y' && p.status === 'active')!;
expect(active.targetClinicId).toBe('clinic-diag'); // 兜底
});
test('reason 未变但最后到诊诊所变了 → 就地改归属,不升版本(修存量)', async () => {
const { prisma, plans } = makeStore({
plans: [
{
id: 'p-old',
patientId: 'pat-z',
version: 1,
status: 'active',
priorityScore: 50,
targetClinicId: 'clinic-old', // 存量错分
reasons: [{ scenario: SCEN, subKey: 'missing_tooth@whole' }],
},
],
});
prisma.$queryRaw = jest.fn(async () => [{ patient_id: 'pat-z', clinic_id: 'clinic-new' }]);
const res = await engine(
prisma,
makeScenario([hit('pat-z', 'missing_tooth@whole', 50, 'clinic-diag')]),
).runAllForHost({ hostId: HOST, tenantId: TENANT, now: NOW });
expect(res.plansUnchanged).toBe(1); // reason 没变
expect(plans.filter((p) => p.patientId === 'pat-z')).toHaveLength(1); // 没升版本
expect(plans.find((p) => p.id === 'p-old')!.targetClinicId).toBe('clinic-new'); // ⭐ 就地改归属
});
});
......@@ -42,6 +42,8 @@ function makePrismaMock(opts: {
findFirst: jest.fn().mockResolvedValue(null),
findMany: jest.fn().mockResolvedValue([]),
},
// 最后到诊诊所查询(prefetchForBatch/resolveLastVisitClinic):本套件不测归属 → 返空 = 回退兜底
$queryRaw: jest.fn().mockResolvedValue([]),
planGenerationLog: {
create: jest.fn().mockResolvedValue({ id: 'log-1' }),
update: jest.fn().mockResolvedValue({}),
......
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