Commit 2e0610be by luoqi

feat: 预约/跟进跳转前先落一条执行结果 + 约定下次回访归「成功」

═ 为什么 ═
这两个按钮跳走之后客服基本不回来 —— 这正是 plan_executions 回写率只有 11%
(生产 65 个认领单只有 7 条结果)的根源。回写率低到这个程度,"成功率""退回率"
全是猜的。⇒ 把一次必答的极简输入挪到跳转之前:人还在 PAC 里、手还在键盘上,
那一刻才录得到。

═ 做了什么 ═
1) 吸附在按钮上的小浮层(Popover),各只收**一个必填**:
   · 预约 → 备注   → 落 success_appointed(结案 + 60 天抑制)
   · 跟进 → 下次时间 → 落 scheduled_next(不结案,snooze 到那天)
   多一个字段都不行 —— 这是拦在人和他真正想做的事之间的门,门越重越会被绕开。

2) scheduled_next 的 group: keep → close(「保持」→「成功」)。
   ️ drivesStatus 保持 'keep' —— group 与状态机在这一项上**故意不一致**:
   算成功是统计口径,工单必须留着 snooze 到回访日。改成 completed 的话,
   客服刚答应患者"那天再联系您",这单当场出池,约好的回访再也不会发生。
   面板分组是读 meta 现算的 → 「保持」组自动只剩未接通/秒挂,历史数据一并重归类。

═ 几条纪律 ═
· **先落库、后跳转**。反过来的话写失败人已跳走,他会以为记过了。
· URL 未配置时**先拦住**, 不先把单结了再告诉他跳不过去。
· channel 记 'other', 不许默认 'phone' —— 我们不知道他怎么触达的,编一个会把
  触达方式分布做脏。
· 浮层里写明后果(「记为转化新预约并结案」)—— 不写就是让他在不知情下关掉工单。

实测(真库):跟进 → scheduled_next/other/2026-08-12 落库,plan 仍 assigned +
snoozed_until=回访日;预约 → success_appointed + 备注落库,plan → completed。
未配 URL 时确认不落库、浮层保持打开。新增 taxonomy 回归锁住 group/drivesStatus 分离。
1013 tests,两个 tsc + next build 干净。
parent 2cea1a0d
import { EXECUTION_OUTCOME_META, EXECUTION_OUTCOME_GROUP_META } from '@pac/types';
/**
* 执行结果的**归类**与**状态机**是两件事,这个 spec 锁的就是它们的分离。
*
* 🔴 `scheduled_next`(约定下次回访)是唯一一个 `group` 与 `drivesStatus` **故意不一致**的项:
* group: 'close' —— 统计口径:约到下次是**有效推进**,不该跟"未接通/秒挂"一起算「保持」
* drivesStatus: 'keep' —— 状态机:工单必须留着并 snooze 到回访日
* 看着像笔误,所以极可能被后来的人"顺手改一致"。而改成 'completed' 的后果是:
* 客服刚答应了患者"那天再联系您",这单却当场出池,**约好的回访再也不会发生**,且不报错。
*/
describe('执行结果分类 —— 归类口径 ≠ 状态机', () => {
test('⭐⭐ 约定下次回访:算「成功」但**不结案**', () => {
const m = EXECUTION_OUTCOME_META.scheduled_next;
expect(m.group).toBe('close'); // 统计上算成功
expect(m.drivesStatus).toBe('keep'); // ⛔ 绝不能是 completed
});
test('⭐ 转化新预约:算「成功」且结案(顶栏「预约」快速记录落的就是它)', () => {
const m = EXECUTION_OUTCOME_META.success_appointed;
expect(m.group).toBe('close');
expect(m.drivesStatus).toBe('completed');
});
test('「保持」组里只剩真正没进展的(未接通 / 秒挂)', () => {
const keep = Object.entries(EXECUTION_OUTCOME_META)
.filter(([, m]) => m.group === 'keep' && !m.hiddenInForm)
.map(([k]) => k)
.sort();
expect(keep).toEqual(['no_answer', 'quick_hangup']);
});
test('⛔ 归到「成功」组的,要么结案、要么留单 —— 但**绝不能**是 abandoned', () => {
// abandoned 是"放弃",出现在成功组等于把放弃算成成功,统计当场反向
for (const [key, m] of Object.entries(EXECUTION_OUTCOME_META)) {
if (m.group === 'close') {
expect([key, m.drivesStatus]).not.toEqual([key, 'abandoned']);
}
}
});
test('每个 outcome 的 group 都必须是已登记的组(拼错会静默从面板消失)', () => {
const groups = new Set(Object.keys(EXECUTION_OUTCOME_GROUP_META));
for (const [key, m] of Object.entries(EXECUTION_OUTCOME_META)) {
expect([key, groups.has(m.group)]).toEqual([key, true]);
}
});
});
...@@ -42,6 +42,7 @@ import { ...@@ -42,6 +42,7 @@ import {
treatmentCategoryNameZhFor, treatmentCategoryNameZhFor,
diagnosisCodeNameZh, diagnosisCodeNameZh,
EXECUTION_OUTCOME_META, EXECUTION_OUTCOME_META,
ExecutionChannel,
RECALL_FEEDBACK_OPTIONS, RECALL_FEEDBACK_OPTIONS,
ABANDON_REASON_META, ABANDON_REASON_META,
personaFeatureSortKey, personaFeatureSortKey,
...@@ -54,6 +55,7 @@ import { ...@@ -54,6 +55,7 @@ import {
type ExecutionOutcome, type ExecutionOutcome,
} from '@pac/types'; } from '@pac/types';
import { AIStamp, Chip, PriorityBar, SidebarCard, tone } from './shared'; import { AIStamp, Chip, PriorityBar, SidebarCard, tone } from './shared';
import { QuickLogPopover } from './quick-log-popover';
import { PriorityHover, type PriorityBreakdown } from '@/components/priority-hover'; import { PriorityHover, type PriorityBreakdown } from '@/components/priority-hover';
import { HoverCard, HoverCardTrigger, HoverCardContent } from '@/components/ui/hover-card'; import { HoverCard, HoverCardTrigger, HoverCardContent } from '@/components/ui/hover-card';
import { shortPersonaValueLabel, compactPersonaValue } from './persona-display'; import { shortPersonaValueLabel, compactPersonaValue } from './persona-display';
...@@ -332,14 +334,19 @@ export function PlanDetailApp({ ...@@ -332,14 +334,19 @@ export function PlanDetailApp({
return treatmentCategoryNameZhFor(cat, sig?.toothPosition, sig?.patientAge ?? null); return treatmentCategoryNameZhFor(cat, sig?.toothPosition, sig?.patientAge ?? null);
})(); })();
/**
* 提交一条执行结果。
* @returns 是否落库成功 —— ⭐ 顶栏「预约 / 跟进」的快速记录要据此决定**跳不跳宿主**:
* 没落库就跳过去,客服会以为记过了,而这次改动的全部意义就是那条记录。
*/
const submitOutcome = async (formData: { const submitOutcome = async (formData: {
channel: string; channel: string;
outcome: string; outcome: string;
notes: string; notes: string;
scheduledNextAt: string; scheduledNextAt: string;
abandonReasons: string[]; abandonReasons: string[];
}) => { }): Promise<boolean> => {
if (!gateCheck()) return; // 认领闸 if (!gateCheck()) return false; // 认领闸
try { try {
const result = await submitExecution(plan.id, { const result = await submitExecution(plan.id, {
channel: formData.channel, channel: formData.channel,
...@@ -368,9 +375,11 @@ export function PlanDetailApp({ ...@@ -368,9 +375,11 @@ export function PlanDetailApp({
`本次为第 ${result.contactAttempts} 次,Plan 自动 abandoned`, `本次为第 ${result.contactAttempts} 次,Plan 自动 abandoned`,
); );
} }
return true;
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : String(err); const msg = err instanceof Error ? err.message : String(err);
showToast('rose', '提交失败', msg.slice(0, 80)); showToast('rose', '提交失败', msg.slice(0, 80));
return false;
} }
}; };
...@@ -416,10 +425,25 @@ export function PlanDetailApp({ ...@@ -416,10 +425,25 @@ export function PlanDetailApp({
openHostAction('OPEN_POTENTIAL_TREATMENT', hostActionCtx, potentialTreatmentPayload()); openHostAction('OPEN_POTENTIAL_TREATMENT', hostActionCtx, potentialTreatmentPayload());
} }
: undefined; : undefined;
/**
* 跟进 —— 先落一条「约定下次回访」,再跳宿主(理由同 createAppointment)。
*
* ⚠️ 这条 outcome 的 `drivesStatus` 是 **keep** —— 工单**不结案**,snooze 到那一天再浮现。
* 它归「成功」组只是**统计口径**(约到了下次是有效推进),⛔ 别据此以为该关单。
*/
const openReturnVisit = hostActionMode('OPEN_RETURN_VISIT') const openReturnVisit = hostActionMode('OPEN_RETURN_VISIT')
? () => { ? async (p: { scheduledNextAt?: string }): Promise<boolean> => {
if (!gateCheck()) return; const ok = await submitOutcome({
// ⛔ 同样不许默认成 'phone':不知道就记 other,别编触达方式
channel: ExecutionChannel.OTHER,
outcome: 'scheduled_next',
notes: '',
scheduledNextAt: p.scheduledNextAt ?? '',
abandonReasons: [],
});
if (!ok) return false;
openHostAction('OPEN_RETURN_VISIT', hostActionCtx); openHostAction('OPEN_RETURN_VISIT', hostActionCtx);
return true;
} }
: undefined; : undefined;
// 宿主回访模式:回访动作在宿主侧完成 → 隐藏 PAC 通话结果区,顶栏补「关闭」入口(关闭机会弹窗)。 // 宿主回访模式:回访动作在宿主侧完成 → 隐藏 PAC 通话结果区,顶栏补「关闭」入口(关闭机会弹窗)。
...@@ -468,10 +492,18 @@ export function PlanDetailApp({ ...@@ -468,10 +492,18 @@ export function PlanDetailApp({
showToast('rose', '关闭失败', msg.slice(0, 80)); showToast('rose', '关闭失败', msg.slice(0, 80));
} }
}; };
const createAppointment = () => { /**
if (!gateCheck()) return; // 认领闸:建预约是实打实的操作 * 新建预约 —— **先在 PAC 落一条执行结果,再跳宿主**。
*
* ⭐ 顺序不能反:跳走之后客服基本不回来(回写率 11% 就是这么来的),
* 写失败还跳过去的话,他会以为记过了。
* ⚠️ 落的是 `success_appointed` → `drivesStatus: 'completed'`,**这单当场结案**。
* 浮层里已经把这句话写给用户看了(⛔ 别删那行小字)。
*/
const createAppointment = async (p: { notes?: string }): Promise<boolean> => {
// 宿主 actionUrls.CREATE_APPOINTMENT(会话下发)→ 通用占位替换 → 打开宿主页。 // 宿主 actionUrls.CREATE_APPOINTMENT(会话下发)→ 通用占位替换 → 打开宿主页。
// 未配置则提示去配,不写死任何 URL。缺失键清空。 // 未配置则提示去配,不写死任何 URL。缺失键清空。
// ⚠️ URL 先解析:没配的话**不该先把单结了**再告诉他跳不过去。
const url = resolveActionUrl('CREATE_APPOINTMENT', { const url = resolveActionUrl('CREATE_APPOINTMENT', {
patientId: patient.externalId, patientId: patient.externalId,
brandId: patient.brandId, brandId: patient.brandId,
...@@ -480,11 +512,23 @@ export function PlanDetailApp({ ...@@ -480,11 +512,23 @@ export function PlanDetailApp({
}); });
if (!url) { if (!url) {
showToast('amber', '未配置新建预约', '请在宿主管理页配置 actionUrls.CREATE_APPOINTMENT'); showToast('amber', '未配置新建预约', '请在宿主管理页配置 actionUrls.CREATE_APPOINTMENT');
return; return false;
} }
// 认领闸在 submitOutcome 里(它是真正的写动作)
const ok = await submitOutcome({
// ⚠️ channel 记 `other`,⛔ 不许默认成 'phone' —— 我们**不知道**他是怎么触达的
// (可能先打了电话,也可能直接在系统里约)。编一个渠道会把触达方式的分布做脏。
channel: ExecutionChannel.OTHER,
outcome: 'success_appointed',
notes: p.notes ?? '',
scheduledNextAt: '',
abandonReasons: [],
});
if (!ok) return false;
// 不弹成功 toast —— 新标签页开出来用户自己看得见,再报一句是噪音(业务 2026-07-30)。 // 不弹成功 toast —— 新标签页开出来用户自己看得见,再报一句是噪音(业务 2026-07-30)。
// 失败路径仍有提示:未配置 → 上面那条 amber toast;被 sandbox 拦 → openHostUrl 里降级跳转。 // 失败路径仍有提示:未配置 → 上面那条 amber toast;被 sandbox 拦 → openHostUrl 里降级跳转。
openHostUrl(url); openHostUrl(url);
return true;
}; };
// ── 宠物引导 1:话术已生成 + 在本患者页停留 15s → 发话引导给话术打「是否好用」评价 ── // ── 宠物引导 1:话术已生成 + 在本患者页停留 15s → 发话引导给话术打「是否好用」评价 ──
...@@ -1065,10 +1109,13 @@ function TopBar({ ...@@ -1065,10 +1109,13 @@ function TopBar({
fmtRel?: (d: Date) => string; fmtRel?: (d: Date) => string;
/** 右上角动作:潜在治疗机会(actionUrls.OPEN_POTENTIAL_TREATMENT;未配置传 undefined → 不渲染) */ /** 右上角动作:潜在治疗机会(actionUrls.OPEN_POTENTIAL_TREATMENT;未配置传 undefined → 不渲染) */
onOpenPotential?: () => void; onOpenPotential?: () => void;
/** 右上角动作:新建预约(宿主 CREATE_APPOINTMENT 跳转) */ /**
onCreateAppointment?: () => void; * 右上角动作:新建预约 —— **先落一条执行结果,再跳宿主 CREATE_APPOINTMENT**。
/** 右上角动作:回访(actionUrls.OPEN_RETURN_VISIT;未配置传 undefined → 不渲染),主按钮样式 */ * 返回 false = 没落库 → 浮层不关、也不跳转(见 QuickLogPopover)。
onOpenReturnVisit?: () => void; */
onCreateAppointment?: (p: { notes?: string; scheduledNextAt?: string }) => Promise<boolean>;
/** 右上角动作:跟进 —— 先落「约定下次回访」,再跳宿主 OPEN_RETURN_VISIT。语义同上 */
onOpenReturnVisit?: (p: { notes?: string; scheduledNextAt?: string }) => Promise<boolean>;
/** 右上角动作:关闭机会(危险级;仅宿主回访模式下出现,替代通话结果区的关闭路径) */ /** 右上角动作:关闭机会(危险级;仅宿主回访模式下出现,替代通话结果区的关闭路径) */
onCloseOpportunity?: () => void; onCloseOpportunity?: () => void;
/** hover「回访」按钮时的引导回调(宠物发话引导做召回反馈) */ /** hover「回访」按钮时的引导回调(宠物发话引导做召回反馈) */
...@@ -1214,20 +1261,22 @@ function TopBar({ ...@@ -1214,20 +1261,22 @@ function TopBar({
</button> </button>
)} )}
{onCreateAppointment && ( {onCreateAppointment && (
<button // ⭐ 跳宿主**之前**先记一笔(见 QuickLogPopover 的文件头:回写率 11% 的解法)
type="button" <QuickLogPopover kind="appointment" onConfirm={onCreateAppointment}>
onClick={onCreateAppointment} <button
title="新建预约" type="button"
className="inline-flex items-center gap-1.5 rounded-md border border-slate-100 bg-white px-2 sm:px-2.5 py-1 text-[11.5px] font-medium text-slate-700 transition-colors hover:border-brand-300 hover:bg-brand-50 hover:text-brand-700" title="新建预约"
> className="inline-flex items-center gap-1.5 rounded-md border border-slate-100 bg-white px-2 sm:px-2.5 py-1 text-[11.5px] font-medium text-slate-700 transition-colors hover:border-brand-300 hover:bg-brand-50 hover:text-brand-700"
<CalendarPlus className="h-3.5 w-3.5" /> >
<span className="hidden sm:inline">预约</span> <CalendarPlus className="h-3.5 w-3.5" />
</button> <span className="hidden sm:inline">预约</span>
</button>
</QuickLogPopover>
)} )}
{onOpenReturnVisit && ( {onOpenReturnVisit && (
<QuickLogPopover kind="follow_up" onConfirm={onOpenReturnVisit}>
<button <button
type="button" type="button"
onClick={onOpenReturnVisit}
onMouseEnter={onHoverReturnVisit} onMouseEnter={onHoverReturnVisit}
// 按钮文案「跟进」而非「回访」(业务 2026-07-30):这个按钮跳的是宿主侧的动作页, // 按钮文案「跟进」而非「回访」(业务 2026-07-30):这个按钮跳的是宿主侧的动作页,
// 落到宿主那边不一定叫回访;而 PAC 里「回访」已被"诊所回访记录 / 历史联系"占着, // 落到宿主那边不一定叫回访;而 PAC 里「回访」已被"诊所回访记录 / 历史联系"占着,
...@@ -1239,6 +1288,7 @@ function TopBar({ ...@@ -1239,6 +1288,7 @@ function TopBar({
<CalendarClock className="h-3.5 w-3.5" /> <CalendarClock className="h-3.5 w-3.5" />
<span className="hidden sm:inline">跟进</span> <span className="hidden sm:inline">跟进</span>
</button> </button>
</QuickLogPopover>
)} )}
{onCloseOpportunity && ( {onCloseOpportunity && (
<button <button
......
'use client';
import { useState } from 'react';
import { Loader2 } from 'lucide-react';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { cn } from '@/lib/utils';
/**
* 「预约 / 跟进」跳宿主**之前**的快速记录 —— 吸附在按钮上的小浮层。
*
* ═══ 为什么要有它 ═══════════════════════════════════════════
* 这两个按钮跳走之后,客服在宿主那边把事办完就不回来了 —— 这正是
* `plan_executions` 回写率只有 **11%** 的根源(生产 65 个认领单只有 7 条执行结果)。
* 回写率低到这个程度,"成功率""退回率"这些数就全是猜的。
* ⇒ 把一次**必答**的极简输入挪到跳转之前:人还在 PAC 里、手还在键盘上,那一刻才录得到。
*
* ⚠️ **先落库、后跳转**。⛔ 反过来的话:写失败了人已经跳走,他会以为记过了,
* 而这次改动的全部意义就是那条记录。
* ⚠️ 只收**一个**必填字段。多一个都不行 —— 这是拦在人和他真正想做的事之间的一道门,
* 门越重,他越会去找绕开的路(而绕开的代价就是又回到 11%)。
*/
export function QuickLogPopover({
kind,
disabled,
children,
onConfirm,
}: {
kind: 'appointment' | 'follow_up';
disabled?: boolean;
/** 触发器 = 顶栏那颗按钮本体(样式不在这里管) */
children: React.ReactNode;
/** 落库 + 跳转。返回 false = 没落库(此时**不要**跳转,浮层保持打开让他重试) */
onConfirm: (payload: { notes?: string; scheduledNextAt?: string }) => Promise<boolean>;
}) {
const [open, setOpen] = useState(false);
const [notes, setNotes] = useState('');
const [date, setDate] = useState('');
const [time, setTime] = useState('10:00');
const [busy, setBusy] = useState(false);
const isAppt = kind === 'appointment';
// ⚠️ 必填校验放在**提交按钮的 disabled** 上,⛔ 不要提交后再报错 ——
// 浮层只有一个输入框,报错文案会把它撑成两倍高,而人已经知道自己没填。
const ready = isAppt ? notes.trim().length > 0 : date.length > 0 && time.length > 0;
const reset = () => {
setNotes('');
setDate('');
setTime('10:00');
};
const confirm = async () => {
if (!ready || busy) return;
setBusy(true);
try {
const ok = await onConfirm(
isAppt
? { notes: notes.trim() }
: // datetime-local 的形态('2026-08-10T10:00'),由上层统一转 ISO
{ scheduledNextAt: `${date}T${time}` },
);
if (ok) {
setOpen(false);
reset();
}
} finally {
setBusy(false);
}
};
return (
<Popover
open={open}
onOpenChange={(o) => {
if (disabled) return;
setOpen(o);
if (!o) reset();
}}
>
<PopoverTrigger asChild>{children}</PopoverTrigger>
<PopoverContent align="end" className="w-64 p-3">
<div className="space-y-2">
<div>
<p className="text-[12.5px] font-medium text-slate-800">
{isAppt ? '记一笔:已约到' : '记一笔:约好下次'}
</p>
{/* ⚠️ 后果必须写在这里。「预约」落的是 success_appointed —— 它会**把这单结案**,
人从「我的」里消失。不写清楚就是让他在不知情的情况下关掉一个工单。 */}
<p className="mt-0.5 text-[10.5px] leading-snug text-slate-400">
{isAppt
? '记为「转化新预约」并结案,随后打开宿主预约页'
: '记为「约定下次回访」,工单留到那天再浮现'}
</p>
</div>
{isAppt ? (
<textarea
autoFocus
rows={3}
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="约的什么、什么时候(必填)"
className="w-full resize-none rounded-md border border-slate-200 px-2 py-1.5 text-[12.5px] outline-none placeholder:text-slate-300 focus:border-brand-400"
/>
) : (
<div className="flex items-center gap-1.5">
<input
autoFocus
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
className="min-w-0 flex-1 rounded-md border border-slate-200 px-2 py-1.5 text-[12.5px] outline-none focus:border-brand-400"
/>
<input
type="time"
value={time}
onChange={(e) => setTime(e.target.value)}
className="w-[86px] flex-none rounded-md border border-slate-200 px-2 py-1.5 text-[12.5px] outline-none focus:border-brand-400"
/>
</div>
)}
<div className="flex items-center justify-end gap-1.5 pt-0.5">
<button
type="button"
onClick={() => setOpen(false)}
className="rounded-md px-2 py-1 text-[11.5px] text-slate-500 hover:bg-slate-50"
>
取消
</button>
<button
type="button"
disabled={!ready || busy}
onClick={confirm}
className={cn(
'inline-flex items-center gap-1 rounded-md px-2.5 py-1 text-[11.5px] font-medium text-white transition-colors',
ready && !busy ? 'bg-brand-600 hover:bg-brand-700' : 'cursor-not-allowed bg-slate-200',
)}
>
{busy && <Loader2 className="h-3 w-3 animate-spin" />}
记录并前往
</button>
</div>
</div>
</PopoverContent>
</Popover>
);
}
...@@ -622,10 +622,21 @@ export const EXECUTION_OUTCOME_META: Record< ...@@ -622,10 +622,21 @@ export const EXECUTION_OUTCOME_META: Record<
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 },
declined_recent: { group: 'give_up', labelZh: '再考虑', tone: 'rose', drivesStatus: 'abandoned', suppressDays: 7 }, // 非终态:7天冷静期后自动浮现(plan 仍「进行中」) declined_recent: { group: 'give_up', labelZh: '再考虑', tone: 'rose', drivesStatus: 'abandoned', suppressDays: 7 }, // 非终态:7天冷静期后自动浮现(plan 仍「进行中」)
/**
* ⭐ 约定下次回访 —— **归「成功」组,但工单不结案**(2026-08-04 产品定)。
*
* ⚠️ `group` 与 `drivesStatus` 在这一项上**故意不一致**,这不是笔误:
* · `group: 'close'` —— 统计口径:约到了下次,是**有效推进**,不该跟"未接通/秒挂"
* 一起算进「保持」。主管看成功率时要看得见这一类。
* · `drivesStatus: 'keep'` —— 状态机:工单**必须留着**并 snooze 到回访日。
* ⛔ 千万别顺手改成 'completed' —— 那会让这单直接出池,
* 而客服刚刚答应了患者"那天再联系您",约好的回访再也不会发生。
* ⇒ 改动只影响**怎么归类**,不影响**工单去哪儿**。
*/
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 }, // 接通秒挂,算保持,很快再试 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 }, 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 },
......
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