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 @@ ...@@ -22,6 +22,7 @@
"cold-import": "ts-node --transpile-only src/cli/cold-import.cli.ts", "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", "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", "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", "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": "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", "sync-incremental:prod": "node --max-old-space-size=4096 dist/cli/sync-incremental.cli.js",
......
/**
* Seed Assignment CLI —— 造一批「已经跑完的」分配批次。
*
* ⚠️⚠️⚠️ **用途只有一个:验工程。** ⚠️⚠️⚠️
*
* 可以拿它验:
* · 跟踪报表的 SQL 会不会超时、查询计划走没走索引
* · 分子分母口径对不对(agentStats 之和 = planned、退回率两个数)
* · `n < 50` 时"样本不足"判定真的生效(不出百分比、不画图)
* · 退回原因聚合有没有漏掉 `event='release'` 过滤
* (不过滤会把 auto_release 的 timeout/assignment_expired 和 feedback 的 up/down 混进来)
* · 边界:全退回 / 全到期 / 零转化 / 单人独吞
*
* ⛔⛔ **绝对不许拿它的数去调任何默认值**(容量 50、时效 3 天、专属优先策略、批次规模 100)。
* T20 的自优化要的是"真实客服在真实压力下怎么反应",而这里的每一个数都是我按
* `--release-rate` 之类的参数**编出来的** —— 拿它反推 = 自己教自己。
* 而且比没有数据更危险:假信号会驱动你真的去改策略,然后错的策略顶着"数据支持"的名义跑起来。
* 真规律只能让真人跑真批次,没有替代品。
*
* Usage:
* pnpm seed-assignment -- --host=jvs-dw --n=200
* pnpm seed-assignment -- --host=jvs-dw --n=60 --release-rate=0.3 --expire-rate=0.2
* pnpm seed-assignment -- --host=jvs-dw --clean # 只清理,不造
*/
import { NestFactory } from '@nestjs/core';
import { Logger } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import {
AssignStrategy,
PlanEventReason,
PlanEventType,
ReleaseReason,
releaseReasonsForForm,
} from '@pac/types';
import { AppModule } from '../app.module';
import { PrismaService } from '../prisma/prisma.service';
interface Args {
host: string;
n: number;
/** 分下去之后被客服带原因退回的比例 */
releaseRate: number;
/** 到期被系统收走的比例 */
expireRate: number;
/** 客服打开过详情页(view 事件)的比例 —— 「动过没」的唯一可用判据 */
viewRate: number;
clean: boolean;
}
function parseArgs(argv: string[]): Args {
const a: Args = { host: 'jvs-dw', n: 200, releaseRate: 0.15, expireRate: 0.1, viewRate: 0.6, clean: false };
for (const s of argv) {
if (s.startsWith('--host=')) a.host = s.slice(7);
else if (s.startsWith('--n=')) a.n = Number(s.slice(4));
else if (s.startsWith('--release-rate=')) a.releaseRate = Number(s.slice(15));
else if (s.startsWith('--expire-rate=')) a.expireRate = Number(s.slice(14));
else if (s.startsWith('--view-rate=')) a.viewRate = Number(s.slice(12));
else if (s === '--clean') a.clean = true;
}
return a;
}
/**
* 确定性伪随机 —— ⛔ 不用 Math.random():
* 造完一批发现报表不对,得能**原样重造一遍**再看,否则每次数据都不一样,根本没法排查。
*/
function mulberry32(seed: number): () => number {
let t = seed;
return () => {
t = (t + 0x6d2b79f5) | 0;
let r = Math.imul(t ^ (t >>> 15), 1 | t);
r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r;
return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
};
}
async function main(): Promise<void> {
const logger = new Logger('SeedAssignment');
const args = parseArgs(process.argv.slice(2));
const app = await NestFactory.createApplicationContext(AppModule, { logger: ['warn', 'error'] });
const prisma = app.get(PrismaService);
const host = await prisma.host.findFirst({ where: { name: args.host }, select: { id: true } });
if (!host) throw new Error(`Host not found by name=${args.host}`);
// ── 清理:seed 造的批次一律带 SEED_ 前缀的 requestId,只清自己造的 ──
const seeded = await prisma.planAssignment.findMany({
where: { hostId: host.id, requestId: { startsWith: 'SEED_' } },
select: { id: true },
});
if (seeded.length > 0) {
const ids = seeded.map((s) => s.id);
const plans = await prisma.followupPlan.findMany({
where: { assignmentId: { in: ids } },
select: { id: true },
});
await prisma.planEventLog.deleteMany({ where: { planId: { in: plans.map((p) => p.id) } } });
await prisma.followupPlan.updateMany({
where: { assignmentId: { in: ids } },
data: {
assignmentId: null, assignedBy: null, assignStrategy: null, assignmentExpiresAt: null,
dedicatedCsAtAssign: null, dedicatedCsLastVisitAt: null, priorityScoreAtAssign: null,
sourceConfidenceAtAssign: null, selectionMode: null,
releaseReason: null, releaseNote: null,
status: 'active', assigneeUserId: null, assignedAt: null, recycleAt: null,
},
});
await prisma.planAssignment.deleteMany({ where: { id: { in: ids } } });
logger.log(`已清理 ${seeded.length} 个 seed 批次`);
}
if (args.clean) {
await app.close();
return;
}
// ── 取候选:未分配的活跃 plan ────────────────────────────────
const pool = await prisma.followupPlan.findMany({
where: { hostId: host.id, status: 'active', assigneeUserId: null, supersededAt: null },
orderBy: { priorityScore: 'desc' },
take: args.n,
select: { id: true, tenantId: true, patientId: true, targetClinicId: true, priorityScore: true },
});
if (pool.length === 0) throw new Error('召回池里没有可用候选 —— 先跑 recompute-plans');
const clinicId = pool[0]!.targetClinicId;
const tenantId = pool[0]!.tenantId;
const rows = pool.filter((p) => p.targetClinicId === clinicId);
// 名册里挑几个真客服(用真 id,报表里的姓名解析才走得通)
const roster = await prisma.patientReturnVisit.groupBy({
by: ['taskDirectorId'],
where: { hostId: host.id, tenantId, clinicId, taskDirectorId: { not: null } },
_count: { _all: true },
orderBy: { _count: { taskDirectorId: 'desc' } },
take: 6,
});
const agents = roster.map((r) => r.taskDirectorId!).filter(Boolean);
if (agents.length === 0) throw new Error('该诊所没有回访记录,取不到客服 id');
const rand = mulberry32(20260802);
const now = new Date();
const assignedAt = new Date(now.getTime() - 5 * 86400_000); // 5 天前分的
const expiresAt = new Date(now.getTime() - 2 * 86400_000); // 2 天前就到期了
const batch = await prisma.planAssignment.create({
data: {
hostId: host.id,
tenantId,
clinicId,
createdBy: agents[0]!,
requestId: `SEED_${randomUUID()}`,
criteria: { seeded: true, note: '⚠️ 造的数据,只验工程,不得用于反推规律' },
attributes: { benefit: { text: '8 月种植体检免费(seed)' } },
expiresAt,
status: 'confirmed',
},
select: { id: true },
});
const releaseOptions = releaseReasonsForForm().filter((r) => r !== ReleaseReason.OTHER);
let released = 0;
let expired = 0;
let viewed = 0;
for (let i = 0; i < rows.length; i++) {
const p = rows[i]!;
const agent = agents[i % agents.length]!;
const strategy =
i % 10 < 7
? AssignStrategy.DEDICATED
: i % 10 < 9
? AssignStrategy.SPREAD_OVERFLOW
: AssignStrategy.SPREAD_NO_DEDICATED;
const roll = rand();
const isReleased = roll < args.releaseRate;
const isExpired = !isReleased && roll < args.releaseRate + args.expireRate;
const isViewed = rand() < args.viewRate;
await prisma.$transaction(async (tx) => {
await tx.followupPlan.update({
where: { id: p.id },
data: {
assignmentId: batch.id,
assignedBy: agents[0]!,
assignStrategy: strategy,
priorityScoreAtAssign: p.priorityScore,
sourceConfidenceAtAssign: 1,
selectionMode: rand() < 0.1 ? 'explore' : 'rank',
dedicatedCsAtAssign: strategy === AssignStrategy.DEDICATED ? agent : null,
// ⚠️ 三种终态互斥,列的组合必须自洽,否则验出来的报表口径是假的:
// 退回 → active + 有 releaseReason + 无期限
// 到期 → active + 无 releaseReason + 无期限(系统收的,不是客服退的)
// 在手 → assigned + 有期限
...(isReleased
? {
status: 'active', assigneeUserId: null, assignedAt: null, assignmentExpiresAt: null,
releaseReason: releaseOptions[i % releaseOptions.length]!,
}
: isExpired
? { status: 'active', assigneeUserId: null, assignedAt: null, assignmentExpiresAt: null }
: { status: 'assigned', assigneeUserId: agent, assignedAt, assignmentExpiresAt: expiresAt }),
},
});
// 分配事件(每条都有)
await tx.planEventLog.create({
data: {
hostId: host.id, tenantId, planId: p.id, patientId: p.patientId,
event: PlanEventType.ASSIGN, assigneeUserId: agent, actorUserId: agents[0]!,
createdAt: assignedAt,
},
});
// 「动过没」的唯一可用判据 —— plan_executions 回写率仅 11%,拿它判会把 89% 打过电话的算成没动
if (isViewed) {
await tx.planEventLog.create({
data: {
hostId: host.id, tenantId, planId: p.id, patientId: p.patientId,
event: PlanEventType.VIEW, actorUserId: agent,
createdAt: new Date(assignedAt.getTime() + 3600_000),
},
});
viewed++;
}
if (isReleased) {
await tx.planEventLog.create({
data: {
hostId: host.id, tenantId, planId: p.id, patientId: p.patientId,
event: PlanEventType.RELEASE, assigneeUserId: null, actorUserId: agent,
reason: releaseOptions[i % releaseOptions.length]!,
heldSeconds: 86400, createdAt: new Date(assignedAt.getTime() + 86400_000),
},
});
released++;
}
if (isExpired) {
// ⚠️ 到期是**系统**收的:event=auto_release、actor 为空、⛔ 不写 release_reason。
// 混进 release_reason 会让退回率的分子分母一起虚高 —— 这正是要验的口径之一。
await tx.planEventLog.create({
data: {
hostId: host.id, tenantId, planId: p.id, patientId: p.patientId,
event: PlanEventType.AUTO_RELEASE, assigneeUserId: null, actorUserId: null,
reason: PlanEventReason.ASSIGNMENT_EXPIRED,
heldSeconds: 3 * 86400, createdAt: expiresAt,
},
});
expired++;
}
});
}
logger.warn(
`[SEED · 造的数据] 批次 ${batch.id.slice(0, 8)}:${rows.length} 条 / ${agents.length} 位客服 —— ` +
`退回 ${released} · 到期 ${expired} · 打开过 ${viewed}。` +
`⛔ 只用于验报表口径与性能,**不得据此调整任何默认值**。`,
);
await app.close();
}
void main().catch((e) => {
console.error(e);
process.exit(1);
});
...@@ -396,21 +396,24 @@ export class PlanAssignmentService { ...@@ -396,21 +396,24 @@ export class PlanAssignmentService {
// 账本里有:分配时写的 assign 事件带 assigneeUserId,且时间 >= 批次创建时刻, // 账本里有:分配时写的 assign 事件带 assigneeUserId,且时间 >= 批次创建时刻,
// 按 planId 取回来即可。这是 plan_event_logs 存在的意义 —— // 按 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 const assignEvents = planIds.length
? await this.prisma.planEventLog.findMany({ ? await this.prisma.planEventLog.findMany({
where: { where: { planId: { in: planIds }, tenantId: scope.tenantId, event: PlanEventType.ASSIGN },
planId: { in: planIds }, select: { planId: true, assigneeUserId: true },
tenantId: scope.tenantId, orderBy: { createdAt: 'desc' },
event: PlanEventType.ASSIGN,
createdAt: { gte: head.createdAt },
},
select: { planId: true, assigneeUserId: true, createdAt: true },
orderBy: { createdAt: 'asc' },
}) })
: []; : [];
const originalOwner = new Map<string, string>(); const originalOwner = new Map<string, string>();
for (const e of assignEvents) { 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(); 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