Commit 25fc944e by luoqi

feat(recall): 识别不准确也改永久 + 认领入口收口到列表页 + 潜在/预约过闸

三处业务反馈跟进。

## 1. 「机会识别不准确」14d → 永久

原 14d 的理由是"算法修好后本就该重新召回",但那是**我们**的排期,不该由
患者兜着 —— 两周后同一条不准的召回原样弹回来,客服只会再关一次再骂一次,
而算法多半还没改。真要在修复后放回来,应由「改完规则 → 定向重算/解除抑制」
这条显式动作驱动,而不是赌"两周内一定修好"的定时器。

至此 WEAK_SIGNAL_SUPPRESS_DAYS(14d)的两个使用者(treated / inaccurate)
全部转永久 → **删除该常量**,免得后来者把新原因挂到一个已被推翻的假设上。

## 2. 认领入口收口到列表页

详情页顶栏的「认领」按钮去掉,改为**只读**徽标(未认领 · 仅查看 / 仅查看)。
同一动作两个入口会让状态同步和归属口径都变复杂;列表页本就有行内认领 +
批量认领,那里才是自然入口。拦截文案同步改为指回列表。

️ 随之出现的缺口已一并补上:列表认领只 patchItem 本地行,详情页不会知道
   → 刚认领完回详情点操作仍被拦。为此给 plan-sync-store 加**反向通道**
   notifyClaim(列表 → 详情),认领/返池后详情页的闸即时解锁/上锁。
   assigneeUserId 用 undefined 表示"本次事件与认领无关"(不能用 null,
   那会被当成已返池而误上锁)。

## 3. 「潜在」「预约」也过认领闸

顶栏跳宿主的三个动作(潜在/预约/回访)现在口径统一:全部过闸。
「潜在」虽是查看,但看完顺手就在宿主侧动手了,而 PAC 这边没认领 = 没归属人,
回头对不上账。统一口径比"哪个算查看"的细分更好维护。

## 验证
- 314 单测通过(抑制期用例改用 unreachable/wrong_number 的 30d 档
  继续锁住"短档不被 outcome 60d 盖掉"这条红线)
- 本地真实患者(吕学文 90 / 李石明 78,已从本地 CH 摄入)实测:
  未认领 → 顶栏「未认领 · 仅查看」徽标、点「潜在」被拦并提示去列表认领;
  列表点认领 → 详情页徽标即时消失、「关闭机会」弹窗正常打开(未重拉页面)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
parent 435cd818
......@@ -4,7 +4,6 @@ import {
BREAKER_SUPPRESS_DAYS,
ABANDON_REASON_META,
abandonReasonsFor,
WEAK_SIGNAL_SUPPRESS_DAYS,
} from '@pac/types';
import { resolveSnoozedUntil } from '../src/modules/plan/recall-suppression';
import {
......@@ -212,37 +211,41 @@ describe('放弃原因细分抑制窗(ABANDON_REASON_META.suppressDays,多选取
expect(snooze([])).toBe(60);
});
test('⭐ 弱语义原因单独勾选 → 短档 14d,不被 outcome 的 60d 盖掉', () => {
// 「识别不准确」是 PAC 自己的信号有问题,不该按「患者拒绝」的重档罚人。
// 这条锁住 max 的种子不能是 outcome 默认档(否则 14d 永远生效不了)。
expect(snooze(['inaccurate'])).toBe(WEAK_SIGNAL_SUPPRESS_DAYS);
test('⭐ 短档原因单独勾选 → 用自己的短档,不被 outcome 的 60d 盖掉', () => {
// 这条锁住 max 的种子不能是 outcome 默认档(否则任何比 60d 短的档都永远生效不了)。
// 「无法联系客户」/「号码空号」是"暂时够不着",不是拒绝,短冷静期后该再试。
expect(snooze(['unreachable'])).toBe(30);
expect(snooze(['wrong_number'])).toBe(30);
});
test('⭐ 「已完成治疗」= 永久,不是 14d —— 客服断言临床事实,不该两周后再问一遍', () => {
// 2026-07 业务反馈驱动的改动:原 14d 档押注「DW 同步 + 排除闸接手」,生产实测证伪
// (缺牙两例:DW 无完成记录 / 义齿在上颌而召回在下颌,排除闸都接不住)。
// 也修掉了原先的不自洽:「已在外院治疗」永久,「已完成治疗」却 14d。
test('⭐ 「已完成治疗」+「机会识别不准确」= 永久(2026-07 由 14d 改)', () => {
// 原 14d 弱语义档押注「等一个 DW 同步周期 + 排除闸接手」,生产实测证伪:
// 缺牙两例(DW 无完成记录 / 义齿在上颌而召回在下颌)排除闸都接不住,到期只会
// 原样弹回来再挨一次投诉。「识别不准确」同理 —— 算法什么时候修好是我们的排期,
// 不该由患者兜着。两者都改永久后,WEAK_SIGNAL_SUPPRESS_DAYS 常量已删除。
expect(snooze(['treated'])).toBe(SUPPRESS_PERMANENT_DAYS);
expect(snooze(['inaccurate'])).toBe(SUPPRESS_PERMANENT_DAYS);
// 与「已在外院治疗」同档 —— 在别家做完 vs 在自己家做完,抑制强度应一致
expect(snooze(['treated'])).toBe(snooze(['treated_elsewhere']));
});
test('多选取 max —— 每个勾中的原因都独立成立,取最严的', () => {
// 同时勾「识别不准确(14d)」+「选择竞品机构(永久)」:人确实去别家了,
// 不能因为信号也有问题就 14 天后再召回。
// 勾中任一永久档就不能被短档拉回来
expect(snooze(['inaccurate', 'competitor'])).toBe(SUPPRESS_PERMANENT_DAYS);
expect(snooze(['unreachable', 'inaccurate'])).toBe(30);
expect(snooze(['unreachable', 'inaccurate'])).toBe(SUPPRESS_PERMANENT_DAYS);
// 两个非永久档之间仍取大的
expect(snooze(['unreachable', 'no_intent'])).toBe(60); // max(30, 60 默认)
});
test('没配 suppressDays 的原因 → 参与 max 时用 outcome 默认档', () => {
expect(snooze(['no_intent'])).toBe(60); // 关闭弹窗侧
expect(snooze(['patient_refused'])).toBe(60); // PAC 表单侧
expect(snooze(['inaccurate', 'no_intent'])).toBe(60); // max(14, 60)
expect(snooze(['wrong_number', 'no_intent'])).toBe(60); // max(30, 60)
});
test('未知 key 被忽略;全是未知 → 退回 outcome 默认档', () => {
expect(snooze(['bogus'])).toBe(60);
expect(snooze(['bogus', 'inaccurate'])).toBe(WEAK_SIGNAL_SUPPRESS_DAYS);
expect(snooze(['bogus', 'unreachable'])).toBe(30);
});
test('熔断优先级仍最高', () => {
......
......@@ -201,11 +201,15 @@ export function PlanDetailApp({
? 'mine'
: 'other';
const canOperate = claimState === 'mine';
/** 操作前置校验:放行返 true;拦下则弹提示并返 false(调用方 `if (!gateCheck()) return;`) */
/**
* 操作前置校验:放行返 true;拦下则弹提示并返 false(调用方 `if (!gateCheck()) return;`)。
* ⭐ 认领入口只留**列表页**那一处(左栏行内「认领」/ 批量认领)—— 详情页顶栏不再放认领按钮,
* 避免同一动作两个入口、两套状态同步。所以文案要把人**指回列表**,否则等于只说"不行"不说去哪。
*/
const gateCheck = (): boolean => {
if (canOperate) return true;
if (claimState === 'unclaimed') {
showToast('amber', '请先认领后再操作', '这条召回还没人认领,点顶栏「认领」接手后即可操作');
showToast('amber', '请先认领后再操作', '这条召回还没人认领,在左侧列表点该患者的「认领」接手后即可操作');
} else {
showToast('amber', '无法操作', `该召回已被其他同事认领(${assigneeUserId}),如需接手请让其返池`);
}
......@@ -213,23 +217,14 @@ export function PlanDetailApp({
};
const claimGate: ClaimGate = { canOperate, claimState, check: gateCheck };
// 认领(= 指派给自己,跟列表页「认领」同一个接口)
const [claiming, setClaiming] = useState(false);
const handleClaim = async () => {
if (claiming || !meId) return;
setClaiming(true);
try {
await plansApi.assign(plan.id, meId);
// 就地解锁,不等重拉
setPlanOverride((p) => ({ ...p, status: 'assigned', assigneeUserId: meId }));
usePlanSyncStore.getState().notify(plan.id, 'assigned');
showToast('emerald', '认领成功', '现在可以操作这条召回了');
} catch (e) {
showToast('rose', '认领失败', e instanceof Error ? e.message.slice(0, 80) : String(e));
} finally {
setClaiming(false);
}
};
// 反向通道(列表 → 详情):在列表点了「认领」/「返池」→ 本页认领闸即时解锁 / 上锁,
// 不用等重拉。assigneeUserId===undefined 表示该事件跟认领无关(如提交执行),跳过。
const syncSeq = usePlanSyncStore((s) => s.seq);
useEffect(() => {
const { planId: changedId, assigneeUserId: nextAssignee } = usePlanSyncStore.getState();
if (changedId !== plan.id || nextAssignee === undefined) return;
setPlanOverride((p) => ({ ...p, assigneeUserId: nextAssignee }));
}, [syncSeq, plan.id]);
// 流式 done / error 时弹 toast
useEffect(() => {
......@@ -367,10 +362,15 @@ export function PlanDetailApp({
clinicId: plan?.targetClinicId,
medicalRecordNumber: patient.medicalRecordNumber,
};
// 顶栏跳宿主的三个动作(潜在 / 预约 / 回访)**全部过认领闸**:
// 它们都会把人带进宿主系统对这个患者动手(哪怕「潜在」只是看,看完顺手就在宿主侧操作了),
// 而 PAC 这边没认领 = 没有归属人,回头对不上账。口径统一比"哪个算查看"的细分更好维护。
const openPotential = hostActionMode('OPEN_POTENTIAL_TREATMENT')
? () => openHostAction('OPEN_POTENTIAL_TREATMENT', hostActionCtx)
? () => {
if (!gateCheck()) return;
openHostAction('OPEN_POTENTIAL_TREATMENT', hostActionCtx);
}
: undefined;
// 「回访」跳宿主侧真正执行回访 → 过认领闸;「潜在」是纯查看,不拦(见下方 openPotential)
const openReturnVisit = hostActionMode('OPEN_RETURN_VISIT')
? () => {
if (!gateCheck()) return;
......@@ -506,8 +506,6 @@ export function PlanDetailApp({
: undefined
}
claimGate={claimGate}
onClaim={handleClaim}
claiming={claiming}
/>
</HeaderSlotPortal>
......@@ -979,17 +977,13 @@ function TopBar({
onHoverReturnVisit,
onCloseOpportunity,
claimGate,
onClaim,
claiming,
}: {
plan: typeof mockPlan;
reason: typeof mockPlan.reasons[0];
patientId?: string;
/** 认领闸(见 ClaimGate);未传 = 不做认领约束(mock / 独立预览场景) */
/** 认领闸(见 ClaimGate);未传 = 不做认领约束(mock / 独立预览场景)。
* 顶栏只用它渲染**只读**徽标 + 传给召回反馈控件;认领动作在列表页,不在这。 */
claimGate?: ClaimGate;
/** 认领动作 —— 仅 claimState='unclaimed' 时在顶栏渲染按钮 */
onClaim?: () => void;
claiming?: boolean;
onRefreshAggregate?: () => void | Promise<void>;
showToast?: (kind: string, title: string, msg: string) => void;
fmtRel?: (d: Date) => string;
......@@ -1078,30 +1072,23 @@ function TopBar({
claimGate={claimGate}
/>
)}
{/* 认领态徽标 —— 让"为什么点不动"在点之前就看得见,不用等 toast 解释。
未认领 → 可点的「认领」按钮(闸的唯一出口);他人认领 → 只读徽标。 */}
{claimGate?.claimState === 'unclaimed' && onClaim && (
<button
type="button"
onClick={onClaim}
disabled={claiming}
title="认领后才能操作这条召回(提交结果 / 关闭机会 / 重生成话术 / 打反馈)"
{/* 认领态徽标(**只读**,不是入口)—— 让"为什么点不动"在点之前就看得见。
认领动作统一收口到列表页,顶栏不放按钮:同一动作两个入口会让状态同步和归属口径都变复杂。 */}
{claimGate && claimGate.claimState !== 'mine' && (
<span
title={
claimGate.claimState === 'unclaimed'
? '未认领 —— 只能查看;在左侧列表点该患者的「认领」接手后才能操作'
: '该召回已被其他同事认领,你只能查看'
}
className={cn(
'inline-flex flex-none items-center gap-1 rounded-md px-2 py-1 text-[11.5px] font-medium transition-colors',
claiming
? 'cursor-not-allowed bg-slate-100 text-slate-400'
: 'bg-amber-500 text-white hover:bg-amber-600',
'inline-flex flex-none items-center rounded-md px-2 py-1 text-[11.5px] font-medium',
claimGate.claimState === 'unclaimed'
? 'bg-amber-50 text-amber-700'
: 'bg-slate-100 text-slate-500',
)}
>
{claiming ? '认领中…' : '认领'}
</button>
)}
{claimGate?.claimState === 'other' && (
<span
title="该召回已被其他同事认领,你只能查看"
className="inline-flex flex-none items-center gap-1 rounded-md bg-slate-100 px-2 py-1 text-[11.5px] font-medium text-slate-500"
>
仅查看
{claimGate.claimState === 'unclaimed' ? '未认领 · 仅查看' : '仅查看'}
</span>
)}
</div>
......
......@@ -144,6 +144,8 @@ export function PatientPickerRail({
await plansApi.assign(planId, sub);
if (view === 'pool') removeItem(planId);
else patchItem(planId, { status: 'assigned', assigneeUserId: sub });
// 反向通知详情页:认领入口只在本列表,详情页的操作闸要靠这条即时解锁
usePlanSyncStore.getState().notifyClaim(planId, sub);
toast.success(`已认领 ${name}`);
} catch (err) {
toast.error(err instanceof Error ? err.message : '认领失败');
......@@ -158,6 +160,8 @@ export function PatientPickerRail({
await plansApi.recycle(planId);
if (view === 'mine') removeItem(planId);
else patchItem(planId, { status: 'active', assigneeUserId: null });
// 反向通知详情页:返池后要把闸重新上锁,否则还停在详情页的人仍能操作已交还的单
usePlanSyncStore.getState().notifyClaim(planId, null);
toast.success(`已返池 ${name}`);
} catch (err) {
toast.error(err instanceof Error ? err.message : '返池失败');
......
......@@ -3,8 +3,12 @@
import { create } from 'zustand';
/**
* 工作台跨栏同步 — 详情页(右)动作改了 plan 状态后,通知左栏选患者列原地更新。
* 极简事件总线:seq 自增触发订阅;消费方按 planId patch 对应行(不重拉、不丢滚动)。
* 工作台跨栏同步 — 极简事件总线:seq 自增触发订阅;消费方按 planId patch 对应行
* (不重拉、不丢滚动)。**双向**:
* · 详情(右)→ 列表(左):`notify` —— 提交执行后同步行状态 / outcome 角标
* · 列表(左)→ 详情(右):`notifyClaim` —— 认领 / 返池后同步认领人,
* 让详情页的认领闸即时解锁或上锁。认领入口只在列表页,没这条反向通道的话,
* 刚认领完回详情点操作仍会被拦(要等重拉才生效)。
*/
interface PlanSyncState {
seq: number;
......@@ -13,7 +17,13 @@ interface PlanSyncState {
status: string | null;
/** 本次提交的执行 outcome(左栏「成功/不成功/保持」角标即时反映);null = 未变 */
outcome: string | null;
/** 新认领人(认领=userId / 返池=null)。
* ⚠️ `undefined` = 本次事件跟认领无关,消费方**不要动**自己的认领态 ——
* 不能用 null 表达"无关",那会被当成"已返池"而误上锁。 */
assigneeUserId: string | null | undefined;
notify: (planId: string, status?: string, outcome?: string) => void;
/** 列表页认领 / 返池后调用(反向通道) */
notifyClaim: (planId: string, assigneeUserId: string | null) => void;
/** 当前工作台正在看的患者(详情页 ready 时发布;右下角助手按它出场景化开场建议) */
current: { planId: string; patientName: string } | null;
setCurrent: (current: { planId: string; patientName: string } | null) => void;
......@@ -24,8 +34,24 @@ export const usePlanSyncStore = create<PlanSyncState>((set) => ({
planId: null,
status: null,
outcome: null,
assigneeUserId: undefined,
notify: (planId, status, outcome) =>
set((s) => ({ seq: s.seq + 1, planId, status: status ?? null, outcome: outcome ?? null })),
set((s) => ({
seq: s.seq + 1,
planId,
status: status ?? null,
outcome: outcome ?? null,
// 执行类事件不表态认领人 → undefined,详情页据此跳过认领态更新
assigneeUserId: undefined,
})),
notifyClaim: (planId, assigneeUserId) =>
set((s) => ({
seq: s.seq + 1,
planId,
status: assigneeUserId ? 'assigned' : 'active',
outcome: null,
assigneeUserId,
})),
current: null,
setCurrent: (current) => set({ current }),
}));
......@@ -528,12 +528,10 @@ export type ExecutionOutcomeTone = 'emerald' | 'amber' | 'sky' | 'slate' | 'rose
/// 用大值而非 Infinity,方便落库 / 算 cutoff;100 年足够覆盖业务生命周期。
export const SUPPRESS_PERMANENT_DAYS = 36500;
/// 「弱语义」放弃原因的短抑制档 —— 现仅用于「机会识别不准确」:问题出在**我们的算法**,
/// 修好规则后本就该重新召回,压太久等于把修复效果一起压掉;不该按「患者拒绝」的重档罚人。
/// 介于「再考虑 7d」与「明确拒绝 90d」之间。
/// ⚠️ 「已完成治疗」2026-07 已移出本档改永久 —— 那是对**临床事实**的断言,不是算法问题,
/// 且实测排除闸接不住(理由见 ABANDON_REASON_META.treated)。
export const WEAK_SIGNAL_SUPPRESS_DAYS = 14;
/// ⚠️ 原「弱语义短抑制档」WEAK_SIGNAL_SUPPRESS_DAYS(14d)2026-07 已删除。
/// 它只服务过「已完成治疗」和「机会识别不准确」两个原因,两者现已全部改永久:
/// 该档押的注是「等一个 DW 同步周期 + 排除闸接手」,生产实测证伪(见 ABANDON_REASON_META
/// 对应两条注释)。留着只会引诱后来者把新原因挂上一个已被推翻的假设,故不保留。
/// 熔断(连续未接通累计达上限强制 abandoned)的抑制天数 —— 不是"拒绝",是"暂时联系不上",
/// 短冷静期后允许重新进池(后续可接换渠道策略)。
......@@ -710,7 +708,12 @@ export const ABANDON_REASON_META: Record<
// ── 宿主关闭弹窗(文案按宿主口径)──
no_intent: { labelZh: '客户无意愿', desc: '客户明确表示没有治疗意愿', shownIn: 'close' }, // 60d
inaccurate: { labelZh: '机会识别不准确', desc: '识别的治疗机会与患者实际情况不符', shownIn: 'close', suppressDays: WEAK_SIGNAL_SUPPRESS_DAYS },
// ⭐ 永久(2026-07 由 14d 改):客服断言"PAC 这条召回identify错了"。原 14d 的理由是
// "算法修好后本就该重新召回",但那是**我们**的排期,不该由患者兜着 —— 两周后同一条
// 不准的召回原样弹回来,客服只会再关一次、再骂一次,而算法多半还没改。
// 真要在修复后放回来,应由「改完规则 → 定向重算/解除抑制」这条显式动作驱动,
// 而不是靠一个赌"两周内一定修好"的定时器。
inaccurate: { labelZh: '机会识别不准确', desc: '识别的治疗机会与患者实际情况不符', shownIn: 'close', suppressDays: SUPPRESS_PERMANENT_DAYS },
competitor: { labelZh: '选择竞品机构', desc: '客户已选择或倾向其他机构治疗', shownIn: 'close', suppressDays: SUPPRESS_PERMANENT_DAYS },
price: { labelZh: '价格因素', desc: '客户因价格原因暂不考虑治疗', shownIn: 'close' }, // 60d
// ⭐ 永久(2026-07 由 14d 改):客服断言"这个机会的治疗已经做完了" —— 这是**临床事实判断**,
......
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