Commit 2d8b146e by luoqi

fix(plan): 归因继承的判据换轴 —— 看客服碰过没,不看召回理由变没变

上一版判据(「召回理由整组换掉就不继承」)是**找错了轴**,产品当场指出两个反例:

  ① 召回一会儿出现一会儿不出现,**恰恰可能是被客服处理过**造成的
     (患者来了、治了一部分、又诊断出新问题)→ 该结账的反而被当成"理由变了"继承走
  ② 同一种诊断**再次出现,也不代表客服没处理过** → 该结账的同样继承了

**理由的变化根本不携带「客服做没做事」的信息**,它只反映患者临床状态在动。
两个方向都会错,所以整条判据作废,换成:

  客服没碰过 → 这单还欠着 → 继承(理由怎么变都继承)
  客服碰过了 → 批次这一条的账已落定 → 不继承,后面再冒出来的是新工单

批次分的是**人**不是诊断:主管要知道的是「这 100 个人有多少被联系/处理了」。
一个人一次都没被联系过,账就还欠着 —— 临床理由怎么变都不改变这件事。

️ 「碰过没」不新造判据,**直接复用 T21 / D-11 那一套**(撤销整批判"哪些单不能收"):
以 view 事件为主(实测 view 1,024 条 vs plan_executions 7 条,回写率 11%),
外加行上三个现成强信号(snoozedUntil / releaseReason / contactAttempts)。
 别改成只看 plan_executions —— 会把 89% 已打过电话的单判成"没碰过",
于是归因一直继承下去,**批次的账永远结不掉**。

性能:view 事件**只对带 assignment_id 的单查**,批量路径一次 groupBy 预取。
不收窄的话 runAllForHost 全量会为 44 万患者各查一次(已加回归锁住)。

与 T20′ 咬合不变:不继承时旧行仍带 assignment_id 且已 superseded → 跟踪按患者取最新版
取到的就是它,身上带着当时的处置(releaseReason / 已 view)→ 批次分子分母都不丢。

D-12 那条 🔴🔴 守恒断言(「退回后升版本归因仍在」)改为断言**结果**而非机制:
退回也是一种处置 → 新版本不带归因,但旧行带着 assignment_id + releaseReason 留在批次里,
退回率照样算得出。原断言测的是当时的实现手段,不是它想守的东西。

顺带补上 mock 的 planEventLog.groupBy/findFirst(此前没有,view 判据在单测里无从验证)。

965 tests passing。教条 T20″ 与契约 D-12 已按新轴重写,并把"曾经写错的那版判据 + 为什么错"
留在文档里 —— 这个错很容易再犯一次。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent aa5fdaf7
...@@ -129,6 +129,27 @@ export class PlanEngineService { ...@@ -129,6 +129,27 @@ export class PlanEngineService {
} }
/** /**
* 解析「客服碰过没」。⚠️ 只在**真的需要**时才去查 view 事件:
* · 行上三个强信号任一为真 → 直接判碰过,不查库
* · 该单压根没有 assignment_id → 归因本来就是空的,查了也没用
* 批量路径用预取集合(见 prefetchForBatch),单患者路径才走这一次 findFirst。
* —— 不这么收窄的话,runAllForHost 全量 44 万患者会多出 44 万次查询。
*/
private async agentTouched(
latest: PlanWithReasons,
prefetched?: { touchedPlanIds: ReadonlySet<string> },
): Promise<boolean> {
if (planTouchedByAgent(latest, false)) return true;
if (latest.assignmentId == null) return false;
if (prefetched) return prefetched.touchedPlanIds.has(latest.id);
const v = await this.prisma.planEventLog.findFirst({
where: { planId: latest.id, event: PlanEventType.VIEW },
select: { id: true },
});
return v != null;
}
/**
* ⭐ 缺口1 helper:关闭该患者遗留的 active(非 assigned)plan(本轮 0 命中 → 退出召回池)。 * ⭐ 缺口1 helper:关闭该患者遗留的 active(非 assigned)plan(本轮 0 命中 → 退出召回池)。
* *
* 关 status='active' **或 'assigned'** 的最新一条(2026-06 决策:缺口全消失 → 即使 assigned 也关, * 关 status='active' **或 'assigned'** 的最新一条(2026-06 决策:缺口全消失 → 即使 assigned 也关,
...@@ -221,7 +242,7 @@ export class PlanEngineService { ...@@ -221,7 +242,7 @@ export class PlanEngineService {
// 一次性 createMany(每患仍一行,审计/监控口径不变,只是写法从 12.5万次 → 几次)。 // 一次性 createMany(每患仍一行,审计/监控口径不变,只是写法从 12.5万次 → 几次)。
// 注:selectHits 的全表扫不在本优化内(时间规则随时可改,每轮必须全量重选才正确)。 // 注:selectHits 的全表扫不在本优化内(时间规则随时可改,每轮必须全量重选才正确)。
const patientIds = [...hitsByPatient.keys()]; const patientIds = [...hitsByPatient.keys()];
const { latestByPatient, snoozedByPatient, personaByPatient, lastVisitClinicByPatient } = const { latestByPatient, snoozedByPatient, personaByPatient, lastVisitClinicByPatient, touchedPlanIds } =
await this.prefetchForBatch(scope, patientIds, now); await this.prefetchForBatch(scope, patientIds, now);
const EMPTY_SNOOZE = new Map<string, Date>(); const EMPTY_SNOOZE = new Map<string, Date>();
const logRows: Prisma.PlanGenerationLogCreateManyInput[] = []; const logRows: Prisma.PlanGenerationLogCreateManyInput[] = [];
...@@ -243,6 +264,7 @@ export class PlanEngineService { ...@@ -243,6 +264,7 @@ export class PlanEngineService {
snoozedAnchors: snoozedByPatient.get(patientId) ?? EMPTY_SNOOZE, snoozedAnchors: snoozedByPatient.get(patientId) ?? EMPTY_SNOOZE,
personaId: personaByPatient.get(patientId) ?? null, personaId: personaByPatient.get(patientId) ?? null,
lastVisitClinicId: lastVisitClinicByPatient.get(patientId) ?? null, lastVisitClinicId: lastVisitClinicByPatient.get(patientId) ?? null,
touchedPlanIds,
}, },
}); });
// 计数:JS 单线程,await 恢复后同步自增,chunk 内并发无交错 // 计数:JS 单线程,await 恢复后同步自增,chunk 内并发无交错
...@@ -371,6 +393,8 @@ export class PlanEngineService { ...@@ -371,6 +393,8 @@ export class PlanEngineService {
snoozedAnchors: Map<string, Date>; snoozedAnchors: Map<string, Date>;
personaId: string | null; personaId: string | null;
lastVisitClinicId: string | null; lastVisitClinicId: string | null;
/// 批量路径预取的「客服碰过」集合(只覆盖带 assignment_id 的单,见 prefetchForBatch)
touchedPlanIds: ReadonlySet<string>;
}; };
}): Promise<'created' | 'superseded' | 'unchanged' | 'suppressed'> { }): Promise<'created' | 'superseded' | 'unchanged' | 'suppressed'> {
const { scope, patientId, hits, prefetched } = input; const { scope, patientId, hits, prefetched } = input;
...@@ -565,43 +589,34 @@ export class PlanEngineService { ...@@ -565,43 +589,34 @@ export class PlanEngineService {
const carryAssignment = latest?.status === 'assigned' && !clinicMoved; const carryAssignment = latest?.status === 'assigned' && !clinicMoved;
/** /**
* ⭐ **批次归因继承的边界:理由全换了就不是同一张工单了**(产品裁决 2026-08-02)。 * ⭐ **批次归因继承的边界:看客服碰过没,不看召回理由变没变**(产品裁决 2026-08-02)。
* *
* D-12 原文是「归因列**无条件**继承」,那是为了修另一个 bug(退回后 plan 是 `active`, * ── 为什么不是「理由变了就不继承」(那是我先想错的一版)──────────────
* 挂在 `carryAssignment` 上会让退回单的归因连分子带分母静默归零)。但「无条件」过头了: * 拿"召回理由整组换掉"当判据,两个方向都会错:
* 升版本的判据就是 **(scenario, subKey) 集合变了 = 召回理由换了**, * ① 召回一会儿出现一会儿不出现,**很可能正是客服处理过**造成的(患者来了、治了一部分、
* 而理由**整组**换掉时,新版本已经是一张**全新的工单**了 —— 患者新长了颗龋齿, * 又诊断出新问题)。按理由判 → 该结账的反而"理由变了"被当成新工单继承走。
* 跟主管当初按「潜在种植」分下去的那一单没有关系,不该继续算在那个批次头上。 * ② 同一种诊断**再次出现,也不代表客服没处理过**。按理由判 → 同样该结账的却继承了。
* 理由的变化根本不携带"客服做没做事"的信息 —— 它只反映患者的临床状态在动。
* *
* 判据取**临床缺口类型**的交集,不是 subKey 原文 —— subKey 自带牙位 * ── 正解 ────────────────────────────────────────────────────
* (`caries_no_filling@18;28`),同一个需求多长一颗牙就会变字符串, * 客服**没碰过** → 这单还欠着 → **继承**(理由怎么变都继承:批次分的是**人**,
* 按原文比会把「又坏了一颗牙」误判成「全新工单」,那是矫枉过正。 * 主管要知道的是"这 100 个人有多少被联系了")
* 客服**碰过了** → 批次这一条的账已经落定 → **不继承**,后面再冒出来的是新工单
* *
* ⭐ 与跟踪口径(T20′)恰好咬合:不继承时旧版本仍带着 assignment_id 留在批次里、 * ⚠️ 「碰过没」不新造判据,**直接复用 T21 / D-11 那一套**(撤销整批时判"哪些单不能收"):
* 且已 superseded → 跟踪按「按患者取最新版」拿到的就是那条 superseded 行 → * 以 `view` 事件为主 —— 生产实测 view 有 1,024 条,而 `plan_executions` 只有 7 条
* 记为 **resolved(已处理)**。语义完全正确:**这个批次针对的需求确实没了** * (回写率 11%),拿执行记录判会把 89% 已经打过电话的单当成"没碰过"
* 而新工单干干净净地回池,等下一个批次。两边都不用打补丁 * 另外三个是行上现成的强信号(写了回访结果 / 退回过 / 提交过执行),顺手一起认
* *
* ⚠️ **已知边界(刻意不做,不是漏了)**:批次目标需求消失、但**别的**需求还在时 * ⭐ 与跟踪口径(T20′)咬合:不继承时旧版本仍带着 assignment_id 留在批次里、且已 superseded
* (如 {缺牙, 龋齿} → {龋齿}),交集非空 → 仍然继承,于是「种植需求其实已解决」 * → 跟踪按「按患者取最新版」拿到的就是那条旧行,它身上带着当时的处置
* 这件事在批次里看不出来。要判准它需要把批次的 `criteria.potentialTreatment` * (releaseReason / 已 view 等),批次的分子分母都不丢。新工单干净回池,等下一个批次。
* 映射到 subKey(implant→missing_tooth …)—— 那是**一套新口径**,得产品先定。
* 当前样本量下不值得为它引入一张映射表(T14:没数据支撑的精度是假精度)。
*/ */
const needKey = (sc: string, sk: string | null | undefined) => const carryAttribution = latest != null && !(await this.agentTouched(latest, prefetched));
`${sc}|${(sk ?? '').split('@')[0]}`;
const sharesAnyNeed =
latest != null &&
(() => {
const oldNeeds = new Set(latest.reasons.map((r) => needKey(r.scenario, r.subKey)));
return usableHits.some((h) => oldNeeds.has(needKey(h.scenarioKey, h.subKey)));
})();
/** 归因继承 = 还是同一张工单(至少共享一个临床缺口类型) */
const carryAttribution = sharesAnyNeed;
if (latest?.assignmentId && !carryAttribution) { if (latest?.assignmentId && !carryAttribution) {
this.logger.log( this.logger.log(
`plan ${latest.id} 升版本且召回理由整组更换 批次归因**不继承**` + `plan ${latest.id} 升版本,但客服已碰过该单 批次归因**不继承**` +
`(批次 ${latest.assignmentId.slice(0, 8)} 的这一条"需求已了"结账,新工单回池)`, `(批次 ${latest.assignmentId.slice(0, 8)} 的这一条账已落定,新工单回池)`,
); );
} }
if (latest?.status === 'assigned' && clinicMoved) { if (latest?.status === 'assigned' && clinicMoved) {
...@@ -806,6 +821,9 @@ export class PlanEngineService { ...@@ -806,6 +821,9 @@ export class PlanEngineService {
snoozedByPatient: Map<string, Map<string, Date>>; snoozedByPatient: Map<string, Map<string, Date>>;
personaByPatient: Map<string, string>; personaByPatient: Map<string, string>;
lastVisitClinicByPatient: Map<string, string>; lastVisitClinicByPatient: Map<string, string>;
/// 「客服碰过」的 planId 集合 —— ⚠️ **只覆盖带 assignment_id 的单**:
/// 没进过批次的单本来就没有归因可继承,为它们查 view 事件是白扫全表。
touchedPlanIds: Set<string>;
}> { }> {
const latestByPatient = new Map<string, PlanWithReasons>(); const latestByPatient = new Map<string, PlanWithReasons>();
const snoozedByPatient = new Map<string, Map<string, Date>>(); const snoozedByPatient = new Map<string, Map<string, Date>>();
...@@ -881,7 +899,28 @@ export class PlanEngineService { ...@@ -881,7 +899,28 @@ export class PlanEngineService {
`; `;
for (const v of visits) lastVisitClinicByPatient.set(v.patient_id, v.clinic_id); for (const v of visits) lastVisitClinicByPatient.set(v.patient_id, v.clinic_id);
} }
return { latestByPatient, snoozedByPatient, personaByPatient, lastVisitClinicByPatient }; // ⭐ 一次查完「客服碰过哪些单」—— 只问带 assignment_id 的那些(通常是很小的子集),
// 否则 runAllForHost 全量会为 44 万患者各查一次 view 事件。
// 判据同教条 T21 / 契约 D-11(撤销整批判"哪些不能收"),两处必须是同一套。
const batchPlanIds = [...latestByPatient.values()]
.filter((p) => p.assignmentId != null)
.map((p) => p.id);
const touchedPlanIds = new Set<string>();
for (let i = 0; i < batchPlanIds.length; i += 1000) {
const rows = await this.prisma.planEventLog.groupBy({
by: ['planId'],
where: {
planId: { in: batchPlanIds.slice(i, i + 1000) },
tenantId: scope.tenantId,
event: PlanEventType.VIEW,
},
});
for (const r of rows) touchedPlanIds.add(r.planId);
}
return {
latestByPatient, snoozedByPatient, personaByPatient, lastVisitClinicByPatient, touchedPlanIds,
};
} }
/// 单刷路径:解析该患者最后一次到诊(encounter/emr)所在诊所(批量路径走 prefetchForBatch)。 /// 单刷路径:解析该患者最后一次到诊(encounter/emr)所在诊所(批量路径走 prefetchForBatch)。
...@@ -917,6 +956,33 @@ type ScenarioHitWithKey = ScenarioHit & { scenarioKey: string }; ...@@ -917,6 +956,33 @@ type ScenarioHitWithKey = ScenarioHit & { scenarioKey: string };
/// 批量预取/upsert 用:plan 含 reasons(对齐 upsertPlan 的 include:{reasons:true}) /// 批量预取/upsert 用:plan 含 reasons(对齐 upsertPlan 的 include:{reasons:true})
type PlanWithReasons = Prisma.FollowupPlanGetPayload<{ include: { reasons: true } }>; type PlanWithReasons = Prisma.FollowupPlanGetPayload<{ include: { reasons: true } }>;
/**
* 「这单客服碰过没」—— 批次归因该不该继续继承,只看这一件事。
*
* ⚠️ 判据与「撤销整批时哪些单不能收」(教条 T21 / 契约 D-11)**是同一套**,
* 刻意不另造:同一个业务问题在两处用两个判据,迟早对不上,而且没人会发现。
*
* ⭐ 主判据是 `view` 事件(客服打开过详情页)。⛔ 别改成只看 `plan_executions` ——
* 生产实测回写率仅 11%(65 个认领单只有 7 条执行结果),那样会把 89% **已经打过电话**
* 的单判成"没碰过",于是它们的归因被一直继承下去,批次的账永远结不掉。
*
* 行上另外三个信号是顺手认的强证据(有就一定碰过,没有不代表没碰过):
* snoozedUntil 写了回访结果(约下次 / 拒绝 / 放弃)
* releaseReason 退回过 —— 退回也是一种处置,账同样落定了
* contactAttempts 提交过执行(与 plan_executions 同源,弱但免费)
*/
export function planTouchedByAgent(
row: { snoozedUntil: Date | null; releaseReason: string | null; contactAttempts: number },
hasViewEvent: boolean,
): boolean {
return (
hasViewEvent ||
row.snoozedUntil != null ||
row.releaseReason != null ||
row.contactAttempts > 0
);
}
export interface EngineRunResult { export interface EngineRunResult {
scenariosRun: number; scenariosRun: number;
patientsHit: number; patientsHit: number;
......
...@@ -59,7 +59,14 @@ const inArr = (v: unknown): unknown[] | null => ...@@ -59,7 +59,14 @@ const inArr = (v: unknown): unknown[] | null =>
/// 忠实内存 mock:findFirst/findMany 真的按 where(patientId/status 支持 in,snoozedUntil.gt)过滤、 /// 忠实内存 mock:findFirst/findMany 真的按 where(patientId/status 支持 in,snoozedUntil.gt)过滤、
/// 按 version desc 排序;update/updateMany/create/$transaction 真的改同一个 plans 数组。 /// 按 version desc 排序;update/updateMany/create/$transaction 真的改同一个 plans 数组。
function makeStore(seed: { plans?: Partial<Plan>[]; personas?: { patientId: string; id: string }[] } = {}) { function makeStore(
seed: {
plans?: Partial<Plan>[];
personas?: { patientId: string; id: string }[];
/// 客服打开过详情页的 planId(教条 T21 的「已动过」判据,归因该不该继承看它)
viewedPlanIds?: string[];
} = {},
) {
let idc = 1000; let idc = 1000;
const plans: Plan[] = (seed.plans ?? []).map((p, i) => ({ const plans: Plan[] = (seed.plans ?? []).map((p, i) => ({
id: p.id ?? `seed-${i}`, id: p.id ?? `seed-${i}`,
...@@ -213,6 +220,14 @@ function makeStore(seed: { plans?: Partial<Plan>[]; personas?: { patientId: stri ...@@ -213,6 +220,14 @@ function makeStore(seed: { plans?: Partial<Plan>[]; personas?: { patientId: stri
events.push(data); events.push(data);
return data; return data;
}), }),
/// 「客服打开过详情页」—— 归因该不该继续继承看它(教条 T21 的「已动过」判据)
groupBy: jest.fn(async ({ where }: { where: { planId?: { in?: string[] } } }) => {
const ids = new Set(where?.planId?.in ?? []);
return (seed.viewedPlanIds ?? []).filter((id) => ids.has(id)).map((planId) => ({ planId }));
}),
findFirst: jest.fn(async ({ where }: { where: { planId?: string } }) =>
(seed.viewedPlanIds ?? []).includes(where?.planId ?? '') ? { id: 'ev-1' } : null,
),
createMany: jest.fn(async ({ data }: { data: Array<Record<string, unknown>> }) => { createMany: jest.fn(async ({ data }: { data: Array<Record<string, unknown>> }) => {
events.push(...data); events.push(...data);
return { count: data.length }; return { count: data.length };
...@@ -822,9 +837,15 @@ describe('分配归因守恒 — 升版本不得吞掉批次归属', () => { ...@@ -822,9 +837,15 @@ describe('分配归因守恒 — 升版本不得吞掉批次归属', () => {
expect(v2.assigneeUserId).toBe('staff-1'); expect(v2.assigneeUserId).toBe('staff-1');
}); });
test('🔴🔴 ② **退回后**(status=active)+ 有归因 → 升版本后归因**仍在**', async () => { test('🔴🔴 ② **退回后**(status=active)+ 有归因 → 批次**仍然看得到这条**(退回率不失真)', async () => {
// 这一支是四份子方案全都漏掉的。carryAssignment 在这里是 false, // 这一支是四份子方案全都漏掉的。原始担心是:carryAssignment 在这里是 false,
// 归因若跟着它走就会归零 —— 而这恰恰是最需要统计的一类单(被退回的)。 // 归因若跟着它走就会归零 —— 而这恰恰是最需要统计的一类单(被退回的)。
//
// ⚠️ 2026-08 改判:担心成立,但**兜住它的不是"新版本继承归因"**。
// 退回本身就是一次处置 → 按新判据「客服碰过就不继承」,新版本不该带归因;
// 真正兜住退回率的是**旧行**:它带着 assignment_id + releaseReason 留在批次里,
// 而跟踪按「按患者取最新版、不过滤 superseded」正好取到它。
// 所以这里断言的是**结果**(批次看得到、原因还在),不是机制(v2 继承了)。
const { prisma, plans } = makeStore({ const { prisma, plans } = makeStore({
plans: [ plans: [
{ {
...@@ -849,15 +870,19 @@ describe('分配归因守恒 — 升版本不得吞掉批次归属', () => { ...@@ -849,15 +870,19 @@ describe('分配归因守恒 — 升版本不得吞掉批次归属', () => {
makeScenario([hit('pat-2', 'caries@11;36', 60, 'clinic-diag')]), makeScenario([hit('pat-2', 'caries@11;36', 60, 'clinic-diag')]),
).runAllForHost({ hostId: HOST, tenantId: TENANT, now: NOW }); ).runAllForHost({ hostId: HOST, tenantId: TENANT, now: NOW });
const v1 = plans.find((p) => p.patientId === 'pat-2' && p.version === 1)!;
const v2 = plans.find((p) => p.patientId === 'pat-2' && p.version === 2)!; const v2 = plans.find((p) => p.patientId === 'pat-2' && p.version === 2)!;
// 分母:这条单**属于批次 N**,退回不改变这个历史事实 // 分母:这条单**属于批次 N**,退回不改变这个历史事实 —— 记在旧行上
expect(v2.assignmentId).toBe('batch-N'); expect(v1.assignmentId).toBe('batch-N');
expect(v2.assignedBy).toBe('leader-1'); expect(v1.assignedBy).toBe('leader-1');
expect(v2.assignStrategy).toBe('spread'); expect(v1.assignStrategy).toBe('spread');
// 分子:退回原因也要留着,否则「退回 5 条,原因分布如下」就只剩个数字 // 分子:退回原因也要留着,否则「退回 5 条,原因分布如下」就只剩个数字
expect(v2.releaseReason).toBe('over_capacity'); expect(v1.releaseReason).toBe('over_capacity');
expect(v2.releaseNote).toBe('手上还有 40 个'); expect(v1.releaseNote).toBe('手上还有 40 个');
// 归属确实没有(它本来就在池子里) expect(v1.status).toBe('superseded'); // 跟踪取最新版时,带 batch-N 的只剩它
// 新版本是**另一张工单**:客服已经对上一张做过处置,批次的账落定了
expect(v2.assignmentId).toBeNull();
expect(v2.releaseReason).toBeNull();
expect(v2.status).toBe('active'); expect(v2.status).toBe('active');
expect(v2.assigneeUserId).toBeNull(); expect(v2.assigneeUserId).toBeNull();
}); });
...@@ -949,16 +974,20 @@ describe('分配归因守恒 — 升版本不得吞掉批次归属', () => { ...@@ -949,16 +974,20 @@ describe('分配归因守恒 — 升版本不得吞掉批次归属', () => {
}); });
// ============================================================= // =============================================================
// 2026-08 · 归因继承的**边界**:理由整组换掉 = 全新工单,不继承 // 2026-08 · 归因继承的**边界**:看客服碰过没,不看召回理由变没变
// ============================================================= // =============================================================
// //
// 产品裁决:「该患者新的诊断等信号形成的工单升版本了,是全新的,不应该继承」。 // 产品裁决的推理链(值得完整记下来,因为第一版判据是错的):
// 「不继承应该是看客服有没处理过」
// 「召回一会儿出现一会儿不出现,也可能是被客服处理过所致」
// 「客服没处理过、召回变了,值得继承」
// 「该患者召回再次出现同一种诊断,也不代表客服没处理过」
// //
// D-12 原文写的是「无条件继承」,那其实是**给当时跟踪查询的缺陷打的补丁** —— // ⛔ 先做错的一版是拿「召回理由整组换掉」当判据,两个方向都会错:
// 那时 detail 过滤 `supersededAt: null`,旧版本看不见,不继承就等于分母缩水。 // ① 理由消失/重现**恰恰可能是客服处理过**造成的 → 该结账的反而被当成新工单
// 跟踪改成「按患者取最新版、不过滤 superseded」之后,历史留在旧行上, // ② 同一诊断再次出现**不代表没处理过** → 该结账的也继承了
// 「无条件」就不再是必需的了,而且它有害:把一张全新工单硬算进一个早就结束的批次 // 理由的变化根本不携带"客服做没做事"的信息,它只反映患者临床状态在动
describe('归因继承的边界 — 理由整组更换 = 全新工单', () => { describe('归因继承的边界 — 判据是「客服碰过没」', () => {
const seedAttrib = { const seedAttrib = {
assignmentId: 'batch-N', assignmentId: 'batch-N',
assignedBy: 'leader-1', assignedBy: 'leader-1',
...@@ -966,100 +995,78 @@ describe('归因继承的边界 — 理由整组更换 = 全新工单', () => { ...@@ -966,100 +995,78 @@ describe('归因继承的边界 — 理由整组更换 = 全新工单', () => {
selectionMode: 'explore', selectionMode: 'explore',
priorityScoreAtAssign: 77, priorityScoreAtAssign: 77,
}; };
const seedPlan = (over: Partial<Plan>): Partial<Plan> => ({
test('⭐⭐ 缺口类型整组换掉(龋齿 → 缺牙)→ 新版本**不带**批次归因', async () => { patientId: 'pat-x', version: 1, status: 'assigned', assigneeUserId: 'staff-1',
const { prisma, plans } = makeStore({ targetClinicId: 'clinic-home', reasons: [{ scenario: SCEN, subKey: 'caries@11' }],
plans: [ ...seedAttrib, ...over,
{
id: 'p-9', patientId: 'pat-9', version: 1, status: 'assigned',
assigneeUserId: 'staff-9', targetClinicId: 'clinic-home',
reasons: [{ scenario: SCEN, subKey: 'caries@11' }],
...seedAttrib,
},
],
}); });
prisma.$queryRaw = jest.fn(async () => [{ patient_id: 'pat-9', clinic_id: 'clinic-home' }]); const run = async (seed: Parameters<typeof makeStore>[0], subKey: string) => {
await engine(prisma, makeScenario([hit('pat-9', 'missing_tooth@whole', 60, 'clinic-diag')])) const store = makeStore(seed);
store.prisma.$queryRaw = jest.fn(async () => [{ patient_id: 'pat-x', clinic_id: 'clinic-home' }]);
await engine(store.prisma, makeScenario([hit('pat-x', subKey, 60, 'clinic-diag')]))
.runAllForHost({ hostId: HOST, tenantId: TENANT, now: NOW }); .runAllForHost({ hostId: HOST, tenantId: TENANT, now: NOW });
return store.plans.find((p) => p.patientId === 'pat-x' && p.version === 2)!;
};
const v2 = plans.find((p) => p.patientId === 'pat-9' && p.version === 2)!; test('⭐⭐ 客服**没碰过** + 理由整组换掉(龋齿 → 缺牙)→ 照样**继承**', async () => {
// 批次分的是**人**不是诊断:主管要知道的是"这 100 个人有多少被联系了"。
// 这个人一次都没被联系过,账就还欠着 —— 理由怎么变都不改变这件事。
const v2 = await run({ plans: [seedPlan({ id: 'p-a' })] }, 'missing_tooth@whole');
expect(v2.assignmentId).toBe('batch-N');
expect(v2.selectionMode).toBe('explore');
});
test('⭐⭐ 客服**打开过详情页**(view)→ 账落定,**不继承**', async () => {
// ⭐ view 是主判据(教条 T21):生产实测 view 1,024 条 vs plan_executions 7 条。
const v2 = await run({ plans: [seedPlan({ id: 'p-b' })], viewedPlanIds: ['p-b'] }, 'caries@11;36');
expect(v2.assignmentId).toBeNull(); expect(v2.assignmentId).toBeNull();
expect(v2.assignedBy).toBeNull(); // ⭐ 快照列必须一起归零:留下 selectionMode='explore' 却查不到批次的孤儿行,
expect(v2.assignStrategy).toBeNull(); // 会污染 T20 的因果分析(探索组样本本来就小)
// ⭐ 快照列必须**一起**归零 —— 留下 selectionMode='explore' 却查不到批次的孤儿行,
// 会直接污染 T20 的因果分析(探索组样本本来就小)
expect(v2.selectionMode).toBeNull(); expect(v2.selectionMode).toBeNull();
expect(v2.priorityScoreAtAssign).toBeNull(); expect(v2.priorityScoreAtAssign).toBeNull();
}); });
test('⭐⭐ 但批次**不会因此丢人**:旧版本仍带着归因留在批次里', async () => { test('⭐ 写了回访结果(snoozedUntil)→ 不继承', async () => {
// 这条是上一条能成立的前提。D-12 当初怕的「分母静默缩水」由它兜住 —— const v2 = await run(
// 跟踪按患者取最新版时,带 batch-N 的行只剩 v1,于是这个患者照样算在批次里, { plans: [seedPlan({ id: 'p-c', snoozedUntil: new Date('2027-01-01') })] },
// 且状态是 superseded → 按 T20′ 记为「已处理」,语义正是"这个批次针对的需求没了"。 'caries@11;36',
const { prisma, plans } = makeStore({ );
plans: [ expect(v2.assignmentId).toBeNull();
{
id: 'p-10', patientId: 'pat-10', version: 1, status: 'active',
assigneeUserId: null, targetClinicId: 'clinic-home',
reasons: [{ scenario: SCEN, subKey: 'caries@11' }],
assignmentId: 'batch-N', assignedBy: 'leader-1',
releaseReason: 'over_capacity', releaseNote: '手上还有 40 个',
},
],
}); });
prisma.$queryRaw = jest.fn(async () => [{ patient_id: 'pat-10', clinic_id: 'clinic-home' }]);
await engine(prisma, makeScenario([hit('pat-10', 'missing_tooth@whole', 60, 'clinic-diag')]))
.runAllForHost({ hostId: HOST, tenantId: TENANT, now: NOW });
const v1 = plans.find((p) => p.patientId === 'pat-10' && p.version === 1)!; test('⭐ 退回过(releaseReason)→ 不继承 —— 退回也是一种处置', async () => {
expect(v1.assignmentId).toBe('batch-N'); // ⛔ 旧行的归因绝不能被清 const v2 = await run(
expect(v1.releaseReason).toBe('over_capacity'); // 退回率的分子也还在 { plans: [seedPlan({ id: 'p-d', status: 'active', assigneeUserId: null, releaseReason: 'over_capacity' })] },
expect(v1.status).toBe('superseded'); 'caries@11;36',
);
expect(v2.assignmentId).toBeNull();
}); });
test('⭐ 判据看**缺口类型**不看 subKey 原文 —— 同一需求多长一颗牙不算全新', async () => { test('⭐ 提交过执行(contactAttempts)→ 不继承', async () => {
// subKey 自带牙位(caries_no_filling@18;28),按原文比会把「又坏了一颗牙」 const v2 = await run({ plans: [seedPlan({ id: 'p-e', contactAttempts: 2 })] }, 'caries@11;36');
// 误判成全新工单,那是矫枉过正:患者还是那个患者,需求还是那个需求。 expect(v2.assignmentId).toBeNull();
const { prisma, plans } = makeStore({
plans: [
{
id: 'p-11', patientId: 'pat-11', version: 1, status: 'assigned',
assigneeUserId: 'staff-11', targetClinicId: 'clinic-home',
reasons: [{ scenario: SCEN, subKey: 'caries_no_filling@18' }],
...seedAttrib,
},
],
}); });
prisma.$queryRaw = jest.fn(async () => [{ patient_id: 'pat-11', clinic_id: 'clinic-home' }]);
await engine(prisma, makeScenario([hit('pat-11', 'caries_no_filling@18;28', 60, 'clinic-diag')]))
.runAllForHost({ hostId: HOST, tenantId: TENANT, now: NOW });
const v2 = plans.find((p) => p.patientId === 'pat-11' && p.version === 2)!; test('⭐⭐ 不继承时批次**不会丢人**:旧行仍带着归因,跟踪取到的就是它', async () => {
expect(v2.assignmentId).toBe('batch-N'); // 这条是上面几条能成立的前提。D-12 当初怕的「分母静默缩水」由它兜住 ——
expect(v2.selectionMode).toBe('explore'); // 带 batch-N 的行只剩 v1,跟踪按患者取最新版正好取到,状态 superseded → 记「已处理」。
const store = makeStore({ plans: [seedPlan({ id: 'p-f' })], viewedPlanIds: ['p-f'] });
store.prisma.$queryRaw = jest.fn(async () => [{ patient_id: 'pat-x', clinic_id: 'clinic-home' }]);
await engine(store.prisma, makeScenario([hit('pat-x', 'caries@11;36', 60, 'clinic-diag')]))
.runAllForHost({ hostId: HOST, tenantId: TENANT, now: NOW });
const v1 = store.plans.find((p) => p.id === 'p-f')!;
expect(v1.assignmentId).toBe('batch-N'); // ⛔ 旧行的归因绝不能被清
expect(v1.status).toBe('superseded');
}); });
test('⭐ 需求**增加**(交集非空)照样继承 —— 别把"又多一个缺口"当成换了工单', async () => { test('⛔ 没进过批次的单不去查 view 事件 —— 全量重算会多出 44 万次查询', async () => {
const { prisma, plans } = makeStore({ const store = makeStore({
plans: [ plans: [seedPlan({ id: 'p-g', assignmentId: null, assignedBy: null, selectionMode: null, priorityScoreAtAssign: null })],
{
id: 'p-12', patientId: 'pat-12', version: 1, status: 'assigned',
assigneeUserId: 'staff-12', targetClinicId: 'clinic-home',
reasons: [{ scenario: SCEN, subKey: 'missing_tooth@14' }],
...seedAttrib,
},
],
}); });
prisma.$queryRaw = jest.fn(async () => [{ patient_id: 'pat-12', clinic_id: 'clinic-home' }]); store.prisma.$queryRaw = jest.fn(async () => [{ patient_id: 'pat-x', clinic_id: 'clinic-home' }]);
await engine( await engine(store.prisma, makeScenario([hit('pat-x', 'caries@11;36', 60, 'clinic-diag')]))
prisma, .runAllForHost({ hostId: HOST, tenantId: TENANT, now: NOW });
makeScenario([ const called = (store.prisma.planEventLog.groupBy as jest.Mock).mock.calls;
hit('pat-12', 'missing_tooth@14', 60, 'clinic-diag'), for (const [args] of called) expect(args.where.planId.in).not.toContain('p-g');
hit('pat-12', 'caries@27', 55, 'clinic-diag'),
]),
).runAllForHost({ hostId: HOST, tenantId: TENANT, now: NOW });
const v2 = plans.find((p) => p.patientId === 'pat-12' && p.version === 2)!;
expect(v2.assignmentId).toBe('batch-N');
}); });
}); });
...@@ -87,7 +87,7 @@ Y 轴 窗口温度 plan_reasons 566,365 条 ← daysSince 现成,读时 ...@@ -87,7 +87,7 @@ Y 轴 窗口温度 plan_reasons 566,365 条 ← daysSince 现成,读时
| **D-9** | 名册 / 负载 | **一个** REST 端点 `GET /pac/v1/plans/agents?clinicId=&withWorkload=`,MCP 工具 `get_agents` 与前端共用 | ❌ service 的两端点方案:五之四明写「开两个必然口径漂移」 | | **D-9** | 名册 / 负载 | **一个** REST 端点 `GET /pac/v1/plans/agents?clinicId=&withWorkload=`,MCP 工具 `get_agents` 与前端共用 | ❌ service 的两端点方案:五之四明写「开两个必然口径漂移」 |
| **D-10** | 撤销授权判据 | `batch.createdBy === actorUserId \|\| permissions.includes(PLAN_VIEW_ALL)`,在 service 顶部一次性校验,**不逐行调 `assertCanRecycle`** | ❌ mcp 的 `confirmedByUser: boolean`:模型自己填,拦不住任何东西 | | **D-10** | 撤销授权判据 | `batch.createdBy === actorUserId \|\| permissions.includes(PLAN_VIEW_ALL)`,在 service 顶部一次性校验,**不逐行调 `assertCanRecycle`** | ❌ mcp 的 `confirmedByUser: boolean`:模型自己填,拦不住任何东西 |
| **D-11** | 「已动过 / 不可撤销」判据 | **以 `plan_event_logs` 的 `view` 事件为主** | ⚠️ **原案已部分证伪(2026-08-02 实测)**:原建议 `EXISTS(plan_executions) OR contactAttempts > 0 OR view`。实测 `contact_attempts > 0` **仅 7 条,与 `plan_executions` 同源**(提交执行时才累加),帮不上忙。可用信号只有 **`view` 事件(已 1,024 条)**。理由不变:回写率仅 11%,只看执行记录会把 89% 已打过电话的单静默收走。⚠️ 另注意 **撤销(主管收整批) ≠ 退回(客服退单条)**,见教条 T21 | | **D-11** | 「已动过 / 不可撤销」判据 | **以 `plan_event_logs` 的 `view` 事件为主** | ⚠️ **原案已部分证伪(2026-08-02 实测)**:原建议 `EXISTS(plan_executions) OR contactAttempts > 0 OR view`。实测 `contact_attempts > 0` **仅 7 条,与 `plan_executions` 同源**(提交执行时才累加),帮不上忙。可用信号只有 **`view` 事件(已 1,024 条)**。理由不变:回写率仅 11%,只看执行记录会把 89% 已打过电话的单静默收走。⚠️ 另注意 **撤销(主管收整批) ≠ 退回(客服退单条)**,见教条 T21 |
| **D-12**<br/>*(2026-08-02 修订)* | 引擎新版本的字段继承 | 归因列**与 `carryAssignment` 解耦**(原案),但**不是无条件** —— ⭐ **召回理由整组换掉时不继承**(新版本已是另一张工单)。判据取「临床缺口类型」交集(subKey 去牙位),空集 → 不继承。⚠️ 原「无条件」其实是**给当时跟踪查询缺陷打的补丁**(那时 detail 过滤 `supersededAt: null`,旧行看不见,不继承就分母缩水);跟踪改成「按患者取最新版、不过滤 superseded」后,历史留在旧行上,「无条件」不再必需且有害。⚠️ 快照五列与 `assignment_id` **同生共死**(孤儿快照会污染 T20 因果分析) | ❌ 只改 `carryAssignment`(data D1 / service N2 / mcp 都是这个):实测 `plan-engine.service.ts:471` `const carryAssignment = latest?.status === 'assigned' && !clinicMoved` —— **退回后 plan 是 `status='active'`**`plan.service.ts:672`),走不到这个分支,退回单的归因会连分子带分母一起静默归零 | | **D-12**<br/>*(2026-08-02 修订)* | 引擎新版本的字段继承 | 归因列**与 `carryAssignment` 解耦**(原案),但**不是无条件** —— ⭐ 判据是**客服碰过没**`carryAttribution`):没碰过 → 这单还欠着,理由怎么变都继承;碰过了 → 批次这一条的账已落定,不继承。「碰过」复用 T21/D-11 那一套(`view` 事件为主 + `snoozedUntil`/`releaseReason`/`contactAttempts`)。⛔ **不要拿「召回理由变了」当判据** —— 理由变化不携带「客服做没做事」的信息,两个方向都会错,详见教条 T20″。⚠️ 快照五列与 `assignment_id` **同生共死** |
| **D-13** | 服务文件命名 | 统一 **`apps/pac-service/src/modules/plan/plan-assignment.service.ts`** + `agent-roster.service.ts` | service 的 `assignment.service.ts` 撞名 | | **D-13** | 服务文件命名 | 统一 **`apps/pac-service/src/modules/plan/plan-assignment.service.ts`** + `agent-roster.service.ts` | service 的 `assignment.service.ts` 撞名 |
| **D-14** | UI 原语 | **零新依赖**:checkbox 用原生 `<input type="checkbox" className="... accent-brand-600">``patient-picker-rail.tsx:518-523` 已是此写法),折叠用受控 `useState<Set<string>>`,tooltip 用已装的 `hover-card` | ❌ mcp 「必须补 checkbox / collapsible」:Radix accordion **未安装**,内网拉包本身是风险 | | **D-14** | UI 原语 | **零新依赖**:checkbox 用原生 `<input type="checkbox" className="... accent-brand-600">``patient-picker-rail.tsx:518-523` 已是此写法),折叠用受控 `useState<Set<string>>`,tooltip 用已装的 `hover-card` | ❌ mcp 「必须补 checkbox / collapsible」:Radix accordion **未安装**,内网拉包本身是风险 |
| **D-15** | MCP 工具命名 | 全 snake_case,与现有 7 个(`mcp-server.factory.ts` 7 处 `registerTool`)一致:`get_current_user` / `get_agents` / `get_cohort_attributes` / `list_assignment_batches` / `get_assignment_detail` | 教条五的 camelCase 是**接口草案记法**,不是工具名 | | **D-15** | MCP 工具命名 | 全 snake_case,与现有 7 个(`mcp-server.factory.ts` 7 处 `registerTool`)一致:`get_current_user` / `get_agents` / `get_cohort_attributes` / `list_assignment_batches` / `get_assignment_detail` | 教条五的 camelCase 是**接口草案记法**,不是工具名 |
......
...@@ -373,166 +373,49 @@ plan_event_logs view 1,024 ✅ 唯一可用 —— 客服打开过详情页 ...@@ -373,166 +373,49 @@ plan_event_logs view 1,024 ✅ 唯一可用 —— 客服打开过详情页
`max(version)` 天然去重;关闭时最新版就是那条 superseded 行,正好是信号)。 `max(version)` 天然去重;关闭时最新版就是那条 superseded 行,正好是信号)。
回归见 `tests/assignment-tracking-invariants.spec.ts` 第三组。 回归见 `tests/assignment-tracking-invariants.spec.ts` 第三组。
##### T20″ · 归因继承的边界:理由整组换掉 = 全新工单,不继承(2026-08-02 产品裁决) ##### T20″ · 归因继承的边界:看**客服碰过没**,不看召回理由变没变(2026-08-02 产品裁决)
> 产品原话:「该患者新的诊断等信号形成的工单升版本了是全新的,不应该继承吧」。
先分清**两套继承**——它们此前被混在一起讨论过: 先分清**两套继承**——它们此前被混在一起讨论过:
| 继承什么 | 条件 | 回答的问题 | | 继承什么 | 条件 | 回答的问题 |
|---|---|---| |---|---|---|
| **客服相关**`status` / `assignee_user_id` / `assigned_at` / `contact_attempts`) | `carryAssignment` = 旧版 assigned && 未换诊所 | 还挂在同一个人名下吗 | | **客服相关**`status` / `assignee_user_id` / `assigned_at` / `contact_attempts`) | `carryAssignment` = 旧版 assigned && 未换诊所 | 还挂在同一个人名下吗 |
| **批次归因**`assignment_id` / `assigned_by` / `assign_strategy` + 快照五列) | `carryAttribution` = **还是同一张工单吗** | 这单当初来自哪批 | | **批次归因**`assignment_id` / `assigned_by` / `assign_strategy` + 快照五列) | `carryAttribution` = **客服碰过没** | 这单的账结了吗 |
升版本的判据就是 **(scenario, subKey) 集合变了 = 召回理由换了**。理由**整组**换掉时,
新版本已是一张全新工单(患者新长了颗龋齿,跟主管当初按「潜在种植」分下去的那单没关系),
**不该继续算在那个批次头上**
- **判据取「临床缺口类型」交集,不是 subKey 原文** —— subKey 自带牙位
`caries_no_filling@18;28`),按原文比会把「又坏了一颗牙」误判成全新工单,属矫枉过正。
- **快照五列与 `assignment_id` 同生共死** —— 留下 `selectionMode='explore'` 却查不到批次的
孤儿行,会直接污染 T20 的因果分析(探索组样本本来就小)。
**与 T20′ 恰好咬合**:不继承时旧行仍带 `assignment_id` 留在批次里且已 superseded →
跟踪按「按患者取最新版」拿到的就是那条 superseded 行 → 记为 **`resolved`(已处理)**
语义完全正确:**这个批次针对的需求确实没了**;而新工单干净地回池,等下一个批次。两边都不用打补丁。
> ⚠️ **D-12「无条件继承」是给当时跟踪缺陷打的补丁**:那时 `detail` 过滤 `supersededAt: null`,
> 旧行看不见,不继承就等于分母缩水。跟踪修好之后,「无条件」不再必需,而且有害。
⚠️ **已知边界(刻意不做,不是漏了)**:批次目标需求消失、但**别的**需求还在时
`{缺牙, 龋齿}``{龋齿}`),交集非空 → 仍然继承,于是「种植需求其实已解决」在批次里看不出来。
要判准它需要把 `criteria.potentialTreatment` 映射到 subKey(implant→missing_tooth…)——
那是**一套新口径**,得产品先定;当前样本量下不值得为它引入一张映射表(T14:没数据支撑的精度是假精度)。
---
## 四、关键数据事实(2026-08 生产实测,避免后人重新推导)
### 4.0 初选矩阵实测(本地 5,825 患者样本,8 × 3 全部有量)
| 潜在治疗 | 🔥热 | 🌡温 | ❄️冷 | 合计 |
|---|---|---|---|---|
| 潜在补牙 filling | 83 | 117 | 989 | 1,189 |
| 潜在拔牙 extraction | 101 | 130 | 652 | 883 |
| 潜在修复 restoration | 34 | 65 | 521 | 620 |
| 潜在种植 implant | 43 | 39 | 294 | 376 |
| 潜在牙周 perio | 17 | 11 | 345 | 373 |
| 潜在根管 endo | 16 | 21 | 169 | 206 |
| 潜在正畸 ortho | 25 | 38 | 89 | 152 |
| 潜在早矫 early_ortho | 19 | 19 | 43 | 81 |
> ⚠️ 用 `focusCategory` 口径时,这 81 个**早矫**机会会被埋进 152 个正畸里 ——
> 而两者的打法、话术、沟通对象(家长 vs 本人)完全不同。这是换 X 轴最直接的收益。
### 4.1 为什么温度选「临床窗口」而不是 RFM / 生命周期
三个候选维度在召回池里的真实分布:
| 维度 | 分布 | 结论 |
|---|---|---|
| `urgency_level` 急迫性 | **78% 打满 10** | ❌ 判定过宽,该分的没分开 |
| `lifecycle_stage` 生命周期 | **60.6% 成熟客**,流失客仅 0.9% | ❌ 阈值(末诊 540 天内均为成熟)跟牙科节奏不匹配 |
| `rfm` 价值分群 | 28% / 24% / 44% ✅ 均匀 | ⚠️ 但**不回答「现在有没有生意可做」** |
| **临床窗口** | 热 11% / 温 11% / 冷 78% | ✅ **偏斜但有意义** —— 池子本就是积压,78% 超窗是真相 |
> 窗口口径下热+温仍有 4.6 万条,**远超团队产能**,三档够用,不必再细分。
### 4.2 专属客服(分配第一因素)
``` ```
存储 patients.preferences->'dedicatedCs' = { id, name } 客服没碰过 → 这单还欠着 → 继承(理由怎么变都继承)
来源 摄入时从宿主 current_task_director 落 客服碰过了 → 批次这一条的账已落定 → 不继承,后面再冒出来的是新工单
覆盖 368,868 / 441,875 = 83.5%,涉及 1,092 个客服
``` ```
⚠️ **但单诊所在岗客服仅 30-39 人** —— 说明专属客服里**大量已离职** > **批次分的是「人」不是「诊断」**:主管要知道的是「这 100 个人有多少被联系/处理了」
分配时必须校验在岗,否则会把任务分给离职的人 > 一个人一次都没被联系过,账就还欠着 —— 临床理由怎么变都不改变这件事
### 4.3 在岗客服的数据源 **曾经写错的一版判据:「召回理由整组换掉就不继承」。两个方向都会错**(产品当场指出):
``` 1. 召回**一会儿出现一会儿不出现,恰恰可能是客服处理过**造成的(患者来了、治了一部分、
表 patient_return_visits.task_director_id / task_director_name 又诊断出新问题)→ 该结账的反而被当成"理由变了"继承走。
索引 (host_id, tenant_id, clinic_id, task_director_id, task_date DESC) 2. **同一种诊断再次出现,也不代表客服没处理过** → 该结账的同样继承了。
覆盖 166.7 万条回访,99.9% 带客服 id,2,196 个客服
```
⚠️ **判「在岗」必须用 `source_created_at`,不能用 `task_date`** ——
后者含未来排程(生产实测最远 2033 年、DW 侧甚至有 2121 年),会把早已离职的人误判为在岗。
⚠️ **客服与诊所是多对多**(实测 24% 跨诊所),名册里同一人会出现在多个诊所下,属正常。
### 4.35 现有实现调研结论(2026-08,四方向并行 + 交叉复核,逐条已自验)
| 结论 | 证据 |
|---|---|
| **`staff` 也持有 `PLAN_ASSIGN` / `PLAN_RECYCLE`****不能用它区分主管** | `enums/index.ts:37-46` |
| leader 相对 staff **只多 2 个权限**`PLAN_VIEW_ALL` + `STATS_VIEW` | 同上 |
| **`STATS_VIEW` 是死权限** —— 全仓仅 3 处命中,零端点零组件 | — |
| **MCP 端点是 `@Public()`**`PermissionsGuard` 短路;写工具必须在 handler 内自查 | `mcp.controller.ts:26-27` |
| **`McpAuthContext` 只有 `{ scope, permissions }`**,无 role | `mcp-auth.service.ts:7-10` → 契合 T19,够用 |
| 7 个 MCP 工具**全部只读**,无任何写工具 | `mcp-server.factory.ts` 7 处 `registerTool` |
| **`plan_event_logs` 只写不读** —— 5 个写入点,**读取点 0** | 效果跟踪将是它的第一个读场景 |
| **`plan_reasons.campaignId` 从未被写过**,且是 reason 级、supersede 不带 | 不能复用作分配批次 |
| ⚠️ **引擎丢归属不记账** —— `patch.assigneeUserId = null` 且无 `recordPlanEvent` | `plan-engine.service.ts:398` |
| ⚠️ **`recycle` 的 `reason` 被丢弃** —— controller 写作 `@Body() _dto`,service 签名无该参 | `plan.controller.ts:127` |
| **`RECYCLE_TIMEOUT_HOURS = 24` 模块级硬编码**,注释说应来自 `tenant.rules_config`(该配置不存在) | `plan.service.ts:50` |
| **PAC 无 users 表**;客服名册只有 `data/jvs-dw/users.json`(结构已按未来两表设计) | `mock-users.ts:4-25` |
| 前端**三处 assign 调用全传 `user.sub`**,从未传过别人 | rail:150 / list:171,190 |
| 前端**无法把 userId 翻成人名** —— `dictionary.users[sub]` 只覆盖当前登录人 | `lib/utils.ts:66-71` |
| `components/ui/` **无 table / checkbox / tooltip / accordion** | 确认单要用,需先补 |
| artifact iframe 是 `sandbox="allow-scripts"` + CSP `connect-src 'none'` | **卡片内不可能触发写** |
> ⚠️ 「引擎丢归属不记账」最危险:主管分了 50 单,引擎重算把一部分静默收回池子,**主管毫不知情**,
> 且退回率统计天生偏低。做分配前必须先补这条记账。
### 4.36 开发规划阶段的新发现(2026-08-02,对抗批判产出)
以下五条是**四份分层方案都漏掉、由批判环节抓出**的,开发时务必留意:
| # | 发现 | 后果 |
|---|---|---|
| **1** | `followup_plans` 还需第 6 列 **`assign_strategy`**`dedicated`/`spread`/`manual`) | T20 要按「专属 vs 铺平」算完成率差。⚠️ **事后补不了** —— `patients.preferences.dedicatedCs` 是 upsert 覆盖的「当前值」,历史丢失,**分配当时不记就永久没了** |
| **2** | 撤销的「已动过」判据**不能只看 `plan_executions`** | 回写率仅 11%(4.5)。只看执行记录会把 89% **已打过电话**的单收走并清归属 —— 正是 `claim-guard.ts` 第 1 条要防的。⚠️ 规划建议叠加 `contactAttempts`**实测该字段与 `plan_executions` 同源、同为 7 条,帮不上忙** → 最终以 **`view` 事件**为主,见 T21 |
| **3** | 归因列必须**无条件继承**,与 `carryAssignment` 解耦 | `plan-engine.service.ts:471` 的条件是 `latest?.status === 'assigned'`,而**退回后 plan 是 `status='active'`**`plan.service.ts:672`)→ 走不到该分支,退回单的归因会**连分子带分母静默归零** |
| **4** | 「引擎丢归属不记账」实测是 **5 处**,不是 1 处 | 除 `plan-engine.service.ts:398`,还有升版本(`:471`/`:511`)、`closeStaleActivePlan(:139)``recycle-scheduler(:87)``plan.service.recycle(:670)` |
| **5** | `assign()` / `recycle()` **不校验 `scope.clinicIds`**`plan_event_logs` / `plan_reason` **不在 `SCOPED_MODELS`** | 跨诊所越权 + 租户隔离扩展覆盖不到。与分配无关(现存问题),但分配会放大影响面 |
### 4.37 ID 空间验证(G0.1,已实测通过)
```
登录侧有行为的人 58 回访名册 2,198 重合 47(81%)
```
**同一 id 空间成立。** 但 11 个只在登录侧的回访数全为 0 →
**新入职 / 只做召回不做回访的客服,名册里查不到**
→ 设计要求:名册之外仍须允许主管**显式指定**,并提示「该客服无近期回访记录」 **理由的变化根本不携带「客服做没做事」的信息**,它只反映患者临床状态在动。拿它当判据是找错了轴
### 4.38 🔴 圈人关联画像必须按**患者**取当前版,不能按 `plan.persona_id` ⚠️ 「碰过没」**不新造判据,直接复用 T21 / D-11 那一套**(撤销整批时判「哪些单不能收」):
**`view` 事件为主**(实测 view 1,024 条 vs `plan_executions` 7 条,回写率 11%),
外加行上三个现成强信号(`snoozedUntil` / `releaseReason` / `contactAttempts`)。
⛔ 别改成只看 `plan_executions` —— 那会把 89% 已经打过电话的单判成「没碰过」,
于是它们的归因被一直继承下去,**批次的账永远结不掉**
2026-08-02 做温度轴时抓到的**静默归零**缺陷(当时已写进 `assignment-proposal.service`): ⚠️ **性能**`view` 事件只对**带 `assignment_id`** 的单查(批量路径一次 `groupBy` 预取)。
不收窄的话 `runAllForHost` 全量会为 44 万患者各查一次。
```sql **与 T20′ 咬合**:不继承时旧行仍带着 `assignment_id` 留在批次里且已 superseded →
-- ❌ 看着天经地义,实际是个定时炸弹 跟踪按「按患者取最新版」取到的就是那条旧行,它身上带着当时的处置(`releaseReason` / 已 view),
WHERE pe.id = fp.persona_id AND pe.superseded_at IS NULL **批次的分子分母都不丢**。新工单干净地回池,等下一个批次。
```
`followup_plans.persona_id` 指向**建 plan 那一刻**的画像版本;画像一升版本那行就被 supersede, > ⚠️ D-12「无条件继承」也是**给当时跟踪缺陷打的补丁**(那时 `detail` 过滤 `supersededAt: null`,
而 plan 的外键**不跟着走** → 条件恒为假 → 整个「潜在治疗」筛选变成 0 条, > 旧行看不见,不继承就分母缩水)。跟踪修好后它不再必需,且会把一张全新工单硬算进旧批次。
**不报错、不告警**,主管只看到「这个格子没人了」。
**实测**(跑完一次全量 persona 重算之后):
```
池子里还指向"活着的画像版本"的 plan 97 / 2,724
同一个「潜在种植」条件 · 按 persona_id 0 条
同一个「潜在种植」条件 · 按 patient_id 376 条 ← 列表页 buildListWhere 一直是这个口径
```
**一律按 `pe.patient_id = fp.patient_id AND pe.superseded_at IS NULL`**,与列表页同口径。 ⚠️ **快照五列与 `assignment_id` 同生共死** —— 留下 `selectionMode='explore'` 却查不到批次的
两处不一致正是 T6a「同源保证」明令要防的「矩阵上有人、点进去列表没有」。 孤儿行,会直接污染 T20 的因果分析(探索组样本本来就小)。
回归护栏见 `tests/assignment-cohort-join.spec.ts`(单测拦不住 SQL 语义,退而锁源码形态)。
### 4.4 现有 MCP 工具的缺口 ### 4.4 现有 MCP 工具的缺口
......
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