Commit e9387833 by luoqi

feat(确认单): 给运营结论 —— 这活多大 / 谁加了担子 / 哪些不用看

产品 2026-08-04 评审原话:「我不可能挨个去看他分的对不对,你在这儿应该给的是
**结论性的东西**…如果你不能告诉他哪不对、哪里需要主管判断,那就默认他必须
一条一条看过去才能确认。」

卡片顶部新增 opsNote,只答三个问题:
- 这活多大:本批每人拿到 1~4 条;分完手上共 57~62 条 ——
  按每人每天 15 通算,最满的那位要 5 天打完
- 谁的批次全是自己的老客户(dedicated === count)→ 点名,主管可以直接过

顺带修掉一个真问题:卡片原来只写「分完每人 57~62 条」——那是**在手总量**,
而那一批实际每人只给 1~4 条。拟分 16 条的单子上写着 57~62,很容易读成
"这批好大一坨"。现在增量与总量分开说,并把这句从 basisNote 里挪走
(它答的是"凭什么这么分",不是"这活多大";两处都说就是复述)。

️ DAILY_CALLS_PER_AGENT=15 是评审口头经验值不是实测,所以文案写成
「按每天 15 通算」而不是「需要 5 天」,让主管看得见这个前提;
 常量注释钉死不许接进落人算法 —— 那等于把删掉的"容量"从后门放回来。
️ 结论钉在卡片上不进助手那段话:助手的话会被后续对话冲走,而重排换卡片时
它也不会重说 —— 与 refillNote 同一条理由。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent cd84233a
...@@ -3,6 +3,7 @@ import { Prisma } from '@prisma/client'; ...@@ -3,6 +3,7 @@ import { Prisma } from '@prisma/client';
import { import {
ASSIGNMENT_EXPIRES_DAYS_DEFAULT, ASSIGNMENT_EXPIRES_DAYS_DEFAULT,
BATCH_SIZE_PER_AGENT_FIRST, BATCH_SIZE_PER_AGENT_FIRST,
DAILY_CALLS_PER_AGENT,
AssignStrategy, AssignStrategy,
type AgentInfo, type AgentInfo,
type AssignmentProposal, type AssignmentProposal,
...@@ -368,6 +369,42 @@ export class AssignmentProposalService { ...@@ -368,6 +369,42 @@ export class AssignmentProposalService {
(it) => it.assignStrategy === AssignStrategy.SPREAD_NO_DEDICATED, (it) => it.assignStrategy === AssignStrategy.SPREAD_NO_DEDICATED,
).length; ).length;
/**
* 🔴 **运营结论**(见 schema 的 opsNote 注释)—— 主管扫一眼就能决定按不按确认的三件事。
*
* ⚠️ ①②**必须分开说**:「分完手上 57~62 条」是总量,「这批给他 1~4 条」是增量。
* 卡片原来只写前者,主管很容易把这一批读成"好大一坨"(实测 拟分 16 条却写着 57~62)。
* ⚠️ 天数是**换算不是承诺**:文案里必须带上「按每天 N 通算」这个前提,
* ⛔ 别写成「需要 4 天」—— 那个 15 是评审口头给的经验值,不是实测(见 DAILY_CALLS_PER_AGENT)。
*/
const perAgent = [...byAgent].map(([u, l]) => ({
name: agentById.get(u)?.name ?? u.slice(0, 8),
count: l.length,
dedicated: l.filter((x) => x.assignStrategy === AssignStrategy.DEDICATED).length,
after: (agentById.get(u)?.inHand ?? 0) + l.length,
}));
const rangeText = (ns: number[]) => {
const lo = Math.min(...ns);
const hi = Math.max(...ns);
return lo === hi ? `${lo}` : `${lo}~${hi}`;
};
/// 用**最重**那位算天数 —— 「这批打得完吗」问的是最慢的人什么时候收工,不是平均
const heaviest = perAgent.length > 0 ? Math.max(...perAgent.map((a) => a.after)) : 0;
const daysNeeded = Math.ceil(heaviest / DAILY_CALLS_PER_AGENT);
/// 「这批全是他自己的老客户」—— 这种批次主管基本不用看,可以直接过
const allOwn = perAgent.filter((a) => a.count > 0 && a.dedicated === a.count);
const opsNote =
perAgent.length === 0
? ''
: `**这批 ${items.placed.length} 条 · ${perAgent.length} 位客服**\n` +
`本批每人拿到 ${rangeText(perAgent.map((a) => a.count))} 条;` +
`分完之后手上共 ${rangeText(perAgent.map((a) => a.after))} 条 —— ` +
`按每人每天 ${DAILY_CALLS_PER_AGENT} 通算,最满的那位要 ${daysNeeded} 天打完。` +
(allOwn.length > 0
? `\n${allOwn.length === perAgent.length ? '**每一位**' : `**${allOwn.map((a) => a.name).join('、')}**`}` +
`拿到的全是自己的老客户,这部分不用您费神。`
: '');
return { return {
clinicId, clinicId,
potentialTreatment: potentialTreatment ?? null, potentialTreatment: potentialTreatment ?? null,
...@@ -509,6 +546,7 @@ export class AssignmentProposalService { ...@@ -509,6 +546,7 @@ export class AssignmentProposalService {
* ⚠️ 说的是**换了谁**,⛔ 不是"凑满了没有":这一版重排不改落人规则、不破水位, * ⚠️ 说的是**换了谁**,⛔ 不是"凑满了没有":这一版重排不改落人规则、不破水位,
* 排不进的照样进待分配 —— 卡片形状跟平时一模一样,只是这批人换过了。 * 排不进的照样进待分配 —— 卡片形状跟平时一模一样,只是这批人换过了。
*/ */
opsNote,
refillNote: !input.preferPlaceable refillNote: !input.preferPlaceable
? '' ? ''
: `**已重挑这 ${target} 人**:池子里 ${freeInBatch} 位无主的全换进来了,` + : `**已重挑这 ${target} 人**:池子里 ${freeInBatch} 位无主的全换进来了,` +
...@@ -1058,18 +1096,19 @@ function basisNote(x: { ...@@ -1058,18 +1096,19 @@ function basisNote(x: {
loads: Array<{ name: string; after: number }>; loads: Array<{ name: string; after: number }>;
overrides: Array<{ name: string; maxThisBatch?: number; expiresInDays?: number }>; overrides: Array<{ name: string; maxThisBatch?: number; expiresInDays?: number }>;
}): string { }): string {
const after = x.loads.map((l) => l.after);
const lo = after.length ? Math.min(...after) : 0;
const hi = after.length ? Math.max(...after) : 0;
const top = x.loads.find((l) => l.after === hi);
const how = const how =
x.placed > 0 x.placed > 0
? // ⚠️ ⛔ 别再套两层括号(原文是「…(最多的是康慧捧)(他们原本还有 951 条在手)」)—— ? // ⚠️ ⛔ 别再套两层括号(原文是「…(最多的是康慧捧)(他们原本还有 951 条在手)」)——
// 括号套括号在窄面板里读起来像乱码,而"原本多少条在手"卡片上每一行都写着。 // 括号套括号在窄面板里读起来像乱码,而"原本多少条在手"卡片上每一行都写着。
/**
* 🔴 「分完每人 57~62 条」**已挪到卡片的运营结论**(opsNote,2026-08-06)。
* ⛔ 别加回来:① 两处都说就是复述(产品点名过"确认单信息多而重复");
* ② 那个数是**分完之后的在手总量**,而这一段讲的是"这一批怎么分的" ——
* 摆在一起主管会把 57~62 读成"这批给他 57 条"(实测拟分 16 条时就写着 57~62)。
* ⇒ 增量与总量的区分、以及"要打几天",统一由 opsNote 负责。
*/
`**已排好 ${x.placed} 人 · ${x.loads.length} 位客服**\n` + `**已排好 ${x.placed} 人 · ${x.loads.length} 位客服**\n` +
`有专属的先回自己人手上,其余谁手上活少先给谁。分完每人 ` + `有专属的先回自己人手上,其余谁手上活少先给谁;${x.expiresInDays} 天没动自动退回。`
(lo === hi ? `${hi} 条` : `${lo}~${hi} 条,最多的是${top?.name}`) +
`;${x.expiresInDays} 天没动自动退回。`
: ''; : '';
// ⚠️ 精调必须**点名**:主管看到「李莉只有 5 条」要立刻知道那是他自己设的,不是算错了 // ⚠️ 精调必须**点名**:主管看到「李莉只有 5 条」要立刻知道那是他自己设的,不是算错了
const tuned = x.overrides.length const tuned = x.overrides.length
...@@ -1125,6 +1164,7 @@ function emptyProposal( ...@@ -1125,6 +1164,7 @@ function emptyProposal(
items: [], items: [],
pending: [], pending: [],
pendingNote: '', pendingNote: '',
opsNote: '',
refillNote: '', refillNote: '',
canRefill: false, canRefill: false,
poolOwnership: null, poolOwnership: null,
......
...@@ -642,11 +642,38 @@ describe('基数沿用 —— 本批人数与时效', () => { ...@@ -642,11 +642,38 @@ describe('基数沿用 —— 本批人数与时效', () => {
expect(r.placed).toBe(100); expect(r.placed).toBe(100);
}); });
test('⭐ 分配后的水位要写进 basisNote(没有阈值判断,只报事实)', async () => { /**
* 🔴 **运营结论**(opsNote)—— 2026-08-04 产品评审提的那条:
* 「我不可能挨个去看他分的对不对,你在这儿应该给的是**结论性的东西**…
* 如果你不能告诉他哪不对、哪里需要主管判断,那就默认他必须一条一条看过去才能确认。」
*
* ⚠️ 最要命的是**增量与总量分不开**:卡片原来只写「分完每人 57~62 条」(在手总量),
* 而那一批实际只给每人 1~4 条 —— 主管一眼读成"这批好大一坨"。
*/
test('⭐⭐ 运营结论:本批拿到几条 / 分完手上几条 / 要打几天,三个数都要有', async () => {
const r = await mk(1000, 3, { inHand: 0, lastCriteria: { batchSize: 9, expiresInDays: 3 } }) const r = await mk(1000, 3, { inHand: 0, lastCriteria: { batchSize: 9, expiresInDays: 3 } })
.propose(SCOPE, { clinicId: 'c1' }); .propose(SCOPE, { clinicId: 'c1' });
expect(r.byAgent.every((x: { loadAfter: number }) => x.loadAfter === 3)).toBe(true); expect(r.byAgent.every((x: { loadAfter: number }) => x.loadAfter === 3)).toBe(true);
// ⚠️ 2026-08-06 措辞缩短:去掉了套两层的括号(「…(最多的是X)(他们原本还有 N 条在手)」) expect(r.opsNote).toContain('本批每人拿到 3 条');
expect(r.basisNote).toContain('分完每人 3 条'); expect(r.opsNote).toContain('分完之后手上共 3 条');
// ⚠️ 天数必须带上「按每天 N 通算」的前提 —— 那个 15 是口头经验值不是实测,
// ⛔ 不许写成"需要 X 天"这种像承诺的说法
expect(r.opsNote).toMatch(/按每人每天 \d+ 通算/);
});
test('🔴 「分完每人多少条」只许出现在 opsNote,⛔ basisNote 不再复述', async () => {
const r = await mk(1000, 3, { inHand: 0, lastCriteria: { batchSize: 9, expiresInDays: 3 } })
.propose(SCOPE, { clinicId: 'c1' });
// 两处都说 = 产品点名过的"确认单信息多而重复";而且 basisNote 讲的是"怎么分的",
// 把在手总量摆进去会被读成"这批给他这么多"
expect(r.basisNote).not.toContain('分完每人');
expect(r.basisNote).toContain('有专属的先回自己人手上');
});
test('⭐ 拿到的全是自己老客户的客服要点名 —— 那种批次主管可以直接过', async () => {
const r = await mk(1000, 3, { inHand: 0, lastCriteria: { batchSize: 9, expiresInDays: 3 } })
.propose(SCOPE, { clinicId: 'c1' });
// 这个 fixture 里没有专属关系 → 一个都不该点名(⛔ 别把"0 位"也写出来)
expect(r.opsNote).not.toContain('全是自己的老客户');
}); });
}); });
...@@ -729,6 +729,23 @@ describe('重新排一版 —— 只换挑谁,不动落人', () => { ...@@ -729,6 +729,23 @@ describe('重新排一版 —— 只换挑谁,不动落人', () => {
expect(SVC).not.toMatch(/ranked\.map\(\(c, i\) => \(\{/); expect(SVC).not.toMatch(/ranked\.map\(\(c, i\) => \(\{/);
}); });
/**
* 🔴 **运营结论钉在卡片上,⛔ 不是助手那段话里。**
*
* 与重排交代同一条理由:助手那段话会被后续对话冲走,而卡片一直在;
* 重排换卡片时助手也不会重新说一遍。⇒ 「这活多大」必须跟着卡片走。
* 由来:2026-08-04 产品评审「你在**这儿**应该给的是结论性的东西」—— 这儿 = 确认单。
*/
test('🔴 运营结论渲染在卡片上,且助手不重复说一遍', () => {
expect(SHEET).toMatch(/sheet\.opsNote/);
// 助手只念 selectionNote / basisNote / pendingNote 三段,⛔ opsNote 不在其中
const ASSISTANT = readFileSync(
join(__dirname, '../src/modules/assistant/assistant.service.ts'),
'utf8',
);
expect(ASSISTANT).not.toMatch(/sheet\.opsNote/);
});
test('🔴🔴 重排换了什么必须由**卡片自己**说 —— 助手不会重新说一遍', () => { test('🔴🔴 重排换了什么必须由**卡片自己**说 —— 助手不会重新说一遍', () => {
// 重排走 HTTP 只换卡片,上面那段旧话还在("34 人要您定"),不交代就是两份打架的口径 // 重排走 HTTP 只换卡片,上面那段旧话还在("34 人要您定"),不交代就是两份打架的口径
expect(SVC).toMatch(/refillNote:/); expect(SVC).toMatch(/refillNote:/);
......
...@@ -123,6 +123,29 @@ function CopyableName({ name }: { name: string }) { ...@@ -123,6 +123,29 @@ function CopyableName({ name }: { name: string }) {
); );
} }
/**
* 服务端成品句子里的 `**加粗**` —— 只认这一个标记,⛔ 不引 Markdown 渲染器。
*
* ⚠️ 这些句子是**服务端拼好的**(basisNote / refillNote / opsNote),里面混着患者和客服姓名。
* 上一个完整的 Markdown 渲染器进来,姓名里的 `*` `_` `#` 就会被当成语法吃掉 ——
* 而那是主管要照着念的名字。加粗是唯一需要的表达,自己切最省事也最安全。
*/
function Emphasized({ text }: { text: string }) {
return (
<>
{text.split('**').map((seg, i) =>
i % 2 === 1 ? (
<strong key={i} className="font-semibold">
{seg}
</strong>
) : (
<span key={i}>{seg}</span>
),
)}
</>
);
}
function carryOverrides(fromSheet: Record<string, AgentOverride>): Record<string, AgentOverride> { function carryOverrides(fromSheet: 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)) {
...@@ -992,15 +1015,23 @@ export function AssignmentConfirmSheet({ ...@@ -992,15 +1015,23 @@ export function AssignmentConfirmSheet({
*/} */}
{sheet.refillNote && ( {sheet.refillNote && (
<div className="whitespace-pre-line border-b border-sky-100 bg-sky-50/60 px-3 py-2 text-[11.5px] leading-relaxed text-sky-900"> <div className="whitespace-pre-line border-b border-sky-100 bg-sky-50/60 px-3 py-2 text-[11.5px] leading-relaxed text-sky-900">
{sheet.refillNote.split('**').map((seg, i) => <Emphasized text={sheet.refillNote} />
i % 2 === 1 ? ( </div>
<strong key={i} className="font-semibold">
{seg}
</strong>
) : (
<span key={i}>{seg}</span>
),
)} )}
{/*
①.6 **运营结论** —— 主管扫一眼就能决定按不按确认的三件事。
🔴 由来(2026-08-04 产品评审原话):「我不可能挨个去看他分的对不对,
你在这儿应该给的是**结论性的东西**…如果你不能告诉他哪不对、哪里需要主管判断,
那就默认他必须一条一条看过去才能确认。」
⚠️ 位置在重排交代**之后**、客服清单之前:重排是"这一版怎么来的",
运营结论是"这一版是多大的活",顺序按主管的阅读次序来。
⛔ 别把待分配的解释挪进来 —— 那件事在下面单独一组,问的是"要您拍板什么",
和这里问的"这活多大"不是一回事。
*/}
{sheet.opsNote && (
<div className="whitespace-pre-line border-b border-slate-100 bg-slate-50/70 px-3 py-2 text-[11.5px] leading-relaxed text-slate-700">
<Emphasized text={sheet.opsNote} />
</div> </div>
)} )}
......
...@@ -111,6 +111,23 @@ export type CreateAssignmentRequest = z.infer<typeof CreateAssignmentRequestSche ...@@ -111,6 +111,23 @@ export type CreateAssignmentRequest = z.infer<typeof CreateAssignmentRequestSche
*/ */
export const BATCH_SIZE_PER_AGENT_FIRST = 20; export const BATCH_SIZE_PER_AGENT_FIRST = 20;
/**
* **每人每天打得完多少通** —— 只用来把"手上多少条"翻译成"要打几天"。
*
* ⭐ 由来(2026-08-04 产品评审原话):主管说他没法逐条审,
* 「你在这儿应该给的是结论性的东西 —— 你要打 180 个电话,
* 根据你在岗的人数这一拨电话要打几天,每个客服日均是多少个」。
* ⇒ 卡片必须把「分完他手上 58 条」换算成「约 4 天的量」,那才是他要的结论。
*
* ⚠️⚠️ 它**不是**上限、不是配额、⛔ 更不参与落人 —— 落人只有水位法,没有阈值
* (见上面 BATCH_SIZE_PER_AGENT_FIRST 里"容量为什么被删掉")。
* ⛔ 别把它接进 placeAgents / capOf,那等于把删掉的"容量"从后门放回来。
* ⚠️ 15 是评审当天口头给的经验值(原话「我觉得一天打超过 15 个就没意义了」),
* **不是实测**。真值应该等跑起来后从 plan_executions 反推 —— 那之前它就是个换算尺,
* 所以卡片上必须写成「按每天 15 通算」而不是「需要 4 天」,让主管看得见这个前提。
*/
export const DAILY_CALLS_PER_AGENT = 15;
/// **首次**分配的时效起点(之后沿用上一次)。同批次人数,是基数不是常量。 /// **首次**分配的时效起点(之后沿用上一次)。同批次人数,是基数不是常量。
export const ASSIGNMENT_EXPIRES_DAYS_DEFAULT = 3; export const ASSIGNMENT_EXPIRES_DAYS_DEFAULT = 3;
/** /**
...@@ -575,6 +592,22 @@ export const AssignmentProposalSchema = z.object({ ...@@ -575,6 +592,22 @@ export const AssignmentProposalSchema = z.object({
* (手上 69 条 vs 别人 58 条)。不说这一句,那个 69 就成了无法解释的数。 * (手上 69 条 vs 别人 58 条)。不说这一句,那个 69 就成了无法解释的数。
*/ */
refillNote: z.string(), refillNote: z.string(),
/**
* 🔴 **运营结论** —— 主管扫一眼就该知道的三件事,⛔ 不是分配明细的复述。
*
* 由来(2026-08-04 产品评审):主管当场说他没法审这张单 ——
* 「我不可能挨个去看他分的对不对,你在这儿应该给的是结论性的东西…
* **如果你不能告诉他哪不对、哪里需要主管判断,那就默认他必须一条一条看过去才能确认**」
*
* 于是这段只回答三个问题,每个都是"看完就能决定按不按确认"的:
* ① **这是多大的活** —— 分完之后每人手上多少条 ≈ 几天的量(见 DAILY_CALLS_PER_AGENT)
* ② **这批给谁加了担子** —— 本批给他几条(⚠️ 与"手上总量"是两个数,必须分开说)
* ③ **谁的这批全是自己的老客户** —— 那种批次主管基本不用看,可以直接过
*
* ⚠️ ⛔ 别把「待分配」的解释塞进来:那件事有 pendingNote,而且它问的是"要你拍板什么",
* 和这段问的"这活多大"不是一回事,混在一起两边都读不清。
*/
opsNote: 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