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 = [ ...@@ -24,12 +24,14 @@ export const TRACKING_GUIDE = [
'「处理」不等于「成功」:progress 是处理率,只说「这单动过了」,不说「谈成了」。', '「处理」不等于「成功」:progress 是处理率,只说「这单动过了」,不说「谈成了」。',
'「已出池·引擎判定需求已了」是引擎按客观事实判定召回需求没了,⛔ 不是「转化成功/成交」,⛔ 不要拿这些数算转化率。', '「已出池·引擎判定需求已了」是引擎按客观事实判定召回需求没了,⛔ 不是「转化成功/成交」,⛔ 不要拿这些数算转化率。',
'报处理率必须带上「本批已跑天数」:跑了三个月的批次天然比跑了三天的好看,不带年龄直接比是耍流氓。', '报处理率必须带上「本批已跑天数」:跑了三个月的批次天然比跑了三天的好看,不带年龄直接比是耍流氓。',
'退回率永远给两个数:「客服退回 5 / 已处置 40 = 12.5%(另有 60 条未动)」——「没人动」和「动了但退回」是完全不同的信号,只报一个百分比会把前者藏起来。', '退回和没动一起报:「客服退回 5、另有 60 条一次都没动」——「没人动」和「动了但退回」是完全不同的信号,只报一个会把另一个藏起来。',
'released(客服退回)与 recalled(主管回收)是两件事:前者是客服说这单我不接,带原因、进退回原因分布;后者是主管在调整各客服在手的单时把活收回来,没有原因。把 recalled 算进退回率,他就是在拿自己的动作当调下一批的依据。', '主管工作台那一列叫「预约成功率」,是 booked / done(真打过的),⛔ 分子不含约定下次回访、⛔ 分母不含退回的(那些他压根没打)。',
'released(客服退回)与 recalled(主管回收)是两件事:前者是客服说这单我不接,带原因、进退回原因分布;后者是主管在调整各客服在手的单时把活收回来,没有原因。把 recalled 算进客服的退回数,他就是在拿自己的动作当调下一批的依据。',
'分母小于 50 时直接说「样本量不足」,⛔ 不要输出百分比、⛔ 不要画图。', '分母小于 50 时直接说「样本量不足」,⛔ 不要输出百分比、⛔ 不要画图。',
'outcomes(通话成效)与 releaseReasons(退回原因)是两件不同的事,⛔ 绝不能混说:releaseReasons =「这单不该我做」,客服没打就还回去了,是**分配**问题;outcomes =「打了,结果这样」,客服做了事,是**召回效果**问题。说反了主管会去改错的东西。', 'outcomes(通话成效)与 releaseReasons(退回原因)是两件不同的事,⛔ 绝不能混说:releaseReasons =「这单不该我做」,客服没打就还回去了,是**分配**问题;outcomes =「打了,结果这样」,客服做了事,是**召回效果**问题。说反了主管会去改错的东西。',
'outcomes.noOutcome(一次结果都没有)必须单独报出来,⛔ 不许算进「不成功」——那不是效果差,是根本没做/没记。', 'outcomes.noOutcome(一次结果都没有)必须单独报出来,⛔ 不许算进「不成功」——那不是效果差,是根本没做/没记。',
'outcomes.success 含「约定下次回访」,⛔ 别说成「成交/转化了这么多」。', 'outcomes.appointed(约上)= 患者定下了来的日子;outcomes.scheduledNext(约下次)= 客服答应了那天再联系一次,患者什么都没答应、单子还在他手上。两个数差着一整个台阶,⛔ 别加起来说成「约上了这么多」。',
'outcomes.success 是这两项的合计,⛔ 别单独报它 —— 报了就得解释,而要解释的数本身就该拆开说。',
'outcomes.records 是逐条明细 + 客服手写的电话纪要(notes)。主管问「哪个患者/为什么/客服怎么说的」,答案只在这里,⛔ 别只回聚合数。', 'outcomes.records 是逐条明细 + 客服手写的电话纪要(notes)。主管问「哪个患者/为什么/客服怎么说的」,答案只在这里,⛔ 别只回聚合数。',
'notes 为 null =「没留纪要」(不是没打)。结果填了、纪要空着本身是信息:说明只点了个选项。', 'notes 为 null =「没留纪要」(不是没打)。结果填了、纪要空着本身是信息:说明只点了个选项。',
'引用纪要时照原话,⛔ 别润色成「客户表示…」——主管要看的就是客服当时怎么写的。', '引用纪要时照原话,⛔ 别润色成「客户表示…」——主管要看的就是客服当时怎么写的。',
......
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('批次表要认得"主管回收"', () => { ...@@ -559,7 +559,9 @@ describe('批次表要认得"主管回收"', () => {
test('⭐ 界面:列头分「客服退回」与「回收」,两处都要', () => { test('⭐ 界面:列头分「客服退回」与「回收」,两处都要', () => {
// 列表行(常量里) // 列表行(常量里)
expect(UI).toMatch(/const NUM_COLS = \['条数', '已处置', '没动', '客服退回', '回收', '超期', '约上'\]/); // ⚠️ 2026-08-22「约上」拆成「约上 / 约下次」—— 这条锁的是**退回与回收分列**,
// ⛔ 别把整个列表的列名写死在这里(那会让每次加列都在这条上假红)
expect(UI).toMatch(/const NUM_COLS = \['条数', '已处置', '没动', '客服退回', '回收',/);
// 详情里按客服拆 // 详情里按客服拆
expect(UI).toMatch(/'客服', '分到', '已处置', '没动', '客服退回', '回收'/); expect(UI).toMatch(/'客服', '分到', '已处置', '没动', '客服退回', '回收'/);
// 原因分布只属于客服退回 // 原因分布只属于客服退回
......
...@@ -96,7 +96,7 @@ const EXAMPLES_STAFF = [ ...@@ -96,7 +96,7 @@ const EXAMPLES_STAFF = [
* ⇒ 例句是**摆在他眼前、还请他点**的一句话,用一个界面上不存在的词最糟: * ⇒ 例句是**摆在他眼前、还请他点**的一句话,用一个界面上不存在的词最糟:
* 他点了,模型还得去猜那是哪一档。 * 他点了,模型还得去猜那是哪一档。
* 🔴 **「成功 / 不成功」是 `TRACKING_GUIDE` 专门在防的那个词。** 字段确实有 * 🔴 **「成功 / 不成功」是 `TRACKING_GUIDE` 专门在防的那个词。** 字段确实有
* (`outcomes.success`),但它含「约定下次回访」,⛔ 不是"谈成了几个" * (`outcomes.appointed`=约上 / `outcomes.scheduledNext`=约下次,⛔ 别加起来说)
* 而提示词那条更硬:本系统**不统计成功与否**。例句把这个词递给他、还请他点, * 而提示词那条更硬:本系统**不统计成功与否**。例句把这个词递给他、还请他点,
* 模型只有两条路:如实说没有这个数(那这条例句就是个坑),或者去圆。 * 模型只有两条路:如实说没有这个数(那这条例句就是个坑),或者去圆。
* ⚠️ **「任务」是客服的词。** 他这一屏叫「我分的批次」;单条任务是客服执行页的事。 * ⚠️ **「任务」是客服的词。** 他这一屏叫「我分的批次」;单条任务是客服执行页的事。
......
...@@ -74,7 +74,7 @@ export function AssistantWidget() { ...@@ -74,7 +74,7 @@ export function AssistantWidget() {
* 3. get_assignment_detail 单批下钻 * 3. get_assignment_detail 单批下钻
* *
* ⛔ 第 2、3 条里点名的指标**每一个都得是工具真返回的**,否则例句就是许愿: * ⛔ 第 2、3 条里点名的指标**每一个都得是工具真返回的**,否则例句就是许愿:
* 分配数=planned、未动过=untouched、已处理=progress.done、成功=outcomes.success * 分配数=planned、未动过=untouched、已处理=progress.done、约上=outcomes.appointed
* 超期=expired、退回+原因=released/releaseReasons、不成功+原因=outcomes.failed/byOutcome。 * 超期=expired、退回+原因=released/releaseReasons、不成功+原因=outcomes.failed/byOutcome。
* ⚠️ 写「未动过」不写「曝光」、写「已处理」不写「完成」—— 都是服务端的原词, * ⚠️ 写「未动过」不写「曝光」、写「已处理」不写「完成」—— 都是服务端的原词,
* 换个说法会把口径带偏(「已处理」≠ 谈成了,这条 note 服务端每次都在喊)。 * 换个说法会把口径带偏(「已处理」≠ 谈成了,这条 note 服务端每次都在喊)。
......
...@@ -15,7 +15,7 @@ import { useAssistantStore } from '@/stores/assistant-store'; ...@@ -15,7 +15,7 @@ import { useAssistantStore } from '@/stores/assistant-store';
* ⚠️ 设计稿是 1440×1000 的固定框,这里做**自适应**(2026-08-07 产品定,与现有页面一致): * ⚠️ 设计稿是 1440×1000 的固定框,这里做**自适应**(2026-08-07 产品定,与现有页面一致):
* 右栏固定 34rem、左栏吃剩下的;<1280px 时右栏落到下面(⛔ 别横向滚 —— * 右栏固定 34rem、左栏吃剩下的;<1280px 时右栏落到下面(⛔ 别横向滚 ——
* 这页会嵌在宿主 iframe 里,横条比换行难受得多)。 * 这页会嵌在宿主 iframe 里,横条比换行难受得多)。
* ⚠️ 34rem 比设计稿的 470px 宽 —— 那个宽度装不下退回率的两个分母(见下方注释)。 * ⚠️ 34rem 比设计稿的 470px 宽 —— 那个宽度装不下预约成功率下面那行分母(见下方注释)。
* *
* ⚠️ 诊所是这一屏**所有查询的前提**(批次、团队都按诊所)。没有诊所就什么都不查, * ⚠️ 诊所是这一屏**所有查询的前提**(批次、团队都按诊所)。没有诊所就什么都不查,
* ⛔ 别用「全部诊所」兜底:那会把别家的负载混进来,而主管管的是自己这一家。 * ⛔ 别用「全部诊所」兜底:那会把别家的负载混进来,而主管管的是自己这一家。
...@@ -77,7 +77,7 @@ export function SupervisorWorkbench() { ...@@ -77,7 +77,7 @@ export function SupervisorWorkbench() {
<BatchTracking clinicId={clinicId} /> <BatchTracking clinicId={clinicId} />
</section> </section>
</div> </div>
{/* ⚠️ 470px(设计稿宽度)装不下「退回率」那一列: {/* ⚠️ 470px(设计稿宽度)装不下「预约成功率」那一列:
百分比下面还有「退回 3 / 已处置 3 = 100.0%,另有 65 条没动」—— 百分比下面还有「退回 3 / 已处置 3 = 100.0%,另有 65 条没动」——
两个分母都得给(那是定死的口径),整行就被切掉右半截(实测)。 两个分母都得给(那是定死的口径),整行就被切掉右半截(实测)。
⇒ 放宽到 34rem;⛔ 别为了塞进 470 去砍分母。 */} ⇒ 放宽到 34rem;⛔ 别为了塞进 470 去砍分母。 */}
......
...@@ -17,7 +17,7 @@ const WINDOWS = [ ...@@ -17,7 +17,7 @@ const WINDOWS = [
* *
* ── 一张表里混着两种时间性,这是最容易读错的地方 ──────────────── * ── 一张表里混着两种时间性,这是最容易读错的地方 ────────────────
* · **当前在手 / 当前超期** —— 此刻的状态,与窗口无关; * · **当前在手 / 当前超期** —— 此刻的状态,与窗口无关;
* · **完成 / 退回率 / 没动** —— 都在选中的那个窗口里,换窗口一起变。 * · **完成 / 预约成功率 / 没动** —— 都在选中的那个窗口里,换窗口一起变。
* ⚠️ 所以这两列都写死「**当前**」。只写「在手」时实测会被读成"这 7 天分了 62 条", * ⚠️ 所以这两列都写死「**当前**」。只写「在手」时实测会被读成"这 7 天分了 62 条",
* 而「超期」如果不带「当前」,摆在窗口切换器正下方就会被当成"这 7 天超了几条"。 * 而「超期」如果不带「当前」,摆在窗口切换器正下方就会被当成"这 7 天超了几条"。
* *
...@@ -28,9 +28,17 @@ const WINDOWS = [ ...@@ -28,9 +28,17 @@ const WINDOWS = [
* ⚠️ 而窗口那一支数的是**已经回池、没有客服挂着**的单,那不是谁的超期。 * ⚠️ 而窗口那一支数的是**已经回池、没有客服挂着**的单,那不是谁的超期。
* 实测:界面上「超期 41 / 193」100% 来自它,真正在手且过时限的是 0 条。 * 实测:界面上「超期 41 / 193」100% 来自它,真正在手且过时限的是 0 条。
* *
* ⚠️ 退回率那行小字是**服务端拼好的**(`rateNote`),⛔ 前端别自己算: * 🔴 「**预约成功率**」2026-08-22 顶掉了原来的「退回率」(产品定):
* 它必须带两个分母(「退回 3 / 已处置 21 = 14.3%,另有 2 条没动」)—— * · 分子 —— 真的**约上**的(`success_appointed`,患者定下了来的日子),
* 只给百分比会把"没动"藏起来,而那个数本身就是信号。 * ⛔ **不含「约定下次回访」**:那只是客服答应了"那天再联系您",患者什么都没答应。
* 算进来的话,一个只会往后拖的人能拖出一条漂亮的成功率。
* · 分母 —— 「完成」(他**真打过**的),⛔ 不是"已处置":
* 退回的单他压根没打,摆进分母等于因为他退单而扣他的成功率。
* ⛔ 退回和没动**没有消失**,降级到那行小字里 —— 两者仍是主管调分配的输入(T7),
* 而"没人动"和"动了但退回"是完全不同的信号。
*
* ⚠️ 那行小字是**服务端拼好的**(`rateNote`),⛔ 前端别自己算:
* 界面、助手、导出三处口径必须一致。
*/ */
export function TeamStatus({ clinicId }: { clinicId: string | null }) { export function TeamStatus({ clinicId }: { clinicId: string | null }) {
const [days, setDays] = useState<number>(7); const [days, setDays] = useState<number>(7);
...@@ -38,7 +46,7 @@ export function TeamStatus({ clinicId }: { clinicId: string | null }) { ...@@ -38,7 +46,7 @@ export function TeamStatus({ clinicId }: { clinicId: string | null }) {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
/// ⭐ 助手那边确认/撤销后跟着重拉 —— 每个人的在手/分到/退回率分母都变了(见 assignment-sync-store) /// ⭐ 助手那边确认/撤销后跟着重拉 —— 每个人的在手/分到/成功率分母都变了(见 assignment-sync-store)
const syncSeq = useAssignmentSyncStore((s) => s.seq); const syncSeq = useAssignmentSyncStore((s) => s.seq);
useEffect(() => { useEffect(() => {
if (!clinicId) return; if (!clinicId) return;
...@@ -138,8 +146,9 @@ export function TeamStatus({ clinicId }: { clinicId: string | null }) { ...@@ -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 className="whitespace-nowrap px-2.5 py-1.5 text-right text-[11px] font-medium text-slate-500">
完成 完成
</th> </th>
{/* ⚠️ 「预约」两个字不能省 —— 「成功率」会被读成"打通了就算成功" */}
<th className="whitespace-nowrap px-3.5 py-1.5 text-right text-[11px] font-medium text-slate-500"> <th className="whitespace-nowrap px-3.5 py-1.5 text-right text-[11px] font-medium text-slate-500">
退回 预约成功
</th> </th>
</tr> </tr>
</thead> </thead>
...@@ -189,9 +198,10 @@ export function TeamStatus({ clinicId }: { clinicId: string | null }) { ...@@ -189,9 +198,10 @@ export function TeamStatus({ clinicId }: { clinicId: string | null }) {
</td> </td>
<td className="nums px-2.5 py-2 text-right text-emerald-700">{a.done}</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"> <td className="px-3.5 py-2 text-right">
{/* ⚠️ 百分比与两个分母**分两行**:一行塞不下,而分母不能省 */} {/* ⚠️ 百分比与分母**分两行**:一行塞不下,而分母不能省
⚠️ 分母是 `done`(他真打过的)不是 `handled` —— 见文件头那段 */}
<div className="nums text-slate-900"> <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>
<div className="nums text-[10.5px] leading-snug text-slate-400"> <div className="nums text-[10.5px] leading-snug text-slate-400">
{a.rateNote} {a.rateNote}
......
...@@ -329,14 +329,28 @@ export const AssignmentBriefSchema = z.object({ ...@@ -329,14 +329,28 @@ export const AssignmentBriefSchema = z.object({
*/ */
handled: z.number().int(), handled: z.number().int(),
/** /**
* 「约上」—— 本批里最近一次通话结果落在 `close` 组的人数(转化新预约 + **约定下次回访**) * 「约上」—— 本批里最近一次通话结果是 **`success_appointed`(转化新预约)** 的人数
* *
* ⚠️ 与 detail 的 `outcomes.success` **同一定义**,⛔ 别只数"转化新预约": * 🔴 **2026-08-22 收窄口径**:此前它是整个 `close` 组的合计,
* 两处都写着"约上",小一号主管就只能猜哪个对。 * 也就是「转化新预约 + 约定下次回访」两项加在一起。
* ⚠️ 那两件事差着一整个台阶:前者患者**进了预约表**、有个日子会来;
* 后者只是客服答应了"那天再联系您",患者什么都没答应。
* 混成一个数,主管看到「约上 6」会当成 6 个人要来了。
* ⇒ 「约上」= 只数真的约上的;约了下次回访的走 `bookedNext`。
*
* ⚠️ 与 detail 的 `outcomes.appointed` **同一定义**,⛔ 别在两处各立标准。
* ⚠️ 上线初期这个数会长期是 0 或个位数 —— ⛔ **不许拿它算转化率**, * ⚠️ 上线初期这个数会长期是 0 或个位数 —— ⛔ **不许拿它算转化率**,
* 样本不足时要明说(见 outcomes.note 的写法),画出 0.x% 会让主管以为功能没用。 * 样本不足时要明说(见 outcomes.note 的写法),画出 0.x% 会让主管以为功能没用。
*/ */
booked: z.number().int(), booked: z.number().int(),
/**
* 「约下次」—— 最近一次通话结果是 `scheduled_next`(约定下次回访)的人数。
*
* ⚠️ 它**不是成效**,是**还没结束**:单子仍挂在客服手上、snooze 到回访日。
* ⛔ 别把它和 `booked` 加起来对外叫「约上」—— 那正是收窄口径要拆开的东西。
* ⚠️ `booked + bookedNext === outcomes.success`(close 组的合计),回归里锁着。
*/
bookedNext: z.number().int(),
}); });
export type AssignmentBrief = z.infer<typeof AssignmentBriefSchema>; export type AssignmentBrief = z.infer<typeof AssignmentBriefSchema>;
...@@ -412,8 +426,27 @@ export const AssignmentDetailResponseSchema = AssignmentBriefSchema.extend({ ...@@ -412,8 +426,27 @@ export const AssignmentDetailResponseSchema = AssignmentBriefSchema.extend({
* 把两者放进同一个减法会重复扣减,noOutcome 被压成 0(踩过)。 * 把两者放进同一个减法会重复扣减,noOutcome 被压成 0(踩过)。
*/ */
outcomes: z.object({ 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: 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):明确拒绝 / 已在外院 / 再考虑 /// 不成功(give_up):明确拒绝 / 已在外院 / 再考虑
failed: z.number().int(), failed: z.number().int(),
/// 打了但没进展(keep):未接通 / 秒挂 /// 打了但没进展(keep):未接通 / 秒挂
...@@ -1219,8 +1252,22 @@ export const AgentWorkloadRowSchema = z.object({ ...@@ -1219,8 +1252,22 @@ export const AgentWorkloadRowSchema = z.object({
/// 窗口内分到但一条都没动的 /// 窗口内分到但一条都没动的
idle: z.number().int(), 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(), 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