Commit 504b0e19 by luoqi

merge: 调整在手 + 三个分配修正 → test

带进来两部分:
· fix/session-batch 那三个修正(团队状态按诊所收窄 / 提案封顶到 500 / 准星裁切)
  —— 它与 feat/plan-rearrange 指向同一个提交,合一次两边都进来
· 调整各客服在手的单(换人 / 改时限 / 收回)+ 批次表的「回收」列

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parents 413a27d0 523d260f
Pipeline #3583 failed in 0 seconds
-- 主管重排(调整各客服在手的单)的操作记录 —— plan_event_logs.operation_id
--
-- 【为什么要这一列】重排**不建批次**(单子的 assignment_id 一律不动,否则原批的
-- planned 会静默缩水 —— 见 schema.prisma 里 assignment_id 那段红字)。
-- 但"这 22 条属于同一次重排"必须记得住:审计、提交幂等、将来撤销一次重排,
-- 三条路都要按它筛。本表的规矩是「要按它筛就该立柱」。
--
-- 【⛔ 为什么不复用 assignment_id】那一列没有外键(裸 uuid),物理上塞得进重排 id ——
-- 但 ledgerStatsByAssignment / handledByAssignment 都在 `WHERE assignment_id IN (…)`
-- 上出数。两个 id 空间混一列,迟早串台,而且不报错。
--
-- 【为什么普通 CREATE INDEX 就够,不用 CONCURRENTLY】
-- 生产实测(2026-08-20)plan_event_logs 只有 2,298 行 / 1 MB —— 建索引是毫秒级,
-- ACCESS EXCLUSIVE 锁的时间可以忽略。
-- ⚠️ 这张表是 append-only 账本,会随分配功能铺开而增长。**将来再给它加索引时
-- 先量一遍行数**:上到百万级就必须换成 CONCURRENTLY,而 CONCURRENTLY 要求
-- 整份 migration.sql 只有一条语句(见 20260802150000 那份的说明)。
-- AlterTable
ALTER TABLE "plan_event_logs" ADD COLUMN "operation_id" UUID;
-- CreateIndex
CREATE INDEX "plan_event_logs_operation_id_idx" ON "plan_event_logs"("operation_id");
......@@ -1651,6 +1651,24 @@ model PlanEventLog {
*/
assignmentId String? @map("assignment_id") @db.Uuid
/**
* 这条事件属于**哪一次主管重排**(调整各客服在手的单)
*
* ── 为什么必须立柱,而不是塞 details ──
* 本表的规矩是「只放**查询不按它过滤**的内容;要按它筛就该立柱」。
* "这次重排动了什么" / 提交幂等 / 将来撤销一次重排,三条路都要按它筛。
*
* ── 为什么⛔ 不复用 `assignment_id` ──
* 那一列**没有外键**( uuid),物理上塞得进重排 id ——
* `ledgerStatsByAssignment` / `handledByAssignment` 都在 `WHERE assignment_id IN ()`
* 上出数。两个 id 空间混一列,迟早串台,且不报错。
*
* ⚠️ 重排**不建批次**:重排事件的 `assignment_id` 仍是这条单**原来那一批**
* (归属不动,批次报表的分母才不塌)。两列同时有值是正常的。
* claim / view / feedback 这类非重排事件此列为 null
*/
operationId String? @map("operation_id") @db.Uuid
/// 事件专属细节(不立柱的部分, feedback 的文字说明)
/// ⚠️ 只放"查询不按它过滤"的内容;要按它筛就该立柱。
details Json?
......@@ -1669,6 +1687,8 @@ model PlanEventLog {
@@index([hostId, tenantId, event, createdAt])
/// 批次报表主查询:某批分了几条 / 退了几条 / 到期几条(event 一起进索引,聚合不回表)
@@index([assignmentId, event])
/// 一次重排动了哪些单(审计 / 幂等 / 将来撤销重排)
@@index([operationId])
@@map("plan_event_logs")
}
......
......@@ -123,7 +123,7 @@ import { Permission } from '@pac/types';
*/
// ⚠️ `-b` 是 2026-08-17 那次「纪律挂在组上」的实验,已 revert(`git show 82f357e`)——
// ⛔ 别复用它:库里有那一版的 `agent_invocations` 行,复用等于两份不同正文同名。
export const ASSISTANT_PROMPT_VERSION = 'assistant@2026-08-19-a';
export const ASSISTANT_PROMPT_VERSION = 'assistant@2026-08-20-f';
/**
* ① 装置 —— 你是谁、和使用者什么关系、你看不见什么。
......@@ -318,7 +318,7 @@ const ASSIGNMENT_SCENE = `## 他现在做的这件事:把一批人分给客服
### 确认之后
这批的人员和时限改不了,要改只能**撤销重分**。你对已经分下去的这批能做的只有两件:撤销、补挂福利 —— 「已撤销」只能出现在撤销工具真的返回之后。
这批的人员和时限**不在这张单上改了** —— 单子已经在客服手上。要动它们,走**调整各客服在手的单**那条路:把他手上的摆出来,换人、改时限、移出。说成整批撤销重分,他会把已经发出去的一批全收回来,而他要的往往只是挪一两个人。在这张单上还能做的只有补挂福利;整批收回才是撤销 —— 「已撤销」只能出现在撤销工具真的返回之后。
福利挂在整批上,会进这批人的话术,补挂只影响此后生成的那些。他说什么就原样写进去,⛔ 别替他加条件、期限或承诺。⛔ 「这批还没带福利」**不用你再提一遍** —— 确认之前你已经问过一次了;他回头问起,或者要补,照办就是。
......
......@@ -15,7 +15,8 @@ import { DictationGateway } from './dictation.gateway';
* 复用 AiModule 的 AiProviderService(provider 可切换);McpClientService 真连 PAC MCP 端点。
*/
@Module({
// PlanModule:本地工具 propose_assignment 用 AssignmentProposalService 取数
// PlanModule:本地工具 propose_assignment 用 AssignmentProposalService 取数;
// propose_rearrange 用 PlanRearrangeService + AgentRosterService(姓名→id)
imports: [AiModule, AuthModule, PlanModule],
// 🔬 AssistantLabController = 裸 agent 试验台,⛔ 与产品助手无代码关联(见该文件头)
controllers: [AssistantController, AssistantLabController],
......
......@@ -26,6 +26,9 @@ import {
} from './assistant-invocation';
import { McpClientService } from './mcp-client.service';
import { AssignmentProposalService } from '../plan/assignment-proposal.service';
import { PlanRearrangeService } from '../plan/plan-rearrange.service';
import { AgentRosterService } from '../plan/agent-roster.service';
import { rearrangeFacts } from '../plan/rearrange-facts';
// ⭐ 给模型的事实投影(⛔ 不再给成品句子,见该文件顶部注释)
import { modelFacts, sheetSnapshotFacts } from '../plan/assignment-facts';
// 引导节点的中文标识 + 取值域(单一真源,沿革见那边注释)
......@@ -194,6 +197,10 @@ export class AssistantService {
private readonly provider: AiProviderService,
private readonly mcp: McpClientService,
private readonly proposals: AssignmentProposalService,
// 调整是与分配**并列**的另一条路(不建批次、没有福利),不塞进 proposals
private readonly rearrange: PlanRearrangeService,
/// 姓名 -> userId:模型手上只有客服姓名,而服务端按 id 取数
private readonly roster: AgentRosterService,
private readonly config: ConfigService<AppConfig, true>,
private readonly recorder: InvocationRecorderService,
) {}
......@@ -778,7 +785,9 @@ export class AssistantService {
'他读到那句时结果早就在了,顺序是倒的。' +
'\n⚠️ 批次**已经确认分配**之后:只有 `set_benefit` 还能改(福利只影响此后生成的话术,' +
'界面会真的改并回报"有几条已经写好的话术作废了 / 几条客服已经打开过");' +
'人员和时限**改不了** —— 单子已经在客服手上,那要走撤销重分。' +
'人员和时限**在这张单上改不了** —— 单子已经在客服手上。' +
'要换人、改时限、移出,走**调整各客服在手的单**那条路;' +
'⛔ 别说成"只能整批撤销重分",那条路早就不是唯一的了。整批收回才是撤销。' +
'⛔ 这两种情况都以界面回的那句为准,别自己判断成没成。',
inputSchema: jsonSchema({
type: 'object',
......@@ -893,6 +902,213 @@ export class AssistantService {
return JSON.stringify({ 已交给界面执行: ops.length, 界面已把结果发进对话: true });
},
});
// ── 本地工具:调整单(调整各客服在手的单)────────────────────
// ⛔ 与上面确认单那三件**完全隔离**:分开的工具、分开的侧信道事件、分开的界面组件。
// 两者形状像但语义不通(调整不建批次、没有福利、没有"本批多大"),
// 缝在一起必然要给一半参数配空值,而那一半迟早有人在某个分支上忘了判。
/// 本轮出过调整单没有 —— show_rearrange 只有在出过之后才有意义
let rearrangePushed = false;
tools.propose_rearrange = tool({
description:
'把此刻**已经在客服手上**的单摆成一版可编辑的调整单:谁手上多少条、其中几条超期。' +
'\n他要动已经分下去的单时用它,要换看的范围(本来只看某几位,现在要看全队)时也用它。' +
'\n眼前已经有一版的时候不用 —— 再来一次是**整版重出**,他刚在上面调好的全没了,' +
'而他往往只想挪一两个人;他这时候说的具体改动,落在那一版上,由改调整单那件工具做。' +
'\n它摆出来的是**原样**:谁手上什么样就是什么样。' +
'摆完把你看到的说给他听(谁手上多少、谁最多、有没有超期),然后等他说怎么调。' +
'\n把患者从一位客服手里挪到另一位手里,是**关系层面的决定**,只有他有资格拍。' +
'你先替他排好了,他看到的就不再是现在什么样,而是你觉得该什么样 ——' +
'他要么照单全收(那等于你替他定了),要么得先把你排的拆回去,才能说自己的想法。' +
'\n它跟从池子里选人发单是两件事:那件把没人管的发下去,这件把已经有人管的换个安排。' +
'\n它不写任何数据。要真的生效,得他在调整单上点确认。',
inputSchema: jsonSchema({
type: 'object',
properties: {
agents: {
type: 'array',
items: { type: 'string' },
description:
'只看这几位客服手上的(客服姓名)。他点了名就传,没点名就不传,那样是全队在手。' +
'\n传了之后这一版**只有**这几位的单,别人手上的一条都不在里面;' +
'要把其中一位手上的分给别人,收的那几位也得在这个名单里,否则他们在这一版上不存在。',
},
},
}),
execute: async (args: unknown) => {
const names = ((args as { agents?: unknown })?.agents ?? []) as unknown[];
const clinicId = input.activeClinicId ?? scope.clinicIds[0];
if (!clinicId) {
return '当前登录人没有绑定诊所,无法出调整单 —— 请主管确认数据范围。';
}
/**
* ⚠️ 模型手上只有**姓名**,而服务端按 userId 取数 —— 姓名→id 在这里解析,
* ⛔ 别把 userId 塞进工具入参让模型去背:那条路 `agentOverrides` 已经踩过
* (写成姓名的那一条被静默丢掉,主管拿到一版没按他说的算的方案)。
*/
const roster = await this.roster.list(scope, clinicId);
const wanted = names.filter((n): n is string => typeof n === 'string' && !!n.trim());
const matched = wanted
.map((n) => roster.agents.find((a) => a.name === n.trim())?.userId)
.filter((v): v is string => !!v);
const missed = wanted.filter((n) => !roster.agents.some((a) => a.name === n.trim()));
if (wanted.length > 0 && matched.length === 0) {
return JSON.stringify({
没出调整单: true,
原因: '这些名字在本诊所的客服名册里一个都没找到',
没找到的: missed,
});
}
const sheet = await this.rearrange.draft(
scope,
clinicId,
matched.length > 0 ? matched : undefined,
);
push({ type: 'rearrange_sheet', operationId: sheet.operationId, sheet });
rearrangePushed = true;
this.logger.log(
`工具调用:propose_rearrange 范围=${sheet.scope} 在手=${sheet.total} 超期=${sheet.overdueTotal}`,
);
return JSON.stringify({
...rearrangeFacts(sheet),
...(missed.length > 0 ? { 名册里没找到的名字: missed } : {}),
});
},
});
tools.edit_rearrange_sheet = tool({
description:
'改他眼前那张调整单:把谁换给谁、改时限、移出。' +
'\n**他说出了要动哪些,才用这件。** 他点了名、或者圈了一组(某位客服手上的、' +
'超期的那些),都算说出来了。' +
'\n他只说了不满意什么(手上不平、谁太多了、调一调),没说要动谁、给谁、动几条 ——' +
'那三个数是他要定的。你替他定了,他要么照单全收,要么得先把你定的拆回去才能说自己的想法。' +
'这时候把摆出来的事实说给他听,等他这一句。' +
'\n这些改动落在**眼前那一版**上。重新出一版会把他刚调好的全清掉。' +
'\n一条指令由三件事拼起来:**选谁**(select)· **干什么**(action)· **给谁**(to,只有换人要)。' +
'每一格该填什么、填错会怎样,写在各自的参数说明里。' +
'\n这一版没有福利这件事 —— 这里的人来自若干不同批次,而福利是挂在批次上的。' +
'\n患者用姓名或病历号指代,客服用姓名;匹配由界面拿它手里那份单子完成。' +
'\n改完什么都不用补:界面会把结果直接发进对话,而且**先于你这段话**到他那儿 ——' +
'你说"已完成"或"等结果再告诉您"的时候,结果早就在他眼前了。' +
'\n它仍然不写数据:要真的生效,得他在调整单上点确认。',
inputSchema: jsonSchema({
type: 'object',
properties: {
ops: {
type: 'array',
description: '一组修改,按给出的顺序依次执行。',
items: {
type: 'object',
properties: {
select: {
type: 'object',
description: '这条修改作用在哪些单上。',
properties: {
group: {
type: 'string',
enum: ['patients', 'agent', 'overdue', 'all'],
description:
'patients=点名的这几位患者;agent=某位客服手上的全部;' +
'overdue=这一版里已经超期的那些;all=这一版全部。' +
'\n他点一个名字时,默认那是**患者**;只有他说的是某位客服手上的整批,才用 agent。' +
'agent 一次动的是几十条,而同一个名字可能既是客服又是患者 ——' +
'选成 agent 时他会以为只动了一条。',
},
patients: {
type: 'array',
items: { type: 'string' },
description: '患者姓名或病历号,可给多个。group=patients 时必填。',
},
agent: {
type: 'string',
description: '客服姓名。group=agent 时必填。',
},
},
required: ['group'],
},
action: {
type: 'string',
enum: ['move', 'set_expiry', 'remove'],
description:
'move=换给别的客服,配 to;set_expiry=改时限,配 days;' +
'remove=移出(收回池子,谁都不挂着)。',
},
to: {
type: 'object',
description:
'换给谁,action=move 时必填。' +
'\nbalance = 谁手上少先给谁;agents 只给一位就是都给他。' +
'\n收的人**必须在这一版里**:这一版是点名几位客服出的时候,' +
'别人在这一版上不存在,写上去也落不上。',
properties: {
mode: { type: 'string', enum: ['balance'] },
agents: {
type: 'array',
items: { type: 'string' },
description:
'把候选限定在这几位客服(姓名)。不给 = 这一版里的其他人,' +
'按谁手上少先给谁。',
},
},
required: ['mode'],
},
days: {
type: 'number',
minimum: 1,
maximum: 90,
description:
'新的时限天数,action=set_expiry 时必填。' +
'\n从今天起算,不是在原来的天数上加。改短了会当场制造超期,界面会把这件事标出来。',
},
},
required: ['select', 'action'],
},
},
},
required: ['ops'],
}),
execute: async (args: unknown) => {
const ops = (args as { ops?: unknown[] })?.ops;
if (!Array.isArray(ops) || ops.length === 0) return '没有给出任何操作,调整单未改动。';
/**
* 🔴 **⛔ 这里绝不能拦「本轮还没出过调整单」**(2026-08-20 实测栽过)。
*
* `rearrangePushed` 只在**同一轮** chat() 里为真(tools 每轮现建)。而调整单
* 绝大多数时候是**上一轮**推出去的 —— 主管看着它才说「胡剑波分配到张萌这里」。
* 加了这道闸,那句话进来时这件工具回一句「先出一版」,模型只能照办:
* 实测链路 = 试 edit → 被回绝 → propose_rearrange(**整版重出**,旧的当场作废)
* → 再 edit。主管眼里是"我只让他挪一个人,他把整张单换了"。
* ⚠️ 确认单那边的 `edit_assignment_sheet` **从来没有这道闸**,就是这个原因。
* ⇒ 判断"有没有一张还能改的单"是**界面**的事(它手里那份才是权威,
* 见 chat-blocks 的 findActiveRearrange);找不到就静默不动,⛔ 不该由这里猜。
*/
push({ type: 'rearrange_sheet_edit', ops });
return JSON.stringify({ 已交给界面执行: ops.length, 界面已把结果发进对话: true });
},
});
tools.show_rearrange = tool({
description:
'让他看到这一版调整单的完整明细:谁手上哪些人、各自还剩几天 —— 他要据此决定确不确认。' +
'\n这个调用**落在你正文里的哪个位置是有含义的**:它标记「明细从这里开始看」。' +
'\n这一版还没出来时调,他看到的是一块空的。' +
'\n明细是给他核对你刚说的那几句用的 —— 先把这一版是什么、你替他动了什么讲出来,讲完再调。' +
'\n位置由这次调用定,而你**看不见**它最后落在他屏幕的哪一侧;再用话去指它' +
'(在下面、在上面),他会去找一个不存在的地方。' +
'\n它不改任何数据,没有返回值给你用。',
inputSchema: jsonSchema({ type: 'object', properties: {} }),
execute: async () => {
// ⚠️ 措辞刻意**不说「先出一版」**:调整单往往是上一轮推的,那句话会把模型
// 推去 propose_rearrange 整版重出(见 edit 那件工具里的红字)。这里只陈述事实。
if (!rearrangePushed) {
return '这一轮没有新出的调整单 —— 明细在上面那一版里,不用再摆一次。';
}
this.logger.log('工具调用:show_rearrange');
push({ type: 'rearrange_sheet_anchor' });
return '';
},
});
}
// 本地"渲染"工具(不走 MCP):模型把自包含 HTML 片段交给前端,在沙箱 iframe 里渲染成卡片/报表。
......
......@@ -24,7 +24,8 @@ export const TRACKING_GUIDE = [
'「处理」不等于「成功」:progress 是处理率,只说「这单动过了」,不说「谈成了」。',
'「已出池·引擎判定需求已了」是引擎按客观事实判定召回需求没了,⛔ 不是「转化成功/成交」,⛔ 不要拿这些数算转化率。',
'报处理率必须带上「本批已跑天数」:跑了三个月的批次天然比跑了三天的好看,不带年龄直接比是耍流氓。',
'退回率永远给两个数:「退回 5 / 已处置 40 = 12.5%(另有 60 条未动)」——「没人动」和「动了但退回」是完全不同的信号,只报一个百分比会把前者藏起来。',
'退回率永远给两个数:「客服退回 5 / 已处置 40 = 12.5%(另有 60 条未动)」——「没人动」和「动了但退回」是完全不同的信号,只报一个百分比会把前者藏起来。',
'released(客服退回)与 recalled(主管回收)是两件事:前者是客服说这单我不接,带原因、进退回原因分布;后者是主管在调整各客服在手的单时把活收回来,没有原因。把 recalled 算进退回率,他就是在拿自己的动作当调下一批的依据。',
'分母小于 50 时直接说「样本量不足」,⛔ 不要输出百分比、⛔ 不要画图。',
'outcomes(通话成效)与 releaseReasons(退回原因)是两件不同的事,⛔ 绝不能混说:releaseReasons =「这单不该我做」,客服没打就还回去了,是**分配**问题;outcomes =「打了,结果这样」,客服做了事,是**召回效果**问题。说反了主管会去改错的东西。',
'outcomes.noOutcome(一次结果都没有)必须单独报出来,⛔ 不许算进「不成功」——那不是效果差,是根本没做/没记。',
......
......@@ -497,7 +497,7 @@ export class McpServerFactory {
// ⚠️ 原文第一句是「**我**分过的批次」——「我」指谁在这句里是含糊的
// (说话人是工具描述,读的人是模型),而下面 `mine` 恰恰就是"只看自己发起的"。
// 一含糊就会连带 `mine` 的默认值一起被猜错。
'已经分下去的批次列表 + 每批汇总(分了多少 / 已处理 / 还在手 / 已退回 / 涉及几个客服)。' +
'已经分下去的批次列表 + 每批汇总(分了多少 / 已处理 / 还在手 / 客服退回 / 主管回收 / 涉及几个客服)。' +
'\n他问哪批出了问题、哪批还没动静时用它。要看某批细节再用 get_assignment_detail。' +
'\n⚠️ 返回里带 `_guide`,读数前先看它。',
inputSchema: {
......
......@@ -192,6 +192,17 @@ export function modelFacts(p: AssignmentProposal): Record<string, unknown> {
},
'怎么派的': {
'已排好': p.placed,
/**
* 🔴 **点确认真正会落下去几条** —— 2026-08-20 走查加。
*
* 在此之前模型手里只有「已排好 200」和引导节点上的「300 人要您定」,
* 「确认会写几条」得它自己减一次。实测它没减:收尾那句写的是
* 「确认之后这 **500** 人的名单和 3 天时限就定死了」—— 而待分配那 300 条
* 主管不处理就**不落库**,确认按钮上写的也是 200。
* ⇒ 要它说准的数就**直接给**,⛔ 别让它做算术(同「本批人数取小的那个」那次)。
* ⚠️ 与卡片上确认按钮里的数**同源同值**(都是 placed),⛔ 别让两处对不上。
*/
'点确认会落下去的条数': p.placed,
'涉及客服数': agents,
'分给自己专属客服的': dedicated,
'给手上最空的人的': spread,
......
......@@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import {
ASSIGNMENT_EXPIRES_DAYS_DEFAULT,
ASSIGNMENT_ITEMS_HARD_LIMIT,
DAILY_CALLS_PER_AGENT,
AssignStrategy,
type AgentInfo,
......@@ -296,13 +297,24 @@ export class AssignmentProposalService {
`提案:条件=${JSON.stringify(criteria)} 候选=${candidateTotal} 基数=${batchSize}`,
);
/**
* 本批**实际**分多少 = `min(基数 N, 候选总数)`。
* 本批**实际**分多少 = `min(基数 N, 候选总数, 单批硬上限)`。
*
* ⚠️ 候选不够时压低的是 `target`,⛔ **不回写基数** —— 那是这一批的偶然事实。
* 回写的话,主管点一次 44 人的小格子,以后所有批次就永远是 44 人,
* 而他完全看不出为什么变小了(界面上只会显示"沿用上次")。
*
* 🔴 **第三项 `ASSIGNMENT_ITEMS_HARD_LIMIT` 是 2026-08-20 补的,补的是一个必现的死路。**
* 基数默认 = `在岗人数 × 每人每天通话数 × 时限天数`,23 人的诊所按默认值就是
* `23 × 15 × 3 = 1035` —— 而确认接口的 `items` 有 `.max(500)` 的技术护栏
* (单事务 + PG bind 变量上限)。于是提案照样出 537 条的确认单,主管在上面
* 拖了半天、点确认,收到一句「请求字段校验失败」,**改什么都没用**。
* ⚠️ 不是边界情况:**12 个客服以上、用默认值就必然触发**(12×15×3 = 540 > 500)。
*
* ⛔ 别改成"把 HARD_LIMIT 调大" —— 它是技术护栏,不是业务旋钮(见 schema 那条注释)。
* ⛔ 也别回写进 `batchSize`:同上,那是这一批的偶然事实,回写会把主管的习惯值
* 永久钉死在 500。
*/
const target = Math.min(batchSize, candidateTotal);
const target = Math.min(batchSize, candidateTotal, ASSIGNMENT_ITEMS_HARD_LIMIT);
// 同患者只留一条(schema 注释承诺的 partial UNIQUE 实际不存在,不能当保障用)
const seenPatient = new Set<string>();
......
import { createZodDto } from 'nestjs-zod';
import {
RearrangeDraftSchema,
RearrangeSubmitRequestSchema,
RearrangeSubmitResponseSchema,
} from '@pac/types';
export class RearrangeDraftDto extends createZodDto(RearrangeDraftSchema) {}
export class RearrangeSubmitRequestDto extends createZodDto(RearrangeSubmitRequestSchema) {}
export class RearrangeSubmitResponseDto extends createZodDto(RearrangeSubmitResponseSchema) {}
......@@ -123,8 +123,10 @@ export function classifyPlanProgress(
*/
function mergeStats(
live: { planned: number; agents: number; released: number } | undefined,
ledger: { planned: number; agents: number; released: number; expired: number; revoked: number } | undefined,
): { planned: number; agents: number; released: number; expired: number } {
ledger:
| { planned: number; agents: number; released: number; recalled: number; expired: number; revoked: number }
| undefined,
): { planned: number; agents: number; released: number; recalled: number; expired: number } {
const l = live ?? { planned: 0, agents: 0, released: 0 };
const legacy = !ledger || ledger.planned === 0;
return {
......@@ -132,6 +134,13 @@ function mergeStats(
agents: legacy ? l.agents : ledger.agents,
released: legacy ? l.released : ledger.released,
/**
* ⚠️ 主管回收**只有账本口径**,老批次(账本没记批次号)一律 0 ——
* ⛔ 别去 followup_plans 上现算:那边只看得到"现在不在人手上",
* 分不出是客服退回、到期、还是主管收回的,猜一个数比缺一个数更糟。
* ⚠️ 老批次天然为 0 也是对的:调整这条路 2026-08-20 才有,更早的批次不可能有回收。
*/
recalled: ledger?.recalled ?? 0,
/**
* ⚠️ 超期**不跟着 legacy 回落**(2026-08-19):它现在只有一个来源 ——
* **此刻仍挂在人手上且已过时限**(现算),而那一支对新老批次一样成立。
* ⛔ 别让它跟着 `legacy` 归零:老批次照样可能有单还压在人手上。
......@@ -526,7 +535,17 @@ export class PlanAssignmentService {
* 但显示一个偏小的数,总好过显示 0 让主管以为这批没分成。
*/
private async ledgerStatsByAssignment(ids: string[]): Promise<
Map<string, { planned: number; agents: number; released: number; expired: number; revoked: number }>
Map<
string,
{
planned: number;
agents: number;
released: number;
recalled: number;
expired: number;
revoked: number;
}
>
> {
if (ids.length === 0) return new Map();
/**
......@@ -540,12 +559,26 @@ export class PlanAssignmentService {
* 注释里的反引号会把模板提前截断,而报错指向的是几行之后的地方(踩过)。
*/
const rows = await this.prisma.$queryRaw<
Array<{ assignment_id: string; planned: bigint; agents: bigint; released: bigint; revoked: bigint }>
Array<{
assignment_id: string;
planned: bigint;
agents: bigint;
released: bigint;
recalled: bigint;
revoked: bigint;
}>
>(Prisma.sql`
SELECT assignment_id,
count(DISTINCT patient_id) FILTER (WHERE event = 'assign') AS planned,
count(DISTINCT assignee_user_id) FILTER (WHERE event = 'assign') AS agents,
-- ⚠️ 并上 transfer:主管重排把一条换给别人之后,**接手人也经手了这一批**。
-- 只数 assign 的话,批次上写着「涉及 3 人」而实际有 4 个人碰过这些单,
-- 而且不会有任何报错(重排不改 assignment_id,那些单仍算这一批)。
-- ⛔ planned 不并 —— 它按 patient 去重,同一患者被转手不该多算一条。
count(DISTINCT assignee_user_id) FILTER (WHERE event IN ('assign','transfer')) AS agents,
count(DISTINCT plan_id) FILTER (WHERE event = 'release') AS released,
-- 主管在调整单里收回池子的 —— ⛔ 与 released 分开:那是客服不接,这是主管收活。
-- 混在一起会把主管自己的调度算进退回原因分布,而他正是靠那张分布决定下一批。
count(DISTINCT plan_id) FILTER (WHERE event = 'rearrange_remove') AS recalled,
count(DISTINCT plan_id) FILTER (WHERE event = 'auto_release'
AND reason = ${PlanEventReason.REVOKED}) AS revoked
FROM plan_event_logs
......@@ -574,6 +607,7 @@ export class PlanAssignmentService {
r.assignment_id,
{
planned: Number(r.planned), agents: Number(r.agents), released: Number(r.released),
recalled: Number(r.recalled),
// ⚠️ 只认「此刻挂在人手上且过了时限」这一支(理由见上面那段) ——
// ⛔ 别再并上账本里的 auto_release:那些单已经回池、没有客服挂着,不是谁的超期。
expired: nowMap.get(r.assignment_id) ?? 0,
......@@ -882,6 +916,26 @@ export class PlanAssignmentService {
* ⚠️ 退回率**两个分母都给**:「退回 3 / 已处置 21 = 14.3%,另有 2 条没动」。
* 没动的数量本身是信号,只给一个百分比会把它藏起来(文档「三个必须先定的口径」)。
*
* 🔴 **每一支都必须按 `target_clinic_id` 收窄到本诊所**(2026-08-20 修的线上 bug)。
* ── 现场 ──────────────────────────────────────────────────
* 杭州大厦主管工作台显示「23 位在岗 · 在手 53」,其中李欣 52 条。实查:
* · 杭州大厦自己的 followup_plans:active 17,938 / superseded 4,137 / **assigned 0**
* · 那 52 条的 target_clinic_id 全是上海世纪大道,07-30 她在**那边**自己认领的
* ⇒ 一条都不属于杭州大厦,整栏是幻觉。
*
* 根因是**名册按诊所取、计数却按全租户取**:客服在哪家做过回访就进哪家名册
* (名册从回访行为反推,宿主没给权威员工名单),跨诊所工作的人于是把别家的
* 在手/超期/完成全带了过来。`assignee_user_id IN (本诊所名册)` 挡不住这件事 ——
* 它限的是**人**,不是**单**。
*
* ⚠️ 六支全改,⛔ 不许只改「在手」:同一屏上"在手 0 / 超期 41"这种自相矛盾
* 比全错更难查。plan_event_logs / plan_executions 没有诊所列,经 plan_id
* 反查 `followup_plans.target_clinic_id`(**归属**),⛔ 不是 executor_clinic_id
* (那是执行地,同一条单可能在别家诊所打的电话)。
*
* ⚠️ 代价(已知并接受):跨诊所的客服,本屏不再显示他在别家的负荷。分配前想知道
* "这人在别处忙不忙",需要另加一列(产品未定),⛔ 别为此把诊所过滤去掉。
*
* ⛔ 名册**沿用 AgentRosterService**,不在这里另查一份:
* 分配时问「还能吃多少」、跟踪时问「手上压了多少」是同一份数据的两种读法(T10)。
* 另查一份必然口径漂移,而漂了不报错。
......@@ -911,6 +965,7 @@ export class PlanAssignmentService {
FROM followup_plans
WHERE host_id = ${scope.hostId}::uuid
AND tenant_id = ${scope.tenantId}
AND target_clinic_id = ${clinicId}
AND status = 'assigned'
AND superseded_at IS NULL
AND assignee_user_id IN (${Prisma.join(ids)})
......@@ -943,6 +998,7 @@ export class PlanAssignmentService {
FROM followup_plans
WHERE host_id = ${scope.hostId}::uuid
AND tenant_id = ${scope.tenantId}
AND target_clinic_id = ${clinicId}
AND status = 'assigned'
AND superseded_at IS NULL
AND assignment_expires_at IS NOT NULL
......@@ -965,6 +1021,7 @@ export class PlanAssignmentService {
FROM followup_plans
WHERE host_id = ${scope.hostId}::uuid
AND tenant_id = ${scope.tenantId}
AND target_clinic_id = ${clinicId}
AND status = 'assigned'
AND superseded_at IS NULL
AND assignment_expires_at IS NOT NULL
......@@ -988,6 +1045,8 @@ export class PlanAssignmentService {
AND event = ${PlanEventType.RELEASE}
AND created_at >= ${since}
AND actor_user_id IN (${Prisma.join(ids)})
AND EXISTS (SELECT 1 FROM followup_plans p
WHERE p.id = plan_event_logs.plan_id AND p.target_clinic_id = ${clinicId})
GROUP BY actor_user_id`);
/**
......@@ -1002,6 +1061,8 @@ export class PlanAssignmentService {
AND tenant_id = ${scope.tenantId}
AND created_at >= ${since}
AND operator_user_id IN (${Prisma.join(ids)})
AND EXISTS (SELECT 1 FROM followup_plans p
WHERE p.id = plan_executions.plan_id AND p.target_clinic_id = ${clinicId})
GROUP BY operator_user_id`);
/**
......@@ -1031,6 +1092,8 @@ export class PlanAssignmentService {
AND e.event = ${PlanEventType.ASSIGN}
AND e.created_at >= ${since}
AND e.assignee_user_id IN (${Prisma.join(ids)})
AND EXISTS (SELECT 1 FROM followup_plans p
WHERE p.id = e.plan_id AND p.target_clinic_id = ${clinicId})
AND NOT EXISTS (
SELECT 1 FROM plan_event_logs r
WHERE r.plan_id = e.plan_id
......@@ -1156,7 +1219,9 @@ export class PlanAssignmentService {
*/
const batchEvents = await this.prisma.planEventLog.findMany({
where: { assignmentId: id, tenantId: scope.tenantId },
select: { planId: true, assigneeUserId: true, event: true, reason: true },
// ⚠️ `details` 要选出来:主管回收记的是「从谁手上收的」(details.from),
// 而事件本身的 assigneeUserId 是 null(收回之后无人归属)。
select: { planId: true, assigneeUserId: true, event: true, reason: true, details: true },
orderBy: { createdAt: 'desc' },
});
const assignEvents = batchEvents.length
......@@ -1200,7 +1265,7 @@ export class PlanAssignmentService {
const touch = (key: string): AssignmentAgentStat => {
let a = byAgent.get(key);
if (!a) {
a = { userId: key, name: null, planned: 0, inHand: 0, released: 0, overdue: 0, done: 0 };
a = { userId: key, name: null, planned: 0, inHand: 0, released: 0, recalled: 0, overdue: 0, done: 0 };
byAgent.set(key, a);
}
return a;
......@@ -1223,6 +1288,19 @@ export class PlanAssignmentService {
touch(originalOwner.get(e.planId) ?? RELEASED_BUCKET).released++;
if (e.reason) reasonCount.set(e.reason, (reasonCount.get(e.reason) ?? 0) + 1);
}
/**
* 主管回收 —— 归到**被收的那个人**头上(`details.from`),⛔ 不是原始承接人:
* 一条单可能先被转手、再被收回,那时候"从谁手上收的"跟"当初分给谁"不是一个人。
* ⚠️ ⛔ 不进 `reasonCount`:退回原因分布只回答「客服为什么不接这单」,
* 把主管自己的调度混进去,他就是在拿自己的动作当决策依据。
*/
const seenRecall = new Set<string>();
for (const e of batchEvents) {
if (e.event !== PlanEventType.REARRANGE_REMOVE || seenRecall.has(e.planId)) continue;
seenRecall.add(e.planId);
const from = (e.details as { from?: string } | null)?.from;
touch(from ?? originalOwner.get(e.planId) ?? RELEASED_BUCKET).recalled++;
}
}
for (const p of plans) {
......@@ -1325,8 +1403,16 @@ export class PlanAssignmentService {
(bucket.suppressed ? `,${bucket.suppressed} 条客服写了回访结果(约下次/拒绝/放弃)` : '') +
`;还在客服手上未处理 ${bucket.inHandPending} 条,落回池子 ${bucket.backToPool} 条` +
// 退回与到期必须分开说 —— 主管的下一步动作相反(改分配策略 vs 派多了/时限太紧)
(counts.released || counts.expired
? `(其中客服主动退回 ${counts.released} 条、到期没人动 ${counts.expired} 条)`
/**
* ⚠️ 「落回池子」这一桶里现在有**三种来路**,只报两种主管会自己去凑第三种:
* 客服主动退回 / 到期没人动 / **主管在调整单里收回**。
* 少报第三种时他看到的是「落回池子 12 条(其中退回 3、到期 0)」——
* 剩下 9 条没有出处,而那 9 条正是他自己收回来的。
*/
(counts.released || counts.expired || counts.recalled
? `(其中客服主动退回 ${counts.released} 条、到期没人动 ${counts.expired} 条` +
(counts.recalled ? `、你自己在调整时收回 ${counts.recalled} 条` : '') +
`)`
: '') +
`。` +
(reassigned ? `另有 ${reassigned} 人已被后面的批次挑走,他们之后的表现算在那一批上。` : '') +
......@@ -1441,6 +1527,7 @@ export class PlanAssignmentService {
// ⛔ 别改回 `plans.length` —— 那是"现在还归本批的人",会随重分失血(见 mergeStats)。
planned: counts.planned,
released: counts.released,
recalled: counts.recalled,
expired: counts.expired,
agents: counts.agents,
progress,
......
......@@ -58,6 +58,7 @@ export function recordPlanEventsBulk(
heldSeconds: input.heldSeconds ?? null,
reason: input.reason ?? null,
assignmentId: input.assignmentId ?? null,
operationId: input.operationId ?? null,
details: input.details ?? undefined,
})),
});
......@@ -89,6 +90,14 @@ export interface PlanEventInput {
* 可能是上一批留下的陈迹(退回/到期都刻意不清它),顶上去等于给旧批次凭空加人。
*/
assignmentId?: string | null;
/**
* 这条事件属于**哪一次主管重排**(见 schema 里 `operation_id` 的说明)。
*
* ⚠️ 只有重排三件事该传:transfer / expiry_changed / rearrange_remove。
* ⚠️ 与 `assignmentId` **不互斥**:重排不改归属批次,所以重排事件两列都有值 ——
* assignmentId 是这条单**原来那一批**,operationId 是这次重排。
*/
operationId?: string | null;
/** 事件专属细节(不按它查询的内容,如反馈文字) */
details?: Prisma.InputJsonObject | null;
}
......@@ -106,6 +115,7 @@ export function recordPlanEvent(tx: PlanEventLogWriter, input: PlanEventInput):
heldSeconds: input.heldSeconds ?? null,
reason: input.reason ?? null,
assignmentId: input.assignmentId ?? null,
operationId: input.operationId ?? null,
// undefined 才让 Prisma 落 NULL;传 null 会被当成 JSON null 值
details: input.details ?? undefined,
},
......
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { randomUUID } from 'node:crypto';
import {
DAILY_CALLS_PER_AGENT,
Permission,
PlanEventType,
REARRANGE_ITEMS_HARD_LIMIT,
REARRANGE_SKIP_REASON_ZH,
type RearrangeDraft,
type RearrangeItem,
type RearrangeSkipReason,
type RearrangeSubmitRequest,
type RearrangeSubmitResponse,
} from '@pac/types';
import { calcAge } from '@pac/utils';
import { PrismaService } from '../../prisma/prisma.service';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';
import { AgentRosterService } from './agent-roster.service';
import { endOfDayInHostTimezone } from './plan-assignment.service';
import { computeHeldSeconds, recordPlanEventsBulk } from './plan-event.recorder';
import { requirePermission, rejectSyntheticIdentity, type DispatchActor } from './dispatch-guard';
import { planLabelAnchorsSql, temperatureBucketCaseSql } from './reason-temperature.sql';
/**
* PlanRearrangeService —— 主管**调整**:调整各客服此刻在手的单。
*
* ═══ 与「分配」的边界(⛔ 别把两条路缝在一起)═══════════════════════════
* 分配是「从池子里挑一批人发下去」,调整是「已经在客服手上的这些,怎么调」。
* 形状像,语义不通 —— 具体四条差异写在 `@pac/types` 的 `plan-rearrange.ts` 文件头。
*
* 🔴 **调整不建批次,单子的 `assignment_id` 一律不动。**
* 动了原批的 `planned` 会静默缩水(schema.prisma 里 assignment_id 那段红字记过实测:
* 某批分过 9 条,重分后 planned 显示 0)。所以调整改的只有三样:
* `assignee_user_id` / `assignment_expires_at` / 以及移出时的归属清空。
*
* 🔴 **移出⛔ 不写 `release` 事件。**
* 批次详情的退回原因分布是 `for (e of batchEvents) if (e.event === 'release')` ——
* 只按 assignment_id 过滤、**不看是谁干的**。挂 release 会同时污染那一批的
* 退回原因分布、并给原客服记一笔他没做过的「退回」。⇒ 走 `rearrange_remove`。
*
* 🔴 **逐条落,落不上的如实回报。**
* ⛔ 不照抄分配 `create()` 的「数量对不上整体拒绝」:那边整批还没发出去,重来代价小;
* 而调整时主管可能已经调了二十条,因为客服刚好打完一条就全部作废 —— 一次就废掉这个功能。
*/
@Injectable()
export class PlanRearrangeService {
private readonly logger = new Logger(PlanRearrangeService.name);
constructor(
private readonly prisma: PrismaService,
private readonly roster: AgentRosterService,
) {}
// ═══════════════════════════════════════════════════════════
// 取草稿(只读)
// ═══════════════════════════════════════════════════════════
/**
* @param agentUserIds 主管点名的客服;不传 = 全景(全队在手)
*
* ⚠️ 人员集合一律走 `AgentRosterService`(与分配、团队状态同一个名册)——
* 名册外的人手上的单本期**不管**(产品 2026-08-20 定)。
* ⛔ 别在这里另起一份人员口径:三处各写一套,主管会在三个界面上看到三个数。
*/
async draft(
scope: TenantScopeContext,
clinicId: string,
agentUserIds?: string[],
): Promise<RearrangeDraft> {
const roster = await this.roster.list(scope, clinicId);
const rosterIds = roster.agents.map((a) => a.userId);
const wanted = agentUserIds?.map((s) => s.trim()).filter(Boolean) ?? [];
const scoped = wanted.length > 0 ? rosterIds.filter((id) => wanted.includes(id)) : rosterIds;
if (scoped.length === 0) {
throw new NotFoundException(
wanted.length > 0
? `点名的客服都不在「${clinicId}」的名册里,没有可调整的在手单。`
: `「${clinicId}」的客服名册是空的(近 12 个月没有回访记录),没有可调整的在手单。`,
);
}
/**
* ⭐ 多取一条用来判断有没有被硬上限截断 —— `truncated > 0` 时界面必须说出来。
* ⛔ 静默截断 = 少掉的那些主管永远不知道(no silent cap)。
*/
const rows = await this.prisma.followupPlan.findMany({
where: {
hostId: scope.hostId,
tenantId: scope.tenantId,
targetClinicId: clinicId,
status: 'assigned',
supersededAt: null,
assigneeUserId: { in: scoped },
...(scope.sourceUnits.length
? { patient: { sourceUnit: { in: scope.sourceUnits } } }
: {}),
},
select: {
id: true,
patientId: true,
assigneeUserId: true,
assignmentId: true,
assignmentExpiresAt: true,
snoozedUntil: true,
patient: {
select: {
name: true,
medicalRecordNumber: true,
gender: true,
birthDate: true,
// ⚠️ 专属客服在 `preferences.dedicatedCs.id`(JSON)—— 与分配那边同源,
// ⛔ 别另起一套口径(那一列由宿主摄入覆盖,PAC 只读)
preferences: true,
profile: { select: { lastVisitDoctor: true } },
},
},
},
// 先按客服、再按最紧的时限 —— 主管展开一位客服时先看到快到期/已超期的
orderBy: [{ assigneeUserId: 'asc' }, { assignmentExpiresAt: 'asc' }],
take: REARRANGE_ITEMS_HARD_LIMIT + 1,
});
const truncated = Math.max(0, rows.length - REARRANGE_ITEMS_HARD_LIMIT);
const kept = rows.slice(0, REARRANGE_ITEMS_HARD_LIMIT);
const planIds = kept.map((r) => r.id);
const [labelTemp, benefits] = await Promise.all([
this.labelsAndTemperatureOf(planIds),
this.benefitOf([...new Set(kept.map((r) => r.assignmentId).filter((v): v is string => !!v))]),
]);
const now = new Date();
const items: RearrangeItem[] = kept.map((r) => {
const lt = labelTemp.get(r.id);
return {
planId: r.id,
patientId: r.patientId,
patientName: r.patient?.name ?? null,
medicalRecordNumber: r.patient?.medicalRecordNumber ?? null,
gender: r.patient?.gender ?? null,
age: r.patient?.birthDate ? calcAge(r.patient.birthDate) : null,
lastVisitDoctor: r.patient?.profile?.lastVisitDoctor ?? null,
assigneeUserId: r.assigneeUserId!,
dedicatedCsUserId: dedicatedOf(r.patient?.preferences),
potentialTreatment: lt?.label ?? null,
temperature: lt?.temperature ?? null,
expiresAt: r.assignmentExpiresAt?.toISOString() ?? null,
overdueDays: overdueDaysOf(r.assignmentExpiresAt, r.snoozedUntil, now),
snoozedUntil: r.snoozedUntil?.toISOString() ?? null,
fromAssignmentId: r.assignmentId,
benefit: r.assignmentId ? (benefits.get(r.assignmentId) ?? null) : null,
};
});
const byAgent = new Map<string, { inHand: number; overdue: number }>();
for (const id of scoped) byAgent.set(id, { inHand: 0, overdue: 0 });
for (const it of items) {
const a = byAgent.get(it.assigneeUserId);
if (!a) continue;
a.inHand += 1;
if (it.overdueDays != null) a.overdue += 1;
}
const nameOf = new Map(roster.agents.map((a) => [a.userId, a.name ?? null]));
return {
operationId: randomUUID(),
clinicId,
scope: wanted.length > 0 ? 'agents' : 'all',
scopedAgentUserIds: wanted.length > 0 ? scoped : [],
agents: scoped.map((userId) => ({
userId,
name: nameOf.get(userId) ?? null,
inHand: byAgent.get(userId)?.inHand ?? 0,
overdue: byAgent.get(userId)?.overdue ?? 0,
})),
items,
total: items.length,
overdueTotal: items.filter((i) => i.overdueDays != null).length,
truncated,
dailyCalls: DAILY_CALLS_PER_AGENT,
};
}
/**
* 每条单的「什么机会 + 患者末诊落在哪一档」。
*
* ⭐ 复用矩阵那条路(`planLabelAnchorsSql` + `temperatureBucketCaseSql`)——
* ⛔ 别在这儿另算一套:调整单上写「2–3 年」而矩阵上是「1–2 年」,主管无从判断谁对。
* ⚠️ 一条单可能有多个标签(一个患者两种潜在治疗),但**温度只有一个** ——
* 锚点是患者末诊、边界是固定天数,与标签无关(见 reason-temperature 的 BOUNDS)。
* 所以这里按 plan 聚合,标签取字典序第一个当代表(界面只展示一个)。
*/
private async labelsAndTemperatureOf(
planIds: string[],
): Promise<Map<string, { label: string | null; temperature: string | null }>> {
const out = new Map<string, { label: string | null; temperature: string | null }>();
if (planIds.length === 0) return out;
const filter = Prisma.sql`fp.id IN (${Prisma.join(planIds.map((i) => Prisma.sql`${i}::uuid`))})`;
const bucket = temperatureBucketCaseSql(
Prisma.sql`max(la.hot_until)`,
Prisma.sql`max(la.warm_until)`,
Prisma.sql`max(la.anchor_at)`,
);
const rows = await this.prisma.$queryRaw<
Array<{ plan_id: string; label: string | null; temperature: string | null }>
>(Prisma.sql`
WITH la AS (${planLabelAnchorsSql(filter)})
SELECT la.plan_id, min(la.label) AS label, ${bucket} AS temperature
FROM la GROUP BY la.plan_id`);
for (const r of rows) out.set(r.plan_id, { label: r.label, temperature: r.temperature });
return out;
}
/** 批次挂的福利 —— ⛔ 只读。调整跨批次,改福利会破坏「这批的效果 = 这个福利的效果」。 */
private async benefitOf(assignmentIds: string[]): Promise<Map<string, string>> {
const out = new Map<string, string>();
if (assignmentIds.length === 0) return out;
const rows = await this.prisma.planAssignment.findMany({
where: { id: { in: assignmentIds } },
select: { id: true, attributes: true },
});
for (const r of rows) {
const text = (r.attributes as { benefit?: { text?: string } } | null)?.benefit?.text;
if (typeof text === 'string' && text.trim()) out.set(r.id, text);
}
return out;
}
// ═══════════════════════════════════════════════════════════
// 提交(主管手动确认之后)
// ═══════════════════════════════════════════════════════════
async submit(
scope: TenantScopeContext,
actor: DispatchActor,
dto: RearrangeSubmitRequest,
): Promise<RearrangeSubmitResponse> {
// ── 闸:身份(与分配同一份判据,⛔ 别另写)────────────────────
requirePermission(actor, Permission.PLAN_DISPATCH);
rejectSyntheticIdentity(actor);
// ── 幂等:这次调整已经落过 → 一个字都不写 ──────────────────
// 放最前面而不是靠唯一键冲突:让重放**不产生任何副作用**(与分配 create 同一条纪律)。
const already = await this.prisma.planEventLog.findFirst({
where: { hostId: scope.hostId, tenantId: scope.tenantId, operationId: dto.operationId },
select: { id: true },
});
if (already) {
return {
operationId: dto.operationId,
duplicate: true,
moved: 0,
expiryChanged: 0,
removed: 0,
skipped: [],
note: '这次调整此前已经提交过,本次没有任何改动。',
};
}
// ── 闸:诊所在不在数据范围内 ──────────────────────────────
if (scope.clinicIds.length > 0 && !scope.clinicIds.includes(dto.clinicId)) {
throw new NotFoundException(`诊所 ${dto.clinicId} 不在你的数据范围内`);
}
const moveBy = new Map(dto.moves.map((m) => [m.planId, m.toUserId]));
const expiryBy = new Map(dto.expiry.map((e) => [e.planId, e.expiresInDays]));
const removeSet = new Set(dto.removes.map((r) => r.planId));
/**
* 移出与改派/改时限**不能落在同一条单上** —— 那是调用方的 bug,不是并发。
* ⛔ 不"贴心地"挑一个执行:两种意图相反(一个留在人手上、一个收回池子),
* 猜错了主管完全看不出来。
*/
const conflict = [...removeSet].filter((id) => moveBy.has(id) || expiryBy.has(id));
if (conflict.length > 0) {
throw new BadRequestException(
`有 ${conflict.length} 条同时被要求「移出」和「改派/改时限」,意图矛盾,本次未执行。`,
);
}
const planIds = [...new Set([...moveBy.keys(), ...expiryBy.keys(), ...removeSet])];
if (planIds.length === 0) {
throw new BadRequestException('这次调整一条改动都没有,未执行。');
}
if (planIds.length > REARRANGE_ITEMS_HARD_LIMIT) {
throw new BadRequestException(`单次调整最多 ${REARRANGE_ITEMS_HARD_LIMIT} 条`);
}
const rows = await this.prisma.followupPlan.findMany({
where: {
id: { in: planIds },
hostId: scope.hostId,
tenantId: scope.tenantId,
targetClinicId: dto.clinicId,
...(scope.sourceUnits.length
? { patient: { sourceUnit: { in: scope.sourceUnits } } }
: {}),
},
select: {
id: true,
patientId: true,
status: true,
assigneeUserId: true,
assignedAt: true,
assignmentId: true,
assignmentExpiresAt: true,
supersededAt: true,
},
});
const byId = new Map(rows.map((r) => [r.id, r]));
const skipped: RearrangeSubmitResponse['skipped'] = [];
const skip = (planId: string, op: 'move' | 'expiry' | 'remove', reason: RearrangeSkipReason) =>
skipped.push({ planId, op, reason });
/** 这条单此刻还能不能动 —— 三种 op 共用的前置判断 */
const liveOf = (planId: string, op: 'move' | 'expiry' | 'remove') => {
const r = byId.get(planId);
if (!r) {
skip(planId, op, 'out_of_scope');
return null;
}
if (r.supersededAt != null || r.status === 'superseded') {
skip(planId, op, 'superseded');
return null;
}
if (r.status === 'completed' || r.status === 'abandoned') {
skip(planId, op, 'already_done');
return null;
}
if (r.status !== 'assigned' || r.assigneeUserId == null) {
skip(planId, op, 'not_in_hand');
return null;
}
return r;
};
type Live = NonNullable<ReturnType<typeof liveOf>>;
const moves: Array<{ row: Live; to: string }> = [];
for (const [planId, to] of moveBy) {
const row = liveOf(planId, 'move');
if (!row) continue;
if (row.assigneeUserId === to) {
skip(planId, 'move', 'same_assignee');
continue;
}
moves.push({ row, to });
}
const expiries: Array<{ row: Live; days: number }> = [];
for (const [planId, days] of expiryBy) {
const row = liveOf(planId, 'expiry');
if (row) expiries.push({ row, days });
}
const removes: Live[] = [];
for (const planId of removeSet) {
const row = liveOf(planId, 'remove');
if (row) removes.push(row);
}
if (moves.length + expiries.length + removes.length === 0) {
throw new BadRequestException(
`这 ${planIds.length} 条都动不了(${summarizeSkipped(skipped)}),本次未执行。`,
);
}
const tz = await this.hostTimezone(scope.hostId);
const now = new Date();
const result = await this.prisma.$transaction(async (tx) => {
const events: Parameters<typeof recordPlanEventsBulk>[1] = [];
const base = {
hostId: scope.hostId,
tenantId: scope.tenantId,
operationId: dto.operationId,
};
// ── ① 改派 ────────────────────────────────────────────
// ⭐ 乐观并发:`assignee_user_id = v.from_user` 是全部依据 —— 从上面 findMany
// 到这一刻若有人抢先改派/退回,该行不满足条件、不被更新,RETURNING 里就没有它。
let movedIds: string[] = [];
if (moves.length > 0) {
const values = moves.map(
({ row, to }) =>
Prisma.sql`(${row.id}::uuid, ${row.assigneeUserId!}::text, ${to}::text)`,
);
const applied = await tx.$queryRaw<Array<{ id: string }>>(Prisma.sql`
UPDATE followup_plans AS fp SET
assignee_user_id = v.to_user,
updated_at = ${now}::timestamptz
FROM (VALUES ${Prisma.join(values)}) AS v(plan_id, from_user, to_user)
WHERE fp.id = v.plan_id
AND fp.status = 'assigned'
AND fp.superseded_at IS NULL
AND fp.assignee_user_id = v.from_user
RETURNING fp.id`);
movedIds = applied.map((a) => a.id);
const ok = new Set(movedIds);
for (const { row } of moves) if (!ok.has(row.id)) skip(row.id, 'move', 'assignee_changed');
for (const { row, to } of moves) {
if (!ok.has(row.id)) continue;
events.push({
...base,
planId: row.id,
patientId: row.patientId,
event: PlanEventType.TRANSFER,
// 变更**后**的归属人 —— 与 assign 同语义
assigneeUserId: to,
actorUserId: actor.userId,
// ⭐ 原客服进 details:列上只有"变更后",而"从谁那儿来"事后补不回来
details: { from: row.assigneeUserId },
// ⭐ 仍是**原来那一批** —— 调整不改归属批次(见文件头红字)
assignmentId: row.assignmentId,
});
}
}
// ── ② 改时限 ──────────────────────────────────────────
let expiredIds: string[] = [];
if (expiries.length > 0) {
const values = expiries.map(
({ row, days }) =>
Prisma.sql`(${row.id}::uuid, ${endOfDayInHostTimezone(now, days, tz)}::timestamptz)`,
);
const applied = await tx.$queryRaw<Array<{ id: string }>>(Prisma.sql`
UPDATE followup_plans AS fp SET
assignment_expires_at = v.expires_at,
updated_at = ${now}::timestamptz
FROM (VALUES ${Prisma.join(values)}) AS v(plan_id, expires_at)
WHERE fp.id = v.plan_id
AND fp.status = 'assigned'
AND fp.superseded_at IS NULL
RETURNING fp.id`);
expiredIds = applied.map((a) => a.id);
const ok = new Set(expiredIds);
for (const { row } of expiries) if (!ok.has(row.id)) skip(row.id, 'expiry', 'not_in_hand');
for (const { row, days } of expiries) {
if (!ok.has(row.id)) continue;
events.push({
...base,
planId: row.id,
patientId: row.patientId,
event: PlanEventType.EXPIRY_CHANGED,
// 归属没变 —— 改派同批发生时这里记的是**改派后**那个人
assigneeUserId: moveBy.get(row.id) ?? row.assigneeUserId,
actorUserId: actor.userId,
details: {
from: row.assignmentExpiresAt?.toISOString() ?? null,
to: endOfDayInHostTimezone(now, days, tz).toISOString(),
days,
},
assignmentId: row.assignmentId,
});
}
}
// ── ③ 移出(收回池子)──────────────────────────────────
let removedIds: string[] = [];
if (removes.length > 0) {
const values = removes.map(
(row) => Prisma.sql`(${row.id}::uuid, ${row.assigneeUserId!}::text)`,
);
/**
* 清空哪些列与客服退回(`PlanService.recycle`)**逐列对齐**:
* ⛔ 不清 assignment_id / assigned_by / assign_strategy —— 那是"这单来自哪批"
* 这个历史事实,也是批次分母;
* ⛔ 绝不动 snoozed_until —— 移出是"换个人来做",不是"这人别召了";
* ⛔ 不写 release_reason —— 那一列是**客服**的「我为什么不接这单」,
* 主管的调度动作往里写会污染退回原因。
*/
const applied = await tx.$queryRaw<Array<{ id: string }>>(Prisma.sql`
UPDATE followup_plans AS fp SET
status = 'active',
assignee_user_id = NULL,
assigned_at = NULL,
recycle_at = NULL,
assignment_expires_at = NULL,
updated_at = ${now}::timestamptz
FROM (VALUES ${Prisma.join(values)}) AS v(plan_id, from_user)
WHERE fp.id = v.plan_id
AND fp.status = 'assigned'
AND fp.superseded_at IS NULL
AND fp.assignee_user_id = v.from_user
RETURNING fp.id`);
removedIds = applied.map((a) => a.id);
const ok = new Set(removedIds);
for (const row of removes) if (!ok.has(row.id)) skip(row.id, 'remove', 'assignee_changed');
for (const row of removes) {
if (!ok.has(row.id)) continue;
events.push({
...base,
planId: row.id,
patientId: row.patientId,
event: PlanEventType.REARRANGE_REMOVE,
assigneeUserId: null, // 收回后无人归属
actorUserId: actor.userId,
heldSeconds: computeHeldSeconds(row.assignedAt, now),
details: { from: row.assigneeUserId },
assignmentId: row.assignmentId,
});
}
}
await recordPlanEventsBulk(tx, events);
return { moved: movedIds.length, expiryChanged: expiredIds.length, removed: removedIds.length };
});
const note =
`调整已生效:` +
[
result.moved ? `改派 ${result.moved} 条` : '',
result.expiryChanged ? `改时限 ${result.expiryChanged} 条` : '',
result.removed ? `移出 ${result.removed} 条` : '',
]
.filter(Boolean)
.join('、') +
(skipped.length > 0 ? `。另有 ${skipped.length} 条没动:${summarizeSkipped(skipped)}` : '。');
this.logger.log(
`调整 ${dto.operationId}:改派 ${result.moved} / 改时限 ${result.expiryChanged} / ` +
`移出 ${result.removed} / 跳过 ${skipped.length},操作人=${actor.userId}`,
);
return { operationId: dto.operationId, duplicate: false, ...result, skipped, note };
}
/** 与 PlanAssignmentService 同一份回落(宿主没配就按上海)—— 时限一律按宿主当地日末 */
private async hostTimezone(hostId: string): Promise<string> {
const host = await this.prisma.host.findUnique({
where: { id: hostId },
select: { pullConfig: true },
});
return (host?.pullConfig as { timezone?: string } | null)?.timezone ?? 'Asia/Shanghai';
}
}
/** `patients.preferences.dedicatedCs.id` —— 与 PlanAssignmentService.dedicatedCsOf 同一条路径 */
function dedicatedOf(preferences: unknown): string | null {
const id = (preferences as { dedicatedCs?: { id?: string } } | null)?.dedicatedCs?.id;
return typeof id === 'string' && id ? id : null;
}
/**
* 已经超期几天 —— 未超期返回 null。
*
* 🔴 「且没约下次回访」这半句不能少,与团队状态表 / 批次超期**同一份判据**:
* 客服打了电话、约好了下次的单会一直"过期"下去,但那不是没处置。
* ⛔ 三处判据不许分家 —— 分了主管会在三个界面上看到三个数,而且不报错。
*/
export function overdueDaysOf(
expiresAt: Date | null,
snoozedUntil: Date | null,
now: Date,
): number | null {
if (expiresAt == null || expiresAt >= now) return null;
if (snoozedUntil != null && snoozedUntil > now) return null;
return Math.floor((now.getTime() - expiresAt.getTime()) / 86_400_000);
}
/** 跳过原因汇总成一句人话 —— ⛔ 界面与助手都念服务端这一句,别各拼一份 */
export function summarizeSkipped(skipped: RearrangeSubmitResponse['skipped']): string {
const count = new Map<RearrangeSkipReason, number>();
for (const s of skipped) count.set(s.reason, (count.get(s.reason) ?? 0) + 1);
return [...count]
.map(([reason, n]) => `${REARRANGE_SKIP_REASON_ZH[reason]} ${n} 条`)
.join('、');
}
......@@ -2,6 +2,8 @@ import { Module } from '@nestjs/common';
import { PlanController } from './plan.controller';
import { PlanService } from './plan.service';
import { AssignmentController } from './assignment.controller';
import { RearrangeController } from './rearrange.controller';
import { PlanRearrangeService } from './plan-rearrange.service';
import { PlanAssignmentService } from './plan-assignment.service';
import { AgentRosterService } from './agent-roster.service';
import { AssignmentProposalService } from './assignment-proposal.service';
......@@ -50,10 +52,14 @@ import { RecallDebugService } from './recall-debug/recall-debug.service';
// `GET /plans/:id`(id='assignments'),然后去查一个不存在的 plan,
// 报的还是 Prisma 的 uuid 解析错(90000),完全看不出是路由撞了。
// 实测踩过一次。同类先例见 plan.controller:80 的 `doctors` 那条注释。
controllers: [AssignmentController, PlanController, RecallDebugController],
// ⚠️ 顺序有意义:RearrangeController / AssignmentController 必须排在 PlanController
// **之前** —— 后者挂在 `plans` 上且有裸 `@Get(':id')`,Nest 按注册顺序匹配,
// 排在它后面的话 `/plans/rearrange` 会被当成 planId='rearrange'(实测踩过)。
controllers: [RearrangeController, AssignmentController, PlanController, RecallDebugController],
providers: [
PlanService,
PlanAssignmentService,
PlanRearrangeService,
AgentRosterService,
AssignmentProposalService,
CohortAttributesService,
......@@ -67,6 +73,6 @@ import { RecallDebugService } from './recall-debug/recall-debug.service';
PlanLabelService,
],
// MCP 的主管工具直接用这两个 service(条件注册,见 mcp-server.factory)
exports: [PlanService, PlanLabelService, PlanAssignmentService, AgentRosterService, AssignmentProposalService, CohortAttributesService, ExecutionService, ExecutionCallbackService, PlanEngineService, ChainComposerService],
exports: [PlanService, PlanLabelService, PlanAssignmentService, PlanRearrangeService, AgentRosterService, AssignmentProposalService, CohortAttributesService, ExecutionService, ExecutionCallbackService, PlanEngineService, ChainComposerService],
})
export class PlanModule {}
import type { RearrangeDraft, RearrangeSubmitResponse } from '@pac/types';
/**
* rearrange-facts —— 把一版调整单投影成**给模型的结构化事实**。
*
* ⚠️ 纪律与 `assignment-facts` 完全一致,⛔ 别在这儿放宽:
* ⛔ **一个字的指令都不许有**(「请照抄」「不要说成…」之类)。事实归事实,规范归提示词。
* ⛔ 不含 planId / 患者姓名 —— 那些走侧信道给界面,模型手里不该有。
* (客服姓名可以给:模型要用它组织「谁手上多少」这句话,确认单那边同样给。)
* ⚠️ 键名用中文:模型照着键名说就是对的。
*
* ⛔ 这里**不给成品句子**。唯一的例外是 `没落上的` 那一句 —— 它来自服务端的
* `summarizeSkipped`,口径必须与界面完全一致(两边各拼一份必然对不上)。
*/
/** 出一版调整单之后回给模型的事实 */
export function rearrangeFacts(d: RearrangeDraft): Record<string, unknown> {
const perAgent = d.agents
.filter((a) => a.inHand > 0)
.sort((x, y) => y.inHand - x.inHand)
.map((a) => ({
客服: a.name ?? '未知客服',
在手: a.inHand,
已超期: a.overdue,
// 「这些大概要打几天」—— 与确认单同一把换算尺(每人每天几通)
约几天的量: Math.ceil(a.inHand / d.dailyCalls),
}));
return {
调整单已呈现: true,
这一版是: {
范围:
d.scope === 'all'
? '这家诊所全部客服此刻在手的单'
: `点名的 ${d.scopedAgentUserIds.length} 位客服此刻在手的单`,
// ⚠️ 事实,不是指令:告诉它这一版**没被动过**,免得它顺口说成"我已经排好了"
内容: '原样 —— 谁手上什么样就摆成什么样,没有做过任何调整',
在手合计: d.total,
其中已超期: d.overdueTotal,
涉及客服: perAgent.length,
换算尺: `每人每天 ${d.dailyCalls} 通`,
},
每位客服: perAgent,
// 只在真的截断时出现 —— ⛔ 静默截断等于让主管以为"这就是全部"
...(d.truncated > 0
? {
没装下的: {
条数: d.truncated,
原因: '单次调整最多装 500 条,超出的这一版没有列进来',
},
}
: {}),
他可以让你做的事: ['把某几位患者换给别的客服', '改某几条的时限', '把某几条移出(收回池子)'],
这一步不写数据: true,
要生效得他在调整单上点确认: true,
};
}
/** 界面把调整提交完之后,回报给模型的事实(界面自己发进对话,这里是备用投影) */
export function rearrangeResultFacts(r: RearrangeSubmitResponse): Record<string, unknown> {
return {
调整已生效: !r.duplicate,
重复提交: r.duplicate,
改派: r.moved,
改时限: r.expiryChanged,
移出: r.removed,
没落上的条数: r.skipped.length,
// ⚠️ 成品句子,口径与界面同源(服务端 summarizeSkipped)——⛔ 别让模型自己归并原因
没落上的: r.note,
};
}
import { Body, Controller, Get, Post, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { ZodResponse } from 'nestjs-zod';
import { Permission } from '@pac/types';
import { RequirePermission } from '../../common/decorators/permissions.decorator';
import { TenantScope } from '../../common/decorators/tenant-scope.decorator';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';
import { resolveClinicId } from '../../common/decorators/resolve-clinic-id';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../../common/decorators/current-user.decorator';
import { PlanRearrangeService } from './plan-rearrange.service';
import { RearrangeDraftDto, RearrangeSubmitRequestDto, RearrangeSubmitResponseDto } from './dto/plan-rearrange.dto';
/**
* 主管调整端点 —— 调整各客服此刻在手的单。
*
* ⚠️ **本控制器必须注册在 `PlanController` 之前**(见 plan.module 的 controllers 数组)。
* `PlanController` 挂在 `plans` 上且有一个裸 `@Get(':id')`,Nest 按注册顺序匹配 ——
* 排在它后面的话 `GET /plans/rearrange` 这类单段路径会被当成 planId='rearrange',
* 报出来是 Prisma 的 uuid 解析错,看不出是路由问题(AssignmentController 实测踩过)。
*
* ⛔ 刻意**不复用** `plans/assignments` 前缀:调整不建批次、不属于任何一次分配,
* 挂进去会让"这是分配的一部分"这个错觉在路由上就成立。
*/
@ApiTags('plan-rearrange')
@ApiBearerAuth('accessToken')
@Controller('plans/rearrange')
export class RearrangeController {
constructor(private readonly rearrange: PlanRearrangeService) {}
@Get('draft')
@RequirePermission(Permission.PLAN_DISPATCH)
@ZodResponse({ status: 200, type: RearrangeDraftDto })
@ApiOperation({
summary: '取一版可编辑的在手快照 —— 不传 agents = 全景(全队),传了 = 只调这几位',
description:
'⛔ 不写任何数据。`operationId` 由服务端铸造,提交时原样回传做幂等键。' +
'人员集合走客服名册(与分配、团队状态同源)—— 名册外的人手上的单本期不管。',
})
async draft(
@TenantScope() scope: TenantScopeContext,
@Query('clinicId') clinicId?: string,
/// 逗号分隔的客服 userId;不传 = 全队
@Query('agents') agents?: string,
) {
return this.rearrange.draft(
scope,
// 🔴 越权闸:这条路会吐**患者姓名**,收了 clinicId 就必须过闸(纪律见 assignment.controller)
resolveClinicId(scope, clinicId),
agents
? agents
.split(',')
.map((s) => s.trim())
.filter(Boolean)
: undefined,
);
}
@Post()
@RequirePermission(Permission.PLAN_DISPATCH)
@ZodResponse({ status: 200, type: RearrangeSubmitResponseDto })
@ApiOperation({
summary: '确认调整 —— 主管在调整单上点确认时调用(这条路唯一的写动作)',
description:
'幂等:同一个 operationId 重复提交返回 duplicate:true,一个字都不写。' +
'**逐条落**,落不上的(客服刚打完 / 已退回 / 被重算取代)逐条在 skipped 里说明;' +
'⛔ 不是"数量对不上整体拒绝"。',
})
async submit(
@TenantScope() scope: TenantScopeContext,
@CurrentUser() user: AuthenticatedUser,
@Body() dto: RearrangeSubmitRequestDto,
) {
return this.rearrange.submit(scope, { userId: user.sub, permissions: user.permissions }, dto);
}
}
......@@ -457,11 +457,27 @@ describe('确认后没配福利 → 提醒补挂,但不许编效果', () => {
* 锁整句等于把"这条规矩必须写成一句话"也锁了进去。
* ⇒ 锁的是**这一步在、且三条不变量都在**。
*/
test('提示词里要说清"确认后仍可补挂、但人员时限改不了"', () => {
test('提示词里要说清"确认后仍可补挂、人员时限走调整那条路"', () => {
expect(PROMPTS).toMatch(/### 确认之后/);
expect(PROMPTS).toMatch(/补挂/);
expect(PROMPTS).toMatch(/人员和时限改不了/);
expect(PROMPTS).toMatch(/撤销重分/);
/**
* 🔴 **2026-08-20:人员和时限不再是"改不了"。**
*
* 这条断言原来钉的是「人员和时限改不了」+「撤销重分」—— 那是「调整各客服在手的单」
* 上线之前的事实。走查里模型照着这两句对主管说了「确认之后这 500 人的名单和 3 天时限
* 就定死了,要改只能整批撤销重分」,而那时候调整那条路已经能走了。
* ⇒ 断言跟着事实走,⛔ 不是把测试删掉了事:它守的是"确认之后到底能做什么"这件事
* 在提示词里必须写清楚,这个意图没变。
*/
expect(PROMPTS).toMatch(/不在这张单上改了/);
expect(PROMPTS).toMatch(/调整各客服在手的单/);
/**
* ⚠️ 旧说法必须**被写清后果**,⛔ 不能只是删掉、也⛔ 不是写一句禁令。
* 删掉之后模型仍可能凭上一版的印象说出来(它见过太多"撤销重分"的语料);
* 而光禁止扶不住它 —— 得说清照那句做会发生什么(他把整批收回来,
* 可他要的往往只是挪一两个人)。所以这里查的是那句**后果**在不在。
*/
expect(PROMPTS).toMatch(/说成整批撤销重分,他会把已经发出去的一批全收回来/);
});
});
......@@ -677,10 +693,20 @@ describe('待分配的人 —— 对话指令要能指到', () => {
});
test('⭐ 姓名可点击复制,且⛔ 不能吃掉整行的拖拽', () => {
expect(SHEET).toMatch(/function CopyableName/);
expect(SHEET).toMatch(/draggable=\{false\}/); // 不加会被当成拖文本
expect(SHEET).toMatch(/e\.stopPropagation\(\)/);
// 两处行都要用上:客服组里的 + 待分配里的
/**
* 🔴 2026-08-20 `CopyableName` / `DaySelect` 挪进了 `sheet-primitives.tsx` ——
* 重排单要跟确认单**手感一致**(产品:「不用二次学习成本」),而这两个原件身上
* 那点来之不易的行为(懒挂 Radix、复制反馈、`draggable={false}`)抄成两份必然漂。
* ⇒ 行为断言跟着挪到那个文件上;确认单这边仍然要断言**用上了**它。
*/
const PRIM = readFileSync(
join(__dirname, '../../pac-web/src/components/assistant/sheet-primitives.tsx'),
'utf8',
);
expect(PRIM).toMatch(/export function CopyableName/);
expect(PRIM).toMatch(/draggable=\{false\}/); // 不加会被当成拖文本
expect(PRIM).toMatch(/e\.stopPropagation\(\)/);
expect(SHEET).toMatch(/import \{ CopyableName, DaySelect \} from '\.\/sheet-primitives'/);
// 三处都要用上:客服组标题 + 客服组里的患者 + 待分配里的患者
expect((SHEET.match(/<CopyableName /g) ?? []).length).toBeGreaterThanOrEqual(3);
});
......
......@@ -162,6 +162,34 @@ describe('PlanEventType / PLAN_EVENT_META — 扩展性约束', () => {
expect(PLAN_EVENT_META.release.holdsPatient).toBe(false);
expect(PLAN_EVENT_META.auto_release.holdsPatient).toBe(false);
});
// ── 主管重排(调整各客服在手的单)—— 2026-08-20 ──────────────────
test('⭐⭐ 重排三件事都不算「客服处理过」—— 是主管在调度,不是有人跟进了患者', () => {
for (const t of [
PlanEventType.TRANSFER,
PlanEventType.EXPIRY_CHANGED,
PlanEventType.REARRANGE_REMOVE,
]) {
expect(PLAN_EVENT_META[t].byHuman).toBe(false);
expect(HUMAN_TOUCH_EVENTS).not.toContain(t);
}
});
test('⭐ 改派/改时限之后单子还在人手上,移出之后不在', () => {
expect(PLAN_EVENT_META.transfer.holdsPatient).toBe(true);
expect(PLAN_EVENT_META.expiry_changed.holdsPatient).toBe(true);
expect(PLAN_EVENT_META.rearrange_remove.holdsPatient).toBe(false);
});
test('⭐⭐ 重排移出**不是** release —— 复用会污染批次的退回原因分布', () => {
// 批次详情按 `event === 'release'` 聚合退回原因,且**只按 assignment_id 过滤、
// 不看是谁干的**(plan-assignment.service 的 detail())。重排移出若挂 release:
// ① 会进那一批的退回原因分布(污染主管调下一批的输入);
// ② 会给**原客服**记一笔「退回」—— 他没退,是主管收走的。
// 两个错都不抛异常,所以在这里钉死。
expect(PlanEventType.REARRANGE_REMOVE).not.toBe(PlanEventType.RELEASE);
expect(PLAN_EVENT_META.rearrange_remove.labelZh).not.toBe(PLAN_EVENT_META.release.labelZh);
});
});
describe('computeHeldSeconds — 必须在清空 assignedAt 前算', () => {
......
import { Permission, PlanEventType } from '@pac/types';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import {
PlanRearrangeService,
overdueDaysOf,
summarizeSkipped,
} from '../src/modules/plan/plan-rearrange.service';
/**
* 主管调整回归。
*
* 三条红线错一条都是**静默**损失:
* ① 移出写成 `release` → 污染那一批的退回原因分布,并给原客服记一笔他没做过的退回
* ② 动了 `assignment_id` → 原批的 planned 缩水,批次报表的分母塌掉
* ③ 整体拒绝而不是逐条落 → 主管调了二十条,因为客服刚打完一条就全废
*/
const OP = 'a1b2c3d4-1111-4222-8333-444455556666';
const SCOPE = {
hostId: 'h1',
tenantId: 't1',
sourceUnits: [] as string[],
clinicIds: ['c1'],
userId: 'leader-1',
} as never;
const LEADER = { userId: 'leader-1', permissions: [Permission.PLAN_DISPATCH] };
const CLINIC = 'c1';
type Row = {
id: string;
patientId: string;
status: string;
assigneeUserId: string | null;
assignedAt: Date | null;
assignmentId: string | null;
assignmentExpiresAt: Date | null;
supersededAt: Date | null;
};
const row = (over: Partial<Row> & { id: string }): Row => ({
patientId: `pat-${over.id}`,
status: 'assigned',
assigneeUserId: 'agent-A',
assignedAt: new Date('2026-08-18T00:00:00Z'),
assignmentId: 'batch-1',
assignmentExpiresAt: new Date('2026-08-21T15:59:59Z'),
supersededAt: null,
...over,
});
function makeService(opts: { rows: Row[]; alreadyDone?: boolean; applied?: 'all' | 'none' }) {
const events: Array<Record<string, unknown>> = [];
const statements: string[] = [];
const applyAll = (opts.applied ?? 'all') === 'all';
const runRaw = (sql: { strings?: string[]; values?: unknown[] }) => {
const text = (sql.strings ?? []).join(' ');
statements.push(text);
if (!applyAll) return [];
// 从 VALUES 里把 plan_id 挑出来(每组的第一个值就是 plan_id)
const ids = (sql.values ?? []).filter(
(v): v is string => typeof v === 'string' && opts.rows.some((r) => r.id === v),
);
return [...new Set(ids)].map((id) => ({ id }));
};
const tx = { $queryRaw: jest.fn(async (sql: never) => runRaw(sql)), planEventLog: {
createMany: jest.fn(async ({ data }: { data: Array<Record<string, unknown>> }) => {
events.push(...data);
return { count: data.length };
}),
} };
const prisma = {
planEventLog: {
findFirst: jest.fn(async () => (opts.alreadyDone ? { id: 'e1' } : null)),
},
followupPlan: { findMany: jest.fn(async () => opts.rows) },
host: { findUnique: jest.fn(async () => ({ pullConfig: { timezone: 'Asia/Shanghai' } })) },
$transaction: jest.fn(async (fn: (t: unknown) => Promise<unknown>) => fn(tx)),
} as never;
const roster = { list: jest.fn() } as never;
return { svc: new PlanRearrangeService(prisma, roster), events, statements };
}
describe('overdueDaysOf —— 与团队状态表同一份判据', () => {
const now = new Date('2026-08-20T12:00:00Z');
test('没到期 → null', () => {
expect(overdueDaysOf(new Date('2026-08-25T00:00:00Z'), null, now)).toBeNull();
});
test('没有时限(自认领 / 老数据)→ null', () => {
expect(overdueDaysOf(null, null, now)).toBeNull();
});
test('过了时限 → 天数向下取整', () => {
expect(overdueDaysOf(new Date('2026-08-18T00:00:00Z'), null, now)).toBe(2);
});
test('⭐⭐ 约了下次回访的**不算超期** —— 他打过电话了,不能被指责压着单没动', () => {
expect(
overdueDaysOf(new Date('2026-08-10T00:00:00Z'), new Date('2026-09-01T00:00:00Z'), now),
).toBeNull();
});
test('约的下次已经过去了 → 恢复计超期', () => {
expect(
overdueDaysOf(new Date('2026-08-10T00:00:00Z'), new Date('2026-08-15T00:00:00Z'), now),
).toBe(10);
});
});
describe('PlanRearrangeService.submit', () => {
test('⭐ 幂等:同一个 operationId 重复提交,一个字都不写', async () => {
const { svc, events } = makeService({ rows: [], alreadyDone: true });
const r = await svc.submit(SCOPE, LEADER, {
operationId: OP,
clinicId: CLINIC,
moves: [{ planId: 'p1', toUserId: 'agent-B' }],
expiry: [],
removes: [],
});
expect(r.duplicate).toBe(true);
expect(r.moved).toBe(0);
expect(events).toHaveLength(0);
});
test('没有派单权 → 直接拒', async () => {
const { svc } = makeService({ rows: [] });
await expect(
svc.submit(SCOPE, { userId: 'staff-1', permissions: [] }, {
operationId: OP, clinicId: CLINIC, moves: [], expiry: [], removes: [],
}),
).rejects.toThrow(/权限/);
});
test('⛔ 同一条既移出又改派 → 意图矛盾,整单拒(⛔ 不猜一个执行)', async () => {
const { svc } = makeService({ rows: [row({ id: 'p1' })] });
await expect(
svc.submit(SCOPE, LEADER, {
operationId: OP,
clinicId: CLINIC,
moves: [{ planId: 'p1', toUserId: 'agent-B' }],
expiry: [],
removes: [{ planId: 'p1' }],
}),
).rejects.toThrow(/矛盾/);
});
test('⭐⭐ 改派写 transfer、⛔ 不写 release;assignment_id 保留原批;带 operation_id', async () => {
const { svc, events } = makeService({ rows: [row({ id: 'p1' })] });
const r = await svc.submit(SCOPE, LEADER, {
operationId: OP,
clinicId: CLINIC,
moves: [{ planId: 'p1', toUserId: 'agent-B' }],
expiry: [],
removes: [],
});
expect(r.moved).toBe(1);
expect(events).toHaveLength(1);
const e = events[0]!;
expect(e.event).toBe(PlanEventType.TRANSFER);
expect(e.event).not.toBe(PlanEventType.RELEASE);
// 变更**后**的归属人 + 原客服进 details(列上只有"变更后",从谁那儿来事后补不回来)
expect(e.assigneeUserId).toBe('agent-B');
expect(e.details).toEqual({ from: 'agent-A' });
expect(e.actorUserId).toBe('leader-1');
// 🔴 调整不改归属批次
expect(e.assignmentId).toBe('batch-1');
expect(e.operationId).toBe(OP);
});
test('⭐⭐ 移出写 rearrange_remove —— ⛔ 绝不是 release(那会污染批次退回原因分布)', async () => {
const { svc, events, statements } = makeService({ rows: [row({ id: 'p1' })] });
const r = await svc.submit(SCOPE, LEADER, {
operationId: OP, clinicId: CLINIC, moves: [], expiry: [], removes: [{ planId: 'p1' }],
});
expect(r.removed).toBe(1);
expect(events[0]!.event).toBe(PlanEventType.REARRANGE_REMOVE);
expect(events.some((e) => e.event === PlanEventType.RELEASE)).toBe(false);
// 收回后无人归属;原客服仍进 details
expect(events[0]!.assigneeUserId).toBeNull();
expect(events[0]!.details).toEqual({ from: 'agent-A' });
// 🔴 SQL 里⛔ 不许出现这三列 —— 清了就丢掉"这单来自哪批"(批次分母)
const sql = statements.join(' ');
expect(sql).not.toMatch(/assignment_id\s*=/);
expect(sql).not.toMatch(/release_reason/);
expect(sql).not.toMatch(/snoozed_until\s*=/);
});
test('改时限写 expiry_changed,details 记前后', async () => {
const { svc, events } = makeService({ rows: [row({ id: 'p1' })] });
const r = await svc.submit(SCOPE, LEADER, {
operationId: OP, clinicId: CLINIC, moves: [], expiry: [{ planId: 'p1', expiresInDays: 5 }], removes: [],
});
expect(r.expiryChanged).toBe(1);
expect(events[0]!.event).toBe(PlanEventType.EXPIRY_CHANGED);
const d = events[0]!.details as { from: string | null; to: string; days: number };
expect(d.days).toBe(5);
expect(d.from).toBe('2026-08-21T15:59:59.000Z');
expect(typeof d.to).toBe('string');
});
test('⭐⭐ 逐条落:能动的照落,动不了的逐条回报(⛔ 不整体拒绝)', async () => {
const { svc, events } = makeService({
rows: [
row({ id: 'ok' }),
row({ id: 'done', status: 'completed' }),
row({ id: 'gone', supersededAt: new Date() }),
row({ id: 'pooled', status: 'active', assigneeUserId: null }),
row({ id: 'same' }),
],
});
const r = await svc.submit(SCOPE, LEADER, {
operationId: OP,
clinicId: CLINIC,
moves: [
{ planId: 'ok', toUserId: 'agent-B' },
{ planId: 'done', toUserId: 'agent-B' },
{ planId: 'gone', toUserId: 'agent-B' },
{ planId: 'pooled', toUserId: 'agent-B' },
{ planId: 'same', toUserId: 'agent-A' }, // 本来就在他手上
{ planId: 'ghost', toUserId: 'agent-B' }, // 不在范围内
],
expiry: [],
removes: [],
});
expect(r.moved).toBe(1);
expect(events).toHaveLength(1);
const reasons = Object.fromEntries(r.skipped.map((s) => [s.planId, s.reason]));
expect(reasons).toEqual({
done: 'already_done',
gone: 'superseded',
pooled: 'not_in_hand',
same: 'same_assignee',
ghost: 'out_of_scope',
});
expect(r.note).toMatch(/改派 1 条/);
expect(r.note).toMatch(/另有 5 条没动/);
});
test('一条都动不了 → 明确报错,⛔ 不留一个"成功 0 条"的假成功', async () => {
const { svc } = makeService({ rows: [row({ id: 'p1', status: 'completed' })] });
await expect(
svc.submit(SCOPE, LEADER, {
operationId: OP, clinicId: CLINIC, moves: [{ planId: 'p1', toUserId: 'agent-B' }], expiry: [], removes: [],
}),
).rejects.toThrow(/都动不了/);
});
test('乐观并发:期间被别人改派走 → 那条落不上,回报 assignee_changed', async () => {
const { svc } = makeService({ rows: [row({ id: 'p1' })], applied: 'none' });
await expect(
svc.submit(SCOPE, LEADER, {
operationId: OP, clinicId: CLINIC, moves: [{ planId: 'p1', toUserId: 'agent-B' }], expiry: [], removes: [],
}),
).resolves.toMatchObject({ moved: 0, skipped: [{ planId: 'p1', op: 'move', reason: 'assignee_changed' }] });
});
test('诊所不在数据范围内 → 拒', async () => {
const { svc } = makeService({ rows: [] });
await expect(
svc.submit(SCOPE, LEADER, {
operationId: OP, clinicId: 'other-clinic', moves: [{ planId: 'p1', toUserId: 'b' }], expiry: [], removes: [],
}),
).rejects.toThrow(/不在你的数据范围内/);
});
});
describe('summarizeSkipped —— 界面与助手念同一句', () => {
test('按原因归并计数', () => {
expect(
summarizeSkipped([
{ planId: 'a', op: 'move', reason: 'already_done' },
{ planId: 'b', op: 'move', reason: 'already_done' },
{ planId: 'c', op: 'remove', reason: 'superseded' },
]),
).toBe('客服已经处理完了 2 条、这条已被重算取代 1 条');
});
});
describe('批次报表要认得调整 —— 源码扫描(这条 SQL 没有单测能覆盖到)', () => {
const SRC = readFileSync(
join(__dirname, '../src/modules/plan/plan-assignment.service.ts'),
'utf8',
);
test('⭐⭐ 「涉及几人」必须并上 transfer —— 接手人也经手了这一批', () => {
// 调整不改 assignment_id,被转手的单仍算这一批。只数 assign 的话,
// 批次上写着「涉及 3 人」而实际有 4 个人碰过 —— 且不会有任何报错。
expect(SRC).toMatch(
/count\(DISTINCT assignee_user_id\)\s+FILTER \(WHERE event IN \('assign','transfer'\)\)/,
);
});
test('⛔ 「条数」不许并 transfer —— 它按 patient 去重,转手不该多算一条', () => {
const plannedLine = SRC.split('\n').find((l) => l.includes('AS planned'));
expect(plannedLine).toBeDefined();
expect(plannedLine).not.toMatch(/transfer/);
});
});
describe('两件工具的边界 —— 源码扫描(实测栽过:主管只想挪一个人,整张单被换掉)', () => {
const SRC = readFileSync(
join(__dirname, '../src/modules/assistant/assistant.service.ts'),
'utf8',
);
const slice = (from: string, to: string) => SRC.slice(SRC.indexOf(from), SRC.indexOf(to));
/** 去注释 —— ⚠️ `[^:]` 是为了不误伤 `https://`(同 mcp-clinic-scope 那份) */
const strip = (x: string) =>
x.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/[^\n]*/g, '$1');
test('🔴🔴 改调整单⛔ 不许拦「本轮还没出过」—— 单子多半是上一轮推的', () => {
// `rearrangePushed` 只在同一轮 chat() 里为真(tools 每轮现建)。
// 拦了之后主管说「胡剑波分配到张萌这里」,这件工具回「先出一版」,
// 模型只能去 propose_rearrange 整版重出,他刚调好的全没了。
// ⚠️ 确认单那边的 edit_assignment_sheet 从来没有这道闸,就是这个原因。
const edit = slice('tools.edit_rearrange_sheet', 'tools.show_rearrange');
// ⚠️ 先去注释:execute 里那段红字**就是在讲**这个变量为什么不能用,
// 不剥掉的话这条断言会被自己的说明文字打败。
const executeBody = strip(edit.slice(edit.indexOf('execute:')));
expect(executeBody).not.toMatch(/rearrangePushed/);
});
test('⭐ 出调整单那件工具必须说清「已经有一版时不用它」+ 说清后果', () => {
const propose = slice('tools.propose_rearrange', 'tools.edit_rearrange_sheet');
expect(propose).toMatch(/眼前已经有一版的时候不用/);
// ⚠️ 光禁止扶不住它 —— 必须写清再调一次会发生什么
expect(propose).toMatch(/整版重出/);
expect(propose).toMatch(/他刚在上面调好的全没了/);
});
test('🔴🔴 出完调整单要**停下来等他说**,⛔ 不许自己先排一版', () => {
// 2026-08-20 实测:主管说「把在手的调一调,别都堆在一个人身上」,
// 模型当场把最多那位手上的 15 条铺给了别人 —— 主管看到的不再是"现在什么样",
// 而是"模型觉得该什么样"。把患者从一位客服手里挪走是**关系层面的决定**,
// 只有主管有资格拍(同 2026-08-06 拿掉自动改派那次的判据)。
const propose = slice('tools.propose_rearrange', 'tools.edit_rearrange_sheet');
expect(propose).toMatch(/摆出来的是\*\*原样\*\*/);
expect(propose).toMatch(/等他说怎么调/);
expect(propose).toMatch(/关系层面的决定/);
// 后果:先替他排好了,他看到的就不是"现在什么样"了
expect(propose).toMatch(/而是你觉得该什么样/);
});
test('🔴 改调整单的条件必须是「他说出了要动哪些」,泛泛的不满意不算', () => {
const edit = slice('tools.edit_rearrange_sheet', 'tools.show_rearrange');
expect(edit).toMatch(/他说出了要动哪些,才用这件/);
// 泛泛的不满意 ≠ 指令。⚠️ 断言的是**规则**,⛔ 不是那几句例句 ——
// 例句多了模型会照着背,而规则才是它要判的东西。
expect(edit).toMatch(/没说要动谁、给谁、动几条/);
expect(edit).toMatch(/那三个数是他要定的/);
});
test('⛔ 两处回给模型的话里都不许出现「先出一版」这种把它推去重出的措辞', () => {
// show_rearrange 拿不到单时也一样 —— 那句话会把它推去 propose。
const show = SRC.slice(SRC.indexOf('tools.show_rearrange'));
const literals = show.slice(0, show.indexOf('});')).match(/'[^'\n]*'/g) ?? [];
expect(literals.filter((x) => x.includes('先出一版'))).toEqual([]);
});
});
describe('调整单与确认单**手感一致** —— 源码扫描(产品:不用二次学习成本)', () => {
const web = (f: string) =>
readFileSync(join(__dirname, `../../pac-web/src/components/assistant/${f}`), 'utf8');
const CONFIRM = web('assignment-confirm-sheet.tsx');
const REARRANGE = web('rearrange-sheet.tsx');
test('⭐ 两张单共用同一套呈现原件 —— 抄成两份必然漂,而漂了就是手感不一样', () => {
for (const src of [CONFIRM, REARRANGE]) {
expect(src).toMatch(/from '\.\/sheet-primitives'/);
expect(src).toMatch(/<DaySelect/);
expect(src).toMatch(/<CopyableName /);
}
});
test('⭐ 拖拽载荷必须是同一个 key —— 不同的话两张单的拖法就成了两回事', () => {
for (const src of [CONFIRM, REARRANGE]) {
expect(src).toMatch(/setData\('text\/plan-id'/);
expect(src).toMatch(/getData\('text\/plan-id'\)/);
}
});
test('⭐ 同样的四件事都得有:汇总抬头 · 操作说明 · 展开名单 · 整宽确认按钮', () => {
for (const src of [CONFIRM, REARRANGE]) {
// 抬头:同一支底色 + 同一个人形图标
expect(src).toMatch(/bg-brand-50\/50/);
expect(src).toMatch(/<Users className/);
// 操作说明:同一段灰条,且都挂「我自己说」兜底
expect(src).toMatch(/展开客服看名单/);
expect(src).toMatch(/我自己说/);
// 名单折叠:整宽一条,紧贴确认按钮上方
expect(src).toMatch(/收起名单/);
expect(src).toMatch(/展开名单(/);
// 确认:整宽主按钮
expect(src).toMatch(/className="w-full gap-1\.5"/);
}
});
test('🔴 右上角⛔ 不许有"一次改全部"的时限控件(2026-08-20 走查拿掉)', () => {
// 这一版的条目已经在客服手上、各有各的到期时刻。一次改全部 = 把 286 条的截止日
// 一起推平,而卡片上「已改 N 条」当场变成 290 —— 主管完全看不出自己动了什么。
// ⚠️ 只查代码:注释里当然要出现这些词(这段红字就是在讲为什么没有它)。
const strip = (x: string) =>
x.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/[^\n]*/g, '$1');
expect(strip(REARRANGE)).not.toMatch(/全部改成/);
// 逐条那一个必须还在
expect(REARRANGE).toMatch(/<DaySelect\s+compact/);
});
test('⭐ 「专属」角标要显示 —— 拖走 = 拆一段老关系,他得在动手前看见', () => {
expect(REARRANGE).toMatch(/dedicated && !moved/);
expect(REARRANGE).toMatch(/专属/);
// 与确认单同一支色
expect(REARRANGE).toMatch(/bg-brand-50 px-1 text-\[10px\] font-normal text-brand-700/);
});
test('🔴 放置区是**整组**,⛔ 不只是抬头那一行', () => {
// 展开后患者名单那一片在视觉上就属于这位客服,主管自然会往那儿放;
// 只把抬头做成落点,他拖到名单里松手什么都不会发生,而且没有任何提示。
const group = REARRANGE.slice(REARRANGE.indexOf('key={g.userId}'));
const head = group.slice(0, group.indexOf('<div className="flex w-full items-center'));
expect(head).toMatch(/onDragOver=/);
expect(head).toMatch(/onDrop=/);
expect(head).toMatch(/setDragOver\(g\.userId\)/); // 落点要高亮,否则他不知道会掉给谁
});
test('🔴 拖到边缘要能自动滚 —— 与确认单同一份实现', () => {
for (const src of [CONFIRM, REARRANGE]) {
expect(src).toMatch(/const EDGE = 56/);
expect(src).toMatch(/requestAnimationFrame\(step\)/);
expect(src).toMatch(/onDragOver=\{onDragOverAnywhere\}/);
expect(src).toMatch(/onDragEnd=\{stopAutoScroll\}/);
}
// ⛔ 名单自己⛔ 不许再套一层滚动 —— 套了就把上面那套自动滚动废掉一半
expect(REARRANGE).toMatch(/<CardContent className="p-0">/);
});
test('⛔ 但**内容**各归各的:调整单里没有"批"的那几样', () => {
/**
* 「福利 / 待分配 / 本批多大」都是**批**的概念。调整不建批次、这一版的人来自
* 若干不同批次 —— 摆在调整单上等于在说一件不存在的事
* (产品 2026-08-20:两者独立、不要污染)。
* ⚠️ 只查**代码**,注释里当然要出现这些词(整段红字就是在讲为什么没有它们)。
* ⚠️ 福利**可以只读展示**(这条来自的那一批挂了什么,转手前他有权知道),
* ⛔ 不许可编辑 —— 所以查的是编辑入口,不是"福利"两个字。
*/
const strip = (x: string) =>
x.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/[^\n]*/g, '$1');
const code = strip(REARRANGE);
expect(code).not.toMatch(/BenefitPopover/); // 挂福利的入口
expect(code).not.toMatch(/set_benefit/);
expect(code).not.toMatch(/待分配/);
expect(code).not.toMatch(/本批/);
// 反向:确认单那三样都在,证明这条断言不是因为写错了正则而恒真
const confirm = strip(CONFIRM);
expect(confirm).toMatch(/BenefitPopover/);
expect(confirm).toMatch(/待分配/);
});
});
/**
* 🔴 **每一个工具都必须有中文说法** —— 没有的话等待文案会把英文工具名摆给主管看。
*
* 2026-08-20 走查抓到的原话:界面上显示「正在调用 propose_rearrange」。
* `step-trace.tsx` 的兜底是 `正在调用 ${tool}` —— 刻意显示原始英文名(⛔ 不显示空白),
* 就是为了让"漏配"一眼可见。但"一眼可见"的前提是有人正好在看,
* ⇒ 用这条测试把它变成"漏配就红"。
*/
describe('工具的中文说法 —— 漏一个就会把英文名摆给主管看', () => {
const web = (f: string) =>
readFileSync(join(__dirname, `../../pac-web/src/components/assistant/${f}`), 'utf8');
const STEP = web('step-trace.tsx');
const SVC = readFileSync(
join(__dirname, '../src/modules/assistant/assistant.service.ts'),
'utf8',
);
const MCP = readFileSync(join(__dirname, '../src/modules/mcp/mcp-server.factory.ts'), 'utf8');
/** step-trace 里登记了哪些 key */
const mapped = new Set(
[...STEP.matchAll(/^\s{2}([a-z_]+):\s*\{\s*label:/gm)].map((m) => m[1]!),
);
test('⭐ 助手的本地工具全都有中文说法', () => {
const local = [...SVC.matchAll(/tools\.([a-z_]+)\s*=\s*tool\(/g)].map((m) => m[1]!);
expect(local.length).toBeGreaterThan(3); // 正则没匹配到东西时这条会先炸,⛔ 别让它恒真
expect(local.filter((t) => !mapped.has(t))).toEqual([]);
});
test('⭐ MCP 注册的工具全都有中文说法', () => {
const mcp = [...MCP.matchAll(/registerTool\(\s*'([a-z_]+)'/g)].map((m) => m[1]!);
expect(mcp.length).toBeGreaterThan(5);
expect(mcp.filter((t) => !mapped.has(t))).toEqual([]);
});
test('⛔ 中文说法里不许夹英文工具名(主管的词汇表就是他在界面上见过的那些)', () => {
const rows = [...STEP.matchAll(/label: '([^']*)', running: '([^']*)'/g)];
expect(rows.length).toBeGreaterThan(5);
for (const [, label, running] of rows) {
expect(label).not.toMatch(/[a-z]_[a-z]/); // snake_case = 工具名
expect(running).not.toMatch(/[a-z]_[a-z]/);
}
});
});
/**
* 🔴 **主管回收必须在批次表上有出处** —— 2026-08-20 走查。
*
* 在此之前,主管在调整单里收回的单子在批次那张表上**凭空消失**:
* 既不在「没动」(它已经不是 assigned)、也不在「退回」(那是客服的动作)。
* 主管一对数就发现少了几条,而少的那几条查不出去哪了。
*/
describe('批次表要认得"主管回收"', () => {
const SRC = readFileSync(
join(__dirname, '../src/modules/plan/plan-assignment.service.ts'),
'utf8',
);
const UI = readFileSync(
join(__dirname, '../../pac-web/src/components/supervisor/batch-tracking.tsx'),
'utf8',
);
test('⭐ 账本里单独数一列 recalled', () => {
expect(SRC).toMatch(
/count\(DISTINCT plan_id\)\s+FILTER \(WHERE event = 'rearrange_remove'\)\s+AS recalled/,
);
});
test('⛔ 回收⛔ 不许混进 released —— 混了退回原因分布就被主管自己的调度污染', () => {
// released 只数 release 事件
expect(SRC).toMatch(/FILTER \(WHERE event = 'release'\)\s+AS released/);
// 退回原因分布只在 release 那一支里累加(rearrange_remove 那一支⛔ 不碰 reasonCount)
const recallLoop = SRC.slice(SRC.indexOf('const seenRecall'), SRC.indexOf('const seenRecall') + 700);
expect(recallLoop).not.toMatch(/reasonCount/);
});
test('⭐ 「落回池子」那句要把三种来路都报出来', () => {
expect(SRC).toMatch(/你自己在调整时收回/);
});
test('🔴 分组表头的 colSpan 必须从列名推,⛔ 不许手写数字', () => {
// 2026-08-20 走查:加「回收」那一列时 colSpan 还写着手抄的 7,分组行少跨一格 ——
// 最后一列在「还在跑的」「8 月」这两行上留出一块白,而且不报任何错。
expect(UI).toMatch(/colSpan=\{NUM_COLS\.length \+ 1\}/);
expect(UI).not.toMatch(/colSpan=\{\d+\}/);
// 列名也必须只有这一处定义,表头从它 map
expect(UI).toMatch(/\{NUM_COLS\.map\(/);
});
test('⭐ 界面:列头分「客服退回」与「回收」,两处都要', () => {
// 列表行(常量里)
expect(UI).toMatch(/const NUM_COLS = \['条数', '已处置', '没动', '客服退回', '回收', '超期', '约上'\]/);
// 详情里按客服拆
expect(UI).toMatch(/'客服', '分到', '已处置', '没动', '客服退回', '回收'/);
// 原因分布只属于客服退回
expect(UI).toMatch(/客服退回原因分布/);
});
});
......@@ -350,8 +350,11 @@ body {
/* 扣扳机的那一下:准星收紧,紧接着交给粒子(pacStreamFly) */
.pac-reticle:active::after { inset: 3.5px; animation: none; transition: inset 0.08s ease-out; }
/* ⚠️ from 从 `-2px` 改成 `0`(2026-08-20):负 inset = 画到格子**外面**,
而渐变面带 `overflow-hidden` —— 最边上那一格的这一帧会被裁掉半个角。
0 → 2px 一样读得出"从边缘一口咬进来",且全程在盒子内。 */
@keyframes pacReticleLock {
from { inset: -2px; opacity: 0; }
from { inset: 0; opacity: 0; }
to { inset: 2px; opacity: 1; }
}
@keyframes pacReticleBreathe {
......
......@@ -3,7 +3,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Check, ChevronDown, ChevronRight, GripVertical, Loader2, Undo2, Users, X } from 'lucide-react';
import {
ASSIGNMENT_EXPIRES_DAYS_PRESETS,
potentialTreatmentItemName,
AssignStrategy,
REVOKE_WINDOW_MINUTES,
......@@ -27,13 +26,6 @@ import { ConfirmSheetSignals } from './confirm-sheet-signals';
import { BenefitPopover } from './benefit-popover';
import { Card, CardContent } from '@/components/ui/card';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
......@@ -43,6 +35,7 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { CopyableName, DaySelect } from './sheet-primitives';
import { cn, formatGender } from '@/lib/utils';
/**
......@@ -97,40 +90,6 @@ const OP_ZH: Record<SheetEditOp['action'], string> = {
};
/**
* 患者姓名 —— **点一下复制**(2026-08-06 产品加)。
*
* ⚠️ 主管审这批人时常要把名字贴到别处(问医生、查宿主档案、发消息),
* 在一个 480px 的列表里手动选中一个中文名很难选准。
* ⚠️ `stopPropagation` 是必须的:这一行同时是**折叠开关的兄弟**且整行可拖,
* 不拦住会连带触发别的行为。
* ⚠️ `draggable={false}`:不加的话按住名字拖会被浏览器当成拖文本,把整行的拖拽吃掉。
* ⛔ 复制的是**姓名本身**,不含病历号/性别年龄 —— 那些是给眼睛看的上下文,
* 粘出去多带一串反而要手动删。
*/
function CopyableName({ name }: { name: string }) {
const [copied, setCopied] = useState(false);
return (
<button
type="button"
draggable={false}
title="点击复制姓名"
onClick={(e) => {
e.stopPropagation();
void navigator.clipboard?.writeText(name);
setCopied(true);
setTimeout(() => setCopied(false), 1200);
}}
className={cn(
'rounded px-0.5 transition-colors',
copied ? 'bg-emerald-50 text-emerald-700' : 'hover:bg-brand-50 hover:text-brand-700',
)}
>
{copied ? '已复制' : name}
</button>
);
}
/**
* 服务端成品句子里的 `**加粗**` —— 只认这一个标记,⛔ 不引 Markdown 渲染器。
*
* ⚠️ 这些句子是**服务端拼好的**(basisNote / refillNote),里面混着患者和客服姓名。
......@@ -161,80 +120,6 @@ function carryOverrides(fromSheet: Record<string, AgentOverride>): Record<string
return out;
}
/**
* 时限选择 —— 1~7 天。用 shadcn `Select`(Radix),不用原生 `<select>`:
* 原生下拉在深色/窄屏下样式不可控,而这个控件现在**每一条召回计划都有一个**,
* 出现几百次,样式必须跟卡片其余部分一致。
* ⚠️ 值域来自 `ASSIGNMENT_EXPIRES_DAYS_PRESETS`(共享常量),⛔ 别在这里内联 [1..7]。
*/
/**
* 时限下拉 —— **点了才挂真正的 Radix Select**。
*
* 🔴 由来(2026-08-05 实测):原来每一行直接渲染一个 `<Select>`。展开一个 36 条的客服组
* = 一次性挂 36 个 Radix Select,每个都带 portal / 焦点管理 / 键盘导航,
* 展开那一下明显卡顿。而这些下拉**绝大多数根本不会被点** —— 主管一批 200 人,
* 真正逐条改时限的是个位数。
*
* ✅ 平时只是一个 `<button>`(样式与 SelectTrigger 一致),点击才换成真 Select 并自动展开。
* ⚠️ `defaultOpen` 是关键:换过去之后要立刻弹开,否则用户得点两下。
* ⚠️ 收起后**不还原成按钮** —— 还原会让"刚改完又想再改"变成再等一次挂载,
* 而且已经挂上的那一个不再是成本问题(卡的是 36 个同时挂)。
*/
function DaySelect({
value,
disabled,
onChange,
compact,
}: {
value: number;
disabled?: boolean;
onChange: (days: number) => void;
/** 条目行用紧凑版(只显示数字,省掉"天"字省宽度) */
compact?: boolean;
}) {
const [live, setLive] = useState(false);
const cls = cn('h-6 gap-1 px-1.5 text-[11px]', compact ? 'w-[54px]' : 'w-[76px]');
if (!live) {
return (
<button
type="button"
disabled={disabled}
onClick={() => setLive(true)}
// 样式与 SelectTrigger 对齐 —— 换过去时不许有跳动
className={cn(
cls,
'inline-flex items-center justify-between rounded-md border border-input bg-transparent',
'shadow-xs transition-colors hover:bg-accent/50 disabled:cursor-not-allowed disabled:opacity-50',
)}
>
<span>{compact ? value : `${value} 天`}</span>
<ChevronDown className="h-3 w-3 opacity-50" />
</button>
);
}
return (
<Select
value={String(value)}
disabled={disabled}
defaultOpen
onValueChange={(v) => onChange(Number(v))}
>
<SelectTrigger className={cls}>
<SelectValue />
</SelectTrigger>
{/* z 层级已在 ui/select.tsx 统一抬到 70(助手窗是 z-60,见那里的层级约定) */}
<SelectContent>
{ASSIGNMENT_EXPIRES_DAYS_PRESETS.map((d) => (
<SelectItem key={d} value={String(d)} className="text-[12px]">
{d}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
export function AssignmentConfirmSheet({
requestId,
......@@ -753,7 +638,7 @@ export function AssignmentConfirmSheet({
}),
);
} catch (e) {
setError(e instanceof Error ? e.message : '确认失败');
setError(describeConfirmError(e));
} finally {
setSubmitting(false);
}
......@@ -806,7 +691,9 @@ export function AssignmentConfirmSheet({
const notes: string[] = [];
// ⭐ 已确认 = 卡片进入只读态。此时唯一还能改的是**福利**(它是前向的,只影响之后生成的话术),
// 而且必须走接口真写库;人员 / 时限那些动的是已经发到客服手上的单,只能走撤销重分。
// 而且必须走接口真写库;人员 / 时限那些动的是已经发到客服手上的单 ——
// ⚠️ 2026-08-20 起它们**不是没辙了**:走「调整各客服在手的单」那条路(独立的一张单)。
// ⛔ 别再把话说成「只能撤销重分」,那句从这天起就是错的。
if (assignmentId) {
for (const { ops } of fresh) {
for (const op of ops) {
......@@ -828,8 +715,9 @@ export function AssignmentConfirmSheet({
continue;
}
notes.push(
`这批**已经确认分配**,${OP_ZH[op.action] ?? op.action}」改不了 —— ` +
`单子已经在客服手上。要调整请撤销这批重新分( 30 分钟),或让客服自己退回`,
`这批**已经确认分配**,${OP_ZH[op.action] ?? op.action}」在这张单上改不了 —— ` +
`单子已经在客服手上 —— 让我把在手的摆出来调(换人、改时限、移出),` +
`或者撤销整批重新分`,
);
}
}
......@@ -2002,3 +1890,26 @@ export function AssignmentConfirmSheet({
</Card>
);
}
/**
* 把确认失败讲成主管能照着做的话。
*
* 🔴 服务端对 zod 校验失败回的是一句**笼统**的「请求字段校验失败」(10002),
* 字段级原因在 `details` 里 —— 而这里原本只取 `e.message`,于是主管看到的
* 永远是那七个字:不知道哪一项不合格、更不知道该改成多少。
* 2026-08-20 生产实遇:537 条的确认单撞上 `items.max(500)`,主管在卡片上
* 反复改都没用,因为**改什么都不影响这条报错**。
*
* ⚠️ 只拼 `message`,⛔ 不拼 `path`/`code`:`items`、`too_big` 这类字样对主管
* 没有意义,只会让他觉得"这是个 bug,不是我能处理的事"。
*/
function describeConfirmError(e: unknown): string {
const detail = (e as { details?: unknown } | null)?.details;
if (Array.isArray(detail)) {
const msgs = detail
.map((d) => (d as { message?: unknown })?.message)
.filter((m): m is string => typeof m === 'string' && m.length > 0);
if (msgs.length > 0) return msgs.join(';');
}
return e instanceof Error && e.message ? e.message : '确认失败';
}
......@@ -31,6 +31,7 @@ import { Permission, type AssignmentProposal, type Signal } from '@pac/types';
import { useHasPermission } from '@/hooks/use-permission';
import { useAssistantStore } from '@/stores/assistant-store';
import { AssignmentConfirmSheet } from './assignment-confirm-sheet';
import { RearrangeSheet } from './rearrange-sheet';
import { SignalPrompt } from './signal-prompts';
import { ReasoningRow, toolMeta } from './step-trace';
import { isDiscardedDraft, stripToolEcho } from './chat-blocks';
......@@ -101,13 +102,29 @@ const EXAMPLES_STAFF = [
* ⚠️ **「任务」是客服的词。** 他这一屏叫「我分的批次」;单条任务是客服执行页的事。
*
* ⛔ 例句里点名的每一样都必须是工具真返回的 —— 例句先许愿,会逼着模型去圆。
* ⚠️ 顺序 = 他的实际动线:看人手 → 分 → 追 → 单批下钻。
* ⚠️ 顺序 = 他的实际动线:看人手 → 分 → 追 → 单批下钻 → **调已经分下去的**。
*
* ═══ 2026-08-20 加的最后一条(调整)═══════════════════════════════
* ⭐ 它落在 `propose_rearrange`,是这一屏上唯一能**动已经分下去的单**的路 ——
* 而门诊经理提这件事的原话就是"分下去之后我什么都做不了"。不摆出来他不会知道有。
* ⚠️ 措辞是**不下断言**的:⛔ 不写「有人手上压太多了」——
* 那是一句**事实主张**,而队伍恰好很平均时,模型出的单会跟这句话对不上,
* 它只有两条路:如实说"其实挺平均"(那这条例句就是个坑),或者去圆。
* 🔴 **也⛔ 不许带着"怎么调"的意思**(2026-08-20 实测栽过)。
* 第一版写的是「帮我把在手的调一调,别都堆在一个人身上」—— 后半句被模型读成了
* **指令**:它当场把最多那位手上的 15 条铺给了其他人,主管看到的不再是"现在什么样",
* 而是"模型觉得该什么样"。而把患者从一位客服手里挪到另一位手里是**关系层面的决定**,
* 只有主管有资格拍(同 2026-08-06 拿掉自动改派那次的判据)。
* ⇒ 例句只说**他要干这件事**,⛔ 不说要往哪个方向调 —— 方向由他下一句说。
* ⚠️ 用「在手」这个词是因为团队那张表的表头就写着「当前在手」—— 他见过。
* ⛔ 别写「转手」:那只覆盖换人,盖不住改时限和移出(产品 2026-08-20 原话)。
*/
export const EXAMPLES_LEADER = [
'现在谁手上最空,谁快扛不住了',
'帮我给「种植 · 三个月到半年」这批患者出一份分配方案',
'我分下去那几批现在怎么样了,有没有谁堆着没动',
'最近一批分下去的,处理到什么程度了,退回的是什么原因',
'把大家在手的摆出来,我要调几条',
];
......@@ -249,6 +266,8 @@ function BlockView({
block,
onSheetConfirmed,
onSheetEdited,
onRearrangeEdited,
onRearrangeConfirmed,
onSignalIntent,
onSignalCompose,
onSheetReplaced,
......@@ -268,6 +287,10 @@ function BlockView({
) => void;
/** 助手改确认单后,界面回报的那一句(注入消息流) */
onSheetEdited?: (summary: string, modelText?: string) => void;
/** 助手改**调整单**后,界面回报的那一句 —— ⛔ 与上面那条各走各的,别合并 */
onRearrangeEdited?: (summary: string, modelText?: string) => void;
/** 调整确认之后的结果(注入消息流,模型据此往下说) */
onRearrangeConfirmed?: (summary: string, modelFacts: string) => void;
/** 「重新排一版」回来的新提案 —— 整块换掉那张卡 */
onSheetReplaced?: (requestId: string, next: AssignmentProposal) => void;
/** ⭐ 引导节点按钮 —— 与助手共用同一组 intent(见 chat-blocks.intentToSheetOps) */
......@@ -299,9 +322,25 @@ function BlockView({
if (block.kind === 'reasoning') return <ReasoningRow text={block.text} />;
// 位置标记本身不渲染 —— MessageView 已经把卡片搬到它那儿了(理论上到不了这)
if (block.kind === 'sheet_anchor') return null;
// 同上:调整单的位置标记本身不渲染(MessageView 已把卡片搬过去了)
if (block.kind === 'rearrange_anchor') return null;
if (block.kind === 'rearrange_sheet') {
return (
<RearrangeSheet
operationId={block.operationId}
sheet={block.sheet}
state={block.state}
edits={block.edits}
streaming={streaming}
onEditApplied={onRearrangeEdited}
onConfirmed={onRearrangeConfirmed}
onCompose={onSignalCompose}
/>
);
}
if (block.kind === 'artifact') return <ArtifactView artifact={block.artifact} />;
if (block.kind === 'guidance') {
// ⚠️ 取不到 = 这条已经不在当前确认单上了(重排过 / 已处置)→ 不渲染。
// ⚠️ 取不到 = 这条已经不在当前确认单上了(调整过 / 已处置)→ 不渲染。
// ⛔ 别渲染一个点了没反应的按钮,那比不渲染更糟。
const s = signalOf?.(block.signalKey);
if (!s || !onSignalIntent) return null;
......@@ -595,6 +634,8 @@ function MessageView({
streaming,
onSheetConfirmed,
onSheetEdited,
onRearrangeEdited,
onRearrangeConfirmed,
onSignalIntent,
onSignalCompose,
onSheetReplaced,
......@@ -613,6 +654,10 @@ function MessageView({
) => void;
/** 助手改确认单后,界面回报的那一句(注入消息流) */
onSheetEdited?: (summary: string, modelText?: string) => void;
/** 助手改**调整单**后,界面回报的那一句 */
onRearrangeEdited?: (summary: string, modelText?: string) => void;
/** 调整确认之后的结果 */
onRearrangeConfirmed?: (summary: string, modelFacts: string) => void;
/** 「重新排一版」回来的新提案 —— 整块换掉那张卡 */
onSheetReplaced?: (requestId: string, next: AssignmentProposal) => void;
/** ⭐ 引导节点按钮 —— 与助手共用同一组 intent(见 chat-blocks.intentToSheetOps) */
......@@ -664,6 +709,8 @@ function MessageView({
* 比卡片在前更怪。说完了再出现,顺序才成立。
*/
const sheets = message.blocks.filter((b) => b.kind === 'assignment_sheet');
// ⚠️ 调整单单独一叠 —— 与确认单**各有各的锚点**,同一条消息里两张都可能在
const rearranges = message.blocks.filter((b) => b.kind === 'rearrange_sheet');
/**
* ⭐ **收起思考与工具调用**时,就在这里把那两类块滤掉(2026-08-15)。
*
......@@ -677,6 +724,7 @@ function MessageView({
const rest = message.blocks.filter(
(b) =>
b.kind !== 'assignment_sheet' &&
b.kind !== 'rearrange_sheet' &&
/**
* 🔴 **工具灰行整个不给主管看**(2026-08-19 产品定)——「出分配方案 `propose_assignment`」
* 这一行里,前半是产品说法、后半是**函数名**,而主管的词汇表就是他在界面上见过的那些
......@@ -699,7 +747,7 @@ function MessageView({
*
* 这里只做两件事:
* ① `placedKeys` —— 已经插进正文的那几条,卡片上不再重复显示;
* ② `signalOf` —— 按 key 从**当前**这张确认单上取那条节点(重排后自动失效)。
* ② `signalOf` —— 按 key 从**当前**这张确认单上取那条节点(调整后自动失效)。
* 🔴 **漏调不丢东西**:模型没插的那几条仍旧显示在卡片上(今天的行为原样保留)。
* ⇒ 卡片本身就是兜底面,⛔ 不需要另做一套"孤儿补渲染"。
*/
......@@ -708,7 +756,7 @@ function MessageView({
/**
* signalKey → 当前这张确认单上那条节点;取不到 = 别渲染。
*
* 🔴 两道关:① 这张单上根本没这条(重排换过版);
* 🔴 两道关:① 这张单上根本没这条(调整换过版);
* ② **主语已经没了** —— 待分配被处置光之后,卡片会上报"还活着"的 key,
* 不在里面的就不再渲染(2026-08-13 实测:点完「各自归专属客服」,
* 待分配清零、卡片头也变了,而正文里那四个按钮还在,再点是空操作)。
......@@ -750,6 +798,9 @@ function MessageView({
* 🔴 漏调 / 无根 → 走老路(等流式结束、钉末尾)。⛔ 卡片永远不会不出现。
*/
const anchorAt = rest.findIndex((b, i) => b.kind === 'sheet_anchor' && isRooted(i));
const rearrangeAnchorAt = rest.findIndex(
(b, i) => b.kind === 'rearrange_anchor' && isRooted(i),
);
const orphan = rest.filter((b, i) => b.kind === 'guidance' && !isRooted(i));
const orphanKeys = new Set(
orphan.map((b) => (b.kind === 'guidance' ? b.signalKey : '')).filter(Boolean),
......@@ -763,12 +814,16 @@ function MessageView({
if (i === anchorAt) body.push(...sheets); // 锚点本身不渲染,只是卡片的位置
continue;
}
if (b.kind === 'rearrange_anchor') {
if (i === rearrangeAnchorAt) body.push(...rearranges);
continue;
}
body.push(b);
}
/**
* 🔴 **无根引导流式期间不渲染**(2026-08-14 走查)。
*
* 走查现象:点完「只选儿牙会员那 76 人」重排,正文才写到「这一格总人数:76 人(刚好就是」,
* 走查现象:点完「只选儿牙会员那 76 人」调整,正文才写到「这一格总人数:76 人(刚好就是」,
* 底下就已经挂出了「15 人的专属客服这轮已排满」那一排按钮 —— 一个**派活**层面的节点,
* 压在还没写完的**选人**那一段下面。
*
......@@ -783,6 +838,8 @@ function MessageView({
...body,
...(streaming ? [] : orphan),
...(anchorAt !== -1 || streaming ? [] : sheets),
// 与确认单同一条规矩:漏调锚点 / 无根 → 等说完、钉末尾。⛔ 卡片永远不会不出现
...(rearrangeAnchorAt !== -1 || streaming ? [] : rearranges),
];
return (
/**
......@@ -799,7 +856,15 @@ function MessageView({
// 主管删的条、拖的改派、逐条时限、以及"能不能撤销"的确认时刻,
// 都会在助手说下一句话时被悄悄清空(实测:确认后撤销按钮直接不出现)。
// ⚠️ 这是排序改动引入的回归 —— 排序本身没错,错在 key 跟着位置走。
key={b.kind === 'assignment_sheet' ? `sheet:${b.requestId}` : `b:${i}`}
key={
b.kind === 'assignment_sheet'
? `sheet:${b.requestId}`
: // 🔴 同理:调整单也必须用稳定 key,否则助手每追加一句话,
// 主管拖的改派 / 改的时限 / 移出的那几条会被悄悄清空
b.kind === 'rearrange_sheet'
? `rearrange:${b.operationId}`
: `b:${i}`
}
block={b}
placedKeys={placedKeys}
signalOf={signalOf}
......@@ -809,6 +874,8 @@ function MessageView({
orphanKeys={orphanKeys}
onSheetConfirmed={onSheetConfirmed}
onSheetEdited={onSheetEdited}
onRearrangeEdited={onRearrangeEdited}
onRearrangeConfirmed={onRearrangeConfirmed}
onSignalIntent={onSignalIntent}
onSignalCompose={onSignalCompose}
onSheetReplaced={onSheetReplaced}
......@@ -965,6 +1032,16 @@ export function AssistantChat({
*/
const onSheetEdited = (summary: string, modelText?: string) =>
appendAssistantNote(summary, modelText);
/**
* 调整单那条路的两句回灌 —— ⛔ 与上面确认单的**各走各的**。
* 走的是同一条 `appendAssistantNote`(那只是"把一句话放进消息流"),
* 但入口分开:两条路的语义不同,合并之后哪句是谁说的就查不出来了。
*/
const onRearrangeEdited = (summary: string, modelText?: string) =>
appendAssistantNote(summary, modelText);
const onRearrangeConfirmed = (summary: string, modelFacts: string) =>
// ⚠️ `asNewMessage`:确认发生在模型发言之外,贴上一条尾巴上他读不出来助手说了话
appendAssistantNote(summary, modelFacts, { asNewMessage: true });
/** 「重新排一版」回来 → 整块换掉那张卡(人群没变、人换了) */
const onSheetReplaced = (requestId: string, next: AssignmentProposal) =>
replaceSheet(requestId, next);
......@@ -1261,7 +1338,7 @@ export function AssistantChat({
* 而它在对话里占着大半屏 —— 主管往回翻时读到的是一份不存在的方案。
*
* ⚠️ 「他要能看见自己出过几版」这个诉求由**他自己那两句话**保住了:
* 「出一份分配方案」→「每人每天按 20 通算,重新排一版」,一眼看得出重排过。
* 「出一份分配方案」→「每人每天按 20 通算,重新排一版」,一眼看得出调整过。
* ⚠️ 判据与上下文回传**共用** `isDiscardedDraft` ——
* ⛔ 别在这里另写一份"哪种算作废",界面藏了而上下文没清是最难查的那种不一致。
*/
......@@ -1272,6 +1349,8 @@ export function AssistantChat({
streaming={status === 'streaming' && i === shown.length - 1 && m.role === 'assistant'}
onSheetConfirmed={onSheetConfirmed}
onSheetEdited={onSheetEdited}
onRearrangeEdited={onRearrangeEdited}
onRearrangeConfirmed={onRearrangeConfirmed}
onSheetReplaced={onSheetReplaced}
onSignalIntent={applySignalIntent}
onSignalCompose={composeDraft}
......
import { describe, expect, it } from 'vitest';
import type { AssignmentProposal } from '@pac/types';
import type { AssignmentProposal, RearrangeDraft } from '@pac/types';
import {
applyIncomingRearrange,
applyIncomingSheet,
findActiveRearrange,
findActiveSheet,
intentToPrompt,
intentToSheetOps,
......@@ -291,7 +293,7 @@ describe('stripToolEcho · 抄出来的工具记录', () => {
/**
* 🔴 位置标记不进历史 —— 2026-08-13 实测的回归。
*
* 主管点「只选…」重排,第二轮只跑了 1 步:`propose_assignment` 调了,
* 主管点「只选…」调整,第二轮只跑了 1 步:`propose_assignment` 调了,
* `show_sheet` / `show_guidance` 一个都没调 —— 卡片掉到末尾、按钮全堆在卡上。
* 原因之一是上一轮的记录还在上下文里,「已插入选项按钮」被读成"已经摆过了"。
* ⚠️ 这条锁的是**别让位置标记回传**;⛔ 真做了事的工具必须留(否则模型会重复调)。
......@@ -377,3 +379,133 @@ describe('历史回传 · 结构化(唯一路径)', () => {
});
});
// ═════════════════════════════════════════════════════════
// 调整单 —— 与确认单**各走各的**,这一组专门锁住"别互相作废"
// ═════════════════════════════════════════════════════════
const rdraft = (n: number) => ({ total: n } as unknown as RearrangeDraft);
function rmsg(id: string, ...sheets: Array<[string, DraftState]>): ChatMessage {
return {
id,
role: 'assistant',
blocks: sheets.map(([operationId, state]): Block => ({
kind: 'rearrange_sheet',
operationId,
sheet: rdraft(1),
state,
})),
};
}
function rstates(ms: ChatMessage[]): Array<[string, DraftState]> {
return ms.flatMap((m) =>
m.blocks
.filter((b): b is Extract<Block, { kind: 'rearrange_sheet' }> => b.kind === 'rearrange_sheet')
.map((b) => [b.operationId, b.state] as [string, DraftState]),
);
}
describe('applyIncomingRearrange', () => {
it('第一版挂到宿主消息下,状态 active', () => {
const out = applyIncomingRearrange([rmsg('m1')], {
operationId: 'o1',
sheet: rdraft(10),
hostMessageId: 'm1',
});
expect(rstates(out)).toEqual([['o1', 'active']]);
});
it('🔴 新的一版到达时,把**上一轮消息里**的 active 作废(跨消息扫描)', () => {
const out = applyIncomingRearrange([rmsg('m1', ['o1', 'active']), rmsg('m2')], {
operationId: 'o2',
sheet: rdraft(2),
hostMessageId: 'm2',
});
expect(rstates(out)).toEqual([
['o1', 'superseded'],
['o2', 'active'],
]);
});
it('同 id 重推 = 换内容并清空 edits(旧指令对新名单没有意义)', () => {
const prev: ChatMessage[] = [
{
id: 'm1',
role: 'assistant',
blocks: [
{
kind: 'rearrange_sheet',
operationId: 'o1',
sheet: rdraft(1),
state: 'active',
edits: [{ seq: 1, ops: [] }],
},
],
},
];
const out = applyIncomingRearrange(prev, {
operationId: 'o1',
sheet: rdraft(9),
hostMessageId: 'm1',
});
const b = out[0]!.blocks[0] as Extract<Block, { kind: 'rearrange_sheet' }>;
expect(b.sheet).toEqual(rdraft(9));
expect(b.edits).toEqual([]);
});
it('⭐⭐ ⛔ 不许作废确认单 —— 主管可以一边挂着待确认的分配单、一边调在手的', () => {
const prev = [msg('m1', ['r1', 'active']), rmsg('m2', ['o1', 'active'])];
const out = applyIncomingRearrange(prev, {
operationId: 'o2',
sheet: rdraft(3),
hostMessageId: 'm2',
});
// 确认单原封不动
expect(states(out)).toEqual([['r1', 'active']]);
// 只有调整单那一张被作废
expect(rstates(out)).toEqual([
['o1', 'superseded'],
['o2', 'active'],
]);
});
it('⭐⭐ 反过来也一样:新确认单⛔ 不许作废调整单', () => {
const prev = [rmsg('m1', ['o1', 'active']), msg('m2', ['r1', 'active'])];
const out = applyIncomingSheet(prev, {
requestId: 'r2',
sheet: sheet(3),
hostMessageId: 'm2',
});
expect(rstates(out)).toEqual([['o1', 'active']]);
expect(states(out)).toEqual([
['r1', 'superseded'],
['r2', 'active'],
]);
});
});
describe('findActiveRearrange', () => {
it('拿最后那张 active', () => {
const at = findActiveRearrange([
rmsg('m1', ['o1', 'superseded']),
rmsg('m2', ['o2', 'active']),
]);
expect(at).toEqual({ messageIndex: 1, blockIndex: 0 });
});
it('⭐⭐ ⛔ 不回落到 confirmed —— 调整一确认就生效了,再改等于让他以为改了而库里没动', () => {
expect(findActiveRearrange([rmsg('m1', ['o1', 'confirmed'])])).toBeNull();
});
it('⛔ 不选 superseded / cancelled', () => {
expect(
findActiveRearrange([rmsg('m1', ['o1', 'superseded'], ['o2', 'cancelled'])]),
).toBeNull();
});
it('⛔ 不会错拿确认单', () => {
expect(findActiveRearrange([msg('m1', ['r1', 'active'])])).toBeNull();
});
});
import type { AssignmentProposal, SheetEditOp } from '@pac/types';
import type {
AssignmentProposal,
RearrangeDraft,
RearrangeEditOp,
SheetEditOp,
} from '@pac/types';
/**
* chat-blocks —— 助手消息流的**数据模型 + 纯函数操作**。
......@@ -50,7 +55,7 @@ export interface Artifact {
* **作废那一版整条不再显示**(见 isDiscardedDraft)。它一个字都没落库,
* 人数、分给谁全是废数,却在对话里占着大半屏 —— 往回翻读到的是一份不存在的方案。
* 「他要能看见自己出过几版」由**主管自己那两句话**保住:
* 「出一份分配方案」→「每人每天按 20 通算,重新排一版」,一眼看得出重排过。
* 「出一份分配方案」→「每人每天按 20 通算,重新排一版」,一眼看得出调整过。
* ⚠️ `confirmed` / `cancelled` 是**终态**,⛔ 不许被后续的作废扫描回退。
*/
export type DraftState = 'active' | 'superseded' | 'confirmed' | 'cancelled';
......@@ -85,7 +90,7 @@ export type Block =
* ⭐ 模型写完「389 人卡在待分配…」那一条,紧接着调一次工具 → 这个块就插在
* 消息流的**此刻这个位置**,按钮正好落在它的解释下面。
* ⚠️ 和 artifact 同一个模式:工具调用即"在流里插一个界面块"。
* ⚠️ 只存 `signalKey`,⛔ 不存整个 signal —— 确认单可能被重排替换(refill),
* ⚠️ 只存 `signalKey`,⛔ 不存整个 signal —— 确认单可能被调整替换(refill),
* 届时按 key 从**当前**那张单上取,取不到就不渲染(那条已经不存在了)。
* 🔴 漏调不丢东西:没被插进正文的 action 节点仍旧显示在确认单卡片上(卡片按
* 已插入的 key 去重)。⇒ 这个块优化的是**位置**,⛔ 不是可见性。
......@@ -124,6 +129,31 @@ export type Block =
* ⚠️ 用队列而不是"最新一条":主管连着说两句时,两批指令都要生效。
*/
edits?: { seq: number; ops: SheetEditOp[] }[];
}
/**
* 调整单卡片**摆在这** —— 位置由模型决定(`show_rearrange` 工具)。
*
* ⚠️ 与 `sheet_anchor` 是**两个标记**,⛔ 别合并:同一条消息里两张单都可能存在
* (他先分了一批、又要调在手的),合并之后两张卡会抢同一个位置。
*/
| { kind: 'rearrange_anchor' }
/**
* 调整单 —— 调整各客服**在手**的单。原生 React 组件(要能点确认,理由同确认单)。
*
* ⛔ **与 `assignment_sheet` 完全隔离**(产品 2026-08-20 定):
* 两者形状像但语义不通 —— 调整不建批次、没有福利、没有"本批多大"、没有"待分配"。
* 共用一个块必然要给一半字段配空值,而那一半迟早有人在某个分支上忘了判。
*
* ⚠️ `sheet` 由**服务端经侧信道推来**,不是模型生成的 —— 它含几百个 planId。
*/
| {
kind: 'rearrange_sheet';
/** 服务端铸的幂等键,提交时原样回传 */
operationId: string;
sheet: RearrangeDraft;
state: DraftState;
/** 助手下发的语义编辑指令队列(`edit_rearrange_sheet` 走侧信道推来) */
edits?: { seq: number; ops: RearrangeEditOp[] }[];
};
export interface ChatMessage {
......@@ -185,7 +215,7 @@ export function upsertArtifact(
* 新草稿到达 —— upsert 它,并把**此前所有 `active` 的当场作废**。
*
* 🔴 必须**跨消息**扫:上一版几乎总在**上一轮**的消息里
* (主管看着它才说「重排一版」的)。只扫当前消息永远扫不到,而且不会报错。
* (主管看着它才说「调整一版」的)。只扫当前消息永远扫不到,而且不会报错。
* ⚠️ 只作废 `active`:`confirmed` / `cancelled` 是终态,⛔ 不许回退。
* ⚠️ 同一 `requestId` 重推时清空 `edits` —— 旧的编辑指令对新名单没有意义。
*
......@@ -318,7 +348,7 @@ export function intentToPrompt(
}
/**
* 「按别的条件选」—— ⛔ 不改任何东西,只是把「先看看还有哪些口子」做成一个按钮。
* ⚠️ 措辞里要写明**别急着重排**:不然模型会顺手出一版新确认单,
* ⚠️ 措辞里要写明**别急着调整**:不然模型会顺手出一版新确认单,
* 而主管这一下问的是"有哪些选择",不是"照某个条件重来"。
*/
case 'cohort.attrs_ask':
......@@ -388,7 +418,7 @@ export function stripToolEcho(text: string): string {
* ⚠️ 判据是「**只剩**作废的」,⛔ 不是「有作废的」:同一轮里模型可能出过两版
* (`applyIncomingSheet` 把旧的**就地**扫成作废、新的追加到同一条消息)。
* 那时这条消息里还有活着的那版,整条藏掉会把有效的一起抹了。
* ⚠️ 只认 `superseded`(重排顶掉的、一条都没落库)。
* ⚠️ 只认 `superseded`(调整顶掉的、一条都没落库)。
* ⛔ `cancelled`(已确认后又撤销)与 `confirmed` 都是真发生过的事,不能藏也不能清。
*/
export function isDiscardedDraft(m: ChatMessage): boolean {
......@@ -501,3 +531,63 @@ export function toApiMessages(m: ChatMessage): ApiMessage[] {
return out;
}
// ─────────────────────────────────────────────────────────
// 调整单 —— 与上面确认单那两个函数同构,⛔ 刻意不合并
// 合并要么加一个 kind 参数、要么加泛型,两种写法都会让"哪张单该被作废"
// 这件事变成运行时判断 —— 而这正是 2026-08-12「作废了错的那张」栽过的地方。
// ─────────────────────────────────────────────────────────
/**
* 新调整单到达 —— upsert 它,并把此前所有 `active` 的调整单当场作废。
*
* ⚠️ 只扫 `rearrange_sheet`:确认单是另一条路,⛔ 不许被这里作废
* (主管完全可能一边挂着一张待确认的分配单、一边让助手调在手的)。
*/
export function applyIncomingRearrange(
messages: ChatMessage[],
input: { operationId: string; sheet: RearrangeDraft; hostMessageId: string },
): ChatMessage[] {
const { operationId, sheet, hostMessageId } = input;
let placed = false;
const swept = messages.map((m) => ({
...m,
blocks: m.blocks.map((b): Block => {
if (b.kind !== 'rearrange_sheet') return b;
if (b.operationId === operationId) {
placed = true;
// 同 id 重推清空 edits —— 旧指令对新名单没有意义
return { ...b, sheet, state: 'active', edits: [] };
}
return b.state === 'active' ? { ...b, state: 'superseded' } : b;
}),
}));
if (placed) return swept;
return swept.map((m) =>
m.id === hostMessageId
? {
...m,
blocks: [...m.blocks, { kind: 'rearrange_sheet', operationId, sheet, state: 'active' }],
}
: m,
);
}
/**
* 找**他现在说的那张**调整单 —— 助手的修改指令打到这张上。
*
* ⚠️ 与确认单不同:这里**只认 `active`**。调整一旦确认就落库了,⛔ 没有"确认后还能改"
* 这回事(确认单那边有,因为福利是前向的、可以补挂;调整没有福利)。
* 打到 `confirmed` 上等于让主管以为改了、而库里一动没动。
*/
export function findActiveRearrange(
messages: ChatMessage[],
): { messageIndex: number; blockIndex: number } | null {
for (let mi = messages.length - 1; mi >= 0; mi--) {
const blocks = messages[mi]?.blocks ?? [];
const bi = blocks.findIndex((b) => b.kind === 'rearrange_sheet' && b.state === 'active');
if (bi >= 0) return { messageIndex: mi, blockIndex: bi };
}
return null;
}
'use client';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Check, ChevronDown, ChevronRight, GripVertical, Loader2, Users, X } from 'lucide-react';
import {
ASSIGNMENT_EXPIRES_DAYS_DEFAULT,
ASSIGNMENT_EXPIRES_DAYS_PRESETS,
POTENTIAL_TREATMENT_CARD_LABEL,
Permission,
TEMPERATURE_META,
type RearrangeDraft,
type RearrangeEditOp,
type RearrangeItem,
type TemperatureValue,
} from '@pac/types';
import { Can } from '@/components/can';
import { rearrangeApi } from '@/components/plans/rearrange-api';
import { useAssignmentSyncStore } from '@/stores/assignment-sync-store';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { CopyableName, DaySelect } from './sheet-primitives';
import { cn, formatGender } from '@/lib/utils';
import type { DraftState } from './chat-blocks';
/**
* 调整单 —— 调整各客服**在手**的单。
*
* ═══ 为什么是独立组件,而**外形必须跟确认单一模一样** ═══════════════
* 产品 2026-08-20 定的两句话,缺一不可:
* ① 「调整单组件跟确认单独立,不要污染」—— 语义没有交集:
* 没有福利(这一版的人来自若干不同批次)、没有待分配(人已经在手上)、
* 没有"本批多大"(人数不是算出来的)。缝进一个组件就要给一半控件配
* `if (mode === 'rearrange') return null`,而那一半迟早有人漏判。
* ② 「交互展示需跟确认单保持一致,不用二次学习成本」—— 主管的手只学一遍:
* 同一个折叠层级、同一个拖拽载荷(`text/plan-id`)、同一个 ✕、
* 同一个时限下拉、同一个「展开名单」、同一个整宽确认按钮。
* ⇒ **结构照抄、内容各归各的**。共用的只有没有业务语义的呈现原件
* (`sheet-primitives` 的 CopyableName / DaySelect)—— 抄成两份必然漂,
* 而漂了的表现就是"两张单子手感不一样",正是 ② 要防的那件事。
*/
type Draft = {
/** planId → 换给谁 */
moves: Record<string, string>;
/** planId → 新时限天数 */
expiry: Record<string, number>;
/** 被移出(收回池子)的 */
removed: Set<string>;
};
const EMPTY: Draft = { moves: {}, expiry: {}, removed: new Set() };
export function RearrangeSheet({
operationId,
sheet,
state,
edits,
streaming,
onEditApplied,
onConfirmed,
onCompose,
}: {
operationId: string;
sheet: RearrangeDraft;
state: DraftState;
edits?: { seq: number; ops: RearrangeEditOp[] }[];
streaming?: boolean;
/** 改完之后把**实际结果**回一句进对话(模型看不到卡片,不回它就会替系统撒谎) */
onEditApplied?: (summary: string, modelText?: string) => void;
onConfirmed?: (summary: string, modelFacts: string) => void;
/** 兜底:拖点改覆盖不到的,把焦点落到输入框(⛔ 不预填任何话) */
onCompose?: (draft: string) => void;
}) {
const readOnly = state !== 'active';
const superseded = state === 'superseded';
const [draft, setDraft] = useState<Draft>(EMPTY);
const [bodyOpen, setBodyOpen] = useState(false);
const [open, setOpen] = useState<Set<string>>(new Set());
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [done, setDone] = useState<string | null>(null);
/// 正被拖过的那一组 —— 高亮落点,主管才知道松手会掉到谁那儿
const [dragOver, setDragOver] = useState<string | null>(null);
/**
* 拖到边缘自动滚 —— 与确认单**同一份实现**(逐行照抄,⛔ 别自己另写一套手感)。
*
* 🔴 没有它,名单一长主管就"够不着":要把患者拖给屏幕外的客服,得先松手、
* 滚一段、再拖一次 —— 而松手那一下就已经落到别人头上了。
* ⚠️ 滚的是**外层**那个滚动容器(从卡片往上找第一个真的能滚的祖先),
* ⛔ 不是卡片自己 —— 所以上面 CardContent 不能再套一层 overflow。
*/
const rootRef = useRef<HTMLDivElement>(null);
const scrollerRef = useRef<HTMLElement | null>(null);
const autoScroll = useRef<{ dir: number; raf: number } | null>(null);
const stopAutoScroll = () => {
if (autoScroll.current) cancelAnimationFrame(autoScroll.current.raf);
autoScroll.current = null;
};
const onDragOverAnywhere = (e: React.DragEvent) => {
const sc = scrollerRef.current;
if (!sc) return;
const r = sc.getBoundingClientRect();
const EDGE = 56; // 边缘感应带
const dir = e.clientY < r.top + EDGE ? -1 : e.clientY > r.bottom - EDGE ? 1 : 0;
if (dir === 0) return stopAutoScroll();
if (autoScroll.current?.dir === dir) return; // 同方向已经在滚
stopAutoScroll();
const step = () => {
sc.scrollTop += dir * 12;
autoScroll.current = { dir, raf: requestAnimationFrame(step) };
};
autoScroll.current = { dir, raf: requestAnimationFrame(step) };
};
useEffect(() => {
let el = rootRef.current?.parentElement ?? null;
while (el) {
const oy = getComputedStyle(el).overflowY;
if ((oy === 'auto' || oy === 'scroll') && el.scrollHeight > el.clientHeight) break;
el = el.parentElement;
}
scrollerRef.current = el;
}, []);
const nameOf = useMemo(
() => new Map(sheet.agents.map((a) => [a.userId, a.name ?? a.userId])),
[sheet.agents],
);
const zhAgent = (id: string) => nameOf.get(id) ?? `#${id.slice(0, 8)}`;
const zhPatient = (planId: string) => {
const it = sheet.items.find((x) => x.planId === planId);
return it?.patientName ?? `#${planId.slice(0, 8)}`;
};
const ownerOf = useCallback(
(it: RearrangeItem) => draft.moves[it.planId] ?? it.assigneeUserId,
[draft.moves],
);
const live = useMemo(
() => sheet.items.filter((i) => !draft.removed.has(i.planId)),
[sheet.items, draft.removed],
);
/** 调整后每位客服手上有哪些 —— ⚠️ 空的客服也要留一行(他可能刚被搬空) */
const groups = useMemo(() => {
const m = new Map<string, RearrangeItem[]>();
for (const a of sheet.agents) m.set(a.userId, []);
for (const it of live) {
const owner = ownerOf(it);
if (!m.has(owner)) m.set(owner, []);
m.get(owner)!.push(it);
}
return [...m.entries()].map(([userId, list]) => ({
userId,
list,
before: sheet.agents.find((a) => a.userId === userId)?.inHand ?? 0,
}));
}, [live, ownerOf, sheet.agents]);
const changed =
Object.keys(draft.moves).length + Object.keys(draft.expiry).length + draft.removed.size;
// ── 手工改动回一句进对话 —— 与确认单同一套(模型看不到卡片)──────
const echoQueue = useRef<string[]>([]);
const [echoSeq, setEchoSeq] = useState(0);
const echo = useCallback((summary: string) => {
echoQueue.current.push(summary);
setEchoSeq((n) => n + 1);
}, []);
useEffect(() => {
if (echoSeq === 0 || echoQueue.current.length === 0) return;
const text = echoQueue.current.join(';');
echoQueue.current = [];
onEditApplied?.(text);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [echoSeq]);
// ── 助手的语义指令队列 ────────────────────────────────────
/**
* ⭐ 与确认单同一条路:助手推**语义**指令,由这里拿手上这份名单去匹配执行。
* ⛔ 别让服务端解析姓名 —— 名单只有界面手里有(它含几百个 planId,不进模型上下文)。
* ⚠️ 按 `seq` 只执行新增的那一批:主管连着说两句时两批都要生效。
*/
const appliedSeq = useRef(0);
useEffect(() => {
if (readOnly || !edits?.length) return;
const fresh = edits.filter((e) => e.seq > appliedSeq.current);
if (fresh.length === 0) return;
appliedSeq.current = Math.max(...fresh.map((e) => e.seq));
const notes: string[] = [];
setDraft((prev) => {
const next: Draft = {
moves: { ...prev.moves },
expiry: { ...prev.expiry },
removed: new Set(prev.removed),
};
for (const { ops } of fresh) {
for (const op of ops) {
const picked = selectItems(sheet, next, op);
if (picked.length === 0) {
notes.push(`「${describeSelect(op)}」没找到对应的单,跳过`);
continue;
}
if (op.action === 'remove') {
for (const it of picked) next.removed.add(it.planId);
notes.push(`移出 ${picked.length} 条`);
} else if (op.action === 'set_expiry' && op.days != null) {
for (const it of picked) next.expiry[it.planId] = op.days;
notes.push(`${picked.length} 条时限改成 ${op.days} 天`);
} else if (op.action === 'move') {
const pool = (op.to?.agents ?? [])
.map((n) => sheet.agents.find((a) => (a.name ?? '') === n.trim())?.userId)
.filter((v): v is string => !!v);
const candidates = pool.length > 0 ? pool : sheet.agents.map((a) => a.userId);
const spread = balance(picked, candidates, sheet, next);
if (spread.length === 0) {
notes.push('没有可以接手的客服,跳过');
continue;
}
for (const [planId, to] of spread) next.moves[planId] = to;
const who = [...new Set(spread.map(([, to]) => zhAgent(to)))];
notes.push(`${spread.length} 条换给了 ${who.join('、')}`);
}
}
}
return next;
});
if (notes.length > 0) echo(notes.join(';') + '。');
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [edits, readOnly]);
// ── 确认 ─────────────────────────────────────────────────
const confirm = async () => {
if (submitting || readOnly || changed === 0) return;
setSubmitting(true);
setError(null);
try {
const r = await rearrangeApi.submit({
operationId,
clinicId: sheet.clinicId,
moves: Object.entries(draft.moves).map(([planId, toUserId]) => ({ planId, toUserId })),
expiry: Object.entries(draft.expiry).map(([planId, expiresInDays]) => ({
planId,
expiresInDays,
})),
removes: [...draft.removed].map((planId) => ({ planId })),
});
setDone(r.note);
// 团队状态 / 批次 / 矩阵都跟着重拉 —— 在手量当场变了
useAssignmentSyncStore.getState().notifyChanged();
onConfirmed?.(
r.note,
JSON.stringify({
调整已生效: !r.duplicate,
改派: r.moved,
改时限: r.expiryChanged,
移出: r.removed,
没落上的条数: r.skipped.length,
结果原话: r.note,
}),
);
} catch (e) {
setError(e instanceof Error ? e.message : '调整没有成功');
} finally {
setSubmitting(false);
}
};
const scopeText =
sheet.scope === 'all'
? '全队在手'
: `${sheet.scopedAgentUserIds.map(zhAgent).join('、')} 手上的`;
return (
<Card
ref={rootRef}
onDragOver={onDragOverAnywhere}
onDragEnd={stopAutoScroll}
onDrop={stopAutoScroll}
className={
superseded
? 'overflow-hidden border-slate-200 text-[12px] opacity-60 shadow-none'
: 'overflow-hidden border-brand-100 text-[12px] shadow-sm'
}
>
{/* ⭐ 作废横幅钉在**卡片顶部** —— 单子可以很长,底下那句主管看不到 */}
{superseded && (
<div className="border-b border-amber-200 bg-amber-50 px-3 py-1.5 text-[11px] font-medium text-amber-800">
这一版已被新的一版取代,不能确认 —— 当前有效的是下面最新的那一张
</div>
)}
{/* ① 汇总 —— 与确认单同一行样式(必须 flex-wrap,窄窗会装不下) */}
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 border-b bg-brand-50/50 px-3 py-2">
<Users className="h-3.5 w-3.5 flex-none text-brand-600" />
<span className="font-semibold text-slate-800">
<span className="whitespace-nowrap">{scopeText} {live.length}</span>
<span className="whitespace-nowrap"> · {groups.filter((g) => g.list.length > 0).length} 位客服</span>
</span>
{sheet.overdueTotal > 0 && (
<Badge
variant="secondary"
className="flex-none whitespace-nowrap bg-amber-100 text-[10px] font-normal text-amber-700"
>
超期 {sheet.overdueTotal}
</Badge>
)}
{changed > 0 && (
<Badge variant="secondary" className="flex-none whitespace-nowrap text-[10px] font-normal">
已改 {changed}
</Badge>
)}
{draft.removed.size > 0 && (
<Badge variant="secondary" className="flex-none whitespace-nowrap text-[10px] font-normal">
已移出 {draft.removed.size}
</Badge>
)}
{/*
🔴 **右上角⛔ 不放"一次改全部"的时限控件**(产品 2026-08-20 走查拿掉)。
确认单那个位置放的是**这一整批的时限** —— 那一批还没发出去,它是个默认值,
改它是无害的。而这一版的条目**已经在客服手上、各有各的到期时刻**:
一次改全部等于把 286 条的截止日一起推平,而卡片上「已改 N 条」当场变成 290,
主管完全看不出自己动了什么(实测就是这么显示的)。
⇒ 时限只留**逐条**那一个。要成批改,让他说一句(助手走 set_expiry,选谁由他定)。
*/}
</div>
{/* ①.1 这张单能怎么改 —— 与确认单同一段位置、同一种写法 */}
{!readOnly && (
<div className="bg-slate-50/70 px-3 py-2 text-[11.5px] leading-relaxed text-slate-500">
展开客服看名单 · <span className="text-slate-700">拖动患者</span>换给别人 ·
<span className="text-slate-700">×</span> 移出(收回池子) · 每行可单独改时限。
{onCompose && (
<>
{' '}想自定义谁换给谁等更多操作,直接{' '}
<button
type="button"
onClick={() => onCompose('')}
className="rounded border border-dashed border-slate-300 px-1.5 py-0.5 text-[11px] leading-4 text-slate-500 transition-colors hover:border-brand-400 hover:text-brand-700"
>
我自己说
</button>
</>
)}
</div>
)}
{/* ⛔ 静默截断 = 让主管以为"这就是全部" */}
{sheet.truncated > 0 && (
<div className="border-b border-amber-100 bg-amber-50/60 px-3 py-2 text-[11.5px] leading-relaxed text-amber-900">
还有 <span className="font-medium">{sheet.truncated}</span> 条没装进这一版(单次最多
500 条)—— 这一版调完再出一版接着调。
</div>
)}
{/*
② 名单(收起体)—— 与确认单一样,只有名单进得来。
⚠️ 与确认单**逐类对齐**:`p-0` + `divide-y`,⛔ 不给它自己的 max-h/overflow。
🔴 内嵌滚动会把拖拽自动滚动打断 —— `scrollerRef` 找的是**外层**那个滚动容器,
名单自己再滚一层,拖到边缘时滚的是外层、而目标客服在内层看不见的地方,
那正是"放置不到不可见的地方"这个投诉的成因。
*/}
{bodyOpen && (
<CardContent className="p-0">
<div className="divide-y">
{groups.map((g) => {
const isOpen = open.has(g.userId);
const after = g.list.length;
const need = Math.max(1, Math.ceil(after / sheet.dailyCalls));
const overdue = g.list.filter((r) => r.overdueDays != null).length;
/**
* 🔴 **放置区是整组,⛔ 不只是抬头那一行**(产品 2026-08-20 走查)。
* 展开之后患者名单那一片在视觉上就属于这位客服,主管自然会往那儿放 ——
* 只把抬头做成落点,他拖到名单里松手什么都不会发生,而且没有任何提示。
* ⇒ `onDragOver` / `onDrop` 挂在**外层**,名单是它的子节点,冒泡上来照样命中。
*/
return (
<div
key={g.userId}
onDragOver={(e) => {
if (readOnly) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
setDragOver(g.userId);
}}
onDragLeave={() => setDragOver((u) => (u === g.userId ? null : u))}
onDrop={(e) => {
if (readOnly) return;
e.preventDefault();
setDragOver(null);
const planId = e.dataTransfer.getData('text/plan-id');
if (!planId) return;
setDraft((p) => ({ ...p, moves: { ...p.moves, [planId]: g.userId } }));
echo(`调整单已更新:「${zhPatient(planId)}」换给了${zhAgent(g.userId)}。`);
}}
className={cn(
dragOver === g.userId && 'bg-brand-50/60 ring-1 ring-inset ring-brand-300',
)}
>
<div className="flex w-full items-center gap-2 px-3 py-2 hover:bg-slate-50">
<button
type="button"
onClick={() =>
setOpen((s) => {
const n = new Set(s);
if (n.has(g.userId)) n.delete(g.userId);
else n.add(g.userId);
return n;
})
}
aria-label={isOpen ? '收起' : '展开'}
className="flex-none"
>
<ChevronRight
className={cn(
'h-3 w-3 text-slate-400 transition-transform',
isOpen && 'rotate-90',
)}
/>
</button>
{/* ⚠️ 姓名是可点击复制的 <button>,⛔ 不能套进展开那个 button(button 嵌 button 非法) */}
<span className="min-w-0 truncate font-medium text-slate-700">
<CopyableName name={zhAgent(g.userId)} />
</span>
<button
type="button"
onClick={() =>
setOpen((s) => {
const n = new Set(s);
if (n.has(g.userId)) n.delete(g.userId);
else n.add(g.userId);
return n;
})
}
className="flex min-w-0 flex-1 items-center text-left"
>
<span className="flex-none tabular-nums text-slate-500">
{after}
{/* ⚠️ 只在**真的变了**时才写前后 —— 没变的行写「15 → 15」是噪音 */}
{after !== g.before && (
<span className="ml-1 text-[10.5px] text-slate-400">
(在手 {g.before}{after})
</span>
)}
</span>
</button>
{overdue > 0 && (
<span className="flex-none tabular-nums text-[10.5px] text-amber-700">
超期 {overdue}
</span>
)}
<span
className="flex-none tabular-nums text-[10.5px] text-slate-400"
title={`按每人每天 ${sheet.dailyCalls} 通算:手上共 ${after} 条 ≈ ${need} 天`}
>
{need}
</span>
{!readOnly && after > 0 && (
<Button
variant="ghost"
size="icon"
title="把他手上这些全部移出(收回池子)"
onClick={() => {
const ids = g.list.map((x) => x.planId);
setDraft((p) => {
const n = new Set(p.removed);
for (const id of ids) n.add(id);
return { ...p, removed: n };
});
echo(`调整单已更新:${zhAgent(g.userId)}手上 ${ids.length} 条全部移出。`);
}}
className="h-6 w-6 flex-none text-slate-400 hover:text-rose-600"
>
<X className="h-3.5 w-3.5" />
</Button>
)}
</div>
{isOpen && (
<ul className="space-y-px bg-slate-50/60 px-2 pb-2">
{g.list.length === 0 && (
<li className="px-1.5 py-1 text-[11.5px] text-slate-400">
这一版调完他手上没有了
</li>
)}
{g.list.map((i) => (
<PatientRow
key={i.planId}
item={i}
readOnly={readOnly}
days={draft.expiry[i.planId] ?? null}
moved={draft.moves[i.planId] != null}
dedicated={i.dedicatedCsUserId === i.assigneeUserId}
onExpiry={(d) => {
setDraft((p) => ({ ...p, expiry: { ...p.expiry, [i.planId]: d } }));
echo(
`调整单已更新:「${zhPatient(i.planId)}」这条时限单独改成 ${d} 天。`,
);
}}
onRemove={() => {
setDraft((p) => {
const n = new Set(p.removed);
n.add(i.planId);
return { ...p, removed: n };
});
echo(`调整单已更新:「${zhPatient(i.planId)}」移出,收回池子。`);
}}
/>
))}
</ul>
)}
</div>
);
})}
</div>
</CardContent>
)}
{/* ②.5 移出的那些 —— 可撤回。⚠️ ⛔ 不进收起体:他刚点的 ✕ 必须看得见 */}
{draft.removed.size > 0 && !readOnly && (
<div className="border-t bg-slate-50 px-3 py-2">
<p className="mb-1 text-[11px] text-slate-500">
移出 {draft.removed.size} 条 —— 确认后回到召回池,谁都不挂着。点一下撤回:
</p>
<div className="flex flex-wrap gap-1.5">
{[...draft.removed].map((planId) => (
<button
key={planId}
type="button"
onClick={() => {
setDraft((p) => {
const n = new Set(p.removed);
n.delete(planId);
return { ...p, removed: n };
});
echo(`调整单已更新:「${zhPatient(planId)}」撤回移出。`);
}}
className="rounded border bg-white px-1.5 py-0.5 text-[11px] text-slate-600 transition-colors hover:border-brand-400 hover:text-brand-700"
>
{zhPatient(planId)}
</button>
))}
</div>
</div>
)}
{/* ③ 展开 / 收起名单 —— 整宽一条,紧贴确认按钮上方(位置与确认单一致) */}
<button
type="button"
onClick={() => setBodyOpen((v) => !v)}
aria-expanded={bodyOpen}
className="flex w-full items-center justify-center gap-1 border-t bg-slate-50/70 py-1.5 text-[11.5px] text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-700"
>
<ChevronDown className={cn('h-3.5 w-3.5 transition-transform', bodyOpen && 'rotate-180')} />
{bodyOpen
? '收起名单'
: `展开名单(${groups.filter((g) => g.list.length > 0).length} 位客服 · ${live.length} 条)`}
</button>
{/* ④ 确认 */}
<div className="border-t px-3 py-2">
{error && <p className="mb-1.5 text-[11px] text-rose-600">{error}</p>}
{done ? (
<p className="text-[11.5px] text-slate-700">{done}</p>
) : superseded ? (
<span className="text-[11.5px] font-medium text-amber-700">
这一版已被新的一版取代 —— 请看下面最新的那张
</span>
) : readOnly ? (
<span className="text-[11.5px] text-slate-400">这一版已经结束,只作回顾</span>
) : (
<Can
perm={Permission.PLAN_DISPATCH}
fallback={<p className="text-[11px] text-slate-400">你没有分配权限,请让主管确认</p>}
>
<Button
disabled={submitting || changed === 0 || streaming}
onClick={() => void confirm()}
className="w-full gap-1.5"
>
{submitting ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Check className="h-3.5 w-3.5" />
)}
{changed === 0 ? '还没有任何改动' : `确认调整 ${changed} 条`}
</Button>
</Can>
)}
</div>
</Card>
);
}
// ─────────────────────────────────────────────────────────
// 一行患者 —— 字段顺序与确认单逐列对齐(姓名 · 病历号 · 性别年龄 · 医生 · 角标 · 时限 · ✕)
// ─────────────────────────────────────────────────────────
function PatientRow({
item,
days,
moved,
dedicated,
readOnly,
onExpiry,
onRemove,
}: {
item: RearrangeItem;
days: number | null;
moved: boolean;
/** 这条现在正在**这位患者的专属客服**手上 —— 拖走就是拆一段老关系 */
dedicated: boolean;
readOnly: boolean;
onExpiry: (days: number) => void;
onRemove: () => void;
}) {
const treat = item.potentialTreatment
? (POTENTIAL_TREATMENT_CARD_LABEL[item.potentialTreatment] ?? item.potentialTreatment)
: null;
const temp = item.temperature
? TEMPERATURE_META[item.temperature as TemperatureValue]?.zh
: null;
const snoozed = item.snoozedUntil != null && new Date(item.snoozedUntil) > new Date();
/** 还剩几天 —— 已超期 / 没有时限时为 null(那两种由左边各自的角标说清) */
const left =
item.expiresAt != null && item.overdueDays == null
? Math.max(1, Math.ceil((new Date(item.expiresAt).getTime() - Date.now()) / 86_400_000))
: null;
return (
<li
draggable={!readOnly}
onDragStart={(e) => {
e.dataTransfer.setData('text/plan-id', item.planId);
e.dataTransfer.effectAllowed = 'move';
}}
className={cn(
'flex items-center gap-1.5 rounded px-1.5 py-1 hover:bg-white',
!readOnly && 'cursor-grab active:cursor-grabbing',
)}
>
{!readOnly && <GripVertical className="h-3 w-3 flex-none text-slate-300" />}
<span className="min-w-0 flex-1 truncate text-[11.5px] text-slate-700">
<CopyableName name={item.patientName ?? `#${item.patientId.slice(0, 8)}`} />
{item.medicalRecordNumber && (
<span className="ml-1 font-mono text-[10px] text-slate-400">
{item.medicalRecordNumber}
</span>
)}
{(item.gender || item.age != null) && (
<span className="ml-1 text-[10px] tabular-nums text-slate-400">
{formatGender(item.gender)}·{item.age ?? '?'}
</span>
)}
</span>
{/* 这条是什么机会 / 患者末诊落在哪一档 —— 调整单独有(确认单那一版整格同一档,不用逐条写) */}
{treat && <span className="flex-none text-[10px] text-slate-400">{treat}</span>}
{temp && <span className="flex-none text-[10px] text-slate-400">{temp}</span>}
{/* 医生 —— 与确认单同一种写法。⚠️ 调整单只给「最近就诊」那一列(口径见 schema 里的红字) */}
{item.lastVisitDoctor && (
<span className="flex-none text-[10px] text-slate-400">主治 {item.lastVisitDoctor}</span>
)}
{/* 🔴 约了下次回访的要标出来:挪走 = 把已经约好的人从原客服手里拿走 */}
{snoozed && (
<Badge
variant="secondary"
className="flex-none bg-emerald-50 px-1 text-[10px] font-normal text-emerald-700"
>
已约下次
</Badge>
)}
{item.overdueDays != null ? (
<Badge
variant="secondary"
className="flex-none whitespace-nowrap bg-amber-50 px-1 text-[10px] font-normal tabular-nums text-amber-700"
>
超期 {item.overdueDays}
</Badge>
) : (
left != null && (
<span className="flex-none whitespace-nowrap text-[10px] tabular-nums text-slate-400">
{left}
</span>
)
)}
{/*
⛔ 只标「专属」,不标"不是他的老客户" —— 没标的就是不是,反而更好读(同确认单)。
⚠️ 判据是「**现在**正在他的专属客服手上」,换过人之后这条就不成立了 ——
所以 `!moved`。换走之后改标「已换人」,并在 title 里补一句他拆掉了什么。
*/}
{dedicated && !moved && (
<Badge
variant="secondary"
className="flex-none bg-brand-50 px-1 text-[10px] font-normal text-brand-700"
>
专属
</Badge>
)}
{/* ⚠️ 与确认单的「指定」同一支紫 —— 都表示"主管手工点过这一条" */}
{moved && (
<Badge
variant="secondary"
className="flex-none bg-violet-50 px-1 text-[10px] font-normal text-violet-700"
title={dedicated ? '原来在这位患者的专属客服手上' : undefined}
>
已换人
</Badge>
)}
{item.benefit && (
<Badge
variant="secondary"
className="max-w-[22%] flex-none truncate bg-slate-100 px-1 text-[10px] font-normal text-slate-500"
title={`这条所属的那一批挂着福利:${item.benefit}`}
>
福利 · {item.benefit}
</Badge>
)}
{/*
⭐ 时限**逐条**:落库写进这条 plan 的 assignment_expires_at。
🔴 未改动时显示的是**这条还剩几天**,⛔ 不是一个凭空的默认值 ——
确认单那边整批同一个时限,拿默认值显示是对的;这一版的条目来自若干不同批次,
各有各的到期时刻,显示成同一个数就是在说谎。
⚠️ 剩余天数落在 1~7 之外(已超期、或当初设得很长)时下拉装不下,回落到系统默认;
真实状态由左边那个「超期 N 天 / 剩 N 天」负责说清 —— 那一处永远是准的。
*/}
<DaySelect
compact
value={days ?? (left != null && ASSIGNMENT_EXPIRES_DAYS_PRESETS.includes(left) ? left : ASSIGNMENT_EXPIRES_DAYS_DEFAULT)}
disabled={readOnly}
onChange={onExpiry}
/>
{!readOnly && (
<Button
variant="ghost"
size="icon"
title="移出(收回池子)"
onClick={onRemove}
className="h-5 w-5 flex-none text-slate-300 hover:text-rose-600"
>
<X className="h-3 w-3" />
</Button>
)}
</li>
);
}
// ─────────────────────────────────────────────────────────
// 助手指令 → 具体条目
// ─────────────────────────────────────────────────────────
/** 一条 `select` 落到哪些单上 —— ⚠️ 已经被移出的不再参与 */
function selectItems(sheet: RearrangeDraft, draft: Draft, op: RearrangeEditOp): RearrangeItem[] {
const alive = sheet.items.filter((i) => !draft.removed.has(i.planId));
const ownerOf = (i: RearrangeItem) => draft.moves[i.planId] ?? i.assigneeUserId;
switch (op.select.group) {
case 'all':
return alive;
case 'overdue':
return alive.filter((i) => i.overdueDays != null);
case 'agent': {
const id = sheet.agents.find((a) => (a.name ?? '') === op.select.agent?.trim())?.userId;
return id ? alive.filter((i) => ownerOf(i) === id) : [];
}
case 'patients': {
const keys = (op.select.patients ?? []).map((s) => s.trim()).filter(Boolean);
return alive.filter((i) =>
keys.some((k) => i.patientName === k || i.medicalRecordNumber === k),
);
}
default:
return [];
}
}
/** 「谁手上少先给谁」—— 与服务端落人同一个思路(水位法),⛔ 不随机 */
function balance(
picked: RearrangeItem[],
candidates: string[],
sheet: RearrangeDraft,
draft: Draft,
): Array<[string, string]> {
const ownerOf = (i: RearrangeItem) => draft.moves[i.planId] ?? i.assigneeUserId;
const load = new Map<string, number>();
for (const c of candidates) load.set(c, 0);
for (const i of sheet.items) {
if (draft.removed.has(i.planId)) continue;
const o = ownerOf(i);
if (load.has(o)) load.set(o, load.get(o)! + 1);
}
const out: Array<[string, string]> = [];
for (const it of picked) {
const from = ownerOf(it);
// ⚠️ 不能把人"换给他自己" —— 那不是一次改派
const pool = candidates.filter((c) => c !== from);
if (pool.length === 0) continue;
// 同水位按 userId 打破平局 —— 确定性:同一张单每次算出来都一样
pool.sort((x, y) => load.get(x)! - load.get(y)! || (x < y ? -1 : 1));
const to = pool[0]!;
load.set(from, Math.max(0, (load.get(from) ?? 1) - 1));
load.set(to, (load.get(to) ?? 0) + 1);
out.push([it.planId, to]);
}
return out;
}
/** 一条指令没匹配上时,回给主管/模型的那半句 —— 说清「你指的是哪些」 */
function describeSelect(op: RearrangeEditOp): string {
switch (op.select.group) {
case 'all':
return '这一版全部';
case 'overdue':
return '已超期的';
case 'agent':
return `${op.select.agent ?? ''} 手上的`;
case 'patients':
return (op.select.patients ?? []).join('、');
default:
return '这些';
}
}
'use client';
import { useState } from 'react';
import { ChevronDown } from 'lucide-react';
import { ASSIGNMENT_EXPIRES_DAYS_PRESETS } from '@pac/types';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { cn } from '@/lib/utils';
/**
* 两张单子共用的**呈现原件** —— 确认单(分配)与重排单各自独立,但主管的手只学一遍。
*
* ═══ 为什么这两个可以共用,而组件本身不行 ═══════════════════════════
* 这里的两个都是**没有业务语义**的交互原件:一个是"点名字复制",一个是"改天数"。
* 它们身上那点来之不易的行为(懒挂 Radix、复制反馈)一旦抄成两份必然漂 ——
* 而漂了的表现是**两张单子手感不一样**,正是"用户不用二次学习"要防的那件事。
* ⚠️ 有业务语义的东西(福利、待分配、批次时限、落人算法)⛔ 仍然各归各的,
* ⛔ 别拿这个文件当"把两张单子缝起来"的入口(产品 2026-08-20:重排单与确认单独立)。
*/
/**
* 患者姓名 —— **点一下复制**(2026-08-06 产品加)。
*
* ⚠️ 主管审这批人时常要把名字贴到别处(问医生、查宿主档案、发消息),
* 在一个 480px 的列表里手动选中一个中文名很难选准。
* ⚠️ `stopPropagation` 是必须的:这一行同时是**折叠开关的兄弟**且整行可拖,
* 不拦住会连带触发别的行为。
* ⚠️ `draggable={false}`:不加的话按住名字拖会被浏览器当成拖文本,把整行的拖拽吃掉。
* ⛔ 复制的是**姓名本身**,不含病历号/性别年龄 —— 那些是给眼睛看的上下文,
* 粘出去多带一串反而要手动删。
*/
export function CopyableName({ name }: { name: string }) {
const [copied, setCopied] = useState(false);
return (
<button
type="button"
draggable={false}
title="点击复制姓名"
onClick={(e) => {
e.stopPropagation();
void navigator.clipboard?.writeText(name);
setCopied(true);
setTimeout(() => setCopied(false), 1200);
}}
className={cn(
'rounded px-0.5 transition-colors',
copied ? 'bg-emerald-50 text-emerald-700' : 'hover:bg-brand-50 hover:text-brand-700',
)}
>
{copied ? '已复制' : name}
</button>
);
}
/**
* 时限选择 —— 1~7 天。用 shadcn `Select`(Radix),不用原生 `<select>`:
* 原生下拉在深色/窄屏下样式不可控,而这个控件现在**每一条召回计划都有一个**,
* 出现几百次,样式必须跟卡片其余部分一致。
* ⚠️ 值域来自 `ASSIGNMENT_EXPIRES_DAYS_PRESETS`(共享常量),⛔ 别在这里内联 [1..7]。
*/
/**
* 时限下拉 —— **点了才挂真正的 Radix Select**。
*
* 🔴 由来(2026-08-05 实测):原来每一行直接渲染一个 `<Select>`。展开一个 36 条的客服组
* = 一次性挂 36 个 Radix Select,每个都带 portal / 焦点管理 / 键盘导航,
* 展开那一下明显卡顿。而这些下拉**绝大多数根本不会被点** —— 主管一批 200 人,
* 真正逐条改时限的是个位数。
*
* ✅ 平时只是一个 `<button>`(样式与 SelectTrigger 一致),点击才换成真 Select 并自动展开。
* ⚠️ `defaultOpen` 是关键:换过去之后要立刻弹开,否则用户得点两下。
* ⚠️ 收起后**不还原成按钮** —— 还原会让"刚改完又想再改"变成再等一次挂载,
* 而且已经挂上的那一个不再是成本问题(卡的是 36 个同时挂)。
*/
export function DaySelect({
value,
disabled,
onChange,
compact,
}: {
value: number;
disabled?: boolean;
onChange: (days: number) => void;
/**
* 条目行用紧凑版 —— **只是窄一点**,⛔ 不省单位。
*
* 🔴 2026-08-20 产品走查:原来紧凑版只显示一个光秃秃的数字(「1」),
* 而它旁边一行里还有「剩 1 天」「约 2 天」这些数 —— 一个不带单位的 1 摆在中间
* 读不出是什么。省下的那点宽度换来一次"这是什么"的停顿,不划算。
*/
compact?: boolean;
}) {
const [live, setLive] = useState(false);
// ⚠️ 紧凑版从 54 放到 66:要装下「1 天」+ 箭头,54 会把「天」挤掉
const cls = cn('h-6 gap-1 px-1.5 text-[11px]', compact ? 'w-[66px]' : 'w-[76px]');
if (!live) {
return (
<button
type="button"
disabled={disabled}
onClick={() => setLive(true)}
// 样式与 SelectTrigger 对齐 —— 换过去时不许有跳动
className={cn(
cls,
'inline-flex items-center justify-between rounded-md border border-input bg-transparent',
'shadow-xs transition-colors hover:bg-accent/50 disabled:cursor-not-allowed disabled:opacity-50',
)}
>
<span>{value}</span>
<ChevronDown className="h-3 w-3 opacity-50" />
</button>
);
}
return (
<Select
value={String(value)}
disabled={disabled}
defaultOpen
onValueChange={(v) => onChange(Number(v))}
>
<SelectTrigger className={cls}>
<SelectValue />
</SelectTrigger>
{/* z 层级已在 ui/select.tsx 统一抬到 70(助手窗是 z-60,见那里的层级约定) */}
<SelectContent>
{ASSIGNMENT_EXPIRES_DAYS_PRESETS.map((d) => (
<SelectItem key={d} value={String(d)} className="text-[12px]">
{d}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
......@@ -56,11 +56,20 @@ const TOOL_META: Record<string, { label: string; running: string }> = {
render_artifact: { label: '出图表', running: '正在画图' },
// 取一篇做法(pull 式加载,见服务端 assistant-playbooks):不查库不改数据
open_playbook: { label: '取做法', running: '正在取这类活的做法' },
// ── 调整各客服在手的单(2026-08-20)——与上面确认单那三件一一对应 ──
// ⚠️ 说的是**动作**,⛔ 不是工具名:漏配的话兜底文案会把 `propose_rearrange`
// 这串英文直接摆给主管看(2026-08-20 走查抓到的就是这个)。
propose_rearrange: { label: '出调整单', running: '正在把各客服手上的单摆出来' },
edit_rearrange_sheet: { label: '改调整单', running: '正在按你说的改调整单' },
// ── 纯位置标记,不查库不写库(见 assistant.service 的 place_*)──
show_sheet: { label: '摆确认单卡片', running: '正在摆确认单' },
show_rearrange: { label: '摆调整单卡片', running: '正在摆调整单' },
show_guidance: { label: '摆选项按钮', running: '正在摆选项按钮' },
};
/** 给测试用 —— ⚠️ 只读导出,⛔ 别在运行时拿它当映射表(那是 `toolMeta` 的活) */
export const TOOL_META_KEYS = Object.keys(TOOL_META);
export function toolMeta(tool: string) {
return TOOL_META[tool] ?? { label: tool, running: `正在调用 ${tool}` };
}
......
'use client';
import { useCallback, useRef, useState } from 'react';
import type { AssignmentProposal, SheetEditOp, Signal } from '@pac/types';
import type {
AssignmentProposal,
RearrangeDraft,
RearrangeEditOp,
SheetEditOp,
Signal,
} from '@pac/types';
import { env } from '@/lib/env';
import { emitPetEvent } from '@/lib/pet-events';
import { useAuthStore } from '@/stores/auth-store';
......@@ -13,7 +19,9 @@ import { assignmentsApi } from '@/components/plans/assignments-api';
// ⭐ 数据模型 + 纯函数操作已抽到 chat-blocks(草稿状态机是最该有回归的一段逻辑,
// 内联在 hook 里只能端到端点一遍才能验)。这里 re-export 保持下游 import 不变。
import {
applyIncomingRearrange,
applyIncomingSheet,
findActiveRearrange,
findActiveSheet,
findToolIdx,
intentToPrompt,
......@@ -77,7 +85,7 @@ export function useAssistantChat() {
* 正在执行的引导节点 intent —— 按钮据此转圈并整排禁用。
*
* 🔴 必须在**这一层**(2026-08-13):会花时间的两类动作都已经不在卡片里了 ——
* `pending.refill` 走 HTTP 重排(几秒),`basis.set` / `cohort.*` 走
* `pending.refill` 走 HTTP 调整(几秒),`basis.set` / `cohort.*` 走
* `send()` 让模型重出一版(几十秒)。⛔ 局部改单(to_owner / spread / remove /
* expiry.set)是同步的,⛔ 不要给它们转圈:闪一下反而像出错了。
*/
......@@ -130,7 +138,7 @@ export function useAssistantChat() {
/**
* 消息流的**同步镜像** —— 给那些"要先读当前状态、再发请求"的回调用。
* ⛔ 别把这类读取塞进 `setMessages` 的 updater:StrictMode 会把 updater 跑两次,
* 写在里面的副作用(如重排接口)就会**发两遍**。
* 写在里面的副作用(如调整接口)就会**发两遍**。
*/
const messagesRef = useRef<ChatMessage[]>([]);
messagesRef.current = messages;
......@@ -206,7 +214,7 @@ export function useAssistantChat() {
/**
* 文本 / 思考的增量追加 —— **追加到最后一个同类块**,被别的东西打断就新起一段。
*
* ⚠️ 这就是"时序"的全部实现:块的先后 = 事件到达的先后,⛔ 前端不重排
* ⚠️ 这就是"时序"的全部实现:块的先后 = 事件到达的先后,⛔ 前端不调整
* 所以「思考 → 调工具 → 写字 → 再调」在消息里长什么样,就是它真实发生的顺序。
*/
const appendStream = (kind: 'text' | 'reasoning', t: string) =>
......@@ -273,6 +281,44 @@ export function useAssistantChat() {
: [...blocks, { kind: 'sheet_anchor' as const }],
);
break;
// ── 调整单(调整各客服在手的单)——— 与确认单三件事同构、⛔ 各走各的 ──
case 'rearrange_sheet':
setMessages((prev) =>
applyIncomingRearrange(prev, {
operationId: String(evt.operationId ?? ''),
sheet: evt.sheet as RearrangeDraft,
hostMessageId: assistantId,
}),
);
break;
case 'rearrange_sheet_edit':
// ⚠️ 与确认单同一条纪律:跨消息找**唯一那张 active**。
// ⛔ 但这里不回落到 confirmed —— 调整一确认就落库了,没有"确认后还能改"。
setMessages((prev) => {
const at = findActiveRearrange(prev);
if (!at) return prev;
const { messageIndex: mi, blockIndex: bi } = at;
const b = prev[mi]!.blocks[bi] as Extract<Block, { kind: 'rearrange_sheet' }>;
const next: Block = {
...b,
edits: [
...(b.edits ?? []),
{ seq: (b.edits?.length ?? 0) + 1, ops: (evt.ops ?? []) as RearrangeEditOp[] },
],
};
return prev.map((m, k) =>
k === mi ? { ...m, blocks: m.blocks.map((x, j) => (j === bi ? next : x)) } : m,
);
});
break;
case 'rearrange_sheet_anchor':
// ⚠️ 与 sheet_anchor 是**两个标记**:同一条消息里两张单都可能存在
patch((blocks) =>
blocks.some((b) => b.kind === 'rearrange_anchor')
? blocks
: [...blocks, { kind: 'rearrange_anchor' as const }],
);
break;
case 'assignment_guidance': {
/**
* ⭐ 模型说「这条的按钮插在这」—— `patch` 追加到**当前正在写的**这条消息末尾,
......@@ -608,7 +654,7 @@ export function useAssistantChat() {
*
* ⚠️ 2026-08-12 从确认单组件搬到这里:待分配的按钮已经不在卡片上了
* (它跟着模型的文案走),卡片再留一份实现就是**没人走的死代码**。
* ⚠️ 条件必须**原样带回** —— 少带一个(口径、治疗项、时间档),重排就换了人群,
* ⚠️ 条件必须**原样带回** —— 少带一个(口径、治疗项、时间档),调整就换了人群,
* 而且不报错。
*/
if (intent === 'pending.refill') {
......@@ -639,7 +685,7 @@ export function useAssistantChat() {
...(s.narrowedBy?.minSpendYuan ? { minSpendYuan: s.narrowedBy.minSpendYuan } : {}),
/**
* 🔴 **他在这一版上设的按客服精调也要带回**(2026-08-15 补)。
* 服务端的基线读的是**上一次已确认批次**,所以不带 = 重排一下
* 服务端的基线读的是**上一次已确认批次**,所以不带 = 调整一下
* 「李莉这批只给 5 条」就没了,李莉照常吃满,⛔ 而且不报错。
* ⚠️ 发**当前这一版**的(`s.agentOverrides` 已经是基线与本轮合并后的结果),
* ⛔ 别只发"本轮新加的":那样上一次沿用下来的会被当成撤销。
......
......@@ -352,14 +352,28 @@ function MatrixRow({
* 走 state 才能保证"点不动的格子不给准星";
* ② 准星和十字必须**同生同灭**,同一个来源才不会一个亮一个不亮。
* ⚠️ 数字**微微抬起** —— `nums` 是等宽数字,放大不会把邻居推歪。
* 🔴 放大挂在**里面那个 span** 上,⛔ 不挂在 button 上(2026-08-20 修):
* 渐变面为了圆角带 `overflow-hidden`,button 一 `scale(1.06)`,
* **最边上那一列/那一行**的盒子就顶出容器约 3px —— 准星的竖臂
* (`inset:2px` 处、宽 1.5px)整条落在容器外被裁光,横臂只剩内侧一截。
* 现场是「3 年以上」那列:左两角完好,右两角只剩两个短横杠。
* ⛔ 别改成去掉 `overflow-hidden`:它是用来把**行/列高亮的白纱**裁进圆角的,
* 去掉之后首行末行的纱会把四个圆角戳成方的。
*/
isAimed && 'pac-reticle scale-[1.06] bg-white/12',
isAimed && 'pac-reticle bg-white/12',
// 其余整片退让 —— ⚠️ 只压**不在十字上**的,压全部等于什么都没压
aim && !onCross && 'opacity-45',
isSelected && 'outline-solid outline-2 -outline-offset-2 outline-brand-600',
)}
>
<span
className={cn(
'inline-block transition-transform duration-150',
isAimed && 'scale-[1.06]',
)}
>
{n ? n.toLocaleString() : '—'}
</span>
</button>
);
})}
......
import type { RearrangeDraft, RearrangeSubmitRequest, RearrangeSubmitResponse } from '@pac/types';
import { api } from '@/lib/api-client';
/**
* 重排(调整各客服在手的单)。
*
* ⛔ 刻意**不并进** `assignmentsApi`:重排不建批次、不属于任何一次分配,
* 路径也不在 `plans/assignments` 之下。并进去会让"这是分配的一部分"这个错觉
* 在代码组织上就成立。
*/
export const rearrangeApi = {
/** 取一版可编辑的在手快照 —— 不传 agents = 全队 */
draft: (clinicId: string, agents?: string[]) =>
api.get<RearrangeDraft>(
`/pac/v1/plans/rearrange/draft?clinicId=${encodeURIComponent(clinicId)}` +
(agents?.length ? `&agents=${encodeURIComponent(agents.join(','))}` : ''),
),
/** 确认重排 —— 这条路唯一的写动作 */
submit: (body: RearrangeSubmitRequest) =>
api.post<RearrangeSubmitResponse>('/pac/v1/plans/rearrange', body),
};
......@@ -12,6 +12,15 @@ import { cn } from '@/lib/utils';
const PAGE = 20;
/**
* 数字列的列名 —— **单一真源**。
*
* 🔴 分组表头那一行的 `colSpan` 从它推(`NUM_COLS.length + 1`,+1 是最左边的「批次」列)。
* ⛔ 别在那儿手写数字:2026-08-20 加「回收」那一列时就是这么栽的 ——
* 分组行少跨一格,最后一列在「还在跑的」「8 月」这两行上留出一块白,且不报任何错。
*/
const NUM_COLS = ['条数', '已处置', '没动', '客服退回', '回收', '超期', '约上'] as const;
/**
* 「没动」标琥珀的阈值(占本批/本人分到量的比例)。
* ⚠️ **列表和抽屉共用这一个常量** —— 各写各的必然漂,而漂了主管只会以为某一处算错了。
*/
......@@ -234,7 +243,16 @@ export function BatchTracking({ clinicId }: { clinicId: string | null }) {
<thead>
<tr className="sticky top-0 z-10 bg-slate-50 shadow-[inset_0_-1px_0_#E2E8F0]">
<th className="px-3.5 py-1.5 text-left text-[11px] font-medium text-slate-500">批次</th>
{['条数', '已处置', '没动', '退回', '超期', '约上'].map((h) => (
{/*
⚠️ 列名从 `NUM_COLS` 来 —— 分组表头那一行的 colSpan 也从它推。
🔴 「退回」写成「**客服退回**」,并单列「**回收**」(2026-08-20 产品定)。
在此之前主管在调整单里收回的单子**在这张表上凭空消失**:既不在没动、
也不在退回,他一对数就发现少了几条,而少的那几条查不出去哪了。
⚠️ 两件事必须分开:「客服退回」是客服说我不接这单(带原因,进原因分布),
「回收」是主管自己把活收回来 —— 混在一起,他就是在拿自己的动作
当调下一批的依据。
*/}
{NUM_COLS.map((h) => (
<th
key={h}
className="whitespace-nowrap px-2.5 py-1.5 text-right text-[11px] font-medium text-slate-500"
......@@ -344,7 +362,13 @@ function BatchGroup({
return (
<>
<tr>
<td colSpan={7} className={cn('border-t px-3.5 py-1.5', t.rail, t.head)}>
{/*
🔴 **colSpan 必须从列表推**(2026-08-20 走查栽过):加了「回收」那一列之后,
这里还写着手抄的 7,分组表头那一行少跨一格 —— 最后一列(约上)在
「还在跑的」「8 月」这两行上留出一块白,而且不报任何错。
⚠️ `+1` 是最左边那一列「批次」,它不在 NUM_COLS 里。
*/}
<td colSpan={NUM_COLS.length + 1} className={cn('border-t px-3.5 py-1.5', t.rail, t.head)}>
<span className={cn('text-[11px] font-semibold', t.headKey)}>{group.key}</span>
<span className="ml-1.5 text-[10.5px] text-slate-400">{group.note}</span>
</td>
......@@ -383,6 +407,8 @@ function BatchGroup({
{b.inHand}
</td>
<td className={cn('nums px-2.5 py-2 text-right', t.released)}>{b.released}</td>
{/* ⚠️ 回收是**中性**的(主管自己的调度),⛔ 别给退回那支红 —— 红会读成"出问题了" */}
<td className={cn('nums px-2.5 py-2 text-right', t.idle)}>{b.recalled}</td>
<td className={cn('nums px-2.5 py-2 text-right', t.expired)}>{b.expired}</td>
<td className={cn('nums px-2.5 py-2 text-right', t.booked)}>{b.booked}</td>
</tr>
......@@ -427,7 +453,7 @@ function BatchDrawer({ id, onClose }: { id: string; onClose: () => void }) {
<>
{/*
🔴 **z-20 不能省**(2026-08-07 实测):表格的 `thead` 是 `sticky z-10`,
抽屉不给层级的话,那一排列头(已处置 / 没动 / 退回 / 到期回池 / 约上)
抽屉不给层级的话,那一排列头(已处置 / 没动 / 客服退回 / 回收 / 超期 / 约上)
会**浮在抽屉标题上面**,跟抽屉自己那行汇总叠在一起 —— 看着像表头样式坏了,
实际是层级问题。⛔ 别去改 thead 的 sticky(那是列表滚动要用的)。
⚠️ `top-[3.25rem]` 对齐面板自己的标题行高度:抽屉盖住表格但**不盖标题**,
......@@ -445,8 +471,8 @@ function BatchDrawer({ id, onClose }: { id: string; onClose: () => void }) {
</div>
{d && (
<div className="nums mt-0.5 text-[11px] text-slate-600">
已处置 {d.handled} · 没动 {d.inHand} · 退回 {d.released} · 超期 {d.expired} ·
约上 {d.booked}
已处置 {d.handled} · 没动 {d.inHand} · 客服退回 {d.released}
{d.recalled > 0 && <> · 回收 {d.recalled}</>} · 超期 {d.expired} · 约上 {d.booked}
</div>
)}
</div>
......@@ -475,7 +501,7 @@ function BatchDrawer({ id, onClose }: { id: string; onClose: () => void }) {
<table className="w-full border-collapse text-[12px] text-slate-700">
<thead>
<tr>
{['客服', '分到', '已处置', '没动', '退回'].map((h, i) => (
{['客服', '分到', '已处置', '没动', '客服退回', '回收'].map((h, i) => (
<th
key={h}
className={cn(
......@@ -508,15 +534,18 @@ function BatchDrawer({ id, onClose }: { id: string; onClose: () => void }) {
{a.inHand}
</td>
<td className="nums py-1.5 text-right text-rose-600">{a.released}</td>
{/* 从**他手上**收回去的(⛔ 不是当初分给他的那个人) */}
<td className="nums py-1.5 text-right text-slate-500">{a.recalled}</td>
</tr>
))}
</tbody>
</table>
<div className="mt-4 text-[11.5px] font-semibold text-slate-700">退回原因分布</div>
{/* ⚠️ 只统计**客服退回**的原因 —— 主管回收没有原因,也⛔ 不该进这张分布 */}
<div className="mt-4 text-[11.5px] font-semibold text-slate-700">客服退回原因分布</div>
{/* 🔴 退回率两个分母都给 —— 只给百分比会把"没动"藏起来 */}
<div className="nums mt-0.5 text-[10.5px] text-slate-400">
退回 {d.released} / 已处置 {d.handled}
客服退回 {d.released} / 已处置 {d.handled}
{d.handled > 0 ? ` = ${((d.released / d.handled) * 100).toFixed(1)}%` : ''},另有{' '}
{d.inHand} 条没动
</div>
......
......@@ -1089,6 +1089,37 @@ export const PlanEventType = {
* ⛔ 别把它算进"客服处理过这个患者",那个口径会静默膨胀成"复制一下也算"。
*/
SCRIPT_COPY: 'script_copy',
// ── 主管调整各客服**在手**的单 —— 2026-08-20 ────────────────────
/**
* 调整 · 改派:把一条**已经在客服手上**的单换给另一位客服。
*
* ⚠️ 与 `assign` 分开而不是复用,是为了 `byHuman: false` ——
* 转手不是「客服处理过这个患者」。挂 `assign` 会让 HUMAN_TOUCH_EVENTS
* 把"被转了一手"算成"有人跟进过",而那是 filter 派生的,改错不报任何错。
*
* 列怎么用:`assignee_user_id` = 接手人 / `actor_user_id` = 主管(操作人) /
* `details.from` = 原客服 / `assignment_id` = **原批**(⛔ 调整不改归属批次) /
* `operation_id` = 本次调整。
*/
TRANSFER: 'transfer',
/**
* 调整 · 改时限:`details` 记改动前后(`{ from, to }`,ISO 时刻)。
* ⚠️ 归属没变 → `holdsPatient: true`。
*/
EXPIRY_CHANGED: 'expiry_changed',
/**
* 调整 · 移出:主管把一条在手单收回池子。
*
* 🔴 ⛔ **绝不能复用 `release`**(2026-08-20 定,踩之前先写下来):
* 批次详情的退回原因分布是 `for (e of batchEvents) if (e.event === 'release')`
* —— **只按 assignment_id 过滤,不看是谁干的**(见 plan-assignment.service
* 的 detail())。调整的移出若挂 release,会同时:
* ① 进那一批的**退回原因分布** —— 污染主管调下一批的输入;
* ② 给**原客服**记一笔「退回」—— 他没退,是主管收走的。
* 两个错都不抛任何异常。
*/
REARRANGE_REMOVE: 'rearrange_remove',
} as const;
export type PlanEventType = (typeof PlanEventType)[keyof typeof PlanEventType];
......@@ -1125,6 +1156,12 @@ export const PLAN_EVENT_META: Record<
// ⚠️ holdsPatient: false —— 复制可能发生在未认领的单上(主管浏览时也能复制),
// 跟归属无关,不参与归属区间计算。
script_copy: { labelZh: '复制话术', group: 'usage', byHuman: false, holdsPatient: false },
// ── 主管调整 —— 三个都 byHuman: false:调整是**主管**在调度,不是客服处理了患者。
// 给 true 会让「已处置」把"被主管挪了一下"算成"有人跟进过"。
transfer: { labelZh: '调整改派', group: 'ownership', byHuman: false, holdsPatient: true },
expiry_changed: { labelZh: '调整改时限', group: 'ownership', byHuman: false, holdsPatient: true },
// ⚠️ holdsPatient: false —— 移出之后单子回池,没有客服挂着(与 release 同)。
rearrange_remove: { labelZh: '调整移出', group: 'ownership', byHuman: false, holdsPatient: false },
};
/// 「该患者被客服处理过」的事件集合 —— 统计口径收口在这里,避免各处各写一套。
......
......@@ -6,6 +6,7 @@ export * from './fact';
export * from './persona';
export * from './plan';
export * from './plan-assignment';
export * from './plan-rearrange';
export * from './agent';
export * from './sync';
export * from './admin';
......
......@@ -68,7 +68,14 @@ export const CreateAssignmentRequestSchema = z.object({
* (纯日期被当成 UTC 零点,整整偏 8 小时)。服务端按 host 时区转成**当地日末**。
*/
expiresInDays: z.number().int().positive().max(90),
items: z.array(AssignmentItemSchema).min(1).max(ASSIGNMENT_ITEMS_HARD_LIMIT),
items: z
.array(AssignmentItemSchema)
.min(1)
/// ⚠️ 消息要带数字:zod 的默认文案会被 all-exceptions.filter 包成一句笼统的
/// 「请求字段校验失败」,主管看不出是哪一项超了、上限是多少(2026-08-20 生产实遇)。
.max(ASSIGNMENT_ITEMS_HARD_LIMIT, {
message: `单次分配最多 ${ASSIGNMENT_ITEMS_HARD_LIMIT} 条,请减少本批人数后重试`,
}),
});
export type CreateAssignmentRequest = z.infer<typeof CreateAssignmentRequestSchema>;
......@@ -256,7 +263,17 @@ export const AssignmentBriefSchema = z.object({
* SQL 里 `release_reason = NULL`),数它等于"被退过又被重分的人从没退过"。
* ⚠️ **不含到期**:到期是"客服压根没动",与"看了判断不该我做"是两回事,见 expired。
*/
released: z.number().int().describe('客服主动退回(不含到期)'),
released: z.number().int().describe('客服主动退回(不含到期、不含主管回收)'),
/**
* 主管在**调整各客服在手的单**里把它收回池子的条数(账本 `rearrange_remove`)。
*
* ⭐ 与 `released` 分开的理由跟当初分开事件是同一条:那是**客服**说「我不接这单」,
* 这是**主管**把活收回来。混在一起,退回原因分布会被主管自己的调度污染,
* 而主管正是靠那张分布决定下一批怎么发。
* ⚠️ 它跟 `inHand` 互斥(收回之后单子不在人手上了),但**不从 `planned` 里减** ——
* 当时确实分了这个人,分母不能缩水。
*/
recalled: z.number().int().describe('主管调整时收回池子的条数'),
/**
* 到期自动回池的条数(账本 auto_release + reason=assignment_expired)。
*
......@@ -318,6 +335,14 @@ export const AssignmentAgentStatSchema = z.object({
inHand: z.number().int(),
released: z.number().int(),
/**
* 主管从**他手上**收回去的条数(调整单里的移出)。
*
* ⚠️ 归的是「从谁手上收的」,⛔ 不是「当初分给谁」—— 一条单可能先被转手再被收回,
* 那时候两者不是一个人。
* ⚠️ 它⛔ 不进退回率:退回率回答的是「他为什么不接」,而这一列是主管自己的动作。
*/
recalled: z.number().int(),
/**
* 仍在手、已过时限、**且没有约定回访**。
*
* 🔴 「且没有约定回访」这半句不能少。到期回收器刻意跳过 `snoozedUntil` 在未来的单
......@@ -438,6 +463,8 @@ export const AssignmentDetailResponseSchema = AssignmentBriefSchema.extend({
* 而"没动过"的数量本身就是信号(主管据此判断是分多了还是客服没跟)。
* 只给一个百分比会让主管把"没人动"误读成"做得不错"。
*/
/// 主管在调整单里收回池子的条数(口径同列表行的 `recalled`)
recalled: z.number().int(),
untouched: z.number().int().describe('分下去后客服从未打开过详情页的条数'),
/**
* 处理进度 —— **判据是池子状态,不是回写**(产品裁决 2026-08-02)。
......@@ -733,7 +760,7 @@ export const AssignmentProposalSchema = z.object({
/// ⭐ 与矩阵格子同一种数法(count DISTINCT patient_id),⛔ 不受取明细的 LIMIT 影响 ——
/// 从矩阵点进来的主管会拿这个数跟他刚看到的格子对
candidateTotal: z.number().int().describe('候选总数 = 该格子/该条件下的患者数(与矩阵格子对得上)'),
target: z.number().int().describe('本批**实际**分多少人 = min(基数 N, 候选总数)'),
target: z.number().int().describe('本批**实际**分多少人 = min(基数 N, 候选总数, 单批硬上限 500)'),
/**
* 基数 N —— **要被记住的那个数**(沿用上次 / 首次按 在岗人数×20 估 / 本次主管指定)。
* ⚠️ 与 `target` 分开:候选不够时 target 会被压低,但那是**这一批的偶然事实**,
......
import { z } from 'zod';
// =============================================================
// 主管调整 —— 调整各客服**在手**的单
// GET /pac/v1/plans/rearrange/draft 取一版可编辑快照(只读)
// POST /pac/v1/plans/rearrange 提交(主管手动确认后才走这一步)
// =============================================================
/**
* ── 与「分配」是**两条独立的路**,⛔ 别互相引用 ──────────────────
*
* 分配(`plan-assignment.ts`)回答的是「从池子里挑一批人发下去」;
* 调整回答的是「已经在客服手上的这些,怎么调」。两者的形状看着像,语义不通:
*
* · 调整**不建批次** —— 单子的 `assignment_id` 一律不动。动了原批的
* `planned` 会静默缩水(见 schema.prisma 里 assignment_id 那段红字)。
* · 调整**没有福利** —— 福利挂在批次上才归得了因,而调整的人来自若干不同批次。
* 这里只**只读**展示"这条来自哪批、带什么福利"。
* · 调整**没有「本批多大」** —— 人数不是算出来的,是"他手上现有多少"。
* · 调整**没有「待分配」** —— 人已经在手上了,不存在"专属客服排满"这回事。
*
* ⇒ 共用一套 schema 必然要给一半字段配 null,而那一半迟早有人在某个分支上忘了判。
*/
/// 单次调整最多动多少条 —— 与分配同一道技术护栏(单事务 + PG bind 变量上限)
export const REARRANGE_ITEMS_HARD_LIMIT = 500;
// ── 一条在手的单 ─────────────────────────────────────────────
export const RearrangeItemSchema = z.object({
planId: z.string(),
patientId: z.string(),
/// ⭐ 姓名必须带 —— 主管要判断的是"这个人该不该挪",给 id 前缀等于让他对着乱码猜
patientName: z.string().nullable(),
medicalRecordNumber: z.string().nullable(),
gender: z.string().nullable(),
age: z.number().int().nullable(),
/**
* 最近一次**就诊**的医生 —— 直接取 `patient_profiles.last_visit_doctor`,与列表页同源。
*
* ⛔ 这里**刻意不给「主治医生」**:确认单那一列是从触发本次召回的那条诊断现算的
* (排除影像 AI、没有则回落全量最高频真人),口径不平凡。在这儿另写一遍
* 必然漂 —— 调整单显示 A、点进详情显示 B,而且不报错。要给就复用那条路,别重写。
*/
lastVisitDoctor: z.string().nullable(),
/// **此刻**在谁手上
assigneeUserId: z.string(),
/**
* 这位患者的**专属客服**(宿主 DW 的 current_task_director,PAC 只读)。
*
* ⭐ 界面据此在行上标「专属」——判据是 `dedicatedCsUserId === assigneeUserId`,
* 也就是「这条现在正在他的专属客服手上」。主管把它拖走 = 拆掉一段老关系,
* 他有权在动手**之前**看见这件事(与确认单那一列同一个词、同一支色)。
* ⚠️ null 有两种情况:这位患者没有专属客服 / 有但不是当前持有人。
* 界面只在"是当前持有人"时标 —— 不是他的老客户反而不标,更好读(同确认单)。
*/
dedicatedCsUserId: z.string().nullable(),
/// 这条单是什么机会(潜在治疗 code,如 implant)+ 患者末诊落在哪一档
potentialTreatment: z.string().nullable(),
temperature: z.string().nullable(),
/// 当前时限(ISO 时刻);null = 这条不是批次分下来的(老数据 / 自认领)
expiresAt: z.string().nullable(),
/**
* 已经超期几天 —— 未超期为 null。
* ⚠️ 与团队状态表**同口径**:约了下次回访的(见 snoozedUntil)不算超期。
* ⛔ 两处判据不许分家,否则主管在两个界面上看到两个数。
*/
overdueDays: z.number().int().nullable(),
/**
* 客服约了下次回访的时刻 —— 有值且在未来 = 这条**已经处理过了**。
* ⭐ 界面要标出来:把这种单挪给别人,等于把已经约好的人从原客服手里拿走。
*/
snoozedUntil: z.string().nullable(),
/// 这条来自哪一批(⛔ 只读,调整不改它)
fromAssignmentId: z.string().nullable(),
/// 那一批挂的福利(⛔ 只读。⛔ 调整不能改福利 —— 见文件头)
benefit: z.string().nullable(),
});
export type RearrangeItem = z.infer<typeof RearrangeItemSchema>;
// ── 按客服的汇总行 ───────────────────────────────────────────
export const RearrangeAgentRowSchema = z.object({
userId: z.string(),
name: z.string().nullable(),
/// 此刻在手多少条
inHand: z.number().int(),
/// 其中已超期多少条
overdue: z.number().int(),
});
export type RearrangeAgentRow = z.infer<typeof RearrangeAgentRowSchema>;
// ── 草稿(只读快照)────────────────────────────────────────────
export const RearrangeDraftSchema = z.object({
/**
* 幂等键 —— **服务端在出草稿时铸造**,提交时原样回传。
* ⛔ 不由前端或模型生成:挡不住重连与重试,而"又调整了一次"没有天然自然键可去重。
*/
operationId: z.string(),
clinicId: z.string(),
/// 全景(全队在手)还是局部(只这几位客服)
scope: z.enum(['all', 'agents']),
/// scope='agents' 时主管点名的那几位(用于界面回显"你在调谁")
scopedAgentUserIds: z.array(z.string()),
agents: z.array(RearrangeAgentRowSchema),
items: z.array(RearrangeItemSchema),
/// 这一版一共多少条 / 其中超期多少条
total: z.number().int(),
overdueTotal: z.number().int(),
/**
* 被硬上限截断了多少条 —— **> 0 时界面必须说出来**。
* ⛔ 不许静默截断:少了的那些主管永远不知道(no silent cap)。
*/
truncated: z.number().int(),
/// 换算尺:每人每天打几通(界面算"这些约几天的量"用,与确认单同一个常量)
dailyCalls: z.number().int(),
});
export type RearrangeDraft = z.infer<typeof RearrangeDraftSchema>;
// ── 提交 ─────────────────────────────────────────────────────
export const RearrangeSubmitRequestSchema = z.object({
operationId: z.string().min(8).max(128),
clinicId: z.string().min(1),
/// 改派:这条换给谁
moves: z.array(z.object({ planId: z.string().uuid(), toUserId: z.string().min(1) })).default([]),
/// 改时限:相对天数(与分配同口径,服务端按 host 时区转成当地日末)
expiry: z
.array(
z.object({ planId: z.string().uuid(), expiresInDays: z.number().int().positive().max(90) }),
)
.default([]),
/// 移出:收回池子。⛔ **不带原因** —— 这是主管侧的调度,不是客服「我不接这单」
removes: z.array(z.object({ planId: z.string().uuid() })).default([]),
});
export type RearrangeSubmitRequest = z.infer<typeof RearrangeSubmitRequestSchema>;
/**
* 一条没落上的原因。
*
* 🔴 调整**逐条落、落不上的如实回报**,⛔ 不照抄分配那套「数量对不上整体拒绝」:
* 分配那边整批还没发出去,重来一次代价小;而调整时主管可能已经调了二十条,
* 因为客服刚好打完一条就全部作废 —— 那个体验一次就废掉这个功能。
*/
export const RearrangeSkipReasonSchema = z.enum([
/// 已经不在人手上了(客服在这期间退回 / 被收回)
'not_in_hand',
/// 客服已经处理完了(completed / abandoned)
'already_done',
/// 被引擎重算取代(该患者的召回信号变了)
'superseded',
/// 这期间被改派给了别人 —— 主管手里那份草稿已经过时
'assignee_changed',
/// 改派的目标就是当前这位,什么都不用做
'same_assignee',
/// 不在本诊所 / 不在你的数据范围内
'out_of_scope',
]);
export type RearrangeSkipReason = z.infer<typeof RearrangeSkipReasonSchema>;
export const REARRANGE_SKIP_REASON_ZH: Record<RearrangeSkipReason, string> = {
not_in_hand: '这条已经不在客服手上了',
already_done: '客服已经处理完了',
superseded: '这条已被重算取代',
assignee_changed: '这期间被改派给了别人',
same_assignee: '本来就在这位客服手上',
out_of_scope: '不在你的数据范围内',
};
export const RearrangeSubmitResponseSchema = z.object({
operationId: z.string(),
/// 同一个 operationId 重复提交 → true,一个字都没写
duplicate: z.boolean(),
moved: z.number().int(),
expiryChanged: z.number().int(),
removed: z.number().int(),
skipped: z.array(
z.object({
planId: z.string(),
op: z.enum(['move', 'expiry', 'remove']),
reason: RearrangeSkipReasonSchema,
}),
),
/// 成品句子 —— 界面与助手都直接念它(口径收口在服务端,⛔ 两边别各拼一份)
note: z.string(),
});
export type RearrangeSubmitResponse = z.infer<typeof RearrangeSubmitResponseSchema>;
// =============================================================
// 助手对调整单的语义编辑指令(`edit_rearrange_sheet` 走侧信道下发,界面执行)
// =============================================================
/**
* ⛔ 与确认单的 `SheetEditOp` **刻意不共用**。
*
* 两边看着像,取值域不同、且差别正是"调整是什么"的定义:
* · 没有 `set_benefit` —— 这一版的人来自若干不同批次,福利挂在批次上;
* · 没有 `pending` / `batch` 分组 —— 调整里不存在"待分配",也没有"整批";
* · 多了 `overdue` 分组 —— 「把超期的都转走」是调整最常说的一句;
* · `to.mode` 只有 `balance` —— 「各自归专属客服」属于分配那一版的落人规则,
* 在调整里没有意义(人已经在手上了,专属关系也不由 PAC 决定)。
* 共用一套枚举必然要在两边各加一堆"这个值在这条路上不合法"的判断。
*/
export const RearrangeSelectSchema = z.object({
/// patients=点名的患者;agent=某位客服手上全部;overdue=这一版里已超期的;all=这一版全部
group: z.enum(['patients', 'agent', 'overdue', 'all']),
/// 患者姓名或病历号(group='patients' 必填)
patients: z.array(z.string()).optional(),
/// 客服姓名(group='agent' 必填)
agent: z.string().optional(),
});
export type RearrangeSelect = z.infer<typeof RearrangeSelectSchema>;
export const RearrangeEditOpSchema = z
.object({
select: RearrangeSelectSchema,
action: z.enum(['move', 'set_expiry', 'remove']),
/// 换给谁(action='move' 必填)。⛔ 只有 balance:见上面为什么没有 owner
to: z
.object({ mode: z.literal('balance'), agents: z.array(z.string()).optional() })
.optional(),
/// 新时限天数(action='set_expiry' 必填),从今天起算
days: z.number().int().positive().max(90).optional(),
})
/**
* ⚠️ 校验放 schema 而不是执行处:这些指令由**模型**生成,少一格是常态。
* 在这里拦住,模型能拿到一句说得清的错并重试;放到界面里拦,主管看到的是
* "指令执行了但什么都没变"。
*/
.superRefine((op, ctx) => {
if (op.action === 'move' && !op.to) {
ctx.addIssue({ code: 'custom', message: "action:'move' 必须给 to(谁手上少先给谁)" });
}
if (op.action === 'set_expiry' && op.days == null) {
ctx.addIssue({ code: 'custom', message: "action:'set_expiry' 必须给 days" });
}
if (op.select.group === 'patients' && !op.select.patients?.length) {
ctx.addIssue({ code: 'custom', message: "group:'patients' 必须给患者姓名或病历号" });
}
if (op.select.group === 'agent' && !op.select.agent) {
ctx.addIssue({ code: 'custom', message: "group:'agent' 必须给客服姓名" });
}
});
export type RearrangeEditOp = z.infer<typeof RearrangeEditOpSchema>;
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