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 ?? [],
}; };
} }
} }
...@@ -5,6 +5,8 @@ import { TreatmentInitiationRecallScenario } from './scenarios/treatment-initiat ...@@ -5,6 +5,8 @@ import { TreatmentInitiationRecallScenario } from './scenarios/treatment-initiat
import type { PlanScenarioPlugin, ScenarioHit, ScenarioScope } from './scenario.interface'; import type { PlanScenarioPlugin, ScenarioHit, ScenarioScope } from './scenario.interface';
import { reasonNeedsRefresh } from './reason-refresh'; import { reasonNeedsRefresh } from './reason-refresh';
import { runPool } from '../../../common/run-pool'; import { runPool } from '../../../common/run-pool';
import { PlanEventType, PlanEventReason } from '@pac/types';
import { recordPlanEvent, recordPlanEventsBulk, computeHeldSeconds } from '../plan-event.recorder';
/** /**
* PlanEngineService — 跑所有 scenario plugin,产 FollowupPlan + PlanReason * PlanEngineService — 跑所有 scenario plugin,产 FollowupPlan + PlanReason
...@@ -143,13 +145,38 @@ export class PlanEngineService { ...@@ -143,13 +145,38 @@ export class PlanEngineService {
const latest = await this.prisma.followupPlan.findFirst({ const latest = await this.prisma.followupPlan.findFirst({
where: { hostId: scope.hostId, tenantId: scope.tenantId, patientId }, where: { hostId: scope.hostId, tenantId: scope.tenantId, patientId },
orderBy: { version: 'desc' }, orderBy: { version: 'desc' },
select: { id: true, status: true }, // ⭐ 多取四列是为了记账(见下):没有 assigneeUserId 判不出该不该记,
// 没有 assignedAt 算不出持有时长(清空后永远算不出来)。
select: {
id: true, status: true, hostId: true, tenantId: true,
patientId: true, assigneeUserId: true, assignedAt: true,
},
}); });
if (!latest || (latest.status !== 'active' && latest.status !== 'assigned')) return false; if (!latest || (latest.status !== 'active' && latest.status !== 'assigned')) return false;
await this.prisma.followupPlan.update({ // ⭐ 关的若是**有人认领的**单 → 必须留账:客服手上的单被系统收走了,
// 不记账则「他接了多少、跟丢多少」永久查不到,退回率的分母也天生偏小。
// 无人认领的关闭不记(那是纯粹的池子出清,不涉及归属变更,记了全是噪声)。
const hadAssignee = latest.assigneeUserId != null;
const heldSeconds = computeHeldSeconds(latest.assignedAt, scope.now);
await this.prisma.$transaction(async (tx) => {
await tx.followupPlan.update({
where: { id: latest.id }, where: { id: latest.id },
data: { status: 'superseded', supersededAt: scope.now }, data: { status: 'superseded', supersededAt: scope.now },
}); });
if (hadAssignee) {
await recordPlanEvent(tx, {
hostId: latest.hostId,
tenantId: latest.tenantId,
planId: latest.id,
patientId: latest.patientId,
event: PlanEventType.AUTO_RELEASE,
assigneeUserId: null,
actorUserId: null, // 系统行为
heldSeconds,
reason: PlanEventReason.SIGNALS_CLEARED,
});
}
});
return true; return true;
} }
...@@ -266,19 +293,56 @@ export class PlanEngineService { ...@@ -266,19 +293,56 @@ export class PlanEngineService {
// 致已认领的误召计划在修复后仍残留旧理由 → 客服按错信息跟进。终态不动) // 致已认领的误召计划在修复后仍残留旧理由 → 客服按错信息跟进。终态不动)
// - in-pool 的 assigned 不会被误关:其患者在 hitsByPatient,会被 has 命中跳过(理由已由 upsert 刷新+继承认领) // - in-pool 的 assigned 不会被误关:其患者在 hitsByPatient,会被 has 命中跳过(理由已由 upsert 刷新+继承认领)
// - 不创建后继版本(无信号是终结,非版本演进) // - 不创建后继版本(无信号是终结,非版本演进)
// - ⭐ 关掉的若是**有人认领的**单 → 同事务补一条 auto_release 账(2026-08 加)。
// 这条路是每日全量跑的,漏记的量级远大于单刷路径 closeStaleActivePlan:
// 主管分下去的单被系统悄悄收回,主管无感知、退回率分母偏小,正是分配功能最怕的。
const activePlans = await this.prisma.followupPlan.findMany({ const activePlans = await this.prisma.followupPlan.findMany({
where: { hostId: scope.hostId, tenantId: scope.tenantId, status: { in: ['active', 'assigned'] } }, where: { hostId: scope.hostId, tenantId: scope.tenantId, status: { in: ['active', 'assigned'] } },
select: { id: true, patientId: true }, // 多取两列供记账用(assignedAt 清不清都要先算持有时长)
select: { id: true, patientId: true, assigneeUserId: true, assignedAt: true },
}); });
const staleIds = activePlans const staleRows = activePlans.filter((pl) => !hitsByPatient.has(pl.patientId));
.filter((pl) => !hitsByPatient.has(pl.patientId)) if (staleRows.length > 0) {
.map((pl) => pl.id); // ⚠️ 必须分片:原实现是一条 `id: { in: staleIds }`,PG bind 变量上限 32767 —— 池子上了
if (staleIds.length > 0) { // 三万条(生产 44 万患者完全可能)就会直接报错。顺带让每片自成事务,
const res = await this.prisma.followupPlan.updateMany({ // 一片失败不至于让整轮关闭全部回滚。
where: { id: { in: staleIds } }, const CHUNK = 1000;
let closed = 0;
let released = 0;
for (let i = 0; i < staleRows.length; i += CHUNK) {
const chunk = staleRows.slice(i, i + CHUNK);
const events = chunk
.filter((pl) => pl.assigneeUserId != null)
.map((pl) => ({
hostId: scope.hostId,
tenantId: scope.tenantId,
planId: pl.id,
patientId: pl.patientId,
event: PlanEventType.AUTO_RELEASE,
assigneeUserId: null,
actorUserId: null, // 系统行为
heldSeconds: computeHeldSeconds(pl.assignedAt, now),
reason: PlanEventReason.SIGNALS_CLEARED,
}));
await this.prisma.$transaction(async (tx) => {
const res = await tx.followupPlan.updateMany({
where: { id: { in: chunk.map((pl) => pl.id) } },
data: { status: 'superseded', supersededAt: now }, data: { status: 'superseded', supersededAt: now },
}); });
plansClosed = res.count; closed += res.count;
await recordPlanEventsBulk(tx, events);
});
released += events.length;
}
plansClosed = closed;
if (released > 0) {
// ⚠️ 事件洪峰预警:一次口径变更可能让大量诊所的单同时失去信号 →
// 一轮写出成百上千条 auto_release。这行日志是给运维看的"这不是 bug"。
this.logger.warn(
`[归属账本] 本轮关闭 ${closed} 条无信号 plan,其中 ${released} 条**原本挂在客服名下**,` +
`已记 auto_release(reason=signals_cleared)。数量异常偏高时先查是不是召回口径刚变过。`,
);
}
} }
return { return {
...@@ -388,6 +452,9 @@ export class PlanEngineService { ...@@ -388,6 +452,9 @@ export class PlanEngineService {
// goal 是静态文案(不含天数)→ 直接比。算法改了措辞(如高龄缺牙 goal 改「活动义齿为主」) // goal 是静态文案(不含天数)→ 直接比。算法改了措辞(如高龄缺牙 goal 改「活动义齿为主」)
// 要能落到存量 plan,否则详情页「本次目标」永远是旧话。 // 要能落到存量 plan,否则详情页「本次目标」永远是旧话。
if ((latest.goal ?? null) !== (head.goal ?? null)) patch.goal = head.goal ?? null; if ((latest.goal ?? null) !== (head.goal ?? null)) patch.goal = head.goal ?? null;
// ⭐ 记账用:必须在 patch 生效**前**取,清空后 assignedAt 就没了(算不出持有时长)。
// null = 本次不涉及归属释放,不记账。
let clinicMoveRelease: { assigneeUserId: string; heldSeconds: number | null } | null = null;
if (latest.targetClinicId !== targetClinicId) { if (latest.targetClinicId !== targetClinicId) {
patch.targetClinicId = targetClinicId; patch.targetClinicId = targetClinicId;
// ⭐ 归属漂移 + 已认领 → 顺带返池:诊所隔离是硬边界,单子漂出原认领人 scope 后 // ⭐ 归属漂移 + 已认领 → 顺带返池:诊所隔离是硬边界,单子漂出原认领人 scope 后
...@@ -398,6 +465,15 @@ export class PlanEngineService { ...@@ -398,6 +465,15 @@ export class PlanEngineService {
patch.assigneeUserId = null; patch.assigneeUserId = null;
patch.assignedAt = null; patch.assignedAt = null;
patch.recycleAt = null; patch.recycleAt = null;
// ⭐ 这一步是**系统把单从客服手里收走**,必须留账。
// 不记的后果:主管分了 50 单,引擎重算悄悄收回一部分,主管毫不知情;
// 且这些单既没 release 也没 auto_release,退回率统计天生偏低。
if (latest.assigneeUserId != null) {
clinicMoveRelease = {
assigneeUserId: latest.assigneeUserId,
heldSeconds: computeHeldSeconds(latest.assignedAt, scope.now),
};
}
} }
this.logger.log( this.logger.log(
`plan ${latest.id} 跟进诊所重归属 ${latest.targetClinicId ?? '∅'} ${targetClinicId ?? '∅'}` + `plan ${latest.id} 跟进诊所重归属 ${latest.targetClinicId ?? '∅'} ${targetClinicId ?? '∅'}` +
...@@ -427,6 +503,20 @@ export class PlanEngineService { ...@@ -427,6 +503,20 @@ export class PlanEngineService {
if (Object.keys(patch).length > 0) { if (Object.keys(patch).length > 0) {
await tx.followupPlan.update({ where: { id: latest.id }, data: patch }); await tx.followupPlan.update({ where: { id: latest.id }, data: patch });
} }
// 归属释放与状态变更同生共死(recordPlanEvent 只收 tx 就是为了这个)
if (clinicMoveRelease) {
await recordPlanEvent(tx, {
hostId: latest.hostId,
tenantId: latest.tenantId,
planId: latest.id,
patientId: latest.patientId,
event: PlanEventType.AUTO_RELEASE,
assigneeUserId: null,
actorUserId: null, // 系统行为
heldSeconds: clinicMoveRelease.heldSeconds,
reason: PlanEventReason.CLINIC_MOVED,
});
}
for (const r of staleRows) { for (const r of staleRows) {
const h = hitByKey.get(key(r.scenario, r.subKey))!; const h = hitByKey.get(key(r.scenario, r.subKey))!;
await tx.planReason.update({ await tx.planReason.update({
...@@ -487,12 +577,40 @@ export class PlanEngineService { ...@@ -487,12 +577,40 @@ export class PlanEngineService {
}) })
)?.id ?? null; )?.id ?? null;
// ⭐ 升版本时归属**没被继承** → 客服手上的单在这一刻没了,必须留账。
// ⚠️ 判定必须在事务**之前**定好,不能进了事务再读 latest.status ——
// supersede 那句 update 之后 latest 已经是 'superseded',条件当场失效。
// (真 Prisma 返回的是脱离副本看不出来,内存 mock 直接暴露了这个别名陷阱。)
// ⚠️ planId 记**旧版本**的 id:归属本来就挂在那一版上,新版本是空归属的另一条记录,
// 记到新版会让"这个客服持有过哪些单"的回溯全部对不上。
// 触发条件目前只有一种(carryAssignment=false 且旧版 assigned):诊所重归属。
// 若将来加了别的不继承情形,reason 要一并加进 PlanEventReason,别复用 clinic_moved。
const supersedeRelease =
latest != null && latest.status === 'assigned' && !carryAssignment && latest.assigneeUserId != null
? {
planId: latest.id,
patientId: latest.patientId,
hostId: latest.hostId,
tenantId: latest.tenantId,
heldSeconds: computeHeldSeconds(latest.assignedAt, scope.now),
}
: null;
await this.prisma.$transaction(async (tx) => { await this.prisma.$transaction(async (tx) => {
if (latest && (latest.status === 'active' || latest.status === 'assigned')) { if (latest && (latest.status === 'active' || latest.status === 'assigned')) {
await tx.followupPlan.update({ await tx.followupPlan.update({
where: { id: latest.id }, where: { id: latest.id },
data: { status: 'superseded', supersededAt: new Date() }, data: { status: 'superseded', supersededAt: new Date() },
}); });
if (supersedeRelease) {
await recordPlanEvent(tx, {
...supersedeRelease,
event: PlanEventType.AUTO_RELEASE,
assigneeUserId: null,
actorUserId: null, // 系统行为
reason: PlanEventReason.CLINIC_MOVED,
});
}
} }
await tx.followupPlan.create({ await tx.followupPlan.create({
data: { data: {
......
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 取代了它)。
* *
* 进来直接落到详情工作台,选人规则(纯规则): * 进来直接落到详情工作台,选人规则(纯规则):
* ① 我的「进行中」第一个(优先续上手头工作) * ① 我的「进行中」第一个(优先续上手头工作)
......
'use client';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useRouter } from 'next/navigation';
import { ListChecks, CheckCircle2 } from 'lucide-react';
import type { PlanListItem } from '@pac/types';
import { cn } from '@/lib/utils';
import { useAuthStore } from '@/stores/auth-store';
import { useMyTasks } from '@/components/plans/use-my-tasks';
// 跟列表页 KPI 卡同款配色
const KPI_TONE: Record<string, string> = {
amber: 'bg-amber-50 text-amber-700 ring-amber-200',
emerald: 'bg-emerald-50 text-emerald-700 ring-emerald-200',
};
type Filter = 'active' | 'done' | 'all';
const FILTERS: { v: Filter; label: string }[] = [
{ v: 'active', label: '进行中' },
{ v: 'done', label: '已结案' },
{ v: 'all', label: '全部' },
];
const isDone = (s: string) => s === 'completed' || s === 'abandoned';
/**
* 详情页左侧"我的任务"沉浸抽屉 —— hover 触发条滑出,可固定(pin)。
* 服务端分页 + 触底加载更多(useMyTasks);筛选/搜索客户端在已加载项上做。
* 点任务 → 跳到对应 /plans/{id},不离开详情沉浸流。
*/
export function TaskDrawer({ currentPlanId }: { currentPlanId: string }) {
const router = useRouter();
const clinicDict = useAuthStore((s) => s.user?.dictionary?.clinics);
const { items, total, loading, hasMore, loadMore } = useMyTasks();
const [hover, setHover] = useState(false);
const [pinned, setPinned] = useState(false);
const [search, setSearch] = useState('');
const [filter, setFilter] = useState<Filter>('active');
const open = hover || pinned;
// 统计(已加载项)
const stats = useMemo(() => {
const pending = items.filter((p) => p.status === 'assigned').length;
const closed = items.filter((p) => isDone(p.status)).length;
return { pending, closed, total };
}, [items, total]);
// 客户端筛选 + 搜索 + 当前任务置顶
const visible = useMemo(() => {
let arr = items;
if (filter === 'active') arr = arr.filter((p) => p.status === 'assigned');
else if (filter === 'done') arr = arr.filter((p) => isDone(p.status));
if (search.trim()) {
const q = search.trim().toLowerCase();
arr = arr.filter(
(p) =>
(p.patient.name ?? p.patient.nameMasked ?? '').toLowerCase().includes(q) ||
(p.patient.phoneMasked ?? '').includes(q) ||
p.patient.externalId.toLowerCase().includes(q),
);
}
return [...arr].sort((a, b) => {
if (a.id === currentPlanId) return -1;
if (b.id === currentPlanId) return 1;
return b.priorityScore - a.priorityScore;
});
}, [items, filter, search, currentPlanId]);
// 触底加载哨兵
const sentinelRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!open || !sentinelRef.current) return;
const el = sentinelRef.current;
const io = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting) loadMore();
},
{ root: el.closest('[data-scroll]'), rootMargin: '120px' },
);
io.observe(el);
return () => io.disconnect();
}, [open, loadMore, visible.length]);
return (
<>
{/* 隐式 hover 触发条 — 左边缘全高。移动端(<md)无 hover,直接隐藏 */}
<div className="hidden md:block fixed left-0 top-0 bottom-0 w-3 z-40" onMouseEnter={() => setHover(true)}>
{!open && (
<div className="absolute left-0 top-1/2 -translate-y-1/2 flex flex-col items-center gap-1">
<span className="block w-1 h-12 bg-brand-500/40 rounded-r-full hover:bg-brand-600 transition-colors" />
<span className="text-[9px] text-slate-400 -rotate-90 origin-center mt-4 whitespace-nowrap tracking-widest pointer-events-none">
我的任务
</span>
</div>
)}
</div>
<aside
className={cn(
'fixed left-0 top-0 bottom-0 w-[320px] bg-white border-r border-slate-100 shadow-xl z-50',
'transition-transform duration-200 ease-out flex flex-col',
open ? 'translate-x-0' : '-translate-x-full',
)}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
>
{/* Header */}
<header className="flex-none px-3 pt-3 pb-2 border-b border-slate-100">
<div className="flex items-center justify-between gap-2 mb-2.5">
<div className="flex items-center gap-2 min-w-0">
<span className="inline-flex items-center justify-center w-7 h-7 rounded-md bg-brand-50 text-brand-700 ring-1 ring-brand-200 flex-none">
<svg viewBox="0 0 24 24" className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 11l3 3L22 4 M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
</svg>
</span>
<div className="min-w-0">
<div className="text-[13px] font-semibold text-slate-900 leading-tight">我的任务</div>
<div className="text-[10px] text-slate-500 tabular-nums">{total}</div>
</div>
</div>
<button
onClick={() => setPinned((v) => !v)}
title={pinned ? '取消固定' : '固定打开'}
className={cn(
'w-6 h-6 rounded inline-flex items-center justify-center transition-colors flex-none',
pinned ? 'bg-brand-600 text-white' : 'text-slate-400 hover:bg-slate-100 hover:text-slate-700',
)}
>
<svg viewBox="0 0 24 24" className={cn('w-3.5 h-3.5 transition-transform', pinned && 'rotate-45')} fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 17v5M5 3h14l-2 7H7L5 3zM7 10l-1.5 4h13L17 10" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
</div>
{/* KPI 卡(沿用列表页 KpiCard 样式,聚焦"我的")*/}
<div className="grid grid-cols-2 gap-1.5">
<KpiMini tone="amber" icon={<ListChecks className="h-4 w-4" />} label="我的进行中" value={stats.pending} />
<KpiMini tone="emerald" icon={<CheckCircle2 className="h-4 w-4" />} label="我已结案" value={stats.closed} />
</div>
</header>
{/* 搜索 + 筛选 */}
<div className="flex-none px-3 pt-2.5 pb-2 border-b border-slate-100 space-y-2">
<div className="relative">
<svg viewBox="0 0 24 24" className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-slate-400" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="11" cy="11" r="7" />
<path d="m21 21-4.3-4.3" strokeLinecap="round" />
</svg>
<input
type="search"
placeholder="搜索 姓名 / 手机 / ID(已加载项)"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-7 pr-2.5 py-1.5 rounded-md border border-slate-100 bg-slate-50 text-[12px] text-slate-800 focus:outline-none focus:ring-2 focus:ring-brand-200 focus:border-brand-300 focus:bg-white transition-all"
/>
</div>
<div className="grid grid-cols-3 gap-1">
{FILTERS.map((t) => {
const count =
t.v === 'active' ? stats.pending : t.v === 'done' ? stats.closed : items.length;
return (
<button
key={t.v}
onClick={() => setFilter(t.v)}
className={cn(
'px-1 py-1 rounded inline-flex items-center justify-center gap-1 text-[11px] transition-colors',
filter === t.v ? 'bg-brand-600 text-white font-semibold' : 'bg-slate-100 text-slate-600 hover:bg-slate-200',
)}
>
<span>{t.label}</span>
<span className="tabular-nums opacity-80">{count}</span>
</button>
);
})}
</div>
</div>
{/* 列表 + 触底分页 */}
<div data-scroll className="flex-1 overflow-y-auto p-2">
{visible.length === 0 && !loading ? (
<p className="text-center text-[11px] text-slate-400 py-8">未找到任务</p>
) : (
<ul className="space-y-1.5">
{visible.map((p) => (
<TaskRow
key={p.id}
item={p}
active={p.id === currentPlanId}
clinicName={p.targetClinicId ? clinicDict?.[p.targetClinicId] ?? null : null}
onSelect={() => {
setHover(false);
if (p.id !== currentPlanId) router.push(`/plans/${p.id}`);
}}
/>
))}
</ul>
)}
{/* 触底哨兵 */}
<div ref={sentinelRef} className="h-6 flex items-center justify-center">
{loading && <span className="text-[10.5px] text-slate-400">加载中…</span>}
{!hasMore && items.length > 0 && !loading && (
<span className="text-[10px] text-slate-300">没有更多了</span>
)}
</div>
</div>
</aside>
</>
);
}
/** 抽屉版 KPI 卡 —— 复刻列表页 KpiCard 视觉(icon 环 + label + 大数字),窄栏适配 */
function KpiMini({
tone,
icon,
label,
value,
}: {
tone: keyof typeof KPI_TONE;
icon: React.ReactNode;
label: string;
value: number;
}) {
const T = KPI_TONE[tone] ?? '';
return (
<div className="flex items-center gap-2 rounded-lg border border-slate-100 bg-white px-2 py-1.5">
<span className={cn('inline-flex h-7 w-7 flex-none items-center justify-center rounded-lg ring-1 ring-inset', T)}>
{icon}
</span>
<div className="min-w-0">
<div className="text-[10px] leading-tight text-slate-500">{label}</div>
<div className={cn('text-[18px] font-bold leading-none nums mt-0.5', T.split(' ')[1] ?? '')}>{value}</div>
</div>
</div>
);
}
function TaskRow({
item,
active,
clinicName,
onSelect,
}: {
item: PlanListItem;
active: boolean;
clinicName: string | null;
onSelect: () => void;
}) {
const name = item.patient.name ?? item.patient.nameMasked ?? item.patient.externalId;
const done = isDone(item.status);
return (
<li>
<button
onClick={onSelect}
className={cn(
'w-full text-left rounded-md border px-2.5 py-2 transition-colors',
active
? 'border-brand-400 bg-brand-50/60 ring-1 ring-brand-100'
: 'border-slate-100 bg-white hover:border-slate-300 hover:bg-slate-50',
done && 'opacity-70',
)}
>
<div className="flex items-center justify-between gap-2">
<span className="truncate text-[12.5px] font-semibold text-slate-900">{name}</span>
<span
className={cn(
'flex-none text-[10px] tabular-nums font-semibold px-1.5 py-0.5 rounded',
item.priorityScore >= 80 ? 'bg-rose-100 text-rose-700' : item.priorityScore >= 60 ? 'bg-amber-100 text-amber-700' : 'bg-slate-100 text-slate-600',
)}
>
{item.priorityScore}
</span>
</div>
<div className="mt-0.5 flex items-center gap-1.5 text-[10.5px] text-slate-500 min-w-0">
<span className="truncate">{item.scenarioLabel}</span>
{clinicName && (
<>
<span className="text-slate-300">·</span>
<span className="truncate">{clinicName}</span>
</>
)}
</div>
{active && <div className="mt-1 text-[10px] text-brand-700 font-medium">● 当前查看</div>}
</button>
</li>
);
}
...@@ -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 Link from 'next/link';
import { useEffect, useMemo, useState } from 'react';
import { toast } from 'sonner';
import {
ChevronDown,
ChevronLeft,
ChevronRight,
Flame,
ListChecks,
LogOut,
PhoneCall,
RefreshCw,
Search,
Waves,
X,
} from 'lucide-react';
import type { ListPlansQuery, PlanListItem } from '@pac/types';
import { Permission, potentialTreatmentCardLabel } from '@pac/types';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useAuthStore, visibleClinics } from '@/stores/auth-store';
import { useHasPermission } from '@/hooks/use-permission';
import { ApiError } from '@/lib/api-client';
import { cn, formatGender, userDisplayName, roleNameZh } from '@/lib/utils';
import { plansApi } from './plans-api';
import { usePlansList } from './use-plans-list';
import { usePlanCounts } from './use-plan-counts';
import { ReasonLine } from '@/components/plan-detail/reason-line';
import { HoverCard, HoverCardTrigger, HoverCardContent } from '@/components/ui/hover-card';
import { PriorityHover, type PriorityBreakdown } from '@/components/priority-hover';
// ─────────────────────────────────────────────────────────────────
// 召回任务工作台(/plans)
// 设计来源:claude.ai/design TlY76ilunKQrB9T5JFJJ-g · Variant B 工作台 + 卡片网格
//
// 客服真实用户流:
// 1. 进来看 KPI(我的进行中 / 池中高优 / 我完成 / 召回池余量)
// 2. 切 tab:我的工单(默认) / 召回池 / 全部
// 3. 卡片网格 — 永久 checkbox + 行内单条 / 批量操作
// 4. 单条:认领(pool)/ 继续(我的 assigned)/ 详情(其他状态)/ 取消认领(我的 assigned 回池)
// 5. 多选:批量认领(pool 视图)+ 批量回收(我的视图)
// ─────────────────────────────────────────────────────────────────
type Density = 'comfy' | 'regular' | 'dense';
type SortKey = 'priority_desc' | 'priority_asc';
type View = NonNullable<ListPlansQuery['view']>;
// 一页条数固定,density(宽松/标准/紧凑)只影响间距/视觉/列数,不改分页条数
const PAGE_SIZE = 12;
// 三档密度的视觉 token —— 拉开差异:列数 + 卡片内距 + 字号 + 理由行数 + 间距
const DENSITY = {
comfy: {
grid: 'grid-cols-1 lg:grid-cols-2',
gridGap: 'gap-4',
pad: 'p-4',
headGap: 'gap-3',
title: 'text-[15.5px]',
reason: 'text-[13px] line-clamp-3',
foot: 'px-4 py-3',
},
regular: {
grid: 'grid-cols-1 lg:grid-cols-2 xl:grid-cols-3',
gridGap: 'gap-3',
pad: 'p-3.5',
headGap: 'gap-3',
title: 'text-[14px]',
reason: 'text-[12.5px] line-clamp-2',
foot: 'px-3.5 py-2.5',
},
dense: {
grid: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4',
gridGap: 'gap-2',
pad: 'p-2.5',
headGap: 'gap-2',
title: 'text-[12.5px]',
reason: 'text-[11.5px] line-clamp-1',
foot: 'px-2.5 py-1.5',
},
} as const satisfies Record<Density, Record<string, string>>;
const STATUS_META: Record<string, { label: string; tone: string }> = {
active: { label: '待认领', tone: 'bg-sky-100 text-sky-700' },
assigned: { label: '进行中', tone: 'bg-amber-100 text-amber-700' },
completed: { label: '已完成', tone: 'bg-emerald-100 text-emerald-700' },
abandoned: { label: '已放弃', tone: 'bg-rose-100 text-rose-700' },
superseded: { label: '已替代', tone: 'bg-slate-100 text-slate-600' },
};
export function PlansListApp() {
const user = useAuthStore((s) => s.user);
const canViewAll = useHasPermission(Permission.PLAN_VIEW_ALL);
const canAssign = useHasPermission(Permission.PLAN_ASSIGN);
const canRecycle = useHasPermission(Permission.PLAN_RECYCLE);
const [density, setDensity] = useState<Density>('regular');
const [sort, setSort] = useState<SortKey>('priority_desc');
const [q, setQ] = useState('');
const [selected, setSelected] = useState<Set<string>>(new Set());
const [refreshToken, setRefreshToken] = useState(0);
const pageSize = PAGE_SIZE;
const { state, query, setQuery, refresh } = usePlansList({
view: 'mine',
page: 1,
pageSize,
sort, // 初始 query 带默认 sort,避免挂载时"先无 sort 拉一次、再带 sort 拉一次"的双请求
});
const { counts, reload: reloadCounts } = usePlanCounts(refreshToken);
const view = (query.view ?? 'mine') as View;
const refreshAll = () => {
refresh();
setRefreshToken((t) => t + 1);
void reloadCounts();
};
// W3 末:keyword / sort 改服务端(usePlansList → /pac/v1/plans?keyword=&sort=),
// 跨全表搜索 + 跨页排序;前端不再 .filter / .sort 本页 items
// - keyword 300ms debounce 防抖,避免每键一次请求
// - sort 变化立刻 setQuery
useEffect(() => {
const trimmed = q.trim();
const handle = setTimeout(() => {
setQuery({ keyword: trimmed || undefined });
}, 300);
return () => clearTimeout(handle);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [q]);
useEffect(() => {
setQuery({ sort });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sort]);
const visible: PlanListItem[] = state.status === 'ready' ? state.data.items : [];
const toggleSelect = (id: string) =>
setSelected((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
const togglePageSelect = () => {
const visibleIds = visible.map((p) => p.id);
const allOn = visibleIds.length > 0 && visibleIds.every((id) => selected.has(id));
setSelected((prev) => {
const next = new Set(prev);
if (allOn) visibleIds.forEach((id) => next.delete(id));
else visibleIds.forEach((id) => next.add(id));
return next;
});
};
const clearSelected = () => setSelected(new Set());
const handleClaimOne = async (plan: PlanListItem) => {
if (!user) return;
try {
await plansApi.assign(plan.id, user.sub);
// 成功不弹 toast —— refreshAll 后该行就地变为"已认领",列表本身即反馈
refreshAll();
} catch (e) {
toast.error('认领失败', { description: errMsg(e) });
}
};
const handleRecycleOne = async (plan: PlanListItem) => {
try {
await plansApi.recycle(plan.id);
// 成功不弹 toast —— refreshAll 后该行回到池中,列表本身即反馈
refreshAll();
} catch (e) {
toast.error('回收失败', { description: errMsg(e) });
}
};
const handleBatchClaim = async () => {
if (!user || !selected.size) return;
const ids = Array.from(selected);
const results = await Promise.allSettled(ids.map((id) => plansApi.assign(id, user.sub)));
const ok = results.filter((r) => r.status === 'fulfilled').length;
const fail = results.length - ok;
if (ok) toast.success(`批量认领 ${ok} 条`, { description: fail ? `失败 ${fail} 条` : undefined });
if (!ok && fail) toast.error('批量认领全部失败');
clearSelected();
refreshAll();
};
const handleBatchRecycle = async () => {
if (!selected.size) return;
const ids = Array.from(selected);
const results = await Promise.allSettled(ids.map((id) => plansApi.recycle(id)));
const ok = results.filter((r) => r.status === 'fulfilled').length;
const fail = results.length - ok;
if (ok) toast.success(`已回收 ${ok} 条`, { description: fail ? `失败 ${fail} 条` : undefined });
if (!ok && fail) toast.error('批量回收全部失败');
clearSelected();
refreshAll();
};
// 切换 view / status / q / sort 时回到第一页 + 清空选择(常识)
// ⭐ 切 view 时清空 status — 不同 view 状态可选项不同(pool 无 filter,mine 不可选 active)
// 不清会出现 mine + status=active 这种空查询的怪状态
const setView = (v: View) => {
setQuery({ view: v, status: undefined, page: 1 });
clearSelected();
};
const setStatus = (s: ListPlansQuery['status'] | undefined) => {
setQuery({ status: s, page: 1 });
clearSelected();
};
// 诊所多选筛选 — 选项 = 当前用户可见诊所(clinicIds 受限范围;集团级空→回退字典全部)。状态存 query.targetClinicIds
const clinicOptions = useMemo(() => visibleClinics(user), [user]);
const selectedClinics = query.targetClinicIds ?? [];
const setClinics = (ids: string[]) => {
setQuery({ targetClinicIds: ids.length ? ids : undefined, page: 1 });
clearSelected();
};
// 只看真实号码(外部对照表核实过;测试库其余为造数假号)
const realPhoneOnly = query.phoneVerified === true;
const toggleRealPhone = () => {
setQuery({ phoneVerified: realPhoneOnly ? undefined : true, page: 1 });
clearSelected();
};
return (
<div className="flex h-full min-h-screen flex-col bg-slate-50">
<PageHeader user={user} mineAssigned={counts.mineAssigned} poolHi={counts.pool} onRefresh={refreshAll} />
<div className="grid flex-none grid-cols-2 gap-3 border-b border-slate-100 bg-slate-50 px-5 py-3 md:grid-cols-4">
<KpiCard tone="amber" icon={<ListChecks className="h-4 w-4" />} label="我的进行中" value={counts.mineAssigned} sub={`共 ${counts.mine} 个工单`} />
<KpiCard tone="rose" icon={<Flame className="h-4 w-4" />} label="召回池余量" value={counts.pool} sub="池中待领取" />
<KpiCard tone="emerald" icon={<ListChecks className="h-4 w-4" />} label="我完成" value={counts.mineCompleted} sub={successRateText(counts)} />
<KpiCard tone="brand" icon={<Waves className="h-4 w-4" />} label="全部计划" value={counts.all} sub="活跃 + 历史" />
</div>
<div className="flex-none bg-white">
<ViewTabs current={view} counts={counts} canViewAll={canViewAll} onChange={setView} />
</div>
<FilterBar
view={view}
q={q}
setQ={setQ}
status={query.status}
setStatus={setStatus}
sort={sort}
setSort={setSort}
clinicOptions={clinicOptions}
selectedClinics={selectedClinics}
setClinics={setClinics}
realPhoneOnly={realPhoneOnly}
onToggleRealPhone={toggleRealPhone}
density={density}
setDensity={setDensity}
allSelected={visible.length > 0 && visible.every((p) => selected.has(p.id))}
someSelected={visible.some((p) => selected.has(p.id))}
onTogglePage={togglePageSelect}
/>
<BatchBar
count={selected.size}
view={view}
canAssign={canAssign}
canRecycle={canRecycle}
onClaim={handleBatchClaim}
onRecycle={handleBatchRecycle}
onClear={clearSelected}
/>
<div className="flex-1 overflow-y-auto p-4">
{state.status === 'loading' && <PlaceholderCard text="加载中…" />}
{state.status === 'error' && (
<Card>
<CardContent className="space-y-2 py-10 text-center">
<div className="text-sm text-rose-600">无法加载召回池</div>
<div className="text-xs text-muted-foreground">code={state.code ?? '?'} · {state.message}</div>
<Button variant="outline" size="sm" onClick={refreshAll}>重试</Button>
</CardContent>
</Card>
)}
{state.status === 'ready' && visible.length === 0 && (
<div className="py-20 text-center text-slate-500">
<div className="mb-2 text-3xl">🌿</div>
<p className="text-[13px]">{q ? '无匹配结果(尝试清空搜索)' : '本视图暂无任务'}</p>
</div>
)}
{state.status === 'ready' && visible.length > 0 && (
<div className={cn('grid', DENSITY[density].gridGap, DENSITY[density].grid)}>
{visible.map((p) => (
<PatientPlanCard
key={p.id}
plan={p}
me={user?.sub ?? ''}
view={view}
density={density}
selected={selected.has(p.id)}
onSelect={() => toggleSelect(p.id)}
canAssign={canAssign}
canRecycle={canRecycle}
onClaim={() => handleClaimOne(p)}
onUnclaim={() => handleRecycleOne(p)}
/>
))}
</div>
)}
</div>
{state.status === 'ready' && (
<Pagination
page={state.data.page}
pageSize={state.data.pageSize}
total={state.data.total}
onPage={(p) => {
setQuery({ page: p });
clearSelected();
}}
/>
)}
</div>
);
}
// ─────────────────────────────────────────────────────────────────
// PageHeader — PAC logo + 标题 + me + 刷新
// ─────────────────────────────────────────────────────────────────
function PageHeader({
user,
mineAssigned,
poolHi,
onRefresh,
}: {
user: ReturnType<typeof useAuthStore.getState>['user'];
mineAssigned: number;
poolHi: number;
onRefresh: () => void;
}) {
const greeting = mineAssigned > 0 ? `今天好,还有 ${mineAssigned} 个待打` : poolHi > 0 ? `池中还有 ${poolHi} 个待领取` : '今天好';
return (
<header className="flex flex-none items-center justify-between gap-3 border-b border-slate-100 bg-white px-5 py-3">
<div className="flex min-w-0 items-center gap-3">
<div className="inline-flex items-center gap-2">
<span className="inline-flex h-7 w-7 items-center justify-center rounded-md bg-brand-600 text-[12px] font-bold text-white">PAC</span>
<span className="text-[13px] font-semibold text-slate-900">疗效保障</span>
</div>
<span className="text-slate-300">/</span>
<div className="min-w-0">
<h1 className="truncate text-[15px] font-semibold leading-tight text-slate-900">召回任务工作台</h1>
<p className="mt-0.5 truncate text-[11px] text-slate-500">{greeting}</p>
</div>
</div>
<div className="flex flex-none items-center gap-3">
<Button variant="outline" size="sm" onClick={onRefresh} className="h-8">
<RefreshCw className="h-3.5 w-3.5" /> 刷新
</Button>
<div className="hidden text-right text-[11.5px] leading-tight text-slate-500 md:block">
<div className="font-medium text-slate-700">{userDisplayName(user)}</div>
<div className="nums">{user?.clinicIds?.length ?? 0} 个诊所 · {roleNameZh(user?.role)}</div>
</div>
<span className="inline-flex h-8 w-8 items-center justify-center rounded-full bg-gradient-to-br from-brand-400 to-brand-600 text-[12px] font-bold text-white">
{userDisplayName(user).charAt(0).toUpperCase()}
</span>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400 hover:text-rose-600"
title="退出登录"
aria-label="退出登录"
onClick={() => {
useAuthStore.getState().clear(); // 清 token → AuthGate 自动弹回登录(mock/SSO 同此口径)
// 不弹 toast —— 立即跳回登录页本身即反馈
}}
>
<LogOut className="h-4 w-4" />
</Button>
</div>
</header>
);
}
// ─────────────────────────────────────────────────────────────────
// KPI Card
// ─────────────────────────────────────────────────────────────────
const TONE_BG: Record<string, string> = {
amber: 'bg-amber-50 text-amber-700 ring-amber-200',
rose: 'bg-rose-50 text-rose-700 ring-rose-200',
emerald: 'bg-emerald-50 text-emerald-700 ring-emerald-200',
brand: 'bg-brand-50 text-brand-700 ring-brand-200',
};
function KpiCard({
tone,
icon,
label,
value,
sub,
}: {
tone: keyof typeof TONE_BG;
icon: React.ReactNode;
label: string;
value: number;
sub?: string;
}) {
const T = TONE_BG[tone] ?? '';
return (
<div className="flex items-center gap-3 rounded-lg border border-slate-100 bg-white px-3 py-2.5">
<span className={cn('inline-flex h-9 w-9 flex-none items-center justify-center rounded-lg ring-1 ring-inset', T)}>
{icon}
</span>
<div className="min-w-0 flex-1">
<div className="text-[11px] leading-tight text-slate-500">{label}</div>
<div className="mt-0.5 flex items-baseline gap-1.5">
<span className={cn('text-[22px] font-bold leading-none nums', T.split(' ')[1] ?? '')}>{value}</span>
{sub && <span className="truncate text-[10.5px] text-slate-400">{sub}</span>}
</div>
</div>
</div>
);
}
function successRateText(c: { mineCompleted: number; mine: number }): string {
if (c.mine === 0) return '暂无数据';
return `转化率 ${Math.round((c.mineCompleted / c.mine) * 100)}%`;
}
// ─────────────────────────────────────────────────────────────────
// View Tabs
// ─────────────────────────────────────────────────────────────────
function ViewTabs({
current,
counts,
canViewAll,
onChange,
}: {
current: View;
counts: { mine: number; pool: number; all: number };
canViewAll: boolean;
onChange: (v: View) => void;
}) {
const tabs: Array<{ v: View; label: string; hint: string; gated?: boolean }> = [
{ v: 'mine', label: '我的工单', hint: '已分配给我的(active+assigned)' },
{ v: 'pool', label: '召回池', hint: '待领取(active+未分配)' },
{ v: 'all', label: '全部', hint: '全状态', gated: !canViewAll },
];
return (
<div className="border-b border-slate-100">
<div className="flex items-center px-3">
{tabs.map((t) => {
const active = current === t.v;
const count = counts[t.v];
if (t.gated) {
return (
<button
key={t.v}
disabled
title="需要 PLAN_VIEW_ALL 权限"
className="px-4 py-2 text-[13px] text-slate-300 cursor-not-allowed"
>
{t.label}
</button>
);
}
return (
<button
key={t.v}
onClick={() => onChange(t.v)}
title={t.hint}
className={cn(
'relative inline-flex items-center gap-1.5 px-4 py-2.5 text-[13px] transition-colors',
active ? 'font-semibold text-slate-900' : 'text-slate-500 hover:text-slate-900',
)}
>
{t.label}
<span
className={cn(
'inline-flex h-[18px] min-w-[20px] items-center justify-center rounded px-1 text-[10.5px] font-bold nums',
active ? 'bg-brand-600 text-white' : 'bg-slate-100 text-slate-600',
)}
>
{count}
</span>
{active && <span className="absolute -bottom-px left-3 right-3 h-[2px] rounded-full bg-brand-600" />}
</button>
);
})}
</div>
</div>
);
}
// ─────────────────────────────────────────────────────────────────
// FilterBar — 搜索 / 状态 / 排序 / 密度 / 全选本页
// ─────────────────────────────────────────────────────────────────
/// 诊所多选筛选(Popover + 复选框)。选项来自 token dictionary.clinics(用户可见诊所)。
function ClinicFilter({
options,
selected,
onChange,
}: {
options: Array<{ id: string; name: string }>;
selected: string[];
onChange: (ids: string[]) => void;
}) {
const sel = new Set(selected);
const toggle = (id: string) => {
const next = new Set(sel);
if (next.has(id)) next.delete(id);
else next.add(id);
onChange([...next]);
};
const label = selected.length === 0 ? '全部诊所' : `诊所 · ${selected.length}`;
return (
<Popover>
<PopoverTrigger asChild>
<button className="inline-flex h-8 items-center gap-1.5 rounded-md border border-slate-100 bg-white px-3 text-xs hover:border-slate-300">
<span className={cn(selected.length ? 'font-medium text-slate-900' : 'text-slate-600')}>{label}</span>
<ChevronDown className="h-3.5 w-3.5 text-slate-400" />
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-56 max-h-72 overflow-y-auto p-1.5">
<div className="mb-1 flex items-center justify-between border-b border-slate-100 px-1.5 py-1">
<span className="text-[10.5px] text-slate-400">按诊所筛选(可多选)</span>
{selected.length > 0 && (
<button onClick={() => onChange([])} className="text-[10.5px] text-brand-600 hover:underline">
清空
</button>
)}
</div>
{options.map((o) => (
<label
key={o.id}
className="flex cursor-pointer items-center gap-2 rounded px-1.5 py-1.5 hover:bg-slate-50"
>
<input
type="checkbox"
className="h-3.5 w-3.5 accent-brand-600"
checked={sel.has(o.id)}
onChange={() => toggle(o.id)}
/>
<span className="truncate text-[12px] text-slate-700">{o.name}</span>
</label>
))}
</PopoverContent>
</Popover>
);
}
function FilterBar({
view,
q, setQ, status, setStatus,
sort, setSort, density, setDensity,
clinicOptions, selectedClinics, setClinics,
realPhoneOnly, onToggleRealPhone,
allSelected, someSelected, onTogglePage,
}: {
view: View;
q: string;
setQ: (v: string) => void;
status: ListPlansQuery['status'] | undefined;
setStatus: (s: ListPlansQuery['status'] | undefined) => void;
sort: SortKey;
setSort: (s: SortKey) => void;
density: Density;
setDensity: (d: Density) => void;
clinicOptions: Array<{ id: string; name: string }>;
selectedClinics: string[];
setClinics: (ids: string[]) => void;
realPhoneOnly: boolean;
onToggleRealPhone: () => void;
allSelected: boolean;
someSelected: boolean;
onTogglePage: () => void;
}) {
// 按 view 决定状态可选 — 跟后端 view filter 口径对齐(plan.service.ts)
// pool: WHERE status=active AND assigneeUserId IS NULL → 全部都是"待认领",status filter 无意义,隐藏
// mine: WHERE assigneeUserId=user → 可能 assigned/completed/abandoned,不会有 active
// all: 无 view 过滤,全状态可见
const statusOptions: Array<{ value: string; label: string }> | null =
view === 'pool'
? null // pool 隐藏 status filter(语义上等价"待认领")
: view === 'mine'
? [
{ value: '__all', label: '全部我的' },
{ value: 'assigned', label: '进行中' },
{ value: 'completed', label: '已完成' },
{ value: 'abandoned', label: '已放弃' },
]
: [
{ value: '__all', label: '全部状态' },
{ value: 'active', label: '待认领' },
{ value: 'assigned', label: '已认领' },
{ value: 'completed', label: '已完成' },
{ value: 'abandoned', label: '已放弃' },
];
return (
<div className="flex flex-none flex-wrap items-center gap-2 border-b border-slate-100 bg-white px-5 py-2.5">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400" />
<Input
compact
type="search"
placeholder="搜索 姓名 / 手机 / 外部 ID"
value={q}
onChange={(e) => setQ(e.target.value)}
className="w-64 pl-8"
/>
</div>
{statusOptions && (
<Select value={status ?? '__all'} onValueChange={(v) => setStatus(v === '__all' ? undefined : (v as ListPlansQuery['status']))}>
<SelectTrigger className="h-8 w-32 text-xs"><SelectValue placeholder="状态" /></SelectTrigger>
<SelectContent>
{statusOptions.map((o) => (
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
)}
{clinicOptions.length > 1 && (
<ClinicFilter options={clinicOptions} selected={selectedClinics} onChange={setClinics} />
)}
<Select value={sort} onValueChange={(v) => setSort(v as SortKey)}>
<SelectTrigger className="h-8 w-44 text-xs"><SelectValue placeholder="排序" /></SelectTrigger>
<SelectContent>
<SelectItem value="priority_desc">优先级 高 → 低</SelectItem>
<SelectItem value="priority_asc">优先级 低 → 高</SelectItem>
</SelectContent>
</Select>
{/* 只看真实号码(外部对照表核实;测试库其余为造数假号) */}
<button
type="button"
onClick={onToggleRealPhone}
title="只看已核实的真实手机号"
className={cn(
'inline-flex h-8 items-center gap-1 rounded-md border px-2.5 text-xs transition-colors',
realPhoneOnly
? 'border-brand-300 bg-brand-50 font-medium text-brand-700'
: 'border-slate-100 bg-white text-slate-600 hover:bg-slate-50',
)}
>
<PhoneCall className="h-3.5 w-3.5" />
真实号码
</button>
<div className="inline-flex items-center rounded-md bg-slate-100 p-0.5">
{(['comfy', 'regular', 'dense'] as const).map((d) => (
<button
key={d}
onClick={() => setDensity(d)}
className={cn(
'rounded px-2 py-1 text-[11.5px] transition-colors',
density === d ? 'bg-white font-semibold text-slate-900 shadow-sm' : 'text-slate-500 hover:text-slate-800',
)}
title={`密度 · ${d}`}
>
{{ comfy: '宽松', regular: '标准', dense: '紧凑' }[d]}
</button>
))}
</div>
<div className="ml-auto flex items-center gap-2">
<label className="inline-flex cursor-pointer select-none items-center gap-1.5">
<input
type="checkbox"
className="h-4 w-4 accent-brand-600 cursor-pointer"
checked={allSelected}
ref={(el) => {
if (el) el.indeterminate = !allSelected && someSelected;
}}
onChange={onTogglePage}
/>
<span className="text-[11.5px] text-slate-600">全选本页</span>
</label>
</div>
</div>
);
}
// ─────────────────────────────────────────────────────────────────
// BatchBar — 浮出
// ─────────────────────────────────────────────────────────────────
function BatchBar({
count, view, canAssign, canRecycle,
onClaim, onRecycle, onClear,
}: {
count: number;
view: View;
canAssign: boolean;
canRecycle: boolean;
onClaim: () => void;
onRecycle: () => void;
onClear: () => void;
}) {
if (!count) return null;
return (
<div className="flex flex-none items-center gap-3 border-y border-brand-200 bg-brand-50/80 px-5 py-2 backdrop-blur">
<span className="text-[12px] text-brand-800">
已选 <span className="nums font-bold">{count}</span>
</span>
<span className="text-slate-300">·</span>
{view !== 'mine' && canAssign && (
<Button size="sm" className="h-7" onClick={onClaim}>批量认领</Button>
)}
{view !== 'pool' && canRecycle && (
<Button size="sm" variant="outline" className="h-7" onClick={onRecycle}>批量回收</Button>
)}
<Button size="sm" variant="ghost" className="ml-auto h-7 text-slate-500 hover:text-slate-700" onClick={onClear}>
<X className="h-3.5 w-3.5" /> 取消选择
</Button>
</div>
);
}
// ─────────────────────────────────────────────────────────────────
// PatientPlanCard — 富卡片
// ─────────────────────────────────────────────────────────────────
function PatientPlanCard({
plan, me, view, density, selected, onSelect,
canAssign, canRecycle, onClaim, onUnclaim,
}: {
plan: PlanListItem;
me: string;
view: View;
density: Density;
selected: boolean;
onSelect: () => void;
canAssign: boolean;
canRecycle: boolean;
onClaim: () => void;
onUnclaim: () => void;
}) {
const p = plan;
const clinicDict = useAuthStore((s) => s.user?.dictionary?.clinics);
// 诊所名只在「跨诊所权限」下才有信息量:单诊所的人每张卡都是同一家,纯噪音。
// ⚠️ 判据用 `!== 1` 而不是 `> 1`:visibleClinics 在诊所字典尚未拉到时会返回 0
// (集团级用户 clinicIds 为空 → 回退全字典 → 字典空则 0),按 `> 1` 会把集团级用户
// 也误隐藏。只在**确知是单诊所**时才隐藏,未知一律显示 —— 多显示一个字段是小事,
// 把跨诊所的人看不到诊所归属是大事。
const showClinic = useAuthStore((s) => visibleClinics(s.user).length) !== 1;
const isMine = p.assigneeUserId === me;
const isPool = p.status === 'active' && !p.assigneeUserId;
const isClosed = p.status === 'completed' || p.status === 'abandoned';
const displayName = p.patient.name ?? p.patient.nameMasked ?? p.patient.externalId;
const d = DENSITY[density];
const pad = d.pad;
const titleSize = d.title;
return (
<article
className={cn(
'group relative flex flex-col rounded-lg border bg-white transition-all',
selected ? 'border-brand-400 ring-2 ring-brand-100' : 'border-slate-100 hover:border-slate-300 hover:shadow-sm',
isClosed && 'opacity-75',
)}
>
{/* 头:复选 + 患者 + 优先级 */}
<div className={cn('flex items-start', d.headGap, pad, 'pb-2')}>
<span
className="flex-none cursor-pointer pt-0.5"
onClick={(e) => {
e.stopPropagation();
onSelect();
}}
>
<input
type="checkbox"
className="h-4 w-4 accent-brand-600 cursor-pointer"
checked={selected}
onChange={onSelect}
onClick={(e) => e.stopPropagation()}
/>
</span>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
<span className={cn('truncate font-semibold leading-tight text-slate-900', titleSize)}>{displayName}</span>
{/* 顺序对齐详情页患者卡:姓名 → 病历号 → 性别·年龄。
宿主不一定给病历号(jvs-dw 91% / friday 63%),缺就整块不占位 */}
{p.patient.medicalRecordNumber && (
<span className="truncate font-mono text-[10.5px] text-slate-400 nums" title="病历号">
{p.patient.medicalRecordNumber}
</span>
)}
<span className="text-[10.5px] text-slate-500 nums">
{formatGender(p.patient.gender)} · {p.patient.age ?? '?'}
</span>
</div>
<div className="mt-0.5 flex items-center gap-1.5 text-[11px] text-slate-500">
<span className="font-mono nums">{p.patient.phoneMasked ?? '电话未知'}</span>
{p.patient.phoneVerified && (
<span
title="真实手机号(外部对照表已核实)"
className="inline-flex items-center rounded bg-brand-50 px-1 text-[10px] font-medium leading-4 text-brand-700 ring-1 ring-inset ring-brand-200"
>
</span>
)}
{/* 诊所名:单诊所权限的人每张卡都是同一家,纯噪音 → 隐藏;跨诊所才有区分价值 */}
{p.targetClinicId && showClinic && (
<>
<span className="text-slate-300">·</span>
<span className="truncate">{clinicDict?.[p.targetClinicId] ?? p.targetClinicId}</span>
</>
)}
</div>
</div>
<PriorityHover
score={p.priorityScore}
breakdown={(p.reasons[0]?.breakdown as { priority?: PriorityBreakdown } | null | undefined)?.priority}
>
<span className="inline-flex items-center gap-1 cursor-help group/score">
<PriorityBar score={p.priorityScore} />
<svg className="w-3 h-3 text-slate-400 opacity-0 group-hover/score:opacity-100 transition-opacity" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10" />
<path d="M12 16v-4M12 8h.01" strokeLinecap="round" />
</svg>
</span>
</PriorityHover>
</div>
{/* 中:scenario + status */}
<div className={cn('flex flex-wrap items-center gap-1.5', pad, 'pb-2 pt-0')}>
<ScenarioChip label={p.scenarioLabel} scenario={p.scenario} />
<StatusPill status={p.status} />
{/* 潜在治疗(出自画像 potential_treatment):覆盖该患者全部"诊断了没做"的项,
比下面的召回理由更宽 —— 理由只是这次值得召回的那条。全列不折叠。 */}
<PotentialTreatmentChips labels={p.patient.potentialTreatments} />
</div>
{/* 召回理由富文本(跟详情页 WhyCard 同源 ReasonLine):后端给 signals raw JSON,前端字典翻译。
列表只展示 primary(MAX priorityScore)一条,卡片保持紧凑;多 reason 全量在详情页看。 */}
<div className={cn('leading-snug text-slate-700', d.reason, pad, 'pb-3 pt-0')}>
{/* primary = reasons[0](后端按 priorityScore desc 返回 → 最高优先级);其余 +N hover 展示 */}
{p.reasons[0] ? <ReasonLine reason={p.reasons[0]} /> : <span>{p.inclusionReason}</span>}
{p.reasons.length > 1 && (
<HoverCard openDelay={120} closeDelay={80}>
<HoverCardTrigger asChild>
<button
onClick={(e) => e.stopPropagation()}
className="ml-1.5 align-middle rounded px-1 text-[10.5px] text-slate-400 hover:text-brand-700 hover:bg-slate-100 cursor-default"
>
+{p.reasons.length - 1}
</button>
</HoverCardTrigger>
<HoverCardContent align="start" sideOffset={6} className="w-80 p-3">
<p className="mb-1.5 text-[11px] font-medium text-slate-500">
其余 {p.reasons.length - 1} 项潜在治疗
</p>
<ul className="space-y-1.5 text-[12px] leading-relaxed text-slate-700">
{p.reasons.slice(1).map((r) => (
<li key={r.id} className="flex gap-1.5">
<span className="mt-[2px] flex-none text-rose-400"></span>
<div className="min-w-0 flex-1">
<ReasonLine reason={r} />
</div>
</li>
))}
</ul>
</HoverCardContent>
</HoverCard>
)}
</div>
{/* 底:meta + 操作 */}
<div className={cn('mt-auto flex items-center justify-between gap-2 border-t border-slate-100', d.foot)}>
<div className="flex min-w-0 items-center gap-2.5 text-[11px] text-slate-600">
{/* 上次到诊 + 那一次的医生(同源于同一次就诊,不会日期/医生错配)。
医生名只有 emr_record 带,取不到就只显示日期,不拿医生 id 顶替。 */}
{p.patient.lastVisitAt && (
<span className="truncate nums" title="上次到诊">
上次 {p.patient.lastVisitAt}
{p.patient.lastVisitDoctor && (
<span className="text-slate-500"> · {p.patient.lastVisitDoctor}</span>
)}
</span>
)}
{p.assigneeUserId && view === 'all' && (
<span className="truncate font-mono text-[10.5px] text-slate-500">承接 {p.assigneeUserId}</span>
)}
</div>
<div className="flex flex-none items-center gap-1.5">
{isPool && canAssign && (
<button
onClick={onClaim}
className="rounded-md bg-brand-600 px-2.5 py-1 text-[12px] font-semibold text-white transition-colors hover:bg-brand-700"
>
认领 →
</button>
)}
{isMine && p.status === 'assigned' && (
<>
{canRecycle && (
<button
onClick={onUnclaim}
className="rounded-md px-2 py-1 text-[11.5px] text-slate-500 hover:bg-slate-100 hover:text-slate-800"
>
取消认领
</button>
)}
<Link
href={`/plans/${p.id}`}
className="rounded-md bg-slate-900 px-2.5 py-1 text-[12px] font-semibold text-white hover:bg-slate-800"
>
继续 →
</Link>
</>
)}
{!isPool && !(isMine && p.status === 'assigned') && (
<Link
href={`/plans/${p.id}`}
className="rounded-md border border-slate-100 px-2.5 py-1 text-[12px] text-slate-700 hover:bg-slate-50"
>
详情 →
</Link>
)}
</div>
</div>
</article>
);
}
// ─────────────────────────────────────────────────────────────────
// 原子组件
// ─────────────────────────────────────────────────────────────────
function PriorityBar({ score }: { score: number }) {
const pct = Math.max(0, Math.min(1, score / 100));
const filled = Math.max(1, Math.round(pct * 5));
const colors = ['bg-emerald-400', 'bg-emerald-500', 'bg-amber-400', 'bg-amber-500', 'bg-rose-500'];
const labelTone =
pct >= 0.8 ? 'text-rose-700'
: pct >= 0.6 ? 'text-amber-700'
: pct >= 0.4 ? 'text-amber-700'
: pct >= 0.2 ? 'text-emerald-700'
: 'text-slate-500';
// 10 分制(两位小数)= 排序键 score / 10。
// ⚠️ 不要改回读 breakdown.raw:那会让「显示的数」和「排序用的数」分成两条源,
// 2026-07-29 就是这么出的乱序 bug(排序用 0-100 整数、显示用 0-10 两位小数)。
// 现在 score 由 priority-scorer 保证 = raw × 10,除以 10 即还原展示值,单一真理源。
const disp = (score / 10).toFixed(2);
return (
<span
className="inline-flex flex-none items-center gap-1"
title={`优先级 ${disp} / 10`}
>
<span className="inline-flex items-center gap-0.5">
{Array.from({ length: 5 }).map((_, i) => (
<span key={i} className={cn('h-2 w-[7px] rounded-sm', i < filled ? colors[i] : 'bg-slate-200')} />
))}
</span>
<span className={cn('text-[10.5px] font-semibold nums', labelTone)}>{disp}</span>
</span>
);
}
function StatusPill({ status }: { status: string }) {
const m = STATUS_META[status] ?? { label: status, tone: 'bg-slate-100 text-slate-700' };
return <span className={cn('inline-block rounded px-1.5 py-0.5 text-[10.5px] font-medium', m.tone)}>{m.label}</span>;
}
/**
* 潜在治疗标签(画像 potential_treatment.labels)。
*
* 样式上刻意比 ScenarioChip 弱一档(无底色、细描边、slate 色):场景 chip 是"这次为什么召",
* 是主信息;潜在治疗是"这人还有哪些没做",是背景信息。抢色会让客服分不清主次。
* 全列不折叠(业务口径):标签多的患者本来就该优先跟,让它换行占地方是对的。
*/
function PotentialTreatmentChips({ labels }: { labels: string[] }) {
if (!labels.length) return null;
// 不折叠(与 rail 同口径,见 patient-picker-rail 注释):全列出来,让容器自然换行。
return (
<>
{labels.map((l) => (
<span
key={l}
className="inline-block rounded px-1.5 py-0.5 text-[10.5px] text-slate-600 ring-1 ring-inset ring-slate-200"
>
{potentialTreatmentCardLabel(l)}
</span>
))}
</>
);
}
function ScenarioChip({ label, scenario }: { label: string; scenario: string }) {
const tone =
scenario === 'treatment_initiation_recall'
? 'bg-rose-50 text-rose-700 ring-rose-200'
: 'bg-brand-50 text-brand-700 ring-brand-200';
return <span className={cn('inline-block rounded px-1.5 py-0.5 text-[10.5px] font-medium ring-1 ring-inset', tone)}>{label}</span>;
}
// ─────────────────────────────────────────────────────────────────
// Pagination
// ─────────────────────────────────────────────────────────────────
function Pagination({
page, pageSize, total, onPage,
}: {
page: number;
pageSize: number;
total: number;
onPage: (p: number) => void;
}) {
const totalPages = Math.max(1, Math.ceil(total / pageSize));
return (
<div className="flex flex-none items-center justify-between border-t border-slate-100 bg-white px-5 py-2.5 text-[11.5px] text-slate-500">
<div>
<b className="nums font-semibold text-slate-800">{total}</b> 条 · 第 <b className="nums">{page}</b>/<span className="nums">{totalPages}</span>
</div>
<div className="flex items-center gap-1">
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => onPage(page - 1)} className="h-7">
<ChevronLeft className="h-3.5 w-3.5" /> 上一页
</Button>
<Button variant="outline" size="sm" disabled={page >= totalPages} onClick={() => onPage(page + 1)} className="h-7">
下一页 <ChevronRight className="h-3.5 w-3.5" />
</Button>
</div>
</div>
);
}
function PlaceholderCard({ text }: { text: string }) {
return (
<Card>
<CardContent className="py-10 text-center text-sm text-muted-foreground">{text}</CardContent>
</Card>
);
}
// ─────────────────────────────────────────────────────────────────
// helpers
// ─────────────────────────────────────────────────────────────────
function errMsg(e: unknown): string {
if (e instanceof ApiError) return `code=${e.code} · ${e.message}`;
return e instanceof Error ? e.message : String(e);
}
'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