Commit 9fa29b7b by luoqi

feat(plan): 容量去掉上下限 + 两个基数支持按客服精调

① 容量无上下限:原来卡在 20-50。那是主管对自己团队的判断,系统没有任何数据
   能证明 5 太少或 200 太多(全生产 plan_executions 仅 7 条),拿一个同样没依据的
   区间去卡他,只会让他撞上一个解释不了的墙。只校验正整数。
   连带撤掉名册接口的 capacityRange —— 返回区间会被读成「合法范围」,而它从来不是。

② 两个基数从"整体一个值"扩成"整体默认 + 按客服精调"(agentOverrides):
   · 按客服**容量**:改的是人数(Σ 容量−在手)→ 会换人群 → 走对话让助手重出单;
   · 按客服**时效**:不改人群 → 卡片上展开那一行直接改,落到他名下每条任务的
     assignment_expires_at(写路径早已支持逐条覆盖)。
   精调与基数一样沿用,所以卡片标「精调」+ capacityNote 点名 ——
   一条上个月的临时精调静默沿用三个月,没人会发现。

️ 精调必须穿到落人层:placeAgents 从收单个 capacity 改成收 capacityOf(userId)。
   只改展示不改 room,李莉照样被分满 20 条而卡片上写着 5 —— 不报错,要等她抱怨。
   已加回归并验证:改回单值该用例即红。

本地实测:薛玫单独设 7 天 → 行上出现「精调」标,整批仍 3 天;总人数不变。
教条 T22 补精调与无上下限两节。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent 9de8b34e
...@@ -158,6 +158,22 @@ export class AssistantService { ...@@ -158,6 +158,22 @@ export class AssistantService {
description: description:
'本批人数的**一次性**覆盖(主管说「这批只要 60 人」)。⚠️ 不会改基数,下次仍按容量算。', '本批人数的**一次性**覆盖(主管说「这批只要 60 人」)。⚠️ 不会改基数,下次仍按容量算。',
}, },
agentOverrides: {
type: 'object',
additionalProperties: {
type: 'object',
properties: {
capacity: { type: 'number' },
expiresInDays: { type: 'number' },
},
},
description:
'按客服精调(userId → {capacity, expiresInDays}),压过整体基数。' +
'主管说「李莉这周只给 5 条」「王强那批给 7 天」时传。' +
'⚠️ 先用 list_agents 拿到 userId,⛔ 不要用姓名当 key。' +
'⚠️ 精调**也会被记住**,所以主管说「李莉恢复正常」时要把她从这个表里去掉' +
'(传一个不含她的完整表,⛔ 别只传变化的那一条)。',
},
exploreRatio: { type: 'number', description: '探索配额占比 0-0.2' }, exploreRatio: { type: 'number', description: '探索配额占比 0-0.2' },
}, },
}), }),
...@@ -169,6 +185,7 @@ export class AssistantService { ...@@ -169,6 +185,7 @@ export class AssistantService {
personaTags?: string; personaTags?: string;
capacity?: number; capacity?: number;
expiresInDays?: number; expiresInDays?: number;
agentOverrides?: Record<string, { capacity?: number; expiresInDays?: number }>;
targetCount?: number; targetCount?: number;
exploreRatio?: number; exploreRatio?: number;
}; };
......
...@@ -301,8 +301,9 @@ export class McpServerFactory { ...@@ -301,8 +301,9 @@ export class McpServerFactory {
'某诊所的在岗客服名册 + 各自在手负载。分配前用它看"有哪些人、谁手上空"。' + '某诊所的在岗客服名册 + 各自在手负载。分配前用它看"有哪些人、谁手上空"。' +
'\n⚠️ 在岗按「近 N 月有回访记录」近似判定,**不代表系统确认在职**;' + '\n⚠️ 在岗按「近 N 月有回访记录」近似判定,**不代表系统确认在职**;' +
'名册外的客服也可以指定(用 include 传 userId)。' + '名册外的客服也可以指定(用 include 传 userId)。' +
'\n⚠️ 容量上限是**默认值**不是实测,返回里带 capacityRange 与 inHand,' + '\n⚠️ 这里只回**在手**(客观量),不回容量 —— 容量是主管的基数(沿用他上一次的值),' +
'**不要替主管做减法说"还能吃 N 个"** —— 他知道谁在休假,你不知道。', '⛔ **不要替他做减法说"还能吃 N 个"**,他知道谁在休假,你不知道。' +
'要看本批每人分多少,那是 propose_assignment 出的确认单的事。',
inputSchema: { inputSchema: {
clinicId: z.string().describe('诊所 id(必填,名册天然是诊所维度的)'), clinicId: z.string().describe('诊所 id(必填,名册天然是诊所维度的)'),
months: z.number().int().min(1).max(36).optional().describe('在岗窗口月数,默认 12'), months: z.number().int().min(1).max(36).optional().describe('在岗窗口月数,默认 12'),
......
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { AGENT_CAPACITY_RANGE, type AgentInfo, type ListAgentsResponse } from '@pac/types'; import type { AgentInfo, ListAgentsResponse } from '@pac/types';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator'; import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';
...@@ -137,11 +137,10 @@ export class AgentRosterService { ...@@ -137,11 +137,10 @@ export class AgentRosterService {
lastVisitAt: r?.lastAt?.toISOString() ?? null, lastVisitAt: r?.lastAt?.toISOString() ?? null,
/// 名册里没有 = 近 N 月无回访记录。**不是"不能分"** —— 见类注释 /// 名册里没有 = 近 N 月无回访记录。**不是"不能分"** —— 见类注释
inRoster: r != null, inRoster: r != null,
// ⛔ **不返回 remaining**。容量「20-50」是一个**区间默认值**,不是这个人的真实上限; // ⛔ **不返回 remaining,也不再返回容量区间**。
// 把它减出来会让助手说「李莉还能吃 38 个」—— 那是拿默认值做完了减法再当事实说出口, // 容量没有上下限(见 AGENT_CAPACITY_DEFAULT),给一个「20-50」会被读成"合法范围";
// 一句免责声明救不回来。返回区间 + 在手,减法留给主管(他知道谁在休假)。 // 而把余量减出来会让助手说「李莉还能吃 38 个」—— 拿一个未经验证的数做完减法当事实说出口。
capacityRange: [...AGENT_CAPACITY_RANGE] as [number, number], // 名册只回**在手**这个客观量;本批实际容量在确认单的 byAgent 里逐人给。
capacityBasis: 'default' as const,
}; };
}); });
...@@ -157,7 +156,6 @@ export class AgentRosterService { ...@@ -157,7 +156,6 @@ export class AgentRosterService {
// LLM 做阈值判断和免责声明都不可靠,直接把该说的话给它抄。 // LLM 做阈值判断和免责声明都不可靠,直接把该说的话给它抄。
rosterNote: rosterNote:
`在岗名册按「近 ${months} 个月有回访记录」近似判定,不代表系统确认在职;` + `在岗名册按「近 ${months} 个月有回访记录」近似判定,不代表系统确认在职;` +
`容量上限 ${AGENT_CAPACITY_RANGE[0]}-${AGENT_CAPACITY_RANGE[1]} 为默认值(暂无历史数据反推)。` +
`名册外的客服也可以指定。`, `名册外的客服也可以指定。`,
}; };
} }
......
...@@ -17,8 +17,6 @@ function agent(userId: string, inHand: number, name = userId): AgentInfo { ...@@ -17,8 +17,6 @@ function agent(userId: string, inHand: number, name = userId): AgentInfo {
recentVisits: 10, recentVisits: 10,
lastVisitAt: '2026-07-01T00:00:00.000Z', lastVisitAt: '2026-07-01T00:00:00.000Z',
inRoster: true, inRoster: true,
capacityRange: [20, 50],
capacityBasis: 'default',
}; };
} }
...@@ -333,6 +331,46 @@ describe('基数沿用 —— 容量与时效', () => { ...@@ -333,6 +331,46 @@ describe('基数沿用 —— 容量与时效', () => {
expect(r.selectionNote).toContain('不改容量基数'); expect(r.selectionNote).toContain('不改容量基数');
}); });
/**
* 🔴🔴 按客服精调必须**穿到落人那一层**。
*
* 「李莉这周带教,只给 5 条」—— 如果精调只改了展示、没进 room 计算,
* 她照样会被分满 20 条,而卡片上写着 5。这种错不会报错,要到她抱怨时才发现。
*/
test('🔴 李莉容量精调 5 → 她只拿 5 条,总人数也跟着变(8×20 + 5)', async () => {
const r = await mk(1000, 9, {
lastCriteria: { capacity: 20, expiresInDays: 3, agentOverrides: { a3: { capacity: 5 } } },
}).propose(SCOPE, { clinicId: 'c1' });
expect(r.target).toBe(8 * 20 + 5);
const lily = r.byAgent.find((x: { userId: string }) => x.userId === 'a3');
expect(lily.capacity).toBe(5);
expect(lily.overridden).toBe(true);
expect(lily.count).toBeLessThanOrEqual(5);
// 精调必须**点名**,否则推导链里的「9 × 20」自己就对不上账
expect(r.capacityNote).toContain('容量 5');
});
test('⭐ 时效精调只落在那个人身上,不动整体基数', async () => {
const r = await mk(1000, 9, {
lastCriteria: { capacity: 20, expiresInDays: 3, agentOverrides: { a3: { expiresInDays: 7 } } },
}).propose(SCOPE, { clinicId: 'c1' });
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 === 'a0').expiresInDays).toBe(3);
expect(r.target).toBe(180); // 时效不改人数
});
test('⛔ 精调表里的垃圾值静默丢弃,不让一条坏精调把整张确认单顶掉', async () => {
const r = await mk(1000, 9, {
lastCriteria: {
capacity: 20,
agentOverrides: { a1: { capacity: 0 }, a2: { capacity: '很多' }, a3: { expiresInDays: 999 } },
},
}).propose(SCOPE, { clinicId: 'c1' });
expect(r.agentOverrides).toEqual({});
expect(r.target).toBe(180);
});
test('⭐ 在手 ≥ 容量的人本批不参与,但要列进 skippedAgents', async () => { test('⭐ 在手 ≥ 容量的人本批不参与,但要列进 skippedAgents', async () => {
const r = await mk(1000, 9, { inHand: 25, lastCriteria: { capacity: 20, expiresInDays: 3 } }).propose( const r = await mk(1000, 9, { inHand: 25, lastCriteria: { capacity: 20, expiresInDays: 3 } }).propose(
SCOPE, SCOPE,
......
...@@ -6,6 +6,7 @@ import { ...@@ -6,6 +6,7 @@ import {
ASSIGN_STRATEGY_META, ASSIGN_STRATEGY_META,
ASSIGNMENT_EXPIRES_DAYS_PRESETS, ASSIGNMENT_EXPIRES_DAYS_PRESETS,
Permission, Permission,
type AgentOverride,
type AssignmentProposal, type AssignmentProposal,
type AssignStrategy, type AssignStrategy,
} from '@pac/types'; } from '@pac/types';
...@@ -29,6 +30,27 @@ import { cn } from '@/lib/utils'; ...@@ -29,6 +30,27 @@ import { cn } from '@/lib/utils';
* 助手窗只有 400px 宽,所以**不用 table**(横向撑爆),用卡片列表; * 助手窗只有 400px 宽,所以**不用 table**(横向撑爆),用卡片列表;
* 患者明细按客服折叠,展开才看 —— 兼顾"尽明细"与"一眼可确认"。 * 患者明细按客服折叠,展开才看 —— 兼顾"尽明细"与"一眼可确认"。
*/ */
/**
* 精调表落库形态 = 提案带来的(含容量部分) + 卡片上刚改的时效。
*
* ⚠️ 卡片只能改时效,所以**容量部分必须原样带走** —— 少带一次,主管上次设的
* 「李莉只给 5 条」就在这一次确认里被悄悄清掉了,而界面上完全看不出来。
*/
function mergeOverrides(
fromSheet: Record<string, AgentOverride>,
expiryByAgent: Record<string, number>,
): Record<string, AgentOverride> {
const out: Record<string, AgentOverride> = {};
for (const [userId, o] of Object.entries(fromSheet)) {
// 时效部分交给卡片当前值决定(下面统一写),这里只留容量
if (o.capacity != null) out[userId] = { capacity: o.capacity };
}
for (const [userId, days] of Object.entries(expiryByAgent)) {
out[userId] = { ...(out[userId] ?? {}), expiresInDays: days };
}
return out;
}
export function AssignmentConfirmSheet({ export function AssignmentConfirmSheet({
requestId, requestId,
sheet, sheet,
...@@ -53,6 +75,20 @@ export function AssignmentConfirmSheet({ ...@@ -53,6 +75,20 @@ export function AssignmentConfirmSheet({
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [open, setOpen] = useState<Set<string>>(new Set()); const [open, setOpen] = useState<Set<string>>(new Set());
/**
* 按客服的**时效精调**(userId → 天数)。初值来自提案(沿用上次的精调)。
*
* ⚠️ 为什么时效能在卡片上精调、容量不能:时效**不改人群**(只是这几条单子多久回池),
* 容量会 —— 人数是 Σ(容量−在手) 推出来的,改一下整批人就变了,那必须重出确认单。
* ⚠️ 落库时写到该客服名下**每一条**任务的 `assignment_expires_at`(逐条覆盖批次时效)。
*/
const [expiryByAgent, setExpiryByAgent] = useState<Record<string, number>>(() =>
Object.fromEntries(
sheet.byAgent
.filter((a) => a.expiresInDays !== sheet.expiresInDays)
.map((a) => [a.userId, a.expiresInDays]),
),
);
const done = state === 'confirmed'; const done = state === 'confirmed';
...@@ -75,14 +111,22 @@ export function AssignmentConfirmSheet({ ...@@ -75,14 +111,22 @@ export function AssignmentConfirmSheet({
// ⚠️ 时效存**卡片当前值**不是提案值:主管刚在上面改成 5 天,记住的就得是 5。 // ⚠️ 时效存**卡片当前值**不是提案值:主管刚在上面改成 5 天,记住的就得是 5。
capacity: sheet.capacity, capacity: sheet.capacity,
expiresInDays, expiresInDays,
// ⭐ 按客服的精调也进快照 —— 下次一并沿用(容量部分原样带走,时效部分用卡片当前值)
agentOverrides: mergeOverrides(sheet.agentOverrides, expiryByAgent),
}, },
expiresInDays, expiresInDays,
// ⚠️ 落库的是**卡片当前值**,不是模型原提案 —— 主管改了时效就得按改后的走 // ⚠️ 落库的是**卡片当前值**,不是模型原提案 —— 主管改了时效就得按改后的走。
// 逐条 expiresInDays 只在**与批次不同**时才带:一样还带等于把批次时效冻进每条,
// 将来批次层面改时效(撤销重发之类)就改不动这些单子了。
items: sheet.items.map((it) => ({ items: sheet.items.map((it) => ({
planId: it.planId, planId: it.planId,
assigneeUserId: it.assigneeUserId, assigneeUserId: it.assigneeUserId,
assignStrategy: it.assignStrategy, assignStrategy: it.assignStrategy,
selectionMode: it.selectionMode, selectionMode: it.selectionMode,
...(expiryByAgent[it.assigneeUserId] != null &&
expiryByAgent[it.assigneeUserId] !== expiresInDays
? { expiresInDays: expiryByAgent[it.assigneeUserId] }
: {}),
})), })),
}); });
const summary = const summary =
...@@ -153,9 +197,55 @@ export function AssignmentConfirmSheet({ ...@@ -153,9 +197,55 @@ export function AssignmentConfirmSheet({
铺平 {a.spread} 铺平 {a.spread}
</span> </span>
)} )}
{/* ⭐ 精调过的人必须一眼看得出 —— 一条上个月的临时精调如果静默沿用,没人会发现 */}
{(a.overridden || expiryByAgent[a.userId] != null) && (
<span className="flex-none rounded bg-violet-50 px-1.5 py-0.5 text-[10px] text-violet-700 ring-1 ring-inset ring-violet-200">
精调
</span>
)}
</button> </button>
{isOpen && ( {isOpen && (
<ul className="space-y-0.5 bg-slate-50/60 px-3 pb-2 pl-8 text-[11px] text-slate-500"> <div className="bg-slate-50/60 px-3 pb-2 pl-8">
{/* 按客服的时效精调 —— 落到他名下**每一条**任务上。
容量只读:改它会换人群,得回对话让助手重出单(见组件顶部注释)。 */}
<div className="mb-1.5 flex flex-wrap items-center gap-1.5 text-[10.5px] text-slate-500">
<span>容量 {a.capacity}</span>
<span className="text-slate-300">·</span>
<span>时效</span>
{ASSIGNMENT_EXPIRES_DAYS_PRESETS.map((d) => {
const eff = expiryByAgent[a.userId] ?? expiresInDays;
return (
<button
key={d}
type="button"
disabled={done}
onClick={() =>
setExpiryByAgent((s) => {
const n = { ...s };
// 选回批次时效 = 取消精调(⛔ 不要留一条"恰好等于批次"的精调,
// 否则主管改批次时效时这个人不跟着动,而卡片上看不出为什么)
if (d === expiresInDays) delete n[a.userId];
else n[a.userId] = d;
return n;
})
}
className={cn(
'rounded border px-1.5 py-0.5 transition-colors',
eff === d
? 'border-brand-500 bg-brand-50 font-medium text-brand-700'
: 'border-slate-200 bg-white text-slate-500 hover:bg-slate-50',
done && 'opacity-60',
)}
>
{d}
</button>
);
})}
{expiryByAgent[a.userId] != null && (
<span className="text-violet-600">单独设定</span>
)}
</div>
<ul className="space-y-0.5 text-[11px] text-slate-500">
{mine.slice(0, 30).map((i) => ( {mine.slice(0, 30).map((i) => (
<li key={i.planId} className="flex items-center gap-1.5"> <li key={i.planId} className="flex items-center gap-1.5">
<span className="truncate">#{i.patientId.slice(0, 8)}</span> <span className="truncate">#{i.patientId.slice(0, 8)}</span>
...@@ -171,6 +261,7 @@ export function AssignmentConfirmSheet({ ...@@ -171,6 +261,7 @@ export function AssignmentConfirmSheet({
<li className="text-[10px] text-slate-400">…另有 {mine.length - 30}</li> <li className="text-[10px] text-slate-400">…另有 {mine.length - 30}</li>
)} )}
</ul> </ul>
</div>
)} )}
</div> </div>
); );
...@@ -184,7 +275,9 @@ export function AssignmentConfirmSheet({ ...@@ -184,7 +275,9 @@ export function AssignmentConfirmSheet({
</div> </div>
)} )}
{/* ③ 微调 —— **只有两项**(T13)。多加一项就违教条,评审按这条卡 */} {/* ③ 微调 —— 仍然**只有两类**(T13):指定客服、时效。
时效有两级:这里是整批默认,展开某个客服可以单独给他一个(⚠️ 仍是"时效"这一项,
不是第三项);容量只读,改它会换人群 → 回对话让助手重出单。 */}
<div className="space-y-1.5 border-t border-slate-100 px-3 py-2"> <div className="space-y-1.5 border-t border-slate-100 px-3 py-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="flex-none text-slate-500">时效</span> <span className="flex-none text-slate-500">时效</span>
......
...@@ -71,7 +71,7 @@ v1 **轻量**:不核销、不接宿主福利数据,福利就是**话术勾 ...@@ -71,7 +71,7 @@ v1 **轻量**:不核销、不接宿主福利数据,福利就是**话术勾
> ⚠️ 「100」是**举例,不是参数**。批次规模由 T22 的容量推出来,⛔ 别把这句话读成 > ⚠️ 「100」是**举例,不是参数**。批次规模由 T22 的容量推出来,⛔ 别把这句话读成
> 「系统里应该有一个 `DEFAULT_BATCH_SIZE = 100`」——2026-08-03 之前就是这么读的, > 「系统里应该有一个 `DEFAULT_BATCH_SIZE = 100`」——2026-08-03 之前就是这么读的,
> 结果代码里立了一个 100 的常量,和裁决表里写死的「拟分 N 人 = 默认分满容量」互相矛盾了两周。 > 结果代码里立了一个 100 的常量,和裁决表里写死的「拟分 N 人 = 默认分满容量」互相矛盾了两周。
> 这一条约束的是**容量该拍多大**(所以首次默认取下界 20,不是上界 50),不是批次规模本身。 > 这一条约束的是**容量该拍多大**(所以首次默认取 20 这种保守值,不是 50),不是批次规模本身。
### T22 · 分配只有两个基数:**容量** 和 **时效**,且都沿用主管上一次的值 ### T22 · 分配只有两个基数:**容量** 和 **时效**,且都沿用主管上一次的值
...@@ -79,9 +79,28 @@ v1 **轻量**:不核销、不接宿主福利数据,福利就是**话术勾 ...@@ -79,9 +79,28 @@ v1 **轻量**:不核销、不接宿主福利数据,福利就是**话术勾
| 基数 | 含义 | 首次默认 | | 基数 | 含义 | 首次默认 |
|---|---|---| |---|---|---|
| **容量** | 一个客服**同时**能压多少条(存量上限,不是每日增量) | 20(区间下界) | | **容量** | 一个客服**同时**能压多少条(存量上限,不是每日增量) | 20 |
| **时效** | 这批单子多久没动就自动回池 | 3 天 | | **时效** | 这批单子多久没动就自动回池 | 3 天 |
**容量没有上下限**。这是主管对自己团队的判断,系统没有任何数据能证明 5 太少或 200 太多
(全生产 `plan_executions` 仅 7 条)。拿一个同样没有依据的区间(原来的 20–50)去卡他,
只会让他撞上一个解释不了的墙。只校验正整数 —— 那不是业务上限,是「0 条」没有意义。
> 连带撤掉:名册接口不再返回 `capacityRange` —— 返回一个区间会被读成「合法范围」,而它从来不是。
**两个基数是整体默认,可以按客服精调**`agentOverrides: userId → {capacity?, expiresInDays?}`):
「李莉这周带教只给 5 条」「王强那批给 7 天」。
| | 改什么 | 会不会换人群 | 入口 |
|---|---|---|---|
| 按客服**容量** | 他这批拿几条 | **会**(人数是 Σ 容量−在手) | 回对话说一句,助手重出确认单 |
| 按客服**时效** | 他名下每条任务的到期 | 不会 | 卡片上展开那一行直接改 |
精调**与基数一样会被沿用**,所以卡片上必须标「精调」二字并写进 capacityNote 点名 ——
一条上个月的临时精调如果静默沿用三个月,没人会发现。
时效精调落到 `followup_plans.assignment_expires_at`(逐条覆盖批次时效,写路径早已支持)。
⚠️ 精调值**等于批次默认时不落**:留一条"恰好等于批次"的精调,主管改批次时效时这个人不跟着动,
而卡片上看不出为什么。
**批次规模不是基数,是推出来的**`本批人数 = Σ max(0, 容量 − 该客服在手)` **批次规模不是基数,是推出来的**`本批人数 = Σ max(0, 容量 − 该客服在手)`
在手 ≥ 容量的人本批不参与,但**仍要列出来**标「已满」——「他不是被漏了,是已经满了」。 在手 ≥ 容量的人本批不参与,但**仍要列出来**标「已满」——「他不是被漏了,是已经满了」。
...@@ -100,9 +119,6 @@ v1 **轻量**:不核销、不接宿主福利数据,福利就是**话术勾 ...@@ -100,9 +119,6 @@ v1 **轻量**:不核销、不接宿主福利数据,福利就是**话术勾
拿 60 反推出「以后每人 6.7 条」等于把临时决定固化成长期参数,而下次没人记得为什么变了。 拿 60 反推出「以后每人 6.7 条」等于把临时决定固化成长期参数,而下次没人记得为什么变了。
他真想改容量会直接说「每人 30 条」—— 那是另一句话。 他真想改容量会直接说「每人 30 条」—— 那是另一句话。
⚠️ **容量不做成卡片控件**:改容量会改变人群(人数由它推出),而卡片的微调项
只允许"不改人群"的那两个(T13)。改容量回对话说一句,助手重出单。时效不改人群,留在卡片上。
⚠️ 批次规模会随在手量波动(同样的容量,这次推出 340、下次 80)。因此确认单上 ⚠️ 批次规模会随在手量波动(同样的容量,这次推出 340、下次 80)。因此确认单上
**必须显示推导链**`在岗 17 位 × 每人容量 20 − 已在手 260 = 可分 80 人` **必须显示推导链**`在岗 17 位 × 每人容量 20 − 已在手 260 = 可分 80 人`
外加**出处**(沿用 7-28 那次 / 首次默认 / 本次指定)—— 外加**出处**(沿用 7-28 那次 / 首次默认 / 本次指定)——
...@@ -746,7 +762,7 @@ patient_transactions 经 patient_id → 客观新预约(canonical_payload.cre ...@@ -746,7 +762,7 @@ patient_transactions 经 patient_id → 客观新预约(canonical_payload.cre
| 福利是否核销 | **v1 不核销** | 先验证「带福利批次转化是否更高」,再谈打通卡券系统 | | 福利是否核销 | **v1 不核销** | 先验证「带福利批次转化是否更高」,再谈打通卡券系统 |
| **助手要不要校验专属客服在岗** | **不校验** | 在岗数据目前不够精确;**由主管在确认单上自行说明**,不让助手拿半准的数据挡人 | | **助手要不要校验专属客服在岗** | **不校验** | 在岗数据目前不够精确;**由主管在确认单上自行说明**,不让助手拿半准的数据挡人 |
| **「在岗」怎么判** | **近似即可**:默认按 `source_created_at` 近 12 月;**最终以主管信息为准** | 数据只做默认值,主管说了算 | | **「在岗」怎么判** | **近似即可**:默认按 `source_created_at` 近 12 月;**最终以主管信息为准** | 数据只做默认值,主管说了算 |
| 容量上限 | **在手总量 20-50,首次取下界 20,之后沿用主管上次的值** | 不是每日增量;见 T22 | | 容量上限 | **无上下限**;首次默认 20,之后沿用主管上次的值,可按客服精调 | 不是每日增量;见 T22 |
| 初选 X 轴用什么 | **画像的潜在治疗 8 类** | 见 T6a;`focusCategory` 是技术类目不是业务机会,且会把早矫埋进正畸 | | 初选 X 轴用什么 | **画像的潜在治疗 8 类** | 见 T6a;`focusCategory` 是技术类目不是业务机会,且会把早矫埋进正畸 |
| 拟分 N 人怎么定 | **默认分满容量**`Σ 容量−在手`) | 第一性:容量上限本身已是「一个人同时能处理多少」的约束,不必再打折。⚠️ 曾被实现成一个 100 的常量,与本裁决矛盾,2026-08-03 改回,见 T22 | | 拟分 N 人怎么定 | **默认分满容量**`Σ 容量−在手`) | 第一性:容量上限本身已是「一个人同时能处理多少」的约束,不必再打折。⚠️ 曾被实现成一个 100 的常量,与本裁决矛盾,2026-08-03 改回,见 T22 |
| 明细默认展示多少 | **按客服折叠**,展开才看 | 兼顾「尽明细」与「一眼可确认」 | | 明细默认展示多少 | **按客服折叠**,展开才看 | 兼顾「尽明细」与「一眼可确认」 |
......
...@@ -90,21 +90,18 @@ export type CreateAssignmentRequest = z.infer<typeof CreateAssignmentRequestSche ...@@ -90,21 +90,18 @@ export type CreateAssignmentRequest = z.infer<typeof CreateAssignmentRequestSche
*/ */
/** /**
* 单个客服同时能跟进的**在手总量**区间(不是每日增量)。 * **首次**分配的容量起点(之后沿用上一次)—— 单个客服同时能跟进的**在手总量**(不是每日增量)。
* *
* ⚠️ 区间本身仍是**没有数据支撑的默认值** —— 全生产 `plan_executions` 仅 7 条, * ⛔ **容量没有上下限**(2026-08-03 产品定):这是主管对自己团队的判断,
* 算不出任何人的真实吞吐。凡是用到它的地方都必须按 T14 当场标明出处, * 系统没有任何数据可以证明 5 太少或 200 太多(全生产 `plan_executions` 仅 7 条)。
* 等 T20 沉淀出「完成率开始下滑的拐点」再替换。 * 拿一个同样没有依据的区间去卡他,只会让他撞上一个解释不了的墙。
*/ * ⚠️ 只校验**正整数** —— 那不是业务上下限,是"0 条 / -3 条"没有意义。
export const AGENT_CAPACITY_RANGE: readonly [number, number] = [20, 50];
/**
* **首次**分配的容量起点(之后沿用上一次)。
* *
* ⚠️ 取**下界 20** 而不是上界:批次规模现在由容量推出来(17 人 × 50 = 850 条一批, * ⚠️ 取 20 作起点而不是更大的数:批次规模由容量推出来(17 人 × 50 = 850 条一批,
* 正是 T5「宁可 100 人做透,不做 1000 人做浅」反对的做法)。 * 正是 T5「宁可 100 人做透,不做 1000 人做浅」反对的做法)。
* 起点低、主管觉得不够再往上调 —— 反过来(起点高、发现做不完再往下调)那一批已经分出去了。 * 起点低、主管觉得不够再往上调 —— 反过来(起点高、发现做不完再往下调)那一批已经分出去了。
*/ */
export const AGENT_CAPACITY_DEFAULT = AGENT_CAPACITY_RANGE[0]; export const AGENT_CAPACITY_DEFAULT = 20;
/// **首次**分配的时效起点(之后沿用上一次)。同容量,是基数不是常量。 /// **首次**分配的时效起点(之后沿用上一次)。同容量,是基数不是常量。
export const ASSIGNMENT_EXPIRES_DAYS_DEFAULT = 3; export const ASSIGNMENT_EXPIRES_DAYS_DEFAULT = 3;
...@@ -125,9 +122,9 @@ export const AgentInfoSchema = z.object({ ...@@ -125,9 +122,9 @@ export const AgentInfoSchema = z.object({
/// 是否在名册内。⚠️ **不是"能不能分"** —— 名册是建议来源不是白名单, /// 是否在名册内。⚠️ **不是"能不能分"** —— 名册是建议来源不是白名单,
/// 实测有客服只做召回不做回访(回访数 0),分给他完全合法 /// 实测有客服只做召回不做回访(回访数 0),分给他完全合法
inRoster: z.boolean(), inRoster: z.boolean(),
capacityRange: z.tuple([z.number().int(), z.number().int()]), /// ⛔ 这里**不再返回容量区间** —— 容量没有上下限(见 AGENT_CAPACITY_DEFAULT),
/// 'default' = 区间是拍的默认值;将来数据够了会变成 'derived' /// 返回一个 [20,50] 会被读成"合法范围",而它从来不是。
capacityBasis: z.literal('default'), /// 本批实际用的容量在确认单的 byAgent 里逐人给。
}); });
export type AgentInfo = z.infer<typeof AgentInfoSchema>; export type AgentInfo = z.infer<typeof AgentInfoSchema>;
...@@ -271,6 +268,24 @@ export const ProposedItemSchema = z.object({ ...@@ -271,6 +268,24 @@ export const ProposedItemSchema = z.object({
}); });
export type ProposedItem = z.infer<typeof ProposedItemSchema>; export type ProposedItem = z.infer<typeof ProposedItemSchema>;
/**
* 按客服的**精调**。
*
* 两个基数(容量/时效)是**整体默认**,这里是针对某一个人的覆盖:
* 「李莉这周带教,只给 5 条」「王强手上都是老客,给他 7 天」。
*
* ⚠️ 两者的改动性质**不同**,所以入口也不同:
* · `capacity` 改的是**人数**(Σ 容量−在手)→ 会换人群 → 必须回对话让助手重出确认单;
* · `expiresInDays` 不改人群 → 就在卡片上那一行改(落到该客服名下**每一条任务**的
* `followup_plans.assignment_expires_at`,与批次时效同口径)。
*/
export const AgentOverrideSchema = z.object({
/// ⛔ 无上下限,只要正整数(理由同 AGENT_CAPACITY_DEFAULT)
capacity: z.number().int().positive().optional(),
expiresInDays: z.number().int().positive().max(90).optional(),
});
export type AgentOverride = z.infer<typeof AgentOverrideSchema>;
export const ProposalAgentRowSchema = z.object({ export const ProposalAgentRowSchema = z.object({
userId: z.string(), userId: z.string(),
name: z.string().nullable(), name: z.string().nullable(),
...@@ -278,6 +293,12 @@ export const ProposalAgentRowSchema = z.object({ ...@@ -278,6 +293,12 @@ 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('两种铺平合并显示 —— 主管界面只分「专属/铺平」两档'),
/// ⭐ 本批对**这个人**实际生效的两个值(没精调就等于整体基数)。
/// 逐人下发而不是让前端自己合并 —— 合并逻辑写两遍必然漂,而漂了不报错
capacity: z.number().int(),
expiresInDays: z.number().int(),
/// 是否被精调过(卡片上要标出来,否则"为什么李莉只有 5 条"没人答得上)
overridden: z.boolean(),
}); });
export type ProposalAgentRow = z.infer<typeof ProposalAgentRowSchema>; export type ProposalAgentRow = z.infer<typeof ProposalAgentRowSchema>;
...@@ -305,6 +326,9 @@ export const AssignmentProposalSchema = z.object({ ...@@ -305,6 +326,9 @@ export const AssignmentProposalSchema = z.object({
.describe('基数来源:沿用上次 / 首次默认 / 本次主管明确指定'), .describe('基数来源:沿用上次 / 首次默认 / 本次主管明确指定'),
/// 沿用时是"沿用哪一次"(ISO 日期);非沿用为 null /// 沿用时是"沿用哪一次"(ISO 日期);非沿用为 null
basisFrom: z.string().nullable(), basisFrom: z.string().nullable(),
/// 按客服的精调(userId → 覆盖值)。⚠️ 与基数一样会被记住,所以卡片上必须标出来 ——
/// 「李莉休假那周只给 5 条」如果悄悄沿用三个月,没人会发现
agentOverrides: z.record(z.string(), AgentOverrideSchema),
placed: z.number().int(), placed: z.number().int(),
/// ⚠️ 分不下去的**不摊派**给已满的人:硬塞是 T5 的反面,而且会立刻造出 over_capacity 退回, /// ⚠️ 分不下去的**不摊派**给已满的人:硬塞是 T5 的反面,而且会立刻造出 over_capacity 退回,
/// 而那正是要用来反推容量默认值的信号 —— 自己造出来就没法反推了 /// 而那正是要用来反推容量默认值的信号 —— 自己造出来就没法反推了
......
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