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}>
<div className="text-[9.5px] font-semibold text-slate-400 mb-1 pl-0.5">{grp.label}</div> {/*
分组标题(成功 / 不成功 / 保持)—— **隐藏**(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="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>
......
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