Commit 9cd29898 by luoqi

feat(分配): 默认时限 1 天 → 3 天

产品定。️ 时限是**乘数不是摆设**,改它连动三处,一处都不能漏:

  ① 本批人数 N = 在岗人数 × 每天 15 通 × 时限  → 一批推的人数跟着变(135 → 405,以 9 人在岗算)
  ② `daily_overload` 阈值 = 每天几通 × 时限     → 判「谁超期」的尺从 15 条变成 45 条
  ③ 对外文档里的示例式子                        → 改了默认不改示例,文档就在教一个不存在的口径

── 改了哪些 ──────────────────────────────────────────────────────
· `ASSIGNMENT_EXPIRES_DAYS_DEFAULT` 1 → 3(唯一真源;服务端两处引用它,web 全程读服务端下发,
  DB 无默认值 —— 所以没有第二个地方存着这个数)
· 测试:默认口径那几条改成断**式子**(`9 * RATE * D`), 不再写死 135/405 那个积 ——
  这个数调过两次(3→1→3),每次都要来改一遍字面量,而式子一次没变。
  另加一条**单独钉住 D=3** 的用例:式子是不变式,而"默认几天"是产品决定,改它必须是有意识的动作。
· `assignment-agent.mdx` 六处示例重算成自洽的一组:
  405 = 在岗 9 人 × 15 通 × 3 天;最忙那位 75 条 ≈ 5 天 > 3 天;建议「改成 5 天」。
· 两份文档里「daily_overload 每批基本都会亮」的说法:那是 **D=1 的推论**(阈值 15 条),
   不是这个节点的性质。D=3 之后它回到「真的压不下」才亮 —— 措辞要求( 不写「打不完/超了/过载」)
  与频率无关,照旧成立。

──  刻意没动 ───────────────────────────────────────────────────
注释里那些「× 1 天」全是**事故记录**(2026-08-14/15 实测原话),沿革改了反而失真。
`golden/run.ts` 与 signals 用例里的 `expiresInDays: 1` 是**显式传值**的夹具,不是默认值。

验证:1363 passed(+1),两个 app 的 tsc 绿,types dist 运行时读到 3。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent 645aa88c
......@@ -22,7 +22,7 @@ flowchart TD
A1["① 选人 · 主管初选<br/>1 做哪一类还没启动的治疗<br/>2 找多久没来的人<br/>—— 凭运营经验,他张口就来"] --> A2
A2["① 选人 · 助手精选<br/>3 要不要只挑高价值的 —— 先摆这批人的全景<br/>4 团队还吃得下多少 —— 先摆在岗人手与在手量<br/>—— · —— · —— · —— · —— · ——<br/>不等他给全,先按这几条默认出一版:<br/>没联系过的排前面,其余按优先级<br/>每人每天 15 通 · 时限 1 天<br/>这批多大 = 在岗人数 × 15 × 时限"]
A2["① 选人 · 助手精选<br/>3 要不要只挑高价值的 —— 先摆这批人的全景<br/>4 团队还吃得下多少 —— 先摆在岗人手与在手量<br/>—— · —— · —— · —— · —— · ——<br/>不等他给全,先按这几条默认出一版:<br/>没联系过的排前面,其余按优先级<br/>每人每天 15 通 · 时限 3 天<br/>这批多大 = 在岗人数 × 15 × 时限"]
A2 --> D1{"这批人对吗?"}
D1 --> G1["助手摆出的引导 —— 满足条件才出现<br/>「能加个条件」:候选 ≥50 人 · 他还没加过 · 切完还剩 ≥10 人<br/>  消费高于本批平均 / 转介绍达人 / 权益身份 / 获客渠道<br/>「这批多大」:人数是系统估的(他没自己指定过)<br/>  可改 每人每天几通 · 时限几天"]
......@@ -109,14 +109,14 @@ flowchart TD
|---|---|---|
| **谁排前面** | 没联系过的排前面,其余按优先级从高到低 | 「先打消费高的」 |
| **每人每天打几通** | 15 通 | 「按 20 通算」 |
| **多久要打完** | 1 天 | 「给 5 天」 |
| **多久要打完** | 3 天 | 「给 5 天」 |
| **这批发多大** | 在岗人数 × 每天通数 × 时限 | 「改成 200 人」 |
<Callout type="warn">**默认值一个都不许藏。** 出方案时式子摊开写:「本批 405 人 = 在岗 27 人 × 每天 15 通 × 1 天」。
<Callout type="warn">**默认值一个都不许藏。** 出方案时式子摊开写:「本批 405 人 = 在岗 9 人 × 每天 15 通 × 3 天」。
一个他看不见的默认值,等于系统替他做了一个他不知道的决定 —— 而这批人是真发下去了。
⇒ 摆出来他才有得改;不摆,他连「原来还能改这个」都不知道。</Callout>
人手在**两个地方**主动摆给他看,不用他去找:出方案时是上面那个式子;确认单上是每位客服一行「约 3 天」,算的是**分完之后他手上的总量**(在手 + 本批),不只是本批那几条。超出时限的用**颜色**提示,⛔ 不写「打不完 / 超了 / 过载」—— 几乎每批都会有人超,说成故障主管就会开始怀疑系统,而不是做他该做的判断。
人手在**两个地方**主动摆给他看,不用他去找:出方案时是上面那个式子;确认单上是每位客服一行「约 5 天」,算的是**分完之后他手上的总量**(在手 + 本批),不只是本批那几条。超出时限的用**颜色**提示,⛔ 不写「打不完 / 超了 / 过载」—— 手上压着旧单的人本来就会超,那是常态不是故障;说成故障,主管会开始怀疑系统,而不是做他该做的判断。
### 能加哪几个条件
......@@ -342,12 +342,12 @@ flowchart LR
|---|---|---|
| **专属排满了** | 「182 人的专属客服这轮已排满」——**第三趟那一组**,四个选择:换无专属的补上(池子还有人时才给)/ 铺平给在岗 / 各自归专属客服 / 移出本批 | 这批不发给他们 |
| **能加个条件** | 「这批候选 2,663 人,也可以只选其中一类」+ 四个带人数的选项(见 §1) | 就按这批候选全部人来 |
| **这批多大** | 「本批 405 人 = 在岗 27 人 × 每天 15 通 × 1 天」,式子摊开给他看 | 就按这个数发 |
| **最忙的那位** | 「这批发下去,最忙的是王强:手上共 45 条,约 3 天的量;**另有 2 位也超过 1 天**」 | 就按这个时限发,到期没打完的自动回池 |
| **这批多大** | 「本批 405 人 = 在岗 9 人 × 每天 15 通 × 3 天」,式子摊开给他看 | 就按这个数发 |
| **最忙的那位** | 「这批发下去,最忙的是王强:手上共 75 条,约 5 天的量;**另有 2 位也超过 3 天**」 | 就按这个时限发,到期没打完的自动回池 |
⚠️ **为什么要报「另有几位」**:只说最忙的一个,主管分不出两种局面 —— 而这两种局面该做的事**正好相反**:只有王强超,就给他少分点、改派几个;全队都超,就得减少这批、延长时限。只有他一个人超时那半句不出现,⛔ 不制造无谓噪音;也⛔ 不在这里铺开每个人 —— 确认单上每位客服那一行已经写着「约 N 天」,引导只负责**点出要他定的事**,不负责展示数据。
⚠️ **「最忙的那位」只给一个选项,而且必须带数**:「整批时限改成 **3** 天」—— 那个 3 是按最忙那位的量算出来的。曾经还有「改每人每天打几通」「减少本批人数」两个,删掉了:它们**一个数都不带**,点下去等于替主管说了句「减少一些」—— **一个不带数的按钮,严格弱于他自己开口说一句。**
⚠️ **「最忙的那位」只给一个选项,而且必须带数**:「整批时限改成 **5** 天」—— 那个 5 是按最忙那位的量算出来的。曾经还有「改每人每天打几通」「减少本批人数」两个,删掉了:它们**一个数都不带**,点下去等于替主管说了句「减少一些」—— **一个不带数的按钮,严格弱于他自己开口说一句。**
每条按钮都带三样:**是什么**、**为什么**、**不处理等于什么**。三条设计原则:
......
import { AssignStrategy, type AgentInfo, type TemperatureValue } from '@pac/types';
import {
ASSIGNMENT_EXPIRES_DAYS_DEFAULT as D,
AssignStrategy,
DAILY_CALLS_PER_AGENT as RATE,
type AgentInfo,
type TemperatureValue,
} from '@pac/types';
// ⚠️ **用 import 不用 require**(2026-08-12):原来这两个 describe 里各写了一行
// `const { AssignmentProposalService } = require(...)` —— require 的返回是 any,
// 于是给构造器加第三个依赖时**类型检查查不出来**,运行时那个依赖是 undefined,
......@@ -461,9 +467,11 @@ describe('selectionNote —— 候选不够时的措辞', () => {
const SCOPE = { hostId: 'h', tenantId: 't', sourceUnits: [], clinicIds: ['c1'], userId: 'u' };
/// 首次估法:在岗 9 人 × 20 = 180(⚠️ 那个 20 不是容量上限,只是首次没有历史时的估法)
// 默认口径:在岗 9 人 × 每天 15 通 × 1 天(2026-08-12:一批推一天的活,⛔ 不再沿用上一次)
const N = 9 * 15 * 1;
// ⚠️ 跟着**式子**走(在岗 × 每天几通 × 时限),⛔ 别写死那个积 ——
// 默认时限调过两次(3→1→3),写死就得每次来改一遍,而式子一次都没变。
const N = 9 * RATE * D;
test('⭐⭐ 候选 44 < 基数 180 → 说「一共就 44 人,全部纳入」,⛔ 不许说「取前 N 人」', async () => {
test('⭐⭐ 候选比基数少 → 说「一共就 44 人,全部纳入」,⛔ 不许说「取前 N 人」', async () => {
const r = await svcWith(44, 9).propose(SCOPE, { clinicId: 'c1', potentialTreatment: 'implant' });
expect(r.selectionNote).toContain('一共就 44 人');
expect(r.selectionNote).toContain('全要了');
......@@ -472,7 +480,7 @@ describe('selectionNote —— 候选不够时的措辞', () => {
expect(r.selectionNote).not.toContain('取前');
});
test('⭐ 候选 500 > 基数 180 → 照实说「这批 180 人,从 500 人里挑」+ 剩下的还没轮到', async () => {
test('⭐ 候选多于基数 → 照实说「这批 N 人,从 500 人里挑」+ 剩下的还没轮到', async () => {
const r = await svcWith(500, 9).propose(SCOPE, { clinicId: 'c1', potentialTreatment: 'implant' });
expect(r.target).toBe(N);
// ⚠️ 2026-08-06 重排:每段以加粗的「数字+是什么」起头,措辞随之变化
......@@ -576,16 +584,32 @@ describe('基数沿用 —— 本批人数与时限', () => {
const r = await mk(1000, 9, { lastCriteria: { batchSize: 300, expiresInDays: 5 } }).propose(SCOPE, {
clinicId: 'c1',
});
expect(r.batchSize).toBe(9 * 15 * 1);
expect(r.expiresInDays).toBe(1);
// ⚠️ 断的是**式子**(在岗 × 每天几通 × 时限),⛔ 不写死 135/405 那个积 ——
// 默认时限调过两次(3→1→3),每调一次就要来改这几个字面量,而式子一次都没变。
expect(r.batchSize).toBe(9 * RATE * D);
expect(r.expiresInDays).toBe(D);
expect(r.basis).toBe('default');
});
test('⭐ 默认口径 = 在岗 9 人 × 每天 15 通 × 1 天 = 135,且说清是默认值', async () => {
/**
* ⭐⭐ **默认时限这个数本身**,单独钉一次。
*
* 🔴 上面那几条用的是常量,所以它们**不会**因为默认值被改动而变红 —— 那正是想要的
* (式子才是不变式)。但"当前默认到底是几天"是**产品决定**,改它必须是一次
* 有意识的动作:⇒ 这里写死。红了就去看 `ASSIGNMENT_EXPIRES_DAYS_DEFAULT` 上那段沿革,
* 确认三处连动都跟着改了(N 的乘数 / daily_overload 的阈值 / 对外文档的示例式子)。
* ⚠️ 沿革:2026-08-12 由 3 改 1(每天推一天的活);2026-08-19 改回 **3**。
*/
test('⭐⭐ 默认时限 = 3 天(改这个数是产品决定,连动三处)', () => {
expect(D).toBe(3);
});
test('⭐ 默认口径 = 在岗 9 人 × 每天 15 通 × 时限,且说清是默认值', async () => {
const r = await mk(1000, 9).propose(SCOPE, { clinicId: 'c1' });
expect(r.batchSize).toBe(135);
expect(r.target).toBe(135);
expect(r.expiresInDays).toBe(1);
expect(r.batchSize).toBe(9 * RATE * D);
// ⚠️ 候选 1000 人管够,所以 target 就等于基数(不够时会被压低,那是另一条用例)
expect(r.target).toBe(9 * RATE * D);
expect(r.expiresInDays).toBe(D);
expect(r.basis).toBe('default');
/**
* 🔴 2026-08-14 从 `basisNote` 迁到 `modelFacts` —— basisNote **只写不读**,
......@@ -617,12 +641,12 @@ describe('基数沿用 —— 本批人数与时限', () => {
test('🔴 这批候选只有 44 → 本批 44,但基数仍是默认那个数', async () => {
const r = await mk(44, 9).propose(SCOPE, { clinicId: 'c1' });
expect(r.target).toBe(44);
expect(r.batchSize).toBe(135);
expect(r.batchSize).toBe(9 * RATE * D);
/**
* 🔴 2026-08-14 **改判**:原断言是 `basisNote` 里要有「您设的 135 没变」。两处都错了 ——
* ① basisNote 没有消费方(已删);
* ② 135 是系统按「在岗 × 每天几通 × 时限」算的,**主管一个数都没设过**,
* 叫它"您设的"会让他去找自己什么时候设过 135
* ② 那个数是系统按「在岗 × 每天几通 × 时限」算的,**主管一个数都没设过**,
* 叫它"您设的"会让他去找自己什么时候设过
* ⇒ `basis='default'` 时这个数**根本不进模型上下文**(见 assignment-facts 那段注释),
* 对应的断言在 `assignment-signals.spec` 的「首次默认的基数不给模型」。
* ⚠️ 这里仍然要锁的是 `batchSize` **没有被回写成 44**(上面那句),那才是这条测试的正题。
......
......@@ -243,13 +243,19 @@
#### `daily_overload` 是**工作量预估**,不是异常告警
N 按「在岗 × 每天 15 通 × 时限」算,**只算新增、⛔ 不扣在手** —— 所以只要谁手上还有东西,
需要的天数就会超过时限,这个节点**每批基本都会亮****这是对的**(产品定 2026-08-12):
每一批本来就该把「这活多大、要打几天」讲清楚,主管据此决定延时限、调每日通数、还是减量。
N 按「在岗 × 每天 15 通 × 时限」算,**只算新增、⛔ 不扣在手** —— 而这个节点判的是
「分完之后**手上总量**(在手 + 本批)要打几天」,所以谁手上还压着旧单,就可能超。
**超了不是故障**(产品定 2026-08-12):每一批本来就该把「这活多大、要打几天」讲清楚,
主管据此决定延时限、调每日通数、还是减量。
> ⚠️ **亮的频率跟默认时限挂钩,⛔ 别把它当成这个节点的性质。**
> 2026-08-12~08-19 默认时限是 **1 天**,阈值 `15 × 1 = 15 条`,于是"只要谁手上还有旧单就会亮",
> 当时的文档把「每批基本都会亮」写成了结论。2026-08-19 默认改回 **3 天**(阈值 45 条)之后,
> 它回到「真的压不下」才亮。⇒ 下面两条硬要求**与频率无关**,是措辞和口径本身的要求。
由此推出两条硬要求:
1. **措辞不许像报错。** ⛔ 不出现「打不完 / 超了 / 过载」——它常亮,说成故障主管就会
1. **措辞不许像报错。** ⛔ 不出现「打不完 / 超了 / 过载」——超期是常态不是故障,说成故障主管就会
开始怀疑系统而不是做决定。
2. **数字必须拆开**:「手上共 60 条」要写成「本批 45 + 原本在手 15」。
不拆开主管会读成"这批一下压了 60 条给他"(增量与总量分不开是同类问题里最常犯的)。
......
......@@ -131,14 +131,23 @@ export const DAILY_CALLS_PER_AGENT = 15;
/**
* 批次时限的默认天数。
*
* 🔴 2026-08-12 由 3 改为 **1**(产品定):走「**每天推一天的活**」的节奏 ——
* N = 在岗人数 × 每天 ${DAILY_CALLS_PER_AGENT} 通 × 时限,D=1 时正好是一天的量,
* 当天没打完的自动落回池子、明天重排。
* ⚠️ 连带后果(已知并接受):`daily_overload` 的阈值随之变成 `15 × 1`,
* 于是**只要谁手上还有旧单,这个节点就会亮**。那不是 bug ——
* 它是工作量预估,每批本来就该讲清楚(见 assignment-signals 里该节点的注释)。
* ⚠️ **改这个数会连动三处**,⛔ 别只改这里:
* ① 本批人数 N = 在岗人数 × 每天 ${DAILY_CALLS_PER_AGENT} 通 × D —— 时限是乘数,
* D 变一倍,一批推的人数就变一倍(`assignment-proposal.service` 的注释里有同一句);
* ② `daily_overload` 的阈值 = 每天几通 × D —— 判「谁超期」的那把尺跟着变;
* ③ 对外文档里的示例式子(`assignment-agent.mdx`)—— 那些数是算给主管看的,
* 改了默认值而不改示例,文档就在教一个不存在的口径。
*
* ── 沿革 ──────────────────────────────────────────────────────
* 2026-08-12 3 → **1**(产品定):走「每天推一天的活」的节奏,当天没打完的落回池子、明天重排。
* 2026-08-19 1 → **3**(产品定):改回三天一批。
* ⚠️ 于是 08-12 那条「连带后果」也跟着回去了 —— 当时的原话是
* 「D=1 ⇒ 阈值 15×1 ⇒ **只要谁手上还有旧单,这个节点就会亮**,那不是 bug」。
* D=3 之后阈值是 45 条,`daily_overload` 回到「真的压不下」才亮。
* ⛔ 别把那句"每批都会亮是对的"当成仍然成立的现状写进新文档 ——
* 它是 D=1 那一版的推论,不是这个节点的性质。
*/
export const ASSIGNMENT_EXPIRES_DAYS_DEFAULT = 1;
export const ASSIGNMENT_EXPIRES_DAYS_DEFAULT = 3;
/**
* 卡片上时限可选的天数。**给全部 1-7 天**,不是 3/5/7 三档。
*
......
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