Commit dc31d486 by luoqi

feat(plan): 召回分配 P1 —— plan_assignments 建表 + 归因六列 + 引擎守恒

️ **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>
parent 3cbb3899
-- 门诊经理批次分配 —— 建 plan_assignments + followup_plans 六列归因
--
-- 【为什么这一份可以用普通 DDL,不必 CONCURRENTLY】
-- 20260728020000 那次踩过:Prisma 把整份 migration.sql 用**一次 simple query** 发给 PG,
-- PG 对「一个查询串里多条语句」隐式开事务块,而 CONCURRENTLY 禁止在事务块内跑 →
-- ERROR 25001,且 _prisma_migrations 留一条卡死记录(P3018)**堵死整条迁移流水线**。
-- 分界线是**语句条数,不是 Prisma 版本**。
-- 本次不需要它:唯一要在存量表上建的索引是 followup_plans(assignment_id),
-- 该表生产约 25 万行(秒级),不是 persona_features(7.66M / 5.4GB)也不是
-- patient_transactions(12–24M)。patient_facts 1300 万行本次**一个字都不动**。
--
-- 【为什么加六列不会重写 25 万行】
-- 六列**全部可空、全部无默认值** → PG 11+ 纯元数据操作(不 rewrite、不 full scan)。
-- 一旦给任何一列加 DEFAULT 或 NOT NULL,这个前提当场失效。
-- ⚠️ 存量**不回填**:NULL 即语义正确(= 上线前的自助认领单)。
-- 25 万行 UPDATE 会真正重写表 + 膨胀 + 触发 autovacuum,收益为零。
--
-- 【锁窗口】⚠️ 整份文件跑在**一个隐式事务**里 → 下面 ALTER 拿到的 ACCESS EXCLUSIVE
-- 会一直持有到 COMMIT,真实阻塞窗口 ≈ 整份迁移时长(本地实测 1–3 秒)。
-- **后人别往这份文件里追加慢语句**(回填、大表建索引),那会把阻塞窗口线性放大。
-- 首句 lock_timeout 是为了 R3:PG 锁队列近似 FIFO,一个**等待中**的 ACCESS EXCLUSIVE
-- 会挡住排在它后面的所有 SELECT —— 部署时若正好有慢查询压在 followup_plans 上,
-- 召回工作台会在 ALTER 真正拿到锁**之前**就整体停摆。设 10s 是把
-- 「全站排队几分钟」换成「迁移快速失败、重试即可」。
--
-- 【失败后怎么办】整份 SQL 在一个事务里,**不可能出现「表建了列没加」的半吊子 schema**。
-- npx prisma migrate resolve --rolled-back 20260802090000_add_plan_assignments
-- 然后避开 DW 08:00(沪) 落库后的增量 cron 窗口重试。
SET LOCAL lock_timeout = '10s';
-- ── plan_assignments ─────────────────────────────────────────
-- ⚠️ id 不给 DB 默认值:本仓 uuid 一律由 Prisma 客户端生成(@default(uuid())),
-- 与 plan_event_logs 等既有表一致。写 DEFAULT gen_random_uuid() 会造成永久 schema drift。
CREATE TABLE "plan_assignments" (
"id" UUID NOT NULL,
"host_id" UUID NOT NULL,
"tenant_id" TEXT NOT NULL,
"clinic_id" TEXT,
"created_by" TEXT NOT NULL,
"request_id" TEXT NOT NULL,
"criteria" JSONB NOT NULL,
"attributes" JSONB,
"expires_at" TIMESTAMPTZ(3) NOT NULL,
"status" TEXT NOT NULL DEFAULT 'confirmed',
"revoked_at" TIMESTAMPTZ(3),
"revoked_by" TEXT,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "plan_assignments_pkey" PRIMARY KEY ("id")
);
-- 幂等键:同一 requestId 重复提交回查已有批次,不产生第二个批次
CREATE UNIQUE INDEX "plan_assignments_host_id_tenant_id_request_id_key"
ON "plan_assignments"("host_id", "tenant_id", "request_id");
-- 主管看「我分的那些批」
CREATE INDEX "plan_assignments_host_id_tenant_id_clinic_id_created_at_idx"
ON "plan_assignments"("host_id", "tenant_id", "clinic_id", "created_at");
CREATE INDEX "plan_assignments_created_by_created_at_idx"
ON "plan_assignments"("created_by", "created_at");
ALTER TABLE "plan_assignments"
ADD CONSTRAINT "plan_assignments_host_id_fkey"
FOREIGN KEY ("host_id") REFERENCES "hosts"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- ── followup_plans 六列归因 ──────────────────────────────────
-- ⭐ **一条 ALTER 带六个 ADD COLUMN**(一次拿锁,不是六次抢锁)。
-- 拆成六条 ALTER 会让上面说的锁队列风险乘以六。
ALTER TABLE "followup_plans"
ADD COLUMN "assignment_id" UUID,
ADD COLUMN "assignment_expires_at" TIMESTAMPTZ(3),
ADD COLUMN "assigned_by" TEXT,
ADD COLUMN "release_reason" TEXT,
ADD COLUMN "release_note" TEXT,
ADD COLUMN "assign_strategy" TEXT;
CREATE INDEX "followup_plans_assignment_id_idx" ON "followup_plans"("assignment_id");
-- ⭐ ON DELETE RESTRICT 是**刻意的**:批次是审计对象,删批次会让 N 行归因永久失真。
-- NOT VALID + 单独 VALIDATE:后者只拿 SHARE UPDATE EXCLUSIVE(不挡读写),
-- 避免建约束时对 25 万行做一次带 ACCESS EXCLUSIVE 的全表校验。
-- 存量全是 NULL,校验必然通过。
ALTER TABLE "followup_plans"
ADD CONSTRAINT "followup_plans_assignment_id_fkey"
FOREIGN KEY ("assignment_id") REFERENCES "plan_assignments"("id")
ON DELETE RESTRICT ON UPDATE CASCADE
NOT VALID;
ALTER TABLE "followup_plans" VALIDATE CONSTRAINT "followup_plans_assignment_id_fkey";
......@@ -153,6 +153,7 @@ model Host {
planExecutions PlanExecution[]
planGenerationLogs PlanGenerationLog[]
planEventLogs PlanEventLog[]
planAssignments PlanAssignment[]
agentInvocations AgentInvocation[]
@@map("hosts")
......@@ -1048,6 +1049,41 @@ model FollowupPlan {
recallFeedback String? @map("recall_feedback") // 'up' | 'down'
recallFeedbackNote String? @map("recall_feedback_note") @db.Text
// ── 分配归因(2026-08 门诊经理批次分配)——————————————————————
// ⚠️ 六列全部可空、全部无默认值:这是迁移不重写 25 万行的前提(PG 11+ 纯元数据操作)
// 存量 NULL 即语义正确(= 上线前的自助认领单),**不回填**
/// 属于哪个分配批次。**null = 自助认领**,不是"未知"
/// ⚠️ 退回时**不清空** —— 它是退回率的分母:"这批分了 60 条,退回 5 条"要靠它算。
/// ⚠️ 引擎升版本时**无条件继承**( carryAssignment 解耦, plan-engine 那里的长注释)
assignmentId String? @map("assignment_id") @db.Uuid
/// 本条单子的时效(建批次时从 plan_assignments.expires_at 继承,允许逐条覆盖)
/// ⚠️ 与上面的 recycle_at **两条互不干扰的路**:recycle_at 是自动回收(生产未启用),
/// 本列是分配时效。别把两者混用,更别让一方去读另一方
assignmentExpiresAt DateTime? @map("assignment_expires_at") @db.Timestamptz(3)
/// 谁分的。现有 assignee_user_id 只记"分给谁","谁分的" ——
/// 没有它就分不出"主管派的""客服自己领的",而这正是分配功能要统计的第一件事
assignedBy String? @map("assigned_by")
/// 退回原因( ReleaseReason 枚举)。主表存**当前值**(就地覆盖),
/// 全量历史在 plan_event_logs.reason(同一条 plan 被退三次的三个原因都要留下)
/// ⚠️⚠️ ** recall_feedback**(上面那列):
/// recall_feedback 回答「这条召回准不准」(客服对系统/算法的反馈)
/// release_reason 回答「我为什么不接这单」(客服对任务的处置)
/// 两者可同时填,聚合时**绝不能混到一起**,否则两边统计一起废掉。
/// 本列**不带抑制窗**:退回是"换个人来做",不是"这人别召了"
/// 照抄 abandon_reasons 那套 suppressDays 会把患者静默压 30~90 天。
releaseReason String? @map("release_reason")
releaseNote String? @map("release_note") @db.Text
/// 这条是怎么落到该客服头上的:dedicated(命中专属客服)/ spread(专属容量不足转铺平)/ manual(主管指定)
/// **必须在分配当时就记,事后永远补不回来** —— 唯一的反推来源
/// patients.preferences.dedicatedCs upsert 覆盖的"当前值",历史不留痕。
/// 用途:反推"专属 vs 铺平的完成率差是否显著",进而决定默认策略要不要改。
assignStrategy String? @map("assign_strategy")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
/// 被新版本取代的时间;status='active'/'assigned' 等其他状态时为 null
......@@ -1062,6 +1098,10 @@ model FollowupPlan {
scripts PlanScript[]
summaries PlanSummary[]
executions PlanExecution[]
/// ⚠️ **必须显式写 onDelete: Restrict**Prisma 对可选关系的默认行为是 `SetNull` ——
/// 将来任何一次误删批次(数据修复 / 清测试数据)都会把 N 行的 assignment_id 静默置空,
/// **不报错**,而归因一旦置空就再也复原不了。批次是审计对象,本就不该能被删。
assignment PlanAssignment? @relation(fields: [assignmentId], references: [id], onDelete: Restrict)
@@unique([hostId, tenantId, patientId, version])
@@index([hostId, tenantId])
......@@ -1070,6 +1110,8 @@ model FollowupPlan {
@@index([assigneeUserId])
/// 召回池主查询路径:status + assignee 过滤 + priorityScore 排序联合索引
@@index([status, assigneeUserId, priorityScore])
/// 批次跟踪主路径:「这批分了哪些、现在什么状态」
@@index([assignmentId])
/// MIGRATION:partial UNIQUE (host_id, tenant_id, patient_id) WHERE status IN ('active','assigned')
/// 一个 patient 同时只一条活动 planScenario 维度去掉( v1 (patient_id, scenario) 改造而来)
@@map("followup_plans")
......@@ -1504,6 +1546,79 @@ model PlanEventLog {
@@map("plan_event_logs")
}
/// PlanAssignment **一次分配动作**(一个批次)。门诊经理在召回池选一批人、
/// 配一套打法,确认后落到 N 个客服名下;这张表存的是那"一次动作"本身。
///
/// ── 语义边界 ──
/// · 批次 工单队列。分配是**批次运营**,从不追求把池子分完 ——
/// "主管扛不住全量"不是缺陷,设计上就不打算分全量。
/// · 一次分配**不跨诊所**:福利政策与效果归因都按诊所走。
/// · 归属结果落在 followup_plans(assignment_id 等六列),本表只存批次级信息。
///
/// ── 立柱纪律( plan_event_logs.details 同一条口径)──
/// 「只放**查询不按它过滤**的内容;要按它筛就该立柱。」故:
/// · 初筛条件(潜在治疗 / 温度) criteria JSON,**不立柱**
/// 将来会有别的初筛策略,为当前这一种立两根柱子,第二种来了就得加列或让字段闲置。
/// · 福利 attributes JSON,**不立柱**。福利只是附加属性中的一种,不是一等公民。
/// · 人数 / 客服数 **不立柱**, followup_plans COUNT 出来。立柱等于埋一个会漂的冗余。
///
/// ⚠️ 已知且**已接受**的口径漂移:一条 plan 在批次 N 被分 退回 进批次 N+1,
/// assignment_id 就地翻成 N+1,批次 N COUNT 会悄悄少 1
/// 产品已确认可接受(2026-08),不为此另立 plan_assignment_members 关联表 ——
/// "主表存当前值、全量历史留在 plan_event_logs"的既有模式一致。
/// 也不要退而求其次去 plan_event_logs.details 里塞 assignmentId 做离线校正:
/// 那种用法(WHERE details->>'assignmentId'=X)按本仓自己的规矩就该立柱,
/// 而立柱正是通用日志表要避免的业务化,且无索引 + 混着高频 view 事件 = 全表 jsonb 扫。
model PlanAssignment {
id String @id @default(uuid()) @db.Uuid
hostId String @map("host_id") @db.Uuid
tenantId String @map("tenant_id")
/// 本批次面向的诊所。null = 集团统一客服池( followup_plans.target_clinic_id 同口径)
clinicId String? @map("clinic_id")
/// 发起分配的主管(宿主侧 user id)PAC 不立 users TEXT,展示名由服务端解析后随 payload 下发
createdBy String @map("created_by")
/// 幂等键 —— **服务端在生成确认单时铸造**,随确认单下发、确认时原样回传。
/// 不能让前端/模型生成:挡不住 SSE 重连与模型重试,"重复分配"这件事没有天然的
/// 自然键可去重(同一主管同一秒对同一批人再点一次是合法操作)
/// 冲突时**不报错**, requestId 回查已有批次原样返回 —— 抛错会让模型以为失败、
/// 换个参数再试一次,那才是真正的重复分配。
requestId String @map("request_id")
/// 初筛条件快照 { potentialTreatment, temperature, ... } + 收敛规则(orderBy/limit)
/// 快照而非引用:口径将来会变,批次要能解释"当时是按什么圈的"
criteria Json
/// 附加属性 { benefit?: { text } }。福利 v1 **不核销、不接宿主卡券**,
/// 只是话术勾子 + 归因标签(先验证"带福利的批次转化是否更高")
attributes Json?
/// 批次时效(T11:不存在无限期批次, NOT NULL)。单条可用
/// followup_plans.assignment_expires_at 覆盖
expiresAt DateTime @map("expires_at") @db.Timestamptz(3)
/// confirmed(已确认分配)| revoked(整批撤销)
/// ⚠️ 撤销功能本身推迟到第二刀,但三列现在就建齐 ——
/// 否则那时要再取一次 followup_plans ACCESS EXCLUSIVE
status String @default("confirmed")
revokedAt DateTime? @map("revoked_at") @db.Timestamptz(3)
revokedBy String? @map("revoked_by")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
host Host @relation(fields: [hostId], references: [id])
plans FollowupPlan[]
@@unique([hostId, tenantId, requestId])
/// 主管看"我分的那些批"
@@index([hostId, tenantId, clinicId, createdAt])
@@index([createdBy, createdAt])
@@map("plan_assignments")
}
// =============================================================
// 横切支撑 Agent Pool / 同步账本
// =============================================================
......
......@@ -465,6 +465,10 @@ export class PlanEngineService {
patch.assigneeUserId = null;
patch.assignedAt = null;
patch.recycleAt = null;
// ⚠️ 只清"在办期限",**不清 assignment_id / assigned_by / assign_strategy** ——
// 那三列是批次归因(这单来自哪批、谁分的、当时怎么落的人),是历史事实。
// 清了批次的分母就少一条,退回率、完成率一起失真。
patch.assignmentExpiresAt = null;
// ⭐ 这一步是**系统把单从客服手里收走**,必须留账。
// 不记的后果:主管分了 50 单,引擎重算悄悄收回一部分,主管毫不知情;
// 且这些单既没 release 也没 auto_release,退回率统计天生偏低。
......@@ -631,6 +635,27 @@ export class PlanEngineService {
assignedAt: carryAssignment ? latest!.assignedAt : null,
recycleAt: carryAssignment ? latest!.recycleAt : null,
contactAttempts: carryAssignment ? latest!.contactAttempts : 0,
// ── 分配归因:与 carryAssignment **解耦**,无条件继承 ─────────────────
// ⚠️⚠️ 这是最容易写错、且写错**不会报错**的一处。
// 直觉写法是把这几列也挂到 carryAssignment 上,但那个标志只在
// `latest.status === 'assigned'` 时为真 —— 而**退回后的 plan 是 `status='active'`**
// (见 PlanService.recycle)。于是每一条被退回过的单,只要引擎升一次版本,
// 它的批次归因就连**分子带分母**一起静默归零:
// 批次跟踪看到的是"分了 55 条"(实际 60),退回率的分母凭空缩水,
// 而 T20 的全部结论都建立在这个分母上。
// 归因是**历史事实**(这条单子来自哪个批次、谁分的、当时按什么策略落的人),
// 跟"现在还挂不挂在人名下"是两件事,不该被后者控制。
assignmentId: latest?.assignmentId ?? null,
assignedBy: latest?.assignedBy ?? null,
assignStrategy: latest?.assignStrategy ?? null,
releaseReason: latest?.releaseReason ?? null,
releaseNote: latest?.releaseNote ?? null,
// ⚠️ 唯独 assignmentExpiresAt **跟随归属**,不无条件继承:
// 它不是归因,是"当前这次分配的截止时刻"。归属都没了还留着期限,
// 会让列自身失去自洽(非空 ⟺ 有一次在办的分配),超期口径全得靠调用方
// 每次记得再 AND 一个 status='assigned' 才不出错 —— 那种隐式约定迟早破。
assignmentExpiresAt: carryAssignment ? latest!.assignmentExpiresAt : null,
reasons: {
// W3 末改:plan_reasons 维度 = (plan, scenario, sub_key) — 每独立临床缺口一行,
// 不再合并子规则(K08 缺牙 / K05 牙周 / K04 根管 是不同治疗体系,合并 reason
......
......@@ -633,6 +633,16 @@ export class PlanService {
assigneeUserId,
assignedAt: now,
recycleAt: new Date(now.getTime() + RECYCLE_TIMEOUT_HOURS * 3600 * 1000),
/// 谁触发的这次归属。自认领时 == assigneeUserId,指派他人时不同 ——
/// 这一列让「主管派的」与「客服自己领的」在**主表**上就能分开,不必每次回查事件流
assignedBy: actorUserId ?? assigneeUserId,
/// ⭐ 重新有人接手 → 上一次的退回原因不再是当前状态,清掉。
/// (历史那条仍在 plan_event_logs 里,不会丢)
/// 不清的话主表会同时出现"已分配给 A"和"因手上排满被退回",自相矛盾。
releaseReason: null,
releaseNote: null,
/// ⛔ 刻意**不写** assignmentId / assignStrategy:这是单条认领/指派路径,不是批次。
/// 批次那三列由 P2 的批量写入负责;这里瞎写会凭空造出归属不到任何批次的"孤儿归因"。
},
});
// 生命周期事件账本(append-only,收录边界见 PlanEventLog 注释)。
......@@ -726,6 +736,14 @@ export class PlanService {
assigneeUserId: null,
assignedAt: null,
recycleAt: null,
// 在办期限随归属一起结束(列的自洽:非空 ⟺ 有一次在办的分配)
assignmentExpiresAt: null,
// 退回原因落主表**当前值**;全量历史在 plan_event_logs(同一条被退三次要留三条)
releaseReason: releaseReason ?? null,
releaseNote: note ?? null,
// ⚠️ **不清 assignment_id / assigned_by / assign_strategy** ——
// 退回不改变"这单来自哪个批次"这个历史事实,而它正是退回率的**分母**:
// 「这批分了 60 条,退回 5 条」全靠 assignment_id 还在。清了就只剩分子。
// ⛔⛔ **绝不要在这里动 snoozedUntil**。
// 退回 = "换个人来做",不是"这人别召了"。照抄放弃原因那套抑制窗
// (ABANDON_REASON_META.suppressDays)会把被退回的患者静默压 30~90 天 ——
......
......@@ -84,7 +84,15 @@ export class RecycleSchedulerService implements OnModuleInit {
await tx.followupPlan.updateMany({
// 带 status 条件 → 并发下若已被人工返池/结案则本次不生效(幂等)
where: { id: p.id, status: 'assigned' },
data: { status: 'active', assigneeUserId: null, assignedAt: null, recycleAt: null },
// ⚠️ 只清归属四件套 + 在办期限;**assignment_id / assigned_by / assign_strategy 保留** ——
// 它们是批次归因(历史事实),清了批次的分母就少一条。口径与 PlanService.recycle 一致。
data: {
status: 'active',
assigneeUserId: null,
assignedAt: null,
recycleAt: null,
assignmentExpiresAt: null,
},
});
await recordPlanEvent(tx, {
hostId: p.hostId,
......
......@@ -19,7 +19,14 @@ import { currentTenant } from '../common/tenant-context';
const logger = new Logger('TenantGuard');
/** 带 tenant_id 的 14 张表(模型名)。 */
/**
* 带 tenant_id 的 16 张表(模型名)。
*
* ⚠️ 新建带 tenant_id 的表时**必须登记到这里** —— 漏了不会报错,只是少一层兜底,
* 而这一层正是"service 忘了加 tenantId"时唯一还站着的东西。
* 2026-08 补登 PlanEventLog + PlanAssignment:前者此前只写不读,分配的批次跟踪
* 是它的第一个读场景 —— 正好是纵深防御该兜底的时候。
*/
const SCOPED_MODELS = new Set<string>([
'Patient',
'PatientTransaction',
......@@ -33,10 +40,15 @@ const SCOPED_MODELS = new Set<string>([
'PlanGenerationLog',
'PlanScript',
'PlanSummary',
'PlanEventLog',
'PlanAssignment',
'AgentInvocation',
'SyncLog',
]);
/// ⛔ PlanEventLog / PlanAssignment **不进** SOURCE_UNIT_MODELS —— 两表都没有 source_unit 列,
/// 加进去会让每次查询都注入一个不存在的字段(Prisma 直接抛错)。
/// 品牌隔离对它们由 scope.clinicIds + 关联 plan/patient 覆盖。
/** 同时带 source_unit 列的模型(品牌粒度过滤可直接打在自身)。 */
const SOURCE_UNIT_MODELS = new Set<string>(['Patient', 'PatientFact', 'PatientReturnVisit']);
......
......@@ -36,6 +36,13 @@ interface Plan {
assignedAt: Date | null;
recycleAt: Date | null;
contactAttempts: number;
// 分配归因六列(2026-08)
assignmentId: string | null;
assignmentExpiresAt: Date | null;
assignedBy: string | null;
releaseReason: string | null;
releaseNote: string | null;
assignStrategy: string | null;
}
const inArr = (v: unknown): unknown[] | null =>
......@@ -62,6 +69,12 @@ function makeStore(seed: { plans?: Partial<Plan>[]; personas?: { patientId: stri
assignedAt: p.assignedAt ?? null,
recycleAt: p.recycleAt ?? null,
contactAttempts: p.contactAttempts ?? 0,
assignmentId: p.assignmentId ?? null,
assignmentExpiresAt: p.assignmentExpiresAt ?? null,
assignedBy: p.assignedBy ?? null,
releaseReason: p.releaseReason ?? null,
releaseNote: p.releaseNote ?? null,
assignStrategy: p.assignStrategy ?? null,
}));
const personas = seed.personas ?? [];
const logs: Array<Record<string, unknown>> = [];
......@@ -131,6 +144,12 @@ function makeStore(seed: { plans?: Partial<Plan>[]; personas?: { patientId: stri
assignedAt: (data.assignedAt as Date) ?? null,
recycleAt: (data.recycleAt as Date) ?? null,
contactAttempts: (data.contactAttempts as number) ?? 0,
assignmentId: (data.assignmentId as string) ?? null,
assignmentExpiresAt: (data.assignmentExpiresAt as Date) ?? null,
assignedBy: (data.assignedBy as string) ?? null,
releaseReason: (data.releaseReason as string) ?? null,
releaseNote: (data.releaseNote as string) ?? null,
assignStrategy: (data.assignStrategy as string) ?? null,
};
plans.push(p);
return p;
......@@ -709,7 +728,7 @@ describe('归属账本 — 引擎释放归属必须记 auto_release', () => {
expect(ledger(events)).toHaveLength(0);
});
test('归属被继承(诊所没变)→ 没有释放,不记账', async () => {
test('🔴 归属被继承(诊所没变)→ 没有释放,不记账', async () => {
const { prisma, events } = makeStore({
plans: [
{
......@@ -732,3 +751,177 @@ describe('归属账本 — 引擎释放归属必须记 auto_release', () => {
expect(ledger(events)).toHaveLength(0);
});
});
// =============================================================
// 2026-08 · 分配归因守恒(召回分配 P1.2 / 契约 D-12)
// =============================================================
//
// 🔴 **不绿则 P2 的写路径不许上生产。**
//
// 为什么单开一组:归因列若挂在 carryAssignment 上,只有 `status='assigned'` 那一支能继承,
// 而**退回后的 plan 是 `status='active'`** —— 每一条被退回过的单,引擎升一次版本
// 就把批次归因连**分子带分母**一起抹掉,不报错、无告警,等发现时历史已经花了。
// 只测 assigned 那一支会漏掉这个,所以下面第 2 条才是真正的看门测试。
describe('分配归因守恒 — 升版本不得吞掉批次归属', () => {
const seedAttrib = {
assignmentId: 'batch-N',
assignedBy: 'leader-1',
assignStrategy: 'dedicated',
assignmentExpiresAt: new Date('2026-08-10T00:00:00Z'),
};
test('① 已分配(assigned)+ 诊所未变 → 升版本后归因与归属全部守恒', async () => {
const { prisma, plans } = makeStore({
plans: [
{
id: 'p-1',
patientId: 'pat-1',
version: 1,
status: 'assigned',
assigneeUserId: 'staff-1',
targetClinicId: 'clinic-home',
reasons: [{ scenario: SCEN, subKey: 'caries@11' }], // 与新 hit 不同 → 升版本
...seedAttrib,
},
],
});
prisma.$queryRaw = jest.fn(async () => [{ patient_id: 'pat-1', clinic_id: 'clinic-home' }]);
await engine(
prisma,
makeScenario([hit('pat-1', 'missing_tooth@whole', 60, 'clinic-diag')]),
).runAllForHost({ hostId: HOST, tenantId: TENANT, now: NOW });
const v2 = plans.find((p) => p.patientId === 'pat-1' && p.version === 2)!;
expect(v2.assignmentId).toBe('batch-N');
expect(v2.assignedBy).toBe('leader-1');
expect(v2.assignStrategy).toBe('dedicated');
// 归属继承 → 在办期限也跟着继承
expect(v2.assignmentExpiresAt).toEqual(seedAttrib.assignmentExpiresAt);
expect(v2.assigneeUserId).toBe('staff-1');
});
test('🔴🔴 ② **退回后**(status=active)+ 有归因 → 升版本后归因**仍在**', async () => {
// 这一支是四份子方案全都漏掉的。carryAssignment 在这里是 false,
// 归因若跟着它走就会归零 —— 而这恰恰是最需要统计的一类单(被退回的)。
const { prisma, plans } = makeStore({
plans: [
{
id: 'p-2',
patientId: 'pat-2',
version: 1,
status: 'active', // ⭐ 退回后的状态,不是 assigned
assigneeUserId: null,
targetClinicId: 'clinic-home',
reasons: [{ scenario: SCEN, subKey: 'caries@11' }],
assignmentId: 'batch-N',
assignedBy: 'leader-1',
assignStrategy: 'spread',
releaseReason: 'over_capacity',
releaseNote: '手上还有 40 个',
},
],
});
prisma.$queryRaw = jest.fn(async () => [{ patient_id: 'pat-2', clinic_id: 'clinic-home' }]);
await engine(
prisma,
makeScenario([hit('pat-2', 'missing_tooth@whole', 60, 'clinic-diag')]),
).runAllForHost({ hostId: HOST, tenantId: TENANT, now: NOW });
const v2 = plans.find((p) => p.patientId === 'pat-2' && p.version === 2)!;
// 分母:这条单**属于批次 N**,退回不改变这个历史事实
expect(v2.assignmentId).toBe('batch-N');
expect(v2.assignedBy).toBe('leader-1');
expect(v2.assignStrategy).toBe('spread');
// 分子:退回原因也要留着,否则「退回 5 条,原因分布如下」就只剩个数字
expect(v2.releaseReason).toBe('over_capacity');
expect(v2.releaseNote).toBe('手上还有 40 个');
// 归属确实没有(它本来就在池子里)
expect(v2.status).toBe('active');
expect(v2.assigneeUserId).toBeNull();
});
test('③ 诊所重归属导致不继承 → 归因守恒,但**在办期限清空**', async () => {
const { prisma, plans } = makeStore({
plans: [
{
id: 'p-3',
patientId: 'pat-3',
version: 1,
status: 'assigned',
assigneeUserId: 'staff-3',
targetClinicId: 'clinic-old',
reasons: [{ scenario: SCEN, subKey: 'caries@11' }],
...seedAttrib,
},
],
});
prisma.$queryRaw = jest.fn(async () => [{ patient_id: 'pat-3', clinic_id: 'clinic-new' }]);
await engine(
prisma,
makeScenario([hit('pat-3', 'missing_tooth@whole', 60, 'clinic-diag')]),
).runAllForHost({ hostId: HOST, tenantId: TENANT, now: NOW });
const v2 = plans.find((p) => p.patientId === 'pat-3' && p.version === 2)!;
expect(v2.assignmentId).toBe('batch-N'); // 归因守恒
expect(v2.assignedBy).toBe('leader-1');
expect(v2.assigneeUserId).toBeNull(); // 归属释放
// ⭐ 期限跟随归属:归属没了还留着期限,列就不自洽了(非空 ⟺ 有一次在办的分配)
expect(v2.assignmentExpiresAt).toBeNull();
});
test('④ unchanged 分支的诊所重归属 → 就地改也只清期限,不清归因', async () => {
const { prisma, plans } = makeStore({
plans: [
{
id: 'p-4',
patientId: 'pat-4',
version: 1,
status: 'assigned',
assigneeUserId: 'staff-4',
targetClinicId: 'clinic-old',
reasons: [{ scenario: SCEN, subKey: 'missing_tooth@whole' }], // 与新 hit 相同 → unchanged
...seedAttrib,
},
],
});
prisma.$queryRaw = jest.fn(async () => [{ patient_id: 'pat-4', clinic_id: 'clinic-new' }]);
await engine(
prisma,
makeScenario([hit('pat-4', 'missing_tooth@whole', 50, 'clinic-diag')]),
).runAllForHost({ hostId: HOST, tenantId: TENANT, now: NOW });
const p = plans.find((x) => x.id === 'p-4')!;
expect(p.assignmentId).toBe('batch-N');
expect(p.assignedBy).toBe('leader-1');
expect(p.assignStrategy).toBe('dedicated');
expect(p.assigneeUserId).toBeNull();
expect(p.assignmentExpiresAt).toBeNull();
});
test('⑤ 无归因的普通单升版本 → 六列都是 null,不凭空造归因', async () => {
const { prisma, plans } = makeStore({
plans: [
{
id: 'p-5',
patientId: 'pat-5',
version: 1,
status: 'active',
targetClinicId: 'clinic-home',
reasons: [{ scenario: SCEN, subKey: 'caries@11' }],
},
],
});
prisma.$queryRaw = jest.fn(async () => [{ patient_id: 'pat-5', clinic_id: 'clinic-home' }]);
await engine(
prisma,
makeScenario([hit('pat-5', 'missing_tooth@whole', 60, 'clinic-diag')]),
).runAllForHost({ hostId: HOST, tenantId: TENANT, now: NOW });
const v2 = plans.find((p) => p.patientId === 'pat-5' && p.version === 2)!;
expect(v2.assignmentId).toBeNull(); // null = 自助认领,不是"未知"
expect(v2.assignedBy).toBeNull();
expect(v2.assignStrategy).toBeNull();
expect(v2.releaseReason).toBeNull();
expect(v2.assignmentExpiresAt).toBeNull();
});
});
......@@ -128,6 +128,57 @@ describe('recycle —— 退回原因必须落到账本(T7)', () => {
expect(updates[0]).not.toHaveProperty('snoozedUntil');
expect(updates[0]).toMatchObject({ status: 'active', assigneeUserId: null });
});
test('⭐ 退回原因同时落主表当前值(列表页要直接显示,不必回查事件流)', async () => {
const { prisma, updates } = makePrisma();
const svc = await buildService(prisma);
await svc.recycle(scopeWith([]), 'p1', 'u-me', true, ReleaseReason.NOT_MY_PATIENT);
expect(updates[0]).toMatchObject({ releaseReason: 'not_my_patient', releaseNote: null });
});
test('⭐⭐ 红线:退回**不清 assignment_id / assigned_by / assign_strategy** —— 那是退回率的分母', async () => {
const { prisma, updates } = makePrisma();
const svc = await buildService(prisma);
await svc.recycle(scopeWith([]), 'p1', 'u-me', true, ReleaseReason.OVER_CAPACITY);
// 「这批分了 60 条,退回 5 条」全靠 assignment_id 还在;清了就只剩分子
expect(updates[0]).not.toHaveProperty('assignmentId');
expect(updates[0]).not.toHaveProperty('assignedBy');
expect(updates[0]).not.toHaveProperty('assignStrategy');
// 但「在办期限」要随归属一起结束(列的自洽:非空 ⟺ 有一次在办的分配)
expect(updates[0]).toMatchObject({ assignmentExpiresAt: null });
});
});
describe('assign —— 归属列与批次列的分工', () => {
test('⭐ 记 assignedBy;⛔ 不碰 assignmentId / assignStrategy(那是批量写入的活)', async () => {
const { prisma, updates } = makePrisma({ ...BASE_PLAN, status: 'active', assigneeUserId: null });
const svc = await buildService(prisma);
await svc.assign(scopeWith([]), 'p1', 'u-staff', 'u-leader');
expect(updates[0]).toMatchObject({ assigneeUserId: 'u-staff', assignedBy: 'u-leader' });
// 单条认领/指派不属于任何批次 —— 瞎写会造出归属不到任何批次的"孤儿归因"
expect(updates[0]).not.toHaveProperty('assignmentId');
expect(updates[0]).not.toHaveProperty('assignStrategy');
});
test('⭐ 重新有人接手 → 清掉上一次的退回原因(否则主表自相矛盾)', async () => {
const { prisma, updates } = makePrisma({
...BASE_PLAN,
status: 'active',
assigneeUserId: null,
releaseReason: 'over_capacity',
});
const svc = await buildService(prisma);
await svc.assign(scopeWith([]), 'p1', 'u-staff', 'u-staff');
// 不清的话会同时显示「已分配给 u-staff」和「因手上排满被退回」
expect(updates[0]).toMatchObject({ releaseReason: null, releaseNote: null });
});
test('自认领时 assignedBy == assignee(主表上就能分出"自己领的"与"主管派的")', async () => {
const { prisma, updates } = makePrisma({ ...BASE_PLAN, status: 'active', assigneeUserId: null });
const svc = await buildService(prisma);
await svc.assign(scopeWith([]), 'p1', 'u-me', 'u-me');
expect(updates[0]).toMatchObject({ assigneeUserId: 'u-me', assignedBy: 'u-me' });
});
});
describe('写路径诊所硬边界(F4)', () => {
......
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