Commit 61537b7d by luoqi

feat(plan): 首批 N 按「在岗人数×20」估 + 与候选取小;专属改为硬约束独占

① 基数 N 的取值改成产品定的算法:
   首次 min(在岗人数 × 20, 候选总数),之后 min(上一次的 N, 候选总数),时效首次 3 天后沿用。
   ️ 与候选取小压低的是 target, **不回写基数** —— 候选不够是这一批的偶然事实,
   存进去的话主管点一次 44 人的小格子,以后所有批次永远 44 人,而他看不出为什么。
   所以确认单同时给 batchSize(基数)与 target(本批实际),卡片存前者。
   ️ fetchLimit 改按基数算,让 count 与取明细还能并发,不多一个来回。

② 🔴 硬约束:**客服不能接别人的专属患者**。有专属(且在名册内)的患者只有一个去处,
   给不下就落不下(unplaced),不允许溢出给第三个人。
   · 推翻原「专属已满 → 溢出转铺平」,SPREAD_OVERFLOW 不再产生(枚举保留,老批次在用);
   · 两趟分工改为「专属独占 → 无主患者水位法」,后者负责抹平前者造成的不齐;
   · maxThisBatch 精调压过专属:说了只给 5 条,第 6 个她的专属患者就落不下, 不许改派;
   · 上一版给专属加的"目标水位封顶"与本约束冲突(会把专属患者改派给第三人),撤掉。

️ 直接后果,已在教条里写明:「最后齐平」变成尽力而为。实测 N=340 时康慧捧
一个人是其中 248 人的专属,他就拿 248 条 —— 那些患者只有他能接。
调节手段是给他设 maxThisBatch 或换更小的 N, 不是让系统偷偷分给别人。

本地实测:1,081 候选 → 首批 340 人(17×20),池子里还有 741 人排队。988 tests green。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent c33dafed
import { Permission, ASSIGNMENT_EXPIRES_DAYS_DEFAULT, BATCH_SIZE_DEFAULT } from '@pac/types';
import { Permission, ASSIGNMENT_EXPIRES_DAYS_DEFAULT, BATCH_SIZE_PER_AGENT_FIRST } from '@pac/types';
/**
* 按能力切换的助手工作流约束(拼在 SYSTEM_PROMPT 之后)。
......@@ -83,15 +83,19 @@ const DISPATCHER_EXTRA = `
⚠️ 标了 multi 的维度一个人可命中多项,合计大于总人数,**别拿它算百分比**。
### 拟分方案怎么给(主管会把关,你负责有理有据)
- **专属优先**:该患者有专属客服且在名册内 → 分给他
- **溢出走水位法**:无专属 / 专属已离岗 / 专属这轮名额用完 → 铺给其他在岗客服,
**每一条都给当前手上最少的那个人**,分完后大家的在手量趋于齐平。
⛔ 不是"每人加一样多" —— 那样起点不齐终点还是不齐。
本批一条没分到的人仍会列出来(他手上本来就最多),⛔ 别说成"他被跳过了"。
- 🔴 **客服不能接别人的专属患者。** 有专属客服(且在名册内)的患者**只有一个去处**,
分不下就是分不下(计入 unplaced),⛔ 绝不能说"改派给别人"或"铺给其他客服"。
- **专属独占**:有专属的患者先落到各自专属客服头上(他们没有第二个去处)。
- **无主患者走水位法**:没有专属 / 专属已离岗的患者,**每一条都给当前手上最少的那个人**,
用这批自由人抹平第一趟造成的不齐。⛔ 不是"每人加一样多"(起点不齐终点还是不齐)。
⚠️ 「最后齐平」是**尽力而为不是保证**:100 个人里 76 个是同一个人的专属,他就是会拿 76 条。
主管的调节手段是给他设本批名额(agentOverrides)或换个更大的 N,⛔ 别建议"分给别人"。
本批一条没分到的人仍会列出来,⛔ 别说成"他被跳过了"。
- **⛔ 没有"容量上限"这个东西。** 负载就是在手量本身,水位法已经在照顾它。
主管问"会不会分太多"就照 basisNote 说分完后每人多少条,⛔ 不要编一个"上限"出来。
- **两个基数:本批人数 + 时效,都自动沿用主管上一次的值** —— ⛔ 别问他,那正是这个设计要省掉的输入。
首次没有上一次才用默认(${BATCH_SIZE_DEFAULT} 人 / ${ASSIGNMENT_EXPIRES_DAYS_DEFAULT} 天)。
首次没有上一次才估(在岗人数 × ${BATCH_SIZE_PER_AGENT_FIRST} / ${ASSIGNMENT_EXPIRES_DAYS_DEFAULT} 天),
并且**都要再与本批候选总数取小** —— 候选不够时如实说"一共就这么多人"。
basisNote 里写好了值、出处(「沿用 X 月 X 日那次」/「首次默认」)和分配后的水位,**照抄**。
- 他说「这批 200 人」→ 传 targetCount(**基数**,会被记住);「给 5 天」→ 传 expiresInDays;
「李莉这周最多 5 条」→ 传 agentOverrides(按客服精调,也会被记住)。
......
......@@ -138,7 +138,7 @@ export class AgentRosterService {
/// 名册里没有 = 近 N 月无回访记录。**不是"不能分"** —— 见类注释
inRoster: r != null,
// ⛔ **不返回 remaining,也不再返回容量区间**。
// "容量/上限"这个概念已经删掉(见 BATCH_SIZE_DEFAULT):负载就是 inHand 本身,
// "容量/上限"这个概念已经删掉(见 BATCH_SIZE_PER_AGENT_FIRST):负载就是 inHand 本身,
// 落人走水位法(先给手上最少的)。给一个区间会被读成"合法范围",
// 把余量减出来会让助手说「李莉还能吃 38 个」—— 拿未经验证的数做完减法当事实说出口。
// 名册只回**在手**这个客观量;分配后的水位在确认单的 byAgent 里逐人给。
......
......@@ -2,7 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import {
ASSIGNMENT_EXPIRES_DAYS_DEFAULT,
BATCH_SIZE_DEFAULT,
BATCH_SIZE_PER_AGENT_FIRST,
AssignStrategy,
type AgentInfo,
type AssignmentProposal,
......@@ -122,10 +122,16 @@ export class AssignmentProposalService {
// 结果是**第二批必然为 0**:第一批把所有人填到水位,再算就是 Σ(容量−容量)。
// 主管想连着圈两批人分,只能把容量往上棚,而容量会被记住 → 下周的"习惯"是个虚高的数。
// 现在:N 决定推多少,水位法决定给谁,负载由 `inHand` 本身表达,不设阈值。
const target =
/**
* 基数 N —— **要被记住的那个数**。
* 本次主管指定 > 沿用上一次 > 首次估法 `在岗人数 × 20`。
* ⚠️ 首次那个 20 **不是容量上限**,只是"没有任何历史时一批推多大"的估法,
* 之后就再也不出现了(沿用上次的值)。
*/
const batchSize =
input.targetCount != null
? Math.max(0, Math.floor(input.targetCount))
: baseline.batchSize;
: (baseline.batchSize ?? agents.length * BATCH_SIZE_PER_AGENT_FIRST);
const expiresInDays =
input.expiresInDays != null && input.expiresInDays > 0
? Math.floor(input.expiresInDays)
......@@ -142,9 +148,10 @@ export class AssignmentProposalService {
const capOf = (userId: string) => agentOverrides[userId]?.maxThisBatch ?? Infinity;
const expOf = (userId: string) => agentOverrides[userId]?.expiresInDays ?? expiresInDays;
const inHandTotal = agents.reduce((a, g) => a + g.inHand, 0);
if (target === 0 || agents.length === 0) {
if (batchSize === 0 || agents.length === 0) {
return emptyProposal(clinicId, potentialTreatment, agents, roster.rosterNote, {
target,
target: 0,
batchSize,
expiresInDays,
basis,
basisFrom: baseline.from,
......@@ -161,11 +168,22 @@ export class AssignmentProposalService {
// 主管刚在矩阵上看过 1,080,当场就对不上。这个数**只能来自 count**。
// ⚠️ 用 `count(DISTINCT patient_id)`,与矩阵格子同一种数法 —— 换成 count(*)
// 就变成「按 plan 数」,同一个人两条召回会被数两次,又对不上了。
const fetchLimit = Math.ceil(target * 1.5) + 20;
// ⭐ 取数上限按**基数**算(不是按 target)—— target 还没算出来:它是
// `min(基数, 候选总数)`,而候选总数正是下面这次 count 的结果。
// 按基数取一定够(target ≤ 基数),这样 count 与取明细还能并发,不多一个来回。
const fetchLimit = Math.ceil(batchSize * 1.5) + 20;
const [candidateTotal, pool] = await Promise.all([
this.countCandidates(scope, criteria, now),
this.selectCandidates(scope, criteria, fetchLimit, now),
]);
/**
* 本批**实际**分多少 = `min(基数 N, 候选总数)`。
*
* ⚠️ 候选不够时压低的是 `target`,⛔ **不回写基数** —— 那是这一批的偶然事实。
* 回写的话,主管点一次 44 人的小格子,以后所有批次就永远是 44 人,
* 而他完全看不出为什么变小了(界面上只会显示"沿用上次")。
*/
const target = Math.min(batchSize, candidateTotal);
// 同患者只留一条(schema 注释承诺的 partial UNIQUE 实际不存在,不能当保障用)
const seenPatient = new Set<string>();
......@@ -214,6 +232,8 @@ export class AssignmentProposalService {
/// ⭐ 真候选总数(count 出来的),⛔ 不是 ranked.length —— 后者被 fetchLimit 截过
candidateTotal,
target,
/// ⭐ 要被记住的是**基数**不是 target —— 候选不够时 target 被压低,那是一批的偶然事实
batchSize,
expiresInDays,
basis,
basisFrom: baseline.from,
......@@ -248,6 +268,7 @@ export class AssignmentProposalService {
expiresInDays,
basis,
basisFrom: baseline.from,
batchSize,
target,
loads: [...byAgent].map(([u, l]) => ({
name: agentById.get(u)?.name ?? u.slice(0, 8),
......@@ -319,7 +340,7 @@ export class AssignmentProposalService {
scope: TenantScopeContext,
clinicId: string,
): Promise<{
batchSize: number;
batchSize: number | null;
expiresInDays: number;
from: string | null;
agentOverrides: Record<string, { maxThisBatch?: number; expiresInDays?: number }>;
......@@ -348,8 +369,10 @@ export class AssignmentProposalService {
const batchSize = posInt(c?.batchSize) ?? posInt(c?.target);
const expiresInDays = posInt(c?.expiresInDays);
// ⚠️ 只要有一个读到就算"沿用"(另一个补默认):老批次可能只存了其中一个
// ⚠️ batchSize 读不到就返回 **null**,不在这里兜默认 —— 首次的估法要用到"在岗人数",
// 而这里不认识名册。兜底交给 propose()。
return {
batchSize: batchSize ?? BATCH_SIZE_DEFAULT,
batchSize,
expiresInDays: expiresInDays ?? ASSIGNMENT_EXPIRES_DAYS_DEFAULT,
from: batchSize != null || expiresInDays != null ? last!.createdAt.toISOString() : null,
agentOverrides: sanitizeOverrides(c?.agentOverrides),
......@@ -398,24 +421,26 @@ export class AssignmentProposalService {
}
/**
* ② 落人:**专属优先 → 溢出均分 + 在手硬护栏**。
* ② 落人:**专属独占 → 无主患者水位法铺平**。
*
* ⭐ 导出成**纯函数**(不进 class):它是本文件里唯一值得单独测的算法,
* 而纯函数不需要起 Nest 容器、不需要 mock Prisma,测试成本低一个量级。
*
* ── 为什么是均分,不是「水位拉平」 ──────────────────────────
* 水位拉平(按在手量补到同一水位)看着更聪明,实际站不住,三条:
* 1. 它的输入是 `remaining = 容量 − 在手`,而**容量是拍的默认值** ——
* 拿默认值做完减法再当事实说出口("李莉还能吃 38 个"),一句免责声明救不回来。
* 2. 在手量当前**不可信**:自动回收长期关闭、执行回写率仅 11%、
* 现存认领单 79% 已过时限。以它为决策依据 = 把噪声固化成政策。
* 3. **自毁 T20**:要反推的第一项就是"各客服实际能吃多少",那需要负载**方差**作自变量;
* 而水位拉平把消灭方差当目标函数。
* 另外它是全局耦合的:主管改一条指定客服,所有人的数字都要跳 —— 违 T13「一次确认」。
* 均分是局部的:改一条只影响那一条。
* ═══ 🔴 硬约束(2026-08-03 产品定):**客服不能接别人的专属患者** ═══
* 有专属客服(且在名册内)的患者,**只有一个去处** —— 那个人。
* 给不下就落不下(unplaced),⛔ 不允许溢出给第三个人。
*
* ⚠️ 切换到贪心「最少在手优先」将来只是一行排序的改动、不需要新列、不影响任何历史归因 ——
* 所以这个决定**应该等在手量可信之后再用数据做**,现在不预支。
* 这条推翻了原来的「专属已满 → 溢出转铺平」:那时 `SPREAD_OVERFLOW` 表达的是
* "有专属只是没轮到",现在这种情况**不会再产生**(枚举值保留,老批次还在用)。
*
* ── 于是两趟的分工变了 ──────────────────────────────────────
* 第一趟 **专属独占**:有专属的患者先落 —— 他们没有 plan B,先占坑;
* 第二趟 **水位法**:只有**无主患者**(无专属 / 专属已离岗)参与,
* 每一条都给当前在手最少的人 → 用这批自由人去**抹平**第一趟造成的不齐。
*
* ⚠️ 「最后齐平」现在是**尽力而为**,不是保证:如果 100 个人里 76 个是同一个人的专属,
* 他就是会拿 76 条 —— 那 76 个患者只有他能接。主管的调节手段是
* `maxThisBatch`(给他设本批名额)或换个更大的 N,⛔ 不是让系统偷偷分给别人。
*/
export function placeAgents(
chosen: Array<{ planId: string; patientId: string; selectionMode: 'rank' | 'explore' }>,
......@@ -423,13 +448,16 @@ export function placeAgents(
dedicatedByPatient: Map<string, string>,
/**
* 本批给某人的**名额上限**(精调过才有,默认 Infinity = 不限)。
* ⛔ 这不是"容量" —— 容量那个概念已经删了(见 BATCH_SIZE_DEFAULT)。
* 它只表达「李莉这周带教,这批最多给 5 条」这种一次性名额,0 = 这轮不给他。
* ⛔ 这不是"容量" —— 容量那个概念已经删了。它只表达
* 「李莉这周带教,这批最多给 5 条」这种一次性名额,0 = 这轮不给他。
* ⚠️ 它**压过专属**:主管说了只给 5 条,第 6 个她的专属患者就落不下(unplaced),
* ⛔ 不许转给别人 —— 那正是上面那条硬约束禁止的事。
*/
maxOf: (userId: string) => number = () => Infinity,
): { placed: ProposedItem[]; unplaced: number } {
const rosterIds = new Set(agents.map((a) => a.userId));
/// ⭐ 水位 = 该客服**分配后**手上有多少 = 在手 + 本批已给。两趟共用同一本账。
/// ⭐ 水位 = 该客服**分配后**手上有多少 = 在手 + 本批已给。两趟共用同一本账 ——
/// 分开算的话第二趟会把第一趟已经灌高的人当成"还很空",专属大户被再灌一轮。
const load = new Map<string, number>(agents.map((a) => [a.userId, a.inHand]));
const given = new Map<string, number>(agents.map((a) => [a.userId, 0]));
const canTake = (u: string) => (given.get(u) ?? 0) < maxOf(u);
......@@ -437,58 +465,46 @@ export function placeAgents(
load.set(u, (load.get(u) ?? 0) + 1);
given.set(u, (given.get(u) ?? 0) + 1);
};
/**
* 🔴 **目标水位** = 这一批分完之后,大家理想中应该停在的高度。
*
* `(团队现有在手 + 本批人数) / 在岗人数`
*
* 它不是新旋钮 —— 完全由 N 和在手量推出来。存在的理由只有一个:
* **给专属那一趟封顶**。容量删掉之后没有任何东西约束专属了,实测一次:
* 100 条里 76 条是同一个人的专属,他一个人吃到 76,水位法只剩零头可铺,
* 「最后保持齐平」当场落空。
*
* ⚠️ 这不是"不给专属了" —— 是他**这一批的份额已经满了**,后面同样是他专属的患者
* 转成铺平(标 SPREAD_OVERFLOW,关系还在,只是这轮没轮到)。语义与原来
* 「专属客服已达容量 → 溢出转铺平」完全一致,只是把"容量"换成了"这批的应得份额"。
* ⚠️ 向上取整 + 至少 1:否则 N 比人数还小时目标水位算出 0,专属一条都进不去,
* 整批全走铺平 —— 那等于把专属关系整个关掉。
*/
const inHandSum = agents.reduce((a, g) => a + g.inHand, 0);
const waterline =
agents.length > 0 ? Math.max(1, Math.ceil((inHandSum + chosen.length) / agents.length)) : 0;
/// 这个患者的专属客服(且在名册内);null = 无主,谁都能接
const ownerOf = (patientId: string) => {
const d = dedicatedByPatient.get(patientId);
return d && rosterIds.has(d) ? d : null;
};
const placed: ProposedItem[] = [];
const overflow: typeof chosen = [];
const freeAgents: typeof chosen = [];
let unplaced = 0;
// ── 第一趟:专属命中(**受目标水位约束**)────────────────────
// ── 第一趟:专属独占(他们没有 plan B,先占坑)──────────────
for (const c of chosen) {
const dcs = dedicatedByPatient.get(c.patientId);
if (dcs && rosterIds.has(dcs) && canTake(dcs) && (load.get(dcs) ?? 0) < waterline) {
take(dcs);
const owner = ownerOf(c.patientId);
if (owner == null) {
freeAgents.push(c);
continue;
}
if (!canTake(owner)) {
// 专属客服本批名额已满 → **这个患者本批落不下**。
// ⛔ 不许改派:他有专属,别的客服不能接(硬约束)。
unplaced++;
continue;
}
take(owner);
placed.push({
planId: c.planId,
patientId: c.patientId,
assigneeUserId: dcs,
assigneeUserId: owner,
assignStrategy: AssignStrategy.DEDICATED,
selectionMode: c.selectionMode,
});
} else {
overflow.push(c);
}
}
// ── 第二趟:溢出**水位法** ────────────────────────────────
// 每一条都给**当前水位最低**的人 —— 分完后大家手上的量趋于齐平
// ── 第二趟:无主患者走**水位法** ──────────────────────────
// 每一条都给**当前水位最低**的人 —— 用这批自由人抹平第一趟造成的不齐
//
// ⚠️ 与原来的「轮转均分」不是一回事,差别正是主管抱怨的那个场景:
// 轮转均分给每人**加一样多**,起点不齐(有人刚被专属灌了 20 条)终点还是不齐;
// 水位法给**手上最少的人先加**,把差距抹平。
// ⚠️ 必须接着第一趟的账算(load 是同一个 Map)—— 分开算的话专属大户会被再灌一轮,
// 而这一点在代码上完全不显眼。
// ⚠️ 与"轮转均分"不是一回事:均分给每人**加一样多**,起点不齐(有人刚被专属灌高)
// 终点还是不齐;水位法给**手上最少的人先加**,把差距抹平。
// ⚠️ 同水位按 userId 排序打破平局 —— 两次算出同样的分法是主管敢按确认键的前提。
let unplaced = 0;
for (const c of overflow) {
for (const c of freeAgents) {
let who: string | null = null;
let best = Infinity;
for (const a of agents) {
......@@ -500,23 +516,17 @@ export function placeAgents(
}
}
if (who == null) {
// ⚠️ 水位法**没有上限**,所以这里正常到不了:只有名册为空、
// 或所有人都被精调成 0 名额时才会落到这一支。⛔ 别把它读成"团队满了"。
// 名册为空,或所有人都被精调成 0 名额
unplaced++;
continue;
}
take(who);
const dcs = dedicatedByPatient.get(c.patientId);
placed.push({
planId: c.planId,
patientId: c.patientId,
assigneeUserId: who,
// ⭐ 两种铺平必须分开记:有专属只是没轮到(关系还在) vs 从头没有专属(关系本就薄)。
// 混成一个值,T20 算出来的"铺平完成率低"就分不清是策略问题还是人群问题。
assignStrategy:
dcs && rosterIds.has(dcs)
? AssignStrategy.SPREAD_OVERFLOW
: AssignStrategy.SPREAD_NO_DEDICATED,
// 这一趟全是**从头就没有可用专属**的人(有专属的在第一趟就分流走了)
assignStrategy: AssignStrategy.SPREAD_NO_DEDICATED,
selectionMode: c.selectionMode,
});
}
......@@ -538,7 +548,7 @@ function pickEvenly<T>(arr: T[], n: number): T[] {
/**
* 正整数校验(Json 里读出来的东西什么都可能是)。
* ⛔ 这里**不做上下限** —— 这些数是主管对自己团队的判断,系统没有依据卡他(见 BATCH_SIZE_DEFAULT)
* ⛔ 这里**不做上下限** —— 这些数是主管对自己团队的判断,系统没有依据卡他。
*/
function posInt(v: unknown): number | null {
return typeof v === 'number' && Number.isInteger(v) && v > 0 ? v : null;
......@@ -596,6 +606,8 @@ function basisNote(x: {
expiresInDays: number;
basis: 'inherited' | 'default' | 'explicit';
basisFrom: string | null;
/// 基数(要被记住的那个);⚠️ 与 target 不同 —— 候选不够时 target 被压低
batchSize: number;
target: number;
loads: Array<{ name: string; after: number }>;
overrides: Array<{ name: string; maxThisBatch?: number; expiresInDays?: number }>;
......@@ -621,13 +633,16 @@ function basisNote(x: {
)
.join('、')}(单独精调,会一直沿用到您改回来);`
: '';
// ⚠️ 候选不够被压低时要说清"基数还是那个" —— 否则主管以为系统偷偷把他的设置改小了
const capped =
x.target < x.batchSize ? `(候选只有 ${x.target} 人,本批就这么多;基数不变)` : '';
const from =
x.basis === 'inherited' && x.basisFrom
? `本批人数 ${x.target} / 时效 ${x.expiresInDays} 天**沿用 ${ymd(x.basisFrom)} 那次分配**,要改直接说(如「这批 200 人」「给 5 天」),下次自动记住。`
? `本批人数 ${x.batchSize}${capped} / 时效 ${x.expiresInDays} 天**沿用 ${ymd(x.basisFrom)} 那次分配**,要改直接说(如「这批 200 人」「给 5 天」),下次自动记住。`
: x.basis === 'explicit'
? `本批人数 ${x.target} / 时效 ${x.expiresInDays} 天是**您本次指定的**,确认后下次自动沿用。`
: `本批人数 ${x.target} / 时效 ${x.expiresInDays} 天是**首次默认值**(无历史可沿用,也无数据反推团队真实吞吐)——` +
`觉得不合适直接说个数,确认后下次自动沿用。`;
? `本批人数 ${x.batchSize}${capped} / 时效 ${x.expiresInDays} 天是**您本次指定的**,确认后下次自动沿用。`
: `本批人数 ${x.batchSize}${capped}(首次按在岗 ${x.agents} 位 × ${BATCH_SIZE_PER_AGENT_FIRST} 估)/ ` +
`时效 ${x.expiresInDays} 天(**默认值**,无历史结案数据可反推)—— 觉得不合适直接说个数,确认后下次自动沿用。`;
return `${water}${tuned}${from}`;
}
......@@ -638,6 +653,7 @@ function emptyProposal(
rosterNote: string,
base: {
target: number;
batchSize: number;
expiresInDays: number;
basis: 'inherited' | 'default' | 'explicit';
basisFrom: string | null;
......@@ -649,6 +665,7 @@ function emptyProposal(
potentialTreatment: potentialTreatment ?? null,
candidateTotal: 0,
target: base.target,
batchSize: base.batchSize,
expiresInDays: base.expiresInDays,
basis: base.basis,
basisFrom: base.basisFrom,
......@@ -660,11 +677,11 @@ function emptyProposal(
// 空提案里所有在岗的人都"没分到"
skippedAgents: agents.map((a) => ({ userId: a.userId, name: a.name, inHand: a.inHand })),
rosterNote,
basisNote: `本批人数 ${base.target} / 时效 ${base.expiresInDays} 天。`,
basisNote: `本批人数 ${base.batchSize} / 时效 ${base.expiresInDays} 天。`,
selectionNote:
agents.length === 0
? '该诊所名册为空,无法出分配方案 —— 请主管直接指定客服。'
: // target=0 只能是主管自己说的(基数默认永远 > 0)
: // 基数=0 只能是主管自己说的(默认估法永远 > 0)
'本批人数被设成了 0,没有可分配的量 —— 说个数字我重出一版。',
};
}
......@@ -39,20 +39,32 @@ describe('placeAgents —— 专属优先', () => {
});
/**
* 🔴🔴 **专属那一趟也受目标水位约束**(2026-08-03)。
* 🔴🔴 **客服不能接别人的专属患者**(2026-08-03 产品定,硬约束)。
*
* 容量删掉之后没有任何东西约束专属了,本地实测一次:100 条里 76 条是同一个人的专属,
* 他一个人吃到 76,水位法只剩零头可铺 —— 「最后保持齐平」当场落空。
* 目标水位 =(团队在手 + 本批人数)/ 在岗人数,不是新旋钮,由 N 推出来
* 有专属客服(且在名册内)的患者**只有一个去处**。这条推翻了原来的
* 「专属已满 → 溢出转铺平」—— 那时 spread_overflow 表达"有专属只是没轮到",
* 现在这种情况不会再产生(枚举值保留,老批次还在用)
*/
test('🔴 3 条全是 a 的专属、两人在岗 → a 只拿应得的 2 条,第 3 条溢出给 b', () => {
test('🔴 3 条全是 a 的专属、两人在岗 → **全给 a**,⛔ 一条都不许溢给 b', () => {
const chosen = pick(3);
const dedicated = new Map(chosen.map((c) => [c.patientId, 'a']));
const { placed } = placeAgents(chosen, [agent('a', 0), agent('b', 0)], dedicated);
expect(placed.filter((p) => p.assigneeUserId === 'a')).toHaveLength(2);
// ⚠️ 溢出的那条**仍然标 spread_overflow**:关系还在,只是这轮份额满了
const spilled = placed.find((p) => p.assigneeUserId === 'b')!;
expect(spilled.assignStrategy).toBe(AssignStrategy.SPREAD_OVERFLOW);
const { placed, unplaced } = placeAgents(chosen, [agent('a', 0), agent('b', 0)], dedicated);
expect(placed.filter((p) => p.assigneeUserId === 'a')).toHaveLength(3);
expect(placed.filter((p) => p.assigneeUserId === 'b')).toHaveLength(0);
expect(unplaced).toBe(0);
expect(placed.every((p) => p.assignStrategy === AssignStrategy.DEDICATED)).toBe(true);
});
test('🔴 专属客服本批名额用完 → 剩下的**落不下**(unplaced),⛔ 不许改派', () => {
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,
);
expect(placed).toHaveLength(2);
expect(placed.every((p) => p.assigneeUserId === 'a')).toBe(true);
// ⚠️ b 手上很空,但那 3 个患者是 a 的 —— 宁可不分,也不能给 b
expect(unplaced).toBe(3);
});
test('⭐ 专属客服**不在名册** → 走铺平,且标 spread_no_dedicated', () => {
......@@ -72,21 +84,19 @@ describe('placeAgents —— 专属优先', () => {
expect(placed.every((p) => p.assignStrategy === AssignStrategy.SPREAD_NO_DEDICATED)).toBe(true);
});
test('⭐⭐ 专属**已满** → 溢出给别人,且标 spread_overflow(与"无专属"区分开)', () => {
const chosen = pick(3);
const dedicated = new Map(chosen.map((c) => [c.patientId, 'a']));
// a 本批名额只有 1 个(精调)
const { placed } = placeAgents(chosen, [agent('a', 0), agent('b', 0)], dedicated, (u) =>
u === 'a' ? 1 : Infinity,
);
test('⭐⭐ 有主与无主混在一批 → 有主的归各自专属,无主的才参与铺平', () => {
const chosen = pick(4);
// 前两条是 a 的专属,后两条无主
const dedicated = new Map(chosen.slice(0, 2).map((c) => [c.patientId, 'a']));
const { placed } = 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(1);
// 关系还在,只是这次没轮到他 —— 与 spread_no_dedicated 是两群人
expect(byStrategy[AssignStrategy.SPREAD_OVERFLOW]).toBe(2);
expect(byStrategy[AssignStrategy.SPREAD_NO_DEDICATED]).toBeUndefined();
expect(byStrategy[AssignStrategy.DEDICATED]).toBe(2);
expect(byStrategy[AssignStrategy.SPREAD_NO_DEDICATED]).toBe(2);
// ⭐ 水位法把无主的两条都给了空着的 b —— 正好抹平第一趟造成的不齐
expect(placed.filter((p) => p.assigneeUserId === 'b')).toHaveLength(2);
});
});
......@@ -122,13 +132,17 @@ describe('placeAgents —— 溢出水位法', () => {
expect(placed.filter((p) => p.assigneeUserId === 'b')).toHaveLength(4);
});
test('🔴 专属吃到目标水位就转铺平,最终两人齐平(6 条 → 3 / 3)', () => {
// 6 条里前 4 条是 a 的专属,但目标水位 = 6/2 = 3 → a 拿 3,第 4 条溢出
/**
* ⚠️ 「最后齐平」是**尽力而为不是保证**:第一趟专属独占先把 a 灌到 4,
* 第二趟只有 2 条无主的可用,全给 b 也只到 2 —— 抹不平就是抹不平,
* ⛔ 系统不许为了好看把 a 的患者偷偷分给 b(硬约束)。
*/
test('🔴 专属灌高抹不平时:无主的全给低水位那位,但不硬凑齐平', () => {
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);
expect(placed.filter((p) => p.assigneeUserId === 'a')).toHaveLength(3);
expect(placed.filter((p) => p.assigneeUserId === 'b')).toHaveLength(3);
expect(placed.filter((p) => p.assigneeUserId === 'a')).toHaveLength(4);
expect(placed.filter((p) => p.assigneeUserId === 'b')).toHaveLength(2);
});
test('⭐⭐ 精调成 0 名额的人**一条都不给**', () => {
......@@ -146,17 +160,15 @@ describe('placeAgents —— 溢出水位法', () => {
expect(unplaced).toBe(3);
});
test('⭐⭐ 专属段与铺平段**共用同一本账**(否则专属大户会被再灌一轮)', () => {
// 6 条全是 a 的专属;a 本批名额 2 → 2 条 dedicated,4 条该溢出给 b
test('⭐⭐ 两趟**共用同一本账**:专属灌高的人在铺平段不会被当成"还很空"再吃一轮', () => {
// 前 3 条是 a 的专属,后 3 条无主 → a 已到 3,无主的应该全给 b
const chosen = pick(6);
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 dedicated = new Map(chosen.slice(0, 3).map((c) => [c.patientId, 'a']));
const { placed, unplaced } = placeAgents(chosen, [agent('a', 0), agent('b', 0)], dedicated);
expect(unplaced).toBe(0);
// ⚠️ 若两段各自算账,a 会在铺平段又被当成"还有名额"再吃一轮 —— 这一点在代码上完全不显眼
expect(placed.filter((p) => p.assigneeUserId === 'a')).toHaveLength(2);
expect(placed.filter((p) => p.assigneeUserId === 'b')).toHaveLength(4);
// ⚠️ 若两段各自算账,a 会在铺平段又被当成"手上是 0"再吃一轮 —— 代码上完全不显眼
expect(placed.filter((p) => p.assigneeUserId === 'a')).toHaveLength(3);
expect(placed.filter((p) => p.assigneeUserId === 'b')).toHaveLength(3);
});
test('名册为空 → 一条都落不上,不报错', () => {
......@@ -242,17 +254,17 @@ describe('selectionNote —— 候选不够时的措辞', () => {
return new AssignmentProposalService(prisma, roster);
}
const SCOPE = { hostId: 'h', tenantId: 't', sourceUnits: [], clinicIds: ['c1'], userId: 'u' };
/// 首次默认批次人数(BATCH_SIZE_DEFAULT)
const N = 100;
/// 首次估法:在岗 9 人 × 20 = 180(⚠️ 那个 20 不是容量上限,只是首次没有历史时的估法)
const N = 9 * 20;
test('⭐⭐ 候选 44 < 本批 100 → 说「一共就 44 人,全部纳入」,⛔ 不许说「取前 N 人」', async () => {
test('⭐⭐ 候选 44 < 基数 180 → 说「一共就 44 人,全部纳入」,⛔ 不许说「取前 N 人」', async () => {
const r = await svcWith(44, 9).propose(SCOPE, { clinicId: 'c1', potentialTreatment: 'implant' });
expect(r.selectionNote).toContain('一共就 44 人');
expect(r.selectionNote).toContain('全部纳入');
expect(r.selectionNote).not.toContain('取前');
});
test('⭐ 候选 500 > 本批 100 → 照实说「从 500 位候选里取前 100 人」+ 剩下的还在排队', async () => {
test('⭐ 候选 500 > 基数 180 → 照实说「从 500 位候选里取前 180 人」+ 剩下的还在排队', async () => {
const r = await svcWith(500, 9).propose(SCOPE, { clinicId: 'c1', potentialTreatment: 'implant' });
expect(r.target).toBe(N);
expect(r.selectionNote).toContain(`从 500 位候选里取前 ${N} 人`);
......@@ -354,12 +366,26 @@ describe('基数沿用 —— 本批人数与时效', () => {
expect(r.basis).toBe('inherited');
});
test('⭐ 从来没分过 → 首次默认(100 人 / 3 天),且明说是默认', async () => {
test('⭐ 从来没分过 → 首次按「在岗 9 人 × 20」估 = 180 / 3 天,且说清是估的', async () => {
const r = await mk(1000, 9).propose(SCOPE, { clinicId: 'c1' });
expect(r.target).toBe(100);
expect(r.batchSize).toBe(180);
expect(r.target).toBe(180);
expect(r.expiresInDays).toBe(3);
expect(r.basis).toBe('default');
expect(r.basisNote).toContain('首次默认值');
expect(r.basisNote).toContain('在岗 9 位 × 20');
});
/**
* 🔴 候选不够时压低的是**这一批**,⛔ 不是基数。
* 回写的话:主管点一次 44 人的小格子,以后所有批次永远是 44 人,而他看不出为什么。
*/
test('🔴 上次 300 人、这批候选只有 44 → 本批 44,但基数仍是 300', async () => {
const r = await mk(44, 9, { lastCriteria: { batchSize: 300, expiresInDays: 5 } }).propose(SCOPE, {
clinicId: 'c1',
});
expect(r.target).toBe(44);
expect(r.batchSize).toBe(300);
expect(r.basisNote).toContain('基数不变');
});
test('⭐ 主管本次说「这批 200 人」→ explicit,且下次会沿用', async () => {
......
......@@ -109,9 +109,10 @@ export function AssignmentConfirmSheet({
selectionNote: sheet.selectionNote,
// ⭐ 两个基数落进快照 —— **下一次分配就是从这里读出来沿用的**(零新列)。
// ⚠️ 时效存**卡片当前值**不是提案值:主管刚在上面改成 5 天,记住的就得是 5。
// ⚠️ 存 batchSize 这个**新键**,同时 target 仍在(上面)—— 沿用时两个都认,
// 老批次只有 target,新批次两个都有,改版不丢"沿用"
batchSize: sheet.target,
// ⚠️ 存的是**基数**不是 target —— 候选不够时 target 被压低,那是这一批的偶然事实。
// 存 target 的话,主管点一次 44 人的小格子,以后所有批次就永远是 44 人。
// ⚠️ 同时 target 也在(上面)—— 沿用时两个键都认,改版不丢"沿用"。
batchSize: sheet.batchSize,
expiresInDays,
// ⭐ 按客服的精调也进快照 —— 下次一并沿用(容量部分原样带走,时效部分用卡片当前值)
agentOverrides: mergeOverrides(sheet.agentOverrides, expiryByAgent),
......@@ -320,7 +321,9 @@ export function AssignmentConfirmSheet({
本批 {sheet.target}
</span>
<span className="text-[10px] text-slate-400">
要改说「这批 {sheet.target * 2} 人」· 池子里还有人随时能再分一批
{sheet.target < sheet.batchSize
? `候选只有这么多(基数 ${sheet.batchSize} 不变)`
: `要改说「这批 ${sheet.batchSize * 2} 人」· 池子里还有人随时能再分一批`}
</span>
</div>
<p className="text-[10.5px] leading-relaxed text-slate-400">
......
......@@ -76,10 +76,18 @@ v1 **轻量**:不核销、不接宿主福利数据,福利就是**话术勾
(2026-08-03 产品定;同日两次改判,下面把弯路一并记下来,别再走一遍)
| 基数 | 含义 | 首次默认 |
|---|---|---|
| **本批人数 N** | 这一轮推多少人 | 100 |
| **时效** | 这批单子多久没动就自动回池 | 3 天 |
| 基数 | 含义 | 首次 | 之后 |
|---|---|---|---|
| **本批人数 N** | 这一轮推多少人 | `min(在岗人数 × 20, 候选总数)` | `min(上一次的 N, 候选总数)` |
| **时效** | 这批单子多久没动就自动回池 | 3 天 | 沿用上一次 |
⚠️ 首次那个 **20 不是容量上限**,只是"第一次没有任何历史时,一批推多大"的估法,
之后就再也不出现(沿用主管上次用的数)。
🔴 **与候选总数取小的那一步压低的是 `target`,⛔ 不回写基数。**
候选不够是**这一批的偶然事实**:主管点一次 44 人的小格子,若把 44 存成基数,
以后所有批次就永远是 44 人,而他完全看不出为什么变小了(界面只会显示"沿用上次")。
所以确认单同时给两个数:`batchSize`(基数,被记住的)与 `target`(本批实际,= min 之后)。
**没有"容量"这第三个数。** 它被删过一次,别再加回来。
......@@ -92,22 +100,30 @@ v1 **轻量**:不核销、不接宿主福利数据,福利就是**话术勾
> `inHand` 本身就是负载,落人走水位法直接拿它排序。再挂一条"不强制的上限"比没有更糟:
> 主管会以为系统在拦,其实没拦(T14:别给假证据)。
**落人 = 专属优先 → 溢出水位法。**
### 🔴 硬约束:**客服不能接别人的专属患者**
1. **第一趟 · 专属**:该患者有专属客服且在名册内 → 给他,**但受目标水位约束**(下条)。
2. **第二趟 · 水位法**:每一条都给**当前在手最少**的那个人;同水位按 userId 打破平局(确定性)。
⛔ 不是"每人加一样多" —— 起点不齐时,均分增量的终点还是不齐。
(2026-08-03 产品定)有专属客服(且在名册内)的患者,**只有一个去处** —— 那个人。
给不下就落不下(计入 `unplaced`),⛔ 不允许溢出给第三个人。
**目标水位 = (团队现有在手 + N) / 在岗人数**(向上取整,至少 1)。它**不是新旋钮**,完全由 N 推出来,
存在的唯一理由是**给专属那一趟封顶**
> 这条**推翻**了原来的「专属已满 → 溢出转铺平」。`AssignStrategy.SPREAD_OVERFLOW`
> 曾表达"有专属只是没轮到",现在这种情况**不会再产生**(枚举值保留,老批次还在用)。
> 容量删掉之后没有任何东西约束专属了。本地实测:100 条里 76 条是同一个人的专属,
> 他一个人吃到 76,水位法只剩零头可铺 —— 「最后保持齐平」当场落空。
> 加上水位约束后同一批:**每人在手 5~6 条**。
>
> ⚠️ 这不是"不给专属了",是**他这批的份额满了**,后面同样属于他的患者转铺平
> (仍标 `spread_overflow`,关系还在、只是这轮没轮到)。语义与原来
> 「专属已达容量 → 溢出转铺平」完全一致,只是把"容量"换成了"这批的应得份额"。
**落人两趟,分工由这条硬约束决定:**
1. **专属独占**:有专属的患者先落到各自专属客服头上 —— 他们没有 plan B,先占坑。
⚠️ `maxThisBatch` 精调**压过专属**:主管说了只给李莉 5 条,第 6 个她的专属患者就落不下,
⛔ 不许改派。
2. **无主患者走水位法**:无专属 / 专属已离岗的患者,每一条都给**当前在手最少**的人;
同水位按 userId 打破平局(确定性)。⛔ 不是"每人加一样多" —— 起点不齐时终点还是不齐。
这批自由人的作用就是**抹平第一趟造成的不齐**
⚠️ **「最后齐平」是尽力而为,不是保证。** 本地实测:N=340 时,康慧捧一个人是其中 248 人的专属,
他就会拿 248 条 —— 那 248 个患者只有他能接。
主管的调节手段是给他设 `maxThisBatch`、或换个更小的 N,
**不是让系统偷偷分给别人**(那正是这条硬约束禁止的事)。
> 中途曾试过给专属那一趟加"目标水位"封顶(`(在手+N)/人数`),实测确实齐平(每人 5~6 条)。
> 但它与本硬约束直接冲突 —— 超出份额的专属患者会被改派给第三个人。硬约束赢,水位约束撤掉。
**两个基数都可以按客服精调**`agentOverrides: userId → {maxThisBatch?, expiresInDays?}`):
......
......@@ -83,14 +83,16 @@ export type CreateAssignmentRequest = z.infer<typeof CreateAssignmentRequestSche
* ⛔ 所以别把它们做成"每次弹窗问一遍"或"永远用系统默认" —— 前者违 T13(直出不追问),
* 后者让第一次的调整白费。
*
* ⚠️ **没有"容量"这第三个数**(它被删过一次,别再加回来 —— 理由见 BATCH_SIZE_DEFAULT)。
* ⚠️ **没有"容量"这第三个数**(它被删过一次,别再加回来 —— 理由见 BATCH_SIZE_PER_AGENT_FIRST)。
* 负载不靠阈值表达:落人走**水位法**(从在手最少的人开始填),手上多的人这轮自然少拿。
*/
/**
* **首次**分配的批次人数(之后沿用上一次)—— 基数①「这一轮推多少人」
* **首次**分配时,每个在岗客服按多少条估 —— 首批 N = `min(在岗人数 × 20, 本批实际候选数)`
*
* ⭐ 2026-08-03 定案:分配只有 **本批人数 N** 和 **时效** 两个基数,⛔ **没有"容量"这个数**。
* 这里的 20 **不是容量上限**,只是"第一次没有任何历史时,一批推多大"的估法 ——
* 它只在**首次**参与一次计算,之后 N 沿用主管上次用的值,这个常量再也不出现。
*
* ── 为什么容量被删掉 ────────────────────────────────────────
* 它当过一阵"每人在手上限",并用来推批次规模(`Σ 容量−在手`)。两个问题:
......@@ -100,11 +102,9 @@ export type CreateAssignmentRequest = z.infer<typeof CreateAssignmentRequestSche
* 水位法直接拿它排序、从最空的人开始填,压力已经被表达了。
* 再挂一条"不强制的上限"比没有更糟 —— 主管会以为系统在拦,其实没拦(T14:别给假证据)。
*
* ⚠️ 取 100 是 T5「宁可 100 人做透,不做 1000 人做浅」的量级,**仍是默认值**:
* 没有任何数据证明 100 比 80 或 150 好,等 T20 沉淀出「批次规模 × 完成率」再替换。
* ⛔ 别再给它加"上下限":这是主管对自己团队的判断,系统没有依据卡他。
* ⛔ 别给 N 加上下限:这是主管对自己团队的判断,系统没有依据卡他。
*/
export const BATCH_SIZE_DEFAULT = 100;
export const BATCH_SIZE_PER_AGENT_FIRST = 20;
/// **首次**分配的时效起点(之后沿用上一次)。同批次人数,是基数不是常量。
export const ASSIGNMENT_EXPIRES_DAYS_DEFAULT = 3;
......@@ -125,7 +125,7 @@ export const AgentInfoSchema = z.object({
/// 是否在名册内。⚠️ **不是"能不能分"** —— 名册是建议来源不是白名单,
/// 实测有客服只做召回不做回访(回访数 0),分给他完全合法
inRoster: z.boolean(),
/// ⛔ 这里**不返回容量/上限之类的数** —— 那个概念已经删掉(见 BATCH_SIZE_DEFAULT)。
/// ⛔ 这里**不返回容量/上限之类的数** —— 那个概念已经删掉(见 BATCH_SIZE_PER_AGENT_FIRST)。
/// `inHand` 就是负载本身,落人按它走水位法;返回一个区间只会被读成"合法范围"。
});
export type AgentInfo = z.infer<typeof AgentInfoSchema>;
......@@ -321,7 +321,14 @@ export const AssignmentProposalSchema = z.object({
/// ⭐ 与矩阵格子同一种数法(count DISTINCT patient_id),⛔ 不受取明细的 LIMIT 影响 ——
/// 从矩阵点进来的主管会拿这个数跟他刚看到的格子对
candidateTotal: z.number().int().describe('候选总数 = 该格子/该条件下的患者数(与矩阵格子对得上)'),
target: z.number().int().describe('本批人数 N(基数①:沿用上次 / 首次默认 / 本次指定)'),
target: z.number().int().describe('本批**实际**分多少人 = min(基数 N, 候选总数)'),
/**
* 基数 N —— **要被记住的那个数**(沿用上次 / 首次按 在岗人数×20 估 / 本次主管指定)。
* ⚠️ 与 `target` 分开:候选不够时 target 会被压低,但那是**这一批的偶然事实**,
* ⛔ 不能回写成基数 —— 否则点一次 44 人的小格子,以后所有批次就永远是 44 人,
* 而主管完全看不出为什么变小了。
*/
batchSize: z.number().int(),
/// ── 两个基数,连同它们的**出处**一起下发 ────────────────────────
/// ⚠️ 出处必须跟着值走:主管看到「容量 20」时,「这 20 是哪来的」和这个数本身一样重要 ——
/// 沿用上次 / 首次默认 / 他自己刚说的,三者对应完全不同的信任度(T14:界面元素也算证据)。
......
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