Commit 3b735314 by luoqi

fix(回访结果): 秒挂归到不成功类 + 30 天抑制期

秒挂原来算在「保持」里,而它明明是没接通的一种。改 group 之后
tone 和提示文案没跟着改 —— 边框还是 slate、提示还写着「等下次跟进」,
而实际已经压 30 天。教训:一个字段辐射到多处渲染时,每一处都要跟着查。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent 0f767c19
...@@ -3,11 +3,18 @@ import { EXECUTION_OUTCOME_META, EXECUTION_OUTCOME_GROUP_META } from '@pac/types ...@@ -3,11 +3,18 @@ import { EXECUTION_OUTCOME_META, EXECUTION_OUTCOME_GROUP_META } from '@pac/types
/** /**
* 执行结果的**归类**与**状态机**是两件事,这个 spec 锁的就是它们的分离。 * 执行结果的**归类**与**状态机**是两件事,这个 spec 锁的就是它们的分离。
* *
* 🔴 `scheduled_next`(约定下次回访)是唯一一个 `group` 与 `drivesStatus` **故意不一致**的项: * 🔴 有两项 `group` 与 `drivesStatus` **故意不一致**,看着像笔误,极易被后来的人"顺手改一致":
* group: 'close' —— 统计口径:约到下次是**有效推进**,不该跟"未接通/秒挂"一起算「保持」 *
* `scheduled_next`(约定下次回访)
* group: 'close' —— 统计口径:约到下次是**有效推进**,不该跟"未接通"一起算「保持」
* drivesStatus: 'keep' —— 状态机:工单必须留着并 snooze 到回访日 * drivesStatus: 'keep' —— 状态机:工单必须留着并 snooze 到回访日
* 看着像笔误,所以极可能被后来的人"顺手改一致"。而改成 'completed' 的后果是: * 改成 'completed' 的后果:客服刚答应患者"那天再联系您",这单却当场出池,
* 客服刚答应了患者"那天再联系您",这单却当场出池,**约好的回访再也不会发生**,且不报错。 * **约好的回访再也不会发生**,且不报错。
*
* `quick_hangup`(秒挂,2026-08-06 产品定)
* group: 'give_up' —— 统计口径:**接通了、被主动挂断**,是一次失败的触达
* drivesStatus: 'keep' —— 状态机:可能只是在开车/开会,⛔ 不能当永久拒绝结掉单
* 改成 'abandoned' 的后果:一个只是时机不对的患者被永久关掉。
*/ */
describe('执行结果分类 —— 归类口径 ≠ 状态机', () => { describe('执行结果分类 —— 归类口径 ≠ 状态机', () => {
test('⭐⭐ 约定下次回访:算「成功」但**不结案**', () => { test('⭐⭐ 约定下次回访:算「成功」但**不结案**', () => {
...@@ -22,12 +29,43 @@ describe('执行结果分类 —— 归类口径 ≠ 状态机', () => { ...@@ -22,12 +29,43 @@ describe('执行结果分类 —— 归类口径 ≠ 状态机', () => {
expect(m.drivesStatus).toBe('completed'); expect(m.drivesStatus).toBe('completed');
}); });
test('「保持」组里只剩真正没进展的(未接通 / 秒挂)', () => { test('⭐⭐ 秒挂:算「不成功」但**不结案**,且必须有冷静期', () => {
const m = EXECUTION_OUTCOME_META.quick_hangup;
// 接通了却被挂断 = 联系上了但被拒绝,⛔ 别跟"没联系上"混为一谈
expect(m.group).toBe('give_up');
expect(m.drivesStatus).toBe('keep'); // ⛔ 绝不能是 abandoned
// ⛔ 不能像未接通那样 null —— 人家刚挂断你,当天回拨是骚扰
expect(m.suppressDays).toBeGreaterThan(0);
// ⚠️ 比「再考虑」(7 天)**更长**:再考虑是患者答应了会想想,该按时回来问;
// 秒挂是连话都不想说,再快回去只会更烦。⛔ 别"顺手"改成比它短。
expect(m.suppressDays!).toBeGreaterThan(EXECUTION_OUTCOME_META.declined_recent.suppressDays!);
// 但仍轻于「明确拒绝」—— 秒挂没说过任何拒绝的话
expect(m.suppressDays!).toBeLessThan(EXECUTION_OUTCOME_META.refused.suppressDays!);
});
test('⭐ 同一组里颜色必须一致 —— 选中时的边框色取自 tone', () => {
// 实测走查:秒挂改判「不成功」后 tone 还留着灰(未接通那档),
// 主管在同一组里看到一个异色项,以为自己点错了。
const tones = new Set(
Object.entries(EXECUTION_OUTCOME_META)
.filter(([, m]) => m.group === 'give_up' && !m.hiddenInForm)
.map(([, m]) => m.tone),
);
expect([...tones]).toEqual(['rose']);
});
test('「保持」组里只剩**真正没联系上**的(未接通)', () => {
const keep = Object.entries(EXECUTION_OUTCOME_META) const keep = Object.entries(EXECUTION_OUTCOME_META)
.filter(([, m]) => m.group === 'keep' && !m.hiddenInForm) .filter(([, m]) => m.group === 'keep' && !m.hiddenInForm)
.map(([k]) => k) .map(([k]) => k)
.sort(); .sort();
expect(keep).toEqual(['no_answer', 'quick_hangup']); // ⛔ 秒挂不在这里:它是"联系上了但被拒绝",与"打不通"要改的东西完全相反
// (换时段 vs 换话术/换人群),混在一起两个信号互相稀释。
expect(keep).toEqual(['no_answer']);
});
test('未接通**不设**冷静期 —— 没联系上就该尽快再试', () => {
expect(EXECUTION_OUTCOME_META.no_answer.suppressDays).toBeNull();
}); });
test('⛔ 归到「成功」组的,要么结案、要么留单 —— 但**绝不能**是 abandoned', () => { test('⛔ 归到「成功」组的,要么结案、要么留单 —— 但**绝不能**是 abandoned', () => {
......
...@@ -268,15 +268,31 @@ describe('放弃原因细分抑制窗(ABANDON_REASON_META.suppressDays,多选取 ...@@ -268,15 +268,31 @@ describe('放弃原因细分抑制窗(ABANDON_REASON_META.suppressDays,多选取
}); });
describe('两种回访模式共用一套原因 —— PAC 侧不为「关闭」另立概念', () => { describe('两种回访模式共用一套原因 —— PAC 侧不为「关闭」另立概念', () => {
test('shownIn 分流:两个面板各取各的子集,other 两边都有', () => { test('shownIn 分流:两个面板各取各的子集,也有共用的', () => {
const form = abandonReasonsFor('form'); const form = abandonReasonsFor('form');
const close = abandonReasonsFor('close'); const close = abandonReasonsFor('close');
// 各自专有
expect(form).toContain('wrong_number'); expect(form).toContain('wrong_number');
expect(form).not.toContain('inaccurate');
expect(close).toContain('inaccurate');
expect(close).not.toContain('wrong_number'); expect(close).not.toContain('wrong_number');
expect(form).toContain('other'); expect(close).toContain('no_intent');
expect(close).toContain('other'); expect(form).not.toContain('no_intent');
// ⭐ 共用(2026-08-05):客服在 PAC 表单里也要能说「这条召回判断错了 / 正在治 / 已有人跟」
for (const k of ['inaccurate', 'in_treatment', 'already_followed']) {
expect(form).toContain(k);
expect(close).toContain(k);
}
});
test('⭐⭐ PAC 表单**不给「其他」** —— 它是统计黑洞,会变成最省事的默认选项', () => {
// 放弃原因分布是主管调下一批的输入,而"其他"什么都不告诉他。
// ⚠️ 宿主关闭弹窗仍保留(那边口径不由我们定)。
expect(abandonReasonsFor('form')).not.toContain('other');
expect(abandonReasonsFor('close')).toContain('other');
});
test('⭐ 新增两个原因都带抑制窗 —— 漏了会让放弃过的单第二天原样弹回来', () => {
expect(ABANDON_REASON_META.in_treatment.suppressDays).toBeGreaterThan(0);
expect(ABANDON_REASON_META.already_followed.suppressDays).toBeGreaterThan(0);
}); });
test('hidden 的历史值不进任何面板', () => { test('hidden 的历史值不进任何面板', () => {
......
...@@ -6,6 +6,7 @@ import { ...@@ -6,6 +6,7 @@ import {
EXECUTION_OUTCOME_GROUP_META, EXECUTION_OUTCOME_GROUP_META,
ABANDON_REASON_META, ABANDON_REASON_META,
abandonReasonsFor, abandonReasonsFor,
SUPPRESS_PERMANENT_DAYS,
type ExecutionOutcome, type ExecutionOutcome,
type ExecutionOutcomeGroup, type ExecutionOutcomeGroup,
} from '@pac/types'; } from '@pac/types';
...@@ -83,15 +84,34 @@ const CHANNELS = [ ...@@ -83,15 +84,34 @@ const CHANNELS = [
}, },
]; ];
/// drivesStatus → 用户口径中文提示 /**
/// completed — 终态(成功转化 / 客户决策放弃跟进) * 「点了这个会怎么样」的一句话 —— **必须同时看 drivesStatus 和 suppressDays**。
/// abandoned — 终态(系统层面无效或客服主动放弃) *
/// keep — 任务保留等下次跟进(熔断器到上限会自动 abandon) * 🔴 由来(2026-08-06 走查):原来只按 `drivesStatus` 查表,于是「秒挂」显示
const STATE_HINTS: Record<string, string> = { * 「本次任务保留,等下次跟进」—— 而它刚被配上 30 天冷静期,**下次跟进是一个月以后**。
completed: '本次任务将结案', * 客服看到"等下次跟进"会以为过两天还能再打,实际这单一个月不会再出现。
abandoned: '本次任务将关闭(放弃)', * ⛔ 别再退回只看 drivesStatus 的写法:抑制期是**用户能感知**的后果,不说就是骗人(T14)。
keep: '本次任务保留,等下次跟进', *
}; * ⚠️ `abandoned` 也不是铁板一块:带有限抑制期的(如「再考虑」7 天)其实会**自己回来**,
* 说成"将关闭"会让客服以为这人废了,于是白白再手动跟一遍。
*
* @param drives completed(结案)/ abandoned(关闭)/ keep(保留)
* @param days 抑制天数;null = 不固定(看约定回访日)
* @param scheduled 是否是「约定下次回访」—— 它的抑制点是主管填的那个日期,不是天数
*/
function stateHint(drives: string, days: number | null, scheduled: boolean): string {
if (drives === 'completed') return '本次任务将结案';
if (drives === 'abandoned') {
// 永久档(外院治疗 / 无效)用的是一个极大的天数,不是"多少天后回来"
return days != null && days < SUPPRESS_PERMANENT_DAYS
? `本次任务将关闭,${days} 天后若仍有需要会重新出现`
: '本次任务将关闭(不再召回)';
}
if (scheduled) return '本次任务保留,到您约定的日期再跟进';
return days != null
? `本次任务保留,但 ${days} 天内不再出现`
: '本次任务保留,等下次跟进';
}
// 放弃原因:取 ABANDON_REASON_META 的 form 子集(跟宿主关闭弹窗共用一套 key、各显示各的文案)。 // 放弃原因:取 ABANDON_REASON_META 的 form 子集(跟宿主关闭弹窗共用一套 key、各显示各的文案)。
// 原本这里是写死的中文数组 + 一张中译 enum 的映射表,已删 —— 映射表里「已转介他人→treated_elsewhere」 // 原本这里是写死的中文数组 + 一张中译 enum 的映射表,已删 —— 映射表里「已转介他人→treated_elsewhere」
...@@ -101,6 +121,9 @@ const ABANDON_REASONS = abandonReasonsFor('form').map((key) => ({ ...@@ -101,6 +121,9 @@ const ABANDON_REASONS = abandonReasonsFor('form').map((key) => ({
labelZh: ABANDON_REASON_META[key].labelZh, labelZh: ABANDON_REASON_META[key].labelZh,
})); }));
/** 分组标题(成功 / 不成功 / 保持)是否显示 —— 见下方渲染处的注释 */
const OUTCOME_GROUP_TITLE_VISIBLE: boolean = false;
export function OutcomeForm({ export function OutcomeForm({
plan, plan,
onSubmit, onSubmit,
...@@ -124,9 +147,20 @@ export function OutcomeForm({ ...@@ -124,9 +147,20 @@ export function OutcomeForm({
const [submitted, setSubmitted] = useState(false); const [submitted, setSubmitted] = useState(false);
const curMeta = outcome ? EXECUTION_OUTCOME_META[outcome as ExecutionOutcome] : null; const curMeta = outcome ? EXECUTION_OUTCOME_META[outcome as ExecutionOutcome] : null;
const cur = curMeta ? { tone: curMeta.tone, drives: curMeta.drivesStatus } : null; const cur = curMeta
? { tone: curMeta.tone, drives: curMeta.drivesStatus, suppressDays: curMeta.suppressDays }
: null;
const needsScheduledNext = outcome === 'scheduled_next'; const needsScheduledNext = outcome === 'scheduled_next';
const needsAbandonReasons = !!outcome && outcome === 'refused'; /**
* 放弃原因子选项**只挂在「放弃」下**(2026-08-05 从「明确拒绝」移过来)。
*
* ⚠️ 为什么移:子选项(号码空号 / 迁居外地 / 已转介 / 治疗机会判断不对 / 治疗中 / 已跟进)
* 描述的全是**客服对这条召回的处置判断**,不是患者的态度。挂在「明确拒绝」下会逼客服
* 为了填原因而去选一个不符合事实的结果项 —— 号码是空号,他哪知道患者拒不拒绝。
* ⚠️ 两者的 drivesStatus 都是 abandoned,所以这次移动**不改变任何单子的最终状态**,
* 只是把"为什么放弃"问到了对的那个选项下面。
*/
const needsAbandonReasons = !!outcome && outcome === 'abandoned';
// 终态(已结案/已放弃/已被替代)不可再写 execution(后端也会拒);提交按钮置灰 + 顶部提示 // 终态(已结案/已放弃/已被替代)不可再写 execution(后端也会拒);提交按钮置灰 + 顶部提示
const isTerminal = ['completed', 'abandoned', 'superseded'].includes(plan.status ?? ''); const isTerminal = ['completed', 'abandoned', 'superseded'].includes(plan.status ?? '');
const canSubmit = !!outcome && !!channel && !submitted && !isTerminal; const canSubmit = !!outcome && !!channel && !submitted && !isTerminal;
...@@ -206,7 +240,16 @@ export function OutcomeForm({ ...@@ -206,7 +240,16 @@ export function OutcomeForm({
<div className="space-y-2"> <div className="space-y-2">
{OUTCOME_GROUPS.map((grp) => ( {OUTCOME_GROUPS.map((grp) => (
<div key={grp.key}> <div key={grp.key}>
{/*
分组标题(成功 / 不成功 / 保持)—— **隐藏**(2026-08-05 产品走查)。
⛔ 只藏标题,**分组本身必须留着**:它决定选项的排列顺序和 grid 分行,
也是 EXECUTION_OUTCOME_META 里 group 字段的展示投影 ——
把 OUTCOME_GROUPS 拍平会让选项顺序变成声明序,而那个顺序没有业务含义。
置 true 即恢复。
*/}
{OUTCOME_GROUP_TITLE_VISIBLE && (
<div className="text-[9.5px] font-semibold text-slate-400 mb-1 pl-0.5">{grp.label}</div> <div className="text-[9.5px] font-semibold text-slate-400 mb-1 pl-0.5">{grp.label}</div>
)}
<div className="grid grid-cols-2 gap-1"> <div className="grid grid-cols-2 gap-1">
{grp.options.map((o) => { {grp.options.map((o) => {
const T = tone(o.tone); const T = tone(o.tone);
...@@ -236,7 +279,7 @@ export function OutcomeForm({ ...@@ -236,7 +279,7 @@ export function OutcomeForm({
<svg viewBox="0 0 24 24" className="w-3 h-3" fill="none" stroke="currentColor" strokeWidth="2"> <svg viewBox="0 0 24 24" className="w-3 h-3" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M5 12h14M13 5l7 7-7 7" strokeLinecap="round" /> <path d="M5 12h14M13 5l7 7-7 7" strokeLinecap="round" />
</svg> </svg>
<span>{STATE_HINTS[cur.drives] || '本次任务将更新'}</span> <span>{stateHint(cur.drives, cur.suppressDays, needsScheduledNext)}</span>
</div> </div>
)} )}
</div> </div>
......
...@@ -513,7 +513,7 @@ export const ExecutionOutcome = { ...@@ -513,7 +513,7 @@ export const ExecutionOutcome = {
// 4 起步(W2 设计) // 4 起步(W2 设计)
ABANDONED: 'abandoned', // 已联系,客服放弃 → Plan abandoned ABANDONED: 'abandoned', // 已联系,客服放弃 → Plan abandoned
NO_ANSWER: 'no_answer', // 未接通(打不通)→ Plan 仍 active 等下次 NO_ANSWER: 'no_answer', // 未接通(打不通)→ Plan 仍 active 等下次
QUICK_HANGUP: 'quick_hangup', // 接通秒挂(没说上话)→ 算"保持",Plan 仍 active QUICK_HANGUP: 'quick_hangup', // 接通秒挂(被主动挂断)→ 统计算"不成功",Plan 仍 active + 3 天冷静期
SCHEDULED_NEXT: 'scheduled_next', // 已联系,约定下次 → Plan 仍 assigned SCHEDULED_NEXT: 'scheduled_next', // 已联系,约定下次 → Plan 仍 assigned
SUCCESS_APPOINTED: 'success_appointed', // 已联系,成功新预约 → Plan completed(新预约跳到宿主创建,本表不冗余存) SUCCESS_APPOINTED: 'success_appointed', // 已联系,成功新预约 → Plan completed(新预约跳到宿主创建,本表不冗余存)
...@@ -626,7 +626,7 @@ export const EXECUTION_OUTCOME_META: Record< ...@@ -626,7 +626,7 @@ export const EXECUTION_OUTCOME_META: Record<
* ⭐ 约定下次回访 —— **归「成功」组,但工单不结案**(2026-08-04 产品定)。 * ⭐ 约定下次回访 —— **归「成功」组,但工单不结案**(2026-08-04 产品定)。
* *
* ⚠️ `group` 与 `drivesStatus` 在这一项上**故意不一致**,这不是笔误: * ⚠️ `group` 与 `drivesStatus` 在这一项上**故意不一致**,这不是笔误:
* · `group: 'close'` —— 统计口径:约到了下次,是**有效推进**,不该跟"未接通/秒挂" * · `group: 'close'` —— 统计口径:约到了下次,是**有效推进**,不该跟"未接通"
* 一起算进「保持」。主管看成功率时要看得见这一类。 * 一起算进「保持」。主管看成功率时要看得见这一类。
* · `drivesStatus: 'keep'` —— 状态机:工单**必须留着**并 snooze 到回访日。 * · `drivesStatus: 'keep'` —— 状态机:工单**必须留着**并 snooze 到回访日。
* ⛔ 千万别顺手改成 'completed' —— 那会让这单直接出池, * ⛔ 千万别顺手改成 'completed' —— 那会让这单直接出池,
...@@ -636,7 +636,31 @@ export const EXECUTION_OUTCOME_META: Record< ...@@ -636,7 +636,31 @@ export const EXECUTION_OUTCOME_META: Record<
scheduled_next: { group: 'close', labelZh: '约定下次回访', tone: 'amber', drivesStatus: 'keep', suppressDays: null }, // 带 scheduledNextAt → snooze 到回访日 scheduled_next: { group: 'close', labelZh: '约定下次回访', tone: 'amber', drivesStatus: 'keep', suppressDays: null }, // 带 scheduledNextAt → snooze 到回访日
// ── 保持(keep,留工单「进行中」)── // ── 保持(keep,留工单「进行中」)──
no_answer: { group: 'keep', labelZh: '未接通', tone: 'slate', drivesStatus: 'keep', suppressDays: null }, no_answer: { group: 'keep', labelZh: '未接通', tone: 'slate', drivesStatus: 'keep', suppressDays: null },
quick_hangup: { group: 'keep', labelZh: '秒挂', tone: 'slate', drivesStatus: 'keep', suppressDays: null }, // 接通秒挂,算保持,很快再试 /**
* ⭐ 秒挂 —— **归「不成功」组,但工单不结案**(2026-08-06 产品定)。
*
* ⚠️ 与 `scheduled_next` 同一个套路:`group` 与 `drivesStatus` **故意不一致**,不是笔误。
* · `group: 'give_up'` —— 统计口径:**接通了、患者主动挂断**,这是一次失败的触达。
* ⛔ 别再跟「未接通」一起算进「保持」:未接通是**没联系上**
* (对方意愿一无所知),秒挂是**联系上了但被拒绝**,
* 两者混在一起会让"打不通"和"人家不想聊"看起来一样,
* 而这两件事要改的东西完全相反(换时段 vs 换话术/换人群)。
* · `drivesStatus: 'keep'` —— 秒挂**不等于永久拒绝**:可能只是在开车/开会。
* ⛔ 别顺手改成 'abandoned' —— 那会把单子直接结掉,
* 一个可能只是时机不对的患者就再也不会被联系。
*
* ⭐ `suppressDays: 30`(产品 2026-08-06 定)—— 必须有冷静期,⛔ 不能像「未接通」那样 null:
* 人家刚挂断你,当天回拨是骚扰。
* ⚠️ 比「再考虑」(7 天)**还长**,不是笔误:「再考虑」是患者**答应了会想想**,
* 本来就该按时回来问;秒挂是**连话都不想说**,再快回去只会更烦。
* (梯度:未接通 0 / 再考虑 7 / **秒挂 30** / 明确拒绝 90 / 外院·无效 永久)
* ⚠️ 这个数是**产品可调**的常量,不是算法推出来的。
*
* ⚠️ `tone: 'rose'` 跟着 group 走 —— 它归「不成功」就得和明确拒绝/放弃同色。
* ⛔ 别留 'slate'(那是「未接通」的灰):选中时边框颜色取自 tone,
* 留灰会让主管在同一组里看到一个异色项,以为点错了(实测走查提出)。
*/
quick_hangup: { group: 'give_up', labelZh: '秒挂', tone: 'rose', drivesStatus: 'keep', suppressDays: 30 },
// ── 历史值(hiddenInForm):新建不展示,仅供历史 plan_executions 翻译 label ── // ── 历史值(hiddenInForm):新建不展示,仅供历史 plan_executions 翻译 label ──
considering: { group: 'keep', labelZh: '待跟进', tone: 'sky', drivesStatus: 'keep', suppressDays: null, hiddenInForm: true }, considering: { group: 'keep', labelZh: '待跟进', tone: 'sky', drivesStatus: 'keep', suppressDays: null, hiddenInForm: true },
marked_invalid: { group: 'give_up', labelZh: '无效', tone: 'rose', drivesStatus: 'abandoned', suppressDays: SUPPRESS_PERMANENT_DAYS, hiddenInForm: true }, marked_invalid: { group: 'give_up', labelZh: '无效', tone: 'rose', drivesStatus: 'abandoned', suppressDays: SUPPRESS_PERMANENT_DAYS, hiddenInForm: true },
...@@ -644,7 +668,11 @@ export const EXECUTION_OUTCOME_META: Record< ...@@ -644,7 +668,11 @@ export const EXECUTION_OUTCOME_META: Record<
sms_sent: { group: 'keep', labelZh: '未接通·已发短信', tone: 'slate', drivesStatus: 'keep', suppressDays: null, hiddenInForm: true }, sms_sent: { group: 'keep', labelZh: '未接通·已发短信', tone: 'slate', drivesStatus: 'keep', suppressDays: null, hiddenInForm: true },
needs_doctor: { group: 'keep', labelZh: '需要找医生', tone: 'sky', drivesStatus: 'keep', suppressDays: null, hiddenInForm: true }, needs_doctor: { group: 'keep', labelZh: '需要找医生', tone: 'sky', drivesStatus: 'keep', suppressDays: null, hiddenInForm: true },
pending_info: { group: 'keep', labelZh: '需进一步确认信息', tone: 'sky', drivesStatus: 'keep', suppressDays: null, hiddenInForm: true }, pending_info: { group: 'keep', labelZh: '需进一步确认信息', tone: 'sky', drivesStatus: 'keep', suppressDays: null, hiddenInForm: true },
abandoned: { group: 'give_up', labelZh: '客服放弃', tone: 'rose', drivesStatus: 'abandoned', suppressDays: 60, hiddenInForm: true }, // ⭐ 2026-08-05 在表单里露出来(原 hiddenInForm)。它是**唯一**带放弃原因子选项的结果项 ——
// 原来挂在 `refused`(明确拒绝)上,但"拒绝"是患者的态度、"放弃"是客服的处置决定,
// 子选项(空号/迁居/已转介/判断不对…)描述的全是**后者**。挂错了会逼客服为了填原因
// 而去选一个不符合事实的「明确拒绝」。
abandoned: { group: 'give_up', labelZh: '放弃', tone: 'rose', drivesStatus: 'abandoned', suppressDays: 60 },
}; };
/// 召回反馈(选填·多选)— 一线客服对"这条召回本身准不准"的反馈,**正交于通话结果**。 /// 召回反馈(选填·多选)— 一线客服对"这条召回本身准不准"的反馈,**正交于通话结果**。
...@@ -689,6 +717,8 @@ export const AbandonReason = { ...@@ -689,6 +717,8 @@ export const AbandonReason = {
OUT_OF_SERVICE_AREA: 'out_of_service_area', OUT_OF_SERVICE_AREA: 'out_of_service_area',
TREATED_ELSEWHERE: 'treated_elsewhere', TREATED_ELSEWHERE: 'treated_elsewhere',
NOT_INTERESTED: 'not_interested', NOT_INTERESTED: 'not_interested',
IN_TREATMENT: 'in_treatment',
ALREADY_FOLLOWED: 'already_followed',
DO_NOT_CONTACT_REQUEST: 'do_not_contact_request', // 自动加 DNC(⚠️ 尚未实现,见 TODO) DO_NOT_CONTACT_REQUEST: 'do_not_contact_request', // 自动加 DNC(⚠️ 尚未实现,见 TODO)
// ── 宿主回访模式的「关闭机会」弹窗 ── // ── 宿主回访模式的「关闭机会」弹窗 ──
NO_INTENT: 'no_intent', NO_INTENT: 'no_intent',
...@@ -707,6 +737,8 @@ export const AbandonReasonSchema = z.enum([ ...@@ -707,6 +737,8 @@ export const AbandonReasonSchema = z.enum([
'out_of_service_area', 'out_of_service_area',
'treated_elsewhere', 'treated_elsewhere',
'not_interested', 'not_interested',
'in_treatment',
'already_followed',
'do_not_contact_request', 'do_not_contact_request',
'no_intent', 'no_intent',
'inaccurate', 'inaccurate',
...@@ -749,6 +781,10 @@ export const ABANDON_REASON_META: Record< ...@@ -749,6 +781,10 @@ export const ABANDON_REASON_META: Record<
treated_elsewhere: { labelZh: '已转介他人', shownIn: 'form', suppressDays: SUPPRESS_PERMANENT_DAYS }, treated_elsewhere: { labelZh: '已转介他人', shownIn: 'form', suppressDays: SUPPRESS_PERMANENT_DAYS },
do_not_contact_request: { labelZh: '投诉 / 不愿打扰', shownIn: 'form', suppressDays: SUPPRESS_PERMANENT_DAYS }, do_not_contact_request: { labelZh: '投诉 / 不愿打扰', shownIn: 'form', suppressDays: SUPPRESS_PERMANENT_DAYS },
not_interested: { labelZh: '不感兴趣', shownIn: 'form', hidden: true }, // 历史值,从没被写过 not_interested: { labelZh: '不感兴趣', shownIn: 'form', hidden: true }, // 历史值,从没被写过
// ── 2026-08-05 新增(客服放弃子选项)──
// ⭐ 都是**客服对这条召回本身的判断**,不是患者态度 —— 所以只挂在「放弃」下,不挂「明确拒绝」。
in_treatment: { labelZh: '治疗中', desc: '该机会对应的治疗正在进行,不需要再邀约', shownIn: 'both', suppressDays: SUPPRESS_PERMANENT_DAYS },
already_followed: { labelZh: '已跟进', desc: '已有其他同事/渠道跟进过,本条不重复打扰', shownIn: 'both', suppressDays: 60 },
// ── 宿主关闭弹窗(文案按宿主口径)── // ── 宿主关闭弹窗(文案按宿主口径)──
no_intent: { labelZh: '客户无意愿', desc: '客户明确表示没有治疗意愿', shownIn: 'close' }, // 60d no_intent: { labelZh: '客户无意愿', desc: '客户明确表示没有治疗意愿', shownIn: 'close' }, // 60d
...@@ -757,7 +793,9 @@ export const ABANDON_REASON_META: Record< ...@@ -757,7 +793,9 @@ export const ABANDON_REASON_META: Record<
// 不准的召回原样弹回来,客服只会再关一次、再骂一次,而算法多半还没改。 // 不准的召回原样弹回来,客服只会再关一次、再骂一次,而算法多半还没改。
// 真要在修复后放回来,应由「改完规则 → 定向重算/解除抑制」这条显式动作驱动, // 真要在修复后放回来,应由「改完规则 → 定向重算/解除抑制」这条显式动作驱动,
// 而不是靠一个赌"两周内一定修好"的定时器。 // 而不是靠一个赌"两周内一定修好"的定时器。
inaccurate: { labelZh: '机会识别不准确', desc: '识别的治疗机会与患者实际情况不符', shownIn: 'close', suppressDays: SUPPRESS_PERMANENT_DAYS }, // ⚠️ shownIn 从 'close' 放宽到 'both'(2026-08-05):PAC 自带表单也要能说「这条召回判断错了」——
// 那是客服最常见的放弃理由之一,只在宿主关闭弹窗里给,等于 PAC 自己的表单填不了。
inaccurate: { labelZh: '治疗机会判断不对', desc: '识别的治疗机会与患者实际情况不符', shownIn: 'both', suppressDays: SUPPRESS_PERMANENT_DAYS },
competitor: { labelZh: '选择竞品机构', desc: '客户已选择或倾向其他机构治疗', shownIn: 'close', suppressDays: SUPPRESS_PERMANENT_DAYS }, competitor: { labelZh: '选择竞品机构', desc: '客户已选择或倾向其他机构治疗', shownIn: 'close', suppressDays: SUPPRESS_PERMANENT_DAYS },
price: { labelZh: '价格因素', desc: '客户因价格原因暂不考虑治疗', shownIn: 'close' }, // 60d price: { labelZh: '价格因素', desc: '客户因价格原因暂不考虑治疗', shownIn: 'close' }, // 60d
// ⭐ 永久(2026-07 由 14d 改):客服断言"这个机会的治疗已经做完了" —— 这是**临床事实判断**, // ⭐ 永久(2026-07 由 14d 改):客服断言"这个机会的治疗已经做完了" —— 这是**临床事实判断**,
...@@ -771,7 +809,10 @@ export const ABANDON_REASON_META: Record< ...@@ -771,7 +809,10 @@ export const ABANDON_REASON_META: Record<
unreachable: { labelZh: '无法联系客户', desc: '多次尝试后仍无法联系到客户', shownIn: 'close', suppressDays: 30 }, unreachable: { labelZh: '无法联系客户', desc: '多次尝试后仍无法联系到客户', shownIn: 'close', suppressDays: 30 },
// ── 共用 ── // ── 共用 ──
other: { labelZh: '其他原因(需填写说明)', desc: '需要进一步说明具体原因', shownIn: 'both', needNote: true }, // ⚠️ shownIn 从 'both' 收窄到 'close'(2026-08-05):PAC 表单撤掉「其他」。
// 它是**统计黑洞** —— 原因分布是主管调下一批的输入,而"其他"什么都不告诉他;
// 留着必然变成最省事的默认选项。宿主关闭弹窗保留(那边口径不由我们定)。
other: { labelZh: '其他原因(需填写说明)', desc: '需要进一步说明具体原因', shownIn: 'close', needNote: true },
}; };
/// 取某面板要展示的原因清单(过滤 hidden;顺序 = META 声明顺序) /// 取某面板要展示的原因清单(过滤 hidden;顺序 = META 声明顺序)
...@@ -811,33 +852,49 @@ export function abandonReasonsFor(panel: 'form' | 'close'): AbandonReason[] { ...@@ -811,33 +852,49 @@ export function abandonReasonsFor(panel: 'form' | 'close'): AbandonReason[] {
* 不要当成无谓摩擦砍掉。 * 不要当成无谓摩擦砍掉。
*/ */
export const ReleaseReason = { export const ReleaseReason = {
/// 人不对 —— 这个患者不归我 // ── 机会侧:客服**还没打电话**,坐在工位上看着工单就能判断的 ──
NOT_MY_PATIENT: 'not_my_patient', /// 这条召回不成立 —— 病历里其实做过了 / 诊断牙位对不上
/// 接不住 —— 手上已经排满 WRONG_OPPORTUNITY: 'wrong_opportunity',
OVER_CAPACITY: 'over_capacity', /// 已经在处理了 —— 已约上 / 正在治疗 / 别的同事已在跟
/// 接不住 —— 我这段时间不在(休假 / 调岗 / 转岗) ALREADY_IN_PROGRESS: 'already_in_progress',
AGENT_UNAVAILABLE: 'agent_unavailable', /// 现在打不合适 —— 最近刚联系过、或刚来过院
/// 人不对 —— 该由别的角色跟(医生 / 咨询师 / 客户经理) TOO_SOON: 'too_soon',
NEEDS_OTHER_ROLE: 'needs_other_role', /// 信息不全 —— 空号 / 没号码 / 缺诊断,开不了口
/// 时机不对 —— 给的时效内做不完
DEADLINE_TOO_TIGHT: 'deadline_too_tight',
/// 时机不对 —— 最近刚联系过,这会儿再打会烦
RECENTLY_CONTACTED: 'recently_contacted',
/// 信息不足 —— 缺电话 / 缺诊断,没法开口
PATIENT_INFO_MISSING: 'patient_info_missing', PATIENT_INFO_MISSING: 'patient_info_missing',
/// 其他(必须写说明) /// 患者不愿被打扰 —— 历史投诉 / 明确要求别再联系
DO_NOT_DISTURB: 'do_not_disturb',
/// 该由别的角色跟 —— 需要医生 / 咨询师介入,不是客服能推的
NEEDS_OTHER_ROLE: 'needs_other_role',
/// 兜底 —— 上面都不是,必须写明具体情况(自由文本进 release_note)
OTHER: 'other', OTHER: 'other',
} as const; } as const;
export type ReleaseReason = (typeof ReleaseReason)[keyof typeof ReleaseReason]; /**
* ⚠️ 类型 = 当前值 ∪ **历史值**。库里已经有 `not_my_patient` 等老数据,
* 类型收窄会让那些单一读就编译不过/运行时炸。展示层靠 `hidden` 过滤,见 `releaseReasonsForForm()`。
*/
export type ReleaseReason =
| (typeof ReleaseReason)[keyof typeof ReleaseReason]
| 'not_my_patient'
| 'agent_unavailable'
| 'deadline_too_tight'
| 'recently_contacted'
| 'over_capacity';
export const ReleaseReasonSchema = z.enum([ export const ReleaseReasonSchema = z.enum([
'wrong_opportunity',
'already_in_progress',
'too_soon',
'patient_info_missing',
'do_not_disturb',
'needs_other_role',
'other',
// ⚠️ 下面五个是**历史值**,2026-08-05 从表单撤掉但仍要能反序列化老数据 ——
// 库里已经有 release_reason='not_my_patient' 的行,schema 收窄会让那些单一读就 500。
'not_my_patient', 'not_my_patient',
'over_capacity',
'agent_unavailable', 'agent_unavailable',
'needs_other_role',
'deadline_too_tight', 'deadline_too_tight',
'recently_contacted', 'recently_contacted',
'patient_info_missing', 'over_capacity',
'other',
]); ]);
/** /**
...@@ -846,6 +903,13 @@ export const ReleaseReasonSchema = z.enum([ ...@@ -846,6 +903,13 @@ export const ReleaseReasonSchema = z.enum([
* 这是退回原因分布唯一的下游用途:一批退回里 `capacity` 占了一半 → 容量默认值定高了; * 这是退回原因分布唯一的下游用途:一批退回里 `capacity` 占了一半 → 容量默认值定高了;
* `roster` 占一半 → 在岗判定不准。没有这一层映射,原因分布就只是一张好看的饼图。 * `roster` 占一半 → 在岗判定不准。没有这一层映射,原因分布就只是一张好看的饼图。
*/ */
/**
* 退回原因的**分组** —— 只管 UI 归类与统计分桶。
* ⭐ 2026-08-05 加 'opportunity':退回原因改版后重心从"我为什么不接"挪到
* "这条召回哪里不对",绝大多数原因落在这一组。
*/
export type ReleaseReasonGroup = 'opportunity' | 'timing' | 'data' | 'person' | 'capacity' | 'other';
export type ReleaseReasonLever = export type ReleaseReasonLever =
/// 圈人条件 —— 分给谁、按什么圈(专属策略 / 初筛条件) /// 圈人条件 —— 分给谁、按什么圈(专属策略 / 初筛条件)
| 'targeting' | 'targeting'
...@@ -876,22 +940,50 @@ export const RELEASE_REASON_META: Record< ...@@ -876,22 +940,50 @@ export const RELEASE_REASON_META: Record<
{ {
labelZh: string; labelZh: string;
desc?: string; desc?: string;
group: 'person' | 'capacity' | 'timing' | 'data' | 'other'; group: ReleaseReasonGroup;
lever: ReleaseReasonLever; lever: ReleaseReasonLever;
needNote?: boolean; needNote?: boolean;
hidden?: boolean; hidden?: boolean;
} }
> = { > = {
not_my_patient: { labelZh: '不是我的客户', desc: '这位患者一直由别人跟', group: 'person', lever: 'targeting' }, // ── 机会侧(2026-08-05 改版的重心)────────────────────────────────
wrong_opportunity: { labelZh: '这条召回不成立', desc: '病历里其实做过了 / 诊断牙位对不上', group: 'opportunity', lever: 'targeting' },
already_in_progress: { labelZh: '已经在处理了', desc: '已约上 / 正在治疗 / 别的同事在跟', group: 'opportunity', lever: 'targeting' },
too_soon: { labelZh: '现在打不合适', desc: '最近刚联系过,或刚来过院', group: 'timing', lever: 'targeting' },
patient_info_missing: { labelZh: '信息不全', desc: '空号 / 没号码 / 缺诊断,开不了口', group: 'data', lever: 'data' },
do_not_disturb: { labelZh: '患者不愿被打扰', desc: '历史投诉 / 明确要求别再联系', group: 'opportunity', lever: 'targeting' },
needs_other_role: { labelZh: '该由其他角色跟', desc: '需要医生 / 咨询师介入,不是客服能推的', group: 'person', lever: 'targeting' }, needs_other_role: { labelZh: '该由其他角色跟', desc: '需要医生 / 咨询师介入,不是客服能推的', group: 'person', lever: 'targeting' },
over_capacity: { labelZh: '手上排满了', desc: '在手单已超出能跟进的量', group: 'capacity', lever: 'capacity' },
agent_unavailable: { labelZh: '这段时间不在', desc: '休假 / 调岗 / 已不做召回', group: 'capacity', lever: 'roster' }, // ── 兜底 ────────────────────────────────────────────────────────
deadline_too_tight: { labelZh: '时效太紧', desc: '在给定期限内做不完', group: 'timing', lever: 'timing' }, // ⚠️ 保留「其他」是**有意的**(与放弃原因那边相反)。退回发生在**还没打电话**的时点,
recently_contacted: { labelZh: '最近刚联系过', desc: '短期内重复触达会引起反感', group: 'timing', lever: 'targeting' }, // 客服看到的情况比打完之后更杂(系统里没体现的临时状况、跨部门交代…)。
patient_info_missing: { labelZh: '患者信息不足', desc: '缺号码 / 缺诊断,无法开口', group: 'data', lever: 'data' }, // 没有兜底,他会被迫从上面挑一个**不符合事实**的原因交差 —— 那比"其他"更毁统计。
// 代价靠 needNote 兜:必须写明具体情况,服务端也拦(见 plan.service 的 recycle)。
other: { labelZh: '其他原因', desc: '需要写明具体情况', group: 'other', lever: 'none', needNote: true }, other: { labelZh: '其他原因', desc: '需要写明具体情况', group: 'other', lever: 'none', needNote: true },
// ── 历史值:不再展示,仅供翻译老数据 ────────────────────────────
not_my_patient: { labelZh: '不是我的客户', group: 'person', lever: 'targeting', hidden: true },
agent_unavailable: { labelZh: '这段时间不在', group: 'capacity', lever: 'roster', hidden: true },
deadline_too_tight: { labelZh: '时效太紧', group: 'timing', lever: 'timing', hidden: true },
recently_contacted: { labelZh: '最近刚联系过', group: 'timing', lever: 'targeting', hidden: true },
over_capacity: { labelZh: '手上排满了', group: 'capacity', lever: 'capacity', hidden: true },
}; };
/**
* **历史值** —— 2026-08-05 退回原因改版时从表单撤下的旧 key。
*
* ⚠️ 它们仍必须能反序列化:库里已经有这些值的行,schema/META 收窄会让那些单一读就炸。
* ⛔ 别把它们加回 `ReleaseReason` 常量 —— 那个常量是"**现在能选什么**"的单一真理源,
* 表单、seed、统计口径都从它派生。历史值只活在类型联合 + META(hidden) 里。
*/
export const RELEASE_REASON_HISTORICAL = [
'not_my_patient',
'agent_unavailable',
'deadline_too_tight',
'recently_contacted',
'over_capacity',
] as const satisfies readonly ReleaseReason[];
/// 退回弹窗要展示的原因清单(过滤 hidden;顺序 = META 声明顺序 = 按 group 已排好) /// 退回弹窗要展示的原因清单(过滤 hidden;顺序 = META 声明顺序 = 按 group 已排好)
export function releaseReasonsForForm(): ReleaseReason[] { export function releaseReasonsForForm(): ReleaseReason[] {
return (Object.keys(RELEASE_REASON_META) as ReleaseReason[]).filter( return (Object.keys(RELEASE_REASON_META) as ReleaseReason[]).filter(
......
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