Commit 41566fd7 by luoqi

fix: 退回原因分布与 agentStats 名册也走账本(补上一刀的漏)

上一刀把 released 计数改成账本口径,但**分布和名册还读 followup_plans**,
于是自己跟自己对不上:

  followup_plans.release_reason 是当前值,而 assign 的 SQL 里有 release_reason = NULL
  → 一条退过的单被后面的批次挑走后:
      released      1  (账本)
      releaseReasons []  (列已被清空)
  主管看到"退了 1 条"却没有任何原因,会以为是客服没填。实测复现。

更糟的是 agentStats 的名册按当前 plans 建 —— 被挑走的人从所有人名下蒸发,
「各人之和 === planned」这条本仓最早锁的不变量当场破(而 planned 已是账本口径)。

修法:名册与退回都从本批账本事件建(按 assignment_id 取,不是按当前 planIds)。
  · release 事件自身 assigneeUserId 是 null → 按 planId 回查 assign 的原始承接人
  · 同一 plan 多条 assign(退回→自认领→再退回)去重后计数
  · 老批次(账本没批次号)整条回落到原路径,行为不变

实测(真库):把退过的那条重新分配一次 → 列被清空、账本仍在 →
  分布之和 1 === released 1;agentStats 各人之和 9 === planned 9。
1008 tests,tsc 干净。
parent 9eeb29b9
......@@ -663,7 +663,23 @@ export class PlanAssignmentService {
// agentStats 各人之和比 planned 少一大截,而**不报任何错**。
// (实测:seed 造的事件时间在 5 天前,199 条里 50 条当场蒸发。)
// 正解:plan 当前的 assignment_id 就是本批,所以它**最近一次** assign 必然属于本批。
const assignEvents = planIds.length
/**
* ⭐ **本批的全量事件**(按 assignment_id 取,不是按当前 planIds)。
*
* 🔴 为什么必须按批次号取:被后面的批次挑走的人**已经不在 `plans` 里**了。
* 按 planIds 取的话,他们的 assign / release 事件一条都捞不到 ——
* 于是 `agentStats` 各人之和比 `planned`(账本口径)少一大截,
* 而「各人之和 === planned」正是本文件回归里锁着的不变量。
* ⚠️ 老批次(账本没记批次号)返回空 → 回落到按 planIds 取,行为与从前一致。
*/
const batchEvents = await this.prisma.planEventLog.findMany({
where: { assignmentId: id, tenantId: scope.tenantId },
select: { planId: true, assigneeUserId: true, event: true, reason: true },
orderBy: { createdAt: 'desc' },
});
const assignEvents = batchEvents.length
? batchEvents.filter((e) => e.event === PlanEventType.ASSIGN)
: planIds.length
? await this.prisma.planEventLog.findMany({
where: { planId: { in: planIds }, tenantId: scope.tenantId, event: PlanEventType.ASSIGN },
select: { planId: true, assigneeUserId: true },
......@@ -678,17 +694,43 @@ export class PlanAssignmentService {
const now = new Date();
const byAgent = new Map<string, AssignmentAgentStat>();
const reasonCount = new Map<string, number>();
for (const p of plans) {
// 当前归属优先;已退回(assignee 为 null)则回落到账本里的原始承接人。
// 两者都没有(理论上不该发生:批次分配一定写了 assign 事件)→ 归到兜底桶,
// 宁可显示一个「未归属」也不要静默摊到某个真人头上。
const key = p.assigneeUserId ?? originalOwner.get(p.id) ?? RELEASED_BUCKET;
const touch = (key: string): AssignmentAgentStat => {
let a = byAgent.get(key);
if (!a) {
a = { userId: key, name: null, planned: 0, inHand: 0, released: 0, overdue: 0 };
byAgent.set(key, a);
}
a.planned++;
return a;
};
/**
* 名册来自**账本**:本批当时分给了谁、每人几条 —— 历史事实,不随重分变化。
* ⚠️ 走 originalOwner 而不是逐条事件,是为了对同一 plan 的多次 assign 去重
* (退回→自认领→再退回 会留下第二条 assign)。
*/
const ledgerRoster = batchEvents.length > 0;
if (ledgerRoster) {
for (const [, owner] of originalOwner) touch(owner).planned++;
// 退回:账本 release 事件按 planId 归到**原始承接人**
// (release 事件本身的 assigneeUserId 是 null —— 释放后无人归属)
const seenRelease = new Set<string>();
for (const e of batchEvents) {
if (e.event !== PlanEventType.RELEASE || seenRelease.has(e.planId)) continue;
seenRelease.add(e.planId);
touch(originalOwner.get(e.planId) ?? RELEASED_BUCKET).released++;
if (e.reason) reasonCount.set(e.reason, (reasonCount.get(e.reason) ?? 0) + 1);
}
}
for (const p of plans) {
// 当前归属优先;已退回(assignee 为 null)则回落到账本里的原始承接人。
// 两者都没有(理论上不该发生:批次分配一定写了 assign 事件)→ 归到兜底桶,
// 宁可显示一个「未归属」也不要静默摊到某个真人头上。
const key = p.assigneeUserId ?? originalOwner.get(p.id) ?? RELEASED_BUCKET;
const a = touch(key);
// ⚠️ 走账本名册时**不再从这里累加 planned/released** —— 上面已经按账本记全了,
// 再加一遍就是双计。这里只负责叠加**当前状态**(在手 / 超期)。
if (!ledgerRoster) a.planned++;
if (p.status === 'assigned') {
a.inHand++;
// 🔴 「且没约下次回访」这半句不能少。到期回收器刻意跳过 snoozedUntil 在未来的单
......@@ -699,7 +741,11 @@ export class PlanAssignmentService {
const snoozed = p.snoozedUntil != null && p.snoozedUntil > now;
if (!snoozed && p.assignmentExpiresAt && p.assignmentExpiresAt < now) a.overdue++;
}
if (p.releaseReason) {
// 🔴 老批次才走这条。`followup_plans.release_reason` 是**当前值**,
// 而重分时 assign 的 SQL 会把它 `= NULL` 清掉 —— 实测:一条退过的单被重分后,
// `released` 还是 1(账本口径),退回原因分布却变成 `[]`,两个数当场对不上。
// 有账本时一律用账本(上面那段),⛔ 不许再叠这一层。
if (!ledgerRoster && p.releaseReason) {
a.released++;
reasonCount.set(p.releaseReason, (reasonCount.get(p.releaseReason) ?? 0) + 1);
}
......@@ -714,6 +760,7 @@ export class PlanAssignmentService {
]);
const counts = mergeStats(
{
// ⚠️ 这三个只在**老批次**(账本没记批次号)时才会被采用 —— 见 mergeStats。
planned: plans.length,
agents: [...byAgent.keys()].filter((k) => k !== RELEASED_BUCKET).length,
released: plans.filter((p) => p.releaseReason != null).length,
......
......@@ -30,6 +30,8 @@ function makePrisma(opts: {
criteria?: Record<string, unknown>;
status?: string;
createdAt?: Date;
/** 本批账本事件(assign / release);空 = 老批次,走回落路径 */
events?: Array<{ planId: string; event: string; assigneeUserId: string | null; reason: string | null }>;
}) {
const plans = opts.plans ?? [];
const queryRaw = jest.fn(async () =>
......@@ -67,7 +69,14 @@ function makePrisma(opts: {
})),
),
},
planEventLog: { findMany: jest.fn(async () => []), groupBy: jest.fn(async () => []) },
planEventLog: {
// ⚠️ 按 where 分岔:带 assignmentId 的是「本批全量事件」(新路径),
// 带 planId 的是老批次回落路径 —— 后者在本 spec 里一律为空。
findMany: jest.fn(async (args: { where: Record<string, unknown> }) =>
args.where.assignmentId ? (opts.events ?? []) : [],
),
groupBy: jest.fn(async () => []),
},
host: { findUnique: jest.fn(async () => ({ pullConfig: { timezone: 'Asia/Shanghai' } })) },
$queryRaw: queryRaw,
} as unknown as PrismaService;
......@@ -131,6 +140,63 @@ describe('批次归因 —— planned 不随重分失血', () => {
});
});
describe('退回原因 —— 分布必须与 released 对得上', () => {
/**
* 🔴 实测踩到:`followup_plans.release_reason` 是**当前值**,而重分时 assign 的 SQL
* 会把它 `= NULL` 清掉。于是一条退过的单被后面的批次挑走之后:
* `released` 还是 1(账本口径),`releaseReasons` 却变成 `[]` —— **两个数当场对不上**,
* 而且不报错。主管看到"退了 1 条"却没有任何原因,以为是客服没填。
* ⇒ 分布也必须走账本。
*/
test('⭐⭐ 人被重分走(列已被清空)后,原因分布仍在,且与 released 对得上', async () => {
const { prisma } = makePrisma({
plans: [], // 全被后面的批次挑走 → followup_plans 里一条不剩
ledger: { planned: 9, agents: 2, released: 1, expired: 2, revoked: 0 },
events: [
{ planId: 'p1', event: 'assign', assigneeUserId: 'a', reason: null },
{ planId: 'p1', event: 'release', assigneeUserId: null, reason: 'not_my_patient' },
],
});
const svc = await build(prisma);
const d = await svc.detail(SCOPE, BATCH);
expect(d.releaseReasons).toEqual([{ reason: 'not_my_patient', labelZh: '不是我的客户', n: 1 }]);
expect(d.releaseReasons.reduce((s, x) => s + x.n, 0)).toBe(d.released);
});
test('⭐⭐ agentStats 各人之和 === planned(重分走的人也要留在名册里)', async () => {
// 名册来自账本 assign 事件 —— 按当前 plans 建的话,被挑走的人从所有人名下蒸发,
// 各人之和比 planned 少一大截,而这正是本仓早就锁着的不变量。
const { prisma } = makePrisma({
plans: [],
ledger: { planned: 3, agents: 2, released: 1, expired: 0, revoked: 0 },
events: [
{ planId: 'p1', event: 'assign', assigneeUserId: 'a', reason: null },
{ planId: 'p2', event: 'assign', assigneeUserId: 'a', reason: null },
{ planId: 'p3', event: 'assign', assigneeUserId: 'b', reason: null },
{ planId: 'p3', event: 'release', assigneeUserId: null, reason: 'over_capacity' },
],
});
const svc = await build(prisma);
const d = await svc.detail(SCOPE, BATCH);
expect(d.agentStats.reduce((s, x) => s + x.planned, 0)).toBe(d.planned);
expect(d.agentStats.find((x) => x.userId === 'b')!.released).toBe(1);
});
test('⛔ 同一 plan 的多次 assign 不重复计数(退回→自认领→再退回会留第二条 assign)', async () => {
const { prisma } = makePrisma({
plans: [],
ledger: { planned: 1, agents: 1, released: 0, expired: 0, revoked: 0 },
events: [
{ planId: 'p1', event: 'assign', assigneeUserId: 'a', reason: null },
{ planId: 'p1', event: 'assign', assigneeUserId: 'a', reason: null },
],
});
const svc = await build(prisma);
const d = await svc.detail(SCOPE, BATCH);
expect(d.agentStats.find((x) => x.userId === 'a')!.planned).toBe(1);
});
});
describe('批次归因 —— 到期与退回必须分开', () => {
test('⭐⭐ expired 与 released 是两个数(合成一个"回池率"两种病都看不出来)', async () => {
const { prisma } = makePrisma({
......
......@@ -23,6 +23,10 @@ function makePrisma(opts: {
events: Array<{ planId: string; event: string; assigneeUserId: string | null; createdAt: Date }>;
}) {
const eventFindMany = jest.fn(async (args: { where: Record<string, unknown>; orderBy?: unknown }) => {
// ⚠️ 本 spec 模拟的是**老批次**(账本没记批次号)—— 所以按 assignmentId 取一律为空,
// detail 会回落到按 planId 取事件 + 读 followup_plans.release_reason,
// 而那条回落路径正是本 spec 要锁的。新口径见 assignment-ledger-attribution.spec。
if (args.where.assignmentId) return [];
const w = args.where as { event?: string };
let rows = opts.events.filter((e) => (w.event ? e.event === w.event : true));
rows = [...rows].sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); // desc
......
......@@ -914,6 +914,18 @@ artifact iframe 是 `sandbox="allow-scripts"` + CSP `connect-src 'none'`,**卡
> ⚠️ **老批次**(本列上线前)账本里没批次号 → 回落到 `followup_plans` 现算(偏小),
> ⛔ 但**到期数没有任何可回落的源**,只能给 0:拿 `backToPool` 顶替会把退回算成到期。
> 🔴 **退回原因也必须走账本 —— 主表那一列会被重分清空。**
> `followup_plans.release_reason` 是**当前值**,而 assign 的 SQL 里有 `release_reason = NULL`。
> 于是一条退过的单被后面的批次挑走之后:`released` 还是 1(账本口径),
> `releaseReasons` 却变成 `[]` —— **两个数当场对不上**,且不报错;
> 主管看到"退了 1 条"却没有任何原因,会以为是客服没填。(实测复现过。)
> ⚠️ `agentStats` 的**名册**同理要从账本 assign 事件建,⛔ 不能按当前 `plans` 建 ——
> 被挑走的人会从所有人名下蒸发,「各人之和 === planned」这条老不变量当场破,
> 而它正是本仓最早锁的两条之一。
> ⚠️ release 事件自身的 `assigneeUserId` 是 **null**(释放后无人归属),
> 归人只能按 `planId` 回查 assign 事件里的原始承接人。
> ⚠️ 同一 plan 可能有**多条 assign**(退回→自认领→再退回),去重后再计数。
> ⭐ **到期 ≠ 退回,两个数必须分开报。** 主管的下一步动作**相反**:
> 退回多 → 分配策略不对(派给了不该派的人);到期多 → 派多了 / 时效太紧 / 人不在岗。
> 合成一个"回池率"两种病都看不出来。
......
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