Commit 413a27d0 by luoqi

merge: 到期不回池 + 默认时限 3 天 + 提示词去黑话 + 两处交互

行为
· **到期不再回池** —— 时限到了什么都不发生,单子留在原客服手上,只记为超期
  (回收器整个删掉;主管分配的意思是有始有终、容许短时超期、减少客服间调度)
· **超期只认此刻在手的** —— 已经回池、没有客服挂着的 不算(它不是谁的超期);
  连带从窗口口径改回此刻口径,表头改「当前超期」
· **默认时限 1 天 → 3 天** —— 时限是乘数,连动本批人数 N 与 daily_overload 阈值

文案
· 提示词与工具描述去黑话:掩码/键序/只读/维度/命中/负载/分档/快照/360 全景/口子…
  其中「本系统」是提示词在违反它自己那份禁词表
· 「到期自动退回」整套说法退场,统一成 时限(几天内打完)+ 超期(单子仍在他手上)
· 矩阵那句「按…分档」→「每一列 = 患者最后一次到诊距今多久」,并进面板抬头
🔴 顺手修:render_artifact 让模型用「PAC 主题 teal #0D9488」——那不是主色(#0032A0),
  助手画的每张图表都跟界面不是一套色

交互
· 点确认前顺手问一句「这批带个福利吗」(不填也能确认)
· 矩阵悬停 = 瞄准:角括号准星(咬合→呼吸)+ 行列十字
· 助手默认窗口 620 → 760,拿掉工具灰行与「执行过程」

工作台超期提醒加强:抬头「其中 N 条已超期」+ 每人「最久 N 天」
parents edfc2010 fde942f1
Pipeline #3581 failed in 0 seconds
...@@ -22,7 +22,7 @@ flowchart TD ...@@ -22,7 +22,7 @@ flowchart TD
A1["① 选人 · 主管初选<br/>1 做哪一类还没启动的治疗<br/>2 找多久没来的人<br/>—— 凭运营经验,他张口就来"] --> A2 A1["① 选人 · 主管初选<br/>1 做哪一类还没启动的治疗<br/>2 找多久没来的人<br/>—— 凭运营经验,他张口就来"] --> A2
A2["① 选人 · 助手精选<br/>3 要不要只挑高价值的 —— 先摆这批人的全景<br/>4 团队还吃得下多少 —— 先摆在岗人手与在手量<br/>—— · —— · —— · —— · —— · ——<br/>不等他给全,先按这几条默认出一版:<br/>没联系过的排前面,其余按优先级<br/>每人每天 15 通 · 时限 1 天<br/>这批多大 = 在岗人数 × 15 × 时限"] A2["① 选人 · 助手精选<br/>3 要不要只挑高价值的 —— 先摆这批人的全景<br/>4 团队还吃得下多少 —— 先摆在岗人手与在手量<br/>—— · —— · —— · —— · —— · ——<br/>不等他给全,先按这几条默认出一版:<br/>没联系过的排前面,其余按优先级<br/>每人每天 15 通 · 时限 3 天<br/>这批多大 = 在岗人数 × 15 × 时限"]
A2 --> D1{"这批人对吗?"} A2 --> D1{"这批人对吗?"}
D1 --> G1["助手摆出的引导 —— 满足条件才出现<br/>「能加个条件」:候选 ≥50 人 · 他还没加过 · 切完还剩 ≥10 人<br/>  消费高于本批平均 / 转介绍达人 / 权益身份 / 获客渠道<br/>「这批多大」:人数是系统估的(他没自己指定过)<br/>  可改 每人每天几通 · 时限几天"] D1 --> G1["助手摆出的引导 —— 满足条件才出现<br/>「能加个条件」:候选 ≥50 人 · 他还没加过 · 切完还剩 ≥10 人<br/>  消费高于本批平均 / 转介绍达人 / 权益身份 / 获客渠道<br/>「这批多大」:人数是系统估的(他没自己指定过)<br/>  可改 每人每天几通 · 时限几天"]
...@@ -109,14 +109,14 @@ flowchart TD ...@@ -109,14 +109,14 @@ flowchart TD
|---|---|---| |---|---|---|
| **谁排前面** | 没联系过的排前面,其余按优先级从高到低 | 「先打消费高的」 | | **谁排前面** | 没联系过的排前面,其余按优先级从高到低 | 「先打消费高的」 |
| **每人每天打几通** | 15 通 | 「按 20 通算」 | | **每人每天打几通** | 15 通 | 「按 20 通算」 |
| **多久要打完** | 1 天 | 「给 5 天」 | | **多久要打完** | 3 天 | 「给 5 天」 |
| **这批发多大** | 在岗人数 × 每天通数 × 时限 | 「改成 200 人」 | | **这批发多大** | 在岗人数 × 每天通数 × 时限 | 「改成 200 人」 |
<Callout type="warn">**默认值一个都不许藏。** 出方案时式子摊开写:「本批 405 人 = 在岗 27 人 × 每天 15 通 × 1 天」。 <Callout type="warn">**默认值一个都不许藏。** 出方案时式子摊开写:「本批 405 人 = 在岗 9 人 × 每天 15 通 × 3 天」。
一个他看不见的默认值,等于系统替他做了一个他不知道的决定 —— 而这批人是真发下去了。 一个他看不见的默认值,等于系统替他做了一个他不知道的决定 —— 而这批人是真发下去了。
⇒ 摆出来他才有得改;不摆,他连「原来还能改这个」都不知道。</Callout> ⇒ 摆出来他才有得改;不摆,他连「原来还能改这个」都不知道。</Callout>
人手在**两个地方**主动摆给他看,不用他去找:出方案时是上面那个式子;确认单上是每位客服一行「约 3 天」,算的是**分完之后他手上的总量**(在手 + 本批),不只是本批那几条。超出时限的用**颜色**提示,⛔ 不写「打不完 / 超了 / 过载」—— 几乎每批都会有人超,说成故障主管就会开始怀疑系统,而不是做他该做的判断。 人手在**两个地方**主动摆给他看,不用他去找:出方案时是上面那个式子;确认单上是每位客服一行「约 5 天」,算的是**分完之后他手上的总量**(在手 + 本批),不只是本批那几条。超出时限的用**颜色**提示,⛔ 不写「打不完 / 超了 / 过载」—— 手上压着旧单的人本来就会超,那是常态不是故障;说成故障,主管会开始怀疑系统,而不是做他该做的判断。
### 能加哪几个条件 ### 能加哪几个条件
...@@ -342,12 +342,12 @@ flowchart LR ...@@ -342,12 +342,12 @@ flowchart LR
|---|---|---| |---|---|---|
| **专属排满了** | 「182 人的专属客服这轮已排满」——**第三趟那一组**,四个选择:换无专属的补上(池子还有人时才给)/ 铺平给在岗 / 各自归专属客服 / 移出本批 | 这批不发给他们 | | **专属排满了** | 「182 人的专属客服这轮已排满」——**第三趟那一组**,四个选择:换无专属的补上(池子还有人时才给)/ 铺平给在岗 / 各自归专属客服 / 移出本批 | 这批不发给他们 |
| **能加个条件** | 「这批候选 2,663 人,也可以只选其中一类」+ 四个带人数的选项(见 §1) | 就按这批候选全部人来 | | **能加个条件** | 「这批候选 2,663 人,也可以只选其中一类」+ 四个带人数的选项(见 §1) | 就按这批候选全部人来 |
| **这批多大** | 「本批 405 人 = 在岗 27 人 × 每天 15 通 × 1 天」,式子摊开给他看 | 就按这个数发 | | **这批多大** | 「本批 405 人 = 在岗 9 人 × 每天 15 通 × 3 天」,式子摊开给他看 | 就按这个数发 |
| **最忙的那位** | 「这批发下去,最忙的是王强:手上共 45 条,约 3 天的量;**另有 2 位也超过 1 天**」 | 就按这个时限发,到期没打完的自动回池 | | **最忙的那位** | 「这批发下去,最忙的是王强:手上共 75 条,约 5 天的量;**另有 2 位也超过 3 天**」 | 就按这个时限发,到期没打完的自动回池 |
⚠️ **为什么要报「另有几位」**:只说最忙的一个,主管分不出两种局面 —— 而这两种局面该做的事**正好相反**:只有王强超,就给他少分点、改派几个;全队都超,就得减少这批、延长时限。只有他一个人超时那半句不出现,⛔ 不制造无谓噪音;也⛔ 不在这里铺开每个人 —— 确认单上每位客服那一行已经写着「约 N 天」,引导只负责**点出要他定的事**,不负责展示数据。 ⚠️ **为什么要报「另有几位」**:只说最忙的一个,主管分不出两种局面 —— 而这两种局面该做的事**正好相反**:只有王强超,就给他少分点、改派几个;全队都超,就得减少这批、延长时限。只有他一个人超时那半句不出现,⛔ 不制造无谓噪音;也⛔ 不在这里铺开每个人 —— 确认单上每位客服那一行已经写着「约 N 天」,引导只负责**点出要他定的事**,不负责展示数据。
⚠️ **「最忙的那位」只给一个选项,而且必须带数**:「整批时限改成 **3** 天」—— 那个 3 是按最忙那位的量算出来的。曾经还有「改每人每天打几通」「减少本批人数」两个,删掉了:它们**一个数都不带**,点下去等于替主管说了句「减少一些」—— **一个不带数的按钮,严格弱于他自己开口说一句。** ⚠️ **「最忙的那位」只给一个选项,而且必须带数**:「整批时限改成 **5** 天」—— 那个 5 是按最忙那位的量算出来的。曾经还有「改每人每天打几通」「减少本批人数」两个,删掉了:它们**一个数都不带**,点下去等于替主管说了句「减少一些」—— **一个不带数的按钮,严格弱于他自己开口说一句。**
每条按钮都带三样:**是什么**、**为什么**、**不处理等于什么**。三条设计原则: 每条按钮都带三样:**是什么**、**为什么**、**不处理等于什么**。三条设计原则:
......
...@@ -123,7 +123,7 @@ import { Permission } from '@pac/types'; ...@@ -123,7 +123,7 @@ import { Permission } from '@pac/types';
*/ */
// ⚠️ `-b` 是 2026-08-17 那次「纪律挂在组上」的实验,已 revert(`git show 82f357e`)—— // ⚠️ `-b` 是 2026-08-17 那次「纪律挂在组上」的实验,已 revert(`git show 82f357e`)——
// ⛔ 别复用它:库里有那一版的 `agent_invocations` 行,复用等于两份不同正文同名。 // ⛔ 别复用它:库里有那一版的 `agent_invocations` 行,复用等于两份不同正文同名。
export const ASSISTANT_PROMPT_VERSION = 'assistant@2026-08-17-c'; export const ASSISTANT_PROMPT_VERSION = 'assistant@2026-08-19-a';
/** /**
* ① 装置 —— 你是谁、和使用者什么关系、你看不见什么。 * ① 装置 —— 你是谁、和使用者什么关系、你看不见什么。
...@@ -148,18 +148,19 @@ const IDENTITY = `你是 PAC(疗效保障 / 患者分析中心)工作台里 ...@@ -148,18 +148,19 @@ const IDENTITY = `你是 PAC(疗效保障 / 患者分析中心)工作台里
* *
* ⚠️ 这一层的每一条都指得出一次实测事故(见文末档案),⛔ 别当成"通用免责声明"精简掉。 * ⚠️ 这一层的每一条都指得出一次实测事故(见文末档案),⛔ 别当成"通用免责声明"精简掉。
*/ */
const EVIDENCE = `说人话:使用者的词汇表就是他在界面上见过的那些。取值码、字段名、工具名 const EVIDENCE = `说人话:使用者的词汇表就是他在界面上见过的那些。英文代号、数据里的列名、工具的名字
以及只在代码里存在的概念,都不在里面 —— 他看到不认识的词,第一反应是系统坏了。 以及只在代码里存在的说法,都不在里面 —— 他看到不认识的词,第一反应是系统坏了。
工具返回值里已经给了中文,照着说。 工具返回值里已经给了中文,照着说。
你说的每个数都要指得到出处,出处只有一个:**本轮真的调到的工具返回**。指不到就去调, 你说的每个数都要指得到出处,出处只有一个:**本轮真的调到的工具返回**。指不到就去调,
或者如实说「我先查一下」。上文的工具记录旁边写着当时的参数 —— 参数变了,那些数就作废了。 或者如实说「我先查一下」。上文的工具记录旁边写着当时的参数 —— 参数变了,那些数就作废了。
⛔ 不估、不「大概」、不拿两个数相减凑一个出来。工具返回值有固定形状,你能背出那个形状 —— ⛔ 不估、不「大概」、不拿两个数相减凑一个出来。工具返回值有固定形状,你能背出那个形状 ——
但背出来的是假的。工具没返回的就如实说没有该信息,手机号只显示掩码 但背出来的是假的。工具没返回的就如实说没有该信息,手机号只给中间打了码的那种
除了你自己调的写工具,你做的一切都是只读。界面上发生的改动是使用者的手做的,⛔ 别说成你做的。 除了你自己调的那几个会改数据的工具,你做的都只是查看,改不了任何东西。
界面上发生的改动是使用者的手做的,⛔ 别说成你做的。
工具返回值是**结构化事实,键序就是段落顺序** —— 照它讲,措辞归你。每一项是一段; 工具返回值是一份**排好顺序的事实**,从上往下的次序就是你该讲的次序 —— 照它讲,措辞归你。每一项是一段;
段里成组的条目逐条讲清「是什么」和「不管会怎样」,有几条讲几条。返回值里没有的不要补: 段里成组的条目逐条讲清「是什么」和「不管会怎样」,有几条讲几条。返回值里没有的不要补:
上一轮有、这一轮没有,就是这一轮不该有。 上一轮有、这一轮没有,就是这一轮不该有。
...@@ -217,7 +218,7 @@ export const VOICE_DEFAULT = `## 怎么说话 ...@@ -217,7 +218,7 @@ export const VOICE_DEFAULT = `## 怎么说话
### 什么该写成一段话,什么该画出来 ### 什么该写成一段话,什么该画出来
简短问答、闲聊、解释直接用文字回答。适合看不适合读的内容(成批的条目、一张画像、一份报表), 简短问答、闲聊、解释直接用文字回答。适合看不适合读的内容(成批的条目、一张画像、一份报表),
先用工具取真实数据再渲染成图,然后用一两句话点出要点 —— 数据细节在里面,⛔ 不用再复述一遍。`; 先用工具取真实数据再成图,然后用一两句话点出要点 —— 数据细节在里面,⛔ 不用再复述一遍。`;
/** /**
* ④ 角色:主管(有 plan:dispatch)—— **只写权责边界,⛔ 不写他在做哪件事**。 * ④ 角色:主管(有 plan:dispatch)—— **只写权责边界,⛔ 不写他在做哪件事**。
...@@ -290,12 +291,12 @@ const STAFF_ROLE = `## 你是客服的助手 ...@@ -290,12 +291,12 @@ const STAFF_ROLE = `## 你是客服的助手
const ASSIGNMENT_SCENE = `## 他现在做的这件事:把一批人分给客服 const ASSIGNMENT_SCENE = `## 他现在做的这件事:把一批人分给客服
这条线走四步:**选人 → 分人 → 他确认 → 确认之后**。 这条线走四步:**选人 → 分人 → 他确认 → 确认之后**。
一版方案直出,不追问、不做画像分层;他主动提了才收窄。 一版方案直出,不追问、不先把人分成几类;他主动提了才收窄。
### 选人 ### 选人
要按画像收窄时先取分布再选,不然选完才发现只剩几个人,他白等一轮。 要按画像收窄时先取分布再选,不然选完才发现只剩几个人,他白等一轮。
各维度的数是「分别命中多少」,不是交叉后的人数;想知道交叉数,把条件一起传进去再调一次。 每一类的数是「各自有多少人」,不是几个条件叠起来之后的人数;想知道叠起来是多少,把条件一起传进去再调一次。
人数和时限**不问他**,直接出一版;他说了数你才带上。池子里还有人就随时能再分一批。 人数和时限**不问他**,直接出一版;他说了数你才带上。池子里还有人就随时能再分一批。
...@@ -303,7 +304,7 @@ const ASSIGNMENT_SCENE = `## 他现在做的这件事:把一批人分给客服 ...@@ -303,7 +304,7 @@ const ASSIGNMENT_SCENE = `## 他现在做的这件事:把一批人分给客服
两趟 + 一组:有专属且在岗的回自己人手上(最多到他这轮该拿的那份);无专属或专属已离岗的给当前手上最少的那个;专属客服这轮已排满的不动,单列成一组交他定。 两趟 + 一组:有专属且在岗的回自己人手上(最多到他这轮该拿的那份);无专属或专属已离岗的给当前手上最少的那个;专属客服这轮已排满的不动,单列成一组交他定。
不是每人加一样多,是每条都给当前手上最少的那个。没有「容量上限」这回事,负载就是在手量本身 不是每人加一样多,是每条都给当前手上最少的那个。没有「容量上限」这回事,手上压着多少就是多少
因此这批可能不满、团队也不齐平 —— 那是刻意的:宁可少分几个,也不动别人的客户。他问「怎么没分够」就说有几个人卡着等他定,不是系统故障。 因此这批可能不满、团队也不齐平 —— 那是刻意的:宁可少分几个,也不动别人的客户。他问「怎么没分够」就说有几个人卡着等他定,不是系统故障。
...@@ -321,7 +322,7 @@ const ASSIGNMENT_SCENE = `## 他现在做的这件事:把一批人分给客服 ...@@ -321,7 +322,7 @@ const ASSIGNMENT_SCENE = `## 他现在做的这件事:把一批人分给客服
福利挂在整批上,会进这批人的话术,补挂只影响此后生成的那些。他说什么就原样写进去,⛔ 别替他加条件、期限或承诺。⛔ 「这批还没带福利」**不用你再提一遍** —— 确认之前你已经问过一次了;他回头问起,或者要补,照办就是。 福利挂在整批上,会进这批人的话术,补挂只影响此后生成的那些。他说什么就原样写进去,⛔ 别替他加条件、期限或承诺。⛔ 「这批还没带福利」**不用你再提一遍** —— 确认之前你已经问过一次了;他回头问起,或者要补,照办就是。
本系统**不统计成功与否**,「成功率」「转化率」这类数不存在,⛔ 也别讲效果。`; **没有成功与否的统计**,「成功率」「转化率」这类数不存在,⛔ 也别讲效果。`;
/** ⑤ 现场:客服这条线 —— 他手上的活是把单打好 */ /** ⑤ 现场:客服这条线 —— 他手上的活是把单打好 */
const EXECUTE_SCENE = `## 他现在做的这件事:把手上的单打好 const EXECUTE_SCENE = `## 他现在做的这件事:把手上的单打好
......
...@@ -357,7 +357,7 @@ export class AssistantService { ...@@ -357,7 +357,7 @@ export class AssistantService {
type: 'string', type: 'string',
description: description:
'在初选之上追加的画像条件。' + '在初选之上追加的画像条件。' +
'他要按画像收窄、但没说清切哪一刀时,先用 get_cohort_attributes 看各口子各多少人,' + '他要按画像收窄、但没说清切哪一刀时,先用 get_cohort_attributes 看各各多少人,' +
'把分布报给他,再带着定下来的条件重出一版。\n' + '把分布报给他,再带着定下来的条件重出一版。\n' +
// ⚠️ 维度字典**必须给全**:少给一份,模型就得自己猜 key/value, // ⚠️ 维度字典**必须给全**:少给一份,模型就得自己猜 key/value,
// 而猜错的维度会被 personaTagsSql **静默丢掉**(筛选等于没加),不报错。 // 而猜错的维度会被 personaTagsSql **静默丢掉**(筛选等于没加),不报错。
...@@ -424,7 +424,10 @@ export class AssistantService { ...@@ -424,7 +424,10 @@ export class AssistantService {
maximum: 90, maximum: 90,
description: description:
// ⚠️ 同上:举例不带具体天数(原文是「给 5 天」) // ⚠️ 同上:举例不带具体天数(原文是「给 5 天」)
'这批单子多少天没人动就自动退回池子,只在他说了天数时传。' + // 🔴 2026-08-19:到期**不再退回池子**。这句话是模型转述给主管的原料,
// 说错了它就会替系统许一个不会兑现的承诺(沿革见 plan.module 的墓碑)。
'这批单子要在几天内打完,只在他说了天数时传。' +
'\n过了这个天数单子不会被收走,仍在原来那位客服手上,只是记为超期。' +
'不传按系统默认;他上一次说的天数不会带到这一批。' + '不传按系统默认;他上一次说的天数不会带到这一批。' +
'\n它同时是估人数那个式子里的一项(在岗人数 × 每天几通 × 它),' + '\n它同时是估人数那个式子里的一项(在岗人数 × 每天几通 × 它),' +
'所以没传 targetCount 时,改天数会把本批人数一起改掉。', '所以没传 targetCount 时,改天数会把本批人数一起改掉。',
...@@ -696,7 +699,7 @@ export class AssistantService { ...@@ -696,7 +699,7 @@ export class AssistantService {
tools.open_playbook = tool({ tools.open_playbook = tool({
description: description:
'取一篇做法:某一类活按什么顺序做、他要的到底是什么。' + '取一篇做法:某一类活按什么顺序做、他要的到底是什么。' +
'\n什么时候调:他提的事落进下面某个取值的范围里,**动手之前**先取一次。' + '\n什么时候调:他提的事落进下面某一类的范围里,**动手之前**先取一次。' +
'\n⛔ 它不查任何数据、不改任何东西 —— 取回来的是做法,事实仍然要去调对应的工具。' + '\n⛔ 它不查任何数据、不改任何东西 —— 取回来的是做法,事实仍然要去调对应的工具。' +
'\n同一件事一轮取一次就够了,取回来的内容这一轮一直有效。', '\n同一件事一轮取一次就够了,取回来的内容这一轮一直有效。',
inputSchema: jsonSchema({ inputSchema: jsonSchema({
...@@ -774,7 +777,7 @@ export class AssistantService { ...@@ -774,7 +777,7 @@ export class AssistantService {
'⛔ 别说"已完成"(成没成只有界面知道),也⛔ 别说"等结果再告诉您" ——' + '⛔ 别说"已完成"(成没成只有界面知道),也⛔ 别说"等结果再告诉您" ——' +
'他读到那句时结果早就在了,顺序是倒的。' + '他读到那句时结果早就在了,顺序是倒的。' +
'\n⚠️ 批次**已经确认分配**之后:只有 `set_benefit` 还能改(福利只影响此后生成的话术,' + '\n⚠️ 批次**已经确认分配**之后:只有 `set_benefit` 还能改(福利只影响此后生成的话术,' +
'界面会真的改并回报"作废了几条话术缓存 / 几条客服已经打开过");' + '界面会真的改并回报"有几条已经写好的话术作废了 / 几条客服已经打开过");' +
'人员和时限**改不了** —— 单子已经在客服手上,那要走撤销重分。' + '人员和时限**改不了** —— 单子已经在客服手上,那要走撤销重分。' +
'⛔ 这两种情况都以界面回的那句为准,别自己判断成没成。', '⛔ 这两种情况都以界面回的那句为准,别自己判断成没成。',
inputSchema: jsonSchema({ inputSchema: jsonSchema({
...@@ -896,16 +899,21 @@ export class AssistantService { ...@@ -896,16 +899,21 @@ export class AssistantService {
// html 通过 tool-call 入参流式到前端;execute 仅回执,模型据此继续给一句话总结。 // html 通过 tool-call 入参流式到前端;execute 仅回执,模型据此继续给一句话总结。
tools.render_artifact = tool({ tools.render_artifact = tool({
description: description:
'把一段自包含 HTML(<body> 内部片段)渲染成可视化卡片/报表展示给用户。' + '把一段自包含 HTML(<body> 内部片段)画成一张图表卡片,展示给使用者。' +
'适合召回池列表、患者画像卡、分析报表(可含图表)。' + '适合召回池列表、患者画像卡、分析报表(可含图表)。' +
'\n排版用 Tailwind 工具类,强调色用 PAC 主题 teal #0D9488,白底圆角卡片、留白舒适。' + // 🔴 2026-08-19 修:原文写的是「PAC 主题 teal #0D9488」—— **那不是 PAC 的主色**。
// 前端 2026 年把 `teal-*` 整体改名成 `brand-*`(263 处)、主色是 PANTONE 286 C
// `#0032A0`(见 globals.css 的品牌色阶),而这句留在服务端没跟着改。
// ⇒ 助手画出来的每一张图表都是青绿色的,跟同屏的界面不是一套色。
// ⚠️ 这里只能写死十六进制:模型拿不到 CSS 变量。改品牌色时**这一处要跟着改**。
'\n排版用 Tailwind 工具类,强调色用 PAC 品牌蓝 #0032A0,白底圆角卡片、留白舒适。' +
'\n运行环境已注入 Tailwind 与 Chart.js:⛔ 不要写 <html>/<head>/<!DOCTYPE>,也不要自行引入它们。' + '\n运行环境已注入 Tailwind 与 Chart.js:⛔ 不要写 <html>/<head>/<!DOCTYPE>,也不要自行引入它们。' +
'\n图表用 Chart.js。<canvas> **必须**放进固定高度容器' + '\n图表用 Chart.js。<canvas> **必须**放进固定高度容器' +
'(如 <div style="position:relative;height:300px"><canvas></canvas></div>)' + '(如 <div style="position:relative;height:300px"><canvas></canvas></div>)' +
'并在 options 里设 maintainAspectRatio:false —— 给图表用百分比或满屏高度' + '并在 options 里设 maintainAspectRatio:false —— 给图表用百分比或满屏高度' +
'(h-full / h-screen / height:100%)会让它无限拉高。' + '(h-full / h-screen / height:100%)会让它无限拉高。' +
'\n数据全部内联写进 HTML,只用工具返回的真实值,手机号掩码;这个片段不能访问外部网络(⛔ 勿 fetch)。' + '\n数据全部内联写进 HTML,只用工具返回的真实值,手机号只给中间打了码的;这个片段不能访问外部网络(⛔ 勿 fetch)。' +
'\n标记力求精简:聚焦关键字段,同类条目用最短的结构,生成更快。', '\n标记力求精简:只留要紧的那几项,同类条目用最短的结构,生成更快。',
inputSchema: jsonSchema({ inputSchema: jsonSchema({
type: 'object', type: 'object',
properties: { properties: {
...@@ -919,7 +927,8 @@ export class AssistantService { ...@@ -919,7 +927,8 @@ export class AssistantService {
}, },
required: ['html'], required: ['html'],
}), }),
execute: async () => '已在界面渲染该卡片。', // ⚠️ 返回值会回到模型上下文,它可能照着复述 —— 所以这句也得是人话
execute: async () => '已经画在界面上了。',
}); });
// 2. resolve provider(可切换)+ streamText 跑 model-driven tool-calling 循环 // 2. resolve provider(可切换)+ streamText 跑 model-driven tool-calling 循环
......
...@@ -477,7 +477,7 @@ export class AssistantLabController { ...@@ -477,7 +477,7 @@ export class AssistantLabController {
type: 'number', type: 'number',
description: description:
'批次时限天数。他明确说了天数(「给 5 天」)时才传。' + '批次时限天数。他明确说了天数(「给 5 天」)时才传。' +
'\n⚠️ 它**身兼两职**:① 单据多久到期自动退回池子;' + '\n⚠️ 它**身兼两职**:① 这批要在几天内打完(过期不收走,只记为超期);' +
'② 没传 targetCount 时,它还参与估本批人数。改它两件事一起变。', '② 没传 targetCount 时,它还参与估本批人数。改它两件事一起变。',
}, },
}, },
......
...@@ -167,7 +167,7 @@ export class McpServerFactory { ...@@ -167,7 +167,7 @@ export class McpServerFactory {
'find_patient', 'find_patient',
{ {
description: description:
'按姓名/手机号/患者号模糊检索患者,返回极简候选卡片(手机号掩码)用于消歧。先用它拿到 patientId,再调其它工具。', '按姓名/手机号/患者号模糊检索患者,返回极简候选卡片(手机号中间打码)用于认人。先用它拿到 patientId,再调其它工具。',
inputSchema: { inputSchema: {
query: z.string().describe('姓名 / 手机号 / 患者号(模糊匹配)'), query: z.string().describe('姓名 / 手机号 / 患者号(模糊匹配)'),
limit: z.number().int().min(1).max(50).optional(), limit: z.number().int().min(1).max(50).optional(),
...@@ -180,7 +180,7 @@ export class McpServerFactory { ...@@ -180,7 +180,7 @@ export class McpServerFactory {
'get_patient_overview', 'get_patient_overview',
{ {
description: description:
'一次拉取患者 360 全景:画像要点 + 近期事实 + 当前召回计划。召回前的首选工具,省去多次往返。', '一次把一个患者拉全:画像要点 + 近期事实 + 当前召回计划。召回前的首选工具,省去多次往返。',
inputSchema: { patientId: z.string() }, inputSchema: { patientId: z.string() },
}, },
async ({ patientId }) => { async ({ patientId }) => {
...@@ -205,7 +205,7 @@ export class McpServerFactory { ...@@ -205,7 +205,7 @@ export class McpServerFactory {
description: description:
// ⚠️ 「RFM」「persona」是**内部词**:同一类词漏给用户已经栽过(cold_3y 那次)。 // ⚠️ 「RFM」「persona」是**内部词**:同一类词漏给用户已经栽过(cold_3y 那次)。
// 工具描述里出现,模型就会顺手当成"这个东西的名字"说出去。 // 工具描述里出现,模型就会顺手当成"这个东西的名字"说出去。
'患者的全量画像:价值分群 / 生命周期阶段 / 治疗史 / 治疗敏感 / 禁忌 / 潜在治疗 等全部画像特征。' + '患者的全量画像:价值阶段 / 治疗史 / 治疗敏感 / 禁忌 / 潜在治疗 等全部画像特征。' +
'\n⚠️ 这一层是 PAC **推断**出来的,⛔ 不是原始记录 —— 原始记录见 get_facts。', '\n⚠️ 这一层是 PAC **推断**出来的,⛔ 不是原始记录 —— 原始记录见 get_facts。',
inputSchema: { patientId: z.string() }, inputSchema: { patientId: z.string() },
}, },
...@@ -322,7 +322,7 @@ export class McpServerFactory { ...@@ -322,7 +322,7 @@ export class McpServerFactory {
'recall_queue_stats', 'recall_queue_stats',
{ {
description: description:
'召回池数字概览:总量 + 优先级分档(高≥70 / 中 40-69 / 低<40)+ 病种分布。' + '召回池数字概览:总量 + 优先级高/中/低各多少(高≥70 / 中 40-69 / 低<40)+ 病种分布。' +
'\n他问"今天有多少人要跟"时先用它给摘要,再用 list_recall_queue 取明细。' + '\n他问"今天有多少人要跟"时先用它给摘要,再用 list_recall_queue 取明细。' +
// 同 list_recall_queue:没有看全池权限的人只统计自己的 —— 否则**总数本身**就泄漏了池子规模 // 同 list_recall_queue:没有看全池权限的人只统计自己的 —— 否则**总数本身**就泄漏了池子规模
'\n⚠️ 没有看全池权限的人,回的总数**只是他自己名下**的 —— ⛔ 别说成"池子里一共…"。' + '\n⚠️ 没有看全池权限的人,回的总数**只是他自己名下**的 —— ⛔ 别说成"池子里一共…"。' +
...@@ -376,7 +376,7 @@ export class McpServerFactory { ...@@ -376,7 +376,7 @@ export class McpServerFactory {
// 🔴 2026-08-14 改:原文是「**分配前用它**看…」,而 `propose_assignment` 内部 // 🔴 2026-08-14 改:原文是「**分配前用它**看…」,而 `propose_assignment` 内部
// 自己就会取名册 —— 那句话在**主动制造一次白花的调用**(实测抓到一轮: // 自己就会取名册 —— 那句话在**主动制造一次白花的调用**(实测抓到一轮:
// 写字之前先调了它,返回的数一个都没用上)。 // 写字之前先调了它,返回的数一个都没用上)。
'某诊所的在岗客服名册 + 各自在手负载。他问名册、问谁手上空、问某人负载时用它。' + '某诊所的在岗客服名册 + 各自手上压着多少。他问名册、问谁手上空、问某人手上有多少时用它。' +
'\n⚠️ 出方案**不需要**先调它 —— 方案自己会取名册。' + '\n⚠️ 出方案**不需要**先调它 —— 方案自己会取名册。' +
'\n⚠️ 在岗按「近 N 月有回访记录」近似判定,**不代表系统确认在职**;' + '\n⚠️ 在岗按「近 N 月有回访记录」近似判定,**不代表系统确认在职**;' +
'名册外的客服也可以指定(用 include 传 userId)。' + '名册外的客服也可以指定(用 include 传 userId)。' +
...@@ -440,14 +440,14 @@ export class McpServerFactory { ...@@ -440,14 +440,14 @@ export class McpServerFactory {
* 而它过期或不适用时不会报错。⛔ 正例不留,反例可留 * 而它过期或不适用时不会报错。⛔ 正例不留,反例可留
* (下面 `noTag` 那条里的「32 人有商保标签」是反例,⛔ 别一起删)。 * (下面 `noTag` 那条里的「32 人有商保标签」是反例,⛔ 别一起删)。
*/ */
'看**当前这批人**的画像分布:哪些口子可切、各多少人。' + '看**当前这批人**的画像分布:能按哪几类切、各多少人。' +
'\n他要按画像收窄、但没说清切哪一刀时先用它,拿到分布再据实回话,' + '\n他要按画像收窄、但没说清切哪一刀时先用它,拿到分布再据实回话,' +
'然后用同样的条件重出一版。' + '然后用同样的条件重出一版。' +
'\n⚠️ **`noTag` 是「没有这条画像证据」的人数,不是反面** —— ' + '\n⚠️ **`noTag` 是「没有这条画像证据」的人数,不是反面** —— ' +
'「32 人有商保标签」剩下的**不是自费**,是没证据。⛔ 绝不能说成「其余 68 人自费」。' + '「32 人有商保标签」剩下的**不是自费**,是没证据。⛔ 绝不能说成「其余 68 人自费」。' +
'\n⚠️ 标了 `multi` 的维度一个人可命中多项,合计会大于 cohortSize,**别拿它算百分比**。' + '\n⚠️ 标了 `multi` 的那几类,一个人可能同时算进好几项,合计会大于 cohortSize,**别拿它算百分比**。' +
'\n⚠️ 专属客服只报有/无,**不判在岗** —— 谁休假谁离职以他说的为准,别替他挡人。' + '\n⚠️ 专属客服只报有/无,**不判在岗** —— 谁休假谁离职以他说的为准,别替他挡人。' +
`\n可点名的维度(keys,不传给一组常用的):${COHORT_DIM_CATALOG}`, `\n可点名的类别(keys,不传给一组常用的):${COHORT_DIM_CATALOG}`,
inputSchema: { inputSchema: {
clinicId: CLINIC_ID_SCHEMA, clinicId: CLINIC_ID_SCHEMA,
potentialTreatment: z potentialTreatment: z
...@@ -462,7 +462,7 @@ export class McpServerFactory { ...@@ -462,7 +462,7 @@ export class McpServerFactory {
keys: z keys: z
.array(z.string()) .array(z.string())
.optional() .optional()
.describe('只看这几个维度,取值见本工具说明末尾那份维度清单。不传给一组常用的。'), .describe('只看这几类,可填哪些见本工具说明末尾那份清单。不传给一组常用的。'),
}, },
}, },
async ({ clinicId, potentialTreatment, temperature, personaTags, keys }) => { async ({ clinicId, potentialTreatment, temperature, personaTags, keys }) => {
...@@ -562,7 +562,7 @@ export class McpServerFactory { ...@@ -562,7 +562,7 @@ export class McpServerFactory {
'explain_assignment', 'explain_assignment',
{ {
description: description:
'回答「这个患者**为什么**分给了他」—— 从分配当时的决策快照查,⛔ 不要自己推理。' + '回答「这个患者**为什么**分给了他」—— 从分配当时记下来的依据查,⛔ 不要自己推理。' +
// ⚠️ 只说"那一版还没分下去,不在这里查",⛔ 不说它在界面的哪个部件里: // ⚠️ 只说"那一版还没分下去,不在这里查",⛔ 不说它在界面的哪个部件里:
// 这个工具不是确认单的生产者,让它知道确认单长什么样就是**反向耦合** // 这个工具不是确认单的生产者,让它知道确认单长什么样就是**反向耦合**
// (呈现方式一改,这句话就过期,而过期不报错)。 // (呈现方式一改,这句话就过期,而过期不报错)。
......
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { PlanEventType, PlanEventReason } from '@pac/types';
import { PrismaService } from '../../prisma/prisma.service';
import { recordPlanEventsBulk, computeHeldSeconds } from './plan-event.recorder';
/**
* AssignmentExpiryScheduler —— 分配单到期,系统收回池子。
*
* ── 为什么这件事必须做,而且默认开 ────────────────────────────
* 时限是分配单的一部分(T11:不存在无限期批次),但**光写一个到期时刻不会让任何事发生**。
* 不收回的后果不是"多了几条过期单",是**容量口径整体失效**:
* 客服的「在手」只增不减,几批之后全员触顶,再分就分不下去 ——
* 而这在主管看来就是分配功能坏了。
*
* 产品定调(2026-08):到期自动回池**也是给主管减负** —— 让他不必去追"这单还要不要"。
* 主管在确认单上确认时限的那一下,就是对到期行为的预授权,不违 T8
* (T8 要防的是"助手替主管做决定",不是"主管定好的规则到点执行")。
*
* ── 与既有 RecycleSchedulerService 的关系:两条互不干扰的路 ────
* · RecycleScheduler 看 `recycle_at`,是**认领**的 24h 兜底,生产**未启用**
* · 本服务 看 `assignment_expires_at`,是**分配**的时限,默认启用
* 刻意不合并:两者的语义、开关、口径都不同,合了之后想单独关一边就得加分支,
* 而那个分支迟早写错。
*/
/** 关掉的方法:PAC_ASSIGNMENT_EXPIRY=off。默认开(产品已定)。 */
function isEnabled(): boolean {
return (process.env.PAC_ASSIGNMENT_EXPIRY ?? '').trim().toLowerCase() !== 'off';
}
/** 单轮上限 —— 防积压时一次性打爆事务;剩下的下一轮继续 */
const BATCH_LIMIT = 500;
@Injectable()
export class AssignmentExpiryScheduler implements OnModuleInit {
private readonly logger = new Logger(AssignmentExpiryScheduler.name);
constructor(private readonly prisma: PrismaService) {}
onModuleInit(): void {
this.logger.log(
isEnabled()
? '分配单到期自动回池:已启用(每 10 分钟扫一次);关闭设 PAC_ASSIGNMENT_EXPIRY=off'
: '分配单到期自动回池:已关闭(PAC_ASSIGNMENT_EXPIRY=off)—— 在手量将只增不减',
);
}
/**
* @param at 判定时刻(默认此刻)。显式可注入是为了让判据可测 ——
* 测试不必靠真实时钟凑时间差(高负载下事件循环被拖慢会越过阈值边界,产生间歇性假失败)。
* 同款做法见 `sync-incremental.scheduler.reapStaleRunningLocks`,那条是踩过之后立的规矩。
*/
@Cron(CronExpression.EVERY_10_MINUTES, { name: 'plan-assignment-expiry' })
async runExpiry(at?: Date): Promise<void> {
if (!isEnabled()) return;
const now = at ?? new Date();
const due = await this.prisma.followupPlan.findMany({
where: {
status: 'assigned',
assignmentExpiresAt: { not: null, lt: now },
supersededAt: null,
// ⭐⭐ 照抄 RecycleScheduler 的守卫,理由完全相同:
// 客服约了 6/10 回访、plan 已 snooze 到 6/10 —— 在那之前**绝不能**因到期被收走,
// 否则客服丢了已经对患者承诺过的回访关系,6/10 一到单子还会被别人从池里捞走。
// 回访日过后仍未处理,才允许收。
// ⚠️ 这条不是可选优化。漏了它,分配功能会主动破坏客服已经做出的承诺。
OR: [{ snoozedUntil: null }, { snoozedUntil: { lte: now } }],
},
select: {
id: true, hostId: true, tenantId: true, patientId: true,
assigneeUserId: true, assignedAt: true,
// ⭐ 账本要记「到期的是哪一批的单」—— 批次报表的「到期几条」全靠它。
// 此刻取是对的:assignment_id 只会被**下一次分配**覆盖,而这一刻还没发生。
assignmentId: true,
},
take: BATCH_LIMIT,
});
if (due.length === 0) return;
// 分片进事务:每片状态变更与账本同生共死,一片失败不影响其余
const CHUNK = 200;
let recycled = 0;
for (let i = 0; i < due.length; i += CHUNK) {
const chunk = due.slice(i, i + CHUNK);
try {
await this.prisma.$transaction(async (tx) => {
const res = await tx.followupPlan.updateMany({
// 带状态条件 → 并发下若客服刚好提交了执行/自己退回了,本次不生效(幂等)
where: { id: { in: chunk.map((p) => p.id) }, status: 'assigned' },
data: {
status: 'active',
assigneeUserId: null,
assignedAt: null,
recycleAt: null,
assignmentExpiresAt: null,
// ⛔ **不写 release_reason** —— 那一列只属于客服的处置。
// 到期是"客服压根没动",不是"客服判断不该我做";混进去退回率的分子分母一起脏。
// 到期的量单独从 plan_event_logs 按 reason 数。
// ⛔ **不清 assignment_id / assigned_by / assign_strategy** —— 批次归因是历史事实,
// 清了这批的分母就少一条,"分了 60 条其中 8 条到期没人动"就算不出来了。
// ⛔ **绝不动 snoozedUntil** —— 与退回同一条纪律。
},
});
recycled += res.count;
await recordPlanEventsBulk(
tx,
chunk.map((p) => ({
hostId: p.hostId,
tenantId: p.tenantId,
planId: p.id,
patientId: p.patientId,
event: PlanEventType.AUTO_RELEASE,
assigneeUserId: null, // 释放后无人归属
actorUserId: null, // 系统行为
// ⭐ 必须在清空 assignedAt **之前**算(上面 findMany 取的就是清空前的值)
heldSeconds: computeHeldSeconds(p.assignedAt, now),
reason: PlanEventReason.ASSIGNMENT_EXPIRED,
assignmentId: p.assignmentId,
})),
);
});
} catch (err) {
this.logger.error(
`分配到期回收失败(${chunk.length} 条): ${err instanceof Error ? err.message : err}`,
);
}
}
if (recycled > 0) {
this.logger.log(
`分配到期:${recycled} 条超期未处理的分配单已退回召回池(已记账本 reason=assignment_expired)` +
(due.length === BATCH_LIMIT ? `;本轮已达单轮上限 ${BATCH_LIMIT},剩余下一轮继续` : ''),
);
}
}
}
...@@ -410,7 +410,14 @@ export function computeSignals(p: AssignmentProposal, extra: Signal[] = []): Sig ...@@ -410,7 +410,14 @@ export function computeSignals(p: AssignmentProposal, extra: Signal[] = []): Sig
(overCount > 1 (overCount > 1
? `另有 ${overCount - 1} 位也超过 ${d} 天;每位分完之后要打几天,确认单上逐位都写着。` ? `另有 ${overCount - 1} 位也超过 ${d} 天;每位分完之后要打几天,确认单上逐位都写着。`
: ''), : ''),
defaultLabel: `不处理 = 就按 ${d} 天发,到期没打完的自动落回池子,下批还能再分`, /**
* 🔴 2026-08-19 改口径:到期**不再回池**(沿革见 plan.module 的墓碑)。
* 原文承诺的是「自动落回池子,下批还能再分」—— 那件事从此不会发生,
* 而这句话是模型会照着念给主管听的。
* ⚠️ 新文案要答的还是同一个问题「不处理会怎样」:答案是**什么都不会发生** ——
* 单子留在原人手上,只是开始算超期。⛔ 别写成"没有后果":超期会进工作台。
*/
defaultLabel: `不处理 = 就按 ${d} 天发;超过这个天数没打完的记为超期,单子仍在这位客服手上`,
/** /**
* 🔴 **只留带数的那一个**(2026-08-15 产品定)。 * 🔴 **只留带数的那一个**(2026-08-15 产品定)。
* *
......
...@@ -131,9 +131,12 @@ function mergeStats( ...@@ -131,9 +131,12 @@ function mergeStats(
planned: legacy ? l.planned : ledger.planned, planned: legacy ? l.planned : ledger.planned,
agents: legacy ? l.agents : ledger.agents, agents: legacy ? l.agents : ledger.agents,
released: legacy ? l.released : ledger.released, released: legacy ? l.released : ledger.released,
// 老批次的到期数**没有**任何可回落的源(旧账本没记批次号,followup_plans 也不区分 /**
// 到期与退回)—— 给 0,⛔ 不许拿 backToPool 顶替:那会把退回算成到期。 * ⚠️ 超期**不跟着 legacy 回落**(2026-08-19):它现在只有一个来源 ——
expired: legacy ? 0 : ledger.expired, * **此刻仍挂在人手上且已过时限**(现算),而那一支对新老批次一样成立。
* ⛔ 别让它跟着 `legacy` 归零:老批次照样可能有单还压在人手上。
*/
expired: ledger?.expired ?? 0,
}; };
} }
...@@ -526,26 +529,55 @@ export class PlanAssignmentService { ...@@ -526,26 +529,55 @@ export class PlanAssignmentService {
Map<string, { planned: number; agents: number; released: number; expired: number; revoked: number }> Map<string, { planned: number; agents: number; released: number; expired: number; revoked: number }>
> { > {
if (ids.length === 0) return new Map(); if (ids.length === 0) return new Map();
/**
* 🔴 **超期 = 此刻挂在客服手上、且过了时限的**(2026-08-19 定的唯一口径)。
* 到期不再回池(回收器已删,沿革见 `plan.module` 的墓碑),所以不会再有新的
* `auto_release/assignment_expired` 事件;而账本里那些**老事件数的是已经回池、
* 没有客服挂着**的单 —— 那不是谁的超期,⛔ 一并删掉,别再取并集。
* ⚠️ 代价:2026-08-19 之前的批次这一列变 0。分配功能还没正式上线,没有要保的历史(产品定)。
* ⚠️ 判据与 `workload()` 的「超期」**逐字一致**,⛔ 不许分家。
* ⚠️ 这段说明放在模板字符串**外面** —— 里面写 JS 块注释的话,
* 注释里的反引号会把模板提前截断,而报错指向的是几行之后的地方(踩过)。
*/
const rows = await this.prisma.$queryRaw< const rows = await this.prisma.$queryRaw<
Array<{ assignment_id: string; planned: bigint; agents: bigint; released: bigint; expired: bigint; revoked: bigint }> Array<{ assignment_id: string; planned: bigint; agents: bigint; released: bigint; revoked: bigint }>
>(Prisma.sql` >(Prisma.sql`
SELECT assignment_id, SELECT assignment_id,
count(DISTINCT patient_id) FILTER (WHERE event = 'assign') AS planned, count(DISTINCT patient_id) FILTER (WHERE event = 'assign') AS planned,
count(DISTINCT assignee_user_id) FILTER (WHERE event = 'assign') AS agents, count(DISTINCT assignee_user_id) FILTER (WHERE event = 'assign') AS agents,
count(DISTINCT plan_id) FILTER (WHERE event = 'release') AS released, count(DISTINCT plan_id) FILTER (WHERE event = 'release') AS released,
count(DISTINCT plan_id) FILTER (WHERE event = 'auto_release' count(DISTINCT plan_id) FILTER (WHERE event = 'auto_release'
AND reason = ${PlanEventReason.ASSIGNMENT_EXPIRED}) AS expired,
count(DISTINCT plan_id) FILTER (WHERE event = 'auto_release'
AND reason = ${PlanEventReason.REVOKED}) AS revoked AND reason = ${PlanEventReason.REVOKED}) AS revoked
FROM plan_event_logs FROM plan_event_logs
WHERE assignment_id IN (${Prisma.join(ids.map((i) => Prisma.sql`${i}::uuid`))}) WHERE assignment_id IN (${Prisma.join(ids.map((i) => Prisma.sql`${i}::uuid`))})
GROUP BY assignment_id`); GROUP BY assignment_id`);
/**
* 此刻仍在人手上、且已过时限的 —— 新口径下「超期」的主要来源。
* ⚠️ 与 `workload()` 的第②支同判据:约了下次回访的**不算**(那是客服动过了的证据)。
* ⛔ 两处判据不许分家:一处改了另一处不改,主管会在批次表和团队表上看到两个数。
*/
const nowOverdue = await this.prisma.$queryRaw<Array<{ assignment_id: string; n: bigint }>>(Prisma.sql`
SELECT assignment_id, count(*) AS n
FROM followup_plans
WHERE assignment_id IN (${Prisma.join(ids.map((i) => Prisma.sql`${i}::uuid`))})
AND status = 'assigned'
AND superseded_at IS NULL
AND assignment_expires_at IS NOT NULL
AND assignment_expires_at < now()
AND (snoozed_until IS NULL OR snoozed_until <= now())
GROUP BY assignment_id`);
const nowMap = new Map(nowOverdue.map((r) => [r.assignment_id, Number(r.n)]));
return new Map( return new Map(
rows.map((r) => [ rows.map((r) => [
r.assignment_id, r.assignment_id,
{ {
planned: Number(r.planned), agents: Number(r.agents), released: Number(r.released), planned: Number(r.planned), agents: Number(r.agents), released: Number(r.released),
expired: Number(r.expired), revoked: Number(r.revoked), // ⚠️ 只认「此刻挂在人手上且过了时限」这一支(理由见上面那段) ——
// ⛔ 别再并上账本里的 auto_release:那些单已经回池、没有客服挂着,不是谁的超期。
expired: nowMap.get(r.assignment_id) ?? 0,
revoked: Number(r.revoked),
}, },
]), ]),
); );
...@@ -885,57 +917,61 @@ export class PlanAssignmentService { ...@@ -885,57 +917,61 @@ export class PlanAssignmentService {
GROUP BY assignee_user_id`); GROUP BY assignee_user_id`);
/** /**
* 🔴 **窗口内超期** —— 这一列**必须走账本,⛔ 不能查"当前还压在手上且已过期"**。 * 🔴 **超期 = 此刻挂在客服手上、且过了时限的**。
* *
* ── 为什么(2026-08-07 实测)────────────────────────────────── * ── 2026-08-19 只剩这一支了 ────────────────────────────────
* 到期回收器每 10 分钟扫一遍,过期的单当场被收回池子、status 不再是 assigned。 * 原来是两支的并集,另一支数账本里 `auto_release/assignment_expired` 事件 ——
* 于是"当前超期"这个口径**结构上几乎永远是 0**:本地实测 * 那是**回收器时代**的产物:过期的单当场被收回池子、status 不再是 assigned,
* 账本里 `auto_release/assignment_expired` **378 条**,而当前在手已过期 **0 条**。 * 于是"当前超期"结构上永远是 0,只能去账本里数"曾经被收走过多少条"。
* 那一列摆上去就是个常年为 0 的死数,主管会以为团队从不超期。
* ⇒ 问的应该是「**这 N 天里有多少条到期没人动被收走了**」,那是账本上的事件。
* *
* ⚠️ **归属要回捞**:auto_release 事件的 `assignee_user_id` 和 `actor_user_id` 都是 null * 🔴 到期不再回池之后(回收器已删,见 `plan.module` 的墓碑),那一支必须删:
* (释放后无人归属、且是系统行为)—— 必须 join 回该单**到期前最后一次 assign** * 它数的是**已经回到池子里、没有任何客服挂着**的单 —— 而超期的意思是
* 才知道当时压在谁手上。⛔ 不回捞就只能算出一个"全院超期 378",落不到人头上。 * 「**有人**该打没打」。没人挂着的单不是谁的超期,主管对它也做不了任何事。
* ⚠️ 实测(2026-08-19 测试服):界面上显示的「超期 41 / 193」**100% 来自那一支**,
* 而真正在手且过时限的是 **0 条** —— 一屏数字全是幻觉,且不报错。
* ⇒ 一个口径,一处实现:`assigned` + 过了时限 + 没约下次。
* *
* ⚠️ 还要**并上"仍在手且窗口内到期"**那一小撮:回收器关掉时(PAC_ASSIGNMENT_EXPIRY=off) * ⚠️ 代价(已知并接受):2026-08-19 之前那些被收走过的单不再计入,老批次这一列变 0。
* 账本会是空的而单子堆在手上 —— 只查账本会显示 0,而真相是堆了一片。 * 分配功能还没正式上线,没有需要保住的历史(产品定)。
* 两边按 plan_id 去重。 * ⚠️ **约了下次回访的不算**:那是客服动过了的证据 —— 打了电话、约好下次的人
* ⚠️ 约了下次回访的**不算**:回收器刻意跳过它们,那是客服动过了的证据(与 detail 同判据)。 * 不该被显示成"压着单没动"。⛔ 这条守卫不许省。
* ⚠️ 判据与 `ledgerStatsByAssignment` 里那支、以及下面 `oldest` **逐字一致**,⛔ 不许分家:
* 一处改了另一处不改,主管会在批次表和团队表上看到两个数。
*/ */
const overdue = await this.prisma.$queryRaw<Array<{ uid: string; n: bigint }>>(Prisma.sql` const overdue = await this.prisma.$queryRaw<Array<{ uid: string; n: bigint }>>(Prisma.sql`
SELECT uid, count(DISTINCT plan_id) AS n FROM ( SELECT assignee_user_id AS uid, count(*) AS n
-- ① 窗口内被自动回收的(账本);归属回捞到到期前最后一次 assign FROM followup_plans
SELECT owner.assignee_user_id AS uid, e.plan_id WHERE host_id = ${scope.hostId}::uuid
FROM plan_event_logs e AND tenant_id = ${scope.tenantId}
JOIN LATERAL ( AND status = 'assigned'
SELECT a.assignee_user_id AND superseded_at IS NULL
FROM plan_event_logs a AND assignment_expires_at IS NOT NULL
WHERE a.plan_id = e.plan_id AND assignment_expires_at < ${now}
AND a.event = ${PlanEventType.ASSIGN} AND (snoozed_until IS NULL OR snoozed_until <= ${now})
AND a.created_at <= e.created_at AND assignee_user_id IN (${Prisma.join(ids)})
ORDER BY a.created_at DESC GROUP BY assignee_user_id`);
LIMIT 1
) owner ON TRUE /**
WHERE e.host_id = ${scope.hostId}::uuid * 最久的那条超期了多少天 —— ⚠️ 只看**此刻仍在他手上**的。
AND e.tenant_id = ${scope.tenantId} *
AND e.event = ${PlanEventType.AUTO_RELEASE} * 🔴 光给条数答不了「这事有多急」:昨天刚过时限的 41 条,和压了 12 天的 41 条,
AND e.reason = ${PlanEventReason.ASSIGNMENT_EXPIRED} * 主管该做的事完全不同。⛔ 别把历史上被回收走的算进来 —— 那些早就不在他桌上了。
AND e.created_at >= ${since} * ⚠️ 判据与上面 overdue 的第②支**逐字一致**(含 snoozed 守卫),⛔ 不许分家:
UNION * 一处改了另一处不改,同一屏上会出现"超期 41 条,最久 0 天"这种自相矛盾。
-- ② 还压在手上、到期时刻落在窗口内、且没约下次(回收器关掉时只有这一半) */
SELECT fp.assignee_user_id AS uid, fp.id AS plan_id const oldest = await this.prisma.$queryRaw<Array<{ uid: string; days: number }>>(Prisma.sql`
FROM followup_plans fp SELECT assignee_user_id AS uid,
WHERE fp.host_id = ${scope.hostId}::uuid floor(EXTRACT(EPOCH FROM (${now} - min(assignment_expires_at))) / 86400)::int AS days
AND fp.tenant_id = ${scope.tenantId} FROM followup_plans
AND fp.status = 'assigned' WHERE host_id = ${scope.hostId}::uuid
AND fp.superseded_at IS NULL AND tenant_id = ${scope.tenantId}
AND fp.assignment_expires_at >= ${since} AND status = 'assigned'
AND fp.assignment_expires_at < ${now} AND superseded_at IS NULL
AND (fp.snoozed_until IS NULL OR fp.snoozed_until <= ${now}) AND assignment_expires_at IS NOT NULL
) u AND assignment_expires_at < ${now}
WHERE uid IN (${Prisma.join(ids)}) AND (snoozed_until IS NULL OR snoozed_until <= ${now})
GROUP BY uid`); AND assignee_user_id IN (${Prisma.join(ids)})
GROUP BY assignee_user_id`);
/** /**
* 窗口内的「退回」—— 走**账本**(历史事实,永不变)。 * 窗口内的「退回」—— 走**账本**(历史事实,永不变)。
...@@ -1011,6 +1047,7 @@ export class PlanAssignmentService { ...@@ -1011,6 +1047,7 @@ export class PlanAssignmentService {
const asgM = num(assigned); const asgM = num(assigned);
const liveM = new Map(live.map((r) => [r.uid, Number(r.in_hand)])); const liveM = new Map(live.map((r) => [r.uid, Number(r.in_hand)]));
const ovdM = num(overdue); const ovdM = num(overdue);
const oldestM = new Map(oldest.map((r) => [r.uid, Number(r.days)]));
const agents = roster.agents.map((a) => { const agents = roster.agents.map((a) => {
const inHand = liveM.get(a.userId) ?? 0; const inHand = liveM.get(a.userId) ?? 0;
...@@ -1025,6 +1062,8 @@ export class PlanAssignmentService { ...@@ -1025,6 +1062,8 @@ export class PlanAssignmentService {
name: a.name, name: a.name,
inHand, inHand,
overdue: ovdM.get(a.userId) ?? 0, overdue: ovdM.get(a.userId) ?? 0,
// ⚠️ 0 天(今天刚过)与"没有超期"是两件事 —— 前者给 0,后者给 null
overdueOldestDays: oldestM.get(a.userId) ?? null,
done: doneN, done: doneN,
released: relN, released: relN,
handled, handled,
......
...@@ -9,7 +9,6 @@ import { CohortAttributesService } from './cohort-attributes.service'; ...@@ -9,7 +9,6 @@ import { CohortAttributesService } from './cohort-attributes.service';
import { ExecutionService } from './execution.service'; import { ExecutionService } from './execution.service';
import { ExecutionCallbackService } from './execution-callback.service'; import { ExecutionCallbackService } from './execution-callback.service';
import { RecycleSchedulerService } from './recycle-scheduler.service'; import { RecycleSchedulerService } from './recycle-scheduler.service';
import { AssignmentExpiryScheduler } from './assignment-expiry.scheduler';
import { PlanEngineService } from './engine/plan-engine.service'; import { PlanEngineService } from './engine/plan-engine.service';
import { ChainComposerService } from './engine/chain-composer.service'; import { ChainComposerService } from './engine/chain-composer.service';
import { TreatmentInitiationRecallScenario } from './engine/scenarios/treatment-initiation-recall.scenario'; import { TreatmentInitiationRecallScenario } from './engine/scenarios/treatment-initiation-recall.scenario';
...@@ -21,6 +20,29 @@ import { RecallDebugService } from './recall-debug/recall-debug.service'; ...@@ -21,6 +20,29 @@ import { RecallDebugService } from './recall-debug/recall-debug.service';
* v2.1:plan 一期只跑潜在治疗新链召回(treatment_initiation_recall)。 * v2.1:plan 一期只跑潜在治疗新链召回(treatment_initiation_recall)。
* 链已完成召回(aftercare)留后续,文件已删。 * 链已完成召回(aftercare)留后续,文件已删。
*/ */
/**
* 🔴 **`AssignmentExpiryScheduler` 已删(2026-08-19 产品定)** —— ⛔ 别加回来。
*
* 它做的事是「时限一到,把单子从客服手上收回召回池」。产品改判,理由是**分配的语义**:
* · **主管分配的意思是有始有终** —— 他决定了这批人交给谁,那这批人就该在他手上走完;
* 回池等于把这个决定作废,再重新分一次。
* · **容许客服短时超期** —— 没按时打完是常态不是异常,给缓冲让他继续跟进。
* · **减少客服之间的调度** —— 回池再分会让同一批患者在人之间来回换手,
* 而换手本身有成本(客户关系断掉、新接手的人要重新熟悉)。
* ⇒ **时限到了什么都不发生**:状态不变、归属不变,只是从此算「超期」,
* 超期由主管在工作台上看见并处理,⛔ 不由系统替他收单。
*
* ⚠️ ⛔ **别用测试服的落人分布来给这条决定"补证据"**(我试过,被驳回):
* 那是种子数据跑出来的批次,它的 `assign_strategy` 分布说明不了真实运营会怎样。
* 这条决定站在**语义**上,不站在概率上 —— 而语义不会因为换一批数据就变。
*
* ⚠️ 连带成立的两件事(⛔ 别当成 bug 去"修"):
* ① 「在手」只增不减 —— 那是**真的**还压在他手上,产品要看的就是整体负载;
* ② 「最忙的那位」会常亮 —— 它本来就是工作量预估不是异常告警(见 assignment-signals)。
* ⚠️ 单子回池仍有两条路,都是**人主动做的**:客服退回、主管撤销(限时 30 分钟)。
*
* 原实现与那 10 条用例在 git 里:`git show HEAD~1 -- apps/pac-service/src/modules/plan/assignment-expiry.scheduler.ts`
*/
@Module({ @Module({
// ⚠️⚠️ **AssignmentController 必须排在 PlanController 之前**,顺序不是随意的。 // ⚠️⚠️ **AssignmentController 必须排在 PlanController 之前**,顺序不是随意的。
// 两者的路由前缀都是 `plans`,而 PlanController 有一条裸 `@Get(':id')`(plan.controller:94)。 // 两者的路由前缀都是 `plans`,而 PlanController 有一条裸 `@Get(':id')`(plan.controller:94)。
...@@ -38,7 +60,6 @@ import { RecallDebugService } from './recall-debug/recall-debug.service'; ...@@ -38,7 +60,6 @@ import { RecallDebugService } from './recall-debug/recall-debug.service';
ExecutionService, ExecutionService,
ExecutionCallbackService, ExecutionCallbackService,
RecycleSchedulerService, RecycleSchedulerService,
AssignmentExpiryScheduler,
PlanEngineService, PlanEngineService,
ChainComposerService, ChainComposerService,
TreatmentInitiationRecallScenario, TreatmentInitiationRecallScenario,
......
import { AssignmentExpiryScheduler } from '../src/modules/plan/assignment-expiry.scheduler';
import type { PrismaService } from '../src/prisma/prisma.service';
/**
* 分配单到期自动回池回归。
*
* 产品定调:到期回池是给主管减负(他不必再去追"这单还要不要")。
* 但它是**系统主动把单从客服手里收走**,三条红线错一条都会造成静默的数据/信任损失:
* ① 约好回访的单绝不能被收(snoozedUntil 守卫)—— 收了就是系统主动毁客服对患者的承诺
* ② 不写 release_reason —— 那列只属于客服的处置,到期混进去退回率分子分母一起脏
* ③ 不清批次归因三列 —— 清了这批的分母就少一条
*/
const NOW = new Date('2026-08-10T03:00:00Z');
function makeService(rows: Array<Record<string, unknown>>) {
const captured: {
where?: Record<string, unknown>;
data?: Record<string, unknown>;
updateWhere?: Record<string, unknown>;
} = {};
const events: Array<Record<string, unknown>> = [];
const findMany = jest.fn(async ({ where }: { where: Record<string, unknown> }) => {
captured.where = where;
return rows;
});
const tx = {
followupPlan: {
updateMany: jest.fn(
async (args: { where: Record<string, unknown>; data: Record<string, unknown> }) => {
captured.data = args.data;
captured.updateWhere = args.where;
return { count: rows.length };
},
),
},
planEventLog: {
createMany: jest.fn(async ({ data }: { data: Array<Record<string, unknown>> }) => {
events.push(...data);
return { count: data.length };
}),
},
};
const prisma = {
followupPlan: { findMany },
$transaction: jest.fn(async (fn: (t: typeof tx) => Promise<unknown>) => fn(tx)),
} as unknown as PrismaService;
return { svc: new AssignmentExpiryScheduler(prisma), captured, events, findMany, tx };
}
const PLAN = {
id: 'p1',
hostId: 'h1',
tenantId: 't1',
patientId: 'pat1',
assigneeUserId: 'u-staff',
assignedAt: new Date(NOW.getTime() - 3 * 86400_000), // 3 天前分的
};
describe('AssignmentExpiryScheduler', () => {
const OLD = process.env.PAC_ASSIGNMENT_EXPIRY;
afterEach(() => {
if (OLD === undefined) delete process.env.PAC_ASSIGNMENT_EXPIRY;
else process.env.PAC_ASSIGNMENT_EXPIRY = OLD;
});
test('⭐⭐ 红线①:查询条件必须带 snoozedUntil 守卫(约好回访的单不能被收)', async () => {
delete process.env.PAC_ASSIGNMENT_EXPIRY;
const { svc, captured } = makeService([PLAN]);
await svc.runExpiry(NOW);
// 客服约了 6/10 回访、plan snooze 到 6/10 —— 到期也不能收,
// 收了客服就丢了已对患者承诺的回访关系,而且单子还会被别人从池里捞走
expect(captured.where?.OR).toEqual([
{ snoozedUntil: null },
{ snoozedUntil: { lte: expect.any(Date) } },
]);
// 只收已过期的 assigned
expect(captured.where?.status).toBe('assigned');
expect(captured.where?.assignmentExpiresAt).toMatchObject({ not: null });
expect(captured.where?.supersededAt).toBeNull();
});
test('⭐⭐ 红线②:**不写 release_reason** —— 到期不是客服的处置', async () => {
const { svc, captured } = makeService([PLAN]);
await svc.runExpiry(NOW);
// 退回(客服看了判断"不该我做")与到期(客服压根没动)是两件事。
// 混进同一列,退回率的分子分母一起虚高,而"到期未动"这个数本身才是主管要的信号。
expect(captured.data).not.toHaveProperty('releaseReason');
expect(captured.data).not.toHaveProperty('releaseNote');
});
test('⭐⭐ 红线③:**不清批次归因三列**,但清在办期限', async () => {
const { svc, captured } = makeService([PLAN]);
await svc.runExpiry(NOW);
expect(captured.data).not.toHaveProperty('assignmentId');
expect(captured.data).not.toHaveProperty('assignedBy');
expect(captured.data).not.toHaveProperty('assignStrategy');
expect(captured.data).toMatchObject({
status: 'active',
assigneeUserId: null,
assignmentExpiresAt: null,
});
});
test('⭐ 红线④:绝不动 snoozedUntil(与退回同一条纪律)', async () => {
const { svc, captured } = makeService([PLAN]);
await svc.runExpiry(NOW);
expect(captured.data).not.toHaveProperty('snoozedUntil');
});
test('落账本:auto_release + reason=assignment_expired + 持有时长算得出来', async () => {
const { svc, events } = makeService([PLAN]);
await svc.runExpiry(NOW);
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({
event: 'auto_release',
reason: 'assignment_expired',
assigneeUserId: null,
actorUserId: null, // 系统行为
});
// 时间界注入 → 精确 3 天,不给宽容区间也不会 flake
expect(events[0]!.heldSeconds).toBe(3 * 86400);
});
test('并发安全:updateMany 的 where 带 status 条件(客服刚提交执行则本次不生效)', async () => {
const { svc, captured } = makeService([PLAN]);
await svc.runExpiry(NOW);
expect(captured.updateWhere?.status).toBe('assigned');
});
test('无到期单 → 不发任何写(纯净轮次零副作用)', async () => {
const { svc, tx } = makeService([]);
await svc.runExpiry(NOW);
expect(tx.followupPlan.updateMany).not.toHaveBeenCalled();
expect(tx.planEventLog.createMany).not.toHaveBeenCalled();
});
test('⭐ 开关 off → 一行都不碰(连查询都不发)', async () => {
process.env.PAC_ASSIGNMENT_EXPIRY = 'off';
const { svc, findMany } = makeService([PLAN]);
await svc.runExpiry(NOW);
expect(findMany).not.toHaveBeenCalled();
});
test('⭐ 默认是**开**的 —— 与 PAC_PLAN_AUTO_RECYCLE(默认关)相反,别搞混', async () => {
delete process.env.PAC_ASSIGNMENT_EXPIRY;
const { svc, findMany } = makeService([PLAN]);
await svc.runExpiry(NOW);
expect(findMany).toHaveBeenCalled();
// 不收回的后果不是"多几条过期单",是容量口径整体失效:在手只增不减,几批后全员触顶
});
});
...@@ -31,10 +31,15 @@ const BATCH = 'c02e1b80-1111-4222-8333-444455556666'; ...@@ -31,10 +31,15 @@ const BATCH = 'c02e1b80-1111-4222-8333-444455556666';
* `Cannot read properties of undefined (reading 'toISOString')` 这种跟真因毫无关系的错。 * `Cannot read properties of undefined (reading 'toISOString')` 这种跟真因毫无关系的错。
* ⚠️ 改成看 SQL 里的特征词:加查询时**只要不撞词就不用动测试**。 * ⚠️ 改成看 SQL 里的特征词:加查询时**只要不撞词就不用动测试**。
*/ */
function sqlKind(q: unknown): 'ledger' | 'outcomeDist' | 'outcomeRecords' | 'other' { function sqlKind(
q: unknown,
): 'ledger' | 'nowOverdue' | 'outcomeDist' | 'outcomeRecords' | 'other' {
const text = ((q as { strings?: string[] })?.strings ?? []).join(' '); const text = ((q as { strings?: string[] })?.strings ?? []).join(' ');
if (text.includes('LEFT JOIN patients')) return 'outcomeRecords'; if (text.includes('LEFT JOIN patients')) return 'outcomeRecords';
if (text.includes('GROUP BY outcome')) return 'outcomeDist'; if (text.includes('GROUP BY outcome')) return 'outcomeDist';
// ⚠️ 这一支必须排在 `plan_event_logs` **之前**判:它查的是 followup_plans,
// 而 2026-08-19 起「超期」= 账本历史 ∪ **此刻仍挂在人手上且已过时限**(见 ledgerStatsByAssignment)。
if (text.includes('assignment_expires_at <')) return 'nowOverdue';
if (text.includes('plan_event_logs')) return 'ledger'; if (text.includes('plan_event_logs')) return 'ledger';
return 'other'; return 'other';
} }
...@@ -49,18 +54,28 @@ function makePrisma(opts: { ...@@ -49,18 +54,28 @@ function makePrisma(opts: {
createdAt?: Date; createdAt?: Date;
/** 本批账本事件(assign / release);空 = 老批次,走回落路径 */ /** 本批账本事件(assign / release);空 = 老批次,走回落路径 */
events?: Array<{ planId: string; event: string; assigneeUserId: string | null; reason: string | null }>; events?: Array<{ planId: string; event: string; assigneeUserId: string | null; reason: string | null }>;
/** 此刻仍挂在人手上、且已过时限的条数(新口径下「超期」的主力来源) */
nowOverdue?: number;
}) { }) {
const plans = opts.plans ?? []; const plans = opts.plans ?? [];
const queryRaw = jest.fn(async (q: unknown) => const queryRaw = jest.fn(async (q: unknown) => {
sqlKind(q) === 'ledger' && opts.ledger const kind = sqlKind(q);
? [{ if (kind === 'ledger' && opts.ledger) {
return [{
assignment_id: BATCH, assignment_id: BATCH,
planned: BigInt(opts.ledger.planned), agents: BigInt(opts.ledger.agents), planned: BigInt(opts.ledger.planned), agents: BigInt(opts.ledger.agents),
released: BigInt(opts.ledger.released), expired: BigInt(opts.ledger.expired), released: BigInt(opts.ledger.released),
// ⚠️ 2026-08-19 起账本**不再产出 expired** —— 超期只有 nowOverdue 一个来源。
// `opts.ledger.expired` 保留在夹具里,是为了让「账本里有到期事件也不算」那条用例
// 能把它填进去而**证明它没被读**。
revoked: BigInt(opts.ledger.revoked), revoked: BigInt(opts.ledger.revoked),
}] }];
: [], }
); if (kind === 'nowOverdue') {
return opts.nowOverdue ? [{ assignment_id: BATCH, n: BigInt(opts.nowOverdue) }] : [];
}
return [];
});
const prisma = { const prisma = {
planAssignment: { planAssignment: {
findFirst: jest.fn(async () => ({ findFirst: jest.fn(async () => ({
...@@ -215,13 +230,70 @@ describe('退回原因 —— 分布必须与 released 对得上', () => { ...@@ -215,13 +230,70 @@ describe('退回原因 —— 分布必须与 released 对得上', () => {
const d = await svc.detail(SCOPE, BATCH); const d = await svc.detail(SCOPE, BATCH);
expect(d.agentStats.find((x) => x.userId === 'a')!.planned).toBe(1); expect(d.agentStats.find((x) => x.userId === 'a')!.planned).toBe(1);
}); });
/**
* 🔴🔴 **「超期」的口径 2026-08-19 换了源** —— 到期不再回池(回收器已删,沿革见 `plan.module` 的墓碑)。
*
* 从此不会再有新的 `auto_release/assignment_expired` 事件。只数账本的话,
* **2026-08-19 之后的每一个批次这一列都永远是 0** —— 而主管看到的是"这批没人超期",
* 那是这次改动最容易静默造出来的假象。
* ⇒ 现在取**并集**:账本里到期回收过的(历史) ∪ 此刻仍挂在人手上且已过时限的(现状)。
*/
test('⭐⭐ 新批次没有到期事件 → 超期靠**现算**,⛔ 不许是 0', async () => {
const { prisma } = makePrisma({
plans: [],
// 账本里 expired=0:新口径下再也不会有这类事件了
ledger: { planned: 10, agents: 2, released: 1, expired: 0, revoked: 0 },
nowOverdue: 4,
events: [],
});
const svc = await build(prisma);
const d = await svc.detail(SCOPE, BATCH);
expect(d.expired).toBe(4);
});
/**
* 🔴🔴 **已经回池的⛔ 不算超期**(2026-08-19 产品定的判据)。
*
* 账本里那些 `auto_release/assignment_expired` 数的是**回收器时代被收走的单** ——
* 它们此刻躺在池子里、没有任何客服挂着。而「超期」的意思是「**有人**该打没打」:
* 没人挂着的单不是谁的超期,主管对它也做不了任何事。
* ⚠️ 实测(测试服):界面上「超期 41 / 193」**100% 来自账本那一支**,
* 而真正在手且过时限的是 0 条 —— 一屏数字全是幻觉,且不报错。
* ⇒ 这条用例把账本填满,断言它**一条都不许漏进来**。
*/
test('🔴 账本里有到期事件也⛔ 不算 —— 那些单已经回池、没有客服挂着', async () => {
const { prisma } = makePrisma({
plans: [],
ledger: { planned: 10, agents: 2, released: 1, expired: 3, revoked: 0 },
// 此刻挂在人手上的是 0 → 超期就该是 0,⛔ 不是账本那个 3
events: [],
});
const svc = await build(prisma);
const d = await svc.detail(SCOPE, BATCH);
expect(d.expired).toBe(0);
});
test('🔴 账本与在手同时有值 → 只认在手那个,⛔ 不相加', async () => {
const { prisma } = makePrisma({
plans: [],
ledger: { planned: 20, agents: 3, released: 2, expired: 3, revoked: 0 },
nowOverdue: 5,
events: [],
});
const svc = await build(prisma);
const d = await svc.detail(SCOPE, BATCH);
expect(d.expired).toBe(5);
});
}); });
describe('批次归因 —— 到期与退回必须分开', () => { describe('批次归因 —— 到期与退回必须分开', () => {
test('⭐⭐ expired 与 released 是两个数(合成一个"回池率"两种病都看不出来)', async () => { // ⚠️ 2026-08-19 起 expired 来自 `nowOverdue`(此刻在手且过时限),⛔ 不再来自账本
test('⭐⭐ expired 与 released 是两个数(合成一个数两种病都看不出来)', async () => {
const { prisma } = makePrisma({ const { prisma } = makePrisma({
plans: [{ id: 'p1', status: 'assigned', assigneeUserId: 'a', releaseReason: null }], plans: [{ id: 'p1', status: 'assigned', assigneeUserId: 'a', releaseReason: null }],
ledger: { planned: 9, agents: 2, released: 3, expired: 2, revoked: 0 }, ledger: { planned: 9, agents: 2, released: 3, expired: 0, revoked: 0 },
nowOverdue: 2,
}); });
const svc = await build(prisma); const svc = await build(prisma);
const d = await svc.detail(SCOPE, BATCH); const d = await svc.detail(SCOPE, BATCH);
...@@ -232,7 +304,8 @@ describe('批次归因 —— 到期与退回必须分开', () => { ...@@ -232,7 +304,8 @@ describe('批次归因 —— 到期与退回必须分开', () => {
test('⭐ note 要把两者分开说出来 —— 助手是照抄这句话的', async () => { test('⭐ note 要把两者分开说出来 —— 助手是照抄这句话的', async () => {
const { prisma } = makePrisma({ const { prisma } = makePrisma({
plans: [{ id: 'p1', status: 'assigned', assigneeUserId: 'a', releaseReason: null }], plans: [{ id: 'p1', status: 'assigned', assigneeUserId: 'a', releaseReason: null }],
ledger: { planned: 9, agents: 2, released: 3, expired: 2, revoked: 0 }, ledger: { planned: 9, agents: 2, released: 3, expired: 0, revoked: 0 },
nowOverdue: 2,
}); });
const svc = await build(prisma); const svc = await build(prisma);
const note = (await svc.detail(SCOPE, BATCH)).progress.note; const note = (await svc.detail(SCOPE, BATCH)).progress.note;
......
import { AssignStrategy, type AgentInfo, type TemperatureValue } from '@pac/types'; import {
ASSIGNMENT_EXPIRES_DAYS_DEFAULT as D,
AssignStrategy,
DAILY_CALLS_PER_AGENT as RATE,
type AgentInfo,
type TemperatureValue,
} from '@pac/types';
// ⚠️ **用 import 不用 require**(2026-08-12):原来这两个 describe 里各写了一行 // ⚠️ **用 import 不用 require**(2026-08-12):原来这两个 describe 里各写了一行
// `const { AssignmentProposalService } = require(...)` —— require 的返回是 any, // `const { AssignmentProposalService } = require(...)` —— require 的返回是 any,
// 于是给构造器加第三个依赖时**类型检查查不出来**,运行时那个依赖是 undefined, // 于是给构造器加第三个依赖时**类型检查查不出来**,运行时那个依赖是 undefined,
...@@ -461,9 +467,11 @@ describe('selectionNote —— 候选不够时的措辞', () => { ...@@ -461,9 +467,11 @@ describe('selectionNote —— 候选不够时的措辞', () => {
const SCOPE = { hostId: 'h', tenantId: 't', sourceUnits: [], clinicIds: ['c1'], userId: 'u' }; const SCOPE = { hostId: 'h', tenantId: 't', sourceUnits: [], clinicIds: ['c1'], userId: 'u' };
/// 首次估法:在岗 9 人 × 20 = 180(⚠️ 那个 20 不是容量上限,只是首次没有历史时的估法) /// 首次估法:在岗 9 人 × 20 = 180(⚠️ 那个 20 不是容量上限,只是首次没有历史时的估法)
// 默认口径:在岗 9 人 × 每天 15 通 × 1 天(2026-08-12:一批推一天的活,⛔ 不再沿用上一次) // 默认口径:在岗 9 人 × 每天 15 通 × 1 天(2026-08-12:一批推一天的活,⛔ 不再沿用上一次)
const N = 9 * 15 * 1; // ⚠️ 跟着**式子**走(在岗 × 每天几通 × 时限),⛔ 别写死那个积 ——
// 默认时限调过两次(3→1→3),写死就得每次来改一遍,而式子一次都没变。
const N = 9 * RATE * D;
test('⭐⭐ 候选 44 < 基数 180 → 说「一共就 44 人,全部纳入」,⛔ 不许说「取前 N 人」', async () => { test('⭐⭐ 候选比基数少 → 说「一共就 44 人,全部纳入」,⛔ 不许说「取前 N 人」', async () => {
const r = await svcWith(44, 9).propose(SCOPE, { clinicId: 'c1', potentialTreatment: 'implant' }); const r = await svcWith(44, 9).propose(SCOPE, { clinicId: 'c1', potentialTreatment: 'implant' });
expect(r.selectionNote).toContain('一共就 44 人'); expect(r.selectionNote).toContain('一共就 44 人');
expect(r.selectionNote).toContain('全要了'); expect(r.selectionNote).toContain('全要了');
...@@ -472,7 +480,7 @@ describe('selectionNote —— 候选不够时的措辞', () => { ...@@ -472,7 +480,7 @@ describe('selectionNote —— 候选不够时的措辞', () => {
expect(r.selectionNote).not.toContain('取前'); expect(r.selectionNote).not.toContain('取前');
}); });
test('⭐ 候选 500 > 基数 180 → 照实说「这批 180 人,从 500 人里挑」+ 剩下的还没轮到', async () => { test('⭐ 候选多于基数 → 照实说「这批 N 人,从 500 人里挑」+ 剩下的还没轮到', async () => {
const r = await svcWith(500, 9).propose(SCOPE, { clinicId: 'c1', potentialTreatment: 'implant' }); const r = await svcWith(500, 9).propose(SCOPE, { clinicId: 'c1', potentialTreatment: 'implant' });
expect(r.target).toBe(N); expect(r.target).toBe(N);
// ⚠️ 2026-08-06 重排:每段以加粗的「数字+是什么」起头,措辞随之变化 // ⚠️ 2026-08-06 重排:每段以加粗的「数字+是什么」起头,措辞随之变化
...@@ -576,16 +584,32 @@ describe('基数沿用 —— 本批人数与时限', () => { ...@@ -576,16 +584,32 @@ describe('基数沿用 —— 本批人数与时限', () => {
const r = await mk(1000, 9, { lastCriteria: { batchSize: 300, expiresInDays: 5 } }).propose(SCOPE, { const r = await mk(1000, 9, { lastCriteria: { batchSize: 300, expiresInDays: 5 } }).propose(SCOPE, {
clinicId: 'c1', clinicId: 'c1',
}); });
expect(r.batchSize).toBe(9 * 15 * 1); // ⚠️ 断的是**式子**(在岗 × 每天几通 × 时限),⛔ 不写死 135/405 那个积 ——
expect(r.expiresInDays).toBe(1); // 默认时限调过两次(3→1→3),每调一次就要来改这几个字面量,而式子一次都没变。
expect(r.batchSize).toBe(9 * RATE * D);
expect(r.expiresInDays).toBe(D);
expect(r.basis).toBe('default'); expect(r.basis).toBe('default');
}); });
test('⭐ 默认口径 = 在岗 9 人 × 每天 15 通 × 1 天 = 135,且说清是默认值', async () => { /**
* ⭐⭐ **默认时限这个数本身**,单独钉一次。
*
* 🔴 上面那几条用的是常量,所以它们**不会**因为默认值被改动而变红 —— 那正是想要的
* (式子才是不变式)。但"当前默认到底是几天"是**产品决定**,改它必须是一次
* 有意识的动作:⇒ 这里写死。红了就去看 `ASSIGNMENT_EXPIRES_DAYS_DEFAULT` 上那段沿革,
* 确认三处连动都跟着改了(N 的乘数 / daily_overload 的阈值 / 对外文档的示例式子)。
* ⚠️ 沿革:2026-08-12 由 3 改 1(每天推一天的活);2026-08-19 改回 **3**。
*/
test('⭐⭐ 默认时限 = 3 天(改这个数是产品决定,连动三处)', () => {
expect(D).toBe(3);
});
test('⭐ 默认口径 = 在岗 9 人 × 每天 15 通 × 时限,且说清是默认值', async () => {
const r = await mk(1000, 9).propose(SCOPE, { clinicId: 'c1' }); const r = await mk(1000, 9).propose(SCOPE, { clinicId: 'c1' });
expect(r.batchSize).toBe(135); expect(r.batchSize).toBe(9 * RATE * D);
expect(r.target).toBe(135); // ⚠️ 候选 1000 人管够,所以 target 就等于基数(不够时会被压低,那是另一条用例)
expect(r.expiresInDays).toBe(1); expect(r.target).toBe(9 * RATE * D);
expect(r.expiresInDays).toBe(D);
expect(r.basis).toBe('default'); expect(r.basis).toBe('default');
/** /**
* 🔴 2026-08-14 从 `basisNote` 迁到 `modelFacts` —— basisNote **只写不读**, * 🔴 2026-08-14 从 `basisNote` 迁到 `modelFacts` —— basisNote **只写不读**,
...@@ -617,12 +641,12 @@ describe('基数沿用 —— 本批人数与时限', () => { ...@@ -617,12 +641,12 @@ describe('基数沿用 —— 本批人数与时限', () => {
test('🔴 这批候选只有 44 → 本批 44,但基数仍是默认那个数', async () => { test('🔴 这批候选只有 44 → 本批 44,但基数仍是默认那个数', async () => {
const r = await mk(44, 9).propose(SCOPE, { clinicId: 'c1' }); const r = await mk(44, 9).propose(SCOPE, { clinicId: 'c1' });
expect(r.target).toBe(44); expect(r.target).toBe(44);
expect(r.batchSize).toBe(135); expect(r.batchSize).toBe(9 * RATE * D);
/** /**
* 🔴 2026-08-14 **改判**:原断言是 `basisNote` 里要有「您设的 135 没变」。两处都错了 —— * 🔴 2026-08-14 **改判**:原断言是 `basisNote` 里要有「您设的 135 没变」。两处都错了 ——
* ① basisNote 没有消费方(已删); * ① basisNote 没有消费方(已删);
* ② 135 是系统按「在岗 × 每天几通 × 时限」算的,**主管一个数都没设过**, * ② 那个数是系统按「在岗 × 每天几通 × 时限」算的,**主管一个数都没设过**,
* 叫它"您设的"会让他去找自己什么时候设过 135 * 叫它"您设的"会让他去找自己什么时候设过
* ⇒ `basis='default'` 时这个数**根本不进模型上下文**(见 assignment-facts 那段注释), * ⇒ `basis='default'` 时这个数**根本不进模型上下文**(见 assignment-facts 那段注释),
* 对应的断言在 `assignment-signals.spec` 的「首次默认的基数不给模型」。 * 对应的断言在 `assignment-signals.spec` 的「首次默认的基数不给模型」。
* ⚠️ 这里仍然要锁的是 `batchSize` **没有被回写成 44**(上面那句),那才是这条测试的正题。 * ⚠️ 这里仍然要锁的是 `batchSize` **没有被回写成 44**(上面那句),那才是这条测试的正题。
......
...@@ -130,8 +130,16 @@ describe('引导节点 · 判定', () => { ...@@ -130,8 +130,16 @@ describe('引导节点 · 判定', () => {
// ⚠️ 增量与总量必须分开 —— 不拆开主管会以为这批一下压了 60 条给他 // ⚠️ 增量与总量必须分开 —— 不拆开主管会以为这批一下压了 60 条给他
expect(s.why).toContain('本批 45 条'); expect(s.why).toContain('本批 45 条');
expect(s.why).toContain('原本在手 15 条'); expect(s.why).toContain('原本在手 15 条');
// ⚠️ 默认路径要说清后果是可接受的(落回池子、下批还能分),⛔ 不制造紧迫感 /**
expect(s.defaultLabel).toContain('落回池子'); * ⚠️ 默认路径要**说清后果**,⛔ 不制造紧迫感 —— 这条不变,变的是后果本身:
* 2026-08-19 起到期**不再回池**(回收器已删,沿革见 `plan.module` 的墓碑),
* 所以原来那句「落回池子、下批还能分」成了一个不会兑现的承诺。
* ⇒ 现在断的是新事实:超期之后单子**仍在这位客服手上**。
* ⛔ 「回池」这个词不许再出现在这条里。
*/
expect(s.defaultLabel).toContain('仍在这位客服手上');
expect(s.defaultLabel).not.toContain('回池');
expect(s.defaultLabel).not.toContain('池子');
}); });
test('⭐ 刚好打得完(45 条 / 3 天)→ ⛔ 不出 daily_overload', () => { test('⭐ 刚好打得完(45 条 / 3 天)→ ⛔ 不出 daily_overload', () => {
......
...@@ -227,8 +227,15 @@ describe('助手措辞 —— 码给工具,中文给人', () => { ...@@ -227,8 +227,15 @@ describe('助手措辞 —— 码给工具,中文给人', () => {
const PROMPT_TEXT = ( const PROMPT_TEXT = (
PROMPTS.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^[ \t]*\/\/.*$/gm, '') PROMPTS.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^[ \t]*\/\/.*$/gm, '')
); );
expect(PROMPT_TEXT).toMatch(/取值码、字段名、工具名/); /**
expect(PROMPT_TEXT).toMatch(/只在代码里存在的概念/); * ⚠️ 2026-08-19 这几个词本身**被改成人话了**(「取值码 / 字段名 / 概念」也是黑话,
* 而这份提示词的第一条就是"别用只在代码里存在的说法" —— 它在违反自己)。
* ⇒ 断言改成断**这四类还在不在**,⛔ 不再钉死那一串顿号分隔的原文:
* 钉原文的话,每次把话说得更像人话都要来改一次测试,而规则一个字没变。
*/
for (const kind of [/英文代号/, /列名/, /工具的名字/, /只在代码里存在/]) {
expect(PROMPT_TEXT).toMatch(kind);
}
for (const leaked of ['cold_3y', 'personaTags', 'get_cohort_attributes']) { for (const leaked of ['cold_3y', 'personaTags', 'get_cohort_attributes']) {
expect(PROMPT_TEXT).not.toContain(leaked); expect(PROMPT_TEXT).not.toContain(leaked);
} }
...@@ -423,7 +430,10 @@ describe('确认后没配福利 → 提醒补挂,但不许编效果', () => { ...@@ -423,7 +430,10 @@ describe('确认后没配福利 → 提醒补挂,但不许编效果', () => {
}); });
test('⭐ `本批福利` 这个事实要一直给模型 —— 他回头问「这批带福利没」才答得上来', () => { test('⭐ `本批福利` 这个事实要一直给模型 —— 他回头问「这批带福利没」才答得上来', () => {
expect(SHEET).toMatch(/本批福利: benefit\.trim\(\) \|\| null/); // ⚠️ 2026-08-19 起取的是 `benefitNow`(确认时那一层浮层现填的),⛔ 不是 `benefit` 那个 state:
// 浮层"边填边确认",读 state 拿到的是上一帧的空串(沿革见 benefit-popover)。
// ⇒ 断言放宽到"这个键取的是本次确认要挂的那个值",⛔ 不再钉死变量名。
expect(SHEET).toMatch(/本批福利: \w+\.trim\(\) \|\| null/);
}); });
test('⭐ 确认摘要里用中文项目名,⛔ 不许出现 endo/filling 这种码', () => { test('⭐ 确认摘要里用中文项目名,⛔ 不许出现 endo/filling 这种码', () => {
......
...@@ -297,6 +297,73 @@ body { ...@@ -297,6 +297,73 @@ body {
.pac-stream-dot { display: none; } .pac-stream-dot { display: none; }
} }
/* ── 初选矩阵:悬停 = **瞄准**(点下去才是发射,接上面那串粒子)────────────
⚠️ 这一整段只在 `pool-matrix` 用。它做的是**一件事的两半**:
· 准星(这里)—— 你正指着**哪一格**;
· 十字(组件里的行/列高亮)—— 那一格**是谁 × 什么时候**。
8 行 × 6 列的表,格子离行头和列头都很远,光把格子框起来答不了后一半。
🔴 用**角括号**而不是整圈实线框:
实线框留给「**已经选中**」(组件里 `selected` 那一支)。两个状态长得一样的话,
"我正指着" 和 "我已经选了" 就分不出来 —— 而这一点下去人就发出去了。
⚠️ 画在**伪元素**上,⛔ 不画在按钮自己的 background 上:
按钮的底色要留给十字那层薄纱,两者叠在一起时得各归各的图层。
⚠️ 这个类由组件**按 state 挂**(只挂在瞄中的那一格),所以下面 `::after` ⛔ 不加 `:hover` ——
0 人的格子是 disabled,而 disabled 的 hover 跨浏览器不一致,靠 CSS 拦不住。 */
.pac-reticle { position: relative; }
.pac-reticle::after {
content: '';
position: absolute;
inset: 2px;
pointer-events: none;
--pac-ret-c: var(--color-brand-600);
--pac-ret-b: 1.5px; /* 线宽 */
--pac-ret-l: 7px; /* 每个角的臂长 */
/* 四个角 = 8 条渐变(每角一横一竖)。⚠️ 这样零 DOM、零 SVG,
而且随格子尺寸自适应(角永远贴在四角,中间留空)。 */
background-image:
linear-gradient(var(--pac-ret-c), var(--pac-ret-c)),
linear-gradient(var(--pac-ret-c), var(--pac-ret-c)),
linear-gradient(var(--pac-ret-c), var(--pac-ret-c)),
linear-gradient(var(--pac-ret-c), var(--pac-ret-c)),
linear-gradient(var(--pac-ret-c), var(--pac-ret-c)),
linear-gradient(var(--pac-ret-c), var(--pac-ret-c)),
linear-gradient(var(--pac-ret-c), var(--pac-ret-c)),
linear-gradient(var(--pac-ret-c), var(--pac-ret-c));
background-repeat: no-repeat;
background-size:
var(--pac-ret-l) var(--pac-ret-b), var(--pac-ret-b) var(--pac-ret-l),
var(--pac-ret-l) var(--pac-ret-b), var(--pac-ret-b) var(--pac-ret-l),
var(--pac-ret-l) var(--pac-ret-b), var(--pac-ret-b) var(--pac-ret-l),
var(--pac-ret-l) var(--pac-ret-b), var(--pac-ret-b) var(--pac-ret-l);
background-position:
0 0, 0 0,
100% 0, 100% 0,
0 100%, 0 100%,
100% 100%, 100% 100%;
/* ⭐ 两段:先**咬合**(从格子外一口收进来),再**呼吸**(产品要的"忽大忽小")。
⚠️ 呼吸幅度只有 1.5px、周期 2.4s —— 再大就从"锁定中"变成"这里出错了"。 */
animation:
pacReticleLock 0.18s cubic-bezier(0.2, 1.3, 0.4, 1),
pacReticleBreathe 2.4s ease-in-out 0.18s infinite;
}
/* 扣扳机的那一下:准星收紧,紧接着交给粒子(pacStreamFly) */
.pac-reticle:active::after { inset: 3.5px; animation: none; transition: inset 0.08s ease-out; }
@keyframes pacReticleLock {
from { inset: -2px; opacity: 0; }
to { inset: 2px; opacity: 1; }
}
@keyframes pacReticleBreathe {
0%, 100% { inset: 2px; opacity: 1; }
50% { inset: 3.5px; opacity: 0.7; }
}
@media (prefers-reduced-motion: reduce) {
/* 无障碍:准星照旧画出来(它是**信息**:你指着哪一格),只是不动 */
.pac-reticle::after { animation: none; }
}
/* ── 「分一批新的 · 里世界」传送门 —— **2026-08-11 删** ────────────── /* ── 「分一批新的 · 里世界」传送门 —— **2026-08-11 删** ──────────────
这里原来是那版全屏「打地鼠」的动效:一个 200×200 的小圆从按钮位置 这里原来是那版全屏「打地鼠」的动效:一个 200×200 的小圆从按钮位置
`transform: scale()` 放大盖满全屏(`.pac-portal-disc`),内容随后 `opacity` 淡入 `transform: scale()` 放大盖满全屏(`.pac-portal-disc`),内容随后 `opacity` 淡入
......
...@@ -24,6 +24,7 @@ import { useAssistantStore } from '@/stores/assistant-store'; ...@@ -24,6 +24,7 @@ import { useAssistantStore } from '@/stores/assistant-store';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { ConfirmSheetSignals } from './confirm-sheet-signals'; import { ConfirmSheetSignals } from './confirm-sheet-signals';
import { BenefitPopover } from './benefit-popover';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { import {
Select, Select,
...@@ -641,7 +642,13 @@ export function AssignmentConfirmSheet({ ...@@ -641,7 +642,13 @@ export function AssignmentConfirmSheet({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [echoSeq]); }, [echoSeq]);
const confirm = async () => { /**
* @param benefitNow **这一次**要挂的福利文案。
* ⚠️ 走参数、⛔ 不读 `benefit` 这个 state:确认前那层浮层是「边填边确认」——
* `setBenefit(v)` 之后紧接着调 `confirm()`,闭包里拿到的仍是**上一帧**的空串,
* 于是福利静默丢掉、而落库和话术都不会报错。
*/
const confirm = async (benefitNow: string = benefit) => {
setSubmitting(true); setSubmitting(true);
setError(null); setError(null);
try { try {
...@@ -674,7 +681,7 @@ export function AssignmentConfirmSheet({ ...@@ -674,7 +681,7 @@ export function AssignmentConfirmSheet({
}, },
}, },
// T4:福利挂批次(话术勾子 + 归因标签);空串 = 不带福利,⛔ 别落一个空 benefit 对象 // T4:福利挂批次(话术勾子 + 归因标签);空串 = 不带福利,⛔ 别落一个空 benefit 对象
...(benefit.trim() ? { attributes: { benefit: { text: benefit.trim() } } } : {}), ...(benefitNow.trim() ? { attributes: { benefit: { text: benefitNow.trim() } } } : {}),
expiresInDays, expiresInDays,
// ⚠️ 落库的是**卡片当前值**,不是模型原提案。 // ⚠️ 落库的是**卡片当前值**,不是模型原提案。
// 逐条 expiresInDays 只在**与批次不同**时才带:一样还带等于把批次时限冻进每条, // 逐条 expiresInDays 只在**与批次不同**时才带:一样还带等于把批次时限冻进每条,
...@@ -741,7 +748,7 @@ export function AssignmentConfirmSheet({ ...@@ -741,7 +748,7 @@ export function AssignmentConfirmSheet({
时限天数: expiresInDays, 时限天数: expiresInDays,
...(dropped.size ? { 主管移除: dropped.size } : {}), ...(dropped.size ? { 主管移除: dropped.size } : {}),
...(res.skipped.length ? { 没落上: res.skipped.length } : {}), ...(res.skipped.length ? { 没落上: res.skipped.length } : {}),
本批福利: benefit.trim() || null, 本批福利: benefitNow.trim() || null,
}, },
}), }),
); );
...@@ -1343,8 +1350,11 @@ export function AssignmentConfirmSheet({ ...@@ -1343,8 +1350,11 @@ export function AssignmentConfirmSheet({
)} )}
{/* ⭐ 批次时限放**右上角**:它是"这一整批"的设置,和左边"这一整批多少人"同一层级, {/* ⭐ 批次时限放**右上角**:它是"这一整批"的设置,和左边"这一整批多少人"同一层级,
放在底部微调区会让人以为它跟下面的逐条时限是一回事。 放在底部微调区会让人以为它跟下面的逐条时限是一回事。
⚠️ 文案是「N 天**后自动退回**」而不是光一个「时限」—— ⚠️ 文案不能是光一个「时限」—— 主管要知道这个数**管什么**,不然它对他没有意义。
主管要知道到期会发生什么(单子回池),不然这个数对他没有意义。 */} 🔴 2026-08-19 由「N 天**后自动退回**」改成「N 天**内打完**」:到期不再回池了
(沿革见 plan.module 的墓碑),原文是在许一个不会兑现的承诺。
⚠️ 到期之后会怎样(记为超期、单子仍在他手上)写在下面那行操作说明里,
⛔ 不塞进这个角落 —— 这里只有一行的宽度,塞进去会把选择器挤到换行。 */}
<div className="ml-auto flex flex-none items-center gap-1 whitespace-nowrap text-[10.5px] text-slate-500"> <div className="ml-auto flex flex-none items-center gap-1 whitespace-nowrap text-[10.5px] text-slate-500">
<DaySelect <DaySelect
value={expiresInDays} value={expiresInDays}
...@@ -1354,7 +1364,7 @@ export function AssignmentConfirmSheet({ ...@@ -1354,7 +1364,7 @@ export function AssignmentConfirmSheet({
echo(`确认单已更新:整批时限改成 ${d} 天。`); echo(`确认单已更新:整批时限改成 ${d} 天。`);
}} }}
/> />
<span>后自动退回</span> <span>内打完</span>
</div> </div>
</div> </div>
...@@ -1910,14 +1920,31 @@ export function AssignmentConfirmSheet({ ...@@ -1910,14 +1920,31 @@ export function AssignmentConfirmSheet({
perm={Permission.PLAN_DISPATCH} perm={Permission.PLAN_DISPATCH}
fallback={<p className="text-[11px] text-slate-400">你没有分配权限,请让主管确认</p>} fallback={<p className="text-[11px] text-slate-400">你没有分配权限,请让主管确认</p>}
> >
<Button {/**
disabled={submitting || items.length === 0} * ⭐ 确认前顺手问一句**这批带不带福利**(2026-08-19 产品加)。
onClick={() => void confirm()} *
className="w-full gap-1.5" * 福利只影响**此后生成**的话术,而确认之后客服就开始陆续打开这批单 ——
* 这颗按钮是最后一个还来得及的停顿点。
* ⚠️ 它**不是一道闸**:空着照样确认(浮层里主按钮永远可点)。
* ⚠️ 外层这颗按钮只当触发器,`onClick` 交给浮层 —— ⛔ 别在这儿也挂一个
* `void confirm()`:两条路都会确认,而幂等键相同的第二次请求
* 服务端会当重放**静默吃掉**,界面上看着像"点了没反应"。
*/}
<BenefitPopover
value={benefit}
count={items.length}
disabled={items.length === 0}
submitting={submitting}
onConfirm={(b) => {
setBenefit(b);
void confirm(b);
}}
> >
<Button disabled={submitting || items.length === 0} className="w-full gap-1.5">
{submitting ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Check className="h-3.5 w-3.5" />} {submitting ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Check className="h-3.5 w-3.5" />}
确认分配 {items.length} 条 确认分配 {items.length} 条
</Button> </Button>
</BenefitPopover>
</Can> </Can>
)} )}
</div> </div>
......
'use client';
import { useEffect, useState } from 'react';
import { Loader2 } from 'lucide-react';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { cn } from '@/lib/utils';
/**
* 点「确认分配」之前吸附上来的一层 —— **顺手问一句这批带不带福利**。
*
* ═══ 为什么要在这个时刻问 ═══════════════════════════════════════
* 福利只影响**此后生成**的话术。确认之后客服就开始陆续打开这批单,
* 每过一会儿能用上它的人就少一个 —— 所以要问就得在**确认之前**问,
* 而这颗按钮是这条路上最后一个停顿点。
*
* ⚠️ **它不是一道闸**:输入框空着照样能确认(按钮不 disabled)。
* 产品原话「当然不挂也没关系」—— ⛔ 别做成必填,那会把它变成一道门,
* 而门越重人越会去找绕开的路(同 `QuickLogPopover` 上那段)。
*
* ═══ ⚠️ 这里刻意**不**写福利话术的护栏 ═══════════════════════════
* 「不得追加条件 / 期限 / 承诺」那道闸在**生成侧**,⛔ 不在这个输入框。
* 在这儿再写一遍就是第二份规则,两边迟早会漂(同 assistant-prompts 的
* 「同一件事只许有一份」)。这里只用 placeholder 示范**形状** ——
* 一句短的、能直接念出口的话,而不是一段带承诺的文案。
*
* ⚠️ 与 2026-08-15 删掉的那条 `benefit_missing` ⛔ **不是一回事**:
* 那条是**确认之后**在卡片里长出来的黄色引导块(产品判定:确认单里不做福利引导)。
* 这一层在**确认之前**、吸附在按钮上、不占卡片版面、不留残留。
* ⛔ 别因为"福利提醒删过一次"就把这层也删了 —— 删的是位置和形态,不是这件事。
*
* 样式对齐 `plan-detail/quick-log-popover`(这一屏已有的同类形态:跳走之前顺手问一句)。
*/
export function BenefitPopover({
/** 卡片当前的福利文案(模型 `set_benefit` 写过就有值)—— 打开时预填 */
value,
/** 「确认分配 N 条」里的 N */
count,
disabled,
submitting,
/** 触发器 = 那颗确认按钮本体(样式不在这里管) */
children,
/** 带着最终文案去确认。空串 = 不挂福利 */
onConfirm,
}: {
value: string;
count: number;
disabled?: boolean;
submitting?: boolean;
children: React.ReactNode;
onConfirm: (benefit: string) => void;
}) {
const [open, setOpen] = useState(false);
const [text, setText] = useState(value);
// ⚠️ 模型在对话里改了福利(set_benefit)时要跟上 —— ⛔ 别只在挂载时取一次,
// 那样主管说完「带上老客回馈」再点确认,看到的还是空框。
useEffect(() => setText(value), [value]);
const go = () => {
setOpen(false);
onConfirm(text.trim());
};
return (
<Popover
open={open}
onOpenChange={(o) => {
if (disabled || submitting) return;
setOpen(o);
// ⚠️ 关掉时回到卡片当前值,⛔ 别留着他刚敲了一半又反悔的那半句
if (!o) setText(value);
}}
>
<PopoverTrigger asChild>{children}</PopoverTrigger>
<PopoverContent align="center" side="top" className="w-72 p-3">
<div className="space-y-2">
<div>
<p className="text-[12.5px] font-medium text-slate-800">这批带个福利吗</p>
{/* ⚠️ 要写清**它有什么用**:不写的话这就是个没来由的输入框,
而主管对没来由的输入框只有一个反应 —— 跳过。 */}
<p className="mt-0.5 text-[10.5px] leading-snug text-slate-400">
客服打电话时能用上的那句由头。挂上之后,这批人的话术会带着它写。
<span className="text-slate-500">不填也能直接确认。</span>
</p>
</div>
<input
autoFocus
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') go();
}}
// 200 = 落库那份 schema 的上限(`attributes.benefit.text` max(200))——
// ⛔ 别只在这儿放宽:填得进去、确认那一步被打回来,而错在几十秒之后才出现。
maxLength={200}
// ⚠️ 例子示范的是**形状**(短、能直接念),⛔ 不是让他照抄这个活动
placeholder="老客回馈 · 洁牙 8 折"
className="w-full rounded-md border px-2 py-1.5 text-[12.5px] outline-none placeholder:text-slate-300 focus:border-brand-400"
/>
<div className="flex items-center justify-end gap-1.5 pt-0.5">
<button
type="button"
onClick={() => setOpen(false)}
className="rounded-md px-2 py-1 text-[11.5px] text-slate-500 hover:bg-slate-50"
>
取消
</button>
{/* ⚠️ 主按钮**永远可点**(空着 = 不挂福利)—— 见文件头「它不是一道闸」。
⚠️ 文案与外面那颗按钮逐字一致:这一下就是那一下,⛔ 别让他以为还有下一步。 */}
<button
type="button"
disabled={submitting}
onClick={go}
className={cn(
'inline-flex items-center gap-1 rounded-md px-2.5 py-1 text-[11.5px] font-medium text-white transition-colors',
submitting ? 'cursor-not-allowed bg-slate-200' : 'bg-brand-600 hover:bg-brand-700',
)}
>
{submitting && <Loader2 className="h-3 w-3 animate-spin" />}
确认分配 {count}
</button>
</div>
</div>
</PopoverContent>
</Popover>
);
}
...@@ -1572,7 +1572,7 @@ function ExpiryBadge({ expiresAt }: { expiresAt: Date | null }) { ...@@ -1572,7 +1572,7 @@ function ExpiryBadge({ expiresAt }: { expiresAt: Date | null }) {
if (ms <= 0) { if (ms <= 0) {
return ( return (
<span className="inline-flex items-center gap-1 rounded-md bg-rose-50 px-2 py-1 text-[11.5px] font-medium text-rose-600 ring-1 ring-inset ring-rose-200"> <span className="inline-flex items-center gap-1 rounded-md bg-rose-50 px-2 py-1 text-[11.5px] font-medium text-rose-600 ring-1 ring-inset ring-rose-200">
过期 · 即将退回池子 超期
</span> </span>
); );
} }
...@@ -1591,10 +1591,10 @@ function ExpiryBadge({ expiresAt }: { expiresAt: Date | null }) { ...@@ -1591,10 +1591,10 @@ function ExpiryBadge({ expiresAt }: { expiresAt: Date | null }) {
: 'bg-slate-50 text-slate-500 ring-slate-200'; : 'bg-slate-50 text-slate-500 ring-slate-200';
return ( return (
<span <span
title={`${expiresAt.toLocaleString('zh-CN')} 到期后自动退回召回池`} title={`${expiresAt.toLocaleString('zh-CN')} 之前打完;超过之后记为超期,单子仍归你`}
className={`inline-flex items-center gap-1 rounded-md px-2 py-1 text-[11.5px] font-medium ring-1 ring-inset ${tone}`} className={`inline-flex items-center gap-1 rounded-md px-2 py-1 text-[11.5px] font-medium ring-1 ring-inset ${tone}`}
> >
{label}退回 {label}
</span> </span>
); );
} }
......
'use client'; 'use client';
import { useState } from 'react';
import { import {
TEMPERATURE_AXIS_ZH,
TEMPERATURE_META, TEMPERATURE_META,
TEMPERATURE_ORDER, TEMPERATURE_ORDER,
potentialTreatmentItemName, potentialTreatmentItemName,
...@@ -119,6 +119,18 @@ export function PoolMatrix({ ...@@ -119,6 +119,18 @@ export function PoolMatrix({
}) => void; }) => void;
}) { }) {
const showUnknown = data.unknownTotal > 0; const showUnknown = data.unknownTotal > 0;
/**
* ⭐ **正指着哪一格** —— 悬停的行 × 列坐标(2026-08-19 产品要「瞄靶」时一起加的)。
*
* 🔴 为什么要提到这一层、⛔ 不留在 CSS 的 `:hover` 里:
* `:hover` 只够点亮**格子自己**,而这张表 8 行 × 6 列、格子离行头和列头都很远 ——
* 人指着一格时真正要确认的是「这是**哪个治疗** × **哪个时间档**」,
* 那两个字在表的最左和最上。⇒ 坐标必须能传给行标签和列头,那就得是 state。
* ⚠️ 只在**有人可分**的格子上设(`n > 0`):0 格点不动,给它十字等于指了个空。
* ⚠️ 整片 `onMouseLeave` 兜底清空 —— ⛔ 别只靠每个格子的 leave:
* 鼠标从格子直接飞出容器时,leave 有概率被下一个 enter 抢在前面,十字就留在原地不走了。
*/
const [aim, setAim] = useState<{ treatment: string; temperature: TempKey } | null>(null);
return ( return (
<Card className={cn('w-[560px] border-0 shadow-none', className)}> <Card className={cn('w-[560px] border-0 shadow-none', className)}>
{/* {/*
...@@ -137,20 +149,35 @@ export function PoolMatrix({ ...@@ -137,20 +149,35 @@ export function PoolMatrix({
<div className="flex items-center pb-1.5"> <div className="flex items-center pb-1.5">
<div className={cn(LABEL_W, 'shrink-0')} /> <div className={cn(LABEL_W, 'shrink-0')} />
<div className="flex flex-1"> <div className="flex flex-1">
{COLUMNS.map((c) => ( {COLUMNS.map((c) => {
const onAxis = aim?.temperature === c;
return (
<div <div
key={c} key={c}
title={TEMPERATURE_META[c].hint} title={TEMPERATURE_META[c].hint}
className={cn( className={cn(
// ⚠️ whitespace-nowrap:「3 年以上」会折成两行,把整排列头撑高、基线也歪 // ⚠️ whitespace-nowrap:「3 年以上」会折成两行,把整排列头撑高、基线也歪
'flex flex-1 items-center justify-center gap-1 whitespace-nowrap text-[11px] font-semibold', 'flex flex-1 items-center justify-center gap-1 whitespace-nowrap text-[11px] font-semibold',
'transition-opacity duration-150',
HUE[c].text, HUE[c].text,
// ⭐ 命中那一列**保持原样**,其余**退让** —— ⛔ 不是给命中的那列加亮:
// 加亮会让六个列头在悬停时颜色各不相同(色相本身是信息,见 HUE 那段);
// 压暗其余的既不动色相,又让"我在这一列"一眼看出来。
aim && !onAxis && 'opacity-35',
)} )}
> >
<span className={cn('h-1.5 w-1.5 rounded-sm', HUE[c].dot)} /> <span
className={cn(
'rounded-sm transition-all duration-150',
HUE[c].dot,
// 命中列的圆点长大一圈 —— 它是这条轴的"刻度指针"
onAxis ? 'h-2 w-2' : 'h-1.5 w-1.5',
)}
/>
{TEMPERATURE_META[c].zh} {TEMPERATURE_META[c].zh}
</div> </div>
))} );
})}
</div> </div>
{showUnknown && ( {showUnknown && (
<div className="w-12 shrink-0 text-center text-[11px] whitespace-nowrap text-muted-foreground">算不出</div> <div className="w-12 shrink-0 text-center text-[11px] whitespace-nowrap text-muted-foreground">算不出</div>
...@@ -160,17 +187,40 @@ export function PoolMatrix({ ...@@ -160,17 +187,40 @@ export function PoolMatrix({
<div className="flex items-start"> <div className="flex items-start">
{/* 行标签独立成列 —— 它不该压在渐变面上(压上去白底文字会跟着色相变脏) */} {/* 行标签独立成列 —— 它不该压在渐变面上(压上去白底文字会跟着色相变脏) */}
<div className={cn(LABEL_W, 'shrink-0')}> <div className={cn(LABEL_W, 'shrink-0')}>
{data.rows.map((row) => ( {data.rows.map((row) => {
<div key={row.key} className={cn(ROW_H, 'flex items-center text-xs font-medium')}> const onAxis = aim?.treatment === row.key;
return (
<div
key={row.key}
className={cn(
ROW_H,
'flex items-center text-xs font-medium transition-all duration-150',
// ⚠️ 命中那一行**往右挪 2px + 上主色**,其余压暗 —— 位移比变色更早被眼睛捕捉到,
// 而这一列字很短(种植/早矫/拔牙),只变色容易被数字那片抢掉。
onAxis && 'translate-x-0.5 text-brand-700',
aim && !onAxis && 'opacity-35',
)}
>
{potentialTreatmentItemName(row.key)} {potentialTreatmentItemName(row.key)}
</div> </div>
))} );
})}
</div> </div>
{/* ⭐ 1b 的主体:**一整张**渐变面,8×3 个格子透明浮在上面 */} {/* ⭐ 1b 的主体:**一整张**渐变面,8×3 个格子透明浮在上面 */}
<div className={cn('flex-1 overflow-hidden rounded', PLANE)}> <div
className={cn('flex-1 overflow-hidden rounded', PLANE)}
onMouseLeave={() => setAim(null)}
>
{data.rows.map((row) => ( {data.rows.map((row) => (
<MatrixRow key={row.key} row={row} selected={selected} onPick={onPick} /> <MatrixRow
key={row.key}
row={row}
selected={selected}
onPick={onPick}
aim={aim}
onAim={setAim}
/>
))} ))}
</div> </div>
...@@ -193,14 +243,15 @@ export function PoolMatrix({ ...@@ -193,14 +243,15 @@ export function PoolMatrix({
</div> </div>
{/* {/*
⚠️ 2026-08-11 大幅缩短:原来这里要解释"前两档按该治疗自己的周期、后四档按年数"—— 🔴 「每一列 = 患者最后一次到诊距今多久」**搬到面板抬头去了**(2026-08-19 产品:
六档改成同一把尺子之后,那半句话没有了,⛔ 别再写回去。 「不用分那么散」)—— 见 `new-batch.tsx` 的抬头行,那儿已经有一句「点一格 = …」,
⭐ 剩下那半句是**必须留的**:末诊是**患者级**的,同一个人的几个潜在治疗必然落同一档。 两句同为"这一屏怎么读"的说明,分在表格上下两头看着散。
不说清楚,主管会以为"这人三个机会都刚诊断"。⛔ 别省。 ⛔ 别在这里再写回一句:同一件事写第二份,两边会各自漂。
⚠️ 这句话本身**不许消失**(沿革与理由见 `TEMPERATURE_AXIS_ZH` 上那段):
列头写的是「三个月内」,而"什么的三个月"决定了它是哪批人;
「患者」两个字尤其不能丢 —— 末诊是**患者级**的,同一个人的几个潜在治疗
必然落同一列,不说清楚主管会以为"这人三个机会都刚诊断"。
*/} */}
<p className="mt-2 text-[10.5px] leading-relaxed text-muted-foreground">
<span className="font-medium">{TEMPERATURE_AXIS_ZH}距今多久</span>分档
</p>
{data.note && ( {data.note && (
<p className="mt-1.5 rounded bg-amber-50 px-2 py-1.5 text-[10.5px] leading-relaxed text-amber-800"> <p className="mt-1.5 rounded bg-amber-50 px-2 py-1.5 text-[10.5px] leading-relaxed text-amber-800">
...@@ -216,6 +267,8 @@ function MatrixRow({ ...@@ -216,6 +267,8 @@ function MatrixRow({
row, row,
selected, selected,
onPick, onPick,
aim,
onAim,
}: { }: {
row: PoolMatrixRow; row: PoolMatrixRow;
selected?: { treatment: string; temperature: TempKey } | null; selected?: { treatment: string; temperature: TempKey } | null;
...@@ -226,6 +279,9 @@ function MatrixRow({ ...@@ -226,6 +279,9 @@ function MatrixRow({
count: number; count: number;
rect: { x: number; y: number; w: number; h: number }; rect: { x: number; y: number; w: number; h: number };
}) => void; }) => void;
/** 正指着哪一格(见 PoolMatrix 里那段) */
aim: { treatment: string; temperature: TempKey } | null;
onAim: (a: { treatment: string; temperature: TempKey } | null) => void;
}) { }) {
// ⛔ 中文一律查 labels.ts,别在组件里另写一张表 —— 改措辞要即时生效、且全站一处。 // ⛔ 中文一律查 labels.ts,别在组件里另写一张表 —— 改措辞要即时生效、且全站一处。
// 用**项目名**(种植 / 早矫 / 拔牙)而不是卡片措辞(种植治疗): // 用**项目名**(种植 / 早矫 / 拔牙)而不是卡片措辞(种植治疗):
...@@ -236,11 +292,16 @@ function MatrixRow({ ...@@ -236,11 +292,16 @@ function MatrixRow({
{COLUMNS.map((c) => { {COLUMNS.map((c) => {
const n = row.counts[c] ?? 0; const n = row.counts[c] ?? 0;
const isSelected = selected?.treatment === row.key && selected.temperature === c; const isSelected = selected?.treatment === row.key && selected.temperature === c;
// 十字:同一行 或 同一列(交点就是准星那一格)
const onCross = aim != null && (aim.treatment === row.key || aim.temperature === c);
const isAimed = aim?.treatment === row.key && aim.temperature === c;
return ( return (
<button <button
key={c} key={c}
type="button" type="button"
disabled={n === 0} disabled={n === 0}
// ⚠️ 只有**能点**的格子才设瞄点:0 格点不动,给它十字等于指了个空
onMouseEnter={() => n > 0 && onAim({ treatment: row.key, temperature: c })}
onClick={(e) => { onClick={(e) => {
const r = e.currentTarget.getBoundingClientRect(); const r = e.currentTarget.getBoundingClientRect();
onPick({ onPick({
...@@ -266,11 +327,35 @@ function MatrixRow({ ...@@ -266,11 +327,35 @@ function MatrixRow({
// 会把带 var() 的任意值解析成"只有颜色的阴影",偏移和扩散全丢, // 会把带 var() 的任意值解析成"只有颜色的阴影",偏移和扩散全丢,
// 实测算出来是 `oklab(0 0 0 / 0) 0 0 0 0 inset` —— 完全透明,**看不出坏了**。 // 实测算出来是 `oklab(0 0 0 / 0) 0 0 0 0 inset` —— 完全透明,**看不出坏了**。
// 负 offset 让描边画在格子内侧,与相邻格子不互相挤压(设计稿也是这么写的)。 // 负 offset 让描边画在格子内侧,与相邻格子不互相挤压(设计稿也是这么写的)。
'transition-[background-color,opacity,transform] duration-150',
n === 0 n === 0
? 'cursor-default text-slate-900/35' ? 'cursor-default text-slate-900/35'
// ⚠️ 必须带 `outline-solid`:基态的 `outline-none` 把 outline-style 钉成 none, // ⚠️ 必须带 `outline-solid`:基态的 `outline-none` 把 outline-style 钉成 none,
// 只加 `outline-2` 只改宽度 —— 实测算出来是 width:2px 但 style:none,**一条线都不画**。 // 只加 `outline-2` 只改宽度 —— 实测算出来是 width:2px 但 style:none,**一条线都不画**。
: 'cursor-pointer font-medium outline-none hover:outline-solid hover:outline-2 hover:-outline-offset-2 hover:outline-brand-600 focus-visible:outline-solid focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-brand-600', // 🔴 hover 那一圈**换成角括号准星**(`.pac-reticle`,globals.css)——
// 实线框留给「已经选中」,两个状态长得一样就分不出"正指着"和"已选了"。
// focus-visible **仍走实线 outline**:键盘可达性⛔ 不能只靠一段动画。
: 'cursor-pointer font-medium outline-none focus-visible:outline-solid focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-brand-600',
/**
* ⭐ 十字那层**薄纱**。
* ⚠️ 与文件头「hover ⛔ 别换底色,会在渐变面上戳一个洞」⛔ 不冲突:
* 那条管的是**一个格子**单独换底 —— 一个亮块浮在连续面上确实是个洞。
* 而这里是**一整行 + 一整列**,读出来是一个十字,不是一个洞;
* 且它是**瞬时交互态**,鼠标一走就没,⛔ 从不由数量驱动(那条纪律没动)。
* ⚠️ 白纱 8% 是上限:再浓就把底下"由烫到冷"那条轴洗白了。
*/
onCross && !isAimed && 'bg-white/8',
/**
* 准星那一格。
* ⚠️ `pac-reticle` **由 state 挂,⛔ 不写成 CSS 的 `:hover::after`**:
* ① 0 格是 `disabled`,而 disabled 元素的 hover 行为跨浏览器不一致 ——
* 走 state 才能保证"点不动的格子不给准星";
* ② 准星和十字必须**同生同灭**,同一个来源才不会一个亮一个不亮。
* ⚠️ 数字**微微抬起** —— `nums` 是等宽数字,放大不会把邻居推歪。
*/
isAimed && 'pac-reticle scale-[1.06] bg-white/12',
// 其余整片退让 —— ⚠️ 只压**不在十字上**的,压全部等于什么都没压
aim && !onCross && 'opacity-45',
isSelected && 'outline-solid outline-2 -outline-offset-2 outline-brand-600', isSelected && 'outline-solid outline-2 -outline-offset-2 outline-brand-600',
)} )}
> >
......
...@@ -234,7 +234,7 @@ export function BatchTracking({ clinicId }: { clinicId: string | null }) { ...@@ -234,7 +234,7 @@ export function BatchTracking({ clinicId }: { clinicId: string | null }) {
<thead> <thead>
<tr className="sticky top-0 z-10 bg-slate-50 shadow-[inset_0_-1px_0_#E2E8F0]"> <tr className="sticky top-0 z-10 bg-slate-50 shadow-[inset_0_-1px_0_#E2E8F0]">
<th className="px-3.5 py-1.5 text-left text-[11px] font-medium text-slate-500">批次</th> <th className="px-3.5 py-1.5 text-left text-[11px] font-medium text-slate-500">批次</th>
{['条数', '已处置', '没动', '退回', '到期回池', '约上'].map((h) => ( {['条数', '已处置', '没动', '退回', '超期', '约上'].map((h) => (
<th <th
key={h} key={h}
className="whitespace-nowrap px-2.5 py-1.5 text-right text-[11px] font-medium text-slate-500" className="whitespace-nowrap px-2.5 py-1.5 text-right text-[11px] font-medium text-slate-500"
...@@ -445,7 +445,7 @@ function BatchDrawer({ id, onClose }: { id: string; onClose: () => void }) { ...@@ -445,7 +445,7 @@ function BatchDrawer({ id, onClose }: { id: string; onClose: () => void }) {
</div> </div>
{d && ( {d && (
<div className="nums mt-0.5 text-[11px] text-slate-600"> <div className="nums mt-0.5 text-[11px] text-slate-600">
已处置 {d.handled} · 没动 {d.inHand} · 退回 {d.released} · 到期回池 {d.expired} · 已处置 {d.handled} · 没动 {d.inHand} · 退回 {d.released} · 超期 {d.expired} ·
约上 {d.booked} 约上 {d.booked}
</div> </div>
)} )}
......
...@@ -106,8 +106,17 @@ export function NewBatchPanel({ clinicId }: { clinicId: string | null }) { ...@@ -106,8 +106,17 @@ export function NewBatchPanel({ clinicId }: { clinicId: string | null }) {
<div className="flex w-full flex-col overflow-hidden rounded-lg border bg-white"> <div className="flex w-full flex-col overflow-hidden rounded-lg border bg-white">
<div className="flex flex-none flex-wrap items-center gap-x-2 gap-y-1 border-b px-3.5 py-2.5"> <div className="flex flex-none flex-wrap items-center gap-x-2 gap-y-1 border-b px-3.5 py-2.5">
<span className="text-[13.5px] font-semibold text-slate-900">分一批新的</span> <span className="text-[13.5px] font-semibold text-slate-900">分一批新的</span>
{/*
⭐ 两句都是「这一屏怎么读」——**并到同一行**(2026-08-19 产品:「不用分那么散」)。
「每一列 = …」原来挂在表格**下面**(见 `pool-matrix.tsx` 那段注释),
一句在表上、一句在表下,同一类说明被表格劈成两半。
⚠️ 用 `·` 分隔:这一页别处也是这么并的(「1 个诊所 · 主管」「33 位在岗 · 在手 0 条」)。
⚠️ 两句**都不加粗**:它们是同一条灰色说明带,只加粗其中一句会读成"这句更要紧"。
⛔ 「每一列 = …」这句不许省(沿革见 `TEMPERATURE_AXIS_ZH`):列头写的是「三个月内」,
而"什么的三个月"决定了它是哪批人;「患者」两个字尤其不能丢 —— 末诊是**患者级**的。
*/}
<span className="text-[11px] text-slate-400"> <span className="text-[11px] text-slate-400">
点一格 = 把这批人交给助手,助手出确认单 点一格 = 把这批人交给助手,助手出确认单 · 每一列 = {TEMPERATURE_AXIS_ZH}距今多久
</span> </span>
</div> </div>
{/* ⚠️ 小屏兜底:矩阵有六列,窄了就横向滚,⛔ 别让它压缩到看不清数字 */} {/* ⚠️ 小屏兜底:矩阵有六列,窄了就横向滚,⛔ 别让它压缩到看不清数字 */}
......
...@@ -16,14 +16,17 @@ const WINDOWS = [ ...@@ -16,14 +16,17 @@ const WINDOWS = [
* 团队现在什么状态 —— 主管工作台右栏。 * 团队现在什么状态 —— 主管工作台右栏。
* *
* ── 一张表里混着两种时间性,这是最容易读错的地方 ──────────────── * ── 一张表里混着两种时间性,这是最容易读错的地方 ────────────────
* · **当前在手** —— 此刻手上还压着多少,与窗口无关; * · **当前在手 / 当前超期** —— 此刻的状态,与窗口无关;
* · **超期 / 完成 / 退回率 / 没动** —— 都在选中的那个窗口里,换窗口一起变。 * · **完成 / 退回率 / 没动** —— 都在选中的那个窗口里,换窗口一起变。
* ⚠️ 所以表头第一列写死「**当前**在手」。只写「在手」时实测会被读成"这 7 天分了 62 条"。 * ⚠️ 所以这两列都写死「**当前**」。只写「在手」时实测会被读成"这 7 天分了 62 条",
* 而「超期」如果不带「当前」,摆在窗口切换器正下方就会被当成"这 7 天超了几条"。
* *
* 🔴 「超期」是**窗口口径**(2026-08-07 实测推翻了"当前超期"): * 🔴 「超期」2026-08-19 **由窗口口径改回此刻口径**(到期不再回池,见 plan.module 的墓碑):
* 到期回收器每 10 分钟扫一遍,过期的单当场被收走 —— "当前还压在手上且已过期" * · 2026-08-07 当初改成窗口口径,是因为回收器每 10 分钟把过期的单收走,
* 结构上几乎永远是 0(实测账本 378 条到期回收 vs 当前在手已过期 0 条)。 * "当前还压在手上且已过期"结构上永远是 0;
* 摆那个数上去,主管会以为团队从不超期。 * · 回收器删掉之后这个前提没了 —— 超期的单就留在他手上,此刻口径才是有东西的那个。
* ⚠️ 而窗口那一支数的是**已经回池、没有客服挂着**的单,那不是谁的超期。
* 实测:界面上「超期 41 / 193」100% 来自它,真正在手且过时限的是 0 条。
* *
* ⚠️ 退回率那行小字是**服务端拼好的**(`rateNote`),⛔ 前端别自己算: * ⚠️ 退回率那行小字是**服务端拼好的**(`rateNote`),⛔ 前端别自己算:
* 它必须带两个分母(「退回 3 / 已处置 21 = 14.3%,另有 2 条没动」)—— * 它必须带两个分母(「退回 3 / 已处置 21 = 14.3%,另有 2 条没动」)——
...@@ -55,6 +58,15 @@ export function TeamStatus({ clinicId }: { clinicId: string | null }) { ...@@ -55,6 +58,15 @@ export function TeamStatus({ clinicId }: { clinicId: string | null }) {
const rows = data?.agents ?? []; const rows = data?.agents ?? [];
const inHandTotal = rows.reduce((s, a) => s + a.inHand, 0); const inHandTotal = rows.reduce((s, a) => s + a.inHand, 0);
/** /**
* ⭐ **全队超期合计** —— 2026-08-19 加(到期不再回池,超期从此只增不减,见 plan.module 的墓碑)。
*
* 🔴 在此之前超期只逐人写在表格里,而主管扫这一屏时先看的是抬头那行 ——
* 一个人 41 条不吓人,全队 196 条是另一回事,而后者原来**要他自己把 18 行加起来**。
* ⚠️ 挂在「在手」后面、不另起一行:产品定「在乎的是总量和超期」,
* ⛔ 别把在手拆成"时限内 / 超期"两个数 —— 压在他手上的就是压在他手上的。
*/
const overdueTotal = rows.reduce((s, a) => s + a.overdue, 0);
/**
* 「超期最多」的判据。 * 「超期最多」的判据。
* 🔴 **并列第一时一个都不标**(2026-08-07 实测:17 位客服全是 23 条,结果每一行都挂着 * 🔴 **并列第一时一个都不标**(2026-08-07 实测:17 位客服全是 23 条,结果每一行都挂着
* 「超期最多」—— 标一片等于没标,而且看着像系统坏了)。 * 「超期最多」—— 标一片等于没标,而且看着像系统坏了)。
...@@ -70,6 +82,10 @@ export function TeamStatus({ clinicId }: { clinicId: string | null }) { ...@@ -70,6 +82,10 @@ export function TeamStatus({ clinicId }: { clinicId: string | null }) {
<span className="text-[13.5px] font-semibold text-slate-900">团队现在什么状态</span> <span className="text-[13.5px] font-semibold text-slate-900">团队现在什么状态</span>
<span className="nums text-[11px] text-slate-400"> <span className="nums text-[11px] text-slate-400">
{rows.length} 位在岗 · 在手 {inHandTotal} {rows.length} 位在岗 · 在手 {inHandTotal}
{overdueTotal > 0 && (
// ⚠️ 上琥珀、跟「超期」那一列同一支色 —— 同一件事在一屏里⛔ 不许有两个颜色
<span className="text-amber-700"> · {overdueTotal} 条已超期</span>
)}
</span> </span>
<span className="ml-auto inline-flex rounded-lg bg-slate-100 p-0.5"> <span className="ml-auto inline-flex rounded-lg bg-slate-100 p-0.5">
{WINDOWS.map((w) => ( {WINDOWS.map((w) => (
...@@ -114,8 +130,10 @@ export function TeamStatus({ clinicId }: { clinicId: string | null }) { ...@@ -114,8 +130,10 @@ export function TeamStatus({ clinicId }: { clinicId: string | null }) {
<th className="whitespace-nowrap px-2.5 py-1.5 text-right text-[11px] font-medium text-slate-500"> <th className="whitespace-nowrap px-2.5 py-1.5 text-right text-[11px] font-medium text-slate-500">
当前在手 当前在手
</th> </th>
{/* ⚠️ 「当前」两个字与左边那列同理 —— 它紧挨在窗口切换器下面,
不写就会被读成"这 7 天超了几条"(2026-08-19 改回此刻口径) */}
<th className="whitespace-nowrap px-2.5 py-1.5 text-right text-[11px] font-medium text-slate-500"> <th className="whitespace-nowrap px-2.5 py-1.5 text-right text-[11px] font-medium text-slate-500">
超期 当前超期
</th> </th>
<th className="whitespace-nowrap px-2.5 py-1.5 text-right text-[11px] font-medium text-slate-500"> <th className="whitespace-nowrap px-2.5 py-1.5 text-right text-[11px] font-medium text-slate-500">
完成 完成
...@@ -157,6 +175,17 @@ export function TeamStatus({ clinicId }: { clinicId: string | null }) { ...@@ -157,6 +175,17 @@ export function TeamStatus({ clinicId }: { clinicId: string | null }) {
)} )}
> >
{a.overdue} {a.overdue}
{/**
* ⭐ **压了多久**跟在条数下面(2026-08-19 加)。
* 🔴 光给条数答不了「这事有多急」:昨天刚过时限的 41 条,
* 和压了 12 天的 41 条,主管该做的事完全不同。
* ⚠️ `> 0` 才显示:今天刚过时限的是 0 天,写「最久 0 天」是废话一行。
*/}
{a.overdueOldestDays != null && a.overdueOldestDays > 0 && (
<div className="text-[10.5px] leading-snug font-normal text-amber-700/70">
最久 {a.overdueOldestDays}
</div>
)}
</td> </td>
<td className="nums px-2.5 py-2 text-right text-emerald-700">{a.done}</td> <td className="nums px-2.5 py-2 text-right text-emerald-700">{a.done}</td>
<td className="px-3.5 py-2 text-right"> <td className="px-3.5 py-2 text-right">
......
...@@ -243,13 +243,20 @@ ...@@ -243,13 +243,20 @@
#### `daily_overload` 是**工作量预估**,不是异常告警 #### `daily_overload` 是**工作量预估**,不是异常告警
N 按「在岗 × 每天 15 通 × 时限」算,**只算新增、⛔ 不扣在手** —— 所以只要谁手上还有东西, N 按「在岗 × 每天 15 通 × 时限」算,**只算新增、⛔ 不扣在手** —— 而这个节点判的是
需要的天数就会超过时限,这个节点**每批基本都会亮****这是对的**(产品定 2026-08-12): 「分完之后**手上总量**(在手 + 本批)要打几天」,所以谁手上还压着旧单,就可能超。
每一批本来就该把「这活多大、要打几天」讲清楚,主管据此决定延时限、调每日通数、还是减量。 **超了不是故障**(产品定 2026-08-12):每一批本来就该把「这活多大、要打几天」讲清楚,
主管据此决定延时限、调每日通数、还是减量。
> ⚠️ **亮的频率会随两件事变,⛔ 别把某一时期的频率当成这个节点的性质。**
> · 默认时限:2026-08-12~08-19 是 **1 天**(阈值 `15 × 1 = 15 条`),08-19 改回 **3 天**(阈值 45 条);
> · **到期是否回池**:2026-08-19 起**不再回池**,超期的单继续挂在客服手上 ⇒ 在手只增不减,
> 于是这个节点又回到「常亮」。**这仍然是对的** —— 它报的是真实负载,而那个负载确实没消失。
> ⇒ 下面两条硬要求**与频率无关**,是措辞和口径本身的要求。
由此推出两条硬要求: 由此推出两条硬要求:
1. **措辞不许像报错。** ⛔ 不出现「打不完 / 超了 / 过载」——它常亮,说成故障主管就会 1. **措辞不许像报错。** ⛔ 不出现「打不完 / 超了 / 过载」——超期是常态不是故障,说成故障主管就会
开始怀疑系统而不是做决定。 开始怀疑系统而不是做决定。
2. **数字必须拆开**:「手上共 60 条」要写成「本批 45 + 原本在手 15」。 2. **数字必须拆开**:「手上共 60 条」要写成「本批 45 + 原本在手 15」。
不拆开主管会读成"这批一下压了 60 条给他"(增量与总量分不开是同类问题里最常犯的)。 不拆开主管会读成"这批一下压了 60 条给他"(增量与总量分不开是同类问题里最常犯的)。
......
...@@ -131,14 +131,23 @@ export const DAILY_CALLS_PER_AGENT = 15; ...@@ -131,14 +131,23 @@ export const DAILY_CALLS_PER_AGENT = 15;
/** /**
* 批次时限的默认天数。 * 批次时限的默认天数。
* *
* 🔴 2026-08-12 由 3 改为 **1**(产品定):走「**每天推一天的活**」的节奏 —— * ⚠️ **改这个数会连动三处**,⛔ 别只改这里:
* N = 在岗人数 × 每天 ${DAILY_CALLS_PER_AGENT} 通 × 时限,D=1 时正好是一天的量, * ① 本批人数 N = 在岗人数 × 每天 ${DAILY_CALLS_PER_AGENT} 通 × D —— 时限是乘数,
* 当天没打完的自动落回池子、明天重排。 * D 变一倍,一批推的人数就变一倍(`assignment-proposal.service` 的注释里有同一句);
* ⚠️ 连带后果(已知并接受):`daily_overload` 的阈值随之变成 `15 × 1`, * ② `daily_overload` 的阈值 = 每天几通 × D —— 判「谁超期」的那把尺跟着变;
* 于是**只要谁手上还有旧单,这个节点就会亮**。那不是 bug —— * ③ 对外文档里的示例式子(`assignment-agent.mdx`)—— 那些数是算给主管看的,
* 它是工作量预估,每批本来就该讲清楚(见 assignment-signals 里该节点的注释)。 * 改了默认值而不改示例,文档就在教一个不存在的口径。
*/ *
export const ASSIGNMENT_EXPIRES_DAYS_DEFAULT = 1; * ── 沿革 ──────────────────────────────────────────────────────
* 2026-08-12 3 → **1**(产品定):走「每天推一天的活」的节奏,当天没打完的落回池子、明天重排。
* 2026-08-19 1 → **3**(产品定):改回三天一批。
* ⚠️ 于是 08-12 那条「连带后果」也跟着回去了 —— 当时的原话是
* 「D=1 ⇒ 阈值 15×1 ⇒ **只要谁手上还有旧单,这个节点就会亮**,那不是 bug」。
* D=3 之后阈值是 45 条,`daily_overload` 回到「真的压不下」才亮。
* ⛔ 别把那句"每批都会亮是对的"当成仍然成立的现状写进新文档 ——
* 它是 D=1 那一版的推论,不是这个节点的性质。
*/
export const ASSIGNMENT_EXPIRES_DAYS_DEFAULT = 3;
/** /**
* 卡片上时限可选的天数。**给全部 1-7 天**,不是 3/5/7 三档。 * 卡片上时限可选的天数。**给全部 1-7 天**,不是 3/5/7 三档。
* *
...@@ -256,7 +265,7 @@ export const AssignmentBriefSchema = z.object({ ...@@ -256,7 +265,7 @@ export const AssignmentBriefSchema = z.object({
* 到期多 → 派多了 / 时限太紧 / 人不在岗。 * 到期多 → 派多了 / 时限太紧 / 人不在岗。
* 合成一个"回池率"两种病都看不出来。 * 合成一个"回池率"两种病都看不出来。
*/ */
expired: z.number().int().describe('到期自动回池(客服没动)'), expired: z.number().int().describe('超期未处置的条数(单子仍在客服手上)'),
agents: z.number().int().describe('涉及几个客服(取自账本)'), agents: z.number().int().describe('涉及几个客服(取自账本)'),
/** /**
* 已处理条数 —— 判据是**池子状态**(出池 / 被抑制),不是回写。 * 已处理条数 —— 判据是**池子状态**(出池 / 被抑制),不是回写。
...@@ -1097,8 +1106,9 @@ export type SheetEditOp = z.infer<typeof SheetEditOpSchema>; ...@@ -1097,8 +1106,9 @@ export type SheetEditOp = z.infer<typeof SheetEditOpSchema>;
* 🔴 **团队现在什么状态** —— 主管工作台右栏 / `getAgentWorkload`。 * 🔴 **团队现在什么状态** —— 主管工作台右栏 / `getAgentWorkload`。
* *
* ⚠️⚠️ **一张表里混着两种时间性**,界面必须写明,否则主管会把「在手 62」读成"这 7 天分了 62 条": * ⚠️⚠️ **一张表里混着两种时间性**,界面必须写明,否则主管会把「在手 62」读成"这 7 天分了 62 条":
* · `inHand` —— **此刻**的状态(唯一一个),表头要写「**当前**在手」; * · `inHand` / `overdue` / `overdueOldestDays` —— **此刻**的状态,表头要写「**当前**在手」;
* · `overdue` / `done` / `released` / `handled` / `idle` —— **窗口内**发生的事,换窗口一起变。 * ⚠️ `overdue` 2026-08-19 从"窗口内"改成"此刻"(到期不再回池,它就是在手的一个子集)。
* · `done` / `released` / `handled` / `idle` —— **窗口内**发生的事,换窗口一起变。
* ⚠️ 名册与分配那边**同一个来源**(AgentRosterService)—— 分配问「还能吃多少」、 * ⚠️ 名册与分配那边**同一个来源**(AgentRosterService)—— 分配问「还能吃多少」、
* 跟踪问「手上压了多少」,是同一份数据的两种读法(T10)。⛔ 别另查一份。 * 跟踪问「手上压了多少」,是同一份数据的两种读法(T10)。⛔ 别另查一份。
*/ */
...@@ -1108,17 +1118,27 @@ export const AgentWorkloadRowSchema = z.object({ ...@@ -1108,17 +1118,27 @@ export const AgentWorkloadRowSchema = z.object({
/// **当前**手上还压着多少 —— 这一列是唯一的"此刻"口径(跨诊所,与 AgentInfo.inHand 同源) /// **当前**手上还压着多少 —— 这一列是唯一的"此刻"口径(跨诊所,与 AgentInfo.inHand 同源)
inHand: z.number().int(), inHand: z.number().int(),
/** /**
* **窗口内**超期 = 这段时间里到期没人动、被收回池子的条数。 * 超期 = **此刻挂在他手上、且过了时限**的条数。
* *
* 🔴 ⛔ **不是"当前还压在手上且已过期"**(2026-08-07 实测推翻): * 🔴 **已经回到池子里的⛔ 不算**(2026-08-19 产品定):超期的意思是「**有人**该打没打」——
* 到期回收器每 10 分钟扫一遍,过期的单当场被收走 —— 那个口径**结构上几乎永远是 0** * 没有客服挂着的单不是谁的超期,主管对它也做不了任何事。
* (实测:账本 378 条到期回收,而"当前在手已过期" 0 条)。摆上去是个常年为 0 的死数, * ⚠️ 原来这一列还并着账本里 `auto_release/assignment_expired` 那一支(回收器时代
* 主管会以为团队从不超期。 * 被收走的单)。实测:界面上「超期 41 / 193」**100% 来自那一支**,而真正在手
* ⚠️ 归属回捞自"到期前最后一次 assign" —— auto_release 事件本身不带人(释放后无人归属)。 * 且过时限的是 0 条 —— 一屏数字全是幻觉,且不报错。⇒ 那一支已删。
* ⚠️ **约了下次回访的不算**:回收器刻意跳过它们,那是客服动过了的证据。 * ⚠️ **约了下次回访的不算**:那是客服动过了的证据。不排掉的话,
* 不排掉的话,打了电话、约好下次的人反而被显示成"压着单没动"。 * 打了电话、约好下次的人反而被显示成"压着单没动"。
* ⚠️ 这一列因此和 `inHand` 一样是**此刻**口径,⛔ 不随窗口变 —— 表头别写成"窗口内"。
*/ */
overdue: z.number().int(), overdue: z.number().int(),
/**
* 最久的那条超期了多少天(没有超期的给 null)。
*
* 🔴 光给条数**答不了主管真正要问的那句**「这事有多急」——
* 昨天刚过时限的 41 条,和压了 12 天的 41 条,该做的事完全不同。
* ⚠️ 只看**此刻仍在他手上**的那些:历史上被回收走的单已经不在他桌上了,
* 把它们的"压了多久"算进来是在说一件早就结束的事。
*/
overdueOldestDays: z.number().int().nullable(),
/// 窗口内写过通话结果的条数(按单去重) /// 窗口内写过通话结果的条数(按单去重)
done: z.number().int(), done: z.number().int(),
/// 窗口内主动退回的条数(走账本,⛔ 不读 followup_plans.release_reason —— 那是当前值会被清) /// 窗口内主动退回的条数(走账本,⛔ 不读 followup_plans.release_reason —— 那是当前值会被清)
......
...@@ -123,10 +123,13 @@ export const COLD_TEMPERATURES: readonly TemperatureValue[] = [ ...@@ -123,10 +123,13 @@ export const COLD_TEMPERATURES: readonly TemperatureValue[] = [
]; ];
/** /**
* 矩阵 Y 轴的**轴名** —— 列头上方那句「按 X 距今多久分档」用它。 * 时间那根轴的**轴名** —— 矩阵表格下面那句「每一列 = X 距今多久」用它。
* *
* ⚠️ 这不是装饰:列头写的是「三个月内」,而"什么的三个月"决定了它是哪批人。 * ⚠️ 这不是装饰:列头写的是「三个月内」,而"什么的三个月"决定了它是哪批人。
* ⛔ 别把这句话从界面上拿掉。 * ⛔ 别把这句话从界面上拿掉。
* ⚠️ 「患者」两个字是这句里最要紧的:末诊是**患者级**的,同一个人的几个潜在治疗
* 必然落同一列 —— 去掉就变成"这一格治疗三个月内",主管会以为这人刚诊断。
* ⛔ 界面上⛔ 不说「分档」:那是内部说法,主管看到的是一张表,他要的是"这些列各是什么"。
*/ */
export const TEMPERATURE_AXIS_ZH = '患者最后一次到诊'; export const TEMPERATURE_AXIS_ZH = '患者最后一次到诊';
......
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