Commit 6a1bee6f by luoqi

refactor(术语): 时效 → 时限,全线统一 213 处 —— 但三类同名不同义的不动

产品定的口径是「时限」(截止期限),而界面此前 63 处全写「时效」。
提示词第②层的铁律是「使用者的词汇表 = 他在界面上见过的那些」⇒ 界面、提示词、
工具描述、文档必须同时改,只改一处等于制造新的漂移。

改了 35 个文件 / 213 处:界面文案 · 提示词正文 · 工具描述 · 枚举 labelZh · 注释。
 字段名一律不动(expiresInDays / set_expiry / assignment_expires_at)——
  那是接口与数据库契约,跟着中文改会连累落库与老数据。

️ 跳过 7 个文件 —— 那里的「时效」是**别的意思**,连坐会改出假语义:
  · tone.ts:「urgent = 有时效紧迫」是话术**语气**,不是期限
  · clinical-signals / persona-feature-specs / signal-extractor / contraindication:
    临床禁忌的「时效」= 有效期(妊娠 280 天、哺乳 180 天、放疗终身)
  (enums 里那条 `deadline_too_tight` 的注释早就警告过「同名不同义是统计事故的
    标准配方」—— 这次正是同一类风险,只是方向反过来。)

提示词正文变了 ⇒ 按纪律 bump `ASSISTANT_PROMPT_VERSION` → assistant@2026-08-16-b。
packages/types 改了 src ⇒ 重建 dist(否则 tsc 绿、一跑就炸)。

验证:service tsc + web tsc 通过;jest 85 套 1330 例全过。
parent df0d263d
...@@ -11,7 +11,7 @@ ...@@ -11,7 +11,7 @@
* (不过滤会把 auto_release 的 timeout/assignment_expired 和 feedback 的 up/down 混进来) * (不过滤会把 auto_release 的 timeout/assignment_expired 和 feedback 的 up/down 混进来)
* · 边界:全退回 / 全到期 / 零转化 / 单人独吞 * · 边界:全退回 / 全到期 / 零转化 / 单人独吞
* *
* ⛔⛔ **绝对不许拿它的数去调任何默认值**(容量 50、时 3 天、专属优先策略、批次规模 100)。 * ⛔⛔ **绝对不许拿它的数去调任何默认值**(容量 50、时 3 天、专属优先策略、批次规模 100)。
* T20 的自优化要的是"真实客服在真实压力下怎么反应",而这里的每一个数都是我按 * T20 的自优化要的是"真实客服在真实压力下怎么反应",而这里的每一个数都是我按
* `--release-rate` 之类的参数**编出来的** —— 拿它反推 = 自己教自己。 * `--release-rate` 之类的参数**编出来的** —— 拿它反推 = 自己教自己。
* 而且比没有数据更危险:假信号会驱动你真的去改策略,然后错的策略顶着"数据支持"的名义跑起来。 * 而且比没有数据更危险:假信号会驱动你真的去改策略,然后错的策略顶着"数据支持"的名义跑起来。
......
...@@ -663,7 +663,7 @@ export class PlanScriptOrchestrator { ...@@ -663,7 +663,7 @@ export class PlanScriptOrchestrator {
// 临时:hardcoded jvs-dw 诊所字典(TODO #56 接 host 字典或新建 clinics 表) // 临时:hardcoded jvs-dw 诊所字典(TODO #56 接 host 字典或新建 clinics 表)
// ⚠️ 直接吐 UUID 进 prompt 会让 LLM 编造"XX 客服中心",必须翻译成中文名 // ⚠️ 直接吐 UUID 进 prompt 会让 LLM 编造"XX 客服中心",必须翻译成中文名
clinicName: resolveClinicName(plan.targetClinicId), clinicName: resolveClinicName(plan.targetClinicId),
// 福利:只有批次仍 confirmed 时才带。⚠️ 不判 expiresAt —— 时是"客服什么时候该打完", // 福利:只有批次仍 confirmed 时才带。⚠️ 不判 expiresAt —— 时是"客服什么时候该打完",
// 不是"福利什么时候失效";福利本身的有效期写在文案里(如"8月…"),由主管负责。 // 不是"福利什么时候失效";福利本身的有效期写在文案里(如"8月…"),由主管负责。
benefit: benefit:
plan.assignment?.status === 'confirmed' plan.assignment?.status === 'confirmed'
......
...@@ -28,7 +28,7 @@ import { Permission } from '@pac/types'; ...@@ -28,7 +28,7 @@ import { Permission } from '@pac/types';
* ⛔ 同一件事**只许有一份**。写第二份等于开了两个事实源,两边会各自漂移 * ⛔ 同一件事**只许有一份**。写第二份等于开了两个事实源,两边会各自漂移
* (2026-08-13 实测:名册那句提示词写「是近似值」,工具描述写「只报有/无,不判在岗」, * (2026-08-13 实测:名册那句提示词写「是近似值」,工具描述写「只报有/无,不判在岗」,
* 后者才是实现 —— 而改实现的人只会去改工具描述,没人想得起提示词里还有一份)。 * 后者才是实现 —— 而改实现的人只会去改工具描述,没人想得起提示词里还有一份)。
* 2026-08-14 又清一处:「不传就按在岗 × 每天几通 × 时估」在提示词和 `targetCount` * 2026-08-14 又清一处:「不传就按在岗 × 每天几通 × 时估」在提示词和 `targetCount`
* / `dailyCalls` 两个参数说明里各有一份,且提示词那份把 15 硬编码进了字符串。 * / `dailyCalls` 两个参数说明里各有一份,且提示词那份把 15 硬编码进了字符串。
* ⛔ 结构上已经不可能发生的事不写。死条文不是无害的:规则越多每条被遵守的概率越低 * ⛔ 结构上已经不可能发生的事不写。死条文不是无害的:规则越多每条被遵守的概率越低
* (实测:给了四条排版规则,`###` 一个都没出),它在挤占真正要紧那几条的注意力。 * (实测:给了四条排版规则,`###` 一个都没出),它在挤占真正要紧那几条的注意力。
...@@ -85,7 +85,7 @@ import { Permission } from '@pac/types'; ...@@ -85,7 +85,7 @@ import { Permission } from '@pac/types';
* *
* 🔴 **⛔ 别把内部键名写进提示词。** 2026-08-13 实测:提示词里点名了 * 🔴 **⛔ 别把内部键名写进提示词。** 2026-08-13 实测:提示词里点名了
* 「这一格还能怎么选」「本批人数还能怎么调」,于是判据把这两组挡掉、事实里根本没有它们时, * 「这一格还能怎么选」「本批人数还能怎么调」,于是判据把这两组挡掉、事实里根本没有它们时,
* 模型仍然照着名字**补了一段出来**(还顺手编了「改时只能减少」这种假话)。 * 模型仍然照着名字**补了一段出来**(还顺手编了「改时只能减少」这种假话)。
* *
* 🔴 **⛔ 别把 UI 部件名写进 ①②③。** 「卡片」「按钮」会随前端改,而这三层是两条链路共用的。 * 🔴 **⛔ 别把 UI 部件名写进 ①②③。** 「卡片」「按钮」会随前端改,而这三层是两条链路共用的。
* 部件名留在工具描述里 —— 部件没了工具就没了,描述跟着一起消失。 * 部件名留在工具描述里 —— 部件没了工具就没了,描述跟着一起消失。
...@@ -114,7 +114,7 @@ import { Permission } from '@pac/types'; ...@@ -114,7 +114,7 @@ import { Permission } from '@pac/types';
* 抄一万遍。审计靠「这一行的 promptVersion + 代码里那个版本的正文」两边对 —— * 抄一万遍。审计靠「这一行的 promptVersion + 代码里那个版本的正文」两边对 ——
* ⇒ 版本不 bump,这条链就断了,而断了不会有任何报错。 * ⇒ 版本不 bump,这条链就断了,而断了不会有任何报错。
*/ */
export const ASSISTANT_PROMPT_VERSION = 'assistant@2026-08-16-a'; export const ASSISTANT_PROMPT_VERSION = 'assistant@2026-08-16-b';
/** /**
* ① 装置 —— 你是谁、和使用者什么关系、你看不见什么。 * ① 装置 —— 你是谁、和使用者什么关系、你看不见什么。
...@@ -252,7 +252,7 @@ const STAFF_ROLE = `## 你是客服的助手 ...@@ -252,7 +252,7 @@ const STAFF_ROLE = `## 你是客服的助手
* ═══ 为什么这一块按四步排 ═══════════════════════════════════════════ * ═══ 为什么这一块按四步排 ═══════════════════════════════════════════
* 🔴 2026-08-15 改成 `选人 → 分人 → 他确认 → 确认之后`(产品定)。 * 🔴 2026-08-15 改成 `选人 → 分人 → 他确认 → 确认之后`(产品定)。
* 在此之前它是**一句流程线 + 一堆平铺的规矩**:确认语义、专属底线、画像分布、 * 在此之前它是**一句流程线 + 一堆平铺的规矩**:确认语义、专属底线、画像分布、
* 不统计成功、福利(一整段挤着四个 ⛔)、落人算法、人数时 —— 各自都对, * 不统计成功、福利(一整段挤着四个 ⛔)、落人算法、人数时 —— 各自都对,
* 但读者(模型)要自己去认每一条属于哪一步。 * 但读者(模型)要自己去认每一条属于哪一步。
* ⚠️ 分节之后每条规矩**站在它生效的那一步里**,位置本身就是它的适用范围 —— * ⚠️ 分节之后每条规矩**站在它生效的那一步里**,位置本身就是它的适用范围 ——
* 福利这件事在③和④各有一条,靠**它在哪一节**就分得清谁提: * 福利这件事在③和④各有一条,靠**它在哪一节**就分得清谁提:
...@@ -286,7 +286,7 @@ const ASSIGNMENT_SCENE = `## 他现在做的这件事:把一批人分给客服 ...@@ -286,7 +286,7 @@ const ASSIGNMENT_SCENE = `## 他现在做的这件事:把一批人分给客服
要按画像收窄时先取分布再选,不然选完才发现只剩几个人,他白等一轮。 要按画像收窄时先取分布再选,不然选完才发现只剩几个人,他白等一轮。
各维度的数是「分别命中多少」,不是交叉后的人数;想知道交叉数,把条件一起传进去再调一次。 各维度的数是「分别命中多少」,不是交叉后的人数;想知道交叉数,把条件一起传进去再调一次。
人数和时**不问他**,直接出一版;他说了数你才带上。池子里还有人就随时能再分一批。 人数和时**不问他**,直接出一版;他说了数你才带上。池子里还有人就随时能再分一批。
### 分人 ### 分人
...@@ -306,7 +306,7 @@ const ASSIGNMENT_SCENE = `## 他现在做的这件事:把一批人分给客服 ...@@ -306,7 +306,7 @@ const ASSIGNMENT_SCENE = `## 他现在做的这件事:把一批人分给客服
### 确认之后 ### 确认之后
这批的人员和时改不了,要改只能**撤销重分**。你对已经分下去的这批能做的只有两件:撤销、补挂福利 —— 「已撤销」只能出现在撤销工具真的返回之后。 这批的人员和时改不了,要改只能**撤销重分**。你对已经分下去的这批能做的只有两件:撤销、补挂福利 —— 「已撤销」只能出现在撤销工具真的返回之后。
福利挂在整批上,会进这批人的话术,补挂只影响此后生成的那些。他说什么就原样写进去,⛔ 别替他加条件、期限或承诺。⛔ 「这批还没带福利」**不用你再提一遍** —— 确认之前你已经问过一次了;他回头问起,或者要补,照办就是。 福利挂在整批上,会进这批人的话术,补挂只影响此后生成的那些。他说什么就原样写进去,⛔ 别替他加条件、期限或承诺。⛔ 「这批还没带福利」**不用你再提一遍** —— 确认之前你已经问过一次了;他回头问起,或者要补,照办就是。
......
...@@ -45,7 +45,7 @@ interface ChatBody { ...@@ -45,7 +45,7 @@ interface ChatBody {
/** /**
* 眼前那张**还没确认**的确认单,此刻的样子 —— 模型用 `get_current_sheet` 取。 * 眼前那张**还没确认**的确认单,此刻的样子 —— 模型用 `get_current_sheet` 取。
* *
* 🔴 为什么由前端捎来:确认之前,改派 / 移出 / 时 / 福利**只存在于卡片组件里**, * 🔴 为什么由前端捎来:确认之前,改派 / 移出 / 时 / 福利**只存在于卡片组件里**,
* 服务端手上只有最初那一版提案(见 `SheetSnapshotSchema` 上那段)。 * 服务端手上只有最初那一版提案(见 `SheetSnapshotSchema` 上那段)。
* ⚠️ 与 `activeClinicId` 同一条纪律:前端可改的入参 —— * ⚠️ 与 `activeClinicId` 同一条纪律:前端可改的入参 ——
* 它只用来**告诉模型现在什么样**,⛔ 一个字都不许拿去写库。 * 它只用来**告诉模型现在什么样**,⛔ 一个字都不许拿去写库。
......
...@@ -156,7 +156,7 @@ const SYSTEM = `你是 PAC(疗效保障 / 患者分析中心)工作台里的 ...@@ -156,7 +156,7 @@ const SYSTEM = `你是 PAC(疗效保障 / 患者分析中心)工作台里的
不是每人加一样多,是每条都给当前手上最少的那个。没有「容量上限」这回事,负载就是在手量本身。 不是每人加一样多,是每条都给当前手上最少的那个。没有「容量上限」这回事,负载就是在手量本身。
人数和时效不问他:不传就按「在岗人数 × 每天 ${DAILY_CALLS_PER_AGENT} 通 × 时效」估,并与符合条件的总人数取小。他说了数才传。池子里还有人就随时能再分一批。`; 人数和时限不问他:不传就按「在岗人数 × 每天 ${DAILY_CALLS_PER_AGENT} 通 × 时限」估,并与符合条件的总人数取小。他说了数才传。池子里还有人就随时能再分一批。`;
// 🔴 `relabel`(编号→标识)与 `regroup`(要主管定的→怎么派的)已删(2026-08-14): // 🔴 `relabel`(编号→标识)与 `regroup`(要主管定的→怎么派的)已删(2026-08-14):
// 两者都搬进了产品的 `modelFacts`,裸台再做一遍就是**空转** —— 而空转的代码 // 两者都搬进了产品的 `modelFacts`,裸台再做一遍就是**空转** —— 而空转的代码
...@@ -393,12 +393,12 @@ export class AssistantLabController { ...@@ -393,12 +393,12 @@ export class AssistantLabController {
* 「返回的是结构化事实…组织成人话」→ 系统提示词的「怎么把工具给的事实讲出来」已经管了 * 「返回的是结构化事实…组织成人话」→ 系统提示词的「怎么把工具给的事实讲出来」已经管了
* 「绝不要说已经分配好了」 → 系统提示词的权责段 + 返回值键名 `已经分下去了` 各一份,这是第三份 * 「绝不要说已经分配好了」 → 系统提示词的权责段 + 返回值键名 `已经分下去了` 各一份,这是第三份
* 「专属客服排满的不会自动改派」 → 返回值里 `专属客服排满、这批没发的` 自己就说清了(判据②) * 「专属客服排满的不会自动改派」 → 返回值里 `专属客服排满、这批没发的` 自己就说清了(判据②)
* ⚠️ 「不问人数时、直出不追问」也删了 —— 系统提示词的流程线里写着,这里是复述。 * ⚠️ 「不问人数时、直出不追问」也删了 —— 系统提示词的流程线里写着,这里是复述。
*/ */
description: description:
'从召回池按条件选出一批人,算出一版分配方案:谁分给谁、每人几条、谁卡着没发。' + '从召回池按条件选出一批人,算出一版分配方案:谁分给谁、每人几条、谁卡着没发。' +
'\n什么时候调:他说出要哪一格的人(治疗项 + 时间档)就调。' + '\n什么时候调:他说出要哪一格的人(治疗项 + 时间档)就调。' +
'他要换条件重来(只选其中一类、改人数、改时)也是**重调这个工具出新的一版**,' + '他要换条件重来(只选其中一类、改人数、改时)也是**重调这个工具出新的一版**,' +
'⛔ 不是在上一版已经排好的人里再挑。' + '⛔ 不是在上一版已经排好的人里再挑。' +
'\n⚠️ 本批人数取**小的那个**:估出来的基数 vs 符合条件的总人数。' + '\n⚠️ 本批人数取**小的那个**:估出来的基数 vs 符合条件的总人数。' +
'⛔ 所以这一格人不够时,把人数往上调没有任何作用 —— 卡住的是候选,不是基数。' + '⛔ 所以这一格人不够时,把人数往上调没有任何作用 —— 卡住的是候选,不是基数。' +
...@@ -470,13 +470,13 @@ export class AssistantLabController { ...@@ -470,13 +470,13 @@ export class AssistantLabController {
dailyCalls: { dailyCalls: {
type: 'number', type: 'number',
description: description:
'「每人每天打几通」,1–60。**只用来估本批人数**(在岗人数 × 它 × 时)。' + '「每人每天打几通」,1–60。**只用来估本批人数**(在岗人数 × 它 × 时)。' +
'\n⛔ 它不是容量上限,⛔ 也不参与"谁分给谁"。⚠️ 传了 targetCount 时它不起作用。', '\n⛔ 它不是容量上限,⛔ 也不参与"谁分给谁"。⚠️ 传了 targetCount 时它不起作用。',
}, },
expiresInDays: { expiresInDays: {
type: 'number', type: 'number',
description: description:
'批次时天数。他明确说了天数(「给 5 天」)时才传。' + '批次时天数。他明确说了天数(「给 5 天」)时才传。' +
'\n⚠️ 它**身兼两职**:① 单据多久到期自动退回池子;' + '\n⚠️ 它**身兼两职**:① 单据多久到期自动退回池子;' +
'② 没传 targetCount 时,它还参与估本批人数。改它两件事一起变。', '② 没传 targetCount 时,它还参与估本批人数。改它两件事一起变。',
}, },
...@@ -507,7 +507,7 @@ export class AssistantLabController { ...@@ -507,7 +507,7 @@ export class AssistantLabController {
: sheet.candidateTotal, : sheet.candidateTotal,
已排好: sheet.placed, 已排好: sheet.placed,
待分配: sheet.pending.length, 待分配: sheet.pending.length,
天数: sheet.expiresInDays, 天数: sheet.expiresInDays,
}, },
}); });
const facts = modelFacts(sheet); const facts = modelFacts(sheet);
...@@ -581,7 +581,7 @@ export class AssistantLabController { ...@@ -581,7 +581,7 @@ export class AssistantLabController {
'待分配 = 有人卡着没发出去、等他定去向;' + '待分配 = 有人卡着没发出去、等他定去向;' +
'换个条件选 = 这一格可以只选其中一类人,重出一版;' + '换个条件选 = 这一格可以只选其中一类人,重出一版;' +
'本批人数 = 这一批发多少人,可以调;' + '本批人数 = 这一批发多少人,可以调;' +
'打不完 = 按当前时效算这批打不完,可以调时效或每天几通。' + '打不完 = 按当前时限算这批打不完,可以调时限或每天几通。' +
'\n⚠️ 只能开放**这一版真的有**的那几类;没有的会被退回,并告诉你这一版有哪几类。', '\n⚠️ 只能开放**这一版真的有**的那几类;没有的会被退回,并告诉你这一版有哪几类。',
}, },
}, },
......
...@@ -31,7 +31,7 @@ import type { TenantScopeContext } from '../../common/decorators/tenant-scope.de ...@@ -31,7 +31,7 @@ import type { TenantScopeContext } from '../../common/decorators/tenant-scope.de
* 主场 3,799 条在另一家,这儿 3 条)。医生/护士/前台偶尔被记成一次回访负责人, * 主场 3,799 条在另一家,这儿 3 条)。医生/护士/前台偶尔被记成一次回访负责人,
* 就进了名册。 * 就进了名册。
* ⚠️ 危害不止那张表不好看:`rosterCount` 直接进默认批次估算 * ⚠️ 危害不止那张表不好看:`rosterCount` 直接进默认批次估算
* (在岗人数 × 每天几通 × 时),193 会让默认批次比真实规模**大一个数量级**。 * (在岗人数 × 每天几通 × 时),193 会让默认批次比真实规模**大一个数量级**。
* *
* ⇒ 判据 = **在本诊所的回访量占他个人总量 ≥20%,或在本诊所 ≥20 条**。 * ⇒ 判据 = **在本诊所的回访量占他个人总量 ≥20%,或在本诊所 ≥20 条**。
* ⚠️ 两条是**或**,缺一不可: * ⚠️ 两条是**或**,缺一不可:
......
...@@ -8,18 +8,18 @@ import { recordPlanEventsBulk, computeHeldSeconds } from './plan-event.recorder' ...@@ -8,18 +8,18 @@ import { recordPlanEventsBulk, computeHeldSeconds } from './plan-event.recorder'
* AssignmentExpiryScheduler —— 分配单到期,系统收回池子。 * AssignmentExpiryScheduler —— 分配单到期,系统收回池子。
* *
* ── 为什么这件事必须做,而且默认开 ──────────────────────────── * ── 为什么这件事必须做,而且默认开 ────────────────────────────
* 时是分配单的一部分(T11:不存在无限期批次),但**光写一个到期时刻不会让任何事发生**。 * 时是分配单的一部分(T11:不存在无限期批次),但**光写一个到期时刻不会让任何事发生**。
* 不收回的后果不是"多了几条过期单",是**容量口径整体失效**: * 不收回的后果不是"多了几条过期单",是**容量口径整体失效**:
* 客服的「在手」只增不减,几批之后全员触顶,再分就分不下去 —— * 客服的「在手」只增不减,几批之后全员触顶,再分就分不下去 ——
* 而这在主管看来就是分配功能坏了。 * 而这在主管看来就是分配功能坏了。
* *
* 产品定调(2026-08):到期自动回池**也是给主管减负** —— 让他不必去追"这单还要不要"。 * 产品定调(2026-08):到期自动回池**也是给主管减负** —— 让他不必去追"这单还要不要"。
* 主管在确认单上确认时的那一下,就是对到期行为的预授权,不违 T8 * 主管在确认单上确认时的那一下,就是对到期行为的预授权,不违 T8
* (T8 要防的是"助手替主管做决定",不是"主管定好的规则到点执行")。 * (T8 要防的是"助手替主管做决定",不是"主管定好的规则到点执行")。
* *
* ── 与既有 RecycleSchedulerService 的关系:两条互不干扰的路 ──── * ── 与既有 RecycleSchedulerService 的关系:两条互不干扰的路 ────
* · RecycleScheduler 看 `recycle_at`,是**认领**的 24h 兜底,生产**未启用** * · RecycleScheduler 看 `recycle_at`,是**认领**的 24h 兜底,生产**未启用**
* · 本服务 看 `assignment_expires_at`,是**分配**的时,默认启用 * · 本服务 看 `assignment_expires_at`,是**分配**的时,默认启用
* 刻意不合并:两者的语义、开关、口径都不同,合了之后想单独关一边就得加分支, * 刻意不合并:两者的语义、开关、口径都不同,合了之后想单独关一边就得加分支,
* 而那个分支迟早写错。 * 而那个分支迟早写错。
*/ */
......
...@@ -81,7 +81,7 @@ export function modelFacts(p: AssignmentProposal): Record<string, unknown> { ...@@ -81,7 +81,7 @@ export function modelFacts(p: AssignmentProposal): Record<string, unknown> {
const tuned = Object.entries(p.agentOverrides ?? {}).map(([userId, o]) => ({ const tuned = Object.entries(p.agentOverrides ?? {}).map(([userId, o]) => ({
'客服': nameOf.get(userId) ?? userId.slice(0, 8), '客服': nameOf.get(userId) ?? userId.slice(0, 8),
...(o.maxThisBatch != null ? { '这批最多给他': o.maxThisBatch } : {}), ...(o.maxThisBatch != null ? { '这批最多给他': o.maxThisBatch } : {}),
...(o.expiresInDays != null ? { '他这批的时天数': o.expiresInDays } : {}), ...(o.expiresInDays != null ? { '他这批的时天数': o.expiresInDays } : {}),
})); }));
return { return {
...@@ -146,8 +146,8 @@ export function modelFacts(p: AssignmentProposal): Record<string, unknown> { ...@@ -146,8 +146,8 @@ export function modelFacts(p: AssignmentProposal): Record<string, unknown> {
* 「这几条要讲在第 1 段」:结构能表达的事就别写成规则(规则越多越不被遵守)。 * 「这几条要讲在第 1 段」:结构能表达的事就别写成规则(规则越多越不被遵守)。
* *
* 🔴 **两组必须分开给。** 合成一个键时模型会拿一个标题罩住两组按钮 —— * 🔴 **两组必须分开给。** 合成一个键时模型会拿一个标题罩住两组按钮 ——
* 实测走查抓到「还能怎么选:」下面挂着「改时 / 改每天几通」, * 实测走查抓到「还能怎么选:」下面挂着「改时 / 改每天几通」,
* 而改时根本不是选人。⇒ 一个键只说一件事。 * 而改时根本不是选人。⇒ 一个键只说一件事。
* ⚠️ 顺序 `人群 人数`,⛔ 不许反:换人群会让本批人数重新估一遍 * ⚠️ 顺序 `人群 人数`,⛔ 不许反:换人群会让本批人数重新估一遍
* (实测候选 2509→312 后,本批 495 被压成 312),先调人数那下就白调了。 * (实测候选 2509→312 后,本批 495 被压成 312),先调人数那下就白调了。
*/ */
...@@ -179,7 +179,7 @@ export function modelFacts(p: AssignmentProposal): Record<string, unknown> { ...@@ -179,7 +179,7 @@ export function modelFacts(p: AssignmentProposal): Record<string, unknown> {
...(p.skippedAgents.length > 0 ...(p.skippedAgents.length > 0
? { '他们现在手上有': range(p.skippedAgents.map((a) => a.inHand)) } ? { '他们现在手上有': range(p.skippedAgents.map((a) => a.inHand)) }
: {}), : {}),
'时天数': p.expiresInDays, '时天数': p.expiresInDays,
'本批每人拿到': range(perBatch), '本批每人拿到': range(perBatch),
'分完之后每人手上': range(loads), '分完之后每人手上': range(loads),
// ⚠️ 读**这一版真用的**那个数,⛔ 不引常量:主管说过「按 20 通算」就该是 20 // ⚠️ 读**这一版真用的**那个数,⛔ 不引常量:主管说过「按 20 通算」就该是 20
...@@ -247,8 +247,8 @@ export function sheetSnapshotFacts(s: SheetSnapshot): Record<string, unknown> { ...@@ -247,8 +247,8 @@ export function sheetSnapshotFacts(s: SheetSnapshot): Record<string, unknown> {
待你定的: s.pending, 待你定的: s.pending,
他移出本批的: s.dropped, 他移出本批的: s.dropped,
被改派过的: s.moved, 被改派过的: s.moved,
整批时天数: s.expiresInDays, 整批时天数: s.expiresInDays,
单独改过时的条数: s.rowExpiryOverrides, 单独改过时的条数: s.rowExpiryOverrides,
本批福利: s.benefit, 本批福利: s.benefit,
每位客服现在几条: s.byAgent.map((a) => ({ 客服: a.name, 条数: a.count })), 每位客服现在几条: s.byAgent.map((a) => ({ 客服: a.name, 条数: a.count })),
}; };
......
...@@ -133,13 +133,13 @@ export class AssignmentProposalService { ...@@ -133,13 +133,13 @@ export class AssignmentProposalService {
personaTags?: string; personaTags?: string;
/** 只要累计净消费高于这个数(元)的人 —— 「还能再筛一刀」里那条消费选项 */ /** 只要累计净消费高于这个数(元)的人 —— 「还能再筛一刀」里那条消费选项 */
minSpendYuan?: number; minSpendYuan?: number;
/** 批次时天数(基数②);不传 = 沿用上一次分配的值 */ /** 批次时天数(基数②);不传 = 沿用上一次分配的值 */
expiresInDays?: number; expiresInDays?: number;
/** /**
* 「每人每天打几通」的换算尺 —— 只用来估 N(在岗 × 它 × 时)。 * 「每人每天打几通」的换算尺 —— 只用来估 N(在岗 × 它 × 时)。
* *
* ⭐ 2026-08-13 加:引导节点上主管填一个数(比如 8),要重出一版。 * ⭐ 2026-08-13 加:引导节点上主管填一个数(比如 8),要重出一版。
* ⛔ **不让模型自己乘** —— 它手里有"在岗 33 人""时 1 天", * ⛔ **不让模型自己乘** —— 它手里有"在岗 33 人""时 1 天",
* 但报出去的每一个数都必须来自工具计算(T14)。这里由服务端算。 * 但报出去的每一个数都必须来自工具计算(T14)。这里由服务端算。
* ⚠️ 它**不是容量上限**、⛔ 不参与落人,和 DAILY_CALLS_PER_AGENT 同性质。 * ⚠️ 它**不是容量上限**、⛔ 不参与落人,和 DAILY_CALLS_PER_AGENT 同性质。
*/ */
...@@ -153,7 +153,7 @@ export class AssignmentProposalService { ...@@ -153,7 +153,7 @@ export class AssignmentProposalService {
agentOverrides?: Record<string, { maxThisBatch?: number; expiresInDays?: number }>; agentOverrides?: Record<string, { maxThisBatch?: number; expiresInDays?: number }>;
/** /**
* 本批人数 N(基数①);不传 = 沿用上一次分配的值。 * 本批人数 N(基数①);不传 = 沿用上一次分配的值。
* ⭐ 与时同为基数 —— 主管说「这批 200 人」会被记住,下次默认就是 200。 * ⭐ 与时同为基数 —— 主管说「这批 200 人」会被记住,下次默认就是 200。
*/ */
targetCount?: number; targetCount?: number;
/** 探索配额占比(0-0.2)。⭐ 唯一的因果抓手,见 selectionMode 注释 */ /** 探索配额占比(0-0.2)。⭐ 唯一的因果抓手,见 selectionMode 注释 */
...@@ -200,7 +200,7 @@ export class AssignmentProposalService { ...@@ -200,7 +200,7 @@ export class AssignmentProposalService {
]); ]);
const agents = roster.agents; const agents = roster.agents;
// ── 两个基数:本批人数 N + 时 ────────────────────────── // ── 两个基数:本批人数 N + 时 ──────────────────────────
// 优先级:本次明确指定 > 沿用上一次 > 首次默认。 // 优先级:本次明确指定 > 沿用上一次 > 首次默认。
// ⭐ `basis` 要跟着值一起下发 —— 主管看到「本批 100 人」时,"这 100 哪来的"和这个数一样重要。 // ⭐ `basis` 要跟着值一起下发 —— 主管看到「本批 100 人」时,"这 100 哪来的"和这个数一样重要。
// //
...@@ -209,7 +209,7 @@ export class AssignmentProposalService { ...@@ -209,7 +209,7 @@ export class AssignmentProposalService {
// 主管想连着圈两批人分,只能把容量往上棚,而容量会被记住 → 下周的"习惯"是个虚高的数。 // 主管想连着圈两批人分,只能把容量往上棚,而容量会被记住 → 下周的"习惯"是个虚高的数。
// 现在:N 决定推多少,水位法决定给谁,负载由 `inHand` 本身表达,不设阈值。 // 现在:N 决定推多少,水位法决定给谁,负载由 `inHand` 本身表达,不设阈值。
/** /**
* 时 D —— 本次指定 > 默认。⚠️ 必须**先算它**,因为 N 要用到(见下)。 * 时 D —— 本次指定 > 默认。⚠️ 必须**先算它**,因为 N 要用到(见下)。
* 🔴 2026-08-12 起**不再沿用上一次**(产品定)。 * 🔴 2026-08-12 起**不再沿用上一次**(产品定)。
*/ */
const expiresInDays = const expiresInDays =
...@@ -222,7 +222,7 @@ export class AssignmentProposalService { ...@@ -222,7 +222,7 @@ export class AssignmentProposalService {
* 🔴 2026-08-12 改判(产品定): * 🔴 2026-08-12 改判(产品定):
* ① **不再沿用上一次**。原设计是"调一次省一次",但它让 N 悄悄漂 —— * ① **不再沿用上一次**。原设计是"调一次省一次",但它让 N 悄悄漂 ——
* 某次为了一个小格子调成 44,此后所有批次就永远是 44,主管看不出为什么变小了。 * 某次为了一个小格子调成 44,此后所有批次就永远是 44,主管看不出为什么变小了。
* ② **N 与时挂钩**:一批推多少,取决于这批要在几天内打完。 * ② **N 与时挂钩**:一批推多少,取决于这批要在几天内打完。
* D 从 3 天改成 5 天,能承接的量本来就该跟着变。 * D 从 3 天改成 5 天,能承接的量本来就该跟着变。
* ⚠️ 这里的 15(`DAILY_CALLS_PER_AGENT`)**仍然不是容量上限**,⛔ 不参与落人 —— * ⚠️ 这里的 15(`DAILY_CALLS_PER_AGENT`)**仍然不是容量上限**,⛔ 不参与落人 ——
* 落人只有水位法,没有阈值("容量"被删过一次,理由见 BATCH_SIZE_PER_AGENT_FIRST)。 * 落人只有水位法,没有阈值("容量"被删过一次,理由见 BATCH_SIZE_PER_AGENT_FIRST)。
...@@ -463,9 +463,9 @@ export class AssignmentProposalService { ...@@ -463,9 +463,9 @@ export class AssignmentProposalService {
* *
* 🔴 2026-08-13 整块删掉 —— 同一批数现在有**三个**出口,卡片那份是第三遍: * 🔴 2026-08-13 整块删掉 —— 同一批数现在有**三个**出口,卡片那份是第三遍:
* ① 模型的正文(modelFacts 的「本批每人拿到 / 分完之后每人手上」); * ① 模型的正文(modelFacts 的「本批每人拿到 / 分完之后每人手上」);
* ② 引导节点 `batch_size_basis`(这批多大 = 在岗 × 每天几通 × 时,可改); * ② 引导节点 `batch_size_basis`(这批多大 = 在岗 × 每天几通 × 时,可改);
* ③ 引导节点 `daily_overload`(最忙那位手上共几条、约几天的量)。 * ③ 引导节点 `daily_overload`(最忙那位手上共几条、约几天的量)。
* ⛔ 别再加回来:卡片上那个位置现在放**只有卡片能做的事**(拖、点 ×、逐条改时), * ⛔ 别再加回来:卡片上那个位置现在放**只有卡片能做的事**(拖、点 ×、逐条改时),
* 那些是模型说不了、也做不成引导节点的。 * 那些是模型说不了、也做不成引导节点的。
*/ */
...@@ -517,7 +517,7 @@ export class AssignmentProposalService { ...@@ -517,7 +517,7 @@ export class AssignmentProposalService {
spread: list.filter((x) => x.assignStrategy !== AssignStrategy.DEDICATED).length, spread: list.filter((x) => x.assignStrategy !== AssignStrategy.DEDICATED).length,
/// ⭐ 分配后他手上有多少 —— 「负载」的唯一表达(没有阈值线,主管自己看这列齐不齐) /// ⭐ 分配后他手上有多少 —— 「负载」的唯一表达(没有阈值线,主管自己看这列齐不齐)
loadAfter: (agentById.get(userId)?.inHand ?? 0) + list.length, loadAfter: (agentById.get(userId)?.inHand ?? 0) + list.length,
/// 逐人下发生效时 —— 前端直接显示,⛔ 不让它自己再合并一次(合并逻辑写两遍必然漂) /// 逐人下发生效时 —— 前端直接显示,⛔ 不让它自己再合并一次(合并逻辑写两遍必然漂)
expiresInDays: expOf(userId), expiresInDays: expOf(userId),
overridden: agentOverrides[userId] != null, overridden: agentOverrides[userId] != null,
})), })),
...@@ -807,10 +807,10 @@ export class AssignmentProposalService { ...@@ -807,10 +807,10 @@ export class AssignmentProposalService {
} }
/** /**
* 两个基数的**沿用** —— 取该主管在该诊所上一次分配用的容量与时 * 两个基数的**沿用** —— 取该主管在该诊所上一次分配用的容量与时
* *
* ⭐ 这是「先出全景确认单、主管反馈调整」这个设计的兑现点: * ⭐ 这是「先出全景确认单、主管反馈调整」这个设计的兑现点:
* 他第一次把容量/时调顺手,系统记住,**第二次起零输入**。 * 他第一次把容量/时调顺手,系统记住,**第二次起零输入**。
* ⚠️ 先找**他自己**的上一次,再退到**本诊所**任何人的上一次 —— * ⚠️ 先找**他自己**的上一次,再退到**本诊所**任何人的上一次 ——
* 容量是团队吞吐的判断,但不同主管的手感不同;先私后公,两级都没有才用默认。 * 容量是团队吞吐的判断,但不同主管的手感不同;先私后公,两级都没有才用默认。
* ⚠️ 值存在 `criteria` 这个 Json 快照里(**零新列**);读不到就当没有,⛔ 不抛错 —— * ⚠️ 值存在 `criteria` 这个 Json 快照里(**零新列**);读不到就当没有,⛔ 不抛错 ——
...@@ -1289,7 +1289,7 @@ function sanitizeOverrides( ...@@ -1289,7 +1289,7 @@ function sanitizeOverrides(
typeof o.maxThisBatch === 'number' && Number.isInteger(o.maxThisBatch) && o.maxThisBatch >= 0 typeof o.maxThisBatch === 'number' && Number.isInteger(o.maxThisBatch) && o.maxThisBatch >= 0
? o.maxThisBatch ? o.maxThisBatch
: null; : null;
// 时有上限 90 天:那不是业务判断,是 `expiresInDays` 契约本来就写死的(schema max(90)) // 时有上限 90 天:那不是业务判断,是 `expiresInDays` 契约本来就写死的(schema max(90))
const days = posInt(o.expiresInDays); const days = posInt(o.expiresInDays);
const expiresInDays = days != null && days <= 90 ? days : null; const expiresInDays = days != null && days <= 90 ? days : null;
if (maxThisBatch == null && expiresInDays == null) continue; if (maxThisBatch == null && expiresInDays == null) continue;
......
...@@ -37,7 +37,7 @@ export const ASSIGNMENT_INTENTS = { ...@@ -37,7 +37,7 @@ export const ASSIGNMENT_INTENTS = {
PENDING_REFILL: 'pending.refill', PENDING_REFILL: 'pending.refill',
/// 待分配的整组移出本批 /// 待分配的整组移出本批
PENDING_REMOVE: 'pending.remove', PENDING_REMOVE: 'pending.remove',
/// 改批次时 /// 改批次时
EXPIRY_SET: 'expiry.set', EXPIRY_SET: 'expiry.set',
/** /**
* 🔴 `DAILY_RATE_SET`(daily_rate.set)与 `BATCH_SIZE_SET`(batch_size.set) * 🔴 `DAILY_RATE_SET`(daily_rate.set)与 `BATCH_SIZE_SET`(batch_size.set)
...@@ -51,11 +51,11 @@ export const ASSIGNMENT_INTENTS = { ...@@ -51,11 +51,11 @@ export const ASSIGNMENT_INTENTS = {
* ⇒ 一个 intent 只该有一种带数的走法。 * ⇒ 一个 intent 只该有一种带数的走法。
*/ */
/** /**
* 改「本批多大」那个式子里的一项(`args.days` 时 / `args.rate` 每天几通)—— * 改「本批多大」那个式子里的一项(`args.days` 时 / `args.rate` 每天几通)——
* **要重跑算法**:N = 在岗 × 每天几通 × 时,改哪一项人数都跟着变。 * **要重跑算法**:N = 在岗 × 每天几通 × 时,改哪一项人数都跟着变。
* *
* 🔴 ⛔ 别拿 `EXPIRY_SET` 去凑:那个只把已经算好的这批人的**到期日**改掉, * 🔴 ⛔ 别拿 `EXPIRY_SET` 去凑:那个只把已经算好的这批人的**到期日**改掉,
* 人数一个不变 —— 而这条节点的原话是「改时或改每天几通,这批人数都会跟着变」。 * 人数一个不变 —— 而这条节点的原话是「改时或改每天几通,这批人数都会跟着变」。
* 用错了节点就在说谎(2026-08-13 实测:卡片显示 3 天,节点仍写着 ×1 天,人数没动)。 * 用错了节点就在说谎(2026-08-13 实测:卡片显示 3 天,节点仍写着 ×1 天,人数没动)。
*/ */
BASIS_SET: 'basis.set', BASIS_SET: 'basis.set',
...@@ -362,9 +362,9 @@ export function computeSignals(p: AssignmentProposal, extra: Signal[] = []): Sig ...@@ -362,9 +362,9 @@ export function computeSignals(p: AssignmentProposal, extra: Signal[] = []): Sig
// ── ② 工作量预估 —— 这批要打几天 ────────────────────────────────── // ── ② 工作量预估 —— 这批要打几天 ──────────────────────────────────
// //
// ⭐ 2026-08-12 产品定:这**不是异常告警,是每批都该讲清楚的一件事**。 // ⭐ 2026-08-12 产品定:这**不是异常告警,是每批都该讲清楚的一件事**。
// N 按 `在岗 × 每天 15 通 × 时` 算,只算新增、⛔ 不扣在手 —— // N 按 `在岗 × 每天 15 通 × 时` 算,只算新增、⛔ 不扣在手 ——
// 所以只要谁手上还有东西,需要的天数就会超过时。**那是事实,不是 bug**: // 所以只要谁手上还有东西,需要的天数就会超过时。**那是事实,不是 bug**:
// 主管要么延长时、要么调低每日通数(N 跟着变小)、要么直接减这批人数。 // 主管要么延长时、要么调低每日通数(N 跟着变小)、要么直接减这批人数。
// ⛔ 所以措辞里不许出现「打不完 / 超了 / 过载」这类像报错的词 —— // ⛔ 所以措辞里不许出现「打不完 / 超了 / 过载」这类像报错的词 ——
// 它常亮,说成故障主管就会开始怀疑系统而不是做决定。 // 它常亮,说成故障主管就会开始怀疑系统而不是做决定。
// //
...@@ -379,11 +379,11 @@ export function computeSignals(p: AssignmentProposal, extra: Signal[] = []): Sig ...@@ -379,11 +379,11 @@ export function computeSignals(p: AssignmentProposal, extra: Signal[] = []): Sig
if (heaviest && heaviest.loadAfter > p.dailyCalls * d) { if (heaviest && heaviest.loadAfter > p.dailyCalls * d) {
const needDays = Math.ceil(heaviest.loadAfter / p.dailyCalls); const needDays = Math.ceil(heaviest.loadAfter / p.dailyCalls);
/** /**
* 🔴 **超时的一共几位**(2026-08-16 产品定)。只报最忙那一位分不出两种相反的局面: * 🔴 **超时的一共几位**(2026-08-16 产品定)。只报最忙那一位分不出两种相反的局面:
* · 只有他一个人超 → 该做的是**给他少分点 / 改派几个**; * · 只有他一个人超 → 该做的是**给他少分点 / 改派几个**;
* · 全队都超 → 该做的是**减少这批 / 延长时**。 * · 全队都超 → 该做的是**减少这批 / 延长时**。
* 同一句话、相反的处置 —— 而这条唯一给的选项是「整批时改成 N 天」, * 同一句话、相反的处置 —— 而这条唯一给的选项是「整批时改成 N 天」,
* 碰上第一种局面它本身就是错的(为一个人的负载去延长整批时)。 * 碰上第一种局面它本身就是错的(为一个人的负载去延长整批时)。
* ⚠️ 只加一个数,⛔ 不在这里铺开每个人:确认单上每位客服那一行已经有「约 N 天」, * ⚠️ 只加一个数,⛔ 不在这里铺开每个人:确认单上每位客服那一行已经有「约 N 天」,
* 分布本来就在眼皮底下 ⇒ 这里一句话把他引过去看就够(引导节点的职责是 * 分布本来就在眼皮底下 ⇒ 这里一句话把他引过去看就够(引导节点的职责是
* **点出要他定的事**,不是展示数据)。 * **点出要他定的事**,不是展示数据)。
...@@ -401,7 +401,7 @@ export function computeSignals(p: AssignmentProposal, extra: Signal[] = []): Sig ...@@ -401,7 +401,7 @@ export function computeSignals(p: AssignmentProposal, extra: Signal[] = []): Sig
// ⚠️ 「按每天 15 通算」这个前提要写出来:它是评审当天的口头经验值,不是实测。 // ⚠️ 「按每天 15 通算」这个前提要写出来:它是评审当天的口头经验值,不是实测。
why: why:
`其中本批 ${heaviest.count} 条、原本在手 ${heaviest.inHandBefore} 条;` + `其中本批 ${heaviest.count} 条、原本在手 ${heaviest.inHandBefore} 条;` +
`按每人每天 ${p.dailyCalls} 通算需要 ${needDays} 天,本批时定的是 ${d} 天。` + `按每人每天 ${p.dailyCalls} 通算需要 ${needDays} 天,本批时定的是 ${d} 天。` +
(overCount > 1 (overCount > 1
? `另有 ${overCount - 1} 位也超过 ${d} 天;每位分完之后要打几天,确认单上逐位都写着。` ? `另有 ${overCount - 1} 位也超过 ${d} 天;每位分完之后要打几天,确认单上逐位都写着。`
: ''), : ''),
...@@ -410,7 +410,7 @@ export function computeSignals(p: AssignmentProposal, extra: Signal[] = []): Sig ...@@ -410,7 +410,7 @@ export function computeSignals(p: AssignmentProposal, extra: Signal[] = []): Sig
* 🔴 **只留带数的那一个**(2026-08-15 产品定)。 * 🔴 **只留带数的那一个**(2026-08-15 产品定)。
* *
* 删掉的是「改每人每天打几通」和「减少本批人数」—— 它们**一个数都不带**: * 删掉的是「改每人每天打几通」和「减少本批人数」—— 它们**一个数都不带**:
* 既没算出目标值(不像 `改成 ${needDays} `),也没有输入框 * 既没算出目标值(不像 `改成 ${needDays} `),也没有输入框
* (不像 `batch_size_basis` 那两条,各自预填着当前值)。点下去发出的那句话 * (不像 `batch_size_basis` 那两条,各自预填着当前值)。点下去发出的那句话
* 于是也是空的:「这批人数**减少一些**,重新排一版」「每人每天按**更少的通数**算」—— * 于是也是空的:「这批人数**减少一些**,重新排一版」「每人每天按**更少的通数**算」——
* 那个数只能由模型自己拍,而它没有依据可拍。 * 那个数只能由模型自己拍,而它没有依据可拍。
...@@ -420,7 +420,7 @@ export function computeSignals(p: AssignmentProposal, extra: Signal[] = []): Sig ...@@ -420,7 +420,7 @@ export function computeSignals(p: AssignmentProposal, extra: Signal[] = []): Sig
*/ */
options: [ options: [
{ {
label: `整批时改成 ${needDays} `, label: `整批时改成 ${needDays} `,
intent: ASSIGNMENT_INTENTS.EXPIRY_SET, intent: ASSIGNMENT_INTENTS.EXPIRY_SET,
args: { days: needDays }, args: { days: needDays },
}, },
...@@ -459,7 +459,7 @@ export function computeSignals(p: AssignmentProposal, extra: Signal[] = []): Sig ...@@ -459,7 +459,7 @@ export function computeSignals(p: AssignmentProposal, extra: Signal[] = []): Sig
// //
// 🔴 **2026-08-16 撤掉「候选够不着 N 就不出」那道闸**(产品指出)。 // 🔴 **2026-08-16 撤掉「候选够不着 N 就不出」那道闸**(产品指出)。
// 原判据是 `candidateTotal > batchSize`,理由写的是:候选 312 / N 495 时瓶颈是候选, // 原判据是 `candidateTotal > batchSize`,理由写的是:候选 312 / N 495 时瓶颈是候选,
// 往上调(时 1→3 天 ⇒ N=1485)`target` 还是 312,点了没反应,而 why 说"人数会跟着变"⇒ 说谎。 // 往上调(时 1→3 天 ⇒ N=1485)`target` 还是 312,点了没反应,而 why 说"人数会跟着变"⇒ 说谎。
// ⚠️ **那个理由只考虑了往上调。** 主管同样可以**往下调**:候选 312 / N 495 时把每天几通 // ⚠️ **那个理由只考虑了往上调。** 主管同样可以**往下调**:候选 312 / N 495 时把每天几通
// 从 15 改成 5,N=165 < 312 ⇒ target 真的变成 165。他想少发一些,而这道闸把入口藏了。 // 从 15 改成 5,N=165 < 312 ⇒ target 真的变成 165。他想少发一些,而这道闸把入口藏了。
// ⇒ 闸撤掉,改成**据实说明**:候选够不着 N 时,why 里直接讲清楚"往上不会更多、往下可以少发"。 // ⇒ 闸撤掉,改成**据实说明**:候选够不着 N 时,why 里直接讲清楚"往上不会更多、往下可以少发"。
...@@ -480,11 +480,11 @@ export function computeSignals(p: AssignmentProposal, extra: Signal[] = []): Sig ...@@ -480,11 +480,11 @@ export function computeSignals(p: AssignmentProposal, extra: Signal[] = []): Sig
why: capped why: capped
? `这是系统按这个式子估的,不是根据历史算的。符合条件的只有 ${p.candidateTotal} 人,已经全在本批里了 —— ` + ? `这是系统按这个式子估的,不是根据历史算的。符合条件的只有 ${p.candidateTotal} 人,已经全在本批里了 —— ` +
`往上调不会更多,往下调可以少发一些。` `往上调不会更多,往下调可以少发一些。`
: `这是系统按这个式子估的,不是根据历史算的。改时或改每天几通,这批人数都会跟着变。`, : `这是系统按这个式子估的,不是根据历史算的。改时或改每天几通,这批人数都会跟着变。`,
defaultLabel: '不处理 = 就按这个数发', defaultLabel: '不处理 = 就按这个数发',
options: [ options: [
{ {
label: '改时', label: '改时',
intent: ASSIGNMENT_INTENTS.BASIS_SET, intent: ASSIGNMENT_INTENTS.BASIS_SET,
input: { argKey: 'days', unit: '天', min: 1, max: 90, default: d }, input: { argKey: 'days', unit: '天', min: 1, max: 90, default: d },
}, },
......
...@@ -1285,7 +1285,7 @@ export class PlanAssignmentService { ...@@ -1285,7 +1285,7 @@ export class PlanAssignmentService {
(bucket.resolved ? `,其中 ${bucket.resolved} 位患者的召回需求**已经不在池子里了**(引擎判定无活信号)` : '') + (bucket.resolved ? `,其中 ${bucket.resolved} 位患者的召回需求**已经不在池子里了**(引擎判定无活信号)` : '') +
(bucket.suppressed ? `,${bucket.suppressed} 条客服写了回访结果(约下次/拒绝/放弃)` : '') + (bucket.suppressed ? `,${bucket.suppressed} 条客服写了回访结果(约下次/拒绝/放弃)` : '') +
`;还在客服手上未处理 ${bucket.inHandPending} 条,落回池子 ${bucket.backToPool} 条` + `;还在客服手上未处理 ${bucket.inHandPending} 条,落回池子 ${bucket.backToPool} 条` +
// 退回与到期必须分开说 —— 主管的下一步动作相反(改分配策略 vs 派多了/时太紧) // 退回与到期必须分开说 —— 主管的下一步动作相反(改分配策略 vs 派多了/时太紧)
(counts.released || counts.expired (counts.released || counts.expired
? `(其中客服主动退回 ${counts.released} 条、到期没人动 ${counts.expired} 条)` ? `(其中客服主动退回 ${counts.released} 条、到期没人动 ${counts.expired} 条)`
: '') + : '') +
......
...@@ -54,7 +54,7 @@ import { planLabelAnchorsSql, temperatureBucketCaseSql } from './reason-temperat ...@@ -54,7 +54,7 @@ import { planLabelAnchorsSql, temperatureBucketCaseSql } from './reason-temperat
/// assignment 后多久未结案自动回收。 /// assignment 后多久未结案自动回收。
/// ⚠️ 别再基于「这个值应该可配」去做 env 化/配置化 —— 自动回收生产**未启用** /// ⚠️ 别再基于「这个值应该可配」去做 env 化/配置化 —— 自动回收生产**未启用**
/// (PAC_PLAN_AUTO_RECYCLE 未配),这个常量当前没有任何读者会真正生效; /// (PAC_PLAN_AUTO_RECYCLE 未配),这个常量当前没有任何读者会真正生效;
/// 而分配的时走的是另一条路(plan_assignments.expires_at / assignment_expires_at), /// 而分配的时走的是另一条路(plan_assignments.expires_at / assignment_expires_at),
/// 与它互不干扰。原注释写的「后续接 tenant 配置」会误导人 —— PAC 全仓没有 Tenant 模型。 /// 与它互不干扰。原注释写的「后续接 tenant 配置」会误导人 —— PAC 全仓没有 Tenant 模型。
const RECYCLE_TIMEOUT_HOURS = 24; const RECYCLE_TIMEOUT_HOURS = 24;
......
...@@ -132,8 +132,8 @@ describe('确认单草稿态 —— 前端产、请求带、工具读', () => { ...@@ -132,8 +132,8 @@ describe('确认单草稿态 —— 前端产、请求带、工具读', () => {
describe('改单回声 —— 每一条改动路径都要发', () => { describe('改单回声 —— 每一条改动路径都要发', () => {
/** /**
* 🔴 2026-08-15 之前**只有一条**路径发回声,另外几条全静默: * 🔴 2026-08-15 之前**只有一条**路径发回声,另外几条全静默:
* 拖动改派、点 ×(明细行 / 待分配组)、右上角整批时效、每行单独时效 * 拖动改派、点 ×(明细行 / 待分配组)、右上角整批时限、每行单独时限
* 连引导按钮里的「改时」都不发 —— 而那颗按钮正是模型自己开出来的。 * 连引导按钮里的「改时」都不发 —— 而那颗按钮正是模型自己开出来的。
* ⇒ 这条测的是**纪律在源码上可数**:改状态的地方后面必须紧跟一个 echo。 * ⇒ 这条测的是**纪律在源码上可数**:改状态的地方后面必须紧跟一个 echo。
* ⚠️ 窗口给 12 行:批量改单那条会在 `setX(...)` 与 `notes.push(...)` 之间夹一段注释, * ⚠️ 窗口给 12 行:批量改单那条会在 `setX(...)` 与 `notes.push(...)` 之间夹一段注释,
* 6 行会把它误判成静默。⛔ 别再放宽 —— 真静默的点周围**几十行内**一个都没有, * 6 行会把它误判成静默。⛔ 别再放宽 —— 真静默的点周围**几十行内**一个都没有,
...@@ -212,7 +212,7 @@ describe('模型选择 —— 谁压过谁', () => { ...@@ -212,7 +212,7 @@ describe('模型选择 —— 谁压过谁', () => {
* *
* 拆开看:19 人做了 95.8% 的回访,98 人只有 1 条;**160 人的主场在别家诊所**, * 拆开看:19 人做了 95.8% 的回访,98 人只有 1 条;**160 人的主场在别家诊所**,
* 他们在这儿总共 330 条(产品指认的康慧捧:主场 3,799 条在另一家,这儿 3 条)。 * 他们在这儿总共 330 条(产品指认的康慧捧:主场 3,799 条在另一家,这儿 3 条)。
* 而 `rosterCount` 直接进默认批次估算(在岗人数 × 每天几通 × 时)—— * 而 `rosterCount` 直接进默认批次估算(在岗人数 × 每天几通 × 时)——
* 名册虚高一个数量级,默认批次就跟着虚高一个数量级。 * 名册虚高一个数量级,默认批次就跟着虚高一个数量级。
* *
* ⚠️ 这里锁的是**判据在不在**,⛔ 不锁阈值数字(20% / 20 条是产品可调的)。 * ⚠️ 这里锁的是**判据在不在**,⛔ 不锁阈值数字(20% / 20 条是产品可调的)。
......
...@@ -14,7 +14,7 @@ import { PrismaService } from '../src/prisma/prisma.service'; ...@@ -14,7 +14,7 @@ import { PrismaService } from '../src/prisma/prisma.service';
* *
* 另一半:到期与退回**必须分开数**。主管的下一步动作是相反的 —— * 另一半:到期与退回**必须分开数**。主管的下一步动作是相反的 ——
* 退回多 → 分配策略不对(派给了不该派的人); * 退回多 → 分配策略不对(派给了不该派的人);
* 到期多 → 派多了 / 时太紧 / 人不在岗。 * 到期多 → 派多了 / 时太紧 / 人不在岗。
* 合成一个"回池率"两种病都看不出来。 * 合成一个"回池率"两种病都看不出来。
*/ */
......
...@@ -508,12 +508,12 @@ describe('selectionNote —— 候选不够时的措辞', () => { ...@@ -508,12 +508,12 @@ describe('selectionNote —— 候选不够时的措辞', () => {
}); });
/** /**
* 两个基数(本批人数 + 时)的**沿用**,以及"连着分第二批"。 * 两个基数(本批人数 + 时)的**沿用**,以及"连着分第二批"。
* *
* 🔴 这是「先出确认单、主管反馈调整」这个设计的兑现点:第一次调顺手,第二次起零输入。 * 🔴 这是「先出确认单、主管反馈调整」这个设计的兑现点:第一次调顺手,第二次起零输入。
* 沿用断了不会报错 —— 只是主管每次都要重调一遍,然后觉得"这系统不记事"。 * 沿用断了不会报错 —— 只是主管每次都要重调一遍,然后觉得"这系统不记事"。
*/ */
describe('基数沿用 —— 本批人数与时', () => { describe('基数沿用 —— 本批人数与时', () => {
const SCOPE = { hostId: 'h', tenantId: 't', sourceUnits: [], clinicIds: ['c1'], userId: 'u' }; const SCOPE = { hostId: 'h', tenantId: 't', sourceUnits: [], clinicIds: ['c1'], userId: 'u' };
const mk = (candidateCount: number, agentCount: number, opts: Record<string, unknown> = {}) => { const mk = (candidateCount: number, agentCount: number, opts: Record<string, unknown> = {}) => {
const prisma = { const prisma = {
...@@ -567,7 +567,7 @@ describe('基数沿用 —— 本批人数与时效', () => { ...@@ -567,7 +567,7 @@ describe('基数沿用 —— 本批人数与时效', () => {
}); });
/** /**
* 🔴 2026-08-12 产品改判:**不再沿用上一次**,每次都按 `在岗 × 每天 15 通 × 时` 算。 * 🔴 2026-08-12 产品改判:**不再沿用上一次**,每次都按 `在岗 × 每天 15 通 × 时` 算。
* *
* 原设计是"调一次省一次",但它让 N 悄悄漂 —— 某次为了一个小格子调成 44, * 原设计是"调一次省一次",但它让 N 悄悄漂 —— 某次为了一个小格子调成 44,
* 此后所有批次就永远是 44,而主管完全看不出为什么变小了。 * 此后所有批次就永远是 44,而主管完全看不出为什么变小了。
...@@ -603,8 +603,8 @@ describe('基数沿用 —— 本批人数与时效', () => { ...@@ -603,8 +603,8 @@ describe('基数沿用 —— 本批人数与时效', () => {
} }
}); });
/** 🔴 N 跟时挂钩:D 从 3 改到 5,能承接的量本来就该跟着变。 */ /** 🔴 N 跟时挂钩:D 从 3 改到 5,能承接的量本来就该跟着变。 */
test('🔴 时给 5 天 → N 跟着变成 9 × 15 × 5', async () => { test('🔴 时给 5 天 → N 跟着变成 9 × 15 × 5', async () => {
const r = await mk(2000, 9).propose(SCOPE, { clinicId: 'c1', expiresInDays: 5 }); const r = await mk(2000, 9).propose(SCOPE, { clinicId: 'c1', expiresInDays: 5 });
expect(r.expiresInDays).toBe(5); expect(r.expiresInDays).toBe(5);
expect(r.batchSize).toBe(9 * 15 * 5); expect(r.batchSize).toBe(9 * 15 * 5);
...@@ -621,7 +621,7 @@ describe('基数沿用 —— 本批人数与时效', () => { ...@@ -621,7 +621,7 @@ describe('基数沿用 —— 本批人数与时效', () => {
/** /**
* 🔴 2026-08-14 **改判**:原断言是 `basisNote` 里要有「您设的 135 没变」。两处都错了 —— * 🔴 2026-08-14 **改判**:原断言是 `basisNote` 里要有「您设的 135 没变」。两处都错了 ——
* ① basisNote 没有消费方(已删); * ① basisNote 没有消费方(已删);
* ② 135 是系统按「在岗 × 每天几通 × 时」算的,**主管一个数都没设过**, * ② 135 是系统按「在岗 × 每天几通 × 时」算的,**主管一个数都没设过**,
* 叫它"您设的"会让他去找自己什么时候设过 135。 * 叫它"您设的"会让他去找自己什么时候设过 135。
* ⇒ `basis='default'` 时这个数**根本不进模型上下文**(见 assignment-facts 那段注释), * ⇒ `basis='default'` 时这个数**根本不进模型上下文**(见 assignment-facts 那段注释),
* 对应的断言在 `assignment-signals.spec` 的「首次默认的基数不给模型」。 * 对应的断言在 `assignment-signals.spec` 的「首次默认的基数不给模型」。
...@@ -668,7 +668,7 @@ describe('基数沿用 —— 本批人数与时效', () => { ...@@ -668,7 +668,7 @@ describe('基数沿用 —— 本批人数与时效', () => {
expect(r.placed).toBe(100); expect(r.placed).toBe(100);
}); });
test('⭐ 时精调只落在那个人身上,不动整体基数', async () => { test('⭐ 时精调只落在那个人身上,不动整体基数', async () => {
const r = await mk(1000, 9, { const r = await mk(1000, 9, {
lastCriteria: { expiresInDays: 3, agentOverrides: { a3: { expiresInDays: 7 } } }, lastCriteria: { expiresInDays: 3, agentOverrides: { a3: { expiresInDays: 7 } } },
}).propose(SCOPE, { clinicId: 'c1', targetCount: 100 }); }).propose(SCOPE, { clinicId: 'c1', targetCount: 100 });
...@@ -693,7 +693,7 @@ describe('基数沿用 —— 本批人数与时效', () => { ...@@ -693,7 +693,7 @@ describe('基数沿用 —— 本批人数与时效', () => {
* 2026-08-04 产品评审要的是"结论性的东西"(本批拿到几条 / 分完手上几条 / 要打几天)。 * 2026-08-04 产品评审要的是"结论性的东西"(本批拿到几条 / 分完手上几条 / 要打几天)。
* 那个诉求没变,只是**出口换了**——同一批数现在有三处,卡片那份是第三遍: * 那个诉求没变,只是**出口换了**——同一批数现在有三处,卡片那份是第三遍:
* ① 模型正文(modelFacts 的「本批每人拿到 / 分完之后每人手上」); * ① 模型正文(modelFacts 的「本批每人拿到 / 分完之后每人手上」);
* ② 引导节点 `batch_size_basis`(这批多大 = 在岗 × 每天几通 × 时,可改); * ② 引导节点 `batch_size_basis`(这批多大 = 在岗 × 每天几通 × 时,可改);
* ③ 引导节点 `daily_overload`(最忙那位手上共几条、约几天的量)。 * ③ 引导节点 `daily_overload`(最忙那位手上共几条、约几天的量)。
* ⇒ 下面只留 basisNote 那半条不变式(增量与总量**仍然不许混**)。 * ⇒ 下面只留 basisNote 那半条不变式(增量与总量**仍然不许混**)。
*/ */
......
...@@ -104,8 +104,8 @@ describe('引导节点 · 判定', () => { ...@@ -104,8 +104,8 @@ describe('引导节点 · 判定', () => {
* 同样是空的(「减少一些」)。⇒ 按钮不带数 ⇒ 严格弱于他自己说一句。 * 同样是空的(「减少一些」)。⇒ 按钮不带数 ⇒ 严格弱于他自己说一句。
* ⚠️ 这条断言用 `toEqual` 锁**整个数组**,就是为了拦住"顺手再加一个"。 * ⚠️ 这条断言用 `toEqual` 锁**整个数组**,就是为了拦住"顺手再加一个"。
*/ */
test('🔴 手上的量超出时 → 出 daily_overload,只给带数的那一个动作', () => { test('🔴 手上的量超出时 → 出 daily_overload,只给带数的那一个动作', () => {
// 每天 15 通 × 3 天 = 45 条是时内打得完的量;60 条要 4 天 // 每天 15 通 × 3 天 = 45 条是时内打得完的量;60 条要 4 天
const p = proposal({ expiresInDays: 3, byAgent: [agent('李莉', 30), agent('张悦', 60)] }); const p = proposal({ expiresInDays: 3, byAgent: [agent('李莉', 30), agent('张悦', 60)] });
const s = computeSignals(p).find((x) => x.key === 'daily_overload')!; const s = computeSignals(p).find((x) => x.key === 'daily_overload')!;
expect(s).toBeDefined(); expect(s).toBeDefined();
...@@ -165,7 +165,7 @@ describe('引导节点 · 判定', () => { ...@@ -165,7 +165,7 @@ describe('引导节点 · 判定', () => {
/** /**
* 🔴 两项都必须走 `basis.set`(**重跑算法**),⛔ 不许是 `expiry.set`: * 🔴 两项都必须走 `basis.set`(**重跑算法**),⛔ 不许是 `expiry.set`:
* 那个只改已经算好这批人的到期日,人数一个不变 —— 而本节点的 why 写着 * 那个只改已经算好这批人的到期日,人数一个不变 —— 而本节点的 why 写着
* 「改时或改每天几通,这批人数都会跟着变」。用错了节点就在说谎。 * 「改时或改每天几通,这批人数都会跟着变」。用错了节点就在说谎。
*/ */
expect(s.options.map((o) => o.intent)).toEqual(['basis.set', 'basis.set']); expect(s.options.map((o) => o.intent)).toEqual(['basis.set', 'basis.set']);
const byKey = Object.fromEntries(s.options.map((o) => [o.input!.argKey, o.input!])); const byKey = Object.fromEntries(s.options.map((o) => [o.input!.argKey, o.input!]));
...@@ -178,7 +178,7 @@ describe('引导节点 · 判定', () => { ...@@ -178,7 +178,7 @@ describe('引导节点 · 判定', () => {
} }
}); });
test('⛔ 主管自己设过基数/时 → 不出这条(他知道那两个数哪来的)', () => { test('⛔ 主管自己设过基数/时 → 不出这条(他知道那两个数哪来的)', () => {
expect(keys(proposal({ basis: 'explicit' }))).not.toContain('batch_size_basis'); expect(keys(proposal({ basis: 'explicit' }))).not.toContain('batch_size_basis');
}); });
...@@ -230,7 +230,7 @@ describe('引导节点 · 判定', () => { ...@@ -230,7 +230,7 @@ describe('引导节点 · 判定', () => {
expect(short.map((x) => x.key)).toEqual(['pending', 'daily_overload', 'batch_size_basis']); expect(short.map((x) => x.key)).toEqual(['pending', 'daily_overload', 'batch_size_basis']);
/** /**
* 🔴 候选够不着 N 时,那句话必须**据实**说清方向 —— 往上调确实不会更多。 * 🔴 候选够不着 N 时,那句话必须**据实**说清方向 —— 往上调确实不会更多。
* ⛔ 不许再说「改时或改每天几通,这批人数都会跟着变」:他往上调一次没反应, * ⛔ 不许再说「改时或改每天几通,这批人数都会跟着变」:他往上调一次没反应,
* 下次就不信这张卡片上的任何一句话了。 * 下次就不信这张卡片上的任何一句话了。
*/ */
const capped = short.find((x) => x.key === 'batch_size_basis')!; const capped = short.find((x) => x.key === 'batch_size_basis')!;
...@@ -258,7 +258,7 @@ describe('引导节点 · 判定', () => { ...@@ -258,7 +258,7 @@ describe('引导节点 · 判定', () => {
* 🔴 三条判据都要认出「这一版是主管自己收窄来的」(2026-08-13 走查)。 * 🔴 三条判据都要认出「这一版是主管自己收窄来的」(2026-08-13 走查)。
* 在此之前它们只看这一版的绝对状态,于是主管按消费切完之后: * 在此之前它们只看这一版的绝对状态,于是主管按消费切完之后:
* · short_supply 把他自己造成的"人少"报成故障,还递上两个会**丢掉他刚加的条件**的按钮; * · short_supply 把他自己造成的"人少"报成故障,还递上两个会**丢掉他刚加的条件**的按钮;
* · batch_size_basis 在候选是瓶颈时仍然说「改时人数会跟着变」—— 那句话是假的; * · batch_size_basis 在候选是瓶颈时仍然说「改时人数会跟着变」—— 那句话是假的;
* · narrow 又递上同一把刀(阈值还是他那一刀造出来的),形成自我收窄的螺旋。 * · narrow 又递上同一把刀(阈值还是他那一刀造出来的),形成自我收窄的螺旋。
*/ */
/** /**
...@@ -519,7 +519,7 @@ describe('给模型的事实', () => { ...@@ -519,7 +519,7 @@ describe('给模型的事实', () => {
* 🔴 **键名本身就会被念出去** —— 2026-08-14 实测原话: * 🔴 **键名本身就会被念出去** —— 2026-08-14 实测原话:
* 「这一格总共就 3 人,比估算的**基数**(120)少得多」。 * 「这一格总共就 3 人,比估算的**基数**(120)少得多」。
* 当时这个键叫 `您设的基数`:① 「基数」就列在提示词的禁说清单里, * 当时这个键叫 `您设的基数`:① 「基数」就列在提示词的禁说清单里,
* ② 那 120 是系统按「在岗 × 每天几通 × 时」算的,主管一个数都没设过。 * ② 那 120 是系统按「在岗 × 每天几通 × 时」算的,主管一个数都没设过。
* ⇒ 禁令拦不住紧挨着数字的**键名**;键名必须自己说人话。 * ⇒ 禁令拦不住紧挨着数字的**键名**;键名必须自己说人话。
*/ */
test('🔴 事实里不许出现「基数」「您设的」这类词(键名会被原样念给主管)', () => { test('🔴 事实里不许出现「基数」「您设的」这类词(键名会被原样念给主管)', () => {
...@@ -529,7 +529,7 @@ describe('给模型的事实', () => { ...@@ -529,7 +529,7 @@ describe('给模型的事实', () => {
}); });
/** /**
* 🔴 首次默认那个数是**机器算的**(在岗 × 每天几通 × 时),主管没设过 —— * 🔴 首次默认那个数是**机器算的**(在岗 × 每天几通 × 时),主管没设过 ——
* 给了他就要听一句「估的基数是 45,但这格只有 5 人」(2026-08-14 实测原话)。 * 给了他就要听一句「估的基数是 45,但这格只有 5 人」(2026-08-14 实测原话)。
* ⇒ 没有"设置被改小"这回事可防时,这个数就不进模型上下文。 * ⇒ 没有"设置被改小"这回事可防时,这个数就不进模型上下文。
*/ */
...@@ -684,7 +684,7 @@ describe('给模型的事实', () => { ...@@ -684,7 +684,7 @@ describe('给模型的事实', () => {
expect(f.怎么选的.本批人数还能怎么调?.map((x) => x['是什么'])).toEqual(titles('size')); expect(f.怎么选的.本批人数还能怎么调?.map((x) => x['是什么'])).toEqual(titles('size'));
// 🔴 派活那一组 2026-08-14 从顶层搬进「怎么派的」—— 就近,跟另外两组一致 // 🔴 派活那一组 2026-08-14 从顶层搬进「怎么派的」—— 就近,跟另外两组一致
expect(f.怎么派的.要你定的?.map((x) => x['是什么'])).toEqual(titles('dispatch')); expect(f.怎么派的.要你定的?.map((x) => x['是什么'])).toEqual(titles('dispatch'));
// 🔴 改人数那条**⛔ 不许**混进选人那一组(走查抓到过「还能怎么选:」下面挂着改时 // 🔴 改人数那条**⛔ 不许**混进选人那一组(走查抓到过「还能怎么选:」下面挂着改时
expect(f.怎么选的.这一格还能怎么选 ?? []).not.toContainEqual( expect(f.怎么选的.这一格还能怎么选 ?? []).not.toContainEqual(
expect.objectContaining({ 是什么: expect.stringContaining('每天') }), expect.objectContaining({ 是什么: expect.stringContaining('每天') }),
); );
...@@ -767,9 +767,9 @@ describe('没分到的那几位 —— 「为什么没轮到」要答得上来', ...@@ -767,9 +767,9 @@ describe('没分到的那几位 —— 「为什么没轮到」要答得上来',
}); });
/** /**
* 🔴 「最忙的那位」要报**一共几位超时**(2026-08-16 产品定)。 * 🔴 「最忙的那位」要报**一共几位超时**(2026-08-16 产品定)。
* 只报最忙一位,主管分不出"个别人忙"和"全队都忙" —— 而这两种局面的处置相反: * 只报最忙一位,主管分不出"个别人忙"和"全队都忙" —— 而这两种局面的处置相反:
* 前者该给他少分点/改派,后者该减量/延时效。而本节点唯一的选项是「整批时效改成 N 天」, * 前者该给他少分点/改派,后者该减量/延时限。而本节点唯一的选项是「整批时限改成 N 天」,
* 碰上前者它本身就是错的。 * 碰上前者它本身就是错的。
*/ */
describe('引导节点 · 最忙的那位要说清是一个人还是一片', () => { describe('引导节点 · 最忙的那位要说清是一个人还是一片', () => {
...@@ -781,8 +781,8 @@ describe('引导节点 · 最忙的那位要说清是一个人还是一片', () ...@@ -781,8 +781,8 @@ describe('引导节点 · 最忙的那位要说清是一个人还是一片', ()
loadAfter, loadAfter,
}); });
test('🔴 多人超时 → 报「另有 N 位」,并把他引到确认单看逐位明细', () => { test('🔴 多人超时 → 报「另有 N 位」,并把他引到确认单看逐位明细', () => {
// 时 1 天 × 每天 15 通 = 15 条封顶;三个人都超 // 时 1 天 × 每天 15 通 = 15 条封顶;三个人都超
const s = computeSignals( const s = computeSignals(
proposal({ proposal({
expiresInDays: 1, expiresInDays: 1,
......
...@@ -234,7 +234,7 @@ export const GOLDEN_CASES: GoldenCase[] = [ ...@@ -234,7 +234,7 @@ export const GOLDEN_CASES: GoldenCase[] = [
/** /**
* 🔴 主管在卡片上做的改动**模型看不见** —— 确认之前那份状态只在浏览器里。 * 🔴 主管在卡片上做的改动**模型看不见** —— 确认之前那份状态只在浏览器里。
* 在 `get_current_sheet` 之前,它只能把对话里几行回声自己累加, * 在 `get_current_sheet` 之前,它只能把对话里几行回声自己累加,
* 而手动拖动 / 点 × / 改时这几条路当时连回声都没有:卡片上写着 3 天,它嘴上还说 1 天。 * 而手动拖动 / 点 × / 改时这几条路当时连回声都没有:卡片上写着 3 天,它嘴上还说 1 天。
* ⛔ `mustNotCall: propose_assignment` —— 问"现在什么情况"是**看**,不是重出一版。 * ⛔ `mustNotCall: propose_assignment` —— 问"现在什么情况"是**看**,不是重出一版。
*/ */
why: '2026-08-15 实测:主管问「那 1 位为什么没轮到」,模型手里只有一个光秃秃的数字,答不上来。', why: '2026-08-15 实测:主管问「那 1 位为什么没轮到」,模型手里只有一个光秃秃的数字,答不上来。',
......
...@@ -447,10 +447,10 @@ describe('确认后没配福利 → 提醒补挂,但不许编效果', () => { ...@@ -447,10 +447,10 @@ describe('确认后没配福利 → 提醒补挂,但不许编效果', () => {
* 锁整句等于把"这条规矩必须写成一句话"也锁了进去。 * 锁整句等于把"这条规矩必须写成一句话"也锁了进去。
* ⇒ 锁的是**这一步在、且三条不变量都在**。 * ⇒ 锁的是**这一步在、且三条不变量都在**。
*/ */
test('提示词里要说清"确认后仍可补挂、但人员时改不了"', () => { test('提示词里要说清"确认后仍可补挂、但人员时改不了"', () => {
expect(PROMPTS).toMatch(/### 确认之后/); expect(PROMPTS).toMatch(/### 确认之后/);
expect(PROMPTS).toMatch(/补挂/); expect(PROMPTS).toMatch(/补挂/);
expect(PROMPTS).toMatch(/人员和时改不了/); expect(PROMPTS).toMatch(/人员和时改不了/);
expect(PROMPTS).toMatch(/撤销重分/); expect(PROMPTS).toMatch(/撤销重分/);
}); });
}); });
...@@ -630,8 +630,8 @@ describe('待分配的人 —— 对话指令要能指到', () => { ...@@ -630,8 +630,8 @@ describe('待分配的人 —— 对话指令要能指到', () => {
expect(SHEET).toMatch(/pendingRest\(\)\.filter\(hit\)/); expect(SHEET).toMatch(/pendingRest\(\)\.filter\(hit\)/);
}); });
test('⭐ 给待分配的人改时要说明"分给客服后才生效"(否则主管以为已进批次)', () => { test('⭐ 给待分配的人改时要说明"分给客服后才生效"(否则主管以为已进批次)', () => {
expect(SHEET).toMatch(/分给客服后这个时才生效/); expect(SHEET).toMatch(/分给客服后这个时才生效/);
}); });
test('⭐⭐ findAgent 也要能指到「本批未分到」的客服 —— 他们恰恰手上最空', () => { test('⭐⭐ findAgent 也要能指到「本批未分到」的客服 —— 他们恰恰手上最空', () => {
...@@ -944,9 +944,9 @@ describe('确认单指令 —— 三个正交的轴', () => { ...@@ -944,9 +944,9 @@ describe('确认单指令 —— 三个正交的轴', () => {
], ],
['待分配的先都不管', { select: { group: 'pending' }, action: 'remove' }], ['待分配的先都不管', { select: { group: 'pending' }, action: 'remove' }],
// ⭐ 下面三条在旧设计里**一条都表达不了** —— 正交化白捡的 // ⭐ 下面三条在旧设计里**一条都表达不了** —— 正交化白捡的
['待分配的时给 5 天', { select: { group: 'pending' }, action: 'set_expiry', days: 5 }], ['待分配的时给 5 天', { select: { group: 'pending' }, action: 'set_expiry', days: 5 }],
[ [
'张悦名下的时给 5 天', '张悦名下的时给 5 天',
{ select: { group: 'agent', agent: '张悦' }, action: 'set_expiry', days: 5 } { select: { group: 'agent', agent: '张悦' }, action: 'set_expiry', days: 5 }
], ],
['王强的单给 5 天', { select: { group: 'patients', patients: ['王强'] }, action: 'set_expiry', days: 5 }], ['王强的单给 5 天', { select: { group: 'patients', patients: ['王强'] }, action: 'set_expiry', days: 5 }],
...@@ -1067,7 +1067,7 @@ describe('重新排一版 —— 只换挑谁,不动落人', () => { ...@@ -1067,7 +1067,7 @@ describe('重新排一版 —— 只换挑谁,不动落人', () => {
* *
* 2026-08-04 产品评审要的"结论性的东西"(这活多大)诉求没变,只是出口换了 —— * 2026-08-04 产品评审要的"结论性的东西"(这活多大)诉求没变,只是出口换了 ——
* 同一批数现在有三处,卡片那份是第三遍:模型正文 + `batch_size_basis` + `daily_overload`。 * 同一批数现在有三处,卡片那份是第三遍:模型正文 + `batch_size_basis` + `daily_overload`。
* ⇒ 卡片那个位置改放**只有卡片能做的事**(拖、点 ×、逐条改时)。 * ⇒ 卡片那个位置改放**只有卡片能做的事**(拖、点 ×、逐条改时)。
* ⚠️ 这条测试锁的是**不许加回来**。 * ⚠️ 这条测试锁的是**不许加回来**。
*/ */
test('⛔ opsNote 已删,卡片那个位置放的是"这张单能怎么改"', () => { test('⛔ opsNote 已删,卡片那个位置放的是"这张单能怎么改"', () => {
...@@ -1230,7 +1230,7 @@ describe('工具描述不许与实现漂移', () => { ...@@ -1230,7 +1230,7 @@ describe('工具描述不许与实现漂移', () => {
join(__dirname, '../src/modules/assistant/assistant.service.ts'), join(__dirname, '../src/modules/assistant/assistant.service.ts'),
'utf8', 'utf8',
); );
// 服务端夹的三处:dailyCalls 1–60 / exploreRatio 0–0.2 / 时 ≤90(与落库 schema 对齐) // 服务端夹的三处:dailyCalls 1–60 / exploreRatio 0–0.2 / 时 ≤90(与落库 schema 对齐)
expect(SRC).toMatch(/minimum: 1,\s*\n\s*maximum: 60/); expect(SRC).toMatch(/minimum: 1,\s*\n\s*maximum: 60/);
expect(SRC).toMatch(/minimum: 0,\s*\n\s*maximum: 0\.2/); expect(SRC).toMatch(/minimum: 0,\s*\n\s*maximum: 0\.2/);
expect(SRC).toMatch(/maximum: 90/); expect(SRC).toMatch(/maximum: 90/);
......
...@@ -73,7 +73,7 @@ describe('ReleaseReason —— 退回原因', () => { ...@@ -73,7 +73,7 @@ describe('ReleaseReason —— 退回原因', () => {
test('⭐ 与召回反馈值域不重叠 —— 同名不同义是统计事故的标准配方', () => { test('⭐ 与召回反馈值域不重叠 —— 同名不同义是统计事故的标准配方', () => {
// RECALL_FEEDBACK_OPTIONS.bad_timing = "召回时机不对(太早/太晚)",冲算法去的; // RECALL_FEEDBACK_OPTIONS.bad_timing = "召回时机不对(太早/太晚)",冲算法去的;
// 退回的"时太紧"冲派单去的。故本枚举刻意不设 bad_timing。 // 退回的"时太紧"冲派单去的。故本枚举刻意不设 bad_timing。
const feedbackValues = RECALL_FEEDBACK_OPTIONS.map((o) => o.value as string); const feedbackValues = RECALL_FEEDBACK_OPTIONS.map((o) => o.value as string);
for (const k of Object.values(ReleaseReason)) { for (const k of Object.values(ReleaseReason)) {
if (k === ReleaseReason.OTHER) continue; // other 是通用兜底键,允许同名 if (k === ReleaseReason.OTHER) continue; // other 是通用兜底键,允许同名
......
...@@ -785,7 +785,7 @@ function MessageView({ ...@@ -785,7 +785,7 @@ function MessageView({
// 🔴 确认单**必须用稳定 key**(requestId),⛔ 不能用下标。 // 🔴 确认单**必须用稳定 key**(requestId),⛔ 不能用下标。
// 下标 key + "把 sheet 钉到消息末尾"的排序 = 助手每追加一句文本, // 下标 key + "把 sheet 钉到消息末尾"的排序 = 助手每追加一句文本,
// sheet 的下标就变 → React 卸载重建组件 → **本地状态全丢**: // sheet 的下标就变 → React 卸载重建组件 → **本地状态全丢**:
// 主管删的条、拖的改派、逐条时、以及"能不能撤销"的确认时刻, // 主管删的条、拖的改派、逐条时、以及"能不能撤销"的确认时刻,
// 都会在助手说下一句话时被悄悄清空(实测:确认后撤销按钮直接不出现)。 // 都会在助手说下一句话时被悄悄清空(实测:确认后撤销按钮直接不出现)。
// ⚠️ 这是排序改动引入的回归 —— 排序本身没错,错在 key 跟着位置走。 // ⚠️ 这是排序改动引入的回归 —— 排序本身没错,错在 key 跟着位置走。
key={b.kind === 'assignment_sheet' ? `sheet:${b.requestId}` : `b:${i}`} key={b.kind === 'assignment_sheet' ? `sheet:${b.requestId}` : `b:${i}`}
......
...@@ -39,7 +39,7 @@ export function AssistantWidget() { ...@@ -39,7 +39,7 @@ export function AssistantWidget() {
* ⚠️ **这个 480 与下面 className 里的 `w-[480px]` 是一对,改一处要改两处** * ⚠️ **这个 480 与下面 className 里的 `w-[480px]` 是一对,改一处要改两处**
* (Tailwind 的任意值必须是静态字面量,没法从这里的常量插进去)。 * (Tailwind 的任意值必须是静态字面量,没法从这里的常量插进去)。
* 🔴 400 → 480(2026-08-11 产品:「窗口调宽一点」):确认单在 400px 里排不开 —— * 🔴 400 → 480(2026-08-11 产品:「窗口调宽一点」):确认单在 400px 里排不开 ——
* 汇总行的「已移除 1」被挤成竖排单字,时下拉顶出边界。 * 汇总行的「已移除 1」被挤成竖排单字,时下拉顶出边界。
* ⛔ 别再往回收:那张卡是这个窗口的**主要内容**,窗口要迁就它。 * ⛔ 别再往回收:那张卡是这个窗口的**主要内容**,窗口要迁就它。
*/ */
const W = Math.min(480, window.innerWidth - 16); const W = Math.min(480, window.innerWidth - 16);
......
...@@ -179,7 +179,7 @@ describe('引导节点 intent 路由', () => { ...@@ -179,7 +179,7 @@ describe('引导节点 intent 路由', () => {
]); ]);
}); });
it('改时 → 局部改单;缺 days → null(⛔ 不许瞎猜一个天数)', () => { it('改时 → 局部改单;缺 days → null(⛔ 不许瞎猜一个天数)', () => {
expect(intentToSheetOps('expiry.set', { days: 5 })).toEqual([ expect(intentToSheetOps('expiry.set', { days: 5 })).toEqual([
{ select: { group: 'batch' }, action: 'set_expiry', days: 5 }, { select: { group: 'batch' }, action: 'set_expiry', days: 5 },
]); ]);
......
...@@ -327,13 +327,13 @@ export function intentToPrompt( ...@@ -327,13 +327,13 @@ export function intentToPrompt(
// 删于 2026-08-14:换格子是主管在矩阵上一点的事,而矩阵每格都写着人数, // 删于 2026-08-14:换格子是主管在矩阵上一点的事,而矩阵每格都写着人数,
// 卡片上的按钮反而不带数 ⇒ 严格弱于矩阵。⛔ 别加回来。 // 卡片上的按钮反而不带数 ⇒ 严格弱于矩阵。⛔ 别加回来。
/** /**
* 「本批多大」那个式子改了一项 → **重跑算法**(N = 在岗 × 每天几通 × 时)。 * 「本批多大」那个式子改了一项 → **重跑算法**(N = 在岗 × 每天几通 × 时)。
* ⭐ 带上主管填的那个数:propose_assignment 有 `expiresInDays` / `dailyCalls` 两个参数, * ⭐ 带上主管填的那个数:propose_assignment 有 `expiresInDays` / `dailyCalls` 两个参数,
* **服务端**据此重估 N。⛔ 别让模型自己去乘 —— 报出去的数必须是工具算的。 * **服务端**据此重估 N。⛔ 别让模型自己去乘 —— 报出去的数必须是工具算的。
* ⚠️ ⛔ 别退化成 `expiry.set`:那个只改到期日,人数一个不变,而节点原话说会变。 * ⚠️ ⛔ 别退化成 `expiry.set`:那个只改到期日,人数一个不变,而节点原话说会变。
*/ */
case 'basis.set': { case 'basis.set': {
if (typeof args?.days === 'number') return `时改成 ${args.days} 天,重新排一版`; if (typeof args?.days === 'number') return `时改成 ${args.days} 天,重新排一版`;
if (typeof args?.rate === 'number') return `每人每天按 ${args.rate} 通算,重新排一版`; if (typeof args?.rate === 'number') return `每人每天按 ${args.rate} 通算,重新排一版`;
return null; return null;
} }
......
...@@ -105,7 +105,7 @@ function Row({ ...@@ -105,7 +105,7 @@ function Row({
muted?: boolean; muted?: boolean;
}) { }) {
/** /**
* ⭐ **`post_confirm` 那类在只读态下照样可点** —— 只读说的是"这批人和时定死了", * ⭐ **`post_confirm` 那类在只读态下照样可点** —— 只读说的是"这批人和时定死了",
* 而这类节点恰恰是**分下去之后仍然能做**的那几件(补挂福利只影响此后生成的话术)。 * 而这类节点恰恰是**分下去之后仍然能做**的那几件(补挂福利只影响此后生成的话术)。
* ⛔ 跟着 readOnly 一起禁掉,就等于摆一句「这批还没带福利」然后什么也不让他做。 * ⛔ 跟着 readOnly 一起禁掉,就等于摆一句「这批还没带福利」然后什么也不让他做。
*/ */
......
...@@ -28,12 +28,12 @@ import type { Signal, SignalOption } from '@pac/types'; ...@@ -28,12 +28,12 @@ import type { Signal, SignalOption } from '@pac/types';
* ⇒ 兜底是**同一排里更次要的一个按钮**(虚线框、灰字),⛔ 不做成和选项平级的输入框。 * ⇒ 兜底是**同一排里更次要的一个按钮**(虚线框、灰字),⛔ 不做成和选项平级的输入框。
*/ */
/** /**
* 要填一个数的选项 —— 「改时 [1] 天」「改每天几通 [15] 通」。 * 要填一个数的选项 —— 「改时 [1] 天」「改每天几通 [15] 通」。
* *
* ⭐ 为什么是输入框而不是几个固定档:天数和通数是**连续量**,列 3/5/7 天必然漏掉他要的那个, * ⭐ 为什么是输入框而不是几个固定档:天数和通数是**连续量**,列 3/5/7 天必然漏掉他要的那个,
* 而漏掉的那次他只能去下面的自由输入打字 → 走模型理解 → 慢且可能错。填数是确定性的。 * 而漏掉的那次他只能去下面的自由输入打字 → 走模型理解 → 慢且可能错。填数是确定性的。
* ⚠️ `min/max` 是护栏:越界直接不给提交,⛔ 别让它走到服务端才报错。 * ⚠️ `min/max` 是护栏:越界直接不给提交,⛔ 别让它走到服务端才报错。
* ⚠️ 值没变就不提交 —— 「改时」填回原来的 1 天等于什么都没做, * ⚠️ 值没变就不提交 —— 「改时」填回原来的 1 天等于什么都没做,
* 真发出去会白跑一次重排(`daily_rate.set` 那条要重跑算法)。 * 真发出去会白跑一次重排(`daily_rate.set` 那条要重跑算法)。
*/ */
function NumberOption({ function NumberOption({
......
...@@ -358,7 +358,7 @@ export const mockPlan = { ...@@ -358,7 +358,7 @@ export const mockPlan = {
assigneeUserId: 'usr_csliu' as string | null, assigneeUserId: 'usr_csliu' as string | null,
assignedAt: NOW, assignedAt: NOW,
recycleAt: new Date(NOW.getTime() + 4 * 3600_000) as Date | null, recycleAt: new Date(NOW.getTime() + 4 * 3600_000) as Date | null,
// 分配单时:mock 给 2 天后到期(真实值 1-7 天,由主管在确认单上定) // 分配单时:mock 给 2 天后到期(真实值 1-7 天,由主管在确认单上定)
assignmentExpiresAt: new Date(NOW.getTime() + 2 * 24 * 3600_000) as Date | null, assignmentExpiresAt: new Date(NOW.getTime() + 2 * 24 * 3600_000) as Date | null,
/// 召回冷静期 / 终态抑制窗到期时间(null=无抑制)— 头部"下次回访 / 已暂缓至 X"角标 /// 召回冷静期 / 终态抑制窗到期时间(null=无抑制)— 头部"下次回访 / 已暂缓至 X"角标
snoozedUntil: null as Date | null, snoozedUntil: null as Date | null,
......
...@@ -1430,7 +1430,7 @@ function TopBar({ ...@@ -1430,7 +1430,7 @@ function TopBar({
)} )}
</div> </div>
)} )}
{/* ⭐ 分配单时 —— 只在单子**还挂在人手上**时显示(回到池子里的没有"还剩多久")。 {/* ⭐ 分配单时 —— 只在单子**还挂在人手上**时显示(回到池子里的没有"还剩多久")。
⚠️ 基于 assignmentExpiresAt(已启用),不是 recycleAt(认领的 24h 兜底,从未启用)。 */} ⚠️ 基于 assignmentExpiresAt(已启用),不是 recycleAt(认领的 24h 兜底,从未启用)。 */}
{plan.assigneeUserId && <ExpiryBadge expiresAt={plan.assignmentExpiresAt} />} {plan.assigneeUserId && <ExpiryBadge expiresAt={plan.assignmentExpiresAt} />}
{/* {/*
...@@ -1544,16 +1544,16 @@ function SupervisorBackLink() { ...@@ -1544,16 +1544,16 @@ function SupervisorBackLink() {
// ExpiryBadge — 分配单还剩多久(到点自动退回召回池) // ExpiryBadge — 分配单还剩多久(到点自动退回召回池)
// ────────────────────────────────────────── // ──────────────────────────────────────────
/** /**
* ⭐ 显示的是 **`assignmentExpiresAt`**(分配单时),⛔ 不是 `recycleAt`。 * ⭐ 显示的是 **`assignmentExpiresAt`**(分配单时),⛔ 不是 `recycleAt`。
* *
* 两个机制,长期被混为一谈: * 两个机制,长期被混为一谈:
* · `recycleAt` 认领后 24h 兜底回收 —— 生产**从未启用**,值几乎恒为 null。 * · `recycleAt` 认领后 24h 兜底回收 —— 生产**从未启用**,值几乎恒为 null。
* 头部原来那个 `HH:MM:SS` 倒计时就是基于它的,所以一直挂着"未设置回收时间", * 头部原来那个 `HH:MM:SS` 倒计时就是基于它的,所以一直挂着"未设置回收时间",
* 后来干脆注释掉了(见 git 历史里那句"暂无自动回收机制")。 * 后来干脆注释掉了(见 git 历史里那句"暂无自动回收机制")。
* · `assignmentExpiresAt` 主管在确认单上定的时(1-7 天)—— **已启用**, * · `assignmentExpiresAt` 主管在确认单上定的时(1-7 天)—— **已启用**,
* AssignmentExpiryScheduler 每 10 分钟扫一次,到点回池并记 `assignment_expired`。 * AssignmentExpiryScheduler 每 10 分钟扫一次,到点回池并记 `assignment_expired`。
* *
* ⚠️ 粒度也跟着换了:原来是 `HH:MM:SS`(为 24h 设计的),而现在时是**天**级 —— * ⚠️ 粒度也跟着换了:原来是 `HH:MM:SS`(为 24h 设计的),而现在时是**天**级 ——
* 3 天的单显示 `71:59:58` 没人读得出来。改成「还剩 2 天」/「还剩 5 小时」, * 3 天的单显示 `71:59:58` 没人读得出来。改成「还剩 2 天」/「还剩 5 小时」,
* 只有进入最后 1 小时才精确到分钟(那时候才真需要紧迫感)。 * 只有进入最后 1 小时才精确到分钟(那时候才真需要紧迫感)。
* ⚠️ 只在**单子还挂在人手上**时显示:回到池子里的单没有"还剩多久"可言。 * ⚠️ 只在**单子还挂在人手上**时显示:回到池子里的单没有"还剩多久"可言。
...@@ -1581,7 +1581,7 @@ function ExpiryBadge({ expiresAt }: { expiresAt: Date | null }) { ...@@ -1581,7 +1581,7 @@ function ExpiryBadge({ expiresAt }: { expiresAt: Date | null }) {
const days = Math.floor(hours / 24); const days = Math.floor(hours / 24);
const label = const label =
days >= 1 ? `还剩 ${days} 天` : hours >= 1 ? `还剩 ${hours} 小时` : `还剩 ${mins} 分钟`; days >= 1 ? `还剩 ${days} 天` : hours >= 1 ? `还剩 ${hours} 小时` : `还剩 ${mins} 分钟`;
// 最后一天开始转暖色,最后一小时转红 —— ⛔ 别更早变红:时本来就是几天, // 最后一天开始转暖色,最后一小时转红 —— ⛔ 别更早变红:时本来就是几天,
// 提前两天就飘红会让客服对颜色脱敏,真到期时反而看不见 // 提前两天就飘红会让客服对颜色脱敏,真到期时反而看不见
const tone = const tone =
hours < 1 hours < 1
......
...@@ -26,7 +26,7 @@ import { cn } from '@/lib/utils'; ...@@ -26,7 +26,7 @@ import { cn } from '@/lib/utils';
* `plansApi.recycle` 早就支持传 releaseReason、枚举齐了、聚合报表(releaseReasons 分布) * `plansApi.recycle` 早就支持传 releaseReason、枚举齐了、聚合报表(releaseReasons 分布)
* 也写了 —— 唯一的调用点却是 `plansApi.recycle(planId)`,一个参数都不传。 * 也写了 —— 唯一的调用点却是 `plansApi.recycle(planId)`,一个参数都不传。
* 于是那张分布报表永远拿到 null:主管只知道"退了 12 条",不知道该改什么 * 于是那张分布报表永远拿到 null:主管只知道"退了 12 条",不知道该改什么
* (是派多了?时太紧?还是压根不该派给他?)—— 而这三种的下一步动作完全不同。 * (是派多了?时太紧?还是压根不该派给他?)—— 而这三种的下一步动作完全不同。
* *
* ⚠️ 每个原因都带一句 `desc`:「不是我的客户」和「该由其他角色跟」光看标题分不出, * ⚠️ 每个原因都带一句 `desc`:「不是我的客户」和「该由其他角色跟」光看标题分不出,
* 分不出就会随手点第一个,那比不填还糟(假数据比没数据难发现)。 * 分不出就会随手点第一个,那比不填还糟(假数据比没数据难发现)。
...@@ -71,7 +71,7 @@ export function ReleaseReasonDialog({ ...@@ -71,7 +71,7 @@ export function ReleaseReasonDialog({
<div className="max-h-[46vh] space-y-1 overflow-y-auto py-1"> <div className="max-h-[46vh] space-y-1 overflow-y-auto py-1">
{/* {/*
⚠️ 走 releaseReasonsForForm() 而不是 Object.keys —— META 里留着 5 个**历史值** ⚠️ 走 releaseReasonsForForm() 而不是 Object.keys —— META 里留着 5 个**历史值**
(不是我的客户 / 这段时间不在 / 时太紧 / 最近刚联系过 / 其他), (不是我的客户 / 这段时间不在 / 时太紧 / 最近刚联系过 / 其他),
它们只为翻译库里的老数据而存在,⛔ 不能出现在选项里。 它们只为翻译库里的老数据而存在,⛔ 不能出现在选项里。
直接 Object.keys 会把它们一起渲染出来,而且不报错。 直接 Object.keys 会把它们一起渲染出来,而且不报错。
*/} */}
......
...@@ -18,7 +18,7 @@ const PAGE = 20; ...@@ -18,7 +18,7 @@ const PAGE = 20;
const IDLE_HEAVY = 0.2; const IDLE_HEAVY = 0.2;
/** /**
* 「还在跑的」判据 —— **时还没到,或还有没人动过的单**。 * 「还在跑的」判据 —— **时还没到,或还有没人动过的单**。
* *
* ⚠️ 这是**前端算的**,因为它是一个展示分组不是业务状态: * ⚠️ 这是**前端算的**,因为它是一个展示分组不是业务状态:
* 服务端的 `status` 只有 confirmed/revoked,没有"跑没跑完"这个概念, * 服务端的 `status` 只有 confirmed/revoked,没有"跑没跑完"这个概念,
...@@ -164,13 +164,13 @@ export function BatchTracking({ clinicId }: { clinicId: string | null }) { ...@@ -164,13 +164,13 @@ export function BatchTracking({ clinicId }: { clinicId: string | null }) {
const out: BatchGroupData[] = []; const out: BatchGroupData[] = [];
if (scope === 'running') { if (scope === 'running') {
return [ return [
{ key: '还在跑的', note: '时未到、或还有没人动过的单', past: false, rows: shown }, { key: '还在跑的', note: '时未到、或还有没人动过的单', past: false, rows: shown },
]; ];
} }
const runIds = new Set(running.map((b) => b.id)); const runIds = new Set(running.map((b) => b.id));
const run = shown.filter((b) => runIds.has(b.id)); const run = shown.filter((b) => runIds.has(b.id));
if (run.length) if (run.length)
out.push({ key: '还在跑的', note: '时未到、或还有没人动过的单', past: false, rows: run }); out.push({ key: '还在跑的', note: '时未到、或还有没人动过的单', past: false, rows: run });
let lastKey = ''; let lastKey = '';
for (const b of shown) { for (const b of shown) {
if (runIds.has(b.id)) continue; if (runIds.has(b.id)) continue;
......
...@@ -73,7 +73,7 @@ interface AssistantState { ...@@ -73,7 +73,7 @@ interface AssistantState {
* 🔴 **眼前那张还没确认的确认单,此刻的样子** —— 与 `activeClinicId` 同一条旁路, * 🔴 **眼前那张还没确认的确认单,此刻的样子** —— 与 `activeClinicId` 同一条旁路,
* 跟着每次 chat 请求发给服务端,模型用 `get_current_sheet` 取。 * 跟着每次 chat 请求发给服务端,模型用 `get_current_sheet` 取。
* *
* ⚠️ 为什么非走这条路不可:确认之前,改派/移出/时/福利**只存在于卡片组件里**, * ⚠️ 为什么非走这条路不可:确认之前,改派/移出/时/福利**只存在于卡片组件里**,
* 服务端没有可查的东西(见 `SheetSnapshotSchema` 上那段)。 * 服务端没有可查的东西(见 `SheetSnapshotSchema` 上那段)。
* ⚠️ 只有**现役**那张挂在这里 —— 卡片自己在 effect 里挂、卸载时只清自己那份 * ⚠️ 只有**现役**那张挂在这里 —— 卡片自己在 effect 里挂、卸载时只清自己那份
* (按 requestId 比对),否则重出一版后旧卡片卸载会把新的一起清掉。 * (按 requestId 比对),否则重出一版后旧卡片卸载会把新的一起清掉。
......
...@@ -839,7 +839,7 @@ export function abandonReasonsFor(panel: 'form' | 'close'): AbandonReason[] { ...@@ -839,7 +839,7 @@ export function abandonReasonsFor(panel: 'form' | 'close'): AbandonReason[] {
* 2. **≠ 召回反馈 [[RECALL_FEEDBACK_OPTIONS]]**。反馈回答「这条召回准不准」(冲算法去的); * 2. **≠ 召回反馈 [[RECALL_FEEDBACK_OPTIONS]]**。反馈回答「这条召回准不准」(冲算法去的);
* 退回回答「我为什么不接」(冲派单去的)。同一次退回可能两者都填,也可能只填一个。 * 退回回答「我为什么不接」(冲派单去的)。同一次退回可能两者都填,也可能只填一个。
* ⚠️ 值域刻意不复用:`bad_timing` 在反馈里指「召回时机不对(太早/太晚)」, * ⚠️ 值域刻意不复用:`bad_timing` 在反馈里指「召回时机不对(太早/太晚)」,
* 在退回里会被读成「时太紧」—— 同名不同义是统计事故的标准配方,故本枚举不设该键。 * 在退回里会被读成「时太紧」—— 同名不同义是统计事故的标准配方,故本枚举不设该键。
* *
* 3. ⛔ **本枚举永远不带 `suppressDays`** —— 类型里就不给这个字段,用编译器拦住。 * 3. ⛔ **本枚举永远不带 `suppressDays`** —— 类型里就不给这个字段,用编译器拦住。
* 照抄 ABANDON_REASON_META 那套抑制窗会把被退回的患者静默压 30~90 天, * 照抄 ABANDON_REASON_META 那套抑制窗会把被退回的患者静默压 30~90 天,
...@@ -917,7 +917,7 @@ export type ReleaseReasonLever = ...@@ -917,7 +917,7 @@ export type ReleaseReasonLever =
| 'capacity' | 'capacity'
/// 名册口径 —— 在岗判定准不准 /// 名册口径 —— 在岗判定准不准
| 'roster' | 'roster'
/// 时默认值 —— 给几天 /// 时默认值 —— 给几天
| 'timing' | 'timing'
/// 数据质量 —— 摄入侧的缺口,不是分配策略问题 /// 数据质量 —— 摄入侧的缺口,不是分配策略问题
| 'data' | 'data'
...@@ -964,7 +964,7 @@ export const RELEASE_REASON_META: Record< ...@@ -964,7 +964,7 @@ export const RELEASE_REASON_META: Record<
// ── 历史值:不再展示,仅供翻译老数据 ──────────────────────────── // ── 历史值:不再展示,仅供翻译老数据 ────────────────────────────
not_my_patient: { labelZh: '不是我的客户', group: 'person', lever: 'targeting', hidden: true }, not_my_patient: { labelZh: '不是我的客户', group: 'person', lever: 'targeting', hidden: true },
agent_unavailable: { labelZh: '这段时间不在', group: 'capacity', lever: 'roster', hidden: true }, agent_unavailable: { labelZh: '这段时间不在', group: 'capacity', lever: 'roster', hidden: true },
deadline_too_tight: { labelZh: '时太紧', group: 'timing', lever: 'timing', hidden: true }, deadline_too_tight: { labelZh: '时太紧', group: 'timing', lever: 'timing', hidden: true },
recently_contacted: { labelZh: '最近刚联系过', group: 'timing', lever: 'targeting', hidden: true }, recently_contacted: { labelZh: '最近刚联系过', group: 'timing', lever: 'targeting', hidden: true },
over_capacity: { labelZh: '手上排满了', group: 'capacity', lever: 'capacity', hidden: true }, over_capacity: { labelZh: '手上排满了', group: 'capacity', lever: 'capacity', hidden: true },
}; };
......
...@@ -55,7 +55,7 @@ export const FollowupPlanSchema = z.object({ ...@@ -55,7 +55,7 @@ export const FollowupPlanSchema = z.object({
* *
* 与 `recycleAt` 是**两个机制**,别混: * 与 `recycleAt` 是**两个机制**,别混:
* · recycleAt 认领后 24h 兜底,**未启用**; * · recycleAt 认领后 24h 兜底,**未启用**;
* · assignmentExpiresAt 主管在确认单上定的时(1-7 天),**已启用** * · assignmentExpiresAt 主管在确认单上定的时(1-7 天),**已启用**
* (AssignmentExpiryScheduler 每 10 分钟扫一次,到点回池并记 assignment_expired)。 * (AssignmentExpiryScheduler 每 10 分钟扫一次,到点回池并记 assignment_expired)。
* 客服要看的是后者:它决定他手上这单还剩多久。 * 客服要看的是后者:它决定他手上这单还剩多久。
*/ */
......
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