Commit 718e1bbf by luoqi

fix(分配): daily_overload 重写为「工作量预估」—— 它常亮是对的,措辞不能像报错

产品定(2026-08-12):N 只算新增不扣在手,所以这个节点每批基本都会亮 ——
那不是 bug,每一批本来就该把「这活多大、要打几天」讲清楚。

-  措辞去掉「打不完 / 超了 / 过载」:常亮 + 报错口吻 = 主管开始怀疑系统,
  而不是做决定。改成「这批发下去,最忙的是张悦:手上共 60 条,约 4 天的量」。
- ️ 数字**拆开说**:「本批 45 条、原本在手 15 条」。不拆开主管会读成
  "这批一下压了 60 条给他"(增量与总量分不开,同类问题栽过)。
- 默认路径说清后果是可接受的(到期落回池子、下批还能再分), 不制造紧迫感。
- 补第三个动作 DAILY_RATE_SET「改每人每天打几通」—— 它同时决定 N,
  原来只给了延时效/减人数两条。
- 文档补一节说明为什么不改成 `Σ max(0, 15D − 在手)`:那会踩回 2026-08-03
  删掉的坑(第二批恒为 0)。

测试:新增「措辞不许像报错 + 必须拆开说」一条,fixture 支持分别给
本批条数与在手条数。1224 passed。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent e6e079c2
......@@ -39,6 +39,8 @@ export const ASSIGNMENT_INTENTS = {
PENDING_REMOVE: 'pending.remove',
/// 改批次时效
EXPIRY_SET: 'expiry.set',
/// 改「每人每天打几通」的估法 —— 它同时决定 N(在岗 × 每日通数 × 时效)
DAILY_RATE_SET: 'daily_rate.set',
/// 改本批人数
BATCH_SIZE_SET: 'batch_size.set',
/// 换时间档 / 换治疗项(重出一版)
......@@ -90,12 +92,18 @@ export function computeSignals(p: AssignmentProposal): Signal[] {
});
}
// ── ② 每日工作量 —— 时效内打不完 ────────────────────────────────────
// ── ② 工作量预估 —— 这批要打几天 ──────────────────────────────────
//
// ⚠️ 判据是**分完之后手上最多的那位**能不能在时效内打完(按每天 15 通算),
// ⛔ 不是"这批给了他多少":他手上原本还有别的批次,那些也要打。
// ⚠️ 15(DAILY_CALLS_PER_AGENT)在这里只是**换算尺**,⛔ 不是上限、⛔ 不参与落人。
const heaviest = p.byAgent.reduce<{ name: string | null; loadAfter: number } | null>(
// ⭐ 2026-08-12 产品定:这**不是异常告警,是每批都该讲清楚的一件事**。
// N 按 `在岗 × 每天 15 通 × 时效` 算,只算新增、⛔ 不扣在手 ——
// 所以只要谁手上还有东西,需要的天数就会超过时效。**那是事实,不是 bug**:
// 主管要么延长时效、要么调低每日通数(N 跟着变小)、要么直接减这批人数。
// ⛔ 所以措辞里不许出现「打不完 / 超了 / 过载」这类像报错的词 ——
// 它常亮,说成故障主管就会开始怀疑系统而不是做决定。
//
// ⚠️ 判据看**分完之后手上最多的那位**:他手上原本还有别的批次,那些也要打。
// ⚠️ 15(DAILY_CALLS_PER_AGENT)只是**换算尺**,⛔ 不是上限、⛔ 不参与落人。
const heaviest = p.byAgent.reduce<AssignmentProposal['byAgent'][number] | null>(
(max, a) => (max === null || a.loadAfter > max.loadAfter ? a : max),
null,
);
......@@ -105,12 +113,21 @@ export function computeSignals(p: AssignmentProposal): Signal[] {
key: 'daily_overload',
severity: SEV.WRONG_EXPECTATION,
tier: 'action',
title: `${heaviest.name ?? '有人'}分完后手上 ${heaviest.loadAfter} 条,${d} 天内打不完`,
// ⚠️ 把"按每天 15 通算"这个前提写出来 —— 它是经验值不是实测,主管得看得见前提。
why: `按每天 ${DAILY_CALLS_PER_AGENT} 通算需要 ${needDays} 天,而本批时效是 ${d} 天。`,
defaultLabel: '不处理 = 按现在这样发,到期没打完的会落回池子',
title: `这批发下去,最忙的是${heaviest.name ?? '有人'}:手上共 ${heaviest.loadAfter} 条,约 ${needDays} 天的量`,
// ⚠️ **必须把数字拆开** —— 「手上 75 条」里有多少是这批带来的、多少是原本就有的,
// 不拆开主管会以为这批一下压了 75 条给他(实测同类问题:增量与总量分不开)。
// ⚠️ 「按每天 15 通算」这个前提要写出来:它是评审当天的口头经验值,不是实测。
why:
`其中本批 ${heaviest.count} 条、原本在手 ${heaviest.inHandBefore} 条;` +
`按每人每天 ${DAILY_CALLS_PER_AGENT} 通算需要 ${needDays} 天,本批时效定的是 ${d} 天。`,
defaultLabel: `不处理 = 就按 ${d} 天发,到期没打完的自动落回池子,下批还能再分`,
options: [
{ label: `时效延到 ${needDays} `, intent: ASSIGNMENT_INTENTS.EXPIRY_SET, args: { days: needDays } },
{
label: `时效改成 ${needDays} `,
intent: ASSIGNMENT_INTENTS.EXPIRY_SET,
args: { days: needDays },
},
{ label: '改每人每天打几通', intent: ASSIGNMENT_INTENTS.DAILY_RATE_SET },
{ label: '减少本批人数', intent: ASSIGNMENT_INTENTS.BATCH_SIZE_SET },
],
});
......
......@@ -41,15 +41,16 @@ function proposal(over: Partial<AssignmentProposal> = {}): AssignmentProposal {
} as AssignmentProposal;
}
const agent = (name: string, loadAfter: number) =>
/** @param count 本批给他几条 @param inHandBefore 分配前他手上还有几条 */
const agent = (name: string, count: number, inHandBefore = 0) =>
({
userId: name,
name,
inHandBefore: 0,
count: loadAfter,
inHandBefore,
count,
dedicated: 0,
spread: loadAfter,
loadAfter,
spread: count,
loadAfter: inHandBefore + count,
expiresInDays: 3,
overridden: false,
}) as AssignmentProposal['byAgent'][number];
......@@ -83,16 +84,39 @@ describe('引导节点 · 判定', () => {
expect(yes.options.map((o) => o.intent)).toContain(ASSIGNMENT_INTENTS.PENDING_REFILL);
});
test('🔴 分完之后最重的那位在时效内打不完 → 出 daily_overload,并给出要几天', () => {
// 每天 15 通 × 3 天 = 45 条是刚好打得完的量;60 条要 4 天
test('🔴 手上的量超出时效 → 出 daily_overload,给出要几天 + 三个可选动作', () => {
// 每天 15 通 × 3 天 = 45 条是时效内打得完的量;60 条要 4 天
const p = proposal({ expiresInDays: 3, byAgent: [agent('李莉', 30), agent('张悦', 60)] });
const s = computeSignals(p).find((x) => x.key === 'daily_overload')!;
expect(s).toBeDefined();
expect(s.title).toContain('张悦');
expect(s.why).toContain('4 天');
expect(s.options.map((o) => o.intent)).toEqual([
ASSIGNMENT_INTENTS.EXPIRY_SET,
ASSIGNMENT_INTENTS.DAILY_RATE_SET,
ASSIGNMENT_INTENTS.BATCH_SIZE_SET,
]);
expect(s.options.some((o) => o.args?.days === 4)).toBe(true);
});
/**
* 🔴 这个节点**每批基本都会亮**(N 只算新增、不扣在手),产品定这是对的 ——
* 它是工作量预估,不是异常。⇒ 措辞不许像报错,否则常亮就变成"系统天天出故障"。
*/
test('🔴 措辞不许像报错,且必须把「本批 / 原本在手」拆开说', () => {
// 本批 45 + 原本在手 15 = 60 —— 拆开才看得出这批只压了 45
const p = proposal({ expiresInDays: 3, byAgent: [agent('张悦', 45, 15)] });
const s = computeSignals(p).find((x) => x.key === 'daily_overload')!;
for (const w of ['打不完', '超了', '过载', '超出', '警告', '异常']) {
expect(`${s.title}${s.why}`).not.toContain(w);
}
// ⚠️ 增量与总量必须分开 —— 不拆开主管会以为这批一下压了 60 条给他
expect(s.why).toContain('本批 45 条');
expect(s.why).toContain('原本在手 15 条');
// ⚠️ 默认路径要说清后果是可接受的(落回池子、下批还能分),⛔ 不制造紧迫感
expect(s.defaultLabel).toContain('落回池子');
});
test('⭐ 刚好打得完(45 条 / 3 天)→ ⛔ 不出 daily_overload', () => {
expect(keys(proposal({ expiresInDays: 3, byAgent: [agent('张悦', 45)] }))).not.toContain(
'daily_overload',
......
......@@ -198,7 +198,7 @@
| sev | 层 | key | 判定(程序) | 默认(no-op) | 选项(点击执行) |
|---|---|---|---|---|---|
| **1** | 需处置 | `pending` | `pending > 0` | 留着 = 这批不发给他们 | ① 各自归专属客服<br>② 铺平给在岗<br>**换无主患者补上**(见下)<br>④ 移出本批 |
| **2** | 需处置 | `daily_overload` | 某客服「本批条数 ÷ D」> 每日 15 条 | 不动 | 延长时效 / 减他这批的量 |
| **2** | 需处置 | `daily_overload` | 分完后最重的那位 `手上总条数 > 15 × D` | 就按 D 天发,到期落回池子 | 延长时效 / 改每人每天几通 / 减少本批人数 |
| **2** | 需处置 | `short_supply` | 候选数 < N | 全给 | 换口径 / 放宽一档 |
| **3** | 需处置 | `highlight` | 画像维度偏离基线 ≥ 2×,取 Top 2–3 | 不特殊处理 | 见下 |
| **4** | 提示 | `expiry_default` | D 是默认值 | 用默认 | 改天数 |
......@@ -206,6 +206,22 @@
**终止分支(不是引导节点)**`empty` —— `total == 0`,无法继续,只能放宽或换格。
#### `daily_overload` 是**工作量预估**,不是异常告警
N 按「在岗 × 每天 15 通 × 时效」算,**只算新增、⛔ 不扣在手** —— 所以只要谁手上还有东西,
需要的天数就会超过时效,这个节点**每批基本都会亮****这是对的**(产品定 2026-08-12):
每一批本来就该把「这活多大、要打几天」讲清楚,主管据此决定延时效、调每日通数、还是减量。
由此推出两条硬要求:
1. **措辞不许像报错。** ⛔ 不出现「打不完 / 超了 / 过载」——它常亮,说成故障主管就会
开始怀疑系统而不是做决定。
2. **数字必须拆开**:「手上共 60 条」要写成「本批 45 + 原本在手 15」。
不拆开主管会读成"这批一下压了 60 条给他"(增量与总量分不开是同类问题里最常犯的)。
> ⚠️ 曾考虑把 N 改成 `Σ max(0, 15D − 在手)`(扣掉在手)让这个节点不再常亮。
> ⛔ **不要这么做** —— 那会踩回 2026-08-03 删掉的坑:第一批把所有人填满后,第二批恒为 0。
#### `pending` 的第 ③ 个选项:换无主患者补上
待分配的人不发出去,这批**实发量就少了**。从池子里取**等量的无主患者**补进来:
......
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