Commit 0d7c6a53 by luoqi

feat(主管工作台): 「约上」拆成约上/约下次,退回率换成预约成功率,「还在跑的」改名

四处口径/措辞,主管实测提的:

① 「还在跑的」→「还没收尾」
   被读成"分配还没完成",像有个后台任务在跑。它说的是**批次**的生命周期。
   ️ 没叫「时限内」:判据有两支,另一支(还有单压在客服手上)在时限过了
   之后仍然成立,叫「时限内」会让过了期、单还压着的批次莫名待在这一组。
   判据一个字没动。

② 「约上」收窄成**只数转化新预约**,约定下次回访单列「约下次」
   两者原来都算 close 组、合成一个数,靠小字解释「含约定下次回访」。
   而两件事差着一整个台阶 —— 前者患者进了预约表、有个日子会来;
   后者只是客服答应"那天再联系您",患者什么都没答应,单子还挂在他手上
   (drivesStatus 一个 completed 一个 keep,状态机上就是两回事)。
   主管看到「约上 6」会当成 6 个人要来了。

③ 抽屉「通话成效」四个桶 → 五个
   第一格「成功」拆成「约上 / 约下次」。要靠小字解释的数,本身就该拆开摆。
   五个桶仍然穷尽(= 条数),回归里锁着。

④ 「退回率」→「预约成功率」
   分子 = 真的约上的( 不含约定下次回访 —— 算进来的话,一个只会往后拖的人
   能拖出一条漂亮的成功率);
   分母 = 「完成」他真打过的( 不是"已处置" —— 退回的单他压根没打,
   摆进分母等于因为他退单而扣他的成功率)。
    退回和没动没有消失,降级成后面那行小字:两者仍是主管调分配的输入(T7)。

- schema: brief/detail 加 bookedNext,outcomes 加 appointed/scheduledNext
  (success 保留为两者合计, 但界面不再单独摆它),workload row 加 booked
- SQL 由「close 组 IN (...)」改成按 outcome 逐项 FILTER,DISTINCT ON 不动
- 助手解读规范同步(guides.ts),旧那句「outcomes.success 含约定下次回访」删掉
- 新增 booked-vs-scheduled-next.spec.ts(21 条)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent 245f0b01
......@@ -24,12 +24,14 @@ export const TRACKING_GUIDE = [
'「处理」不等于「成功」:progress 是处理率,只说「这单动过了」,不说「谈成了」。',
'「已出池·引擎判定需求已了」是引擎按客观事实判定召回需求没了,⛔ 不是「转化成功/成交」,⛔ 不要拿这些数算转化率。',
'报处理率必须带上「本批已跑天数」:跑了三个月的批次天然比跑了三天的好看,不带年龄直接比是耍流氓。',
'退回率永远给两个数:「客服退回 5 / 已处置 40 = 12.5%(另有 60 条未动)」——「没人动」和「动了但退回」是完全不同的信号,只报一个百分比会把前者藏起来。',
'released(客服退回)与 recalled(主管回收)是两件事:前者是客服说这单我不接,带原因、进退回原因分布;后者是主管在调整各客服在手的单时把活收回来,没有原因。把 recalled 算进退回率,他就是在拿自己的动作当调下一批的依据。',
'退回和没动一起报:「客服退回 5、另有 60 条一次都没动」——「没人动」和「动了但退回」是完全不同的信号,只报一个会把另一个藏起来。',
'主管工作台那一列叫「预约成功率」,是 booked / done(真打过的),⛔ 分子不含约定下次回访、⛔ 分母不含退回的(那些他压根没打)。',
'released(客服退回)与 recalled(主管回收)是两件事:前者是客服说这单我不接,带原因、进退回原因分布;后者是主管在调整各客服在手的单时把活收回来,没有原因。把 recalled 算进客服的退回数,他就是在拿自己的动作当调下一批的依据。',
'分母小于 50 时直接说「样本量不足」,⛔ 不要输出百分比、⛔ 不要画图。',
'outcomes(通话成效)与 releaseReasons(退回原因)是两件不同的事,⛔ 绝不能混说:releaseReasons =「这单不该我做」,客服没打就还回去了,是**分配**问题;outcomes =「打了,结果这样」,客服做了事,是**召回效果**问题。说反了主管会去改错的东西。',
'outcomes.noOutcome(一次结果都没有)必须单独报出来,⛔ 不许算进「不成功」——那不是效果差,是根本没做/没记。',
'outcomes.success 含「约定下次回访」,⛔ 别说成「成交/转化了这么多」。',
'outcomes.appointed(约上)= 患者定下了来的日子;outcomes.scheduledNext(约下次)= 客服答应了那天再联系一次,患者什么都没答应、单子还在他手上。两个数差着一整个台阶,⛔ 别加起来说成「约上了这么多」。',
'outcomes.success 是这两项的合计,⛔ 别单独报它 —— 报了就得解释,而要解释的数本身就该拆开说。',
'outcomes.records 是逐条明细 + 客服手写的电话纪要(notes)。主管问「哪个患者/为什么/客服怎么说的」,答案只在这里,⛔ 别只回聚合数。',
'notes 为 null =「没留纪要」(不是没打)。结果填了、纪要空着本身是信息:说明只点了个选项。',
'引用纪要时照原话,⛔ 别润色成「客户表示…」——主管要看的就是客服当时怎么写的。',
......
......@@ -20,6 +20,8 @@ import {
REVOKE_WINDOW_MINUTES,
TEMPERATURE_META,
potentialTreatmentCardLabel,
/// ⚠️ 值不是类型:SQL 里要用 `ExecutionOutcome.SUCCESS_APPOINTED` 这个字面量
ExecutionOutcome,
type TemperatureValue,
type AssignmentAgentStat,
type AssignmentDetailResponse,
......@@ -27,7 +29,6 @@ import {
type CreateAssignmentRequest,
type CreateAssignmentResponse,
type ListAssignmentsResponse,
type ExecutionOutcome,
type ReleaseReason,
type RevokeAssignmentResponse,
type SetAssignmentBenefitResponse,
......@@ -780,13 +781,19 @@ export class PlanAssignmentService {
}
/**
* 「约上」—— **批次列表**那一列(2026-08-07 主管工作台)。
* 「约上」与「约下次」—— **批次列表**那两列(2026-08-07 主管工作台)。
*
* 🔴 **2026-08-22 拆成两个数**。此前这里数的是整个 `close` 组
* (转化新预约 + 约定下次回访),界面上叫「约上」。
* ⚠️ 那两件事差着一整个台阶:前者患者**进了预约表**、有个日子会来;
* 后者只是客服答应了"那天再联系您",患者什么都没答应,单子还挂在他手上。
* 合成一个数,主管看到「约上 6」会当成 6 个人要来了。
* ⇒ `appointed` = `success_appointed` / `scheduledNext` = `scheduled_next`。
* ⚠️ 与 detail 的 `outcomes.appointed` / `outcomes.scheduledNext` **同一定义**,
* ⛔ 别在这里另立标准 —— 两处对不上主管就只能猜哪个对。
*
* ⚠️ 与 `outcomeStats` 同一口径,只是**一次问一批**:列表一屏 20 行,
* 逐行调 outcomeStats 就是 20 次往返。⛔ 别在 map 里 await。
* ⚠️ 「约上」= EXECUTION_OUTCOME 里 `group='close'` 的那些(转化新预约 + **约定下次回访**)——
* 与 detail 的 `outcomes.success` **同一个定义**,⛔ 别在这里另立标准(只数转化会比详情页小,
* 而两处都写着"约上",主管对不上就只能猜哪个对)。
* ⚠️ 每条单只取**最近一次**执行结果(DISTINCT ON):同一条单可能打过好几通,
* 数行会把一个人算好几次。
*/
......@@ -807,24 +814,31 @@ export class PlanAssignmentService {
return new Map(rows.map((r) => [r.assignment_id, Number(r.n)]));
}
private async bookedByAssignment(ids: string[]): Promise<Map<string, number>> {
private async bookedByAssignment(
ids: string[],
): Promise<Map<string, { appointed: number; scheduledNext: number }>> {
if (ids.length === 0) return new Map();
const closeOutcomes = (Object.keys(EXECUTION_OUTCOME_META) as ExecutionOutcome[]).filter(
(o) => EXECUTION_OUTCOME_META[o].group === 'close',
);
if (closeOutcomes.length === 0) return new Map();
const rows = await this.prisma.$queryRaw<Array<{ assignment_id: string; n: bigint | number }>>(
const rows = await this.prisma.$queryRaw<
Array<{ assignment_id: string; appointed: bigint | number; next: bigint | number }>
>(
Prisma.sql`
SELECT assignment_id, count(*) AS n FROM (
SELECT assignment_id,
count(*) FILTER (WHERE outcome = ${ExecutionOutcome.SUCCESS_APPOINTED}) AS appointed,
count(*) FILTER (WHERE outcome = ${ExecutionOutcome.SCHEDULED_NEXT}) AS next
FROM (
SELECT DISTINCT ON (plan_id) plan_id, assignment_id, outcome
FROM plan_executions
WHERE assignment_id IN (${Prisma.join(ids.map((i) => Prisma.sql`${i}::uuid`))})
ORDER BY plan_id, created_at DESC
) latest
WHERE outcome IN (${Prisma.join(closeOutcomes)})
GROUP BY assignment_id`,
);
return new Map(rows.map((r) => [r.assignment_id, Number(r.n)]));
return new Map(
rows.map((r) => [
r.assignment_id,
{ appointed: Number(r.appointed), scheduledNext: Number(r.next) },
]),
);
}
/**
......@@ -944,7 +958,8 @@ export class PlanAssignmentService {
inHand: l.inHand,
done: l.done,
/// 与 detail 的 `outcomes.success` 同一定义(见 bookedByAssignment)
booked: booked.get(h.id) ?? 0,
booked: booked.get(h.id)?.appointed ?? 0,
bookedNext: booked.get(h.id)?.scheduledNext ?? 0,
/// 执行口径的「已处置」—— 与 agentStats[].done 同源,⛔ 不是 done(池子口径)
handled: handled.get(h.id) ?? 0,
};
......@@ -1161,10 +1176,39 @@ export class PlanAssignmentService {
)
GROUP BY e.assignee_user_id`);
/**
* ⭐ 窗口内**真的约上**的条数 —— 「预约成功率」的分子(2026-08-22)。
*
* ⚠️ 只数 `success_appointed`,⛔ **不含 `scheduled_next`**:
* 约定下次回访只是客服答应了"那天再联系您",患者什么都没答应。
* 算进来的话,一个只会往后拖的人能拖出一条漂亮的成功率,
* 而主管正是靠这一列判断谁真的把人叫回来了。
* ⚠️ 与别处同一条纪律:每条单只取**最近一次**执行(DISTINCT ON),
* 打了三次才约上是**一个**成功。
* ⚠️ 归属按**那条执行的操作人**算:A 打过、B 才约上的算 B 的。
* ⇒ 同一条单可能同时进 A 和 B 的 `done`(那是 count(DISTINCT plan_id)),
* 却只进 B 的 `booked` —— A 的成功率因此偏低,这是**对的**:约上的不是他。
*/
const booked = await this.prisma.$queryRaw<Array<{ uid: string; n: bigint }>>(Prisma.sql`
SELECT operator_user_id AS uid, count(*) AS n FROM (
SELECT DISTINCT ON (plan_id) plan_id, operator_user_id, outcome
FROM plan_executions
WHERE host_id = ${scope.hostId}::uuid
AND tenant_id = ${scope.tenantId}
AND created_at >= ${since}
AND operator_user_id IN (${Prisma.join(ids)})
AND EXISTS (SELECT 1 FROM followup_plans p
WHERE p.id = plan_executions.plan_id AND p.target_clinic_id = ${clinicId})
ORDER BY plan_id, created_at DESC
) latest
WHERE outcome = ${ExecutionOutcome.SUCCESS_APPOINTED}
GROUP BY operator_user_id`);
const num = (rows: Array<{ uid: string; n: bigint }>) =>
new Map(rows.map((r) => [r.uid, Number(r.n)]));
const relM = num(released);
const doneM = num(done);
const bookedM = num(booked);
const asgM = num(assigned);
const liveM = new Map(live.map((r) => [r.uid, Number(r.in_hand)]));
const ovdM = num(overdue);
......@@ -1174,6 +1218,7 @@ export class PlanAssignmentService {
const inHand = liveM.get(a.userId) ?? 0;
const doneN = doneM.get(a.userId) ?? 0;
const relN = relM.get(a.userId) ?? 0;
const bookedN = bookedM.get(a.userId) ?? 0;
// 已处置 = 写了结果的 + 退回的 —— 两者都是"这单他动过了"
const handled = doneN + relN;
// 没动 = 窗口内分到的 − 已处置。⚠️ 可能为负(分配在窗口外、处置在窗口内),兜 0
......@@ -1189,13 +1234,27 @@ export class PlanAssignmentService {
released: relN,
handled,
idle,
booked: bookedN,
/**
* ⭐ 成品句子,⛔ 前端别自己拼 —— 退回率必须**两个分母都带**,
* 写在这里保证界面、助手、导出三处口径一致(T14 的"工具返回值"那一半)。
* ⭐ 成品句子,⛔ 前端别自己拼 —— 写在这里保证界面、助手、导出三处口径一致
* (T14 的"工具返回值"那一半)。
*
* 🔴 2026-08-22 由「退回率」改成「**预约成功率**」(产品定):
* · 分子 —— `booked`(真的约上),⛔ 不含约定下次回访;
* · 分母 —— `done`(他**真打过**的),⛔ 不用 handled(=done+released):
* 退回的单他压根没打,摆进分母等于因为他退单而扣他的成功率。
* ⛔ 退回和没动**没有消失**,降级成后半段那句 —— 两者都是主管调分配的输入(T7),
* 而"没人动"和"动了但退回"是完全不同的信号,一个百分比藏得住两个。
*/
rateNote: handled
? `退回 ${relN} / 已处置 ${handled} = ${((relN / handled) * 100).toFixed(1)}%` +
(idle ? `,另有 ${idle} 条没动` : '')
rateNote: doneN
? `约上 ${bookedN} / 完成 ${doneN} = ${((bookedN / doneN) * 100).toFixed(1)}%` +
(relN || idle
? `(${[relN ? `退回 ${relN}` : '', idle ? `没动 ${idle}` : '']
.filter(Boolean)
.join(' · ')})`
: '')
: relN
? `这 ${days} 天一条都没打,退回 ${relN} 条` + (idle ? `,另有 ${idle} 条没动` : '')
: idle
? `这 ${days} 天分到 ${idle} 条,一条都还没动`
: `这 ${days} 天没有分到新的`,
......@@ -1494,6 +1553,16 @@ export class PlanAssignmentService {
const failed = sumOf('give_up');
const keepCount = sumOf('keep');
/**
* 🔴 **成功组里的两项分开报**(2026-08-22 产品定)。
* 主管看到「成功 0 / 不成功 0 / 没进展 0 / 没有结果 6」,第一句问的是"这个成功是什么意思"。
* ⚠️ 转化新预约 = 患者**进了预约表**、有个日子会来;
* 约定下次回访 = 客服答应了"那天再联系您",患者什么都没答应,单子还挂在他手上。
* ⛔ 靠一行小字解释的数,本身就该拆开摆。
* ⚠️ `appointed + scheduledNext === success` —— 回归里锁着。
*/
const appointed = outcomeCounts.get(ExecutionOutcome.SUCCESS_APPOINTED) ?? 0;
const scheduledNext = outcomeCounts.get(ExecutionOutcome.SCHEDULED_NEXT) ?? 0;
/**
* 「一次结果都没有」的人数 = 本批人数 − 有结果的人。
*
* ⭐ 这个数才是主管最该先看的:它不是"效果不好",是**根本没做/没记**。
......@@ -1507,6 +1576,8 @@ export class PlanAssignmentService {
const noOutcome = Math.max(0, counts.planned - withOutcome);
const outcomes = {
success,
appointed,
scheduledNext,
failed,
keep: keepCount,
noOutcome,
......@@ -1591,9 +1662,10 @@ export class PlanAssignmentService {
progress,
done: progress.done,
inHand: plans.filter((p) => p.status === 'assigned').length,
/// ⭐ 与列表那一列**同一个数**:列表走 bookedByAssignment,这里直接用 outcomes.success ——
/// 两处都是 `group='close'` 的合计,⛔ 别让它们分叉(界面上都叫「约上」)。
booked: outcomes.success,
/// ⭐ 与列表那两列**同一个数**:列表走 bookedByAssignment,这里直接用拆好的两项 ——
/// ⛔ 别让它们分叉(界面上叫的是同一个词)。
booked: outcomes.appointed,
bookedNext: outcomes.scheduledNext,
/// 执行口径:本批真的落了通话结果的条数(= agentStats[].done 之和)
handled: executed.size,
agentStats: [...byAgent.values()].filter((a) => a.userId !== RELEASED_BUCKET),
......
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { EXECUTION_OUTCOME_META, ExecutionOutcome } from '@pac/types';
/**
* 「约上」到底指什么 —— 口径收窄的回归(2026-08-22 产品定)。
*
* ── 由来 ────────────────────────────────────────────────────────
* 主管指着批次列表的「约上」和抽屉里的「成功」问:这两个数是什么意思。
* 当时两处都是 `EXECUTION_OUTCOME_GROUP='close'` 的合计,也就是
* 转化新预约(success_appointed) + 约定下次回访(scheduled_next)
* 加在一起,靠一行小字解释「含约定下次回访,不等于都成交了」。
*
* 🔴 那两件事差着一整个台阶:
* · 转化新预约 —— 患者**进了预约表**,有个日子会来;
* · 约定下次回访 —— 客服答应了"那天再联系您",患者什么都没答应,
* 单子还挂在他手上 snooze 到回访日。
* 合成一个数,主管看到「约上 6」会当成 6 个人要来了。
* ⛔ 而要靠小字解释的数,本身就该拆开摆。
*
* ⇒ 「约上」= 只数 success_appointed;约定下次回访单列「约下次」。
* 这条口径同时决定了主管工作台那一列「预约成功率」的分子。
*/
const SVC = readFileSync(
join(__dirname, '../src/modules/plan/plan-assignment.service.ts'),
'utf8',
);
const web = (p: string) => readFileSync(join(__dirname, '../../pac-web/src', p), 'utf8');
const LIST = web('components/supervisor/batch-tracking.tsx');
const TEAM = web('components/supervisor/team-status.tsx');
describe('① 两个结果项本来就分得开', () => {
test('⭐⭐ success_appointed 与 scheduled_next 是两个独立的 outcome', () => {
expect(ExecutionOutcome.SUCCESS_APPOINTED).toBe('success_appointed');
expect(ExecutionOutcome.SCHEDULED_NEXT).toBe('scheduled_next');
});
/**
* ⚠️ 两者**都在 close 组**,这不是笔误 —— 约定下次回访确实是有效推进,
* 统计上不该跟「未接通」一起算进「保持」(见 EXECUTION_OUTCOME_META 那段)。
* ⛔ 但"归成功组"不等于"约上了":拆的是**显示**,⛔ 别去改 group。
*/
test('⭐ 两者都归 close 组 —— 拆的是显示,⛔ 别顺手改 group', () => {
expect(EXECUTION_OUTCOME_META.success_appointed.group).toBe('close');
expect(EXECUTION_OUTCOME_META.scheduled_next.group).toBe('close');
});
test('close 组就这两项 —— 多一项的话 appointed + scheduledNext 就加不出 success', () => {
const close = (Object.keys(EXECUTION_OUTCOME_META) as ExecutionOutcome[]).filter(
(o) => EXECUTION_OUTCOME_META[o].group === 'close',
);
expect(close.sort()).toEqual(['scheduled_next', 'success_appointed']);
});
/**
* 🔴 状态机上也是两回事,这才是"差一个台阶"的硬证据:
* 约上 → 工单结案;约下次 → 工单**留着**,snooze 到回访日。
*/
test('⭐⭐ 约上结案、约下次不结案', () => {
expect(EXECUTION_OUTCOME_META.success_appointed.drivesStatus).toBe('completed');
expect(EXECUTION_OUTCOME_META.scheduled_next.drivesStatus).toBe('keep');
});
});
describe('② 服务端:两个数分开取', () => {
test('⭐⭐ 批次列表按 outcome 逐项 FILTER,⛔ 不再按 close 组合计', () => {
expect(SVC).toContain(
'count(*) FILTER (WHERE outcome = ${ExecutionOutcome.SUCCESS_APPOINTED}) AS appointed',
);
expect(SVC).toContain(
'count(*) FILTER (WHERE outcome = ${ExecutionOutcome.SCHEDULED_NEXT}) AS next',
);
// ⛔ 老写法:把 close 组的 outcome 灌进 IN (...)
expect(SVC).not.toContain("EXECUTION_OUTCOME_META[o].group === 'close'");
});
test('⭐ 列表与详情**同一个数** —— 分叉了主管就只能猜哪个对', () => {
expect(SVC).toContain('booked: outcomes.appointed');
expect(SVC).toContain('bookedNext: outcomes.scheduledNext');
});
test('详情把 close 组的两项分开报,success 仍是合计', () => {
expect(SVC).toContain(
'const appointed = outcomeCounts.get(ExecutionOutcome.SUCCESS_APPOINTED) ?? 0;',
);
expect(SVC).toContain(
'const scheduledNext = outcomeCounts.get(ExecutionOutcome.SCHEDULED_NEXT) ?? 0;',
);
expect(SVC).toContain("const success = sumOf('close');");
});
test('每条单只取最近一次执行 —— 打了三次才约上是一个成功不是三个', () => {
// DISTINCT ON 必须还在:换成 count(*) 会让成功数随拨打次数膨胀
expect(SVC).toMatch(/SELECT DISTINCT ON \(plan_id\) plan_id, assignment_id, outcome/);
});
});
describe('③ 主管工作台:退回率 → 预约成功率', () => {
/**
* 🔴 分子 ⛔ 不含约定下次回访:算进来的话,一个只会往后拖的人
* 能拖出一条漂亮的成功率,而主管正是靠这一列判断谁真的把人叫回来了。
*/
test('⭐⭐ 分子只数 success_appointed', () => {
const q = SVC.slice(SVC.indexOf('const booked = await this.prisma.$queryRaw'));
expect(q).toContain('WHERE outcome = ${ExecutionOutcome.SUCCESS_APPOINTED}');
expect(q.slice(0, 1200)).not.toContain('SCHEDULED_NEXT');
});
/**
* 🔴 分母是 `done`(他真打过的)⛔ 不是 `handled`(=done+released):
* 退回的单他压根没打,摆进分母等于因为他退单而扣他的成功率。
*/
test('⭐⭐ 分母是「完成」不是「已处置」', () => {
expect(SVC).toContain('`约上 ${bookedN} / 完成 ${doneN} = ');
expect(TEAM).toContain('a.done ? `${((a.booked / a.done) * 100).toFixed(1)}%` : \'\'');
});
/**
* ⛔ 退回和没动**没有消失** —— 两者仍是主管调分配的输入(T7),
* 而"没人动"和"动了但退回"是完全不同的信号,一个百分比藏得住两个。
*/
test('⭐ 退回与没动降级进小字,⛔ 没被删掉', () => {
expect(SVC).toContain('`退回 ${relN}`');
expect(SVC).toContain('`没动 ${idle}`');
// 一条都没打但退了单的人,不能显示成「没有分到新的」
expect(SVC).toContain('`这 ${days} 天一条都没打,退回 ${relN} 条`');
});
test('⭐ 列头写「预约成功率」——「成功率」会被读成"打通了就算成功"', () => {
expect(TEAM).toContain('预约成功率');
expect(TEAM).not.toContain('>\n 退回率\n');
});
});
describe('④ 界面:分组改名 + 两列 + 五个桶', () => {
/**
* 🔴 「还在跑的」被主管读成"**分配还没完成**"(2026-08-22 实测)——
* 像是有个后台任务在跑。它说的其实是**批次**还没收尾。
* ⚠️ 「时限内」也不行:判据有两支,另一支(还有单压在手上)在时限过了之后仍成立。
*/
test('⭐⭐ 「还在跑的」已改名,⛔ 一处都不许留', () => {
const live = LIST.split('\n')
.filter((l) => !l.trimStart().startsWith('*') && !l.includes('//'))
.join('\n');
expect(live).not.toContain('还在跑的');
expect(LIST).toContain('还没收尾');
expect(LIST).toContain('时限没到、或还有单压在客服手上');
});
test('判据本身没动 —— 改的是名字不是口径', () => {
expect(LIST).toContain('return new Date(b.expiresAt).getTime() > now || b.inHand > 0;');
});
test('⭐ 列表拆成「约上」「约下次」两列', () => {
expect(LIST).toContain("'超期', '约上', '约下次'");
expect(LIST).toContain('{b.bookedNext}');
// ⚠️ 中性灰,⛔ 别跟「约上」同一支绿 —— 同色会被加起来读成"成效"
expect(LIST).toContain("bookedNext: 'text-slate-500'");
});
/**
* ⚠️ 五个桶**穷尽**(约上 + 约下次 + 不成功 + 没进展 + 没有结果 === 条数)——
* 少一个就对不上数,而主管一对数发现少了人就再也信不过这块(T14)。
*/
test('⭐⭐ 通话成效五个桶,⛔ 不再摆一个要靠小字解释的「成功」', () => {
expect(LIST).toContain('grid-cols-5');
expect(LIST).toContain("{ k: '约上', n: d.outcomes.appointed");
expect(LIST).toContain("{ k: '约下次', n: d.outcomes.scheduledNext");
expect(LIST).not.toContain("{ k: '成功', n: d.outcomes.success");
// 旧的那句解释也该跟着走 —— 拆开之后它是废话
expect(LIST).not.toContain('「成功」含约定下次回访');
});
test('小字只解释两件不显然的事,⛔ 不重复标签本身', () => {
expect(LIST).toContain('「约上」是患者定下了来的日子');
expect(LIST).toContain('单子还在客服手上');
});
test('分组表头的 colSpan 仍从 NUM_COLS 推 —— 加列不许手抄数字', () => {
expect(LIST).toContain('colSpan={NUM_COLS.length + 1}');
expect(LIST).not.toMatch(/colSpan=\{\d+\}/);
});
});
describe('⑤ 给模型的解读规范跟得上', () => {
const GUIDES = readFileSync(join(__dirname, '../src/modules/mcp/guides.ts'), 'utf8');
test('⭐ 说清 appointed 与 scheduledNext 差在哪,⛔ 别让它把两个数加起来', () => {
expect(GUIDES).toContain('outcomes.appointed(约上)');
expect(GUIDES).toContain('outcomes.scheduledNext(约下次)');
expect(GUIDES).toContain('别加起来说成「约上了这么多」');
});
test('⭐ 旧那句「outcomes.success 含约定下次回访」已经不成立,不许留着', () => {
expect(GUIDES).not.toContain("outcomes.success 含「约定下次回访」");
});
test('预约成功率的分子分母都写明', () => {
expect(GUIDES).toContain('booked / done');
expect(GUIDES).toContain('分子不含约定下次回访');
});
});
......@@ -559,7 +559,9 @@ describe('批次表要认得"主管回收"', () => {
test('⭐ 界面:列头分「客服退回」与「回收」,两处都要', () => {
// 列表行(常量里)
expect(UI).toMatch(/const NUM_COLS = \['条数', '已处置', '没动', '客服退回', '回收', '超期', '约上'\]/);
// ⚠️ 2026-08-22「约上」拆成「约上 / 约下次」—— 这条锁的是**退回与回收分列**,
// ⛔ 别把整个列表的列名写死在这里(那会让每次加列都在这条上假红)
expect(UI).toMatch(/const NUM_COLS = \['条数', '已处置', '没动', '客服退回', '回收',/);
// 详情里按客服拆
expect(UI).toMatch(/'客服', '分到', '已处置', '没动', '客服退回', '回收'/);
// 原因分布只属于客服退回
......
......@@ -96,7 +96,7 @@ const EXAMPLES_STAFF = [
* ⇒ 例句是**摆在他眼前、还请他点**的一句话,用一个界面上不存在的词最糟:
* 他点了,模型还得去猜那是哪一档。
* 🔴 **「成功 / 不成功」是 `TRACKING_GUIDE` 专门在防的那个词。** 字段确实有
* (`outcomes.success`),但它含「约定下次回访」,⛔ 不是"谈成了几个"
* (`outcomes.appointed`=约上 / `outcomes.scheduledNext`=约下次,⛔ 别加起来说)
* 而提示词那条更硬:本系统**不统计成功与否**。例句把这个词递给他、还请他点,
* 模型只有两条路:如实说没有这个数(那这条例句就是个坑),或者去圆。
* ⚠️ **「任务」是客服的词。** 他这一屏叫「我分的批次」;单条任务是客服执行页的事。
......
......@@ -74,7 +74,7 @@ export function AssistantWidget() {
* 3. get_assignment_detail 单批下钻
*
* ⛔ 第 2、3 条里点名的指标**每一个都得是工具真返回的**,否则例句就是许愿:
* 分配数=planned、未动过=untouched、已处理=progress.done、成功=outcomes.success
* 分配数=planned、未动过=untouched、已处理=progress.done、约上=outcomes.appointed
* 超期=expired、退回+原因=released/releaseReasons、不成功+原因=outcomes.failed/byOutcome。
* ⚠️ 写「未动过」不写「曝光」、写「已处理」不写「完成」—— 都是服务端的原词,
* 换个说法会把口径带偏(「已处理」≠ 谈成了,这条 note 服务端每次都在喊)。
......
......@@ -16,9 +16,9 @@ const PAGE = 20;
*
* 🔴 分组表头那一行的 `colSpan` 从它推(`NUM_COLS.length + 1`,+1 是最左边的「批次」列)。
* ⛔ 别在那儿手写数字:2026-08-20 加「回收」那一列时就是这么栽的 ——
* 分组行少跨一格,最后一列在「还在跑的」「8 月」这两行上留出一块白,且不报任何错。
* 分组行少跨一格,最后一列在「还没收尾」「8 月」这两行上留出一块白,且不报任何错。
*/
const NUM_COLS = ['条数', '已处置', '没动', '客服退回', '回收', '超期', '约上'] as const;
const NUM_COLS = ['条数', '已处置', '没动', '客服退回', '回收', '超期', '约上', '约下次'] as const;
/**
* 「没动」标琥珀的阈值(占本批/本人分到量的比例)。
......@@ -27,10 +27,17 @@ const NUM_COLS = ['条数', '已处置', '没动', '客服退回', '回收', '
const IDLE_HEAVY = 0.2;
/**
* 「还在跑的」判据 —— **时限还没到,或还有没人动过的单**。
* 「还没收尾」判据 —— **时限还没到,或还有单压在客服手上**。
*
* 🔴 **2026-08-22 由「还在跑的」改名**(产品实测有歧义):
* 主管把「还在跑的」读成"**分配还没完成**"——像是有个后台任务在跑、结果还没出来。
* 而它说的是**批次**的生命周期:这几批还来得及补救。
* ⚠️ 「时限内」也不行:判据有两支,另一支(还有单压在手上)在时限过了之后仍然成立,
* 写成「时限内」会让一批过了期、单还压着的批次莫名其妙地待在这一组里。
* ⇒ 「还没收尾」直接说的就是那件事,且不碰"分配"这个词。
*
* ⚠️ 这是**前端算的**,因为它是一个展示分组不是业务状态:
* 服务端的 `status` 只有 confirmed/revoked,没有"跑没跑完"这个概念,
* 服务端的 `status` 只有 confirmed/revoked,没有"收没收尾"这个概念,
* 而这个分组的意义纯粹是「哪几批还来得及补救」。
* ⛔ 别为它去后端加一列 —— 口径一旦落库,改起来要迁移数据。
*/
......@@ -166,20 +173,20 @@ export function BatchTracking({ clinicId }: { clinicId: string | null }) {
const shown = scope === 'running' ? running : items;
/**
* 按月分组(「还在跑的」单独一组排最前)。
* ⚠️ 分组只在「全部」视图里做 —— 「还在跑的」本身就是一组,再按月切没有意义。
* 按月分组(「还没收尾」单独一组排最前)。
* ⚠️ 分组只在「全部」视图里做 —— 「还没收尾」本身就是一组,再按月切没有意义。
*/
const groups = useMemo(() => {
const out: BatchGroupData[] = [];
if (scope === 'running') {
return [
{ key: '还在跑的', note: '时限未到、或还有没人动过的单', past: false, rows: shown },
{ key: '还没收尾', note: '时限没到、或还有单压在客服手上', past: false, rows: shown },
];
}
const runIds = new Set(running.map((b) => b.id));
const run = shown.filter((b) => runIds.has(b.id));
if (run.length)
out.push({ key: '还在跑的', note: '时限未到、或还有没人动过的单', past: false, rows: run });
out.push({ key: '还没收尾', note: '时限没到、或还有单压在客服手上', past: false, rows: run });
let lastKey = '';
for (const b of shown) {
if (runIds.has(b.id)) continue;
......@@ -210,7 +217,7 @@ export function BatchTracking({ clinicId }: { clinicId: string | null }) {
: 'text-slate-500 hover:text-slate-700',
)}
>
在跑的 {running.length}
没收尾 {running.length}
</button>
<button
type="button"
......@@ -280,7 +287,7 @@ export function BatchTracking({ clinicId }: { clinicId: string | null }) {
<span className="h-px flex-1 bg-slate-50" />
<span className="nums text-[10.5px] text-slate-400">
{scope === 'running'
? `还在跑的 ${running.length} 批已全部显示`
? `还没收尾的 ${running.length} 批已全部显示`
: cursor
? `已显示 ${items.length} / ${total} 批 · 往下滚动继续加载`
: `已显示全部 ${items.length} 批`}
......@@ -298,7 +305,7 @@ export function BatchTracking({ clinicId }: { clinicId: string | null }) {
type BatchGroupData = { key: string; note: string; past: boolean; rows: AssignmentBrief[] };
/**
* 「还在跑的」与「历史」两套配色(2026-08-11 产品:两者要在**颜色上**分得开)。
* 「还没收尾」与「历史」两套配色(2026-08-11 产品:两者要在**颜色上**分得开)。
*
* ⭐ 分法是「**主色 + 满饱和** vs 灰底 + 褪色」:
* 在跑的那几批带一条主色竖轨、白底、数字保持原来的语义色(绿=已处置、红=退回、
......@@ -324,7 +331,13 @@ const TONE = {
idleHeavy: 'font-semibold text-amber-700',
released: 'text-rose-600',
expired: 'text-amber-700',
booked: 'text-slate-900',
booked: 'text-emerald-700',
/**
* ⚠️ 「约下次」是**中性灰**,⛔ 别跟「约上」同一支绿 ——
* 两者差着一整个台阶(一个进了预约表、一个只是答应再联系),
* 同色会让主管把两列加起来读成"成效"。
*/
bookedNext: 'text-slate-500',
// 组标题也带上主色(浅底 + 深字)—— 竖轨太细,标题这一条才是扫视时先看到的
head: 'bg-brand-50',
headKey: 'text-brand-800',
......@@ -341,7 +354,8 @@ const TONE = {
idleHeavy: 'font-medium text-amber-800/60',
released: 'text-rose-800/55',
expired: 'text-amber-800/55',
booked: 'text-slate-500',
booked: 'text-emerald-800/55',
bookedNext: 'text-slate-400',
head: 'bg-slate-100/70',
headKey: 'text-slate-500',
},
......@@ -365,7 +379,7 @@ function BatchGroup({
{/*
🔴 **colSpan 必须从列表推**(2026-08-20 走查栽过):加了「回收」那一列之后,
这里还写着手抄的 7,分组表头那一行少跨一格 —— 最后一列(约上)在
「还在跑的」「8 月」这两行上留出一块白,而且不报任何错。
「还没收尾」「8 月」这两行上留出一块白,而且不报任何错。
⚠️ `+1` 是最左边那一列「批次」,它不在 NUM_COLS 里。
*/}
<td colSpan={NUM_COLS.length + 1} className={cn('border-t px-3.5 py-1.5', t.rail, t.head)}>
......@@ -410,7 +424,9 @@ function BatchGroup({
{/* ⚠️ 回收是**中性**的(主管自己的调度),⛔ 别给退回那支红 —— 红会读成"出问题了" */}
<td className={cn('nums px-2.5 py-2 text-right', t.idle)}>{b.recalled}</td>
<td className={cn('nums px-2.5 py-2 text-right', t.expired)}>{b.expired}</td>
{/* ⚠️ 「约上」= 转化新预约(患者进了预约表),⛔ 不含约定下次回访 —— 那在下一列 */}
<td className={cn('nums px-2.5 py-2 text-right', t.booked)}>{b.booked}</td>
<td className={cn('nums px-2.5 py-2 text-right', t.bookedNext)}>{b.bookedNext}</td>
</tr>
);
})}
......@@ -473,6 +489,7 @@ function BatchDrawer({ id, onClose }: { id: string; onClose: () => void }) {
<div className="nums mt-0.5 text-[11px] text-slate-600">
已处置 {d.handled} · 没动 {d.inHand} · 客服退回 {d.released}
{d.recalled > 0 && <> · 回收 {d.recalled}</>} · 超期 {d.expired} · 约上 {d.booked}
{d.bookedNext > 0 && <> · 约下次 {d.bookedNext}</>}
</div>
)}
</div>
......@@ -576,14 +593,21 @@ function BatchDrawer({ id, onClose }: { id: string; onClose: () => void }) {
它是写给**模型**看的(带 `**` 和 ⛔ 的指令),在界面上是原样渲染的乱码;
真正给人看的成效数在下面这四个桶里。⛔ 别再把模型指令当界面文案用。 */}
<div className="mt-4 text-[11.5px] font-semibold text-slate-700">通话成效</div>
{/* ⚠️ 四个桶**穷尽**(success + failed + keep + noOutcome === 条数,回归里锁着)——
所以四个都要摆出来,少一个就对不上数(T14)。
🔴 「没有结果」不是"效果差"是**根本没做/没记**,所以它跟前三个分开、
并且是唯一上色的:主管先要看的就是它。 */}
<div className="mt-1.5 grid grid-cols-4 gap-2 text-center">
{/* 🔴 **2026-08-22 由四个桶改成五个** —— 原来第一格叫「成功」,
含**转化新预约 + 约定下次回访**两项,靠下面一行小字解释。
产品实测:主管看到「成功 0 / 不成功 0 / 没进展 0 / 没有结果 6」,
第一句问的就是"这个成功是什么意思"。
⛔ 要靠小字解释的数,本身就该拆开摆。
⚠️ 五个桶**穷尽**(约上 + 约下次 + 不成功 + 没进展 + 没有结果 === 条数,
回归里锁着)—— 少一个就对不上数(T14)。
🔴 「没有结果」不是"效果差"是**根本没做/没记**,所以它跟前四个分开、
并且是唯一上琥珀的:主管先要看的就是它。 */}
<div className="mt-1.5 grid grid-cols-5 gap-1.5 text-center">
{(
[
{ k: '成功', n: d.outcomes.success, c: 'text-emerald-700' },
{ k: '约上', n: d.outcomes.appointed, c: 'text-emerald-700' },
// ⚠️ 灰不是绿:它是"还没结束",不是成效
{ k: '约下次', n: d.outcomes.scheduledNext, c: 'text-slate-600' },
{ k: '不成功', n: d.outcomes.failed, c: 'text-slate-600' },
{ k: '没进展', n: d.outcomes.keep, c: 'text-slate-600' },
{
......@@ -593,15 +617,16 @@ function BatchDrawer({ id, onClose }: { id: string; onClose: () => void }) {
},
] as const
).map((o) => (
<div key={o.k} className="rounded-lg bg-slate-50 py-1.5">
<div key={o.k} className="rounded-lg bg-slate-50 px-1 py-1.5">
<div className={cn('nums text-[15px] font-semibold', o.c)}>{o.n}</div>
<div className="text-[10.5px] text-slate-500">{o.k}</div>
<div className="text-[10.5px] whitespace-nowrap text-slate-500">{o.k}</div>
</div>
))}
</div>
{/* ⚠️ 「成功」含**约定下次回访** —— 不写这句主管会读成"成交了这么多" */}
{/* ⚠️ 拆开之后这行小字只解释**两件不显然的事**,⛔ 别再重复标签本身 */}
<div className="mt-1.5 text-[10.5px] leading-relaxed text-slate-400">
「成功」含约定下次回访,不等于都成交了;「没有结果」是这批里一次都没打/没记的。
「约上」是患者定下了来的日子;「约下次」只是约好再联系一次,单子还在客服手上。
「没有结果」是这批里一次都没打/没记的。
</div>
{/*
......
......@@ -15,7 +15,7 @@ import { useAssistantStore } from '@/stores/assistant-store';
* ⚠️ 设计稿是 1440×1000 的固定框,这里做**自适应**(2026-08-07 产品定,与现有页面一致):
* 右栏固定 34rem、左栏吃剩下的;<1280px 时右栏落到下面(⛔ 别横向滚 ——
* 这页会嵌在宿主 iframe 里,横条比换行难受得多)。
* ⚠️ 34rem 比设计稿的 470px 宽 —— 那个宽度装不下退回率的两个分母(见下方注释)。
* ⚠️ 34rem 比设计稿的 470px 宽 —— 那个宽度装不下预约成功率下面那行分母(见下方注释)。
*
* ⚠️ 诊所是这一屏**所有查询的前提**(批次、团队都按诊所)。没有诊所就什么都不查,
* ⛔ 别用「全部诊所」兜底:那会把别家的负载混进来,而主管管的是自己这一家。
......@@ -77,7 +77,7 @@ export function SupervisorWorkbench() {
<BatchTracking clinicId={clinicId} />
</section>
</div>
{/* ⚠️ 470px(设计稿宽度)装不下「退回率」那一列:
{/* ⚠️ 470px(设计稿宽度)装不下「预约成功率」那一列:
百分比下面还有「退回 3 / 已处置 3 = 100.0%,另有 65 条没动」——
两个分母都得给(那是定死的口径),整行就被切掉右半截(实测)。
⇒ 放宽到 34rem;⛔ 别为了塞进 470 去砍分母。 */}
......
......@@ -17,7 +17,7 @@ const WINDOWS = [
*
* ── 一张表里混着两种时间性,这是最容易读错的地方 ────────────────
* · **当前在手 / 当前超期** —— 此刻的状态,与窗口无关;
* · **完成 / 退回率 / 没动** —— 都在选中的那个窗口里,换窗口一起变。
* · **完成 / 预约成功率 / 没动** —— 都在选中的那个窗口里,换窗口一起变。
* ⚠️ 所以这两列都写死「**当前**」。只写「在手」时实测会被读成"这 7 天分了 62 条",
* 而「超期」如果不带「当前」,摆在窗口切换器正下方就会被当成"这 7 天超了几条"。
*
......@@ -28,9 +28,17 @@ const WINDOWS = [
* ⚠️ 而窗口那一支数的是**已经回池、没有客服挂着**的单,那不是谁的超期。
* 实测:界面上「超期 41 / 193」100% 来自它,真正在手且过时限的是 0 条。
*
* ⚠️ 退回率那行小字是**服务端拼好的**(`rateNote`),⛔ 前端别自己算:
* 它必须带两个分母(「退回 3 / 已处置 21 = 14.3%,另有 2 条没动」)——
* 只给百分比会把"没动"藏起来,而那个数本身就是信号。
* 🔴 「**预约成功率**」2026-08-22 顶掉了原来的「退回率」(产品定):
* · 分子 —— 真的**约上**的(`success_appointed`,患者定下了来的日子),
* ⛔ **不含「约定下次回访」**:那只是客服答应了"那天再联系您",患者什么都没答应。
* 算进来的话,一个只会往后拖的人能拖出一条漂亮的成功率。
* · 分母 —— 「完成」(他**真打过**的),⛔ 不是"已处置":
* 退回的单他压根没打,摆进分母等于因为他退单而扣他的成功率。
* ⛔ 退回和没动**没有消失**,降级到那行小字里 —— 两者仍是主管调分配的输入(T7),
* 而"没人动"和"动了但退回"是完全不同的信号。
*
* ⚠️ 那行小字是**服务端拼好的**(`rateNote`),⛔ 前端别自己算:
* 界面、助手、导出三处口径必须一致。
*/
export function TeamStatus({ clinicId }: { clinicId: string | null }) {
const [days, setDays] = useState<number>(7);
......@@ -38,7 +46,7 @@ export function TeamStatus({ clinicId }: { clinicId: string | null }) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
/// ⭐ 助手那边确认/撤销后跟着重拉 —— 每个人的在手/分到/退回率分母都变了(见 assignment-sync-store)
/// ⭐ 助手那边确认/撤销后跟着重拉 —— 每个人的在手/分到/成功率分母都变了(见 assignment-sync-store)
const syncSeq = useAssignmentSyncStore((s) => s.seq);
useEffect(() => {
if (!clinicId) return;
......@@ -138,8 +146,9 @@ export function TeamStatus({ clinicId }: { clinicId: string | null }) {
<th className="whitespace-nowrap px-2.5 py-1.5 text-right text-[11px] font-medium text-slate-500">
完成
</th>
{/* ⚠️ 「预约」两个字不能省 —— 「成功率」会被读成"打通了就算成功" */}
<th className="whitespace-nowrap px-3.5 py-1.5 text-right text-[11px] font-medium text-slate-500">
退回
预约成功
</th>
</tr>
</thead>
......@@ -189,9 +198,10 @@ export function TeamStatus({ clinicId }: { clinicId: string | null }) {
</td>
<td className="nums px-2.5 py-2 text-right text-emerald-700">{a.done}</td>
<td className="px-3.5 py-2 text-right">
{/* ⚠️ 百分比与两个分母**分两行**:一行塞不下,而分母不能省 */}
{/* ⚠️ 百分比与分母**分两行**:一行塞不下,而分母不能省
⚠️ 分母是 `done`(他真打过的)不是 `handled` —— 见文件头那段 */}
<div className="nums text-slate-900">
{a.handled ? `${((a.released / a.handled) * 100).toFixed(1)}%` : '—'}
{a.done ? `${((a.booked / a.done) * 100).toFixed(1)}%` : '—'}
</div>
<div className="nums text-[10.5px] leading-snug text-slate-400">
{a.rateNote}
......
......@@ -329,14 +329,28 @@ export const AssignmentBriefSchema = z.object({
*/
handled: z.number().int(),
/**
* 「约上」—— 本批里最近一次通话结果落在 `close` 组的人数(转化新预约 + **约定下次回访**)
* 「约上」—— 本批里最近一次通话结果是 **`success_appointed`(转化新预约)** 的人数
*
* ⚠️ 与 detail 的 `outcomes.success` **同一定义**,⛔ 别只数"转化新预约":
* 两处都写着"约上",小一号主管就只能猜哪个对。
* 🔴 **2026-08-22 收窄口径**:此前它是整个 `close` 组的合计,
* 也就是「转化新预约 + 约定下次回访」两项加在一起。
* ⚠️ 那两件事差着一整个台阶:前者患者**进了预约表**、有个日子会来;
* 后者只是客服答应了"那天再联系您",患者什么都没答应。
* 混成一个数,主管看到「约上 6」会当成 6 个人要来了。
* ⇒ 「约上」= 只数真的约上的;约了下次回访的走 `bookedNext`。
*
* ⚠️ 与 detail 的 `outcomes.appointed` **同一定义**,⛔ 别在两处各立标准。
* ⚠️ 上线初期这个数会长期是 0 或个位数 —— ⛔ **不许拿它算转化率**,
* 样本不足时要明说(见 outcomes.note 的写法),画出 0.x% 会让主管以为功能没用。
*/
booked: z.number().int(),
/**
* 「约下次」—— 最近一次通话结果是 `scheduled_next`(约定下次回访)的人数。
*
* ⚠️ 它**不是成效**,是**还没结束**:单子仍挂在客服手上、snooze 到回访日。
* ⛔ 别把它和 `booked` 加起来对外叫「约上」—— 那正是收窄口径要拆开的东西。
* ⚠️ `booked + bookedNext === outcomes.success`(close 组的合计),回归里锁着。
*/
bookedNext: z.number().int(),
});
export type AssignmentBrief = z.infer<typeof AssignmentBriefSchema>;
......@@ -412,8 +426,27 @@ export const AssignmentDetailResponseSchema = AssignmentBriefSchema.extend({
* 把两者放进同一个减法会重复扣减,noOutcome 被压成 0(踩过)。
*/
outcomes: z.object({
/// 成功(EXECUTION_OUTCOME_GROUP close):转化新预约 + 约定下次回访
/**
* 成功组(EXECUTION_OUTCOME_GROUP `close`)的合计 = `appointed + scheduledNext`。
*
* ⚠️ 它**只是个合计**,⛔ 界面上不许单独摆一个「成功 N」——
* 2026-08-22 产品实测:主管看到「成功 0 / 不成功 0 / 没进展 0 / 没有结果 6」,
* 问的第一句就是"这个成功是什么意思"。答案得靠下面那行小字,
* 而要靠小字解释的数,本身就该拆开摆。
*/
success: z.number().int(),
/**
* ⭐ 「约上」—— `success_appointed`(转化新预约):患者**进了预约表**,有个日子会来。
* ⚠️ 与列表那一列 `booked` 同一个数,⛔ 别让它们分叉。
*/
appointed: z.number().int(),
/**
* ⭐ 「约下次」—— `scheduled_next`(约定下次回访):客服答应了"那天再联系您",
* 患者什么都没答应,单子仍挂在他手上 snooze 到回访日。
* ⚠️ 它归 `close` 组是**统计口径**上的事(是有效推进,不该跟"未接通"混在一起),
* ⛔ 但它不是"约上了" —— 见 EXECUTION_OUTCOME_META 里 scheduled_next 那段。
*/
scheduledNext: z.number().int(),
/// 不成功(give_up):明确拒绝 / 已在外院 / 再考虑
failed: z.number().int(),
/// 打了但没进展(keep):未接通 / 秒挂
......@@ -1219,8 +1252,22 @@ export const AgentWorkloadRowSchema = z.object({
/// 窗口内分到但一条都没动的
idle: z.number().int(),
/**
* ⭐ 成品句子:「退回 3 / 已处置 21 = 14.3%,另有 2 条没动」。
* 🔴 **两个分母都要带** —— 只给一个百分比会把"没动"藏起来,而那个数本身就是信号。
* ⭐ 窗口内**真的约上**的条数 —— 最近一次通话结果是 `success_appointed`(转化新预约)。
*
* 🔴 ⛔ **不含「约定下次回访」**(2026-08-22 产品定):那只是客服答应了
* "那天再联系您",患者什么都没答应。算进来的话,一个只会往后拖的人
* 能拖出一条漂亮的成功率,而主管正是靠这一列判断谁真的把人叫回来了。
* ⚠️ 归属按**那条执行的操作人**算:同一条单 A 打过、B 才约上的,算 B 的。
*/
booked: z.number().int(),
/**
* ⭐ 成品句子:「约上 3 / 完成 21 = 14.3%」+「退回 2 · 没动 5」。
*
* 🔴 **分母和旁支都要带** —— 只给一个百分比会把"没动"藏起来,而那个数本身就是信号。
* ⚠️ 2026-08-22 由「退回率」改成「预约成功率」:分子从 released 换成 booked、
* 分母从 handled(done+released)换成 **done(他真打过的)** ——
* 退回的单他压根没打,摆进分母等于因为他退单而扣他的成功率。
* ⛔ 退回和没动**没有消失**,降级成这句话后半段:两者都是主管调分配的输入(T7)。
* ⛔ 前端别自己拼:界面、助手、导出三处口径必须一致。
*/
rateNote: 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