Commit db989933 by luoqi

feat(分配): 到期不再回池 —— 超期留在原客服手上,只是记为超期

产品定。理由是**分配的语义**:
  · **主管分配的意思是有始有终** —— 他决定了这批人交给谁,那就该在他手上走完;
    回池等于把这个决定作废、再重新分一次。
  · **容许客服短时超期** —— 没按时打完是常态不是异常,给缓冲让他继续跟进。
  · **减少客服之间的调度** —— 回池再分会让同一批患者在人之间来回换手,而换手本身有成本。
 别用测试服的落人分布给这条"补证据":那是种子数据,说明不了真实运营。
  这条站在语义上,不站在概率上。

── 行为 ────────────────────────────────────────────────────────
`AssignmentExpiryScheduler` **整个删掉**( 不是翻 PAC_ASSIGNMENT_EXPIRY):
时限到了什么都不发生 —— 状态不变、归属不变,只是从此算「超期」。
墓碑与沿革写在 `plan.module.ts` 上,原实现与那 10 条用例在 git 里。
️ 单子回池仍有两条路,都是**人主动做的**:客服退回、主管撤销(限时 30 分钟)。

️ 连带成立、 别当 bug 修:①「在手」只增不减 —— 那是真的还压在他手上,
  产品要看的就是整体负载( **不拆**成"时限内/超期"两个数);
  ②「最忙的那位」会常亮 —— 它本来就是工作量预估不是异常告警。

── 🔴 超期换了源(最容易静默出错的一处)────────────────────────
从此不会再有新的 `auto_release/assignment_expired` 事件。只数账本的话,
**08-19 之后每个批次这一列都永远是 0**,而主管看到的是"这批没人超期"。
⇒ 批次的 `expired` 改成**取并集**:账本里到期回收过的(历史) ∪ 此刻仍挂在人手上
  且已过时限的(现状),与 `workload()` 那两支同一套算法。三条新用例钉住。

── 文案统一口径(「回池」不再出现在与"到期"相关的任何一句里)──────
退场:自动退回 / 落回池子 / 到期回池 / 即将退回池子
在用:**时限**(几天内打完)· **超期**(过了时限还没处置,单子仍在他手上)
  · 引导节点「不处理会怎样」→「就按 N 天发;超过这个天数没打完的记为超期,单子仍在这位客服手上」
  · propose_assignment 的 expiresInDays 描述(模型会照着念)→「要在几天内打完;
    过了不会被收走,仍在原来那位客服手上,只是记为超期」
  · 确认单右上角「N 天后自动退回」→「N 天内打完」
  · 客服执行页「已过期 · 即将退回池子」→「已超期」;「还剩 3 天退回」→「还剩 3 天」
  · 批次列头「到期回池」→「超期」
️ 「退回」这个词**没有全禁** —— 客服主动退回、主管撤销确实会回池,那两条路照旧。

── 工作台的超期提醒加强 ────────────────────────────────────────
· 抬头多一句「其中 N 条已超期」—— 一个人 41 条不吓人,全队 196 条是另一回事
· 超期那一格带上「最久 N 天」(新增 `overdueOldestDays`)—— 昨天刚过时限的 41 条
  和压了 12 天的 41 条,该做的事完全不同

存量不用管:分配功能还没正式上线。

验证:1354 passed,两个 app 的 tsc 绿,next build 通过。

️ 顺手修了一处**上一个提交漏掉的**:`mcp-clinic-scope` 扫源码断言 `本批福利: benefit.trim()`,
  而福利浮层那次把它改成了 `benefitNow` —— 那次只跑了 tsc/build,没跑 jest。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent b85ac222
......@@ -424,7 +424,10 @@ export class AssistantService {
maximum: 90,
description:
// ⚠️ 同上:举例不带具体天数(原文是「给 5 天」)
'这批单子多少天没人动就自动退回池子,只在他说了天数时传。' +
// 🔴 2026-08-19:到期**不再退回池子**。这句话是模型转述给主管的原料,
// 说错了它就会替系统许一个不会兑现的承诺(沿革见 plan.module 的墓碑)。
'这批单子要在几天内打完,只在他说了天数时传。' +
'\n过了这个天数单子不会被收走,仍在原来那位客服手上,只是记为超期。' +
'不传按系统默认;他上一次说的天数不会带到这一批。' +
'\n它同时是估人数那个式子里的一项(在岗人数 × 每天几通 × 它),' +
'所以没传 targetCount 时,改天数会把本批人数一起改掉。',
......
......@@ -477,7 +477,7 @@ export class AssistantLabController {
type: 'number',
description:
'批次时限天数。他明确说了天数(「给 5 天」)时才传。' +
'\n⚠️ 它**身兼两职**:① 单据多久到期自动退回池子;' +
'\n⚠️ 它**身兼两职**:① 这批要在几天内打完(过期不收走,只记为超期);' +
'② 没传 targetCount 时,它还参与估本批人数。改它两件事一起变。',
},
},
......
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { PlanEventType, PlanEventReason } from '@pac/types';
import { PrismaService } from '../../prisma/prisma.service';
import { recordPlanEventsBulk, computeHeldSeconds } from './plan-event.recorder';
/**
* AssignmentExpiryScheduler —— 分配单到期,系统收回池子。
*
* ── 为什么这件事必须做,而且默认开 ────────────────────────────
* 时限是分配单的一部分(T11:不存在无限期批次),但**光写一个到期时刻不会让任何事发生**。
* 不收回的后果不是"多了几条过期单",是**容量口径整体失效**:
* 客服的「在手」只增不减,几批之后全员触顶,再分就分不下去 ——
* 而这在主管看来就是分配功能坏了。
*
* 产品定调(2026-08):到期自动回池**也是给主管减负** —— 让他不必去追"这单还要不要"。
* 主管在确认单上确认时限的那一下,就是对到期行为的预授权,不违 T8
* (T8 要防的是"助手替主管做决定",不是"主管定好的规则到点执行")。
*
* ── 与既有 RecycleSchedulerService 的关系:两条互不干扰的路 ────
* · RecycleScheduler 看 `recycle_at`,是**认领**的 24h 兜底,生产**未启用**
* · 本服务 看 `assignment_expires_at`,是**分配**的时限,默认启用
* 刻意不合并:两者的语义、开关、口径都不同,合了之后想单独关一边就得加分支,
* 而那个分支迟早写错。
*/
/** 关掉的方法:PAC_ASSIGNMENT_EXPIRY=off。默认开(产品已定)。 */
function isEnabled(): boolean {
return (process.env.PAC_ASSIGNMENT_EXPIRY ?? '').trim().toLowerCase() !== 'off';
}
/** 单轮上限 —— 防积压时一次性打爆事务;剩下的下一轮继续 */
const BATCH_LIMIT = 500;
@Injectable()
export class AssignmentExpiryScheduler implements OnModuleInit {
private readonly logger = new Logger(AssignmentExpiryScheduler.name);
constructor(private readonly prisma: PrismaService) {}
onModuleInit(): void {
this.logger.log(
isEnabled()
? '分配单到期自动回池:已启用(每 10 分钟扫一次);关闭设 PAC_ASSIGNMENT_EXPIRY=off'
: '分配单到期自动回池:已关闭(PAC_ASSIGNMENT_EXPIRY=off)—— 在手量将只增不减',
);
}
/**
* @param at 判定时刻(默认此刻)。显式可注入是为了让判据可测 ——
* 测试不必靠真实时钟凑时间差(高负载下事件循环被拖慢会越过阈值边界,产生间歇性假失败)。
* 同款做法见 `sync-incremental.scheduler.reapStaleRunningLocks`,那条是踩过之后立的规矩。
*/
@Cron(CronExpression.EVERY_10_MINUTES, { name: 'plan-assignment-expiry' })
async runExpiry(at?: Date): Promise<void> {
if (!isEnabled()) return;
const now = at ?? new Date();
const due = await this.prisma.followupPlan.findMany({
where: {
status: 'assigned',
assignmentExpiresAt: { not: null, lt: now },
supersededAt: null,
// ⭐⭐ 照抄 RecycleScheduler 的守卫,理由完全相同:
// 客服约了 6/10 回访、plan 已 snooze 到 6/10 —— 在那之前**绝不能**因到期被收走,
// 否则客服丢了已经对患者承诺过的回访关系,6/10 一到单子还会被别人从池里捞走。
// 回访日过后仍未处理,才允许收。
// ⚠️ 这条不是可选优化。漏了它,分配功能会主动破坏客服已经做出的承诺。
OR: [{ snoozedUntil: null }, { snoozedUntil: { lte: now } }],
},
select: {
id: true, hostId: true, tenantId: true, patientId: true,
assigneeUserId: true, assignedAt: true,
// ⭐ 账本要记「到期的是哪一批的单」—— 批次报表的「到期几条」全靠它。
// 此刻取是对的:assignment_id 只会被**下一次分配**覆盖,而这一刻还没发生。
assignmentId: true,
},
take: BATCH_LIMIT,
});
if (due.length === 0) return;
// 分片进事务:每片状态变更与账本同生共死,一片失败不影响其余
const CHUNK = 200;
let recycled = 0;
for (let i = 0; i < due.length; i += CHUNK) {
const chunk = due.slice(i, i + CHUNK);
try {
await this.prisma.$transaction(async (tx) => {
const res = await tx.followupPlan.updateMany({
// 带状态条件 → 并发下若客服刚好提交了执行/自己退回了,本次不生效(幂等)
where: { id: { in: chunk.map((p) => p.id) }, status: 'assigned' },
data: {
status: 'active',
assigneeUserId: null,
assignedAt: null,
recycleAt: null,
assignmentExpiresAt: null,
// ⛔ **不写 release_reason** —— 那一列只属于客服的处置。
// 到期是"客服压根没动",不是"客服判断不该我做";混进去退回率的分子分母一起脏。
// 到期的量单独从 plan_event_logs 按 reason 数。
// ⛔ **不清 assignment_id / assigned_by / assign_strategy** —— 批次归因是历史事实,
// 清了这批的分母就少一条,"分了 60 条其中 8 条到期没人动"就算不出来了。
// ⛔ **绝不动 snoozedUntil** —— 与退回同一条纪律。
},
});
recycled += res.count;
await recordPlanEventsBulk(
tx,
chunk.map((p) => ({
hostId: p.hostId,
tenantId: p.tenantId,
planId: p.id,
patientId: p.patientId,
event: PlanEventType.AUTO_RELEASE,
assigneeUserId: null, // 释放后无人归属
actorUserId: null, // 系统行为
// ⭐ 必须在清空 assignedAt **之前**算(上面 findMany 取的就是清空前的值)
heldSeconds: computeHeldSeconds(p.assignedAt, now),
reason: PlanEventReason.ASSIGNMENT_EXPIRED,
assignmentId: p.assignmentId,
})),
);
});
} catch (err) {
this.logger.error(
`分配到期回收失败(${chunk.length} 条): ${err instanceof Error ? err.message : err}`,
);
}
}
if (recycled > 0) {
this.logger.log(
`分配到期:${recycled} 条超期未处理的分配单已退回召回池(已记账本 reason=assignment_expired)` +
(due.length === BATCH_LIMIT ? `;本轮已达单轮上限 ${BATCH_LIMIT},剩余下一轮继续` : ''),
);
}
}
}
......@@ -410,7 +410,14 @@ export function computeSignals(p: AssignmentProposal, extra: Signal[] = []): Sig
(overCount > 1
? `另有 ${overCount - 1} 位也超过 ${d} 天;每位分完之后要打几天,确认单上逐位都写着。`
: ''),
defaultLabel: `不处理 = 就按 ${d} 天发,到期没打完的自动落回池子,下批还能再分`,
/**
* 🔴 2026-08-19 改口径:到期**不再回池**(沿革见 plan.module 的墓碑)。
* 原文承诺的是「自动落回池子,下批还能再分」—— 那件事从此不会发生,
* 而这句话是模型会照着念给主管听的。
* ⚠️ 新文案要答的还是同一个问题「不处理会怎样」:答案是**什么都不会发生** ——
* 单子留在原人手上,只是开始算超期。⛔ 别写成"没有后果":超期会进工作台。
*/
defaultLabel: `不处理 = 就按 ${d} 天发;超过这个天数没打完的记为超期,单子仍在这位客服手上`,
/**
* 🔴 **只留带数的那一个**(2026-08-15 产品定)。
*
......
......@@ -131,9 +131,13 @@ function mergeStats(
planned: legacy ? l.planned : ledger.planned,
agents: legacy ? l.agents : ledger.agents,
released: legacy ? l.released : ledger.released,
// 老批次的到期数**没有**任何可回落的源(旧账本没记批次号,followup_plans 也不区分
// 到期与退回)—— 给 0,⛔ 不许拿 backToPool 顶替:那会把退回算成到期。
expired: legacy ? 0 : ledger.expired,
/**
* ⚠️ 超期**不跟着 legacy 回落**(2026-08-19 改)。
* 老口径下它只能来自账本,而老账本没记批次号 ⇒ 只能给 0。
* 新口径里它还有一支是**此刻现算**的(仍挂在人手上且已过时限),
* 那一支对老批次一样成立 —— 所以有账本就用账本那份,没有也照给现算的部分。
*/
expired: ledger?.expired ?? 0,
};
}
......@@ -526,26 +530,58 @@ export class PlanAssignmentService {
Map<string, { planned: number; agents: number; released: number; expired: number; revoked: number }>
> {
if (ids.length === 0) return new Map();
/**
* 🔴 **超期的口径 2026-08-19 变了** —— 到期不再回池(回收器已删,沿革见 `plan.module` 的墓碑)。
* ⇒ 从此不会再有新的 `auto_release/assignment_expired` 事件,
* 光数账本的话**新批次这一列永远是 0**。
* ⚠️ 但老批次的事件还在库里,⛔ 不能删掉那一支 —— 两边**取并集**:
* 账本里到期回收过的(历史) ∪ 此刻仍挂在人手上且已过时限的(现状)。
* 与 `workload()` 的「超期」同一套算法(那边当年为"回收器关掉"就写了两支)。
* ⚠️ 这段说明放在模板字符串**外面** —— 里面写 JS 块注释的话,
* 注释里的反引号会把模板提前截断,而报错指向的是几行之后的地方(踩过)。
*/
const rows = await this.prisma.$queryRaw<
Array<{ assignment_id: string; planned: bigint; agents: bigint; released: bigint; expired: bigint; revoked: bigint }>
Array<{ assignment_id: string; planned: bigint; agents: bigint; released: bigint; expired_legacy: bigint; revoked: bigint }>
>(Prisma.sql`
SELECT assignment_id,
count(DISTINCT patient_id) FILTER (WHERE event = 'assign') AS planned,
count(DISTINCT assignee_user_id) FILTER (WHERE event = 'assign') AS agents,
count(DISTINCT plan_id) FILTER (WHERE event = 'release') AS released,
-- 历史那一支:2026-08-19 之前被回收器收走的(说明见上面那段注释)
count(DISTINCT plan_id) FILTER (WHERE event = 'auto_release'
AND reason = ${PlanEventReason.ASSIGNMENT_EXPIRED}) AS expired,
AND reason = ${PlanEventReason.ASSIGNMENT_EXPIRED}) AS expired_legacy,
count(DISTINCT plan_id) FILTER (WHERE event = 'auto_release'
AND reason = ${PlanEventReason.REVOKED}) AS revoked
FROM plan_event_logs
WHERE assignment_id IN (${Prisma.join(ids.map((i) => Prisma.sql`${i}::uuid`))})
GROUP BY assignment_id`);
/**
* 此刻仍在人手上、且已过时限的 —— 新口径下「超期」的主要来源。
* ⚠️ 与 `workload()` 的第②支同判据:约了下次回访的**不算**(那是客服动过了的证据)。
* ⛔ 两处判据不许分家:一处改了另一处不改,主管会在批次表和团队表上看到两个数。
*/
const nowOverdue = await this.prisma.$queryRaw<Array<{ assignment_id: string; n: bigint }>>(Prisma.sql`
SELECT assignment_id, count(*) AS n
FROM followup_plans
WHERE assignment_id IN (${Prisma.join(ids.map((i) => Prisma.sql`${i}::uuid`))})
AND status = 'assigned'
AND superseded_at IS NULL
AND assignment_expires_at IS NOT NULL
AND assignment_expires_at < now()
AND (snoozed_until IS NULL OR snoozed_until <= now())
GROUP BY assignment_id`);
const nowMap = new Map(nowOverdue.map((r) => [r.assignment_id, Number(r.n)]));
return new Map(
rows.map((r) => [
r.assignment_id,
{
planned: Number(r.planned), agents: Number(r.agents), released: Number(r.released),
expired: Number(r.expired), revoked: Number(r.revoked),
// ⚠️ 直接相加,⛔ 不去重:一条单不可能既"当年被回收过"又"此刻还挂着"——
// 被回收过就不再是 assigned,而回收器已经没了,不会再产生新的。
expired: Number(r.expired_legacy) + (nowMap.get(r.assignment_id) ?? 0),
revoked: Number(r.revoked),
},
]),
);
......@@ -938,6 +974,28 @@ export class PlanAssignmentService {
GROUP BY uid`);
/**
* 最久的那条超期了多少天 —— ⚠️ 只看**此刻仍在他手上**的。
*
* 🔴 光给条数答不了「这事有多急」:昨天刚过时限的 41 条,和压了 12 天的 41 条,
* 主管该做的事完全不同。⛔ 别把历史上被回收走的算进来 —— 那些早就不在他桌上了。
* ⚠️ 判据与上面 overdue 的第②支**逐字一致**(含 snoozed 守卫),⛔ 不许分家:
* 一处改了另一处不改,同一屏上会出现"超期 41 条,最久 0 天"这种自相矛盾。
*/
const oldest = await this.prisma.$queryRaw<Array<{ uid: string; days: number }>>(Prisma.sql`
SELECT assignee_user_id AS uid,
floor(EXTRACT(EPOCH FROM (${now} - min(assignment_expires_at))) / 86400)::int AS days
FROM followup_plans
WHERE host_id = ${scope.hostId}::uuid
AND tenant_id = ${scope.tenantId}
AND status = 'assigned'
AND superseded_at IS NULL
AND assignment_expires_at IS NOT NULL
AND assignment_expires_at < ${now}
AND (snoozed_until IS NULL OR snoozed_until <= ${now})
AND assignee_user_id IN (${Prisma.join(ids)})
GROUP BY assignee_user_id`);
/**
* 窗口内的「退回」—— 走**账本**(历史事实,永不变)。
* ⚠️ ⛔ 不能读 `followup_plans.release_reason`:那是**当前值**,重分时会被清成 NULL,
* 于是退过的单在重分后就查不到了(detail 那边已经踩过这个坑)。
......@@ -1011,6 +1069,7 @@ export class PlanAssignmentService {
const asgM = num(assigned);
const liveM = new Map(live.map((r) => [r.uid, Number(r.in_hand)]));
const ovdM = num(overdue);
const oldestM = new Map(oldest.map((r) => [r.uid, Number(r.days)]));
const agents = roster.agents.map((a) => {
const inHand = liveM.get(a.userId) ?? 0;
......@@ -1025,6 +1084,8 @@ export class PlanAssignmentService {
name: a.name,
inHand,
overdue: ovdM.get(a.userId) ?? 0,
// ⚠️ 0 天(今天刚过)与"没有超期"是两件事 —— 前者给 0,后者给 null
overdueOldestDays: oldestM.get(a.userId) ?? null,
done: doneN,
released: relN,
handled,
......
......@@ -9,7 +9,6 @@ import { CohortAttributesService } from './cohort-attributes.service';
import { ExecutionService } from './execution.service';
import { ExecutionCallbackService } from './execution-callback.service';
import { RecycleSchedulerService } from './recycle-scheduler.service';
import { AssignmentExpiryScheduler } from './assignment-expiry.scheduler';
import { PlanEngineService } from './engine/plan-engine.service';
import { ChainComposerService } from './engine/chain-composer.service';
import { TreatmentInitiationRecallScenario } from './engine/scenarios/treatment-initiation-recall.scenario';
......@@ -21,6 +20,29 @@ import { RecallDebugService } from './recall-debug/recall-debug.service';
* v2.1:plan 一期只跑潜在治疗新链召回(treatment_initiation_recall)。
* 链已完成召回(aftercare)留后续,文件已删。
*/
/**
* 🔴 **`AssignmentExpiryScheduler` 已删(2026-08-19 产品定)** —— ⛔ 别加回来。
*
* 它做的事是「时限一到,把单子从客服手上收回召回池」。产品改判,理由是**分配的语义**:
* · **主管分配的意思是有始有终** —— 他决定了这批人交给谁,那这批人就该在他手上走完;
* 回池等于把这个决定作废,再重新分一次。
* · **容许客服短时超期** —— 没按时打完是常态不是异常,给缓冲让他继续跟进。
* · **减少客服之间的调度** —— 回池再分会让同一批患者在人之间来回换手,
* 而换手本身有成本(客户关系断掉、新接手的人要重新熟悉)。
* ⇒ **时限到了什么都不发生**:状态不变、归属不变,只是从此算「超期」,
* 超期由主管在工作台上看见并处理,⛔ 不由系统替他收单。
*
* ⚠️ ⛔ **别用测试服的落人分布来给这条决定"补证据"**(我试过,被驳回):
* 那是种子数据跑出来的批次,它的 `assign_strategy` 分布说明不了真实运营会怎样。
* 这条决定站在**语义**上,不站在概率上 —— 而语义不会因为换一批数据就变。
*
* ⚠️ 连带成立的两件事(⛔ 别当成 bug 去"修"):
* ① 「在手」只增不减 —— 那是**真的**还压在他手上,产品要看的就是整体负载;
* ② 「最忙的那位」会常亮 —— 它本来就是工作量预估不是异常告警(见 assignment-signals)。
* ⚠️ 单子回池仍有两条路,都是**人主动做的**:客服退回、主管撤销(限时 30 分钟)。
*
* 原实现与那 10 条用例在 git 里:`git show HEAD~1 -- apps/pac-service/src/modules/plan/assignment-expiry.scheduler.ts`
*/
@Module({
// ⚠️⚠️ **AssignmentController 必须排在 PlanController 之前**,顺序不是随意的。
// 两者的路由前缀都是 `plans`,而 PlanController 有一条裸 `@Get(':id')`(plan.controller:94)。
......@@ -38,7 +60,6 @@ import { RecallDebugService } from './recall-debug/recall-debug.service';
ExecutionService,
ExecutionCallbackService,
RecycleSchedulerService,
AssignmentExpiryScheduler,
PlanEngineService,
ChainComposerService,
TreatmentInitiationRecallScenario,
......
import { AssignmentExpiryScheduler } from '../src/modules/plan/assignment-expiry.scheduler';
import type { PrismaService } from '../src/prisma/prisma.service';
/**
* 分配单到期自动回池回归。
*
* 产品定调:到期回池是给主管减负(他不必再去追"这单还要不要")。
* 但它是**系统主动把单从客服手里收走**,三条红线错一条都会造成静默的数据/信任损失:
* ① 约好回访的单绝不能被收(snoozedUntil 守卫)—— 收了就是系统主动毁客服对患者的承诺
* ② 不写 release_reason —— 那列只属于客服的处置,到期混进去退回率分子分母一起脏
* ③ 不清批次归因三列 —— 清了这批的分母就少一条
*/
const NOW = new Date('2026-08-10T03:00:00Z');
function makeService(rows: Array<Record<string, unknown>>) {
const captured: {
where?: Record<string, unknown>;
data?: Record<string, unknown>;
updateWhere?: Record<string, unknown>;
} = {};
const events: Array<Record<string, unknown>> = [];
const findMany = jest.fn(async ({ where }: { where: Record<string, unknown> }) => {
captured.where = where;
return rows;
});
const tx = {
followupPlan: {
updateMany: jest.fn(
async (args: { where: Record<string, unknown>; data: Record<string, unknown> }) => {
captured.data = args.data;
captured.updateWhere = args.where;
return { count: rows.length };
},
),
},
planEventLog: {
createMany: jest.fn(async ({ data }: { data: Array<Record<string, unknown>> }) => {
events.push(...data);
return { count: data.length };
}),
},
};
const prisma = {
followupPlan: { findMany },
$transaction: jest.fn(async (fn: (t: typeof tx) => Promise<unknown>) => fn(tx)),
} as unknown as PrismaService;
return { svc: new AssignmentExpiryScheduler(prisma), captured, events, findMany, tx };
}
const PLAN = {
id: 'p1',
hostId: 'h1',
tenantId: 't1',
patientId: 'pat1',
assigneeUserId: 'u-staff',
assignedAt: new Date(NOW.getTime() - 3 * 86400_000), // 3 天前分的
};
describe('AssignmentExpiryScheduler', () => {
const OLD = process.env.PAC_ASSIGNMENT_EXPIRY;
afterEach(() => {
if (OLD === undefined) delete process.env.PAC_ASSIGNMENT_EXPIRY;
else process.env.PAC_ASSIGNMENT_EXPIRY = OLD;
});
test('⭐⭐ 红线①:查询条件必须带 snoozedUntil 守卫(约好回访的单不能被收)', async () => {
delete process.env.PAC_ASSIGNMENT_EXPIRY;
const { svc, captured } = makeService([PLAN]);
await svc.runExpiry(NOW);
// 客服约了 6/10 回访、plan snooze 到 6/10 —— 到期也不能收,
// 收了客服就丢了已对患者承诺的回访关系,而且单子还会被别人从池里捞走
expect(captured.where?.OR).toEqual([
{ snoozedUntil: null },
{ snoozedUntil: { lte: expect.any(Date) } },
]);
// 只收已过期的 assigned
expect(captured.where?.status).toBe('assigned');
expect(captured.where?.assignmentExpiresAt).toMatchObject({ not: null });
expect(captured.where?.supersededAt).toBeNull();
});
test('⭐⭐ 红线②:**不写 release_reason** —— 到期不是客服的处置', async () => {
const { svc, captured } = makeService([PLAN]);
await svc.runExpiry(NOW);
// 退回(客服看了判断"不该我做")与到期(客服压根没动)是两件事。
// 混进同一列,退回率的分子分母一起虚高,而"到期未动"这个数本身才是主管要的信号。
expect(captured.data).not.toHaveProperty('releaseReason');
expect(captured.data).not.toHaveProperty('releaseNote');
});
test('⭐⭐ 红线③:**不清批次归因三列**,但清在办期限', async () => {
const { svc, captured } = makeService([PLAN]);
await svc.runExpiry(NOW);
expect(captured.data).not.toHaveProperty('assignmentId');
expect(captured.data).not.toHaveProperty('assignedBy');
expect(captured.data).not.toHaveProperty('assignStrategy');
expect(captured.data).toMatchObject({
status: 'active',
assigneeUserId: null,
assignmentExpiresAt: null,
});
});
test('⭐ 红线④:绝不动 snoozedUntil(与退回同一条纪律)', async () => {
const { svc, captured } = makeService([PLAN]);
await svc.runExpiry(NOW);
expect(captured.data).not.toHaveProperty('snoozedUntil');
});
test('落账本:auto_release + reason=assignment_expired + 持有时长算得出来', async () => {
const { svc, events } = makeService([PLAN]);
await svc.runExpiry(NOW);
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({
event: 'auto_release',
reason: 'assignment_expired',
assigneeUserId: null,
actorUserId: null, // 系统行为
});
// 时间界注入 → 精确 3 天,不给宽容区间也不会 flake
expect(events[0]!.heldSeconds).toBe(3 * 86400);
});
test('并发安全:updateMany 的 where 带 status 条件(客服刚提交执行则本次不生效)', async () => {
const { svc, captured } = makeService([PLAN]);
await svc.runExpiry(NOW);
expect(captured.updateWhere?.status).toBe('assigned');
});
test('无到期单 → 不发任何写(纯净轮次零副作用)', async () => {
const { svc, tx } = makeService([]);
await svc.runExpiry(NOW);
expect(tx.followupPlan.updateMany).not.toHaveBeenCalled();
expect(tx.planEventLog.createMany).not.toHaveBeenCalled();
});
test('⭐ 开关 off → 一行都不碰(连查询都不发)', async () => {
process.env.PAC_ASSIGNMENT_EXPIRY = 'off';
const { svc, findMany } = makeService([PLAN]);
await svc.runExpiry(NOW);
expect(findMany).not.toHaveBeenCalled();
});
test('⭐ 默认是**开**的 —— 与 PAC_PLAN_AUTO_RECYCLE(默认关)相反,别搞混', async () => {
delete process.env.PAC_ASSIGNMENT_EXPIRY;
const { svc, findMany } = makeService([PLAN]);
await svc.runExpiry(NOW);
expect(findMany).toHaveBeenCalled();
// 不收回的后果不是"多几条过期单",是容量口径整体失效:在手只增不减,几批后全员触顶
});
});
......@@ -31,10 +31,15 @@ const BATCH = 'c02e1b80-1111-4222-8333-444455556666';
* `Cannot read properties of undefined (reading 'toISOString')` 这种跟真因毫无关系的错。
* ⚠️ 改成看 SQL 里的特征词:加查询时**只要不撞词就不用动测试**。
*/
function sqlKind(q: unknown): 'ledger' | 'outcomeDist' | 'outcomeRecords' | 'other' {
function sqlKind(
q: unknown,
): 'ledger' | 'nowOverdue' | 'outcomeDist' | 'outcomeRecords' | 'other' {
const text = ((q as { strings?: string[] })?.strings ?? []).join(' ');
if (text.includes('LEFT JOIN patients')) return 'outcomeRecords';
if (text.includes('GROUP BY outcome')) return 'outcomeDist';
// ⚠️ 这一支必须排在 `plan_event_logs` **之前**判:它查的是 followup_plans,
// 而 2026-08-19 起「超期」= 账本历史 ∪ **此刻仍挂在人手上且已过时限**(见 ledgerStatsByAssignment)。
if (text.includes('assignment_expires_at <')) return 'nowOverdue';
if (text.includes('plan_event_logs')) return 'ledger';
return 'other';
}
......@@ -49,18 +54,27 @@ function makePrisma(opts: {
createdAt?: Date;
/** 本批账本事件(assign / release);空 = 老批次,走回落路径 */
events?: Array<{ planId: string; event: string; assigneeUserId: string | null; reason: string | null }>;
/** 此刻仍挂在人手上、且已过时限的条数(新口径下「超期」的主力来源) */
nowOverdue?: number;
}) {
const plans = opts.plans ?? [];
const queryRaw = jest.fn(async (q: unknown) =>
sqlKind(q) === 'ledger' && opts.ledger
? [{
assignment_id: BATCH,
planned: BigInt(opts.ledger.planned), agents: BigInt(opts.ledger.agents),
released: BigInt(opts.ledger.released), expired: BigInt(opts.ledger.expired),
revoked: BigInt(opts.ledger.revoked),
}]
: [],
);
const queryRaw = jest.fn(async (q: unknown) => {
const kind = sqlKind(q);
if (kind === 'ledger' && opts.ledger) {
return [{
assignment_id: BATCH,
planned: BigInt(opts.ledger.planned), agents: BigInt(opts.ledger.agents),
released: BigInt(opts.ledger.released),
// ⚠️ 列名是 `expired_legacy`(账本那一支);现算那一支由下面 nowOverdue 单独回
expired_legacy: BigInt(opts.ledger.expired),
revoked: BigInt(opts.ledger.revoked),
}];
}
if (kind === 'nowOverdue') {
return opts.nowOverdue ? [{ assignment_id: BATCH, n: BigInt(opts.nowOverdue) }] : [];
}
return [];
});
const prisma = {
planAssignment: {
findFirst: jest.fn(async () => ({
......@@ -215,6 +229,51 @@ describe('退回原因 —— 分布必须与 released 对得上', () => {
const d = await svc.detail(SCOPE, BATCH);
expect(d.agentStats.find((x) => x.userId === 'a')!.planned).toBe(1);
});
/**
* 🔴🔴 **「超期」的口径 2026-08-19 换了源** —— 到期不再回池(回收器已删,沿革见 `plan.module` 的墓碑)。
*
* 从此不会再有新的 `auto_release/assignment_expired` 事件。只数账本的话,
* **2026-08-19 之后的每一个批次这一列都永远是 0** —— 而主管看到的是"这批没人超期",
* 那是这次改动最容易静默造出来的假象。
* ⇒ 现在取**并集**:账本里到期回收过的(历史) ∪ 此刻仍挂在人手上且已过时限的(现状)。
*/
test('⭐⭐ 新批次没有到期事件 → 超期靠**现算**,⛔ 不许是 0', async () => {
const { prisma } = makePrisma({
plans: [],
// 账本里 expired=0:新口径下再也不会有这类事件了
ledger: { planned: 10, agents: 2, released: 1, expired: 0, revoked: 0 },
nowOverdue: 4,
events: [],
});
const svc = await build(prisma);
const d = await svc.detail(SCOPE, BATCH);
expect(d.expired).toBe(4);
});
test('⭐ 老批次的到期事件仍然算数 —— ⛔ 别因为回收器没了就把这一支删了', async () => {
const { prisma } = makePrisma({
plans: [],
ledger: { planned: 10, agents: 2, released: 1, expired: 3, revoked: 0 },
// 老批次的单早被收走了,此刻挂在人手上的是 0
events: [],
});
const svc = await build(prisma);
const d = await svc.detail(SCOPE, BATCH);
expect(d.expired).toBe(3);
});
test('⭐ 两支并存时相加 —— 一条单不可能既被收走过又还挂着', async () => {
const { prisma } = makePrisma({
plans: [],
ledger: { planned: 20, agents: 3, released: 2, expired: 3, revoked: 0 },
nowOverdue: 5,
events: [],
});
const svc = await build(prisma);
const d = await svc.detail(SCOPE, BATCH);
expect(d.expired).toBe(8);
});
});
describe('批次归因 —— 到期与退回必须分开', () => {
......
......@@ -130,8 +130,16 @@ describe('引导节点 · 判定', () => {
// ⚠️ 增量与总量必须分开 —— 不拆开主管会以为这批一下压了 60 条给他
expect(s.why).toContain('本批 45 条');
expect(s.why).toContain('原本在手 15 条');
// ⚠️ 默认路径要说清后果是可接受的(落回池子、下批还能分),⛔ 不制造紧迫感
expect(s.defaultLabel).toContain('落回池子');
/**
* ⚠️ 默认路径要**说清后果**,⛔ 不制造紧迫感 —— 这条不变,变的是后果本身:
* 2026-08-19 起到期**不再回池**(回收器已删,沿革见 `plan.module` 的墓碑),
* 所以原来那句「落回池子、下批还能分」成了一个不会兑现的承诺。
* ⇒ 现在断的是新事实:超期之后单子**仍在这位客服手上**。
* ⛔ 「回池」这个词不许再出现在这条里。
*/
expect(s.defaultLabel).toContain('仍在这位客服手上');
expect(s.defaultLabel).not.toContain('回池');
expect(s.defaultLabel).not.toContain('池子');
});
test('⭐ 刚好打得完(45 条 / 3 天)→ ⛔ 不出 daily_overload', () => {
......
......@@ -423,7 +423,10 @@ describe('确认后没配福利 → 提醒补挂,但不许编效果', () => {
});
test('⭐ `本批福利` 这个事实要一直给模型 —— 他回头问「这批带福利没」才答得上来', () => {
expect(SHEET).toMatch(/本批福利: benefit\.trim\(\) \|\| null/);
// ⚠️ 2026-08-19 起取的是 `benefitNow`(确认时那一层浮层现填的),⛔ 不是 `benefit` 那个 state:
// 浮层"边填边确认",读 state 拿到的是上一帧的空串(沿革见 benefit-popover)。
// ⇒ 断言放宽到"这个键取的是本次确认要挂的那个值",⛔ 不再钉死变量名。
expect(SHEET).toMatch(/本批福利: \w+\.trim\(\) \|\| null/);
});
test('⭐ 确认摘要里用中文项目名,⛔ 不许出现 endo/filling 这种码', () => {
......
......@@ -1350,8 +1350,11 @@ export function AssignmentConfirmSheet({
)}
{/* ⭐ 批次时限放**右上角**:它是"这一整批"的设置,和左边"这一整批多少人"同一层级,
放在底部微调区会让人以为它跟下面的逐条时限是一回事。
⚠️ 文案是「N 天**后自动退回**」而不是光一个「时限」——
主管要知道到期会发生什么(单子回池),不然这个数对他没有意义。 */}
⚠️ 文案不能是光一个「时限」—— 主管要知道这个数**管什么**,不然它对他没有意义。
🔴 2026-08-19 由「N 天**后自动退回**」改成「N 天**内打完**」:到期不再回池了
(沿革见 plan.module 的墓碑),原文是在许一个不会兑现的承诺。
⚠️ 到期之后会怎样(记为超期、单子仍在他手上)写在下面那行操作说明里,
⛔ 不塞进这个角落 —— 这里只有一行的宽度,塞进去会把选择器挤到换行。 */}
<div className="ml-auto flex flex-none items-center gap-1 whitespace-nowrap text-[10.5px] text-slate-500">
<DaySelect
value={expiresInDays}
......@@ -1361,7 +1364,7 @@ export function AssignmentConfirmSheet({
echo(`确认单已更新:整批时限改成 ${d} 天。`);
}}
/>
<span>后自动退回</span>
<span>内打完</span>
</div>
</div>
......
......@@ -1572,7 +1572,7 @@ function ExpiryBadge({ expiresAt }: { expiresAt: Date | null }) {
if (ms <= 0) {
return (
<span className="inline-flex items-center gap-1 rounded-md bg-rose-50 px-2 py-1 text-[11.5px] font-medium text-rose-600 ring-1 ring-inset ring-rose-200">
过期 · 即将退回池子
超期
</span>
);
}
......@@ -1591,10 +1591,10 @@ function ExpiryBadge({ expiresAt }: { expiresAt: Date | null }) {
: 'bg-slate-50 text-slate-500 ring-slate-200';
return (
<span
title={`${expiresAt.toLocaleString('zh-CN')} 到期后自动退回召回池`}
title={`${expiresAt.toLocaleString('zh-CN')} 之前打完;超过之后记为超期,单子仍归你`}
className={`inline-flex items-center gap-1 rounded-md px-2 py-1 text-[11.5px] font-medium ring-1 ring-inset ${tone}`}
>
{label}退回
{label}
</span>
);
}
......
......@@ -234,7 +234,7 @@ export function BatchTracking({ clinicId }: { clinicId: string | null }) {
<thead>
<tr className="sticky top-0 z-10 bg-slate-50 shadow-[inset_0_-1px_0_#E2E8F0]">
<th className="px-3.5 py-1.5 text-left text-[11px] font-medium text-slate-500">批次</th>
{['条数', '已处置', '没动', '退回', '到期回池', '约上'].map((h) => (
{['条数', '已处置', '没动', '退回', '超期', '约上'].map((h) => (
<th
key={h}
className="whitespace-nowrap px-2.5 py-1.5 text-right text-[11px] font-medium text-slate-500"
......@@ -445,7 +445,7 @@ function BatchDrawer({ id, onClose }: { id: string; onClose: () => void }) {
</div>
{d && (
<div className="nums mt-0.5 text-[11px] text-slate-600">
已处置 {d.handled} · 没动 {d.inHand} · 退回 {d.released} · 到期回池 {d.expired} ·
已处置 {d.handled} · 没动 {d.inHand} · 退回 {d.released} · 超期 {d.expired} ·
约上 {d.booked}
</div>
)}
......
......@@ -55,6 +55,15 @@ export function TeamStatus({ clinicId }: { clinicId: string | null }) {
const rows = data?.agents ?? [];
const inHandTotal = rows.reduce((s, a) => s + a.inHand, 0);
/**
* ⭐ **全队超期合计** —— 2026-08-19 加(到期不再回池,超期从此只增不减,见 plan.module 的墓碑)。
*
* 🔴 在此之前超期只逐人写在表格里,而主管扫这一屏时先看的是抬头那行 ——
* 一个人 41 条不吓人,全队 196 条是另一回事,而后者原来**要他自己把 18 行加起来**。
* ⚠️ 挂在「在手」后面、不另起一行:产品定「在乎的是总量和超期」,
* ⛔ 别把在手拆成"时限内 / 超期"两个数 —— 压在他手上的就是压在他手上的。
*/
const overdueTotal = rows.reduce((s, a) => s + a.overdue, 0);
/**
* 「超期最多」的判据。
* 🔴 **并列第一时一个都不标**(2026-08-07 实测:17 位客服全是 23 条,结果每一行都挂着
* 「超期最多」—— 标一片等于没标,而且看着像系统坏了)。
......@@ -70,6 +79,10 @@ export function TeamStatus({ clinicId }: { clinicId: string | null }) {
<span className="text-[13.5px] font-semibold text-slate-900">团队现在什么状态</span>
<span className="nums text-[11px] text-slate-400">
{rows.length} 位在岗 · 在手 {inHandTotal}
{overdueTotal > 0 && (
// ⚠️ 上琥珀、跟「超期」那一列同一支色 —— 同一件事在一屏里⛔ 不许有两个颜色
<span className="text-amber-700">,其中 {overdueTotal} 条已超期</span>
)}
</span>
<span className="ml-auto inline-flex rounded-lg bg-slate-100 p-0.5">
{WINDOWS.map((w) => (
......@@ -157,6 +170,17 @@ export function TeamStatus({ clinicId }: { clinicId: string | null }) {
)}
>
{a.overdue}
{/**
* ⭐ **压了多久**跟在条数下面(2026-08-19 加)。
* 🔴 光给条数答不了「这事有多急」:昨天刚过时限的 41 条,
* 和压了 12 天的 41 条,主管该做的事完全不同。
* ⚠️ `> 0` 才显示:今天刚过时限的是 0 天,写「最久 0 天」是废话一行。
*/}
{a.overdueOldestDays != null && a.overdueOldestDays > 0 && (
<div className="text-[10.5px] leading-snug font-normal text-amber-700/70">
最久 {a.overdueOldestDays}
</div>
)}
</td>
<td className="nums px-2.5 py-2 text-right text-emerald-700">{a.done}</td>
<td className="px-3.5 py-2 text-right">
......
......@@ -248,10 +248,11 @@ N 按「在岗 × 每天 15 通 × 时限」算,**只算新增、⛔ 不扣在
**超了不是故障**(产品定 2026-08-12):每一批本来就该把「这活多大、要打几天」讲清楚,
主管据此决定延时限、调每日通数、还是减量。
> ⚠️ **亮的频率跟默认时限挂钩,⛔ 别把它当成这个节点的性质。**
> 2026-08-12~08-19 默认时限是 **1 天**,阈值 `15 × 1 = 15 条`,于是"只要谁手上还有旧单就会亮",
> 当时的文档把「每批基本都会亮」写成了结论。2026-08-19 默认改回 **3 天**(阈值 45 条)之后,
> 它回到「真的压不下」才亮。⇒ 下面两条硬要求**与频率无关**,是措辞和口径本身的要求。
> ⚠️ **亮的频率会随两件事变,⛔ 别把某一时期的频率当成这个节点的性质。**
> · 默认时限:2026-08-12~08-19 是 **1 天**(阈值 `15 × 1 = 15 条`),08-19 改回 **3 天**(阈值 45 条);
> · **到期是否回池**:2026-08-19 起**不再回池**,超期的单继续挂在客服手上 ⇒ 在手只增不减,
> 于是这个节点又回到「常亮」。**这仍然是对的** —— 它报的是真实负载,而那个负载确实没消失。
> ⇒ 下面两条硬要求**与频率无关**,是措辞和口径本身的要求。
由此推出两条硬要求:
......
......@@ -265,7 +265,7 @@ export const AssignmentBriefSchema = z.object({
* 到期多 → 派多了 / 时限太紧 / 人不在岗。
* 合成一个"回池率"两种病都看不出来。
*/
expired: z.number().int().describe('到期自动回池(客服没动)'),
expired: z.number().int().describe('超期未处置的条数(单子仍在客服手上)'),
agents: z.number().int().describe('涉及几个客服(取自账本)'),
/**
* 已处理条数 —— 判据是**池子状态**(出池 / 被抑制),不是回写。
......@@ -1117,17 +1117,26 @@ export const AgentWorkloadRowSchema = z.object({
/// **当前**手上还压着多少 —— 这一列是唯一的"此刻"口径(跨诊所,与 AgentInfo.inHand 同源)
inHand: z.number().int(),
/**
* **窗口内**超期 = 这段时间里到期没人动、被收回池子的条数。
* **窗口内**超期 = 过了时限还没处置的条数。
*
* 🔴 ⛔ **不是"当前还压在手上且已过期"**(2026-08-07 实测推翻):
* 到期回收器每 10 分钟扫一遍,过期的单当场被收走 —— 那个口径**结构上几乎永远是 0**
* (实测:账本 378 条到期回收,而"当前在手已过期" 0 条)。摆上去是个常年为 0 的死数,
* 主管会以为团队从不超期
* ⚠️ 归属回捞自"到期前最后一次 assign" —— auto_release 事件本身不带人(释放后无人归属)
* ⚠️ **约了下次回访的不算**:回收器刻意跳过它们,那是客服动过了的证据。
* 不排掉的话,打了电话、约好下次的人反而被显示成"压着单没动"。
* 🔴 2026-08-19 起**主力是"当前还压在手上且已过时限"**那一支:到期不再回池,
* 单子留在原人手上(回收器已删,沿革见 `plan.module` 的墓碑)。
* ⚠️ 账本那一支(历史上被自动回收过的)**仍然要并进来**,⛔ 别删:
* 2026-08-19 之前的批次全靠它,删了那些批次的这一列会凭空归零
* 归属回捞自"到期前最后一次 assign" —— auto_release 事件本身不带人
* ⚠️ **约了下次回访的不算**:那是客服动过了的证据。不排掉的话,
* 打了电话、约好下次的人反而被显示成"压着单没动"。
*/
overdue: z.number().int(),
/**
* 最久的那条超期了多少天(没有超期的给 null)。
*
* 🔴 光给条数**答不了主管真正要问的那句**「这事有多急」——
* 昨天刚过时限的 41 条,和压了 12 天的 41 条,该做的事完全不同。
* ⚠️ 只看**此刻仍在他手上**的那些:历史上被回收走的单已经不在他桌上了,
* 把它们的"压了多久"算进来是在说一件早就结束的事。
*/
overdueOldestDays: z.number().int().nullable(),
/// 窗口内写过通话结果的条数(按单去重)
done: z.number().int(),
/// 窗口内主动退回的条数(走账本,⛔ 不读 followup_plans.release_reason —— 那是当前值会被清)
......
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