Commit 9eeb29b9 by luoqi

feat: 批次归因走账本 + 到期可统计 + 批次人话名

主管四问引出的一组修复。

1) 归因失血(不报错的真 bug)
   followup_plans.assignment_id 会被下一次分配覆盖 —— 一条单退回/到期回池后
   被后面的批次挑走,旧批次就少一个人。批次跑得越久缩得越厉害。
   实测 c02e1b80 分过 9 条,重分后 planned 显示 0。与 supersededAt 那坑同构。
   ⇒ plan_event_logs 加 assignment_id(不建明细表:账本本来就是明细,
     缺的只是"属于哪一批"这个维度;该表自己写着"要按它筛就该立柱")。
     四个写入点全覆盖;claim 刻意不写(自认领捞的是池子,plan 上那个 id 是陈迹)。
   ⇒ planned/agents/released/expired 走账本,inHand/done 走当前状态。
   ⇒ 补 progress.reassigned 让穷尽性重新成立(不补则主管一对数就少人)。
   ⇒ 老批次回落到 followup_plans 现算;到期数无源可落,只能给 0。

2) 到期终于能统计
   到期事件一直在账本里(auto_release + assignment_expired),但任何接口都没报,
   全被 backToPool 一桶吞掉。而到期与退回主管的下一步动作相反:
   退回多=分配策略不对,到期多=派多了/时效太紧。现在分开报,note 也分开说。

3) overdue 加 snoozed 守卫
   回收器刻意跳过"约了下次回访"的单,于是它们永远超期;而 progress 已把它算作
   suppressed(已处理)。不加守卫,同一条单「已处理」和「超期」同时成立 ——
   主管看到「薛玫 超期 3」以为她压单,实际她打了电话约好了下次。

4) 批次人话名 label(服务端唯一生成)
   「8/3 23:35 · 牙周治疗 · 窗口内 · 9 人 · 2 位客服」。没有列表页时这是主管
   指认一批的唯一抓手。做这个才发现 temperature 一直没进 criteria 快照 ——
   它参与圈人却没随确认单下发,导致批次说不清"当时按哪个温度圈的"。已补。

顺带修一个潜伏编译错误:assignmentExpiresAt 加进 FollowupPlanSchema 时漏了
serializePlan(19bd658f)。当时没报错是因为 packages/types/dist 是旧的。

实测(真库):新批次 9 人 → 撤销 → 同一批人重分 → 旧批次 planned 仍是 9
(旧实现下是 0)、五桶+重分 0+0+0+9=9;退回 1 条、到期 2 条(等回收器真扫)
分别落账,详情读出「客服主动退回 1 条、到期没人动 2 条」。
1005 tests,两个 tsc + next build 干净。
parent a6f8c3db
-- AlterTable
ALTER TABLE "plan_event_logs" ADD COLUMN "assignment_id" UUID;
-- AlterTable
ALTER TABLE "plan_executions" ALTER COLUMN "inaccurate_treatments" DROP DEFAULT;
-- CreateIndex
CREATE INDEX "plan_event_logs_assignment_id_event_idx" ON "plan_event_logs"("assignment_id", "event");
-- RenameIndex
ALTER INDEX "patient_return_visits_roster_idx" RENAME TO "patient_return_visits_host_id_tenant_id_clinic_id_task_dire_idx";
......@@ -1566,6 +1566,33 @@ model PlanEventLog {
/// 简短原因(auto_release 'timeout';feedback 'up' / 'down')
reason String?
/**
* 这条事件**发生在哪一批的执行过程中**
*
* ── 为什么必须立柱(而不是塞 details Json)──
* 本表对 details 的规矩是「只放**查询不按它过滤**的内容;要按它筛就该立柱」。
* 批次报表要 `groupBy(assignment_id)` 出数 —— 按自己的规矩就得是列。
*
* ── 为什么必须有(而不是继续从 followup_plans.assignment_id 现算)──
* 🔴 `followup_plans.assignment_id` **会被下一次分配覆盖**:一条单退回/到期回池后
* 再被分进新批次,旧批次就"少了一个人"——planned/agents 静默缩水,**批次跑得越久
* (退回重分越多)缩得越厉害**,且不报任何错。与 supersededAt 那个坑同构。
* 实测:c02e1b80 分过 9 ,重分后 `planned` 显示 **0**
* ⚠️ 曾想用「时间窗」从账本里圈出一批(created_at BETWEEN )—— **那是启发式不是键**,
* 同一主管连着分两批、两个主管同时分,就会糊在一起。归因指标不能这么算。
*
* ── 什么时候写、什么时候留 null ──
* assign(批次分配) 本批 id
* release(客服退回) 退的那一刻它归哪一批
* auto_release(到期/撤销) 同上
* claim / view / feedback **null**。自认领是从池子里自己捞的,
* 绝不能拿 plan 上那个**陈旧的** assignment_id 顶上 —— 那会把"自己捞的"
* 算成"某批分的",批次报表凭空多出人。
*
* ⚠️ 稀疏很正常:本表 heldSeconds / reason / assigneeUserId 也都只对部分事件有值。
*/
assignmentId String? @map("assignment_id") @db.Uuid
/// 事件专属细节(不立柱的部分, feedback 的文字说明)
/// ⚠️ 只放"查询不按它过滤"的内容;要按它筛就该立柱。
details Json?
......@@ -1582,6 +1609,8 @@ model PlanEventLog {
@@index([patientId, createdAt])
/// 按租户 + 事件类型出报表
@@index([hostId, tenantId, event, createdAt])
/// 批次报表主查询:某批分了几条 / 退了几条 / 到期几条(event 一起进索引,聚合不回表)
@@index([assignmentId, event])
@@map("plan_event_logs")
}
......
......@@ -71,6 +71,9 @@ export class AssignmentExpiryScheduler implements OnModuleInit {
select: {
id: true, hostId: true, tenantId: true, patientId: true,
assigneeUserId: true, assignedAt: true,
// ⭐ 账本要记「到期的是哪一批的单」—— 批次报表的「到期几条」全靠它。
// 此刻取是对的:assignment_id 只会被**下一次分配**覆盖,而这一刻还没发生。
assignmentId: true,
},
take: BATCH_LIMIT,
});
......@@ -114,6 +117,7 @@ export class AssignmentExpiryScheduler implements OnModuleInit {
// ⭐ 必须在清空 assignedAt **之前**算(上面 findMany 取的就是清空前的值)
heldSeconds: computeHeldSeconds(p.assignedAt, now),
reason: PlanEventReason.ASSIGNMENT_EXPIRED,
assignmentId: p.assignmentId,
})),
);
});
......
......@@ -150,7 +150,7 @@ export class AssignmentProposalService {
const expOf = (userId: string) => agentOverrides[userId]?.expiresInDays ?? expiresInDays;
const inHandTotal = agents.reduce((a, g) => a + g.inHand, 0);
if (batchSize === 0 || agents.length === 0) {
return emptyProposal(clinicId, potentialTreatment, agents, roster.rosterNote, {
return emptyProposal(clinicId, potentialTreatment, input.temperature, agents, roster.rosterNote, {
target: 0,
batchSize,
expiresInDays,
......@@ -246,6 +246,9 @@ export class AssignmentProposalService {
return {
clinicId,
potentialTreatment: potentialTreatment ?? null,
// ⭐ 必须下发 —— 前端确认时要把它写进 criteria 快照,否则批次说不清
// 「当时按哪个温度圈的」(矩阵 8×3,同一治疗项三档是三批完全不同的人)
temperature: input.temperature ?? null,
/// ⭐ 真候选总数(count 出来的),⛔ 不是 ranked.length —— 后者被 fetchLimit 截过
candidateTotal,
target,
......@@ -750,6 +753,7 @@ function basisNote(x: {
function emptyProposal(
clinicId: string,
potentialTreatment: string | undefined,
temperature: string | undefined,
agents: AgentInfo[],
rosterNote: string,
base: {
......@@ -764,6 +768,7 @@ function emptyProposal(
return {
clinicId,
potentialTreatment: potentialTreatment ?? null,
temperature: temperature ?? null,
candidateTotal: 0,
target: base.target,
batchSize: base.batchSize,
......
......@@ -57,6 +57,7 @@ export function recordPlanEventsBulk(
actorUserId: input.actorUserId ?? null,
heldSeconds: input.heldSeconds ?? null,
reason: input.reason ?? null,
assignmentId: input.assignmentId ?? null,
details: input.details ?? undefined,
})),
});
......@@ -80,6 +81,14 @@ export interface PlanEventInput {
* 否则这一列会变成第二个「随手写字符串」的地方,而它正是退回原因分布的唯一数据源。
*/
reason?: PlanEventReasonValue | null;
/**
* 这条事件发生在**哪一批**的执行过程中(见 schema 里 `assignment_id` 的整段说明)。
*
* ⚠️ 只有 assign / release / auto_release 该传。
* ⛔ **claim 绝不能传** —— 自认领是从池子里自己捞的,而 plan 上那个 assignment_id
* 可能是上一批留下的陈迹(退回/到期都刻意不清它),顶上去等于给旧批次凭空加人。
*/
assignmentId?: string | null;
/** 事件专属细节(不按它查询的内容,如反馈文字) */
details?: Prisma.InputJsonObject | null;
}
......@@ -96,6 +105,7 @@ export function recordPlanEvent(tx: PlanEventLogWriter, input: PlanEventInput):
actorUserId: input.actorUserId ?? null,
heldSeconds: input.heldSeconds ?? null,
reason: input.reason ?? null,
assignmentId: input.assignmentId ?? null,
// undefined 才让 Prisma 落 NULL;传 null 会被当成 JSON null 值
details: input.details ?? undefined,
},
......
......@@ -726,6 +726,10 @@ export class PlanService {
!actorUserId || actorUserId === assigneeUserId
? PlanEventType.CLAIM
: PlanEventType.ASSIGN,
// ⛔ **这里不传 assignmentId,别"顺手补上"**。这条路是从池子里单条认领/指派,
// 不属于任何批次;而 plan 上那个 assignment_id 很可能是**上一批的陈迹**
// (退回/到期都刻意不清它)。填进去等于给一个早就结束的批次凭空加人,
// 而且不报错 —— 批次报表会莫名其妙多出几条。
assigneeUserId,
actorUserId: actorUserId ?? assigneeUserId,
});
......@@ -853,6 +857,11 @@ export class PlanService {
// ⚠️ 聚合这一列时**必须同时过滤 event='release'** —— 否则会混进 auto_release 的
// timeout/clinic_moved 和 feedback 的 up/down。
reason: releaseReason ?? null,
// ⭐ 退的是**哪一批**的单 —— 批次报表的退回率靠它,而不是靠
// followup_plans.assignment_id(那个会被下一次分配覆盖)。
// ⚠️ 这一刻取是对的:退回本身不清 assignment_id(上面那段注释),
// 覆盖只发生在他被分进新批次时,那是以后的事。
assignmentId: plan.assignmentId,
details: note ? { note } : null,
});
}
......@@ -976,6 +985,11 @@ function serializePlan(p: PlanRow, assigneeName?: string | null): import('@pac/t
/// 由调用方(detail)解析后注入 —— 序列化函数本身不发查询
assigneeName: assigneeName ?? null,
assignedAt: p.assignedAt?.toISOString() ?? null,
// 🔴 加进 FollowupPlanSchema 时**漏了这里**(19bd658)。当时没报错,是因为
// `packages/types/dist` 是旧的 —— 编译期看的是过期声明。重编 types 才炸出来。
// ⚠️ 教训:改完 packages/types 的 schema 必须 `pnpm --filter @pac/types build`
// 再跑各 app 的 tsc,否则"绿"是假的。
assignmentExpiresAt: p.assignmentExpiresAt?.toISOString() ?? null,
createdAt: p.createdAt.toISOString(),
updatedAt: p.updatedAt.toISOString(),
supersededAt: p.supersededAt?.toISOString() ?? null,
......
......@@ -70,6 +70,8 @@ export class RecycleSchedulerService implements OnModuleInit {
select: {
id: true, hostId: true, tenantId: true, patientId: true,
assigneeUserId: true, assignedAt: true,
// 账本记「回收的是哪一批的单」——口径与 AssignmentExpiryScheduler 一致
assignmentId: true,
},
// 单轮上限:防积压时一次性打爆事务;剩下的下一轮继续
take: 500,
......@@ -104,6 +106,7 @@ export class RecycleSchedulerService implements OnModuleInit {
actorUserId: null, // 系统行为,无操作人
heldSeconds: computeHeldSeconds(p.assignedAt, now),
reason: PlanEventReason.TIMEOUT,
assignmentId: p.assignmentId,
});
});
recycled++;
......
import { Test } from '@nestjs/testing';
import { PlanAssignmentService } from '../src/modules/plan/plan-assignment.service';
import { PrismaService } from '../src/prisma/prisma.service';
/**
* 批次归因走**账本**(plan_event_logs.assignment_id)。
*
* 🔴 起因是一个不报错的真 bug:`followup_plans.assignment_id` **会被下一次分配覆盖**。
* 一条单退回/到期回池后被后面的批次挑走,旧批次就少一个人 ——
* **批次跑得越久(退回重分越多)缩得越厉害**,而且没有任何报错。
* 实测:c02e1b80 分过 9 条,重分之后 `planned` 显示 **0**。
* (与 supersededAt 那个坑同构:批次跑得越有进展,数字反而越小。)
*
* 另一半:到期与退回**必须分开数**。主管的下一步动作是相反的 ——
* 退回多 → 分配策略不对(派给了不该派的人);
* 到期多 → 派多了 / 时效太紧 / 人不在岗。
* 合成一个"回池率"两种病都看不出来。
*/
const SCOPE = {
hostId: 'h1', tenantId: 't1', sourceUnits: [] as string[], clinicIds: [] as string[], userId: 'u1',
} as never;
const BATCH = 'c02e1b80-1111-4222-8333-444455556666';
function makePrisma(opts: {
/** 现在**还归本批**的 plan(重分走的人不在这里 —— 那正是问题所在) */
plans?: Array<{ id: string; status: string; assigneeUserId: string | null; releaseReason: string | null }>;
/** 账本口径的一行汇总;不传 = 模拟老批次(账本没记批次号) */
ledger?: { planned: number; agents: number; released: number; expired: number; revoked: number } | null;
criteria?: Record<string, unknown>;
status?: string;
createdAt?: Date;
}) {
const plans = opts.plans ?? [];
const queryRaw = jest.fn(async () =>
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 prisma = {
planAssignment: {
findFirst: jest.fn(async () => ({
id: BATCH, hostId: 'h1', tenantId: 't1', clinicId: 'c1', createdBy: 'leader-1',
requestId: 'r1', criteria: opts.criteria ?? {}, attributes: null,
expiresAt: new Date('2026-08-06T16:00:00Z'), status: opts.status ?? 'confirmed',
revokedAt: null, revokedBy: null,
createdAt: opts.createdAt ?? new Date('2026-08-03T13:33:00Z'),
updatedAt: new Date('2026-08-03T13:33:00Z'),
})),
findMany: jest.fn(async () => [{
id: BATCH, hostId: 'h1', tenantId: 't1', clinicId: 'c1', createdBy: 'leader-1',
criteria: opts.criteria ?? {}, attributes: null,
expiresAt: new Date('2026-08-06T16:00:00Z'), status: opts.status ?? 'confirmed',
createdAt: opts.createdAt ?? new Date('2026-08-03T13:33:00Z'),
}]),
},
followupPlan: {
findMany: jest.fn(async () =>
plans.map((p) => ({
...p, assignmentId: BATCH, patientId: `pat-${p.id}`, version: 1,
snoozedUntil: null, assignmentExpiresAt: null,
})),
),
},
planEventLog: { findMany: jest.fn(async () => []), groupBy: jest.fn(async () => []) },
host: { findUnique: jest.fn(async () => ({ pullConfig: { timezone: 'Asia/Shanghai' } })) },
$queryRaw: queryRaw,
} as unknown as PrismaService;
return { prisma, queryRaw };
}
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('批次归因 —— planned 不随重分失血', () => {
test('⭐⭐ 人被后面的批次挑走后,planned 仍是**当时分的 9**(⛔ 不是现在剩的 2)', async () => {
const { prisma } = makePrisma({
// 9 个人里 7 个已被重分走 → followup_plans 只剩 2 条还挂在本批
plans: [
{ id: 'p1', status: 'assigned', assigneeUserId: 'a', releaseReason: null },
{ id: 'p2', status: 'assigned', assigneeUserId: 'b', releaseReason: null },
],
ledger: { planned: 9, agents: 2, released: 3, expired: 2, revoked: 0 },
});
const svc = await build(prisma);
const d = await svc.detail(SCOPE, BATCH);
expect(d.planned).toBe(9);
expect(d.agents).toBe(2);
});
test('⭐⭐ 穷尽性:done + inHandPending + backToPool + **reassigned** === planned', async () => {
// 不补 reassigned 这一桶,主管一对数就发现少了 7 个人,而少的那几个永远查不出去哪了
const { prisma } = makePrisma({
plans: [
{ id: 'p1', status: 'assigned', assigneeUserId: 'a', releaseReason: null },
{ id: 'p2', status: 'active', assigneeUserId: null, releaseReason: 'over_capacity' },
],
ledger: { planned: 9, agents: 2, released: 3, expired: 2, revoked: 0 },
});
const svc = await build(prisma);
const p = (await svc.detail(SCOPE, BATCH)).progress;
expect(p.reassigned).toBe(7);
expect(p.done + p.inHandPending + p.backToPool + p.reassigned).toBe(9);
});
test('⭐ 老批次(账本没记批次号)回落到 followup_plans 现算,⛔ 不显示 0', async () => {
// 显示一个偏小的数,总好过显示 0 让主管以为这批根本没分成
const { prisma } = makePrisma({
plans: [
{ id: 'p1', status: 'assigned', assigneeUserId: 'a', releaseReason: null },
{ id: 'p2', status: 'assigned', assigneeUserId: 'b', releaseReason: null },
],
ledger: null,
});
const svc = await build(prisma);
const d = await svc.detail(SCOPE, BATCH);
expect(d.planned).toBe(2);
// ⚠️ 老批次的到期数没有任何可回落的源 → 0,⛔ 不许拿 backToPool 顶替
expect(d.expired).toBe(0);
});
});
describe('批次归因 —— 到期与退回必须分开', () => {
test('⭐⭐ expired 与 released 是两个数(合成一个"回池率"两种病都看不出来)', async () => {
const { prisma } = makePrisma({
plans: [{ id: 'p1', status: 'assigned', assigneeUserId: 'a', releaseReason: null }],
ledger: { planned: 9, agents: 2, released: 3, expired: 2, revoked: 0 },
});
const svc = await build(prisma);
const d = await svc.detail(SCOPE, BATCH);
expect(d.released).toBe(3);
expect(d.expired).toBe(2);
});
test('⭐ note 要把两者分开说出来 —— 助手是照抄这句话的', async () => {
const { prisma } = makePrisma({
plans: [{ id: 'p1', status: 'assigned', assigneeUserId: 'a', releaseReason: null }],
ledger: { planned: 9, agents: 2, released: 3, expired: 2, revoked: 0 },
});
const svc = await build(prisma);
const note = (await svc.detail(SCOPE, BATCH)).progress.note;
expect(note).toContain('客服主动退回 3 条');
expect(note).toContain('到期没人动 2 条');
expect(note).toContain('处理率不是成功率');
});
});
describe('批次名 —— 没有列表页时,主管指认一批的唯一抓手', () => {
test('⭐⭐ 含时间(到分钟)· 治疗项 · **温度** · 人数 · 客服数', async () => {
const { prisma } = makePrisma({
plans: [],
ledger: { planned: 9, agents: 2, released: 0, expired: 0, revoked: 0 },
criteria: { potentialTreatment: 'perio', temperature: 'warm' },
createdAt: new Date('2026-08-03T13:33:00Z'), // 东八区 = 8/3 21:33
});
const svc = await build(prisma);
const d = await svc.detail(SCOPE, BATCH);
expect(d.label).toBe('8/3 21:33 · 牙周治疗 · 窗口内 · 9 人 · 2 位客服');
});
test('⭐ 时间按**宿主时区**,⛔ 不是服务器本地时区', async () => {
// 主管说的"今天下午那批"是他诊所墙上的时间;服务器在别的时区时标签会指向另一天,
// 而这个名字正是他指认批次的抓手。
const { prisma } = makePrisma({
plans: [],
ledger: { planned: 1, agents: 1, released: 0, expired: 0, revoked: 0 },
criteria: {},
createdAt: new Date('2026-08-03T17:00:00Z'), // UTC 8/3 17:00 → 东八区已是 8/4 01:00
});
const svc = await build(prisma);
expect((await svc.detail(SCOPE, BATCH)).label).toContain('8/4 01:00');
});
test('⭐ 老批次没存温度 → 那一段**不出现**(⛔ 不许默认填一个)', async () => {
// 填了会让主管以为那批是按"窗口内"圈的,而他可能圈的是"黄金期"
const { prisma } = makePrisma({
plans: [],
ledger: { planned: 5, agents: 1, released: 0, expired: 0, revoked: 0 },
criteria: { potentialTreatment: 'implant' },
});
const svc = await build(prisma);
const label = (await svc.detail(SCOPE, BATCH)).label;
expect(label).toContain('种植治疗');
expect(label).not.toMatch(/黄金期|窗口内|窗口外/);
});
test('已撤销的批次要在名字里标出来 —— 主管扫列表时最先要排除的就是它', async () => {
const { prisma } = makePrisma({
plans: [],
ledger: { planned: 9, agents: 2, released: 0, expired: 0, revoked: 9 },
status: 'revoked',
});
const svc = await build(prisma);
expect((await svc.detail(SCOPE, BATCH)).label).toContain('已撤销');
});
});
......@@ -51,6 +51,11 @@ function makePrisma(opts: {
),
},
planEventLog: { findMany: eventFindMany, groupBy: jest.fn(async () => []) },
// 批次名要按**宿主时区**格式化(主管说的"今天下午那批"是他墙上的时间)
host: { findUnique: jest.fn(async () => ({ pullConfig: { timezone: 'Asia/Shanghai' } })) },
// ⚠️ 账本口径的计数走 $queryRaw。这里返回空 = 模拟「**老批次**」(账本里没有批次号),
// 于是回落到 followup_plans 现算 —— 本 spec 锁的正是那条回落路径的口径。
// 新口径(账本有数)另有 assignment-ledger-attribution.spec 覆盖。
$queryRaw: jest.fn(async () => []),
} as unknown as PrismaService;
return { prisma, eventFindMany };
......
......@@ -311,6 +311,10 @@ export function AssignmentConfirmSheet({
// 初筛条件快照:批次要能解释"当时按什么圈的"
criteria: {
potentialTreatment: sheet.potentialTreatment,
// ⭐ 温度必须一起存 —— 矩阵 8×3,「牙周·黄金期」和「牙周·窗口内」是两批
// 完全不同的人。只存治疗项的话,事后既说不清这批是怎么圈的,
// 也没法反推"哪种格子分下去效果好"(实测漏了很久)。
temperature: sheet.temperature,
candidateTotal: sheet.candidateTotal,
target: sheet.target,
selectionNote: sheet.selectionNote,
......
......@@ -886,6 +886,60 @@ artifact iframe 是 `sandbox="allow-scripts"` + CSP `connect-src 'none'`,**卡
**也在助手里** —— 主管对话提问,助手查 MCP,用 `render_artifact` 出卡片/图表。
跟踪是**纯只读展示**,正落在「只读用 artifact、可交互用原生组件」的 artifact 一侧。
> 🔴🔴🔴 **归因必须走账本,⛔ 不能数 `followup_plans.assignment_id`。**(2026-08-04 落地)
>
> 那一列**会被下一次分配覆盖**:一条单退回 / 到期回池后被后面的批次挑走,
> 旧批次就少一个人 —— **批次跑得越久(退回重分越多)缩得越厉害**,且不报任何错。
> 实测 `c02e1b80` 分过 9 条,重分之后 `planned` 显示 **0**。
> 与 `supersededAt` 那个坑同构:**批次跑得越有进展,数字反而越小。**
>
> ⇒ `plan_event_logs` 加一列 `assignment_id`(**不建明细表** —— append-only 的账本
> 本来就是明细,缺的只是"属于哪一批"这个维度)。这张表对 `details Json` 自己写着
> 「要按它筛就该立柱」,而我们要 `groupBy` 出报表,按它自己的规矩就得是列。
> 稀疏也不是新先例:`heldSeconds` / `reason` / `assigneeUserId` 本来就只对部分事件有值。
>
> ⚠️ 曾想用**时间窗**从账本里圈一批(`created_at BETWEEN …`)—— **那是启发式不是键**,
> 同一主管连着分两批、两个主管同时分就糊了。归因指标不能这么算。
>
> ⚠️ 写入点四个,**漏一个就少算且不报错**:`create`(assign) / `recycle`(release) /
> `AssignmentExpiryScheduler` + `revoke`(auto_release)。回归里锁着。
> ⛔ **claim 不写** —— 自认领是从池子里自己捞的,而 plan 上那个 `assignment_id`
> 可能是上一批的陈迹(退回/到期都刻意不清它),填进去等于给一个早就结束的批次凭空加人。
>
> ⚠️ **口径分两源,别混**:`planned/agents/released/expired` 走账本(历史事实,永不变);
> `inHand/done` 走 `followup_plans`(当前状态)。
> ⚠️ planned 改成账本口径后,五桶就加不出 planned 了 —— 补 **`reassigned`**
> (已被后续批次挑走)这一桶让穷尽性重新成立。不补的话主管一对数就发现少人,
> 而少的那几个永远查不出去哪了。
> ⚠️ **老批次**(本列上线前)账本里没批次号 → 回落到 `followup_plans` 现算(偏小),
> ⛔ 但**到期数没有任何可回落的源**,只能给 0:拿 `backToPool` 顶替会把退回算成到期。
> ⭐ **到期 ≠ 退回,两个数必须分开报。** 主管的下一步动作**相反**:
> 退回多 → 分配策略不对(派给了不该派的人);到期多 → 派多了 / 时效太紧 / 人不在岗。
> 合成一个"回池率"两种病都看不出来。
> (枚举注释早就写清了「到期是**没处置**、退回是**处置**」,所以到期刻意不写
> `followup_plans.release_reason` —— 但此前**任何接口都没把到期数报出来**,
> 全被 `backToPool` 一桶吞掉了。)
> 🔴 **`overdue` 必须排除"约了下次回访"的单。**
> 到期回收器刻意跳过 `snoozedUntil` 在未来的单(客服约了 6/10 回访,那之前绝不能收走,
> 否则毁掉他对患者的承诺)—— 于是这类单会一直"超期"下去。
> 而 `progress` 那边已经把它算作 **suppressed(已处理)**。不加守卫,同一条单
> 「已处理」和「超期」同时成立:主管看到「薛玫 超期 3」以为她压着单没动,
> **实际上她打了电话、约好了下次** —— 干得最好的那个被指责了。
> ⭐ **批次的人话名字**(`label`,服务端唯一生成点)——「8/3 23:35 · 牙周治疗 · 窗口内 · 9 人 · 2 位客服」。
> 没有批次列表页时,这是主管指认一批的**唯一抓手**(「撤销今天下午牙周窗口内那批」)。
> ⛔ 不许让模型自己拼:措辞会在两轮之间漂,主管就对不上"上次说的那批"。
> ⚠️ 时间按**宿主时区**、精确到**分钟** —— 同一个矩阵格子一天可能分好几批。
>
> 🔴 做这个时才发现 **`temperature` 一直没进 `criteria` 快照**:它参与了圈人
> (`cohort-filter.temperatureSql`),却没随确认单下发。而 schema 对 `criteria` 的承诺是
> 「批次要能解释**当时是按什么圈的**」。矩阵 8×3,「牙周·黄金期」和「牙周·窗口内」
> 是两批完全不同的人,落库后**分不出来**;T20 想反推"哪种格子效果好"直接答不了。
> 已补(proposal → 快照)。⚠️ 老批次补不回来,标签里那一段就**不出现** ——
> ⛔ 不许默认填一个,会让主管以为那批是按"窗口内"圈的。
### 主管实际会问的三类问题 → 三个 MCP 工具
```
......
......@@ -188,16 +188,45 @@ export const AssignmentBriefSchema = z.object({
status: z.string(),
expiresAt: z.string(),
createdAt: z.string(),
/// 以下计数**从 followup_plans 现算**,不是冗余列(立柱等于埋一个会漂的数)
/**
* 本批分了多少人(**按患者去重、取最新版本**)。
* ⚠️ 口径**不是**「活跃版本」—— 那会把引擎判定"需求已了"的人静默剔掉,
* 批次跑得越好、这个数缩水得越厉害。见 plan-assignment.service.detail 的红字注释。
* ⭐ 人话批次名 —— 「8/3 21:33 · 牙周治疗 · 窗口内 · 9 人 · 2 位客服」。
*
* 主管记不住也不该记 uuid。**没有批次列表页**的情况下,他指认一批的唯一办法就是描述它
* (「撤销今天下午牙周窗口内那批」),模型再从列表里按这个名字匹配。
* ⚠️ **服务端生成**,⛔ 不许让模型自己拼:措辞会在两轮之间漂,主管就对不上"上次说的那批"。
* ⚠️ 时间精确到分钟是主要区分器 —— 同一个矩阵格子一天可能分好几批。
*/
label: z.string().describe('人话批次名(服务端生成,唯一对外称呼)'),
/// 以下计数**现算不落列**(T17)。⚠️ 但数据源分两处,别搞混:
/// · planned / agents / released / expired —— 从**账本** `plan_event_logs` 算(历史事实,永不变)
/// · inHand / done —— 从 `followup_plans` 现算(当前状态,会变)
/**
* 本批**当时**分了多少人(按患者去重)。
*
* 🔴 源必须是账本,⛔ 不能数 `followup_plans.assignment_id`:那一列**会被下一次分配覆盖**。
* 一条单退回/到期回池后再被分进新批次,旧批次就少一个人 —— **批次跑得越久
* (退回重分越多)缩得越厉害**,且不报错。实测 c02e1b80 分过 9 条、重分后显示 0。
* ⚠️ 口径也不是「活跃版本」—— 那会把引擎判定"需求已了"的人剔掉,同一个坑的另一面。
*/
planned: z.number().int().describe('本批当时分了多少人(按患者去重,取自账本)'),
inHand: z.number().int().describe('仍挂在客服名下(当前状态)'),
/**
* 客服**主动退回**的条数(取自账本 event='release')。
* ⚠️ ⛔ 不数 `followup_plans.release_reason` —— 重分时那一列会被清空(assign 的
* SQL 里 `release_reason = NULL`),数它等于"被退过又被重分的人从没退过"。
* ⚠️ **不含到期**:到期是"客服压根没动",与"看了判断不该我做"是两回事,见 expired。
*/
released: z.number().int().describe('客服主动退回(不含到期)'),
/**
* 到期自动回池的条数(账本 auto_release + reason=assignment_expired)。
*
* ⚠️ 这是**"没处置"**,不是"处置了"。它和 released 必须分开看,因为主管的下一步相反:
* 退回多 → 分配策略不对(派给了不该派的人);
* 到期多 → 派多了 / 时效太紧 / 人不在岗。
* 合成一个"回池率"两种病都看不出来。
*/
planned: z.number().int().describe('本批分了多少人(按患者去重)'),
inHand: z.number().int().describe('仍挂在客服名下'),
released: z.number().int().describe('已退回'),
agents: z.number().int().describe('涉及几个客服'),
expired: z.number().int().describe('到期自动回池(客服没动)'),
agents: z.number().int().describe('涉及几个客服(取自账本)'),
/**
* 已处理条数 —— 判据是**池子状态**(出池 / 被抑制),不是回写。
* 详见 `AssignmentDetailResponseSchema.progress` 的整段说明。
......@@ -219,7 +248,16 @@ export const AssignmentAgentStatSchema = z.object({
planned: z.number().int(),
inHand: z.number().int(),
released: z.number().int(),
overdue: z.number().int().describe('仍在手且已过 assignment_expires_at'),
/**
* 仍在手、已过时效、**且没有约定回访**。
*
* 🔴 「且没有约定回访」这半句不能少。到期回收器刻意跳过 `snoozedUntil` 在未来的单
* (客服约了 6/10 回访就绝不能在那之前收走,否则毁掉他对患者的承诺)——
* 于是这类单会一直"超期"下去。而 `progress` 那边已经把它算作 **suppressed(已处理)**。
* 不加这条守卫,同一条单就会「已处理」和「超期」同时成立:主管看到「薛玫 超期 3」
* 以为她压着单没动,**实际上她打了电话、约好了下次** —— 干得最好的那个被指责了。
*/
overdue: z.number().int().describe('仍在手、已过时效、且没约下次回访'),
});
export type AssignmentAgentStat = z.infer<typeof AssignmentAgentStatSchema>;
......@@ -257,7 +295,17 @@ export const AssignmentDetailResponseSchema = AssignmentBriefSchema.extend({
suppressed: z.number().int().describe('被抑制(客服写了回访结果)'),
closed: z.number().int().describe('已结案 / 已放弃'),
inHandPending: z.number().int().describe('还在客服手上,尚未处理'),
backToPool: z.number().int().describe('退回或到期,已落回池子'),
backToPool: z.number().int().describe('退回或到期,已落回池子(仍归本批)'),
/**
* 已经**被分进别的批次**的人数。
*
* 他们当时确实在本批(账本记着),但后来退回/到期回池、又被后面的批次挑走了 ——
* 此后的表现该算给新批次,本批说不了。
* ⚠️ 加这一桶是为了让**穷尽性重新成立**:
* `done + inHandPending + backToPool + reassigned === planned`(回归里锁着)。
* planned 改成账本口径后,不补这一桶就会"加不出来",而少的那几个永远查不出去哪了。
*/
reassigned: z.number().int().describe('已被后续批次挑走(后续表现归那一批)'),
ageDays: z.number().int().describe('批次年龄 —— ⚠️ 跨批次比处理率前必须先对齐它'),
note: z.string().describe('成品句子,助手原话转述'),
}),
......@@ -337,6 +385,17 @@ export type ProposalAgentRow = z.infer<typeof ProposalAgentRowSchema>;
export const AssignmentProposalSchema = z.object({
clinicId: z.string(),
potentialTreatment: z.string().nullable(),
/**
* ⭐ 矩阵 Y 轴:窗口温度(hot/warm/cold)。
*
* 🔴 2026-08-04 走查:它**参与了圈人**(cohort-filter 的 temperatureSql),
* 却一直没随确认单下发、也就没进 `plan_assignments.criteria` 快照 ——
* 而 schema 对 criteria 的承诺是「批次要能解释**当时是按什么圈的**」。
* 后果:矩阵 8×3,「牙周·黄金期」和「牙周·窗口内」是两批完全不同的人,
* 落库后**分不出来**;T20 想反推"哪种格子分下去效果好"直接答不了。
* ⚠️ 老批次补不回来(数据当时就没存),只能从此往后有。
*/
temperature: z.string().nullable(),
/// ⭐ 与矩阵格子同一种数法(count DISTINCT patient_id),⛔ 不受取明细的 LIMIT 影响 ——
/// 从矩阵点进来的主管会拿这个数跟他刚看到的格子对
candidateTotal: z.number().int().describe('候选总数 = 该格子/该条件下的患者数(与矩阵格子对得上)'),
......
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