Commit 3fd9af36 by luoqi

feat(plan): 关闭超时自动回收 + 新增 plan 生命周期事件账本

## 1. 自动回收改为默认关闭(PAC_PLAN_AUTO_RECYCLE=on 才跑)

它一直在生产跑着(ScheduleModule 已启用、服务已注册),日志可查:两天触发 9 次、
累计回收 32 单。前端那句"暂无自动回收机制"说的是倒计时控件被隐藏,不是后端。

关闭理由:回收会把 assignee_user_id / assigned_at **就地置 null**,而「认领」现在要
当作「该患者已被客服处理」用于统计 —— 每 10 分钟抹一次,统计地基是漏的。

代码保留不删:超时兜底的原始问题(领了不做 → 患者被锁死)依然成立,后续想改成
更长超时或"只提醒不回收",打开开关即可。启动时打印开关状态(它会静默改数据)。

注:生产当前登录角色全是 leader,自认领 / 自返池都在权限内,无"staff 无法释放工单"
的沉淀风险。将来放开 staff 角色需重新评估(staff 无 PLAN_RECYCLE)。

## 2. PlanEventLog — plan 生命周期事件账本(append-only)

为什么 PlanGenerationLog 顶不上:它是**每次引擎跑批一行**的管道账本,**连 plan_id
都没有**,只记"引擎跑了没有、产出几个"(生产 750 万行 / 24 万患者,约 80 万行/天)。
回答不了"这个 plan 经历了什么"。

已收事件:claim / assign / release / auto_release(归属)+ feedback(召回 👍👎)。
assign 为门诊经理分配预留 —— actor != assignee 时自动区分,无需事后反推。

 收录边界(注释里划死,防止变垃圾桶):
   人工动作 + 会被就地覆盖且事后无法还原的状态变更
   引擎重算 / supersede —— 80 万行/天,与人工动作(约 30~100/天)差 4 个数量级,
     混入会淹没人工动作;引擎侧已有 PlanGenerationLog + followup_plans 版本流
   前端行为埋点(浏览/点击)—— 噪声大、可刷,属产品分析那一摊

### 写入方式:应用层 + 同事务,**不用数据库触发器**
  1. 触发器拿不到操作人 —— actor 在 JWT 里,DB 只看得见行变了;靠 session 变量传递
     还是得应用配合,只是变隐式更易漏。本项目 AsyncLocalStorage 目前也不含 user。
  2. 拿不到 actor 就分不出 claim / assign —— 这恰是本表最核心的价值。
  3. 触发器会被运维脚本误触发:任何一句 `UPDATE followup_plans SET assignee_user_id=NULL`
     (数据修复 / 清理测试数据)都会凭空造出假的 release 事件。
  4. Prisma 不管触发器,得走 raw SQL 迁移,schema 里看不见、易随漂移丢失。
  弱点(已认):靠自觉。缓解 = 写入收口到 recordPlanEvent() 单一入口 + 只接受事务客户端
  (状态变更与账本同生共死)+ 事件类型走枚举编译期拦拼写错误。

### 扩展性
  新增 PlanEventType + PLAN_EVENT_META(@pac/types,照 EXECUTION_OUTCOME_META 的样子):
  事件类型的单一真理源,带 labelZh / group / byHuman / holdsPatient。
  新增事件 = META 加一行,不是在调用处随手写字符串。
  HUMAN_TOUCH_EVENTS 收口「算不算客服处理过」的口径(auto_release 是系统行为,不算)。

两个关键字段的存在理由:
  · heldSeconds 必须在清空 assigned_at **之前**算好(computeHeldSeconds),事后算不出来
  · feedback 立 reason 列(up/down):followup_plans.recall_feedback 是就地覆盖的,
    先 👍👎 会把前一次冲掉,准确度统计只看最终值会低估分子、也看不出"改判"

配套:assign / recycle 接口补传操作人(controller 原先根本没取 user.sub,
自认领与指派他人在数据上完全无法区分)。

## 验证
- 340 单测通过(新增 15 例:四种归属事件、幂等不重复记账、事件类型登记完整性、
  auto_release 不计入人工、computeHeldSeconds 边界、开关默认关且只认显式 'on')
- 本地端到端:认领 → 👎(带 note)→ 返池,账本 3 行齐全、held_seconds 准确;
  同期 followup_plans.recall_feedback 只剩最终值 —— 正是就地覆盖会丢的那部分

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
parent 2c4471a4
Pipeline #3435 failed in 0 seconds
-- CreateTable
CREATE TABLE "plan_event_logs" (
"id" UUID NOT NULL,
"host_id" UUID NOT NULL,
"tenant_id" TEXT NOT NULL,
"plan_id" UUID NOT NULL,
"patient_id" UUID NOT NULL,
"event" TEXT NOT NULL,
"assignee_user_id" TEXT,
"actor_user_id" TEXT,
"held_seconds" INTEGER,
"reason" TEXT,
"details" JSONB,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "plan_event_logs_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "plan_event_logs_plan_id_created_at_idx" ON "plan_event_logs"("plan_id", "created_at");
-- CreateIndex
CREATE INDEX "plan_event_logs_assignee_user_id_created_at_idx" ON "plan_event_logs"("assignee_user_id", "created_at");
-- CreateIndex
CREATE INDEX "plan_event_logs_patient_id_created_at_idx" ON "plan_event_logs"("patient_id", "created_at");
-- CreateIndex
CREATE INDEX "plan_event_logs_host_id_tenant_id_event_created_at_idx" ON "plan_event_logs"("host_id", "tenant_id", "event", "created_at");
-- AddForeignKey
ALTER TABLE "plan_event_logs" ADD CONSTRAINT "plan_event_logs_host_id_fkey" FOREIGN KEY ("host_id") REFERENCES "hosts"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
...@@ -152,6 +152,7 @@ model Host { ...@@ -152,6 +152,7 @@ model Host {
planSummaries PlanSummary[] planSummaries PlanSummary[]
planExecutions PlanExecution[] planExecutions PlanExecution[]
planGenerationLogs PlanGenerationLog[] planGenerationLogs PlanGenerationLog[]
planEventLogs PlanEventLog[]
agentInvocations AgentInvocation[] agentInvocations AgentInvocation[]
@@map("hosts") @@map("hosts")
...@@ -1298,6 +1299,82 @@ model PlanGenerationLog { ...@@ -1298,6 +1299,82 @@ model PlanGenerationLog {
@@map("plan_generation_logs") @@map("plan_generation_logs")
} }
/// PlanEventLog 召回工单**生命周期事件账本**(append-only,不更新不删除)
///
/// 为什么必须单立一张表(而不是靠 PlanGenerationLog):
/// PlanGenerationLog **每次引擎跑批一行**的管道账本 —— 它连 plan_id 都没有,
/// 只记"引擎跑了没有、产出几个"(生产 750 万行 / 24 万患者, 80 万行/)
/// 它回答不了"这个 plan 经历了什么"
/// `followup_plans` 只存**当前**状态:归属、snooze、反馈都是就地覆盖,返池时
/// assignee_user_id / assigned_at 直接置 null —— 痕迹消失。2026-07 生产实测两天内
/// 自动回收 32 ,这些"接手了但没继续"的样本(判断召回池准不准最有价值的那批)
/// 全部无法追溯。
///
/// 收录边界(**故意划死,防止变成垃圾桶**):
/// :人工动作,以及会被**就地覆盖、事后无法还原**的状态变更
/// 不收:引擎驱动的重算 / supersede —— 那是 80 万行/天量级,跟人工动作( 30~100
/// /) 4 个数量级,混进来会把人工动作彻底淹没,查询也会被拖垮。
/// 引擎侧已有 PlanGenerationLog(跑批账本)+ followup_plans 的版本流(version /
/// superseded_at 天然保留历史),不需要在这里重复。
/// 不收:前端行为埋点(页面浏览 / 点击)—— 噪声大、可刷、且属于产品分析那一摊。
/// 本表只记**服务端状态机确凿发生的事**, SyncLog / PersonaRecomputeLog 同性质。
///
/// 支撑的统计口径:
/// · 客服处理过哪些患者 = 该客服有 claim 记录的 patient(**不受后续返池影响**)
/// · 接手后放弃 = claim 之后跟着 release/auto_release 且中间无 execution
/// · 认领时长分布 = heldSeconds(判断超时阈值定得合不合理的依据)
/// · 门诊经理分配上线后:event='claim'(自认领) 'assign'(被指派)天然分开,
/// 不必事后从 assignee 反推 —— 这是 followup_plans 那两列永远做不到的
model PlanEventLog {
id String @id @default(uuid()) @db.Uuid
hostId String @map("host_id") @db.Uuid
tenantId String @map("tenant_id")
planId String @map("plan_id") @db.Uuid
/// 冗余 patientId:统计几乎都按患者聚合(plan supersede id,患者不变)
patientId String @map("patient_id") @db.Uuid
/// 事件类型(应用层 zod enum 校验)。新增事件在此登记,不要塞进 details:
/// ── 归属 ──
/// claim 自认领(actor == assignee)
/// assign 指派他人(actor != assignee)—— 门诊经理分配上线后启用
/// release 主动返池(leader 回收 / 客服自助交还)
/// auto_release 超时自动回收(RecycleSchedulerService;actor null)
/// ── 反馈 ──
/// feedback 召回反馈 👍/👎(followup_plans.recall_feedback 是就地覆盖的,
/// 改判会丢历史;这里留全量,准确度统计才有分子)
event String
/// 归属类事件:变更后的归属人;release / auto_release null(回到无人认领)
assigneeUserId String? @map("assignee_user_id")
/// 执行本次操作的人;auto_release 是系统行为 null
actorUserId String? @map("actor_user_id")
/// 归属持续秒数( release / auto_release 有值 = 释放时刻 - assignedAt)
/// 必须在清空 assigned_at **之前**算好存下 —— 事后算不出来,这是本列存在的全部理由。
heldSeconds Int? @map("held_seconds")
/// 简短原因(auto_release 'timeout';feedback 'up' / 'down')
reason String?
/// 事件专属细节(不立柱的部分, feedback 的文字说明)
/// ⚠️ 只放"查询不按它过滤"的内容;要按它筛就该立柱。
details Json?
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
host Host @relation(fields: [hostId], references: [id])
/// plan 拉事件时间线
@@index([planId, createdAt])
/// "这个客服处理过哪些患者" 主查询路径
@@index([assigneeUserId, createdAt])
/// 按患者聚合( plan 版本)
@@index([patientId, createdAt])
/// 按租户 + 事件类型出报表
@@index([hostId, tenantId, event, createdAt])
@@map("plan_event_logs")
}
// ============================================================= // =============================================================
// 横切支撑 Agent Pool / 同步账本 // 横切支撑 Agent Pool / 同步账本
// ============================================================= // =============================================================
......
...@@ -9,11 +9,12 @@ import { ...@@ -9,11 +9,12 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import type { Response } from 'express'; import type { Response } from 'express';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { ApiCode, Permission, SubmitRecallFeedbackRequestSchema } from '@pac/types'; import { ApiCode, Permission, PlanEventType, SubmitRecallFeedbackRequestSchema } from '@pac/types';
import { z } from 'zod'; import { z } from 'zod';
import { RequirePermission } from '../../common/decorators/permissions.decorator'; import { RequirePermission } from '../../common/decorators/permissions.decorator';
import { BizError } from '../../common/errors/biz-error'; import { BizError } from '../../common/errors/biz-error';
import { assertPlanClaimedBy } from '../plan/claim-guard'; import { assertPlanClaimedBy } from '../plan/claim-guard';
import { recordPlanEvent } from '../plan/plan-event.recorder';
import { import {
TenantScope, TenantScope,
TenantScopeContext, TenantScopeContext,
...@@ -263,15 +264,34 @@ export class PlansAggregateController { ...@@ -263,15 +264,34 @@ export class PlansAggregateController {
) { ) {
await this.assertClaimed(scope, planId, user.sub); // 认领闸 await this.assertClaimed(scope, planId, user.sub); // 认领闸
const { feedback, note } = SubmitRecallFeedbackRequestSchema.parse(body); const { feedback, note } = SubmitRecallFeedbackRequestSchema.parse(body);
// 反馈是对 plan 本身的判断 → 直接写 followup_plans(正交于执行,不进事实层) const plan = await this.prisma.followupPlan.findFirst({
const res = await this.prisma.followupPlan.updateMany({
where: { id: planId, hostId: scope.hostId, tenantId: scope.tenantId }, where: { id: planId, hostId: scope.hostId, tenantId: scope.tenantId },
data: { select: { id: true, hostId: true, tenantId: true, patientId: true },
recallFeedback: feedback, });
recallFeedbackNote: note?.trim() ? note.trim() : null, if (!plan) return { ok: false as const, reason: 'not_found' };
}, // 反馈是对 plan 本身的判断 → 直接写 followup_plans(正交于执行,不进事实层)
await this.prisma.$transaction(async (tx) => {
await tx.followupPlan.update({
where: { id: plan.id },
data: {
recallFeedback: feedback,
recallFeedbackNote: note?.trim() ? note.trim() : null,
},
});
// ⭐ 同时落事件账本:followup_plans.recall_feedback 是**就地覆盖**的,
// 客服先点 👍 后改 👎(或反复评)会把前一次冲掉。准确度统计要的是**全部判定**,
// 只看最终值会低估分子,也看不出"改判"这种强信号。
await recordPlanEvent(tx, {
hostId: plan.hostId,
tenantId: plan.tenantId,
planId: plan.id,
patientId: plan.patientId,
event: PlanEventType.FEEDBACK,
actorUserId: user.sub,
reason: feedback, // 'up' | 'down' —— 立柱,统计直接按它过滤
details: note?.trim() ? { note: note.trim() } : null,
});
}); });
if (res.count === 0) return { ok: false as const, reason: 'not_found' };
return { ok: true as const, feedback }; return { ok: true as const, feedback };
} }
......
import type { Prisma } from '@prisma/client';
import { PlanEventType } from '@pac/types';
/**
* PlanEventLog 的**唯一写入口**。
*
* ── 为什么是应用层写,而不是数据库触发器 ──
* 1. **触发器拿不到操作人**。DB 只看得见"某行变了",而 actor_user_id 在请求上下文里
* (JWT sub)。触发器要拿只能靠 session 变量(SET LOCAL app.user_id),那还是得应用
* 配合,只是把依赖变成隐式的、更容易漏。本项目的 AsyncLocalStorage(tenant-context.ts)
* 目前也只装 hostId/tenantId/sourceUnits,**不含 user**。
* 2. 拿不到 actor 就**分不出 claim / assign** —— 而这正是这张表最核心的价值。
* 3. **触发器会被运维脚本误触发**。任何一句
* `UPDATE followup_plans SET assignee_user_id=NULL ...`(数据修复、回滚、清理测试数据)
* 都会凭空造出一条假的 release 事件,污染统计。应用层写则只记真实业务动作。
* 4. Prisma 不管触发器 —— 得走 raw SQL 迁移,schema 里看不见,漂移/reset 时容易丢。
*
* ── 应用层写的弱点(得认)──
* 靠自觉:将来若有人新加一条直接改 followup_plans 归属的代码路径,不会自动落账。
* 缓解:① 写入点收口到本函数(找调用方一眼看全)
* ② 必须在**同一个事务**里调 —— 状态变更与账本同生共死,不会只成一半
* ③ 事件类型走 PlanEventType 枚举,编译期拦拼写错误
*
* @param tx **事务客户端**(prisma.$transaction 的回调参数)。刻意不接受裸 PrismaService:
* 账本与状态变更必须原子,分开写就可能只落一半。
*/
/**
* 只取事务客户端里 planEventLog 这一块 —— 调用方传 $transaction 的 tx 即可;
* 单测传 mock 时 `as unknown as PlanEventLogWriter` 断言。
*/
export type PlanEventLogWriter = Pick<Prisma.TransactionClient, 'planEventLog'>;
export interface PlanEventInput {
hostId: string;
tenantId: string;
planId: string;
patientId: string;
event: PlanEventType;
/** 变更后的归属人;释放类事件传 null */
assigneeUserId?: string | null;
/** 操作人;系统行为(auto_release)传 null */
actorUserId?: string | null;
/** 归属持续秒数 —— 见 computeHeldSeconds 的注释 */
heldSeconds?: number | null;
/** 简短原因(立柱,可直接过滤):auto_release='timeout';feedback='up'|'down' */
reason?: string | null;
/** 事件专属细节(不按它查询的内容,如反馈文字) */
details?: Prisma.InputJsonObject | null;
}
export function recordPlanEvent(tx: PlanEventLogWriter, input: PlanEventInput): Promise<unknown> {
return tx.planEventLog.create({
data: {
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,
// undefined 才让 Prisma 落 NULL;传 null 会被当成 JSON null 值
details: input.details ?? undefined,
},
});
}
/**
* 算归属持续秒数。
* ⭐ 必须在把 assigned_at 清空**之前**调 —— 释放后该列即为 null,事后再也算不出来。
* 这是 heldSeconds 需要落库(而非查询时现算)的全部理由。
*/
export function computeHeldSeconds(assignedAt: Date | null | undefined, now: Date): number | null {
if (assignedAt == null) return null;
return Math.max(0, Math.round((now.getTime() - assignedAt.getTime()) / 1000));
}
...@@ -91,10 +91,12 @@ export class PlanController { ...@@ -91,10 +91,12 @@ export class PlanController {
@RequirePermission(Permission.PLAN_ASSIGN) @RequirePermission(Permission.PLAN_ASSIGN)
async assign( async assign(
@TenantScope() scope: TenantScopeContext, @TenantScope() scope: TenantScopeContext,
@CurrentUser() user: AuthenticatedUser,
@Param('id') id: string, @Param('id') id: string,
@Body() dto: AssignPlanRequestDto, @Body() dto: AssignPlanRequestDto,
) { ) {
await this.plans.assign(scope, id, dto.assigneeUserId); // 传操作人 —— 账本据此区分自认领(actor==assignee)与指派他人
await this.plans.assign(scope, id, dto.assigneeUserId, user.sub);
return { ok: true as const }; return { ok: true as const };
} }
...@@ -103,10 +105,11 @@ export class PlanController { ...@@ -103,10 +105,11 @@ export class PlanController {
@RequirePermission(Permission.PLAN_RECYCLE) @RequirePermission(Permission.PLAN_RECYCLE)
async recycle( async recycle(
@TenantScope() scope: TenantScopeContext, @TenantScope() scope: TenantScopeContext,
@CurrentUser() user: AuthenticatedUser,
@Param('id') id: string, @Param('id') id: string,
@Body() _dto: RecyclePlanRequestDto, @Body() _dto: RecyclePlanRequestDto,
) { ) {
await this.plans.recycle(scope, id); await this.plans.recycle(scope, id, user.sub);
return { ok: true as const }; return { ok: true as const };
} }
......
...@@ -8,6 +8,7 @@ import { ...@@ -8,6 +8,7 @@ import {
import type { Prisma } from '@prisma/client'; import type { Prisma } from '@prisma/client';
import { import {
Permission, Permission,
PlanEventType,
planScenarioLabel, planScenarioLabel,
subLabelZh, subLabelZh,
applyLiveDays, applyLiveDays,
...@@ -18,6 +19,7 @@ import { ...@@ -18,6 +19,7 @@ import {
} from '@pac/types'; } from '@pac/types';
import { calcAge, maskName, maskPhone } from '@pac/utils'; import { calcAge, maskName, maskPhone } from '@pac/utils';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import { recordPlanEvent, computeHeldSeconds } from './plan-event.recorder';
import { PERSONA_TAG_FILTER_DIMS, parsePersonaTags } from '@pac/types'; import { PERSONA_TAG_FILTER_DIMS, parsePersonaTags } from '@pac/types';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator'; import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';
import type { PlanEngineService } from './engine/plan-engine.service'; import type { PlanEngineService } from './engine/plan-engine.service';
...@@ -435,18 +437,26 @@ export class PlanService { ...@@ -435,18 +437,26 @@ export class PlanService {
// assign // assign
// ───────────────────────────────────────────── // ─────────────────────────────────────────────
/**
* @param actorUserId 执行本次分配的人(从 token 取)。自认领时 = assigneeUserId;
* 门诊经理指派他人时二者不同 —— 账本据此区分 claim / assign,
* 这是 followup_plans 那两列(只存归属结果)永远表达不了的。
*/
async assign( async assign(
scope: TenantScopeContext, scope: TenantScopeContext,
planId: string, planId: string,
assigneeUserId: string, assigneeUserId: string,
_assigneeRole?: string, actorUserId?: 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 } } } : {}),
}, },
select: { id: true, hostId: true, tenantId: true, status: true, assigneeUserId: true }, select: {
id: true, hostId: true, tenantId: true, status: true,
assigneeUserId: true, patientId: true,
},
}); });
if (!plan || plan.hostId !== scope.hostId || plan.tenantId !== scope.tenantId) { if (!plan || plan.hostId !== scope.hostId || plan.tenantId !== scope.tenantId) {
throw new NotFoundException(`Plan ${planId} not found`); throw new NotFoundException(`Plan ${planId} not found`);
...@@ -459,14 +469,34 @@ export class PlanService { ...@@ -459,14 +469,34 @@ export class PlanService {
} }
// 幂等:重复分配给同一人 → 只刷新 recycleAt // 幂等:重复分配给同一人 → 只刷新 recycleAt
const now = new Date(); const now = new Date();
await this.prisma.followupPlan.update({ const isRepeat = plan.assigneeUserId === assigneeUserId;
where: { id: planId }, await this.prisma.$transaction(async (tx) => {
data: { await tx.followupPlan.update({
status: 'assigned', where: { id: planId },
assigneeUserId, data: {
assignedAt: now, status: 'assigned',
recycleAt: new Date(now.getTime() + RECYCLE_TIMEOUT_HOURS * 3600 * 1000), assigneeUserId,
}, assignedAt: now,
recycleAt: new Date(now.getTime() + RECYCLE_TIMEOUT_HOURS * 3600 * 1000),
},
});
// 生命周期事件账本(append-only,收录边界见 PlanEventLog 注释)。
// 幂等重复分配不记账 —— 那是同一次归属的续期,不是新的归属变更。
if (!isRepeat) {
await recordPlanEvent(tx, {
hostId: plan.hostId,
tenantId: plan.tenantId,
planId: plan.id,
patientId: plan.patientId,
// actor 未知(老调用方没传)时按自认领记 —— 当前前端只有认领一条路径
event:
!actorUserId || actorUserId === assigneeUserId
? PlanEventType.CLAIM
: PlanEventType.ASSIGN,
assigneeUserId,
actorUserId: actorUserId ?? assigneeUserId,
});
}
}); });
} }
...@@ -474,13 +504,16 @@ export class PlanService { ...@@ -474,13 +504,16 @@ export class PlanService {
// recycle // recycle
// ───────────────────────────────────────────── // ─────────────────────────────────────────────
async recycle(scope: TenantScopeContext, planId: string): Promise<void> { async recycle(scope: TenantScopeContext, planId: string, actorUserId?: string): 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 } } } : {}),
}, },
select: { id: true, hostId: true, tenantId: true, status: true }, select: {
id: true, hostId: true, tenantId: true, status: true,
assigneeUserId: true, assignedAt: true, patientId: true,
},
}); });
if (!plan || plan.hostId !== scope.hostId || plan.tenantId !== scope.tenantId) { if (!plan || plan.hostId !== scope.hostId || plan.tenantId !== scope.tenantId) {
throw new NotFoundException(`Plan ${planId} not found`); throw new NotFoundException(`Plan ${planId} not found`);
...@@ -490,14 +523,32 @@ export class PlanService { ...@@ -490,14 +523,32 @@ export class PlanService {
if (plan.status === 'completed' || plan.status === 'abandoned' || plan.status === 'superseded') { if (plan.status === 'completed' || plan.status === 'abandoned' || plan.status === 'superseded') {
throw new BadRequestException(`Plan 已终态(${plan.status}),不能返池`); throw new BadRequestException(`Plan 已终态(${plan.status}),不能返池`);
} }
await this.prisma.followupPlan.update({ // 只在**确实挂着人**时记账:active 且无 assignee 的"返池"是空操作,记了是噪声
where: { id: planId }, const hadAssignee = plan.assigneeUserId != null;
data: { // ⭐ 在清空 assignedAt 之前算(见 computeHeldSeconds 注释)
status: 'active', const heldSeconds = computeHeldSeconds(plan.assignedAt, new Date());
assigneeUserId: null, await this.prisma.$transaction(async (tx) => {
assignedAt: null, await tx.followupPlan.update({
recycleAt: null, where: { id: planId },
}, data: {
status: 'active',
assigneeUserId: null,
assignedAt: null,
recycleAt: null,
},
});
if (hadAssignee) {
await recordPlanEvent(tx, {
hostId: plan.hostId,
tenantId: plan.tenantId,
planId: plan.id,
patientId: plan.patientId,
event: PlanEventType.RELEASE,
assigneeUserId: null, // 释放后无人归属
actorUserId: actorUserId ?? null,
heldSeconds,
});
}
}); });
} }
......
import { Injectable, Logger } 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 { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import { recordPlanEvent, computeHeldSeconds } from './plan-event.recorder';
/** /**
* RecycleSchedulerService — 召回工单超时自动回收(GAP3 闭环) * RecycleSchedulerService — 召回工单超时自动回收
* *
* 背景:assign 时锁定 recycleAt = assignedAt + RECYCLE_TIMEOUT_HOURS(默认 24h)。 * 背景:assign 时锁定 recycleAt = assignedAt + RECYCLE_TIMEOUT_HOURS(默认 24h)。
* 旧实现只写了 deadline 却**没有任何 cron 执行回收** —— 客服领了工单去休假 / 漏跟, * 最初实现只写 deadline 没有执行者 —— 客服领了工单去休假 / 漏跟,plan 永远停在
* plan 永远停在 assigned,plan 引擎 upsert 时 `skipped_assigned` 永久跳过, * assigned,引擎 upsert 时 `skipped_assigned` 永久跳过,患者既不会被别人捡走也不更新。
* 这个患者既不会被别人捡走、也不会更新。本服务补上自动回收 * 本服务是那个兜底执行者
* *
* 行为:每 10 分钟扫一遍,把"已分配 + 已过 recycleAt"的 plan 退回召回池: * ⭐ 2026-07:**默认关闭**(PAC_PLAN_AUTO_RECYCLE 未设或非 'on' 即不跑)。
* status: assigned → active;清空 assignee / assignedAt / recycleAt。 * 关闭理由(业务决定):
* 用 updateMany 一条 SQL 批量处理(无需逐行),幂等、并发安全。 * 1. 「认领」现在要当作「该患者已被客服处理」的口径用于统计。自动回收会把
* assignee_user_id / assigned_at **就地清空**,认领痕迹消失 ——
* 生产两天内回收 32 单,这些"接手了但没继续"的样本全部无法追溯。
* 2. staff 角色**没有 PLAN_RECYCLE 权限**(leader 独占),所以自动回收曾是
* staff 认领后唯一的释放路径。关闭后需注意工单沉淀,见下方「关闭后的代价」。
* *
* 注:手动回收(POST /plans/{id}/recycle)仍保留,本 cron 只兜底超时未结案的。 * 代码保留而非删除:超时兜底的原始问题(领了不做 → 患者被锁死)依然成立,
* 后续若改成更长超时(如 7d)或加"仅提醒不回收",打开开关即可。
*
* ⚠️ 关闭后的代价(运维需知):
* staff 认领后若不处理,plan 停在 assigned、离开召回池,**staff 自己无法返池**,
* 只有 leader 能手动回收。按当前速率约每天沉淀 30 单。
* 缓解选项(未做,需业务定):给 staff 自助返池权限(只能返自己的),或拉长超时后重开。
*
* 行为(开启时):每 10 分钟扫一遍,把"已分配 + 已过 recycleAt"的 plan 退回召回池,
* 并为每一条写 PlanEventLog(event='auto_release'),使归属历史可追溯。
*/ */
@Injectable() @Injectable()
export class RecycleSchedulerService { export class RecycleSchedulerService implements OnModuleInit {
private readonly logger = new Logger(RecycleSchedulerService.name); private readonly logger = new Logger(RecycleSchedulerService.name);
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
/** 显式开启才跑 —— 默认关闭,避免"部署上去才发现认领记录又被抹了" */
private get enabled(): boolean {
return (process.env.PAC_PLAN_AUTO_RECYCLE ?? '').trim().toLowerCase() === 'on';
}
onModuleInit(): void {
// 启动时把状态打出来 —— 这个开关会静默改变数据,不能靠猜
this.logger.log(
this.enabled
? '超时自动回收:已开启(PAC_PLAN_AUTO_RECYCLE=on),每 10 分钟扫一次'
: '超时自动回收:已关闭(默认)。认领后仅能由 leader 手动返池;开启设 PAC_PLAN_AUTO_RECYCLE=on',
);
}
@Cron(CronExpression.EVERY_10_MINUTES, { name: 'plan-recycle-stale' }) @Cron(CronExpression.EVERY_10_MINUTES, { name: 'plan-recycle-stale' })
async runRecycle(): Promise<void> { async runRecycle(): Promise<void> {
if (!this.enabled) return;
const now = new Date(); const now = new Date();
const r = await this.prisma.followupPlan.updateMany({ const due = await this.prisma.followupPlan.findMany({
where: { where: {
status: 'assigned', status: 'assigned',
recycleAt: { not: null, lt: now }, recycleAt: { not: null, lt: now },
...@@ -35,15 +66,46 @@ export class RecycleSchedulerService { ...@@ -35,15 +66,46 @@ export class RecycleSchedulerService {
// 回访日过后(snoozedUntil<=now)若仍未处理,才允许回收。 // 回访日过后(snoozedUntil<=now)若仍未处理,才允许回收。
OR: [{ snoozedUntil: null }, { snoozedUntil: { lte: now } }], OR: [{ snoozedUntil: null }, { snoozedUntil: { lte: now } }],
}, },
data: { select: {
status: 'active', id: true, hostId: true, tenantId: true, patientId: true,
assigneeUserId: null, assigneeUserId: true, assignedAt: true,
assignedAt: null,
recycleAt: null,
}, },
// 单轮上限:防积压时一次性打爆事务;剩下的下一轮继续
take: 500,
}); });
if (r.count > 0) { if (due.length === 0) return;
this.logger.log(`auto-recycle: ${r.count} 个超时未结案工单退回召回池`);
// 逐条:要为每条留账本(持有时长只能在清空 assignedAt **之前**算出来)
let recycled = 0;
for (const p of due) {
try {
await this.prisma.$transaction(async (tx) => {
await tx.followupPlan.updateMany({
// 带 status 条件 → 并发下若已被人工返池/结案则本次不生效(幂等)
where: { id: p.id, status: 'assigned' },
data: { status: 'active', assigneeUserId: null, assignedAt: null, recycleAt: null },
});
await recordPlanEvent(tx, {
hostId: p.hostId,
tenantId: p.tenantId,
planId: p.id,
patientId: p.patientId,
event: PlanEventType.AUTO_RELEASE,
assigneeUserId: null,
actorUserId: null, // 系统行为,无操作人
heldSeconds: computeHeldSeconds(p.assignedAt, now),
reason: 'timeout',
});
});
recycled++;
} catch (err) {
this.logger.error(
`auto-recycle 单条失败 plan=${p.id}: ${err instanceof Error ? err.message : err}`,
);
}
}
if (recycled > 0) {
this.logger.log(`auto-recycle: ${recycled} 个超时未结案工单退回召回池(已记账本)`);
} }
} }
} }
import { Test } from '@nestjs/testing';
import { PlanEventType, PLAN_EVENT_META, HUMAN_TOUCH_EVENTS } from '@pac/types';
import { computeHeldSeconds } from '../src/modules/plan/plan-event.recorder';
import { PlanService } from '../src/modules/plan/plan.service';
import { PrismaService } from '../src/prisma/prisma.service';
import { RecycleSchedulerService } from '../src/modules/plan/recycle-scheduler.service';
/**
* 归属账本(PlanEventLog)回归。
*
* 由来:「认领」要当作「该患者已被客服处理」用于统计,但 followup_plans 只存**当前**归属
* (assignee_user_id / assigned_at),返池时就地置 null —— 痕迹消失。生产实测两天自动回收
* 32 单,这些"接手了但没继续"的样本(恰恰是判断召回池准不准最有价值的)全部无法追溯。
*
* 本文件锁四条:
* 1. 认领必须落账,且 actor==assignee 记 claim、actor!=assignee 记 assign(为门诊经理分配预留)
* 2. 返池必须落账,且 heldSeconds 要在清空 assignedAt **之前**算出来(事后算不出来)
* 3. 幂等重复认领不重复记账(那是续期,不是新的归属变更)
* 4. 自动回收默认关闭 —— 开关没开就不能动数据
*/
const SCOPE = { hostId: 'h1', tenantId: 't1', sourceUnits: [] as string[], clinicIds: [] as string[] } as never;
const PLAN = {
id: 'p1',
hostId: 'h1',
tenantId: 't1',
patientId: 'pat1',
status: 'active',
assigneeUserId: null as string | null,
assignedAt: null as Date | null,
};
function makePrisma(planOverride: Partial<typeof PLAN> = {}) {
const created: Record<string, unknown>[] = [];
const tx = {
followupPlan: { update: jest.fn().mockResolvedValue({}), updateMany: jest.fn().mockResolvedValue({ count: 1 }) },
planEventLog: {
create: jest.fn().mockImplementation(({ data }: { data: Record<string, unknown> }) => {
created.push(data);
return Promise.resolve(data);
}),
},
};
const prisma = {
followupPlan: {
findFirst: jest.fn().mockResolvedValue({ ...PLAN, ...planOverride }),
findMany: jest.fn().mockResolvedValue([]),
},
$transaction: jest.fn().mockImplementation(async (fn: (t: typeof tx) => Promise<unknown>) => fn(tx)),
};
return { prisma: prisma as unknown as PrismaService, created, tx };
}
async function buildService(prisma: PrismaService) {
const mod = await Test.createTestingModule({
providers: [PlanService, { provide: PrismaService, useValue: prisma }],
})
.useMocker(() => ({}))
.compile();
return mod.get(PlanService);
}
describe('PlanEventLog — 认领 / 返池落账', () => {
test('⭐ 自认领(actor == assignee)→ action=claim', async () => {
const { prisma, created } = makePrisma();
const svc = await buildService(prisma);
await svc.assign(SCOPE, 'p1', 'u-me', 'u-me');
expect(created).toHaveLength(1);
expect(created[0]).toMatchObject({
event: 'claim',
assigneeUserId: 'u-me',
actorUserId: 'u-me',
planId: 'p1',
patientId: 'pat1',
});
});
test('⭐ 指派他人(actor != assignee)→ action=assign(门诊经理分配上线即可用)', async () => {
const { prisma, created } = makePrisma();
const svc = await buildService(prisma);
await svc.assign(SCOPE, 'p1', 'u-staff', 'u-leader');
expect(created[0]).toMatchObject({
event: 'assign',
assigneeUserId: 'u-staff',
actorUserId: 'u-leader',
});
});
test('调用方没传 actor(老路径)→ 按自认领记,不产生错误的 assign', async () => {
const { prisma, created } = makePrisma();
const svc = await buildService(prisma);
await svc.assign(SCOPE, 'p1', 'u-me');
expect(created[0]).toMatchObject({ event: 'claim', actorUserId: 'u-me' });
});
test('⭐ 幂等:重复认领给同一人 → 只续期,不重复记账', async () => {
const { prisma, created } = makePrisma({ status: 'assigned', assigneeUserId: 'u-me' });
const svc = await buildService(prisma);
await svc.assign(SCOPE, 'p1', 'u-me', 'u-me');
expect(created).toHaveLength(0);
});
test('⭐ 返池落账,且 heldSeconds 按 assignedAt 算出(清空前)', async () => {
const assignedAt = new Date(Date.now() - 3600_000); // 1 小时前
const { prisma, created } = makePrisma({ status: 'assigned', assigneeUserId: 'u-me', assignedAt });
const svc = await buildService(prisma);
await svc.recycle(SCOPE, 'p1', 'u-leader');
expect(created).toHaveLength(1);
expect(created[0]).toMatchObject({
event: 'release',
assigneeUserId: null, // 释放后无人归属
actorUserId: 'u-leader',
});
// 允许几秒抖动
const held = created[0]!.heldSeconds as number;
expect(held).toBeGreaterThanOrEqual(3595);
expect(held).toBeLessThanOrEqual(3605);
});
test('本来就没人认领的"返池" → 空操作,不记噪声', async () => {
const { prisma, created } = makePrisma({ status: 'active', assigneeUserId: null });
const svc = await buildService(prisma);
await svc.recycle(SCOPE, 'p1', 'u-leader');
expect(created).toHaveLength(0);
});
});
describe('PlanEventType / PLAN_EVENT_META — 扩展性约束', () => {
test('⭐ 每个事件类型都在 META 里登记(新增事件忘了登记就失败)', () => {
for (const t of Object.values(PlanEventType)) {
expect(PLAN_EVENT_META[t]).toBeDefined();
expect(PLAN_EVENT_META[t].labelZh.length).toBeGreaterThan(0);
}
// 反向:META 里不能有 enum 之外的孤儿键
expect(Object.keys(PLAN_EVENT_META).sort()).toEqual([...Object.values(PlanEventType)].sort());
});
test('⭐ auto_release 不算人工 —— 统计「客服处理过」不能把系统回收算进去', () => {
expect(PLAN_EVENT_META.auto_release.byHuman).toBe(false);
expect(HUMAN_TOUCH_EVENTS).not.toContain(PlanEventType.AUTO_RELEASE);
expect(HUMAN_TOUCH_EVENTS).toEqual(
expect.arrayContaining([PlanEventType.CLAIM, PlanEventType.ASSIGN, PlanEventType.FEEDBACK]),
);
});
test('归属类事件的 holdsPatient 口径正确(算归属区间用)', () => {
expect(PLAN_EVENT_META.claim.holdsPatient).toBe(true);
expect(PLAN_EVENT_META.assign.holdsPatient).toBe(true);
expect(PLAN_EVENT_META.release.holdsPatient).toBe(false);
expect(PLAN_EVENT_META.auto_release.holdsPatient).toBe(false);
});
});
describe('computeHeldSeconds — 必须在清空 assignedAt 前算', () => {
test('无 assignedAt → null(不是 0,0 会被误读成"秒放")', () => {
expect(computeHeldSeconds(null, new Date())).toBeNull();
expect(computeHeldSeconds(undefined, new Date())).toBeNull();
});
test('正常时长按秒取整', () => {
const now = new Date('2026-07-24T12:00:00Z');
expect(computeHeldSeconds(new Date('2026-07-24T11:00:00Z'), now)).toBe(3600);
});
test('时钟回拨等异常 → 夹到 0,不出负数', () => {
const now = new Date('2026-07-24T12:00:00Z');
expect(computeHeldSeconds(new Date('2026-07-24T13:00:00Z'), now)).toBe(0);
});
});
describe('RecycleSchedulerService — 自动回收默认关闭', () => {
const OLD = process.env.PAC_PLAN_AUTO_RECYCLE;
afterEach(() => {
if (OLD === undefined) delete process.env.PAC_PLAN_AUTO_RECYCLE;
else process.env.PAC_PLAN_AUTO_RECYCLE = OLD;
});
test('⭐ 未设开关 → 一行数据都不碰(连查询都不发)', async () => {
delete process.env.PAC_PLAN_AUTO_RECYCLE;
const { prisma } = makePrisma();
await new RecycleSchedulerService(prisma).runRecycle();
expect((prisma as unknown as { followupPlan: { findMany: jest.Mock } }).followupPlan.findMany)
.not.toHaveBeenCalled();
});
test('开关为其他值(true/1/yes)也不算开 —— 只认显式 on,避免误开', async () => {
const { prisma } = makePrisma();
for (const v of ['true', '1', 'yes', 'ON ']) {
process.env.PAC_PLAN_AUTO_RECYCLE = v;
await new RecycleSchedulerService(prisma).runRecycle();
}
const findMany = (prisma as unknown as { followupPlan: { findMany: jest.Mock } }).followupPlan.findMany;
// 'ON ' trim+lower 后 = 'on' → 应被认为开启,其余三个不开
expect(findMany).toHaveBeenCalledTimes(1);
});
test('显式 on → 才会扫描', async () => {
process.env.PAC_PLAN_AUTO_RECYCLE = 'on';
const { prisma } = makePrisma();
await new RecycleSchedulerService(prisma).runRecycle();
expect((prisma as unknown as { followupPlan: { findMany: jest.Mock } }).followupPlan.findMany)
.toHaveBeenCalled();
});
});
...@@ -739,6 +739,50 @@ export function abandonReasonsFor(panel: 'form' | 'close'): AbandonReason[] { ...@@ -739,6 +739,50 @@ export function abandonReasonsFor(panel: 'form' | 'close'): AbandonReason[] {
} }
// ============================================================= // =============================================================
// Plan 生命周期事件(plan_event_logs.event)
// =============================================================
/// plan_event_logs.event 的单一真理源。**新增事件必须在此登记**,不要在调用处随手写字符串
/// —— 这是这张表能长期扩展而不腐化的前提(否则半年后没人说得清有哪些取值)。
export const PlanEventType = {
/// 归属:自认领(actor == assignee)
CLAIM: 'claim',
/// 归属:指派他人(actor != assignee)—— 门诊经理分配上线后启用
ASSIGN: 'assign',
/// 归属:主动返池
RELEASE: 'release',
/// 归属:超时自动回收(系统行为,无 actor)
AUTO_RELEASE: 'auto_release',
/// 反馈:召回准不准 👍/👎(reason 列存 up/down)
FEEDBACK: 'feedback',
} as const;
export type PlanEventType = (typeof PlanEventType)[keyof typeof PlanEventType];
/**
* 事件元数据 —— 报表分组 + "算不算人工处理"的判定源。
*
* group ownership 归属变更 / feedback 质量反馈(后续可加 lifecycle 等)
* byHuman 是否人工动作。⭐ 统计「客服处理过哪些患者」只算 byHuman=true 的,
* auto_release 是系统行为,不能算成"客服处理过"。
* holdsPatient 该事件之后 plan 是否仍挂在人名下(用于算归属区间)
*/
export const PLAN_EVENT_META: Record<
PlanEventType,
{ labelZh: string; group: 'ownership' | 'feedback'; byHuman: boolean; holdsPatient: boolean }
> = {
claim: { labelZh: '认领', group: 'ownership', byHuman: true, holdsPatient: true },
assign: { labelZh: '指派', group: 'ownership', byHuman: true, holdsPatient: true },
release: { labelZh: '返池', group: 'ownership', byHuman: true, holdsPatient: false },
auto_release: { labelZh: '超时自动回收', group: 'ownership', byHuman: false, holdsPatient: false },
feedback: { labelZh: '召回反馈', group: 'feedback', byHuman: true, holdsPatient: true },
};
/// 「该患者被客服处理过」的事件集合 —— 统计口径收口在这里,避免各处各写一套。
export const HUMAN_TOUCH_EVENTS: PlanEventType[] = (
Object.keys(PLAN_EVENT_META) as PlanEventType[]
).filter((k) => PLAN_EVENT_META[k].byHuman);
// =============================================================
// Plan Scripts / Summaries(异步生成,状态机) // Plan Scripts / Summaries(异步生成,状态机)
// ============================================================= // =============================================================
......
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