Commit 124d3b19 by luoqi

feat(plan): S2.6 完成口径改判 —— 看池子状态,不等回写

产品裁决:「完成」不需要等成功,只要知道**这个患者的召回还在不在池、有没有被抑制**,
两者任一成立就说明这单处理过了。成功不成功先不统计。

否掉原案的「转化率」:它卡在 plan_executions 11% 回写率上,永远只能输出「样本不足」,
等于这一格报表上线即死。新口径的两条判据是**互补不是重复**:

  出池 status='superseded'   引擎按客观事实判定该患者已无活信号(治疗真做了/诊断没了)
                              **零回写依赖** —— 接住不写回访的 89%
  被抑制 snoozedUntil > now  客服写了回访结果(约下次/拒绝/放弃)
                             接住写了的 11%

为什么现在可以不算成功、以后又不会抓瞎:出池原因记在 plan_event_logs、客观事实在
patient_facts(append-only)—— 任何时候都能回过头重算「这批人后来做没做治疗」。
不是放弃了这个数据,是它不需要现在算。

 连带抓出并修掉一个**静默缺陷**(教条 T20′ 尾):
跟踪查询写的是 `where: { supersededAt: null }`,而引擎关闭走 closeStaleActivePlan ——
`status='superseded'` 且**不建后继版本** → 这些行整个消失。
本地实测(60 条批次,8 条已出池):旧口径 planned=52,新口径 60。
**批次跑得越好、数字缩得越厉害,而缩掉的恰恰是唯一算得上成功的那批。**
(D-12 无条件继承只保住了升版本那条路 —— 后继行继承 assignment_id;
 关闭这条路没有后继行,继承救不了。R12 当时只想到了前者。)
正解:拿本批全部行、按患者取最新版本(升版本时新旧行都带 assignment_id,
max(version) 天然去重;关闭时最新版就是那条 superseded 行,正好是信号)。

五桶穷尽(resolved/suppressed/closed/inHandPending/backToPool),
done + inHandPending + backToPool 恒等于 planned —— 少一个分支就是静默丢人,
主管一对数发现少了几个、而少的那几个永远查不出去哪了。回归里锁着。

助手侧双保险:提示词 + 工具描述都写死「处理率不是成功率」,
 不许把 resolved 说成「转化成功」、 不许自己拿这些数算转化率;
报处理率必须带批次年龄(跑三个月的批次天然比跑三天的好看)。

本地真实验证(60 条批次 / 8 出池 / 5 抑制):
  已处理 13 = 出池 8 + 抑制 5;在手未处理 31;回池 16
  五桶求和 60 = planned 60    agentStats 各人之和 60 = planned 60 
  列表页与详情页同一批数字一致 
958 tests passing;测试数据已清理。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent 1d1b7636
...@@ -42,10 +42,16 @@ const DISPATCHER_EXTRA = ` ...@@ -42,10 +42,16 @@ const DISPATCHER_EXTRA = `
例:时效说「3 天(默认值,暂无历史结案数据,积累后按实际反推)」, 例:时效说「3 天(默认值,暂无历史结案数据,积累后按实际反推)」,
⛔ 不要说成「依据该诊所平均结案 2.4 天」—— 那个数算不出来。 ⛔ 不要说成「依据该诊所平均结案 2.4 天」—— 那个数算不出来。
4. **样本不足就明说,不要输出百分比、不要画图。** 4. **「处理」不等于「成功」,⛔ 绝不能混为一谈。**
工具返回带 sufficient=false 或 note 时**照抄 note**。 批次跟踪给的是 progress:**处理率不是成功率**,只说「这单动过了」,不说「谈成了」。
⛔ 见到 0/12 不要渲染成「0.0% 转化率」—— 主管看了会以为功能没用。 · resolved = 这个患者的召回**已经不在池子里**了(引擎按客观事实判定需求已了)
正确说法:「已处置 12 人,暂无转化记录,样本量不足以计算转化率」。 · suppressed = 客服写了回访结果(约下次 / 拒绝 / 放弃)
· inHandPending = 还在手上没动 · backToPool = 退回或到期,落回池子
⛔ 不要把 resolved 说成「转化成功 / 成交」—— 那需要另外的证据,现在不算。
⛔ 不要自己拿这些数去算转化率。主管问「成了几个」就照实说:
「成功与否现在不统计,但底账都在(出池原因 + 客观事实),要看随时能回过头算」。
⚠️ 报处理率**必须带批次年龄**:跑了三个月的批次天然比跑了三天的好看,
不同年龄的批次直接比是耍流氓,note 里已经写好了,原话抄。
5. **退回率永远给两个数。** 5. **退回率永远给两个数。**
「退回 5 / 已处置 40 = 12.5%(另有 60 条未动)」—— 「退回 5 / 已处置 40 = 12.5%(另有 60 条未动)」——
......
...@@ -365,7 +365,8 @@ export class McpServerFactory { ...@@ -365,7 +365,8 @@ export class McpServerFactory {
'list_assignment_batches', 'list_assignment_batches',
{ {
description: description:
'我分过的批次列表 + 每批汇总(分了多少 / 还在手 / 已退回 / 涉及几个客服)。' + '我分过的批次列表 + 每批汇总(分了多少 / 已处理 / 还在手 / 已退回 / 涉及几个客服)。' +
'\n⚠️ done 是**已处理**不是**已成功**(召回出池 / 被抑制 / 已结案),别说成转化。' +
'回答"我分的那些批,哪批出问题了"。要看某批细节再用 get_assignment_detail。', '回答"我分的那些批,哪批出问题了"。要看某批细节再用 get_assignment_detail。',
inputSchema: { inputSchema: {
mine: z.boolean().optional().describe('只看自己发起的,默认看本范围全部'), mine: z.boolean().optional().describe('只看自己发起的,默认看本范围全部'),
...@@ -400,7 +401,12 @@ export class McpServerFactory { ...@@ -400,7 +401,12 @@ export class McpServerFactory {
'get_assignment_detail', 'get_assignment_detail',
{ {
description: description:
'单个批次的全貌:按客服拆 + 退回原因分布 + 未动过的条数。' + '单个批次的全貌:处理进度 + 按客服拆 + 退回原因分布 + 未动过的条数。' +
'\n⚠️⚠️ progress 是**处理率不是成功率** —— 只说"这单动过了",不说"谈成了"。' +
'resolved=该患者召回已出池(引擎按客观事实判定需求已了) / suppressed=客服写了回访结果 / ' +
'inHandPending=还在手上没动 / backToPool=退回或到期落回池子。' +
'⛔ 不要把 resolved 说成"转化成功",⛔ 不要拿这些数自己算转化率。' +
'\n⚠️ 报处理率**必须带批次年龄**(progress.ageDays):跑了三个月的批次天然比跑了三天的好看。' +
'\n⚠️ 退回率**永远给两个数**:「退回 5 / 已处置 40 = 12.5%(另有 60 条未动)」——' + '\n⚠️ 退回率**永远给两个数**:「退回 5 / 已处置 40 = 12.5%(另有 60 条未动)」——' +
'"没人动"和"动了但退回"是完全不同的信号,只报一个百分比会让主管把前者误读成后者。' + '"没人动"和"动了但退回"是完全不同的信号,只报一个百分比会让主管把前者误读成后者。' +
'\n⚠️ 分母小的时候(如 <50)直接说"样本量不足",**不要输出百分比、不要画图**。', '\n⚠️ 分母小的时候(如 <50)直接说"样本量不足",**不要输出百分比、不要画图**。',
......
...@@ -49,6 +49,50 @@ const UPDATE_CHUNK = 500; ...@@ -49,6 +49,50 @@ const UPDATE_CHUNK = 500;
const RELEASED_BUCKET = '__released__'; const RELEASED_BUCKET = '__released__';
/// 批次福利文本(attributes.benefit.text)。⚠️ 别在多处 `as any` 解 JSON,解错了不会报错 /// 批次福利文本(attributes.benefit.text)。⚠️ 别在多处 `as any` 解 JSON,解错了不会报错
/**
* 「这一单处理了没」—— 判据只看**池子状态**,不看客服写没写回访结果。
*
* ═══ 为什么是这个口径(产品裁决 2026-08-02)═════════════════════════
* 「完成」不需要等成功与否,只需要知道**这个患者的召回还在不在池、有没有被抑制** ——
* 两者任一成立就说明这单已经被处理过了。
*
* ⭐ 它最大的好处是**不依赖回写**:生产 `plan_executions` 回写率仅 11%,
* 拿它做完成判据等于 89% 的活白干。而池子状态是引擎按**客观事实**(patient_facts)
* 算出来的,客服写不写都成立。
*
* ⭐ 两条判据是**互补**不是重复:
* · 出池(superseded)—— 引擎发现该患者已无活信号 = 治疗真的做了 / 诊断没了。**零回写依赖**。
* · 被抑制(snoozedUntil 在未来)—— 客服写了回访结果(约下次 / 明确拒绝 / 放弃)。**有回写才有**。
* 前者接住不写回访的 89%,后者接住写了的 11%,并集才是完整的「处理过」。
*
* ⚠️ **不判成功与否**(产品明确「成功不成功先不用统计」)。
* 之所以现在可以不判、以后又不会抓瞎:`plan_event_logs` 记了出池原因,
* `patient_facts` 是 append-only 的客观事实 —— 任何时候都能回过头重算
* 「这批人后来到底做没做治疗」。**不是放弃了这个数据,是它不需要现在算**。
*/
export type PlanProgress = 'resolved' | 'suppressed' | 'closed' | 'inHandPending' | 'backToPool';
export function classifyPlanProgress(
p: {
status: string;
assigneeUserId: string | null;
snoozedUntil: Date | null;
},
now: Date,
): PlanProgress {
// ⚠️ 顺序即优先级,别按"好看"重排:
// 终态先判(superseded 是引擎关闭,不是"还在手上");
// 抑制**优先于在手** —— 客服打了电话约了下次,单子还挂在他名下,但这一单确实已经处理过了。
if (p.status === 'superseded') return 'resolved';
if (p.status === 'completed' || p.status === 'abandoned') return 'closed';
if (p.snoozedUntil && p.snoozedUntil > now) return 'suppressed';
if (p.status === 'assigned') return 'inHandPending';
// 剩下的都是「躺回池子里」:退回的、到期收回的、撤销放回的。
// ⛔ 兜底也归这里而不是静默丢弃 —— 五个桶必须**穷尽**,否则 done+pending 加不出 planned,
// 主管一对数就发现少了人,而少的那几个永远查不出去哪了。
return 'backToPool';
}
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;
...@@ -376,7 +420,7 @@ export class PlanAssignmentService { ...@@ -376,7 +420,7 @@ 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 }), ...(stats.get(h.id) ?? { planned: 0, inHand: 0, released: 0, agents: 0, done: 0 }),
})), })),
}; };
} }
...@@ -393,12 +437,30 @@ export class PlanAssignmentService { ...@@ -393,12 +437,30 @@ export class PlanAssignmentService {
}); });
if (!head) throw new NotFoundException(`批次 ${id} 不存在`); if (!head) throw new NotFoundException(`批次 ${id} 不存在`);
const plans = await this.prisma.followupPlan.findMany({ // 🔴 **绝不能过滤 `supersededAt: null`** —— 那会把**处理得最好的那批人静默删掉**。
where: { assignmentId: id, supersededAt: null }, //
// 引擎发现某患者已无任何活信号(治疗做了 / 诊断消失)时,走 `closeStaleActivePlan`:
// `status='superseded' + supersededAt=now`,且**不建后继版本**。
// 于是「分了 100 人,30 个真的来把治疗做了」在旧写法下显示成 `planned=70` ——
// 批次凭空缩水 30 人,不报错、不告警,而缩掉的恰恰是唯一算得上成功的那 30 个。
// (D-12 的无条件继承只保住了**升版本**那条路:后继行继承 assignment_id;
// 关闭这条路压根没有后继行,继承救不了。R12 当时只想到了前者。)
//
// 正解:拿本批全部行、**按患者取最新版本**。升版本时新旧两行都带 assignment_id
// (D-12),取 max(version) 天然去重;关闭时最新版就是那条 superseded 行,正好是信号。
const allRows = await this.prisma.followupPlan.findMany({
where: { assignmentId: id },
select: { select: {
id: true, status: true, assigneeUserId: true, id: true, patientId: true, version: true, status: true, assigneeUserId: true,
releaseReason: true, assignmentExpiresAt: true, releaseReason: true, assignmentExpiresAt: true, snoozedUntil: true,
}, },
orderBy: { version: 'desc' },
});
const seenPatient = new Set<string>();
const plans = allRows.filter((r) => {
if (seenPatient.has(r.patientId)) return false;
seenPatient.add(r.patientId);
return true;
}); });
const planIds = plans.map((p) => p.id); const planIds = plans.map((p) => p.id);
...@@ -464,6 +526,32 @@ export class PlanAssignmentService { ...@@ -464,6 +526,32 @@ export class PlanAssignmentService {
}) })
: []; : [];
// ── 处理进度(产品裁决:看池子状态,不等回写)────────────────────
const bucket: Record<PlanProgress, number> = {
resolved: 0, suppressed: 0, closed: 0, inHandPending: 0, backToPool: 0,
};
for (const p of plans) bucket[classifyPlanProgress(p, now)]++;
const done = bucket.resolved + bucket.suppressed + bucket.closed;
const ageDays = Math.floor((now.getTime() - head.createdAt.getTime()) / 86_400_000);
const progress = {
done,
resolved: bucket.resolved,
suppressed: bucket.suppressed,
closed: bucket.closed,
inHandPending: bucket.inHandPending,
backToPool: bucket.backToPool,
ageDays,
// ⭐ 成品句子,助手照抄(T14 双保险的「工具返回值」那一半)
note:
`已处理 ${done} / ${plans.length}` +
(bucket.resolved ? `,其中 ${bucket.resolved} 位患者的召回需求**已经不在池子里了**(引擎判定无活信号)` : '') +
(bucket.suppressed ? `,${bucket.suppressed} 条客服写了回访结果(约下次/拒绝/放弃)` : '') +
`;还在客服手上未处理 ${bucket.inHandPending} 条,退回或到期落回池子 ${bucket.backToPool} 条。` +
`⚠️ 这是**处理率不是成功率** —— 只说"这单动过了",不说"谈成了"。` +
`⚠️ 本批已经跑了 ${ageDays} 天,**跟别的批次比之前先看年龄** —— ` +
`跑了三个月的批次天然比跑了三天的好看,直接比是耍流氓。`,
};
const names = await this.resolveNames(scope, [head.createdBy, ...byAgent.keys()]); const names = await this.resolveNames(scope, [head.createdBy, ...byAgent.keys()]);
for (const a of byAgent.values()) a.name = names.get(a.userId) ?? null; for (const a of byAgent.values()) a.name = names.get(a.userId) ?? null;
...@@ -478,6 +566,8 @@ export class PlanAssignmentService { ...@@ -478,6 +566,8 @@ export class PlanAssignmentService {
expiresAt: head.expiresAt.toISOString(), expiresAt: head.expiresAt.toISOString(),
createdAt: head.createdAt.toISOString(), createdAt: head.createdAt.toISOString(),
planned: plans.length, planned: plans.length,
progress,
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, released: plans.filter((p) => p.releaseReason != null).length,
agents: [...byAgent.keys()].filter((k) => k !== RELEASED_BUCKET).length, agents: [...byAgent.keys()].filter((k) => k !== RELEASED_BUCKET).length,
...@@ -498,19 +588,34 @@ export class PlanAssignmentService { ...@@ -498,19 +588,34 @@ export class PlanAssignmentService {
/** 一次 groupBy 出多批的汇总,避免 N+1 */ /** 一次 groupBy 出多批的汇总,避免 N+1 */
private async statsByAssignment( private async statsByAssignment(
ids: string[], ids: string[],
): Promise<Map<string, { planned: number; inHand: number; released: number; agents: number }>> { ): Promise<Map<string, { planned: number; inHand: number; released: number; agents: number; done: number }>> {
// 🔴 与 detail 同一条纪律:**不许过滤 `supersededAt: null`**,按患者取最新版本。
// 过滤掉的正是「引擎判定需求已了」那批(关闭走 supersede 且无后继行),
// 列表页的 planned 会随着批次跑得越好、缩水得越厉害 —— 而且不报错。
const rows = await this.prisma.followupPlan.findMany({ const rows = await this.prisma.followupPlan.findMany({
where: { assignmentId: { in: ids }, supersededAt: null }, where: { assignmentId: { in: ids } },
select: { assignmentId: true, status: true, assigneeUserId: true, releaseReason: true }, select: {
assignmentId: true, patientId: true, version: true,
status: true, assigneeUserId: true, releaseReason: true, snoozedUntil: true,
},
orderBy: { version: 'desc' },
}); });
const out = new Map<string, { planned: number; inHand: number; released: number; agents: number }>(); const out = new Map<string, { planned: number; inHand: number; released: number; agents: number; done: number }>();
const agentSets = new Map<string, Set<string>>(); const agentSets = new Map<string, Set<string>>();
const seen = new Set<string>();
const now = new Date();
for (const r of rows) { for (const r of rows) {
const dedupe = `${r.assignmentId}:${r.patientId}`;
if (seen.has(dedupe)) continue;
seen.add(dedupe);
const k = r.assignmentId!; const k = r.assignmentId!;
const cur = out.get(k) ?? { planned: 0, inHand: 0, released: 0, agents: 0 }; const cur = out.get(k) ?? { planned: 0, inHand: 0, released: 0, agents: 0, done: 0 };
cur.planned++; cur.planned++;
if (r.status === 'assigned') cur.inHand++; if (r.status === 'assigned') cur.inHand++;
if (r.releaseReason) cur.released++; if (r.releaseReason) cur.released++;
// 列表页也给一个「已处理」数 —— 主管扫一眼就知道哪批卡住了,不用逐批点进去
const prog = classifyPlanProgress(r, now);
if (prog === 'resolved' || prog === 'suppressed' || prog === 'closed') cur.done++;
out.set(k, cur); out.set(k, cur);
if (r.assigneeUserId) { if (r.assigneeUserId) {
const s = agentSets.get(k) ?? new Set<string>(); const s = agentSets.get(k) ?? new Set<string>();
......
import { classifyPlanProgress, type PlanProgress } from '../src/modules/plan/plan-assignment.service';
/**
* 「处理了没」的判据回归(产品裁决 2026-08-02)。
*
* 口径:**看池子状态,不等回写** ——
* 这个患者的召回还在不在池(出池 = 引擎判定无活信号)、有没有被抑制(客服写了回访结果),
* 两者任一成立就算处理过了。⚠️ 是**处理**不是**成功**。
*
* 🔴 这里最贵的一条是「五桶穷尽」:少一个分支,done + pending 就加不出 planned,
* 主管一对数发现少了人,而少的那几个**永远查不出去哪了**。
*/
const NOW = new Date('2026-08-02T12:00:00.000Z');
const future = new Date('2026-09-01T00:00:00.000Z');
const past = new Date('2026-07-01T00:00:00.000Z');
const p = (over: Partial<Parameters<typeof classifyPlanProgress>[0]>) =>
classifyPlanProgress(
{ status: 'active', assigneeUserId: null, snoozedUntil: null, ...over },
NOW,
);
describe('处理判据 —— 出池(零回写依赖的那一半)', () => {
test('⭐⭐ superseded = 引擎判定该患者已无活信号 → **已处理**', () => {
// 这是整套口径的基石:治疗真做了 / 诊断没了 → closeStaleActivePlan 把它 supersede。
// 客服写不写回访结果都成立 —— 生产回写率只有 11%,靠回写等于 89% 的活白干。
expect(p({ status: 'superseded' })).toBe('resolved');
});
test('⭐ superseded 优先于一切 —— 就算还挂在客服名下也算出池', () => {
expect(p({ status: 'superseded', assigneeUserId: 'a1' })).toBe('resolved');
});
test('completed / abandoned → 已结案', () => {
expect(p({ status: 'completed' })).toBe('closed');
expect(p({ status: 'abandoned' })).toBe('closed');
});
});
describe('处理判据 —— 抑制(有回写的那一半)', () => {
test('⭐⭐ snoozedUntil 在未来 = 客服写了回访结果 → **已处理**', () => {
expect(p({ status: 'assigned', assigneeUserId: 'a1', snoozedUntil: future })).toBe('suppressed');
});
test('⭐⭐ 抑制**优先于在手** —— 约了下次回访,单子还在他名下,但这一单确实处理过了', () => {
// 排成 inHandPending 会让「已处理」少算一大块,而那正是唯一有回写的那部分。
expect(p({ status: 'assigned', assigneeUserId: 'a1', snoozedUntil: future })).not.toBe('inHandPending');
});
test('⭐ 抑制**过期了**就不算处理了(到点该重新浮现)', () => {
expect(p({ status: 'assigned', assigneeUserId: 'a1', snoozedUntil: past })).toBe('inHandPending');
expect(p({ status: 'active', assigneeUserId: null, snoozedUntil: past })).toBe('backToPool');
});
});
describe('处理判据 —— 未处理的两种', () => {
test('还在客服手上、没写任何结果 → 在手未处理', () => {
expect(p({ status: 'assigned', assigneeUserId: 'a1' })).toBe('inHandPending');
});
test('⭐ 退回 / 到期 / 撤销放回 → 回池(= 没处理成)', () => {
expect(p({ status: 'active', assigneeUserId: null })).toBe('backToPool');
});
});
describe('⭐⭐ 五桶必须穷尽 —— 加不出 planned 就是静默丢人', () => {
test('任何 (status × assignee × snoozed) 组合都落进恰好一个桶', () => {
const statuses = ['active', 'assigned', 'completed', 'abandoned', 'superseded', '未来新增的状态'];
const assignees = [null, 'a1'];
const snoozes = [null, future, past];
const buckets: PlanProgress[] = ['resolved', 'suppressed', 'closed', 'inHandPending', 'backToPool'];
for (const status of statuses) {
for (const assigneeUserId of assignees) {
for (const snoozedUntil of snoozes) {
const got = classifyPlanProgress({ status, assigneeUserId, snoozedUntil }, NOW);
// ⛔ 兜底必须存在:哪天有人加了个新 status,不能让它静默消失
expect(buckets).toContain(got);
}
}
}
});
test('⭐ done + inHandPending + backToPool 恒等于总数', () => {
const rows = [
{ status: 'superseded', assigneeUserId: null, snoozedUntil: null },
{ status: 'superseded', assigneeUserId: 'a1', snoozedUntil: null },
{ status: 'assigned', assigneeUserId: 'a1', snoozedUntil: future },
{ status: 'completed', assigneeUserId: 'a1', snoozedUntil: null },
{ status: 'assigned', assigneeUserId: 'a2', snoozedUntil: null },
{ status: 'active', assigneeUserId: null, snoozedUntil: null },
{ status: 'active', assigneeUserId: null, snoozedUntil: past },
];
const c = { resolved: 0, suppressed: 0, closed: 0, inHandPending: 0, backToPool: 0 };
for (const r of rows) c[classifyPlanProgress(r, NOW)]++;
const done = c.resolved + c.suppressed + c.closed;
expect(done + c.inHandPending + c.backToPool).toBe(rows.length);
expect(done).toBe(4); // 2 出池 + 1 抑制 + 1 结案
expect(c.inHandPending).toBe(1);
expect(c.backToPool).toBe(2);
});
});
...@@ -16,7 +16,10 @@ const SCOPE = { hostId: 'h1', tenantId: 't1', sourceUnits: [] as string[], clini ...@@ -16,7 +16,10 @@ const SCOPE = { hostId: 'h1', tenantId: 't1', sourceUnits: [] as string[], clini
const BATCH = 'b1'; const BATCH = 'b1';
function makePrisma(opts: { function makePrisma(opts: {
plans: Array<{ id: string; status: string; assigneeUserId: string | null; releaseReason: string | null }>; plans: Array<{
id: string; status: string; assigneeUserId: string | null; releaseReason: string | null;
patientId?: string; version?: number; snoozedUntil?: Date | null;
}>;
events: Array<{ planId: string; event: string; assigneeUserId: string | null; createdAt: Date }>; events: Array<{ planId: string; event: string; assigneeUserId: string | null; createdAt: Date }>;
}) { }) {
const eventFindMany = jest.fn(async (args: { where: Record<string, unknown>; orderBy?: unknown }) => { const eventFindMany = jest.fn(async (args: { where: Record<string, unknown>; orderBy?: unknown }) => {
...@@ -38,7 +41,13 @@ function makePrisma(opts: { ...@@ -38,7 +41,13 @@ function makePrisma(opts: {
}, },
followupPlan: { followupPlan: {
findMany: jest.fn(async () => findMany: jest.fn(async () =>
opts.plans.map((p) => ({ ...p, assignmentExpiresAt: null })), opts.plans.map((p, i) => ({
assignmentExpiresAt: null,
patientId: `pat-${p.id}`,
version: 1,
snoozedUntil: null,
...p,
})),
), ),
}, },
planEventLog: { findMany: eventFindMany, groupBy: jest.fn(async () => []) }, planEventLog: { findMany: eventFindMany, groupBy: jest.fn(async () => []) },
...@@ -135,3 +144,77 @@ describe('退回原因分布 —— 不得混进系统原因', () => { ...@@ -135,3 +144,77 @@ describe('退回原因分布 —— 不得混进系统原因', () => {
expect(d.releaseReasons.map((r) => r.reason)).not.toContain(PlanEventReason.ASSIGNMENT_EXPIRED); expect(d.releaseReasons.map((r) => r.reason)).not.toContain(PlanEventReason.ASSIGNMENT_EXPIRED);
}); });
}); });
/**
* 🔴 第三条不变量:**处理得最好的那批人不许从批次里消失**。
*
* 引擎发现某患者已无活信号(治疗真做了 / 诊断没了)时走 `closeStaleActivePlan`:
* `status='superseded' + supersededAt=now`,且**不建后继版本**。
* detail/list 若照着直觉写 `where: { supersededAt: null }`,这些行就整个消失 ——
* 「分了 100 人,30 个真的来把治疗做了」显示成 `planned=70`,
* 批次凭空缩水 30 人、不报错不告警,而缩掉的恰恰是唯一算得上成功的那 30 个。
*
* (D-12 的无条件继承只保住了**升版本**那条路 —— 后继行继承 assignment_id;
* 关闭这条路压根没有后继行,继承救不了。R12 当时只想到了前者。)
*/
describe('批次详情 —— 出池的人必须留在批次里', () => {
test('⭐⭐ superseded 的行照样计入 planned,并记进「已处理」', async () => {
const { prisma } = makePrisma({
plans: [
{ id: 'p1', status: 'superseded', assigneeUserId: 'a1', releaseReason: null },
{ id: 'p2', status: 'superseded', assigneeUserId: null, releaseReason: null },
{ id: 'p3', status: 'assigned', assigneeUserId: 'a1', releaseReason: null },
],
events: [
{ planId: 'p1', event: PlanEventType.ASSIGN, assigneeUserId: 'a1', createdAt: new Date('2026-08-11') },
{ planId: 'p2', event: PlanEventType.ASSIGN, assigneeUserId: 'a2', createdAt: new Date('2026-08-11') },
{ planId: 'p3', event: PlanEventType.ASSIGN, assigneeUserId: 'a1', createdAt: new Date('2026-08-11') },
],
});
const svc = await build(prisma);
const d = await svc.detail(SCOPE, BATCH);
expect(d.planned).toBe(3); // ⛔ 不是 1
expect(d.progress.resolved).toBe(2);
expect(d.progress.done).toBe(2);
expect(d.progress.inHandPending).toBe(1);
// 五桶穷尽:加起来必须还是 planned
expect(d.progress.done + d.progress.inHandPending + d.progress.backToPool).toBe(d.planned);
});
test('⭐ 取数**不许**带 supersededAt 过滤(带了就是把成功的人删掉)', async () => {
const { prisma } = makePrisma({ plans: [], events: [] });
const svc = await build(prisma);
await svc.detail(SCOPE, BATCH);
const where = (prisma.followupPlan.findMany as jest.Mock).mock.calls[0][0].where;
expect(where).not.toHaveProperty('supersededAt');
expect(where).toMatchObject({ assignmentId: BATCH });
});
test('⭐ 同患者多版本(升版本时新旧行都带 assignment_id)→ 按患者取最新版,不重复计数', async () => {
const { prisma } = makePrisma({
plans: [
{ id: 'v2', patientId: 'pat-x', version: 2, status: 'assigned', assigneeUserId: 'a1', releaseReason: null },
{ id: 'v1', patientId: 'pat-x', version: 1, status: 'superseded', assigneeUserId: 'a1', releaseReason: null },
],
events: [{ planId: 'v2', event: PlanEventType.ASSIGN, assigneeUserId: 'a1', createdAt: new Date('2026-08-11') }],
});
const svc = await build(prisma);
const d = await svc.detail(SCOPE, BATCH);
expect(d.planned).toBe(1); // 一个患者算一个人
expect(d.progress.inHandPending).toBe(1); // 取的是最新版(v2 assigned),不是旧的 superseded
expect(d.progress.resolved).toBe(0);
});
test('⭐ note 必须写明「处理率不是成功率」+ 批次年龄警告', async () => {
const { prisma } = makePrisma({
plans: [{ id: 'p1', status: 'superseded', assigneeUserId: 'a1', releaseReason: null }],
events: [{ planId: 'p1', event: PlanEventType.ASSIGN, assigneeUserId: 'a1', createdAt: new Date('2026-08-11') }],
});
const svc = await build(prisma);
const d = await svc.detail(SCOPE, BATCH);
expect(d.progress.note).toContain('处理率不是成功率');
// 跑了三个月的批次天然比跑了三天的好看,不提醒就会被拿去横向比
expect(d.progress.note).toContain('先看年龄');
});
});
...@@ -614,7 +614,7 @@ POST /pac/v1/plans/assignments/:id/revoke @RequirePermission(PLAN_DISPATCH) ...@@ -614,7 +614,7 @@ POST /pac/v1/plans/assignments/:id/revoke @RequirePermission(PLAN_DISPATCH)
| P6.4 撤销(REST + 原生确认组件) | 1.25 | D-4 / D-10 / D-11 | | P6.4 撤销(REST + 原生确认组件) | 1.25 | D-4 / D-10 / D-11 |
| ✅ P6.5 `get_cohort_attributes`(T9-B 调整阶段画像圈人) | 1.0 | **已落地(2026-08-02)**。合并 `getDedicatedCs` + `getPersonaFeatures`(T10);不 join 名册、不返回 `stillActive`。⭐ 关键补充:人群取数抽成**共用** `cohort-filter.ts`,出确认单与看分布同一份 SQL —— 否则会出现「分布说商保 32 人、提案只有 28 人」。⭐ 每个维度单独返回 `noTag`(「没有这条画像证据」≠「反面」),提示词与工具返回双保险 | | ✅ P6.5 `get_cohort_attributes`(T9-B 调整阶段画像圈人) | 1.0 | **已落地(2026-08-02)**。合并 `getDedicatedCs` + `getPersonaFeatures`(T10);不 join 名册、不返回 `stillActive`。⭐ 关键补充:人群取数抽成**共用** `cohort-filter.ts`,出确认单与看分布同一份 SQL —— 否则会出现「分布说商保 32 人、提案只有 28 人」。⭐ 每个维度单独返回 `noTag`(「没有这条画像证据」≠「反面」),提示词与工具返回双保险 |
| P6.6 福利进 prompt + 护栏 + 话术失效规则 | 1.25 | 见下 | | P6.6 福利进 prompt + 护栏 + 话术失效规则 | 1.25 | 见下 |
| P6.7 转化率 + 「因素 × 完成率」报表 | 1.5 | T20 的真正交付物 | | ✅ P6.7 完成口径(原「转化率」) | 1.5 | **口径已裁决并落地(2026-08-02),见教条 T20′**。⭐ 原案「转化率」**已否** —— 它卡在 `plan_executions` 11% 回写率上,永远是「样本不足」。改判:**完成 = 池子状态**(召回出池 / 被抑制),出池那半**零回写依赖**。成功与否先不统计,但底账(出池原因 + `patient_facts`)全在,随时可回溯。⭐ 连带修掉跟踪查询 `supersededAt: null` 的静默缺陷(实测 60 条批次显示成 52 条,缩掉的正是已出池的那 8 个)。**剩「因素 × 完成率」的横向报表未做** —— 需 n≥50 真实样本,属 S3 |
#### P6.1 温度轴 —— **四份方案全部踢皮球的孤儿依赖** #### P6.1 温度轴 —— **四份方案全部踢皮球的孤儿依赖**
......
...@@ -333,6 +333,46 @@ plan_event_logs view 1,024 ✅ 唯一可用 —— 客服打开过详情页 ...@@ -333,6 +333,46 @@ plan_event_logs view 1,024 ✅ 唯一可用 —— 客服打开过详情页
因此核心报表是 **因素 × 完成率**,不是单批的进度条。 因此核心报表是 **因素 × 完成率**,不是单批的进度条。
⚠️ 每行必须带 `n=`**n 不足(如 <50)直接标「样本不足」,不画图不算率**(T14)。 ⚠️ 每行必须带 `n=`**n 不足(如 <50)直接标「样本不足」,不画图不算率**(T14)。
#### T20′ · 「完成」= 池子状态,不是回写(2026-08-02 产品裁决)
**完成不需要等成功。** 只要知道**这个患者的召回还在不在池、有没有被抑制** ——
两者任一成立就说明这单已经被处理过了。**成功不成功先不统计。**
| 判据 | 含义 | 依赖回写吗 |
|---|---|---|
| **出池** `status='superseded'` | 引擎判定该患者已无活信号(治疗真做了 / 诊断没了) | ❌ **不依赖** |
| **被抑制** `snoozedUntil > now` | 客服写了回访结果(约下次 / 拒绝 / 放弃) | ✅ 需要 |
> ⭐ 两条是**互补不是重复**:前者接住不写回访的 **89%**,后者接住写了的 **11%**,
> 并集才是完整的「处理过」。这正是绕开 `plan_executions` 回写率困境的那把钥匙 ——
> 池子状态由引擎按 `patient_facts`(客观事实)算出来,客服写不写都成立。
**为什么现在可以不算成功、以后又不会抓瞎**:出池原因记在 `plan_event_logs`
客观事实在 `patient_facts`(append-only)——**任何时候都能回过头重算**
「这批人后来到底做没做治疗」。不是放弃了这个数据,是它不需要现在算。
⚠️ **报处理率必须带批次年龄。** 跑了三个月的批次天然比跑了三天的好看,
不同年龄的批次直接比是耍流氓 → `progress.ageDays` 随数据一起给,助手原话转述。
##### 🔴 连带修掉的静默缺陷:跟踪查询**不许过滤 `supersededAt: null`**
引擎关闭走 `closeStaleActivePlan``status='superseded'`**不建后继版本**
跟踪侧若照直觉写 `where: { supersededAt: null }`,这些行整个消失 ——
```
本地实测(60 条批次,其中 8 条已出池):
旧口径 planned = 52 ← 批次凭空缩水 8 人,不报错不告警
新口径 planned = 60
```
**缩掉的恰恰是唯一算得上成功的那批。** 批次跑得越好,数字缩得越厉害。
(D-12 的无条件继承只保住了**升版本**那条路 —— 后继行继承 `assignment_id`
关闭这条路压根没有后继行,继承救不了。R12 当时只想到了前者。)
→ 正解:拿本批全部行、**按患者取最新版本**(升版本时新旧行都带 `assignment_id`
`max(version)` 天然去重;关闭时最新版就是那条 superseded 行,正好是信号)。
回归见 `tests/assignment-tracking-invariants.spec.ts` 第三组。
--- ---
## 四、关键数据事实(2026-08 生产实测,避免后人重新推导) ## 四、关键数据事实(2026-08 生产实测,避免后人重新推导)
......
...@@ -171,10 +171,21 @@ export const AssignmentBriefSchema = z.object({ ...@@ -171,10 +171,21 @@ export const AssignmentBriefSchema = z.object({
expiresAt: z.string(), expiresAt: z.string(),
createdAt: z.string(), createdAt: z.string(),
/// 以下计数**从 followup_plans 现算**,不是冗余列(立柱等于埋一个会漂的数) /// 以下计数**从 followup_plans 现算**,不是冗余列(立柱等于埋一个会漂的数)
planned: z.number().int().describe('本批分了多少条(活跃版本)'), /**
* 本批分了多少人(**按患者去重、取最新版本**)。
* ⚠️ 口径**不是**「活跃版本」—— 那会把引擎判定"需求已了"的人静默剔掉,
* 批次跑得越好、这个数缩水得越厉害。见 plan-assignment.service.detail 的红字注释。
*/
planned: z.number().int().describe('本批分了多少人(按患者去重)'),
inHand: z.number().int().describe('仍挂在客服名下'), inHand: z.number().int().describe('仍挂在客服名下'),
released: z.number().int().describe('已退回'), released: z.number().int().describe('已退回'),
agents: z.number().int().describe('涉及几个客服'), agents: z.number().int().describe('涉及几个客服'),
/**
* 已处理条数 —— 判据是**池子状态**(出池 / 被抑制),不是回写。
* 详见 `AssignmentDetailResponseSchema.progress` 的整段说明。
* ⚠️ 是**处理**不是**成功**:只说"这单动过了",不说"谈成了"。
*/
done: z.number().int().describe('已处理(召回已出池 / 被抑制 / 已结案)'),
}); });
export type AssignmentBrief = z.infer<typeof AssignmentBriefSchema>; export type AssignmentBrief = z.infer<typeof AssignmentBriefSchema>;
...@@ -209,6 +220,29 @@ export const AssignmentDetailResponseSchema = AssignmentBriefSchema.extend({ ...@@ -209,6 +220,29 @@ export const AssignmentDetailResponseSchema = AssignmentBriefSchema.extend({
* 只给一个百分比会让主管把"没人动"误读成"做得不错"。 * 只给一个百分比会让主管把"没人动"误读成"做得不错"。
*/ */
untouched: z.number().int().describe('分下去后客服从未打开过详情页的条数'), untouched: z.number().int().describe('分下去后客服从未打开过详情页的条数'),
/**
* 处理进度 —— **判据是池子状态,不是回写**(产品裁决 2026-08-02)。
*
* 「完成」不需要等成功与否,只要知道**这个患者的召回还在不在池、有没有被抑制**:
* · `resolved` 出池了 —— 引擎判定该患者已无活信号(治疗真做了 / 诊断没了)。**零回写依赖**
* · `suppressed` 被抑制 —— 客服写了回访结果(约下次 / 拒绝 / 放弃)
* · `closed` 已结案
* 两条判据互补:前者接住不写回访的 89%,后者接住写了的 11%。
*
* ⚠️ **是处理率不是成功率**。成功与否现在不算,但没丢 ——
* 出池原因在 `plan_event_logs`、客观事实在 `patient_facts`(append-only),随时可回溯重算。
* ⚠️ `done + inHandPending + backToPool` 恒等于 `planned`(五桶穷尽,回归里锁着)。
*/
progress: z.object({
done: z.number().int().describe('已处理 = resolved + suppressed + closed'),
resolved: z.number().int().describe('召回已出池(引擎判定无活信号)'),
suppressed: z.number().int().describe('被抑制(客服写了回访结果)'),
closed: z.number().int().describe('已结案 / 已放弃'),
inHandPending: z.number().int().describe('还在客服手上,尚未处理'),
backToPool: z.number().int().describe('退回或到期,已落回池子'),
ageDays: z.number().int().describe('批次年龄 —— ⚠️ 跨批次比处理率前必须先对齐它'),
note: z.string().describe('成品句子,助手原话转述'),
}),
}); });
export type AssignmentDetailResponse = z.infer<typeof AssignmentDetailResponseSchema>; export type AssignmentDetailResponse = z.infer<typeof AssignmentDetailResponseSchema>;
......
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