Commit 300b3f50 by luoqi

feat(plan): 召回分配 P2 —— 批量写路径(唯一的写动作)

POST/GET /pac/v1/plans/assignments,@RequirePermission(PLAN_DISPATCH)。
这是整条生产线上唯一改库的地方(T8),所以护栏比功能本身花的力气多。

## AssignStrategy 定四值,不是三值

原设计 dedicated/spread/manual。但 spread 会同时装下两群**完全不同**的人:
  · 有专属客服、只是这次容量满了没轮到他 —— 医患关系还在
  · 从头就没有可用专属(无专属 / 专属已离岗)—— 本来就是关系薄弱那批
实测本地 2,724 条候选:专属且在岗 85.1% / 专属但已离岗 7.5% / 无专属 7.3%,
后两类合计约 15%。混成一个值,T20 要算的「专属 vs 铺平完成率差」就废了:
铺平那组低,到底是策略不好还是那群人本来难打,永远分不出来。

️ 主管界面**不多一种状态**:ASSIGN_STRATEGY_META.groupZh 把两种 spread 合并成
「铺平」一档,只有反推分析才拆开看 —— 内部口径的精细度不倒灌到主管的注意力上(T13)。

## 三道闸

① requirePermission —— 与 controller 上的装饰器**故意重复**:装饰器只在 guard 链生效,
   而 MCP 端点是 @Public() 直接短路。判据同时放进 service,换个入口进来护栏还在。
② rejectSyntheticIdentity —— 企微 mintToken 造的 `wx:` 合成身份直接拒。
   教条原文是「本期不管(demo 用途)」,那句话的前提是当时还没有写路径;
   有了写路径,"不管"必须落成"硬拒",否则就是把已知的身份伪造面留在最危险的位置。
③ scope 绑定 + **数量对不上整体拒绝**。对不上只有两种可能:确认单过期(单子被引擎
   supersede 了)或跨诊所越权,两种都不该落一半 —— 落一半的批次事后既解释不清也回不去,
   而主管看到"成功 87/100"完全无从判断哪 13 条为什么没落。

两个 helper 单独成文件(dispatch-guard.ts),S4 挂 MCP 写工具时直接复用,不重写一遍。

## 并发与幂等

按 (客服,策略,时效) 分桶,每桶一条**带条件** updateMany:
`where: { id: {in}, status:'active', assigneeUserId: null, supersededAt: null }`
—— where 带状态条件即并发安全,count 与 chunk 长度的差就是确认期间被抢走的。
同款技巧见 recycle-scheduler。 不循环调 PlanService.assign(500 条 ≈1500 次往返,
且它写不了归因列); 也不让单条 assign 委托批量(每次自助认领都会造一条垃圾批次)。
共用判据收口到 claim-guard.assertAssignable —— 教条七「已认领能否强制改派」
那条待确认决策正好落在这一个函数上,将来只改这一处。

requestId 幂等:**前置回查**(重放零副作用)+ P2002 兜底回查。 冲突不抛错 ——
抛了模型会以为失败、换个参数重试,那才是真正的重复分配。

applied=0 → 抛错回滚,库里不留空批次(空批次会让报表出现一堆 0/0 且查不到原因)。

## 两个实测踩出来的坑

1. 🔴 **路由被吃**:GET /plans/assignments 被 PlanController 的裸 `@Get(':id')` 匹配成
   planId='assignments',报的是 Prisma uuid 解析错(90000),完全看不出是路由撞了。
   → AssignmentController 必须在 module 里**排在 PlanController 之前**(Nest 按注册顺序匹配)。
   同类先例:plan.controller:80 的 `doctors` 路由也踩过。
2. **agentStats 加起来比批次少**:退回后 assignee 被清空,那条从所有人名下消失,
   500 条退 1 条 → 各人 planned 之和 499,主管一眼看出对不上,而"某人退了几条"
   恰恰是他最想看的列。→ 从 plan_event_logs 的 assign 事件回捞原始承接人
   (createdAt >= 批次创建时刻)。这正是账本存在的意义:主表存当前值,历史归属只有账本留得住。

## 其他

· expiresInDays 收**相对天数**,服务端按 host 时区转**当地日末** ——
  让模型算 ISO 时刻正是 commit 45498176 修过的坑(纯日期被当 UTC 零点偏 8 小时);
  按小时算则上午分的和下午分的在同一天不同时刻过期,客服形不成预期。
· items 是 (planId, assigneeUserId) **对**不是分组:溢出转铺平后归属逐条算,
  且 T13 的逐条微调时效在分组形状里无处安放。
· 500 硬护栏是**技术**上限(单事务 + bind 变量),不是批次规模的业务上限 ——
  业务上限该由产品定并加在助手圈人那层,写这里会让两件事永远分不开。
· AssignmentDetail 的字段叫 agentStats 不叫 agents:Brief 里 `agents` 是人数(number),
  同名不同型会让前端拿 brief 类型读 detail 时静默拿到 undefined。

## 验证(本地真实数据 2,724 条活跃 plan)

857 单测通过 + 端到端 11 项:
  staff 调分配端点          → 403 Missing permissions: plan:dispatch
  500 条一次调用            → **741ms**,assigned=500,4 个客服 × 125
  expiresAt                → 2026-08-05T15:59:59.999Z = 北京 08-05 当地日末 
  同 requestId 再调         → duplicate:true,库里仍 1 个批次
  混入他诊所 planId         → 整批拒绝,不留空批次
  确认期间被人抢先认领       → assigned=2,skipped=[claimed_by_other],其余照落
  全部落不上                → 拒绝,批次数不变
  归属账本                  → 504 条 assign 事件,承接人 5 / 发起人 1
  批次列表 / 单批详情        → planned=500 inHand=499 released=1 untouched=499
  agentStats 之和            → 500 == 批次 planned 
  GET /plans/:id            → 未被路由改动误伤
测试数据已清理(批次=0 归因单=0 事件=0 在手=0)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent fa58bdf6
import { Body, Controller, Get, Param, 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 { CurrentUser } from '../../common/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../../common/decorators/current-user.decorator';
import { PlanAssignmentService } from './plan-assignment.service';
import {
CreateAssignmentRequestDto,
CreateAssignmentResponseDto,
ListAssignmentsResponseDto,
AssignmentDetailResponseDto,
} from './dto/plan-assignment.dto';
/**
* 批次分配端点 —— 门诊经理专用。
*
* ⛔ **刻意不改** `POST /plans/:id/assign` 与 `/plans/:id/recycle`:
* 前者维持 PLAN_ASSIGN(认领语义,staff 也有,前端虽撤了入口但后端保留 —— T12/T16),
* 后者维持 PLAN_RECYCLE(退回是客服路径)。
* 把批次能力塞进那两个端点会让"客服自助"与"主管派单"两条路的权限纠缠在一起。
*
* 路由挂在 `plans/assignments` 而不是顶层 `plan-assignments`:
* 它是 plan 的从属资源,跟 `plans/:id/executions` 同一层级心智。
*/
@ApiTags('plan-assignments')
@ApiBearerAuth('accessToken')
@Controller('plans/assignments')
export class AssignmentController {
constructor(private readonly assignments: PlanAssignmentService) {}
@Post()
@RequirePermission(Permission.PLAN_DISPATCH)
@ZodResponse({ status: 201, type: CreateAssignmentResponseDto })
@ApiOperation({
summary: '确认分配 —— 主管在确认单上点确认时调用(整条生产线唯一的写动作)',
description:
'幂等:requestId 由服务端在生成确认单时铸造,重复提交返回同一批次 + duplicate:true。' +
'部分计划在确认期间被他人认领时其余照落,逐条在 skipped 里说明原因;一条都落不上则不建批次。',
})
async create(
@TenantScope() scope: TenantScopeContext,
@CurrentUser() user: AuthenticatedUser,
@Body() dto: CreateAssignmentRequestDto,
) {
return this.assignments.create(
scope,
{ userId: user.sub, permissions: user.permissions },
dto,
);
}
@Get()
@RequirePermission(Permission.PLAN_DISPATCH)
@ZodResponse({ status: 200, type: ListAssignmentsResponseDto })
@ApiOperation({ summary: '我分的那些批 —— 批次列表 + 每批汇总(分了/在手/已退回/涉及几人)' })
async list(
@TenantScope() scope: TenantScopeContext,
@CurrentUser() user: AuthenticatedUser,
@Query('mine') mine?: string,
) {
// mine=1 → 只看自己发起的。默认看本 scope 全部(leader 之间要能互相看见,
// 否则"这个诊所这周分了多少"永远拼不出来)
return this.assignments.list(scope, mine === '1' ? user.sub : undefined);
}
@Get(':id')
@RequirePermission(Permission.PLAN_DISPATCH)
@ZodResponse({ status: 200, type: AssignmentDetailResponseDto })
@ApiOperation({ summary: '单批全貌 —— 按客服拆 + 退回原因分布' })
async detail(@TenantScope() scope: TenantScopeContext, @Param('id') id: string) {
return this.assignments.detail(scope, id);
}
}
...@@ -28,6 +28,32 @@ export interface ClaimablePlan { ...@@ -28,6 +28,32 @@ export interface ClaimablePlan {
} }
/** /**
* 「这条单现在能不能分给某人」—— 单条 assign 与批量分配**共用同一个判据**。
*
* 为什么要提出来:教条七里那条待确认的产品决策「已被认领的单能否强制改派」
* 恰好就落在这一个函数上。收口成一处,将来产品拍板了只改这里 ——
* 散在单条与批量两处,必然只改一处然后两条路行为不一致(而且不会有人发现)。
*
* 返回 null = 可以分;返回字符串 = 不能分的原因码(与 AssignmentSkipped.reason 同域)。
* ⚠️ 刻意**返回原因而不抛异常**:批量场景要的是「其余照落 + 逐条说明」,
* 抛异常会让一条挡住整批。单条路径由调用方自己把原因翻成 BizError。
*/
export function assertAssignable(
plan: { status: string; assigneeUserId: string | null },
targetAssigneeUserId: string,
): 'terminal' | 'claimed_by_other' | null {
if (plan.status === 'completed' || plan.status === 'abandoned' || plan.status === 'superseded') {
return 'terminal';
}
// 已分给**别人** → 挡住(分给同一个人是幂等续期,放行)。
// 这就是「强制改派」那条待确认决策的落点:将来若允许,改成 return null 即可。
if (plan.status === 'assigned' && plan.assigneeUserId !== targetAssigneeUserId) {
return 'claimed_by_other';
}
return null;
}
/**
* 返池归属闸 —— staff 只能退**自己认领的**单(2026-07-29 放开 staff 返池时加)。 * 返池归属闸 —— staff 只能退**自己认领的**单(2026-07-29 放开 staff 返池时加)。
* *
* 为什么必须有:光把 PLAN_RECYCLE 给 staff,客服 A 就能把客服 B 手里的单退回池、 * 为什么必须有:光把 PLAN_RECYCLE 给 staff,客服 A 就能把客服 B 手里的单退回池、
......
import { ForbiddenException } from '@nestjs/common';
import { Permission } from '@pac/types';
/**
* 分配写路径的两道通用闸 —— **独立于传输层**(REST / MCP / 将来别的入口都能用)。
*
* ── 为什么现在就写,而 v1 的写路径明明只有 REST ──────────────────
* MCP 写工具(`assign_plans`)最终一定要实现,只是分期到 S4 等鉴权补齐。
* 但 MCP 端点现在是 `@Public()`(`permissions.guard.ts` 对 public 直接 return true),
* 也就是说**框架级的 @RequirePermission 在那条路上完全不生效**,
* 护栏只能退化成 handler 内自查。等到那时才动手,写路径的形状很可能已经把自查挤不进去了。
*
* 所以这两个 helper 现在就落地、现在就被 REST 路径调用(与 guard 叠加,幂等),
* S4 挂 MCP 工具时直接复用同一份判据 —— 而不是在第二个地方重写一遍权限逻辑。
*/
/** 调用方的最小身份形状(REST 从 JWT 来,MCP 从 McpAuthContext 来,两边都能满足) */
export interface DispatchActor {
userId: string;
permissions: readonly string[];
}
/**
* 权限自查。
*
* ⚠️ 与 `@RequirePermission(PLAN_DISPATCH)` **重复是有意的**:
* 装饰器只在 Nest 的 guard 链上生效,`@Public()` 的端点直接短路 ——
* 而 MCP 恰好就是 `@Public()`。把判据同时放进 service,
* 就不存在"换个入口进来护栏就没了"这回事。
*/
export function requirePermission(actor: DispatchActor, permission: Permission): void {
if (!actor.permissions.includes(permission)) {
throw new ForbiddenException(`缺少权限:${permission}`);
}
}
/**
* 合成身份前缀 —— 这些 id 不对应任何真人。
* 目前只有企微机器人:`weixin-aibot.service.mintToken` 会造一个 `role='staff'` + 全池 scope
* 的 token,给群里任何 @ 机器人的人用。读没问题(本来就是给客服查患者的),
* **写绝对不行** —— 那等于让"谁在群里说话"决定几百条单子的归属,而且账本上的
* `created_by` 会指向一个查无此人的 id,事后追责追不到人。
*/
const SYNTHETIC_ID_PREFIXES = ['wx:'] as const;
/**
* 拒绝合成身份发起写操作。
*
* 教条六·已定取舍写的是「企微通道的合成身份本期不管(demo 用途)」——
* 那句话的前提是**当时还没有写路径**。有了写路径,"不管"就必须落成"硬拒",
* 否则就是把一个已知的身份伪造面留在最危险的位置上。
* 将来做真身份映射时,把映射成功的真 id 传进来即可自然通过。
*/
export function rejectSyntheticIdentity(actor: DispatchActor): void {
if (SYNTHETIC_ID_PREFIXES.some((p) => actor.userId.startsWith(p))) {
throw new ForbiddenException(
'当前身份为渠道合成身份(非真实登录用户),不能发起分配。请在 PAC 工作台登录后操作。',
);
}
}
import { createZodDto } from 'nestjs-zod';
import {
AssignmentDetailResponseSchema,
CreateAssignmentRequestSchema,
CreateAssignmentResponseSchema,
ListAssignmentsResponseSchema,
} from '@pac/types';
export class CreateAssignmentRequestDto extends createZodDto(CreateAssignmentRequestSchema) {}
export class CreateAssignmentResponseDto extends createZodDto(CreateAssignmentResponseSchema) {}
export class ListAssignmentsResponseDto extends createZodDto(ListAssignmentsResponseSchema) {}
export class AssignmentDetailResponseDto extends createZodDto(AssignmentDetailResponseSchema) {}
import { Module } from '@nestjs/common'; 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 { PlanAssignmentService } from './plan-assignment.service';
import { ExecutionService } from './execution.service'; import { ExecutionService } from './execution.service';
import { ExecutionCallbackService } from './execution-callback.service'; import { ExecutionCallbackService } from './execution-callback.service';
import { RecycleSchedulerService } from './recycle-scheduler.service'; import { RecycleSchedulerService } from './recycle-scheduler.service';
...@@ -15,9 +17,16 @@ import { RecallDebugService } from './recall-debug/recall-debug.service'; ...@@ -15,9 +17,16 @@ import { RecallDebugService } from './recall-debug/recall-debug.service';
* 链已完成召回(aftercare)留后续,文件已删。 * 链已完成召回(aftercare)留后续,文件已删。
*/ */
@Module({ @Module({
controllers: [PlanController, RecallDebugController], // ⚠️⚠️ **AssignmentController 必须排在 PlanController 之前**,顺序不是随意的。
// 两者的路由前缀都是 `plans`,而 PlanController 有一条裸 `@Get(':id')`(plan.controller:94)。
// Nest 按**注册顺序**匹配 → 反过来的话 `GET /plans/assignments` 会被当成
// `GET /plans/:id`(id='assignments'),然后去查一个不存在的 plan,
// 报的还是 Prisma 的 uuid 解析错(90000),完全看不出是路由撞了。
// 实测踩过一次。同类先例见 plan.controller:80 的 `doctors` 那条注释。
controllers: [AssignmentController, PlanController, RecallDebugController],
providers: [ providers: [
PlanService, PlanService,
PlanAssignmentService,
ExecutionService, ExecutionService,
ExecutionCallbackService, ExecutionCallbackService,
RecycleSchedulerService, RecycleSchedulerService,
......
...@@ -302,7 +302,7 @@ export function normalizeDatetime(s: string, timezone: string): string { ...@@ -302,7 +302,7 @@ export function normalizeDatetime(s: string, timezone: string): string {
* 简化版:对常用东亚时区直接映射;真要严谨可用 Intl.DateTimeFormat 推导,但 * 简化版:对常用东亚时区直接映射;真要严谨可用 Intl.DateTimeFormat 推导,但
* v1 不处理 DST(中国大陆不实行;若接欧美 host 再迭代)。 * v1 不处理 DST(中国大陆不实行;若接欧美 host 再迭代)。
*/ */
function timezoneToOffsetSuffix(tz: string): string { export function timezoneToOffsetSuffix(tz: string): string {
const fixed: Record<string, string> = { const fixed: Record<string, string> = {
'Asia/Shanghai': '+08:00', 'Asia/Shanghai': '+08:00',
'Asia/Hong_Kong': '+08:00', 'Asia/Hong_Kong': '+08:00',
......
...@@ -889,6 +889,73 @@ export function releaseReasonsForForm(): ReleaseReason[] { ...@@ -889,6 +889,73 @@ export function releaseReasonsForForm(): ReleaseReason[] {
} }
// ============================================================= // =============================================================
// 分配策略(followup_plans.assign_strategy)
// =============================================================
/**
* 这条单子是**怎么落到该客服头上**的。
*
* ⚠️⚠️ **分配当时不记就永久没了**,这是它必须立柱的全部理由:
* 唯一能反推的来源 `patients.preferences.dedicatedCs` 是摄入时 upsert 覆盖的「当前值」,
* 半年后再查只会看到那时的专属客服,不是分配那一刻的。
*
* ── 为什么「铺平」拆成两个值,不是一个 ────────────────────────────
* 直觉会写成 dedicated / spread / manual 三值。但 spread 会同时装下两群**完全不同**的人:
*
* · spread_overflow —— 有专属客服,只是这次容量满了没轮到他。**医患关系还在**。
* · spread_no_dedicated —— 从头就没有可用的专属客服(无专属 / 专属已离职)。
* 这批人本来就是关系薄弱的那群。
*
* 实测(2026-08 本地 2,724 条候选):专属且在岗 85.1% / 专属但已离岗 7.5% / 无专属 7.3%
* —— 后两类合计约 **15%**,量级不小。
*
* 混成一个值,T20 要算的「专属 vs 铺平的完成率差」就废了:铺平那一组的完成率低,
* 到底是**铺平这个策略不好**,还是**那群人本来就难打**,永远分不出来。
* 而这个差值正是用来决定"默认策略要不要改"的 —— 拿一个有混淆变量的数去改策略,
* 比没有数更危险。
*
* 代价是真实的(见 ASSIGN_STRATEGY_META.groupZh):确认单上要多一种可见状态、
* 助手要多一句解释、报表要多一行。所以**只在需要区分的地方区分** ——
* 给主管看时两个 spread 合并成「铺平」一档(groupZh 就是干这个的),
* 只有反推分析才拆开看。
*/
export const AssignStrategy = {
/// 命中专属客服(该客服在岗且容量够)
DEDICATED: 'dedicated',
/// 铺平 · 溢出 —— 有专属客服但这次没轮到他(容量满)
SPREAD_OVERFLOW: 'spread_overflow',
/// 铺平 · 无专属 —— 从头就没有可用的专属客服(无专属 / 专属已离岗)
SPREAD_NO_DEDICATED: 'spread_no_dedicated',
/// 主管在确认单上手工指定(覆盖了助手的建议)
MANUAL: 'manual',
} as const;
export type AssignStrategy = (typeof AssignStrategy)[keyof typeof AssignStrategy];
export const AssignStrategySchema = z.enum([
'dedicated',
'spread_overflow',
'spread_no_dedicated',
'manual',
]);
/**
* 分配策略元数据。
*
* labelZh 精确名称(反推分析 / 调试用)
* groupZh **给主管看的合并档** —— 两种 spread 在界面上都叫「铺平」,
* 不要把内部口径的精细度倒灌到主管的注意力上(与 T13「一次确认」相悖)
* isSpread 是否属于铺平(报表按它二分,别在调用处写 `startsWith('spread')`)
*/
export const ASSIGN_STRATEGY_META: Record<
AssignStrategy,
{ labelZh: string; groupZh: string; isSpread: boolean }
> = {
dedicated: { labelZh: '专属客服', groupZh: '专属', isSpread: false },
spread_overflow: { labelZh: '铺平·专属已满', groupZh: '铺平', isSpread: true },
spread_no_dedicated: { labelZh: '铺平·无专属', groupZh: '铺平', isSpread: true },
manual: { labelZh: '主管指定', groupZh: '指定', isSpread: false },
};
// =============================================================
// Plan 生命周期事件(plan_event_logs.event) // Plan 生命周期事件(plan_event_logs.event)
// ============================================================= // =============================================================
......
...@@ -5,6 +5,7 @@ export * from './patient'; ...@@ -5,6 +5,7 @@ export * from './patient';
export * from './fact'; export * from './fact';
export * from './persona'; export * from './persona';
export * from './plan'; export * from './plan';
export * from './plan-assignment';
export * from './agent'; export * from './agent';
export * from './sync'; export * from './sync';
export * from './admin'; export * from './admin';
......
import { z } from 'zod';
import { AssignStrategySchema } from '../enums';
// =============================================================
// 批次分配(门诊经理)—— POST /pac/v1/plans/assignments
// =============================================================
/**
* 批次里的一条 —— **(planId, assigneeUserId) 对**,不是 `{assigneeUserId, planIds[]}` 分组。
*
* 为什么不用分组形状(看着更省字节):
* 1. 归属是**逐条**算出来的 —— 溢出转铺平后同一个客服名下会混着 dedicated 与
* spread_overflow 两种来源,分组形状表达不了「这一条为什么落到他头上」。
* 2. T13 允许主管在确认单上微调时效,而微调是**逐条**的
* (「本批 3 天,其中 5 条单独设了 7 天」)—— 分组形状里那 5 条无处安放。
*/
export const AssignmentItemSchema = z.object({
planId: z.string().uuid(),
/// 宿主侧 user id(与 JWT.sub 同一 id 空间,已于 2026-08 实测 81% 重合验证)
assigneeUserId: z.string().min(1),
/// ⭐ 逐条带:这条是怎么落到他头上的。事后补不回来,见 AssignStrategy 的注释
assignStrategy: AssignStrategySchema,
/// 单条覆盖批次时效(不传 = 跟批次)。相对天数,与批次同口径,服务端统一转绝对时刻
expiresInDays: z.number().int().positive().max(90).optional(),
});
export type AssignmentItem = z.infer<typeof AssignmentItemSchema>;
/**
* ⚠️ 500 是**技术护栏**(单事务 + PG bind 变量上限),不是批次规模的业务上限。
* 业务上限该由产品定(教条七·待确认),定了之后加在**助手圈人那一层**,别写死在这里 ——
* 写在这里会让"技术能不能扛"和"运营该不该分这么多"两件事永远分不开。
*/
export const ASSIGNMENT_ITEMS_HARD_LIMIT = 500;
export const CreateAssignmentRequestSchema = z.object({
/**
* 幂等键 —— **服务端在生成确认单(propose)时铸造**,随确认单下发、确认时原样回传。
* ⛔ 不由前端或模型生成:挡不住 SSE 重连与模型重试,而"重复分配"没有天然自然键可去重
* (同一主管同一秒对同一批人再点一次,在业务上是合法操作)。
*/
requestId: z.string().min(8).max(128),
/// 批次不跨诊所(福利政策与效果归因都按诊所走)
clinicId: z.string().min(1),
/// 初筛条件快照 + 收敛规则。快照而非引用:口径会变,批次要能解释"当时按什么圈的"
criteria: z.record(z.string(), z.unknown()),
/// 附加属性。福利 v1 不核销、不接卡券,只是话术勾子 + 归因标签
attributes: z
.object({ benefit: z.object({ text: z.string().min(1).max(200) }).optional() })
.optional(),
/**
* 批次时效(相对天数)。
* ⚠️ 收相对天数而不是绝对时刻:让模型算 ISO 时刻正是本仓 commit 4549817 修过的坑
* (纯日期被当成 UTC 零点,整整偏 8 小时)。服务端按 host 时区转成**当地日末**。
*/
expiresInDays: z.number().int().positive().max(90),
items: z.array(AssignmentItemSchema).min(1).max(ASSIGNMENT_ITEMS_HARD_LIMIT),
});
export type CreateAssignmentRequest = z.infer<typeof CreateAssignmentRequestSchema>;
/// 没落上的单及原因 —— 助手照着这个说人话,别让主管对着 uuid 猜
export const AssignmentSkippedSchema = z.object({
planId: z.string(),
reason: z.enum([
/// 已被别人认领 / 已分给别人(现有摩擦,教条七·待确认是否允许强制改派)
'claimed_by_other',
/// 已终态(completed / abandoned / superseded)
'terminal',
/// 同一患者在本批里有多条活动 plan,只留优先级最高的一条
'duplicate_patient',
]),
});
export type AssignmentSkipped = z.infer<typeof AssignmentSkippedSchema>;
export const CreateAssignmentResponseSchema = z.object({
assignmentId: z.string(),
/// true = 同 requestId 重复提交,返回的是**已有批次**,本次没产生任何新写入
duplicate: z.boolean(),
assigned: z.number().int(),
skipped: z.array(AssignmentSkippedSchema),
/// 落库后的批次时效(绝对时刻,前端直接展示,不要自己再算一遍)
expiresAt: z.string(),
});
export type CreateAssignmentResponse = z.infer<typeof CreateAssignmentResponseSchema>;
// ── 批次查询(跟踪的最小形态;完整跟踪见 P5)────────────────────
export const AssignmentBriefSchema = z.object({
id: z.string(),
clinicId: z.string().nullable(),
createdBy: z.string(),
/// 服务端解析好的姓名 —— 前端拿不到 userId→姓名的字典(只覆盖当前登录人)
createdByName: z.string().nullable(),
criteria: z.record(z.string(), z.unknown()),
benefitText: z.string().nullable(),
status: z.string(),
expiresAt: z.string(),
createdAt: z.string(),
/// 以下计数**从 followup_plans 现算**,不是冗余列(立柱等于埋一个会漂的数)
planned: z.number().int().describe('本批分了多少条(活跃版本)'),
inHand: z.number().int().describe('仍挂在客服名下'),
released: z.number().int().describe('已退回'),
agents: z.number().int().describe('涉及几个客服'),
});
export type AssignmentBrief = z.infer<typeof AssignmentBriefSchema>;
export const ListAssignmentsResponseSchema = z.object({
items: z.array(AssignmentBriefSchema),
});
export type ListAssignmentsResponse = z.infer<typeof ListAssignmentsResponseSchema>;
/// 单批里某个客服的承接情况
export const AssignmentAgentStatSchema = z.object({
userId: z.string(),
name: z.string().nullable(),
planned: z.number().int(),
inHand: z.number().int(),
released: z.number().int(),
overdue: z.number().int().describe('仍在手且已过 assignment_expires_at'),
});
export type AssignmentAgentStat = z.infer<typeof AssignmentAgentStatSchema>;
export const AssignmentDetailResponseSchema = AssignmentBriefSchema.extend({
/// ⚠️ 叫 agentStats 不叫 agents:Brief 里的 `agents` 是**人数**(number)。
/// 同名不同型会让前端拿着 brief 的类型去读 detail 时静默拿到 undefined。
agentStats: z.array(AssignmentAgentStatSchema),
/// 退回原因分布。⚠️ 聚合时**必须同时过滤 event='release'** —— 同一列还存着
/// auto_release 的 timeout/clinic_moved 与 feedback 的 up/down,不过滤就全混进来了
releaseReasons: z.array(
z.object({ reason: z.string(), labelZh: z.string(), n: z.number().int() }),
),
/**
* ⚠️ **退回率永远给两个数**:分母是"已处置"(在手 + 已退回之外的都还没动过),
* 而"没动过"的数量本身就是信号(主管据此判断是分多了还是客服没跟)。
* 只给一个百分比会让主管把"没人动"误读成"做得不错"。
*/
untouched: z.number().int().describe('分下去后客服从未打开过详情页的条数'),
});
export type AssignmentDetailResponse = z.infer<typeof AssignmentDetailResponseSchema>;
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