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 { ...@@ -1566,6 +1566,33 @@ model PlanEventLog {
/// 简短原因(auto_release 'timeout';feedback 'up' / 'down') /// 简短原因(auto_release 'timeout';feedback 'up' / 'down')
reason String? 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 的文字说明) /// 事件专属细节(不立柱的部分, feedback 的文字说明)
/// ⚠️ 只放"查询不按它过滤"的内容;要按它筛就该立柱。 /// ⚠️ 只放"查询不按它过滤"的内容;要按它筛就该立柱。
details Json? details Json?
...@@ -1582,6 +1609,8 @@ model PlanEventLog { ...@@ -1582,6 +1609,8 @@ model PlanEventLog {
@@index([patientId, createdAt]) @@index([patientId, createdAt])
/// 按租户 + 事件类型出报表 /// 按租户 + 事件类型出报表
@@index([hostId, tenantId, event, createdAt]) @@index([hostId, tenantId, event, createdAt])
/// 批次报表主查询:某批分了几条 / 退了几条 / 到期几条(event 一起进索引,聚合不回表)
@@index([assignmentId, event])
@@map("plan_event_logs") @@map("plan_event_logs")
} }
......
...@@ -71,6 +71,9 @@ export class AssignmentExpiryScheduler implements OnModuleInit { ...@@ -71,6 +71,9 @@ export class AssignmentExpiryScheduler implements OnModuleInit {
select: { select: {
id: true, hostId: true, tenantId: true, patientId: true, id: true, hostId: true, tenantId: true, patientId: true,
assigneeUserId: true, assignedAt: true, assigneeUserId: true, assignedAt: true,
// ⭐ 账本要记「到期的是哪一批的单」—— 批次报表的「到期几条」全靠它。
// 此刻取是对的:assignment_id 只会被**下一次分配**覆盖,而这一刻还没发生。
assignmentId: true,
}, },
take: BATCH_LIMIT, take: BATCH_LIMIT,
}); });
...@@ -114,6 +117,7 @@ export class AssignmentExpiryScheduler implements OnModuleInit { ...@@ -114,6 +117,7 @@ export class AssignmentExpiryScheduler implements OnModuleInit {
// ⭐ 必须在清空 assignedAt **之前**算(上面 findMany 取的就是清空前的值) // ⭐ 必须在清空 assignedAt **之前**算(上面 findMany 取的就是清空前的值)
heldSeconds: computeHeldSeconds(p.assignedAt, now), heldSeconds: computeHeldSeconds(p.assignedAt, now),
reason: PlanEventReason.ASSIGNMENT_EXPIRED, reason: PlanEventReason.ASSIGNMENT_EXPIRED,
assignmentId: p.assignmentId,
})), })),
); );
}); });
......
...@@ -150,7 +150,7 @@ export class AssignmentProposalService { ...@@ -150,7 +150,7 @@ export class AssignmentProposalService {
const expOf = (userId: string) => agentOverrides[userId]?.expiresInDays ?? expiresInDays; const expOf = (userId: string) => agentOverrides[userId]?.expiresInDays ?? expiresInDays;
const inHandTotal = agents.reduce((a, g) => a + g.inHand, 0); const inHandTotal = agents.reduce((a, g) => a + g.inHand, 0);
if (batchSize === 0 || agents.length === 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, target: 0,
batchSize, batchSize,
expiresInDays, expiresInDays,
...@@ -246,6 +246,9 @@ export class AssignmentProposalService { ...@@ -246,6 +246,9 @@ export class AssignmentProposalService {
return { return {
clinicId, clinicId,
potentialTreatment: potentialTreatment ?? null, potentialTreatment: potentialTreatment ?? null,
// ⭐ 必须下发 —— 前端确认时要把它写进 criteria 快照,否则批次说不清
// 「当时按哪个温度圈的」(矩阵 8×3,同一治疗项三档是三批完全不同的人)
temperature: input.temperature ?? null,
/// ⭐ 真候选总数(count 出来的),⛔ 不是 ranked.length —— 后者被 fetchLimit 截过 /// ⭐ 真候选总数(count 出来的),⛔ 不是 ranked.length —— 后者被 fetchLimit 截过
candidateTotal, candidateTotal,
target, target,
...@@ -750,6 +753,7 @@ function basisNote(x: { ...@@ -750,6 +753,7 @@ function basisNote(x: {
function emptyProposal( function emptyProposal(
clinicId: string, clinicId: string,
potentialTreatment: string | undefined, potentialTreatment: string | undefined,
temperature: string | undefined,
agents: AgentInfo[], agents: AgentInfo[],
rosterNote: string, rosterNote: string,
base: { base: {
...@@ -764,6 +768,7 @@ function emptyProposal( ...@@ -764,6 +768,7 @@ function emptyProposal(
return { return {
clinicId, clinicId,
potentialTreatment: potentialTreatment ?? null, potentialTreatment: potentialTreatment ?? null,
temperature: temperature ?? null,
candidateTotal: 0, candidateTotal: 0,
target: base.target, target: base.target,
batchSize: base.batchSize, batchSize: base.batchSize,
......
...@@ -13,6 +13,9 @@ import { ...@@ -13,6 +13,9 @@ import {
PlanEventType, PlanEventType,
RELEASE_REASON_META, RELEASE_REASON_META,
REVOKE_WINDOW_MINUTES, REVOKE_WINDOW_MINUTES,
TEMPERATURE_META,
potentialTreatmentCardLabel,
type TemperatureValue,
type AssignmentAgentStat, type AssignmentAgentStat,
type AssignmentDetailResponse, type AssignmentDetailResponse,
type AssignmentSkipped, type AssignmentSkipped,
...@@ -93,6 +96,32 @@ export function classifyPlanProgress( ...@@ -93,6 +96,32 @@ export function classifyPlanProgress(
return 'backToPool'; return 'backToPool';
} }
/**
* 合并两个数据源:**历史事实走账本,当前状态走 followup_plans**。
*
* ⚠️ `ledger.planned === 0` 是「老批次」的判据 —— 本列上线前的账本没有批次号。
* 真实批次的 planned 不可能是 0(create 在 applied.length===0 时直接回滚,不留空批次),
* 所以这个判据是安全的。
* ⚠️ 老批次回落到 followup_plans 现算:那个口径**会随重分失血**(偏小),
* 但显示一个偏小的数,总好过显示 0 让主管以为这批根本没分成。
* ⛔ 别为此去猜测补数 —— 猜出来的分母比缺失更难发现。
*/
function mergeStats(
live: { planned: number; agents: number; released: number } | undefined,
ledger: { planned: number; agents: number; released: number; expired: number; revoked: number } | undefined,
): { planned: number; agents: number; released: number; expired: number } {
const l = live ?? { planned: 0, agents: 0, released: 0 };
const legacy = !ledger || ledger.planned === 0;
return {
planned: legacy ? l.planned : ledger.planned,
agents: legacy ? l.agents : ledger.agents,
released: legacy ? l.released : ledger.released,
// 老批次的到期数**没有**任何可回落的源(旧账本没记批次号,followup_plans 也不区分
// 到期与退回)—— 给 0,⛔ 不许拿 backToPool 顶替:那会把退回算成到期。
expired: legacy ? 0 : ledger.expired,
};
}
function readBenefitText(attributes: unknown): string | null { function readBenefitText(attributes: unknown): string | null {
const a = attributes as { benefit?: { text?: string } } | null | undefined; const a = attributes as { benefit?: { text?: string } } | null | undefined;
const t = a?.benefit?.text; const t = a?.benefit?.text;
...@@ -347,6 +376,9 @@ export class PlanAssignmentService { ...@@ -347,6 +376,9 @@ export class PlanAssignmentService {
event: PlanEventType.ASSIGN, event: PlanEventType.ASSIGN,
assigneeUserId: e.item.assigneeUserId, assigneeUserId: e.item.assigneeUserId,
actorUserId: actor.userId, actorUserId: actor.userId,
// ⭐ 批次归因的**真值**就落在这里。followup_plans.assignment_id
// 会被下一次分配覆盖(退回重分很常见),账本这条永不改变。
assignmentId: head.id,
}; };
}), }),
); );
...@@ -453,11 +485,82 @@ export class PlanAssignmentService { ...@@ -453,11 +485,82 @@ export class PlanAssignmentService {
} }
/** /**
* 账本口径的批次统计 —— **历史事实,永不改变**。
*
* 🔴 为什么不能继续数 `followup_plans.assignment_id`:那一列会被**下一次分配覆盖**。
* 退回/到期回池的人被后面的批次挑走后,旧批次就少一个 —— 跑得越久缩得越厉害,
* 且不报错(实测 c02e1b80 分过 9 条、重分后 planned 显示 0)。
*
* ⚠️ 一律 `count(DISTINCT plan_id/patient_id)`,⛔ 不能数行数:
* 同一条单可能被退回两次(退回 → 有人自认领 → 又退回),
* 而第二次退回时 plan 上那个 assignment_id 还是本批的陈迹 —— 数行会把它算成两条。
*
* ⚠️ **老批次(本列上线前的)账本里没有批次号**,这里会返回 0;
* 调用方须回落到 followup_plans 现算(见 mergeStats)—— 那个口径会随重分失血,
* 但显示一个偏小的数,总好过显示 0 让主管以为这批没分成。
*/
private async ledgerStatsByAssignment(ids: string[]): Promise<
Map<string, { planned: number; agents: number; released: number; expired: number; revoked: number }>
> {
if (ids.length === 0) return new Map();
const rows = await this.prisma.$queryRaw<
Array<{ assignment_id: string; planned: bigint; agents: bigint; released: bigint; expired: bigint; revoked: bigint }>
>(Prisma.sql`
SELECT assignment_id,
count(DISTINCT patient_id) FILTER (WHERE event = 'assign') AS planned,
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 = 'auto_release'
AND reason = ${PlanEventReason.ASSIGNMENT_EXPIRED}) AS expired,
count(DISTINCT plan_id) FILTER (WHERE event = 'auto_release'
AND reason = ${PlanEventReason.REVOKED}) AS revoked
FROM plan_event_logs
WHERE assignment_id IN (${Prisma.join(ids.map((i) => Prisma.sql`${i}::uuid`))})
GROUP BY assignment_id`);
return new Map(
rows.map((r) => [
r.assignment_id,
{
planned: Number(r.planned), agents: Number(r.agents), released: Number(r.released),
expired: Number(r.expired), revoked: Number(r.revoked),
},
]),
);
}
/**
* 人话批次名 —— 「8/3 21:33 · 牙周治疗 · 窗口内 · 9 人 · 2 位客服」。
*
* ⚠️ **服务端唯一生成点**。⛔ 别让模型自己拼:措辞会在两轮之间漂,
* 主管就对不上"上次说的那批"(而在没有批次列表页的情况下,这个名字是他指认一批的唯一抓手)。
* ⚠️ 时间到**分钟**是主要区分器 —— 同一个矩阵格子一天可能分好几批,只给日期分不开。
* ⚠️ 温度取自 criteria 快照;**本列上线前的老批次没存温度**,那一段就不出现
* (⛔ 不许默认填一个 —— 会让主管以为那批是按"窗口内"圈的)。
*/
private buildLabel(head: {
criteria: unknown; createdAt: Date; status: string; attributes: unknown;
}, planned: number, agents: number, tz: string): string {
const c = (head.criteria ?? {}) as { potentialTreatment?: string; temperature?: string };
const parts: string[] = [formatBatchTime(head.createdAt, tz)];
if (c.potentialTreatment) parts.push(potentialTreatmentCardLabel(c.potentialTreatment));
if (c.temperature) {
parts.push(TEMPERATURE_META[c.temperature as TemperatureValue]?.zh ?? c.temperature);
}
parts.push(`${planned} 人`);
if (agents > 0) parts.push(`${agents} 位客服`);
const benefit = readBenefitText(head.attributes);
if (benefit) parts.push(`带「${benefit}」`);
// 终态摆在最后 —— 主管扫列表时最先要排除的就是已撤销的
if (head.status === 'revoked') parts.push('已撤销');
return parts.join(' · ');
}
/**
* 批次列表 + 每批汇总。 * 批次列表 + 每批汇总。
* *
* ⚠️ 计数**现算不落列**(T17):planned/inHand/released 都能从 followup_plans 一次 * ⚠️ 计数**现算不落列**(T17),但源分两处:
* groupBy 出来。立柱看着省事,但批次一旦被退回/重算触碰,冗余列就开始漂, * planned/agents/released/expired 走**账本**(历史事实),inHand/done 走 followup_plans(当前状态)。
* 而漂了没有任何报错 —— 报表上少一条谁都不会发现 * 混用一个源必然出错 —— 见 ledgerStatsByAssignment 的红字
*/ */
async list(scope: TenantScopeContext, createdBy?: string): Promise<ListAssignmentsResponse> { async list(scope: TenantScopeContext, createdBy?: string): Promise<ListAssignmentsResponse> {
const heads = await this.prisma.planAssignment.findMany({ const heads = await this.prisma.planAssignment.findMany({
...@@ -472,10 +575,18 @@ export class PlanAssignmentService { ...@@ -472,10 +575,18 @@ export class PlanAssignmentService {
}); });
if (heads.length === 0) return { items: [] }; if (heads.length === 0) return { items: [] };
const stats = await this.statsByAssignment(heads.map((h) => h.id)); const ids = heads.map((h) => h.id);
const names = await this.resolveNames(scope, heads.map((h) => h.createdBy)); const [live, ledger, names, tz] = await Promise.all([
this.statsByAssignment(ids),
this.ledgerStatsByAssignment(ids),
this.resolveNames(scope, heads.map((h) => h.createdBy)),
this.hostTimezone(scope.hostId),
]);
return {
items: heads.map((h) => {
const l = live.get(h.id) ?? { planned: 0, inHand: 0, released: 0, agents: 0, done: 0 };
const counts = mergeStats(l, ledger.get(h.id));
return { return {
items: heads.map((h) => ({
id: h.id, id: h.id,
clinicId: h.clinicId, clinicId: h.clinicId,
createdBy: h.createdBy, createdBy: h.createdBy,
...@@ -485,8 +596,13 @@ export class PlanAssignmentService { ...@@ -485,8 +596,13 @@ export class PlanAssignmentService {
status: h.status, status: h.status,
expiresAt: h.expiresAt.toISOString(), expiresAt: h.expiresAt.toISOString(),
createdAt: h.createdAt.toISOString(), createdAt: h.createdAt.toISOString(),
...(stats.get(h.id) ?? { planned: 0, inHand: 0, released: 0, agents: 0, done: 0 }), label: this.buildLabel(h, counts.planned, counts.agents, tz),
})), ...counts,
// 当前状态两项 —— 与账本口径的四项分开(见 mergeStats)
inHand: l.inHand,
done: l.done,
};
}),
}; };
} }
...@@ -575,7 +691,13 @@ export class PlanAssignmentService { ...@@ -575,7 +691,13 @@ export class PlanAssignmentService {
a.planned++; a.planned++;
if (p.status === 'assigned') { if (p.status === 'assigned') {
a.inHand++; a.inHand++;
if (p.assignmentExpiresAt && p.assignmentExpiresAt < now) a.overdue++; // 🔴 「且没约下次回访」这半句不能少。到期回收器刻意跳过 snoozedUntil 在未来的单
// (客服约了 6/10 回访,那之前绝不能收走)—— 于是这类单会一直"超期"下去,
// 而 progress 那边已经把它算作 suppressed(已处理)。
// 不加守卫,同一条单「已处理」和「超期」同时成立:主管看到「薛玫 超期 3」
// 以为她压着单没动,实际上她打了电话、约好了下次 —— 干得最好的那个被指责了。
const snoozed = p.snoozedUntil != null && p.snoozedUntil > now;
if (!snoozed && p.assignmentExpiresAt && p.assignmentExpiresAt < now) a.overdue++;
} }
if (p.releaseReason) { if (p.releaseReason) {
a.released++; a.released++;
...@@ -583,6 +705,22 @@ export class PlanAssignmentService { ...@@ -583,6 +705,22 @@ export class PlanAssignmentService {
} }
} }
// 账本口径的计数(planned/agents/released/expired)+ 宿主时区(批次名要用)。
// ⚠️ 回落用的"老口径"就地从 plans / byAgent 算,⛔ 不再调 statsByAssignment ——
// 那会为同一批数据再发一次一模一样的查询。
const [ledger, tz] = await Promise.all([
this.ledgerStatsByAssignment([id]),
this.hostTimezone(scope.hostId),
]);
const counts = mergeStats(
{
planned: plans.length,
agents: [...byAgent.keys()].filter((k) => k !== RELEASED_BUCKET).length,
released: plans.filter((p) => p.releaseReason != null).length,
},
ledger.get(id),
);
// 「未动过」= 分下去后客服**从未打开过详情页**。 // 「未动过」= 分下去后客服**从未打开过详情页**。
// ⚠️ 判据只能用 view 事件:plan_executions 回写率仅 11%(生产 65 认领 / 7 条结果), // ⚠️ 判据只能用 view 事件:plan_executions 回写率仅 11%(生产 65 认领 / 7 条结果),
// 拿它当"动过没"会把 89% 打过电话的单误判成没动。contactAttempts 与它同源,同样不可用。 // 拿它当"动过没"会把 89% 打过电话的单误判成没动。contactAttempts 与它同源,同样不可用。
...@@ -600,6 +738,14 @@ export class PlanAssignmentService { ...@@ -600,6 +738,14 @@ export class PlanAssignmentService {
for (const p of plans) bucket[classifyPlanProgress(p, now)]++; for (const p of plans) bucket[classifyPlanProgress(p, now)]++;
const done = bucket.resolved + bucket.suppressed + bucket.closed; const done = bucket.resolved + bucket.suppressed + bucket.closed;
const ageDays = Math.floor((now.getTime() - head.createdAt.getTime()) / 86_400_000); const ageDays = Math.floor((now.getTime() - head.createdAt.getTime()) / 86_400_000);
/**
* ⭐ 已被后续批次挑走的人数 = 账本口径的 planned − 现在还归本批的人数。
*
* planned 改成账本口径(历史事实)之后,五桶就**加不出 planned** 了 ——
* 那几个人退回回池后被别的批次挑走,`followup_plans.assignment_id` 已经改指新批次。
* 补这一桶让穷尽性重新成立;⛔ 不补的话主管一对数就发现少人,而少的那几个永远查不出去哪了。
*/
const reassigned = Math.max(0, counts.planned - plans.length);
const progress = { const progress = {
done, done,
resolved: bucket.resolved, resolved: bucket.resolved,
...@@ -607,13 +753,20 @@ export class PlanAssignmentService { ...@@ -607,13 +753,20 @@ export class PlanAssignmentService {
closed: bucket.closed, closed: bucket.closed,
inHandPending: bucket.inHandPending, inHandPending: bucket.inHandPending,
backToPool: bucket.backToPool, backToPool: bucket.backToPool,
reassigned,
ageDays, ageDays,
// ⭐ 成品句子,助手照抄(T14 双保险的「工具返回值」那一半) // ⭐ 成品句子,助手照抄(T14 双保险的「工具返回值」那一半)
note: note:
`已处理 ${done} / ${plans.length}` + `已处理 ${done} / ${counts.planned}` +
(bucket.resolved ? `,其中 ${bucket.resolved} 位患者的召回需求**已经不在池子里了**(引擎判定无活信号)` : '') + (bucket.resolved ? `,其中 ${bucket.resolved} 位患者的召回需求**已经不在池子里了**(引擎判定无活信号)` : '') +
(bucket.suppressed ? `,${bucket.suppressed} 条客服写了回访结果(约下次/拒绝/放弃)` : '') + (bucket.suppressed ? `,${bucket.suppressed} 条客服写了回访结果(约下次/拒绝/放弃)` : '') +
`;还在客服手上未处理 ${bucket.inHandPending} 条,退回或到期落回池子 ${bucket.backToPool} 条。` + `;还在客服手上未处理 ${bucket.inHandPending} 条,落回池子 ${bucket.backToPool} 条` +
// 退回与到期必须分开说 —— 主管的下一步动作相反(改分配策略 vs 派多了/时效太紧)
(counts.released || counts.expired
? `(其中客服主动退回 ${counts.released} 条、到期没人动 ${counts.expired} 条)`
: '') +
`。` +
(reassigned ? `另有 ${reassigned} 人已被后面的批次挑走,他们之后的表现算在那一批上。` : '') +
`⚠️ 这是**处理率不是成功率** —— 只说"这单动过了",不说"谈成了"。` + `⚠️ 这是**处理率不是成功率** —— 只说"这单动过了",不说"谈成了"。` +
`⚠️ 本批已经跑了 ${ageDays} 天,**跟别的批次比之前先看年龄** —— ` + `⚠️ 本批已经跑了 ${ageDays} 天,**跟别的批次比之前先看年龄** —— ` +
`跑了三个月的批次天然比跑了三天的好看,直接比是耍流氓。`, `跑了三个月的批次天然比跑了三天的好看,直接比是耍流氓。`,
...@@ -632,12 +785,16 @@ export class PlanAssignmentService { ...@@ -632,12 +785,16 @@ export class PlanAssignmentService {
status: head.status, status: head.status,
expiresAt: head.expiresAt.toISOString(), expiresAt: head.expiresAt.toISOString(),
createdAt: head.createdAt.toISOString(), createdAt: head.createdAt.toISOString(),
planned: plans.length, label: this.buildLabel(head, counts.planned, counts.agents, tz),
// ⚠️ planned/agents/released/expired 走**账本**(历史事实),inHand/done 走当前状态。
// ⛔ 别改回 `plans.length` —— 那是"现在还归本批的人",会随重分失血(见 mergeStats)。
planned: counts.planned,
released: counts.released,
expired: counts.expired,
agents: counts.agents,
progress, progress,
done: progress.done, done: progress.done,
inHand: plans.filter((p) => p.status === 'assigned').length, inHand: plans.filter((p) => p.status === 'assigned').length,
released: plans.filter((p) => p.releaseReason != null).length,
agents: [...byAgent.keys()].filter((k) => k !== RELEASED_BUCKET).length,
agentStats: [...byAgent.values()].filter((a) => a.userId !== RELEASED_BUCKET), agentStats: [...byAgent.values()].filter((a) => a.userId !== RELEASED_BUCKET),
releaseReasons: [...reasonCount] releaseReasons: [...reasonCount]
.sort((a, b) => b[1] - a[1]) .sort((a, b) => b[1] - a[1])
...@@ -816,6 +973,7 @@ export class PlanAssignmentService { ...@@ -816,6 +973,7 @@ export class PlanAssignmentService {
actorUserId: actor.userId, actorUserId: actor.userId,
heldSeconds: computeHeldSeconds(p.assignedAt, now), heldSeconds: computeHeldSeconds(p.assignedAt, now),
reason: PlanEventReason.REVOKED, reason: PlanEventReason.REVOKED,
assignmentId,
})), })),
); );
} }
...@@ -1040,6 +1198,21 @@ function summarizeSkipped(skipped: AssignmentSkipped[]): string { ...@@ -1040,6 +1198,21 @@ function summarizeSkipped(skipped: AssignmentSkipped[]): string {
* 在 Node 里按**本机时区**解析 —— 服务器跑在 UTC 就整整偏 8 小时。 * 在 Node 里按**本机时区**解析 —— 服务器跑在 UTC 就整整偏 8 小时。
* 这正是 commit 4549817 修过的同一类坑,只是那次是纯日期。 * 这正是 commit 4549817 修过的同一类坑,只是那次是纯日期。
*/ */
/**
* 批次名里的时间戳 —— 「8/3 21:33」。
*
* ⚠️ 必须按**宿主时区**格式化,⛔ 不能用服务器本地时间:主管说的"今天下午那批"
* 是他诊所墙上的时间;服务器在别的时区时,标签会指向另一天,而这个名字正是他指认批次的抓手。
* ⚠️ 精确到分钟不是啰嗦 —— 同一个矩阵格子一天可能分好几批,只到天就分不开了。
*/
export function formatBatchTime(at: Date, timezone: string): string {
const p = new Intl.DateTimeFormat('zh-CN', {
timeZone: timezone, month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false,
}).formatToParts(at);
const get = (t: string) => p.find((x) => x.type === t)?.value ?? '';
return `${get('month')}/${get('day')} ${get('hour')}:${get('minute')}`;
}
export function endOfDayInHostTimezone(base: Date, days: number, timezone: string): Date { export function endOfDayInHostTimezone(base: Date, days: number, timezone: string): Date {
const suffix = timezoneToOffsetSuffix(timezone); // '+08:00' | 'Z' | ... const suffix = timezoneToOffsetSuffix(timezone); // '+08:00' | 'Z' | ...
const offsetMin = const offsetMin =
......
...@@ -57,6 +57,7 @@ export function recordPlanEventsBulk( ...@@ -57,6 +57,7 @@ export function recordPlanEventsBulk(
actorUserId: input.actorUserId ?? null, actorUserId: input.actorUserId ?? null,
heldSeconds: input.heldSeconds ?? null, heldSeconds: input.heldSeconds ?? null,
reason: input.reason ?? null, reason: input.reason ?? null,
assignmentId: input.assignmentId ?? null,
details: input.details ?? undefined, details: input.details ?? undefined,
})), })),
}); });
...@@ -80,6 +81,14 @@ export interface PlanEventInput { ...@@ -80,6 +81,14 @@ export interface PlanEventInput {
* 否则这一列会变成第二个「随手写字符串」的地方,而它正是退回原因分布的唯一数据源。 * 否则这一列会变成第二个「随手写字符串」的地方,而它正是退回原因分布的唯一数据源。
*/ */
reason?: PlanEventReasonValue | null; reason?: PlanEventReasonValue | null;
/**
* 这条事件发生在**哪一批**的执行过程中(见 schema 里 `assignment_id` 的整段说明)。
*
* ⚠️ 只有 assign / release / auto_release 该传。
* ⛔ **claim 绝不能传** —— 自认领是从池子里自己捞的,而 plan 上那个 assignment_id
* 可能是上一批留下的陈迹(退回/到期都刻意不清它),顶上去等于给旧批次凭空加人。
*/
assignmentId?: string | null;
/** 事件专属细节(不按它查询的内容,如反馈文字) */ /** 事件专属细节(不按它查询的内容,如反馈文字) */
details?: Prisma.InputJsonObject | null; details?: Prisma.InputJsonObject | null;
} }
...@@ -96,6 +105,7 @@ export function recordPlanEvent(tx: PlanEventLogWriter, input: PlanEventInput): ...@@ -96,6 +105,7 @@ export function recordPlanEvent(tx: PlanEventLogWriter, input: PlanEventInput):
actorUserId: input.actorUserId ?? null, actorUserId: input.actorUserId ?? null,
heldSeconds: input.heldSeconds ?? null, heldSeconds: input.heldSeconds ?? null,
reason: input.reason ?? null, reason: input.reason ?? null,
assignmentId: input.assignmentId ?? null,
// undefined 才让 Prisma 落 NULL;传 null 会被当成 JSON null 值 // undefined 才让 Prisma 落 NULL;传 null 会被当成 JSON null 值
details: input.details ?? undefined, details: input.details ?? undefined,
}, },
......
...@@ -726,6 +726,10 @@ export class PlanService { ...@@ -726,6 +726,10 @@ export class PlanService {
!actorUserId || actorUserId === assigneeUserId !actorUserId || actorUserId === assigneeUserId
? PlanEventType.CLAIM ? PlanEventType.CLAIM
: PlanEventType.ASSIGN, : PlanEventType.ASSIGN,
// ⛔ **这里不传 assignmentId,别"顺手补上"**。这条路是从池子里单条认领/指派,
// 不属于任何批次;而 plan 上那个 assignment_id 很可能是**上一批的陈迹**
// (退回/到期都刻意不清它)。填进去等于给一个早就结束的批次凭空加人,
// 而且不报错 —— 批次报表会莫名其妙多出几条。
assigneeUserId, assigneeUserId,
actorUserId: actorUserId ?? assigneeUserId, actorUserId: actorUserId ?? assigneeUserId,
}); });
...@@ -853,6 +857,11 @@ export class PlanService { ...@@ -853,6 +857,11 @@ export class PlanService {
// ⚠️ 聚合这一列时**必须同时过滤 event='release'** —— 否则会混进 auto_release 的 // ⚠️ 聚合这一列时**必须同时过滤 event='release'** —— 否则会混进 auto_release 的
// timeout/clinic_moved 和 feedback 的 up/down。 // timeout/clinic_moved 和 feedback 的 up/down。
reason: releaseReason ?? null, reason: releaseReason ?? null,
// ⭐ 退的是**哪一批**的单 —— 批次报表的退回率靠它,而不是靠
// followup_plans.assignment_id(那个会被下一次分配覆盖)。
// ⚠️ 这一刻取是对的:退回本身不清 assignment_id(上面那段注释),
// 覆盖只发生在他被分进新批次时,那是以后的事。
assignmentId: plan.assignmentId,
details: note ? { note } : null, details: note ? { note } : null,
}); });
} }
...@@ -976,6 +985,11 @@ function serializePlan(p: PlanRow, assigneeName?: string | null): import('@pac/t ...@@ -976,6 +985,11 @@ function serializePlan(p: PlanRow, assigneeName?: string | null): import('@pac/t
/// 由调用方(detail)解析后注入 —— 序列化函数本身不发查询 /// 由调用方(detail)解析后注入 —— 序列化函数本身不发查询
assigneeName: assigneeName ?? null, assigneeName: assigneeName ?? null,
assignedAt: p.assignedAt?.toISOString() ?? 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(), createdAt: p.createdAt.toISOString(),
updatedAt: p.updatedAt.toISOString(), updatedAt: p.updatedAt.toISOString(),
supersededAt: p.supersededAt?.toISOString() ?? null, supersededAt: p.supersededAt?.toISOString() ?? null,
......
...@@ -70,6 +70,8 @@ export class RecycleSchedulerService implements OnModuleInit { ...@@ -70,6 +70,8 @@ export class RecycleSchedulerService implements OnModuleInit {
select: { select: {
id: true, hostId: true, tenantId: true, patientId: true, id: true, hostId: true, tenantId: true, patientId: true,
assigneeUserId: true, assignedAt: true, assigneeUserId: true, assignedAt: true,
// 账本记「回收的是哪一批的单」——口径与 AssignmentExpiryScheduler 一致
assignmentId: true,
}, },
// 单轮上限:防积压时一次性打爆事务;剩下的下一轮继续 // 单轮上限:防积压时一次性打爆事务;剩下的下一轮继续
take: 500, take: 500,
...@@ -104,6 +106,7 @@ export class RecycleSchedulerService implements OnModuleInit { ...@@ -104,6 +106,7 @@ export class RecycleSchedulerService implements OnModuleInit {
actorUserId: null, // 系统行为,无操作人 actorUserId: null, // 系统行为,无操作人
heldSeconds: computeHeldSeconds(p.assignedAt, now), heldSeconds: computeHeldSeconds(p.assignedAt, now),
reason: PlanEventReason.TIMEOUT, reason: PlanEventReason.TIMEOUT,
assignmentId: p.assignmentId,
}); });
}); });
recycled++; 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: { ...@@ -51,6 +51,11 @@ function makePrisma(opts: {
), ),
}, },
planEventLog: { findMany: eventFindMany, groupBy: jest.fn(async () => []) }, 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 () => []), $queryRaw: jest.fn(async () => []),
} as unknown as PrismaService; } as unknown as PrismaService;
return { prisma, eventFindMany }; return { prisma, eventFindMany };
......
...@@ -311,6 +311,10 @@ export function AssignmentConfirmSheet({ ...@@ -311,6 +311,10 @@ export function AssignmentConfirmSheet({
// 初筛条件快照:批次要能解释"当时按什么圈的" // 初筛条件快照:批次要能解释"当时按什么圈的"
criteria: { criteria: {
potentialTreatment: sheet.potentialTreatment, potentialTreatment: sheet.potentialTreatment,
// ⭐ 温度必须一起存 —— 矩阵 8×3,「牙周·黄金期」和「牙周·窗口内」是两批
// 完全不同的人。只存治疗项的话,事后既说不清这批是怎么圈的,
// 也没法反推"哪种格子分下去效果好"(实测漏了很久)。
temperature: sheet.temperature,
candidateTotal: sheet.candidateTotal, candidateTotal: sheet.candidateTotal,
target: sheet.target, target: sheet.target,
selectionNote: sheet.selectionNote, selectionNote: sheet.selectionNote,
......
...@@ -886,6 +886,60 @@ artifact iframe 是 `sandbox="allow-scripts"` + CSP `connect-src 'none'`,**卡 ...@@ -886,6 +886,60 @@ artifact iframe 是 `sandbox="allow-scripts"` + CSP `connect-src 'none'`,**卡
**也在助手里** —— 主管对话提问,助手查 MCP,用 `render_artifact` 出卡片/图表。 **也在助手里** —— 主管对话提问,助手查 MCP,用 `render_artifact` 出卡片/图表。
跟踪是**纯只读展示**,正落在「只读用 artifact、可交互用原生组件」的 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 工具 ### 主管实际会问的三类问题 → 三个 MCP 工具
``` ```
......
...@@ -188,16 +188,45 @@ export const AssignmentBriefSchema = z.object({ ...@@ -188,16 +188,45 @@ export const AssignmentBriefSchema = z.object({
status: z.string(), status: z.string(),
expiresAt: z.string(), expiresAt: z.string(),
createdAt: z.string(), createdAt: z.string(),
/// 以下计数**从 followup_plans 现算**,不是冗余列(立柱等于埋一个会漂的数)
/** /**
* 本批分了多少人(**按患者去重、取最新版本**)。 * ⭐ 人话批次名 —— 「8/3 21:33 · 牙周治疗 · 窗口内 · 9 人 · 2 位客服」。
* ⚠️ 口径**不是**「活跃版本」—— 那会把引擎判定"需求已了"的人静默剔掉, *
* 批次跑得越好、这个数缩水得越厉害。见 plan-assignment.service.detail 的红字注释。 * 主管记不住也不该记 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('本批分了多少人(按患者去重)'), expired: z.number().int().describe('到期自动回池(客服没动)'),
inHand: z.number().int().describe('仍挂在客服名下'), agents: z.number().int().describe('涉及几个客服(取自账本)'),
released: z.number().int().describe('已退回'),
agents: z.number().int().describe('涉及几个客服'),
/** /**
* 已处理条数 —— 判据是**池子状态**(出池 / 被抑制),不是回写。 * 已处理条数 —— 判据是**池子状态**(出池 / 被抑制),不是回写。
* 详见 `AssignmentDetailResponseSchema.progress` 的整段说明。 * 详见 `AssignmentDetailResponseSchema.progress` 的整段说明。
...@@ -219,7 +248,16 @@ export const AssignmentAgentStatSchema = z.object({ ...@@ -219,7 +248,16 @@ export const AssignmentAgentStatSchema = z.object({
planned: z.number().int(), planned: z.number().int(),
inHand: z.number().int(), inHand: z.number().int(),
released: 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>; export type AssignmentAgentStat = z.infer<typeof AssignmentAgentStatSchema>;
...@@ -257,7 +295,17 @@ export const AssignmentDetailResponseSchema = AssignmentBriefSchema.extend({ ...@@ -257,7 +295,17 @@ export const AssignmentDetailResponseSchema = AssignmentBriefSchema.extend({
suppressed: z.number().int().describe('被抑制(客服写了回访结果)'), suppressed: z.number().int().describe('被抑制(客服写了回访结果)'),
closed: z.number().int().describe('已结案 / 已放弃'), closed: z.number().int().describe('已结案 / 已放弃'),
inHandPending: 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('批次年龄 —— ⚠️ 跨批次比处理率前必须先对齐它'), ageDays: z.number().int().describe('批次年龄 —— ⚠️ 跨批次比处理率前必须先对齐它'),
note: z.string().describe('成品句子,助手原话转述'), note: z.string().describe('成品句子,助手原话转述'),
}), }),
...@@ -337,6 +385,17 @@ export type ProposalAgentRow = z.infer<typeof ProposalAgentRowSchema>; ...@@ -337,6 +385,17 @@ export type ProposalAgentRow = z.infer<typeof ProposalAgentRowSchema>;
export const AssignmentProposalSchema = z.object({ export const AssignmentProposalSchema = z.object({
clinicId: z.string(), clinicId: z.string(),
potentialTreatment: z.string().nullable(), 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 影响 —— /// ⭐ 与矩阵格子同一种数法(count DISTINCT patient_id),⛔ 不受取明细的 LIMIT 影响 ——
/// 从矩阵点进来的主管会拿这个数跟他刚看到的格子对 /// 从矩阵点进来的主管会拿这个数跟他刚看到的格子对
candidateTotal: z.number().int().describe('候选总数 = 该格子/该条件下的患者数(与矩阵格子对得上)'), 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