Commit e4ace755 by luoqi

feat(plan): 「机会识别不准确」补必填多选 —— 哪几类推荐治疗不准(仅统计)

落表:plan_executions 加一列 inaccurate_treatments TEXT[] NOT NULL DEFAULT '{}'。
  · **加列不建表**:它是 abandon_reasons 里 'inaccurate' 那项的限定词,不是独立事实 ——
    同一次提交、同一条 execution、基数 ≤8。单独建表只多一次 join。
  · **存 code 不存中文**:这列唯一价值是可统计(哪类召回最容易被判不准 → 回去改规则)。
    中文措辞两天内改过三轮(潜在种植→种植治疗→种植),存中文等于把当时措辞烤进历史数据。
    (同类教训:原 abandon_other 自由文本列就是因为"只能取到字面量、统计不了"被删的。)
  · **不塞 notes**:自由文本统计不了,正好废掉这个字段的唯一价值。

️ 按业务口径明确两条,都写进 schema 注释免得后来人误解:
  ① **不参与抑制**。抑制仍是信号级、按 plan 全部 reason 一起压 —— 客服选"只有种植不准"
     不会只放过根管那条。看到这列别以为闸接上了。
  ② **跟「其他原因」不构成关联**。只在勾了 inaccurate 时收集/落库;后端落库时再判一次,
     免得前端残留勾选污染统计口径。

必填校验前后端各一道。服务端那道不是冗余:这条反馈事后补不回来(没人会为已结案的单再来一遍),
收进来一批空的就等于白填。

候选项 = 本患者画像 potential_treatment.types(同召回池卡片那排标签的来源),所以每人不同;
中文在前端查 POTENTIAL_TREATMENT_CARD_LABEL(改措辞即时生效)。
患者没有潜在治疗标签时不卡必填 —— 理论上不该发生,真遇到宁可放行,不让客服卡在填不了的必填项上。

708 tests / 46 suites + 两端 typecheck 通过。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
parent ea66930a
-- 「机会识别不准确」限定:哪几类推荐治疗不准(plan_executions.inaccurate_treatments)
--
-- 【为什么加列而不是建表】它是 abandon_reasons 里 'inaccurate' 那一项的**限定词**,不是独立事实:
-- 同一次提交、同一条 execution、生命周期完全依附那行,基数 ≤8(画像潜在治疗共 8 类)。
-- 单独建表只多一次 join,换不来任何东西。
--
-- 【为什么存 code 不存中文】这列唯一的价值是**可统计**(哪类召回最容易被判不准 → 回去改规则)。
-- 中文措辞两天内改过三轮(潜在种植 → 种植治疗 → 种植),存中文等于把当时的措辞烤进历史数据,
-- 三个月后统计要 `WHERE label IN (三种写法)`。存 implant / endo / perio… 这种稳定键。
-- (同类教训:原来的 abandon_other 自由文本列就是因为"只能取到字面量、统计不了"被删的。)
--
-- 【⚠️ 不参与抑制】业务 2026-07-30 明确:只作统计 / 算法改进输入。抑制仍是信号级、
-- 按 plan 的全部 reason 一起压(plan-engine.fetchSnoozedSignalKeys)—— 客服选"只有种植不准"
-- **不会**只放过根管那条。看到这列别以为闸已经接上了。
--
-- 【默认值】DEFAULT '{}' 而不是允许 NULL:数组列的空态就用空数组,省掉全站 `?? []`。
-- 存量行自动补 '{}',无需回填。加列 + 常量默认在 PG11+ 是元数据操作,不重写表。
ALTER TABLE "plan_executions"
ADD COLUMN IF NOT EXISTS "inaccurate_treatments" TEXT[] NOT NULL DEFAULT '{}';
...@@ -1303,6 +1303,18 @@ model PlanExecution { ...@@ -1303,6 +1303,18 @@ model PlanExecution {
/// 它只可能取到字面量"其他",信息已被 abandonReasons 'other' 覆盖(2026-07) /// 它只可能取到字面量"其他",信息已被 abandonReasons 'other' 覆盖(2026-07)
abandonReasons String[] @map("abandon_reasons") abandonReasons String[] @map("abandon_reasons")
/// 「机会识别不准确」的限定:客服勾了**哪几类推荐治疗**不准。
/// 存画像 potential_treatment code(implant / endo / perio),**不存中文** ——
/// 中文措辞两天内改过三轮(潜在种植→种植治疗→种植),存中文等于把当时的措辞烤进历史数据,
/// 之后统计要 `WHERE label IN (三种写法)`code 是稳定键。
///
/// ⚠️ **本列只作统计 / 算法改进的输入,不参与抑制计算**(业务 2026-07-30 明确)
/// 抑制仍是信号级、按 plan 的全部 reason 一起压( plan-engine.fetchSnoozedSignalKeys):
/// 客服选"只有种植不准"**不会**只放过根管那条。别看到这列就以为闸已经接上了。
///
/// 只在 abandonReasons 'inaccurate' 时非空;其他原因( other)不收集这个 —— 两者不构成关联。
inaccurateTreatments String[] @map("inaccurate_treatments")
/// outcome=scheduled_next ,下次回访时间 /// outcome=scheduled_next ,下次回访时间
scheduledNextAt DateTime? @map("scheduled_next_at") @db.Timestamptz(3) scheduledNextAt DateTime? @map("scheduled_next_at") @db.Timestamptz(3)
......
...@@ -53,6 +53,8 @@ export interface SubmitExecutionInput { ...@@ -53,6 +53,8 @@ export interface SubmitExecutionInput {
outcome: string; outcome: string;
notes?: string; notes?: string;
abandonReasons?: string[]; abandonReasons?: string[];
/** 「机会识别不准确」勾中的治疗 code(仅统计,不参与抑制);选了 inaccurate 必填 */
inaccurateTreatments?: string[];
scheduledNextAt?: string; scheduledNextAt?: string;
/** 显式覆盖执行诊所;不传则用 plan.targetClinicId 或 scope 第一个 clinicId */ /** 显式覆盖执行诊所;不传则用 plan.targetClinicId 或 scope 第一个 clinicId */
executorClinicId?: string; executorClinicId?: string;
...@@ -120,6 +122,13 @@ export class ExecutionService { ...@@ -120,6 +122,13 @@ export class ExecutionService {
throw new BadRequestException(`未知 outcome=${input.outcome}`); throw new BadRequestException(`未知 outcome=${input.outcome}`);
} }
// ⭐ 选了「机会识别不准确」必须说清是哪几类治疗不准 —— **服务端也拦**,不能只信前端:
// 这条数据的唯一用途是回头统计"哪类召回最容易被判不准"去改规则,收进来一批空的
// 就等于这次反馈白填了,而且事后补不回来(客服不会为一条已结案的单再来一遍)。
if (input.abandonReasons?.includes('inaccurate') && !input.inaccurateTreatments?.length) {
throw new BadRequestException('选了「机会识别不准确」时,必须勾选具体哪些推荐治疗不准');
}
const nextContactAttempts = plan.contactAttempts + 1; const nextContactAttempts = plan.contactAttempts + 1;
let newStatus: string = plan.status; let newStatus: string = plan.status;
let breakerTripped = false; let breakerTripped = false;
...@@ -164,6 +173,11 @@ export class ExecutionService { ...@@ -164,6 +173,11 @@ export class ExecutionService {
outcome: input.outcome, outcome: input.outcome,
notes: input.notes ?? null, notes: input.notes ?? null,
abandonReasons: input.abandonReasons ?? [], abandonReasons: input.abandonReasons ?? [],
// 只在选了 inaccurate 时落 —— 其他原因不构成关联(业务 2026-07-30),
// 免得前端残留的勾选被顺带写进去,污染统计口径
inaccurateTreatments: input.abandonReasons?.includes('inaccurate')
? (input.inaccurateTreatments ?? [])
: [],
scheduledNextAt: input.scheduledNextAt ? new Date(input.scheduledNextAt) : null, scheduledNextAt: input.scheduledNextAt ? new Date(input.scheduledNextAt) : null,
}, },
select: { id: true }, select: { id: true },
...@@ -247,6 +261,7 @@ export class ExecutionService { ...@@ -247,6 +261,7 @@ export class ExecutionService {
outcome: e.outcome as ExecutionOutcome, outcome: e.outcome as ExecutionOutcome,
notes: e.notes, notes: e.notes,
abandonReasons: e.abandonReasons, abandonReasons: e.abandonReasons,
inaccurateTreatments: e.inaccurateTreatments,
scheduledNextAt: e.scheduledNextAt?.toISOString() ?? null, scheduledNextAt: e.scheduledNextAt?.toISOString() ?? null,
createdAt: e.createdAt.toISOString(), createdAt: e.createdAt.toISOString(),
})); }));
......
...@@ -904,6 +904,7 @@ function serializeExecution(e: PlanExecutionRow): import('@pac/types').PlanExecu ...@@ -904,6 +904,7 @@ function serializeExecution(e: PlanExecutionRow): import('@pac/types').PlanExecu
outcome: e.outcome as import('@pac/types').ExecutionOutcome, outcome: e.outcome as import('@pac/types').ExecutionOutcome,
notes: e.notes, notes: e.notes,
abandonReasons: e.abandonReasons, abandonReasons: e.abandonReasons,
inaccurateTreatments: e.inaccurateTreatments,
scheduledNextAt: e.scheduledNextAt?.toISOString() ?? null, scheduledNextAt: e.scheduledNextAt?.toISOString() ?? null,
createdAt: e.createdAt.toISOString(), createdAt: e.createdAt.toISOString(),
}; };
......
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import { ABANDON_REASON_META, AbandonReason, abandonReasonsFor } from '@pac/types'; import {
ABANDON_REASON_META,
AbandonReason,
abandonReasonsFor,
potentialTreatmentCardLabel,
} from '@pac/types';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
...@@ -33,14 +38,21 @@ export function CloseOpportunityDialog({ ...@@ -33,14 +38,21 @@ export function CloseOpportunityDialog({
open, open,
onOpenChange, onOpenChange,
onConfirm, onConfirm,
potentialTreatments = [],
}: { }: {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
/** 确认关闭(原因多选 + 需要说明时的文字)。 */ /** 确认关闭(原因多选 + 需要说明时的文字 + 「识别不准确」勾中的治疗 code)。 */
onConfirm?: (reasons: CloseReasonKey[], note?: string) => void; onConfirm?: (reasons: CloseReasonKey[], note?: string, inaccurateTreatments?: string[]) => void;
/**
* 本患者的潜在治疗 **code**(画像 potential_treatment.types)—— 「机会识别不准确」的候选项。
* 跟召回池卡片那排标签同一个来源,所以每个患者不一样;中文在这里查表(改措辞即时生效)。
*/
potentialTreatments?: string[];
}) { }) {
const [reasons, setReasons] = useState<CloseReasonKey[]>([]); const [reasons, setReasons] = useState<CloseReasonKey[]>([]);
const [note, setNote] = useState(''); const [note, setNote] = useState('');
const [inaccurate, setInaccurate] = useState<string[]>([]);
const toggle = (key: CloseReasonKey) => const toggle = (key: CloseReasonKey) =>
setReasons((prev) => (prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key])); setReasons((prev) => (prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key]));
...@@ -48,11 +60,23 @@ export function CloseOpportunityDialog({ ...@@ -48,11 +60,23 @@ export function CloseOpportunityDialog({
const selected = CLOSE_REASONS.filter((r) => reasons.includes(r.key)); const selected = CLOSE_REASONS.filter((r) => reasons.includes(r.key));
// 勾中任一 needNote 项(目前只有「其他」)→ 说明必填 // 勾中任一 needNote 项(目前只有「其他」)→ 说明必填
const needNote = selected.some((r) => r.needNote === true); const needNote = selected.some((r) => r.needNote === true);
const canConfirm = reasons.length > 0 && (!needNote || note.trim().length > 0); // ⭐ 勾了「机会识别不准确」→ 必须说清是哪几类治疗不准。
// 这条反馈的唯一用途是回头统计"哪类召回最容易被判不准"去改规则,收进来一批空的就等于白填,
// 而且事后补不回来(没人会为一条已结案的单再来一遍)。服务端同样会拦。
// ⚠️ 只跟 inaccurate 关联,跟「其他原因」不构成关联(业务 2026-07-30)。
const needInaccurate = reasons.includes('inaccurate');
// 患者没有潜在治疗标签时不卡住(理论上不该发生 —— 没有机会何来"识别不准"),
// 但真遇到了宁可放行,不能让客服卡在一个填不了的必填项上
const inaccurateOk = !needInaccurate || potentialTreatments.length === 0 || inaccurate.length > 0;
const canConfirm = reasons.length > 0 && (!needNote || note.trim().length > 0) && inaccurateOk;
const toggleInaccurate = (code: string) =>
setInaccurate((prev) => (prev.includes(code) ? prev.filter((c) => c !== code) : [...prev, code]));
const reset = () => { const reset = () => {
setReasons([]); setReasons([]);
setNote(''); setNote('');
setInaccurate([]);
}; };
return ( return (
...@@ -114,6 +138,35 @@ export function CloseOpportunityDialog({ ...@@ -114,6 +138,35 @@ export function CloseOpportunityDialog({
})} })}
</div> </div>
{/* 「机会识别不准确」的限定:哪几类推荐治疗不准 —— 候选项 = 本患者的潜在治疗标签 */}
{needInaccurate && potentialTreatments.length > 0 && (
<div className="space-y-1.5">
<div className="text-[12.5px] font-medium text-slate-900">
哪些推荐治疗不准确?(可多选) <span className="text-rose-600">*</span>
</div>
<div className="flex flex-wrap gap-1.5">
{potentialTreatments.map((code) => {
const on = inaccurate.includes(code);
return (
<button
key={code}
type="button"
onClick={() => toggleInaccurate(code)}
className={cn(
'rounded-md border px-2.5 py-1 text-[12px] transition-colors',
on
? 'border-brand-400 bg-brand-50 font-medium text-brand-800'
: 'border-slate-200 bg-white text-slate-700 hover:border-brand-300 hover:bg-brand-50/50',
)}
>
{potentialTreatmentCardLabel(code)}
</button>
);
})}
</div>
</div>
)}
{needNote && ( {needNote && (
<div className="space-y-1.5"> <div className="space-y-1.5">
<div className="text-[12.5px] font-medium text-slate-900"> <div className="text-[12.5px] font-medium text-slate-900">
...@@ -154,7 +207,12 @@ export function CloseOpportunityDialog({ ...@@ -154,7 +207,12 @@ export function CloseOpportunityDialog({
disabled={!canConfirm} disabled={!canConfirm}
onClick={() => { onClick={() => {
if (reasons.length === 0) return; if (reasons.length === 0) return;
onConfirm?.(reasons, note.trim() ? note.trim() : undefined); // 只在选了 inaccurate 时带上,其他原因不构成关联(免得残留勾选污染统计)
onConfirm?.(
reasons,
note.trim() ? note.trim() : undefined,
needInaccurate ? inaccurate : undefined,
);
onOpenChange(false); onOpenChange(false);
reset(); reset();
}} }}
......
...@@ -22,6 +22,11 @@ export interface SubmitExecutionBody { ...@@ -22,6 +22,11 @@ export interface SubmitExecutionBody {
* key 和 label 同源就不会再漂(那张表里「已转介他人 → treated_elsewhere」就是漂出来的语义错位)。 * key 和 label 同源就不会再漂(那张表里「已转介他人 → treated_elsewhere」就是漂出来的语义错位)。
*/ */
abandonReasons?: string[]; abandonReasons?: string[];
/**
* 「机会识别不准确」勾中的治疗 **code**(画像 potential_treatment.types,不是中文)。
* 选了 inaccurate 就必填 —— 服务端也拦(见 execution.service)。仅统计 / 改算法用,不参与抑制。
*/
inaccurateTreatments?: string[];
scheduledNextAt?: string; scheduledNextAt?: string;
} }
......
...@@ -383,9 +383,15 @@ export function PlanDetailApp({ ...@@ -383,9 +383,15 @@ export function PlanDetailApp({
// · 治疗项目:画像 potential_treatment 的 code → **项目名**(卡片措辞去掉「治疗」后缀, // · 治疗项目:画像 potential_treatment 的 code → **项目名**(卡片措辞去掉「治疗」后缀,
// 宿主拿它当项目名落单;界面上客服看的仍是「种植治疗」。见 labels.ts) // 宿主拿它当项目名落单;界面上客服看的仍是「种植治疗」。见 labels.ts)
// · 阶段:恒「已咨询」,PAC 不猜宿主流程内的状态 // · 阶段:恒「已咨询」,PAC 不猜宿主流程内的状态
const potentialTreatmentPayload = (): HostPotentialTreatmentPayload => { // 本患者的潜在治疗 code(画像 potential_treatment.types)—— 两处用:
const codes = ((persona.features.find((f) => f.key === PersonaFeatureKey.POTENTIAL_TREATMENT) // ① 「打开潜在治疗」postMessage 的 treatments ② 关闭机会弹窗里「哪些推荐治疗不准」的候选项
const potentialTreatmentCodes = useMemo(() => {
const raw = ((persona.features.find((f) => f.key === PersonaFeatureKey.POTENTIAL_TREATMENT)
?.data ?? null) as { types?: unknown } | null)?.types; ?.data ?? null) as { types?: unknown } | null)?.types;
return Array.isArray(raw) ? raw.filter((c): c is string => typeof c === 'string') : [];
}, [persona.features]);
const potentialTreatmentPayload = (): HostPotentialTreatmentPayload => {
const codes = potentialTreatmentCodes;
return { return {
desc: recallBrief ?? visibleReasons[0]?.reason ?? '', desc: recallBrief ?? visibleReasons[0]?.reason ?? '',
treatments: Array.isArray(codes) treatments: Array.isArray(codes)
...@@ -417,7 +423,11 @@ export function PlanDetailApp({ ...@@ -417,7 +423,11 @@ export function PlanDetailApp({
// 双写会让同一件事出现在两处、口径还可能打架(recall_feedback 保留给拇指控件那条路) // 双写会让同一件事出现在两处、口径还可能打架(recall_feedback 保留给拇指控件那条路)
// 成功后自动跳下一位:①我的进行中 → ②召回池(与 /plans 入口解析器同一规则)→ ③没人则回 /plans 空态。 // 成功后自动跳下一位:①我的进行中 → ②召回池(与 /plans 入口解析器同一规则)→ ③没人则回 /plans 空态。
const router = useRouter(); const router = useRouter();
const submitClose = async (reasons: AbandonReason[], note?: string) => { const submitClose = async (
reasons: AbandonReason[],
note?: string,
inaccurateTreatments?: string[],
) => {
if (!gateCheck()) return; // 认领闸(弹窗入口已拦一次,这里兜底防绕过) if (!gateCheck()) return; // 认领闸(弹窗入口已拦一次,这里兜底防绕过)
const labels = reasons.map((r) => ABANDON_REASON_META[r].labelZh).join('、'); const labels = reasons.map((r) => ABANDON_REASON_META[r].labelZh).join('、');
try { try {
...@@ -426,6 +436,7 @@ export function PlanDetailApp({ ...@@ -426,6 +436,7 @@ export function PlanDetailApp({
outcome: 'abandoned', outcome: 'abandoned',
notes: note?.trim() || undefined, notes: note?.trim() || undefined,
abandonReasons: reasons, abandonReasons: reasons,
inaccurateTreatments,
}); });
setPlanOverride({ status: result.planStatus, contactAttempts: result.contactAttempts }); setPlanOverride({ status: result.planStatus, contactAttempts: result.contactAttempts });
usePlanSyncStore.getState().notify(plan.id, result.planStatus, 'abandoned'); usePlanSyncStore.getState().notify(plan.id, result.planStatus, 'abandoned');
...@@ -544,7 +555,11 @@ export function PlanDetailApp({ ...@@ -544,7 +555,11 @@ export function PlanDetailApp({
<CloseOpportunityDialog <CloseOpportunityDialog
open={closeOpen} open={closeOpen}
onOpenChange={setCloseOpen} onOpenChange={setCloseOpen}
onConfirm={(reason, note) => void submitClose(reason, note)} // 「机会识别不准确」的候选项 = 本患者的潜在治疗 code(同召回池卡片那排标签的来源)
potentialTreatments={potentialTreatmentCodes}
onConfirm={(reason, note, inaccurateTreatments) =>
void submitClose(reason, note, inaccurateTreatments)
}
/> />
{/* ⭐ 响应式 — xl≥1280 用 3 列 grid;<xl 用 shadcn Tabs(原因/话术/操作)。 {/* ⭐ 响应式 — xl≥1280 用 3 列 grid;<xl 用 shadcn Tabs(原因/话术/操作)。
......
...@@ -112,6 +112,8 @@ export const PlanExecutionSchema = z.object({ ...@@ -112,6 +112,8 @@ export const PlanExecutionSchema = z.object({
outcome: ExecutionOutcomeSchema, outcome: ExecutionOutcomeSchema,
notes: z.string().nullable(), notes: z.string().nullable(),
abandonReasons: z.array(z.string()), abandonReasons: z.array(z.string()),
/// 「机会识别不准确」勾中的治疗 code(仅统计用,不参与抑制)
inaccurateTreatments: z.array(z.string()),
scheduledNextAt: z.string().nullable(), scheduledNextAt: z.string().nullable(),
/// 召回反馈已迁到 plan 级(FollowupPlan.recallFeedback),不再随执行 /// 召回反馈已迁到 plan 级(FollowupPlan.recallFeedback),不再随执行
createdAt: z.string().describe('提交时间 ≈ 通话结束时间'), createdAt: z.string().describe('提交时间 ≈ 通话结束时间'),
...@@ -248,6 +250,10 @@ export const SubmitExecutionRequestSchema = z.object({ ...@@ -248,6 +250,10 @@ export const SubmitExecutionRequestSchema = z.object({
notes: z.string().optional(), notes: z.string().optional(),
/// 放弃原因(多选,无上限)—— PAC 表单 / 宿主关闭弹窗共用;说明写进 notes /// 放弃原因(多选,无上限)—— PAC 表单 / 宿主关闭弹窗共用;说明写进 notes
abandonReasons: z.array(AbandonReasonSchema).optional(), abandonReasons: z.array(AbandonReasonSchema).optional(),
/// 「机会识别不准确」勾了哪几类推荐治疗不准 —— 画像 potential_treatment 的 code,不是中文。
/// 选了 inaccurate 就必填(服务端也校验,别只信前端);其他原因不带这个字段。
/// 用途只有统计 / 改算法,不参与抑制(见 schema.prisma 那列的注释)。
inaccurateTreatments: z.array(z.string()).optional(),
scheduledNextAt: z.string().optional(), scheduledNextAt: z.string().optional(),
}); });
export type SubmitExecutionRequest = z.infer<typeof SubmitExecutionRequestSchema>; export type SubmitExecutionRequest = z.infer<typeof SubmitExecutionRequestSchema>;
......
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