Commit 3cbb3899 by luoqi

feat(plan): 召回分配 P0 地基 —— 权限/退回原因/引擎记账/写路径边界

分配功能开工前的前置修复。这一刀**不含任何新功能**,但把四个
「不修就会静默出错」的洞补上 —— 分配一上线它们会同时被放大。

## P0.1 新增 PLAN_DISPATCH 权限(主管判据)

PLAN_ASSIGN 连 staff 都有(自助认领语义),STATS_VIEW 是零端点零组件的
死权限 —— 现有权限没一条能区分主管,新立一条。只授 leader + admin。

️ 顺带修了 R7,且**原方案不够**:规划只说前端 auth-store 补 merge
permissions,但 GET /auth/session 是把 JWT 里那份快照原样回传的,
merge 了也还是旧清单。真正的口径是 —— **ROLE_PERMISSIONS 是真理源,
JWT 只是缓存**,三处统一改成按 role 现算:
  · PermissionsGuard   (后端判定)
  · GET /auth/session  (前端拿新权限)
  · McpAuthService     (工具条件注册,少一个工具模型只会说"我没这能力")
不改的话,发版当天所有已登录的 leader 在 token 到期前都是 staff 待遇,
不报错不告警。已用伪造的「发版前 token」实测:JWT 里没有 plan:dispatch,
session 仍返回 true。

## P0.2 ReleaseReason(8 值)+ PlanEventReason

退回原因结构化,每个值绑一根**主管可调的杠杆**(lever)——
否则原因分布只是一张好看的饼图。

 类型里**不给 suppressDays 字段**:照抄放弃原因那套抑制窗会把被退回的
患者静默压 30~90 天,池子里凭空少一批人。用类型系统拦住,再加运行时断言。
 不复用 RECALL_FEEDBACK_OPTIONS 的 bad_timing:同名不同义是统计事故的
标准配方(那个指"召回时机不对",这里会被读成"时效太紧")。

PlanEventReason 给 plan_event_logs.reason 列做登记 —— 该列即将同时承载
系统原因与 8 个退回原因,不登记就是第二个「随手写字符串」的地方。

## P0.5 引擎丢归属补记账 —— 实测是 4 处,规划里漏了最大的那处

规划列的是单刷路径 closeStaleActivePlan,但**每日全量跑的批量收尾**
(runAllForHost 的 updateMany)才是量级最大的:它同样会关掉 assigned 的单,
同样零记账。补完四处:
  · unchanged 分支的诊所重归属        → reason=clinic_moved
  · 升版本 clinicMoved 不继承归属     → reason=clinic_moved(planId 记**旧版本**)
  · closeStaleActivePlan(单刷)      → reason=signals_cleared
  · runAllForHost 批量收尾(全量)    → reason=signals_cleared

️ 判定必须在事务**之前**定好:supersede 那句 update 之后 latest.status
已经是 superseded,进了事务再读条件当场失效(内存 mock 暴露了这个别名陷阱,
真 Prisma 返回脱离副本看不出来)。
️ 批量路径顺带修了一个既有隐患:原来是一条 `id: { in: staleIds }`,
PG bind 变量上限 32767,池子上三万条就直接报错。改成 1000 一片、每片自成事务。
️ 无人认领的关闭**一条事件都不写**,否则每日全量会造事件洪峰;
真有洪峰时打一行 warn 说明"这不是 bug"。

## P0.6 recycle 收退回原因 —— T7 此前根本落不了地

controller 写的是 `@Body() _dto`,下划线,收了就扔。客服填了等于没填。
链路三处打通(schema → controller → service),原因落 plan_event_logs.reason、
说明落 details。other 不带说明**服务端**拒绝(不能只信前端)。
 全程不动 snoozedUntil,并在 update 处写死注释 + 单测钉住。

## P0.7 assign/recycle 补诊所硬边界(F4)

findFirst 只校验 host/tenant/sourceUnit,没有 targetClinicId ——
A 诊所 leader 拿到 B 诊所的 planId 就能跨诊所写入。他**看不到**那些单
(读路径的 buildListWhere 明确挡着),但写入不受挡:隔离在写路径上比读路径
弱一档。批量分配会把它从「知道 planId 才能利用」放大成「有 UI、一次几百条」。

## P0.3 / P0.4 / F3

· 删 5 个未挂载的死文件(1,524 行),顺手改 3 处指向它们的过期注释
· MCP toolsCache 从单例改成按**能力指纹**分桶 —— 这是工具条件注册(P3.5)的
  安全前置:不分桶会串号,进程重启后第一个进来的若是客服,全公司主管都拿
  客服清单,反之更糟,且两种错法都不报错。企微那条合成身份路径显式走 basic 桶。
· schema.prisma 的 recycle_at 注释指向不存在的 tenant.rules_config,删掉

## 验证

· 846 单测通过(新增 23 条断言:权限红线 / 抑制窗红线 / 四处记账 / 边界)
· 本地真实数据(5,825 患者 / 2,724 plan)端到端实测:
    跨诊所 assign → 10004 not found;同诊所 → ok
    other 缺说明 → 10001 拒;非法枚举 → 10002 拒
    退回落库 event=release reason=over_capacity held=12s note 在 details
    plan 回到 active,snoozed_until 未被动
· 文档两处实测更正:迁移是 37 个不是 38;data/jvs-dw/users.json 存在且已入库

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent f1b0b4e8
...@@ -1021,8 +1021,12 @@ model FollowupPlan { ...@@ -1021,8 +1021,12 @@ model FollowupPlan {
/// :回收(reclaim)是动作不是状态,执行后 status assigned 改回 active 并清空 assignee /// :回收(reclaim)是动作不是状态,执行后 status assigned 改回 active 并清空 assignee
status String status String
/// 自动回收 deadline(assignment 时锁定 = assignedAt + tenant.rules_config.recycleTimeoutHours) /// 自动回收 deadline(assignment 时锁定 = assignedAt + PlanService.RECYCLE_TIMEOUT_HOURS,默认 24h)
/// 不是聚合, assignment 时确定的固定值;cron 每分钟扫 WHERE status='assigned' AND recycle_at < NOW() /// 不是聚合, assignment 时确定的固定值;cron WHERE status='assigned' AND recycle_at < NOW()
/// ⚠️ 原注释写「= assignedAt + tenant.rules_config.recycleTimeoutHours」——
/// **PAC 全仓没有 Tenant 模型,也没有 rules_config**,那句话会持续误导后来人去找一个不存在的配置。
/// ⚠️ 自动回收当前**未启用**(PAC_PLAN_AUTO_RECYCLE 未配) 本列写了没人读。
/// 门诊经理分配的时效走的是另一条路(plan_assignments.expires_at),与本列互不干扰,别混用。
recycleAt DateTime? @map("recycle_at") @db.Timestamptz(3) recycleAt DateTime? @map("recycle_at") @db.Timestamptz(3)
/// 召回冷静期 / 终态抑制窗 deadline(execution 回写时按 outcome 计算, EXECUTION_OUTCOME_META.suppressDays): /// 召回冷静期 / 终态抑制窗 deadline(execution 回写时按 outcome 计算, EXECUTION_OUTCOME_META.suppressDays):
......
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common'; import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core'; import { Reflector } from '@nestjs/core';
import type { Permission, AccessTokenPayload } from '@pac/types'; import type { Permission, AccessTokenPayload } from '@pac/types';
import { ROLE_PERMISSIONS } from '@pac/types';
import { PERMISSIONS_KEY } from '../decorators/permissions.decorator'; import { PERMISSIONS_KEY } from '../decorators/permissions.decorator';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator'; import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
...@@ -25,7 +26,16 @@ export class PermissionsGuard implements CanActivate { ...@@ -25,7 +26,16 @@ export class PermissionsGuard implements CanActivate {
const user = req.user; const user = req.user;
if (!user) throw new ForbiddenException('No authenticated user'); if (!user) throw new ForbiddenException('No authenticated user');
const granted = new Set(user.permissions ?? []); // ⭐ 按 **role 现算**,不认 JWT 里那份 permissions 快照。
// JWT 的 permissions 是签发那一刻 resolvePermissions(role) 的结果(auth.service 是唯一产地,
// 从没有过"自定义权限集"这条路),access token 2h 不变 —— 于是**新增一个权限的那次发版**,
// 已登录的人会在 token 到期前一直按老清单判定:leader 拿不到 plan:dispatch,
// 前端按钮藏着、MCP 工具集静默少几个,**不报错不告警**,只能靠"让所有人重登"兜。
// 改成现算后 ROLE_PERMISSIONS 是唯一真理源(claim-guard.ts 早已这么写),
// JWT 那份降级为缓存,只在 role 不认识时兜底。
// ⚠️ 不会放大权限:role 本身仍来自签名过的 JWT,这里只是把 role→permissions 的映射
// 从"签发时固化"改成"检查时现算",改角色仍须重新签票。
const granted = new Set<string>(ROLE_PERMISSIONS[user.role] ?? user.permissions ?? []);
const missing = required.filter((p) => !granted.has(p)); const missing = required.filter((p) => !granted.has(p));
if (missing.length > 0) { if (missing.length > 0) {
throw new ForbiddenException(`Missing permissions: ${missing.join(', ')}`); throw new ForbiddenException(`Missing permissions: ${missing.join(', ')}`);
......
...@@ -5,6 +5,7 @@ import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagg ...@@ -5,6 +5,7 @@ import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagg
import type { ModelMessage } from 'ai'; import type { ModelMessage } from 'ai';
import { AssistantService } from './assistant.service'; import { AssistantService } from './assistant.service';
import { TranscribeService } from './transcribe.service'; import { TranscribeService } from './transcribe.service';
import { CurrentUser, type AuthenticatedUser } from '../../common/decorators/current-user.decorator';
/// multer 内存模式的最小文件形状(不引 @types/multer) /// multer 内存模式的最小文件形状(不引 @types/multer)
interface UploadedAudio { interface UploadedAudio {
...@@ -119,7 +120,12 @@ export class AssistantController { ...@@ -119,7 +120,12 @@ export class AssistantController {
@Post('chat') @Post('chat')
@ApiOperation({ summary: '助手对话(SSE)— 模型自主调 PAC MCP 工具' }) @ApiOperation({ summary: '助手对话(SSE)— 模型自主调 PAC MCP 工具' })
async chat(@Req() req: Request, @Res() res: Response, @Body() body: ChatBody): Promise<void> { async chat(
@Req() req: Request,
@Res() res: Response,
@Body() body: ChatBody,
@CurrentUser() user: AuthenticatedUser,
): Promise<void> {
const token = (req.headers['authorization'] ?? '').replace(/^Bearer\s+/i, ''); const token = (req.headers['authorization'] ?? '').replace(/^Bearer\s+/i, '');
res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Content-Type', 'text/event-stream');
...@@ -145,6 +151,9 @@ export class AssistantController { ...@@ -145,6 +151,9 @@ export class AssistantController {
userToken: token, userToken: token,
modelId: body.model, modelId: body.model,
messages: body.messages ?? [], messages: body.messages ?? [],
// 只用于 MCP 工具清单的缓存分桶(见 mcpCapabilityKey)——
// 不传的话主管和客服会共用一份缓存,谁先进来谁的清单被全员复用。
permissions: user.permissions,
abortSignal: ac.signal, abortSignal: ac.signal,
}); });
......
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { streamText, tool, jsonSchema, stepCountIs, type ModelMessage, type ToolSet } from 'ai'; import { streamText, tool, jsonSchema, stepCountIs, type ModelMessage, type ToolSet } from 'ai';
import { Permission } from '@pac/types';
import { AiProviderService } from '../ai/core/ai-provider.service'; import { AiProviderService } from '../ai/core/ai-provider.service';
import { McpClientService } from './mcp-client.service'; import { McpClientService } from './mcp-client.service';
...@@ -37,10 +38,27 @@ export interface AssistantChatInput { ...@@ -37,10 +38,27 @@ export interface AssistantChatInput {
messages: ModelMessage[]; messages: ModelMessage[];
/** 追加到 system 的渠道特定指令(如企微:纯文字、链接格式、多轮指代)。 */ /** 追加到 system 的渠道特定指令(如企微:纯文字、链接格式、多轮指代)。 */
systemExtra?: string; systemExtra?: string;
/**
* 调用人的权限清单 —— 只用来算 MCP 工具清单的**缓存分桶键**,不在本层做权限判定
* (真正的判定在 MCP server 的条件注册与各 handler 里)。
* 不传 = 按无特权处理:宁可少给工具,也不能把主管的工具漏给客服。
*/
permissions?: readonly string[];
abortSignal?: AbortSignal; abortSignal?: AbortSignal;
} }
/** /**
* 能力指纹 —— 决定 MCP 工具清单缓存分到哪个桶。
*
* ⚠️ 凡是**会改变工具清单**的权限,都必须列进来,否则两类人会共用一份缓存(串号且不报错)。
* 现在只有 `plan:dispatch` 影响清单(主管多出分配/跟踪工具);将来再有条件注册的工具,
* 加权限的同时**必须**在这里加一位,并在 mcp-server.factory 的条件注册处对齐。
*/
export function mcpCapabilityKey(permissions: readonly string[] | undefined): string {
return permissions?.includes(Permission.PLAN_DISPATCH) ? 'dispatch' : 'basic';
}
/**
* AssistantService — 独立的"外部 agent"模拟器(不复用 AiCall 单发框架)。 * AssistantService — 独立的"外部 agent"模拟器(不复用 AiCall 单发框架)。
* *
* 模型自主决定调哪些工具(model-driven tool-calling,非强制工作流): * 模型自主决定调哪些工具(model-driven tool-calling,非强制工作流):
...@@ -61,7 +79,10 @@ export class AssistantService { ...@@ -61,7 +79,10 @@ export class AssistantService {
// 未导出的 Output 类型导致的 .d.ts 命名失败(TS4053)。 // 未导出的 Output 类型导致的 .d.ts 命名失败(TS4053)。
async chat(input: AssistantChatInput): Promise<{ fullStream: AsyncIterable<unknown> }> { async chat(input: AssistantChatInput): Promise<{ fullStream: AsyncIterable<unknown> }> {
// 1. 动态拉 MCP 工具 → 转成 AI SDK tools(execute 回调真 MCP 调用,带用户 token) // 1. 动态拉 MCP 工具 → 转成 AI SDK tools(execute 回调真 MCP 调用,带用户 token)
const mcpTools = await this.mcp.listTools(input.userToken); const mcpTools = await this.mcp.listTools(
input.userToken,
mcpCapabilityKey(input.permissions),
);
const tools: ToolSet = {}; const tools: ToolSet = {};
for (const t of mcpTools) { for (const t of mcpTools) {
tools[t.name] = tool({ tools[t.name] = tool({
......
...@@ -21,8 +21,21 @@ export class McpClientService { ...@@ -21,8 +21,21 @@ export class McpClientService {
// 异常拓扑(反代/独立部署)时用 PAC_MCP_URL 覆盖。 // 异常拓扑(反代/独立部署)时用 PAC_MCP_URL 覆盖。
private readonly url = private readonly url =
process.env.PAC_MCP_URL ?? `http://127.0.0.1:${process.env.PORT ?? '3001'}/pac/v1/mcp`; process.env.PAC_MCP_URL ?? `http://127.0.0.1:${process.env.PORT ?? '3001'}/pac/v1/mcp`;
// 工具清单是静态的(6 个工具与租户无关,scope 只影响执行)→ 进程级缓存,省每轮 tools/list 往返。 /**
private toolsCache: McpToolDef[] | null = null; * 工具清单缓存 —— **按能力分桶**,不是一个全局清单。
*
* ⚠️ 原实现是 `McpToolDef[] | null` 单例。工具清单当时确实与调用人无关(全只读、全员一样),
* 但分配功能要按 `plan:dispatch` **条件注册**工具(主管多几个)——
* 单例缓存这时会当场串号:进程重启后第一个进来的若是客服,
* 缓存下客服的短清单,**全公司的主管在缓存失效前都拿不到分配工具**;
* 反过来更糟,客服会拿到主管的工具清单。
* 而且两种错法都**不报错**:模型只会说"我没有这个能力",或者调了工具被服务端拒绝。
*
* key 用调用方给的**能力指纹**(如 'dispatch' / 'basic')。
* ⚠️ 刻意不在本类里解 JWT 算指纹:这是个纯 HTTP 客户端,让它认识权限模型
* 就等于把权限判定散到第二个地方去。谁调谁负责给 key。
*/
private readonly toolsCache = new Map<string, McpToolDef[]>();
private async rpc(token: string, method: string, params?: unknown): Promise<Record<string, unknown>> { private async rpc(token: string, method: string, params?: unknown): Promise<Record<string, unknown>> {
const res = await fetch(this.url, { const res = await fetch(this.url, {
...@@ -42,11 +55,18 @@ export class McpClientService { ...@@ -42,11 +55,18 @@ export class McpClientService {
return (msg.result ?? {}) as Record<string, unknown>; return (msg.result ?? {}) as Record<string, unknown>;
} }
async listTools(token: string): Promise<McpToolDef[]> { /**
if (this.toolsCache) return this.toolsCache; * @param capabilityKey 能力指纹 —— **相同 key 的人必须拿到相同的工具清单**。
* 由调用方按权限算(见 assistant.service.ts 的 mcpCapabilityKey)。
* 传 `undefined` 会退回单一全局桶,只在确定工具清单与权限无关时才这么用。
*/
async listTools(token: string, capabilityKey = 'default'): Promise<McpToolDef[]> {
const hit = this.toolsCache.get(capabilityKey);
if (hit) return hit;
const r = await this.rpc(token, 'tools/list'); const r = await this.rpc(token, 'tools/list');
this.toolsCache = (r.tools as McpToolDef[]) ?? []; const tools = (r.tools as McpToolDef[]) ?? [];
return this.toolsCache; this.toolsCache.set(capabilityKey, tools);
return tools;
} }
/** 调工具 → 返回纯文本结果(MCP content[].text 拼接);isError 时抛出供 agent 看到。 */ /** 调工具 → 返回纯文本结果(MCP content[].text 拼接);isError 时抛出供 agent 看到。 */
......
...@@ -128,7 +128,11 @@ export class AuthController { ...@@ -128,7 +128,11 @@ export class AuthController {
hostId: user.hostId, hostId: user.hostId,
tenantId: user.tenantId, tenantId: user.tenantId,
role: user.role, role: user.role,
permissions: user.permissions, // ⭐ 按 role 现算,不回传 JWT 里那份快照 —— 与 PermissionsGuard 同源(见那里的长注释)。
// 前端 auth-store.loadSession 会 merge 这个字段,所以发版新增权限后**不必重登**:
// 下一次 loadSession 就把新权限带回来,与服务端判定口径一致。
// 回传 user.permissions 则前后端会一起停在旧清单上,merge 也救不了。
permissions: this.auth.resolvePermissions(user.role),
orgScope: user.orgScope, orgScope: user.orgScope,
clinicIds: scope.clinicIds, // 拦截器已按 host org 树展开 clinicIds: scope.clinicIds, // 拦截器已按 host org 树展开
sourceUnits: scope.sourceUnits, sourceUnits: scope.sourceUnits,
......
import { Injectable, UnauthorizedException } from '@nestjs/common'; import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import type { AccessTokenPayload } from '@pac/types'; import type { AccessTokenPayload } from '@pac/types';
import { ROLE_PERMISSIONS } from '@pac/types';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator'; import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';
import { OrgTreeService } from '../auth/org-tree'; import { OrgTreeService } from '../auth/org-tree';
...@@ -50,7 +51,10 @@ export class McpAuthService { ...@@ -50,7 +51,10 @@ export class McpAuthService {
sourceUnits, sourceUnits,
userId: payload.sub, userId: payload.sub,
}, },
permissions: payload.permissions ?? [], // ⭐ 按 role 现算,与 PermissionsGuard / GET /auth/session 同源(见 permissions.guard 长注释)。
// MCP 这条路尤其要现算:工具清单是按能力**条件注册**的,权限少一个 = 助手手里少几个工具,
// 模型只会说"我没有这个能力",既不报错也看不出是 token 过期。
permissions: ROLE_PERMISSIONS[payload.role] ?? payload.permissions ?? [],
}; };
} }
} }
import type { Prisma } from '@prisma/client'; import type { Prisma } from '@prisma/client';
import { PlanEventType } from '@pac/types'; import { PlanEventType } from '@pac/types';
import type { PlanEventReasonValue } from '@pac/types';
/** /**
* PlanEventLog 的**唯一写入口**。 * PlanEventLog 的**唯一写入口**。
...@@ -31,6 +32,36 @@ import { PlanEventType } from '@pac/types'; ...@@ -31,6 +32,36 @@ import { PlanEventType } from '@pac/types';
*/ */
export type PlanEventLogWriter = Pick<Prisma.TransactionClient, 'planEventLog'>; export type PlanEventLogWriter = Pick<Prisma.TransactionClient, 'planEventLog'>;
/**
* 批量写事件(每日全量重算 / 批量分配用)。
*
* 为什么要有:上面那句「本函数是唯一写入口」不能只是口号 —— 批量场景逐条 `create` 会打出
* 上千次往返,写的人一定会绕过去直接 `createMany`,当场破功。给一个批量口子,
* 纪律才守得住(**并且它内部就是 createMany,没有性能理由再绕**)。
*
* ⚠️ 调用方负责分片:PG 一条语句的 bind 变量上限 32767,本表每行 ~9 个变量 → 单批别超 3000。
*/
export function recordPlanEventsBulk(
tx: PlanEventLogWriter,
inputs: PlanEventInput[],
): Promise<unknown> {
if (inputs.length === 0) return Promise.resolve(null);
return tx.planEventLog.createMany({
data: inputs.map((input) => ({
hostId: input.hostId,
tenantId: input.tenantId,
planId: input.planId,
patientId: input.patientId,
event: input.event,
assigneeUserId: input.assigneeUserId ?? null,
actorUserId: input.actorUserId ?? null,
heldSeconds: input.heldSeconds ?? null,
reason: input.reason ?? null,
details: input.details ?? undefined,
})),
});
}
export interface PlanEventInput { export interface PlanEventInput {
hostId: string; hostId: string;
tenantId: string; tenantId: string;
...@@ -43,8 +74,12 @@ export interface PlanEventInput { ...@@ -43,8 +74,12 @@ export interface PlanEventInput {
actorUserId?: string | null; actorUserId?: string | null;
/** 归属持续秒数 —— 见 computeHeldSeconds 的注释 */ /** 归属持续秒数 —— 见 computeHeldSeconds 的注释 */
heldSeconds?: number | null; heldSeconds?: number | null;
/** 简短原因(立柱,可直接过滤):auto_release='timeout';feedback='up'|'down' */ /**
reason?: string | null; * 简短原因(立柱,可直接过滤)。
* ⚠️ 取值必须来自 [[PlanEventReasonValue]](系统原因 ∪ 客服退回原因)—— 不收裸 string,
* 否则这一列会变成第二个「随手写字符串」的地方,而它正是退回原因分布的唯一数据源。
*/
reason?: PlanEventReasonValue | null;
/** 事件专属细节(不按它查询的内容,如反馈文字) */ /** 事件专属细节(不按它查询的内容,如反馈文字) */
details?: Prisma.InputJsonObject | null; details?: Prisma.InputJsonObject | null;
} }
......
...@@ -124,12 +124,21 @@ export class PlanController { ...@@ -124,12 +124,21 @@ export class PlanController {
@TenantScope() scope: TenantScopeContext, @TenantScope() scope: TenantScopeContext,
@CurrentUser() user: AuthenticatedUser, @CurrentUser() user: AuthenticatedUser,
@Param('id') id: string, @Param('id') id: string,
@Body() _dto: RecyclePlanRequestDto, @Body() dto: RecyclePlanRequestDto,
) { ) {
// 能不能返**别人**的单,看有没有 PLAN_VIEW_ALL(leader/admin 有,staff 没有)—— // 能不能返**别人**的单,看有没有 PLAN_VIEW_ALL(leader/admin 有,staff 没有)——
// staff 现在也能返池,但只能退自己认领的,校验在 service 里(见 recycle 注释)。 // staff 现在也能返池,但只能退自己认领的,校验在 service 里(见 recycle 注释)。
const canRecycleOthers = user.permissions.includes(Permission.PLAN_VIEW_ALL); const canRecycleOthers = user.permissions.includes(Permission.PLAN_VIEW_ALL);
await this.plans.recycle(scope, id, user.sub, canRecycleOthers); // ⚠️ 这里原本写的是 `@Body() _dto` —— 下划线,收了就扔。退回原因在链路第一步蒸发,
// 「客服为什么不接这单」永远统计不出来。透传下去别再丢。
await this.plans.recycle(
scope,
id,
user.sub,
canRecycleOthers,
dto.releaseReason,
dto.releaseNote,
);
return { ok: true as const }; return { ok: true as const };
} }
......
...@@ -9,6 +9,8 @@ import type { Prisma } from '@prisma/client'; ...@@ -9,6 +9,8 @@ import type { Prisma } from '@prisma/client';
import { import {
Permission, Permission,
PlanEventType, PlanEventType,
RELEASE_REASON_META,
type ReleaseReason,
planScenarioLabel, planScenarioLabel,
subLabelZh, subLabelZh,
applyLiveDays, applyLiveDays,
...@@ -47,7 +49,37 @@ import type { ScriptAgentIdentity } from '../ai/calls/draft-plan-script/shared/a ...@@ -47,7 +49,37 @@ import type { ScriptAgentIdentity } from '../ai/calls/draft-plan-script/shared/a
* 注:demo 路径不走本服务(走 PlanAggregateService 聚合),本服务用于真实工作台 CRUD * 注:demo 路径不走本服务(走 PlanAggregateService 聚合),本服务用于真实工作台 CRUD
*/ */
const RECYCLE_TIMEOUT_HOURS = 24; // assignment 后 24h 未结案自动回收(后续接 tenant 配置) /// assignment 后多久未结案自动回收。
/// ⚠️ 别再基于「这个值应该可配」去做 env 化/配置化 —— 自动回收生产**未启用**
/// (PAC_PLAN_AUTO_RECYCLE 未配),这个常量当前没有任何读者会真正生效;
/// 而分配的时效走的是另一条路(plan_assignments.expires_at / assignment_expires_at),
/// 与它互不干扰。原注释写的「后续接 tenant 配置」会误导人 —— PAC 全仓没有 Tenant 模型。
const RECYCLE_TIMEOUT_HOURS = 24;
/**
* 写路径的诊所硬边界 —— assign / recycle 这类**按 planId 直接改**的操作专用。
*
* 为什么必须有(2026-08 补):列表侧 `buildListWhere` 早就写死了「orgScope→clinicIds 是硬边界,
* 始终生效」,但 `assign()` / `recycle()` 的 findFirst 只校验了 host/tenant/sourceUnit,
* **没有 targetClinicId** —— 于是 A 诊所的 leader 只要拿到 B 诊所的 planId,
* 就能把 B 的单分给自己诊所的客服。他在界面上**看不到**那些单(读路径挡着),
* 但写入不受挡。多租户隔离在写路径上比读路径弱一档,正好是最不该弱的地方。
*
* 批量分配会把这个洞从「知道 planId 才能利用」放大成「有 UI、一次几百条」,故先补上。
*
* `null` 也要放行:targetClinicId=null 是集团统一客服池(VIP 中心 / 中央营销),
* 不属于任何诊所,窄 scope 也该能操作 —— 与 buildListWhere 的 OR 写法保持一致。
* (Prisma 的 `in` 不接受 null,所以只能写成 OR;这也是列表侧那样写的原因。)
*
* 包一层 `AND` 是为了**可组合**:调用方的 where 里如果已经有自己的 OR,
* 直接铺一个顶层 OR 会把两组条件并成"或"关系 —— 边界当场失效且看不出来。
*/
function clinicBoundary(scope: TenantScopeContext): Prisma.FollowupPlanWhereInput {
if (!scope.clinicIds.length) return {}; // 空 = 集团级不限,不加过滤
return {
AND: [{ OR: [{ targetClinicId: { in: scope.clinicIds } }, { targetClinicId: null }] }],
};
}
/** /**
* 「上次医生 / 偏好医生」候选名单的缓存 key —— **导出**给批量重算 CLI 收尾时删。 * 「上次医生 / 偏好医生」候选名单的缓存 key —— **导出**给批量重算 CLI 收尾时删。
...@@ -574,6 +606,7 @@ export class PlanService { ...@@ -574,6 +606,7 @@ export class PlanService {
where: { where: {
id: planId, id: planId,
...(scope.sourceUnits.length ? { patient: { sourceUnit: { in: scope.sourceUnits } } } : {}), ...(scope.sourceUnits.length ? { patient: { sourceUnit: { in: scope.sourceUnits } } } : {}),
...clinicBoundary(scope), // ⭐ 诊所硬边界,见该函数注释
}, },
select: { select: {
id: true, hostId: true, tenantId: true, status: true, id: true, hostId: true, tenantId: true, status: true,
...@@ -641,11 +674,20 @@ export class PlanService { ...@@ -641,11 +674,20 @@ export class PlanService {
planId: string, planId: string,
actorUserId?: string, actorUserId?: string,
canRecycleOthers = true, canRecycleOthers = true,
/**
* 退回原因(结构化)。**不是** recallFeedback —— 那个回答「这条召回准不准」(冲算法去的),
* 这个回答「我为什么不接这单」(冲派单去的)。两者可以同时填,统计口径互不相干。
* 见 [[ReleaseReason]] 的三条边界。
*/
releaseReason?: ReleaseReason,
/** 原因的文字补充;`other` 必填(RELEASE_REASON_META.other.needNote) */
releaseNote?: string,
): Promise<void> { ): Promise<void> {
const plan = await this.prisma.followupPlan.findFirst({ const plan = await this.prisma.followupPlan.findFirst({
where: { where: {
id: planId, id: planId,
...(scope.sourceUnits.length ? { patient: { sourceUnit: { in: scope.sourceUnits } } } : {}), ...(scope.sourceUnits.length ? { patient: { sourceUnit: { in: scope.sourceUnits } } } : {}),
...clinicBoundary(scope), // ⭐ 诊所硬边界,见该函数注释
}, },
select: { select: {
id: true, hostId: true, tenantId: true, status: true, id: true, hostId: true, tenantId: true, status: true,
...@@ -662,6 +704,16 @@ export class PlanService { ...@@ -662,6 +704,16 @@ export class PlanService {
} }
// 归属闸:staff 只能退自己的单(口径与用例见 claim-guard.assertCanRecycle) // 归属闸:staff 只能退自己的单(口径与用例见 claim-guard.assertCanRecycle)
assertCanRecycle(plan, actorUserId, canRecycleOthers); assertCanRecycle(plan, actorUserId, canRecycleOthers);
// 选了「其他原因」就必须写清楚 —— **服务端也拦,不只信前端**。
// 落点同 execution.service 的「选 inaccurate 必须勾具体治疗」:
// 这类必填一旦只做在前端,换个客户端(助手 / 脚本 / 企微)就绕过去了,
// 而 other 不带说明等于没填,退回原因分布里会堆出一坨读不出信息的 other。
const note = releaseNote?.trim() || undefined;
if (releaseReason != null && RELEASE_REASON_META[releaseReason].needNote && !note) {
throw new BadRequestException(
`退回原因「${RELEASE_REASON_META[releaseReason].labelZh}」必须填写说明`,
);
}
// 只在**确实挂着人**时记账:active 且无 assignee 的"返池"是空操作,记了是噪声 // 只在**确实挂着人**时记账:active 且无 assignee 的"返池"是空操作,记了是噪声
const hadAssignee = plan.assigneeUserId != null; const hadAssignee = plan.assigneeUserId != null;
// ⭐ 在清空 assignedAt 之前算(见 computeHeldSeconds 注释) // ⭐ 在清空 assignedAt 之前算(见 computeHeldSeconds 注释)
...@@ -674,6 +726,11 @@ export class PlanService { ...@@ -674,6 +726,11 @@ export class PlanService {
assigneeUserId: null, assigneeUserId: null,
assignedAt: null, assignedAt: null,
recycleAt: null, recycleAt: null,
// ⛔⛔ **绝不要在这里动 snoozedUntil**。
// 退回 = "换个人来做",不是"这人别召了"。照抄放弃原因那套抑制窗
// (ABANDON_REASON_META.suppressDays)会把被退回的患者静默压 30~90 天 ——
// 池子里凭空少一批人,没有任何报错,且正好是 T5「剩下的不是遗漏,是还没轮到」的反面。
// ReleaseReason 的类型里刻意不给 suppressDays 字段,就是为了让这件事写不出来。
}, },
}); });
if (hadAssignee) { if (hadAssignee) {
...@@ -686,6 +743,12 @@ export class PlanService { ...@@ -686,6 +743,12 @@ export class PlanService {
assigneeUserId: null, // 释放后无人归属 assigneeUserId: null, // 释放后无人归属
actorUserId: actorUserId ?? null, actorUserId: actorUserId ?? null,
heldSeconds, heldSeconds,
// ⭐ 退回原因落**事件表**:主表(将来的 release_reason 列)只存当前值、会被下一次退回覆盖,
// 而"退回原因分布"要的是全部历史。同一条 plan 被退三次的三个原因都得留下。
// ⚠️ 聚合这一列时**必须同时过滤 event='release'** —— 否则会混进 auto_release 的
// timeout/clinic_moved 和 feedback 的 up/down。
reason: releaseReason ?? null,
details: note ? { note } : null,
}); });
} }
}); });
......
import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule'; import { Cron, CronExpression } from '@nestjs/schedule';
import { PlanEventType } from '@pac/types'; import { PlanEventType, PlanEventReason } from '@pac/types';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import { recordPlanEvent, computeHeldSeconds } from './plan-event.recorder'; import { recordPlanEvent, computeHeldSeconds } from './plan-event.recorder';
...@@ -95,7 +95,7 @@ export class RecycleSchedulerService implements OnModuleInit { ...@@ -95,7 +95,7 @@ export class RecycleSchedulerService implements OnModuleInit {
assigneeUserId: null, assigneeUserId: null,
actorUserId: null, // 系统行为,无操作人 actorUserId: null, // 系统行为,无操作人
heldSeconds: computeHeldSeconds(p.assignedAt, now), heldSeconds: computeHeldSeconds(p.assignedAt, now),
reason: 'timeout', reason: PlanEventReason.TIMEOUT,
}); });
}); });
recycled++; recycled++;
......
...@@ -138,6 +138,9 @@ export class WeixinAibotService implements OnModuleInit, OnModuleDestroy { ...@@ -138,6 +138,9 @@ export class WeixinAibotService implements OnModuleInit, OnModuleDestroy {
userToken: token, userToken: token,
messages: history, messages: history,
systemExtra: this.wxSystemExtra, systemExtra: this.wxSystemExtra,
// ⚠️ 刻意不传 permissions → 走 'basic' 桶。企微这条路用的是 mintToken 造的**合成身份**
// (见 mintToken),不是真人登录态;给它主管工具清单等于把分配能力挂到一个
// 谁都能在群里 @ 出来的机器人上。真身份映射做完之前,这里就该是最小权限。
abortSignal: ac.signal, abortSignal: ac.signal,
}); });
......
import {
Permission,
ROLE_PERMISSIONS,
UserRole,
ReleaseReason,
RELEASE_REASON_META,
ReleaseReasonSchema,
releaseReasonsForForm,
PlanEventReason,
ABANDON_REASON_META,
RECALL_FEEDBACK_OPTIONS,
} from '@pac/types';
/**
* 召回分配 · 地基枚举回归(P0.1 + P0.2)。
*
* 这个文件锁的都是「改错了不会有任何编译错误」的地方 —— 全是运行时语义,
* 类型系统救不了,只能靠断言。
*/
describe('PLAN_DISPATCH —— 主管判据', () => {
test('⭐ 红线:staff 永远不能有 plan:dispatch', () => {
// 加给 staff 就跟 PLAN_ASSIGN 一样废掉 —— 那条权限 staff 也有,
// 正是"不能用它区分主管"的原因,新权限不能重蹈覆辙。
expect(ROLE_PERMISSIONS[UserRole.STAFF]).not.toContain(Permission.PLAN_DISPATCH);
});
test('leader / admin 都有', () => {
expect(ROLE_PERMISSIONS[UserRole.LEADER]).toContain(Permission.PLAN_DISPATCH);
expect(ROLE_PERMISSIONS[UserRole.ADMIN]).toContain(Permission.PLAN_DISPATCH);
});
test('⭐ 现有权限里没有任何一条能替代它当主管判据', () => {
// PLAN_ASSIGN:staff 也有(自助认领语义)→ 区分不了
expect(ROLE_PERMISSIONS[UserRole.STAFF]).toContain(Permission.PLAN_ASSIGN);
// PLAN_RECYCLE:同上
expect(ROLE_PERMISSIONS[UserRole.STAFF]).toContain(Permission.PLAN_RECYCLE);
// 于是 leader 相对 staff 只多这几条,PLAN_DISPATCH 是其中唯一为分配而立的
const extra = ROLE_PERMISSIONS[UserRole.LEADER].filter(
(p) => !ROLE_PERMISSIONS[UserRole.STAFF].includes(p),
);
expect(extra).toContain(Permission.PLAN_DISPATCH);
});
});
describe('ReleaseReason —— 退回原因', () => {
test('枚举与 zod schema 值域必须一致(加了枚举忘了改 schema → 服务端 400)', () => {
expect([...ReleaseReasonSchema.options].sort()).toEqual([...Object.values(ReleaseReason)].sort());
});
test('每个值都在 META 里登记,且 META 无孤儿键', () => {
expect(Object.keys(RELEASE_REASON_META).sort()).toEqual([...Object.values(ReleaseReason)].sort());
for (const k of Object.values(ReleaseReason)) {
expect(RELEASE_REASON_META[k].labelZh.length).toBeGreaterThan(0);
}
});
test('⭐⭐ 红线:退回原因绝不能带抑制窗', () => {
// 退回 = "换个人来做",不是"别做了"。照抄 ABANDON_REASON_META 的 suppressDays
// 会把被退回的患者静默压 30~90 天 —— 池子里凭空少一批人,且没有任何报错。
// TS 类型里已经不给这个字段,这里再从运行时锁一道(防有人改类型时顺手加回来)。
for (const k of Object.values(ReleaseReason)) {
expect(RELEASE_REASON_META[k]).not.toHaveProperty('suppressDays');
}
// 对照:放弃原因是有抑制窗的,两者语义不同不能混用
expect(ABANDON_REASON_META.wrong_number.suppressDays).toBeGreaterThan(0);
});
test('⭐ 与召回反馈值域不重叠 —— 同名不同义是统计事故的标准配方', () => {
// RECALL_FEEDBACK_OPTIONS.bad_timing = "召回时机不对(太早/太晚)",冲算法去的;
// 退回的"时效太紧"冲派单去的。故本枚举刻意不设 bad_timing。
const feedbackValues = RECALL_FEEDBACK_OPTIONS.map((o) => o.value as string);
for (const k of Object.values(ReleaseReason)) {
if (k === ReleaseReason.OTHER) continue; // other 是通用兜底键,允许同名
expect(feedbackValues).not.toContain(k);
}
});
test('other 必须填说明', () => {
expect(RELEASE_REASON_META.other.needNote).toBe(true);
// 其余的都不强制填,否则退回摩擦过大,客服会改走"关闭机会"污染放弃统计
const needNote = Object.values(ReleaseReason).filter((k) => RELEASE_REASON_META[k].needNote);
expect(needNote).toEqual([ReleaseReason.OTHER]);
});
test('每个原因都指向一根可调杠杆(否则原因分布只是好看的饼图)', () => {
for (const k of Object.values(ReleaseReason)) {
const { lever } = RELEASE_REASON_META[k];
expect(lever).toBeTruthy();
// 只有 other 允许无法归因
if (k !== ReleaseReason.OTHER) expect(lever).not.toBe('none');
}
});
test('表单清单过滤 hidden,且顺序 = 声明顺序', () => {
const shown = releaseReasonsForForm();
expect(shown).toEqual(Object.keys(RELEASE_REASON_META).filter(
(k) => !RELEASE_REASON_META[k as ReleaseReason].hidden,
));
expect(shown.length).toBe(8);
});
});
describe('PlanEventReason —— plan_event_logs.reason 取值登记', () => {
test('存量取值仍在(改名会让历史事件读不出来)', () => {
// 这三个生产库里已有数据,枚举里的字面量**不能改**
expect(PlanEventReason.UP).toBe('up');
expect(PlanEventReason.DOWN).toBe('down');
expect(PlanEventReason.TIMEOUT).toBe('timeout');
});
test('引擎补记账要用的两个新原因已登记', () => {
expect(PlanEventReason.CLINIC_MOVED).toBe('clinic_moved');
expect(PlanEventReason.SIGNALS_CLEARED).toBe('signals_cleared');
});
test('⭐ 系统原因与退回原因不得撞值 —— 撞了聚合就串了', () => {
// 两者共用 reason 一列:auto_release 事件带系统原因,release 事件带退回原因。
// 若有同值,「退回原因分布」的 SQL 即使按 event 过滤对了,人读也会混。
const sys = Object.values(PlanEventReason) as string[];
for (const r of Object.values(ReleaseReason)) {
expect(sys).not.toContain(r as string);
}
});
});
...@@ -167,21 +167,40 @@ function makeStore(seed: { plans?: Partial<Plan>[]; personas?: { patientId: stri ...@@ -167,21 +167,40 @@ function makeStore(seed: { plans?: Partial<Plan>[]; personas?: { patientId: stri
// unchanged 分支的 reason 就地刷新会用到(见 reason-refresh.ts) // unchanged 分支的 reason 就地刷新会用到(见 reason-refresh.ts)
const planReason = { update: jest.fn(async (_args: unknown) => ({})) }; const planReason = { update: jest.fn(async (_args: unknown) => ({})) };
// 归属账本(2026-08 引擎补记账后引擎会往这里写):收集下来供断言
const events: Array<Record<string, unknown>> = [];
const planEventLog = {
create: jest.fn(async ({ data }: { data: Record<string, unknown> }) => {
events.push(data);
return data;
}),
createMany: jest.fn(async ({ data }: { data: Array<Record<string, unknown>> }) => {
events.push(...data);
return { count: data.length };
}),
};
const prisma = { const prisma = {
followupPlan, followupPlan,
planReason, planReason,
planEventLog,
persona, persona,
planGenerationLog, planGenerationLog,
// 最后到诊诊所查询(prefetchForBatch):本套件不测归属 → 返空 = 回退兜底 head.targetClinicId // 最后到诊诊所查询(prefetchForBatch):本套件不测归属 → 返空 = 回退兜底 head.targetClinicId
$queryRaw: jest.fn(async (): Promise<Array<{ patient_id: string; clinic_id: string }>> => []), $queryRaw: jest.fn(async (): Promise<Array<{ patient_id: string; clinic_id: string }>> => []),
$transaction: jest.fn(async (cb: (tx: unknown) => Promise<unknown>) => $transaction: jest.fn(async (cb: (tx: unknown) => Promise<unknown>) =>
cb({ cb({
followupPlan: { update: followupPlan.update, create: followupPlan.create }, followupPlan: {
update: followupPlan.update,
updateMany: followupPlan.updateMany,
create: followupPlan.create,
},
planReason: { update: planReason.update }, planReason: { update: planReason.update },
planEventLog,
}), }),
), ),
}; };
return { prisma, plans, logs, planReason }; return { prisma, plans, logs, planReason, events };
} }
function makeScenario(hits: ScenarioHit[]) { function makeScenario(hits: ScenarioHit[]) {
...@@ -558,3 +577,158 @@ describe('跟进诊所归属 = 患者最后到诊诊所(与诊断诊所解耦)', ...@@ -558,3 +577,158 @@ describe('跟进诊所归属 = 患者最后到诊诊所(与诊断诊所解耦)',
expect(v2.assigneeUserId).toBe('staff-3'); // ⭐ 继承不回归 expect(v2.assigneeUserId).toBe('staff-3'); // ⭐ 继承不回归
}); });
}); });
// =============================================================
// 2026-08 · 引擎丢归属必须留账(召回分配 P0.5 / 教条 4.35)
// =============================================================
//
// 为什么这组断言值钱:引擎"悄悄把单从客服手里收走"是**没有任何报错**的行为 ——
// 主管分了 50 单,重算收回一部分,主管毫不知情;且这些单既没 release 也没 auto_release,
// 退回率的分母天生偏小。分配功能上线后这会直接体现为"分下去的单莫名其妙消失"。
describe('归属账本 — 引擎释放归属必须记 auto_release', () => {
const ledger = (events: Array<Record<string, unknown>>) =>
events.filter((e) => e.event === 'auto_release');
test('⭐ unchanged + 诊所重归属收走归属 → 记 clinic_moved,持有时长算得出来', async () => {
const { prisma, events } = makeStore({
plans: [
{
id: 'p-x',
patientId: 'pat-a',
version: 1,
status: 'assigned',
assigneeUserId: 'staff-1',
assignedAt: new Date(NOW.getTime() - 7200_000), // 2 小时前
targetClinicId: 'clinic-old',
reasons: [{ scenario: SCEN, subKey: 'missing_tooth@whole' }],
},
],
});
prisma.$queryRaw = jest.fn(async () => [{ patient_id: 'pat-a', clinic_id: 'clinic-new' }]);
await engine(
prisma,
makeScenario([hit('pat-a', 'missing_tooth@whole', 50, 'clinic-diag')]),
).runAllForHost({ hostId: HOST, tenantId: TENANT, now: NOW });
const rows = ledger(events);
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({
planId: 'p-x',
patientId: 'pat-a',
reason: 'clinic_moved',
assigneeUserId: null, // 释放后无人归属
actorUserId: null, // 系统行为
});
// ⭐ heldSeconds 必须在清空 assignedAt **之前**算出来,清完就永远算不出来了
expect(rows[0]!.heldSeconds).toBe(7200);
});
test('⭐ 升版本 + 归属不继承 → 记 clinic_moved,且 planId 记在**旧版本**上', async () => {
const { prisma, events, plans } = makeStore({
plans: [
{
id: 'p-y',
patientId: 'pat-b',
version: 1,
status: 'assigned',
assigneeUserId: 'staff-2',
assignedAt: new Date(NOW.getTime() - 3600_000),
targetClinicId: 'clinic-old',
reasons: [{ scenario: SCEN, subKey: 'caries@11' }], // 与新 hit 不同 → 升版本
},
],
});
prisma.$queryRaw = jest.fn(async () => [{ patient_id: 'pat-b', clinic_id: 'clinic-new' }]);
await engine(
prisma,
makeScenario([hit('pat-b', 'missing_tooth@whole', 60, 'clinic-diag')]),
).runAllForHost({ hostId: HOST, tenantId: TENANT, now: NOW });
const rows = ledger(events);
expect(rows).toHaveLength(1);
// 归属本来就挂在旧版上;记到新版会让"这个客服持有过哪些单"的回溯全部对不上
expect(rows[0]!.planId).toBe('p-y');
const v2 = plans.find((x) => x.patientId === 'pat-b' && x.version === 2)!;
expect(rows[0]!.planId).not.toBe(v2.id);
expect(rows[0]!.reason).toBe('clinic_moved');
});
test('⭐ 信号清零关闭已认领的单 → 记 signals_cleared', async () => {
const { prisma, events } = makeStore({
plans: [
{
id: 'p-z',
patientId: 'pat-gone',
version: 1,
status: 'assigned',
assigneeUserId: 'staff-3',
assignedAt: new Date(NOW.getTime() - 1800_000),
reasons: [{ scenario: SCEN, subKey: 'caries@11' }],
},
],
});
// 本轮该患者 0 命中 → 走批量收尾的关闭闸
const res = await engine(prisma, makeScenario([])).runAllForHost({
hostId: HOST,
tenantId: TENANT,
now: NOW,
});
expect(res.plansClosed).toBe(1);
const rows = ledger(events);
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({
planId: 'p-z',
reason: 'signals_cleared',
assigneeUserId: null,
actorUserId: null,
});
expect(rows[0]!.heldSeconds).toBe(1800);
});
test('⭐⭐ 无人认领的单被关/被改 → **一条事件都不写**(否则每日全量会造事件洪峰)', async () => {
const { prisma, events } = makeStore({
plans: [
// 无人认领 + 信号清零 → 关闭
{ id: 'p-n1', patientId: 'pat-n1', version: 1, status: 'active', reasons: [{ scenario: SCEN, subKey: 'x' }] },
// 无人认领 + 诊所漂移 → 就地改
{
id: 'p-n2',
patientId: 'pat-n2',
version: 1,
status: 'active',
targetClinicId: 'clinic-old',
reasons: [{ scenario: SCEN, subKey: 'missing_tooth@whole' }],
},
],
});
prisma.$queryRaw = jest.fn(async () => [{ patient_id: 'pat-n2', clinic_id: 'clinic-new' }]);
await engine(
prisma,
makeScenario([hit('pat-n2', 'missing_tooth@whole', 50, 'clinic-diag')]),
).runAllForHost({ hostId: HOST, tenantId: TENANT, now: NOW });
expect(ledger(events)).toHaveLength(0);
});
test('归属被继承(诊所没变)→ 没有释放,不记账', async () => {
const { prisma, events } = makeStore({
plans: [
{
id: 'p-keep',
patientId: 'pat-c',
version: 1,
status: 'assigned',
assigneeUserId: 'staff-4',
assignedAt: new Date(NOW.getTime() - 3600_000),
targetClinicId: 'clinic-home',
reasons: [{ scenario: SCEN, subKey: 'caries@11' }],
},
],
});
prisma.$queryRaw = jest.fn(async () => [{ patient_id: 'pat-c', clinic_id: 'clinic-home' }]);
await engine(
prisma,
makeScenario([hit('pat-c', 'missing_tooth@whole', 60, 'clinic-diag')]),
).runAllForHost({ hostId: HOST, tenantId: TENANT, now: NOW });
expect(ledger(events)).toHaveLength(0);
});
});
...@@ -26,6 +26,16 @@ function makePrismaMock(opts: { ...@@ -26,6 +26,16 @@ function makePrismaMock(opts: {
count: Array.isArray(where?.id?.in) ? where.id.in.length : 0, count: Array.isArray(where?.id?.in) ? where.id.in.length : 0,
})); }));
const create = jest.fn().mockResolvedValue({ id: 'new-plan' }); const create = jest.fn().mockResolvedValue({ id: 'new-plan' });
// 归属账本(2026-08 补记账):收集写入内容供断言
const events: Array<Record<string, unknown>> = [];
const eventCreate = jest.fn(async ({ data }: { data: Record<string, unknown> }) => {
events.push(data);
return data;
});
const eventCreateMany = jest.fn(async ({ data }: { data: Array<Record<string, unknown>> }) => {
events.push(...data);
return { count: data.length };
});
const findFirst = jest.fn().mockResolvedValue(opts.latestPlan ?? null); const findFirst = jest.fn().mockResolvedValue(opts.latestPlan ?? null);
const findMany = jest.fn().mockImplementation(async ({ where }) => { const findMany = jest.fn().mockImplementation(async ({ where }) => {
// runAllForHost 关闭闸:status IN ('active','assigned') // runAllForHost 关闭闸:status IN ('active','assigned')
...@@ -50,15 +60,23 @@ function makePrismaMock(opts: { ...@@ -50,15 +60,23 @@ function makePrismaMock(opts: {
// 批量路径日志一次性 createMany // 批量路径日志一次性 createMany
createMany: jest.fn().mockResolvedValue({ count: 0 }), createMany: jest.fn().mockResolvedValue({ count: 0 }),
}, },
planEventLog: { create: eventCreate, createMany: eventCreateMany },
// ⚠️ tx 里必须复用**同一批** spy —— 关闭 plan 现在跑在事务内(要与归属账本同生共死),
// 若这里另起 jest.fn(),外层对 update/updateMany 的调用断言会全部落空。
$transaction: jest $transaction: jest
.fn() .fn()
.mockImplementation(async (cb: (tx: unknown) => Promise<unknown>) => .mockImplementation(async (cb: (tx: unknown) => Promise<unknown>) =>
cb({ cb({
followupPlan: { update: jest.fn().mockResolvedValue({}), create: jest.fn().mockResolvedValue({}) }, followupPlan: { update, updateMany, create },
planEventLog: { create: eventCreate, createMany: eventCreateMany },
}), }),
), ),
}; };
return { prisma, spies: { update, updateMany, create, findFirst, findMany } }; return {
prisma,
events,
spies: { update, updateMany, create, findFirst, findMany, eventCreate, eventCreateMany },
};
} }
function makeScenario(hits: ScenarioHit[]) { function makeScenario(hits: ScenarioHit[]) {
......
import { Test } from '@nestjs/testing';
import { ReleaseReason } from '@pac/types';
import { PlanService } from '../src/modules/plan/plan.service';
import { PrismaService } from '../src/prisma/prisma.service';
/**
* 召回分配 P0.6 / P0.7 回归 —— 退回原因落库 + 写路径的诊所硬边界。
*
* 两条都属于「不修就会静默出错」的类型:
* · 退回原因原本在 controller 就被丢掉(`@Body() _dto`),客服填了等于没填,
* 而退回原因分布是主管调整分配策略的唯一输入(T7)。
* · assign/recycle 的 findFirst 不校验 targetClinicId,A 诊所 leader 拿到 B 诊所的
* planId 就能跨诊所写入 —— 读路径挡着(他看不到),写路径不挡。
*/
const HOST = 'h1';
const TENANT = 't1';
function scopeWith(clinicIds: string[]) {
return { hostId: HOST, tenantId: TENANT, sourceUnits: [] as string[], clinicIds } as never;
}
const BASE_PLAN = {
id: 'p1',
hostId: HOST,
tenantId: TENANT,
patientId: 'pat1',
status: 'assigned',
assigneeUserId: 'u-me',
assignedAt: new Date(Date.now() - 3600_000),
targetClinicId: 'clinic-a',
};
/**
* findFirst 按 where 真实过滤 —— 只建模本用例关心的两维:id 与 clinicBoundary 的 AND/OR。
* (不建模就等于把"边界有没有生效"这件事假设掉了,而那正是要测的东西。)
*/
function makePrisma(plan: Record<string, unknown> | null = BASE_PLAN) {
const updates: Record<string, unknown>[] = [];
const events: Record<string, unknown>[] = [];
const tx = {
followupPlan: {
update: jest.fn(async ({ data }: { data: Record<string, unknown> }) => {
updates.push(data);
return {};
}),
},
planEventLog: {
create: jest.fn(async ({ data }: { data: Record<string, unknown> }) => {
events.push(data);
return data;
}),
},
};
const findFirst = jest.fn(async ({ where }: { where: Record<string, unknown> }) => {
if (!plan) return null;
const and = where.AND as Array<{ OR: Array<Record<string, unknown>> }> | undefined;
if (and?.length) {
const allowed = and[0]!.OR.flatMap((c) => {
const t = c.targetClinicId as { in?: string[] } | null;
return t === null ? [null] : (t?.in ?? []);
});
if (!allowed.includes(plan.targetClinicId as string | null)) return null;
}
return plan;
});
const prisma = {
followupPlan: { findFirst, findMany: jest.fn(async () => []) },
$transaction: jest.fn(async (fn: (t: typeof tx) => Promise<unknown>) => fn(tx)),
};
return { prisma: prisma as unknown as PrismaService, updates, events, findFirst };
}
async function buildService(prisma: PrismaService): Promise<PlanService> {
const mod = await Test.createTestingModule({
providers: [PlanService, { provide: PrismaService, useValue: prisma }],
})
.useMocker(() => ({}))
.compile();
return mod.get(PlanService);
}
describe('recycle —— 退回原因必须落到账本(T7)', () => {
test('⭐ 带结构化原因 → 落进 plan_event_logs.reason,事件类型是 release', async () => {
const { prisma, events } = makePrisma();
const svc = await buildService(prisma);
await svc.recycle(scopeWith([]), 'p1', 'u-me', true, ReleaseReason.OVER_CAPACITY);
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({
event: 'release',
reason: 'over_capacity',
assigneeUserId: null,
});
});
test('文字说明进 details,不占 reason 列(reason 要能直接 GROUP BY)', async () => {
const { prisma, events } = makePrisma();
const svc = await buildService(prisma);
await svc.recycle(scopeWith([]), 'p1', 'u-me', true, ReleaseReason.OTHER, ' 号码是空号 ');
expect(events[0]).toMatchObject({ reason: 'other', details: { note: '号码是空号' } });
});
test('⭐ other 不写说明 → **服务端**拒绝(不能只信前端)', async () => {
const { prisma } = makePrisma();
const svc = await buildService(prisma);
await expect(
svc.recycle(scopeWith([]), 'p1', 'u-me', true, ReleaseReason.OTHER),
).rejects.toThrow(/必须填写说明/);
// 空白串也算没填
await expect(
svc.recycle(scopeWith([]), 'p1', 'u-me', true, ReleaseReason.OTHER, ' '),
).rejects.toThrow(/必须填写说明/);
});
test('不填原因仍可退(自助认领的单不强制)—— reason 落 null', async () => {
const { prisma, events } = makePrisma();
const svc = await buildService(prisma);
await svc.recycle(scopeWith([]), 'p1', 'u-me', true);
expect(events[0]).toMatchObject({ event: 'release', reason: null });
});
test('⭐⭐ 红线:退回**绝不动 snoozedUntil**(动了池子里会凭空少一批人)', async () => {
const { prisma, updates } = makePrisma();
const svc = await buildService(prisma);
await svc.recycle(scopeWith([]), 'p1', 'u-me', true, ReleaseReason.RECENTLY_CONTACTED);
expect(updates).toHaveLength(1);
// 「最近刚联系过」最像该压一压的原因 —— 正因为像,才要在这里钉死
expect(updates[0]).not.toHaveProperty('snoozedUntil');
expect(updates[0]).toMatchObject({ status: 'active', assigneeUserId: null });
});
});
describe('写路径诊所硬边界(F4)', () => {
test('⭐ 跨诊所 assign → 找不到,不是"分成功了"', async () => {
// scope 只有 clinic-b,plan 属于 clinic-a
const { prisma } = makePrisma();
const svc = await buildService(prisma);
await expect(svc.assign(scopeWith(['clinic-b']), 'p1', 'u-other', 'u-leader')).rejects.toThrow(
/not found/i,
);
});
test('⭐ 跨诊所 recycle → 同样拒绝', async () => {
const { prisma } = makePrisma();
const svc = await buildService(prisma);
await expect(svc.recycle(scopeWith(['clinic-b']), 'p1', 'u-leader', true)).rejects.toThrow(
/not found/i,
);
});
test('本诊所的单照常放行(边界不能误伤)', async () => {
const { prisma, events } = makePrisma();
const svc = await buildService(prisma);
await svc.recycle(scopeWith(['clinic-a']), 'p1', 'u-me', true);
expect(events).toHaveLength(1);
});
test('⭐ 集团池(targetClinicId=null)窄 scope 也能操作 —— 它不属于任何诊所', async () => {
const { prisma, events } = makePrisma({ ...BASE_PLAN, targetClinicId: null });
const svc = await buildService(prisma);
await svc.recycle(scopeWith(['clinic-b']), 'p1', 'u-me', true);
expect(events).toHaveLength(1);
});
test('集团级 scope(clinicIds 为空)不加任何过滤', async () => {
const { prisma, findFirst } = makePrisma();
const svc = await buildService(prisma);
await svc.recycle(scopeWith([]), 'p1', 'u-me', true);
expect((findFirst.mock.calls[0]![0] as { where: Record<string, unknown> }).where.AND)
.toBeUndefined();
});
});
...@@ -11,7 +11,7 @@ import { IdentityCluster } from '@/components/identity-cluster'; ...@@ -11,7 +11,7 @@ import { IdentityCluster } from '@/components/identity-cluster';
import { isEmbedded } from '@/lib/embed'; import { isEmbedded } from '@/lib/embed';
/** /**
* /plans — 工作台入口解析器(原列表页已弃用,组件保留在 plans-list-app.tsx 未挂载)。 * /plans — 工作台入口解析器(原独立列表页已废除,左栏 patient-picker-rail 取代了它)。
* *
* 进来直接落到详情工作台,选人规则(纯规则): * 进来直接落到详情工作台,选人规则(纯规则):
* ① 我的「进行中」第一个(优先续上手头工作) * ① 我的「进行中」第一个(优先续上手头工作)
......
...@@ -667,7 +667,8 @@ function PriorityBar({ score }: { score: number }) { ...@@ -667,7 +667,8 @@ function PriorityBar({ score }: { score: number }) {
const pct = Math.max(0, Math.min(1, score / 100)); const pct = Math.max(0, Math.min(1, score / 100));
const labelTone = const labelTone =
pct >= 0.6 ? (pct >= 0.8 ? 'text-rose-700' : 'text-amber-700') : pct >= 0.2 ? 'text-emerald-700' : 'text-slate-500'; pct >= 0.6 ? (pct >= 0.8 ? 'text-rose-700' : 'text-amber-700') : pct >= 0.2 ? 'text-emerald-700' : 'text-slate-500';
// 同列表页:展示值 = 排序键 / 10,别再读 breakdown.raw(见 plans-list-app 注释) // 展示值 = 排序键 / 10。⚠️ 别改成读 breakdown.raw:那是加权前的分项原始分,
// 与列表排序用的 priorityScore 不同源,两处会对不上号。
const disp = (score / 10).toFixed(2); const disp = (score / 10).toFixed(2);
return ( return (
<span <span
......
...@@ -5,6 +5,7 @@ import type { ...@@ -5,6 +5,7 @@ import type {
ListPlansResponse, ListPlansResponse,
PlanActionAck, PlanActionAck,
PlanCountsResponse, PlanCountsResponse,
ReleaseReason,
} from '@pac/types'; } from '@pac/types';
import { api } from '@/lib/api-client'; import { api } from '@/lib/api-client';
import type { PlanDetailData } from '@/components/plan-detail/plan-detail-types'; import type { PlanDetailData } from '@/components/plan-detail/plan-detail-types';
...@@ -69,10 +70,18 @@ export const plansApi = { ...@@ -69,10 +70,18 @@ export const plansApi = {
assigneeUserId, assigneeUserId,
}), }),
/** 回收 — POST /plans/{id}/recycle { reason? }(mine → pool,plan 仍 active)*/ /**
recycle: (planId: string, reason?: string) => * 退回 / 回收 — POST /plans/{id}/recycle(mine → pool,plan 仍 active)。
*
* ⚠️ 入参从自由文本 `reason` 换成了结构化 `releaseReason`(2026-08):
* 旧字段服务端**从来没读过**(controller 写作 `@Body() _dto`),客服填了等于没填。
* 结构化才能聚合出"退回原因分布",那是主管调整分配策略的输入。
* `other` 必须带 releaseNote,服务端会拦(不是前端校验就够了)。
*/
recycle: (planId: string, releaseReason?: ReleaseReason, releaseNote?: string) =>
api.post<PlanActionAck>(`/pac/v1/plans/${encodeURIComponent(planId)}/recycle`, { api.post<PlanActionAck>(`/pac/v1/plans/${encodeURIComponent(planId)}/recycle`, {
reason, releaseReason,
releaseNote,
}), }),
/** 拉患者明文手机号(reveal,需 PATIENT_VIEW 权限) — GET /patients/{id}/phone-reveal */ /** 拉患者明文手机号(reveal,需 PATIENT_VIEW 权限) — GET /patients/{id}/phone-reveal */
......
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import type { PlanListItem } from '@pac/types';
import { plansApi } from './plans-api';
const PAGE_SIZE = 20;
export interface UseMyTasks {
items: PlanListItem[];
total: number;
loading: boolean;
hasMore: boolean;
error: string | null;
/** 触底分页:加载下一页(累加到 items)*/
loadMore: () => void;
/** 重置重新拉第一页 */
reset: () => void;
}
/**
* 我的任务 — view=mine 服务端分页 + 触底累加(infinite scroll)。
* 状态筛选 / 搜索由调用方在 items 上客户端做(跟设计抽屉一致)。
*/
export function useMyTasks(): UseMyTasks {
const [items, setItems] = useState<PlanListItem[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadingRef = useRef(false);
const fetchPage = useCallback(async (p: number) => {
if (loadingRef.current) return;
loadingRef.current = true;
setLoading(true);
setError(null);
try {
const data = await plansApi.list({ view: 'mine', page: p, pageSize: PAGE_SIZE });
setTotal(data.total);
setItems((prev) => {
if (p === 1) return data.items;
// 去重累加(幂等防重复 enqueue / 快速滚动)
const seen = new Set(prev.map((x) => x.id));
return [...prev, ...data.items.filter((x) => !seen.has(x.id))];
});
setPage(p);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
loadingRef.current = false;
setLoading(false);
}
}, []);
useEffect(() => {
void fetchPage(1);
}, [fetchPage]);
const hasMore = items.length < total;
const loadMore = useCallback(() => {
if (loadingRef.current || !hasMore) return;
void fetchPage(page + 1);
}, [fetchPage, hasMore, page]);
const reset = useCallback(() => {
setItems([]);
setTotal(0);
setPage(1);
void fetchPage(1);
}, [fetchPage]);
return { items, total, loading, hasMore, error, loadMore, reset };
}
...@@ -34,7 +34,7 @@ export interface UsePatientPicker { ...@@ -34,7 +34,7 @@ export interface UsePatientPicker {
/** /**
* 详情页左栏"选患者"列表 — 服务端分页 + 触底累加;筛选变化自动回第一页重拉。 * 详情页左栏"选患者"列表 — 服务端分页 + 触底累加;筛选变化自动回第一页重拉。
* (use-my-tasks 的泛化版:不只 mine,带全套筛选) * (取代了早期只拉 mine 的 use-my-tasks:不只 mine,带全套筛选)
*/ */
export function usePatientPicker(filters: PickerFilters): UsePatientPicker { export function usePatientPicker(filters: PickerFilters): UsePatientPicker {
const [items, setItems] = useState<PlanListItem[]>([]); const [items, setItems] = useState<PlanListItem[]>([]);
......
'use client';
import { useCallback, useEffect, useState } from 'react';
import { plansApi } from './plans-api';
/**
* KPI / Tab badge 用的轻量计数:走后端 /plans/counts 单端点(一次 SQL 聚合 5 个 count),
* 不动主列表 query。任何主列表刷新时,通过 token (`refreshToken`) 触发同步。
*
* 历史:早期是前端并行 5 个 pageSize=1 list 请求,列表页刷新会产生 6+ 个 HTTP 请求 +
* StrictMode dev 双挂翻倍,网络面板看着很吓人。W3 末改为后端聚合。
*/
export interface PlanCounts {
mine: number;
mineAssigned: number;
mineCompleted: number;
pool: number;
all: number;
}
const ZERO: PlanCounts = { mine: 0, mineAssigned: 0, mineCompleted: 0, pool: 0, all: 0 };
export function usePlanCounts(refreshToken: number) {
const [counts, setCounts] = useState<PlanCounts>(ZERO);
const [loading, setLoading] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const data = await plansApi.counts();
setCounts(data);
} catch {
// 失败保持上次值,不打断 UI
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load, refreshToken]);
return { counts, loading, reload: load };
}
'use client';
import { useCallback, useEffect, useState } from 'react';
import type { ListPlansQuery, ListPlansResponse } from '@pac/types';
import { ApiError } from '@/lib/api-client';
import { plansApi } from './plans-api';
export type ListState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'ready'; data: ListPlansResponse }
| { status: 'error'; message: string; code?: number };
export interface UsePlansList {
state: ListState;
query: Partial<ListPlansQuery>;
setQuery: (next: Partial<ListPlansQuery>) => void;
refresh: () => void;
}
const DEFAULT_QUERY: Partial<ListPlansQuery> = { view: 'mine', page: 1, pageSize: 20 };
export function usePlansList(initial: Partial<ListPlansQuery> = DEFAULT_QUERY): UsePlansList {
const [query, setQueryState] = useState<Partial<ListPlansQuery>>(initial);
const [state, setState] = useState<ListState>({ status: 'idle' });
const load = useCallback(async (q: Partial<ListPlansQuery>) => {
setState({ status: 'loading' });
try {
const data = await plansApi.list(q);
setState({ status: 'ready', data });
} catch (err) {
if (err instanceof ApiError) {
setState({ status: 'error', message: err.message, code: err.code });
} else {
setState({ status: 'error', message: err instanceof Error ? err.message : String(err) });
}
}
}, []);
useEffect(() => {
void load(query);
}, [load, query]);
const setQuery = useCallback((next: Partial<ListPlansQuery>) => {
setQueryState((prev) => {
const merged = { ...prev, ...next, page: next.page ?? 1 };
// 引用稳定:值没变就返回原对象,避免 [query] effect 重复 load
// (挂载时 sort/keyword effect 用默认值 setQuery 不再触发多余请求)
const keys = new Set([...Object.keys(prev), ...Object.keys(merged)]);
for (const k of keys) {
if ((prev as Record<string, unknown>)[k] !== (merged as Record<string, unknown>)[k]) {
return merged;
}
}
return prev;
});
}, []);
const refresh = useCallback(() => {
void load(query);
}, [load, query]);
return { state, query, setQuery, refresh };
}
...@@ -33,7 +33,7 @@ interface AuthState { ...@@ -33,7 +33,7 @@ interface AuthState {
expiresAt: number | null; expiresAt: number | null;
user: SessionUser | null; user: SessionUser | null;
setTokens: (input: { accessToken: string; refreshToken: string; expiresIn: number }) => void; setTokens: (input: { accessToken: string; refreshToken: string; expiresIn: number }) => void;
/** 拉 /auth/session,把展开后的 clinicIds/sourceUnits 合并进 user(诊所筛选等用) */ /** 拉 /auth/session,把展开后的 clinicIds/sourceUnits + 现算的 permissions 合并进 user */
loadSession: () => Promise<void>; loadSession: () => Promise<void>;
clear: () => void; clear: () => void;
isAuthenticated: () => boolean; isAuthenticated: () => boolean;
...@@ -84,9 +84,16 @@ export const useAuthStore = create<AuthState>()( ...@@ -84,9 +84,16 @@ export const useAuthStore = create<AuthState>()(
const prev = get().user; const prev = get().user;
// 防闪空:silent refresh 重签 token 时,同一 sub 沿用上次已展开的 clinicIds/sourceUnits // 防闪空:silent refresh 重签 token 时,同一 sub 沿用上次已展开的 clinicIds/sourceUnits
// 作占位,避免 user 被 decodeJwt 重置后诊所筛选短暂清空(loadSession 随后会刷新)。 // 作占位,避免 user 被 decodeJwt 重置后诊所筛选短暂清空(loadSession 随后会刷新)。
// permissions 同理:decodeJwt 拿到的是**旧 token 的快照**,不留住的话每次 silent refresh
// 都会让新权限的入口(如分配按钮)闪一下消失 —— 正是 loadSession 刚修好的那个问题。
const user = const user =
decoded && prev && prev.sub === decoded.sub decoded && prev && prev.sub === decoded.sub
? { ...decoded, clinicIds: prev.clinicIds, sourceUnits: prev.sourceUnits } ? {
...decoded,
clinicIds: prev.clinicIds,
sourceUnits: prev.sourceUnits,
permissions: prev.permissions ?? decoded.permissions,
}
: decoded; : decoded;
set({ set({
accessToken, accessToken,
...@@ -110,6 +117,12 @@ export const useAuthStore = create<AuthState>()( ...@@ -110,6 +117,12 @@ export const useAuthStore = create<AuthState>()(
clinicIds: s.clinicIds, clinicIds: s.clinicIds,
sourceUnits: s.sourceUnits, sourceUnits: s.sourceUnits,
actionUrls: s.actionUrls, actionUrls: s.actionUrls,
// ⭐ permissions 必须 merge:JWT 里那份是签发时的快照,access token 2h 不刷。
// 发版新增一个权限(如 plan:dispatch)后,已登录的人在 token 到期前
// 前端一直按老清单渲染 —— 按钮藏着、入口没有,**不报错**,像是功能没上线。
// 服务端 /auth/session 已改成按 role 现算(见 auth.controller.session 注释),
// 这里接住即可,不必让全员重登。
permissions: s.permissions,
// session 已把服务端诊所名合并进 dictionary(登录传的优先);无则保留 JWT 里的 // session 已把服务端诊所名合并进 dictionary(登录传的优先);无则保留 JWT 里的
dictionary: s.dictionary ?? st.user.dictionary, dictionary: s.dictionary ?? st.user.dictionary,
}, },
......
...@@ -339,7 +339,7 @@ releaseReason/releaseNote/assignStrategy ← ⭐ 无条件 l ...@@ -339,7 +339,7 @@ releaseReason/releaseNote/assignStrategy ← ⭐ 无条件 l
1.`status='assigned'` + `assignment_id=X` 的 plan → 触发升版本 → 新版本 `assignment_id` **仍等于 X** 1.`status='assigned'` + `assignment_id=X` 的 plan → 触发升版本 → 新版本 `assignment_id` **仍等于 X**
2. ⭐ 造 `status='active'`**退回后的状态**)+ `assignment_id=X` + `release_reason='over_capacity'` 的 plan → 触发升版本 → 新版本三者**全部守恒**(只测 assigned 会漏掉这一支,四份方案都漏了) 2. ⭐ 造 `status='active'`**退回后的状态**)+ `assignment_id=X` + `release_reason='over_capacity'` 的 plan → 触发升版本 → 新版本三者**全部守恒**(只测 assigned 会漏掉这一支,四份方案都漏了)
3. 本地 38 迁移全应用的库上 `prisma migrate dev`**零 drift** 3. 本地 37 迁移全应用的库上 `prisma migrate dev`**零 drift**(迁移 A 落地后为 38)
4. `plan_assignments``SCOPED_MODELS` 里,且有一条越权读被拦的测试 4. `plan_assignments``SCOPED_MODELS` 里,且有一条越权读被拦的测试
**交付物**:可上线的独立版本(表存在、不变式成立,但还没有任何写入方)。 **交付物**:可上线的独立版本(表存在、不变式成立,但还没有任何写入方)。
...@@ -384,7 +384,7 @@ POST /pac/v1/plans/assignments/:id/revoke @RequirePermission(PLAN_DISPATCH) ...@@ -384,7 +384,7 @@ POST /pac/v1/plans/assignments/:id/revoke @RequirePermission(PLAN_DISPATCH)
单个 `$transaction(fn, { maxWait: 10_000, timeout: 30_000 })` 单个 `$transaction(fn, { maxWait: 10_000, timeout: 30_000 })`
1. **三道闸**`requirePermission``rejectSyntheticIdentity``scope.userId.startsWith('wx:')` 直接拒绝,六·已定取舍「写工具上线前需另行处理」—— 这就是那个处理)→ **scope 绑定**:一次 `findMany({ where: { id: { in }, hostId, tenantId, patient: { sourceUnit: { in } }, targetClinicId: { in: [...scope.clinicIds, null] } } })`**数量对不上整体拒绝** 1. **三道闸**`requirePermission``rejectSyntheticIdentity``scope.userId.startsWith('wx:')` 直接拒绝,六·已定取舍「写工具上线前需另行处理」—— 这就是那个处理)→ **scope 绑定**:一次 `findMany({ where: { id: { in }, hostId, tenantId, patient: { sourceUnit: { in } }, targetClinicId: { in: [...scope.clinicIds, null] } } })`**数量对不上整体拒绝**
2. **按 `patientId` 去重**(🔴 `schema.prisma:1069` 注释里那条 partial UNIQUE **在 38 份迁移里根本不存在**,已逐份 grep 确认 —— 不能当既有保障用,见 R9) 2. **按 `patientId` 去重**(🔴 `schema.prisma:1069` 注释里那条 partial UNIQUE **在 37 份迁移里根本不存在**,已逐份 grep 确认 —— 不能当既有保障用,见 R9)
3.`plan_assignments` 头行 3.`plan_assignments` 头行
4.`assigneeUserId` 分组,每组一条**带条件 `updateMany`**`where: { id: { in: chunk }, status: { in: ['active'] }, assigneeUserId: null, supersededAt: null }` —— **where 里带状态条件 = 并发安全**`recycle-scheduler.service.ts:85` 已在用此技巧);`count` 与 chunk 长度差 = 被抢走的 4.`assigneeUserId` 分组,每组一条**带条件 `updateMany`**`where: { id: { in: chunk }, status: { in: ['active'] }, assigneeUserId: null, supersededAt: null }` —— **where 里带状态条件 = 并发安全**`recycle-scheduler.service.ts:85` 已在用此技巧);`count` 与 chunk 长度差 = 被抢走的
5. 事件一次 `createMany` —— ⚠️ **必须在 `plan-event.recorder.ts` 里加 `recordPlanEventsBulk(tx, inputs[])`**,该文件头(`:5`)宣称自己是「PlanEventLog 的**唯一写入口**」,在别处写 createMany 当场破功 5. 事件一次 `createMany` —— ⚠️ **必须在 `plan-event.recorder.ts` 里加 `recordPlanEventsBulk(tx, inputs[])`**,该文件头(`:5`)宣称自己是「PlanEventLog 的**唯一写入口**」,在别处写 createMany 当场破功
...@@ -679,7 +679,7 @@ T4 已把落点写死:`shared/fact-block.ts`(标准 + 深度)与 `tiers/st ...@@ -679,7 +679,7 @@ T4 已把落点写死:`shared/fact-block.ts`(标准 + 深度)与 `tiers/st
| **R6** | 🟠 | **MCP `@Public()` 绕过权限**`permissions.guard.ts:16` `if (isPublic) return true` 已实测,`@RequirePermission` 在 MCP 路径完全不生效 | 实测 | D-4 写路径不走 MCP + 只读工具条件注册 + `requirePermission` helper 备着 | P3.5 | | **R6** | 🟠 | **MCP `@Public()` 绕过权限**`permissions.guard.ts:16` `if (isPublic) return true` 已实测,`@RequirePermission` 在 MCP 路径完全不生效 | 实测 | D-4 写路径不走 MCP + 只读工具条件注册 + `requirePermission` helper 备着 | P3.5 |
| **R7** | 🟠 | **上线当天 leader 无 `plan:dispatch`**:JWT 的 `permissions` 是签发时固化的(`auth.service.ts:534`),2 小时内所有 leader 都是 staff 待遇,MCP 工具集静默降级,**无任何错误提示** | 实测 | ① `auth-store.ts:104-117` merge `permissions` ② 上线步骤写明「发版后强制重登 / 临时缩短 access token TTL」③ `get_current_user` 返回 `canDispatch:false` 时助手说「登录态可能过期,请重新登录」而非「你没有权限」 | P0.1 + 上线 | | **R7** | 🟠 | **上线当天 leader 无 `plan:dispatch`**:JWT 的 `permissions` 是签发时固化的(`auth.service.ts:534`),2 小时内所有 leader 都是 staff 待遇,MCP 工具集静默降级,**无任何错误提示** | 实测 | ① `auth-store.ts:104-117` merge `permissions` ② 上线步骤写明「发版后强制重登 / 临时缩短 access token TTL」③ `get_current_user` 返回 `canDispatch:false` 时助手说「登录态可能过期,请重新登录」而非「你没有权限」 | P0.1 + 上线 |
| **R8** | 🟠 | **福利话术缓存**`plan_scripts.planId @unique``schema.prisma:1203`),换批次不重生成 = 对患者做虚假承诺 | 实测 | P6.6 失效规则;**MVS 期福利不进 LLM**(Q-4 降级) | P5 / P6.6 | | **R8** | 🟠 | **福利话术缓存**`plan_scripts.planId @unique``schema.prisma:1203`),换批次不重生成 = 对患者做虚假承诺 | 实测 | P6.6 失效规则;**MVS 期福利不进 LLM**(Q-4 降级) | P5 / P6.6 |
| **R9** | 🟡 | **schema 注释承诺的 partial UNIQUE 不存在**`schema.prisma:1069``data-model.mdx:91` 都写着「一患者只一条活动 plan」,但 38 份迁移里**没有任何一句创建它**(逐份 grep 确认) | 实测 | ⛔ 分配逻辑**不依赖**它去重,批量预检自己按 `patientId` 去重。本次**不修**(补索引前得先查存量违例) | P2.1 | | **R9** | 🟡 | **schema 注释承诺的 partial UNIQUE 不存在**`schema.prisma:1069``data-model.mdx:91` 都写着「一患者只一条活动 plan」,但 37 份迁移里**没有任何一句创建它**(逐份 grep 确认) | 实测 | ⛔ 分配逻辑**不依赖**它去重,批量预检自己按 `patientId` 去重。本次**不修**(补索引前得先查存量违例) | P2.1 |
| **R10** | 🟡 | **`reason` 列成大杂烩**`auto_release` 下将混 `timeout` / `clinic_moved` / `signals_cleared` / `assignment_expired`,全靠自由 TEXT 区分,与 `PlanEventType` 注释「不要在调用处随手写字符串」同一纪律 | — | 新增 `PlanEventReason` 枚举(F1 尾)0.1 人日 | P0.2 | | **R10** | 🟡 | **`reason` 列成大杂烩**`auto_release` 下将混 `timeout` / `clinic_moved` / `signals_cleared` / `assignment_expired`,全靠自由 TEXT 区分,与 `PlanEventType` 注释「不要在调用处随手写字符串」同一纪律 | — | 新增 `PlanEventReason` 枚举(F1 尾)0.1 人日 | P0.2 |
| **R11** | 🟡 | **事件洪峰**`runAllForHost` 全量 44 万患者,补记账后一次口径变更可能一次性写出大量 `auto_release` | — | 加计数日志 + 上线公告 | P0.5 | | **R11** | 🟡 | **事件洪峰**`runAllForHost` 全量 44 万患者,补记账后一次口径变更可能一次性写出大量 `auto_release` | — | 加计数日志 + 上线公告 | P0.5 |
| **R12** | 🟡 | **`assignment_id` 就地覆盖导致历史批次规模漂移**:plan 在批次 N 被分 → 退回 → 进批次 N+1,批次 N 的 COUNT 悄悄少 1 | — | ⛔ **否掉 data 层 R2 的「写进 `plan_event_logs.details` 离线校正」** —— 它的用法 `WHERE details->>'assignmentId'=X``schema.prisma:1484` 自己引用的规则「要按它筛就该立柱」就该立柱,而立柱正是 T18 撤回的东西,且无索引 + 混着高频 `view` 事件 = 全表 jsonb 扫。✅ 正解就是 D-12(无条件继承后 COUNT 不漂,根本不需要补丁)。⚠️ 「同一 plan 跨批次」这一支仍会漂 —— 需产品确认可接受(Q-5) | P1.2 | | **R12** | 🟡 | **`assignment_id` 就地覆盖导致历史批次规模漂移**:plan 在批次 N 被分 → 退回 → 进批次 N+1,批次 N 的 COUNT 悄悄少 1 | — | ⛔ **否掉 data 层 R2 的「写进 `plan_event_logs.details` 离线校正」** —— 它的用法 `WHERE details->>'assignmentId'=X``schema.prisma:1484` 自己引用的规则「要按它筛就该立柱」就该立柱,而立柱正是 T18 撤回的东西,且无索引 + 混着高频 `view` 事件 = 全表 jsonb 扫。✅ 正解就是 D-12(无条件继承后 COUNT 不漂,根本不需要补丁)。⚠️ 「同一 plan 跨批次」这一支仍会漂 —— 需产品确认可接受(Q-5) | P1.2 |
...@@ -718,7 +718,7 @@ T4 已把落点写死:`shared/fact-block.ts`(标准 + 深度)与 `tiers/st ...@@ -718,7 +718,7 @@ T4 已把落点写死:`shared/fact-block.ts`(标准 + 深度)与 `tiers/st
| 验什么 | 怎么验 | 通过标准 | | 验什么 | 怎么验 | 通过标准 |
|---|---|---| |---|---|---|
| 迁移零 drift | 38 迁移全应用的库上 `prisma migrate dev` | 无 drift 输出 | | 迁移零 drift | 37 迁移全应用的库上 `prisma migrate dev` | 无 drift 输出 |
| 端到端剧本 | 第 4 节 MVS 剧本七步 | 一次跑通,中途不改库不看日志 | | 端到端剧本 | 第 4 节 MVS 剧本七步 | 一次跑通,中途不改库不看日志 |
| **口径对数** | 矩阵/筛选出的人数 vs 点进去列表的条数 | ⚠️ 矩阵按**患者去重**、列表按 **plan** 分页,口径不一致主管一眼发现 → hover 里注明「N 位患者」而非「N 条」,且逐格对数 | | **口径对数** | 矩阵/筛选出的人数 vs 点进去列表的条数 | ⚠️ 矩阵按**患者去重**、列表按 **plan** 分页,口径不一致主管一眼发现 → hover 里注明「N 位患者」而非「N 条」,且逐格对数 |
| 名册 `source_created_at` | 造一条 `task_date=2033` 的记录 | 该客服**不**出现在名册里 | | 名册 `source_created_at` | 造一条 `task_date=2033` 的记录 | 该客服**不**出现在名册里 |
...@@ -792,7 +792,7 @@ T4 已把落点写死:`shared/fact-block.ts`(标准 + 深度)与 `tiers/st ...@@ -792,7 +792,7 @@ T4 已把落点写死:`shared/fact-block.ts`(标准 + 深度)与 `tiers/st
| 事实 | 出处 | | 事实 | 出处 |
|---|---| |---|---|
| 迁移目录 **38 个**(不是四份方案里说的 37) | `apps/pac-service/prisma/migrations/` | | 迁移目录 **37 个** —— ⚠️ 本文原写 38,**2026-08-02 复核更正为 37**(磁盘 37 个目录,`_prisma_migrations` 37 条 applied)。四份子方案说的 37 是对的 | `apps/pac-service/prisma/migrations/` |
| `daysSince``VOLATILE_DATA_KEYS` 里,时间流逝不触发重算 | `persona-diff.ts:57` + `:81-90` | | `daysSince``VOLATILE_DATA_KEYS` 里,时间流逝不触发重算 | `persona-diff.ts:57` + `:81-90` |
| `PotentialGap` 无日期锚,只有 `daysSince: number` | `potential-treatment.selector.ts:93-102` | | `PotentialGap` 无日期锚,只有 `daysSince: number` | `potential-treatment.selector.ts:93-102` |
| 8 标签 ← K 码是一对多,窗口阈值冲突 | `potential-treatment.feature.ts:53-81` + `canonical-codes.ts:236-257` | | 8 标签 ← K 码是一对多,窗口阈值冲突 | `potential-treatment.feature.ts:53-81` + `canonical-codes.ts:236-257` |
...@@ -811,7 +811,7 @@ T4 已把落点写死:`shared/fact-block.ts`(标准 + 深度)与 `tiers/st ...@@ -811,7 +811,7 @@ T4 已把落点写死:`shared/fact-block.ts`(标准 + 深度)与 `tiers/st
| `patient_return_visits` 名册索引末列是 `task_date` | `schema.prisma:447` | | `patient_return_visits` 名册索引末列是 `task_date` | `schema.prisma:447` |
| ⭐ `task_director_id``current_task_director` 近 12 月**仅 23.1% 相同** | `schema.prisma:420-426` 注释 | | ⭐ `task_director_id``current_task_director` 近 12 月**仅 23.1% 相同** | `schema.prisma:420-426` 注释 |
| MCP 现有 7 个 `registerTool`,全只读 | `mcp-server.factory.ts:84,97,120,133,151,164,209` | | MCP 现有 7 个 `registerTool`,全只读 | `mcp-server.factory.ts:84,97,120,133,151,164,209` |
| `data/jvs-dw/users.json` **当前工作树下不存在**(mock 名册为空) | `mock-users.ts:26-40` | | ⚠️ **更正**`data/jvs-dw/users.json` **存在且已入库**(36KB / commit `379a4af`,mock 名册非空)。形状 = 未来 `users` + `user_clinics` 两表,含 `lastActiveAt` / `contactCount`(正是 P3.2 名册要算的口径,离线 CLI 已跑过一遍)。⛔ 但它只服务 mock 登录,**不是 PAC 主数据** → P4.3「姓名必须服务端解析」的结论不变 | `mock-users.ts` + 实测 |
| `STAFF` 持有 `PLAN_ASSIGN` + `PLAN_RECYCLE`;LEADER 只多 `PLAN_VIEW_ALL` + `STATS_VIEW` | `enums/index.ts:36-63` | | `STAFF` 持有 `PLAN_ASSIGN` + `PLAN_RECYCLE`;LEADER 只多 `PLAN_VIEW_ALL` + `STATS_VIEW` | `enums/index.ts:36-63` |
**已修正的引用错误**(四份子方案里出现的,实现时按此为准): **已修正的引用错误**(四份子方案里出现的,实现时按此为准):
......
...@@ -19,6 +19,12 @@ export const Permission = { ...@@ -19,6 +19,12 @@ export const Permission = {
PLAN_ASSIGN: 'plan:assign', PLAN_ASSIGN: 'plan:assign',
PLAN_RECYCLE: 'plan:recycle', PLAN_RECYCLE: 'plan:recycle',
PLAN_EXECUTE: 'plan:execute', PLAN_EXECUTE: 'plan:execute',
/// ⭐ 批次分配(门诊经理)—— **主管判据的唯一真理源**。
/// 为什么不复用现成的:PLAN_ASSIGN 连 staff 都有(那是"自助认领"语义,见下方 STAFF 注释),
/// 拿它区分主管等于没区分;STATS_VIEW 虽然只有 leader 有,但全仓零端点零组件(死权限),
/// 借它当判据是把一个从没被验证过的开关变成生产依赖。
/// ⚠️ 只给 LEADER + ADMIN,**永远不要加进 STAFF** —— 加了这条权限就跟 PLAN_ASSIGN 一样废掉。
PLAN_DISPATCH: 'plan:dispatch',
// Patient / persona // Patient / persona
PATIENT_VIEW: 'patient:view', PATIENT_VIEW: 'patient:view',
PERSONA_VIEW: 'persona:view', PERSONA_VIEW: 'persona:view',
...@@ -53,6 +59,7 @@ export const ROLE_PERMISSIONS: Record<UserRole, Permission[]> = { ...@@ -53,6 +59,7 @@ export const ROLE_PERMISSIONS: Record<UserRole, Permission[]> = {
Permission.PLAN_VIEW_OWN, Permission.PLAN_VIEW_OWN,
Permission.PLAN_VIEW_ALL, Permission.PLAN_VIEW_ALL,
Permission.PLAN_ASSIGN, // leader 可分配给任意 staff Permission.PLAN_ASSIGN, // leader 可分配给任意 staff
Permission.PLAN_DISPATCH, // ⭐ 批次分配(主管判据)—— staff 不给,见 Permission 声明处
Permission.PLAN_RECYCLE, // leader:可回收**任意人**已认领的 plan(staff 只能退自己的) Permission.PLAN_RECYCLE, // leader:可回收**任意人**已认领的 plan(staff 只能退自己的)
Permission.PLAN_EXECUTE, Permission.PLAN_EXECUTE,
Permission.PATIENT_VIEW, Permission.PATIENT_VIEW,
...@@ -765,6 +772,123 @@ export function abandonReasonsFor(panel: 'form' | 'close'): AbandonReason[] { ...@@ -765,6 +772,123 @@ export function abandonReasonsFor(panel: 'form' | 'close'): AbandonReason[] {
} }
// ============================================================= // =============================================================
// 退回原因(followup_plans.release_reason)
// =============================================================
/**
* 退回原因 —— 客服把单退回池时填的**处置理由**。
*
* ⚠️⚠️ 三条边界,每一条都被踩过或差点踩:
*
* 1. **≠ 放弃原因 [[AbandonReason]]**。放弃是「这个患者不用再召了」(患者侧结论,进终态);
* 退回是「这单不该由我做」(任务侧处置,**plan 仍 active,回池等下一个人**)。
* 两者都进不了对方的统计口径。
*
* 2. **≠ 召回反馈 [[RECALL_FEEDBACK_OPTIONS]]**。反馈回答「这条召回准不准」(冲算法去的);
* 退回回答「我为什么不接」(冲派单去的)。同一次退回可能两者都填,也可能只填一个。
* ⚠️ 值域刻意不复用:`bad_timing` 在反馈里指「召回时机不对(太早/太晚)」,
* 在退回里会被读成「时效太紧」—— 同名不同义是统计事故的标准配方,故本枚举不设该键。
*
* 3. ⛔ **本枚举永远不带 `suppressDays`** —— 类型里就不给这个字段,用编译器拦住。
* 照抄 ABANDON_REASON_META 那套抑制窗会把被退回的患者静默压 30~90 天,
* 而退回的语义是「换个人来做」,不是「别做了」。压窗 = 池子里凭空少一批人,
* 且正好是教条 T5「剩下的不是遗漏,是还没轮到」的反面。
*
* ── 为什么必须填(T7)──
* 退回是正常路径不是异常。退回率与原因分布是**主管调整分配策略的输入**,
* 每个原因都对应一根可调的杠杆(见 `lever`)—— 这才是"必须填"的目的,
* 不要当成无谓摩擦砍掉。
*/
export const ReleaseReason = {
/// 人不对 —— 这个患者不归我
NOT_MY_PATIENT: 'not_my_patient',
/// 接不住 —— 手上已经排满
OVER_CAPACITY: 'over_capacity',
/// 接不住 —— 我这段时间不在(休假 / 调岗 / 转岗)
AGENT_UNAVAILABLE: 'agent_unavailable',
/// 人不对 —— 该由别的角色跟(医生 / 咨询师 / 客户经理)
NEEDS_OTHER_ROLE: 'needs_other_role',
/// 时机不对 —— 给的时效内做不完
DEADLINE_TOO_TIGHT: 'deadline_too_tight',
/// 时机不对 —— 最近刚联系过,这会儿再打会烦
RECENTLY_CONTACTED: 'recently_contacted',
/// 信息不足 —— 缺电话 / 缺诊断,没法开口
PATIENT_INFO_MISSING: 'patient_info_missing',
/// 其他(必须写说明)
OTHER: 'other',
} as const;
export type ReleaseReason = (typeof ReleaseReason)[keyof typeof ReleaseReason];
export const ReleaseReasonSchema = z.enum([
'not_my_patient',
'over_capacity',
'agent_unavailable',
'needs_other_role',
'deadline_too_tight',
'recently_contacted',
'patient_info_missing',
'other',
]);
/**
* 主管可调的杠杆 —— 每个退回原因指向**一个**具体的默认值/策略。
*
* 这是退回原因分布唯一的下游用途:一批退回里 `capacity` 占了一半 → 容量默认值定高了;
* `roster` 占一半 → 在岗判定不准。没有这一层映射,原因分布就只是一张好看的饼图。
*/
export type ReleaseReasonLever =
/// 圈人条件 —— 分给谁、按什么圈(专属策略 / 初筛条件)
| 'targeting'
/// 容量默认值 —— 一个人同时能压多少
| 'capacity'
/// 名册口径 —— 在岗判定准不准
| 'roster'
/// 时效默认值 —— 给几天
| 'timing'
/// 数据质量 —— 摄入侧的缺口,不是分配策略问题
| 'data'
/// 无法归因(other)
| 'none';
/**
* 退回原因单一真理源。
*
* labelZh / desc — 弹窗卡片的标题 + 说明
* group — UI 分组(渲染顺序 = 声明顺序)
* lever — 该原因反推哪个默认值,见 [[ReleaseReasonLever]]
* needNote — 必填文字说明(存 release_note)
* hidden — 历史值,不再展示(仅供翻译老数据)
*
* ⛔ **没有 suppressDays,也不要加**(见 [[ReleaseReason]] 第 3 条)。
*/
export const RELEASE_REASON_META: Record<
ReleaseReason,
{
labelZh: string;
desc?: string;
group: 'person' | 'capacity' | 'timing' | 'data' | 'other';
lever: ReleaseReasonLever;
needNote?: boolean;
hidden?: boolean;
}
> = {
not_my_patient: { labelZh: '不是我的客户', desc: '这位患者一直由别人跟', group: 'person', lever: 'targeting' },
needs_other_role: { labelZh: '该由其他角色跟', desc: '需要医生 / 咨询师介入,不是客服能推的', group: 'person', lever: 'targeting' },
over_capacity: { labelZh: '手上排满了', desc: '在手单已超出能跟进的量', group: 'capacity', lever: 'capacity' },
agent_unavailable: { labelZh: '这段时间不在', desc: '休假 / 调岗 / 已不做召回', group: 'capacity', lever: 'roster' },
deadline_too_tight: { labelZh: '时效太紧', desc: '在给定期限内做不完', group: 'timing', lever: 'timing' },
recently_contacted: { labelZh: '最近刚联系过', desc: '短期内重复触达会引起反感', group: 'timing', lever: 'targeting' },
patient_info_missing: { labelZh: '患者信息不足', desc: '缺号码 / 缺诊断,无法开口', group: 'data', lever: 'data' },
other: { labelZh: '其他原因', desc: '需要写明具体情况', group: 'other', lever: 'none', needNote: true },
};
/// 退回弹窗要展示的原因清单(过滤 hidden;顺序 = META 声明顺序 = 按 group 已排好)
export function releaseReasonsForForm(): ReleaseReason[] {
return (Object.keys(RELEASE_REASON_META) as ReleaseReason[]).filter(
(k) => !RELEASE_REASON_META[k].hidden,
);
}
// =============================================================
// Plan 生命周期事件(plan_event_logs.event) // Plan 生命周期事件(plan_event_logs.event)
// ============================================================= // =============================================================
...@@ -823,6 +947,41 @@ export const HUMAN_TOUCH_EVENTS: PlanEventType[] = ( ...@@ -823,6 +947,41 @@ export const HUMAN_TOUCH_EVENTS: PlanEventType[] = (
Object.keys(PLAN_EVENT_META) as PlanEventType[] Object.keys(PLAN_EVENT_META) as PlanEventType[]
).filter((k) => PLAN_EVENT_META[k].byHuman); ).filter((k) => PLAN_EVENT_META[k].byHuman);
/**
* plan_event_logs.**reason** 列的取值登记表。
*
* 为什么要有:该列此前只在两处写过自由字符串(`'timeout'` / `'up'|'down'`),不登记也还看得懂;
* 但引擎补记账后它要同时承载「系统为什么收走这单」的三四种原因,再加上客服退回的 8 个 key
* —— 一列混着十几个未登记字符串,半年后没人说得清有哪些取值,
* 正是 [[PlanEventType]] 注释里那句「不要在调用处随手写字符串」要防的事,只是换了一列违反。
*
* ⚠️ 本表**不含退回原因** —— `release` 事件的 reason 直接用 [[ReleaseReason]] 的值,
* 那边已经是登记过的枚举,在这里再抄一份必然漂。合起来的取值域见 [[PlanEventReasonValue]]。
*/
export const PlanEventReason = {
// ── feedback 事件 ──
/// 召回准 👍
UP: 'up',
/// 召回不准 👎
DOWN: 'down',
// ── auto_release 事件(系统收走归属,无 actor)──
/// 超过 recycle_at 被 cron 回收(自动回收开关开启时才可能出现)
TIMEOUT: 'timeout',
/// 跟进诊所重归属 → 单子漂出原认领人 scope,不带着归属走(否则成孤儿单)
CLINIC_MOVED: 'clinic_moved',
/// 该患者本轮召回信号全部消失 → plan 直接关闭,归属一并释放
SIGNALS_CLEARED: 'signals_cleared',
} as const;
export type PlanEventReason = (typeof PlanEventReason)[keyof typeof PlanEventReason];
/// plan_event_logs.reason 的完整取值域 = 系统原因 ∪ 客服退回原因。
/// ⚠️ 写入 reason 列的地方一律标这个类型,别用裸 string。
export type PlanEventReasonValue = PlanEventReason | ReleaseReason;
/// ⚠️ 还没登记、等产品决策落地后再加(**加的时候必须补进上面的枚举,不要现场写字符串**):
/// · 分配批次被主管撤销 —— 教条 T21 / 开发规划 P6.4
/// · 分配单到期被收回 —— 开发规划 Q-7(到期后行为未定,MVS 只标红不收单)
// ============================================================= // =============================================================
// Plan Scripts / Summaries(异步生成,状态机) // Plan Scripts / Summaries(异步生成,状态机)
// ============================================================= // =============================================================
......
...@@ -6,6 +6,7 @@ import { ...@@ -6,6 +6,7 @@ import {
ExecutionChannelSchema, ExecutionChannelSchema,
ExecutionOutcomeSchema, ExecutionOutcomeSchema,
PlanStatusSchema, PlanStatusSchema,
ReleaseReasonSchema,
} from '../enums'; } from '../enums';
import { PagingResponseSchema } from './common'; import { PagingResponseSchema } from './common';
import { PatientSchema } from './patient'; import { PatientSchema } from './patient';
...@@ -239,8 +240,21 @@ export const AssignPlanRequestSchema = z.object({ ...@@ -239,8 +240,21 @@ export const AssignPlanRequestSchema = z.object({
}); });
export type AssignPlanRequest = z.infer<typeof AssignPlanRequestSchema>; export type AssignPlanRequest = z.infer<typeof AssignPlanRequestSchema>;
/**
* 退回(返池)请求。
*
* ⚠️ 2026-08 换掉了原来的 `reason?: string` —— 那个字段**从来没被读过**
* (controller 写作 `@Body() _dto`,service 签名里根本没有该参),
* 于是"客服为什么退回"这条信息在链路第一步就被丢了,退回原因分布无从统计。
*
* 换成结构化枚举而不是自由文本:分布要能聚合、要能反推「该调哪个默认值」
* (见 RELEASE_REASON_META.lever),自由文本做不到这两件事。
* ⚠️ 命名用 `releaseReason` 不用 `reason` —— 与 `recallFeedback`(召回准不准)
* 在语义上是两回事,叫 `reason` 迟早被人当成同一个东西聚合到一起。
*/
export const RecyclePlanRequestSchema = z.object({ export const RecyclePlanRequestSchema = z.object({
reason: z.string().optional(), releaseReason: ReleaseReasonSchema.optional().describe('退回原因(结构化);分配单必填'),
releaseNote: z.string().optional().describe('文字补充;原因为 other 时必填'),
}); });
export type RecyclePlanRequest = z.infer<typeof RecyclePlanRequestSchema>; export type RecyclePlanRequest = z.infer<typeof RecyclePlanRequestSchema>;
......
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