Commit 39cd460a by luoqi

feat(plan): 落人改三趟「专属封顶 → 无主补空 → 有主改派」,做到又满又平

主管走查:一批 340 人分布是 248/34/31/2×14,完全不齐。查了数据,根因是
专属关系极度集中 —— 池子 1,081 人里 755 人(70%)挂在同一个客服名下,
而 17 位在岗中有 10 位名下一个患者都没有,自由患者只有 119。

上一版的硬约束(客服不能接别人的专属患者,给不下就 unplaced)让"满"和"平"
不可能同时成立:严格齐平上限是每人 9 条、一批只有 153 人。
按产品意见改成**优先级**而不是禁止:

① 专属:分给他,但**封顶在目标水位**((团队在手+N)/在岗人数,由 N 推出,非新旋钮)
② 无主补空:无主/专属已离岗的患者给最空的人 —— 拿他们填坑零代价
③ 有主改派:无主的用完还没填平,才把超出水位的专属患者改派出去,标 spread_overflow

️ ②③ 的先后不能颠倒:两趟都是水位法、总量一样,但**拆散的专属关系数不一样**。
合成一趟会随机改派某个有主患者,而同时某个无主患者落给了别人。

实测同一批:248/34/31/2×14 → **每人 20 条,17 位完全齐平,340 条一条不丢**。
spread_overflow 枚举复活(硬约束那版里它"不再产生"),别当死枚举清掉。
989 tests green;教条把两条弯路都记了进去。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent 61537b7d
......@@ -83,14 +83,14 @@ const DISPATCHER_EXTRA = `
⚠️ 标了 multi 的维度一个人可命中多项,合计大于总人数,**别拿它算百分比**。
### 拟分方案怎么给(主管会把关,你负责有理有据)
- 🔴 **客服不能接别人的专属患者。** 有专属客服(且在名册内)的患者**只有一个去处**,
分不下就是分不下(计入 unplaced),⛔ 绝不能说"改派给别人"或"铺给其他客服"。
- **专属独占**:有专属的患者先落到各自专属客服头上(他们没有第二个去处)。
- **无主患者走水位法**:没有专属 / 专属已离岗的患者,**每一条都给当前手上最少的那个人**,
用这批自由人抹平第一趟造成的不齐。⛔ 不是"每人加一样多"(起点不齐终点还是不齐)
⚠️ 「最后齐平」是**尽力而为不是保证**:100 个人里 76 个是同一个人的专属,他就是会拿 76 条
主管的调节手段是给他设本批名额(agentOverrides)或换个更大的 N,⛔ 别建议"分给别人"。
本批一条没分到的人仍会列出来,⛔ 别说成"他被跳过了"
- **三趟落人,目标是「又满又平」**(N 个名额全部落地 + 分完后大家在手量齐平):
① **专属**:有专属且在名册内 → 分给他,但**封顶在目标水位**((团队在手+N)/在岗人数);
② **无主补空**:无专属 / 专属已离岗的患者,给当前手上最少的人 —— 拿他们填坑零代价;
③ **有主改派**:无主的用完还没填平,才把超出水位的专属患者改派给最空的人,标「铺平」。
- ⚠️ 顺序不能乱:**无主的先用**,能不动专属关系就不动
- ⚠️ 改派**不是"抢客户"**:标 spread_overflow 的意思是「关系还在,只是这轮没轮到」
主管问"为什么李莉的患者给了别人"就这么说,⛔ 别说成"系统重新分配了归属"。
- 水位法**不是"每人加一样多"** —— 起点不齐时那样终点还是不齐;是每条都给当前最少的那个
- **⛔ 没有"容量上限"这个东西。** 负载就是在手量本身,水位法已经在照顾它。
主管问"会不会分太多"就照 basisNote 说分完后每人多少条,⛔ 不要编一个"上限"出来。
- **两个基数:本批人数 + 时效,都自动沿用主管上一次的值** —— ⛔ 别问他,那正是这个设计要省掉的输入。
......
......@@ -122,7 +122,8 @@ export class AssistantService {
'\n⚠️ **这只是提案,一个字都没写库**。绝不要说「已经分配好了」。' +
'\n⚠️ 本批人数与时效**会自动沿用主管上一次的值**,⛔ 别问他 —— 那正是这个设计要省掉的输入。' +
'他明确说「这批 200 人」「给 5 天」时才传 targetCount / expiresInDays(都会被记住)。' +
'\n⚠️ **没有"容量上限"** —— 落人走水位法(先给手上最少的),池子里还有人就随时能再分一批。',
'\n⚠️ **没有"容量上限"** —— 落人走「专属(封顶在目标水位)→ 无主补空 → 有主改派」三趟,' +
'结果又满又平;池子里还有人就随时能再分一批。',
inputSchema: jsonSchema({
type: 'object',
properties: {
......
......@@ -421,26 +421,28 @@ export class AssignmentProposalService {
}
/**
* ② 落人:**专属独占 → 无主患者水位法铺平**
* ② 落人:**专属优先 → 无主补空 → 有主改派**,三趟共用同一本水位账
*
* ⭐ 导出成**纯函数**(不进 class):它是本文件里唯一值得单独测的算法,
* 而纯函数不需要起 Nest 容器、不需要 mock Prisma,测试成本低一个量级。
*
* ═══ 🔴 硬约束(2026-08-03 产品定):**客服不能接别人的专属患者** ═══
* 有专属客服(且在名册内)的患者,**只有一个去处** —— 那个人。
* 给不下就落不下(unplaced),⛔ 不允许溢出给第三个人
* ═══ 目标:**又满又平** ═══════════════════════════════════════
* 满 = N 个名额全部落地(⛔ 不因为"他的专属满了"就把人丢掉);
* 平 = 分完后大家在手量趋于齐平
*
* 这条推翻了原来的「专属已满 → 溢出转铺平」:那时 `SPREAD_OVERFLOW` 表达的是
* "有专属只是没轮到",现在这种情况**不会再产生**(枚举值保留,老批次还在用)。
* ── 为什么必须是三趟,不能合成两趟 ────────────────────────────
* 关键在**第二、三趟的先后**:两趟都是水位法、都填最空的人,合并起来总量一样,
* 但**被拆散的专属关系数不一样** —— 先用无主的去补空手的人,能少动一个有主患者。
* 合成一趟的话,系统会随机地把某个有主患者改派出去,而同时某个无主患者落给了别人。
*
* ── 于是两趟的分工变了 ──────────────────────────────────────
* 第一趟 **专属独占**:有专属的患者先落 —— 他们没有 plan B,先占坑;
* 第二趟 **水位法**:只有**无主患者**(无专属 / 专属已离岗)参与,
* 每一条都给当前在手最少的人 → 用这批自由人去**抹平**第一趟造成的不齐。
* ── 目标水位 = (团队现有在手 + 本批人数) / 在岗人数 ──────────────
* **不是新旋钮**,完全由 N 推出来。存在的唯一理由是**给第一趟封顶**:
* 不封顶的话,专属集中的诊所会出现"一个人吃掉整批"——
* 本地实测:池子 1,081 人里 755 人(70%)挂在同一个客服名下,不封顶他一批拿 248 条,
* 而 17 位在岗客服里有 10 位名下一个患者都没有,只能靠 119 个自由患者过活。
*
* ⚠️ 「最后齐平」现在是**尽力而为**,不是保证:如果 100 个人里 76 个是同一个人的专属,
* 他就是会拿 76 条 —— 那 76 个患者只有他能接。主管的调节手段是
* `maxThisBatch`(给他设本批名额)或换个更大的 N,⛔ 不是让系统偷偷分给别人。
* ⚠️ 超出水位的专属患者**不是被丢掉**,是进第三趟改派(标 `SPREAD_OVERFLOW`:
* 关系还在、只是这轮没轮到)。⛔ 别把这一步理解成"抢客户"。
*/
export function placeAgents(
chosen: Array<{ planId: string; patientId: string; selectionMode: 'rank' | 'explore' }>,
......@@ -450,14 +452,13 @@ export function placeAgents(
* 本批给某人的**名额上限**(精调过才有,默认 Infinity = 不限)。
* ⛔ 这不是"容量" —— 容量那个概念已经删了。它只表达
* 「李莉这周带教,这批最多给 5 条」这种一次性名额,0 = 这轮不给他。
* ⚠️ 它**压过专属**:主管说了只给 5 条,第 6 个她的专属患者就落不下(unplaced),
* ⛔ 不许转给别人 —— 那正是上面那条硬约束禁止的事。
* ⚠️ 它**压过一切**(含专属):主管说了只给 5 条,第 6 个她的专属患者会被改派给别人。
*/
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);
......@@ -470,22 +471,26 @@ export function placeAgents(
const d = dedicatedByPatient.get(patientId);
return d && rosterIds.has(d) ? d : null;
};
/// ⚠️ 向上取整 + 至少 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;
const placed: ProposedItem[] = [];
const freeAgents: typeof chosen = [];
const free: typeof chosen = []; // 无主 / 专属已离岗
const held: typeof chosen = []; // 有主,但专属这轮的份额已满
let unplaced = 0;
// ── 第一趟:专属独占(他们没有 plan B,先占坑)──────────────
// ── 第一趟:专属命中(受目标水位封顶)────────────────────────
for (const c of chosen) {
const owner = ownerOf(c.patientId);
if (owner == null) {
freeAgents.push(c);
free.push(c);
continue;
}
if (!canTake(owner)) {
// 专属客服本批名额已满 → **这个患者本批落不下**。
// ⛔ 不许改派:他有专属,别的客服不能接(硬约束)。
unplaced++;
if (!canTake(owner) || (load.get(owner) ?? 0) >= waterline) {
held.push(c);
continue;
}
take(owner);
......@@ -498,25 +503,31 @@ export function placeAgents(
});
}
// ── 第二趟:无主患者走**水位法** ──────────────────────────
// 每一条都给**当前水位最低**的人 —— 用这批自由人抹平第一趟造成的不齐。
//
// ⚠️ 与"轮转均分"不是一回事:均分给每人**加一样多**,起点不齐(有人刚被专属灌高)
// 终点还是不齐;水位法给**手上最少的人先加**,把差距抹平。
// ⚠️ 同水位按 userId 排序打破平局 —— 两次算出同样的分法是主管敢按确认键的前提。
for (const c of freeAgents) {
/**
* 水位法取人:给**当前水位最低**的那个;同水位按 userId 打破平局(确定性 ——
* 同样的输入两次算出同样的分法,是主管敢按确认键的前提)。
* 返回 null = 没人能收(名册空 / 全被精调成 0 名额)。
*/
const lowest = (): string | null => {
let who: string | null = null;
let best = Infinity;
for (const a of agents) {
if (!canTake(a.userId)) continue; // 精调成 0 / 已到本批名额上限
if (!canTake(a.userId)) continue;
const l = load.get(a.userId) ?? 0;
if (l < best || (l === best && who != null && a.userId < who)) {
best = l;
who = a.userId;
}
}
return who;
};
// ── 第二趟:**无主的先去补空手的人** ──────────────────────────
// ⭐ 顺序在这里:无主患者没有关系要顾,拿他们填坑**零代价**;
// 等无主的用完了,才不得已去动第三趟那些有主的。
for (const c of free) {
const who = lowest();
if (who == null) {
// 名册为空,或所有人都被精调成 0 名额
unplaced++;
continue;
}
......@@ -525,12 +536,32 @@ export function placeAgents(
planId: c.planId,
patientId: c.patientId,
assigneeUserId: who,
// 这一趟全是**从头就没有可用专属**的人(有专属的在第一趟就分流走了)
assignStrategy: AssignStrategy.SPREAD_NO_DEDICATED,
selectionMode: c.selectionMode,
});
}
// ── 第三趟:无主的不够了 → 有主患者改派 ────────────────────────
// ⚠️ 标 `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,
});
}
return { placed, unplaced };
}
......
......@@ -39,32 +39,49 @@ describe('placeAgents —— 专属优先', () => {
});
/**
* 🔴🔴 **客服不能接别人的专属患者**(2026-08-03 产品定,硬约束)。
* 🔴🔴 **专属受目标水位封顶,超出的改派**(2026-08-03 产品定:又满又平)。
*
* 有专属客服(且在名册内)的患者**只有一个去处**。这条推翻了原来的
* 「专属已满 → 溢出转铺平」—— 那时 spread_overflow 表达"有专属只是没轮到",
* 现在这种情况不会再产生(枚举值保留,老批次还在用)。
* 不封顶的话专属集中的诊所会一个人吃掉整批 —— 本地实测:池子 70% 挂在同一个人名下,
* 他一批拿 248 条,而 17 位在岗里有 10 位名下一个患者都没有。
*/
test('🔴 3 条全是 a 的专属、两人在岗 → **全给 a**,⛔ 一条都不许溢给 b', () => {
test('🔴 3 条全是 a 的专属、两人在岗 → a 拿 2(水位),第 3 条改派给 b 并标 overflow', () => {
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);
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);
expect(placed.filter((p) => p.assigneeUserId === 'a')).toHaveLength(2);
// ⚠️ 改派的那条标 spread_overflow:**关系还在,只是这轮没轮到**
expect(placed.find((p) => p.assigneeUserId === 'b')!.assignStrategy).toBe(
AssignStrategy.SPREAD_OVERFLOW,
);
});
test('🔴 专属客服本批名额用完 → 剩下的**落不下**(unplaced),⛔ 不许改派', () => {
test('🔴 精调名额压过专属:a 只给 2 条,她剩下的专属患者改派给 b(⛔ 不丢)', () => {
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);
expect(unplaced).toBe(0);
expect(placed.filter((p) => p.assigneeUserId === 'a')).toHaveLength(2);
expect(placed.filter((p) => p.assigneeUserId === 'b')).toHaveLength(3);
});
/**
* 🔴🔴 **顺序**:无主的先去补空手的人,不够了才动有主的。
*
* 两趟都是水位法,合并起来总量一样 —— 但**被拆散的专属关系数不一样**。
* 合成一趟的话,系统会随机地把某个有主患者改派出去,而同时某个无主患者落给了别人。
*/
test('🔴 无主的优先补空手的人 —— 有主的能不动就不动', () => {
// 4 条:2 条是 a 的专属,2 条无主;两人在岗,水位 = 2
const chosen = pick(4);
const dedicated = new Map(chosen.slice(0, 2).map((c) => [c.patientId, 'a']));
const { placed } = placeAgents(chosen, [agent('a', 0), agent('b', 0)], dedicated);
// a 拿自己的 2 条(到水位),b 拿 2 条无主的 —— ⛔ 一条专属关系都没拆
expect(placed.filter((p) => p.assignStrategy === AssignStrategy.DEDICATED)).toHaveLength(2);
expect(placed.filter((p) => p.assignStrategy === AssignStrategy.SPREAD_OVERFLOW)).toHaveLength(0);
expect(placed.filter((p) => p.assigneeUserId === 'b')).toHaveLength(2);
});
test('⭐ 专属客服**不在名册** → 走铺平,且标 spread_no_dedicated', () => {
......@@ -84,19 +101,21 @@ describe('placeAgents —— 专属优先', () => {
expect(placed.every((p) => p.assignStrategy === AssignStrategy.SPREAD_NO_DEDICATED)).toBe(true);
});
test('⭐⭐ 有主与无主混在一批 → 有主的归各自专属,无主的才参与铺平', () => {
const chosen = pick(4);
// 前两条是 a 的专属,后两条无主
const dedicated = new Map(chosen.slice(0, 2).map((c) => [c.patientId, 'a']));
test('⭐⭐ 三种归属标记各归各的(dedicated / no_dedicated / overflow)', () => {
// 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 byStrategy = placed.reduce<Record<string, number>>((m, p) => {
m[p.assignStrategy] = (m[p.assignStrategy] ?? 0) + 1;
return m;
}, {});
expect(byStrategy[AssignStrategy.DEDICATED]).toBe(2);
expect(byStrategy[AssignStrategy.SPREAD_NO_DEDICATED]).toBe(2);
// ⭐ 水位法把无主的两条都给了空着的 b —— 正好抹平第一趟造成的不齐
expect(placed.filter((p) => p.assigneeUserId === 'b')).toHaveLength(2);
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);
});
});
......@@ -133,16 +152,21 @@ describe('placeAgents —— 溢出水位法', () => {
});
/**
* ⚠️ 「最后齐平」是**尽力而为不是保证**:第一趟专属独占先把 a 灌到 4,
* 第二趟只有 2 条无主的可用,全给 b 也只到 2 —— 抹不平就是抹不平,
* ⛔ 系统不许为了好看把 a 的患者偷偷分给 b(硬约束)。
* 🔴🔴 **又满又平**:全是同一个人的专属,照样铺平到所有人头上,一条不丢。
* 这正是"专属集中的诊所"那个场景 —— 池子 70% 挂在一个人名下。
*/
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(4);
expect(placed.filter((p) => p.assigneeUserId === 'b')).toHaveLength(2);
test('🔴 9 条全是 a 的专属、3 人在岗 → 各 3 条(满且平)', () => {
const chosen = pick(9);
const dedicated = new Map(chosen.map((c) => [c.patientId, 'a']));
const { placed, unplaced } = 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,
]);
});
test('⭐⭐ 精调成 0 名额的人**一条都不给**', () => {
......@@ -160,13 +184,13 @@ describe('placeAgents —— 溢出水位法', () => {
expect(unplaced).toBe(3);
});
test('⭐⭐ 两趟**共用同一本账**:专属灌高的人在铺平段不会被当成"还很空"再吃一轮', () => {
// 前 3 条是 a 的专属,后 3 条无主 → a 已到 3,无主的应该全给 b
test('⭐⭐ 三趟**共用同一本账**:专属灌高的人在后两趟不会被当成"还很空"再吃一轮', () => {
// 前 3 条是 a 的专属,后 3 条无主 → a 到水位 3,无主的应该全给 b
const chosen = pick(6);
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 会在铺平段又被当成"手上是 0"再吃一轮 —— 代码上完全不显眼
// ⚠️ 若各趟各自算账,a 会在后面被当成"手上是 0"再吃一轮 —— 代码上完全不显眼
expect(placed.filter((p) => p.assigneeUserId === 'a')).toHaveLength(3);
expect(placed.filter((p) => p.assigneeUserId === 'b')).toHaveLength(3);
});
......
......@@ -100,30 +100,37 @@ v1 **轻量**:不核销、不接宿主福利数据,福利就是**话术勾
> `inHand` 本身就是负载,落人走水位法直接拿它排序。再挂一条"不强制的上限"比没有更糟:
> 主管会以为系统在拦,其实没拦(T14:别给假证据)。
### 🔴 硬约束:**客服不能接别人的专属患者**
### 落人 = 三趟:**专属(封顶水位)→ 无主补空 → 有主改派**
(2026-08-03 产品定)有专属客服(且在名册内)的患者,**只有一个去处** —— 那个人。
给不下就落不下(计入 `unplaced`),⛔ 不允许溢出给第三个人。
(2026-08-03 定案。同日先后试过三种,把弯路一并记下来。)
> 这条**推翻**了原来的「专属已满 → 溢出转铺平」。`AssignStrategy.SPREAD_OVERFLOW`
> 曾表达"有专属只是没轮到",现在这种情况**不会再产生**(枚举值保留,老批次还在用)。
目标是**又满又平**:N 个名额全部落地(⛔ 不因为"他的专属满了"就把人丢掉)+ 分完后大家在手量齐平。
**落人两趟,分工由这条硬约束决定:**
1. **专属**:该患者有专属客服且在名册内 → 分给他,**但封顶在目标水位**
2. **无主补空**:无专属 / 专属已离岗的患者 → 给当前在手最少的人。
⭐ 顺序在这里:这些患者**没有关系要顾,拿他们填坑零代价**
3. **有主改派**:无主的用完还没填平 → 才把超出水位的专属患者改派给最空的人,标 `spread_overflow`
1. **专属独占**:有专属的患者先落到各自专属客服头上 —— 他们没有 plan B,先占坑。
⚠️ `maxThisBatch` 精调**压过专属**:主管说了只给李莉 5 条,第 6 个她的专属患者就落不下,
⛔ 不许改派。
2. **无主患者走水位法**:无专属 / 专属已离岗的患者,每一条都给**当前在手最少**的人;
同水位按 userId 打破平局(确定性)。⛔ 不是"每人加一样多" —— 起点不齐时终点还是不齐。
这批自由人的作用就是**抹平第一趟造成的不齐**
⚠️ **第二、三趟的先后不能颠倒**:两趟都是水位法、总量一样,但**被拆散的专属关系数不一样**
合成一趟的话,系统会随机地把某个有主患者改派出去,而同时某个无主患者落给了别人。
⚠️ **「最后齐平」是尽力而为,不是保证。** 本地实测:N=340 时,康慧捧一个人是其中 248 人的专属,
他就会拿 248 条 —— 那 248 个患者只有他能接。
主管的调节手段是给他设 `maxThisBatch`、或换个更小的 N,
**不是让系统偷偷分给别人**(那正是这条硬约束禁止的事)。
**目标水位 = (团队现有在手 + N) / 在岗人数**(向上取整,至少 1)。
**不是新旋钮**,完全由 N 推出来;存在的唯一理由是给第一趟封顶。
> 中途曾试过给专属那一趟加"目标水位"封顶(`(在手+N)/人数`),实测确实齐平(每人 5~6 条)。
> 但它与本硬约束直接冲突 —— 超出份额的专属患者会被改派给第三个人。硬约束赢,水位约束撤掉。
> **为什么必须封顶**(本地实测,充填 · 窗口外):池子 1,081 人里 **755 人(70%)挂在同一个客服名下**,
> 而 17 位在岗客服中有 **10 位名下一个患者都没有**,自由患者(真无主 57 + 专属已离岗 62)只有 119。
> 不封顶时一批 340 人的分布是 **248 / 34 / 31 / 2×14**;封顶后是 **每人 20,完全齐平**。
⚠️ 改派**不是"抢客户"**`spread_overflow` 的语义是「关系还在,只是这轮没轮到」。
⛔ 助手不许说成"系统重新分配了归属"。
> **弯路一**:曾把「客服不能接别人的专属患者」做成**硬约束**(给不下就 unplaced)。
> 结果在上面那份数据下,「满」和「平」不可能同时成立 ——
> 严格齐平的上限是每人 9 条、一批只有 153 人,而那 755 个患者要几十批才轮得完。
> 改成"优先级"而不是"禁止"之后两者都成立了。
>
> **弯路二**:`AssignStrategy.SPREAD_OVERFLOW` 在硬约束那一版里"不再产生",
> 现在又回来了。⛔ 别再把它当成死枚举清理掉。
**两个基数都可以按客服精调**`agentOverrides: userId → {maxThisBatch?, expiresInDays?}`):
......
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