Commit 6d74e284 by luoqi

refine(主管工作台): 约上→预约上、完成→近N天已处置、成功率只出一个值

主管走查第二轮:

① 「还没收尾」说明补「(含已经超期的)」
   到期不再自动回池(回收器 2026-08-19 删了),过了时限的单就压在客服手上,
   批次靠 inHand>0 留在这一组。不写明的话主管会以为过了时限的都沉到历史里了,
   而那恰恰是最该动手的一批。判据一个字没动。

② 「约上」→「预约上」
   「约上」仍有歧义:约好了既可以是约了预约、也可以是约了下次再联系,
   而这两列分的正是这件事。

③ 「完成」→「近 N 天已处置」
   主管问「完成和已处置是同一个意思吗」—— 是。同一个判据(真的落了通话结果),
   差的只是范围(本批 vs 近 N 天),而同一屏上叫了两个词。
   而且「完成」听起来像"做成了",是这套口径一直在躲的读法。
   ⇒ 统一叫「已处置」,并带上窗口前缀(预约成功率同)——
   不带的话切了 7天/30天,数字变了却看不出为什么。

④ 预约成功率只出一个百分比
   下面那行 rateNote(退回 N · 没动 M)删掉。
    released / idle 没有消失,仍在响应里 —— 只是不该挤在这一列下面。

顺带拆掉一颗雷:AgentWorkloadRow.handled(= done + released)删了。
它和界面上的「已处置」(只算写了结果的)是两个意思,同一个词两个含义 ——
主管问的正是这个。服务端留局部量 touched 算「没动」,语义不变。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent 0d7c6a53
......@@ -30,7 +30,7 @@ export const TRACKING_GUIDE = [
'分母小于 50 时直接说「样本量不足」,⛔ 不要输出百分比、⛔ 不要画图。',
'outcomes(通话成效)与 releaseReasons(退回原因)是两件不同的事,⛔ 绝不能混说:releaseReasons =「这单不该我做」,客服没打就还回去了,是**分配**问题;outcomes =「打了,结果这样」,客服做了事,是**召回效果**问题。说反了主管会去改错的东西。',
'outcomes.noOutcome(一次结果都没有)必须单独报出来,⛔ 不许算进「不成功」——那不是效果差,是根本没做/没记。',
'outcomes.appointed(约上)= 患者定下了来的日子;outcomes.scheduledNext(约下次)= 客服答应了那天再联系一次,患者什么都没答应、单子还在他手上。两个数差着一整个台阶,⛔ 别加起来说成「约上了这么多」。',
'outcomes.appointed(预约上)= 患者定下了来的日子;outcomes.scheduledNext(约下次)= 客服答应了那天再联系一次,患者什么都没答应、单子还在他手上。两个数差着一整个台阶,⛔ 别加起来说成「预约上了这么多」。',
'outcomes.success 是这两项的合计,⛔ 别单独报它 —— 报了就得解释,而要解释的数本身就该拆开说。',
'outcomes.records 是逐条明细 + 客服手写的电话纪要(notes)。主管问「哪个患者/为什么/客服怎么说的」,答案只在这里,⛔ 别只回聚合数。',
'notes 为 null =「没留纪要」(不是没打)。结果填了、纪要空着本身是信息:说明只点了个选项。',
......
......@@ -781,13 +781,13 @@ export class PlanAssignmentService {
}
/**
* 「约上」与「约下次」—— **批次列表**那两列(2026-08-07 主管工作台)。
* 「约上」与「约下次」—— **批次列表**那两列(2026-08-07 主管工作台)。
*
* 🔴 **2026-08-22 拆成两个数**。此前这里数的是整个 `close` 组
* (转化新预约 + 约定下次回访),界面上叫「约上」。
* ⚠️ 那两件事差着一整个台阶:前者患者**进了预约表**、有个日子会来;
* 后者只是客服答应了"那天再联系您",患者什么都没答应,单子还挂在他手上。
* 合成一个数,主管看到「约上 6」会当成 6 个人要来了。
* 合成一个数,主管看到「约上 6」会当成 6 个人要来了。
* ⇒ `appointed` = `success_appointed` / `scheduledNext` = `scheduled_next`。
* ⚠️ 与 detail 的 `outcomes.appointed` / `outcomes.scheduledNext` **同一定义**,
* ⛔ 别在这里另立标准 —— 两处对不上主管就只能猜哪个对。
......@@ -1177,7 +1177,7 @@ export class PlanAssignmentService {
GROUP BY e.assignee_user_id`);
/**
* ⭐ 窗口内**真的约上**的条数 —— 「预约成功率」的分子(2026-08-22)。
* ⭐ 窗口内**真的约上**的条数 —— 「预约成功率」的分子(2026-08-22)。
*
* ⚠️ 只数 `success_appointed`,⛔ **不含 `scheduled_next`**:
* 约定下次回访只是客服答应了"那天再联系您",患者什么都没答应。
......@@ -1219,10 +1219,15 @@ export class PlanAssignmentService {
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
const idle = Math.max(0, (asgM.get(a.userId) ?? 0) - handled);
/**
* 「没动」的减数 = 写了结果的 + 退回的 —— 两者都是"这单他动过了"。
* ⚠️ 这是个**局部量**,⛔ 别再把它当字段发出去(2026-08-22 删了):
* 它曾经叫 `handled`,而界面上的「已处置」指的是 `done`(只算写了结果的)——
* 同一个词两个意思,主管当场就问出来了。
*/
const touched = doneN + relN;
// 没动 = 窗口内分到的 − 动过的。⚠️ 可能为负(分配在窗口外、处置在窗口内),兜 0
const idle = Math.max(0, (asgM.get(a.userId) ?? 0) - touched);
return {
userId: a.userId,
name: a.name,
......@@ -1232,32 +1237,19 @@ export class PlanAssignmentService {
overdueOldestDays: oldestM.get(a.userId) ?? null,
done: doneN,
released: relN,
handled,
idle,
booked: bookedN,
/**
* ⭐ 成品句子,⛔ 前端别自己拼 —— 写在这里保证界面、助手、导出三处口径一致
* (T14 的"工具返回值"那一半)。
* ⭐ 「预约成功率」的分子 —— 界面上出的是 `booked / done` **一个百分比**。
*
* 🔴 2026-08-22 由「退回率」改成「**预约成功率**」(产品定):
* · 分子 —— `booked`(真的约上),⛔ 不含约定下次回访;
* · 分母 —— `done`(他**真打过**的),⛔ 不用 handled(=done+released):
* · 分子 —— `booked`(真的约上),⛔ 不含约定下次回访;
* · 分母 —— `done`(他**真打过**的),⛔ 不是 done+released:
* 退回的单他压根没打,摆进分母等于因为他退单而扣他的成功率。
* ⛔ 退回和没动**没有消失**,降级成后半段那句 —— 两者都是主管调分配的输入(T7),
* 而"没人动"和"动了但退回"是完全不同的信号,一个百分比藏得住两个。
* ⚠️ 原来这一列下面还挂一句成品句子(`rateNote`,带退回/没动两个旁支),
* 2026-08-22 产品定"值就一个就行"删掉 ——
* ⛔ `released` / `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} 天没有分到新的`,
booked: bookedN,
};
});
......
......@@ -108,28 +108,43 @@ describe('③ 主管工作台:退回率 → 预约成功率', () => {
});
/**
* 🔴 分母是 `done`(他真打过的)⛔ 不是 `handled`(=done+released):
* 🔴 分母是 `done`(他真打过的)⛔ 不是 done+released:
* 退回的单他压根没打,摆进分母等于因为他退单而扣他的成功率。
*/
test('⭐⭐ 分母是「完成」不是「已处置」', () => {
expect(SVC).toContain('`约上 ${bookedN} / 完成 ${doneN} = ');
expect(TEAM).toContain('a.done ? `${((a.booked / a.done) * 100).toFixed(1)}%` : \'\'');
test('⭐⭐ 分母是「已处置」(他真打过的),⛔ 不含退回的', () => {
expect(TEAM).toContain("a.done ? `${((a.booked / a.done) * 100).toFixed(1)}%` : '—'");
// ⛔ 不许拿 released 参与计算
expect(TEAM).not.toContain('a.released');
});
/**
* ⛔ 退回和没动**没有消失** —— 两者仍是主管调分配的输入(T7),
* 而"没人动"和"动了但退回"是完全不同的信号,一个百分比藏得住两个
* ⚠️ 2026-08-22 产品定"值就一个就行":成功率下面那行成品句子(`rateNote`)删掉。
* ⛔ 但 `released` / `idle` **没有消失** —— 它们仍在响应里,只是不挤在这一列下面
*/
test('⭐ 退回与没动降级进小字,⛔ 没被删掉', () => {
expect(SVC).toContain('`退回 ${relN}`');
expect(SVC).toContain('`没动 ${idle}`');
// 一条都没打但退了单的人,不能显示成「没有分到新的」
expect(SVC).toContain('`这 ${days} 天一条都没打,退回 ${relN} 条`');
test('⭐ 只出一个百分比,⛔ 下面不再挂小字', () => {
// ⚠️ 判据是**渲染**没了,不是"文件里搜不到这个词"(注释里还记着它的沿革)
expect(TEAM).not.toContain('{a.rateNote}');
expect(SVC).not.toMatch(/^\s+rateNote:/m);
// 数据还在
expect(SVC).toContain('released: relN');
expect(SVC).toContain('idle,');
});
test('⭐ 列头写「预约成功率」——「成功率」会被读成"打通了就算成功"', () => {
expect(TEAM).toContain('预约成功率');
expect(TEAM).not.toContain('>\n 退回率\n');
/**
* 🔴 `handled`(=done+released)这个字段一起删了:它和界面上的「已处置」
* (只算写了结果的)**是两个意思**,而主管当场就问出来了。
*/
test('⭐⭐ 同一个词只能有一个意思 —— handled 字段已删,只留局部量', () => {
expect(SVC).toContain('const touched = doneN + relN;');
expect(SVC).not.toMatch(/^\s+handled,$/m);
});
test('⭐ 列头写「近 N 天预约成功率」,跟着切换器走', () => {
expect(TEAM).toContain('近 {days} 天预约成功率');
expect(TEAM).toContain('近 {days} 天已处置');
// ⛔ 「完成」听起来像"做成了",且与批次表那一列同一判据却叫了两个词
expect(TEAM).not.toMatch(/>\s*完成\s*</);
expect(TEAM).not.toMatch(/>\s*退回率\s*</);
});
});
......@@ -148,14 +163,27 @@ describe('④ 界面:分组改名 + 两列 + 五个桶', () => {
expect(LIST).toContain('时限没到、或还有单压在客服手上');
});
/**
* 🔴 到期**不再自动回池**(2026-08-19 起回收器已删)——
* 过了时限的单就那么压在客服手上,批次靠 `inHand > 0` 留在这一组里。
* ⛔ 不写明的话主管会以为过了时限的批次都沉到历史里了,而那恰恰是最该动手的一批。
*/
test('⭐⭐ 说明里要写明「含已经超期的」', () => {
expect(LIST).toContain('时限没到、或还有单压在客服手上(含已经超期的)');
});
test('判据本身没动 —— 改的是名字不是口径', () => {
expect(LIST).toContain('return new Date(b.expiresAt).getTime() > now || b.inHand > 0;');
});
test('⭐ 列表拆成「约上」「约下次」两列', () => {
expect(LIST).toContain("'超期', '约上', '约下次'");
/**
* ⚠️ 叫「预约上」不叫「约上」(2026-08-22 第二轮):「约上」仍有歧义 ——
* "约好了"既可以是约了预约,也可以是约了下次再联系,而这两列分的正是这件事。
*/
test('⭐ 列表拆成「预约上」「约下次」两列', () => {
expect(LIST).toContain("'超期', '预约上', '约下次'");
expect(LIST).toContain('{b.bookedNext}');
// ⚠️ 中性灰,⛔ 别跟「约上」同一支绿 —— 同色会被加起来读成"成效"
// ⚠️ 中性灰,⛔ 别跟「约上」同一支绿 —— 同色会被加起来读成"成效"
expect(LIST).toContain("bookedNext: 'text-slate-500'");
});
......@@ -165,7 +193,7 @@ describe('④ 界面:分组改名 + 两列 + 五个桶', () => {
*/
test('⭐⭐ 通话成效五个桶,⛔ 不再摆一个要靠小字解释的「成功」', () => {
expect(LIST).toContain('grid-cols-5');
expect(LIST).toContain("{ k: '约上', n: d.outcomes.appointed");
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");
// 旧的那句解释也该跟着走 —— 拆开之后它是废话
......@@ -173,7 +201,7 @@ describe('④ 界面:分组改名 + 两列 + 五个桶', () => {
});
test('小字只解释两件不显然的事,⛔ 不重复标签本身', () => {
expect(LIST).toContain('「约上」是患者定下了来的日子');
expect(LIST).toContain('「约上」是患者定下了来的日子');
expect(LIST).toContain('单子还在客服手上');
});
......@@ -187,9 +215,9 @@ describe('⑤ 给模型的解读规范跟得上', () => {
const GUIDES = readFileSync(join(__dirname, '../src/modules/mcp/guides.ts'), 'utf8');
test('⭐ 说清 appointed 与 scheduledNext 差在哪,⛔ 别让它把两个数加起来', () => {
expect(GUIDES).toContain('outcomes.appointed(约上)');
expect(GUIDES).toContain('outcomes.appointed(约上)');
expect(GUIDES).toContain('outcomes.scheduledNext(约下次)');
expect(GUIDES).toContain('别加起来说成「约上了这么多」');
expect(GUIDES).toContain('别加起来说成「约上了这么多」');
});
test('⭐ 旧那句「outcomes.success 含约定下次回访」已经不成立,不许留着', () => {
......
......@@ -18,7 +18,7 @@ const PAGE = 20;
* ⛔ 别在那儿手写数字:2026-08-20 加「回收」那一列时就是这么栽的 ——
* 分组行少跨一格,最后一列在「还没收尾」「8 月」这两行上留出一块白,且不报任何错。
*/
const NUM_COLS = ['条数', '已处置', '没动', '客服退回', '回收', '超期', '约上', '约下次'] as const;
const NUM_COLS = ['条数', '已处置', '没动', '客服退回', '回收', '超期', '约上', '约下次'] as const;
/**
* 「没动」标琥珀的阈值(占本批/本人分到量的比例)。
......@@ -27,7 +27,12 @@ const NUM_COLS = ['条数', '已处置', '没动', '客服退回', '回收', '
const IDLE_HEAVY = 0.2;
/**
* 「还没收尾」判据 —— **时限还没到,或还有单压在客服手上**。
* 「还没收尾」判据 —— **时限还没到,或还有单压在客服手上(含已经超期的)**。
*
* 🔴 「含已经超期的」不是补充说明,是这一组**最主要的用处**:
* 到期**不再自动回池**(2026-08-19 起,回收器已删)—— 过了时限的单就那么压在
* 客服手上。所以一批可能时限早过了、却因为 `inHand > 0` 仍留在这一组里,
* 而那恰恰是主管最该动手的一批。⛔ 不写明的话,他会以为过了时限的批次都沉到历史里了。
*
* 🔴 **2026-08-22 由「还在跑的」改名**(产品实测有歧义):
* 主管把「还在跑的」读成"**分配还没完成**"——像是有个后台任务在跑、结果还没出来。
......@@ -180,7 +185,7 @@ export function BatchTracking({ clinicId }: { clinicId: string | null }) {
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));
......@@ -311,7 +316,7 @@ type BatchGroupData = { key: string; note: string; past: boolean; rows: Assignme
* 在跑的那几批带一条主色竖轨、白底、数字保持原来的语义色(绿=已处置、红=退回、
* 琥珀=没动/到期)—— 它们是主管**还来得及动手**的;
* 历史批次退成灰底 + 同色相的低饱和,语义色仍在(还看得出哪列是什么),但整块往后退。
* ⛔ 别把历史行做成纯灰(把语义色一起抹掉):那样「退回 12」和「约上 12」看起来一样,
* ⛔ 别把历史行做成纯灰(把语义色一起抹掉):那样「退回 12」和「约上 12」看起来一样,
* 而回顾恰恰就是在比这几列。
* ⛔ 也别只靠那条竖轨 —— 一条 2px 的线滚起来看不见,底色才是扫视时抓得到的。
*
......@@ -333,7 +338,7 @@ const TONE = {
expired: 'text-amber-700',
booked: 'text-emerald-700',
/**
* ⚠️ 「约下次」是**中性灰**,⛔ 别跟「约上」同一支绿 ——
* ⚠️ 「约下次」是**中性灰**,⛔ 别跟「约上」同一支绿 ——
* 两者差着一整个台阶(一个进了预约表、一个只是答应再联系),
* 同色会让主管把两列加起来读成"成效"。
*/
......@@ -378,7 +383,7 @@ function BatchGroup({
<tr>
{/*
🔴 **colSpan 必须从列表推**(2026-08-20 走查栽过):加了「回收」那一列之后,
这里还写着手抄的 7,分组表头那一行少跨一格 —— 最后一列(约)在
这里还写着手抄的 7,分组表头那一行少跨一格 —— 最后一列(约下次)在
「还没收尾」「8 月」这两行上留出一块白,而且不报任何错。
⚠️ `+1` 是最左边那一列「批次」,它不在 NUM_COLS 里。
*/}
......@@ -424,7 +429,7 @@ 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>
......@@ -469,7 +474,7 @@ function BatchDrawer({ id, onClose }: { id: string; onClose: () => void }) {
<>
{/*
🔴 **z-20 不能省**(2026-08-07 实测):表格的 `thead` 是 `sticky z-10`,
抽屉不给层级的话,那一排列头(已处置 / 没动 / 客服退回 / 回收 / 超期 / 约上)
抽屉不给层级的话,那一排列头(已处置 / 没动 / 客服退回 / 回收 / 超期 / 约上)
会**浮在抽屉标题上面**,跟抽屉自己那行汇总叠在一起 —— 看着像表头样式坏了,
实际是层级问题。⛔ 别去改 thead 的 sticky(那是列表滚动要用的)。
⚠️ `top-[3.25rem]` 对齐面板自己的标题行高度:抽屉盖住表格但**不盖标题**,
......@@ -488,7 +493,7 @@ function BatchDrawer({ id, onClose }: { id: string; onClose: () => void }) {
{d && (
<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.recalled > 0 && <> · 回收 {d.recalled}</>} · 超期 {d.expired} · 约上 {d.booked}
{d.bookedNext > 0 && <> · 约下次 {d.bookedNext}</>}
</div>
)}
......@@ -598,14 +603,14 @@ function BatchDrawer({ id, onClose }: { id: string; onClose: () => void }) {
产品实测:主管看到「成功 0 / 不成功 0 / 没进展 0 / 没有结果 6」,
第一句问的就是"这个成功是什么意思"。
⛔ 要靠小字解释的数,本身就该拆开摆。
⚠️ 五个桶**穷尽**(约上 + 约下次 + 不成功 + 没进展 + 没有结果 === 条数,
⚠️ 五个桶**穷尽**(约上 + 约下次 + 不成功 + 没进展 + 没有结果 === 条数,
回归里锁着)—— 少一个就对不上数(T14)。
🔴 「没有结果」不是"效果差"是**根本没做/没记**,所以它跟前四个分开、
并且是唯一上琥珀的:主管先要看的就是它。 */}
<div className="mt-1.5 grid grid-cols-5 gap-1.5 text-center">
{(
[
{ k: '约上', n: d.outcomes.appointed, 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' },
......@@ -625,7 +630,7 @@ function BatchDrawer({ id, onClose }: { id: string; onClose: () => void }) {
</div>
{/* ⚠️ 拆开之后这行小字只解释**两件不显然的事**,⛔ 别再重复标签本身 */}
<div className="mt-1.5 text-[10.5px] leading-relaxed text-slate-400">
「约上」是患者定下了来的日子;「约下次」只是约好再联系一次,单子还在客服手上。
约上」是患者定下了来的日子;「约下次」只是约好再联系一次,单子还在客服手上。
「没有结果」是这批里一次都没打/没记的。
</div>
......
......@@ -17,9 +17,11 @@ const WINDOWS = [
*
* ── 一张表里混着两种时间性,这是最容易读错的地方 ────────────────
* · **当前在手 / 当前超期** —— 此刻的状态,与窗口无关;
* · **完成 / 预约成功率 / 没动** —— 都在选中的那个窗口里,换窗口一起变。
* ⚠️ 所以这两列都写死「**当前**」。只写「在手」时实测会被读成"这 7 天分了 62 条",
* 而「超期」如果不带「当前」,摆在窗口切换器正下方就会被当成"这 7 天超了几条"。
* · **已处置 / 预约成功率** —— 都在选中的那个窗口里,换窗口一起变。
* ⚠️ 所以前两列写死「**当前**」、后两列写死「**近 N 天**」(2026-08-22 产品要求)。
* 只写「在手」时实测会被读成"这 7 天分了 62 条";而「超期」如果不带「当前」,
* 摆在窗口切换器正下方就会被当成"这 7 天超了几条"。
* 反过来「已处置」「预约成功率」不带「近 N 天」,切了窗口数字变了却看不出为什么。
*
* 🔴 「超期」2026-08-19 **由窗口口径改回此刻口径**(到期不再回池,见 plan.module 的墓碑):
* · 2026-08-07 当初改成窗口口径,是因为回收器每 10 分钟把过期的单收走,
......@@ -37,8 +39,13 @@ const WINDOWS = [
* ⛔ 退回和没动**没有消失**,降级到那行小字里 —— 两者仍是主管调分配的输入(T7),
* 而"没人动"和"动了但退回"是完全不同的信号。
*
* ⚠️ 那行小字是**服务端拼好的**(`rateNote`),⛔ 前端别自己算:
* 界面、助手、导出三处口径必须一致。
* ⚠️ 成功率下面**不挂小字**(2026-08-22 产品定"值就一个就行"):
* 原来那行是服务端拼的 `rateNote`,带退回/没动两个旁支。
* ⛔ `released` / `idle` 没有消失,仍在响应里 —— 它们是另一个问题,不该挤在这一列下面。
*
* 🔴 「已处置」这一列 2026-08-22 **由「完成」改名**:主管当场问「完成和已处置
* 是同一个意思吗」—— 是同一个判据(真的落了通话结果),差的只是范围
* (本批 vs 近 N 天),而同一屏上叫了两个词。而且「完成」听起来像"做成了"。
*/
export function TeamStatus({ clinicId }: { clinicId: string | null }) {
const [days, setDays] = useState<number>(7);
......@@ -143,12 +150,15 @@ 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>
{/* ⚠️ 「近 N 天」跟着切换器走 —— 不带的话切了窗口数字变了却看不出为什么。
⚠️ 「已处置」与批次表那一列同一个词:同一个判据(真的落了通话结果),
⛔ 别再叫「完成」——「完成」听起来像"做成了" */}
<th className="whitespace-nowrap px-2.5 py-1.5 text-right text-[11px] font-medium text-slate-500">
完成
{days} 天已处置
</th>
{/* ⚠️ 「预约」两个字不能省 —— 「成功率」会被读成"打通了就算成功" */}
<th className="whitespace-nowrap px-3.5 py-1.5 text-right text-[11px] font-medium text-slate-500">
预约成功率
{days}预约成功率
</th>
</tr>
</thead>
......@@ -197,15 +207,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">
{/* ⚠️ 分母是 `done`(他真打过的),⛔ 不含退回的 —— 见文件头那段。
⚠️ 一个百分比,⛔ 下面不挂小字(2026-08-22 产品定) */}
<td className="nums px-3.5 py-2 text-right text-slate-900">
{a.done ? `${((a.booked / a.done) * 100).toFixed(1)}%` : '—'}
</div>
<div className="nums text-[10.5px] leading-snug text-slate-400">
{a.rateNote}
</div>
</td>
</tr>
);
......
......@@ -329,14 +329,14 @@ export const AssignmentBriefSchema = z.object({
*/
handled: z.number().int(),
/**
* 「约上」—— 本批里最近一次通话结果是 **`success_appointed`(转化新预约)** 的人数。
* 「约上」—— 本批里最近一次通话结果是 **`success_appointed`(转化新预约)** 的人数。
*
* 🔴 **2026-08-22 收窄口径**:此前它是整个 `close` 组的合计,
* 也就是「转化新预约 + 约定下次回访」两项加在一起。
* ⚠️ 那两件事差着一整个台阶:前者患者**进了预约表**、有个日子会来;
* 后者只是客服答应了"那天再联系您",患者什么都没答应。
* 混成一个数,主管看到「约上 6」会当成 6 个人要来了。
* ⇒ 「约上」= 只数真的约上的;约了下次回访的走 `bookedNext`。
* 混成一个数,主管看到「约上 6」会当成 6 个人要来了。
* ⇒ 「预约上」= 只数真的预约上的;约了下次回访的走 `bookedNext`。
*
* ⚠️ 与 detail 的 `outcomes.appointed` **同一定义**,⛔ 别在两处各立标准。
* ⚠️ 上线初期这个数会长期是 0 或个位数 —— ⛔ **不许拿它算转化率**,
......@@ -347,7 +347,7 @@ export const AssignmentBriefSchema = z.object({
* 「约下次」—— 最近一次通话结果是 `scheduled_next`(约定下次回访)的人数。
*
* ⚠️ 它**不是成效**,是**还没结束**:单子仍挂在客服手上、snooze 到回访日。
* ⛔ 别把它和 `booked` 加起来对外叫「约上」—— 那正是收窄口径要拆开的东西。
* ⛔ 别把它和 `booked` 加起来对外叫「约上」—— 那正是收窄口径要拆开的东西。
* ⚠️ `booked + bookedNext === outcomes.success`(close 组的合计),回归里锁着。
*/
bookedNext: z.number().int(),
......@@ -436,7 +436,7 @@ export const AssignmentDetailResponseSchema = AssignmentBriefSchema.extend({
*/
success: z.number().int(),
/**
* ⭐ 「约上」—— `success_appointed`(转化新预约):患者**进了预约表**,有个日子会来。
* ⭐ 「约上」—— `success_appointed`(转化新预约):患者**进了预约表**,有个日子会来。
* ⚠️ 与列表那一列 `booked` 同一个数,⛔ 别让它们分叉。
*/
appointed: z.number().int(),
......@@ -444,7 +444,7 @@ export const AssignmentDetailResponseSchema = AssignmentBriefSchema.extend({
* ⭐ 「约下次」—— `scheduled_next`(约定下次回访):客服答应了"那天再联系您",
* 患者什么都没答应,单子仍挂在他手上 snooze 到回访日。
* ⚠️ 它归 `close` 组是**统计口径**上的事(是有效推进,不该跟"未接通"混在一起),
* ⛔ 但它不是"约上了" —— 见 EXECUTION_OUTCOME_META 里 scheduled_next 那段。
* ⛔ 但它不是"约上了" —— 见 EXECUTION_OUTCOME_META 里 scheduled_next 那段。
*/
scheduledNext: z.number().int(),
/// 不成功(give_up):明确拒绝 / 已在外院 / 再考虑
......@@ -1243,16 +1243,28 @@ export const AgentWorkloadRowSchema = z.object({
* 把它们的"压了多久"算进来是在说一件早就结束的事。
*/
overdueOldestDays: z.number().int().nullable(),
/// 窗口内写过通话结果的条数(按单去重)
/**
* 窗口内**真的落了通话结果**的条数(按单去重)—— 界面上的「近 N 天已处置」。
*
* ⚠️ 与批次那边的 `AssignmentBriefSchema.handled`(本批已处置)、
* `AssignmentAgentStatSchema.done`(批内按人)是**同一个判据** ——
* 都在问"有没有人真的做了事"。差的只是**范围**:本批 vs 近 N 天。
* 🔴 所以界面上必须用**同一个词**(2026-08-22 主管当场问「完成和已处置是同一个意思吗」)。
* ⛔ 原来这一列叫「完成」,而批次表那一列叫「已处置」 ——
* 同一屏上两个词指同一件事,主管只能猜。而且「完成」听起来像"做成了",
* 这正是整套口径反复要避免的读法。
*/
done: z.number().int(),
/// 窗口内主动退回的条数(走账本,⛔ 不读 followup_plans.release_reason —— 那是当前值会被清)
released: z.number().int(),
/// 窗口内已处置 = done + released(两者都是"这单他动过了")
handled: z.number().int(),
/// 窗口内分到但一条都没动的
idle: z.number().int(),
/**
* ⭐ 窗口内**真的约上**的条数 —— 最近一次通话结果是 `success_appointed`(转化新预约)。
* ⭐ 窗口内**真的预约上**的条数 —— 最近一次通话结果是 `success_appointed`(转化新预约)。
*
* 🔴 「预约成功率」= `booked / done`,界面上只出这**一个百分比**(2026-08-22 产品定)。
* ⚠️ `released`(退回)与 `idle`(没动)仍在这一行上,只是不再挂在成功率下面 ——
* ⛔ 它们没有消失,主管要看时是另一个问题、另一个位置。
*
* 🔴 ⛔ **不含「约定下次回访」**(2026-08-22 产品定):那只是客服答应了
* "那天再联系您",患者什么都没答应。算进来的话,一个只会往后拖的人
......@@ -1260,17 +1272,6 @@ export const AgentWorkloadRowSchema = z.object({
* ⚠️ 归属按**那条执行的操作人**算:同一条单 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(),
});
export type AgentWorkloadRow = z.infer<typeof AgentWorkloadRowSchema>;
......
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