Commit c33dafed by luoqi

refactor(plan): 删掉"容量"这个数,批次人数改基数 + 落人走水位法

主管走查抓到死路:批次规模 = Σ(容量−在手) 时,**第二批必然是 0** ——
第一批把所有人填到水位,再算就是 Σ(20−20)。想连着圈两批人分,只能把容量
往上棚,而容量会被记住 → 下周的"习惯"是个虚高的数。

根子是一个数当了两个用:「这轮推多少」和「一个人最多压多少」。拆开后发现
上限那一半根本不需要 —— 负载不需要阈值,inHand 本身就是负载。

· 基数改为 **本批人数 N(首次 100)+ 时效(3 天)**,都沿用主管上一次的值;
  容量、AGENT_CAPACITY_RANGE、capacityRange 全删。
· 落人第二趟由「轮转均分」改 **水位法**:每条都给当前在手最少的人。
  均分是"每人加一样多",起点不齐终点还是不齐;水位法把差距抹平。
· 🔴 **专属那一趟也受目标水位约束**(=(团队在手+N)/在岗人数,由 N 推出,非新旋钮):
  容量删掉后没东西约束专属了,实测 100 条里 76 条是同一人的专属,他吃到 76,
  水位法只剩零头可铺。加约束后同一批变成每人 5~6 条。
  超出份额的专属转 spread_overflow(关系还在,这轮没轮到),语义同原「专属已满转铺平」。
· 按客服精调 capacity → maxThisBatch(本批名额,0 = 这轮不给),不再是"上限"。
· 沿用同时认新键 batchSize 与老键 target —— 否则改版后所有人的沿用静默退回默认。
· 卡片「已满未分配」改「本批未分到(在手 N)」:没有上限了,写「已满(0)」自相矛盾。

本地实测:1,081 候选 → 本批 100 人铺给 17 位客服,分完后每人在手 5~6 条,
池子里还有 981 人排队(话术明说"随时可以再分一批")。986 tests green。
教条 T22 整节重写,把这条弯路记进去。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent 9fa29b7b
import { Permission, AGENT_CAPACITY_DEFAULT, ASSIGNMENT_EXPIRES_DAYS_DEFAULT } from '@pac/types'; import { Permission, ASSIGNMENT_EXPIRES_DAYS_DEFAULT, BATCH_SIZE_DEFAULT } from '@pac/types';
/** /**
* 按能力切换的助手工作流约束(拼在 SYSTEM_PROMPT 之后)。 * 按能力切换的助手工作流约束(拼在 SYSTEM_PROMPT 之后)。
...@@ -10,7 +10,7 @@ import { Permission, AGENT_CAPACITY_DEFAULT, ASSIGNMENT_EXPIRES_DAYS_DEFAULT } f ...@@ -10,7 +10,7 @@ import { Permission, AGENT_CAPACITY_DEFAULT, ASSIGNMENT_EXPIRES_DAYS_DEFAULT } f
* ── 方法论:凡是靠模型"算对"的约束,一律降级成"照抄" ────────── * ── 方法论:凡是靠模型"算对"的约束,一律降级成"照抄" ──────────
* 下面 T14 / T20 那两类要求(标注默认值、样本不足不出百分比)全是**除法和阈值判断**, * 下面 T14 / T20 那两类要求(标注默认值、样本不足不出百分比)全是**除法和阈值判断**,
* 而这恰恰是 LLM 最不可靠的地方。所以工具返回值里直接带成品句子 * 而这恰恰是 LLM 最不可靠的地方。所以工具返回值里直接带成品句子
* (`rosterNote` / `capacityBasis` / `sufficient`),提示词只负责让它**原话抄**。 * (`rosterNote` / `basisNote` / `sufficient`),提示词只负责让它**原话抄**。
* 「提示词 + 工具返回值」双保险,少任何一半都会漏。 * 「提示词 + 工具返回值」双保险,少任何一半都会漏。
*/ */
...@@ -38,8 +38,8 @@ const DISPATCHER_EXTRA = ` ...@@ -38,8 +38,8 @@ const DISPATCHER_EXTRA = `
他若主动提要求(「只要商保直付的」「排掉怕疼的」),那时才用画像收窄。 他若主动提要求(「只要商保直付的」「排掉怕疼的」),那时才用画像收窄。
3. **凡是没有历史数据支撑的建议值,必须当场标明是默认值。** 3. **凡是没有历史数据支撑的建议值,必须当场标明是默认值。**
工具返回里的 rosterNote / capacityBasis 是给你**原话抄**的,别自己改写措辞。 工具返回里的 rosterNote / basisNote 是给你**原话抄**的,别自己改写措辞。
⚠️ 容量/时效现在有三种出处,**照 capacityNote 说,别自己归因**: ⚠️ 人数/时效有三种出处,**照 basisNote 说,别自己归因**:
沿用上次(「沿用 7 月 28 日那次分配」)/ 首次默认 / 本次主管指定。 沿用上次(「沿用 7 月 28 日那次分配」)/ 首次默认 / 本次主管指定。
⛔ 不要把"沿用上次"说成"系统算出来的",也不要把默认值说成「依据该诊所平均结案 2.4 天」 ⛔ 不要把"沿用上次"说成"系统算出来的",也不要把默认值说成「依据该诊所平均结案 2.4 天」
—— 那个数算不出来。 —— 那个数算不出来。
...@@ -84,16 +84,19 @@ const DISPATCHER_EXTRA = ` ...@@ -84,16 +84,19 @@ const DISPATCHER_EXTRA = `
### 拟分方案怎么给(主管会把关,你负责有理有据) ### 拟分方案怎么给(主管会把关,你负责有理有据)
- **专属优先**:该患者有专属客服且在名册内 → 分给他 - **专属优先**:该患者有专属客服且在名册内 → 分给他
- **溢出铺平**:专属客服已达容量 / 无专属 / 专属已离岗 → 铺给其他在岗客服, - **溢出走水位法**:无专属 / 专属已离岗 / 专属这轮名额用完 → 铺给其他在岗客服,
按当前在手量从少到多补,**均分**;在手已达上限的人本批跳过但仍列出来标「已满」 **每一条都给当前手上最少的那个人**,分完后大家的在手量趋于齐平。
- 分不下去就**明说分不下去**,建议缩小批次;⛔ 不要硬塞给已经满的人 ⛔ 不是"每人加一样多" —— 那样起点不齐终点还是不齐。
- **两个基数:容量 + 时效,都自动沿用主管上一次的值** —— ⛔ 别问他,那正是这个设计要省掉的输入。 本批一条没分到的人仍会列出来(他手上本来就最多),⛔ 别说成"他被跳过了"。
首次没有上一次才用默认(容量 ${AGENT_CAPACITY_DEFAULT}、时效 ${ASSIGNMENT_EXPIRES_DAYS_DEFAULT} 天)。 - **⛔ 没有"容量上限"这个东西。** 负载就是在手量本身,水位法已经在照顾它。
capacityNote 里写好了推导链和出处(「沿用 X 月 X 日那次」/「首次默认」),**照抄**。 主管问"会不会分太多"就照 basisNote 说分完后每人多少条,⛔ 不要编一个"上限"出来。
- 批次规模**不是你定的,也不是主管定的,是算出来的**:Σ(容量 − 各人在手)。 - **两个基数:本批人数 + 时效,都自动沿用主管上一次的值** —— ⛔ 别问他,那正是这个设计要省掉的输入。
他说「每人 30 条」→ 传 capacity(基数,会被记住);「给 5 天」→ 传 expiresInDays; 首次没有上一次才用默认(${BATCH_SIZE_DEFAULT} 人 / ${ASSIGNMENT_EXPIRES_DAYS_DEFAULT} 天)。
「这批只要 60 人」→ 传 targetCount(**一次性**,不改基数,下次仍按容量算)。 basisNote 里写好了值、出处(「沿用 X 月 X 日那次」/「首次默认」)和分配后的水位,**照抄**。
⛔ 不要既传 capacity 又传 targetCount —— 两者矛盾时听谁的说不清。 - 他说「这批 200 人」→ 传 targetCount(**基数**,会被记住);「给 5 天」→ 传 expiresInDays;
「李莉这周最多 5 条」→ 传 agentOverrides(按客服精调,也会被记住)。
- **池子里还有人就随时能再分一批** —— 主管连着圈第二批人时照常出确认单,
⛔ 不要说"团队满了"(没有这个概念了)。
- 每一条都要说得出「为什么是他」:专属 / 手上最空 / 主管指定,三选一 - 每一条都要说得出「为什么是他」:专属 / 手上最空 / 主管指定,三选一
`.trim(); `.trim();
......
...@@ -120,9 +120,9 @@ export class AssistantService { ...@@ -120,9 +120,9 @@ export class AssistantService {
'并把卡片直接呈现给主管。' + '并把卡片直接呈现给主管。' +
'\n⚠️ 卡片由界面渲染,**你看不到明细也不需要看** —— 你的任务是转述返回的那句摘要。' + '\n⚠️ 卡片由界面渲染,**你看不到明细也不需要看** —— 你的任务是转述返回的那句摘要。' +
'\n⚠️ **这只是提案,一个字都没写库**。绝不要说「已经分配好了」。' + '\n⚠️ **这只是提案,一个字都没写库**。绝不要说「已经分配好了」。' +
'\n⚠️ 容量与时效**会自动沿用主管上一次的值**,⛔ 别问他 —— 那正是这个设计要省掉的输入。' + '\n⚠️ 本批人数与时效**会自动沿用主管上一次的值**,⛔ 别问他 —— 那正是这个设计要省掉的输入。' +
'他明确说「每人 30 条」「给 5 天」时才传 capacity / expiresInDays(会被记住);' + '他明确说「这批 200 人」「给 5 天」时才传 targetCount / expiresInDays(都会被记住)。' +
'说「这批只要 60 人」传 targetCount(一次性,不改基数)。', '\n⚠️ **没有"容量上限"** —— 落人走水位法(先给手上最少的),池子里还有人就随时能再分一批。',
inputSchema: jsonSchema({ inputSchema: jsonSchema({
type: 'object', type: 'object',
properties: { properties: {
...@@ -143,12 +143,6 @@ export class AssistantService { ...@@ -143,12 +143,6 @@ export class AssistantService {
'主管在调整阶段追加的画像条件("key:value" 逗号串,同维 OR、跨维 AND)。' + '主管在调整阶段追加的画像条件("key:value" 逗号串,同维 OR、跨维 AND)。' +
'先用 get_cohort_attributes 看清各口子多少人,再带着它重出确认单。', '先用 get_cohort_attributes 看清各口子多少人,再带着它重出确认单。',
}, },
capacity: {
type: 'number',
description:
'每个客服的在手容量(基数①)。主管说「每人 30 条」时传;不传自动沿用他上一次的值。' +
'⭐ 批次规模由它推出(Σ 容量−在手),⛔ 不要既传 capacity 又传 targetCount。',
},
expiresInDays: { expiresInDays: {
type: 'number', type: 'number',
description: '批次时效天数(基数②)。主管说「给 5 天」时传;不传自动沿用上一次的值。', description: '批次时效天数(基数②)。主管说「给 5 天」时传;不传自动沿用上一次的值。',
...@@ -156,20 +150,21 @@ export class AssistantService { ...@@ -156,20 +150,21 @@ export class AssistantService {
targetCount: { targetCount: {
type: 'number', type: 'number',
description: description:
'本批人数的**一次性**覆盖(主管说「这批只要 60 人」)。⚠️ 不会改基数,下次仍按容量算。', '本批人数 N(基数①)。主管说「这批 200 人」时传;不传自动沿用他上一次的值(首次 100)。',
}, },
agentOverrides: { agentOverrides: {
type: 'object', type: 'object',
additionalProperties: { additionalProperties: {
type: 'object', type: 'object',
properties: { properties: {
capacity: { type: 'number' }, maxThisBatch: { type: 'number' },
expiresInDays: { type: 'number' }, expiresInDays: { type: 'number' },
}, },
}, },
description: description:
'按客服精调(userId → {capacity, expiresInDays}),压过整体基数。' + '按客服精调(userId → {maxThisBatch, expiresInDays})。' +
'主管说「李莉这周只给 5 条」「王强那批给 7 天」时传。' + 'maxThisBatch = 本批最多给他几条(0 = 这轮不给他),⛔ 不是"容量上限"。' +
'主管说「李莉这周最多 5 条」「王强那批给 7 天」时传。' +
'⚠️ 先用 list_agents 拿到 userId,⛔ 不要用姓名当 key。' + '⚠️ 先用 list_agents 拿到 userId,⛔ 不要用姓名当 key。' +
'⚠️ 精调**也会被记住**,所以主管说「李莉恢复正常」时要把她从这个表里去掉' + '⚠️ 精调**也会被记住**,所以主管说「李莉恢复正常」时要把她从这个表里去掉' +
'(传一个不含她的完整表,⛔ 别只传变化的那一条)。', '(传一个不含她的完整表,⛔ 别只传变化的那一条)。',
...@@ -183,9 +178,8 @@ export class AssistantService { ...@@ -183,9 +178,8 @@ export class AssistantService {
potentialTreatment?: string; potentialTreatment?: string;
temperature?: TemperatureValue; temperature?: TemperatureValue;
personaTags?: string; personaTags?: string;
capacity?: number;
expiresInDays?: number; expiresInDays?: number;
agentOverrides?: Record<string, { capacity?: number; expiresInDays?: number }>; agentOverrides?: Record<string, { maxThisBatch?: number; expiresInDays?: number }>;
targetCount?: number; targetCount?: number;
exploreRatio?: number; exploreRatio?: number;
}; };
...@@ -208,7 +202,7 @@ export class AssistantService { ...@@ -208,7 +202,7 @@ export class AssistantService {
(sheet.unplaced > 0 ? `,另有 ${sheet.unplaced} 人按当前容量分不下去` : '') + '。', (sheet.unplaced > 0 ? `,另有 ${sheet.unplaced} 人按当前容量分不下去` : '') + '。',
`请转述以下三句(**原话**,不要改写、不要省略"默认值"字样):`, `请转述以下三句(**原话**,不要改写、不要省略"默认值"字样):`,
${sheet.selectionNote}`, ${sheet.selectionNote}`,
${sheet.capacityNote}`, ${sheet.basisNote}`,
${sheet.rosterNote}`, ${sheet.rosterNote}`,
`然后提示主管:确认无误请在卡片上点「确认分配」。⛔ 不要说已经分配好了。`, `然后提示主管:确认无误请在卡片上点「确认分配」。⛔ 不要说已经分配好了。`,
].join('\n'); ].join('\n');
......
...@@ -138,9 +138,10 @@ export class AgentRosterService { ...@@ -138,9 +138,10 @@ export class AgentRosterService {
/// 名册里没有 = 近 N 月无回访记录。**不是"不能分"** —— 见类注释 /// 名册里没有 = 近 N 月无回访记录。**不是"不能分"** —— 见类注释
inRoster: r != null, inRoster: r != null,
// ⛔ **不返回 remaining,也不再返回容量区间**。 // ⛔ **不返回 remaining,也不再返回容量区间**。
// 容量没有上下限(见 AGENT_CAPACITY_DEFAULT),给一个「20-50」会被读成"合法范围"; // "容量/上限"这个概念已经删掉(见 BATCH_SIZE_DEFAULT):负载就是 inHand 本身,
// 而把余量减出来会让助手说「李莉还能吃 38 个」—— 拿一个未经验证的数做完减法当事实说出口。 // 落人走水位法(先给手上最少的)。给一个区间会被读成"合法范围",
// 名册只回**在手**这个客观量;本批实际容量在确认单的 byAgent 里逐人给。 // 把余量减出来会让助手说「李莉还能吃 38 个」—— 拿未经验证的数做完减法当事实说出口。
// 名册只回**在手**这个客观量;分配后的水位在确认单的 byAgent 里逐人给。
}; };
}); });
......
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { import {
AGENT_CAPACITY_DEFAULT,
ASSIGNMENT_EXPIRES_DAYS_DEFAULT, ASSIGNMENT_EXPIRES_DAYS_DEFAULT,
BATCH_SIZE_DEFAULT,
AssignStrategy, AssignStrategy,
type AgentInfo, type AgentInfo,
type AssignmentProposal, type AssignmentProposal,
...@@ -81,22 +81,18 @@ export class AssignmentProposalService { ...@@ -81,22 +81,18 @@ export class AssignmentProposalService {
temperature?: TemperatureValue; temperature?: TemperatureValue;
/** T9-B 调整阶段主管追加的画像条件(`key:value` 逗号串) */ /** T9-B 调整阶段主管追加的画像条件(`key:value` 逗号串) */
personaTags?: string; personaTags?: string;
/** 每个客服的在手容量(基数①,**整体默认**);不传 = 沿用上一次分配的值 */ /** 批次时效天数(基数②);不传 = 沿用上一次分配的值 */
capacity?: number;
/** 批次时效天数(基数②,**整体默认**);不传 = 沿用上一次分配的值 */
expiresInDays?: number; expiresInDays?: number;
/** /**
* 按客服的**精调**(userId → 覆盖值),压过整体基数 * 按客服的**精调**(userId → 覆盖值)。
* 「李莉这周带教给 5 条」「王强那批给 7 天」。 * 「李莉这周带教,这批最多给 5 条」「王强那批给 7 天」。
* ⚠️ 与基数一样会被沿用(主管的习惯),所以下发时逐人标 `overridden`, * ⚠️ 与基数一样会被沿用(主管的习惯),所以下发时逐人标 `overridden`,
* 让一条三个月前的临时精调不至于永远静默生效。 * 让一条三个月前的临时精调不至于永远静默生效。
*/ */
agentOverrides?: Record<string, { capacity?: number; expiresInDays?: number }>; agentOverrides?: Record<string, { maxThisBatch?: number; expiresInDays?: number }>;
/** /**
* 拟分人数 —— **一次性覆盖**,主管说「这批只要 60 人」时才给。 * 本批人数 N(基数①);不传 = 沿用上一次分配的值。
* ⛔ 它**不回写基数**:批次规模是一次性的运营选择,容量是人的属性, * ⭐ 与时效同为基数 —— 主管说「这批 200 人」会被记住,下次默认就是 200。
* 拿 60 反推出「以后每人 3.5 条」等于把临时决定固化成长期参数,而且没人记得为什么。
* 他真想改容量会直接说「每人 30 条」—— 那是另一句话,走 capacity。
*/ */
targetCount?: number; targetCount?: number;
/** 探索配额占比(0-0.2)。⭐ 唯一的因果抓手,见 selectionMode 注释 */ /** 探索配额占比(0-0.2)。⭐ 唯一的因果抓手,见 selectionMode 注释 */
...@@ -118,48 +114,41 @@ export class AssignmentProposalService { ...@@ -118,48 +114,41 @@ export class AssignmentProposalService {
]); ]);
const agents = roster.agents; const agents = roster.agents;
// ── 两个基数:容量 + 时效 ──────────────────────────────── // ── 两个基数:本批人数 N + 时效 ──────────────────────────
// 优先级:本次明确指定 > 沿用上一次 > 首次默认。 // 优先级:本次明确指定 > 沿用上一次 > 首次默认。
// ⭐ `basis` 要跟着值一起下发 —— 主管看到「容量 20」时,"这 20 哪来的"和这个数一样重要。 // ⭐ `basis` 要跟着值一起下发 —— 主管看到「本批 100 人」时,"这 100 哪来的"和这个数一样重要。
const capacity = //
input.capacity != null && input.capacity > 0 ? Math.floor(input.capacity) : baseline.capacity; // ⛔ **没有"容量"这个数了**(2026-08-03 第二次改判)。它当过一阵上限并用来推批次规模,
// 结果是**第二批必然为 0**:第一批把所有人填到水位,再算就是 Σ(容量−容量)。
// 主管想连着圈两批人分,只能把容量往上棚,而容量会被记住 → 下周的"习惯"是个虚高的数。
// 现在:N 决定推多少,水位法决定给谁,负载由 `inHand` 本身表达,不设阈值。
const target =
input.targetCount != null
? Math.max(0, Math.floor(input.targetCount))
: baseline.batchSize;
const expiresInDays = const expiresInDays =
input.expiresInDays != null && input.expiresInDays > 0 input.expiresInDays != null && input.expiresInDays > 0
? Math.floor(input.expiresInDays) ? Math.floor(input.expiresInDays)
: baseline.expiresInDays; : baseline.expiresInDays;
const basis: 'inherited' | 'default' | 'explicit' = const basis: 'inherited' | 'default' | 'explicit' =
input.capacity != null || input.expiresInDays != null input.targetCount != null || input.expiresInDays != null
? 'explicit' ? 'explicit'
: baseline.from : baseline.from
? 'inherited' ? 'inherited'
: 'default'; : 'default';
// 按客服精调:本次传的压过沿用的(同一个人两处都有 → 本次赢) // 按客服精调:本次传的压过沿用的(同一个人两处都有 → 本次赢)
const agentOverrides = sanitizeOverrides({ ...baseline.agentOverrides, ...input.agentOverrides }); const agentOverrides = sanitizeOverrides({ ...baseline.agentOverrides, ...input.agentOverrides });
/// 某人本批实际生效的两个值 —— ⛔ 只此一处合并,别在 placeAgents / byAgent / 前端各合一遍 /// 本批给某人的**名额上限**(没精调 = 不限)。⛔ 只此一处合并,别在 placeAgents / byAgent 各合一遍
const capOf = (userId: string) => agentOverrides[userId]?.capacity ?? capacity; const capOf = (userId: string) => agentOverrides[userId]?.maxThisBatch ?? Infinity;
const expOf = (userId: string) => agentOverrides[userId]?.expiresInDays ?? expiresInDays; const expOf = (userId: string) => agentOverrides[userId]?.expiresInDays ?? expiresInDays;
// ── 拟分人数 = 把在岗客服**填满到容量水位** ────────────────
// ⚠️ 这是 2026-08-03 的改判:原来批次规模是一个独立默认值(100),容量只当上限。
// 现在反过来 —— **容量是唯一旋钮,人数是推出来的**。
// 理由:主管在确认单上调的是容量和时效(会被记住),再给他第三个"批次规模"旋钮,
// 三者矛盾时听谁的就说不清了(改容量还是改人数?)。
// ⚠️ 在手 ≥ 容量的人 room=0 → **本批不参与**,但仍进 skippedAgents 列出来
// ("他不是被漏了,是已经满了")。
const totalRoom = agents.reduce((a, g) => a + Math.max(0, capOf(g.userId) - g.inHand), 0);
const inHandTotal = agents.reduce((a, g) => a + g.inHand, 0); const inHandTotal = agents.reduce((a, g) => a + g.inHand, 0);
// targetCount 是一次性覆盖(⛔ 不回写基数,见入参注释)
const target = input.targetCount != null ? Math.max(0, input.targetCount) : totalRoom;
const sizeBasis: 'explicit' | 'capacity' = input.targetCount != null ? 'explicit' : 'capacity';
if (target === 0 || agents.length === 0) { if (target === 0 || agents.length === 0) {
return emptyProposal(clinicId, potentialTreatment, agents, roster.rosterNote, { return emptyProposal(clinicId, potentialTreatment, agents, roster.rosterNote, {
target, target,
capacity,
expiresInDays, expiresInDays,
basis, basis,
basisFrom: baseline.from, basisFrom: baseline.from,
agentOverrides, agentOverrides,
capOf,
}); });
} }
...@@ -225,7 +214,6 @@ export class AssignmentProposalService { ...@@ -225,7 +214,6 @@ export class AssignmentProposalService {
/// ⭐ 真候选总数(count 出来的),⛔ 不是 ranked.length —— 后者被 fetchLimit 截过 /// ⭐ 真候选总数(count 出来的),⛔ 不是 ranked.length —— 后者被 fetchLimit 截过
candidateTotal, candidateTotal,
target, target,
capacity,
expiresInDays, expiresInDays,
basis, basis,
basisFrom: baseline.from, basisFrom: baseline.from,
...@@ -239,28 +227,32 @@ export class AssignmentProposalService { ...@@ -239,28 +227,32 @@ export class AssignmentProposalService {
count: list.length, count: list.length,
dedicated: list.filter((x) => x.assignStrategy === AssignStrategy.DEDICATED).length, dedicated: list.filter((x) => x.assignStrategy === AssignStrategy.DEDICATED).length,
spread: list.filter((x) => x.assignStrategy !== AssignStrategy.DEDICATED).length, spread: list.filter((x) => x.assignStrategy !== AssignStrategy.DEDICATED).length,
/// 逐人下发生效值 —— 前端直接显示,⛔ 不让它自己再合并一次(合并逻辑写两遍必然漂) /// ⭐ 分配后他手上有多少 —— 「负载」的唯一表达(没有阈值线,主管自己看这列齐不齐)
capacity: capOf(userId), loadAfter: (agentById.get(userId)?.inHand ?? 0) + list.length,
/// 逐人下发生效时效 —— 前端直接显示,⛔ 不让它自己再合并一次(合并逻辑写两遍必然漂)
expiresInDays: expOf(userId), expiresInDays: expOf(userId),
overridden: agentOverrides[userId] != null, overridden: agentOverrides[userId] != null,
})), })),
agentOverrides, agentOverrides,
// 已满但仍列出来:主管要看见"这个人不是被漏了,是已经满了" // 本批一条没分到的人**仍然列出来**:主管要看见"他不是被漏了"。
// ⚠️ 语义已变:不是"满了"(没有上限这回事了),是**水位法没轮到他** —— 他手上本来就最多。
skippedAgents: agents skippedAgents: agents
.filter((a) => a.inHand >= capOf(a.userId)) .filter((a) => !byAgent.has(a.userId))
.map((a) => ({ userId: a.userId, name: a.name, inHand: a.inHand })), .map((a) => ({ userId: a.userId, name: a.name, inHand: a.inHand })),
rosterNote: roster.rosterNote, rosterNote: roster.rosterNote,
// ⭐ 成品句子,助手照抄(T14 双保险的"工具返回值"那一半) // ⭐ 成品句子,助手照抄(T14 双保险的"工具返回值"那一半)
capacityNote: capacityNote({ basisNote: basisNote({
agents: agents.length, agents: agents.length,
capacity, placed: items.placed.length,
inHandTotal, inHandTotal,
totalRoom,
expiresInDays, expiresInDays,
basis, basis,
basisFrom: baseline.from, basisFrom: baseline.from,
sizeBasis,
target, target,
loads: [...byAgent].map(([u, l]) => ({
name: agentById.get(u)?.name ?? u.slice(0, 8),
after: (agentById.get(u)?.inHand ?? 0) + l.length,
})),
// 精调过的人要点名 —— 「为什么李莉只有 5 条」必须答得上来 // 精调过的人要点名 —— 「为什么李莉只有 5 条」必须答得上来
overrides: agents overrides: agents
.filter((a) => agentOverrides[a.userId] != null) .filter((a) => agentOverrides[a.userId] != null)
...@@ -273,13 +265,10 @@ export class AssignmentProposalService { ...@@ -273,13 +265,10 @@ export class AssignmentProposalService {
(candidateTotal <= target (candidateTotal <= target
? `这批候选一共就 ${candidateTotal} 人,**全部纳入**(未做取舍)。` ? `这批候选一共就 ${candidateTotal} 人,**全部纳入**(未做取舍)。`
: `按「未进过批次优先 → 优先级高优先 → 患者号」排序,从 ${candidateTotal} 位候选里取前 ${target} 人;`) + : `按「未进过批次优先 → 优先级高优先 → 患者号」排序,从 ${candidateTotal} 位候选里取前 ${target} 人;`) +
// ⚠️ 「只取到 N 人」必须说清是**容量吃不下**而不是**候选不够** —— // ⚠️ 池子里还剩多少必须说 —— 否则主管不知道「还能不能再分一批」(答案是:能)
// 两者主管的下一步动作完全相反(加容量 vs 换人群)。 (candidateTotal > target
(candidateTotal > target && sizeBasis === 'capacity' ? `池子里还有 ${candidateTotal - target} 人排队,**随时可以再分一批**(要多分就说个数)。`
? `本批 ${target} 人是**按容量算出来的**(不是候选不够,池子里还有 ${candidateTotal - target} 人排队);要多分就说「每人 ${capacity + 10} 条」。` : '') +
: candidateTotal > target && sizeBasis === 'explicit'
? `本批 ${target} 人是您本次指定的(⛔ 不改容量基数,下次仍按每人 ${capacity} 条算)。`
: '') +
(exploreN > 0 (exploreN > 0
? `其中 ${exploreN} 人来自探索配额(排名之外抽取,用于日后验证排序是否选得准)。` ? `其中 ${exploreN} 人来自探索配额(排名之外抽取,用于日后验证排序是否选得准)。`
: '') + : '') +
...@@ -330,10 +319,10 @@ export class AssignmentProposalService { ...@@ -330,10 +319,10 @@ export class AssignmentProposalService {
scope: TenantScopeContext, scope: TenantScopeContext,
clinicId: string, clinicId: string,
): Promise<{ ): Promise<{
capacity: number; batchSize: number;
expiresInDays: number; expiresInDays: number;
from: string | null; from: string | null;
agentOverrides: Record<string, { capacity?: number; expiresInDays?: number }>; agentOverrides: Record<string, { maxThisBatch?: number; expiresInDays?: number }>;
}> { }> {
const base = { const base = {
hostId: scope.hostId, hostId: scope.hostId,
...@@ -349,17 +338,20 @@ export class AssignmentProposalService { ...@@ -349,17 +338,20 @@ export class AssignmentProposalService {
}); });
const last = (await pick({ ...base, createdBy: scope.userId })) ?? (await pick({ ...base })); const last = (await pick({ ...base, createdBy: scope.userId })) ?? (await pick({ ...base }));
const c = (last?.criteria ?? null) as { const c = (last?.criteria ?? null) as {
capacity?: unknown; batchSize?: unknown;
/// ⚠️ 老快照里叫 `target`(那时它是推出来的结果,不是基数)—— 一并认,否则第一次改版后
/// 所有人的"沿用"都会退回默认值,而界面上只会显示"首次默认",看不出丢了东西
target?: unknown;
expiresInDays?: unknown; expiresInDays?: unknown;
agentOverrides?: unknown; agentOverrides?: unknown;
} | null; } | null;
const capacity = posInt(c?.capacity); const batchSize = posInt(c?.batchSize) ?? posInt(c?.target);
const expiresInDays = posInt(c?.expiresInDays); const expiresInDays = posInt(c?.expiresInDays);
// ⚠️ 只要有一个读到就算"沿用"(另一个补默认):老批次可能只存了其中一个 // ⚠️ 只要有一个读到就算"沿用"(另一个补默认):老批次可能只存了其中一个
return { return {
capacity: capacity ?? AGENT_CAPACITY_DEFAULT, batchSize: batchSize ?? BATCH_SIZE_DEFAULT,
expiresInDays: expiresInDays ?? ASSIGNMENT_EXPIRES_DAYS_DEFAULT, expiresInDays: expiresInDays ?? ASSIGNMENT_EXPIRES_DAYS_DEFAULT,
from: capacity != null || expiresInDays != null ? last!.createdAt.toISOString() : null, from: batchSize != null || expiresInDays != null ? last!.createdAt.toISOString() : null,
agentOverrides: sanitizeOverrides(c?.agentOverrides), agentOverrides: sanitizeOverrides(c?.agentOverrides),
}; };
} }
...@@ -430,72 +422,104 @@ export function placeAgents( ...@@ -430,72 +422,104 @@ export function placeAgents(
agents: AgentInfo[], agents: AgentInfo[],
dedicatedByPatient: Map<string, string>, dedicatedByPatient: Map<string, string>,
/** /**
* **逐人**生效容量(整体基数 + 该人的精调)。⛔ 别在这里读常量或只收一个数 —— * 本批给某人的**名额上限**(精调过才有,默认 Infinity = 不限)。
* 「李莉这周只给 5 条」正是靠这一层生效的,收单值等于精调对落人无效(而且不会报错)。 * ⛔ 这不是"容量" —— 容量那个概念已经删了(见 BATCH_SIZE_DEFAULT)。
* 它只表达「李莉这周带教,这批最多给 5 条」这种一次性名额,0 = 这轮不给他。
*/ */
capacityOf: (userId: string) => number = () => AGENT_CAPACITY_DEFAULT, maxOf: (userId: string) => number = () => Infinity,
): { placed: ProposedItem[]; unplaced: number } { ): { placed: ProposedItem[]; unplaced: number } {
const room = new Map<string, number>(); const rosterIds = new Set(agents.map((a) => a.userId));
for (const a of agents) room.set(a.userId, Math.max(0, capacityOf(a.userId) - a.inHand)); /// ⭐ 水位 = 该客服**分配后**手上有多少 = 在手 + 本批已给。两趟共用同一本账。
const rosterIds = new Set(agents.map((a) => a.userId)); const load = new Map<string, number>(agents.map((a) => [a.userId, a.inHand]));
const given = new Map<string, number>(agents.map((a) => [a.userId, 0]));
const canTake = (u: string) => (given.get(u) ?? 0) < maxOf(u);
const take = (u: string) => {
load.set(u, (load.get(u) ?? 0) + 1);
given.set(u, (given.get(u) ?? 0) + 1);
};
const placed: ProposedItem[] = []; /**
const overflow: typeof chosen = []; * 🔴 **目标水位** = 这一批分完之后,大家理想中应该停在的高度。
*
* `(团队现有在手 + 本批人数) / 在岗人数`
*
* 它不是新旋钮 —— 完全由 N 和在手量推出来。存在的理由只有一个:
* **给专属那一趟封顶**。容量删掉之后没有任何东西约束专属了,实测一次:
* 100 条里 76 条是同一个人的专属,他一个人吃到 76,水位法只剩零头可铺,
* 「最后保持齐平」当场落空。
*
* ⚠️ 这不是"不给专属了" —— 是他**这一批的份额已经满了**,后面同样是他专属的患者
* 转成铺平(标 SPREAD_OVERFLOW,关系还在,只是这轮没轮到)。语义与原来
* 「专属客服已达容量 → 溢出转铺平」完全一致,只是把"容量"换成了"这批的应得份额"。
* ⚠️ 向上取整 + 至少 1:否则 N 比人数还小时目标水位算出 0,专属一条都进不去,
* 整批全走铺平 —— 那等于把专属关系整个关掉。
*/
const inHandSum = agents.reduce((a, g) => a + g.inHand, 0);
const waterline =
agents.length > 0 ? Math.max(1, Math.ceil((inHandSum + chosen.length) / agents.length)) : 0;
// ── 第一趟:专属命中 ────────────────────────────────────── const placed: ProposedItem[] = [];
for (const c of chosen) { const overflow: typeof chosen = [];
const dcs = dedicatedByPatient.get(c.patientId);
if (dcs && rosterIds.has(dcs) && (room.get(dcs) ?? 0) > 0) {
room.set(dcs, room.get(dcs)! - 1);
placed.push({
planId: c.planId,
patientId: c.patientId,
assigneeUserId: dcs,
assignStrategy: AssignStrategy.DEDICATED,
selectionMode: c.selectionMode,
});
} else {
overflow.push(c);
}
}
// ── 第二趟:溢出均分 ────────────────────────────────────── // ── 第一趟:专属命中(**受目标水位约束**)────────────────────
// ⚠️ 剩余容量必须**接着第一趟的结果**算(room 是同一个累加器)—— for (const c of chosen) {
// 分开算的话专属大户会被再灌一轮,而这一点在代码上完全不显眼。 const dcs = dedicatedByPatient.get(c.patientId);
const takers = agents.filter((a) => (room.get(a.userId) ?? 0) > 0).map((a) => a.userId); if (dcs && rosterIds.has(dcs) && canTake(dcs) && (load.get(dcs) ?? 0) < waterline) {
let cursor = 0; take(dcs);
let unplaced = 0;
for (const c of overflow) {
// 找下一个还有余量的人;一圈都没有 → 分不下去
let hops = 0;
while (hops < takers.length && (room.get(takers[cursor % takers.length]!) ?? 0) <= 0) {
cursor++;
hops++;
}
if (takers.length === 0 || hops >= takers.length) {
// ⛔ **截断不摊派**:硬塞给已经满的人是 T5「宁缺毋滥」的反面,
// 而且会立刻制造 over_capacity 退回 —— 那正是要反推容量默认值的那个信号,
// 自己造出来就没法反推了。
unplaced++;
continue;
}
const who = takers[cursor % takers.length]!;
cursor++;
room.set(who, room.get(who)! - 1);
const dcs = dedicatedByPatient.get(c.patientId);
placed.push({ placed.push({
planId: c.planId, planId: c.planId,
patientId: c.patientId, patientId: c.patientId,
assigneeUserId: who, assigneeUserId: dcs,
// ⭐ 两种铺平必须分开记:有专属只是没轮到(关系还在) vs 从头没有专属(关系本就薄)。 assignStrategy: AssignStrategy.DEDICATED,
// 混成一个值,T20 算出来的"铺平完成率低"就分不清是策略问题还是人群问题。
assignStrategy:
dcs && rosterIds.has(dcs)
? AssignStrategy.SPREAD_OVERFLOW
: AssignStrategy.SPREAD_NO_DEDICATED,
selectionMode: c.selectionMode, selectionMode: c.selectionMode,
}); });
} else {
overflow.push(c);
} }
}
// ── 第二趟:溢出**水位法** ────────────────────────────────
// 每一条都给**当前水位最低**的人 —— 分完后大家手上的量趋于齐平。
//
// ⚠️ 与原来的「轮转均分」不是一回事,差别正是主管抱怨的那个场景:
// 轮转均分给每人**加一样多**,起点不齐(有人刚被专属灌了 20 条)终点还是不齐;
// 水位法给**手上最少的人先加**,把差距抹平。
// ⚠️ 必须接着第一趟的账算(load 是同一个 Map)—— 分开算的话专属大户会被再灌一轮,
// 而这一点在代码上完全不显眼。
// ⚠️ 同水位按 userId 排序打破平局 —— 两次算出同样的分法是主管敢按确认键的前提。
let unplaced = 0;
for (const c of overflow) {
let who: string | null = null;
let best = Infinity;
for (const a of agents) {
if (!canTake(a.userId)) continue; // 精调成 0 / 已到本批名额上限
const l = load.get(a.userId) ?? 0;
if (l < best || (l === best && who != null && a.userId < who)) {
best = l;
who = a.userId;
}
}
if (who == null) {
// ⚠️ 水位法**没有上限**,所以这里正常到不了:只有名册为空、
// 或所有人都被精调成 0 名额时才会落到这一支。⛔ 别把它读成"团队满了"。
unplaced++;
continue;
}
take(who);
const dcs = dedicatedByPatient.get(c.patientId);
placed.push({
planId: c.planId,
patientId: c.patientId,
assigneeUserId: who,
// ⭐ 两种铺平必须分开记:有专属只是没轮到(关系还在) vs 从头没有专属(关系本就薄)。
// 混成一个值,T20 算出来的"铺平完成率低"就分不清是策略问题还是人群问题。
assignStrategy:
dcs && rosterIds.has(dcs)
? AssignStrategy.SPREAD_OVERFLOW
: AssignStrategy.SPREAD_NO_DEDICATED,
selectionMode: c.selectionMode,
});
}
return { placed, unplaced }; return { placed, unplaced };
} }
...@@ -514,7 +538,7 @@ function pickEvenly<T>(arr: T[], n: number): T[] { ...@@ -514,7 +538,7 @@ function pickEvenly<T>(arr: T[], n: number): T[] {
/** /**
* 正整数校验(Json 里读出来的东西什么都可能是)。 * 正整数校验(Json 里读出来的东西什么都可能是)。
* ⛔ 这里**不做上下限** —— 容量是主管对自己团队的判断,系统没有依据卡他(见 AGENT_CAPACITY_DEFAULT)。 * ⛔ 这里**不做上下限** —— 这些数是主管对自己团队的判断,系统没有依据卡他(见 BATCH_SIZE_DEFAULT)。
*/ */
function posInt(v: unknown): number | null { function posInt(v: unknown): number | null {
return typeof v === 'number' && Number.isInteger(v) && v > 0 ? v : null; return typeof v === 'number' && Number.isInteger(v) && v > 0 ? v : null;
...@@ -527,19 +551,23 @@ function posInt(v: unknown): number | null { ...@@ -527,19 +551,23 @@ function posInt(v: unknown): number | null {
*/ */
function sanitizeOverrides( function sanitizeOverrides(
raw: unknown, raw: unknown,
): Record<string, { capacity?: number; expiresInDays?: number }> { ): Record<string, { maxThisBatch?: number; expiresInDays?: number }> {
if (!raw || typeof raw !== 'object') return {}; if (!raw || typeof raw !== 'object') return {};
const out: Record<string, { capacity?: number; expiresInDays?: number }> = {}; const out: Record<string, { maxThisBatch?: number; expiresInDays?: number }> = {};
for (const [userId, v] of Object.entries(raw as Record<string, unknown>)) { for (const [userId, v] of Object.entries(raw as Record<string, unknown>)) {
if (!userId || !v || typeof v !== 'object') continue; if (!userId || !v || typeof v !== 'object') continue;
const o = v as { capacity?: unknown; expiresInDays?: unknown }; const o = v as { maxThisBatch?: unknown; expiresInDays?: unknown };
const capacity = posInt(o.capacity); // ⚠️ 名额上限**允许 0**(= 这轮不给他),所以不能用 posInt
const maxThisBatch =
typeof o.maxThisBatch === 'number' && Number.isInteger(o.maxThisBatch) && o.maxThisBatch >= 0
? o.maxThisBatch
: 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 (capacity == null && expiresInDays == null) continue; if (maxThisBatch == null && expiresInDays == null) continue;
out[userId] = { out[userId] = {
...(capacity != null ? { capacity } : {}), ...(maxThisBatch != null ? { maxThisBatch } : {}),
...(expiresInDays != null ? { expiresInDays } : {}), ...(expiresInDays != null ? { expiresInDays } : {}),
}; };
} }
...@@ -552,38 +580,42 @@ function ymd(iso: string): string { ...@@ -552,38 +580,42 @@ function ymd(iso: string): string {
} }
/** /**
* 容量说明 —— ⭐ 现在是**推导链 + 出处**,不再是一句"这是默认值" * 基数说明 —— **值 + 出处 + 分配后的水位**
* *
* 为什么必须显示推导链:批次规模由容量和在手量推出来,而在手量会变 * 为什么必须给出处:同一个「100 人」,是**他上次自己定的**还是**系统第一次拍的**,
* (客服处理完 / 分配单到期回池)。同样的容量,这次推出 340、下次推出 80 ——
* 主管看不到推导过程就只能怀疑系统抽风。把 `17 × 20 − 260 = 80` 摆出来,
* 数字跳动立刻能归因到"老单还压着"。
*
* 为什么必须显示出处:同一个「20」,是**他上次自己调的**还是**系统第一次拍的**,
* 信任度完全不同(T14:界面元素也算证据)。 * 信任度完全不同(T14:界面元素也算证据)。
*
* 为什么必须给水位:批次规模不再由在手量推导,所以主管看不到"团队现在多满" ——
* 但他仍然需要知道这一批压下去之后每人手上有多少。⛔ 这里**不设阈值、不做判断**
* ("是否超载"没有数据支撑),只报事实:分完后大家在 X~Y 条之间。
*/ */
function capacityNote(x: { function basisNote(x: {
agents: number; agents: number;
capacity: number; placed: number;
inHandTotal: number; inHandTotal: number;
totalRoom: number;
expiresInDays: number; expiresInDays: number;
basis: 'inherited' | 'default' | 'explicit'; basis: 'inherited' | 'default' | 'explicit';
basisFrom: string | null; basisFrom: string | null;
sizeBasis: 'explicit' | 'capacity';
target: number; target: number;
overrides: Array<{ name: string; capacity?: number; expiresInDays?: number }>; loads: Array<{ name: string; after: number }>;
overrides: Array<{ name: string; maxThisBatch?: number; expiresInDays?: number }>;
}): string { }): string {
const chain = const after = x.loads.map((l) => l.after);
`在岗 ${x.agents} 位 × 每人容量 ${x.capacity} − 已在手 ${x.inHandTotal} = 可分 ${x.totalRoom} 人` + const lo = after.length ? Math.min(...after) : 0;
(x.sizeBasis === 'explicit' ? `(本批按您指定的 ${x.target} 人走)` : ''); const hi = after.length ? Math.max(...after) : 0;
// ⚠️ 精调必须**点名**:推导链里写的是整体容量,而李莉实际只有 5 条 —— const top = x.loads.find((l) => l.after === hi);
// 不点出来,那条链自己就对不上账,主管会以为算错了。 const water =
x.placed > 0
? `本批 ${x.placed} 人铺给 ${x.loads.length} 位客服(**从手上最少的开始填**),` +
`分完后每人在手 ${lo === hi ? `${hi} 条` : `${lo}~${hi} 条(最高 ${top?.name})`};` +
`团队原有在手 ${x.inHandTotal} 条。`
: '';
// ⚠️ 精调必须**点名**:主管看到「李莉只有 5 条」时要立刻知道那是他自己设的,不是算错了
const tuned = x.overrides.length const tuned = x.overrides.length
? `其中${x.overrides ? `其中${x.overrides
.map( .map(
(o) => (o) =>
`${o.name}${o.capacity != null ? ` 容量 ${o.capacity}` : ''}${ `${o.name}${o.maxThisBatch != null ? ` 本批最多 ${o.maxThisBatch}` : ''}${
o.expiresInDays != null ? ` 时效 ${o.expiresInDays} 天` : '' o.expiresInDays != null ? ` 时效 ${o.expiresInDays} 天` : ''
}`, }`,
) )
...@@ -591,12 +623,12 @@ function capacityNote(x: { ...@@ -591,12 +623,12 @@ function capacityNote(x: {
: ''; : '';
const from = const from =
x.basis === 'inherited' && x.basisFrom x.basis === 'inherited' && x.basisFrom
? `容量 ${x.capacity} 条 / 时效 ${x.expiresInDays} 天**沿用 ${ymd(x.basisFrom)} 那次分配**,要改直接说(如「每人 30 条」「给 5 天」),下次自动记住。` ? `本批人数 ${x.target} / 时效 ${x.expiresInDays} 天**沿用 ${ymd(x.basisFrom)} 那次分配**,要改直接说(如「这批 200 人」「给 5 天」),下次自动记住。`
: x.basis === 'explicit' : x.basis === 'explicit'
? `容量 ${x.capacity} / 时效 ${x.expiresInDays} 天是**您本次指定的**,确认后下次自动沿用。` ? `本批人数 ${x.target} / 时效 ${x.expiresInDays} 天是**您本次指定的**,确认后下次自动沿用。`
: `容量 ${x.capacity} 条 / 时效 ${x.expiresInDays} 天是**首次默认值**(无历史可沿用,也无数据反推各人真实吞吐)——` + : `本批人数 ${x.target} / 时效 ${x.expiresInDays} 天是**首次默认值**(无历史可沿用,也无数据反推团队真实吞吐)——` +
`觉得不合适直接说个数,确认后下次自动沿用。`; `觉得不合适直接说个数,确认后下次自动沿用。`;
return `${chain};${tuned}${from}`; return `${water}${tuned}${from}`;
} }
function emptyProposal( function emptyProposal(
...@@ -606,13 +638,10 @@ function emptyProposal( ...@@ -606,13 +638,10 @@ function emptyProposal(
rosterNote: string, rosterNote: string,
base: { base: {
target: number; target: number;
capacity: number;
expiresInDays: number; expiresInDays: number;
basis: 'inherited' | 'default' | 'explicit'; basis: 'inherited' | 'default' | 'explicit';
basisFrom: string | null; basisFrom: string | null;
agentOverrides: Record<string, { capacity?: number; expiresInDays?: number }>; agentOverrides: Record<string, { maxThisBatch?: number; expiresInDays?: number }>;
/** 逐人生效容量 —— ⚠️ 空提案也要用它判"谁已满",否则精调过的人会被算错 */
capOf: (userId: string) => number;
}, },
): AssignmentProposal { ): AssignmentProposal {
return { return {
...@@ -620,7 +649,6 @@ function emptyProposal( ...@@ -620,7 +649,6 @@ function emptyProposal(
potentialTreatment: potentialTreatment ?? null, potentialTreatment: potentialTreatment ?? null,
candidateTotal: 0, candidateTotal: 0,
target: base.target, target: base.target,
capacity: base.capacity,
expiresInDays: base.expiresInDays, expiresInDays: base.expiresInDays,
basis: base.basis, basis: base.basis,
basisFrom: base.basisFrom, basisFrom: base.basisFrom,
...@@ -629,17 +657,14 @@ function emptyProposal( ...@@ -629,17 +657,14 @@ function emptyProposal(
unplaced: 0, unplaced: 0,
items: [], items: [],
byAgent: [], byAgent: [],
skippedAgents: agents // 空提案里所有在岗的人都"没分到"
.filter((a) => a.inHand >= base.capOf(a.userId)) skippedAgents: agents.map((a) => ({ userId: a.userId, name: a.name, inHand: a.inHand })),
.map((a) => ({ userId: a.userId, name: a.name, inHand: a.inHand })),
rosterNote, rosterNote,
capacityNote: `每人容量 ${base.capacity} / 时效 ${base.expiresInDays} 天。`, basisNote: `本批人数 ${base.target} / 时效 ${base.expiresInDays} 天。`,
selectionNote: selectionNote:
agents.length === 0 agents.length === 0
? '该诊所名册为空,无法出分配方案 —— 请主管直接指定客服。' ? '该诊所名册为空,无法出分配方案 —— 请主管直接指定客服。'
: // ⚠️ 这句在新模型下会常见得多(容量默认从 50 降到 20):必须说清"加容量"这条出路, : // target=0 只能是主管自己说的(基数默认永远 > 0)
// 否则主管只看到"分不了",不知道旋钮在哪。 '本批人数被设成了 0,没有可分配的量 —— 说个数字我重出一版。',
`在岗 ${agents.length} 位客服在手量都已达容量 ${base.capacity} 条,本批无可分配余量 ——` +
`要么等他们处理完(到期会自动回池),要么直接说「每人 ${base.capacity + 10} 条」提高容量。`,
}; };
} }
import { AssignStrategy, AGENT_CAPACITY_DEFAULT, type AgentInfo } from '@pac/types'; import { AssignStrategy, type AgentInfo } from '@pac/types';
import { placeAgents } from '../src/modules/plan/assignment-proposal.service'; import { placeAgents } from '../src/modules/plan/assignment-proposal.service';
/** /**
...@@ -28,15 +28,33 @@ const pick = (n: number, prefix = 'p') => ...@@ -28,15 +28,33 @@ const pick = (n: number, prefix = 'p') =>
})); }));
describe('placeAgents —— 专属优先', () => { describe('placeAgents —— 专属优先', () => {
test('⭐ 专属客服在名册内且有余量 → 命中 dedicated', () => { test('⭐ 专属客服在名册内、且没超过本批应得份额 → 命中 dedicated', () => {
const chosen = pick(3); const chosen = pick(3);
const dedicated = new Map(chosen.map((c) => [c.patientId, 'a'])); const dedicated = new Map(chosen.map((c) => [c.patientId, 'a']));
const { placed } = placeAgents(chosen, [agent('a', 0), agent('b', 0)], dedicated); // 只有 a 一个人在岗 → 目标水位 = 3,三条全归他
const { placed } = placeAgents(chosen, [agent('a', 0)], dedicated);
expect(placed).toHaveLength(3); expect(placed).toHaveLength(3);
expect(placed.every((p) => p.assigneeUserId === 'a')).toBe(true); expect(placed.every((p) => p.assigneeUserId === 'a')).toBe(true);
expect(placed.every((p) => p.assignStrategy === AssignStrategy.DEDICATED)).toBe(true); expect(placed.every((p) => p.assignStrategy === AssignStrategy.DEDICATED)).toBe(true);
}); });
/**
* 🔴🔴 **专属那一趟也受目标水位约束**(2026-08-03)。
*
* 容量删掉之后没有任何东西约束专属了,本地实测一次:100 条里 76 条是同一个人的专属,
* 他一个人吃到 76,水位法只剩零头可铺 —— 「最后保持齐平」当场落空。
* 目标水位 =(团队在手 + 本批人数)/ 在岗人数,不是新旋钮,由 N 推出来。
*/
test('🔴 3 条全是 a 的专属、两人在岗 → a 只拿应得的 2 条,第 3 条溢出给 b', () => {
const chosen = pick(3);
const dedicated = new Map(chosen.map((c) => [c.patientId, 'a']));
const { placed } = placeAgents(chosen, [agent('a', 0), agent('b', 0)], dedicated);
expect(placed.filter((p) => p.assigneeUserId === 'a')).toHaveLength(2);
// ⚠️ 溢出的那条**仍然标 spread_overflow**:关系还在,只是这轮份额满了
const spilled = placed.find((p) => p.assigneeUserId === 'b')!;
expect(spilled.assignStrategy).toBe(AssignStrategy.SPREAD_OVERFLOW);
});
test('⭐ 专属客服**不在名册** → 走铺平,且标 spread_no_dedicated', () => { test('⭐ 专属客服**不在名册** → 走铺平,且标 spread_no_dedicated', () => {
const chosen = pick(2); const chosen = pick(2);
// 专属是 'ghost'(已离职,名册里查不到) // 专属是 'ghost'(已离职,名册里查不到)
...@@ -57,11 +75,9 @@ describe('placeAgents —— 专属优先', () => { ...@@ -57,11 +75,9 @@ describe('placeAgents —— 专属优先', () => {
test('⭐⭐ 专属**已满** → 溢出给别人,且标 spread_overflow(与"无专属"区分开)', () => { test('⭐⭐ 专属**已满** → 溢出给别人,且标 spread_overflow(与"无专属"区分开)', () => {
const chosen = pick(3); const chosen = pick(3);
const dedicated = new Map(chosen.map((c) => [c.patientId, 'a'])); const dedicated = new Map(chosen.map((c) => [c.patientId, 'a']));
// a 只剩 1 个余量 // a 本批名额只有 1 个(精调)
const { placed } = placeAgents( const { placed } = placeAgents(chosen, [agent('a', 0), agent('b', 0)], dedicated, (u) =>
chosen, u === 'a' ? 1 : Infinity,
[agent('a', AGENT_CAPACITY_DEFAULT - 1), agent('b', 0)],
dedicated,
); );
const byStrategy = placed.reduce<Record<string, number>>((m, p) => { const byStrategy = placed.reduce<Record<string, number>>((m, p) => {
m[p.assignStrategy] = (m[p.assignStrategy] ?? 0) + 1; m[p.assignStrategy] = (m[p.assignStrategy] ?? 0) + 1;
...@@ -74,8 +90,8 @@ describe('placeAgents —— 专属优先', () => { ...@@ -74,8 +90,8 @@ describe('placeAgents —— 专属优先', () => {
}); });
}); });
describe('placeAgents —— 溢出均分 + 在手硬护栏', () => { describe('placeAgents —— 溢出水位法', () => {
test('⭐ 均分:9 条给 3 个空闲客服 → 各 3 条', () => { test('⭐ 起点相同 → 结果仍是均分:9 条给 3 个空闲客服 → 各 3 条', () => {
const chosen = pick(9); const chosen = pick(9);
const { placed, unplaced } = placeAgents( const { placed, unplaced } = placeAgents(
chosen, chosen,
...@@ -87,42 +103,58 @@ describe('placeAgents —— 溢出均分 + 在手硬护栏', () => { ...@@ -87,42 +103,58 @@ describe('placeAgents —— 溢出均分 + 在手硬护栏', () => {
expect(counts).toEqual([3, 3, 3]); expect(counts).toEqual([3, 3, 3]);
}); });
test('⭐⭐ 已满的人**本批一条都不给**(不是少给)', () => { /**
* 🔴🔴 水位法的本体:**从手上最少的人开始填**,不是"每人加一样多"。
*
* 真实场景(2026-08-03 走查):第一批把有专属的人灌高了,第二批如果按人头均分,
* 起点不齐终点还是不齐 —— 主管要的是"最后保持齐平"。
*/
test('🔴 在手 10/0/0,分 6 条 → 全给空的两位并拉平(⛔ 不是每人各 2)', () => {
const { placed } = placeAgents(pick(6), [agent('busy', 10), agent('x', 0), agent('y', 0)], new Map());
expect(placed.filter((p) => p.assigneeUserId === 'busy')).toHaveLength(0);
expect(placed.filter((p) => p.assigneeUserId === 'x')).toHaveLength(3);
expect(placed.filter((p) => p.assigneeUserId === 'y')).toHaveLength(3);
});
test('🔴 水位追平之后才开始雨露均沾(在手 2/0,分 6 条 → 2 / 4)', () => {
const { placed } = placeAgents(pick(6), [agent('a', 2), agent('b', 0)], new Map());
expect(placed.filter((p) => p.assigneeUserId === 'a')).toHaveLength(2);
expect(placed.filter((p) => p.assigneeUserId === 'b')).toHaveLength(4);
});
test('🔴 专属吃到目标水位就转铺平,最终两人齐平(6 条 → 3 / 3)', () => {
// 6 条里前 4 条是 a 的专属,但目标水位 = 6/2 = 3 → a 拿 3,第 4 条溢出
const chosen = pick(6);
const dedicated = new Map(chosen.slice(0, 4).map((c) => [c.patientId, 'a']));
const { placed } = placeAgents(chosen, [agent('a', 0), agent('b', 0)], dedicated);
expect(placed.filter((p) => p.assigneeUserId === 'a')).toHaveLength(3);
expect(placed.filter((p) => p.assigneeUserId === 'b')).toHaveLength(3);
});
test('⭐⭐ 精调成 0 名额的人**一条都不给**', () => {
const chosen = pick(4); const chosen = pick(4);
const { placed } = placeAgents( const { placed } = placeAgents(chosen, [agent('off', 0), agent('free', 0)], new Map(), (u) =>
chosen, u === 'off' ? 0 : Infinity,
[agent('full', AGENT_CAPACITY_DEFAULT), agent('free', 0)],
new Map(),
); );
// 硬塞给已满的人是 T5 的反面,而且会立刻造出 over_capacity 退回 —— expect(placed.filter((p) => p.assigneeUserId === 'off')).toHaveLength(0);
// 那正是要用来反推容量默认值的信号,自己造出来就没法反推了
expect(placed.filter((p) => p.assigneeUserId === 'full')).toHaveLength(0);
expect(placed.filter((p) => p.assigneeUserId === 'free')).toHaveLength(4); expect(placed.filter((p) => p.assigneeUserId === 'free')).toHaveLength(4);
}); });
test('⭐⭐ 容量不够 → **截断不摊派**,如实报 unplaced', () => { test('⭐⭐ 所有人名额都用完 → 如实报 unplaced(⛔ 不硬塞)', () => {
const chosen = pick(5); const { placed, unplaced } = placeAgents(pick(5), [agent('a', 0)], new Map(), () => 2);
// 只剩 2 个余量
const { placed, unplaced } = placeAgents(
chosen,
[agent('a', AGENT_CAPACITY_DEFAULT - 2)],
new Map(),
);
expect(placed).toHaveLength(2); expect(placed).toHaveLength(2);
expect(unplaced).toBe(3); expect(unplaced).toBe(3);
}); });
test('⭐⭐ 专属段与铺平段**共用同一个余量累加器**(否则专属大户会被再灌一轮)', () => { test('⭐⭐ 专属段与铺平段**共用同一本账**(否则专属大户会被再灌一轮)', () => {
// 6 条全是 a 的专属;a 只剩 2 个余量 → 2 条 dedicated,4 条该溢出给 b // 6 条全是 a 的专属;a 本批名额 2 → 2 条 dedicated,4 条该溢出给 b
const chosen = pick(6); const chosen = pick(6);
const dedicated = new Map(chosen.map((c) => [c.patientId, 'a'])); const dedicated = new Map(chosen.map((c) => [c.patientId, 'a']));
const { placed, unplaced } = placeAgents( const { placed, unplaced } = placeAgents(chosen, [agent('a', 0), agent('b', 0)], dedicated, (u) =>
chosen, u === 'a' ? 2 : Infinity,
[agent('a', AGENT_CAPACITY_DEFAULT - 2), agent('b', 0)],
dedicated,
); );
expect(unplaced).toBe(0); expect(unplaced).toBe(0);
// ⚠️ 若两段各自算余量,a 会在铺平段又被当成"还有余量"再吃一轮 —— 这一点在代码上完全不显眼 // ⚠️ 若两段各自算账,a 会在铺平段又被当成"还有名额"再吃一轮 —— 这一点在代码上完全不显眼
expect(placed.filter((p) => p.assigneeUserId === 'a')).toHaveLength(2); expect(placed.filter((p) => p.assigneeUserId === 'a')).toHaveLength(2);
expect(placed.filter((p) => p.assigneeUserId === 'b')).toHaveLength(4); expect(placed.filter((p) => p.assigneeUserId === 'b')).toHaveLength(4);
}); });
...@@ -210,50 +242,50 @@ describe('selectionNote —— 候选不够时的措辞', () => { ...@@ -210,50 +242,50 @@ describe('selectionNote —— 候选不够时的措辞', () => {
return new AssignmentProposalService(prisma, roster); return new AssignmentProposalService(prisma, roster);
} }
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。⚠️ 批次规模现在是**推出来的**,不是默认常量 /// 首次默认批次人数(BATCH_SIZE_DEFAULT)
const DEFAULT_TARGET = 9 * 20; const N = 100;
test('⭐⭐ 候选 44 < 可分 180 → 说「一共就 44 人,全部纳入」,⛔ 不许说「取前 N 人」', async () => { test('⭐⭐ 候选 44 < 本批 100 → 说「一共就 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('全部纳入');
expect(r.selectionNote).not.toContain('取前'); expect(r.selectionNote).not.toContain('取前');
}); });
test('⭐ 候选 500 > 可分 180 → 照实说「从 500 位候选里取前 180 人」', async () => { test('⭐ 候选 500 > 本批 100 → 照实说「从 500 位候选里取前 100 人」+ 剩下的还在排队', 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(DEFAULT_TARGET); expect(r.target).toBe(N);
expect(r.selectionNote).toContain(`从 500 位候选里取前 ${DEFAULT_TARGET} 人`); expect(r.selectionNote).toContain(`从 500 位候选里取前 ${N} 人`);
// ⚠️ 「只分这么多」必须说清是**容量吃不下**而不是**候选不够** —— 主管的下一步动作相反 // ⚠️ 必须告诉主管"还能再分一批" —— 这正是容量模型下他做不到的那件事
expect(r.selectionNote).toContain('按容量算出来的'); expect(r.selectionNote).toContain('随时可以再分一批');
}); });
/** /**
* 🔴🔴 取数上限**不许**冒充候选数。 * 🔴🔴 取数上限**不许**冒充候选数。
* *
* 真实场景(2026-08-03 走查发现):主管在矩阵点「充填 · 窗口外 1,080」, * 真实场景(2026-08-03 走查发现):主管在矩阵点「充填 · 窗口外 1,080」,
* 确认单却说「从 170 位候选里取前 100 人」—— 170 正是 `ceil(target*1.5)+20` 这个 LIMIT。 * 确认单却说「从 170 位候选里取前 100 人」—— 170 正是 `ceil(target*1.5)+20` 这个 LIMIT。
* 而这句话助手要**原话转述**,等于让系统当着主管的面报一个他刚看过的、对不上的数。 * 而这句话助手要**原话转述**,等于让系统当着主管的面报一个他刚看过的、对不上的数。
*/ */
test('🔴 候选 1080 但取数只取回 1.5 倍窗口 → 说的必须是 1080', async () => { test('🔴 候选 1080 但取数只取回 1.5 倍窗口 → 说的必须是 1080', async () => {
const svc = svcWith(1080, 9); const r = await svcWith(1080, 9).propose(SCOPE, {
const r = await svc.propose(SCOPE, { clinicId: 'c1', potentialTreatment: 'filling', temperature: 'cold' }); clinicId: 'c1', potentialTreatment: 'filling', temperature: 'cold',
});
expect(r.candidateTotal).toBe(1080); expect(r.candidateTotal).toBe(1080);
expect(r.selectionNote).toContain(`从 1080 位候选里取前 ${DEFAULT_TARGET} 人`); expect(r.selectionNote).toContain(`从 1080 位候选里取前 ${N} 人`);
expect(r.placed).toBe(DEFAULT_TARGET); expect(r.placed).toBe(N);
}); });
}); });
/** /**
* 两个基数(容量 + 时效)的**沿用** * 两个基数(本批人数 + 时效)的**沿用**,以及"连着分第二批"
* *
* 🔴 这是「先出确认单、主管反馈调整」这个设计的兑现点:第一次调顺手,第二次起零输入。 * 🔴 这是「先出确认单、主管反馈调整」这个设计的兑现点:第一次调顺手,第二次起零输入。
* 沿用断了不会报错 —— 只是主管每次都要重调一遍,然后觉得"这系统不记事"。 * 沿用断了不会报错 —— 只是主管每次都要重调一遍,然后觉得"这系统不记事"。
*/ */
describe('基数沿用 —— 容量与时效', () => { describe('基数沿用 —— 本批人数与时效', () => {
const { AssignmentProposalService } = require('../src/modules/plan/assignment-proposal.service'); const { AssignmentProposalService } = require('../src/modules/plan/assignment-proposal.service');
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 = {
$queryRaw: jest.fn(async (sql: { strings?: string[]; values?: unknown[] }) => { $queryRaw: jest.fn(async (sql: { strings?: string[]; values?: unknown[] }) => {
...@@ -284,102 +316,112 @@ describe('基数沿用 —— 容量与时效', () => { ...@@ -284,102 +316,112 @@ describe('基数沿用 —— 容量与时效', () => {
return new AssignmentProposalService(prisma, roster); return new AssignmentProposalService(prisma, roster);
}; };
test('⭐⭐ 上次用了容量 30 / 时效 5 天 → 本次沿用,批次规模 = 9 × 30', async () => { /**
const r = await mk(1000, 9, { lastCriteria: { capacity: 30, expiresInDays: 5 } }).propose(SCOPE, { * 🔴🔴 **连着分第二批必须还能分**。
*
* 这是 2026-08-03 走查抓到的死路:批次规模曾经 = Σ(容量−在手),
* 于是第一批把所有人填到水位之后,第二批恒为 0 —— 主管只能靠往上棚容量来绕开,
* 而容量会被记住,下周的"习惯"就是个虚高的数。
*/
test('🔴 第一批分完(人人在手 25)→ 第二批照样 100 人,⛔ 不是 0', async () => {
const r = await mk(1000, 9, { inHand: 25, lastCriteria: { batchSize: 100, expiresInDays: 3 } })
.propose(SCOPE, { clinicId: 'c1' });
expect(r.target).toBe(100);
expect(r.placed).toBe(100);
expect(r.unplaced).toBe(0);
expect(r.skippedAgents).toHaveLength(0);
});
test('⭐⭐ 上次用了 300 人 / 5 天 → 本次沿用,并说出沿用哪一次', async () => {
const r = await mk(1000, 9, { lastCriteria: { batchSize: 300, expiresInDays: 5 } }).propose(SCOPE, {
clinicId: 'c1', clinicId: 'c1',
potentialTreatment: 'implant',
}); });
expect(r.capacity).toBe(30); expect(r.target).toBe(300);
expect(r.expiresInDays).toBe(5); expect(r.expiresInDays).toBe(5);
expect(r.basis).toBe('inherited'); expect(r.basis).toBe('inherited');
expect(r.target).toBe(270); expect(r.basisNote).toContain('沿用 2026-07-28');
// 出处必须说出来 —— 「这 30 哪来的」和这个数本身一样重要 });
expect(r.capacityNote).toContain('沿用 2026-07-28');
/**
* ⚠️ 老快照里这个数叫 `target`(那时它是推出来的结果)。不认的话,改版后所有人的
* "沿用"都会静默退回默认值,而界面只显示"首次默认" —— 看不出丢了东西。
*/
test('⭐ 老快照只有 target(没有 batchSize)→ 一样认', async () => {
const r = await mk(1000, 9, { lastCriteria: { target: 250, expiresInDays: 7 } }).propose(SCOPE, {
clinicId: 'c1',
});
expect(r.target).toBe(250);
expect(r.basis).toBe('inherited');
}); });
test('⭐ 从来没分过 → 首次默认(容量 20 / 3 天),且明说是默认', async () => { test('⭐ 从来没分过 → 首次默认(100 人 / 3 天),且明说是默认', async () => {
const r = await mk(1000, 9).propose(SCOPE, { clinicId: 'c1', potentialTreatment: 'implant' }); const r = await mk(1000, 9).propose(SCOPE, { clinicId: 'c1' });
expect(r.capacity).toBe(20); expect(r.target).toBe(100);
expect(r.expiresInDays).toBe(3); expect(r.expiresInDays).toBe(3);
expect(r.basis).toBe('default'); expect(r.basis).toBe('default');
expect(r.capacityNote).toContain('首次默认值'); expect(r.basisNote).toContain('首次默认值');
}); });
test('⭐ 主管本次说「每人 40 条」→ explicit,批次 = 9 × 40', async () => { test('⭐ 主管本次说「这批 200 人」→ explicit,且下次会沿用', async () => {
const r = await mk(1000, 9, { lastCriteria: { capacity: 20, expiresInDays: 3 } }).propose(SCOPE, { const r = await mk(1000, 9, { lastCriteria: { batchSize: 100, expiresInDays: 3 } }).propose(SCOPE, {
clinicId: 'c1', clinicId: 'c1',
capacity: 40, targetCount: 200,
}); });
expect(r.capacity).toBe(40); expect(r.target).toBe(200);
expect(r.basis).toBe('explicit'); expect(r.basis).toBe('explicit');
expect(r.target).toBe(360); expect(r.basisNote).toContain('下次自动沿用');
}); });
/** /**
* 🔴 一次性人数**不许**回写成基数 * 🔴 按客服精调必须**穿到落人那一层**
* 拿「这批只要 60 人」反推出「以后每人 6.7 条」= 把临时决定固化成长期参数, * 「李莉这周带教,这批最多 5 条」—— 只改展示不改落人的话,她照样被水位法灌满,
* 而下次没人记得为什么变了 * 而卡片上写着 5。这种错不报错,要到她抱怨时才发现
*/ */
test('🔴 主管说「这批只要 60 人」→ 人数 60,但容量基数原样不动', async () => { test('🔴 李莉本批最多 5 条 → 她只拿 5 条,其余照样分掉(总数不变)', async () => {
const r = await mk(1000, 9, { lastCriteria: { capacity: 30, expiresInDays: 5 } }).propose(SCOPE, {
clinicId: 'c1',
targetCount: 60,
});
expect(r.target).toBe(60);
expect(r.capacity).toBe(30); // ⛔ 不是 60/9
expect(r.basis).toBe('inherited');
expect(r.selectionNote).toContain('不改容量基数');
});
/**
* 🔴🔴 按客服精调必须**穿到落人那一层**。
*
* 「李莉这周带教,只给 5 条」—— 如果精调只改了展示、没进 room 计算,
* 她照样会被分满 20 条,而卡片上写着 5。这种错不会报错,要到她抱怨时才发现。
*/
test('🔴 李莉容量精调 5 → 她只拿 5 条,总人数也跟着变(8×20 + 5)', async () => {
const r = await mk(1000, 9, { const r = await mk(1000, 9, {
lastCriteria: { capacity: 20, expiresInDays: 3, agentOverrides: { a3: { capacity: 5 } } }, lastCriteria: { batchSize: 100, expiresInDays: 3, agentOverrides: { a3: { maxThisBatch: 5 } } },
}).propose(SCOPE, { clinicId: 'c1' }); }).propose(SCOPE, { clinicId: 'c1' });
expect(r.target).toBe(8 * 20 + 5);
const lily = r.byAgent.find((x: { userId: string }) => x.userId === 'a3'); const lily = r.byAgent.find((x: { userId: string }) => x.userId === 'a3');
expect(lily.capacity).toBe(5); expect(lily.count).toBe(5);
expect(lily.overridden).toBe(true); expect(lily.overridden).toBe(true);
expect(lily.count).toBeLessThanOrEqual(5); // ⚠️ 名额上限**不改批次规模** —— 少的那部分由别人接走
// 精调必须**点名**,否则推导链里的「9 × 20」自己就对不上账 expect(r.placed).toBe(100);
expect(r.capacityNote).toContain('容量 5'); expect(r.basisNote).toContain('本批最多 5 条');
});
test('⭐ 精调成 0 → 这轮完全不给他,并列进 skippedAgents', async () => {
const r = await mk(1000, 9, {
lastCriteria: { batchSize: 100, agentOverrides: { a3: { maxThisBatch: 0 } } },
}).propose(SCOPE, { clinicId: 'c1' });
expect(r.byAgent.find((x: { userId: string }) => x.userId === 'a3')).toBeUndefined();
expect(r.skippedAgents.map((x: { userId: string }) => x.userId)).toContain('a3');
expect(r.placed).toBe(100);
}); });
test('⭐ 时效精调只落在那个人身上,不动整体基数', async () => { test('⭐ 时效精调只落在那个人身上,不动整体基数', async () => {
const r = await mk(1000, 9, { const r = await mk(1000, 9, {
lastCriteria: { capacity: 20, expiresInDays: 3, agentOverrides: { a3: { expiresInDays: 7 } } }, lastCriteria: { batchSize: 100, expiresInDays: 3, agentOverrides: { a3: { expiresInDays: 7 } } },
}).propose(SCOPE, { clinicId: 'c1' }); }).propose(SCOPE, { clinicId: 'c1' });
expect(r.expiresInDays).toBe(3); // 整体不变 expect(r.expiresInDays).toBe(3);
expect(r.byAgent.find((x: { userId: string }) => x.userId === 'a3').expiresInDays).toBe(7); expect(r.byAgent.find((x: { userId: string }) => x.userId === 'a3').expiresInDays).toBe(7);
expect(r.byAgent.find((x: { userId: string }) => x.userId === 'a0').expiresInDays).toBe(3); expect(r.byAgent.find((x: { userId: string }) => x.userId === 'a0').expiresInDays).toBe(3);
expect(r.target).toBe(180); // 时效不改人数
}); });
test('⛔ 精调表里的垃圾值静默丢弃,不让一条坏精调把整张确认单顶掉', async () => { test('⛔ 精调表里的垃圾值静默丢弃,不让一条坏精调把整张确认单顶掉', async () => {
const r = await mk(1000, 9, { const r = await mk(1000, 9, {
lastCriteria: { lastCriteria: {
capacity: 20, batchSize: 100,
agentOverrides: { a1: { capacity: 0 }, a2: { capacity: '很多' }, a3: { expiresInDays: 999 } }, agentOverrides: { a1: { maxThisBatch: -1 }, a2: { maxThisBatch: '很多' }, a3: { expiresInDays: 999 } },
}, },
}).propose(SCOPE, { clinicId: 'c1' }); }).propose(SCOPE, { clinicId: 'c1' });
expect(r.agentOverrides).toEqual({}); expect(r.agentOverrides).toEqual({});
expect(r.target).toBe(180); expect(r.placed).toBe(100);
}); });
test('⭐ 在手 ≥ 容量的人本批不参与,但要列进 skippedAgents', async () => { test('⭐ 分配后的水位要写进 basisNote(没有阈值判断,只报事实)', async () => {
const r = await mk(1000, 9, { inHand: 25, lastCriteria: { capacity: 20, expiresInDays: 3 } }).propose( const r = await mk(1000, 3, { inHand: 0, lastCriteria: { batchSize: 9, expiresInDays: 3 } })
SCOPE, .propose(SCOPE, { clinicId: 'c1' });
{ clinicId: 'c1' }, expect(r.byAgent.every((x: { loadAfter: number }) => x.loadAfter === 3)).toBe(true);
); expect(r.basisNote).toContain('分完后每人在手 3 条');
expect(r.target).toBe(0);
expect(r.placed).toBe(0);
expect(r.skippedAgents).toHaveLength(9);
// 「分不了」必须同时说出旋钮在哪,否则主管只看到死路
expect(r.selectionNote).toContain('每人 30 条');
}); });
}); });
...@@ -21,8 +21,8 @@ import { cn } from '@/lib/utils'; ...@@ -21,8 +21,8 @@ import { cn } from '@/lib/utils';
* artifact 跑在 `sandbox="allow-scripts"` 的 iframe 里、CSP `connect-src 'none'`, * artifact 跑在 `sandbox="allow-scripts"` 的 iframe 里、CSP `connect-src 'none'`,
* **卡片内不可能发出写请求**。分岔记牢:只读展示用 artifact,可交互用原生组件。 * **卡片内不可能发出写请求**。分岔记牢:只读展示用 artifact,可交互用原生组件。
* *
* ── 微调**只有两**(T13)────────────────────────────────── * ── 微调**只有两**(T13)──────────────────────────────────
* 指定客服、时效。多加一项就直接违 T13 —— * 指定客服、时效(整批 + 按客服两级)。多加一类就直接违 T13 ——
* 微调项越多,「一次确认」这个设计目标就越不可能实现。 * 微调项越多,「一次确认」这个设计目标就越不可能实现。
* 要换人群请回到对话里让助手重新圈,不要在卡片上做筛选。 * 要换人群请回到对话里让助手重新圈,不要在卡片上做筛选。
* *
...@@ -31,10 +31,10 @@ import { cn } from '@/lib/utils'; ...@@ -31,10 +31,10 @@ import { cn } from '@/lib/utils';
* 患者明细按客服折叠,展开才看 —— 兼顾"尽明细"与"一眼可确认"。 * 患者明细按客服折叠,展开才看 —— 兼顾"尽明细"与"一眼可确认"。
*/ */
/** /**
* 精调表落库形态 = 提案带来的(含容量部分) + 卡片上刚改的时效。 * 精调表落库形态 = 提案带来的(含名额上限部分) + 卡片上刚改的时效。
* *
* ⚠️ 卡片只能改时效,所以**容量部分必须原样带走** —— 少带一次,主管上次设的 * ⚠️ 卡片只能改时效,所以**名额上限部分必须原样带走** —— 少带一次,主管上次设的
* 「李莉只给 5 条」就在这一次确认里被悄悄清掉了,而界面上完全看不出来。 * 「李莉这批最多 5 条」就在这一次确认里被悄悄清掉了,而界面上完全看不出来。
*/ */
function mergeOverrides( function mergeOverrides(
fromSheet: Record<string, AgentOverride>, fromSheet: Record<string, AgentOverride>,
...@@ -42,8 +42,8 @@ function mergeOverrides( ...@@ -42,8 +42,8 @@ function mergeOverrides(
): Record<string, AgentOverride> { ): Record<string, AgentOverride> {
const out: Record<string, AgentOverride> = {}; const out: Record<string, AgentOverride> = {};
for (const [userId, o] of Object.entries(fromSheet)) { for (const [userId, o] of Object.entries(fromSheet)) {
// 时效部分交给卡片当前值决定(下面统一写),这里只留容量 // 时效部分交给卡片当前值决定(下面统一写),这里只留名额上限
if (o.capacity != null) out[userId] = { capacity: o.capacity }; if (o.maxThisBatch != null) out[userId] = { maxThisBatch: o.maxThisBatch };
} }
for (const [userId, days] of Object.entries(expiryByAgent)) { for (const [userId, days] of Object.entries(expiryByAgent)) {
out[userId] = { ...(out[userId] ?? {}), expiresInDays: days }; out[userId] = { ...(out[userId] ?? {}), expiresInDays: days };
...@@ -68,8 +68,8 @@ export function AssignmentConfirmSheet({ ...@@ -68,8 +68,8 @@ export function AssignmentConfirmSheet({
/** /**
* 时效初值来自**提案**(沿用主管上一次的值),⛔ 不是写死的 3 —— * 时效初值来自**提案**(沿用主管上一次的值),⛔ 不是写死的 3 ——
* 写死的话"记住上次时效"这件事在界面上就永远看不见,主管每次还得重调一遍。 * 写死的话"记住上次时效"这件事在界面上就永远看不见,主管每次还得重调一遍。
* ⚠️ 容量**不做成卡片控件**:改容量会改变人群(人数由它推出), * ⚠️ **本批人数**不做成卡片控件:改人数会换一批人,而卡片的微调项只允许
* 而卡片的微调项只允许"不改人群"的那两个(T13)。改容量回对话说一句,助手重出单。 * "不改人群"的那些(T13)。改人数回对话说一句(「这批 200 人」),助手重出单。
*/ */
const [expiresInDays, setExpiresInDays] = useState(sheet.expiresInDays); const [expiresInDays, setExpiresInDays] = useState(sheet.expiresInDays);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
...@@ -78,8 +78,8 @@ export function AssignmentConfirmSheet({ ...@@ -78,8 +78,8 @@ export function AssignmentConfirmSheet({
/** /**
* 按客服的**时效精调**(userId → 天数)。初值来自提案(沿用上次的精调)。 * 按客服的**时效精调**(userId → 天数)。初值来自提案(沿用上次的精调)。
* *
* ⚠️ 为什么时效能在卡片上精调、容量不能:时效**不改人群**(只是这几条单子多久回池), * ⚠️ 为什么时效能在卡片上精调、人数/名额不能:时效**不改人群**(只是这几条单子多久回池),
* 容量会 —— 人数是 Σ(容量−在手) 推出来的,改一下整批人就变了,那必须重出确认单。 * 另两个会 —— 改了就是换一批人,那必须重出确认单。
* ⚠️ 落库时写到该客服名下**每一条**任务的 `assignment_expires_at`(逐条覆盖批次时效)。 * ⚠️ 落库时写到该客服名下**每一条**任务的 `assignment_expires_at`(逐条覆盖批次时效)。
*/ */
const [expiryByAgent, setExpiryByAgent] = useState<Record<string, number>>(() => const [expiryByAgent, setExpiryByAgent] = useState<Record<string, number>>(() =>
...@@ -109,7 +109,9 @@ export function AssignmentConfirmSheet({ ...@@ -109,7 +109,9 @@ export function AssignmentConfirmSheet({
selectionNote: sheet.selectionNote, selectionNote: sheet.selectionNote,
// ⭐ 两个基数落进快照 —— **下一次分配就是从这里读出来沿用的**(零新列)。 // ⭐ 两个基数落进快照 —— **下一次分配就是从这里读出来沿用的**(零新列)。
// ⚠️ 时效存**卡片当前值**不是提案值:主管刚在上面改成 5 天,记住的就得是 5。 // ⚠️ 时效存**卡片当前值**不是提案值:主管刚在上面改成 5 天,记住的就得是 5。
capacity: sheet.capacity, // ⚠️ 存 batchSize 这个**新键**,同时 target 仍在(上面)—— 沿用时两个都认,
// 老批次只有 target,新批次两个都有,改版不丢"沿用"
batchSize: sheet.target,
expiresInDays, expiresInDays,
// ⭐ 按客服的精调也进快照 —— 下次一并沿用(容量部分原样带走,时效部分用卡片当前值) // ⭐ 按客服的精调也进快照 —— 下次一并沿用(容量部分原样带走,时效部分用卡片当前值)
agentOverrides: mergeOverrides(sheet.agentOverrides, expiryByAgent), agentOverrides: mergeOverrides(sheet.agentOverrides, expiryByAgent),
...@@ -209,7 +211,9 @@ export function AssignmentConfirmSheet({ ...@@ -209,7 +211,9 @@ export function AssignmentConfirmSheet({
{/* 按客服的时效精调 —— 落到他名下**每一条**任务上。 {/* 按客服的时效精调 —— 落到他名下**每一条**任务上。
容量只读:改它会换人群,得回对话让助手重出单(见组件顶部注释)。 */} 容量只读:改它会换人群,得回对话让助手重出单(见组件顶部注释)。 */}
<div className="mb-1.5 flex flex-wrap items-center gap-1.5 text-[10.5px] text-slate-500"> <div className="mb-1.5 flex flex-wrap items-center gap-1.5 text-[10.5px] text-slate-500">
<span>容量 {a.capacity}</span> <span>
在手 {a.inHandBefore}<span className="text-slate-700">{a.loadAfter}</span>
</span>
<span className="text-slate-300">·</span> <span className="text-slate-300">·</span>
<span>时效</span> <span>时效</span>
{ASSIGNMENT_EXPIRES_DAYS_PRESETS.map((d) => { {ASSIGNMENT_EXPIRES_DAYS_PRESETS.map((d) => {
...@@ -268,10 +272,12 @@ export function AssignmentConfirmSheet({ ...@@ -268,10 +272,12 @@ export function AssignmentConfirmSheet({
})} })}
</div> </div>
{/* 已满被跳过的人 —— 仍然列出来:主管要看见"他不是被漏了,是已经满了" */} {/* 本批没分到的人 —— 仍然列出来:主管要看见"他不是被漏了"。
⚠️ 措辞不能再写「已满」:没有容量上限这回事了,他没分到是因为**手上本来就最多**
(水位法没轮到)或被精调成 0 名额。写「已满(0)」会自相矛盾。 */}
{sheet.skippedAgents.length > 0 && ( {sheet.skippedAgents.length > 0 && (
<div className="border-t border-slate-100 px-3 py-1.5 text-[10.5px] text-slate-400"> <div className="border-t border-slate-100 px-3 py-1.5 text-[10.5px] text-slate-400">
已满未分配:{sheet.skippedAgents.map((a) => `${a.name ?? a.userId}(${a.inHand})`).join('、')} 本批未分到:{sheet.skippedAgents.map((a) => `${a.name ?? a.userId}(在手 ${a.inHand})`).join('、')}
</div> </div>
)} )}
...@@ -307,14 +313,14 @@ export function AssignmentConfirmSheet({ ...@@ -307,14 +313,14 @@ export function AssignmentConfirmSheet({
: '首次默认(暂无历史结案数据)'} : '首次默认(暂无历史结案数据)'}
</span> </span>
</div> </div>
{/* 容量:**只读**。人数由它推出来,改它就是换人群 —— 卡片不做这种事(T13) */} {/* 本批人数:**只读**。改它就是换一批人 —— 卡片不做这种事(T13) */}
<div className="flex items-center gap-2 text-[11px] text-slate-500"> <div className="flex items-center gap-2 text-[11px] text-slate-500">
<span className="flex-none">容量</span> <span className="flex-none">人数</span>
<span className="rounded border border-slate-200 px-2 py-0.5 tabular-nums text-slate-600"> <span className="rounded border border-slate-200 px-2 py-0.5 tabular-nums text-slate-600">
每人 {sheet.capacity} 本批 {sheet.target}
</span> </span>
<span className="text-[10px] text-slate-400"> <span className="text-[10px] text-slate-400">
本批 {sheet.target} 人由它推出 · 要改说「每人 {sheet.capacity + 10} 条」 要改说「这批 {sheet.target * 2} 人」· 池子里还有人随时能再分一批
</span> </span>
</div> </div>
<p className="text-[10.5px] leading-relaxed text-slate-400"> <p className="text-[10.5px] leading-relaxed text-slate-400">
...@@ -325,7 +331,7 @@ export function AssignmentConfirmSheet({ ...@@ -325,7 +331,7 @@ export function AssignmentConfirmSheet({
{/* 依据 —— 成品句子原样展示,不在前端改写 */} {/* 依据 —— 成品句子原样展示,不在前端改写 */}
<div className="space-y-0.5 border-t border-slate-100 bg-slate-50/50 px-3 py-2 text-[10.5px] leading-relaxed text-slate-500"> <div className="space-y-0.5 border-t border-slate-100 bg-slate-50/50 px-3 py-2 text-[10.5px] leading-relaxed text-slate-500">
<p>{sheet.selectionNote}</p> <p>{sheet.selectionNote}</p>
<p>{sheet.capacityNote}</p> <p>{sheet.basisNote}</p>
<p>{sheet.rosterNote}</p> <p>{sheet.rosterNote}</p>
</div> </div>
......
...@@ -68,64 +68,76 @@ v1 **轻量**:不核销、不接宿主福利数据,福利就是**话术勾 ...@@ -68,64 +68,76 @@ v1 **轻量**:不核销、不接宿主福利数据,福利就是**话术勾
一次批次宁可只做 100 人做透,不做 1000 人做浅。 一次批次宁可只做 100 人做透,不做 1000 人做浅。
**池子里剩下的不是遗漏,是还没轮到。** **池子里剩下的不是遗漏,是还没轮到。**
> ⚠️ 「100」是**举例,不是参数**。批次规模由 T22 的容量推出来,⛔ 别把这句话读成 > 「100」就是 T22 那个 **N 的首次默认值**,之后沿用主管上一次用的数。
> 「系统里应该有一个 `DEFAULT_BATCH_SIZE = 100`」——2026-08-03 之前就是这么读的, > ⚠️ 它**仍然是默认值**:没有任何数据证明 100 比 80 或 150 好,等 T20 沉淀出
> 结果代码里立了一个 100 的常量,和裁决表里写死的「拟分 N 人 = 默认分满容量」互相矛盾了两周。 > 「批次规模 × 完成率」再替换。⛔ 别给它加上下限 —— 这是主管对自己团队的判断。
> 这一条约束的是**容量该拍多大**(所以首次默认取 20 这种保守值,不是 50),不是批次规模本身。
### T22 · 分配只有两个基数:**容量** 和 **时效**,且都沿用主管上一次的值 ### T22 · 分配只有两个基数:**本批人数 N** 和 **时效**,且都沿用主管上一次的值
(2026-08-03 产品定) (2026-08-03 产品定;同日两次改判,下面把弯路一并记下来,别再走一遍
| 基数 | 含义 | 首次默认 | | 基数 | 含义 | 首次默认 |
|---|---|---| |---|---|---|
| **容量** | 一个客服**同时**能压多少条(存量上限,不是每日增量) | 20 | | **本批人数 N** | 这一轮推多少人 | 100 |
| **时效** | 这批单子多久没动就自动回池 | 3 天 | | **时效** | 这批单子多久没动就自动回池 | 3 天 |
**容量没有上下限**。这是主管对自己团队的判断,系统没有任何数据能证明 5 太少或 200 太多 **没有"容量"这第三个数。** 它被删过一次,别再加回来。
(全生产 `plan_executions` 仅 7 条)。拿一个同样没有依据的区间(原来的 20–50)去卡他,
只会让他撞上一个解释不了的墙。只校验正整数 —— 那不是业务上限,是「0 条」没有意义。
> 连带撤掉:名册接口不再返回 `capacityRange` —— 返回一个区间会被读成「合法范围」,而它从来不是。
**两个基数是整体默认,可以按客服精调**`agentOverrides: userId → {capacity?, expiresInDays?}`): > **弯路记录。** 曾经把「每人在手容量」当唯一旋钮、批次规模 `= Σ(容量 − 在手)` 推出来。
「李莉这周带教只给 5 条」「王强那批给 7 天」。 > 结果 **第二批必然是 0** —— 第一批把所有人填到水位 20,再算就是 `Σ(20−20)`。
> 主管想连着圈两批人分,只能把容量往上棚,而容量会被记住 → 下周的"习惯"是个虚高的数。
> 根子上是**一个数当了两个用**:「这轮推多少」和「一个人最多压多少」在第一批恰好一致,第二批就打架。
>
> 拆开之后发现**上限那一半根本不需要**:真正要表达的是**负载**,而负载不需要阈值 ——
> `inHand` 本身就是负载,落人走水位法直接拿它排序。再挂一条"不强制的上限"比没有更糟:
> 主管会以为系统在拦,其实没拦(T14:别给假证据)。
**落人 = 专属优先 → 溢出水位法。**
1. **第一趟 · 专属**:该患者有专属客服且在名册内 → 给他,**但受目标水位约束**(下条)。
2. **第二趟 · 水位法**:每一条都给**当前在手最少**的那个人;同水位按 userId 打破平局(确定性)。
⛔ 不是"每人加一样多" —— 起点不齐时,均分增量的终点还是不齐。
**目标水位 = (团队现有在手 + N) / 在岗人数**(向上取整,至少 1)。它**不是新旋钮**,完全由 N 推出来,
存在的唯一理由是**给专属那一趟封顶**
> 容量删掉之后没有任何东西约束专属了。本地实测:100 条里 76 条是同一个人的专属,
> 他一个人吃到 76,水位法只剩零头可铺 —— 「最后保持齐平」当场落空。
> 加上水位约束后同一批:**每人在手 5~6 条**。
>
> ⚠️ 这不是"不给专属了",是**他这批的份额满了**,后面同样属于他的患者转铺平
> (仍标 `spread_overflow`,关系还在、只是这轮没轮到)。语义与原来
> 「专属已达容量 → 溢出转铺平」完全一致,只是把"容量"换成了"这批的应得份额"。
**两个基数都可以按客服精调**`agentOverrides: userId → {maxThisBatch?, expiresInDays?}`):
| | 改什么 | 会不会换人群 | 入口 | | | 改什么 | 会不会换人群 | 入口 |
|---|---|---|---| |---|---|---|---|
| 按客服**容量** | 他这批拿几条 | **会**(人数是 Σ 容量−在手) | 回对话说一句,助手重出确认单 | | `maxThisBatch` | **这一批**最多给他几条(0 = 这轮不给) | 会(少的那部分由别人接走,总数不变;但名单变了) | 回对话说一句,助手重出确认单 |
| 按客服**时效** | 他名下每条任务的到期 | 不会 | 卡片上展开那一行直接改 | | `expiresInDays` | 他名下每条任务的到期 | 不会 | 卡片上展开那一行直接改 |
精调**与基数一样会被沿用**,所以卡片上必须标「精调」二字并写进 capacityNote 点名 —— `maxThisBatch` **不是"容量"** —— 它是一次性名额,不是这个人的上限。
精调与基数一样会被沿用,所以卡片上必须标「精调」并在 basisNote 里点名 ——
一条上个月的临时精调如果静默沿用三个月,没人会发现。 一条上个月的临时精调如果静默沿用三个月,没人会发现。
时效精调落到 `followup_plans.assignment_expires_at`(逐条覆盖批次时效,写路径早已支持)。 时效精调落到 `followup_plans.assignment_expires_at`(写路径早已支持逐条覆盖)。
⚠️ 精调值**等于批次默认时不落**:留一条"恰好等于批次"的精调,主管改批次时效时这个人不跟着动,
而卡片上看不出为什么。
**批次规模不是基数,是推出来的**`本批人数 = Σ max(0, 容量 − 该客服在手)`
在手 ≥ 容量的人本批不参与,但**仍要列出来**标「已满」——「他不是被漏了,是已经满了」。
**不许再引入第三个旋钮**("默认批次规模"那种)。两个旋钮能推出的东西,
再给一个就会互相打架:改容量还是改人数?两者矛盾时听谁的?
**为什么是"沿用上一次"而不是"每次问"**:确认单本来就是给主管调的 —— **为什么是"沿用上一次"而不是"每次问"**:确认单本来就是给主管调的 ——
他第一次把容量和时效调到顺手,系统记住,**第二次起零输入** 他第一次把人数和时效调到顺手,系统记住,**第二次起零输入**
这是「先出全景确认单、再让主管反馈调整」这个设计的兑现点:调一次,以后省一次。 这是「先出全景确认单、再让主管反馈调整」这个设计的兑现点:调一次,以后省一次。
所以既不能做成每次弹窗问一遍(违 T13 直出不追问),也不能永远用系统默认(第一次的调整白费)。 ⛔ 既不能做成每次弹窗问一遍(违 T13 直出不追问),也不能永远用系统默认(第一次的调整白费)。
查找顺序:**该主管在该诊所的上一次 → 该诊所任何人的上一次 → 首次默认** 查找顺序:**该主管在该诊所的上一次 → 该诊所任何人的上一次 → 首次默认**
值存在 `plan_assignments.criteria` 这个 Json 快照里(零新列),读不到就当没有,⛔ 不抛错。 值存在 `plan_assignments.criteria` 这个 Json 快照里(零新列),读不到就当没有,⛔ 不抛错。
⚠️ 老快照里 N 这个数叫 `target`(那时它是推出来的结果),**两个键都要认** ——
不认的话改版后所有人的"沿用"都会静默退回默认值,而界面上只显示"首次默认",看不出丢了东西。
⚠️ **一次性人数不回写基数**:主管说「这批只要 60 人」→ 这一批 60 人,容量原样不动。 **确认单上必须写清三件事**(T14:没有证据的不许写进结论):
拿 60 反推出「以后每人 6.7 条」等于把临时决定固化成长期参数,而下次没人记得为什么变了。 ① 值本身(本批 100 人 / 3 天);② **出处**(沿用 7-28 那次 / 首次默认 / 本次指定);
他真想改容量会直接说「每人 30 条」—— 那是另一句话。 **分配后的水位**(「分完后每人在手 5~6 条,最高 康慧捧」)——
⛔ 这里只报事实,**不做"是否超载"的判断**,那个没有数据支撑。
⚠️ 批次规模会随在手量波动(同样的容量,这次推出 340、下次 80)。因此确认单上
**必须显示推导链**`在岗 17 位 × 每人容量 20 − 已在手 260 = 可分 80 人`
外加**出处**(沿用 7-28 那次 / 首次默认 / 本次指定)——
看不到推导过程,主管只能怀疑系统抽风;看不到出处,他分不清这个数该信几分(T14)。
> 这条实际上是**恢复**裁决表里一直写着的「拟分 N 人 = 默认分满容量」 ⚠️ 「本批未分到」的人仍要列出来,但**措辞不能写「已满」** —— 没有上限这回事了
> 只是把"容量"从一个系统常量变成了会被记住的主管偏好 他没分到是因为手上本来就最多(水位法没轮到)或被精调成 0。写「已满(0)」会自相矛盾
### T6a · 初选 X 轴 = 画像的「潜在治疗」8 类,不是 PAC 治疗类目 ### T6a · 初选 X 轴 = 画像的「潜在治疗」8 类,不是 PAC 治疗类目
...@@ -762,9 +774,9 @@ patient_transactions 经 patient_id → 客观新预约(canonical_payload.cre ...@@ -762,9 +774,9 @@ patient_transactions 经 patient_id → 客观新预约(canonical_payload.cre
| 福利是否核销 | **v1 不核销** | 先验证「带福利批次转化是否更高」,再谈打通卡券系统 | | 福利是否核销 | **v1 不核销** | 先验证「带福利批次转化是否更高」,再谈打通卡券系统 |
| **助手要不要校验专属客服在岗** | **不校验** | 在岗数据目前不够精确;**由主管在确认单上自行说明**,不让助手拿半准的数据挡人 | | **助手要不要校验专属客服在岗** | **不校验** | 在岗数据目前不够精确;**由主管在确认单上自行说明**,不让助手拿半准的数据挡人 |
| **「在岗」怎么判** | **近似即可**:默认按 `source_created_at` 近 12 月;**最终以主管信息为准** | 数据只做默认值,主管说了算 | | **「在岗」怎么判** | **近似即可**:默认按 `source_created_at` 近 12 月;**最终以主管信息为准** | 数据只做默认值,主管说了算 |
| 容量上限 | **无上下限**;首次默认 20,之后沿用主管上次的值,可按客服精调 | 不是每日增量;见 T22 | | 容量上限 | **不设**(这个概念已删)—— 负载即 `inHand`,落人走水位法 | 阈值没有数据支撑,且会让第二批分不出来;见 T22 |
| 初选 X 轴用什么 | **画像的潜在治疗 8 类** | 见 T6a;`focusCategory` 是技术类目不是业务机会,且会把早矫埋进正畸 | | 初选 X 轴用什么 | **画像的潜在治疗 8 类** | 见 T6a;`focusCategory` 是技术类目不是业务机会,且会把早矫埋进正畸 |
| 拟分 N 人怎么定 | **默认分满容量**`Σ 容量−在手`) | 第一性:容量上限本身已是「一个人同时能处理多少」的约束,不必再打折。⚠️ 曾被实现成一个 100 的常量,与本裁决矛盾,2026-08-03 改回,见 T22 | | 拟分 N 人怎么定 | **主管的基数,沿用上一次**(首次 100) | 「这轮推多少」是运营选择,不该由任何存量上限推导 —— 那样第二批恒为 0。见 T22 |
| 明细默认展示多少 | **按客服折叠**,展开才看 | 兼顾「尽明细」与「一眼可确认」 | | 明细默认展示多少 | **按客服折叠**,展开才看 | 兼顾「尽明细」与「一眼可确认」 |
| 全景是否先问意图 | **不问**,助手推导 | 每多问一句就多一次决策成本,与极致减负相悖 | | 全景是否先问意图 | **不问**,助手推导 | 每多问一句就多一次决策成本,与极致减负相悖 |
| 批次表叫什么 | **`plan_assignments`** | 沿用 `plan_*` 家族(已有 6 张);语义 = 一次分配动作 | | 批次表叫什么 | **`plan_assignments`** | 沿用 `plan_*` 家族(已有 6 张);语义 = 一次分配动作 |
......
...@@ -72,40 +72,43 @@ export type CreateAssignmentRequest = z.infer<typeof CreateAssignmentRequestSche ...@@ -72,40 +72,43 @@ export type CreateAssignmentRequest = z.infer<typeof CreateAssignmentRequestSche
// ============================================================= // =============================================================
/** /**
* ═══ 分配只有两个基数:**容量** 和 **时效** ═══════════════════════ * ═══ 分配只有两个基数:**本批人数 N** 和 **时效** ═══════════════════
* *
* 两者的取值方式完全一致(2026-08-03 产品定): * 两者的取值方式完全一致(2026-08-03 产品定):
* **沿用该主管在该诊所上一次分配用的值**;没有上一次才用下面的默认。 * **沿用该主管在该诊所上一次分配用的值**;没有上一次才用下面的默认。
* *
* ⭐ 为什么是"沿用"而不是"每次问":确认单本来就是给主管调的 —— * ⭐ 为什么是"沿用"而不是"每次问":确认单本来就是给主管调的 ——
* 第一次他在卡片/对话里把容量和时效调到顺手,系统记住,**第二次起他什么都不用输**。 * 第一次他把人数和时效调到顺手,系统记住,**第二次起他什么都不用输**。
* 这是"先出全景确认单、再让主管反馈调整"这个设计的兑现点:调一次,以后省一次。 * 这是"先出全景确认单、再让主管反馈调整"这个设计的兑现点:调一次,以后省一次。
* ⛔ 所以别把它们做成"每次弹窗问一遍"或"永远用系统默认" —— 前者违 T13(直出不追问), * ⛔ 所以别把它们做成"每次弹窗问一遍"或"永远用系统默认" —— 前者违 T13(直出不追问),
* 后者让第一次的调整白费。 * 后者让第一次的调整白费。
* *
* ⚠️ 批次规模**不是基数**,是这两个基数 + 在手量推出来的结果: * ⚠️ **没有"容量"这第三个数**(它被删过一次,别再加回来 —— 理由见 BATCH_SIZE_DEFAULT)。
* `本批人数 = Σ max(0, 容量 − 该客服在手)`。 * 负载不靠阈值表达:落人走**水位法**(从在手最少的人开始填),手上多的人这轮自然少拿。
* ⛔ 别再引入"默认批次规模"那种第三个旋钮 —— 两个旋钮能推出的东西,
* 再给一个就会互相打架(改容量还是改人数?两者矛盾时听谁的?)。
*/ */
/** /**
* **首次**分配的容量起点(之后沿用上一次)—— 单个客服同时能跟进的**在手总量**(不是每日增量) * **首次**分配的批次人数(之后沿用上一次)—— 基数①「这一轮推多少人」
* *
* ⛔ **容量没有上下限**(2026-08-03 产品定):这是主管对自己团队的判断, * ⭐ 2026-08-03 定案:分配只有 **本批人数 N** 和 **时效** 两个基数,⛔ **没有"容量"这个数**。
* 系统没有任何数据可以证明 5 太少或 200 太多(全生产 `plan_executions` 仅 7 条)。
* 拿一个同样没有依据的区间去卡他,只会让他撞上一个解释不了的墙。
* ⚠️ 只校验**正整数** —— 那不是业务上下限,是"0 条 / -3 条"没有意义。
* *
* ⚠️ 取 20 作起点而不是更大的数:批次规模由容量推出来(17 人 × 50 = 850 条一批, * ── 为什么容量被删掉 ────────────────────────────────────────
* 正是 T5「宁可 100 人做透,不做 1000 人做浅」反对的做法)。 * 它当过一阵"每人在手上限",并用来推批次规模(`Σ 容量−在手`)。两个问题:
* 起点低、主管觉得不够再往上调 —— 反过来(起点高、发现做不完再往下调)那一批已经分出去了。 * ① **第二批必然是 0** —— 第一批把所有人填到水位 20,再算就是 `Σ(20−20)`。
* 主管想连着圈两批人分,只能把容量往上棚,而容量会被记住 → 下周的"习惯"是个虚高的数。
* ② 真正要表达的是**负载**,而负载不需要阈值:**在手量本身就是负载**,
* 水位法直接拿它排序、从最空的人开始填,压力已经被表达了。
* 再挂一条"不强制的上限"比没有更糟 —— 主管会以为系统在拦,其实没拦(T14:别给假证据)。
*
* ⚠️ 取 100 是 T5「宁可 100 人做透,不做 1000 人做浅」的量级,**仍是默认值**:
* 没有任何数据证明 100 比 80 或 150 好,等 T20 沉淀出「批次规模 × 完成率」再替换。
* ⛔ 别再给它加"上下限":这是主管对自己团队的判断,系统没有依据卡他。
*/ */
export const AGENT_CAPACITY_DEFAULT = 20; export const BATCH_SIZE_DEFAULT = 100;
/// **首次**分配的时效起点(之后沿用上一次)。同容量,是基数不是常量。 /// **首次**分配的时效起点(之后沿用上一次)。同批次人数,是基数不是常量。
export const ASSIGNMENT_EXPIRES_DAYS_DEFAULT = 3; export const ASSIGNMENT_EXPIRES_DAYS_DEFAULT = 3;
/// 卡片上给的时效档位(主管在卡片上直接改;改容量要回对话让助手重出单 —— 容量会改变人群) /// 卡片上给的时效档位(时效不改人群 → 卡片可直接改;改人数会换人群 → 回对话让助手重出单)
export const ASSIGNMENT_EXPIRES_DAYS_PRESETS: readonly number[] = [3, 5, 7]; export const ASSIGNMENT_EXPIRES_DAYS_PRESETS: readonly number[] = [3, 5, 7];
export const AgentInfoSchema = z.object({ export const AgentInfoSchema = z.object({
...@@ -122,9 +125,8 @@ export const AgentInfoSchema = z.object({ ...@@ -122,9 +125,8 @@ export const AgentInfoSchema = z.object({
/// 是否在名册内。⚠️ **不是"能不能分"** —— 名册是建议来源不是白名单, /// 是否在名册内。⚠️ **不是"能不能分"** —— 名册是建议来源不是白名单,
/// 实测有客服只做召回不做回访(回访数 0),分给他完全合法 /// 实测有客服只做召回不做回访(回访数 0),分给他完全合法
inRoster: z.boolean(), inRoster: z.boolean(),
/// ⛔ 这里**不再返回容量区间** —— 容量没有上下限(见 AGENT_CAPACITY_DEFAULT), /// ⛔ 这里**不返回容量/上限之类的数** —— 那个概念已经删掉(见 BATCH_SIZE_DEFAULT)。
/// 返回一个 [20,50] 会被读成"合法范围",而它从来不是。 /// `inHand` 就是负载本身,落人按它走水位法;返回一个区间只会被读成"合法范围"。
/// 本批实际用的容量在确认单的 byAgent 里逐人给。
}); });
export type AgentInfo = z.infer<typeof AgentInfoSchema>; export type AgentInfo = z.infer<typeof AgentInfoSchema>;
...@@ -280,8 +282,11 @@ export type ProposedItem = z.infer<typeof ProposedItemSchema>; ...@@ -280,8 +282,11 @@ export type ProposedItem = z.infer<typeof ProposedItemSchema>;
* `followup_plans.assignment_expires_at`,与批次时效同口径)。 * `followup_plans.assignment_expires_at`,与批次时效同口径)。
*/ */
export const AgentOverrideSchema = z.object({ export const AgentOverrideSchema = z.object({
/// ⛔ 无上下限,只要正整数(理由同 AGENT_CAPACITY_DEFAULT) /**
capacity: z.number().int().positive().optional(), * **本批**最多给他几条(⛔ 不是"容量"——那个概念已经删了,这是一次性的名额上限)。
* 「李莉这周带教,这批最多给 5 条」。⚠️ 0 也合法 = 这轮不给他。
*/
maxThisBatch: z.number().int().min(0).optional(),
expiresInDays: z.number().int().positive().max(90).optional(), expiresInDays: z.number().int().positive().max(90).optional(),
}); });
export type AgentOverride = z.infer<typeof AgentOverrideSchema>; export type AgentOverride = z.infer<typeof AgentOverrideSchema>;
...@@ -293,9 +298,10 @@ export const ProposalAgentRowSchema = z.object({ ...@@ -293,9 +298,10 @@ export const ProposalAgentRowSchema = z.object({
count: z.number().int(), count: z.number().int(),
dedicated: z.number().int(), dedicated: z.number().int(),
spread: z.number().int().describe('两种铺平合并显示 —— 主管界面只分「专属/铺平」两档'), spread: z.number().int().describe('两种铺平合并显示 —— 主管界面只分「专属/铺平」两档'),
/// ⭐ 本批对**这个人**实际生效的两个值(没精调就等于整体基数)。 /// ⭐ 分配**之后**他手上有多少(= inHandBefore + count)。
/// 逐人下发而不是让前端自己合并 —— 合并逻辑写两遍必然漂,而漂了不报错 /// 这是「负载」的唯一表达 —— 没有阈值线,主管自己看这一列齐不齐、高不高
capacity: z.number().int(), loadAfter: z.number().int(),
/// 本批对**这个人**生效的时效(没精调就等于批次时效)。逐人下发,⛔ 别让前端再合并一次
expiresInDays: z.number().int(), expiresInDays: z.number().int(),
/// 是否被精调过(卡片上要标出来,否则"为什么李莉只有 5 条"没人答得上) /// 是否被精调过(卡片上要标出来,否则"为什么李莉只有 5 条"没人答得上)
overridden: z.boolean(), overridden: z.boolean(),
...@@ -315,12 +321,11 @@ export const AssignmentProposalSchema = z.object({ ...@@ -315,12 +321,11 @@ export const AssignmentProposalSchema = z.object({
/// ⭐ 与矩阵格子同一种数法(count DISTINCT patient_id),⛔ 不受取明细的 LIMIT 影响 —— /// ⭐ 与矩阵格子同一种数法(count DISTINCT patient_id),⛔ 不受取明细的 LIMIT 影响 ——
/// 从矩阵点进来的主管会拿这个数跟他刚看到的格子对 /// 从矩阵点进来的主管会拿这个数跟他刚看到的格子对
candidateTotal: z.number().int().describe('候选总数 = 该格子/该条件下的患者数(与矩阵格子对得上)'), candidateTotal: z.number().int().describe('候选总数 = 该格子/该条件下的患者数(与矩阵格子对得上)'),
target: z.number().int().describe('拟分人数 —— 由容量与在手量推出,不是独立旋钮'), target: z.number().int().describe('本批人数 N(基数①:沿用上次 / 首次默认 / 本次指定)'),
/// ── 两个基数,连同它们的**出处**一起下发 ──────────────────────── /// ── 两个基数,连同它们的**出处**一起下发 ────────────────────────
/// ⚠️ 出处必须跟着值走:主管看到「容量 20」时,「这 20 是哪来的」和这个数本身一样重要 —— /// ⚠️ 出处必须跟着值走:主管看到「容量 20」时,「这 20 是哪来的」和这个数本身一样重要 ——
/// 沿用上次 / 首次默认 / 他自己刚说的,三者对应完全不同的信任度(T14:界面元素也算证据)。 /// 沿用上次 / 首次默认 / 他自己刚说的,三者对应完全不同的信任度(T14:界面元素也算证据)。
capacity: z.number().int().describe('每个客服的在手容量(本批实际用的那个值)'), expiresInDays: z.number().int().describe('批次时效天数(基数②;卡片初值,主管可在卡片上改)'),
expiresInDays: z.number().int().describe('批次时效天数(卡片的初值,主管可在卡片上改)'),
basis: z basis: z
.enum(['inherited', 'default', 'explicit']) .enum(['inherited', 'default', 'explicit'])
.describe('基数来源:沿用上次 / 首次默认 / 本次主管明确指定'), .describe('基数来源:沿用上次 / 首次默认 / 本次主管明确指定'),
...@@ -330,17 +335,25 @@ export const AssignmentProposalSchema = z.object({ ...@@ -330,17 +335,25 @@ export const AssignmentProposalSchema = z.object({
/// 「李莉休假那周只给 5 条」如果悄悄沿用三个月,没人会发现 /// 「李莉休假那周只给 5 条」如果悄悄沿用三个月,没人会发现
agentOverrides: z.record(z.string(), AgentOverrideSchema), agentOverrides: z.record(z.string(), AgentOverrideSchema),
placed: z.number().int(), placed: z.number().int(),
/// ⚠️ 分不下去的**不摊派**给已满的人:硬塞是 T5 的反面,而且会立刻造出 over_capacity 退回, /**
/// 而那正是要用来反推容量默认值的信号 —— 自己造出来就没法反推了 * 没落上的条数。
* ⚠️ 水位法**没有上限**,所以正常情况恒为 0 —— 只有"名册为空"或
* "所有人都被精调成 0 名额"时才 > 0。⛔ 别把它读成"团队满了"。
*/
unplaced: z.number().int(), unplaced: z.number().int(),
items: z.array(ProposedItemSchema), items: z.array(ProposedItemSchema),
byAgent: z.array(ProposalAgentRowSchema), byAgent: z.array(ProposalAgentRowSchema),
/// 已达容量、本批跳过的人。**仍然列出来** —— 主管要看见"他不是被漏了,是已经满了" /**
* 本批**一条都没分到**的在岗客服。**仍然列出来** —— 主管要看见"他不是被漏了"。
* ⚠️ 语义变了:不再是"已达容量",而是**水位法没轮到他**(手上本来就最多),
* 或者被精调成 0 名额。附上 inHand 让原因不言自明。
*/
skippedAgents: z.array( skippedAgents: z.array(
z.object({ userId: z.string(), name: z.string().nullable(), inHand: z.number().int() }), z.object({ userId: z.string(), name: z.string().nullable(), inHand: z.number().int() }),
), ),
rosterNote: z.string(), rosterNote: z.string(),
capacityNote: z.string(), /// 基数说明:两个基数的值 + **出处**(沿用哪次/首次默认/本次指定)+ 分配后的水位
basisNote: z.string(),
selectionNote: z.string(), selectionNote: z.string(),
}); });
export type AssignmentProposal = z.infer<typeof AssignmentProposalSchema>; export type AssignmentProposal = z.infer<typeof AssignmentProposalSchema>;
......
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