Commit efe60136 by luoqi

feat(cli): seed-assignment 造批次验工程 + 修它抓出的跟踪 bug

## 边界写在文件头:只验工程,不许反推规律

造数据能验的:报表 SQL 的查询计划、分子分母口径、样本不足判定、
退回原因聚合有没有漏过滤、边界(全退回/全到期/零转化/单人独吞)。
 **不能**用来调任何默认值(容量 50 / 时效 3 天 / 专属优先 / 批次规模 100)——
T20 要的是"真人在真实压力下怎么反应",而这里每个数都是按 `--release-rate` 编的。
拿它反推 = 自己教自己,且比没数据更危险:假信号会让错的策略顶着"数据支持"的名义跑起来。

伪随机用 mulberry32 固定种子, 不用 Math.random():
造完发现报表不对,得能原样重造一遍再看。

批次一律带 `SEED_` 前缀的 requestId,`--clean` 只清自己造的(实测零残留)。

## 🔴 seed 当场抓出一个真 bug

`agentStats` 各人 planned 之和 **149 ≠ planned 199** —— 50 条已退回/已到期的
从所有人名下**蒸发**了,而"某人退了几条"恰恰是主管最想看的列。

根因:回捞原始承接人时用了 `assign 事件.createdAt >= 批次头.createdAt`
(想排掉这条 plan 在**上一个**批次里的 assign 事件)。生产里事件与批次头同事务、
时间必然在后,**看不出问题**;seed 造的事件在 5 天前,整批当场消失,且**不报任何错**。

正解:plan 当前的 `assignment_id` 就是本批 ⇒ 它**最近一次** assign 必然属于本批。
按 planId 取 latest,不依赖时间窗。修完 199 = 199 

这就是造数据的价值 —— 它把一个"生产环境下永远碰不到、但一旦碰到就静默错"的
时序假设逼了出来。

## 两条不变量已上锁(4 条断言)

① agentStats 之和必须 = planned(含事件时间早于批次头的情形)
② 退回原因分布不得混进系统原因

第 ② 条实测反例:不过滤 `event='release'` 的话,22 条 `assignment_expired`
会被当成退回原因 —— 退回从 28 变 50,**退回率几乎翻倍**,而报表看起来完全正常。
(当前 detail 从 `followup_plans.release_reason` 列读,天然隔离 —— 因为到期**不写**那一列;
 但将来若有人改成从事件表聚合,这条断言会拦住。)

## 顺带查清了「结果信号」的时间锚

`appointment_record` 的 fact 层 `occurred_at` = **预约时刻**(assembler 的
`occurredAtField: scheduledAt`),content 里**没有** createdAt →
拿它做归因会把"分配前就约好、分配后才到诊"的算成战果。

但**事务层有**:`patient_transactions.canonical_payload.createdAt`
(实测 `"2019-07-08T11:17:08+08:00"`),且事务是 append-only、reparse-proof。
⇒ 「分配后 N 天内患者建了新预约」**可以客观算出来,客服一个字都不用填**。
本地 74,040 条 appointment_created 事务,按批次患者收窄后查询 439ms(全表扫要 973ms,
jsonb 提取用不上索引 —— 报表可接受,别拿它做交互查询)。

 这条对 S3 很关键:`n≥50` 不必卡在 `plan_executions`(回写率 11%)上,**换个分子就行** ——
不用"客服说成了",用"患者真的来了",后者还更硬。

881 单测通过,seed 数据已清理。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent b6868af6
......@@ -22,6 +22,7 @@
"cold-import": "ts-node --transpile-only src/cli/cold-import.cli.ts",
"cold-import:prod": "node --max-old-space-size=8192 dist/cli/cold-import.cli.js",
"reparse": "ts-node --transpile-only src/cli/reparse.cli.ts",
"seed-assignment": "ts-node --transpile-only src/cli/seed-assignment.cli.ts",
"reparse:prod": "node --max-old-space-size=8192 dist/cli/reparse.cli.js",
"sync-incremental": "ts-node --transpile-only src/cli/sync-incremental.cli.ts",
"sync-incremental:prod": "node --max-old-space-size=4096 dist/cli/sync-incremental.cli.js",
......
......@@ -396,21 +396,24 @@ export class PlanAssignmentService {
// 账本里有:分配时写的 assign 事件带 assigneeUserId,且时间 >= 批次创建时刻,
// 按 planId 取回来即可。这是 plan_event_logs 存在的意义 ——
// 主表存当前值,历史归属只有账本留得住。
// ⚠️ 取**最近一次** assign,不按时间窗过滤。
// 曾经写成 `createdAt >= head.createdAt`(想排掉这条 plan 在**上一个**批次里的
// assign 事件),生产里事件与批次头同事务、时间必然在后,看不出问题;
// 但那个条件本身很脆 —— 事件的 createdAt 若由调用方给(补数据、迁移、造数),
// 或与批次头的 DB 时钟有毫秒级先后,整批已退回的单就会从所有人名下消失,
// agentStats 各人之和比 planned 少一大截,而**不报任何错**。
// (实测:seed 造的事件时间在 5 天前,199 条里 50 条当场蒸发。)
// 正解:plan 当前的 assignment_id 就是本批,所以它**最近一次** assign 必然属于本批。
const assignEvents = planIds.length
? await this.prisma.planEventLog.findMany({
where: {
planId: { in: planIds },
tenantId: scope.tenantId,
event: PlanEventType.ASSIGN,
createdAt: { gte: head.createdAt },
},
select: { planId: true, assigneeUserId: true, createdAt: true },
orderBy: { createdAt: 'asc' },
where: { planId: { in: planIds }, tenantId: scope.tenantId, event: PlanEventType.ASSIGN },
select: { planId: true, assigneeUserId: true },
orderBy: { createdAt: 'desc' },
})
: [];
const originalOwner = new Map<string, string>();
for (const e of assignEvents) {
if (e.assigneeUserId) originalOwner.set(e.planId, e.assigneeUserId);
if (e.assigneeUserId && !originalOwner.has(e.planId)) originalOwner.set(e.planId, e.assigneeUserId);
}
const now = new Date();
......
import { Test } from '@nestjs/testing';
import { PlanEventReason, PlanEventType, ReleaseReason } from '@pac/types';
import { PlanAssignmentService } from '../src/modules/plan/plan-assignment.service';
import { PrismaService } from '../src/prisma/prisma.service';
/**
* 批次跟踪的两条不变量 —— **都是 seed 数据当场抓出来的真 bug**,不是想出来的。
*
* 这两条错了都**不报任何错**,只是报表上的数字悄悄不对:
* ① agentStats 各人之和 ≠ planned → 已退回/已到期的单从所有人名下蒸发
* ② 退回原因分布混进系统原因 → 退回率几乎翻倍
* 主管看到的是一个看起来合理、实际错了的数,而"某人退了几条"恰恰是他最想看的列。
*/
const SCOPE = { hostId: 'h1', tenantId: 't1', sourceUnits: [] as string[], clinicIds: [] as string[], userId: 'u1' } as never;
const BATCH = 'b1';
function makePrisma(opts: {
plans: Array<{ id: string; status: string; assigneeUserId: string | null; releaseReason: string | null }>;
events: Array<{ planId: string; event: string; assigneeUserId: string | null; createdAt: Date }>;
}) {
const eventFindMany = jest.fn(async (args: { where: Record<string, unknown>; orderBy?: unknown }) => {
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
return rows;
});
const prisma = {
planAssignment: {
findFirst: jest.fn(async () => ({
id: BATCH, hostId: 'h1', tenantId: 't1', clinicId: 'c1', createdBy: 'leader-1',
requestId: 'r1', criteria: {}, attributes: null,
// ⭐ 批次头**晚于**事件 —— 正是当初那个时间窗过滤会踩的情形
expiresAt: new Date('2026-08-05'), status: 'confirmed',
revokedAt: null, revokedBy: null,
createdAt: new Date('2026-08-10'), updatedAt: new Date('2026-08-10'),
})),
},
followupPlan: {
findMany: jest.fn(async () =>
opts.plans.map((p) => ({ ...p, assignmentExpiresAt: null })),
),
},
planEventLog: { findMany: eventFindMany, groupBy: jest.fn(async () => []) },
$queryRaw: jest.fn(async () => []),
} as unknown as PrismaService;
return { prisma, eventFindMany };
}
async function build(prisma: PrismaService): Promise<PlanAssignmentService> {
const mod = await Test.createTestingModule({
providers: [PlanAssignmentService, { provide: PrismaService, useValue: prisma }],
})
.useMocker(() => ({}))
.compile();
return mod.get(PlanAssignmentService);
}
describe('批次详情 —— agentStats 必须与 planned 对得上', () => {
test('⭐⭐ 已退回的单(assignee 已清空)要按账本回捞原始承接人,不能蒸发', async () => {
const { prisma } = makePrisma({
plans: [
{ id: 'p1', status: 'assigned', assigneeUserId: 'a', releaseReason: null },
{ id: 'p2', status: 'assigned', assigneeUserId: 'a', releaseReason: null },
// 已退回:assignee 为 null,只能靠 assign 事件找回是谁的
{ id: 'p3', status: 'active', assigneeUserId: null, releaseReason: ReleaseReason.OVER_CAPACITY },
],
events: [
{ planId: 'p1', event: PlanEventType.ASSIGN, assigneeUserId: 'a', createdAt: new Date('2026-08-01') },
{ planId: 'p2', event: PlanEventType.ASSIGN, assigneeUserId: 'a', createdAt: new Date('2026-08-01') },
{ planId: 'p3', event: PlanEventType.ASSIGN, assigneeUserId: 'a', createdAt: new Date('2026-08-01') },
],
});
const svc = await build(prisma);
const d = await svc.detail(SCOPE, BATCH);
const sum = d.agentStats.reduce((acc, x) => acc + x.planned, 0);
expect(sum).toBe(d.planned); // 3 = 3
expect(d.agentStats.find((a) => a.userId === 'a')!.released).toBe(1);
});
test('⭐⭐ 事件时间**早于**批次头也要能回捞 —— 不许用时间窗过滤', async () => {
// 这就是 seed 抓到的那一幕:事件写在 5 天前、批次头是刚建的,
// 原实现 `createdAt >= head.createdAt` 把全部 assign 事件排掉,
// 199 条里 50 条已退回/已到期的当场从 agentStats 消失,而**不报错**。
const { prisma } = makePrisma({
plans: [{ id: 'p1', status: 'active', assigneeUserId: null, releaseReason: 'over_capacity' }],
events: [
// 批次头是 2026-08-10,事件在 08-01
{ planId: 'p1', event: PlanEventType.ASSIGN, assigneeUserId: 'a', createdAt: new Date('2026-08-01') },
],
});
const svc = await build(prisma);
const d = await svc.detail(SCOPE, BATCH);
expect(d.agentStats.reduce((acc, x) => acc + x.planned, 0)).toBe(1);
expect(d.agentStats[0]!.userId).toBe('a');
});
test('同一 plan 进过多个批次 → 取**最近一次** assign(当前 assignment_id 就是本批)', async () => {
const { prisma } = makePrisma({
plans: [{ id: 'p1', status: 'active', assigneeUserId: null, releaseReason: 'over_capacity' }],
events: [
{ planId: 'p1', event: PlanEventType.ASSIGN, assigneeUserId: 'old', createdAt: new Date('2026-07-01') },
{ planId: 'p1', event: PlanEventType.ASSIGN, assigneeUserId: 'now', createdAt: new Date('2026-08-01') },
],
});
const svc = await build(prisma);
const d = await svc.detail(SCOPE, BATCH);
expect(d.agentStats[0]!.userId).toBe('now');
});
});
describe('退回原因分布 —— 不得混进系统原因', () => {
test('⭐⭐ 到期(auto_release)**不进**退回原因分布', async () => {
// seed 实测:199 条里 22 条到期。若把它们算进退回,
// 退回数从 28 变 50、退回率几乎翻倍,而报表看起来完全正常。
const { prisma } = makePrisma({
plans: [
{ id: 'p1', status: 'active', assigneeUserId: null, releaseReason: ReleaseReason.OVER_CAPACITY },
// 到期回收:release_reason **为空**(那一列只属于客服的处置)
{ id: 'p2', status: 'active', assigneeUserId: null, releaseReason: null },
{ id: 'p3', status: 'active', assigneeUserId: null, releaseReason: null },
],
events: [
{ planId: 'p1', event: PlanEventType.ASSIGN, assigneeUserId: 'a', createdAt: new Date('2026-08-01') },
{ planId: 'p2', event: PlanEventType.ASSIGN, assigneeUserId: 'a', createdAt: new Date('2026-08-01') },
{ planId: 'p3', event: PlanEventType.ASSIGN, assigneeUserId: 'a', createdAt: new Date('2026-08-01') },
],
});
const svc = await build(prisma);
const d = await svc.detail(SCOPE, BATCH);
expect(d.released).toBe(1); // 只有真退回那一条
expect(d.releaseReasons).toHaveLength(1);
expect(d.releaseReasons[0]!.reason).toBe(ReleaseReason.OVER_CAPACITY);
// 到期的原因码绝不能出现在退回原因分布里
expect(d.releaseReasons.map((r) => r.reason)).not.toContain(PlanEventReason.ASSIGNMENT_EXPIRED);
});
});
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