Commit fde942f1 by luoqi

fix(超期): 已经回池的 不算超期 —— 只认此刻还挂在客服手上的

产品指出的判据:「在手的才算超期,已经在池(没有客服)的不算」。**对,而上一版不是这样。**

── 实测:界面上那个数是幻觉 ──────────────────────────────────────
测试服此刻:
  账本那一支(已回池、没有客服挂着)   841 条
  在手那一支(真挂在人手上)             0 条
而工作台上显示的「超期 41 / 193」**100% 来自前者** —— 一屏数字没有一条是真的,
主管对它们也做不了任何事(没人挂着的单不是谁的超期),而且不报错。

── 改法:一个来源,两处同判据 ────────────────────────────────────
`workload()` 的 overdue 与批次的 `expired` 都收敛成:
  status='assigned' + 过了时限 + 没约下次回访
 账本里 `auto_release/assignment_expired` 那一支整个删掉(含那段 LATERAL 归属回捞)——
  它是**回收器时代**的产物:当时过期的单当场被收走,"当前超期"结构上永远是 0,
  只能去账本里数"曾经被收走过多少条"。回收器没了,这个前提也没了。
️ 代价:2026-08-19 之前的批次这一列变 0。分配功能还没正式上线,没有要保的历史(产品定)。

── 连带:「超期」从**窗口口径**改回**此刻口径** ──────────────────
它现在是「在手」的一个子集,不随近 7 天 / 近 30 天变。
⇒ 表头改「**当前**超期」,与左边「**当前**在手」对齐 —— 不写「当前」的话,
  它紧挨在窗口切换器下面,会被读成"这 7 天超了几条"。
️ 2026-08-07 当初把它改成窗口口径的那条理由(回收器让此刻口径永远是 0)
  连同前提一起作废了,契约与面板注释都标了沿革, 别照旧注释改回去。

── 用例 ────────────────────────────────────────────────────────
上一版我写的两条方向反了(「老批次的到期事件仍然算数」「两支并存时相加」),
改成断**反面**:账本填满也一条都不许漏进来;两边都有值时只认在手那个, 不相加。

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent a8712aaa
...@@ -132,10 +132,9 @@ function mergeStats( ...@@ -132,10 +132,9 @@ function mergeStats(
agents: legacy ? l.agents : ledger.agents, agents: legacy ? l.agents : ledger.agents,
released: legacy ? l.released : ledger.released, released: legacy ? l.released : ledger.released,
/** /**
* ⚠️ 超期**不跟着 legacy 回落**(2026-08-19 改)。 * ⚠️ 超期**不跟着 legacy 回落**(2026-08-19):它现在只有一个来源 ——
* 老口径下它只能来自账本,而老账本没记批次号 ⇒ 只能给 0。 * **此刻仍挂在人手上且已过时限**(现算),而那一支对新老批次一样成立。
* 新口径里它还有一支是**此刻现算**的(仍挂在人手上且已过时限), * ⛔ 别让它跟着 `legacy` 归零:老批次照样可能有单还压在人手上。
* 那一支对老批次一样成立 —— 所以有账本就用账本那份,没有也照给现算的部分。
*/ */
expired: ledger?.expired ?? 0, expired: ledger?.expired ?? 0,
}; };
...@@ -531,25 +530,22 @@ export class PlanAssignmentService { ...@@ -531,25 +530,22 @@ export class PlanAssignmentService {
> { > {
if (ids.length === 0) return new Map(); if (ids.length === 0) return new Map();
/** /**
* 🔴 **超期的口径 2026-08-19 变了** —— 到期不再回池(回收器已删,沿革见 `plan.module` 的墓碑)。 * 🔴 **超期 = 此刻挂在客服手上、且过了时限的**(2026-08-19 定的唯一口径)。
* ⇒ 从此不会再有新的 `auto_release/assignment_expired` 事件, * 到期不再回池(回收器已删,沿革见 `plan.module` 的墓碑),所以不会再有新的
* 光数账本的话**新批次这一列永远是 0**。 * `auto_release/assignment_expired` 事件;而账本里那些**老事件数的是已经回池、
* ⚠️ 但老批次的事件还在库里,⛔ 不能删掉那一支 —— 两边**取并集**: * 没有客服挂着**的单 —— 那不是谁的超期,⛔ 一并删掉,别再取并集。
* 账本里到期回收过的(历史) ∪ 此刻仍挂在人手上且已过时限的(现状)。 * ⚠️ 代价:2026-08-19 之前的批次这一列变 0。分配功能还没正式上线,没有要保的历史(产品定)。
* 与 `workload()` 的「超期」同一套算法(那边当年为"回收器关掉"就写了两支) * ⚠️ 判据与 `workload()` 的「超期」**逐字一致**,⛔ 不许分家
* ⚠️ 这段说明放在模板字符串**外面** —— 里面写 JS 块注释的话, * ⚠️ 这段说明放在模板字符串**外面** —— 里面写 JS 块注释的话,
* 注释里的反引号会把模板提前截断,而报错指向的是几行之后的地方(踩过)。 * 注释里的反引号会把模板提前截断,而报错指向的是几行之后的地方(踩过)。
*/ */
const rows = await this.prisma.$queryRaw< const rows = await this.prisma.$queryRaw<
Array<{ assignment_id: string; planned: bigint; agents: bigint; released: bigint; expired_legacy: bigint; revoked: bigint }> Array<{ assignment_id: string; planned: bigint; agents: bigint; released: bigint; revoked: bigint }>
>(Prisma.sql` >(Prisma.sql`
SELECT assignment_id, SELECT assignment_id,
count(DISTINCT patient_id) FILTER (WHERE event = 'assign') AS planned, count(DISTINCT patient_id) FILTER (WHERE event = 'assign') AS planned,
count(DISTINCT assignee_user_id) FILTER (WHERE event = 'assign') AS agents, count(DISTINCT assignee_user_id) FILTER (WHERE event = 'assign') AS agents,
count(DISTINCT plan_id) FILTER (WHERE event = 'release') AS released, 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_legacy,
count(DISTINCT plan_id) FILTER (WHERE event = 'auto_release' count(DISTINCT plan_id) FILTER (WHERE event = 'auto_release'
AND reason = ${PlanEventReason.REVOKED}) AS revoked AND reason = ${PlanEventReason.REVOKED}) AS revoked
FROM plan_event_logs FROM plan_event_logs
...@@ -578,9 +574,9 @@ export class PlanAssignmentService { ...@@ -578,9 +574,9 @@ export class PlanAssignmentService {
r.assignment_id, r.assignment_id,
{ {
planned: Number(r.planned), agents: Number(r.agents), released: Number(r.released), planned: Number(r.planned), agents: Number(r.agents), released: Number(r.released),
// ⚠️ 直接相加,⛔ 不去重:一条单不可能既"当年被回收过"又"此刻还挂着"—— // ⚠️ 只认「此刻挂在人手上且过了时限」这一支(理由见上面那段) ——
// 被回收过就不再是 assigned,而回收器已经没了,不会再产生新的 // ⛔ 别再并上账本里的 auto_release:那些单已经回池、没有客服挂着,不是谁的超期
expired: Number(r.expired_legacy) + (nowMap.get(r.assignment_id) ?? 0), expired: nowMap.get(r.assignment_id) ?? 0,
revoked: Number(r.revoked), revoked: Number(r.revoked),
}, },
]), ]),
...@@ -921,57 +917,39 @@ export class PlanAssignmentService { ...@@ -921,57 +917,39 @@ export class PlanAssignmentService {
GROUP BY assignee_user_id`); GROUP BY assignee_user_id`);
/** /**
* 🔴 **窗口内超期** —— 这一列**必须走账本,⛔ 不能查"当前还压在手上且已过期"**。 * 🔴 **超期 = 此刻挂在客服手上、且过了时限的**。
* *
* ── 为什么(2026-08-07 实测)────────────────────────────────── * ── 2026-08-19 只剩这一支了 ────────────────────────────────
* 到期回收器每 10 分钟扫一遍,过期的单当场被收回池子、status 不再是 assigned。 * 原来是两支的并集,另一支数账本里 `auto_release/assignment_expired` 事件 ——
* 于是"当前超期"这个口径**结构上几乎永远是 0**:本地实测 * 那是**回收器时代**的产物:过期的单当场被收回池子、status 不再是 assigned,
* 账本里 `auto_release/assignment_expired` **378 条**,而当前在手已过期 **0 条**。 * 于是"当前超期"结构上永远是 0,只能去账本里数"曾经被收走过多少条"。
* 那一列摆上去就是个常年为 0 的死数,主管会以为团队从不超期。
* ⇒ 问的应该是「**这 N 天里有多少条到期没人动被收走了**」,那是账本上的事件。
* *
* ⚠️ **归属要回捞**:auto_release 事件的 `assignee_user_id` 和 `actor_user_id` 都是 null * 🔴 到期不再回池之后(回收器已删,见 `plan.module` 的墓碑),那一支必须删:
* (释放后无人归属、且是系统行为)—— 必须 join 回该单**到期前最后一次 assign** * 它数的是**已经回到池子里、没有任何客服挂着**的单 —— 而超期的意思是
* 才知道当时压在谁手上。⛔ 不回捞就只能算出一个"全院超期 378",落不到人头上。 * 「**有人**该打没打」。没人挂着的单不是谁的超期,主管对它也做不了任何事。
* ⚠️ 实测(2026-08-19 测试服):界面上显示的「超期 41 / 193」**100% 来自那一支**,
* 而真正在手且过时限的是 **0 条** —— 一屏数字全是幻觉,且不报错。
* ⇒ 一个口径,一处实现:`assigned` + 过了时限 + 没约下次。
* *
* ⚠️ 还要**并上"仍在手且窗口内到期"**那一小撮:回收器关掉时(PAC_ASSIGNMENT_EXPIRY=off) * ⚠️ 代价(已知并接受):2026-08-19 之前那些被收走过的单不再计入,老批次这一列变 0。
* 账本会是空的而单子堆在手上 —— 只查账本会显示 0,而真相是堆了一片。 * 分配功能还没正式上线,没有需要保住的历史(产品定)。
* 两边按 plan_id 去重。 * ⚠️ **约了下次回访的不算**:那是客服动过了的证据 —— 打了电话、约好下次的人
* ⚠️ 约了下次回访的**不算**:回收器刻意跳过它们,那是客服动过了的证据(与 detail 同判据)。 * 不该被显示成"压着单没动"。⛔ 这条守卫不许省。
* ⚠️ 判据与 `ledgerStatsByAssignment` 里那支、以及下面 `oldest` **逐字一致**,⛔ 不许分家:
* 一处改了另一处不改,主管会在批次表和团队表上看到两个数。
*/ */
const overdue = await this.prisma.$queryRaw<Array<{ uid: string; n: bigint }>>(Prisma.sql` const overdue = await this.prisma.$queryRaw<Array<{ uid: string; n: bigint }>>(Prisma.sql`
SELECT uid, count(DISTINCT plan_id) AS n FROM ( SELECT assignee_user_id AS uid, count(*) AS n
-- ① 窗口内被自动回收的(账本);归属回捞到到期前最后一次 assign FROM followup_plans
SELECT owner.assignee_user_id AS uid, e.plan_id WHERE host_id = ${scope.hostId}::uuid
FROM plan_event_logs e AND tenant_id = ${scope.tenantId}
JOIN LATERAL ( AND status = 'assigned'
SELECT a.assignee_user_id AND superseded_at IS NULL
FROM plan_event_logs a AND assignment_expires_at IS NOT NULL
WHERE a.plan_id = e.plan_id AND assignment_expires_at < ${now}
AND a.event = ${PlanEventType.ASSIGN} AND (snoozed_until IS NULL OR snoozed_until <= ${now})
AND a.created_at <= e.created_at AND assignee_user_id IN (${Prisma.join(ids)})
ORDER BY a.created_at DESC GROUP BY assignee_user_id`);
LIMIT 1
) owner ON TRUE
WHERE e.host_id = ${scope.hostId}::uuid
AND e.tenant_id = ${scope.tenantId}
AND e.event = ${PlanEventType.AUTO_RELEASE}
AND e.reason = ${PlanEventReason.ASSIGNMENT_EXPIRED}
AND e.created_at >= ${since}
UNION
-- ② 还压在手上、到期时刻落在窗口内、且没约下次(回收器关掉时只有这一半)
SELECT fp.assignee_user_id AS uid, fp.id AS plan_id
FROM followup_plans fp
WHERE fp.host_id = ${scope.hostId}::uuid
AND fp.tenant_id = ${scope.tenantId}
AND fp.status = 'assigned'
AND fp.superseded_at IS NULL
AND fp.assignment_expires_at >= ${since}
AND fp.assignment_expires_at < ${now}
AND (fp.snoozed_until IS NULL OR fp.snoozed_until <= ${now})
) u
WHERE uid IN (${Prisma.join(ids)})
GROUP BY uid`);
/** /**
* 最久的那条超期了多少天 —— ⚠️ 只看**此刻仍在他手上**的。 * 最久的那条超期了多少天 —— ⚠️ 只看**此刻仍在他手上**的。
......
...@@ -65,8 +65,9 @@ function makePrisma(opts: { ...@@ -65,8 +65,9 @@ function makePrisma(opts: {
assignment_id: BATCH, assignment_id: BATCH,
planned: BigInt(opts.ledger.planned), agents: BigInt(opts.ledger.agents), planned: BigInt(opts.ledger.planned), agents: BigInt(opts.ledger.agents),
released: BigInt(opts.ledger.released), released: BigInt(opts.ledger.released),
// ⚠️ 列名是 `expired_legacy`(账本那一支);现算那一支由下面 nowOverdue 单独回 // ⚠️ 2026-08-19 起账本**不再产出 expired** —— 超期只有 nowOverdue 一个来源。
expired_legacy: BigInt(opts.ledger.expired), // `opts.ledger.expired` 保留在夹具里,是为了让「账本里有到期事件也不算」那条用例
// 能把它填进去而**证明它没被读**。
revoked: BigInt(opts.ledger.revoked), revoked: BigInt(opts.ledger.revoked),
}]; }];
} }
...@@ -251,19 +252,29 @@ describe('退回原因 —— 分布必须与 released 对得上', () => { ...@@ -251,19 +252,29 @@ describe('退回原因 —— 分布必须与 released 对得上', () => {
expect(d.expired).toBe(4); expect(d.expired).toBe(4);
}); });
test('⭐ 老批次的到期事件仍然算数 —— ⛔ 别因为回收器没了就把这一支删了', async () => { /**
* 🔴🔴 **已经回池的⛔ 不算超期**(2026-08-19 产品定的判据)。
*
* 账本里那些 `auto_release/assignment_expired` 数的是**回收器时代被收走的单** ——
* 它们此刻躺在池子里、没有任何客服挂着。而「超期」的意思是「**有人**该打没打」:
* 没人挂着的单不是谁的超期,主管对它也做不了任何事。
* ⚠️ 实测(测试服):界面上「超期 41 / 193」**100% 来自账本那一支**,
* 而真正在手且过时限的是 0 条 —— 一屏数字全是幻觉,且不报错。
* ⇒ 这条用例把账本填满,断言它**一条都不许漏进来**。
*/
test('🔴 账本里有到期事件也⛔ 不算 —— 那些单已经回池、没有客服挂着', async () => {
const { prisma } = makePrisma({ const { prisma } = makePrisma({
plans: [], plans: [],
ledger: { planned: 10, agents: 2, released: 1, expired: 3, revoked: 0 }, ledger: { planned: 10, agents: 2, released: 1, expired: 3, revoked: 0 },
// 老批次的单早被收走了,此刻挂在人手上的是 0 // 此刻挂在人手上的是 0 → 超期就该是 0,⛔ 不是账本那个 3
events: [], events: [],
}); });
const svc = await build(prisma); const svc = await build(prisma);
const d = await svc.detail(SCOPE, BATCH); const d = await svc.detail(SCOPE, BATCH);
expect(d.expired).toBe(3); expect(d.expired).toBe(0);
}); });
test('⭐ 两支并存时相加 —— 一条单不可能既被收走过又还挂着', async () => { test('🔴 账本与在手同时有值 → 只认在手那个,⛔ 不相加', async () => {
const { prisma } = makePrisma({ const { prisma } = makePrisma({
plans: [], plans: [],
ledger: { planned: 20, agents: 3, released: 2, expired: 3, revoked: 0 }, ledger: { planned: 20, agents: 3, released: 2, expired: 3, revoked: 0 },
...@@ -272,15 +283,17 @@ describe('退回原因 —— 分布必须与 released 对得上', () => { ...@@ -272,15 +283,17 @@ describe('退回原因 —— 分布必须与 released 对得上', () => {
}); });
const svc = await build(prisma); const svc = await build(prisma);
const d = await svc.detail(SCOPE, BATCH); const d = await svc.detail(SCOPE, BATCH);
expect(d.expired).toBe(8); expect(d.expired).toBe(5);
}); });
}); });
describe('批次归因 —— 到期与退回必须分开', () => { describe('批次归因 —— 到期与退回必须分开', () => {
test('⭐⭐ expired 与 released 是两个数(合成一个"回池率"两种病都看不出来)', async () => { // ⚠️ 2026-08-19 起 expired 来自 `nowOverdue`(此刻在手且过时限),⛔ 不再来自账本
test('⭐⭐ expired 与 released 是两个数(合成一个数两种病都看不出来)', async () => {
const { prisma } = makePrisma({ const { prisma } = makePrisma({
plans: [{ id: 'p1', status: 'assigned', assigneeUserId: 'a', releaseReason: null }], plans: [{ id: 'p1', status: 'assigned', assigneeUserId: 'a', releaseReason: null }],
ledger: { planned: 9, agents: 2, released: 3, expired: 2, revoked: 0 }, ledger: { planned: 9, agents: 2, released: 3, expired: 0, revoked: 0 },
nowOverdue: 2,
}); });
const svc = await build(prisma); const svc = await build(prisma);
const d = await svc.detail(SCOPE, BATCH); const d = await svc.detail(SCOPE, BATCH);
...@@ -291,7 +304,8 @@ describe('批次归因 —— 到期与退回必须分开', () => { ...@@ -291,7 +304,8 @@ describe('批次归因 —— 到期与退回必须分开', () => {
test('⭐ note 要把两者分开说出来 —— 助手是照抄这句话的', async () => { test('⭐ note 要把两者分开说出来 —— 助手是照抄这句话的', async () => {
const { prisma } = makePrisma({ const { prisma } = makePrisma({
plans: [{ id: 'p1', status: 'assigned', assigneeUserId: 'a', releaseReason: null }], plans: [{ id: 'p1', status: 'assigned', assigneeUserId: 'a', releaseReason: null }],
ledger: { planned: 9, agents: 2, released: 3, expired: 2, revoked: 0 }, ledger: { planned: 9, agents: 2, released: 3, expired: 0, revoked: 0 },
nowOverdue: 2,
}); });
const svc = await build(prisma); const svc = await build(prisma);
const note = (await svc.detail(SCOPE, BATCH)).progress.note; const note = (await svc.detail(SCOPE, BATCH)).progress.note;
......
...@@ -16,14 +16,17 @@ const WINDOWS = [ ...@@ -16,14 +16,17 @@ const WINDOWS = [
* 团队现在什么状态 —— 主管工作台右栏。 * 团队现在什么状态 —— 主管工作台右栏。
* *
* ── 一张表里混着两种时间性,这是最容易读错的地方 ──────────────── * ── 一张表里混着两种时间性,这是最容易读错的地方 ────────────────
* · **当前在手** —— 此刻手上还压着多少,与窗口无关; * · **当前在手 / 当前超期** —— 此刻的状态,与窗口无关;
* · **超期 / 完成 / 退回率 / 没动** —— 都在选中的那个窗口里,换窗口一起变。 * · **完成 / 退回率 / 没动** —— 都在选中的那个窗口里,换窗口一起变。
* ⚠️ 所以表头第一列写死「**当前**在手」。只写「在手」时实测会被读成"这 7 天分了 62 条"。 * ⚠️ 所以这两列都写死「**当前**」。只写「在手」时实测会被读成"这 7 天分了 62 条",
* 而「超期」如果不带「当前」,摆在窗口切换器正下方就会被当成"这 7 天超了几条"。
* *
* 🔴 「超期」是**窗口口径**(2026-08-07 实测推翻了"当前超期"): * 🔴 「超期」2026-08-19 **由窗口口径改回此刻口径**(到期不再回池,见 plan.module 的墓碑):
* 到期回收器每 10 分钟扫一遍,过期的单当场被收走 —— "当前还压在手上且已过期" * · 2026-08-07 当初改成窗口口径,是因为回收器每 10 分钟把过期的单收走,
* 结构上几乎永远是 0(实测账本 378 条到期回收 vs 当前在手已过期 0 条)。 * "当前还压在手上且已过期"结构上永远是 0;
* 摆那个数上去,主管会以为团队从不超期。 * · 回收器删掉之后这个前提没了 —— 超期的单就留在他手上,此刻口径才是有东西的那个。
* ⚠️ 而窗口那一支数的是**已经回池、没有客服挂着**的单,那不是谁的超期。
* 实测:界面上「超期 41 / 193」100% 来自它,真正在手且过时限的是 0 条。
* *
* ⚠️ 退回率那行小字是**服务端拼好的**(`rateNote`),⛔ 前端别自己算: * ⚠️ 退回率那行小字是**服务端拼好的**(`rateNote`),⛔ 前端别自己算:
* 它必须带两个分母(「退回 3 / 已处置 21 = 14.3%,另有 2 条没动」)—— * 它必须带两个分母(「退回 3 / 已处置 21 = 14.3%,另有 2 条没动」)——
...@@ -81,7 +84,7 @@ export function TeamStatus({ clinicId }: { clinicId: string | null }) { ...@@ -81,7 +84,7 @@ export function TeamStatus({ clinicId }: { clinicId: string | null }) {
{rows.length} 位在岗 · 在手 {inHandTotal} {rows.length} 位在岗 · 在手 {inHandTotal}
{overdueTotal > 0 && ( {overdueTotal > 0 && (
// ⚠️ 上琥珀、跟「超期」那一列同一支色 —— 同一件事在一屏里⛔ 不许有两个颜色 // ⚠️ 上琥珀、跟「超期」那一列同一支色 —— 同一件事在一屏里⛔ 不许有两个颜色
<span className="text-amber-700">,其中 {overdueTotal} 条已超期</span> <span className="text-amber-700"> · {overdueTotal} 条已超期</span>
)} )}
</span> </span>
<span className="ml-auto inline-flex rounded-lg bg-slate-100 p-0.5"> <span className="ml-auto inline-flex rounded-lg bg-slate-100 p-0.5">
...@@ -127,8 +130,10 @@ export function TeamStatus({ clinicId }: { clinicId: string | null }) { ...@@ -127,8 +130,10 @@ export function TeamStatus({ clinicId }: { clinicId: string | null }) {
<th className="whitespace-nowrap px-2.5 py-1.5 text-right text-[11px] font-medium text-slate-500"> <th className="whitespace-nowrap px-2.5 py-1.5 text-right text-[11px] font-medium text-slate-500">
当前在手 当前在手
</th> </th>
{/* ⚠️ 「当前」两个字与左边那列同理 —— 它紧挨在窗口切换器下面,
不写就会被读成"这 7 天超了几条"(2026-08-19 改回此刻口径) */}
<th className="whitespace-nowrap px-2.5 py-1.5 text-right text-[11px] font-medium text-slate-500"> <th className="whitespace-nowrap px-2.5 py-1.5 text-right text-[11px] font-medium text-slate-500">
超期 当前超期
</th> </th>
<th className="whitespace-nowrap px-2.5 py-1.5 text-right text-[11px] font-medium text-slate-500"> <th className="whitespace-nowrap px-2.5 py-1.5 text-right text-[11px] font-medium text-slate-500">
完成 完成
......
...@@ -1106,8 +1106,9 @@ export type SheetEditOp = z.infer<typeof SheetEditOpSchema>; ...@@ -1106,8 +1106,9 @@ export type SheetEditOp = z.infer<typeof SheetEditOpSchema>;
* 🔴 **团队现在什么状态** —— 主管工作台右栏 / `getAgentWorkload`。 * 🔴 **团队现在什么状态** —— 主管工作台右栏 / `getAgentWorkload`。
* *
* ⚠️⚠️ **一张表里混着两种时间性**,界面必须写明,否则主管会把「在手 62」读成"这 7 天分了 62 条": * ⚠️⚠️ **一张表里混着两种时间性**,界面必须写明,否则主管会把「在手 62」读成"这 7 天分了 62 条":
* · `inHand` —— **此刻**的状态(唯一一个),表头要写「**当前**在手」; * · `inHand` / `overdue` / `overdueOldestDays` —— **此刻**的状态,表头要写「**当前**在手」;
* · `overdue` / `done` / `released` / `handled` / `idle` —— **窗口内**发生的事,换窗口一起变。 * ⚠️ `overdue` 2026-08-19 从"窗口内"改成"此刻"(到期不再回池,它就是在手的一个子集)。
* · `done` / `released` / `handled` / `idle` —— **窗口内**发生的事,换窗口一起变。
* ⚠️ 名册与分配那边**同一个来源**(AgentRosterService)—— 分配问「还能吃多少」、 * ⚠️ 名册与分配那边**同一个来源**(AgentRosterService)—— 分配问「还能吃多少」、
* 跟踪问「手上压了多少」,是同一份数据的两种读法(T10)。⛔ 别另查一份。 * 跟踪问「手上压了多少」,是同一份数据的两种读法(T10)。⛔ 别另查一份。
*/ */
...@@ -1117,15 +1118,16 @@ export const AgentWorkloadRowSchema = z.object({ ...@@ -1117,15 +1118,16 @@ export const AgentWorkloadRowSchema = z.object({
/// **当前**手上还压着多少 —— 这一列是唯一的"此刻"口径(跨诊所,与 AgentInfo.inHand 同源) /// **当前**手上还压着多少 —— 这一列是唯一的"此刻"口径(跨诊所,与 AgentInfo.inHand 同源)
inHand: z.number().int(), inHand: z.number().int(),
/** /**
* **窗口内**超期 = 过了时限还没处置的条数。 * 超期 = **此刻挂在他手上、且过了时限**的条数。
* *
* 🔴 2026-08-19 起**主力是"当前还压在手上且已过时限"**那一支:到期不再回池, * 🔴 **已经回到池子里的⛔ 不算**(2026-08-19 产品定):超期的意思是「**有人**该打没打」——
* 单子留在原人手上(回收器已删,沿革见 `plan.module` 的墓碑) * 没有客服挂着的单不是谁的超期,主管对它也做不了任何事
* ⚠️ 账本那一支(历史上被自动回收过的)**仍然要并进来**,⛔ 别删: * ⚠️ 原来这一列还并着账本里 `auto_release/assignment_expired` 那一支(回收器时代
* 2026-08-19 之前的批次全靠它,删了那些批次的这一列会凭空归零。 * 被收走的单)。实测:界面上「超期 41 / 193」**100% 来自那一支**,而真正在手
* 归属回捞自"到期前最后一次 assign" —— auto_release 事件本身不带人 * 且过时限的是 0 条 —— 一屏数字全是幻觉,且不报错。⇒ 那一支已删
* ⚠️ **约了下次回访的不算**:那是客服动过了的证据。不排掉的话, * ⚠️ **约了下次回访的不算**:那是客服动过了的证据。不排掉的话,
* 打了电话、约好下次的人反而被显示成"压着单没动"。 * 打了电话、约好下次的人反而被显示成"压着单没动"。
* ⚠️ 这一列因此和 `inHand` 一样是**此刻**口径,⛔ 不随窗口变 —— 表头别写成"窗口内"。
*/ */
overdue: z.number().int(), overdue: z.number().int(),
/** /**
......
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