# 召回分配 · 开发规划（v1）

| | |
|---|---|
| **状态** | ⚠️ **本文停在 2026-08-02**（当时 Gate 0 未闭合）。功能此后已落地并跑过真实批次 —— 本文保留为**当时的分期与裁决记录**，⛔ 别拿它判断"现在做到哪了" |
| **现状去哪看** | agent 那一层 → [assignment-agent-dev-plan.md](./assignment-agent-dev-plan.md)（P0–P7）；行为规格 → [assignment-agent-flow.md](./assignment-agent-flow.md） |
| **日期** | 2026-08-02 |
| **教条** | [plan-assignment-doctrine.md](./plan-assignment-doctrine.md) —— T1-T20，**冲突处以教条为准** |
| **产出方式** | 四层并行方案 → 双路对抗批判 → 汇总（7 agent） |


> 依据：`/Users/luoqi/gitlab/pac/docs/design/plan-assignment-doctrine.md`（T1–T20 + 五之二/三/四 + 六·已定取舍）
> 输入：data / service / mcp / web 四份方案 + 挑刺报告 + 排期审查
> 本文是**开发文档**，不是讨论稿。教条冲突处以教条为准；四份方案冲突处由本文第 1 节的契约表裁决。
> 所有 `file:line` 已于 2026-08-02 工作树实测复核。

---

## 〇、一句话目标与范围边界

### 目标

**把召回池的「要不要做」变成「做了没有」** —— 门诊经理在召回池发起一次批次分配，助手直出确认单，主管一次确认落到 N 个客服名下；客服只看得到自己的单，可执行、可带原因退回；主管能回头看这批的结果，并让下一批更准。

### 本期做（v1）

| # | 范围 | 教条 |
|---|---|---|
| 1 | 批次实体 `plan_assignments` + `followup_plans` 归因列 | T17 / 五之二 |
| 2 | 主管确认触发的**批量分配写入**（唯一写动作，走 REST） | T8 |
| 3 | 助手全景直出**原生确认单**（微调仅「指定客服 / 时效」两项） | T13 |
| 4 | 分配时效 `assignment_expires_at`（写入 + 超期展示） | T11 |
| 5 | 客服退回带**结构化原因** | T7 |
| 6 | 前端 T16 改造：客服只有「我的」，认领入口全撤 | T16 |
| 7 | `PLAN_DISPATCH` 权限 + MCP 按能力过滤工具 | T19 |
| 8 | 在岗名册 + 负载（合并为**一个**端点/工具） | 五之四 |
| 9 | 批次跟踪（批次列表 + 单批详情，带 `n=` 与样本不足口径） | T20 |
| 10 | 初选矩阵（8 潜在治疗 × 3 温度）+ 移交动效 | T6a / T6 / 五之三 |
| 11 | 撤销整批（限时） | 六·已定取舍 |
| 12 | 福利（批次属性 + 归因标签 + 话术勾子） | T4 |

### 本期明确不做

- 教条八已列的五项（规则自动分配 / 能力评分 / 抢单 / 强制接受 / KPI 挂钩）
- **MCP 写工具**（`assign_plans` / `revoke_assignment`）—— 见 D-4，与 T8 冲突，v1 不注册
- 分配单**到期后的自动回池 cron** —— 教条七·待确认，且擦 T8；v1 只做「写入 + 超期标红」
- 补建 `followup_plans` 的 partial UNIQUE（一患者一条活动 plan）—— 独立数据清理项，见 R9
- 企微通道的合成身份真身份映射（六·已定取舍：本期不管）
- 小屏（<lg）矩阵形态
- 福利核销 / 接宿主卡券（T4 已定 v1 不核销）

### 交付切法

⚠️ **原规划建议「v1 内分两刀、矩阵放第二刀」，该建议已被证伪并撤销（2026-08-02）。**

原理由是「温度轴的技术链条含一次 persona 全量重算，挂钟时间不受人日控制」。生产实测：

```
X 轴 潜在治疗  persona_features   590,427 条   ← 早已算好,画像常规特征
Y 轴 窗口温度  plan_reasons       566,365 条   ← daysSince 现成,读时与阈值比对
全量矩阵直接跑通                     25 秒 / 零重算
```

**两个轴的数据生产上全部现成，不需要任何重算，也没有跑批窗口。**

→ **矩阵留在第一刀**（与教条六·已定取舍一致）。
→ 但 **25 秒对交互不可接受** → 转为一个**明确的性能任务**（索引 / 物化视图），
   属于 S1 内部的工程项，**不是排期依赖**。

阶段划分改以**能力开放**为轴，见 [教条 · 七之二 阶段性开放规划](./plan-assignment-doctrine.md)（S1-S5）。

---

## 一、跨层契约表（**开工前必须评审通过，之后是唯一真理源**）

四份子方案在这 12 处互相冲突，不裁决就必然返工。以下为**总规划师裁决**，理由从简，冲突原案见括号。

| # | 契约项 | **定** | 被否的方案与理由 |
|---|---|---|---|
| **D-1** | 确认单机制 | 本地工具 `propose_assignment` → 后端展开肥载荷 → **侧信道** `onSideEvent` 推前端 → Block `kind:'assignment_sheet'` | ❌ web 的「从 `tool_call.args` 一次性带全」：`args` 是**模型生成的**（`assistant.controller.ts:174` 转发 `p.input`），等于要模型逐字吐 200 个 uuid，幻觉率 100% 且 ≈12k token |
| **D-2** | 幂等键 | 列名 `request_id`，**服务端在 propose 时铸造**，随确认单下发、确认时原样回传；`@@unique([hostId, tenantId, requestId])` | ❌ service 的「前端组件生成」：挡不住模型/SSE 重试。⚠️ data 层的 model 里**根本没有这一列**，必须补进迁移 A |
| **D-3** | 写端点 | `POST /pac/v1/plans/assignments`（新建 `assignment.controller.ts`，`@Controller('plans/assignments')`） | service 的 `POST /plans/assignments` 与 mcp/web 的 `/pac/v1/plan-assignments` 合并 |
| **D-4** | 写路径通道 | **v1 走 REST + `@RequirePermission(PLAN_DISPATCH)`；MCP 写工具分期到 S4，设计不得堵死** | ⚠️ **已产品裁决（2026-08-02）**：MCP 写工具**最终必须实现**，原案「v1 不做」改为「v1 不注册、但预留」。MCP 现为 `@Public()`（`permissions.guard.ts:16` `if (isPublic) return true` 实测），护栏会退化成 handler 自查 + 模型自填布尔 → 待 MCP 鉴权补齐（S4）再挂。⭐ `requirePermission` / `rejectSyntheticIdentity` 两个 helper **S1 就写好** |
| **D-5** | `followup_plans` 新增列数 | **6 列**（不是 4 也不是 5） | data 的 5 列 + 新增第 6 列 `assign_strategy`，见 D-6 |
| **D-6** | 「专属命中 vs 溢出铺平」怎么记 | **立柱** `assign_strategy TEXT`（`dedicated` / `spread` / `manual`） | 四份方案**全都没记**。T20 要按它分组算完成率 → 「会用来筛」→ 按 T17 就该立柱。⚠️ 事后从 `patients.preferences.dedicatedCs` 反推**不可行**：那列是 upsert 覆盖的「当前值」，历史丢失。**分配当时不记就永久没了** |
| **D-7** | `ReleaseReason` 值域 | 取 **data 层的 8 个**（`not_my_patient` / `over_capacity` / `agent_unavailable` / `needs_other_role` / `deadline_too_tight` / `recently_contacted` / `patient_info_missing` / `other`）。⛔ **类型里不定义 `suppressDays`** | ❌ service 的 6 个：其中 `bad_timing` 与 `RECALL_FEEDBACK_OPTIONS.bad_timing`（`enums/index.ts:641`）**同名不同义**。值域仍需产品过目，见 Q-3 |
| **D-8** | 时效入参形状 | 收 **`expiresInDays: number`**（相对天数），服务端按 `hosts.pullConfig.timezone` 转绝对时刻 | ❌ mcp 的 `expiresAt: z.string().datetime()` 必填：强制模型算 ISO 时刻，正是本仓 `4549817` 修过的「纯日期被当 UTC 零点偏 8 小时」 |
| **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-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` 解耦**（原案），但**不是无条件** —— ⭐ 判据是**客服碰过没**（`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-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 是**接口草案记法**，不是工具名 |

---

## 二、开工前必须闭合（Gate 0 · go/no-go）

这三件**不占开发工时，但占日历**，且第一件不通过整个方案要重排。

### G0.1 ✅ `JWT.sub` 与 `task_director_id` 是同一 ID 空间 —— **已实测通过**

> 2026-08-02 于生产只读验证。原判为 🔴 go/no-go，**现已闭合**。

**为什么曾是 go/no-go**：分配落到 `followup_plans.assignee_user_id`（唯一写入方是
`plan.controller.ts:116` 的 `user.sub`，契约为「宿主给什么就是什么」），
而名册来自 `patient_return_visits.task_director_id`（DW 列）。
两者若非同一 id 空间 → 主管分完 50 单，客服的「我的」全是空的
（`view='mine'` 只是 `where.assigneeUserId = scope.userId`），**不报错、不告警**。

**实测结果**：

```
登录侧有行为的人(assignee ∪ actor ∪ operator)    58
回访名册人数(task_director_id)                2,198
两边都有                                         47   ← 81% 重合
只在登录侧                                       11
```

**81% 重合 → 同一 id 空间成立**（若为两套 id，重合率会接近 0）。

⚠️ **但暴露一个覆盖缺口**：那 11 个只在登录侧的（`5960/5961/5963/5971` 连号新号段、
`9`、`1513`、`admin-luoqi`）回访数**全为 0** —— 即**新入职 / 只做召回不做回访的客服，
名册里查不到**。

→ **设计要求**：`get_agents` 名册之外仍须允许主管**显式指定**，
并提示「该客服无近期回访记录」。不能因为不在名册就分不了。
（`5961` 即前述「晋芳辰」，认领过 7 个患者却零回访记录，是活生生的例子。）

### G0.2 契约表评审（第 1 节）

一次会，把 D-1 ~ D-15 过一遍。**`packages/types/src/enums/index.ts` 是四方争抢点**（权限 + ReleaseReason 都插在同一区域），必须约定：P0.1 与 P0.2 合一个 PR，其余三层等它 merge 再拉分支。

> 工时 0.25 人日（评审）。

### G0.3 产品决策清点

见第 6 节。**Q-1 / Q-2 / Q-3 卡开发起跑，其余可并行。**

---

## 三、前置修复：四个既有洞

教条五之二列了三个，实测**只有一个半是真前置**，另有两个是四份方案都漏掉的。

### F1 · 引擎丢归属不记账 —— **实测是 5 处，不是教条说的 1 处**

| 位置 | 现状 | 修法 |
|---|---|---|
| `plan-engine.service.ts:392-401` | 诊所重归属 → `patch.assigneeUserId = null` 等四件套，`:425` 的条件 tx 内**无记账** | 同一 tx 内、**仅当 `latest.assigneeUserId != null`** 时补 `recordPlanEvent`；`heldSeconds` 必须在 patch 生效**前**用 `latest.assignedAt` 算 |
| `plan-engine.service.ts:471` + `:511-515` | 升版本 + `clinicMoved` → `carryAssignment=false`，旧 assigned 版本被 supersede（`:492-495`），新版本空归属 | `:490` 的 tx 内、supersede 旧版**之前**记账。⚠️ `planId` 用**旧版** `latest.id` |
| `plan-engine.service.ts:139-154` `closeStaleActivePlan` | 信号清零 → assigned 也直接 supersede；`select` 只取 `{id, status}`（`:146`），`update` 裸调**无 tx** | ① `select` 补 `hostId/tenantId/patientId/assigneeUserId/assignedAt` ② 包 tx ③ 有 assignee 才记账 |
| `recycle-scheduler.service.ts:87` | 清三件套 | 补 `assignedBy: null` / `assignmentExpiresAt: null`；**`assignmentId` 保留** |
| `plan.service.ts:670-678` `recycle` | 同上 | 同上 |

**事件类型复用 `auto_release`，靠 `reason` 区分，不新增 `PlanEventType`。**

⚠️ 但 **`reason` 列必须同步枚举化**（四份方案都漏）：该列现已在存 `'timeout'` / `'up'` / `'down'`，本次要再加 `clinic_moved` / `signals_cleared` / `revoked` / 8 个 release reason。`PlanEventType` 的注释原则是「新增事件必须登记，不要在调用处随手写字符串」—— 往 `reason` 里塞 8 个未登记字符串是在同一条纪律上换了一列违反。**新增 `PlanEventReason` 枚举**，同文件同风格，0.1 人日。

> ⚠️ **一次性事件洪峰**：`runAllForHost` 是全量。某次口径变更导致大面积诊所重归属 → 一次性写出大量 `auto_release`。加计数日志，别让运维当 bug。

**位置**：P0（无前置，可最先做）。纯可观测性，不依赖任何新列。

### F2 · `recycle` 的 `reason` 被丢弃 —— T7 落不了地

链路三处（已实测）：

- `packages/types/src/schemas/plan.ts:242-245` `RecyclePlanRequestSchema = { reason?: string }` → 换成 `{ releaseReason?: ReleaseReasonSchema, releaseNote?: string }`
- `apps/pac-service/src/modules/plan/plan.controller.ts:127` `@Body() _dto: RecyclePlanRequestDto` ← **就是这个洞**（下划线），改 `@Body() dto` 并透传
- `plan.service.ts:639` 签名加两参 → `:670-678` update 写两列 → `:680-689` `recordPlanEvent` 传 `reason: releaseReason`、`details: note ? { note } : null`

**条件必填，不是全局必填**：`plan.assignmentId != null && !releaseReason → BadRequestException`。落点同 `execution.service.ts:126-129`（选 `inaccurate` 必须勾具体治疗）—— **服务端也拦，不只信前端**。

⚠️⚠️ **退回 ≠ 放弃：一律不动 `snoozedUntil`**。照抄 `ABANDON_REASON_META` 那套 `suppressDays` 会把被退回的患者静默压 60~90 天，正好是 T5「池子里剩下的不是遗漏，是还没轮到」的反面。**`RELEASE_REASON_META` 的 TS 类型里根本不定义 `suppressDays` 字段**，用类型系统拦住。

**位置**：P0（无前置）。技术上不是分配的前置，但**必须与分配写入同批上线** —— 滞后一个迭代 = 第一批批次的退回原因分布永远是空的，而**第一批数据是最贵的**（T20：跟踪的产出是下一次分配的输入）。

### F3 · `RECYCLE_TIMEOUT_HOURS` 硬编码 —— **降级，不做 env 化**

教条五之二写「T11 时效不可配」是**误判**：T11 的可配性由新的 `assignment_expires_at` 承担，且同节自己也定了「两条路不互相干扰」。加上自动回收生产未启用（`PAC_PLAN_AUTO_RECYCLE` 未配）—— env 化一个**没人读的常量**是纯噪音。

**降级为 0.05 人日改两处注释**：`plan.service.ts:50`（「后续接 tenant 配置」）与 `schema.prisma:1024`（「= assignedAt + tenant.rules_config.recycleTimeoutHours」）—— **PAC 全仓没有 `Tenant` 模型**，那句话会持续误导后来人。删掉它。

**位置**：随手做，不占关键路径。

### F4 🔴（新发现，四份方案都漏）· `assign()` / `recycle()` 不校验 `scope.clinicIds`

实测 `plan.service.ts:572-577` 的 `findFirst` where 只有 `id` + `patient.sourceUnit`，**没有 `targetClinicId`**；而列表侧 `buildListWhere:311-316` 明确做了诊所硬边界（注释：「clinic 隔离：orgScope→clinicIds 是硬边界，**始终生效**」）。

**后果**：诊所 A 的 leader 传 `clinicId=B` + 一批 B 的 planId，就能把 B 诊所的单分给自己诊所的客服。他**看不到**那些单（列表被挡），但**批量写入不受挡**。多租户隔离在写路径上比读路径弱一档 —— 正好是最不该弱的地方。

**这是既有洞，但分配功能会把它从「知道 planId 才能利用」放大成「批量、有 UI」。**

**修法**：在 `assign()` / `recycle()` / 新的批量绑定闸里统一加
`...(scope.clinicIds.length ? { targetClinicId: { in: [...scope.clinicIds, null] } } : {})`。

**位置**：P0（必须先于 P2 批量写入）。

### F5（新发现）· `plan_event_logs` / `plan_reason` 不在 `SCOPED_MODELS`

实测 `apps/pac-service/src/prisma/tenant-guard.extension.ts:23-38` 的 14 张表里没有 `PlanEventLog`。而跟踪功能是它的**第一个读场景** —— 正好是纵深防御该兜底的时候。

**修法**：P1.1 一并登记 `PlanEventLog` + `PlanAssignment`。`SOURCE_UNIT_MODELS`（`:41`）**不加**（两表都无 `source_unit` 列）。

---

## 四、分阶段计划

每阶段**独立可交付、独立可验证**。人日按熟悉本库的开发者估。

### 阶段总览

```
Gate 0 ──► P0 地基(四路并行) ──► P1 表与不变式(同一次发布) ──┬─► P2 写路径 ──┐
                                                             ├─► P3 助手侧 ──┼─► P5 跟踪
                                                             └─► P4 前端 ────┘
                                                                        ↑
                                            ═══════ 到此为 MVS ═══════════╝
                                                                        │
                                                    P6 第二刀(矩阵/撤销/福利话术/画像圈人)
```

---

### P0 · 地基（无前置，四路并行）· 3.95 人日

| 任务 | 改动点 | 人日 |
|---|---|---:|
| **P0.1** 权限 | `packages/types/src/enums/index.ts:15-33` 加 `PLAN_DISPATCH: 'plan:dispatch'`；`:52-63` `ROLE_PERMISSIONS[LEADER]` 加（ADMIN 走 `:64` `Object.values` 自动获得）；⛔ **不加给 STAFF**（4.35：staff 也持有 `PLAN_ASSIGN`，加给 staff 等于把新权限也废掉）；`apps/pac-web/src/stores/auth-store.ts:104-117` **补 merge `permissions`**（实测缺失，`/auth/session` 是返回它的） | 0.5 |
| **P0.2** ReleaseReason | 同文件 **766 行**（`abandonReasonsFor` 之后）加 `ReleaseReason` + `RELEASE_REASON_META`（含 `group` / `lever` / `hidden?`，**无 `suppressDays`**）+ `ReleaseReasonSchema` + `releaseReasonsForForm()`；同时加 `PlanEventReason` 枚举（F1 尾） | 0.5 |
| **P0.3** 删死代码 | `components/plans/plans-list-app.tsx`(1053) / `use-plans-list.ts`(65) / `use-plan-counts.ts`(44) / `components/plan-detail/task-drawer.tsx`(287) / `use-my-tasks.ts`(75)；顺手改 `plans/page.tsx:14` 指向已删文件的注释 | 0.25 |
| **P0.4** toolsCache 分桶 | `apps/pac-service/src/modules/assistant/mcp-client.service.ts:25` `toolsCache: McpToolDef[] \| null` → `Map<string, McpToolDef[]>`，key = 能力指纹（`'dispatch'` / `'basic'`），由调用方传入（`McpClientService` 不该解 JWT）；改 `listTools(token, capabilityKey)` 与 `assistant.service.ts:64` 调用点 | 0.25 |
| **P0.5** 引擎记账 5 处 | 见 F1；含单测 | 1.25 |
| **P0.6** recycle 收原因 | 见 F2；含单测 | 1.0 |
| **P0.7** clinic scope 硬边界 | 见 F4；`plan.service.ts:572-577` / `:645` 附近 + 测试 | 0.3 |
| — | 顺手：改 F3 两处过期注释 | 0.05 |

**⚠️ P0.1 与 P0.2 改同一文件同区域，必须同一个 PR。** P0.4 是 P3.5 条件注册的**安全前置**（不分桶就会串号：进程重启后第一个进来的若是 staff，全公司 leader 都拿 staff 清单）。

**交付物**：可上线的独立版本（无新功能，但引擎不再静默丢账、退回能记原因、客服返池带原因）。

**验收（可证伪）**：

- P0.1 ① 一个 leader 账号**不重登**，`/auth/session` 返回后 `useHasPermission(PLAN_DISPATCH)` 变 true ② 单测断言 `ROLE_PERMISSIONS[STAFF]` 不含 `PLAN_DISPATCH`
- P0.3 `pnpm build` + `tsc --noEmit` 通过，`grep -rn "plans-list-app\|use-my-tasks\|task-drawer" apps/pac-web/src` 零命中
- P0.4 集成测试：staff token 与 leader token 各调一次 `listTools`，返回工具数**不同**
- P0.5 构造三种触发（诊所重归属 / 升版本 clinicMoved / 信号清零），每种在 `plan_event_logs` 留一条 `auto_release` 且 `reason` 可区分、`heldSeconds` 非 null；⭐ 断言「无 assignee 时**不**写事件」（别制造洪峰）
- P0.6 带 `releaseReason='other'` 无 note → **服务端 400**；带合法 reason → `followup_plans.release_reason` 与 `plan_event_logs.reason` 同值；⭐ 断言 `snoozedUntil` **未被修改**
- P0.7 用 A 诊所 leader 的 token 对 B 诊所的 planId 调 assign → **404/403**

---

### P1 · 表与不变式（⚠️ P1.1 与 P1.2 **必须同一次发布**）· 1.35 人日

#### P1.1 迁移 A + schema（0.75 人日）

**新建** `apps/pac-service/prisma/migrations/<YYYYMMDDHHMMSS>_add_plan_assignments/migration.sql`

**Prisma schema 改动**（`apps/pac-service/prisma/schema.prisma`）：

| 位置 | 改动 |
|---|---|
| 插入 **1502**（`PlanEventLog` 的 `}` 之后、`// 横切支撑` 分节头之前） | 新增 `model PlanAssignment` |
| **155** 之后 | `Host` 加 `planAssignments PlanAssignment[]` |
| `model FollowupPlan` **1045 后** | 加 6 列（见下） |
| **1060 后** | `assignment PlanAssignment? @relation(..., onDelete: Restrict)` ⚠️ **必须显式写 `Restrict`** —— Prisma 对可选关系默认 `SetNull`，将来任何一次误删批次会把 N 行归因静默置空且不报错 |
| **1068 后** | `@@index([assignmentId])` |
| `tenant-guard.extension.ts:23-38` | `SCOPED_MODELS` 加 `'PlanAssignment'` + `'PlanEventLog'`（F5） |

**`plan_assignments` 列**（相对 data 层方案的增量已标 ⭐）：

```
id / host_id / tenant_id / clinic_id?
created_by            主管(宿主 user id, TEXT —— PAC 不立 users 表)
request_id            ⭐ 幂等键(D-2)，服务端铸造
criteria      Json    初筛条件快照(T17 不立柱)
attributes    Json?   { benefit?: { text } }(T17)
expires_at            NOT NULL(T11:不存在无限期批次)
status                confirmed | revoked   ⭐ 撤销推迟到第二刀，但三列现在建齐
revoked_at? / revoked_by?
created_at / updated_at
@@unique([host_id, tenant_id, request_id])   ⭐
@@index([host_id, tenant_id, clinic_id, created_at DESC])
@@index([created_by, created_at DESC])
```

⛔ **不设** `planned_count` / `agent_count`（T17：从 `followup_plans` COUNT）；不设 `source_unit`（品牌隔离已由 `scope.clinicIds` + patient.sourceUnit 双重覆盖）；不设 `name`（加了就得管重名管改名）。

**`followup_plans` 新增 6 列**（全部可空、全部无默认值 —— 这是迁移不重写 25 万行的前提）：

| 列 | 类型 | 注释要点 |
|---|---|---|
| `assignment_id` | `UUID?` | **null = 自助认领**，不是「未知」。⚠️ 退回时**不清空**（退回率的分母） |
| `assignment_expires_at` | `TIMESTAMPTZ(3)?` | 生效时效；⚠️ 与 `recycle_at` 是两条互不干扰的路，注释互指 |
| `assigned_by` | `TEXT?` | 谁分的 |
| `release_reason` | `TEXT?` | ⚠️ ≠ `recall_feedback`（1044）；⚠️ 不写抑制窗 |
| `release_note` | `TEXT?` `@db.Text` | `other` 时必填 |
| ⭐ `assign_strategy` | `TEXT?` | `dedicated` / `spread` / `manual`（D-6，T20 反推「专属 vs 铺平」的唯一数据源） |

**迁移 SQL 形态 —— 单文件、多语句、全部普通 DDL，⛔ 不用 CONCURRENTLY**：

复盘 `migrations/20260728020000_persona_features_tag_filter_indexes/migration.sql` 的文件头：Prisma 把整份 SQL 用一次 simple query 发给 PG → 隐式事务块 → `CREATE INDEX CONCURRENTLY` 报 `ERROR 25001`，且 `_prisma_migrations` 会留一条卡死记录（P3018）**堵死整条流水线**。分界线是**语句条数**，不是版本。

本次不需要它：唯一要建索引的存量表是 `followup_plans` **25 万行**（秒级），不是 `persona_features`（7.66M / 5.4GB）或 `patient_transactions`（12–24M）。`patient_facts` 1300 万行**本次一个字都不动**。

语句顺序：

```sql
1. SET LOCAL lock_timeout = '10s';            -- ⭐ 见 R3
2. CREATE TABLE "plan_assignments" (...);
3. CREATE INDEX ×2 (plan_assignments);
4. ALTER TABLE plan_assignments ADD CONSTRAINT ..._host_id_fkey ...;
5. ALTER TABLE "followup_plans"
     ADD COLUMN IF NOT EXISTS "assignment_id" UUID,
     ... 共 6 个 ADD COLUMN;                   -- ⭐ 必须一条 ALTER 带 6 个 ADD(一次锁，不是六次抢锁)
6. CREATE UNIQUE INDEX ... plan_assignments(host_id, tenant_id, request_id);
7. CREATE INDEX IF NOT EXISTS followup_plans_assignment_id_idx ON followup_plans(assignment_id);
8. ALTER TABLE followup_plans ADD CONSTRAINT ..._assignment_id_fkey ... NOT VALID;
9. ALTER TABLE followup_plans VALIDATE CONSTRAINT ..._assignment_id_fkey;
```

**文件头必须写清四件事**：① 为什么不用 CONCURRENTLY（引 `20260728020000` 的结论）② 6 列全可空无默认 → PG 11+ 元数据操作，不重写 25 万行 ③ **整份文件跑在一个隐式事务里，`ACCESS EXCLUSIVE` 持有到 COMMIT，真实阻塞窗口 ≈ 整份迁移时长 1–3 秒，后人别往里追加慢语句** ④ 失败后的救援：`npx prisma migrate resolve --rolled-back <name>`

**存量不回填**：6 列全可空，老数据 NULL 即语义正确（= 上线前的自助认领单）。25 万行 UPDATE 会真正重写表 + 膨胀 + 触发 autovacuum，收益为零。

#### P1.2 引擎无条件继承 + 守恒断言（0.6 人日）

`plan-engine.service.ts:499-520` 的 `create` 里，归因六列**与 `carryAssignment` 解耦、无条件继承**（D-12）：

```
status/assigneeUserId/assignedAt/recycleAt/contactAttempts  ← 仍由 carryAssignment 控制
assignmentId/assignmentExpiresAt/assignedBy/
releaseReason/releaseNote/assignStrategy                     ← ⭐ 无条件 latest?.xxx ?? null
```

丢归属时（`:396-401`、`recycle-scheduler:87`、`plan.service:674`）**`assignment_id` 保留不清**，清的是归属四件套。

**验收 🔴（不绿则 P2 不许上生产）**：

1. 造 `status='assigned'` + `assignment_id=X` 的 plan → 触发升版本 → 新版本 `assignment_id` **仍等于 X**
2. ⭐ 造 `status='active'`（**退回后的状态**）+ `assignment_id=X` + `release_reason='over_capacity'` 的 plan → 触发升版本 → 新版本三者**全部守恒**（只测 assigned 会漏掉这一支，四份方案都漏了）
3. 本地 37 迁移全应用的库上 `prisma migrate dev` → **零 drift**（迁移 A 落地后为 38）
4. `plan_assignments` 在 `SCOPED_MODELS` 里，且有一条越权读被拦的测试

**交付物**：可上线的独立版本（表存在、不变式成立，但还没有任何写入方）。

---

### P2 · 写路径 · 3.1 人日

**新建**
`apps/pac-service/src/modules/plan/plan-assignment.service.ts`
`apps/pac-service/src/modules/plan/assignment.controller.ts`
在 `plan.module.ts:17-31` 注册。

#### 端点

```
POST /pac/v1/plans/assignments            @RequirePermission(PLAN_DISPATCH)
GET  /pac/v1/plans/assignments            @RequirePermission(PLAN_DISPATCH)
GET  /pac/v1/plans/assignments/:id        @RequirePermission(PLAN_DISPATCH)
POST /pac/v1/plans/assignments/:id/revoke @RequirePermission(PLAN_DISPATCH)   ← 第二刀
```

⛔ **不改** `POST /plans/:id/assign`（维持 `PLAN_ASSIGN`，T12/T16：后端保留认领）与 `POST /plans/:id/recycle`（维持 `PLAN_RECYCLE`，退回是客服路径）。

#### 入参

```ts
{
  requestId: string,            // D-2，来自 propose 下发，不由前端/模型生成
  clinicId: string,             // 批次不跨诊所
  criteria: Json,               // 初筛条件快照 + ⭐ 收敛规则(orderBy/limit)，见 Q-2
  attributes?: { benefit?: { text: string } },
  expiresInDays: number,        // D-8，服务端按 host 时区转绝对时刻
  items: Array<{ planId, assigneeUserId, assignStrategy, expiresInDays? }>   // ⭐ assignStrategy 逐条带
}
```

**`items` 是 `(planId, assigneeUserId)` 对，不是 `{assigneeUserId, planIds[]}` 分组** —— T15 溢出转铺平后归属是逐条算的，分组形状让「其中 5 条单独设 7 天」无处安放。

#### 事务与并发

单个 `$transaction(fn, { maxWait: 10_000, timeout: 30_000 })`：

1. **三道闸**：`requirePermission` → `rejectSyntheticIdentity`（`scope.userId.startsWith('wx:')` 直接拒绝，六·已定取舍「写工具上线前需另行处理」—— 这就是那个处理）→ **scope 绑定**：一次 `findMany({ where: { id: { in }, hostId, tenantId, patient: { sourceUnit: { in } }, targetClinicId: { in: [...scope.clinicIds, null] } } })`，**数量对不上整体拒绝**
2. **按 `patientId` 去重**（🔴 `schema.prisma:1069` 注释里那条 partial UNIQUE **在 37 份迁移里根本不存在**，已逐份 grep 确认 —— 不能当既有保障用，见 R9）
3. 建 `plan_assignments` 头行
4. 按 `assigneeUserId` 分组，每组一条**带条件 `updateMany`**：`where: { id: { in: chunk }, status: { in: ['active'] }, assigneeUserId: null, supersededAt: null }` —— **where 里带状态条件 = 并发安全**（`recycle-scheduler.service.ts:85` 已在用此技巧）；`count` 与 chunk 长度差 = 被抢走的
5. 事件一次 `createMany` —— ⚠️ **必须在 `plan-event.recorder.ts` 里加 `recordPlanEventsBulk(tx, inputs[])`**，该文件头（`:5`）宣称自己是「PlanEventLog 的**唯一写入口**」，在别处写 createMany 当场破功
6. `applied.length === 0` → 抛错回滚（**不留空批次**）

⛔ **不能循环调 `PlanService.assign()`**（`plan.service.ts:567`）：每条自带 `findFirst` + 独立 `$transaction`，500 条 = 1500 次往返必炸，且它写不了新列。
⛔ **不让单条 assign 委托批量**：那会给每次认领造一条垃圾批次行。
✅ **共用部分提取到 `claim-guard.ts`**（现有 66 行，不新建）：把 `plan.service.ts:586-591` 提成 `assertAssignable(plan, targetAssigneeUserId)`。教条七的「已被认领的单能否强制改派」**正好落在这一个函数上**，将来产品定了只改这一处。

硬护栏 `items.length <= 500`（技术护栏，**不是**批次规模业务上限，见 Q-2）。chunk ≤ 500（bind-var 32767 上限，每行 ~7 变量 → 3500，安全）。

返回 `{ assignmentId, duplicate, assigned, skipped: [{planId, reason}] }`，助手照着说人话。

#### 幂等

`requestId` 冲突（P2002）→ **不抛错**，按 `requestId` 回查已有批次原样返回 + `duplicate: true`。抛错会让模型以为失败、改个参数重试一次，那才是真正的重复分配。

**验收**：

1. 500 items 一次调用 < 5s 且不抛事务超时
2. 同 `requestId` 调两次 → 只产生一个批次，第二次 `duplicate:true`
3. 批内含 1 条已被他人认领 → `assigned=N-1` + `skipped` 有一条 `claimed_by_other`，**其余照落**
4. 同患者两条活动 plan 落进同一批 → 去重后只落 1 条
5. 传一个不属于本 scope（跨诊所）的 planId → **整批拒绝**
6. `applied=0` → 事务回滚，**库里没有空批次**
7. `expiresInDays=3` + host 时区 Asia/Shanghai → 落库的 `expires_at` 是当地日末，不是 UTC 零点

---

### P3 · 助手侧 · 4.3 人日

| 任务 | 改动点 | 人日 |
|---|---|---:|
| **P3.1** `get_current_user` | `mcp-server.factory.ts` **`:84` 之前**新增注册块（工具顺序影响模型默认注意力，身份判定放最前）；返回 `{ userId, displayName, capabilities: { canDispatch, canViewAllPlans, canExecute }, clinicIds, tenantId }` —— **返回能力不返回 role**（T19）。⚠️ `displayName` 需扩 `mcp-auth.service.ts:7-10` 的 `McpAuthContext` 加 `userName?`（人名是显示串不是权限判据，不违 T19） | 0.4 |
| **P3.2** 名册 + 负载 | **迁移 B**（独立目录，**单语句 + CONCURRENTLY**）：`patient_return_visits` 加 `(host_id, tenant_id, clinic_id, task_director_id, source_created_at DESC)` —— 实测现有索引末列是 `task_date`（`schema.prisma:447`），按 `source_created_at` 卡窗会退化成对 166.7 万行过滤。**加不删**（旧索引另有 patientId 路径用途）。**新建** `agent-roster.service.ts` + `GET /pac/v1/plans/agents`（D-9）+ MCP `get_agents` | 1.25 |
| **P3.3** systemExtra | **新建** `apps/pac-service/src/modules/assistant/assistant-prompts.ts` 导出 `buildSystemExtra({permissions, cohort?})`；`assistant.controller.ts:16-19` `ChatBody` 加 `context?`；`:122` 签名加 `@CurrentUser()`；`:144-149` 调用点传 `systemExtra`。⚠️ **实测 `/assistant/chat` 从来没传过 `systemExtra`** —— 全仓只有 `weixin-aibot.service.ts:140` 在传，「按角色切换工作流约束」目前是**零实现** | 1.0 |
| **P3.4** `propose_assignment` | 本地工具（`assistant.service.ts:76-91` `render_artifact` 旁）；`AssistantChatInput` 加 `onSideEvent?`，controller 传 `send`（`:130-132` 已定义）；后端展开：cohort → planIds → 按 agent 分桶 → 补姓名/优先级/专属客服 → **铸造 `requestId`** → 补默认值注记；**返回给模型的只有一句短话**（「确认单已呈现给主管，等待其确认。不要重复生成，也不要声称已完成分配。」）。⭐ 含**收敛规则**实现（Q-2） | 1.25 |
| **P3.5** 工具门控 | `mcp-server.factory.ts:80 build(ctx)` 里按 `permissions.includes(PLAN_DISPATCH)` 条件注册；⭐ **同时给 `list_recall_queue`（`:164`）的 `view` 参数加权限分流：staff 只能 `mine`** —— 实测 `plan.service.ts:293` 只对 `view='all'` 校验 `PLAN_VIEW_ALL`，`view='pool'` 无校验，staff 可以问助手「召回池里还有谁」绕开 T16 | 0.4 |

**P3.3 提示词正文的核心方法论**（必须写进代码注释）：

> **T14 与 T20 不能只写在提示词里 —— 必须「提示词 + 工具返回值」双保险。**
> LLM 做除法和阈值比较不可靠，而这两条恰恰全是除法和阈值。工具返回里直接带 `sufficient: boolean` 和**成品句子** `note`，提示词的任务从「让模型算对」降级成「让模型照抄」。

必须写进 systemExtra 的六条硬约束：

1. 「你**全程只读**。唯一改变数据的动作是主管在确认单上点确认，那由界面完成。**绝不要说『已经分配好了』**，正确说法是『确认单已呈现，请过目』」（T8 最容易破的地方）
2. 「全景阶段**不问意图、不做画像分层**，直接出确认单」（T13 / T9-A）
3. 「凡是没有历史数据支撑的建议值（时效 / 容量 / 专属优先），**必须当场标明是默认值**；工具返回的 `capacityNote` / `rosterNote` **原话抄进去**」（T14）
4. 「`sufficient=false` → **照抄 note，不输出百分比、不画图、不出 0.0%**」+ 背景事实（全生产 `plan_executions` 仅 7 条、`success_appointed` 0 条）（T20 / 五之四）
5. 「退回率永远给两个数：『退回 5 / 已处置 40 = 12.5%（另有 60 未动）』」（五之四）
6. 「在岗/专属客服是**近似值**，原样转述 `rosterNote`，不说成『系统确认在职』，不替主管挡人」（六·已定取舍：不校验在岗）

**`get_agents` 返回口径**（写死，否则两处会漂）：

- 在手 `inHand` = `followup_plans WHERE assigneeUserId=X AND status='assigned' AND supersededAt IS NULL`
- 名册 = `patient_return_visits GROUP BY task_director_id WHERE source_created_at >= now - N months` —— ⛔ **绝不能用 `task_date`**（`schema.prisma:406` 注释与 4.3 都明说含未来排程，实测最远 2033 年）
- ⚠️ **不返回 `remaining`**（C3）：容量「20-50」是**区间**，返回 `capacityRange: [20,50]` + `inHand` + `capacityBasis: 'default'`，减法留给主管。返回一个精确的 `remaining: 38` 会让模型说「李莉还能吃 38 个」—— 那是用默认值做完了减法，T14 的免责声明救不了

**验收**：

- P3.2 生产量级（166.7 万行）`EXPLAIN ANALYZE` 走**新索引**，< 500ms；⭐ 造一条 `task_date=2033` 的记录，验证该客服**不**出现在名册里
- P3.3 对话验收：给 `n=12、转化 0` 的批次 → 输出必须含「样本量不足」，**不得出现「0.0%」**；给时效建议 → 必须含「默认值」
- P3.4 侧信道验收：tool result 回到模型的内容 **< 100 token**，而前端收到的 payload 含完整 planId 列表
- P3.5 staff token 调 `list_recall_queue({view:'pool'})` → 拒绝或降级为 `mine`

---

### P4 · 前端 · 4.75 人日

#### P4.1 列表页 T16 改造（0.75）· 13 处

| # | 位置 | 改法 |
|---|---|---|
| 1 | `patient-picker-rail.tsx:39-42` `VIEW_TABS` | pool 项加 `requires: Permission.PLAN_DISPATCH` |
| 2 | `:197` `.filter((t) => t.v !== 'all' \|\| canViewAll)` | 改按 `requires` 过滤（现在只过滤 `all`，pool 对所有人可见 = 违 T16） |
| 3 | `:53` `canViewAll` | 删（改用 `canDispatch = useHasPermission(PLAN_DISPATCH)`）—— 否则 eslint no-unused-vars 红 |
| 4 🔴 | `:109-123` 自动回落 effect | **加 `if (!canDispatch) return;`** —— **最易漏**：staff 的「我的」为空时现逻辑 `setView('pool')` 把他直接扔进召回池，tab 藏了但 view 还是 pool，**T16 当场破功** |
| 5-7 | `:143-159` `claim()` / `:312-316` `onClaim` prop / `:683-704` `PatientRow` 形参 | 整段删 |
| 8 | `:778-807` 动作按钮 IIFE | 二选一降为一选一：只剩 `onRecycle`；grid 叠层机制（`:788-806` hover 不重排）保留 |
| 9 | `plans/page.tsx:46` pool 兜底 | 包 `if (canDispatch)` —— 否则是隐式认领入口 |
| 10 | `plans/page.tsx:92-96` 空态文案 | staff「暂无分配给你的任务，等主管派单」；leader 保留 + 「去召回池分配」 |
| 11 | `plan-detail-app.tsx:220-228` `gateCheck` | `:223` 文案「在左侧列表点该患者的『认领』接手」指向不存在的入口，改掉；`:225` 的 `${assigneeUserId}` 换人名 |
| 12 | `plan-detail-app.tsx:449` 关闭机会后 pool 兜底 | 同 9 |
| 13 | `plans-api.ts:66-70` `assign` | **保留** + 注释「前端无入口，留给后续放开主动性（T16/T12）」 |

顺带改 `plan-sync-store.ts:9-11` 的注释（说「认领入口只在列表页」已不成立）。

#### P4.2 assistant-store（0.5）· 硬缺口，四份方案里只有 web 发现

**新建** `apps/pac-web/src/stores/assistant-store.ts`（zustand ~30 行，仿 `plan-sync-store.ts` 的 seq 模式）：`{ open, setOpen, pending: {text, seq} | null, ask(text) }`。

现状：`AssistantWidget` 的 `open` 是组件内 `useState`（`assistant-widget.tsx:21`），外部无法打开；`useAssistantChat()` 在 `AssistantChat` 内实例化（`assistant-chat.tsx:440`），`send` 不外露。**没有它，「移交助手」根本无处落地。**

⚠️ `AssistantWidget` 收起是 CSS 隐藏不卸载（`:63` `invisible`），store 化后**保持这个特性**，别改成条件渲染（会清空主管的对话）。

#### P4.3 客服姓名（0.75）· 确认单的**硬依赖**

现状实测：`lib/utils.ts:66-72` `userDisplayName` 只接受当前登录用户；`dictionary.users` 的两个填充点（`auth.service.ts:597` / `:656-658`）**都只塞登录人自己一条**；后果是 `adapt-data.ts:165-168` 的 `assignee.name` 直接 `= assigneeUserId`，详情页「承接人」显示一串 uuid。

**双管齐下**：

- **A（主路，治「显示」）** 凡返回 userId 的 payload 同时返回服务端解析好的姓名（`assigneeName` / `assignedByName` / 确认单每个客服的 `name`）。与站内既有做法同源（诊所名走 `auth.service.ts:125` `host.clinicNames` 派生；场景标签后端预翻译）。前端加 `agentDisplayName(id, name?)`，无 name 回落 id 前 8 位（**不回落完整 uuid**，400px 卡片会撑爆）
- **B（辅路，治「选人」）** 下拉候选走 P3.2 的 `GET /plans/agents`；前端 `stores/agent-dict-store.ts` 内存缓存一次。⭐ **直接改造 `DoctorPicker`**（`patient-picker-rail.tsx:366-378, 601-659`），不重写

❌ 明确否掉：把 1,092 个客服塞进 JWT `dictionary.users`（token 几十 KB + 每次 base64 解码 + localStorage）；前端逐个 `GET /users/:id`（20 行 = 20 请求）。

⚠️ **不做在岗校验、不打红叉**（六·已定取舍）—— 只在名字旁给中性灰标「近 12 月无回访记录」，主管自己判断。⚠️ 客服与诊所多对多（24% 跨诊所），下拉按 userId 去重。

#### P4.4 确认单（2.0）

| 改动 | 位置 |
|---|---|
| `Block` 联合加 `{ kind:'assignment_sheet'; sheet; state:'pending'\|'confirmed'\|'cancelled' }` | `use-assistant-chat.ts:29-32` |
| `onEvent` 加 `case 'assignment_sheet'`（upsert，与 `upsertArtifact` `:62-86` 同构） | `use-assistant-chat.ts:165-232` |
| `BlockView` 加分支 | `assistant-chat.tsx:124-129` |
| `TOOL_META` 补新工具中文 label | `assistant-chat.tsx:53-62` |
| **新建** `components/assistant/assignment-confirm-sheet.tsx` | 三层：汇总 → 按客服卡片列表（**不是 table**，400px 宽 `assistant-widget.tsx:27`）→ 患者明细（按客服折叠） |
| **新建** `components/plans/assignments-api.ts` | 对齐 `plans-api.ts:12-70` |

**微调只有两项**（T13：指定客服 / 时效）。多加一项直接违 T13，**评审按这条卡**。时效必须带「默认值」标注，文案照抄教条：「3 天（默认值，暂无历史结案数据，积累后按实际反推）」。溢出转铺平要**显式可见**（T15）：被铺平的组打角标「专属容量不足，已转铺平」，可点开改（并写回 `assignStrategy`）。

底部「确认分配」按 `<Can perm={PLAN_DISPATCH}>` 包住（`components/can.tsx:24` 支持 fallback）。

🔴 **确认成功后必须往消息流注入一条 assistant 文本块**：

```
已确认分配：批次 #a1b2c3 · 潜在种植 🔥热 · 3 位客服 · 共 60 条 · 3 天有效 · 福利「8 月种植体检免费」
```

因为 `toApiMessage`（`use-assistant-chat.ts:88-97`）**只回传文本块**（注释原文：「工具块不回传，模型自行重新决策」）—— 不注入的话，下一轮主管说「刚才那批改成 5 天」，模型完全不知道有过「那批」，会重新提议一次。**改动很小但极易漏，漏了会被当成模型能力问题。**

确认成功后卡片切终态（批次 id + 时间，按钮禁用）。

#### P4.5 移交入口 + 动效（0.75）

⭐ MVS 阶段发射点挂在召回池的「**移交助手**」按钮上（矩阵来了只换调用处，正好验证三层架构）：

| 位置 | 改动 |
|---|---|
| `lib/pet-events.ts:16-27` `PetGesture` | 加 `\| 'absorb'`，注释跟在 `:15` 那句「受限词表，LLM 导演也只能从这里选」下 |
| `lib/pet-events.ts:29-40` `PetEvent` | 加 `\| { type:'cohort_handoff'; payload:{ count, treatment, temperature? } }` |
| `components/pet/pet-brain.ts:8-20` + `:22-34` | `PetPose` / `ONE_SHOT_POSE` 各加 `absorb` |
| `pet-brain.ts:112-136` `onPetEvent` | 加分支 `play({ gesture:'absorb', bubble:\`正在挑 ${count} 位…\`, ttlMs: 2400 })` —— **这是唯一决定「怎么演」的地方** |
| `components/pet/pet-body.tsx:136-151` + `:456` `PET_CSS` | 加 `absorbing` flag + `@keyframes pacPetAbsorb`（⚠️ 宠物的 29 个 keyframes 在 `PET_CSS` 模板串里，**不在 `globals.css`**，通过 `:164` 的 `<style>` 注入 SVG） |
| 业务侧 | `emitPetEvent({...})` + `assistantStore.ask(...)`，**仅两行** |

⚠️ `AssistantWidget` 被 `AGENT_INVOKE` 门控（`:20, :50` 无权限 `return null`），且**只挂在 `plans/layout.tsx:74`**（`/plans/[planId]`），空工作台（`plans/page.tsx:72-108`）没有。验收清单写明：完整体验前提是同时具备 `AGENT_INVOKE` + `PLAN_DISPATCH`；空工作台的移交入口要么补挂 widget，要么不提供。

**验收**：

- P4.1 🔴 **staff 账号「我的」为空时，左栏停在「我的」空态，不自动跳到召回池**；staff 全页面搜不到任何「认领」按钮
- P4.4 ① 微调「指定客服」后确认，落库的是**卡片当前值**不是模型原提案 ② 确认后**再问「刚才那批」模型能答上来** ③ 按钮变终态不可再点
- P4.5 `/pet-lab` 可独立预览 `absorb` 姿态

---

### P5 · 跟踪（MVS 版）· 1.8 人日

**MCP 只读工具** `list_assignment_batches` + `get_assignment_detail`（`mcp-server.factory.ts` 新增注册块，仅 `PLAN_DISPATCH` 可见）。

**数据源全部现有表**（五之四已定）：`plan_assignments` / `followup_plans.assignment_id` / `plan_event_logs`（经 planId join）/ `plan_executions`。

⭐ **MVS 不查 `patient_transactions`**（转化率）：五之四已说初期无数据按 T14 明说样本不足 —— 那就**别查**，直接输出「暂无转化记录」。省 0.5 人日，且不会因为一个必然为 0 的分子拖慢查询。

**必须写死的三条口径**：

1. 每个比率带 `{ value, n, numerator, denominator, sufficient, note }`，`note` 是**给模型直接抄的成品句子**
2. 退回率两个数都出（五之四）
3. ⚠️ 聚合 `plan_event_logs.reason` 时**必须同时过滤 `event='release'`** —— 否则 `'timeout'`（auto_release）和 `'up'/'down'`（feedback）会混进退回原因分布。`@@index([hostId, tenantId, event, createdAt])`（`schema.prisma:1499`）正好是这个形状
4. ⚠️ **T18**：绝不给 `plan_event_logs` 加 `assignment_id`。归因走 `plan_id → followup_plans.assignment_id` 多跳一次。**写 SQL 时会很想加那一列，code review 盯死**

✅ 索引已核实无需担心：`schema.prisma:1493` 已有 `@@index([planId, createdAt])`，覆盖「按 planId 批量拉 + 按 event 过滤」。**从风险清单移除**（mcp 层曾列为待实测项）。

⭐ **福利在 MVS 的落地（降级方案，需产品确认 Q-4）**：批次福利文本作为**独立静态字段**展示在客服的 plan 详情页（原文照搬，不经 LLM），客服自己念。0.3 人日，**零合规风险、零重生成成本**，T4 的归因目的完全达成。福利进 prompt 推到第二刀。

**验收**：主管问一句拿到批次列表；每个比率行带 `n=`；`n<50` 的行**不出百分比不画图**；⭐ 退回原因分布的 SQL 里能看到 `event='release'` 过滤。

---

### ⭐ 最小可用切片（MVS）= P0 + P1 + P2 + P3 + P4 + P5

#### 端到端剧本（必须一次连续跑通，中途不改数据库、不看日志）

```
① 初选  主管在召回池按「潜在治疗:种植」筛(现有 personaTags 能力，零开发)
        → 点「移交助手」(emitPetEvent + assistantStore.ask)
② 精选  助手全景直出:产能视图 + 拟分方案(get_agents 名册+负载)
③ 确认  propose_assignment → 侧信道 → 原生确认单三层卡片
        微调仅两项(指定客服 / 时效)，铺平角标可见
④ 分配  点确认 → POST /pac/v1/plans/assignments
        → 落批次 + N 条归属 + N 条 assign 事件 → 注入文本块补记忆
⑤ 执行  切 staff 账号 → 「我的」看到单(左栏只有「我的」，无认领入口)→ 提交执行结果
⑥ 退回  客服退回 → 必填结构化 releaseReason → 回池，snoozedUntil 不动
⑦ 跟踪  切回主管问「我分的那批怎么样」→ list_assignment_batches
        → 分了 60 / 已动 40 / 退回 5 / 超期 8 / 转化「样本量不足」
```

**被砍掉的只有「矩阵可视化」这一个视觉入口。** 生产线七环全在，T1 / T5 / T7 / T8 / T13 / T14 / T16 / T20 全部可验证。价值来自 ④+⑦（把「要不要做」变成「做了没有」），不是 ①。

#### MVS 工时

| 阶段 | 人日 |
|---|---:|
| Gate 0 | 0.5 |
| P0 地基 | 3.95 |
| P1 表与不变式 | 1.35 |
| P2 写路径 | 3.1 |
| P3 助手侧 | 4.3 |
| P4 前端 | 4.75 |
| P5 跟踪 | 1.8 |
| **合计** | **≈ 19.75 人日** |

三道并行（后端 / MCP-助手 / 前端）下，**关键路径挂钟约 9–10 个工作日**（P0.5 → P1.1 → P1.2 → P2.1 → P2.2 → P4.4 → P5.1），加 20% 联调余量 **≈ 12 个工作日**。

> ⚠️ 四份子方案报的 8.0 + 10.5 + 8.5 + 1.7 ≈ 28.7 人日，其中约 8 人日是重复计入（跟踪三查询、名册负载、确认单、引擎记账、recycle reason、权限枚举各被报了 2–4 次）。

---

### P6 · 第二刀（v1 补齐）· ≈ 10 人日

| 任务 | 人日 | 说明 |
|---|---:|---|
| **P6.1 🔴 温度轴口径 + 数据改造** | 2.5 | 见下，**链条最长且含跑批挂钟** |
| ✅ P6.2 `GET /plans/matrix` + `temperature` 参数 | 1.0 | **已落地（2026-08-02）**。⭐ 关键设计:温度边界存成**稳定路径** `temperature.<标签>.hotUntil`（而不是 `detail[]` 数组下标）—— 数组下标因人而异，Prisma 的 json 路径过滤根本表达不了，列表就永远对不上矩阵。挪成稳定路径后矩阵走原生 SQL、列表走 Prisma，谓词字面等价。**本地实测 24 格逐格与列表 total 相等** |
| ✅ P6.3 矩阵组件 + 配色 + 视图切换 | 1.5 | **已落地**。见下；⚠️ 规划建议的「矩阵模式把 rail 加宽到 420px」**实测撤销** —— 1280px 视口下会把中间「参考话术」列挤成竖排文字。8×3 在 320px 内完全够用 |
| P6.4 撤销（REST + 原生确认组件） | 1.25 | D-4 / D-10 / D-11 |
| ✅ P6.5 `get_cohort_attributes`（T9-B 调整阶段画像圈人） | 1.0 | **已落地（2026-08-02）**。合并 `getDedicatedCs` + `getPersonaFeatures`（T10）；不 join 名册、不返回 `stillActive`。⭐ 关键补充：人群取数抽成**共用** `cohort-filter.ts`，出确认单与看分布同一份 SQL —— 否则会出现「分布说商保 32 人、提案只有 28 人」。⭐ 每个维度单独返回 `noTag`（「没有这条画像证据」≠「反面」），提示词与工具返回双保险 |
| P6.6 福利进 prompt + 护栏 + 话术失效规则 | 1.25 | 见下 |
| ✅ P6.7 完成口径（原「转化率」） | 1.5 | **口径已裁决并落地（2026-08-02），见教条 T20′**。⭐ 原案「转化率」**已否** —— 它卡在 `plan_executions` 11% 回写率上，永远是「样本不足」。改判：**完成 = 池子状态**（召回出池 / 被抑制），出池那半**零回写依赖**。成功与否先不统计，但底账（出池原因 + `patient_facts`）全在，随时可回溯。⭐ 连带修掉跟踪查询 `supersededAt: null` 的静默缺陷（实测 60 条批次显示成 52 条，缩掉的正是已出池的那 8 个）。**剩「因素 × 完成率」的横向报表未做** —— 需 n≥50 真实样本，属 S3 |

#### P6.1 温度轴 —— **四份方案全部踢皮球的孤儿依赖**

> ✅ **口径部分已于 2026-08-02 落地**（教条 T6′ + `packages/types/src/temperature.ts`）：
> Q-6 解掉、锚点 `anchorAt` 进 `PotentialGap`、`hotUntil`/`warmUntil` 进
> `persona_features.data.detail[]`、本地 5,825 患者全量重算通过。
> **剩下的是 P6.2 的矩阵端点与索引**（下方「技术链条」的后两环）。
> ⚠️ 一次性版本波：新增两个语义键 → 首次重算全量升版本（本地实测 success=3,020）。
> ⚠️ 顺带抓出并修掉了「按 `plan.persona_id` 关联画像」的静默归零缺陷，见教条 §4.38。

web 说「后端加 query 参数」、mcp 说「属初选矩阵工作流，不是 MCP 层的活」、data/service 只字未提。**没有 owner。**

而且现有数据**算不出来**（已实测复核）：

1. `daysSince` 的唯一存放处是 `persona_features.data.detail[].daysSince`（`potential-treatment.feature.ts:126`）
2. `persona-diff.ts:57` 把 `daysSince` 登记在 `VOLATILE_DATA_KEYS`，`stripVolatile()` 在变更检测前剔掉它，注释原话：「这类键的变化不代表患者变了，只代表『时间过去了』，不该升版本」
3. ⇒ **时间流逝永远不会触发重算，温度档永久冻结在画像计算那一刻**。4.1 那张「热 11% / 温 11% / 冷 78%」是本地刚重建库（画像刚算完）的理想态，生产跑几个月后会整体往「冷」漂
4. `PotentialGap`（`potential-treatment.selector.ts:93-102`）**没有日期锚**，只有 `daysSince: number`

**加上一个未在教条里出现的口径洞**：8 个业务标签 ≠ K 码，阈值**一对多**且已在聚合时被 `Math.max` 跨码合并 —— `extraction ← K01(180/90) + K03(90/60)`，`perio ← K05(120/90) + K06(120/60)`，还有 `*_RECOMMENDED` 各自的窗口（`canonical-codes.ts:236-257`）。**「潜在拔牙」这一格该用哪套阈值判热/温/冷，无解。**

**技术链条**：

```
温度口径拍板(8 标签 × 窗口映射)   ← ❓ 产品/口径决策，教条里没有(Q-1)
  └→ selector 输出锚点日期(不是天数) + extractor 存 anchorAt
       └→ 迁移 C:persona_features 温度偏索引(7.66M 行 / 5.4GB)  ← ⚠️ 单语句 + CONCURRENTLY，独立目录
            └→ 全量 persona 重算(挂钟窗口，不受人日控制)
                 └→ P6.2 matrix 端点
```

⭐ **锚点是事实、天数是时钟** —— 存 `anchorAt`（诊断日期）而不是 `daysSince`，可以进 diff（不进 `VOLATILE_DATA_KEYS`）、可以在 SQL 里现算、不会冻结。这是这条链的关键设计。

#### P6.3 矩阵组件要点

- **新建** `apps/pac-web/src/components/plans/pool-matrix.tsx`；挂在 `patient-picker-rail.tsx:196-215` tab 行右侧的「列表 ⇄ 矩阵」切换（仅 `canDispatch && view==='pool'`）；**不新开路由**（T16）
- 行标签用 `packages/types/src/labels.ts:93-106` 的 `potentialTreatmentCardLabel()`，⛔ 别自己写中文表
- 配色三档**恰好等于 Tailwind 默认调色板**：`bg-blue-100` / `bg-amber-100` / `bg-orange-400`，无需新 token（`globals.css:12` 的规矩：别在组件里写死十六进制）。⚠️ **底色按列固定，与数量完全无关**（五之三）—— 实现上列头决定 class，cell 不参与计算，⛔ 别写任何 `count > N ? ... : ...`。⚠️ 别用 `brand-*`（品牌蓝 `#0032A0` 比 `#DBEAFE` 深太多，混用会让「冷」像选中态）
- rail 是 `w-[320px]` 固定（`:194`），矩阵模式提到 `w-[420px]`（lg+ 在 flex 流内、<lg 是 fixed 抽屉，两种都安全）；`<lg` 不提供矩阵（入口加 `lg:` 前缀藏掉）
- **X 轴零开发**：`packages/types/src/persona-tag-filters.ts:92-109` 的 `potential_treatment` 维度 8 个 value 与 T6a 完全一致，点格子直接塞 `filters.personaTags`
- **Y 轴走 query 参数，不加进 `PERSONA_TAG_FILTER_DIMS`** —— 那张表是客服筛选标签 popover 的渲染源（`patient-picker-rail.tsx:410` 遍历它），加进去温度就会出现在客服面板（违 T16）；且温度是初选轴不是精选标签（六·已定取舍：精选不再切窗口）

#### P6.6 福利进 prompt —— 🔴 唯一一条「做错了会对患者做出虚假承诺」的项

T4 已把落点写死：`shared/fact-block.ts`（标准 + 深度）与 `tiers/stable/prompt.ts`（稳健）**各加一处**，护栏「不得追加条件、期限、承诺，不得改写或夸大」，没配福利整段不生成。

⚠️ 但**四份方案对福利的处理全部止步于存储与展示**，且现有机制会直接打脸 —— 实测 `schema.prisma:1203` **`planId String @unique`**（一个 plan 一份话术），而 `agent-identity.ts` 文件头已记录：召回池是共享的，未认领工单谁都能打开，`POST :id/view` 就是为此埋的点。

**三段后果，一段比一段严重**：

1. 分配**之前**若有人点开过这条 plan，话术已 `status='ready'` 落库 → **福利段永远不出现**，T4 的归因目的失效
2. plan 在批次 A（福利「8月种植体检免费」）→ 退回 → 进批次 B（福利「正畸首付 5 折」）→ 话术缓存**还是 A 的文案**，客服照着念 = **对患者做了一个不存在的承诺**
3. 撤销批次后话术里的福利仍在

**必做**：定义话术失效规则 —— `plan_assignments.attributes.benefit` 变化（进批次 / 换批次 / 撤销）必须把 `plan_scripts` 置 `pending` 重生成，**写进 `plan-assignment.service` 的事务里**，否则一定漏。
⚠️ **成本提醒**：一批 100 人 = 100 次 LLM 重生成的钱与延迟，四份方案都没算。

---

## 五、风险登记

| # | 级 | 风险 | 位置 / 证据 | 缓解 | 归属阶段 |
|---|---|---|---|---|---|
| **R1** | 🔴 | **ID 空间不一致**：`JWT.sub` ≠ `task_director_id` → 分完单客服「我的」全空，**不报错** | `auth.service.ts:537` / `schemas/auth.ts:27` / 本地是自证循环 | **Gate 0 硬闸**，不通过不开工 | G0.1 |
| **R2** | 🔴 | **supersede 吞归因**：`carryAssignment` 只在 `status='assigned'` 为真（`plan-engine.service.ts:471`），而**退回后是 `active`**（`plan.service.ts:672`）→ 退回单的分子分母一起静默归零 | 实测 | D-12 无条件继承 + **对 `active` 入参也断言**的守恒测试 | P1.2 |
| **R3** | 🟠 | **锁队列雪崩**：PG 锁队列近似 FIFO，一个等待中的 `ACCESS EXCLUSIVE` 会挡住其后所有 SELECT。部署时若有慢查询压在 `followup_plans` 上 → 召回工作台在 ALTER 拿到锁**之前**就全停 | `20260728020000` 文件头 | ① 迁移首句 `SET LOCAL lock_timeout='10s'`（把「全站排队几分钟」换成「迁移快速失败」）② 避开 DW 08:00(沪) 落库后的增量 cron 窗口 ③ 部署前扫 `pg_stat_activity` | P1.1 |
| **R4** | 🟠 | **迁移 B/C 混进 A** → `ERROR 25001` + `P3018` **堵死整条迁移流水线** | 同上 | 三份**独立目录**：A 多语句普通 DDL，B/C 各**单语句 + CONCURRENTLY**。上线 checklist 里写死 | P1 / P3.2 / P6.1 |
| **R5** | 🟠 | **撤销/到期误收已作业单**：`EXISTS(plan_executions)` 判「已动过」会漏 89%（4.5 实测：65 认领 / 7 条结果），客服打完的电话永久蒸发 | 4.5 + `claim-guard.ts:1-14` | D-11 三条件 OR | P6.4 |
| **R6** | 🟠 | **MCP `@Public()` 绕过权限**：`permissions.guard.ts:16` `if (isPublic) return true` 已实测，`@RequirePermission` 在 MCP 路径完全不生效 | 实测 | D-4 写路径不走 MCP + 只读工具条件注册 + `requirePermission` helper 备着 | P3.5 |
| **R7** | 🟠 | **上线当天 leader 无 `plan:dispatch`**：JWT 的 `permissions` 是签发时固化的（`auth.service.ts:534`），2 小时内所有 leader 都是 staff 待遇，MCP 工具集静默降级，**无任何错误提示** | 实测 | ① `auth-store.ts:104-117` merge `permissions` ② 上线步骤写明「发版后强制重登 / 临时缩短 access token TTL」③ `get_current_user` 返回 `canDispatch:false` 时助手说「登录态可能过期，请重新登录」而非「你没有权限」 | P0.1 + 上线 |
| **R8** | 🟠 | **福利话术缓存**：`plan_scripts.planId @unique`（`schema.prisma:1203`），换批次不重生成 = 对患者做虚假承诺 | 实测 | P6.6 失效规则；**MVS 期福利不进 LLM**（Q-4 降级） | P5 / P6.6 |
| **R9** | 🟡 | **schema 注释承诺的 partial UNIQUE 不存在**：`schema.prisma:1069` 与 `data-model.mdx:91` 都写着「一患者只一条活动 plan」，但 37 份迁移里**没有任何一句创建它**（逐份 grep 确认） | 实测 | ⛔ 分配逻辑**不依赖**它去重，批量预检自己按 `patientId` 去重。本次**不修**（补索引前得先查存量违例） | P2.1 |
| **R10** | 🟡 | **`reason` 列成大杂烩**：`auto_release` 下将混 `timeout` / `clinic_moved` / `signals_cleared` / `assignment_expired`，全靠自由 TEXT 区分，与 `PlanEventType` 注释「不要在调用处随手写字符串」同一纪律 | — | 新增 `PlanEventReason` 枚举（F1 尾）0.1 人日 | P0.2 |
| **R11** | 🟡 | **事件洪峰**：`runAllForHost` 全量 44 万患者，补记账后一次口径变更可能一次性写出大量 `auto_release` | — | 加计数日志 + 上线公告 | P0.5 |
| **R12** | 🟡 | **`assignment_id` 就地覆盖导致历史批次规模漂移**：plan 在批次 N 被分 → 退回 → 进批次 N+1，批次 N 的 COUNT 悄悄少 1 | — | ⛔ **否掉 data 层 R2 的「写进 `plan_event_logs.details` 离线校正」** —— 它的用法 `WHERE details->>'assignmentId'=X` 按 `schema.prisma:1484` 自己引用的规则「要按它筛就该立柱」就该立柱，而立柱正是 T18 撤回的东西，且无索引 + 混着高频 `view` 事件 = 全表 jsonb 扫。✅ 正解就是 D-12（无条件继承后 COUNT 不漂，根本不需要补丁）。⚠️ 「同一 plan 跨批次」这一支仍会漂 —— 需产品确认可接受（Q-5） | P1.2 |
| **R13** | 🟡 | **`release_reason` 与 `recall_feedback` 聚合串了** → 统计全废 | 五之二 | 命名 DTO 用 `releaseReason` 不用 `reason`；单测覆盖 | P0.6 |
| **R14** | 🟡 | **`cohortId` 下一轮丢失**：`toApiMessage`（`use-assistant-chat.ts:88-97`）只回传文本块 → 第 2 轮主管说「只要商保的」时模型手里没有 cohort，只能重新圈人（可能圈到不同的人）+ 重复消耗 `stepCountIs(8)` | 实测 | 助手回复里带一句可见文本「当前人群：潜在种植·热，383 人」；MVS 期人群条件本身是几十 token 的明文，直接复述即可 | P3.3 |
| **R15** | 🟡 | **`render_artifact` token 账**：五之四要求「数据必须内联」，100 条患者明细要模型逐条**生成**进 HTML，`stepCountIs(8)` 撑不住 | — | systemExtra 写死：artifact 只画**聚合**（漏斗/饼图/条形图），患者明细走 markdown 表格且不进 artifact | P5 |
| **R16** | 🟢 | **专属客服 ≠ 名册**：`schema.prisma:426` 注释实测「近 12 月 367 万对回访，`task_director_id` 与 `current_task_director` 仅 **23.1%** 相同」 | 实测 | 「专属优先」的命中率可能远低于 4.2 的 83.5% 覆盖率所暗示的。**确认单上把命中/未命中数量显式呈现**，别让主管以为「专属优先」大部分时候在起作用 | P3.4 |
| **R17** | 🟢 | 文档漂移：`data-model.mdx` 表清单（20-34 行）**已经漏了 `plan_event_logs`** | — | 顺手补两行 | P1.1 |
| **R18** | 🟢 | 撤销限时是 UX 摩擦不是安全边界（leader 本就有 `PLAN_RECYCLE` + `PLAN_VIEW_ALL`，可逐条 recycle 绕过） | — | 写进注释，避免后人基于「30 分钟后锁死」设计别的东西 | P6.4 |

---

## 六、验证策略

### 分层

| 层 | 验什么 | 不验什么 |
|---|---|---|
| **单测（mock Prisma）** | 纯逻辑：权限矩阵、枚举完备性、时区转换、收敛规则的取数排序、`sufficient` 阈值判定、提示词 builder 的分支 | ⛔ **并发正确性**（见下） |
| **集成测试（真 PG）** | 带条件 `updateMany` 的并发语义、事务回滚、唯一键冲突、守恒断言 | 大数据量性能 |
| **本地真实数据集** | 端到端剧本、口径对数、查询计划 | 生产量级性能 |
| **测试服** | 生产量级索引效果、ID 空间、迁移锁窗口 | — |

### 🔴 E1 · 缺 DB 测试床（四份方案都没提）

实测 `apps/pac-service/tests/` 的 50 个 spec **全部 mock Prisma**（唯一提到 `PrismaClient` 的 `deploy-managed-guard.spec.ts` 也不连库）。而 P2 排了 1.0 人日的「并发抢占 / 部分失败」测试 —— **「带条件 `updateMany` + 判 count」的正确性只能在真 PG 上验证**，mock 里 `count` 是你自己填的，测的是你的假设不是 PG 的行为。

**二选一，必须现在选**：

- **A（推荐）** 建 testcontainers / 本地库的集成测试床 —— **独立工作项 +1.5 人日**，一次性投入，之后所有并发/事务改动都受益
- **B** 承认这块无法自动化，把 P2.3 从「单测」改成**手写联调 checklist**（两个浏览器 tab 同时点、模拟抢占），工时不变但产出是文档不是测试

> 本规划**按 A 计**，已把 1.5 人日列在下方「未计入 MVS 的可选项」。若选 B，MVS 工时不变但回归成本长期偏高。

### 本地真实数据集验证（5,825 患者 / 2,724 plan）

| 验什么 | 怎么验 | 通过标准 |
|---|---|---|
| 迁移零 drift | 37 迁移全应用的库上 `prisma migrate dev` | 无 drift 输出 |
| 端到端剧本 | 第 4 节 MVS 剧本七步 | 一次跑通，中途不改库不看日志 |
| **口径对数** | 矩阵/筛选出的人数 vs 点进去列表的条数 | ⚠️ 矩阵按**患者去重**、列表按 **plan** 分页，口径不一致主管一眼发现 → hover 里注明「N 位患者」而非「N 条」，且逐格对数 |
| 名册 `source_created_at` | 造一条 `task_date=2033` 的记录 | 该客服**不**出现在名册里 |
| 引擎守恒 | 对已分配 + 已退回的 plan 各触发一次重算 | 归因六列守恒 |
| 助手样本量口径 | 造 `n=12、转化 0` 的批次问助手 | 输出含「样本量不足」，**无「0.0%」** |

⚠️ 本地库缺 `fact_complex_cases_out`（教条附录），需手工建同构空表（**必须含 `is_del` / `case_stage` 两列**）。

### 测试服验证（生产量级）

| 验什么 | 通过标准 |
|---|---|
| **G0.1 ID 空间**（最先） | `task_director_id` 与真实 JWT sub 匹配 |
| 迁移 A 锁窗口 | 全程 < 3 秒；`lock_timeout` 生效可验（故意压一条长事务） |
| 迁移 B 后名册查询 | 166.7 万行 `EXPLAIN ANALYZE` 走新索引，< 500ms |
| `source_created_at` 回填完整性 | ⚠️ 该列是刚加的（`20260801150000` / commit `2516596`），**存量 166.7 万行是否已回填完整需先查**，否则名册会静默少人 |
| 批量 500 items | < 5s，不抛事务超时 |
| 跟踪查询 | `plan_event_logs` 的第一个读场景，`EXPLAIN` 走 `(planId, createdAt)` |

### 回滚策略（E4，四份方案都没写）

**结论：代码可回滚，schema 不回滚。** 必须在上线单里写明：

- 迁移失败 → `npx prisma migrate resolve --rolled-back <name>` 后重试（整份 SQL 在一个隐式事务里，**不可能出现「表建了列没加」的半吊子 schema**）
- 上线后回退代码 → 🔴 **P1.1 与 P1.2 必须一起回**。只回 P1.2 会让旧引擎在下次重算时把所有已分配单的归因抹掉
- 已产生的批次数据：保留（`plan_assignments` 与新列对旧代码无害，旧代码只是不读它们）；重新上线后归因仍在

---

## 七、仍待产品决策（**不擅自定，列清楚**）

按「卡不卡开发起跑」排序。

| # | 议题 | 为什么必须产品定 | 选项 / 我的倾向 | 卡谁 |
|---|---|---|---|---|
| **Q-1** 🔴 | **v1 分两刀是否可接受**（第一刀不含矩阵可视化） | 教条六已定「矩阵放 v1」，本规划提议 v1 内分两刀 | ✅ 建议接受。理由：温度轴含一次 persona 全量重算，**挂钟时间不受人日控制**，绑第一刀会让交付被跑批窗口挟持；MVS 的价值来自 ④+⑦ 不是 ① | 全案排期 |
| **Q-2** 🔴 | **「1,189 人的格子 → 一批 100 人」的收敛规则** | 四份方案**全都没有**：data 存 criteria、service 收 items、mcp 明写「本工具不做任何圈人」、web 只渲染。**从 1,189 收到 ~100、并决定「是哪 100」的那一步没有任何一层实现它**。且取舍表内部打架：「拟分 N 人 = 默认分满容量」× 30 人在岗 × 50 = 1,500，正是 T5 说的「1000 人做浅」 | ① 取 N 的**排序键**（默认 `priorityScore desc`？需论证它就是分配的正确排序）② 批次规模上限（教条七·待确认）③「默认分满容量」与「一批 100 人」二选一，或把容量默认值取**区间下界 20** | P3.4 提案展开 |
| **Q-3** 🟠 | **`ReleaseReason` 值域定稿** | D-6 建议取 data 层 8 个（按「主管的一根杠杆」分类，直接服务 T20 反推）；service 层给了另一套 6 个 | 8 个见 D-6。⭐ **可以先落结构 + `other` 一个键开工**（`hidden?` 字段已预留），值域后补 —— 不要让它卡 P0 | P0.2（可解耦） |
| **Q-4** 🟠 | **MVS 期福利的落地方式降级** | T4 明写福利要进 prompt（`shared/fact-block.ts` + `tiers/stable/prompt.ts`），但撞上 `plan_scripts.planId @unique` 的缓存问题（R8），最坏会**对患者做虚假承诺** | 建议 MVS 期降级为「独立静态字段，客服自己念，不经 LLM」—— 零合规风险、零重生成成本，T4 的归因目的完全达成；P6.6 再做 prompt 融入 + 失效规则 | P5 |
| **Q-5** 🟠 | **批次规模统计的漂移是否可接受** | 一个 plan 在批次 N 被分 → 退回 → 进批次 N+1 → `assignment_id` 翻成 N+1，批次 N 的 COUNT 悄悄少 1 | ① 接受（简单，符合「主表存当前值」的既有模式）② 不接受 → 需要一张 `plan_assignment_members` 关联表（+0.5 人日 + 一列迁移）。⛔ 「写进 `plan_event_logs.details`」的补丁方案已被否（R12） | P1.1 建表 |
| ~~**Q-6**~~ ✅ | ~~**温度的 8 标签 × 窗口映射**~~ | **已裁决（2026-08-02），见教条 T6′** | ⭐ 原建议（标签级窗口表 8 行常量）**已否**。真正的解法是**换一层聚合**：逐条 gap 用自己 K 码的窗口判档、再取最热 —— 一对多自动消解，不需要任何新常量，`DiagnosisTreatmentMap` 是真的被复用了。原判「无解」源于默认了聚合必须发生在天数层 | — |
| **Q-7** 🟡 | **分配单到期后的行为**（教条七·待确认） | ① 自动回池 ② 只提醒 ③ 两者皆有。⚠️ ①/③ 会擦 T8 的判据（「任何改变数据库状态的动作只能由主管确认触发」）。可辩护的解释是「主管在确认单上确认了时效 = 预授权」，但**这属于产品解释权，不该由实现认定** | 建议 **MVS 只做 ②**（写入 + 超期标红），①/③ 推到 v2。若选 ①/③，需在确认单文案上把到期行为显式写出来，把预授权变成看得见的；且**必须落 `release_reason='expired'`**（否则 T20 的分母不干净）、**必须照抄 `recycle-scheduler.service.ts:64-68` 的 `snoozedUntil` 守卫**（约好 6/10 回访的单不能被收走） | 不卡 MVS |
| **Q-8** 🟡 | **撤销时限** | 建议可配 env、默认 **30 分钟**，并按 T14 在助手话术里标明是默认值 | 语义是「手滑/分错人」的补救，不是「改主意重新调度」（改主意应走退回 + 重分）。⚠️ 它是 UX 摩擦不是安全边界（R18） | P6.4 |
| **Q-9** 🟡 | **已被认领的单能否强制改派**（教条七·待确认） | 当前后端拦住（`plan.service.ts:589-591`），教条建议保留该摩擦 | 建议保持拦住 → 进 `skipped(reason:'claimed_by_other')`。⭐ 该判据已收口到 `claim-guard.assertAssignable` **一个函数**，将来改只改这一处 | 不卡 |
| **Q-10** 🟢 | 矩阵在 `<lg` 小屏是否提供 | 建议**不提供**（入口加 `lg:` 藏掉）—— 8×3 在 375px 抽屉里做不出可读密度，主管基本桌面办公。若要求移动端可用，需另做纵向堆叠版 +1.0 人日 | 不卡 |

---

## 八、未计入 MVS 的可选投入

| 项 | 人日 | 说明 |
|---|---:|---|
| **DB 集成测试床（testcontainers）** | 1.5 | 见第 6 节 E1。选 B（手写联调 checklist）则为 0，但长期回归成本偏高 |
| `followup_plans` partial UNIQUE 补建 | 1.0 | 需先查存量违例（R9）。独立数据清理项 |
| `assignment_expires_at` partial 索引 | 0.2 | ⚠️ **若 `get_agents(withWorkload)` 的 `overdue` 走全表扫，这条要提到 MVS** —— data 层「唯一用途是到期 cron，而 cron 待定」的论证站不住，因为 `overdue` 是 v1 同步工具、跑在 LLM 工具调用里，每次主管问「团队什么状态」就对 25 万行全表过滤 × N 个客服。**P3.2 联调时实测查询计划，超 300ms 就补** |
| 企微合成身份真身份映射 | ≥1.0 | 六·已定取舍：本期不管。写路径已由 `rejectSyntheticIdentity` 硬拒 |

---

## 九、五条会咬人的排期提醒

1. **P1.1 与 P1.2 之间不许发一个版本。** 中间那个版本会让每日重算静默抹掉批次归因，**不报错、无告警**，等发现时历史已经花了。
2. **迁移 A / B / C 必须是三个目录。** B/C 混进 A 会 `25001` + `P3018` 堵死流水线。
3. **`packages/types/src/enums/index.ts` 是四方争抢点**（权限 + ReleaseReason + PlanEventReason 都插在同一区域）。P0.1/P0.2 合一个 PR，其余三层等它 merge 再拉分支。
4. **迁移 A 里一次把 `request_id`（D-2）、`assign_strategy`（D-6）、`status/revoked_at/revoked_by`（撤销推迟但列先建）全带上** —— 否则第二刀要再取一次 `followup_plans` 的 `ACCESS EXCLUSIVE`。
5. **口径决策（Q-2 / Q-3 / Q-6）最早启动** —— 它们不占开发人日却卡三条并行道；Q-6 尤其要早，它是第二刀矩阵的链条起点，**且教条里根本没有**。

---

## 附录 · 本文引用的实测事实（2026-08-02 工作树）

| 事实 | 出处 |
|---|---|
| 迁移目录 **37 个** —— ⚠️ 本文原写 38，**2026-08-02 复核更正为 37**（磁盘 37 个目录，`_prisma_migrations` 37 条 applied）。四份子方案说的 37 是对的 | `apps/pac-service/prisma/migrations/` |
| `daysSince` 在 `VOLATILE_DATA_KEYS` 里，时间流逝不触发重算 | `persona-diff.ts:57` + `:81-90` |
| `PotentialGap` 无日期锚，只有 `daysSince: number` | `potential-treatment.selector.ts:93-102` |
| 8 标签 ← K 码是一对多，窗口阈值冲突 | `potential-treatment.feature.ts:53-81` + `canonical-codes.ts:236-257` |
| `carryAssignment` 只在 `status==='assigned'` 为真 | `plan-engine.service.ts:471` |
| 退回后 plan 是 `status='active'` | `plan.service.ts:672` |
| `assign()` 的 `findFirst` 无 `targetClinicId` 过滤 | `plan.service.ts:572-577` |
| `sub = user.userId` = 宿主侧 user id | `auth.service.ts:537` + `schemas/auth.ts:27` |
| `PermissionsGuard` 对 `@Public()` 直接 `return true` | `permissions.guard.ts:16` |
| `plan_scripts.planId @unique`（一 plan 一话术） | `schema.prisma:1203` |
| `plan_event_logs` 已有 `@@index([planId, createdAt])` + `([hostId,tenantId,event,createdAt])` | `schema.prisma:1493, 1499` |
| `SCOPED_MODELS` 14 张表，无 `PlanEventLog` / `PlanReason` / `PlanAssignment` | `tenant-guard.extension.ts:23-38` |
| `auth-store.loadSession` 不 merge `permissions` | `apps/pac-web/src/stores/auth-store.ts:104-117` |
| `toolsCache` 是 `McpToolDef[] \| null`，进程级、与 token 无关 | `mcp-client.service.ts:25` |
| `plan.controller.ts` recycle 的 `@Body() _dto`（reason 被丢） | `plan.controller.ts:127` |
| `RECYCLE_TIMEOUT_HOURS = 24`，注释指向不存在的 `tenant.rules_config` | `plan.service.ts:50` |
| `patient_return_visits` 名册索引末列是 `task_date` | `schema.prisma:447` |
| ⭐ `task_director_id` 与 `current_task_director` 近 12 月**仅 23.1% 相同** | `schema.prisma:420-426` 注释 |
| MCP 现有 7 个 `registerTool`，全只读 | `mcp-server.factory.ts:84,97,120,133,151,164,209` |
| ⚠️ **更正**：`data/jvs-dw/users.json` **存在且已入库**（36KB / commit `379a4af`，mock 名册非空）。形状 = 未来 `users` + `user_clinics` 两表，含 `lastActiveAt` / `contactCount`（正是 P3.2 名册要算的口径，离线 CLI 已跑过一遍）。⛔ 但它只服务 mock 登录，**不是 PAC 主数据** → P4.3「姓名必须服务端解析」的结论不变 | `mock-users.ts` + 实测 |
| `STAFF` 持有 `PLAN_ASSIGN` + `PLAN_RECYCLE`；LEADER 只多 `PLAN_VIEW_ALL` + `STATS_VIEW` | `enums/index.ts:36-63` |

**已修正的引用错误**（四份子方案里出现的，实现时按此为准）：

1. service 层称「`tests/plan-event-log.spec.ts` 有断言『`HUMAN_TOUCH_EVENTS` 精确等于四个』」—— 实际是 `arrayContaining([CLAIM, ASSIGN, FEEDBACK])`。真正会拦住新增枚举的是同文件的 `Object.keys(PLAN_EVENT_META).sort() === Object.values(PlanEventType).sort()`。结论（别新增事件类型）仍成立，但理由要改对。
2. data 层多处写「37 份迁移」—— 实际 38。
3. mcp 层把 `plan_event_logs` 的索引列为「待实测风险」—— 已核实覆盖，从风险清单移除。