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
......@@ -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-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` 解耦**(原案),但**不是无条件** —— ⭐ **召回理由整组换掉时不继承**(新版本已是另一张工单)。判据取「临床缺口类型」交集(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-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 是**接口草案记法**,不是工具名 |
......
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