Commit f05fe163 by luoqi

feat(execution): 通话结果重构为 成功/不成功/保持 三组

按业务重新归类通话结果(EXECUTION_OUTCOME_META 单一真理源,前后端同步):
- 成功(close→completed):「转化新预约」(原"成功转化为新预约");去掉"近期不考虑"
- 不成功(give_up):明确拒绝、已在外院治疗(终态 abandoned)+ 再考虑(冷静期7天)
  · 再考虑 = 原 declined_recent 改 drivesStatus=keep + suppressDays=7(非终态,7天后自动浮现)
- 保持(keep):打不通(原"未接通")、秒挂(新 quick_hangup,算保持)、约定下次回访
- 待跟进/无效 → hiddenInForm(不在表单展示,仅翻译历史)
- 新建预约按钮常显(不再仅"成功"时出现)
- group label 改 成功/不成功/保持(仅 outcome-form 消费,不影响列表 tab)

resolveSnoozedUntil(suppressDays!=null → now+N)天然支持 再考虑=keep+7天;测试更新通过。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent 9765bb32
...@@ -161,9 +161,9 @@ function fallback(input: DraftPlanScriptInput): DraftPlanScriptOutput { ...@@ -161,9 +161,9 @@ function fallback(input: DraftPlanScriptInput): DraftPlanScriptOutput {
close: `> "好的${patient.nameMasked},我先按 周六上午10点(示例) 帮您登记面诊时间,具体时段以诊所排班为准,稍后跟前台确认后短信通知您实际时间。还有别的需要么?" close: `> "好的${patient.nameMasked},我先按 周六上午10点(示例) 帮您登记面诊时间,具体时段以诊所排班为准,稍后跟前台确认后短信通知您实际时间。还有别的需要么?"
**回写要点** **回写要点**
- 成功约上面诊 → 提交结果选「成功转化为新预约」,填预约时间 + 医生 - 成功约上面诊 → 通话结果选「转化新预约」,填预约时间 + 医生
- 同意但未定日期 → 选「约定下次回访」,填预计时间 - 同意但未定日期 → 选「约定下次回访」,填预计时间
- 考虑中 → 选「考虑中近期再跟进」,7 天后系统提醒二次跟进`, - 考虑中 → 选「再考虑(冷静期7天)」,7 天后系统自动重新浮现`,
}; };
} }
......
...@@ -23,9 +23,9 @@ describe('召回抑制窗 snoozedUntil 计算', () => { ...@@ -23,9 +23,9 @@ describe('召回抑制窗 snoozedUntil 计算', () => {
expect(daysFromNow(r)).toBe(90); expect(daysFromNow(r)).toBe(90);
}); });
test('近期不考虑 declined_recent → 90d(虽 keep 类,仍冷静期内不进池)', () => { test('再考虑 declined_recent → 7d(keep 非终态,7天冷静期后自动浮现)', () => {
const r = resolveSnoozedUntil({ outcome: 'declined_recent', breakerTripped: false, now: NOW }); const r = resolveSnoozedUntil({ outcome: 'declined_recent', breakerTripped: false, now: NOW });
expect(daysFromNow(r)).toBe(90); expect(daysFromNow(r)).toBe(7);
}); });
test('成功转化 success_appointed → 60d(覆盖预约→治疗→摄入延迟,避免重复召)', () => { test('成功转化 success_appointed → 60d(覆盖预约→治疗→摄入延迟,避免重复召)', () => {
......
...@@ -23,7 +23,7 @@ const fromYMD = (s: string) => { ...@@ -23,7 +23,7 @@ const fromYMD = (s: string) => {
/// outcome 选项从 @pac/types EXECUTION_OUTCOME_META 派生(单一真理源,跟后端共享) /// outcome 选项从 @pac/types EXECUTION_OUTCOME_META 派生(单一真理源,跟后端共享)
/// 改 enum / label / group / 状态机映射只在 packages/types 一处改,前后端同步生效 /// 改 enum / label / group / 状态机映射只在 packages/types 一处改,前后端同步生效
/// 2 级分组:按 group 归类(成功 / 约定回访 / 未接通 / 放弃 / 其他),hiddenInForm 的历史值不展示 /// 2 级分组:按 group 归类(成功 / 不成功 / 保持),hiddenInForm 的历史值不展示
const OUTCOME_GROUPS = ( const OUTCOME_GROUPS = (
Object.keys(EXECUTION_OUTCOME_GROUP_META) as ExecutionOutcomeGroup[] Object.keys(EXECUTION_OUTCOME_GROUP_META) as ExecutionOutcomeGroup[]
) )
...@@ -102,10 +102,8 @@ export function OutcomeForm({ ...@@ -102,10 +102,8 @@ export function OutcomeForm({
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 } : null;
const needsScheduledNext = const needsScheduledNext = outcome === 'scheduled_next';
!!outcome && ['scheduled_next', 'considering'].includes(outcome);
const needsAbandonReasons = !!outcome && outcome === 'refused'; const needsAbandonReasons = !!outcome && outcome === 'refused';
const isSuccess = outcome === 'success_appointed';
// 终态(已结案/已放弃/已被替代)不可再写 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;
...@@ -138,7 +136,6 @@ export function OutcomeForm({ ...@@ -138,7 +136,6 @@ export function OutcomeForm({
<div className="flex-none"> <div className="flex-none">
<div className="text-[10.5px] font-semibold text-slate-500 uppercase tracking-wider mb-1.5"> <div className="text-[10.5px] font-semibold text-slate-500 uppercase tracking-wider mb-1.5">
触达方式 <span className="text-rose-500">*</span> 触达方式 <span className="text-rose-500">*</span>
<span className="ml-2 font-normal normal-case text-[10px] text-slate-400 tracking-normal">系统建议电话</span>
</div> </div>
<div className="grid grid-cols-3 gap-1"> <div className="grid grid-cols-3 gap-1">
{CHANNELS.map((c) => { {CHANNELS.map((c) => {
...@@ -314,26 +311,25 @@ export function OutcomeForm({ ...@@ -314,26 +311,25 @@ export function OutcomeForm({
本次为第 <strong className="text-slate-600 tabular-nums">{plan.contactAttempts + 1}</strong> 次触达 本次为第 <strong className="text-slate-600 tabular-nums">{plan.contactAttempts + 1}</strong> 次触达
</span> </span>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
{isSuccess && ( {/* 新建预约按钮常显(不再仅"成功"时出现)— 客服任何时候都能手动建预约 */}
<button <button
onClick={onCreateAppointment} onClick={onCreateAppointment}
className="px-3 py-1.5 rounded text-[12px] border border-teal-300 text-teal-700 hover:bg-teal-50 inline-flex items-center gap-1 transition-colors" className="px-3 py-1.5 rounded text-[12px] border border-teal-300 text-teal-700 hover:bg-teal-50 inline-flex items-center gap-1 transition-colors"
>
<svg
viewBox="0 0 24 24"
className="w-3.5 h-3.5"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
> >
<svg <rect x="3" y="4" width="18" height="18" rx="2" />
viewBox="0 0 24 24" <path d="M16 2v4M8 2v4M3 10h18M12 14v4M10 16h4" />
className="w-3.5 h-3.5" </svg>
fill="none" 新建预约
stroke="currentColor" </button>
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="3" y="4" width="18" height="18" rx="2" />
<path d="M16 2v4M8 2v4M3 10h18M12 14v4M10 16h4" />
</svg>
新建预约
</button>
)}
<button <button
disabled={!canSubmit} disabled={!canSubmit}
onClick={handleSubmit} onClick={handleSubmit}
......
...@@ -473,7 +473,8 @@ export const PlanStatusSchema = z.enum([ ...@@ -473,7 +473,8 @@ export const PlanStatusSchema = z.enum([
export const ExecutionOutcome = { 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
SCHEDULED_NEXT: 'scheduled_next', // 已联系,约定下次 → Plan 仍 assigned SCHEDULED_NEXT: 'scheduled_next', // 已联系,约定下次 → Plan 仍 assigned
SUCCESS_APPOINTED: 'success_appointed', // 已联系,成功新预约 → Plan completed(新预约跳到宿主创建,本表不冗余存) SUCCESS_APPOINTED: 'success_appointed', // 已联系,成功新预约 → Plan completed(新预约跳到宿主创建,本表不冗余存)
...@@ -492,6 +493,7 @@ export type ExecutionOutcome = (typeof ExecutionOutcome)[keyof typeof ExecutionO ...@@ -492,6 +493,7 @@ export type ExecutionOutcome = (typeof ExecutionOutcome)[keyof typeof ExecutionO
export const ExecutionOutcomeSchema = z.enum([ export const ExecutionOutcomeSchema = z.enum([
'abandoned', 'abandoned',
'no_answer', 'no_answer',
'quick_hangup',
'scheduled_next', 'scheduled_next',
'success_appointed', 'success_appointed',
// 产品收集 // 产品收集
...@@ -524,8 +526,10 @@ export const SUPPRESS_PERMANENT_DAYS = 36500; ...@@ -524,8 +526,10 @@ export const SUPPRESS_PERMANENT_DAYS = 36500;
/// 短冷静期后允许重新进池(后续可接换渠道策略)。 /// 短冷静期后允许重新进池(后续可接换渠道策略)。
export const BREAKER_SUPPRESS_DAYS = 30; export const BREAKER_SUPPRESS_DAYS = 30;
/// 通话结果分组(UI 归类)。三大类正好对应状态机三态 + 列表三个 tab: /// 通话结果分组(UI 归类,group key 不变,仅 label 演进)。大体对应状态机:
/// 结案 close → completed →「已完成」 / 保持 keep → keep →「进行中」 / 放弃 give_up → abandoned →「已放弃」 /// 成功 close → completed →「已完成」 / 保持 keep → keep →「进行中」 / 不成功 give_up → 多为 abandoned
/// ⚠️ 例外:不成功组里的「再考虑(冷静期7天)」drivesStatus='keep'(非终态)— group 只管 UI 归类,
/// 真实状态看 drivesStatus(7天后自动浮现,plan 仍在「进行中」)。group≠status 是允许的。
export type ExecutionOutcomeGroup = 'close' | 'keep' | 'give_up'; export type ExecutionOutcomeGroup = 'close' | 'keep' | 'give_up';
/// 分组元数据(label / 显示顺序 / tone)。outcome-form 据此渲染分组标题。 /// 分组元数据(label / 显示顺序 / tone)。outcome-form 据此渲染分组标题。
...@@ -533,9 +537,9 @@ export const EXECUTION_OUTCOME_GROUP_META: Record< ...@@ -533,9 +537,9 @@ export const EXECUTION_OUTCOME_GROUP_META: Record<
ExecutionOutcomeGroup, ExecutionOutcomeGroup,
{ labelZh: string; order: number; tone: ExecutionOutcomeTone } { labelZh: string; order: number; tone: ExecutionOutcomeTone }
> = { > = {
close: { labelZh: '结案', order: 1, tone: 'emerald' }, close: { labelZh: '成功', order: 1, tone: 'emerald' },
keep: { labelZh: '保持', order: 2, tone: 'amber' }, give_up: { labelZh: '不成功', order: 2, tone: 'rose' },
give_up: { labelZh: '放弃', order: 3, tone: 'rose' }, keep: { labelZh: '保持', order: 3, tone: 'amber' },
}; };
/// ExecutionOutcome 单一真理源(group / label / tone / 状态机 / 抑制窗) /// ExecutionOutcome 单一真理源(group / label / tone / 状态机 / 抑制窗)
...@@ -568,18 +572,19 @@ export const EXECUTION_OUTCOME_META: Record< ...@@ -568,18 +572,19 @@ export const EXECUTION_OUTCOME_META: Record<
hiddenInForm?: boolean; hiddenInForm?: boolean;
} }
> = { > = {
// ── 结案(completed,「已完成」tab)── // ── 成功(close → completed,「已完成」tab)──
success_appointed: { group: 'close', labelZh: '成功转化为新预约', tone: 'emerald', drivesStatus: 'completed', suppressDays: 60 }, success_appointed: { group: 'close', labelZh: '转化新预约', tone: 'emerald', drivesStatus: 'completed', suppressDays: 60 },
declined_recent: { group: 'close', labelZh: '近期不考虑', tone: 'amber', drivesStatus: 'completed', suppressDays: 90 }, // 软结案:90d 后信号级自动复活 // ── 不成功(give_up;明确拒绝/外院 = 终态 abandoned,再考虑 = keep+7天非终态)──
// ── 保持(keep,留工单「进行中」)──
scheduled_next: { group: 'keep', labelZh: '约定下次回访', tone: 'amber', drivesStatus: 'keep', suppressDays: null }, // 带 scheduledNextAt → snooze 到回访日
no_answer: { group: 'keep', labelZh: '未接通', tone: 'slate', drivesStatus: 'keep', suppressDays: null },
considering: { group: 'keep', labelZh: '待跟进', tone: 'sky', drivesStatus: 'keep', suppressDays: null }, // 通用待跟进(细节进纪要)
// ── 放弃(abandoned,「已放弃」tab)──
refused: { group: 'give_up', labelZh: '明确拒绝', tone: 'rose', drivesStatus: 'abandoned', suppressDays: 90 }, refused: { group: 'give_up', labelZh: '明确拒绝', tone: 'rose', drivesStatus: 'abandoned', suppressDays: 90 },
external_treatment: { group: 'give_up', labelZh: '已在外院治疗', tone: 'rose', drivesStatus: 'abandoned', suppressDays: SUPPRESS_PERMANENT_DAYS }, external_treatment: { group: 'give_up', labelZh: '已在外院治疗', tone: 'rose', drivesStatus: 'abandoned', suppressDays: SUPPRESS_PERMANENT_DAYS },
marked_invalid: { group: 'give_up', labelZh: '无效', tone: 'rose', drivesStatus: 'abandoned', suppressDays: SUPPRESS_PERMANENT_DAYS }, declined_recent: { group: 'give_up', labelZh: '再考虑(冷静期7天)', tone: 'amber', drivesStatus: 'keep', suppressDays: 7 }, // 非终态:7天冷静期后自动浮现(plan 仍「进行中」)
// ── 保持(keep,留工单「进行中」)──
no_answer: { group: 'keep', labelZh: '未接通', tone: 'slate', drivesStatus: 'keep', suppressDays: null },
quick_hangup: { group: 'keep', labelZh: '秒挂', tone: 'slate', drivesStatus: 'keep', suppressDays: null }, // 接通秒挂,算保持,很快再试
scheduled_next: { group: 'keep', labelZh: '约定下次回访', tone: 'amber', drivesStatus: 'keep', suppressDays: null }, // 带 scheduledNextAt → snooze 到回访日
// ── 历史值(hiddenInForm):新建不展示,仅供历史 plan_executions 翻译 label ── // ── 历史值(hiddenInForm):新建不展示,仅供历史 plan_executions 翻译 label ──
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 },
rescheduled: { group: 'keep', labelZh: '改约', tone: 'amber', drivesStatus: 'keep', suppressDays: null, hiddenInForm: true }, // 已并入"约定下次回访" rescheduled: { group: 'keep', labelZh: '改约', tone: 'amber', drivesStatus: 'keep', suppressDays: null, hiddenInForm: true }, // 已并入"约定下次回访"
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 },
......
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