Commit a99f8eb8 by luoqi

feat(分配): 确认单每位客服加负载天数;换算尺跟着提案一起下发

主管在确认单上看「每个人要打几天」——「约 N 天」落在每位客服那一行,
超出本批时效只用**颜色**(琥珀)提示。

️ 算的是**分完之后手上的总量**(在手 + 本批), 不是本批那几条:
  他手上原本压着的也要打,只算增量这个数会系统性偏小。
️ 与 daily_overload 引导节点**同一个式子** ceil(总量 / 每天几通):
  那条只报最忙的一位,这一列把每个人都摆出来, 两处别算出不同的数。
 措辞不带「打不完 / 超了 / 过载」——它几乎每批都会有人超,说成故障
  主管就会开始怀疑系统而不是做决定(同引导节点那条纪律)。
  在**卡片里现算**:他拖一个人、点一个 ×,这个数当场就变;服务端那份是快照。

做这个功能撞出两个真 bug:

① **换算尺根本没跟着提案回来。** dailyCalls 此前**只是 propose 的入参**,
   算完就丢 —— 主管说过「每人每天按 20 通算」之后,卡片和 modelFacts 里
   **仍然写死常量 15**:「按每天几通算: 15」和他刚说的 20 直接打架,**而且不报错**。
   与 rosterCount 同一条理由:**乘法用到的数必须跟着积一起发**。
   现在 daily_overload 的判定与措辞、batch_size_basis 的式子、那个输入框的预填值
   全读 p.dailyCalls, 不再引常量(顺带修掉:他填了 20,下次输入框还预填 15
   = 又把他的改动抹了)。

② **「重算丢东西」的第四个** —— 按自己写的通例一测就撞上:refill 那条路不收
   dailyCalls,主管改成 30 通再点一下「补上」,天数整列悄悄退回按 15 算。
   ️ 它**不影响人数**(targetCount 已显式带回),只改换算与呈现 ——
   正因为不影响人数,比前三个更难被发现。已补齐 schema / controller / 前端,
   并加进契约测试那条通例(注明这一点)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent a8c60f7c
...@@ -133,7 +133,7 @@ export function modelFacts(p: AssignmentProposal): Record<string, unknown> { ...@@ -133,7 +133,7 @@ export function modelFacts(p: AssignmentProposal): Record<string, unknown> {
? '主管这次指定的' ? '主管这次指定的'
: p.basis === 'inherited' : p.basis === 'inherited'
? `沿用 ${p.basisFrom ?? '上一次'}` ? `沿用 ${p.basisFrom ?? '上一次'}`
: `系统默认:在岗 ${p.rosterCount} × 每天 ${DAILY_CALLS_PER_AGENT} × ${p.expiresInDays} = ${p.batchSize} `, : `系统默认:在岗 ${p.rosterCount} × 每天 ${p.dailyCalls} × ${p.expiresInDays} = ${p.batchSize} `,
'排序': '没被分过的排在前面,其余按优先级从高到低', '排序': '没被分过的排在前面,其余按优先级从高到低',
...(p.candidateTotal > p.target ...(p.candidateTotal > p.target
? { '这轮还没轮到': p.candidateTotal - p.target } ? { '这轮还没轮到': p.candidateTotal - p.target }
...@@ -182,7 +182,8 @@ export function modelFacts(p: AssignmentProposal): Record<string, unknown> { ...@@ -182,7 +182,8 @@ export function modelFacts(p: AssignmentProposal): Record<string, unknown> {
'时效天数': p.expiresInDays, '时效天数': p.expiresInDays,
'本批每人拿到': range(perBatch), '本批每人拿到': range(perBatch),
'分完之后每人手上': range(loads), '分完之后每人手上': range(loads),
'按每天几通算': DAILY_CALLS_PER_AGENT, // ⚠️ 读**这一版真用的**那个数,⛔ 不引常量:主管说过「按 20 通算」就该是 20
'按每天几通算': p.dailyCalls,
'落人规则': '有专属的先回自己人手上;无主的给当前手上最少的那个', '落人规则': '有专属的先回自己人手上;无主的给当前手上最少的那个',
// ⚠️ 他自己设过的那几位要点名 —— 「为什么某某只有 5 条」的答案只在这里 // ⚠️ 他自己设过的那几位要点名 —— 「为什么某某只有 5 条」的答案只在这里
...(tuned.length > 0 ? { '他单独设过的': tuned } : {}), ...(tuned.length > 0 ? { '他单独设过的': tuned } : {}),
......
...@@ -497,6 +497,8 @@ export class AssignmentProposalService { ...@@ -497,6 +497,8 @@ export class AssignmentProposalService {
batchSize, batchSize,
/// ⭐ 乘法里的那个在岗人数,跟着 batchSize 一起下发 —— ⛔ 别在显示侧另找一个数冒充 /// ⭐ 乘法里的那个在岗人数,跟着 batchSize 一起下发 —— ⛔ 别在显示侧另找一个数冒充
rosterCount: agents.length, rosterCount: agents.length,
/// ⭐ 同理:换算尺也要跟着发,否则主管改过 20 通、显示侧还按 15 算(见 schema 上那段)
dailyCalls,
expiresInDays, expiresInDays,
basis, basis,
basisFrom: baseline.from, basisFrom: baseline.from,
...@@ -1329,6 +1331,7 @@ function emptyProposal( ...@@ -1329,6 +1331,7 @@ function emptyProposal(
target: base.target, target: base.target,
batchSize: base.batchSize, batchSize: base.batchSize,
rosterCount: agents.length, rosterCount: agents.length,
dailyCalls: DAILY_CALLS_PER_AGENT,
expiresInDays: base.expiresInDays, expiresInDays: base.expiresInDays,
basis: base.basis, basis: base.basis,
basisFrom: base.basisFrom, basisFrom: base.basisFrom,
......
...@@ -374,8 +374,10 @@ export function computeSignals(p: AssignmentProposal, extra: Signal[] = []): Sig ...@@ -374,8 +374,10 @@ export function computeSignals(p: AssignmentProposal, extra: Signal[] = []): Sig
(max, a) => (max === null || a.loadAfter > max.loadAfter ? a : max), (max, a) => (max === null || a.loadAfter > max.loadAfter ? a : max),
null, null,
); );
if (heaviest && heaviest.loadAfter > DAILY_CALLS_PER_AGENT * d) { // ⚠️ 判定与措辞都读**这一版真用的**换算尺(`p.dailyCalls`),⛔ 别引常量 ——
const needDays = Math.ceil(heaviest.loadAfter / DAILY_CALLS_PER_AGENT); // 主管把它改成 20 之后,常量算出来的天数和卡片上每一行都对不上。
if (heaviest && heaviest.loadAfter > p.dailyCalls * d) {
const needDays = Math.ceil(heaviest.loadAfter / p.dailyCalls);
out.push({ out.push({
key: 'daily_overload', key: 'daily_overload',
severity: SEV.WRONG_EXPECTATION, severity: SEV.WRONG_EXPECTATION,
...@@ -387,7 +389,7 @@ export function computeSignals(p: AssignmentProposal, extra: Signal[] = []): Sig ...@@ -387,7 +389,7 @@ export function computeSignals(p: AssignmentProposal, extra: Signal[] = []): Sig
// ⚠️ 「按每天 15 通算」这个前提要写出来:它是评审当天的口头经验值,不是实测。 // ⚠️ 「按每天 15 通算」这个前提要写出来:它是评审当天的口头经验值,不是实测。
why: why:
`其中本批 ${heaviest.count} 条、原本在手 ${heaviest.inHandBefore} 条;` + `其中本批 ${heaviest.count} 条、原本在手 ${heaviest.inHandBefore} 条;` +
`按每人每天 ${DAILY_CALLS_PER_AGENT} 通算需要 ${needDays} 天,本批时效定的是 ${d} 天。`, `按每人每天 ${p.dailyCalls} 通算需要 ${needDays} 天,本批时效定的是 ${d} 天。`,
defaultLabel: `不处理 = 就按 ${d} 天发,到期没打完的自动落回池子,下批还能再分`, defaultLabel: `不处理 = 就按 ${d} 天发,到期没打完的自动落回池子,下批还能再分`,
/** /**
* 🔴 **只留带数的那一个**(2026-08-15 产品定)。 * 🔴 **只留带数的那一个**(2026-08-15 产品定)。
...@@ -454,7 +456,7 @@ export function computeSignals(p: AssignmentProposal, extra: Signal[] = []): Sig ...@@ -454,7 +456,7 @@ export function computeSignals(p: AssignmentProposal, extra: Signal[] = []): Sig
stage: 'size', stage: 'size',
/// ⚠️ 「在岗」= **名册人数**(`rosterCount`),⛔ 不是 `byAgent.length`(本批分到人的客服数)—— /// ⚠️ 「在岗」= **名册人数**(`rosterCount`),⛔ 不是 `byAgent.length`(本批分到人的客服数)——
/// 2026-08-15 实测这里和 `modelFacts` 两处都印错过,沿革见 `rosterCount` 的字段注释。 /// 2026-08-15 实测这里和 `modelFacts` 两处都印错过,沿革见 `rosterCount` 的字段注释。
title: `本批 ${p.batchSize} = 在岗 ${p.rosterCount} × 每天 ${DAILY_CALLS_PER_AGENT} × ${d} `, title: `本批 ${p.batchSize} = 在岗 ${p.rosterCount} × 每天 ${p.dailyCalls} × ${d} `,
// ⚠️ 只陈述事实。⛔ 不写「建议改成 X 天」—— 一批推多大是主管的判断。 // ⚠️ 只陈述事实。⛔ 不写「建议改成 X 天」—— 一批推多大是主管的判断。
why: `这是系统按这个式子估的,不是根据历史算的。改时效或改每天几通,这批人数都会跟着变。`, why: `这是系统按这个式子估的,不是根据历史算的。改时效或改每天几通,这批人数都会跟着变。`,
defaultLabel: '不处理 = 就按这个数发', defaultLabel: '不处理 = 就按这个数发',
...@@ -472,7 +474,8 @@ export function computeSignals(p: AssignmentProposal, extra: Signal[] = []): Sig ...@@ -472,7 +474,8 @@ export function computeSignals(p: AssignmentProposal, extra: Signal[] = []): Sig
unit: '通', unit: '通',
min: 1, min: 1,
max: 60, max: 60,
default: DAILY_CALLS_PER_AGENT, // 预填**当前这一版**的值,⛔ 不是常量(他改过 20,输入框还显示 15 = 又把他的改动抹了)
default: p.dailyCalls,
}, },
}, },
], ],
......
...@@ -235,6 +235,8 @@ export class AssignmentController { ...@@ -235,6 +235,8 @@ export class AssignmentController {
...(body.minSpendYuan ? { minSpendYuan: body.minSpendYuan } : {}), ...(body.minSpendYuan ? { minSpendYuan: body.minSpendYuan } : {}),
// 🔴 他在这一版上设的按客服精调 —— 漏了就是重排一下"李莉只给 5 条"悄悄失效 // 🔴 他在这一版上设的按客服精调 —— 漏了就是重排一下"李莉只给 5 条"悄悄失效
...(body.agentOverrides ? { agentOverrides: body.agentOverrides } : {}), ...(body.agentOverrides ? { agentOverrides: body.agentOverrides } : {}),
// 🔴 换算尺 —— 漏了每位客服的「约几天」整列按默认 15 重算(见 schema 上那段)
...(body.dailyCalls ? { dailyCalls: body.dailyCalls } : {}),
...(body.targetCount ? { targetCount: body.targetCount } : {}), ...(body.targetCount ? { targetCount: body.targetCount } : {}),
...(body.expiresInDays ? { expiresInDays: body.expiresInDays } : {}), ...(body.expiresInDays ? { expiresInDays: body.expiresInDays } : {}),
preferPlaceable: true, preferPlaceable: true,
......
...@@ -60,6 +60,9 @@ describe('重算类请求 —— 人群条件必须原样带回', () => { ...@@ -60,6 +60,9 @@ describe('重算类请求 —— 人群条件必须原样带回', () => {
'temperature', 'temperature',
// 🔴 他在这一版上设的按客服精调 —— 第三个"重算丢东西",2026-08-15 补 // 🔴 他在这一版上设的按客服精调 —— 第三个"重算丢东西",2026-08-15 补
'agentOverrides', 'agentOverrides',
// 🔴 换算尺 —— 第四个。它**不影响人数**(targetCount 显式带回),只改「约几天」那一列,
// 正因为不影响人数,漏了更难被发现(当天就是做那个功能时才撞见的)。
'dailyCalls',
]) { ]) {
expect({ 参数: k, 透传: handler.includes(`body.${k}`) }).toEqual({ 参数: k, 透传: true }); expect({ 参数: k, 透传: handler.includes(`body.${k}`) }).toEqual({ 参数: k, 透传: true });
} }
...@@ -75,7 +78,12 @@ describe('重算类请求 —— 人群条件必须原样带回', () => { ...@@ -75,7 +78,12 @@ describe('重算类请求 —— 人群条件必须原样带回', () => {
*/ */
test('🔴 「他在这一版上做过的事」三样一样不能少(重算丢东西的通例)', () => { test('🔴 「他在这一版上做过的事」三样一样不能少(重算丢东西的通例)', () => {
const call = /\.refill\(\{[\s\S]*?\n \}\)/.exec(CHAT_HOOK)?.[0] ?? ''; const call = /\.refill\(\{[\s\S]*?\n \}\)/.exec(CHAT_HOOK)?.[0] ?? '';
for (const k of ['narrowedBy?.personaTags', 'narrowedBy?.minSpendYuan', 's.agentOverrides']) { for (const k of [
'narrowedBy?.personaTags',
'narrowedBy?.minSpendYuan',
's.agentOverrides',
's.dailyCalls',
]) {
expect({ 带回: k, : call.includes(k) }).toEqual({ 带回: k, : true }); expect({ 带回: k, : call.includes(k) }).toEqual({ 带回: k, : true });
} }
// 人群那两轴走 criteria 快照(模型重算的依据),单独锁在上面那条 // 人群那两轴走 criteria 快照(模型重算的依据),单独锁在上面那条
......
import { TEMPERATURE_SINCE_ZH, type AssignmentProposal } from '@pac/types'; import {
DAILY_CALLS_PER_AGENT,
TEMPERATURE_SINCE_ZH,
type AssignmentProposal,
} from '@pac/types';
import type { NarrowFacts } from '../src/modules/plan/cohort-attributes.service'; import type { NarrowFacts } from '../src/modules/plan/cohort-attributes.service';
import { import {
ASSIGNMENT_INTENTS, ASSIGNMENT_INTENTS,
...@@ -25,6 +29,8 @@ function proposal(over: Partial<AssignmentProposal> = {}): AssignmentProposal { ...@@ -25,6 +29,8 @@ function proposal(over: Partial<AssignmentProposal> = {}): AssignmentProposal {
batchSize: 405, batchSize: 405,
/// ⚠️ 在岗名册人数 —— ⛔ 别拿 `byAgent.length` 顶(那是本批分到人的客服数,实测印错过) /// ⚠️ 在岗名册人数 —— ⛔ 别拿 `byAgent.length` 顶(那是本批分到人的客服数,实测印错过)
rosterCount: 27, rosterCount: 27,
/// ⚠️ 换算尺跟着这一版走(主管改过就是他的数)——⛔ 别在判定里引常量,见 schema 上那段
dailyCalls: DAILY_CALLS_PER_AGENT,
expiresInDays: 3, expiresInDays: 3,
basis: 'explicit', basis: 'explicit',
basisFrom: null, basisFrom: null,
......
...@@ -1515,6 +1515,39 @@ export function AssignmentConfirmSheet({ ...@@ -1515,6 +1515,39 @@ export function AssignmentConfirmSheet({
</span> </span>
</span> </span>
</button> </button>
{/**
* ⭐ **这位客服要打几天** —— 主管看「每个人的工作量」就看这一列。
*
* ⚠️ 算的是**分完之后手上的总量**(在手 + 本批),⛔ 不是本批那几条:
* 他手上原本压着的那些也要打,只算增量会让这个数系统性偏小。
* ⚠️ 换算尺读 `sheet.dailyCalls`(这一版真用的那个数),⛔ 不引常量 ——
* 主管说过「按 20 通算」之后,常量算出来的天数和他刚定的口径对不上。
* ⚠️ 与 `daily_overload` 引导节点**同一个式子** `ceil(总量 / 每天几通)`:
* 那条只报最忙的一位,这一列把每个人都摆出来,⛔ 两处别算出不同的数。
* (这里必须在**卡片里现算**:主管拖一个人、点一个 ×,这个数就变了,
* 而服务端那份是出方案那一刻的快照。)
* ⛔ 措辞不带「打不完 / 超了 / 过载」——它几乎每批都会有人超,
* 说成故障主管就会开始怀疑系统而不是做决定(同引导节点那条纪律)。
* 超出时效只用**颜色**提示,把判断留给他。
*/}
{(() => {
const after = g.inHandBefore + g.list.length;
const need = Math.ceil(after / sheet.dailyCalls);
return (
<span
className={cn(
'flex-none tabular-nums text-[10.5px]',
need > expiresInDays ? 'text-amber-600' : 'text-slate-400',
)}
title={
`按每人每天 ${sheet.dailyCalls} 通算:分完手上共 ${after} ${need} 天;` +
`本批时效 ${expiresInDays} `
}
>
约 {need} 天
</span>
);
})()}
{!readOnly && ( {!readOnly && (
<Button <Button
variant="ghost" variant="ghost"
......
...@@ -596,6 +596,8 @@ export function useAssistantChat() { ...@@ -596,6 +596,8 @@ export function useAssistantChat() {
: {}), : {}),
targetCount: s.batchSize, targetCount: s.batchSize,
expiresInDays: s.expiresInDays, expiresInDays: s.expiresInDays,
// 🔴 换算尺:不带回,每位客服那列「约几天」会悄悄退回按默认 15 算
dailyCalls: s.dailyCalls,
}) })
.then((next) => replaceSheet(b.requestId, next)) .then((next) => replaceSheet(b.requestId, next))
.catch(() => appendAssistantNote('重新排版失败,这批没有改动。')) .catch(() => appendAssistantNote('重新排版失败,这批没有改动。'))
......
...@@ -748,6 +748,17 @@ export const AssignmentProposalSchema = z.object({ ...@@ -748,6 +748,17 @@ export const AssignmentProposalSchema = z.object({
* **同一行**赋值,让句子和乘法共用一个真源。 * **同一行**赋值,让句子和乘法共用一个真源。
*/ */
rosterCount: z.number().int().describe('在岗名册人数(= 估算基数时乘进去的那个数)'), rosterCount: z.number().int().describe('在岗名册人数(= 估算基数时乘进去的那个数)'),
/**
* 🔴 **这一版按"每人每天几通"算的** —— 与 `rosterCount` 同一条理由:
* 乘法用到的数必须跟着积一起发。
*
* ⚠️ 2026-08-15 补。此前它**只是 `propose` 的入参**,算完就丢:
* 主管说「每人每天按 20 通算」之后,卡片和 `modelFacts` 里仍然写死常量 15 ——
* 于是「按每天几通算: 15」和他刚说的 20 直接打架,**而且不报错**。
* ⚠️ 凡是要拿它换算的地方(每位客服要打几天、`daily_overload` 的判定与措辞、
* `batch_size_basis` 的式子)都读这一个,⛔ 别再引常量。
*/
dailyCalls: z.number().int().describe('这一版按每人每天几通算(主管改过就是他的数)'),
/// ── 两个基数,连同它们的**出处**一起下发 ──────────────────────── /// ── 两个基数,连同它们的**出处**一起下发 ────────────────────────
/// ⚠️ 出处必须跟着值走:主管看到「容量 20」时,「这 20 是哪来的」和这个数本身一样重要 —— /// ⚠️ 出处必须跟着值走:主管看到「容量 20」时,「这 20 是哪来的」和这个数本身一样重要 ——
/// 沿用上次 / 首次默认 / 他自己刚说的,三者对应完全不同的信任度(T14:界面元素也算证据)。 /// 沿用上次 / 首次默认 / 他自己刚说的,三者对应完全不同的信任度(T14:界面元素也算证据)。
...@@ -949,6 +960,14 @@ export const RefillProposalRequestSchema = z.object({ ...@@ -949,6 +960,14 @@ export const RefillProposalRequestSchema = z.object({
* ⛔ 别把"没传"和"传空"当成一回事。 * ⛔ 别把"没传"和"传空"当成一回事。
*/ */
agentOverrides: z.record(z.string(), AgentOverrideSchema).optional(), agentOverrides: z.record(z.string(), AgentOverrideSchema).optional(),
/**
* 🔴 **换算尺也要带回**(2026-08-15 补 —— 「重算丢东西」的第四个)。
* 主管说过「每人每天按 30 通算」之后点一下「补上」,不带它就退回默认 15:
* 每位客服的「约几天」整列变、`daily_overload` 的判定也跟着变,**而且不报错**。
* ⚠️ 它不影响这一批发多少(`targetCount` 已显式带回),只影响**换算与呈现** ——
* 正因为不影响人数,漏了更难被发现。
*/
dailyCalls: z.number().int().min(1).max(60).optional(),
/// 基数 N 与时效原样带回,否则重排会退回"沿用上一次",人数悄悄变了 /// 基数 N 与时效原样带回,否则重排会退回"沿用上一次",人数悄悄变了
targetCount: z.number().int().positive().optional(), targetCount: z.number().int().positive().optional(),
expiresInDays: z.number().int().positive().max(90).optional(), expiresInDays: z.number().int().positive().max(90).optional(),
......
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