Commit 1409f6f5 by luoqi

feat: 补上退回原因弹窗(T7 此前是零实现) + 分配单服务端强制

走查:分配完登录客服账号点「退回」,没有任何弹窗 —— 一点就退掉了。

查下来这条教条**此前是零实现**:枚举齐了(8 个 ReleaseReason + needNote)、
API 收得了、releaseReasons 分布报表也写了,但前端唯一的退回入口是
`plansApi.recycle(planId)` —— **一个参数都没传**。于是那张分布表永远拿到 null:
主管只知道"退了 12 条",不知道该改什么,而"派多了 / 时效太紧 / 压根不该派给他"
这三种的下一步动作完全不同(T7 说这张表是主管调整分配策略的输入)。

· 新增 ReleaseReasonDialog:8 个原因,每个**带一句 desc** ——「不是我的客户」和
  「该由其他角色跟」光看标题分不出,分不出就会随手点第一个,**假数据比没数据更难发现**。
  other 展开必填说明框,不填不给提交。
· 服务端:**分配来的单**(assignment_id 非空)强制要 releaseReason。
  ️ 只卡分配来的 —— 自助认领的单没有派单方,原因对谁都没用;
  **系统自动到期回收不走这条路**(scheduler 直接改库,记 assignment_expired),不会被卡死。
  ️ 必填必须服务端拦:只做前端的话,换个客户端(助手/脚本/企微)就绕过去了
  (与 other 必填说明、execution 的 inaccurate 必填同一条纪律)。

本地实测(客服「位其蕾」12 条在手):点退回 → 弹窗 → 选「其他原因」出必填框且确认禁用 →
改选「时效太紧」→ 确认 → toast「已退回」、我的 12→10、
followup_plans.release_reason=deadline_too_tight、
plan_event_logs 落 release/deadline_too_tight 且带 held_seconds=2839。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent 9f8c62f6
...@@ -770,6 +770,8 @@ export class PlanService { ...@@ -770,6 +770,8 @@ export class PlanService {
select: { select: {
id: true, hostId: true, tenantId: true, status: true, id: true, hostId: true, tenantId: true, status: true,
assigneeUserId: true, assignedAt: true, patientId: true, assigneeUserId: true, assignedAt: true, patientId: true,
// 分配来的单要强制填原因(见下方 assignmentId 那段)
assignmentId: true,
}, },
}); });
if (!plan || plan.hostId !== scope.hostId || plan.tenantId !== scope.tenantId) { if (!plan || plan.hostId !== scope.hostId || plan.tenantId !== scope.tenantId) {
...@@ -787,6 +789,23 @@ export class PlanService { ...@@ -787,6 +789,23 @@ export class PlanService {
// 这类必填一旦只做在前端,换个客户端(助手 / 脚本 / 企微)就绕过去了, // 这类必填一旦只做在前端,换个客户端(助手 / 脚本 / 企微)就绕过去了,
// 而 other 不带说明等于没填,退回原因分布里会堆出一坨读不出信息的 other。 // 而 other 不带说明等于没填,退回原因分布里会堆出一坨读不出信息的 other。
const note = releaseNote?.trim() || undefined; const note = releaseNote?.trim() || undefined;
/**
* 🔴 **分配来的单,退回必须给原因**(T7)。
*
* 契约里一直写着「分配单必填」(见 plan.ts 的 describe),但**只是句注释** ——
* 服务端从来没拦过,前端也没做弹窗,于是客服点一下「退回」就走了(2026-08-03 走查发现)。
* 结果:`releaseReasons` 那张分布报表拿到的全是 null,而 T7 说这张表
* **是主管调整分配策略的输入** —— 没有它,主管只知道"退了 12 条",不知道该改什么。
*
* ⚠️ 只卡**分配来的**(assignment_id 非空):
* · 自助认领的单(旧路径)没有派单方,原因对谁都没用;
* · **系统自动到期回收不走这里**(assignment-expiry.scheduler 直接改库,
* reason 记 assignment_expired),所以不会被这条卡死。
* ⚠️ 服务端必拦,⛔ 不能只做前端:换个客户端(助手 / 脚本 / 企微)就绕过去了。
*/
if (plan.assignmentId != null && releaseReason == null) {
throw new BadRequestException('这是分配给你的单,退回必须选一个原因');
}
if (releaseReason != null && RELEASE_REASON_META[releaseReason].needNote && !note) { if (releaseReason != null && RELEASE_REASON_META[releaseReason].needNote && !note) {
throw new BadRequestException( throw new BadRequestException(
`退回原因「${RELEASE_REASON_META[releaseReason].labelZh}」必须填写说明`, `退回原因「${RELEASE_REASON_META[releaseReason].labelZh}」必须填写说明`,
......
...@@ -13,6 +13,7 @@ import { ...@@ -13,6 +13,7 @@ import {
type ExecutionOutcome, type ExecutionOutcome,
type ExecutionOutcomeGroup, type ExecutionOutcomeGroup,
type PlanListItem, type PlanListItem,
type ReleaseReason,
personaTagDimId, personaTagDimId,
potentialTreatmentCardLabel, potentialTreatmentCardLabel,
TEMPERATURE_META, TEMPERATURE_META,
...@@ -27,6 +28,7 @@ import { usePlanSyncStore } from '@/stores/plan-sync-store'; ...@@ -27,6 +28,7 @@ import { usePlanSyncStore } from '@/stores/plan-sync-store';
import { useAssistantStore } from '@/stores/assistant-store'; import { useAssistantStore } from '@/stores/assistant-store';
import { usePatientPicker, type PickerFilters } from './use-patient-picker'; import { usePatientPicker, type PickerFilters } from './use-patient-picker';
import { PoolMatrix } from './pool-matrix'; import { PoolMatrix } from './pool-matrix';
import { ReleaseReasonDialog } from './release-reason-dialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import type { PoolMatrix as PoolMatrixData } from './plans-api'; import type { PoolMatrix as PoolMatrixData } from './plans-api';
...@@ -97,6 +99,12 @@ export function PatientPickerRail({ ...@@ -97,6 +99,12 @@ export function PatientPickerRail({
const [matrixLoading, setMatrixLoading] = useState(false); const [matrixLoading, setMatrixLoading] = useState(false);
/// 上一次点过的那一格 —— **只用于矩阵内部回显**(再打开时能看出刚交过哪一格),不外溢到列表 /// 上一次点过的那一格 —— **只用于矩阵内部回显**(再打开时能看出刚交过哪一格),不外溢到列表
const [cell, setCell] = useState<{ treatment: string; temperature: 'hot' | 'warm' | 'cold' } | null>(null); const [cell, setCell] = useState<{ treatment: string; temperature: 'hot' | 'warm' | 'cold' } | null>(null);
/**
* 待确认退回的那一条 —— ⭐ **退回必须先选原因**(T7),⛔ 不能点一下就走。
* 原因分布是主管调整分配策略的输入;拿不到它,他只知道"退了 12 条",不知道该改什么。
*/
const [pendingRelease, setPendingRelease] = useState<{ planId: string; name: string } | null>(null);
const [recycling, setRecycling] = useState(false);
const user = useAuthStore((st) => st.user); const user = useAuthStore((st) => st.user);
const clinicDict = user?.dictionary?.clinics; // 显示 plan 诊所名用(字典查表,非 scope) const clinicDict = user?.dictionary?.clinics; // 显示 plan 诊所名用(字典查表,非 scope)
...@@ -236,16 +244,20 @@ export function PatientPickerRail({ ...@@ -236,16 +244,20 @@ export function PatientPickerRail({
// 返池:回收已认领的 plan → 回到召回池可被再次认领(plan 仍 active)。需 PLAN_RECYCLE 权限。 // 返池:回收已认领的 plan → 回到召回池可被再次认领(plan 仍 active)。需 PLAN_RECYCLE 权限。
// 返池后该 plan 离开「我的」:在我的 tab → 原地移除该行;其它 tab → 仅改状态。 // 返池后该 plan 离开「我的」:在我的 tab → 原地移除该行;其它 tab → 仅改状态。
// 不重拉,保滚动/筛选。 // 不重拉,保滚动/筛选。
const recycle = async (planId: string, name: string) => { const recycle = async (planId: string, name: string, reason: ReleaseReason, note?: string) => {
setRecycling(true);
try { try {
await plansApi.recycle(planId); await plansApi.recycle(planId, reason, note);
if (view === 'mine') removeItem(planId); if (view === 'mine') removeItem(planId);
else patchItem(planId, { status: 'active', assigneeUserId: null }); else patchItem(planId, { status: 'active', assigneeUserId: null });
// 反向通知详情页:返池后要把闸重新上锁,否则还停在详情页的人仍能操作已交还的单 // 反向通知详情页:返池后要把闸重新上锁,否则还停在详情页的人仍能操作已交还的单
usePlanSyncStore.getState().notifyClaim(planId, null); usePlanSyncStore.getState().notifyClaim(planId, null);
toast.success(`已返池 ${name}`); toast.success(`已退回 ${name}`);
setPendingRelease(null);
} catch (err) { } catch (err) {
toast.error(err instanceof Error ? err.message : '返池失败'); toast.error(err instanceof Error ? err.message : '退回失败');
} finally {
setRecycling(false);
} }
}; };
...@@ -437,7 +449,7 @@ export function PatientPickerRail({ ...@@ -437,7 +449,7 @@ export function PatientPickerRail({
p.status !== 'completed' && p.status !== 'completed' &&
p.status !== 'abandoned' && p.status !== 'abandoned' &&
p.status !== 'superseded' p.status !== 'superseded'
? () => void recycle(p.id, p.patient.name ?? '') ? () => setPendingRelease({ planId: p.id, name: p.patient.name ?? '' })
: undefined : undefined
} }
/> />
...@@ -462,6 +474,16 @@ export function PatientPickerRail({ ...@@ -462,6 +474,16 @@ export function PatientPickerRail({
</button> </button>
)} )}
</div> </div>
{/* 退回原因 —— T7:退回是正常路径,但**必须说明原因** */}
<ReleaseReasonDialog
open={pendingRelease != null}
patientName={pendingRelease?.name ?? ''}
submitting={recycling}
onCancel={() => setPendingRelease(null)}
onConfirm={(reason, note) =>
pendingRelease && void recycle(pendingRelease.planId, pendingRelease.name, reason, note)
}
/>
</aside> </aside>
); );
} }
......
'use client';
import { useState } from 'react';
import { Loader2 } from 'lucide-react';
import { RELEASE_REASON_META, type ReleaseReason } from '@pac/types';
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
/**
* 退回原因弹窗 —— 客服点「退回」时必须选一个原因(T7)。
*
* ═══ 为什么这个弹窗必须存在 ═══════════════════════════════════
* T7:「退回是**正常路径**不是异常。退回率与原因分布是**主管调整分配策略的输入** ——
* 这是『必须填原因』的目的,不要当成无谓摩擦砍掉。」
*
* 🔴 2026-08-03 走查发现:**这个弹窗此前根本不存在**。
* `plansApi.recycle` 早就支持传 releaseReason、枚举齐了、聚合报表(releaseReasons 分布)
* 也写了 —— 唯一的调用点却是 `plansApi.recycle(planId)`,一个参数都不传。
* 于是那张分布报表永远拿到 null:主管只知道"退了 12 条",不知道该改什么
* (是派多了?时效太紧?还是压根不该派给他?)—— 而这三种的下一步动作完全不同。
*
* ⚠️ 每个原因都带一句 `desc`:「不是我的客户」和「该由其他角色跟」光看标题分不出,
* 分不出就会随手点第一个,那比不填还糟(假数据比没数据难发现)。
* ⚠️ `other` 必须写说明 —— 服务端也拦(见 plan.service.recycle),
* ⛔ 这类必填只做前端等于没做:换个客户端就绕过去了。
*/
export function ReleaseReasonDialog({
open,
patientName,
submitting,
onCancel,
onConfirm,
}: {
open: boolean;
/** 弹窗标题里点名是谁,避免连点几行后不知道正在退哪一个 */
patientName: string;
submitting?: boolean;
onCancel: () => void;
onConfirm: (reason: ReleaseReason, note?: string) => void;
}) {
const [reason, setReason] = useState<ReleaseReason | null>(null);
const [note, setNote] = useState('');
const needNote = reason != null && RELEASE_REASON_META[reason].needNote === true;
const canSubmit = reason != null && (!needNote || note.trim().length > 0) && !submitting;
const close = () => {
setReason(null);
setNote('');
onCancel();
};
return (
<AlertDialog open={open} onOpenChange={(o) => !o && close()}>
<AlertDialogContent className="max-w-md">
<AlertDialogHeader>
<AlertDialogTitle className="text-[15px]">
退回「{patientName || '这位患者'}
</AlertDialogTitle>
<AlertDialogDescription className="text-[12.5px]">
这单会回到召回池,由主管重新安排。选一个原因 —— 主管靠这个判断是派多了、
时效太紧,还是压根不该派给你。
</AlertDialogDescription>
</AlertDialogHeader>
<div className="max-h-[46vh] space-y-1 overflow-y-auto py-1">
{(Object.keys(RELEASE_REASON_META) as ReleaseReason[]).map((r) => {
const m = RELEASE_REASON_META[r];
const on = reason === r;
return (
<button
key={r}
type="button"
onClick={() => setReason(r)}
className={cn(
'flex w-full flex-col items-start gap-0.5 rounded-md border px-2.5 py-1.5 text-left transition-colors',
on
? 'border-brand-400 bg-brand-50/70'
: 'border-slate-100 bg-white hover:border-brand-200 hover:bg-brand-50/30',
)}
>
<span
className={cn(
'text-[13px]',
on ? 'font-medium text-brand-800' : 'text-slate-700',
)}
>
{m.labelZh}
</span>
{/* ⚠️ desc 不能省:「不是我的客户」vs「该由其他角色跟」光看标题分不出,
分不出就会随手点第一个 —— 假数据比没数据难发现 */}
<span className="text-[11px] leading-snug text-slate-400">{m.desc}</span>
</button>
);
})}
</div>
{needNote && (
<div className="space-y-1">
<textarea
autoFocus
rows={2}
value={note}
onChange={(e) => setNote(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"
/>
<p className="text-[10.5px] text-slate-400">
「其他原因」不写说明等于没填 —— 分布表里会堆出一坨读不出信息的「其他」。
</p>
</div>
)}
<AlertDialogFooter>
<AlertDialogCancel disabled={submitting}>取消</AlertDialogCancel>
<Button
disabled={!canSubmit}
onClick={() => reason && onConfirm(reason, note.trim() || undefined)}
className="gap-1.5"
>
{submitting && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
确认退回
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
...@@ -351,6 +351,19 @@ v1 **轻量**:不核销、不接宿主福利数据,福利就是**话术勾 ...@@ -351,6 +351,19 @@ v1 **轻量**:不核销、不接宿主福利数据,福利就是**话术勾
退回是**正常路径**不是异常。 退回是**正常路径**不是异常。
退回率与原因分布是**主管调整分配策略的输入** —— 这是「必须填原因」的目的,不要当成无谓摩擦砍掉。 退回率与原因分布是**主管调整分配策略的输入** —— 这是「必须填原因」的目的,不要当成无谓摩擦砍掉。
> 🔴 **2026-08-03 走查:这条教条此前是零实现。**
> 枚举齐了(8 个 `ReleaseReason` + `needNote`)、API 收得了、`releaseReasons` 分布报表也写了 ——
> 但**前端唯一的退回入口 `plansApi.recycle(planId)` 一个参数都没传**,也没有弹窗。
> 于是那张分布表拿到的永远是 null:主管只知道"退了 12 条",不知道该改什么,
> 而"派多了 / 时效太紧 / 压根不该派给他"这三种的下一步动作完全不同。
>
> 现在:前端弹 `ReleaseReasonDialog`(8 个原因各带一句 desc —— 光看标题分不出「不是我的客户」
> 和「该由其他角色跟」,分不出就会随手点第一个,**假数据比没数据更难发现**);
> 服务端对**分配来的单**(`assignment_id` 非空)强制要原因,`other` 强制要说明。
> ⚠️ 只卡分配来的:自助认领的单没有派单方,原因对谁都没用;
> **系统自动到期回收不走这条路**(scheduler 直接改库,记 `assignment_expired`),不会被卡死。
> ⚠️ 必填要在**服务端**拦 —— 只做前端的话,换个客户端(助手 / 脚本 / 企微)就绕过去了。
### T8 · 主管决策、助手辅助、客服执行 ### T8 · 主管决策、助手辅助、客服执行
三者职责**不重叠**,各有否定边界: 三者职责**不重叠**,各有否定边界:
......
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