Commit 1242f896 by luoqi

fix(plan): 归属漂移的已认领单顺带返池 — 防诊所硬隔离下的孤儿单

跟进诊所重归属(7b4f0d93)的补丁:诊所隔离是硬边界(list/detail 都强制
targetClinicId ∈ scope.clinicIds),已认领单漂出原认领人 scope 后会变成
谁都看不见的孤儿单(assigned 不进池、新店客服非 assignee、原客服出 scope)。

- unchanged 就地改归属:若 status=assigned 且诊所变了 → 返池(assignee/assignedAt/recycleAt 清空)
- 升版本路径:clinicMoved 时 assigned 不继承,新版本直接回池(与上同规则)
- 两路径都记 log(哪单从哪店改到哪店),上线后可巡检
- 测试 +3:两路径返池 + 归属未变时继承不误伤(12/12);顺带修 mock seed 缺 targetClinicId

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
parent 7b4f0d93
Pipeline #3412 failed in 0 seconds
...@@ -380,7 +380,22 @@ export class PlanEngineService { ...@@ -380,7 +380,22 @@ export class PlanEngineService {
// 不该升版本,就地改即可 —— 否则存量 plan 永远停在旧诊所,重算修不好)。 // 不该升版本,就地改即可 —— 否则存量 plan 永远停在旧诊所,重算修不好)。
const patch: Prisma.FollowupPlanUpdateInput = {}; const patch: Prisma.FollowupPlanUpdateInput = {};
if (latest.priorityScore !== newPriorityScore) patch.priorityScore = newPriorityScore; if (latest.priorityScore !== newPriorityScore) patch.priorityScore = newPriorityScore;
if (latest.targetClinicId !== targetClinicId) patch.targetClinicId = targetClinicId; if (latest.targetClinicId !== targetClinicId) {
patch.targetClinicId = targetClinicId;
// ⭐ 归属漂移 + 已认领 → 顺带返池:诊所隔离是硬边界,单子漂出原认领人 scope 后
// 会变成谁都看不见的孤儿单(assigned 不进池、新店客服非 assignee、原客服出 scope)。
// 业务语义也自洽:单子归新诊所客服跟进,原认领人不再持有。
if (latest.status === 'assigned') {
patch.status = 'active';
patch.assigneeUserId = null;
patch.assignedAt = null;
patch.recycleAt = null;
}
this.logger.log(
`plan ${latest.id} 跟进诊所重归属 ${latest.targetClinicId ?? '∅'} ${targetClinicId ?? '∅'}` +
(latest.status === 'assigned' ? '(原 assigned → 返池)' : ''),
);
}
if (Object.keys(patch).length > 0) { if (Object.keys(patch).length > 0) {
await this.prisma.followupPlan.update({ where: { id: latest.id }, data: patch }); await this.prisma.followupPlan.update({ where: { id: latest.id }, data: patch });
} }
...@@ -392,7 +407,15 @@ export class PlanEngineService { ...@@ -392,7 +407,15 @@ export class PlanEngineService {
const nextVersion = (latest?.version ?? 0) + 1; const nextVersion = (latest?.version ?? 0) + 1;
// ⭐ 若旧版是 assigned:新版本继承分配(客服不丢单、不被抢;只是理由换到最新)。 // ⭐ 若旧版是 assigned:新版本继承分配(客服不丢单、不被抢;只是理由换到最新)。
// contactAttempts 一并带过去 → 频控/熔断计数延续(执行明细行仍挂旧版本,符合版本流语义)。 // contactAttempts 一并带过去 → 频控/熔断计数延续(执行明细行仍挂旧版本,符合版本流语义)。
const carryAssignment = latest?.status === 'assigned'; // 例外:跟进诊所同时发生了重归属 → 不继承、新版本返池(与 unchanged 分支同规则:
// 带着 assignment 漂出原认领人 scope 会变孤儿单,归新诊所客服重新认领)。
const clinicMoved = latest != null && latest.targetClinicId !== targetClinicId;
const carryAssignment = latest?.status === 'assigned' && !clinicMoved;
if (latest?.status === 'assigned' && clinicMoved) {
this.logger.log(
`plan ${latest.id} 升版本且跟进诊所重归属 ${latest.targetClinicId ?? '∅'} ${targetClinicId ?? '∅'},assigned 不继承(返池)`,
);
}
// head / targetClinicId 已在上方(unchanged 分支前)算好,此处直接用。 // head / targetClinicId 已在上方(unchanged 分支前)算好,此处直接用。
// 拉对应 patient 的最新 active persona(可空);批量路径用预取值,单刷照旧查 // 拉对应 patient 的最新 active persona(可空);批量路径用预取值,单刷照旧查
......
...@@ -47,6 +47,7 @@ function makeStore(seed: { plans?: Partial<Plan>[]; personas?: { patientId: stri ...@@ -47,6 +47,7 @@ function makeStore(seed: { plans?: Partial<Plan>[]; personas?: { patientId: stri
version: p.version ?? 1, version: p.version ?? 1,
status: p.status ?? 'active', status: p.status ?? 'active',
priorityScore: p.priorityScore ?? 50, priorityScore: p.priorityScore ?? 50,
targetClinicId: p.targetClinicId ?? null,
snoozedUntil: p.snoozedUntil ?? null, snoozedUntil: p.snoozedUntil ?? null,
reasons: p.reasons ?? [], reasons: p.reasons ?? [],
assigneeUserId: p.assigneeUserId ?? null, assigneeUserId: p.assigneeUserId ?? null,
...@@ -379,4 +380,84 @@ describe('跟进诊所归属 = 患者最后到诊诊所(与诊断诊所解耦)', ...@@ -379,4 +380,84 @@ describe('跟进诊所归属 = 患者最后到诊诊所(与诊断诊所解耦)',
expect(plans.filter((p) => p.patientId === 'pat-z')).toHaveLength(1); // 没升版本 expect(plans.filter((p) => p.patientId === 'pat-z')).toHaveLength(1); // 没升版本
expect(plans.find((p) => p.id === 'p-old')!.targetClinicId).toBe('clinic-new'); // ⭐ 就地改归属 expect(plans.find((p) => p.id === 'p-old')!.targetClinicId).toBe('clinic-new'); // ⭐ 就地改归属
}); });
test('unchanged + 已认领 + 归属漂移 → 顺带返池(防孤儿单:诊所隔离硬边界下原客服将出 scope)', async () => {
const { prisma, plans } = makeStore({
plans: [
{
id: 'p-assigned',
patientId: 'pat-a',
version: 1,
status: 'assigned',
assigneeUserId: 'staff-1',
assignedAt: new Date('2026-07-01'),
recycleAt: new Date('2026-08-01'),
priorityScore: 50,
targetClinicId: 'clinic-old',
reasons: [{ scenario: SCEN, subKey: 'missing_tooth@whole' }],
},
],
});
prisma.$queryRaw = jest.fn(async () => [{ patient_id: 'pat-a', clinic_id: 'clinic-new' }]);
const res = await engine(
prisma,
makeScenario([hit('pat-a', 'missing_tooth@whole', 50, 'clinic-diag')]),
).runAllForHost({ hostId: HOST, tenantId: TENANT, now: NOW });
expect(res.plansUnchanged).toBe(1);
const p = plans.find((x) => x.id === 'p-assigned')!;
expect(p.targetClinicId).toBe('clinic-new');
expect(p.status).toBe('active'); // ⭐ 返池
expect(p.assigneeUserId).toBeNull();
});
test('升版本 + 已认领 + 归属漂移 → assigned 不继承,新版本返池', async () => {
const { prisma, plans } = makeStore({
plans: [
{
id: 'p-assigned2',
patientId: 'pat-b',
version: 1,
status: 'assigned',
assigneeUserId: 'staff-2',
priorityScore: 50,
targetClinicId: 'clinic-old',
reasons: [{ scenario: SCEN, subKey: 'caries@11' }], // 与新 hit subKey 不同 → 升版本
},
],
});
prisma.$queryRaw = jest.fn(async () => [{ patient_id: 'pat-b', clinic_id: 'clinic-new' }]);
await engine(
prisma,
makeScenario([hit('pat-b', 'missing_tooth@whole', 60, 'clinic-diag')]),
).runAllForHost({ hostId: HOST, tenantId: TENANT, now: NOW });
const v2 = plans.find((x) => x.patientId === 'pat-b' && x.version === 2)!;
expect(v2.targetClinicId).toBe('clinic-new');
expect(v2.status).toBe('active'); // ⭐ 不继承 assigned
expect(v2.assigneeUserId).toBeNull();
});
test('升版本 + 已认领 + 归属未变 → assigned 照常继承(返池规则不误伤)', async () => {
const { prisma, plans } = makeStore({
plans: [
{
id: 'p-assigned3',
patientId: 'pat-c',
version: 1,
status: 'assigned',
assigneeUserId: 'staff-3',
priorityScore: 50,
targetClinicId: 'clinic-home',
reasons: [{ scenario: SCEN, subKey: 'caries@11' }],
},
],
});
prisma.$queryRaw = jest.fn(async () => [{ patient_id: 'pat-c', clinic_id: 'clinic-home' }]);
await engine(
prisma,
makeScenario([hit('pat-c', 'missing_tooth@whole', 60, 'clinic-diag')]),
).runAllForHost({ hostId: HOST, tenantId: TENANT, now: NOW });
const v2 = plans.find((x) => x.patientId === 'pat-c' && x.version === 2)!;
expect(v2.status).toBe('assigned');
expect(v2.assigneeUserId).toBe('staff-3'); // ⭐ 继承不回归
});
}); });
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