assignment-revoke.spec.ts
8.2 KB
-
feat(plan): S2.1 撤销整批 —— 限时 + 「已打开过的不收」 · 64b8d4fd
分错人 / 条件填错的唯一出口。POST /plans/assignments/:id/revoke。 ##
⭐ 「已动过」的判据只能用 view 事件 直觉会去查 plan_executions(有执行记录 = 动过),但回写率仅 **11%** (生产 65 个认领单只有 7 条执行结果)—— 拿它判会把 89% **已经打过电话**的单 当成"没动过"收走,那通电话永久蒸发,而客服第二天才发现单子没了。 `contactAttempts` 与 plan_executions 同源(提交执行时才累加)、同为 7 条,一样不可用。 唯一可用的是 PV/UV 埋点的 `view` 事件 —— 客服打开过详情页 = 至少看过。 ⇒ 已 view 的**跳过不收**,并在返回的成品句子里如实告诉主管有几条没收、为什么。 这是 PV/UV 埋点(本为统计而做)的意外收益。 ## 撤销 ≠ 退回(T21)—— 写入纪律三条 撤销 revoke 主管收回**整批**(分错人) → auto_release + reason=revoked 退回 release 客服退回**单条**(不是我的客户)→ release + ReleaseReason⛔ **不写 release_reason**:那列只属于客服的处置,混进去退回率的分子分母一起虚高⛔ **不清批次归因三列**:"这批曾经分过 20 条"是历史事实,批次头已标 revoked, 统计按 status 排除即可。清了这次误操作影响了多少人就再也说不清⛔ **不动 snoozedUntil**(与退回、到期同一条纪律)⭐ 账本 actor 记**主管**(不像到期回收那样为 null)—— 撤销是人做的决定,要能追责 ## 授权与时限 授权(D-10)在 service 顶部**一次性**校验:`createdBy===actor || PLAN_VIEW_ALL`。⛔ 不逐行调 assertCanRecycle —— 那是单条路径判"这单是不是你的", 撤销判的是"这**批**是不是你的",逐行会变成"有一条不是你的就整批失败",语义不对。 窗口 30 分钟。语义是「**手滑/分错人**的补救」,不是「改主意重新调度」—— 改主意应走"客服退回 + 重新分配",那条路有完整的原因记录。⚠ ️⚠ ️ 它是 **UX 摩擦不是安全边界**:leader 本就有 PLAN_RECYCLE + PLAN_VIEW_ALL, 超窗口照样能逐条 recycle。⛔ 别基于「30 分钟后就锁死」去设计别的东西。 超窗口的报错**指路到逐条退回**,不是只说"不行"。 幂等:重复撤销不报错(主管手抖点两下很正常),返回零改动。 合成身份(企微 `wx:`)硬拒 —— 与写路径同一道闸。 ## MCP 工具 `revoke_assignment` 是助手手里**唯一会改数据的工具**,描述里写死: 只在主管**明确要求**时调,⛔ 不主动建议、不试探性调用; skippedTouched 不许说成"失败",note 原话转述。 ## 验证 894 单测(新增 13 条断言)+ 本地真实数据端到端: 20 条在手 / 9 条被打开过 → 收回 11 · **跳过 9** · 批次标 revoked 归因 20 条全在(没清)· 退回原因 0 条(没混写)· 账本 actor=832✅ seed 数据已清理。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>luoqi committed