Commit 9456b544 by luoqi

feat: 批次成效统计 —— 完成 / 成功 / 不成功及原因

前一刀(跳转前必答)让 plan_executions 有数了,这一刀把它接进批次报表。

1) plan_executions 加 assignment_id
   理由与 plan_event_logs 那次一字不差:followup_plans.assignment_id 会被下一次
   分配覆盖,时间窗是启发式不是键。
   ️ 落这一列的判据是 assignment_expires_at != null(「非空 ⟺ 有一次在办的分配」),
    不是 plan.assignment_id 非空 —— 后者在退回后仍留着(那是退回率的分母),
   此时客服自己捞回来打的电话会被算进一个早就结束的批次。

2) detail 增加 outcomes:success / failed / keep / noOutcome / byOutcome / note
   · 每个患者只取最近一次执行(DISTINCT ON) —— 打三次才约上是一个成功不是三个
   · noOutcome 单独成桶:那不是效果差,是根本没做/没记。并进 failed 会把执行问题
     读成召回问题,而两者要改的东西相反
   · 四桶穷尽 success+failed+keep+noOutcome === planned

3)  outcomes 与 releaseReasons 不能互相顶替(工具描述里也写死了)
   releaseReasons = 没打就还回去(分配问题);outcomes = 打了结果这样(召回效果问题)

写这一版时踩了一次:noOutcome 里多减了 reassigned → 被压成 0。
「现在归谁」与「做没做」是正交维度,不能放进同一个减法。已加回归锁住。

实测(真库):3 人批次打 refused + no_answer → 执行带批次号落库 →
详情读出「成功 0 · 不成功 1 · 打了没进展 1 · 一次结果都没有 1」,四桶合计 3 === planned 3,
明细「明确拒绝 1」,note 自带样本不足与「成功含约定下次回访」两条护栏。
1020 tests,两个 tsc 干净。
parent 2e0610be
-- AlterTable
ALTER TABLE "plan_executions" ADD COLUMN "assignment_id" UUID;
-- CreateIndex
CREATE INDEX "plan_executions_assignment_id_plan_id_created_at_idx" ON "plan_executions"("assignment_id", "plan_id", "created_at");
...@@ -1410,6 +1410,25 @@ model PlanExecution { ...@@ -1410,6 +1410,25 @@ model PlanExecution {
/// 本次电话纪要(客服手填的自由文本) /// 本次电话纪要(客服手填的自由文本)
notes String? @db.Text notes String? @db.Text
/**
* 这次执行发生在**哪一批的在办期内**null = 自助认领时做的,不属于任何批次。
*
* ── 为什么要立这一列 ──
* 主管要的「本批成功几个 / 不成功几个 / 都因为什么」只能从 plan_executions ,
* 而这张表原本只有 `plan_id`。想靠 `followup_plans.assignment_id` 反查是**不行**:
* 那一列会被下一次分配覆盖(退回/到期回池后被别的批次挑走就改指了),
* plan_event_logs assignment_id 是同一个坑、同一个理由。
* ⚠️ **不能**"时间窗"去套(某批 assign 之后、release 之前的执行)——
* 那是启发式不是键,两批前后脚就糊了。
*
* ── 什么时候写 ──
* 判据是 `followup_plans.assignment_expires_at != null` ——
* 那一列的自洽性就是「非空 有一次在办的分配」(退回/到期/撤销都会把它清空)
* **不能只看 plan.assignment_id 非空**:它在退回后**仍然留着**(那是退回率的分母),
* 此时客服自己从池子里捞回来打的电话,会被算进一个早就结束的批次。
*/
assignmentId String? @map("assignment_id") @db.Uuid
/// 放弃原因(多选, AbandonReason 应用层 enum)—— **两种回访模式共用这一列**: /// 放弃原因(多选, AbandonReason 应用层 enum)—— **两种回访模式共用这一列**:
/// · 未配宿主回访 PAC 自带通话结果表单(outcome='refused') /// · 未配宿主回访 PAC 自带通话结果表单(outcome='refused')
/// · 已配宿主回访 详情页「关闭机会」弹窗(outcome='abandoned',面向宿主的一套文案) /// · 已配宿主回访 详情页「关闭机会」弹窗(outcome='abandoned',面向宿主的一套文案)
...@@ -1446,6 +1465,8 @@ model PlanExecution { ...@@ -1446,6 +1465,8 @@ model PlanExecution {
/// plan / 客服 拉最近执行 /// plan / 客服 拉最近执行
@@index([planId, createdAt]) @@index([planId, createdAt])
@@index([operatorUserId, createdAt]) @@index([operatorUserId, createdAt])
/// 批次成效报表:某批的执行结果分布( createdAt 是为了"每人取最近一次")
@@index([assignmentId, planId, createdAt])
@@map("plan_executions") @@map("plan_executions")
} }
......
...@@ -415,7 +415,15 @@ export class McpServerFactory { ...@@ -415,7 +415,15 @@ export class McpServerFactory {
'\n⚠️ 报处理率**必须带批次年龄**(progress.ageDays):跑了三个月的批次天然比跑了三天的好看。' + '\n⚠️ 报处理率**必须带批次年龄**(progress.ageDays):跑了三个月的批次天然比跑了三天的好看。' +
'\n⚠️ 退回率**永远给两个数**:「退回 5 / 已处置 40 = 12.5%(另有 60 条未动)」——' + '\n⚠️ 退回率**永远给两个数**:「退回 5 / 已处置 40 = 12.5%(另有 60 条未动)」——' +
'"没人动"和"动了但退回"是完全不同的信号,只报一个百分比会让主管把前者误读成后者。' + '"没人动"和"动了但退回"是完全不同的信号,只报一个百分比会让主管把前者误读成后者。' +
'\n⚠️ 分母小的时候(如 <50)直接说"样本量不足",**不要输出百分比、不要画图**。', '\n⚠️ 分母小的时候(如 <50)直接说"样本量不足",**不要输出百分比、不要画图**。' +
'\n\n⚠️⚠️ `outcomes`(通话成效)与 `releaseReasons`(退回原因)是**两件不同的事**,⛔ 绝不能混说:' +
'\n · releaseReasons = 「这单**不该我做**」—— 客服**没打**就还回去了 → 是**分配**问题' +
'\n · outcomes = 「打了,结果这样」—— 客服**做了事** → 是**召回效果**问题' +
'\n 说反了主管会去改错的东西(一个要调派单、一个要调话术和人群)。' +
'\n⚠️ `outcomes.noOutcome`(一次结果都没有)**必须单独报出来**,' +
'⛔ 不许算进"不成功" —— 那不是效果差,是**根本没做/没记**。' +
'\n⚠️ `outcomes.success` 含**约定下次回访**(约到下次也算有效推进),' +
'⛔ 别说成"成交/转化了这么多"。照抄 `outcomes.note` 最稳。',
inputSchema: { assignmentId: z.string() }, inputSchema: { assignmentId: z.string() },
}, },
async ({ assignmentId }) => jsonResult(await this.assignments.detail(scope, assignmentId)), async ({ assignmentId }) => jsonResult(await this.assignments.detail(scope, assignmentId)),
......
...@@ -94,6 +94,9 @@ export class ExecutionService { ...@@ -94,6 +94,9 @@ export class ExecutionService {
contactAttempts: true, contactAttempts: true,
targetClinicId: true, targetClinicId: true,
assigneeUserId: true, assigneeUserId: true,
// ⭐ 批次归因:这次执行算在哪一批头上(见下方落库处的判据)
assignmentId: true,
assignmentExpiresAt: true,
}, },
}); });
if (!plan) throw new NotFoundException(`Plan ${planId} not found`); if (!plan) throw new NotFoundException(`Plan ${planId} not found`);
...@@ -179,6 +182,14 @@ export class ExecutionService { ...@@ -179,6 +182,14 @@ export class ExecutionService {
? (input.inaccurateTreatments ?? []) ? (input.inaccurateTreatments ?? [])
: [], : [],
scheduledNextAt: input.scheduledNextAt ? new Date(input.scheduledNextAt) : null, scheduledNextAt: input.scheduledNextAt ? new Date(input.scheduledNextAt) : null,
/**
* ⭐ 批次归因 —— 判据是**在办期**(`assignment_expires_at != null`),
* 那一列的自洽性就是「非空 ⟺ 有一次在办的分配」(退回/到期/撤销都会清空它)。
* ⛔ **不要**改成 `plan.assignmentId` 直接落:它在退回之后**仍然留着**
* (那是退回率的分母,刻意不清)。此时客服自己从池子里捞回来打的这通电话,
* 会被算进一个早就结束的批次 —— 那批的"成功数"凭空多一个,且不报错。
*/
assignmentId: plan.assignmentExpiresAt != null ? plan.assignmentId : null,
}, },
select: { id: true }, select: { id: true },
}); });
......
...@@ -8,6 +8,7 @@ import { ...@@ -8,6 +8,7 @@ import {
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { import {
ASSIGNMENT_ITEMS_HARD_LIMIT, ASSIGNMENT_ITEMS_HARD_LIMIT,
EXECUTION_OUTCOME_META,
Permission, Permission,
PlanEventReason, PlanEventReason,
PlanEventType, PlanEventType,
...@@ -22,6 +23,7 @@ import { ...@@ -22,6 +23,7 @@ import {
type CreateAssignmentRequest, type CreateAssignmentRequest,
type CreateAssignmentResponse, type CreateAssignmentResponse,
type ListAssignmentsResponse, type ListAssignmentsResponse,
type ExecutionOutcome,
type ReleaseReason, type ReleaseReason,
type RevokeAssignmentResponse, type RevokeAssignmentResponse,
} from '@pac/types'; } from '@pac/types';
...@@ -529,6 +531,28 @@ export class PlanAssignmentService { ...@@ -529,6 +531,28 @@ export class PlanAssignmentService {
} }
/** /**
* 本批的**通话成效**:每个患者取**最近一次**执行结果,按 outcome 归类。
*
* ⚠️ `DISTINCT ON (plan_id)` 取最近一条,⛔ 不是 `count(*)` 数执行条数 ——
* 打了三次才约上是**一个**成功,不是三个。数条数会让成功数随拨打次数膨胀。
* ⚠️ 只统计 `assignment_id = 本批` 的执行(落库时按"在办期"判定,见 execution.service),
* 所以客服退回后自己捞回来打的电话不会算进这一批。
*/
private async outcomeStats(assignmentId: string): Promise<Map<string, number>> {
const rows = await this.prisma.$queryRaw<Array<{ outcome: string; n: bigint | number }>>(
Prisma.sql`
SELECT outcome, count(*) AS n FROM (
SELECT DISTINCT ON (plan_id) plan_id, outcome
FROM plan_executions
WHERE assignment_id = ${assignmentId}::uuid
ORDER BY plan_id, created_at DESC
) latest
GROUP BY outcome`,
);
return new Map(rows.map((r) => [r.outcome, Number(r.n)]));
}
/**
* 人话批次名 —— 「8/3 21:33 · 牙周治疗 · 窗口内 · 9 人 · 2 位客服」。 * 人话批次名 —— 「8/3 21:33 · 牙周治疗 · 窗口内 · 9 人 · 2 位客服」。
* *
* ⚠️ **服务端唯一生成点**。⛔ 别让模型自己拼:措辞会在两轮之间漂, * ⚠️ **服务端唯一生成点**。⛔ 别让模型自己拼:措辞会在两轮之间漂,
...@@ -754,9 +778,10 @@ export class PlanAssignmentService { ...@@ -754,9 +778,10 @@ export class PlanAssignmentService {
// 账本口径的计数(planned/agents/released/expired)+ 宿主时区(批次名要用)。 // 账本口径的计数(planned/agents/released/expired)+ 宿主时区(批次名要用)。
// ⚠️ 回落用的"老口径"就地从 plans / byAgent 算,⛔ 不再调 statsByAssignment —— // ⚠️ 回落用的"老口径"就地从 plans / byAgent 算,⛔ 不再调 statsByAssignment ——
// 那会为同一批数据再发一次一模一样的查询。 // 那会为同一批数据再发一次一模一样的查询。
const [ledger, tz] = await Promise.all([ const [ledger, tz, outcomeCounts] = await Promise.all([
this.ledgerStatsByAssignment([id]), this.ledgerStatsByAssignment([id]),
this.hostTimezone(scope.hostId), this.hostTimezone(scope.hostId),
this.outcomeStats(id),
]); ]);
const counts = mergeStats( const counts = mergeStats(
{ {
...@@ -819,6 +844,54 @@ export class PlanAssignmentService { ...@@ -819,6 +844,54 @@ export class PlanAssignmentService {
`跑了三个月的批次天然比跑了三天的好看,直接比是耍流氓。`, `跑了三个月的批次天然比跑了三天的好看,直接比是耍流氓。`,
}; };
// ── 通话成效(来自 plan_executions,与「退回原因」是两件事)────────────
const byOutcome = [...outcomeCounts]
.map(([outcome, n]) => ({
outcome,
labelZh: EXECUTION_OUTCOME_META[outcome as ExecutionOutcome]?.labelZh ?? outcome,
group: EXECUTION_OUTCOME_META[outcome as ExecutionOutcome]?.group ?? 'keep',
n,
}))
.sort((a, b) => b.n - a.n);
const sumOf = (g: string) =>
byOutcome.filter((o) => o.group === g).reduce((s, o) => s + o.n, 0);
const success = sumOf('close');
const failed = sumOf('give_up');
const keepCount = sumOf('keep');
/**
* 「一次结果都没有」的人数 = 本批人数 − 有结果的人。
*
* ⭐ 这个数才是主管最该先看的:它不是"效果不好",是**根本没做/没记**。
* 混进 failed 会把执行问题读成召回问题,而这两件事要改的东西完全相反。
* ⛔ **不要再减 `reassigned`**(踩过):被后面批次挑走的人,若在本批期间打过电话,
* 那条执行的 assignment_id 仍是本批 → 已经算在 withOutcome 里了,再减一次是**重复扣减**,
* noOutcome 会被压成 0,而"没人做"这个最该报的信号就此消失。
* 「现在归谁」(reassigned)和「做没做」(outcomes)是**正交**的两个维度,不能放进同一个减法。
*/
const withOutcome = success + failed + keepCount;
const noOutcome = Math.max(0, counts.planned - withOutcome);
const outcomes = {
success,
failed,
keep: keepCount,
noOutcome,
byOutcome,
note:
withOutcome === 0
? `本批 ${counts.planned} 人**还没有任何通话结果**` +
(reassigned ? `(另有 ${reassigned} 人已被后面的批次挑走)` : '') +
`。⚠️ 这说明的是"还没做/没记",⛔ 不是"效果不好" —— 别据此评价召回质量。`
: `打过并记了结果的 ${withOutcome} 人:成功 ${success}、不成功 ${failed}、` +
`打了没进展 ${keepCount};还有 ${noOutcome} 人一次结果都没有。` +
(failed
? `不成功的原因:${byOutcome.filter((o) => o.group === 'give_up').map((o) => `${o.labelZh} ${o.n}`).join('、')}。`
: '') +
`⚠️ 「成功」含**约定下次回访**(约到下次也是有效推进),⛔ 不等于都成交了。` +
(withOutcome < 50
? `⚠️ 只有 ${withOutcome} 个样本,**不要算成功率、不要画图**,样本量不足。`
: ''),
};
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;
...@@ -853,6 +926,8 @@ export class PlanAssignmentService { ...@@ -853,6 +926,8 @@ export class PlanAssignmentService {
// ⚠️ 「未动过」不是「没结果」:分母口径见 AssignmentDetailResponseSchema.untouched 的注释。 // ⚠️ 「未动过」不是「没结果」:分母口径见 AssignmentDetailResponseSchema.untouched 的注释。
// 只输出一个退回率百分比会让主管把"没人动"误读成"做得不错"。 // 只输出一个退回率百分比会让主管把"没人动"误读成"做得不错"。
untouched: plans.length - viewed.length, untouched: plans.length - viewed.length,
// ⚠️ 通话成效(打了之后怎么样)≠ 退回原因(压根没打)。两者并列返回,⛔ 别在读侧合并。
outcomes,
}; };
} }
......
...@@ -222,6 +222,117 @@ describe('批次归因 —— 到期与退回必须分开', () => { ...@@ -222,6 +222,117 @@ describe('批次归因 —— 到期与退回必须分开', () => {
}); });
}); });
describe('通话成效 —— 完成数 / 成功数 / 不成功及原因', () => {
/** outcomeStats 走 $queryRaw;这里按调用顺序返回:①ledger 汇总 ②每人最近一次的 outcome 分布 */
const withOutcomes = (
ledger: { planned: number; agents: number; released: number; expired: number; revoked: number },
dist: Array<{ outcome: string; n: number }>,
) => {
const { prisma } = makePrisma({ plans: [], ledger });
let call = 0;
(prisma as unknown as { $queryRaw: jest.Mock }).$queryRaw = jest.fn(async () => {
call += 1;
return call === 1
? [{
assignment_id: BATCH,
planned: BigInt(ledger.planned), agents: BigInt(ledger.agents),
released: BigInt(ledger.released), expired: BigInt(ledger.expired),
revoked: BigInt(ledger.revoked),
}]
: dist.map((d) => ({ outcome: d.outcome, n: BigInt(d.n) }));
});
return prisma;
};
test('⭐⭐ 按组汇总:成功 / 不成功 / 打了没进展,并给出不成功的原因明细', async () => {
const svc = await build(
withOutcomes({ planned: 20, agents: 2, released: 0, expired: 0, revoked: 0 }, [
{ outcome: 'success_appointed', n: 3 },
{ outcome: 'scheduled_next', n: 2 }, // 也算成功(约到下次是有效推进)
{ outcome: 'refused', n: 4 },
{ outcome: 'external_treatment', n: 1 },
{ outcome: 'no_answer', n: 5 },
]),
);
const o = (await svc.detail(SCOPE, BATCH)).outcomes;
expect(o.success).toBe(5); // 3 + 2
expect(o.failed).toBe(5); // 4 + 1
expect(o.keep).toBe(5);
expect(o.noOutcome).toBe(5); // 20 − 15
expect(o.note).toContain('明确拒绝 4');
expect(o.note).toContain('已在外院治疗 1');
// ⭐ 四桶穷尽
expect(o.success + o.failed + o.keep + o.noOutcome).toBe(20);
});
test('⭐⭐ 被后面批次挑走的人若在本批打过电话,成效仍算本批(⛔ 不许重复扣减)', async () => {
// 踩过:noOutcome 里又减了一次 reassigned → 被压成 0,"没人做"这个最该报的信号消失。
// 「现在归谁」与「做没做」是正交的两个维度。
const svc = await build(
withOutcomes({ planned: 10, agents: 1, released: 0, expired: 0, revoked: 0 }, [
{ outcome: 'success_appointed', n: 4 },
]),
);
const d = await svc.detail(SCOPE, BATCH);
expect(d.progress.reassigned).toBe(10); // 现在一个都不归本批了
expect(d.outcomes.success).toBe(4); // 但那 4 通电话仍算本批的成效
expect(d.outcomes.noOutcome).toBe(6); // ⛔ 不是 0
});
test('⭐⭐ 「一次结果都没有」必须单独成桶,⛔ 不许并进「不成功」', async () => {
// 它不是效果差,是**根本没做/没记** —— 混进去会把执行问题读成召回问题,
// 而这两件事主管要改的东西完全相反。
const svc = await build(
withOutcomes({ planned: 10, agents: 1, released: 0, expired: 0, revoked: 0 }, [
{ outcome: 'refused', n: 1 },
]),
);
const o = (await svc.detail(SCOPE, BATCH)).outcomes;
expect(o.failed).toBe(1);
expect(o.noOutcome).toBe(9);
});
test('⭐ 一条结果都没有时,note 要明说"不是效果不好"', async () => {
const svc = await build(
withOutcomes({ planned: 8, agents: 1, released: 0, expired: 0, revoked: 0 }, []),
);
const o = (await svc.detail(SCOPE, BATCH)).outcomes;
expect(o.noOutcome).toBe(8);
expect(o.note).toContain('还没有任何通话结果');
expect(o.note).toContain('不是"效果不好"');
});
test('⭐ 样本 <50 时 note 必须写明"不要算成功率、不要画图"', async () => {
const svc = await build(
withOutcomes({ planned: 20, agents: 1, released: 0, expired: 0, revoked: 0 }, [
{ outcome: 'success_appointed', n: 2 },
]),
);
expect((await svc.detail(SCOPE, BATCH)).outcomes.note).toContain('样本量不足');
});
test('⭐ note 要提醒「成功」含约定下次回访,⛔ 别读成都成交了', async () => {
const svc = await build(
withOutcomes({ planned: 5, agents: 1, released: 0, expired: 0, revoked: 0 }, [
{ outcome: 'scheduled_next', n: 2 },
]),
);
expect((await svc.detail(SCOPE, BATCH)).outcomes.note).toContain('约定下次回访');
});
test('⛔ 通话成效与退回原因是两套数,不能互相顶替', async () => {
// releaseReasons 来自账本 release 事件(没打就还回去了);outcomes 来自 plan_executions(打了)
const svc = await build(
withOutcomes({ planned: 6, agents: 1, released: 2, expired: 0, revoked: 0 }, [
{ outcome: 'refused', n: 1 },
]),
);
const d = await svc.detail(SCOPE, BATCH);
expect(d.released).toBe(2); // 退回 2 条
expect(d.outcomes.failed).toBe(1); // 打了被拒 1 条 —— 与上面那 2 条无关
});
});
describe('批次名 —— 没有列表页时,主管指认一批的唯一抓手', () => { describe('批次名 —— 没有列表页时,主管指认一批的唯一抓手', () => {
test('⭐⭐ 含时间(到分钟)· 治疗项 · **温度** · 人数 · 客服数', async () => { test('⭐⭐ 含时间(到分钟)· 治疗项 · **温度** · 人数 · 客服数', async () => {
const { prisma } = makePrisma({ const { prisma } = makePrisma({
......
...@@ -82,13 +82,13 @@ export function QuickLogPopover({ ...@@ -82,13 +82,13 @@ export function QuickLogPopover({
<div className="space-y-2"> <div className="space-y-2">
<div> <div>
<p className="text-[12.5px] font-medium text-slate-800"> <p className="text-[12.5px] font-medium text-slate-800">
{isAppt ? '记一笔:已约到' : '记一笔:约好下次'} {isAppt ? '已约到' : '约好下次'}
</p> </p>
{/* ⚠️ 后果必须写在这里。「预约」落的是 success_appointed —— 它会**把这单结案**, {/* ⚠️ 后果必须写在这里。「预约」落的是 success_appointed —— 它会**把这单结案**,
人从「我的」里消失。不写清楚就是让他在不知情的情况下关掉一个工单。 */} 人从「我的」里消失。不写清楚就是让他在不知情的情况下关掉一个工单。 */}
<p className="mt-0.5 text-[10.5px] leading-snug text-slate-400"> <p className="mt-0.5 text-[10.5px] leading-snug text-slate-400">
{isAppt {isAppt
? '记为「转化新预约」并结案,随后打开宿主预约页' ? '记为「转化新预约」并结案,随后打开预约页'
: '记为「约定下次回访」,工单留到那天再浮现'} : '记为「约定下次回访」,工单留到那天再浮现'}
</p> </p>
</div> </div>
...@@ -99,7 +99,7 @@ export function QuickLogPopover({ ...@@ -99,7 +99,7 @@ export function QuickLogPopover({
rows={3} rows={3}
value={notes} value={notes}
onChange={(e) => setNotes(e.target.value)} onChange={(e) => setNotes(e.target.value)}
placeholder="约的什么、什么时候(必填)" placeholder="约的什么、什么时候"
className="w-full resize-none rounded-md border border-slate-200 px-2 py-1.5 text-[12.5px] outline-none placeholder:text-slate-300 focus:border-brand-400" className="w-full resize-none rounded-md border border-slate-200 px-2 py-1.5 text-[12.5px] outline-none placeholder:text-slate-300 focus:border-brand-400"
/> />
) : ( ) : (
......
...@@ -933,6 +933,32 @@ artifact iframe 是 `sandbox="allow-scripts"` + CSP `connect-src 'none'`,**卡 ...@@ -933,6 +933,32 @@ artifact iframe 是 `sandbox="allow-scripts"` + CSP `connect-src 'none'`,**卡
> `followup_plans.release_reason` —— 但此前**任何接口都没把到期数报出来**, > `followup_plans.release_reason` —— 但此前**任何接口都没把到期数报出来**,
> 全被 `backToPool` 一桶吞掉了。) > 全被 `backToPool` 一桶吞掉了。)
> ⭐ **通话成效(完成 / 成功 / 不成功及原因)—— 2026-08-04 落地。**
> 数据源是 `plan_executions`,而那张表原本只有 `plan_id`。⇒ 同样立 `assignment_id`
> (理由与 `plan_event_logs` 一字不差:`followup_plans.assignment_id` 会被下一次分配覆盖;
> 时间窗是启发式不是键)。
> ⚠️ 落这一列的判据是 **`assignment_expires_at != null`**(「非空 ⟺ 有一次在办的分配」),
> ⛔ **不能**只看 `plan.assignment_id` 非空——它在退回后**仍然留着**(那是退回率的分母),
> 此时客服自己从池子里捞回来打的电话会被算进一个早就结束的批次。
>
> ⚠️ 每个患者**只取最近一次**执行(`DISTINCT ON (plan_id) … ORDER BY created_at DESC`),
> ⛔ 不是数执行条数:打了三次才约上是**一个**成功,不是三个。
>
> 🔴🔴 **`outcomes` 与 `releaseReasons` 是两件事,绝不能合并或互相顶替:**
> | | 含义 | 主管该改什么 |
> |---|---|---|
> | `releaseReasons` | 「这单**不该我做**」——**没打**就还回去了 | 分配策略(派给了不该派的人) |
> | `outcomes` | 「打了,结果这样」——**做了事** | 话术与人群 |
> 说反了他会去改错的东西,方向完全相反。
>
> ⚠️ **`noOutcome`(一次结果都没有)必须单独成桶**,⛔ 不许并进「不成功」——
> 那不是效果差,是**根本没做/没记**。混进去会把执行问题读成召回问题。
> ⚠️ 四桶穷尽:`success + failed + keep + noOutcome === planned`。
> ⛔ 这里**不掺 `reassigned`**(踩过):「现在归谁」与「做没做」是**正交**维度——
> 被后面批次挑走的人若在本批打过电话,那条执行仍算本批成效,再减一次是重复扣减,
> `noOutcome` 会被压成 0,而"没人做"这个最该报的信号就此消失。
> ⚠️ `success` **含约定下次回访**,⛔ 别说成"成交了这么多"。
> 🔴 **`overdue` 必须排除"约了下次回访"的单。** > 🔴 **`overdue` 必须排除"约了下次回访"的单。**
> 到期回收器刻意跳过 `snoozedUntil` 在未来的单(客服约了 6/10 回访,那之前绝不能收走, > 到期回收器刻意跳过 `snoozedUntil` 在未来的单(客服约了 6/10 回访,那之前绝不能收走,
> 否则毁掉他对患者的承诺)—— 于是这类单会一直"超期"下去。 > 否则毁掉他对患者的承诺)—— 于是这类单会一直"超期"下去。
......
...@@ -271,6 +271,44 @@ export const AssignmentDetailResponseSchema = AssignmentBriefSchema.extend({ ...@@ -271,6 +271,44 @@ export const AssignmentDetailResponseSchema = AssignmentBriefSchema.extend({
z.object({ reason: z.string(), labelZh: z.string(), n: z.number().int() }), z.object({ reason: z.string(), labelZh: z.string(), n: z.number().int() }),
), ),
/** /**
* ⭐ 本批的**通话成效** —— 客服真打了之后的结果分布(来自 plan_executions)。
*
* ⚠️⚠️ 与上面的 `releaseReasons` 是**两件完全不同的事**,⛔ 绝不能合并或互相顶替:
* · 退回原因 = 「这单**不该我做**」—— 客服**没打**,把单还回去了(分配问题)
* · 通话结果 = 「打了,结果是**这样**」—— 客服**做了事**(召回效果问题)
* 混在一起的话,"退回 5 条"和"打了但被拒 5 条"会读成同一个数,
* 而前者要改的是分配策略、后者要改的是话术和人群 —— 方向完全相反。
*
* ⚠️ 每个患者**只取最近一次**执行,⛔ 不是数执行条数:
* 打了三次才约上,那是**一个**成功,不是三个。数条数会让"成功数"随拨打次数膨胀。
* ⚠️ 四个桶**穷尽**:success + failed + keep + noOutcome === planned(回归里锁着)。
* ⚠️ ⛔ 这里**不掺 `progress.reassigned`**:「现在归谁」和「做没做」是**正交**的两个维度 ——
* 被后面批次挑走的人若在本批期间打过电话,那条执行仍算本批的成效。
* 把两者放进同一个减法会重复扣减,noOutcome 被压成 0(踩过)。
*/
outcomes: z.object({
/// 成功(EXECUTION_OUTCOME_GROUP close):转化新预约 + 约定下次回访
success: z.number().int(),
/// 不成功(give_up):明确拒绝 / 已在外院 / 再考虑
failed: z.number().int(),
/// 打了但没进展(keep):未接通 / 秒挂
keep: z.number().int(),
/// **一次执行结果都没有**的人数 —— ⚠️ 这个数才是主管最该先看的:
/// 它不是"效果不好",是"根本没做/没记"。混进 failed 会把执行问题读成召回问题。
noOutcome: z.number().int(),
/// 明细分布(含中文名与所属组),按人数降序。不成功的「原因」就在这里
byOutcome: z.array(
z.object({
outcome: z.string(),
labelZh: z.string(),
group: z.string(),
n: z.number().int(),
}),
),
/// 成品句子,助手原话转述
note: z.string(),
}),
/**
* ⚠️ **退回率永远给两个数**:分母是"已处置"(在手 + 已退回之外的都还没动过), * ⚠️ **退回率永远给两个数**:分母是"已处置"(在手 + 已退回之外的都还没动过),
* 而"没动过"的数量本身就是信号(主管据此判断是分多了还是客服没跟)。 * 而"没动过"的数量本身就是信号(主管据此判断是分多了还是客服没跟)。
* 只给一个百分比会让主管把"没人动"误读成"做得不错"。 * 只给一个百分比会让主管把"没人动"误读成"做得不错"。
......
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