Commit b7132fae by luoqi

merge: test → main —— 调整在手 + 三个分配修正上生产

带上去的:
· 调整各客服在手的单(换人 / 改时限 / 收回)—— 主管终于能动已经分下去的单
· 批次表新增「回收」列,「退回」改叫「客服退回」
· 团队状态六列全部按诊所收窄(别家诊所的负荷串进本诊所工作台)
· 提案封顶到单批硬上限 500(23 人的诊所按默认值必然出一张确认不了的单)
· 准星最边上那一列/那一行被裁

️ 需要一次迁移:plan_event_logs 加 operation_id(可空 + 索引)。
   生产实测该表 2,298 行 / 1MB,普通 CREATE INDEX 毫秒级,不用 CONCURRENTLY。
 不需要重摄、不需要重算 —— 本次没动召回算法,也没动画像。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parents bbc530b9 504b0e19
Pipeline #3584 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 { ...@@ -1651,6 +1651,24 @@ model PlanEventLog {
*/ */
assignmentId String? @map("assignment_id") @db.Uuid 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 的文字说明) /// 事件专属细节(不立柱的部分, feedback 的文字说明)
/// ⚠️ 只放"查询不按它过滤"的内容;要按它筛就该立柱。 /// ⚠️ 只放"查询不按它过滤"的内容;要按它筛就该立柱。
details Json? details Json?
...@@ -1669,6 +1687,8 @@ model PlanEventLog { ...@@ -1669,6 +1687,8 @@ model PlanEventLog {
@@index([hostId, tenantId, event, createdAt]) @@index([hostId, tenantId, event, createdAt])
/// 批次报表主查询:某批分了几条 / 退了几条 / 到期几条(event 一起进索引,聚合不回表) /// 批次报表主查询:某批分了几条 / 退了几条 / 到期几条(event 一起进索引,聚合不回表)
@@index([assignmentId, event]) @@index([assignmentId, event])
/// 一次重排动了哪些单(审计 / 幂等 / 将来撤销重排)
@@index([operationId])
@@map("plan_event_logs") @@map("plan_event_logs")
} }
......
...@@ -123,7 +123,7 @@ import { Permission } from '@pac/types'; ...@@ -123,7 +123,7 @@ import { Permission } from '@pac/types';
*/ */
// ⚠️ `-b` 是 2026-08-17 那次「纪律挂在组上」的实验,已 revert(`git show 82f357e`)—— // ⚠️ `-b` 是 2026-08-17 那次「纪律挂在组上」的实验,已 revert(`git show 82f357e`)——
// ⛔ 别复用它:库里有那一版的 `agent_invocations` 行,复用等于两份不同正文同名。 // ⛔ 别复用它:库里有那一版的 `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 = `## 他现在做的这件事:把一批人分给客服 ...@@ -318,7 +318,7 @@ const ASSIGNMENT_SCENE = `## 他现在做的这件事:把一批人分给客服
### 确认之后 ### 确认之后
这批的人员和时限改不了,要改只能**撤销重分**。你对已经分下去的这批能做的只有两件:撤销、补挂福利 —— 「已撤销」只能出现在撤销工具真的返回之后。 这批的人员和时限**不在这张单上改了** —— 单子已经在客服手上。要动它们,走**调整各客服在手的单**那条路:把他手上的摆出来,换人、改时限、移出。说成整批撤销重分,他会把已经发出去的一批全收回来,而他要的往往只是挪一两个人。在这张单上还能做的只有补挂福利;整批收回才是撤销 —— 「已撤销」只能出现在撤销工具真的返回之后。
福利挂在整批上,会进这批人的话术,补挂只影响此后生成的那些。他说什么就原样写进去,⛔ 别替他加条件、期限或承诺。⛔ 「这批还没带福利」**不用你再提一遍** —— 确认之前你已经问过一次了;他回头问起,或者要补,照办就是。 福利挂在整批上,会进这批人的话术,补挂只影响此后生成的那些。他说什么就原样写进去,⛔ 别替他加条件、期限或承诺。⛔ 「这批还没带福利」**不用你再提一遍** —— 确认之前你已经问过一次了;他回头问起,或者要补,照办就是。
......
...@@ -15,7 +15,8 @@ import { DictationGateway } from './dictation.gateway'; ...@@ -15,7 +15,8 @@ import { DictationGateway } from './dictation.gateway';
* 复用 AiModule 的 AiProviderService(provider 可切换);McpClientService 真连 PAC MCP 端点。 * 复用 AiModule 的 AiProviderService(provider 可切换);McpClientService 真连 PAC MCP 端点。
*/ */
@Module({ @Module({
// PlanModule:本地工具 propose_assignment 用 AssignmentProposalService 取数 // PlanModule:本地工具 propose_assignment 用 AssignmentProposalService 取数;
// propose_rearrange 用 PlanRearrangeService + AgentRosterService(姓名→id)
imports: [AiModule, AuthModule, PlanModule], imports: [AiModule, AuthModule, PlanModule],
// 🔬 AssistantLabController = 裸 agent 试验台,⛔ 与产品助手无代码关联(见该文件头) // 🔬 AssistantLabController = 裸 agent 试验台,⛔ 与产品助手无代码关联(见该文件头)
controllers: [AssistantController, AssistantLabController], controllers: [AssistantController, AssistantLabController],
......
...@@ -24,7 +24,8 @@ export const TRACKING_GUIDE = [ ...@@ -24,7 +24,8 @@ export const TRACKING_GUIDE = [
'「处理」不等于「成功」:progress 是处理率,只说「这单动过了」,不说「谈成了」。', '「处理」不等于「成功」:progress 是处理率,只说「这单动过了」,不说「谈成了」。',
'「已出池·引擎判定需求已了」是引擎按客观事实判定召回需求没了,⛔ 不是「转化成功/成交」,⛔ 不要拿这些数算转化率。', '「已出池·引擎判定需求已了」是引擎按客观事实判定召回需求没了,⛔ 不是「转化成功/成交」,⛔ 不要拿这些数算转化率。',
'报处理率必须带上「本批已跑天数」:跑了三个月的批次天然比跑了三天的好看,不带年龄直接比是耍流氓。', '报处理率必须带上「本批已跑天数」:跑了三个月的批次天然比跑了三天的好看,不带年龄直接比是耍流氓。',
'退回率永远给两个数:「退回 5 / 已处置 40 = 12.5%(另有 60 条未动)」——「没人动」和「动了但退回」是完全不同的信号,只报一个百分比会把前者藏起来。', '退回率永远给两个数:「客服退回 5 / 已处置 40 = 12.5%(另有 60 条未动)」——「没人动」和「动了但退回」是完全不同的信号,只报一个百分比会把前者藏起来。',
'released(客服退回)与 recalled(主管回收)是两件事:前者是客服说这单我不接,带原因、进退回原因分布;后者是主管在调整各客服在手的单时把活收回来,没有原因。把 recalled 算进退回率,他就是在拿自己的动作当调下一批的依据。',
'分母小于 50 时直接说「样本量不足」,⛔ 不要输出百分比、⛔ 不要画图。', '分母小于 50 时直接说「样本量不足」,⛔ 不要输出百分比、⛔ 不要画图。',
'outcomes(通话成效)与 releaseReasons(退回原因)是两件不同的事,⛔ 绝不能混说:releaseReasons =「这单不该我做」,客服没打就还回去了,是**分配**问题;outcomes =「打了,结果这样」,客服做了事,是**召回效果**问题。说反了主管会去改错的东西。', 'outcomes(通话成效)与 releaseReasons(退回原因)是两件不同的事,⛔ 绝不能混说:releaseReasons =「这单不该我做」,客服没打就还回去了,是**分配**问题;outcomes =「打了,结果这样」,客服做了事,是**召回效果**问题。说反了主管会去改错的东西。',
'outcomes.noOutcome(一次结果都没有)必须单独报出来,⛔ 不许算进「不成功」——那不是效果差,是根本没做/没记。', 'outcomes.noOutcome(一次结果都没有)必须单独报出来,⛔ 不许算进「不成功」——那不是效果差,是根本没做/没记。',
......
...@@ -497,7 +497,7 @@ export class McpServerFactory { ...@@ -497,7 +497,7 @@ export class McpServerFactory {
// ⚠️ 原文第一句是「**我**分过的批次」——「我」指谁在这句里是含糊的 // ⚠️ 原文第一句是「**我**分过的批次」——「我」指谁在这句里是含糊的
// (说话人是工具描述,读的人是模型),而下面 `mine` 恰恰就是"只看自己发起的"。 // (说话人是工具描述,读的人是模型),而下面 `mine` 恰恰就是"只看自己发起的"。
// 一含糊就会连带 `mine` 的默认值一起被猜错。 // 一含糊就会连带 `mine` 的默认值一起被猜错。
'已经分下去的批次列表 + 每批汇总(分了多少 / 已处理 / 还在手 / 已退回 / 涉及几个客服)。' + '已经分下去的批次列表 + 每批汇总(分了多少 / 已处理 / 还在手 / 客服退回 / 主管回收 / 涉及几个客服)。' +
'\n他问哪批出了问题、哪批还没动静时用它。要看某批细节再用 get_assignment_detail。' + '\n他问哪批出了问题、哪批还没动静时用它。要看某批细节再用 get_assignment_detail。' +
'\n⚠️ 返回里带 `_guide`,读数前先看它。', '\n⚠️ 返回里带 `_guide`,读数前先看它。',
inputSchema: { inputSchema: {
......
...@@ -192,6 +192,17 @@ export function modelFacts(p: AssignmentProposal): Record<string, unknown> { ...@@ -192,6 +192,17 @@ export function modelFacts(p: AssignmentProposal): Record<string, unknown> {
}, },
'怎么派的': { '怎么派的': {
'已排好': p.placed, '已排好': p.placed,
/**
* 🔴 **点确认真正会落下去几条** —— 2026-08-20 走查加。
*
* 在此之前模型手里只有「已排好 200」和引导节点上的「300 人要您定」,
* 「确认会写几条」得它自己减一次。实测它没减:收尾那句写的是
* 「确认之后这 **500** 人的名单和 3 天时限就定死了」—— 而待分配那 300 条
* 主管不处理就**不落库**,确认按钮上写的也是 200。
* ⇒ 要它说准的数就**直接给**,⛔ 别让它做算术(同「本批人数取小的那个」那次)。
* ⚠️ 与卡片上确认按钮里的数**同源同值**(都是 placed),⛔ 别让两处对不上。
*/
'点确认会落下去的条数': p.placed,
'涉及客服数': agents, '涉及客服数': agents,
'分给自己专属客服的': dedicated, '分给自己专属客服的': dedicated,
'给手上最空的人的': spread, '给手上最空的人的': spread,
......
...@@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common'; ...@@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { import {
ASSIGNMENT_EXPIRES_DAYS_DEFAULT, ASSIGNMENT_EXPIRES_DAYS_DEFAULT,
ASSIGNMENT_ITEMS_HARD_LIMIT,
DAILY_CALLS_PER_AGENT, DAILY_CALLS_PER_AGENT,
AssignStrategy, AssignStrategy,
type AgentInfo, type AgentInfo,
...@@ -296,13 +297,24 @@ export class AssignmentProposalService { ...@@ -296,13 +297,24 @@ export class AssignmentProposalService {
`提案:条件=${JSON.stringify(criteria)} 候选=${candidateTotal} 基数=${batchSize}`, `提案:条件=${JSON.stringify(criteria)} 候选=${candidateTotal} 基数=${batchSize}`,
); );
/** /**
* 本批**实际**分多少 = `min(基数 N, 候选总数)`。 * 本批**实际**分多少 = `min(基数 N, 候选总数, 单批硬上限)`。
* *
* ⚠️ 候选不够时压低的是 `target`,⛔ **不回写基数** —— 那是这一批的偶然事实。 * ⚠️ 候选不够时压低的是 `target`,⛔ **不回写基数** —— 那是这一批的偶然事实。
* 回写的话,主管点一次 44 人的小格子,以后所有批次就永远是 44 人, * 回写的话,主管点一次 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 实际不存在,不能当保障用) // 同患者只留一条(schema 注释承诺的 partial UNIQUE 实际不存在,不能当保障用)
const seenPatient = new Set<string>(); 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) {}
...@@ -58,6 +58,7 @@ export function recordPlanEventsBulk( ...@@ -58,6 +58,7 @@ export function recordPlanEventsBulk(
heldSeconds: input.heldSeconds ?? null, heldSeconds: input.heldSeconds ?? null,
reason: input.reason ?? null, reason: input.reason ?? null,
assignmentId: input.assignmentId ?? null, assignmentId: input.assignmentId ?? null,
operationId: input.operationId ?? null,
details: input.details ?? undefined, details: input.details ?? undefined,
})), })),
}); });
...@@ -89,6 +90,14 @@ export interface PlanEventInput { ...@@ -89,6 +90,14 @@ export interface PlanEventInput {
* 可能是上一批留下的陈迹(退回/到期都刻意不清它),顶上去等于给旧批次凭空加人。 * 可能是上一批留下的陈迹(退回/到期都刻意不清它),顶上去等于给旧批次凭空加人。
*/ */
assignmentId?: string | null; assignmentId?: string | null;
/**
* 这条事件属于**哪一次主管重排**(见 schema 里 `operation_id` 的说明)。
*
* ⚠️ 只有重排三件事该传:transfer / expiry_changed / rearrange_remove。
* ⚠️ 与 `assignmentId` **不互斥**:重排不改归属批次,所以重排事件两列都有值 ——
* assignmentId 是这条单**原来那一批**,operationId 是这次重排。
*/
operationId?: string | null;
/** 事件专属细节(不按它查询的内容,如反馈文字) */ /** 事件专属细节(不按它查询的内容,如反馈文字) */
details?: Prisma.InputJsonObject | null; details?: Prisma.InputJsonObject | null;
} }
...@@ -106,6 +115,7 @@ export function recordPlanEvent(tx: PlanEventLogWriter, input: PlanEventInput): ...@@ -106,6 +115,7 @@ export function recordPlanEvent(tx: PlanEventLogWriter, input: PlanEventInput):
heldSeconds: input.heldSeconds ?? null, heldSeconds: input.heldSeconds ?? null,
reason: input.reason ?? null, reason: input.reason ?? null,
assignmentId: input.assignmentId ?? null, assignmentId: input.assignmentId ?? null,
operationId: input.operationId ?? null,
// undefined 才让 Prisma 落 NULL;传 null 会被当成 JSON null 值 // undefined 才让 Prisma 落 NULL;传 null 会被当成 JSON null 值
details: input.details ?? undefined, details: input.details ?? undefined,
}, },
......
...@@ -2,6 +2,8 @@ import { Module } from '@nestjs/common'; ...@@ -2,6 +2,8 @@ import { Module } from '@nestjs/common';
import { PlanController } from './plan.controller'; import { PlanController } from './plan.controller';
import { PlanService } from './plan.service'; import { PlanService } from './plan.service';
import { AssignmentController } from './assignment.controller'; import { AssignmentController } from './assignment.controller';
import { RearrangeController } from './rearrange.controller';
import { PlanRearrangeService } from './plan-rearrange.service';
import { PlanAssignmentService } from './plan-assignment.service'; import { PlanAssignmentService } from './plan-assignment.service';
import { AgentRosterService } from './agent-roster.service'; import { AgentRosterService } from './agent-roster.service';
import { AssignmentProposalService } from './assignment-proposal.service'; import { AssignmentProposalService } from './assignment-proposal.service';
...@@ -50,10 +52,14 @@ import { RecallDebugService } from './recall-debug/recall-debug.service'; ...@@ -50,10 +52,14 @@ import { RecallDebugService } from './recall-debug/recall-debug.service';
// `GET /plans/:id`(id='assignments'),然后去查一个不存在的 plan, // `GET /plans/:id`(id='assignments'),然后去查一个不存在的 plan,
// 报的还是 Prisma 的 uuid 解析错(90000),完全看不出是路由撞了。 // 报的还是 Prisma 的 uuid 解析错(90000),完全看不出是路由撞了。
// 实测踩过一次。同类先例见 plan.controller:80 的 `doctors` 那条注释。 // 实测踩过一次。同类先例见 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: [ providers: [
PlanService, PlanService,
PlanAssignmentService, PlanAssignmentService,
PlanRearrangeService,
AgentRosterService, AgentRosterService,
AssignmentProposalService, AssignmentProposalService,
CohortAttributesService, CohortAttributesService,
...@@ -67,6 +73,6 @@ import { RecallDebugService } from './recall-debug/recall-debug.service'; ...@@ -67,6 +73,6 @@ import { RecallDebugService } from './recall-debug/recall-debug.service';
PlanLabelService, PlanLabelService,
], ],
// MCP 的主管工具直接用这两个 service(条件注册,见 mcp-server.factory) // 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 {} 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('确认后没配福利 → 提醒补挂,但不许编效果', () => { ...@@ -457,11 +457,27 @@ describe('确认后没配福利 → 提醒补挂,但不许编效果', () => {
* 锁整句等于把"这条规矩必须写成一句话"也锁了进去。 * 锁整句等于把"这条规矩必须写成一句话"也锁了进去。
* ⇒ 锁的是**这一步在、且三条不变量都在**。 * ⇒ 锁的是**这一步在、且三条不变量都在**。
*/ */
test('提示词里要说清"确认后仍可补挂、但人员时限改不了"', () => { test('提示词里要说清"确认后仍可补挂、人员时限走调整那条路"', () => {
expect(PROMPTS).toMatch(/### 确认之后/); expect(PROMPTS).toMatch(/### 确认之后/);
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('待分配的人 —— 对话指令要能指到', () => { ...@@ -677,10 +693,20 @@ describe('待分配的人 —— 对话指令要能指到', () => {
}); });
test('⭐ 姓名可点击复制,且⛔ 不能吃掉整行的拖拽', () => { test('⭐ 姓名可点击复制,且⛔ 不能吃掉整行的拖拽', () => {
expect(SHEET).toMatch(/function CopyableName/); /**
expect(SHEET).toMatch(/draggable=\{false\}/); // 不加会被当成拖文本 * 🔴 2026-08-20 `CopyableName` / `DaySelect` 挪进了 `sheet-primitives.tsx` ——
expect(SHEET).toMatch(/e\.stopPropagation\(\)/); * 重排单要跟确认单**手感一致**(产品:「不用二次学习成本」),而这两个原件身上
// 两处行都要用上:客服组里的 + 待分配里的 * 那点来之不易的行为(懒挂 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); expect((SHEET.match(/<CopyableName /g) ?? []).length).toBeGreaterThanOrEqual(3);
}); });
......
...@@ -162,6 +162,34 @@ describe('PlanEventType / PLAN_EVENT_META — 扩展性约束', () => { ...@@ -162,6 +162,34 @@ describe('PlanEventType / PLAN_EVENT_META — 扩展性约束', () => {
expect(PLAN_EVENT_META.release.holdsPatient).toBe(false); expect(PLAN_EVENT_META.release.holdsPatient).toBe(false);
expect(PLAN_EVENT_META.auto_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 前算', () => { describe('computeHeldSeconds — 必须在清空 assignedAt 前算', () => {
......
...@@ -350,9 +350,12 @@ body { ...@@ -350,9 +350,12 @@ body {
/* 扣扳机的那一下:准星收紧,紧接着交给粒子(pacStreamFly) */ /* 扣扳机的那一下:准星收紧,紧接着交给粒子(pacStreamFly) */
.pac-reticle:active::after { inset: 3.5px; animation: none; transition: inset 0.08s ease-out; } .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 { @keyframes pacReticleLock {
from { inset: -2px; opacity: 0; } from { inset: 0; opacity: 0; }
to { inset: 2px; opacity: 1; } to { inset: 2px; opacity: 1; }
} }
@keyframes pacReticleBreathe { @keyframes pacReticleBreathe {
0%, 100% { inset: 2px; opacity: 1; } 0%, 100% { inset: 2px; opacity: 1; }
......
...@@ -3,7 +3,6 @@ ...@@ -3,7 +3,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Check, ChevronDown, ChevronRight, GripVertical, Loader2, Undo2, Users, X } from 'lucide-react'; import { Check, ChevronDown, ChevronRight, GripVertical, Loader2, Undo2, Users, X } from 'lucide-react';
import { import {
ASSIGNMENT_EXPIRES_DAYS_PRESETS,
potentialTreatmentItemName, potentialTreatmentItemName,
AssignStrategy, AssignStrategy,
REVOKE_WINDOW_MINUTES, REVOKE_WINDOW_MINUTES,
...@@ -27,13 +26,6 @@ import { ConfirmSheetSignals } from './confirm-sheet-signals'; ...@@ -27,13 +26,6 @@ import { ConfirmSheetSignals } from './confirm-sheet-signals';
import { BenefitPopover } from './benefit-popover'; import { BenefitPopover } from './benefit-popover';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
AlertDialogCancel, AlertDialogCancel,
...@@ -43,6 +35,7 @@ import { ...@@ -43,6 +35,7 @@ import {
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
} from '@/components/ui/alert-dialog'; } from '@/components/ui/alert-dialog';
import { CopyableName, DaySelect } from './sheet-primitives';
import { cn, formatGender } from '@/lib/utils'; import { cn, formatGender } from '@/lib/utils';
/** /**
...@@ -97,40 +90,6 @@ const OP_ZH: Record<SheetEditOp['action'], string> = { ...@@ -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 渲染器。 * 服务端成品句子里的 `**加粗**` —— 只认这一个标记,⛔ 不引 Markdown 渲染器。
* *
* ⚠️ 这些句子是**服务端拼好的**(basisNote / refillNote),里面混着患者和客服姓名。 * ⚠️ 这些句子是**服务端拼好的**(basisNote / refillNote),里面混着患者和客服姓名。
...@@ -161,80 +120,6 @@ function carryOverrides(fromSheet: Record<string, AgentOverride>): Record<string ...@@ -161,80 +120,6 @@ function carryOverrides(fromSheet: Record<string, AgentOverride>): Record<string
return out; 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({ export function AssignmentConfirmSheet({
requestId, requestId,
...@@ -753,7 +638,7 @@ export function AssignmentConfirmSheet({ ...@@ -753,7 +638,7 @@ export function AssignmentConfirmSheet({
}), }),
); );
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : '确认失败'); setError(describeConfirmError(e));
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
...@@ -806,7 +691,9 @@ export function AssignmentConfirmSheet({ ...@@ -806,7 +691,9 @@ export function AssignmentConfirmSheet({
const notes: string[] = []; const notes: string[] = [];
// ⭐ 已确认 = 卡片进入只读态。此时唯一还能改的是**福利**(它是前向的,只影响之后生成的话术), // ⭐ 已确认 = 卡片进入只读态。此时唯一还能改的是**福利**(它是前向的,只影响之后生成的话术),
// 而且必须走接口真写库;人员 / 时限那些动的是已经发到客服手上的单,只能走撤销重分。 // 而且必须走接口真写库;人员 / 时限那些动的是已经发到客服手上的单 ——
// ⚠️ 2026-08-20 起它们**不是没辙了**:走「调整各客服在手的单」那条路(独立的一张单)。
// ⛔ 别再把话说成「只能撤销重分」,那句从这天起就是错的。
if (assignmentId) { if (assignmentId) {
for (const { ops } of fresh) { for (const { ops } of fresh) {
for (const op of ops) { for (const op of ops) {
...@@ -828,8 +715,9 @@ export function AssignmentConfirmSheet({ ...@@ -828,8 +715,9 @@ export function AssignmentConfirmSheet({
continue; continue;
} }
notes.push( notes.push(
`这批**已经确认分配**,${OP_ZH[op.action] ?? op.action}」改不了 —— ` + `这批**已经确认分配**,${OP_ZH[op.action] ?? op.action}」在这张单上改不了 —— ` +
`单子已经在客服手上。要调整请撤销这批重新分( 30 分钟),或让客服自己退回`, `单子已经在客服手上 —— 让我把在手的摆出来调(换人、改时限、移出),` +
`或者撤销整批重新分`,
); );
} }
} }
...@@ -2002,3 +1890,26 @@ export function AssignmentConfirmSheet({ ...@@ -2002,3 +1890,26 @@ export function AssignmentConfirmSheet({
</Card> </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 : '确认失败';
}
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import type { AssignmentProposal } from '@pac/types'; import type { AssignmentProposal, RearrangeDraft } from '@pac/types';
import { import {
applyIncomingRearrange,
applyIncomingSheet, applyIncomingSheet,
findActiveRearrange,
findActiveSheet, findActiveSheet,
intentToPrompt, intentToPrompt,
intentToSheetOps, intentToSheetOps,
...@@ -291,7 +293,7 @@ describe('stripToolEcho · 抄出来的工具记录', () => { ...@@ -291,7 +293,7 @@ describe('stripToolEcho · 抄出来的工具记录', () => {
/** /**
* 🔴 位置标记不进历史 —— 2026-08-13 实测的回归。 * 🔴 位置标记不进历史 —— 2026-08-13 实测的回归。
* *
* 主管点「只选…」重排,第二轮只跑了 1 步:`propose_assignment` 调了, * 主管点「只选…」调整,第二轮只跑了 1 步:`propose_assignment` 调了,
* `show_sheet` / `show_guidance` 一个都没调 —— 卡片掉到末尾、按钮全堆在卡上。 * `show_sheet` / `show_guidance` 一个都没调 —— 卡片掉到末尾、按钮全堆在卡上。
* 原因之一是上一轮的记录还在上下文里,「已插入选项按钮」被读成"已经摆过了"。 * 原因之一是上一轮的记录还在上下文里,「已插入选项按钮」被读成"已经摆过了"。
* ⚠️ 这条锁的是**别让位置标记回传**;⛔ 真做了事的工具必须留(否则模型会重复调)。 * ⚠️ 这条锁的是**别让位置标记回传**;⛔ 真做了事的工具必须留(否则模型会重复调)。
...@@ -377,3 +379,133 @@ describe('历史回传 · 结构化(唯一路径)', () => { ...@@ -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 —— 助手消息流的**数据模型 + 纯函数操作**。 * chat-blocks —— 助手消息流的**数据模型 + 纯函数操作**。
...@@ -50,7 +55,7 @@ export interface Artifact { ...@@ -50,7 +55,7 @@ export interface Artifact {
* **作废那一版整条不再显示**(见 isDiscardedDraft)。它一个字都没落库, * **作废那一版整条不再显示**(见 isDiscardedDraft)。它一个字都没落库,
* 人数、分给谁全是废数,却在对话里占着大半屏 —— 往回翻读到的是一份不存在的方案。 * 人数、分给谁全是废数,却在对话里占着大半屏 —— 往回翻读到的是一份不存在的方案。
* 「他要能看见自己出过几版」由**主管自己那两句话**保住: * 「他要能看见自己出过几版」由**主管自己那两句话**保住:
* 「出一份分配方案」→「每人每天按 20 通算,重新排一版」,一眼看得出重排过。 * 「出一份分配方案」→「每人每天按 20 通算,重新排一版」,一眼看得出调整过。
* ⚠️ `confirmed` / `cancelled` 是**终态**,⛔ 不许被后续的作废扫描回退。 * ⚠️ `confirmed` / `cancelled` 是**终态**,⛔ 不许被后续的作废扫描回退。
*/ */
export type DraftState = 'active' | 'superseded' | 'confirmed' | 'cancelled'; export type DraftState = 'active' | 'superseded' | 'confirmed' | 'cancelled';
...@@ -85,7 +90,7 @@ export type Block = ...@@ -85,7 +90,7 @@ export type Block =
* ⭐ 模型写完「389 人卡在待分配…」那一条,紧接着调一次工具 → 这个块就插在 * ⭐ 模型写完「389 人卡在待分配…」那一条,紧接着调一次工具 → 这个块就插在
* 消息流的**此刻这个位置**,按钮正好落在它的解释下面。 * 消息流的**此刻这个位置**,按钮正好落在它的解释下面。
* ⚠️ 和 artifact 同一个模式:工具调用即"在流里插一个界面块"。 * ⚠️ 和 artifact 同一个模式:工具调用即"在流里插一个界面块"。
* ⚠️ 只存 `signalKey`,⛔ 不存整个 signal —— 确认单可能被重排替换(refill), * ⚠️ 只存 `signalKey`,⛔ 不存整个 signal —— 确认单可能被调整替换(refill),
* 届时按 key 从**当前**那张单上取,取不到就不渲染(那条已经不存在了)。 * 届时按 key 从**当前**那张单上取,取不到就不渲染(那条已经不存在了)。
* 🔴 漏调不丢东西:没被插进正文的 action 节点仍旧显示在确认单卡片上(卡片按 * 🔴 漏调不丢东西:没被插进正文的 action 节点仍旧显示在确认单卡片上(卡片按
* 已插入的 key 去重)。⇒ 这个块优化的是**位置**,⛔ 不是可见性。 * 已插入的 key 去重)。⇒ 这个块优化的是**位置**,⛔ 不是可见性。
...@@ -124,6 +129,31 @@ export type Block = ...@@ -124,6 +129,31 @@ export type Block =
* ⚠️ 用队列而不是"最新一条":主管连着说两句时,两批指令都要生效。 * ⚠️ 用队列而不是"最新一条":主管连着说两句时,两批指令都要生效。
*/ */
edits?: { seq: number; ops: SheetEditOp[] }[]; 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 { export interface ChatMessage {
...@@ -185,7 +215,7 @@ export function upsertArtifact( ...@@ -185,7 +215,7 @@ export function upsertArtifact(
* 新草稿到达 —— upsert 它,并把**此前所有 `active` 的当场作废**。 * 新草稿到达 —— upsert 它,并把**此前所有 `active` 的当场作废**。
* *
* 🔴 必须**跨消息**扫:上一版几乎总在**上一轮**的消息里 * 🔴 必须**跨消息**扫:上一版几乎总在**上一轮**的消息里
* (主管看着它才说「重排一版」的)。只扫当前消息永远扫不到,而且不会报错。 * (主管看着它才说「调整一版」的)。只扫当前消息永远扫不到,而且不会报错。
* ⚠️ 只作废 `active`:`confirmed` / `cancelled` 是终态,⛔ 不许回退。 * ⚠️ 只作废 `active`:`confirmed` / `cancelled` 是终态,⛔ 不许回退。
* ⚠️ 同一 `requestId` 重推时清空 `edits` —— 旧的编辑指令对新名单没有意义。 * ⚠️ 同一 `requestId` 重推时清空 `edits` —— 旧的编辑指令对新名单没有意义。
* *
...@@ -318,7 +348,7 @@ export function intentToPrompt( ...@@ -318,7 +348,7 @@ export function intentToPrompt(
} }
/** /**
* 「按别的条件选」—— ⛔ 不改任何东西,只是把「先看看还有哪些口子」做成一个按钮。 * 「按别的条件选」—— ⛔ 不改任何东西,只是把「先看看还有哪些口子」做成一个按钮。
* ⚠️ 措辞里要写明**别急着重排**:不然模型会顺手出一版新确认单, * ⚠️ 措辞里要写明**别急着调整**:不然模型会顺手出一版新确认单,
* 而主管这一下问的是"有哪些选择",不是"照某个条件重来"。 * 而主管这一下问的是"有哪些选择",不是"照某个条件重来"。
*/ */
case 'cohort.attrs_ask': case 'cohort.attrs_ask':
...@@ -388,7 +418,7 @@ export function stripToolEcho(text: string): string { ...@@ -388,7 +418,7 @@ export function stripToolEcho(text: string): string {
* ⚠️ 判据是「**只剩**作废的」,⛔ 不是「有作废的」:同一轮里模型可能出过两版 * ⚠️ 判据是「**只剩**作废的」,⛔ 不是「有作废的」:同一轮里模型可能出过两版
* (`applyIncomingSheet` 把旧的**就地**扫成作废、新的追加到同一条消息)。 * (`applyIncomingSheet` 把旧的**就地**扫成作废、新的追加到同一条消息)。
* 那时这条消息里还有活着的那版,整条藏掉会把有效的一起抹了。 * 那时这条消息里还有活着的那版,整条藏掉会把有效的一起抹了。
* ⚠️ 只认 `superseded`(重排顶掉的、一条都没落库)。 * ⚠️ 只认 `superseded`(调整顶掉的、一条都没落库)。
* ⛔ `cancelled`(已确认后又撤销)与 `confirmed` 都是真发生过的事,不能藏也不能清。 * ⛔ `cancelled`(已确认后又撤销)与 `confirmed` 都是真发生过的事,不能藏也不能清。
*/ */
export function isDiscardedDraft(m: ChatMessage): boolean { export function isDiscardedDraft(m: ChatMessage): boolean {
...@@ -501,3 +531,63 @@ export function toApiMessages(m: ChatMessage): ApiMessage[] { ...@@ -501,3 +531,63 @@ export function toApiMessages(m: ChatMessage): ApiMessage[] {
return out; 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 { 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 }> = { ...@@ -56,11 +56,20 @@ const TOOL_META: Record<string, { label: string; running: string }> = {
render_artifact: { label: '出图表', running: '正在画图' }, render_artifact: { label: '出图表', running: '正在画图' },
// 取一篇做法(pull 式加载,见服务端 assistant-playbooks):不查库不改数据 // 取一篇做法(pull 式加载,见服务端 assistant-playbooks):不查库不改数据
open_playbook: { label: '取做法', running: '正在取这类活的做法' }, open_playbook: { label: '取做法', running: '正在取这类活的做法' },
// ── 调整各客服在手的单(2026-08-20)——与上面确认单那三件一一对应 ──
// ⚠️ 说的是**动作**,⛔ 不是工具名:漏配的话兜底文案会把 `propose_rearrange`
// 这串英文直接摆给主管看(2026-08-20 走查抓到的就是这个)。
propose_rearrange: { label: '出调整单', running: '正在把各客服手上的单摆出来' },
edit_rearrange_sheet: { label: '改调整单', running: '正在按你说的改调整单' },
// ── 纯位置标记,不查库不写库(见 assistant.service 的 place_*)── // ── 纯位置标记,不查库不写库(见 assistant.service 的 place_*)──
show_sheet: { label: '摆确认单卡片', running: '正在摆确认单' }, show_sheet: { label: '摆确认单卡片', running: '正在摆确认单' },
show_rearrange: { label: '摆调整单卡片', running: '正在摆调整单' },
show_guidance: { label: '摆选项按钮', running: '正在摆选项按钮' }, show_guidance: { label: '摆选项按钮', running: '正在摆选项按钮' },
}; };
/** 给测试用 —— ⚠️ 只读导出,⛔ 别在运行时拿它当映射表(那是 `toolMeta` 的活) */
export const TOOL_META_KEYS = Object.keys(TOOL_META);
export function toolMeta(tool: string) { export function toolMeta(tool: string) {
return TOOL_META[tool] ?? { label: tool, running: `正在调用 ${tool}` }; return TOOL_META[tool] ?? { label: tool, running: `正在调用 ${tool}` };
} }
......
'use client'; 'use client';
import { useCallback, useRef, useState } from 'react'; 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 { env } from '@/lib/env';
import { emitPetEvent } from '@/lib/pet-events'; import { emitPetEvent } from '@/lib/pet-events';
import { useAuthStore } from '@/stores/auth-store'; import { useAuthStore } from '@/stores/auth-store';
...@@ -13,7 +19,9 @@ import { assignmentsApi } from '@/components/plans/assignments-api'; ...@@ -13,7 +19,9 @@ import { assignmentsApi } from '@/components/plans/assignments-api';
// ⭐ 数据模型 + 纯函数操作已抽到 chat-blocks(草稿状态机是最该有回归的一段逻辑, // ⭐ 数据模型 + 纯函数操作已抽到 chat-blocks(草稿状态机是最该有回归的一段逻辑,
// 内联在 hook 里只能端到端点一遍才能验)。这里 re-export 保持下游 import 不变。 // 内联在 hook 里只能端到端点一遍才能验)。这里 re-export 保持下游 import 不变。
import { import {
applyIncomingRearrange,
applyIncomingSheet, applyIncomingSheet,
findActiveRearrange,
findActiveSheet, findActiveSheet,
findToolIdx, findToolIdx,
intentToPrompt, intentToPrompt,
...@@ -77,7 +85,7 @@ export function useAssistantChat() { ...@@ -77,7 +85,7 @@ export function useAssistantChat() {
* 正在执行的引导节点 intent —— 按钮据此转圈并整排禁用。 * 正在执行的引导节点 intent —— 按钮据此转圈并整排禁用。
* *
* 🔴 必须在**这一层**(2026-08-13):会花时间的两类动作都已经不在卡片里了 —— * 🔴 必须在**这一层**(2026-08-13):会花时间的两类动作都已经不在卡片里了 ——
* `pending.refill` 走 HTTP 重排(几秒),`basis.set` / `cohort.*` 走 * `pending.refill` 走 HTTP 调整(几秒),`basis.set` / `cohort.*` 走
* `send()` 让模型重出一版(几十秒)。⛔ 局部改单(to_owner / spread / remove / * `send()` 让模型重出一版(几十秒)。⛔ 局部改单(to_owner / spread / remove /
* expiry.set)是同步的,⛔ 不要给它们转圈:闪一下反而像出错了。 * expiry.set)是同步的,⛔ 不要给它们转圈:闪一下反而像出错了。
*/ */
...@@ -130,7 +138,7 @@ export function useAssistantChat() { ...@@ -130,7 +138,7 @@ export function useAssistantChat() {
/** /**
* 消息流的**同步镜像** —— 给那些"要先读当前状态、再发请求"的回调用。 * 消息流的**同步镜像** —— 给那些"要先读当前状态、再发请求"的回调用。
* ⛔ 别把这类读取塞进 `setMessages` 的 updater:StrictMode 会把 updater 跑两次, * ⛔ 别把这类读取塞进 `setMessages` 的 updater:StrictMode 会把 updater 跑两次,
* 写在里面的副作用(如重排接口)就会**发两遍**。 * 写在里面的副作用(如调整接口)就会**发两遍**。
*/ */
const messagesRef = useRef<ChatMessage[]>([]); const messagesRef = useRef<ChatMessage[]>([]);
messagesRef.current = messages; messagesRef.current = messages;
...@@ -206,7 +214,7 @@ export function useAssistantChat() { ...@@ -206,7 +214,7 @@ export function useAssistantChat() {
/** /**
* 文本 / 思考的增量追加 —— **追加到最后一个同类块**,被别的东西打断就新起一段。 * 文本 / 思考的增量追加 —— **追加到最后一个同类块**,被别的东西打断就新起一段。
* *
* ⚠️ 这就是"时序"的全部实现:块的先后 = 事件到达的先后,⛔ 前端不重排 * ⚠️ 这就是"时序"的全部实现:块的先后 = 事件到达的先后,⛔ 前端不调整
* 所以「思考 → 调工具 → 写字 → 再调」在消息里长什么样,就是它真实发生的顺序。 * 所以「思考 → 调工具 → 写字 → 再调」在消息里长什么样,就是它真实发生的顺序。
*/ */
const appendStream = (kind: 'text' | 'reasoning', t: string) => const appendStream = (kind: 'text' | 'reasoning', t: string) =>
...@@ -273,6 +281,44 @@ export function useAssistantChat() { ...@@ -273,6 +281,44 @@ export function useAssistantChat() {
: [...blocks, { kind: 'sheet_anchor' as const }], : [...blocks, { kind: 'sheet_anchor' as const }],
); );
break; 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': { case 'assignment_guidance': {
/** /**
* ⭐ 模型说「这条的按钮插在这」—— `patch` 追加到**当前正在写的**这条消息末尾, * ⭐ 模型说「这条的按钮插在这」—— `patch` 追加到**当前正在写的**这条消息末尾,
...@@ -608,7 +654,7 @@ export function useAssistantChat() { ...@@ -608,7 +654,7 @@ export function useAssistantChat() {
* *
* ⚠️ 2026-08-12 从确认单组件搬到这里:待分配的按钮已经不在卡片上了 * ⚠️ 2026-08-12 从确认单组件搬到这里:待分配的按钮已经不在卡片上了
* (它跟着模型的文案走),卡片再留一份实现就是**没人走的死代码**。 * (它跟着模型的文案走),卡片再留一份实现就是**没人走的死代码**。
* ⚠️ 条件必须**原样带回** —— 少带一个(口径、治疗项、时间档),重排就换了人群, * ⚠️ 条件必须**原样带回** —— 少带一个(口径、治疗项、时间档),调整就换了人群,
* 而且不报错。 * 而且不报错。
*/ */
if (intent === 'pending.refill') { if (intent === 'pending.refill') {
...@@ -639,7 +685,7 @@ export function useAssistantChat() { ...@@ -639,7 +685,7 @@ export function useAssistantChat() {
...(s.narrowedBy?.minSpendYuan ? { minSpendYuan: s.narrowedBy.minSpendYuan } : {}), ...(s.narrowedBy?.minSpendYuan ? { minSpendYuan: s.narrowedBy.minSpendYuan } : {}),
/** /**
* 🔴 **他在这一版上设的按客服精调也要带回**(2026-08-15 补)。 * 🔴 **他在这一版上设的按客服精调也要带回**(2026-08-15 补)。
* 服务端的基线读的是**上一次已确认批次**,所以不带 = 重排一下 * 服务端的基线读的是**上一次已确认批次**,所以不带 = 调整一下
* 「李莉这批只给 5 条」就没了,李莉照常吃满,⛔ 而且不报错。 * 「李莉这批只给 5 条」就没了,李莉照常吃满,⛔ 而且不报错。
* ⚠️ 发**当前这一版**的(`s.agentOverrides` 已经是基线与本轮合并后的结果), * ⚠️ 发**当前这一版**的(`s.agentOverrides` 已经是基线与本轮合并后的结果),
* ⛔ 别只发"本轮新加的":那样上一次沿用下来的会被当成撤销。 * ⛔ 别只发"本轮新加的":那样上一次沿用下来的会被当成撤销。
......
...@@ -352,14 +352,28 @@ function MatrixRow({ ...@@ -352,14 +352,28 @@ function MatrixRow({
* 走 state 才能保证"点不动的格子不给准星"; * 走 state 才能保证"点不动的格子不给准星";
* ② 准星和十字必须**同生同灭**,同一个来源才不会一个亮一个不亮。 * ② 准星和十字必须**同生同灭**,同一个来源才不会一个亮一个不亮。
* ⚠️ 数字**微微抬起** —— `nums` 是等宽数字,放大不会把邻居推歪。 * ⚠️ 数字**微微抬起** —— `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', aim && !onCross && 'opacity-45',
isSelected && 'outline-solid outline-2 -outline-offset-2 outline-brand-600', isSelected && 'outline-solid outline-2 -outline-offset-2 outline-brand-600',
)} )}
> >
{n ? n.toLocaleString() : '—'} <span
className={cn(
'inline-block transition-transform duration-150',
isAimed && 'scale-[1.06]',
)}
>
{n ? n.toLocaleString() : '—'}
</span>
</button> </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'; ...@@ -12,6 +12,15 @@ import { cn } from '@/lib/utils';
const PAGE = 20; 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 }) { ...@@ -234,7 +243,16 @@ export function BatchTracking({ clinicId }: { clinicId: string | null }) {
<thead> <thead>
<tr className="sticky top-0 z-10 bg-slate-50 shadow-[inset_0_-1px_0_#E2E8F0]"> <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> <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 <th
key={h} key={h}
className="whitespace-nowrap px-2.5 py-1.5 text-right text-[11px] font-medium text-slate-500" className="whitespace-nowrap px-2.5 py-1.5 text-right text-[11px] font-medium text-slate-500"
...@@ -344,7 +362,13 @@ function BatchGroup({ ...@@ -344,7 +362,13 @@ function BatchGroup({
return ( return (
<> <>
<tr> <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={cn('text-[11px] font-semibold', t.headKey)}>{group.key}</span>
<span className="ml-1.5 text-[10.5px] text-slate-400">{group.note}</span> <span className="ml-1.5 text-[10.5px] text-slate-400">{group.note}</span>
</td> </td>
...@@ -383,6 +407,8 @@ function BatchGroup({ ...@@ -383,6 +407,8 @@ function BatchGroup({
{b.inHand} {b.inHand}
</td> </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.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.expired)}>{b.expired}</td>
<td className={cn('nums px-2.5 py-2 text-right', t.booked)}>{b.booked}</td> <td className={cn('nums px-2.5 py-2 text-right', t.booked)}>{b.booked}</td>
</tr> </tr>
...@@ -427,7 +453,7 @@ function BatchDrawer({ id, onClose }: { id: string; onClose: () => void }) { ...@@ -427,7 +453,7 @@ function BatchDrawer({ id, onClose }: { id: string; onClose: () => void }) {
<> <>
{/* {/*
🔴 **z-20 不能省**(2026-08-07 实测):表格的 `thead` 是 `sticky z-10`, 🔴 **z-20 不能省**(2026-08-07 实测):表格的 `thead` 是 `sticky z-10`,
抽屉不给层级的话,那一排列头(已处置 / 没动 / 退回 / 到期回池 / 约上) 抽屉不给层级的话,那一排列头(已处置 / 没动 / 客服退回 / 回收 / 超期 / 约上)
会**浮在抽屉标题上面**,跟抽屉自己那行汇总叠在一起 —— 看着像表头样式坏了, 会**浮在抽屉标题上面**,跟抽屉自己那行汇总叠在一起 —— 看着像表头样式坏了,
实际是层级问题。⛔ 别去改 thead 的 sticky(那是列表滚动要用的)。 实际是层级问题。⛔ 别去改 thead 的 sticky(那是列表滚动要用的)。
⚠️ `top-[3.25rem]` 对齐面板自己的标题行高度:抽屉盖住表格但**不盖标题**, ⚠️ `top-[3.25rem]` 对齐面板自己的标题行高度:抽屉盖住表格但**不盖标题**,
...@@ -445,8 +471,8 @@ function BatchDrawer({ id, onClose }: { id: string; onClose: () => void }) { ...@@ -445,8 +471,8 @@ function BatchDrawer({ id, onClose }: { id: string; onClose: () => void }) {
</div> </div>
{d && ( {d && (
<div className="nums mt-0.5 text-[11px] text-slate-600"> <div className="nums mt-0.5 text-[11px] text-slate-600">
已处置 {d.handled} · 没动 {d.inHand} · 退回 {d.released} · 超期 {d.expired} · 已处置 {d.handled} · 没动 {d.inHand} · 客服退回 {d.released}
约上 {d.booked} {d.recalled > 0 && <> · 回收 {d.recalled}</>} · 超期 {d.expired} · 约上 {d.booked}
</div> </div>
)} )}
</div> </div>
...@@ -475,7 +501,7 @@ function BatchDrawer({ id, onClose }: { id: string; onClose: () => void }) { ...@@ -475,7 +501,7 @@ function BatchDrawer({ id, onClose }: { id: string; onClose: () => void }) {
<table className="w-full border-collapse text-[12px] text-slate-700"> <table className="w-full border-collapse text-[12px] text-slate-700">
<thead> <thead>
<tr> <tr>
{['客服', '分到', '已处置', '没动', '退回'].map((h, i) => ( {['客服', '分到', '已处置', '没动', '客服退回', '回收'].map((h, i) => (
<th <th
key={h} key={h}
className={cn( className={cn(
...@@ -508,15 +534,18 @@ function BatchDrawer({ id, onClose }: { id: string; onClose: () => void }) { ...@@ -508,15 +534,18 @@ function BatchDrawer({ id, onClose }: { id: string; onClose: () => void }) {
{a.inHand} {a.inHand}
</td> </td>
<td className="nums py-1.5 text-right text-rose-600">{a.released}</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> </tr>
))} ))}
</tbody> </tbody>
</table> </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"> <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.handled > 0 ? ` = ${((d.released / d.handled) * 100).toFixed(1)}%` : ''},另有{' '}
{d.inHand} 条没动 {d.inHand} 条没动
</div> </div>
......
...@@ -1089,6 +1089,37 @@ export const PlanEventType = { ...@@ -1089,6 +1089,37 @@ export const PlanEventType = {
* ⛔ 别把它算进"客服处理过这个患者",那个口径会静默膨胀成"复制一下也算"。 * ⛔ 别把它算进"客服处理过这个患者",那个口径会静默膨胀成"复制一下也算"。
*/ */
SCRIPT_COPY: 'script_copy', 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; } as const;
export type PlanEventType = (typeof PlanEventType)[keyof typeof PlanEventType]; export type PlanEventType = (typeof PlanEventType)[keyof typeof PlanEventType];
...@@ -1125,6 +1156,12 @@ export const PLAN_EVENT_META: Record< ...@@ -1125,6 +1156,12 @@ export const PLAN_EVENT_META: Record<
// ⚠️ holdsPatient: false —— 复制可能发生在未认领的单上(主管浏览时也能复制), // ⚠️ holdsPatient: false —— 复制可能发生在未认领的单上(主管浏览时也能复制),
// 跟归属无关,不参与归属区间计算。 // 跟归属无关,不参与归属区间计算。
script_copy: { labelZh: '复制话术', group: 'usage', byHuman: false, 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'; ...@@ -6,6 +6,7 @@ export * from './fact';
export * from './persona'; export * from './persona';
export * from './plan'; export * from './plan';
export * from './plan-assignment'; export * from './plan-assignment';
export * from './plan-rearrange';
export * from './agent'; export * from './agent';
export * from './sync'; export * from './sync';
export * from './admin'; export * from './admin';
......
...@@ -68,7 +68,14 @@ export const CreateAssignmentRequestSchema = z.object({ ...@@ -68,7 +68,14 @@ export const CreateAssignmentRequestSchema = z.object({
* (纯日期被当成 UTC 零点,整整偏 8 小时)。服务端按 host 时区转成**当地日末**。 * (纯日期被当成 UTC 零点,整整偏 8 小时)。服务端按 host 时区转成**当地日末**。
*/ */
expiresInDays: z.number().int().positive().max(90), 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>; export type CreateAssignmentRequest = z.infer<typeof CreateAssignmentRequestSchema>;
...@@ -256,7 +263,17 @@ export const AssignmentBriefSchema = z.object({ ...@@ -256,7 +263,17 @@ export const AssignmentBriefSchema = z.object({
* SQL 里 `release_reason = NULL`),数它等于"被退过又被重分的人从没退过"。 * SQL 里 `release_reason = NULL`),数它等于"被退过又被重分的人从没退过"。
* ⚠️ **不含到期**:到期是"客服压根没动",与"看了判断不该我做"是两回事,见 expired。 * ⚠️ **不含到期**:到期是"客服压根没动",与"看了判断不该我做"是两回事,见 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)。 * 到期自动回池的条数(账本 auto_release + reason=assignment_expired)。
* *
...@@ -318,6 +335,14 @@ export const AssignmentAgentStatSchema = z.object({ ...@@ -318,6 +335,14 @@ export const AssignmentAgentStatSchema = z.object({
inHand: z.number().int(), inHand: z.number().int(),
released: z.number().int(), released: z.number().int(),
/** /**
* 主管从**他手上**收回去的条数(调整单里的移出)。
*
* ⚠️ 归的是「从谁手上收的」,⛔ 不是「当初分给谁」—— 一条单可能先被转手再被收回,
* 那时候两者不是一个人。
* ⚠️ 它⛔ 不进退回率:退回率回答的是「他为什么不接」,而这一列是主管自己的动作。
*/
recalled: z.number().int(),
/**
* 仍在手、已过时限、**且没有约定回访**。 * 仍在手、已过时限、**且没有约定回访**。
* *
* 🔴 「且没有约定回访」这半句不能少。到期回收器刻意跳过 `snoozedUntil` 在未来的单 * 🔴 「且没有约定回访」这半句不能少。到期回收器刻意跳过 `snoozedUntil` 在未来的单
...@@ -438,6 +463,8 @@ export const AssignmentDetailResponseSchema = AssignmentBriefSchema.extend({ ...@@ -438,6 +463,8 @@ export const AssignmentDetailResponseSchema = AssignmentBriefSchema.extend({
* 而"没动过"的数量本身就是信号(主管据此判断是分多了还是客服没跟)。 * 而"没动过"的数量本身就是信号(主管据此判断是分多了还是客服没跟)。
* 只给一个百分比会让主管把"没人动"误读成"做得不错"。 * 只给一个百分比会让主管把"没人动"误读成"做得不错"。
*/ */
/// 主管在调整单里收回池子的条数(口径同列表行的 `recalled`)
recalled: z.number().int(),
untouched: z.number().int().describe('分下去后客服从未打开过详情页的条数'), untouched: z.number().int().describe('分下去后客服从未打开过详情页的条数'),
/** /**
* 处理进度 —— **判据是池子状态,不是回写**(产品裁决 2026-08-02)。 * 处理进度 —— **判据是池子状态,不是回写**(产品裁决 2026-08-02)。
...@@ -733,7 +760,7 @@ export const AssignmentProposalSchema = z.object({ ...@@ -733,7 +760,7 @@ export const AssignmentProposalSchema = z.object({
/// ⭐ 与矩阵格子同一种数法(count DISTINCT patient_id),⛔ 不受取明细的 LIMIT 影响 —— /// ⭐ 与矩阵格子同一种数法(count DISTINCT patient_id),⛔ 不受取明细的 LIMIT 影响 ——
/// 从矩阵点进来的主管会拿这个数跟他刚看到的格子对 /// 从矩阵点进来的主管会拿这个数跟他刚看到的格子对
candidateTotal: z.number().int().describe('候选总数 = 该格子/该条件下的患者数(与矩阵格子对得上)'), candidateTotal: z.number().int().describe('候选总数 = 该格子/该条件下的患者数(与矩阵格子对得上)'),
target: z.number().int().describe('本批**实际**分多少人 = min(基数 N, 候选总数)'), target: z.number().int().describe('本批**实际**分多少人 = min(基数 N, 候选总数, 单批硬上限 500)'),
/** /**
* 基数 N —— **要被记住的那个数**(沿用上次 / 首次按 在岗人数×20 估 / 本次主管指定)。 * 基数 N —— **要被记住的那个数**(沿用上次 / 首次按 在岗人数×20 估 / 本次主管指定)。
* ⚠️ 与 `target` 分开:候选不够时 target 会被压低,但那是**这一批的偶然事实**, * ⚠️ 与 `target` 分开:候选不够时 target 会被压低,但那是**这一批的偶然事实**,
......
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