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]} 为默认值(暂无历史数据反推)。` +
`名册外的客服也可以指定。`, `名册外的客服也可以指定。`,
}; };
} }
......
...@@ -81,11 +81,18 @@ export class AssignmentProposalService { ...@@ -81,11 +81,18 @@ export class AssignmentProposalService {
temperature?: TemperatureValue; temperature?: TemperatureValue;
/** T9-B 调整阶段主管追加的画像条件(`key:value` 逗号串) */ /** T9-B 调整阶段主管追加的画像条件(`key:value` 逗号串) */
personaTags?: string; personaTags?: string;
/** 每个客服的在手容量(基数①);不传 = 沿用上一次分配的值 */ /** 每个客服的在手容量(基数①,**整体默认**);不传 = 沿用上一次分配的值 */
capacity?: number; capacity?: number;
/** 批次时效天数(基数②);不传 = 沿用上一次分配的值 */ /** 批次时效天数(基数②,**整体默认**);不传 = 沿用上一次分配的值 */
expiresInDays?: number; expiresInDays?: number;
/** /**
* 按客服的**精调**(userId → 覆盖值),压过整体基数。
* 「李莉这周带教只给 5 条」「王强那批给 7 天」。
* ⚠️ 与基数一样会被沿用(主管的习惯),所以下发时逐人标 `overridden`,
* 让一条三个月前的临时精调不至于永远静默生效。
*/
agentOverrides?: Record<string, { capacity?: number; expiresInDays?: number }>;
/**
* 拟分人数 —— **一次性覆盖**,主管说「这批只要 60 人」时才给。 * 拟分人数 —— **一次性覆盖**,主管说「这批只要 60 人」时才给。
* ⛔ 它**不回写基数**:批次规模是一次性的运营选择,容量是人的属性, * ⛔ 它**不回写基数**:批次规模是一次性的运营选择,容量是人的属性,
* 拿 60 反推出「以后每人 3.5 条」等于把临时决定固化成长期参数,而且没人记得为什么。 * 拿 60 反推出「以后每人 3.5 条」等于把临时决定固化成长期参数,而且没人记得为什么。
...@@ -126,6 +133,11 @@ export class AssignmentProposalService { ...@@ -126,6 +133,11 @@ export class AssignmentProposalService {
: baseline.from : baseline.from
? 'inherited' ? 'inherited'
: 'default'; : 'default';
// 按客服精调:本次传的压过沿用的(同一个人两处都有 → 本次赢)
const agentOverrides = sanitizeOverrides({ ...baseline.agentOverrides, ...input.agentOverrides });
/// 某人本批实际生效的两个值 —— ⛔ 只此一处合并,别在 placeAgents / byAgent / 前端各合一遍
const capOf = (userId: string) => agentOverrides[userId]?.capacity ?? capacity;
const expOf = (userId: string) => agentOverrides[userId]?.expiresInDays ?? expiresInDays;
// ── 拟分人数 = 把在岗客服**填满到容量水位** ──────────────── // ── 拟分人数 = 把在岗客服**填满到容量水位** ────────────────
// ⚠️ 这是 2026-08-03 的改判:原来批次规模是一个独立默认值(100),容量只当上限。 // ⚠️ 这是 2026-08-03 的改判:原来批次规模是一个独立默认值(100),容量只当上限。
...@@ -134,7 +146,7 @@ export class AssignmentProposalService { ...@@ -134,7 +146,7 @@ export class AssignmentProposalService {
// 三者矛盾时听谁的就说不清了(改容量还是改人数?)。 // 三者矛盾时听谁的就说不清了(改容量还是改人数?)。
// ⚠️ 在手 ≥ 容量的人 room=0 → **本批不参与**,但仍进 skippedAgents 列出来 // ⚠️ 在手 ≥ 容量的人 room=0 → **本批不参与**,但仍进 skippedAgents 列出来
// ("他不是被漏了,是已经满了")。 // ("他不是被漏了,是已经满了")。
const totalRoom = agents.reduce((a, g) => a + Math.max(0, capacity - g.inHand), 0); 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 是一次性覆盖(⛔ 不回写基数,见入参注释) // targetCount 是一次性覆盖(⛔ 不回写基数,见入参注释)
const target = input.targetCount != null ? Math.max(0, input.targetCount) : totalRoom; const target = input.targetCount != null ? Math.max(0, input.targetCount) : totalRoom;
...@@ -146,6 +158,8 @@ export class AssignmentProposalService { ...@@ -146,6 +158,8 @@ export class AssignmentProposalService {
expiresInDays, expiresInDays,
basis, basis,
basisFrom: baseline.from, basisFrom: baseline.from,
agentOverrides,
capOf,
}); });
} }
...@@ -195,7 +209,7 @@ export class AssignmentProposalService { ...@@ -195,7 +209,7 @@ export class AssignmentProposalService {
// 两个主管同时出确认单会互相冲掉对方的缓存,而且是**静默**串数据。 // 两个主管同时出确认单会互相冲掉对方的缓存,而且是**静默**串数据。
// (本仓已有 ingest-resolver-no-instance-state.spec 在防同一类错。) // (本仓已有 ingest-resolver-no-instance-state.spec 在防同一类错。)
const dedicated = await this.dedicatedCsOf(chosen.map((c) => c.patientId)); const dedicated = await this.dedicatedCsOf(chosen.map((c) => c.patientId));
const items = placeAgents(chosen, agents, dedicated, capacity); const items = placeAgents(chosen, agents, dedicated, capOf);
const byAgent = new Map<string, ProposedItem[]>(); const byAgent = new Map<string, ProposedItem[]>();
for (const it of items.placed) { for (const it of items.placed) {
...@@ -225,10 +239,15 @@ export class AssignmentProposalService { ...@@ -225,10 +239,15 @@ 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),
expiresInDays: expOf(userId),
overridden: agentOverrides[userId] != null,
})), })),
agentOverrides,
// 已满但仍列出来:主管要看见"这个人不是被漏了,是已经满了" // 已满但仍列出来:主管要看见"这个人不是被漏了,是已经满了"
skippedAgents: agents skippedAgents: agents
.filter((a) => a.inHand >= capacity) .filter((a) => a.inHand >= capOf(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 双保险的"工具返回值"那一半)
...@@ -242,6 +261,10 @@ export class AssignmentProposalService { ...@@ -242,6 +261,10 @@ export class AssignmentProposalService {
basisFrom: baseline.from, basisFrom: baseline.from,
sizeBasis, sizeBasis,
target, target,
// 精调过的人要点名 —— 「为什么李莉只有 5 条」必须答得上来
overrides: agents
.filter((a) => agentOverrides[a.userId] != null)
.map((a) => ({ name: a.name ?? a.userId.slice(0, 8), ...agentOverrides[a.userId]! })),
}), }),
selectionNote: selectionNote:
// ⚠️ **候选不够时不能说"取前 N 人"** —— 主管从矩阵点了一个 44 人的格子进来, // ⚠️ **候选不够时不能说"取前 N 人"** —— 主管从矩阵点了一个 44 人的格子进来,
...@@ -306,7 +329,12 @@ export class AssignmentProposalService { ...@@ -306,7 +329,12 @@ export class AssignmentProposalService {
private async resolveBaseline( private async resolveBaseline(
scope: TenantScopeContext, scope: TenantScopeContext,
clinicId: string, clinicId: string,
): Promise<{ capacity: number; expiresInDays: number; from: string | null }> { ): Promise<{
capacity: number;
expiresInDays: number;
from: string | null;
agentOverrides: Record<string, { capacity?: number; expiresInDays?: number }>;
}> {
const base = { const base = {
hostId: scope.hostId, hostId: scope.hostId,
tenantId: scope.tenantId, tenantId: scope.tenantId,
...@@ -320,7 +348,11 @@ export class AssignmentProposalService { ...@@ -320,7 +348,11 @@ export class AssignmentProposalService {
select: { criteria: true, createdAt: true }, select: { criteria: true, createdAt: true },
}); });
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 { capacity?: unknown; expiresInDays?: unknown } | null; const c = (last?.criteria ?? null) as {
capacity?: unknown;
expiresInDays?: unknown;
agentOverrides?: unknown;
} | null;
const capacity = posInt(c?.capacity); const capacity = posInt(c?.capacity);
const expiresInDays = posInt(c?.expiresInDays); const expiresInDays = posInt(c?.expiresInDays);
// ⚠️ 只要有一个读到就算"沿用"(另一个补默认):老批次可能只存了其中一个 // ⚠️ 只要有一个读到就算"沿用"(另一个补默认):老批次可能只存了其中一个
...@@ -328,6 +360,7 @@ export class AssignmentProposalService { ...@@ -328,6 +360,7 @@ export class AssignmentProposalService {
capacity: capacity ?? AGENT_CAPACITY_DEFAULT, capacity: capacity ?? AGENT_CAPACITY_DEFAULT,
expiresInDays: expiresInDays ?? ASSIGNMENT_EXPIRES_DAYS_DEFAULT, expiresInDays: expiresInDays ?? ASSIGNMENT_EXPIRES_DAYS_DEFAULT,
from: capacity != null || expiresInDays != null ? last!.createdAt.toISOString() : null, from: capacity != null || expiresInDays != null ? last!.createdAt.toISOString() : null,
agentOverrides: sanitizeOverrides(c?.agentOverrides),
}; };
} }
...@@ -396,11 +429,14 @@ export function placeAgents( ...@@ -396,11 +429,14 @@ export function placeAgents(
chosen: Array<{ planId: string; patientId: string; selectionMode: 'rank' | 'explore' }>, chosen: Array<{ planId: string; patientId: string; selectionMode: 'rank' | 'explore' }>,
agents: AgentInfo[], agents: AgentInfo[],
dedicatedByPatient: Map<string, string>, dedicatedByPatient: Map<string, string>,
/** 本批的容量基数(沿用上次 / 主管指定 / 首次默认);⛔ 别在这里读常量,那样主管调了不生效 */ /**
capacity: number = AGENT_CAPACITY_DEFAULT, * **逐人**生效容量(整体基数 + 该人的精调)。⛔ 别在这里读常量或只收一个数 ——
* 「李莉这周只给 5 条」正是靠这一层生效的,收单值等于精调对落人无效(而且不会报错)。
*/
capacityOf: (userId: string) => number = () => AGENT_CAPACITY_DEFAULT,
): { placed: ProposedItem[]; unplaced: number } { ): { placed: ProposedItem[]; unplaced: number } {
const room = new Map<string, number>(); const room = new Map<string, number>();
for (const a of agents) room.set(a.userId, Math.max(0, capacity - a.inHand)); 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 rosterIds = new Set(agents.map((a) => a.userId));
const placed: ProposedItem[] = []; const placed: ProposedItem[] = [];
...@@ -476,11 +512,40 @@ function pickEvenly<T>(arr: T[], n: number): T[] { ...@@ -476,11 +512,40 @@ function pickEvenly<T>(arr: T[], n: number): T[] {
return Array.from({ length: n }, (_, i) => arr[Math.floor(i * step)]!); return Array.from({ length: n }, (_, i) => arr[Math.floor(i * step)]!);
} }
/** 正整数校验(Json 里读出来的东西什么都可能是) */ /**
* 正整数校验(Json 里读出来的东西什么都可能是)。
* ⛔ 这里**不做上下限** —— 容量是主管对自己团队的判断,系统没有依据卡他(见 AGENT_CAPACITY_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;
} }
/**
* 精调表清洗 —— 来源有两处(Json 快照 / 模型生成的 tool 参数),**两处都不可信**:
* 快照可能是老版本写的,模型可能吐出 `{capacity: "很多"}`。
* 非法值**静默丢弃**而不是抛错:一条坏精调不该让整张确认单出不来。
*/
function sanitizeOverrides(
raw: unknown,
): Record<string, { capacity?: number; expiresInDays?: number }> {
if (!raw || typeof raw !== 'object') return {};
const out: Record<string, { capacity?: number; expiresInDays?: number }> = {};
for (const [userId, v] of Object.entries(raw as Record<string, unknown>)) {
if (!userId || !v || typeof v !== 'object') continue;
const o = v as { capacity?: unknown; expiresInDays?: unknown };
const capacity = posInt(o.capacity);
// 时效有上限 90 天:那不是业务判断,是 `expiresInDays` 契约本来就写死的(schema max(90))
const days = posInt(o.expiresInDays);
const expiresInDays = days != null && days <= 90 ? days : null;
if (capacity == null && expiresInDays == null) continue;
out[userId] = {
...(capacity != null ? { capacity } : {}),
...(expiresInDays != null ? { expiresInDays } : {}),
};
}
return out;
}
/** 沿用的那次分配是哪天(只给日期,精确到秒对主管没意义) */ /** 沿用的那次分配是哪天(只给日期,精确到秒对主管没意义) */
function ymd(iso: string): string { function ymd(iso: string): string {
return iso.slice(0, 10); return iso.slice(0, 10);
...@@ -507,10 +572,23 @@ function capacityNote(x: { ...@@ -507,10 +572,23 @@ function capacityNote(x: {
basisFrom: string | null; basisFrom: string | null;
sizeBasis: 'explicit' | 'capacity'; sizeBasis: 'explicit' | 'capacity';
target: number; target: number;
overrides: Array<{ name: string; capacity?: number; expiresInDays?: number }>;
}): string { }): string {
const chain = const chain =
`在岗 ${x.agents} 位 × 每人容量 ${x.capacity} − 已在手 ${x.inHandTotal} = 可分 ${x.totalRoom} 人` + `在岗 ${x.agents} 位 × 每人容量 ${x.capacity} − 已在手 ${x.inHandTotal} = 可分 ${x.totalRoom} 人` +
(x.sizeBasis === 'explicit' ? `(本批按您指定的 ${x.target} 人走)` : ''); (x.sizeBasis === 'explicit' ? `(本批按您指定的 ${x.target} 人走)` : '');
// ⚠️ 精调必须**点名**:推导链里写的是整体容量,而李莉实际只有 5 条 ——
// 不点出来,那条链自己就对不上账,主管会以为算错了。
const tuned = x.overrides.length
? `其中${x.overrides
.map(
(o) =>
`${o.name}${o.capacity != null ? ` 容量 ${o.capacity}` : ''}${
o.expiresInDays != null ? ` 时效 ${o.expiresInDays} 天` : ''
}`,
)
.join('、')}(单独精调,会一直沿用到您改回来);`
: '';
const from = const from =
x.basis === 'inherited' && x.basisFrom x.basis === 'inherited' && x.basisFrom
? `容量 ${x.capacity} 条 / 时效 ${x.expiresInDays} 天**沿用 ${ymd(x.basisFrom)} 那次分配**,要改直接说(如「每人 30 条」「给 5 天」),下次自动记住。` ? `容量 ${x.capacity} 条 / 时效 ${x.expiresInDays} 天**沿用 ${ymd(x.basisFrom)} 那次分配**,要改直接说(如「每人 30 条」「给 5 天」),下次自动记住。`
...@@ -518,7 +596,7 @@ function capacityNote(x: { ...@@ -518,7 +596,7 @@ function capacityNote(x: {
? `容量 ${x.capacity} 条 / 时效 ${x.expiresInDays} 天是**您本次指定的**,确认后下次自动沿用。` ? `容量 ${x.capacity} 条 / 时效 ${x.expiresInDays} 天是**您本次指定的**,确认后下次自动沿用。`
: `容量 ${x.capacity} 条 / 时效 ${x.expiresInDays} 天是**首次默认值**(无历史可沿用,也无数据反推各人真实吞吐)——` + : `容量 ${x.capacity} 条 / 时效 ${x.expiresInDays} 天是**首次默认值**(无历史可沿用,也无数据反推各人真实吞吐)——` +
`觉得不合适直接说个数,确认后下次自动沿用。`; `觉得不合适直接说个数,确认后下次自动沿用。`;
return `${chain};${from}`; return `${chain};${tuned}${from}`;
} }
function emptyProposal( function emptyProposal(
...@@ -532,6 +610,9 @@ function emptyProposal( ...@@ -532,6 +610,9 @@ function emptyProposal(
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 }>;
/** 逐人生效容量 —— ⚠️ 空提案也要用它判"谁已满",否则精调过的人会被算错 */
capOf: (userId: string) => number;
}, },
): AssignmentProposal { ): AssignmentProposal {
return { return {
...@@ -543,12 +624,13 @@ function emptyProposal( ...@@ -543,12 +624,13 @@ function emptyProposal(
expiresInDays: base.expiresInDays, expiresInDays: base.expiresInDays,
basis: base.basis, basis: base.basis,
basisFrom: base.basisFrom, basisFrom: base.basisFrom,
agentOverrides: base.agentOverrides,
placed: 0, placed: 0,
unplaced: 0, unplaced: 0,
items: [], items: [],
byAgent: [], byAgent: [],
skippedAgents: agents skippedAgents: agents
.filter((a) => a.inHand >= base.capacity) .filter((a) => a.inHand >= base.capOf(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, rosterNote,
capacityNote: `每人容量 ${base.capacity} 条 / 时效 ${base.expiresInDays} 天。`, capacityNote: `每人容量 ${base.capacity} 条 / 时效 ${base.expiresInDays} 天。`,
......
...@@ -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