-
feat(plan): 召回分配 P1 —— plan_assignments 建表 + 归因六列 + 引擎守恒 · dc31d486
⚠ ️ **P1.1 与 P1.2 必须同一次发布,中间不许发版本。** 只上 P1.1(有列没有守恒)会让每日重算把已分配单的归因静默抹掉,不报错无告警, 等发现时历史已经花了;只回滚 P1.2 同理。 ## P1.1 建表与六列 新表 `plan_assignments` = **一次分配动作**(一个批次)。立柱保守,按 plan_event_logs.details 那条既有口径:「只放查询不按它过滤的内容,要按它筛就该立柱」—— 初筛条件进 criteria JSON、福利进 attributes JSON、人数/客服数**不立柱**(从 followup_plans COUNT,立柱等于埋一个会漂的冗余)。⭐ 撤销功能本身推迟到第二刀,但 status/revoked_at/revoked_by **三列现在就建齐** —— 否则那时要再取一次 followup_plans 的 ACCESS EXCLUSIVE。 followup_plans 加六列,**全部可空、全部无默认** —— 这是迁移不重写 25 万行的前提 (PG 11+ 纯元数据操作)。存量 NULL 即语义正确(= 上线前的自助认领单),**不回填**。 其中 `assign_strategy`(dedicated/spread/manual)是四份子方案全都漏掉的第六列: T20 要按「专属 vs 铺平」算完成率差,而唯一的反推来源 patients.preferences.dedicatedCs 是 upsert 覆盖的当前值 —— **分配当时不记就永久没了**。 关系显式写 `onDelete: Restrict`:Prisma 对可选关系默认 SetNull, 将来任何一次误删批次都会把 N 行归因静默置空且不报错。已实测 FK 拒绝删除。 迁移单文件多语句普通 DDL,**刻意不用 CONCURRENTLY**(20260728020000 踩过: Prisma 整份 SQL 一次 simple query → 隐式事务块 → 25001 + P3018 堵死流水线)。 本次不需要:唯一在存量表建的索引是 followup_plans(assignment_id),25 万行秒级。 首句 SET LOCAL lock_timeout='10s' 应对 R3(等待中的 ACCESS EXCLUSIVE 会挡住其后所有 SELECT,把「全站排队几分钟」换成「迁移快速失败」)。六个 ADD COLUMN 合并成**一条** ALTER —— 一次拿锁,不是六次抢锁。FK 走 NOT VALID + 单独 VALIDATE。⚠ ️ id 列**不给 DB 默认值**:本仓 uuid 一律 Prisma 客户端生成,写 DEFAULT gen_random_uuid() 会留永久 drift(第一版写了,已回滚重来)。 SCOPED_MODELS 补登 PlanEventLog + PlanAssignment(F5)。⛔ 不进 SOURCE_UNIT_MODELS: 两表都没有 source_unit 列,加进去每次查询都会注入不存在的字段。 ## P1.2 归因守恒(D-12)—— 本刀最容易写错且写错不报错的地方 直觉写法是把归因列也挂到 carryAssignment 上,但那个标志只在 `latest.status === 'assigned'` 时为真,而**退回后的 plan 是 `status='active'`**。 于是每一条被退回过的单,只要引擎升一次版本,批次归因就连**分子带分母**一起归零: 跟踪看到「分了 55 条」(实际 60),退回率的分母凭空缩水,而 T20 的全部结论 都建立在这个分母上。归因是历史事实,跟"现在还挂不挂在人名下"是两件事。 → assignment_id / assigned_by / assign_strategy / release_reason / release_note **无条件继承**,与 carryAssignment 解耦。⚠ ️ 唯独 assignment_expires_at **跟随归属**(对规划"六列全无条件继承"的一处收窄): 它不是归因,是"当前这次分配的截止时刻"。归属都没了还留着期限,列就不自洽 (非空 ⟺ 有一次在办的分配),超期口径得靠每个调用方都记得再 AND 一个 status='assigned' 才不出错 —— 那种隐式约定迟早破。 三处丢归属(引擎就地改 / recycle / 自动回收 cron)统一口径: 只清归属四件套 + 期限,**批次归因三列保留**。 assign() 补写 assigned_by(自认领时 == assignee,主表上就能分出"自己领的"和 "主管派的"),并清掉上一次的 release_reason(否则主表会同时显示 「已分配给 A」和「因手上排满被退回」)。⛔ 不碰 assignment_id / assign_strategy —— 单条认领不属于任何批次,瞎写会造出孤儿归因。 ## 已接受的口径漂移(产品 2026-08 确认) 一条 plan 在批次 N 被分 → 退回 → 进批次 N+1,assignment_id 就地翻成 N+1, 批次 N 的 COUNT 悄悄少 1。不为此另立 plan_assignment_members 关联表, 与「主表存当前值、全量历史留 plan_event_logs」的既有模式一致。⛔ 也不走 plan_event_logs.details 塞 assignmentId 做离线校正:那种用法按本仓 自己的规矩就该立柱,而立柱正是通用日志表要避免的业务化。已写进 schema 注释。 ## 验证 · 857 单测通过(新增 5 条守恒断言,含**退回后 status='active'** 那一支 —— 四份子方案全都漏了它,只测 assigned 会漏掉真正的看门场景) · migrate deploy 成功,**零 drift**(diff 只剩两条与本次无关的既有项) · 六列实测 nullable / 无 default → 元数据操作 · 本地真实数据端到端:造批次 + 两条归因单(一 assigned、一已退回), 各触发一次升版本 —— assigned 支:批次/assigned_by/strategy/assignee/expiry 全守恒 退回 支:批次/assigned_by/strategy/release_reason **全守恒**,expiry 为空 批次 COUNT live=2 in_hand=1 released=1;FK RESTRICT 实测拒绝删除批次 · 测试数据已清理(remaining batches=0) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>luoqi committed
| Name |
Last commit
|
Last update |
|---|---|---|
| .. | ||
| prisma.module.ts | Loading commit data... | |
| prisma.service.ts | Loading commit data... | |
| tenant-guard.extension.ts | Loading commit data... |