Commit 2dc6c19f by luoqi

feat(分配): 取消自动改派 → 待分配交主管;重排改成「重挑这批人」

三趟落人改成两趟 + 待分配:专属客服排满的人不再被自动改派给别人,
单列一组交主管决策。把患者从他的专属客服手里挪走是关系层面的决定。

「重新排一版」当天推翻两版才定稿,病史都写进注释了:
- v1 预先下发「顶替名单」→ 名单只能从取数窗口挑,池子 19 位无主、
  窗口里只落进 6 位,界面只敢说"顶 3 位"
- v2 把整个窗口丢给落人 + stopAt 跳过 → 水位按 chosen.length 算被窗口撑大
  (⌈(1001+50)/17⌉=62 → ⌈(1001+165)/17⌉=69),34 个"专属排满"的人原地进了
  同一位客服手里(她 59→68,别人 58)。底线没破,但负载塌了、口径全错
- v3(定稿)只换"挑谁":池子里无主的全换进来,其余按优先级用有专属的补满 N,
  然后走完全一样的三趟。有专属的那部分**按客服轮着取** —— 直取前 N 名会把
  名额全给专属大户(111/165 属同一人),另两位客服的余量白白空着。
  实测 拟分 16·待分配 34 → 拟分 31·待分配 19

顺带:确认后可补挂/改/撤福利(此前只改 state 亮角标,DB 一个字没变)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent 1c115cc3
......@@ -98,6 +98,17 @@ export class AssignmentProposalService {
targetCount?: number;
/** 探索配额占比(0-0.2)。⭐ 唯一的因果抓手,见 selectionMode 注释 */
exploreRatio?: number;
/**
* 🔴 **尽量排满**(2026-08-06 产品定)—— 主管点「重新排一版」时传 true。
*
* 默认(false):严格按排序键取前 N,专属客服排满的那些人进「待分配」等他决定。
* 打开后:**遇到专属排满的就跳过,继续往后取**,直到凑够 N 或池子取完。
*
* ⭐ 产品的原话:「优先级不重要,重要的是专属客服的患者不能擅自排给其他客服」——
* 这一批人本来就共享同一组特征(矩阵格子 + 画像条件),批内名次对主管没有意义。
* ⚠️ 代价是这批人整体排名会往后走,但**一条专属关系都没动**,这正是要的。
*/
preferPlaceable?: boolean;
},
now: Date = new Date(),
): Promise<AssignmentProposal> {
......@@ -172,12 +183,29 @@ export class AssignmentProposalService {
// ⭐ 取数上限按**基数**算(不是按 target)—— target 还没算出来:它是
// `min(基数, 候选总数)`,而候选总数正是下面这次 count 的结果。
// 按基数取一定够(target ≤ 基数),这样 count 与取明细还能并发,不多一个来回。
const fetchLimit = Math.ceil(batchSize * 1.5) + 20;
const [counts, pool] = await Promise.all([
/**
* ⚠️ **尽量排满时窗口必须放大**(2026-08-06 实测的 bug)。
* 默认窗口是 `1.5×N + 20`(N=50 → 95 行)。而"可顶替"的无主患者是从这个窗口里找的 ——
* 实测池子里有 17 位无主,窗口里只落进 3 位,另外 14 位排在 95 名之外**根本没被取回**,
* 界面于是只敢说「用 3 位顶替」。⇒ 候选来源被取数窗口限死了,不是池子。
* ⭐ 打开时取到 `4×N + 100`(仍受候选总数封顶)—— 跳过一批排满的之后还够凑 N。
*/
const fetchLimit = input.preferPlaceable
? Math.ceil(batchSize * 4) + 100
: Math.ceil(batchSize * 1.5) + 20;
const [counts, pool, ownership] = await Promise.all([
this.countCandidates(scope, criteria, now),
this.selectCandidates(scope, criteria, fetchLimit, now),
// ⚠️ 只多一次聚合查询,与上面两次并发 —— 不多一个来回
this.poolOwnership(scope, criteria, agents, now),
]);
const candidateTotal = counts.total;
// ⭐ **每一版提案都记下"按什么条件、圈出多少人"** —— 这不是调试日志,是这条生产线的黑匣子。
// 主管连着说三句话就会出三版确认单,人数一版一个样时,唯一能分清
// 「是条件变了」还是「是取数飘了」的证据就是这行(2026-08-06 排查 12→17→13 用的就是它)。
this.logger.log(
`提案:条件=${JSON.stringify(criteria)} 候选=${candidateTotal} 基数=${batchSize}`,
);
/**
* 本批**实际**分多少 = `min(基数 N, 候选总数)`。
*
......@@ -211,16 +239,55 @@ export class AssignmentProposalService {
...c,
selectionMode: 'explore' as const,
}));
const chosen = [...head, ...explore];
// ── ② 落人:专属优先 → 溢出均分 ──────────────────────────
// ⚠️ 专属客服表**作参数传下去**,不挂实例字段:service 是单例,
// 两个主管同时出确认单会互相冲掉对方的缓存,而且是**静默**串数据。
// (本仓已有 ingest-resolver-no-instance-state.spec 在防同一类错。)
const { dedicated, info } = await this.patientsOf(chosen.map((c) => c.patientId));
/// ⭐ 身份 + 专属一次性取**整个取回窗口**,不只取 chosen —— 重挑这一批时要看得见全池
const { dedicated, info } = await this.patientsOf(ranked.map((c) => c.patientId));
const agentById0 = new Map(agents.map((a) => [a.userId, a]));
/**
* 🔴🔴 **「重新排一版」= 重挑这 N 个人,⛔ 不是改落人规则**(2026-08-06 产品定,推翻当天两版)。
*
* ── 前两版都错在同一个地方:去动落人 ──────────────────────────
* ① 第一版预先下发「顶替名单」:名单只能从取数窗口里挑,窗口比池子小,
* 池子 19 位无主、窗口里只落进 6 位 → 界面只敢说"顶 3 位"。
* ② 第二版把整个窗口丢给落人 + `stopAt` 跳过:水位是按 `chosen.length` 算的,
* 于是被窗口撑大(62 → 69),那批"专属排满"的人原地进了**同一位客服**手里
* (她 59 → 68,别人 58)。底线没破,但团队负载塌了,而且没人说得清 68 是怎么来的。
*
* ── 这一版:只换"挑谁",落人一个字不改 ──────────────────────
* 把池子里**所有无主的**都换进这一批,不够的名额再按优先级用有专属的补满 N。
* 然后走**完全一样**的三趟:专属回自己人 → 无主补空 → 排不进的进待分配。
* 实测形状:50 人 = 19 位无主 + 31 位有专属 → 拟分 31 · 待分配 19
* (原来是 拟分 16 · 待分配 34 —— 排好的翻了一倍,而水位、卡片、底线全没动)
*
* ⚠️ 有专属的那部分**按客服轮着取**,⛔ 不能直接取前 31 名:
* 池子里 111/165 属同一位客服,按名次取会把 31 个名额全给她,
* 而她只剩 3 条余量 —— 另外两位客服明明还能各接 3 条和 6 条,却一个人都没进这批。
* ⚠️ 排名在这里**只是默认顺序**,不是要保的东西(产品原话:「为什么要纠结排名」)。
*/
const chosen = input.preferPlaceable
? repickPlaceable(ranked, dedicated, new Set(agents.map((a) => a.userId)), target)
: [...head, ...explore];
// ── ② 落人:专属优先 → 无主补空 → 待分配(两种模式**同一套**)────────
const raw = placeAgents(chosen, agents, dedicated, capOf);
// ⭐ 身份(姓名/病历号)在**落人之后**贴上 —— placeAgents 是纯算法,
// 让它认识"患者叫什么"只会让它更难测,而对分法没有任何影响。
/// 贴身份的公共部分 —— 已落人的和待分配的用**同一份**,⛔ 别各贴各的(会漂)
const withIdentity = <T extends { patientId: string }>(it: T) => {
const p = info.get(it.patientId);
return {
...it,
patientName: p?.name ?? null,
medicalRecordNumber: p?.mrn ?? null,
gender: p?.gender ?? null,
age: p?.age ?? null,
attendingDoctor: p?.preferredDoctor ?? null,
lastVisitDoctor: p?.lastVisitDoctor ?? null,
};
};
const items = {
unplaced: raw.unplaced,
placed: raw.placed.map((it) => {
......@@ -231,17 +298,75 @@ export class AssignmentProposalService {
medicalRecordNumber: p?.mrn ?? null,
gender: p?.gender ?? null,
age: p?.age ?? null,
// ⭐ 两个医生都读 **patient_profiles 现成的列**,⛔ 不在这里从 facts 现算:
// `lastVisitDoctor` = 上次到诊当天那份病历的医生(与 lastVisitAt **同一次就诊**,
// 跨天不认 —— 否则"日期是A次、医生是B次",客服照着说会露馅);
// `preferredDoctor` = 病历里出现频次最高的医生(当前充当"主治/偏好医生")。
// 列表页(plan.service)读的就是同两列,确认单与列表因此天然同源。
attendingDoctor: p?.preferredDoctor ?? null,
lastVisitDoctor: p?.lastVisitDoctor ?? null,
};
}),
};
/**
* 待分配 —— 贴身份 + 贴**专属客服的姓名和在手量**。
* ⚠️ 姓名和在手量是主管做判断的依据(「张三这轮排满了(在手 56),这个人要不要先给别人」),
* ⛔ 只给 userId 等于让他对着 id 猜。
*/
const pending = raw.pending.map((it) => {
const owner = agentById0.get(it.ownerUserId);
return {
...withIdentity(it),
ownerUserId: it.ownerUserId,
ownerName: owner?.name ?? null,
ownerInHand: owner?.inHand ?? 0,
};
});
/**
* 🔴 **可顶替待分配的候选** —— 取回窗口里**排在 target 之后、且没有在岗专属**的人。
*
* ⚠️ 「没有在岗专属」的判据必须与 placeAgents 的 `ownerOf` **完全一致**
* (专属不在名册内 = 视同无主),⛔ 否则这里给出的候选换进去照样会卡住。
* ⚠️ 只取 `pending.length` 个:不够就有几个顶几个(产品:"不够也可以凑"),
* ⛔ 别因为不够就整个不给。
* ⚠️ 带上 `rank`(在候选池里的名次)—— 「换进来的是第几名」是主管判断值不值的唯一依据。
*/
/**
* 🔴 **可以重新排一版吗** —— 池子里还有没有"专属没排满 / 无主"的人可用。
*
* ⚠️ 这里**只回答能不能**,⛔ 不预先算出"顶替名单":
* 原来的做法是从取回窗口里挑无主患者当候选,实测踩了坑 ——
* 池子有 17 位无主,窗口(1.5×N+20=95 行)里只落进 3 位,界面于是只敢说
* 「用 3 位顶替」,而主管明明看到"17 人无主"。**候选来源被取数窗口限死了**。
* ⇒ 现在改成:主管点一下 → **重新出一版**(preferPlaceable),那一版取数窗口更大、
* 遇到排满的直接跳过,天然就能凑满,也不用再维护一份"候选名单"。
*/
const canRefill = raw.pending.length > 0 && ownership.free > 0;
const byAgent = new Map<string, ProposedItem[]>();
for (const it of items.placed) {
const arr = byAgent.get(it.assigneeUserId) ?? [];
arr.push(it);
byAgent.set(it.assigneeUserId, arr);
}
const agentById = new Map(agents.map((a) => [a.userId, a]));
const agentById = agentById0;
/**
* `refillNote` 要用的三个数 —— 「这批换成了谁、卡住的人手上多满」。
* ⚠️ `suggestWaterline` 必须与 placeAgents 里那条**同一个算式**(在手合计 + 本批 N / 在岗人数);
* 两边各算各的必然漂,而漂了不报错 —— 主管看到的就是"卡片说 62、名单里 69"。
*/
const suggestWaterline =
agents.length > 0 ? Math.max(1, Math.ceil((inHandTotal + target) / agents.length)) : 0;
const allAfter = [...byAgent].map(([u, l]) => (agentById.get(u)?.inHand ?? 0) + l.length);
/// ⚠️ 空数组时 `Math.min(...[])` 是 `Infinity`,会印进主管眼里 —— 兜住
const minAfter = allAfter.length > 0 ? Math.min(...allAfter) : 0;
/// 这一批里有几位是无主的 —— 重排的全部意义就是"把他们都换进来了"
const freeInBatch = items.placed.filter(
(it) => it.assignStrategy === AssignStrategy.SPREAD_NO_DEDICATED,
).length;
return {
clinicId,
......@@ -260,6 +385,9 @@ export class AssignmentProposalService {
placed: items.placed.length,
unplaced: items.unplaced,
items: items.placed,
pending,
canRefill,
poolOwnership: raw.pending.length > 0 ? ownership : null,
byAgent: [...byAgent].map(([userId, list]) => ({
userId,
name: agentById.get(userId)?.name ?? null,
......@@ -309,22 +437,87 @@ export class AssignmentProposalService {
* ⚠️ 池子里还剩多少必须说 —— 否则他不知道"还能不能再分一批"(答案是:能)。
*/
selectionNote:
/**
* ⭐ **一句一件事,数字必须自洽**(2026-08-06 走查重排)。
*
* 原来是一大段散文,九个数字混在里面,而且「这批挑了 50 人」和下一段
* 「10 人分给 6 位客服」看起来直接打架 —— 主管第一反应是"到底分了几个"。
* ⇒ 用一个**等式**把它钉死:`50 人 = 已排好 10 + 待您定 40`,矛盾就不存在了。
* ⛔ 别再把选人口径、待分配解释、操作指引写进同一段。
*/
`**这批 ${target} 人**\n` +
(candidateTotal <= target
? `符合条件的一共就 ${candidateTotal} 人,这批**全要了**,没有取舍。`
: `符合条件的一共 ${candidateTotal} 人,这批挑了 ${target} 人:` +
? `符合条件的一共就 ${candidateTotal} 人,**全要了**,没有取舍。`
: `从符合条件的 ${candidateTotal} 人里挑,` +
// ⚠️ 「没被分过的排在前面」**只在池子里真有回池的人时才说**(2026-08-03 走查)。
// 池子的基线已经排除了"还在客服手上"的单,所以绝大多数时候池子里 100% 都没分过 ——
// 这时说这句是废话,还会让主管以为系统在防什么根本不存在的情况。
// ⛔ 但也不能永远不说:分过又退回的人**确实还在池子里**(T7:退回是正常路径,
// 不能因为分过一次就永不再召),他们排在后面,主管有权知道。
(counts.repeat > 0
? `**之前分过又退回的 ${counts.repeat} 人排在后面**,其余按优先级高的排前面。`
: `**优先级高的排在前面**。`) +
`剩下 ${candidateTotal - target} 人在排队,**随时可以再分一批**。`) +
? `之前分过又退回的 ${counts.repeat} 人排在后面,其余按优先级高的排前面。`
: `优先级高的排在前面。`) +
// ⚠️「排队」是内部说法(池子里并没有队);说"还没轮到"主管一听就懂
`剩下 ${candidateTotal - target} 人这轮还没轮到,随时能再分一批。`) +
(exploreN > 0
? `另外有 ${exploreN} 人是特意从排名靠后的位置抽的 —— 用来日后检验"排在前面的是不是真的更容易成"。`
: '') +
(items.unplaced > 0 ? `⚠️ 有 ${items.unplaced} 人没能分下去。` : ''),
(items.unplaced > 0 ? `⚠️ 有 ${items.unplaced} 人没能分下去。` : '') +
/**
* 🔴 「待分配」必须**说出来**,而且要说清"我为什么没分" (2026-08-06 产品定)。
*
* 这一段是整个改动的目的:助手不再偷偷把患者从专属客服手里挪走,
* 但**不说等于藏**。主管看到「拟分 8 人」而不知道另有 12 人卡在这儿,
* 结果就是这 12 个人谁也没管 —— 比原来自动改派还糟。
* ⛔ 所以这句不许省、不许弱化成"另有若干"。
*/
/**
* ⭐ **每段用一个加粗的"数字 + 是什么"起头**(2026-08-06 走查再排)。
*
* 主管扫这段话时眼睛先找的是**数字**。原来数字埋在句子中间,四段长得一模一样,
* 他得逐字读完才知道哪段跟自己有关。⇒ 每段第一行只放「**这批 50 人**」
* 「**⚠️ 其中 40 人要您定**」「**已排好 10 人 · 6 位客服**」,解释放第二行。
* ⚠️ 顺序按**要不要他动手**排:要拍板的那段排在"怎么分的"前面。
* ⛔ 别用 ## 标题:400px 的面板里 h2/h3 太重,一段话撑成半屏。
*/
'',
/**
* 「待分配」单独成段 —— **排在"已排好"之后**(2026-08-06 产品定)。
* 原来夹在选人口径里,主管先看到要他动手的事、再看这批长什么样,顺序是拧的。
*/
pendingNote:
pending.length > 0
? `**⚠️ 其中 ${pending.length} 人要您定**\n` +
`他们的专属客服这轮排满了,我没替您把人挪给别人。` +
`拖给谁、或者移出本批都行;**不动他们就是不分**。` +
// ⭐ 「为什么会这样」—— 不说清楚,主管会以为是系统出错
(ownership.topOwners.length
? `\n候选 ${ownership.withOwner + ownership.free} 人里 ${ownership.withOwner} 人有专属` +
`(${ownership.topOwners.map((o) => `${o.name ?? o.userId} ${o.n} `).join('、')})` +
`,${ownership.free} 人无主。`
: '') +
// ⭐ 只说"能重新排一版",⛔ 不说名次、不说顶几个 ——
// 产品:「排名其实不重要,重要的是运营策略(一批人有相同特征)」。
(canRefill
? `\n卡片上有个「重新排一版」—— 我把池子里**无主的都换进来**重挑这 ${target} 人,` +
`能排好的会多一些,一条专属关系还是不动。要不要换由您点。`
: '')
: '',
/**
* 🔴 **重排交代** —— 见 schema 注释:重排走 HTTP 只换卡片,助手不会重新说一遍,
* ⇒ 换了什么必须由**卡片自己**说,否则上面那段旧话("34 人要您定")当场对不上。
* ⚠️ 说的是**换了谁**,⛔ 不是"凑满了没有":这一版重排不改落人规则、不破水位,
* 排不进的照样进待分配 —— 卡片形状跟平时一模一样,只是这批人换过了。
*/
refillNote: !input.preferPlaceable
? ''
: `**已重挑这 ${target} 人**:池子里 ${freeInBatch} 位无主的全换进来了,` +
`其余按优先级用有专属的补齐。\n` +
`排好 ${items.placed.length} 人` +
(pending.length > 0
? `,还有 ${pending.length} 人的专属客服确实排满了(他们手上已经 ` +
`${minAfter}~${suggestWaterline} 条),得您定 —— 拖给谁或者移出本批。`
: `,没有需要您定的了。`),
};
}
......@@ -417,6 +610,50 @@ export class AssignmentProposalService {
* ⚠️ `count(DISTINCT fp.patient_id)` —— 与矩阵格子(cohort-attributes)**同一种数法**。
* ⛔ 别写成 `count(*)`:同一患者的两条召回会被数两次,又对不上矩阵了。
*/
/**
* 候选池的**专属客服分布** —— 回答「为什么这么多人卡住」。
*
* ⭐ 口径是**整个候选池**(与 candidateTotal 同一个 where),⛔ 不是取回的 1.5 倍样本 ——
* 拿样本算占比会随 N 变化而漂,主管两次看到不同比例会以为数据在变。
* ⚠️ 专属客服存在 `patients.preferences.dedicatedCs.id`(JSON),所以只能在 SQL 里取路径。
* ⚠️ **不在名册内的专属视同无主** —— 与 placeAgents 的 `ownerOf` 同一条规矩
* (人已离岗,那层关系落不了地)。⛔ 两处口径不一致会让"可顶替人数"对不上。
*/
private async poolOwnership(
scope: TenantScopeContext,
criteria: CohortCriteria,
agents: AgentInfo[],
now: Date,
): Promise<{ withOwner: number; free: number; topOwners: Array<{ userId: string; name: string | null; n: number }> }> {
const roster = agents.map((a) => a.userId);
const rows = await this.prisma.$queryRaw<Array<{ owner: string | null; n: bigint | number }>>(
Prisma.sql`
SELECT CASE WHEN p.preferences #>> '{dedicatedCs,id}' = ANY(${roster}::text[])
THEN p.preferences #>> '{dedicatedCs,id}' END AS owner,
count(DISTINCT fp.patient_id) AS n
FROM followup_plans fp
JOIN patients p ON p.id = fp.patient_id
WHERE ${cohortWhereSql(scope, criteria, now)}
GROUP BY 1
`,
);
const nameOf = new Map(agents.map((a) => [a.userId, a.name]));
let withOwner = 0;
let free = 0;
const owners: Array<{ userId: string; name: string | null; n: number }> = [];
for (const r of rows) {
const n = Number(r.n);
if (r.owner) {
withOwner += n;
owners.push({ userId: r.owner, name: nameOf.get(r.owner) ?? null, n });
} else {
free += n;
}
}
owners.sort((a, b) => b.n - a.n || a.userId.localeCompare(b.userId));
return { withOwner, free, topOwners: owners.slice(0, 3) };
}
private async countCandidates(
scope: TenantScopeContext,
criteria: CohortCriteria,
......@@ -456,6 +693,8 @@ export class AssignmentProposalService {
medicalRecordNumber: true,
gender: true,
birthDate: true,
// 两个医生走 profile 现成的列(与列表页同源),⛔ 别在本服务里从 facts 另算一套
profile: { select: { lastVisitDoctor: true, preferredDoctor: true } },
},
});
const dedicated = new Map<string, string>();
......@@ -470,6 +709,8 @@ export class AssignmentProposalService {
// ⚠️ 存的是生日不是年龄 —— 年龄必须**读时算**(与列表页 plan.service 同一个 calcAge),
// 两处各写一遍必然在生日当天差一岁
age: r.birthDate ? calcAge(r.birthDate) : null,
lastVisitDoctor: r.profile?.lastVisitDoctor ?? null,
preferredDoctor: r.profile?.preferredDoctor ?? null,
});
}
return { dedicated, info };
......@@ -480,7 +721,25 @@ export class AssignmentProposalService {
* 落人算法的产出 —— ⛔ **不含患者姓名**:那是身份信息,与"分给谁"这件事无关,
* 由 propose() 在算完之后贴上(见那里的注释)。
*/
type PlacedItem = Omit<ProposedItem, 'patientName' | 'medicalRecordNumber' | 'gender' | 'age'>;
/**
* placeAgents 的产物 —— **只含算法用得上的字段**。
*
* ⚠️ 身份类(姓名/病历号/性别/年龄/两个医生)一律在**落人之后**贴,⛔ 别加进这里:
* placeAgents 是纯算法,让它认识"患者叫什么、谁看的" 只会让它更难测,对分法零影响。
* (加一个展示字段就要在这里补一次 Omit —— 那正是这个 Omit 存在的意义。)
*/
type PlacedItem = Omit<
ProposedItem,
'patientName' | 'medicalRecordNumber' | 'gender' | 'age' | 'attendingDoctor' | 'lastVisitDoctor'
>;
/**
* 「待分配」的算法产物 —— 同样只含算法字段(身份/客服姓名在外面贴)。
* ⛔ 别在这里塞 assigneeUserId:**它就是没有经办人**,那正是它要交给主管的理由。
*/
type PendingItem = Pick<PlacedItem, 'planId' | 'patientId' | 'selectionMode'> & {
ownerUserId: string;
};
/** 确认单要展示的患者身份(与患者详情卡片同口径) */
interface PatientBrief {
......@@ -488,6 +747,78 @@ interface PatientBrief {
mrn: string | null;
gender: string | null;
age: number | null;
/// 上次到诊当天那份病历的接诊医生(与 lastVisitAt 同一次就诊)
lastVisitDoctor: string | null;
/// 主治 / 偏好医生 = 病历里出现频次最高的医生(patient_profiles 现成列)
preferredDoctor: string | null;
}
/**
* 🔴 **「重新排一版」的选人** —— 只换"挑谁",⛔ 不碰落人规则(见 propose 里的长注释)。
*
* 规则两句话:
* ① 池子里**所有无主的**都换进来(他们谁都能接,零代价);
* ② 剩下的名额用有专属的补满 N,**按客服轮着取**。
*
* ⚠️ ② 为什么不能直接取前 N 名:实测池子 165 人里 111 人属同一位客服,
* 按名次取会把名额全给她 —— 而她只剩 3 条余量,另两位客服还能接 3 条和 6 条却一个都没进这批。
* 轮着取保证每位客服的余量都够得着,这是「拟分 16 → 31」的全部来源。
* ⚠️ 「专属不在名册内」视同无主 —— 判据必须与 placeAgents 的 `ownerOf` **一模一样**,
* ⛔ 否则这里挑进来的人到那边照样卡住。
* ⭐ 返回时**按原名次排回去**:落人对顺序不敏感(每位客服的余量各算各的),
* 但卡片和待分配列表是按这个顺序显示的,乱序主管会以为系统在乱来。
*/
export function repickPlaceable<T extends { patientId: string }>(
ranked: T[],
dedicatedByPatient: Map<string, string>,
rosterIds: Set<string>,
target: number,
// ⚠️ `Omit` 不能省:入参可能自带 `selectionMode: 'rank'`(取数那层就是这么标的),
// 写成 `T & {...}` 会被交叉成 `'rank'`,这里重标的 `'swap'` 在类型上凭空消失。
): (Omit<T, 'selectionMode'> & { selectionMode: 'rank' | 'swap' })[] {
const ownerOf = (patientId: string) => {
const d = dedicatedByPatient.get(patientId);
return d && rosterIds.has(d) ? d : null;
};
const free: T[] = [];
const byOwner = new Map<string, T[]>();
for (const c of ranked) {
const o = ownerOf(c.patientId);
if (o == null) {
free.push(c);
continue;
}
const arr = byOwner.get(o) ?? [];
arr.push(c);
byOwner.set(o, arr);
}
const picked = new Set<string>();
for (const c of free.slice(0, target)) picked.add(c.patientId);
/// ⚠️ 客服顺序按 userId 定序 —— 确定性:同样的输入两次算出同一批人
const owners = [...byOwner.keys()].sort();
for (let round = 0; picked.size < target; round++) {
let added = 0;
for (const o of owners) {
if (picked.size >= target) break;
const c = byOwner.get(o)![round];
if (c) {
picked.add(c.patientId);
added++;
}
}
if (added === 0) break; // 池子取完了,凑不满 N 也就到此为止
}
/// 原本按名次就能进这批的算 `rank`,是被换进来的算 `swap` ——
/// ⛔ 别把 swap 混进 rank:rank 组是探索配额的对照基准,混了基准就废了。
const wouldRank = new Set(ranked.slice(0, target).map((c) => c.patientId));
return ranked
.filter((c) => picked.has(c.patientId))
.map((c) => ({
...c,
selectionMode: (wouldRank.has(c.patientId) ? 'rank' : 'swap') as 'rank' | 'swap',
}));
}
/**
......@@ -511,11 +842,11 @@ interface PatientBrief {
* 本地实测:池子 1,081 人里 755 人(70%)挂在同一个客服名下,不封顶他一批拿 248 条,
* 而 17 位在岗客服里有 10 位名下一个患者都没有,只能靠 119 个自由患者过活。
*
* ⚠️ 超出水位的专属患者**不是被丢掉**,是进第三趟改派(标 `SPREAD_OVERFLOW`:
* ⚠️ 超出水位的专属患者**不是被丢掉**,是进「待分配」交主管决策(见第三趟;原为自动改派 ——
* 关系还在、只是这轮没轮到)。⛔ 别把这一步理解成"抢客户"。
*/
export function placeAgents(
chosen: Array<{ planId: string; patientId: string; selectionMode: 'rank' | 'explore' }>,
chosen: Array<{ planId: string; patientId: string; selectionMode: 'rank' | 'explore' | 'swap' }>,
agents: AgentInfo[],
dedicatedByPatient: Map<string, string>,
/**
......@@ -525,7 +856,7 @@ export function placeAgents(
* ⚠️ 它**压过一切**(含专属):主管说了只给 5 条,第 6 个她的专属患者会被改派给别人。
*/
maxOf: (userId: string) => number = () => Infinity,
): { placed: PlacedItem[]; unplaced: number } {
): { placed: PlacedItem[]; unplaced: number; pending: PendingItem[] } {
const rosterIds = new Set(agents.map((a) => a.userId));
/// ⭐ 水位 = 该客服**分配后**手上有多少 = 在手 + 本批已给。三趟共用同一本账 ——
/// 分开算的话后面几趟会把已经灌高的人当成"还很空",专属大户被再灌一轮。
......@@ -544,12 +875,20 @@ export function placeAgents(
/// ⚠️ 向上取整 + 至少 1:否则 N 比人数还小时水位算成 0,第一趟一条专属都进不去,
/// 整批全靠改派 —— 那等于把专属关系整个关掉。
const inHandSum = agents.reduce((a, g) => a + g.inHand, 0);
/**
* 🔴 **`chosen` 必须就是"这一批要分的 N 个人"**,⛔ 不能是取数窗口(2026-08-06 实测炸过)。
*
* 那次「重新排一版」把整个窗口(165 行)丢了进来,水位当场从 ⌈(1001+50)/17⌉ = **62**
* 变成 ⌈(1001+165)/17⌉ = **69** —— 一批"专属排满"的人原地进了同一位客服手里
* (她 59 → 68,别人 58)。底线没破,但团队负载塌了,而且没人说得清 68 是怎么来的。
* ⇒ 现在重排改成**重挑这 N 个人**(见 repickPlaceable),落人这里一个字都不用改。
*/
const waterline =
agents.length > 0 ? Math.max(1, Math.ceil((inHandSum + chosen.length) / agents.length)) : 0;
const placed: PlacedItem[] = [];
const free: typeof chosen = []; // 无主 / 专属已离岗
const held: typeof chosen = []; // 有主,但专属这轮的份额已满
const held: typeof chosen = []; // 有主,但专属这轮的份额已满 → 第三趟进「待分配」
let unplaced = 0;
// ── 第一趟:专属命中(受目标水位封顶)────────────────────────
......@@ -611,28 +950,26 @@ export function placeAgents(
});
}
// ── 第三趟:无主的不够了 → 有主患者改派 ────────────────────────
// ⚠️ 标 `SPREAD_OVERFLOW` 而不是 SPREAD_NO_DEDICATED:**关系还在,只是这轮没轮到**。
// 两者混成一个值,日后算出来的"铺平完成率低"就分不清是策略问题还是人群问题。
for (const c of held) {
const who = lowest();
if (who == null) {
unplaced++;
continue;
}
take(who);
const owner = ownerOf(c.patientId);
placed.push({
planId: c.planId,
patientId: c.patientId,
assigneeUserId: who,
// 极端情况:水位法又转回了他自己的专属(他确实是最空的)→ 那就还是 dedicated
assignStrategy: who === owner ? AssignStrategy.DEDICATED : AssignStrategy.SPREAD_OVERFLOW,
selectionMode: c.selectionMode,
});
}
// ── 第三趟:**不再自动改派** → 交给主管决策(2026-08-06 产品定)──────────
//
// 🔴 原来这一趟会把这些人分给手上最空的客服(标 SPREAD_OVERFLOW),为的是把在手量铺平。
// 产品判定:**把患者从他的专属客服手里挪走是关系层面的决定,助手没资格替主管做**。
// (主管自己可以 —— 他在确认单上拖一下就是,那条会标 MANUAL。)
//
// ⇒ 现在它们进 `pending`(待分配),在确认单上**单列一组**,由主管处置。
// ⚠️ 结果是这一批**可能不满 N、团队也不齐平** —— 那是**刻意的**:
// 宁可少分几个,也不悄悄动别人的客户。⛔ 别为了"填满 N"把这一趟加回来。
// ⚠️ `unplaced` 不接管这些人:那个数说的是"名册空/全被精调成 0"这种系统性无处可放,
// 而这里是**刻意留给人决策**的。混在一起主管就分不清该找谁。
const pending: PendingItem[] = held.map((c) => ({
planId: c.planId,
patientId: c.patientId,
// ownerOf 在这里恒不为 null:无主的走了第二趟,进不到 held
ownerUserId: ownerOf(c.patientId)!,
selectionMode: c.selectionMode,
}));
return { placed, unplaced };
return { placed, unplaced, pending };
}
/**
......@@ -686,8 +1023,14 @@ function sanitizeOverrides(
}
/** 沿用的那次分配是哪天(只给日期,精确到秒对主管没意义) */
/**
* 日期 → **人话**(「8 月 6 日」),⛔ 不是 ISO。
* 主管看到「沿用您 2026-08-06 那次」会觉得是机器日志;而这句是说给他听的。
*/
function ymd(iso: string): string {
return iso.slice(0, 10);
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso.slice(0, 10);
return `${d.getMonth() + 1}${d.getDate()} 日`;
}
/**
......@@ -721,11 +1064,12 @@ function basisNote(x: {
const top = x.loads.find((l) => l.after === hi);
const how =
x.placed > 0
? `${x.placed} 人分给 ${x.loads.length} 位客服:**有专属客服的先回到自己人手上**,` +
`剩下的**谁手上活少先给谁**。分完后每人手里 ` +
(lo === hi ? `${hi} 条` : `${lo}~${hi} 条(最多的是${top?.name})`) +
(x.inHandTotal > 0 ? `(他们原本还有 ${x.inHandTotal} 条在手)` : '') +
`,${x.expiresInDays} 天没动会自动退回池子。`
? // ⚠️ ⛔ 别再套两层括号(原文是「…(最多的是康慧捧)(他们原本还有 951 条在手)」)——
// 括号套括号在窄面板里读起来像乱码,而"原本多少条在手"卡片上每一行都写着。
`**已排好 ${x.placed} 人 · ${x.loads.length} 位客服**\n` +
`有专属的先回自己人手上,其余谁手上活少先给谁。分完每人 ` +
(lo === hi ? `${hi} 条` : `${lo}~${hi} 条,最多的是${top?.name}`) +
`;${x.expiresInDays} 天没动自动退回。`
: '';
// ⚠️ 精调必须**点名**:主管看到「李莉只有 5 条」要立刻知道那是他自己设的,不是算错了
const tuned = x.overrides.length
......@@ -779,6 +1123,11 @@ function emptyProposal(
placed: 0,
unplaced: 0,
items: [],
pending: [],
pendingNote: '',
refillNote: '',
canRefill: false,
poolOwnership: null,
byAgent: [],
// 空提案里所有在岗的人都"没分到"
skippedAgents: agents.map((a) => ({ userId: a.userId, name: a.name, inHand: a.inHand })),
......
......@@ -9,13 +9,17 @@ import { CurrentUser } from '../../common/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../../common/decorators/current-user.decorator';
import { PlanAssignmentService } from './plan-assignment.service';
import { AgentRosterService } from './agent-roster.service';
import { AssignmentProposalService } from './assignment-proposal.service';
import {
CreateAssignmentRequestDto,
CreateAssignmentResponseDto,
ListAssignmentsResponseDto,
AssignmentDetailResponseDto,
ListAgentsResponseDto,
RefillProposalRequestDto,
RevokeAssignmentResponseDto,
SetAssignmentBenefitRequestDto,
SetAssignmentBenefitResponseDto,
} from './dto/plan-assignment.dto';
/**
......@@ -36,6 +40,7 @@ export class AssignmentController {
constructor(
private readonly assignments: PlanAssignmentService,
private readonly roster: AgentRosterService,
private readonly proposals: AssignmentProposalService,
) {}
/**
......@@ -119,6 +124,70 @@ export class AssignmentController {
return this.assignments.revoke(scope, { userId: user.sub, permissions: user.permissions }, id);
}
/**
* 给**已确认**的批次补挂 / 改 / 撤福利。
*
* ⚠️ 由来(2026-08-06 实测):福利原本只随 `create` 落库。主管确认之后再说
* 「这批带上八折」,助手把 `set_benefit` 推给卡片,卡片改了自己的 state、亮了角标、
* 回了句「已更新」—— DB 一个字没变,话术里也没有福利。界面说做了、实际没做。
* 补这条路是为了让"确认后再想起福利"这件**很常见**的事真的能做成,
* 而不是逼主管撤销整批重分(撤销还有 30 分钟窗口和"已打开的不收"两道限制)。
*/
/**
* 🔴 **重新排一版(尽量排满)** —— 卡片上那个按钮。
*
* ⚠️ 这条路**不经过助手**:主管点的是卡片上的按钮,不是说一句话。
* 走 HTTP 直接换掉那张卡,不用等模型再跑一轮(它也没有别的事可做)。
* ⚠️ 纯只读,和 propose 一样 —— **一个字都没写库**。
*/
@Post('propose/refill')
@RequirePermission(Permission.PLAN_DISPATCH)
@ApiOperation({
summary: '重新排一版:遇到专属排满的就跳过,从池子里往后取,尽量凑满 N',
description:
'⚠️ **不改变"不动别人客户"这条底线** —— 只是换一批**专属没排满 / 无主**的人来凑,' +
'一条专属关系都不动。代价是这批人整体排名往后走(产品判定:批内名次对主管没有意义,' +
'这批人本来就共享同一组特征)。',
})
async refill(
@TenantScope() scope: TenantScopeContext,
@Body() body: RefillProposalRequestDto,
) {
return this.proposals.propose(scope, {
clinicId: body.clinicId,
...(body.potentialTreatment ? { potentialTreatment: body.potentialTreatment } : {}),
...(body.temperature ? { temperature: body.temperature as never } : {}),
...(body.personaTags ? { personaTags: body.personaTags } : {}),
...(body.targetCount ? { targetCount: body.targetCount } : {}),
...(body.expiresInDays ? { expiresInDays: body.expiresInDays } : {}),
preferPlaceable: true,
});
}
@Post(':id/benefit')
@RequirePermission(Permission.PLAN_DISPATCH)
@ZodResponse({ status: 200, type: SetAssignmentBenefitResponseDto })
@ApiOperation({
summary: '改本批福利(确认后仍可改)—— 空串 = 撤掉',
description:
'福利是**前向**的:只影响此后生成的话术,所以**不设时间窗**(与撤销不同)。' +
'⚠️ 改动会作废本批的话术缓存(懒重生成);已被客服打开过的条数会如实回报 —— ' +
'那些人手里拿的是旧话术,补挂的福利他看不到。',
})
async setBenefit(
@TenantScope() scope: TenantScopeContext,
@CurrentUser() user: AuthenticatedUser,
@Param('id') id: string,
@Body() body: SetAssignmentBenefitRequestDto,
) {
return this.assignments.setBenefit(
scope,
{ userId: user.sub, permissions: user.permissions },
id,
body.text,
);
}
@Get(':id')
@RequirePermission(Permission.PLAN_DISPATCH)
@ZodResponse({ status: 200, type: AssignmentDetailResponseDto })
......
......@@ -6,6 +6,9 @@ import {
ListAssignmentsResponseSchema,
ListAgentsResponseSchema,
RevokeAssignmentResponseSchema,
RefillProposalRequestSchema,
SetAssignmentBenefitRequestSchema,
SetAssignmentBenefitResponseSchema,
} from '@pac/types';
export class CreateAssignmentRequestDto extends createZodDto(CreateAssignmentRequestSchema) {}
......@@ -14,3 +17,6 @@ export class ListAssignmentsResponseDto extends createZodDto(ListAssignmentsResp
export class AssignmentDetailResponseDto extends createZodDto(AssignmentDetailResponseSchema) {}
export class ListAgentsResponseDto extends createZodDto(ListAgentsResponseSchema) {}
export class RevokeAssignmentResponseDto extends createZodDto(RevokeAssignmentResponseSchema) {}
export class RefillProposalRequestDto extends createZodDto(RefillProposalRequestSchema) {}
export class SetAssignmentBenefitRequestDto extends createZodDto(SetAssignmentBenefitRequestSchema) {}
export class SetAssignmentBenefitResponseDto extends createZodDto(SetAssignmentBenefitResponseSchema) {}
......@@ -26,6 +26,7 @@ import {
type ExecutionOutcome,
type ReleaseReason,
type RevokeAssignmentResponse,
type SetAssignmentBenefitResponse,
} from '@pac/types';
import { PrismaService } from '../../prisma/prisma.service';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';
......@@ -124,7 +125,11 @@ function mergeStats(
};
}
function readBenefitText(attributes: unknown): string | null {
/**
* 读批次福利文案。⚠️ 仓库里**曾有两份**(本处 + plan-script.orchestrator,返回类型还不一样)。
* 2026-08-05 召回简报也要用,故本份导出复用 —— ⛔ 别再抄第四份。
*/
export function readBenefitText(attributes: unknown): string | null {
const a = attributes as { benefit?: { text?: string } } | null | undefined;
const t = a?.benefit?.text;
return typeof t === 'string' && t.trim() ? t : null;
......@@ -974,6 +979,115 @@ export class PlanAssignmentService {
}
/**
* 给**已确认**的批次补挂 / 改 / 撤福利(T4)。
*
* ── 为什么需要这条路(2026-08-06 实测的事故)──────────────────────
* 福利原本**只在确认那一刻**随 `create` 落库。主管点完「确认分配」才想起来
* 「这批带上八折」时,助手照样调 `edit_assignment_sheet` 把 `set_benefit` 推给卡片,
* 卡片改了自己的 `benefit` state、亮出绿色角标、还回了一句「本批福利设为…」——
* 而那个 state **只有 `confirm()` 会读**,批次早就落库了,DB 里一个字没变。
* 于是界面说带上了、话术里没有,主管无从察觉(违 T14)。
*
* ⭐ 选择"补写"而不是"确认后拒绝一切修改":福利是**前向**的东西,
* 它只影响此后生成的话术,补挂在业务上完全成立;逼主管撤销整批重分才是荒谬的
* (撤销还有 30 分钟窗口和"已打开的不收"两道限制,代价远大于收益)。
*
* ⚠️ **不设时间窗**(与 revoke 不同):revoke 限时是因为它要**收回**已经发出去的东西,
* 越晚越危险;改福利只影响还没生成的话术,越晚只是越没用,不会伤到谁。
*
* ⚠️ 返回里必须**如实报**已被打开过的条数:那些客服手里拿的是旧话术,
* 补挂的福利他看不到。⛔ 不许只回一句「已更新」—— 那正是这次事故的形状。
*/
async setBenefit(
scope: TenantScopeContext,
actor: DispatchActor,
ref: string,
text: string,
): Promise<SetAssignmentBenefitResponse> {
requirePermission(actor, Permission.PLAN_DISPATCH);
rejectSyntheticIdentity(actor);
const assignmentId = await this.resolveAssignmentId(scope, ref);
const head = await this.prisma.planAssignment.findFirst({
where: {
id: assignmentId,
hostId: scope.hostId,
tenantId: scope.tenantId,
...(scope.clinicIds.length ? { clinicId: { in: scope.clinicIds } } : {}),
},
});
if (!head) throw new NotFoundException(`批次 ${assignmentId} 不存在`);
// ⛔ 撤销过的批次不许再挂福利:那批人已经回池子了,福利挂上去谁也读不到,
// 只会在批次列表里留下一个"有福利"的假象。
if (head.status === 'revoked') {
throw new BadRequestException('该批次已撤销,不能再改福利 —— 请重新分一批并在确认时带上。');
}
// 授权与 revoke 同一条:自己分的,或有看全池的权限(⛔ 别逐条判归属,那是单条路径的闸)
if (head.createdBy !== actor.userId && !actor.permissions.includes(Permission.PLAN_VIEW_ALL)) {
throw new ForbiddenException('只能修改自己发起的批次');
}
const next = text.trim();
const prev = readBenefitText(head.attributes);
if (next === (prev ?? '')) {
return { assignmentId, benefitText: next || null, scriptsInvalidated: 0, touched: 0, note: '福利没有变化,本次没有任何改动。' };
}
const plans = await this.prisma.followupPlan.findMany({
where: { assignmentId, supersededAt: null },
select: { id: true },
});
const planIds = plans.map((p) => p.id);
// 已被客服打开过的条数 —— 判据与 revoke 同源(view 事件),⛔ 别改用 plan_executions
const touched = planIds.length
? (
await this.prisma.planEventLog.groupBy({
by: ['planId'],
where: { planId: { in: planIds }, event: PlanEventType.VIEW },
})
).length
: 0;
let scriptsInvalidated = 0;
await this.prisma.$transaction(async (tx) => {
const attributes = {
...((head.attributes as Record<string, unknown> | null) ?? {}),
// 空串 = 撤掉福利。⛔ 别落一个 `{text:''}` 的空壳,读侧 readBenefitText 会把它当"有福利"
...(next ? { benefit: { text: next } } : {}),
} as Record<string, unknown>;
if (!next) delete attributes.benefit;
await tx.planAssignment.update({
where: { id: assignmentId },
data: { attributes: attributes as Prisma.InputJsonObject },
});
// ⭐ 与 create / revoke 同一条规矩:福利变了就作废话术缓存,否则福利段永远不会出现
// (或者更糟:撤掉的福利还被念出去)。只作废不重生成,见 invalidateScripts。
if (planIds.length) {
const r = await tx.planScript.deleteMany({ where: { planId: { in: planIds } } });
scriptsInvalidated = r.count;
if (r.count > 0) {
this.logger.log(
`话术缓存作废 ${r.count} 条(批次 ${assignmentId.slice(0, 8)} 改了福利)—— 下次打开详情页时重新生成`,
);
}
}
});
this.logger.log(
`批次 ${assignmentId} 福利:${prev ?? '(无)'} ${next || '(撤掉)'},操作人=${actor.userId}`,
);
const note =
(next ? `本批福利已设为「${next}」。` : '已撤掉本批福利。') +
(scriptsInvalidated > 0
? `${scriptsInvalidated} 条话术缓存已作废,客服下次打开详情页会重新生成、${next ? '带上福利' : '不再带福利'}`
: '') +
(touched > 0
? `⚠️ 其中 ${touched} 条客服**已经打开过**,他手里那份话术是旧的 —— 需要的话请另行知会。`
: '');
return { assignmentId, benefitText: next || null, scriptsInvalidated, touched, note };
}
/**
* 撤销整批 —— 把还没被动过的单收回池子。
*
* ⚠️⚠️ **撤销 ≠ 退回**(T21),两个不同的人做的两件事:
......
import { Test } from '@nestjs/testing';
import { Permission } from '@pac/types';
import { PlanAssignmentService } from '../src/modules/plan/plan-assignment.service';
import { PrismaService } from '../src/prisma/prisma.service';
/**
* 「确认之后再补挂福利」回归。
*
* ── 由来(2026-08-06 实测的事故)──────────────────────────────────
* 福利原本**只在确认那一刻**随 `create` 落库。主管点完「确认分配」才想起来
* 「这批带上八折」时,助手照样把 `set_benefit` 推给确认单卡片 —— 卡片改了自己的
* `benefit` state、亮出绿色角标、还往对话里回了一句「本批福利设为…」,
* 而那个 state **只有 `confirm()` 会读**,批次早就落库了,DB 里一个字没变。
* 界面说做了、话术里没有,主管无从察觉(违 T14「口径对数」)。
*
* ⚠️ 这个 bug 的形状值得记:**没有任何报错**,而且"失败路径"看起来比成功路径还顺 ——
* 角标亮了、话也回了。只有去查库或等客服念话术才会发现。
*
* 这里锁四件做错了不会报错的事:
* ① 真的写进 attributes.benefit(不是只改界面)
* ② 福利变了必须**作废话术缓存** —— 否则福利段永远不会出现 / 撤掉的福利还被念出去
* ③ 空串 = 撤掉,⛔ 不许落一个 `{text:''}` 的空壳(读侧会把它当"有福利")
* ④ 已被客服**打开过**的条数要如实回报 —— 那些人手里是旧话术
*/
const BATCH = 'c02e1b80-1111-4222-8333-444455556666';
const SCOPE = {
hostId: 'h1', tenantId: 't1', sourceUnits: [] as string[], clinicIds: [] as string[], userId: 'leader-1',
} as never;
const LEADER = { userId: 'leader-1', permissions: [Permission.PLAN_DISPATCH] };
function makePrisma(opts: {
attributes?: unknown;
status?: string;
createdBy?: string;
planIds?: string[];
viewedPlanIds?: string[];
}) {
const headUpdates: Array<Record<string, unknown>> = [];
const deletedScriptPlanIds: string[][] = [];
const planIds = opts.planIds ?? ['p1', 'p2', 'p3'];
const tx = {
planAssignment: {
update: jest.fn(async ({ data }: { data: Record<string, unknown> }) => {
headUpdates.push(data);
return {};
}),
},
planScript: {
deleteMany: jest.fn(async (a: { where: { planId: { in: string[] } } }) => {
deletedScriptPlanIds.push(a.where.planId.in);
return { count: a.where.planId.in.length };
}),
},
};
const prisma = {
planAssignment: {
findFirst: jest.fn(async () => ({
id: BATCH, hostId: 'h1', tenantId: 't1', clinicId: 'c1',
createdBy: opts.createdBy ?? 'leader-1',
status: opts.status ?? 'confirmed',
attributes: opts.attributes ?? null,
createdAt: new Date(),
})),
},
followupPlan: { findMany: jest.fn(async () => planIds.map((id) => ({ id }))) },
planEventLog: {
groupBy: jest.fn(async () => (opts.viewedPlanIds ?? []).map((planId) => ({ planId }))),
},
$transaction: jest.fn(async (fn: (t: typeof tx) => Promise<unknown>) => fn(tx)),
} as unknown as PrismaService;
return { prisma, headUpdates, deletedScriptPlanIds };
}
async function build(prisma: PrismaService): Promise<PlanAssignmentService> {
const mod = await Test.createTestingModule({
providers: [PlanAssignmentService, { provide: PrismaService, useValue: prisma }],
})
.useMocker(() => ({}))
.compile();
return mod.get(PlanAssignmentService);
}
describe('确认后补挂福利 —— 必须真的写库', () => {
test('⭐⭐ 红线①:福利写进 attributes.benefit.text(界面亮角标不算数)', async () => {
const { prisma, headUpdates } = makePrisma({});
const svc = await build(prisma);
const r = await svc.setBenefit(SCOPE, LEADER, BATCH, '看牙打八折优惠');
expect(r.benefitText).toBe('看牙打八折优惠');
expect(headUpdates).toHaveLength(1);
expect(headUpdates[0]!.attributes).toEqual({ benefit: { text: '看牙打八折优惠' } });
});
test('⭐⭐ 红线②:福利变了必须作废本批话术缓存,否则福利段永远不会出现', async () => {
const { prisma, deletedScriptPlanIds } = makePrisma({});
const svc = await build(prisma);
const r = await svc.setBenefit(SCOPE, LEADER, BATCH, '老客户复查免挂号费');
expect(deletedScriptPlanIds).toEqual([['p1', 'p2', 'p3']]);
expect(r.scriptsInvalidated).toBe(3);
expect(r.note).toContain('重新生成');
});
test('⭐ 红线③:空串 = 撤掉福利,⛔ 不许留一个 {text:""} 的空壳', async () => {
const { prisma, headUpdates } = makePrisma({ attributes: { benefit: { text: '八折' } } });
const svc = await build(prisma);
const r = await svc.setBenefit(SCOPE, LEADER, BATCH, '');
expect(r.benefitText).toBeNull();
// 读侧 readBenefitText 只看 benefit.text 存不存在 —— 留个空壳会被当成"有福利"
expect(headUpdates[0]!.attributes).not.toHaveProperty('benefit');
expect(r.note).toContain('不再带福利');
});
test('⭐⭐ 红线④:已被客服打开过的条数要如实回报(他手里是旧话术)', async () => {
const { prisma } = makePrisma({ viewedPlanIds: ['p1', 'p2'] });
const svc = await build(prisma);
const r = await svc.setBenefit(SCOPE, LEADER, BATCH, '八折');
expect(r.touched).toBe(2);
expect(r.note).toContain('已经打开过');
});
test('attributes 里原有的别的键要留着(⛔ 别整个覆盖掉)', async () => {
const { prisma, headUpdates } = makePrisma({ attributes: { note: 'x', benefit: { text: '旧' } } });
const svc = await build(prisma);
await svc.setBenefit(SCOPE, LEADER, BATCH, '新');
expect(headUpdates[0]!.attributes).toEqual({ note: 'x', benefit: { text: '新' } });
});
test('值没变 → 不写库、不作废话术(⛔ 别让重复指令白白炸掉一批缓存)', async () => {
const { prisma, headUpdates, deletedScriptPlanIds } = makePrisma({
attributes: { benefit: { text: '八折' } },
});
const svc = await build(prisma);
const r = await svc.setBenefit(SCOPE, LEADER, BATCH, '八折');
expect(headUpdates).toHaveLength(0);
expect(deletedScriptPlanIds).toHaveLength(0);
expect(r.note).toContain('没有变化');
});
});
describe('确认后补挂福利 —— 闸', () => {
test('已撤销的批次不许再挂福利(那批人已经回池子了,挂上去谁也读不到)', async () => {
const { prisma } = makePrisma({ status: 'revoked' });
const svc = await build(prisma);
await expect(svc.setBenefit(SCOPE, LEADER, BATCH, '八折')).rejects.toThrow('已撤销');
});
test('别人发起的批次改不了(与撤销同一条授权:自己分的,或有看全池权限)', async () => {
const { prisma } = makePrisma({ createdBy: 'other-leader' });
const svc = await build(prisma);
await expect(svc.setBenefit(SCOPE, LEADER, BATCH, '八折')).rejects.toThrow('自己发起');
});
test('有 PLAN_VIEW_ALL 的人可以改别人的批次', async () => {
const { prisma } = makePrisma({ createdBy: 'other-leader' });
const svc = await build(prisma);
const r = await svc.setBenefit(
SCOPE,
{ userId: 'leader-1', permissions: [Permission.PLAN_DISPATCH, Permission.PLAN_VIEW_ALL] },
BATCH,
'八折',
);
expect(r.benefitText).toBe('八折');
});
test('没有 PLAN_DISPATCH 权限直接拒', async () => {
const { prisma } = makePrisma({});
const svc = await build(prisma);
await expect(
svc.setBenefit(SCOPE, { userId: 'leader-1', permissions: [] }, BATCH, '八折'),
).rejects.toThrow();
});
test('⚠️ 不设时间窗 —— 与撤销不同:改福利只影响之后生成的话术,越晚只是越没用,不会伤到谁', async () => {
const { prisma } = makePrisma({});
const svc = await build(prisma);
// makePrisma 的 createdAt 是"刚刚",这里直接验证服务端没有读 createdAt 做拒绝
const r = await svc.setBenefit(SCOPE, LEADER, BATCH, '八折');
expect(r.benefitText).toBe('八折');
});
});
import { AssignStrategy, type AgentInfo } from '@pac/types';
import { placeAgents } from '../src/modules/plan/assignment-proposal.service';
import { placeAgents, repickPlaceable } from '../src/modules/plan/assignment-proposal.service';
/**
* 「专属优先 / 溢出铺平」落人算法回归
* 落人算法回归 —— **专属优先 → 无主补空 → 待分配**
*
* 这是主管每天都要看的那张确认单背后的算法 —— 它错了不会报错,
* 只会让主管把单分给不该分的人,而他要到客服抱怨时才知道。
*
* 🔴🔴 **2026-08-06 产品改判:取消「有主改派」。**
* 原来第三趟会把专属客服已排满的患者**自动改派**给手上最空的人(标 SPREAD_OVERFLOW),
* 为的是把团队在手量铺平。现在判定:**把患者从他的专属客服手里挪走是关系层面的决定,
* 助手没资格替主管做** —— 那些人改为进 `pending`(待分配),在确认单上单列一组,
* 主管拖给谁才算数(标 MANUAL)。
* ⇒ 代价是**这一批可能不满 N、团队也不齐平**,那是刻意的取舍。
* ⛔ 下面几条测试就是防止有人"为了填满 N"把自动改派加回来。
*/
function agent(userId: string, inHand: number, name = userId): AgentInfo {
......@@ -39,32 +47,48 @@ describe('placeAgents —— 专属优先', () => {
});
/**
* 🔴🔴 **专属受目标水位封顶,超出的改派**(2026-08-03 产品定:又满又平)
* 🔴🔴 **专属受目标水位封顶;超出的进「待分配」,⛔ 不再自动改派**
*
* 不封顶的话专属集中的诊所会一个人吃掉整批 —— 本地实测:池子 70% 挂在同一个人名下,
* 他一批拿 248 条,而 17 位在岗里有 10 位名下一个患者都没有
* 封顶仍然要:不封顶的话专属集中的诊所会一个人吃掉整批(本地实测池子 70% 挂在同一人名下)。
* 但**超出的那部分不再塞给别人** —— 交给主管决定
*/
test('🔴 3 条全是 a 的专属、两人在岗 → a 拿 2(水位),第 3 条改派给 b 并标 overflow', () => {
test('🔴🔴 3 条全是 a 的专属、两人在岗 → a 拿 2(水位),第 3 条进待分配而**不是**给 b', () => {
const chosen = pick(3);
const dedicated = new Map(chosen.map((c) => [c.patientId, 'a']));
const { placed, unplaced } = placeAgents(chosen, [agent('a', 0), agent('b', 0)], dedicated);
const { placed, unplaced, pending } = placeAgents(
chosen,
[agent('a', 0), agent('b', 0)],
dedicated,
);
expect(unplaced).toBe(0);
expect(placed.filter((p) => p.assigneeUserId === 'a')).toHaveLength(2);
// ⚠️ 改派的那条标 spread_overflow:**关系还在,只是这轮没轮到**
expect(placed.find((p) => p.assigneeUserId === 'b')!.assignStrategy).toBe(
AssignStrategy.SPREAD_OVERFLOW,
);
// ⛔ b 一条都不该拿到 —— 那是别人的客户
expect(placed.filter((p) => p.assigneeUserId === 'b')).toHaveLength(0);
expect(pending).toHaveLength(1);
// 待分配必须带**是谁的专属**,否则主管无从判断要不要挪
expect(pending[0]!.ownerUserId).toBe('a');
});
test('🔴 精调名额压过专属:a 只给 2 条,她剩下的专属患者改派给 b(⛔ 不丢)', () => {
test('🔴 精调名额压过专属:a 只给 2 条,她剩下的专属患者进待分配(⛔ 既不丢也不改派)', () => {
const chosen = pick(5);
const dedicated = new Map(chosen.map((c) => [c.patientId, 'a']));
const { placed, unplaced } = placeAgents(chosen, [agent('a', 0), agent('b', 0)], dedicated, (u) =>
u === 'a' ? 2 : Infinity,
const { placed, unplaced, pending } = placeAgents(
chosen,
[agent('a', 0), agent('b', 0)],
dedicated,
(u) => (u === 'a' ? 2 : Infinity),
);
expect(unplaced).toBe(0);
expect(placed.filter((p) => p.assigneeUserId === 'a')).toHaveLength(2);
expect(placed.filter((p) => p.assigneeUserId === 'b')).toHaveLength(3);
expect(placed.filter((p) => p.assigneeUserId === 'b')).toHaveLength(0);
expect(pending).toHaveLength(3);
});
test('⛔ 算法**再也不产出** SPREAD_OVERFLOW(它只保留给历史数据)', () => {
const chosen = pick(9);
const dedicated = new Map(chosen.map((c) => [c.patientId, 'a']));
const { placed } = placeAgents(chosen, [agent('a', 0), agent('b', 0), agent('c', 0)], dedicated);
expect(placed.some((p) => p.assignStrategy === AssignStrategy.SPREAD_OVERFLOW)).toBe(false);
});
/**
......@@ -101,21 +125,21 @@ describe('placeAgents —— 专属优先', () => {
expect(placed.every((p) => p.assignStrategy === AssignStrategy.SPREAD_NO_DEDICATED)).toBe(true);
});
test('⭐⭐ 三种归属标记各归各的(dedicated / no_dedicated / overflow)', () => {
test('⭐⭐ 两种归属标记各归各的 + 超出的进待分配', () => {
// 6 条:4 条是 a 的专属,2 条无主;两人在岗,水位 = 3
const chosen = pick(6);
const dedicated = new Map(chosen.slice(0, 4).map((c) => [c.patientId, 'a']));
const { placed } = placeAgents(chosen, [agent('a', 0), agent('b', 0)], dedicated);
const { placed, pending } = placeAgents(chosen, [agent('a', 0), agent('b', 0)], dedicated);
const byStrategy = placed.reduce<Record<string, number>>((m, p) => {
m[p.assignStrategy] = (m[p.assignStrategy] ?? 0) + 1;
return m;
}, {});
expect(byStrategy[AssignStrategy.DEDICATED]).toBe(3); // a 到水位 3
expect(byStrategy[AssignStrategy.SPREAD_NO_DEDICATED]).toBe(2); // 无主的给 b
expect(byStrategy[AssignStrategy.SPREAD_OVERFLOW]).toBe(1); // 第 4 条专属改派给 b
// 又满又平
expect(placed.filter((p) => p.assigneeUserId === 'a')).toHaveLength(3);
expect(placed.filter((p) => p.assigneeUserId === 'b')).toHaveLength(3);
expect(byStrategy[AssignStrategy.SPREAD_OVERFLOW]).toBeUndefined(); // ⛔ 不再有改派
expect(pending).toHaveLength(1); // a 的第 4 条 → 待分配
// ⚠️ 不再"又满又平":b 只有 2 条,而那第 4 条**刻意**没塞给他
expect(placed.filter((p) => p.assigneeUserId === 'b')).toHaveLength(2);
});
});
......@@ -152,21 +176,26 @@ describe('placeAgents —— 溢出水位法', () => {
});
/**
* 🔴🔴 **又满又平**:全是同一个人的专属,照样铺平到所有人头上,一条不丢。
* 这正是"专属集中的诊所"那个场景 —— 池子 70% 挂在一个人名下。
* 🔴🔴 **专属集中的诊所:宁可少分,也不动别人的客户**(2026-08-06 改判)。
*
* 这正是那个场景 —— 池子 70% 挂在一个人名下。原来会铺平到所有人头上(各 3 条),
* 现在 a 拿到水位为止,其余 6 条全部进待分配等主管决定。
* ⛔ 谁要把这一条改回 [3,3,3],等于把自动改派又加回来了。
*/
test('🔴 9 条全是 a 的专属、3 人在岗 → 各 3 条(满且平)', () => {
test('🔴🔴 9 条全是 a 的专属、3 人在岗 → a 拿 3(水位),另 6 条进待分配', () => {
const chosen = pick(9);
const dedicated = new Map(chosen.map((c) => [c.patientId, 'a']));
const { placed, unplaced } = placeAgents(
const { placed, unplaced, pending } = placeAgents(
chosen,
[agent('a', 0), agent('b', 0), agent('c', 0)],
dedicated,
);
expect(unplaced).toBe(0);
expect(['a', 'b', 'c'].map((u) => placed.filter((p) => p.assigneeUserId === u).length)).toEqual([
3, 3, 3,
3, 0, 0,
]);
expect(pending).toHaveLength(6);
expect(pending.every((p) => p.ownerUserId === 'a')).toBe(true);
});
test('⭐⭐ 精调成 0 名额的人**一条都不给**', () => {
......@@ -202,6 +231,135 @@ describe('placeAgents —— 溢出水位法', () => {
});
});
/**
* 🔴🔴 **「重新排一版」= 重挑这 N 个人,⛔ 不是改落人规则。**
*
* 2026-08-06 一天之内在同一个地方栽了两次,两次都是**去动落人**:
* ① 预先下发「顶替名单」→ 名单只能从取数窗口里挑,窗口比池子小,
* 池子 19 位无主、窗口里只落进 6 位,界面只敢说"顶 3 位"。
* ② 把整个窗口丢给落人 + `stopAt` 跳过 → 水位按 `chosen.length` 算,被窗口撑大
* (⌈(1001+50)/17⌉=62 → ⌈(1001+165)/17⌉=69),那批"专属排满"的人
* 原地进了**同一位客服**手里(她 59 → 68,别人 58)。底线没破,但负载塌了。
*
* ⇒ 正确做法(产品原话:「为什么要纠结排名,50 个里 19 位无主 31 位有主重新定确认单不行吗」):
* **只换"挑谁"**——池子里无主的全换进来,其余按优先级用有专属的补满 N,
* 然后走**完全一样**的三趟。排不进的照样进待分配,卡片形状一个字不变。
*/
describe('repickPlaceable —— 重新排一版:只换挑谁', () => {
/**
* 复刻实测那个池子的形状:165 位候选,146 位属三位客服(111 / 18 / 16),19 位无主。
* ⚠️ 故意把**大户的人全排在最前面** —— 这是最能暴露"直接取前 N 名"的顺序。
*/
const pool = () => {
const ranked = pick(165);
const dedicated = new Map<string, string>();
for (let i = 0; i < 111; i++) dedicated.set(`p-pat-${i}`, 'kang');
for (let i = 111; i < 129; i++) dedicated.set(`p-pat-${i}`, 'li');
for (let i = 129; i < 145; i++) dedicated.set(`p-pat-${i}`, 'jin');
// 145..163 共 19 位无主;164 也无主 —— 凑够 165 行
return { ranked, dedicated };
};
const roster = new Set(['kang', 'li', 'jin', ...Array.from({ length: 14 }, (_, i) => `x${i}`)]);
const N = 50;
test('🔴 池子里的无主患者**全部**换进这一批', () => {
const { ranked, dedicated } = pool();
const chosen = repickPlaceable(ranked, dedicated, roster, N);
expect(chosen).toHaveLength(N);
const free = chosen.filter((c) => !dedicated.has(c.patientId));
expect(free).toHaveLength(20); // 145..164
});
test('🔴🔴 有专属的那部分**按客服轮着取** —— ⛔ 直取前 N 名会把名额全给大户', () => {
const { ranked, dedicated } = pool();
const chosen = repickPlaceable(ranked, dedicated, roster, N);
const n = (u: string) => chosen.filter((c) => dedicated.get(c.patientId) === u).length;
// 30 个有主名额轮给 3 位客服 → 各 10 个。直取前 30 名的话 kang 会独吞 30、另两位 0
expect(n('kang')).toBe(10);
expect(n('li')).toBe(10);
expect(n('jin')).toBe(10);
});
test('⭐ 这才是「拟分 16 → 31」的来源:换过之后每位客服的余量都够得着', () => {
const { ranked, dedicated } = pool();
const agents = [
agent('kang', 59),
agent('li', 59),
agent('jin', 56),
...Array.from({ length: 14 }, (_, i) => agent(`x${i}`, 56)),
];
// 水位 ⌈(958+50)/17⌉ = 60 → kang 还能接 1、li 1、jin 4,合计 6
const chosen = repickPlaceable(ranked, dedicated, roster, N);
const { placed, pending } = placeAgents(chosen, agents, dedicated);
const ded = placed.filter((p) => p.assignStrategy === AssignStrategy.DEDICATED);
const free = placed.filter((p) => p.assignStrategy === AssignStrategy.SPREAD_NO_DEDICATED);
expect(ded).toHaveLength(6); // 1 + 1 + 4 —— 三位客服的余量都用上了
expect(free).toHaveLength(20);
expect(placed).toHaveLength(26);
// 排不进的照样进待分配 —— ⛔ 不为了填满 N 做任何额外动作
expect(pending).toHaveLength(N - 26);
});
test('🔴🔴 底线:落人一个字没改 —— 每条都进本人的专属客服,⛔ 没有改派', () => {
const { ranked, dedicated } = pool();
const agents = [
agent('kang', 59),
agent('li', 59),
agent('jin', 56),
...Array.from({ length: 14 }, (_, i) => agent(`x${i}`, 56)),
];
const chosen = repickPlaceable(ranked, dedicated, roster, N);
const { placed } = placeAgents(chosen, agents, dedicated);
for (const p of placed) {
const owner = dedicated.get(p.patientId);
if (owner) expect(p.assigneeUserId).toBe(owner);
}
});
test('⭐ 水位不受影响 —— chosen 恒等于本批 N,⛔ 不再是取数窗口', () => {
const { ranked, dedicated } = pool();
expect(repickPlaceable(ranked, dedicated, roster, N)).toHaveLength(N);
expect(repickPlaceable(ranked, dedicated, roster, 20)).toHaveLength(20);
});
test('⭐ 被换进来的标 swap,原本按名次就能进的仍是 rank(探索配额的基准不能混)', () => {
const { ranked, dedicated } = pool();
const chosen = repickPlaceable(ranked, dedicated, roster, N);
// 前 50 名全是 kang 的人,所以换进来的无主/li/jin 都该是 swap
const rank = chosen.filter((c) => c.selectionMode === 'rank');
expect(rank.every((c) => dedicated.get(c.patientId) === 'kang')).toBe(true);
expect(chosen.some((c) => c.selectionMode === 'swap')).toBe(true);
});
test('⭐ 专属客服**不在名册内** = 视同无主 —— 判据必须与落人一致', () => {
const ranked = pick(4);
const dedicated = new Map([
['p-pat-0', 'gone'], // 已离岗
['p-pat-1', 'kang'],
]);
const chosen = repickPlaceable(ranked, dedicated, new Set(['kang']), 4);
// 离岗那位的患者算无主 → 与 p-pat-2/3 一样先被换进来
expect(chosen).toHaveLength(4);
const { placed } = placeAgents(chosen, [agent('kang', 0)], dedicated);
expect(placed.find((p) => p.patientId === 'p-pat-0')!.assignStrategy).toBe(
AssignStrategy.SPREAD_NO_DEDICATED,
);
});
test('⭐ 确定性:同样的池子两次挑出同一批人', () => {
const a = pool();
const b = pool();
expect(repickPlaceable(a.ranked, a.dedicated, roster, N).map((c) => c.planId)).toEqual(
repickPlaceable(b.ranked, b.dedicated, roster, N).map((c) => c.planId),
);
});
test('池子不够 N 时如实少给,⛔ 不报错也不补空', () => {
const ranked = pick(3);
expect(repickPlaceable(ranked, new Map(), roster, 50)).toHaveLength(3);
});
});
describe('placeAgents —— 确定性', () => {
test('⭐ 同样的输入两次算出**同样的分法**(主管微调后会重算,跳动他就不敢按确认)', () => {
const chosen = pick(7);
......@@ -290,20 +448,23 @@ describe('selectionNote —— 候选不够时的措辞', () => {
expect(r.selectionNote).not.toContain('取前');
});
test('⭐ 候选 500 > 基数 180 → 照实说「一共 500 人,这批挑了 180」+ 剩下的还在排队', async () => {
test('⭐ 候选 500 > 基数 180 → 照实说「这批 180 人,从 500 人里挑」+ 剩下的还没轮到', async () => {
const r = await svcWith(500, 9).propose(SCOPE, { clinicId: 'c1', potentialTreatment: 'implant' });
expect(r.target).toBe(N);
expect(r.selectionNote).toContain('一共 500 人');
expect(r.selectionNote).toContain(`这批挑了 ${N} 人`);
expect(r.selectionNote).toContain(`剩下 ${500 - N} 人在排队`);
// ⚠️ 2026-08-06 重排:每段以加粗的「数字+是什么」起头,措辞随之变化
expect(r.selectionNote).toContain('**这批 180 人**');
expect(r.selectionNote).toContain('从符合条件的 500 人里挑');
expect(r.selectionNote).toContain(`**这批 ${N} 人**`);
// ⚠️ 2026-08-06 措辞改过:「排队」是内部说法(池子里并没有队)→「还没轮到」
expect(r.selectionNote).toContain(`剩下 ${500 - N} 人这轮还没轮到`);
// ⚠️ 必须告诉主管"还能再分一批" —— 这正是容量模型下他做不到的那件事
expect(r.selectionNote).toContain('随时可以再分一批');
expect(r.selectionNote).toContain('随时再分一批');
// ⛔ 内部词不许进主管界面(2026-08-03 走查:"表达要给主管看懂,不是给程序员看")
for (const w of ['排序键', '收敛', '铺平', '水位', '基数', '探索配额', '患者号']) {
expect(r.selectionNote).not.toContain(w);
}
// ⚠️ 必须告诉主管"还能再分一批" —— 这正是容量模型下他做不到的那件事
expect(r.selectionNote).toContain('随时可以再分一批');
expect(r.selectionNote).toContain('随时再分一批');
});
/**
......@@ -318,7 +479,7 @@ describe('selectionNote —— 候选不够时的措辞', () => {
clinicId: 'c1', potentialTreatment: 'filling', temperature: 'cold',
});
expect(r.candidateTotal).toBe(1080);
expect(r.selectionNote).toContain('一共 1080 人');
expect(r.selectionNote).toContain('从符合条件的 1080 人里挑');
expect(r.placed).toBe(N);
});
});
......@@ -385,7 +546,7 @@ describe('基数沿用 —— 本批人数与时效', () => {
expect(r.target).toBe(300);
expect(r.expiresInDays).toBe(5);
expect(r.basis).toBe('inherited');
expect(r.basisNote).toContain('2026-07-28');
expect(r.basisNote).toContain('7 月 28 日');
});
/**
......@@ -485,6 +646,7 @@ describe('基数沿用 —— 本批人数与时效', () => {
const r = await mk(1000, 3, { inHand: 0, lastCriteria: { batchSize: 9, expiresInDays: 3 } })
.propose(SCOPE, { clinicId: 'c1' });
expect(r.byAgent.every((x: { loadAfter: number }) => x.loadAfter === 3)).toBe(true);
expect(r.basisNote).toContain('分完后每人手里 3 条');
// ⚠️ 2026-08-06 措辞缩短:去掉了套两层的括号(「…(最多的是X)(他们原本还有 N 条在手)」)
expect(r.basisNote).toContain('分完每人 3 条');
});
});
......@@ -81,7 +81,7 @@ describe('批次详情 —— agentStats 必须与 planned 对得上', () => {
{ id: 'p1', status: 'assigned', assigneeUserId: 'a', releaseReason: null },
{ id: 'p2', status: 'assigned', assigneeUserId: 'a', releaseReason: null },
// 已退回:assignee 为 null,只能靠 assign 事件找回是谁的
{ id: 'p3', status: 'active', assigneeUserId: null, releaseReason: ReleaseReason.OVER_CAPACITY },
{ id: 'p3', status: 'active', assigneeUserId: null, releaseReason: ReleaseReason.ALREADY_IN_PROGRESS },
],
events: [
{ planId: 'p1', event: PlanEventType.ASSIGN, assigneeUserId: 'a', createdAt: new Date('2026-08-01') },
......@@ -133,7 +133,7 @@ describe('退回原因分布 —— 不得混进系统原因', () => {
// 退回数从 28 变 50、退回率几乎翻倍,而报表看起来完全正常。
const { prisma } = makePrisma({
plans: [
{ id: 'p1', status: 'active', assigneeUserId: null, releaseReason: ReleaseReason.OVER_CAPACITY },
{ id: 'p1', status: 'active', assigneeUserId: null, releaseReason: ReleaseReason.ALREADY_IN_PROGRESS },
// 到期回收:release_reason **为空**(那一列只属于客服的处置)
{ id: 'p2', status: 'active', assigneeUserId: null, releaseReason: null },
{ id: 'p3', status: 'active', assigneeUserId: null, releaseReason: null },
......@@ -148,7 +148,7 @@ describe('退回原因分布 —— 不得混进系统原因', () => {
const d = await svc.detail(SCOPE, BATCH);
expect(d.released).toBe(1); // 只有真退回那一条
expect(d.releaseReasons).toHaveLength(1);
expect(d.releaseReasons[0]!.reason).toBe(ReleaseReason.OVER_CAPACITY);
expect(d.releaseReasons[0]!.reason).toBe(ReleaseReason.ALREADY_IN_PROGRESS);
// 到期的原因码绝不能出现在退回原因分布里
expect(d.releaseReasons.map((r) => r.reason)).not.toContain(PlanEventReason.ASSIGNMENT_EXPIRED);
});
......
......@@ -4,6 +4,7 @@ import {
UserRole,
ReleaseReason,
RELEASE_REASON_META,
RELEASE_REASON_HISTORICAL,
ReleaseReasonSchema,
releaseReasonsForForm,
PlanEventReason,
......@@ -45,11 +46,15 @@ describe('PLAN_DISPATCH —— 主管判据', () => {
describe('ReleaseReason —— 退回原因', () => {
test('枚举与 zod schema 值域必须一致(加了枚举忘了改 schema → 服务端 400)', () => {
expect([...ReleaseReasonSchema.options].sort()).toEqual([...Object.values(ReleaseReason)].sort());
// ⚠️ schema/META 的值域 = **现在能选的** ∪ **历史值**(库里有老数据,收窄会让那些单一读就炸)。
// ⛔ 别把历史值加回 ReleaseReason 常量 —— 那个常量是"现在能选什么"的单一真理源。
const all = [...Object.values(ReleaseReason), ...RELEASE_REASON_HISTORICAL].sort();
expect([...ReleaseReasonSchema.options].sort()).toEqual(all);
});
test('每个值都在 META 里登记,且 META 无孤儿键', () => {
expect(Object.keys(RELEASE_REASON_META).sort()).toEqual([...Object.values(ReleaseReason)].sort());
const all = [...Object.values(ReleaseReason), ...RELEASE_REASON_HISTORICAL].sort();
expect(Object.keys(RELEASE_REASON_META).sort()).toEqual(all);
for (const k of Object.values(ReleaseReason)) {
expect(RELEASE_REASON_META[k].labelZh.length).toBeGreaterThan(0);
}
......@@ -97,7 +102,10 @@ describe('ReleaseReason —— 退回原因', () => {
expect(shown).toEqual(Object.keys(RELEASE_REASON_META).filter(
(k) => !RELEASE_REASON_META[k as ReleaseReason].hidden,
));
expect(shown.length).toBe(8);
// ⭐ 表单清单必须**恰好等于**当前枚举 —— 历史值一个都不许露出来
expect([...shown].sort()).toEqual([...Object.values(ReleaseReason)].sort());
// ⭐ 反过来:历史值必须全部 hidden(漏标一个就会出现在客服面前)
for (const k of RELEASE_REASON_HISTORICAL) expect(RELEASE_REASON_META[k].hidden).toBe(true);
});
});
......
......@@ -6,7 +6,10 @@ import type {
ListAgentsResponse,
ListAssignmentsResponse,
AssignmentDetailResponse,
AssignmentProposal,
RefillProposalRequest,
RevokeAssignmentResponse,
SetAssignmentBenefitResponse,
} from '@pac/types';
import { api } from '@/lib/api-client';
......@@ -48,6 +51,28 @@ export const assignmentsApi = {
{},
),
/**
* 改本批福利 —— **确认之后仍然可以改**,空串 = 撤掉。
*
* ⚠️ 确认**之前**改福利不走这里:那时批次还没落库,福利跟着 `create` 一起写。
* 这条路专治"点完确认才想起来要带福利" —— 此前那种情况卡片只改了自己的 state,
* 亮了角标却什么也没写,主管看不出来(2026-08-06 实测)。
*/
setBenefit: (assignmentId: string, text: string) =>
api.post<SetAssignmentBenefitResponse>(
`/pac/v1/plans/assignments/${encodeURIComponent(assignmentId)}/benefit`,
{ text },
),
/**
* 「重新排一版(尽量排满)」—— 卡片上那个按钮。
*
* ⚠️ 不经过助手:主管点的是按钮不是说话,走 HTTP 直接换掉那张卡。
* ⚠️ 条件必须**原样带回**(人群 + 基数),否则重排会退回"沿用上一次",人数悄悄变了。
*/
refill: (body: RefillProposalRequest) =>
api.post<AssignmentProposal>('/pac/v1/plans/assignments/propose/refill', body),
agents: (clinicId: string, include?: string[]) =>
api.get<ListAgentsResponse>(
`/pac/v1/plans/assignments/agents?clinicId=${encodeURIComponent(clinicId)}` +
......
......@@ -26,12 +26,17 @@ export const AssignmentItemSchema = z.object({
* 这条是**怎么被选进批次**的。
* rank 按排序键正常入选(默认)
* explore 随机探索配额抽中的
* swap **顶替进来的** —— 原本排在 N 名之外,主管点了「用无主患者顶替待分配」才进来
*
* ⭐ 探索配额是整套系统里**唯一的因果抓手**:入选本身与结果相关(高分的人本来就更容易成),
* 纯观察数据永远解不开"是排序键选得准,还是这批人本来就好"。留了配额却不标记,
* 等于白留 —— 事后无法把两组分开。
*
* 🔴 `swap` 必须单列,⛔ 不能混进 `rank`(2026-08-06):顶替进来的人**排名本来就靠后**,
* 把他们算作"按排序键选中的",会直接压低 rank 组的表现 —— 而 rank 组的表现正是
* 探索配额要拿来对照的基准。混一次,这个基准就废了,且事后无法拆开。
*/
selectionMode: z.enum(['rank', 'explore']).optional(),
selectionMode: z.enum(['rank', 'explore', 'swap']).optional(),
});
export type AssignmentItem = z.infer<typeof AssignmentItemSchema>;
......@@ -369,13 +374,71 @@ export const ProposedItemSchema = z.object({
/// 主管扫这一列时看的是"这批人大概是谁",光有姓名不够(同名、判断人群构成都要它)
gender: z.string().nullable(),
age: z.number().int().nullable(),
/**
* ⭐ 两个医生 —— 主管审这批人时要看的"这人是谁跟的"。
*
* ⚠️ `attendingDoctor`(主治)口径与**患者详情页完全一致**:触发本次召回那条诊断的
* 真实医生,排除影像 AI,没有则回落全量最高频真人医生(见 assignment-proposal 的 doctorsOf)。
* ⛔ 别在这里另定口径 —— 确认单显示 A 医生、点进详情显示 B 医生,而且不报错。
* ⚠️ 都可能为 null(宿主没给医生名 / 只有影像 AI),前端不占位。
*/
attendingDoctor: z.string().nullable(),
/// 最近一次**就诊**的医生(接诊 / 病历 / 实际治疗,口径同"距上次就诊天数")
lastVisitDoctor: z.string().nullable(),
assigneeUserId: z.string(),
assignStrategy: AssignStrategySchema,
selectionMode: z.enum(['rank', 'explore']),
/// 见 AssignmentItemSchema.selectionMode —— `swap` 是顶替进来的,⛔ 别混进 rank
selectionMode: z.enum(['rank', 'explore', 'swap']),
});
export type ProposedItem = z.infer<typeof ProposedItemSchema>;
/**
* 🔴 **待分配** —— 有专属客服、但那位客服这轮已排满的患者。
*
* ── 为什么单独一类(2026-08-06 产品定,改掉了原来的「有主改派」)──────────
* 原来第三趟会把这些人**自动改派**给手上最空的客服(标 SPREAD_OVERFLOW),
* 目的是把团队在手量铺平。产品判定这条不能由**助手**来做:
* 把患者从他的专属客服手里挪走是**关系层面的决定**,只有主管有资格拍板。
*
* ⇒ 助手不再替他决定,而是**把问题摊开**:这些人进「待分配」,在确认单上单列一组,
* 主管可以拖给任意客服(那就是他的决定,标 MANUAL)、也可以移出本批。
* ⚠️ 主管**不处理**就是不分 —— 待分配的条目**不会**落库(见确认单 confirm)。
* ⛔ 别"贴心地"在确认时兜底分给谁,那等于把刚拿掉的自动改派又偷偷加回来。
*
* ⚠️ `ownerName` 必须带:主管要判断的是"张三这轮排满了,这个人要不要先给别人",
* 只给一个 userId 他没法判断(T14:证据要看得懂)。
*/
export const PendingAssignItemSchema = ProposedItemSchema.omit({
assigneeUserId: true,
assignStrategy: true,
}).extend({
/// 他的专属客服(在名册内);⛔ 不可为 null —— 无主患者走第二趟,根本不会进这里
ownerUserId: z.string(),
ownerName: z.string().nullable(),
/// 这位专属客服此刻在手多少 —— 「为什么排满了」的证据,主管据此决定要不要挪
ownerInHand: z.number().int(),
});
export type PendingAssignItem = z.infer<typeof PendingAssignItemSchema>;
/**
* 候选池的**专属客服分布** —— 回答「为什么会有这么多人卡住」。
*
* ⚠️ 口径是**整个候选池**(与 candidateTotal 同一个 count),⛔ 不是取回的那 1.5 倍样本 ——
* 拿样本算占比会随 N 变化而漂,主管两次看到不同的比例会以为数据在变。
*/
export const PoolOwnershipSchema = z.object({
/// 有在岗专属客服的人数
withOwner: z.number().int(),
/// 无主(没有专属 / 专属已离岗)—— **他们就是可用来顶替的那批**
free: z.number().int(),
/// 专属最集中的几位客服(降序,最多 3 位)。⭐ 这一项才说清"为什么卡"
topOwners: z.array(
z.object({ userId: z.string(), name: z.string().nullable(), n: z.number().int() }),
),
});
export type PoolOwnership = z.infer<typeof PoolOwnershipSchema>;
/**
* 按客服的**精调**。
*
* 两个基数(容量/时效)是**整体默认**,这里是针对某一个人的覆盖:
......@@ -465,6 +528,26 @@ export const AssignmentProposalSchema = z.object({
*/
unplaced: z.number().int(),
items: z.array(ProposedItemSchema),
/**
* 🔴 **待分配**:有专属客服、但那位客服这轮已排满的患者(见 PendingAssignItemSchema)。
*
* ⚠️ 它们**不在** `items` 里、也**不计入** `placed` —— 助手没有分配它们,
* 确认时也不会落库。主管在卡片上拖给谁,那一条才会变成一条真的分配(MANUAL)。
* ⛔ 别把它并进 `unplaced`:那个数说的是"名册空/全被精调成 0"这种**系统性无处可放**,
* 而这里是**刻意留给人决策**的,两者要主管做的事完全不同。
*/
pending: z.array(PendingAssignItemSchema),
/**
* 🔴 **能不能重新排一版** —— 池子里还有"专属没排满 / 无主"的人可用。
*
* ⚠️ 只回答**能不能**,⛔ 不下发"顶替名单":名单只能从取数窗口里挑,
* 而窗口比池子小得多(实测:池子 17 位无主,窗口里只有 3 位)——
* 下发名单就等于把这个上限暴露成"只能顶 3 个"。
* ⇒ 主管点一下 → 服务端**重新出一版**(取数窗口更大 + 遇到排满的跳过),天然凑满。
*/
canRefill: z.boolean(),
/// 候选池的专属分布 —— 「为什么这么多人卡住」的答案(见 PoolOwnershipSchema)
poolOwnership: PoolOwnershipSchema.nullable(),
byAgent: z.array(ProposalAgentRowSchema),
/**
* 本批**一条都没分到**的在岗客服。**仍然列出来** —— 主管要看见"他不是被漏了"。
......@@ -478,58 +561,157 @@ export const AssignmentProposalSchema = z.object({
/// 基数说明:两个基数的值 + **出处**(沿用哪次/首次默认/本次指定)+ 分配后的水位
basisNote: z.string(),
selectionNote: z.string(),
/// 「待分配」那段(含分布说明 + 顶替提示);没有待分配时为空串。⚠️ 排在 basisNote **之后**说
pendingNote: z.string(),
/**
* 🔴 **「重新排一版」到底做了什么** —— 由**卡片自己**渲染,⛔ 不走助手。空串 = 没什么可说的。
*
* 由来(2026-08-06 实测):点了重排,卡片从「拟分 16 · 待分配 34」变成「拟分 50」,
* 而卡片**上面那段话还是旧的**(仍写着"34 人要您定"、"19 人无主")——
* 主管看到的是两份互相打架的口径,合理的第一反应就是「19 个人怎么顶掉了 34 个」。
* 重排走 HTTP 只换卡片,助手不会重新说一遍;⇒ **换了什么必须由卡片自己交代**。
*
* ⚠️ 尤其要交代**代价**:为了凑满 N,专属大户会超出建议水位多接自己的人
* (手上 69 条 vs 别人 58 条)。不说这一句,那个 69 就成了无法解释的数。
*/
refillNote: z.string(),
});
export type AssignmentProposal = z.infer<typeof AssignmentProposalSchema>;
/**
* 「重新排一版(尽量排满)」的入参 —— 卡片上那个按钮打过来的。
*
* ⚠️ 条件必须**原样带回**:重新排的是**同一批人群**,只是换一种取人方式。
* ⛔ 别在服务端凭 assignmentId 反查 —— 这一版还没落库,没有 id 可查。
*/
export const RefillProposalRequestSchema = z.object({
clinicId: z.string().min(1),
potentialTreatment: z.string().nullable().optional(),
temperature: z.string().nullable().optional(),
personaTags: z.string().nullable().optional(),
/// 基数 N 与时效原样带回,否则重排会退回"沿用上一次",人数悄悄变了
targetCount: z.number().int().positive().optional(),
expiresInDays: z.number().int().positive().max(90).optional(),
});
export type RefillProposalRequest = z.infer<typeof RefillProposalRequestSchema>;
// =============================================================
// 助手直接改确认单 —— 本地工具 edit_assignment_sheet 的载荷
// =============================================================
/**
* 确认单的**语义编辑指令**。
* 🔴🔴 **三个正交的轴:选谁(select)× 干什么(action)× 给谁(to)。**
*
* ── 为什么不是一串 action 字面量(2026-08-06 产品判定,推翻原设计)────────
* 原来是 8 条并列的字面量:`remove_patient` / `remove_agent` / `move_patient` /
* `assign_pending` / `assign_pending_to_owner` / `drop_pending` / `set_expiry` / `set_benefit`。
* 摊开看它们根本不是 8 件事,而是**三个轴被压成了一维**:
* remove_agent = 选「某客服名下」× 移出
* drop_pending = 选「待分配」 × 移出
* assign_pending = 选「待分配」 × 改派 × 铺平
* assign_pending_to_owner = 选「待分配」 × 改派 × 各自的专属
* 每加一种"选谁"或一种"给谁",条数就要**乘一遍** —— 而没被乘出来的那些格子,
* 主管说到时模型只能掉进最像的那一格。当天实测栽的两次都是这个形状:
* ·「各自分给各自的专属客服」→ 只有铺平可用 → 18 人被散给了 17 位**别人**(语义正相反)
* ·「把康慧捧名下的都还给她」「待分配的时效给 5 天」→ 至今一条都表达不了
*
* ⚠️ **正交化不消灭"枚举漏值",只是把它变得可数**:少写一个 `mode` 照样会栽。
* 它真正买到的是 ① 加维度不再乘一遍 ② 缺的组合是**表格里的空格**(看得见),
* 不是没人想到的字面量(看不见)③ 覆盖度能按矩阵测。
*
* ⭐ 为什么用「姓名」而不是 planId:模型**拿不到也不该拿到** planId
* (确认单的肥载荷走侧信道直接给界面,不进模型上下文 —— 见 assistant.service 的注释)。
* 所以指令用主管说得出口的东西(患者姓名 / 病历号 / 客服姓名)表达,
* **由界面拿自己手里的那份确认单去匹配**。模型负责"听懂",界面负责"找到人"。
* ⚠️ 这条边界拿不掉 —— 所以做不到"给助手通用能力让它自己写";
* 能做的是把**词汇表**做得足够表达,而不是堆一串硬编码短语。
*
* ⚠️ 能做的**只有卡片上能做的那几件**(局部修正),⛔ 不含换人群 / 改批次人数 ——
* 那两件要重跑服务端算法,只能重出一版确认单。
* ⚠️ 匹配不到 / 匹配到多个,由界面回一句话进对话(见 sheetEditReport),
* ⛔ 别让模型自己猜"大概是成功了"。
* ⚠️ 能做的只有卡片上能做的那几件(局部修正),⛔ 不含换人群 / 改批次人数 ——
* 那两件要重跑服务端算法,只能重出一版确认单(走 propose_assignment)。
* ⚠️ 匹配不到 / 匹配到多个,由界面回一句话进对话,⛔ 别让模型自己猜"大概是成功了"。
*/
export const SheetEditOpSchema = z.discriminatedUnion('action', [
z.object({
action: z.literal('remove_patient'),
patient: z.string().describe('患者姓名或病历号'),
}),
/** ① 选谁 —— 这条指令作用在哪些人身上 */
export const SheetSelectSchema = z.discriminatedUnion('group', [
/** 点名的患者(姓名或病历号);⚠️ 可以给多个,⛔ 别拆成多条指令 */
z.object({
action: z.literal('remove_agent'),
agent: z.string().describe('客服姓名'),
group: z.literal('patients'),
patients: z.array(z.string()).min(1).describe('患者姓名或病历号'),
}),
/** 某位客服名下的全部条目 */
z.object({ group: z.literal('agent'), agent: z.string().describe('客服姓名') }),
/** 「待分配」那一组的全部(专属客服排满、还没落到人头上的那些) */
z.object({ group: z.literal('pending') }),
/** 整批 —— 只对 `set_expiry` / `set_benefit` 有意义 */
z.object({ group: z.literal('batch') }),
]);
/**
* ② 给谁 —— **只有 `action:'assign'` 用**。
*
* 🔴 `owner` 与 `balance` 的结果**正好相反**,是当天那次事故的正题:
* 主管说「各自分给各自的专属客服」= `owner`(每人进自己那位客服手里,⛔ 一条关系都不动);
* 说「平均分 / 谁手上少给谁 / 铺下去」= `balance`。选错主管一眼看得出来。
*/
export const SheetAssignToSchema = z.discriminatedUnion('mode', [
/**
* 各自回**自己的**专属客服。
* ⚠️ 专属客服已离岗 / 不在本批名册的那几个**分不下去**,界面会如实报数,
* ⛔ 不许顺手改派给别人 —— 那正是这一条要避免的事。
*/
z.object({ mode: z.literal('owner') }),
/**
* 铺平:**谁手上少先给谁**(与服务端 placeAgents 的 `lowest()` 同一条规则)。
* ⚠️ ⛔ 不是真随机:主管嘴上说"随机",要的是齐平;真随机会让同一张单每次算出不同结果,
* 破坏"同样的输入两次算出同样的分法"这条他敢按确认键的前提。
* ⭐ `agents` = 把候选**限定**在这几位里。主管点名了人(「分给张悦和李莉」)就填这里;
* 只点一位就等于"都给他"。不填 = 本批所有在岗(含一条都没分到的 —— 他们恰恰手上最空)。
*/
z.object({
action: z.literal('move_patient'),
patient: z.string(),
toAgent: z.string().describe('转给谁(客服姓名);必须已在本批里'),
mode: z.literal('balance'),
agents: z.array(z.string()).optional().describe('限定只分给这几位客服(姓名)'),
}),
z.object({
]);
export const SheetEditOpSchema = z
.object({
select: SheetSelectSchema,
action: z.enum(['assign', 'remove', 'set_expiry', 'set_benefit']),
/// `assign` 必填,其余动作忽略
to: SheetAssignToSchema.optional(),
/// `set_expiry` 必填
days: z.number().int().positive().max(90).optional(),
/**
* 批次福利 —— T4:福利挂在**批次**上,不挂个人;它是**话术勾子 + 归因标签**,
* v1 不核销、不接宿主卡券。落库进 `attributes.benefit.text`,
* 生成话术时作为事实输入进 prompt(已打通,见 plan-script.orchestrator)
* `set_benefit` 必填。批次福利 —— T4:挂在**批次**上不挂个人;
* 它是**话术勾子 + 归因标签**,v1 不核销、不接宿主卡券。
* 落库进 `attributes.benefit.text`,生成话术时作为事实输入进 prompt
* ⚠️ 空串 = 撤掉福利。
*/
action: z.literal('set_benefit'),
text: z.string().max(200),
}),
z.object({
action: z.literal('set_expiry'),
days: z.number().int().positive().max(90),
/// 三选一:给某个患者 / 给某个客服名下全部 / 都不给 = 整批
patient: z.string().optional(),
agent: z.string().optional(),
}),
]);
text: z.string().max(200).optional(),
})
/**
* ⚠️ 缺参数**在这里就拦掉**,⛔ 别留给界面运行时兜 ——
* 界面兜的话只能回一句"没执行",而模型不知道自己少填了什么,下一轮还会再错一次。
*/
.superRefine((op, ctx) => {
if (op.action === 'assign') {
if (!op.to) ctx.addIssue({ code: 'custom', message: "action:'assign' 必须给 to(owner / balance)" });
if (op.select.group === 'batch')
ctx.addIssue({ code: 'custom', message: '整批不能作为改派的对象 —— 请选 pending / agent / patients' });
}
if (op.action === 'remove' && op.select.group === 'batch')
ctx.addIssue({ code: 'custom', message: '整批不能"移出自己" —— 请选具体的人' });
if (op.action === 'set_expiry' && op.days == null)
ctx.addIssue({ code: 'custom', message: "action:'set_expiry' 必须给 days" });
if (op.action === 'set_benefit') {
if (op.text == null)
ctx.addIssue({ code: 'custom', message: "action:'set_benefit' 必须给 text(空串 = 撤掉)" });
if (op.select.group !== 'batch')
ctx.addIssue({ code: 'custom', message: '福利挂在整批上 —— select 必须是 batch' });
}
});
export type SheetSelect = z.infer<typeof SheetSelectSchema>;
export type SheetAssignTo = z.infer<typeof SheetAssignToSchema>;
export type SheetEditOp = z.infer<typeof SheetEditOpSchema>;
// =============================================================
......@@ -568,3 +750,34 @@ export const RevokeAssignmentResponseSchema = z.object({
note: z.string(),
});
export type RevokeAssignmentResponse = z.infer<typeof RevokeAssignmentResponseSchema>;
/**
* 给**已确认**的批次补挂 / 改 / 撤福利。
*
* ⚠️ 由来(2026-08-06 实测):福利原本只在 `create` 那一刻落库。主管**确认之后**再说
* 「这批带上八折」,助手仍走 `edit_assignment_sheet`,卡片改了自己的 state、亮了角标、
* 回了一句「已更新」—— 而 DB 里一个字没变,话术里也不会有福利。
* 界面说做了、实际没做,主管无从察觉,正是 T14 要防的那类。
*/
export const SetAssignmentBenefitRequestSchema = z.object({
/// 福利文案原文;**空串 = 撤掉福利**(⛔ 别用 null,前端清空时给的就是空串)
text: z.string().max(200),
});
export type SetAssignmentBenefitRequest = z.infer<typeof SetAssignmentBenefitRequestSchema>;
export const SetAssignmentBenefitResponseSchema = z.object({
assignmentId: z.string(),
/// 改完之后的值;null = 这批现在不带福利
benefitText: z.string().nullable(),
/// 作废掉的话术缓存条数 —— 这些下次打开详情页会重新生成(懒生成,不急切重跑 LLM)
scriptsInvalidated: z.number().int(),
/**
* 客服**已经打开过**的条数(判据同 revoke:`view` 事件,⛔ 不可用 plan_executions)。
* 他手里那份话术是改福利之前生成的,补挂的福利他看不到 —— 必须如实回报,
* ⛔ 不许只回一句「已更新」。
*/
touched: z.number().int(),
/// 给主管看的一句话(成品句子,助手照抄)
note: z.string(),
});
export type SetAssignmentBenefitResponse = z.infer<typeof SetAssignmentBenefitResponseSchema>;
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