Commit d4f3c58f by luoqi

merge: fix/age-aware-treatment-goal → test

召回侧一组改动:
- 缺牙建议治疗按年龄排序(>70 活动义齿在前、种植并行保留)
- 「已完成治疗」「机会识别不准确」抑制期改永久(原 14d 押注排除闸接手,生产证伪)
- 认领闸:未认领只能看不能操作(服务端硬拦 20504/20505),认领入口收口到列表页
- 历史联系摘要不再把未来排程当漏做(程序给今日锚点 + isFuture 标记 + taskStatus)
- 关闭超时自动回收(默认关,开关 PAC_PLAN_AUTO_RECYCLE=on)
- 新增 plan_event_logs 生命周期事件账本(认领/指派/返池/自动回收/召回反馈)

含 DB 迁移:20260724091507_add_plan_event_log
parents 144aebad 3fd9af36
Pipeline #3436 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 / 同步账本
// ============================================================= // =============================================================
......
...@@ -6,11 +6,27 @@ ...@@ -6,11 +6,27 @@
*/ */
export interface DraftRecallSummaryInput { export interface DraftRecallSummaryInput {
patientNameMasked: string; patientNameMasked: string;
/**
* 生成时刻的"今天"(YYYY-MM-DD)。
* ⭐ 必填 —— 回访记录里**含未来排程**(patient_return_visits.task_date 注释写明),
* 不给锚点模型无从判断某条是"还没到期"还是"漏做了"。
*/
today: string;
/** 历史联系(诊所回访)记录,时间倒序 */ /** 历史联系(诊所回访)记录,时间倒序 */
items: Array<{ items: Array<{
taskDate: string | null; // 回访任务日期 YYYY-MM-DD taskDate: string | null; // 回访任务日期 YYYY-MM-DD
type: string | null; // 常规回访 / 术后回访 / 咨询回访 type: string | null; // 常规回访 / 术后回访 / 咨询回访
status: string | null; // 已回访 / 未回访 status: string | null; // 已回访 / 未回访
/** 任务状态:已完成 / 未完成 / 已预约 / 创建新回访 —— 「已预约」≠「没做」,不给模型会误判 */
taskStatus: string | null;
/**
* ⭐ 该条是否为**尚未到期的未来排程**(taskDate > today)。
* 由 orchestrator **程序判定**后传入,不让 LLM 自己比日期 —— 这是确定性计算,
* 跟话术侧"能程序算的事实全算好、LLM 只润色"同一条纪律。
* 生产事故背景(2026-07):无此字段时模型把 77 天后的排程写成"最新一次未执行,需优先补做",
* 934 条已生成摘要里 196 条最新回访是未来排程、其中 158 条带"未执行/补做"措辞。
*/
isFuture: boolean;
treatmentItems: string | null; // 关联治疗项(如 种植) treatmentItems: string | null; // 关联治疗项(如 种植)
followContent: string | null; // 回访内容(术后复查 / 洁牙提醒 / 活动邀约…) followContent: string | null; // 回访内容(术后复查 / 洁牙提醒 / 活动邀约…)
result: string | null; // 回访结果 result: string | null; // 回访结果
......
import type { DraftRecallSummaryInput } from './input.types'; import type { DraftRecallSummaryInput } from './input.types';
export const DRAFT_RECALL_SUMMARY_PROMPT_VERSION = 'draft_recall_summary@2026-06-16-d'; /**
* 版本历史:
* 2026-06-16-d 初版(仅 taskDate/type/status/内容/结果)
* 2026-07-24-e ⭐ 加今日锚点 + 程序判定的「未来排程」标记 + taskStatus。
* 修生产事故:未来排程被写成"最新一次未执行,需优先补做"。
*/
export const DRAFT_RECALL_SUMMARY_PROMPT_VERSION = 'draft_recall_summary@2026-07-24-e';
export const DRAFT_RECALL_SUMMARY_SYSTEM = `你是牙科诊所客服主管。下面是一个患者的历史联系/回访记录(结构化、看着累)。请提炼成**重点**,让接手客服**一眼抓住关键**,而不是逐条复述。 export const DRAFT_RECALL_SUMMARY_SYSTEM = `你是牙科诊所客服主管。下面是一个患者的历史联系/回访记录(结构化、看着累)。请提炼成**重点**,让接手客服**一眼抓住关键**,而不是逐条复述。
...@@ -11,20 +17,35 @@ export const DRAFT_RECALL_SUMMARY_SYSTEM = `你是牙科诊所客服主管。下 ...@@ -11,20 +17,35 @@ export const DRAFT_RECALL_SUMMARY_SYSTEM = `你是牙科诊所客服主管。下
4. 客观,**禁止**承诺疗效、编造、给医疗建议。 4. 客观,**禁止**承诺疗效、编造、给医疗建议。
5. 严格按 JSON schema 输出,只有一个 key:summary。 5. 严格按 JSON schema 输出,只有一个 key:summary。
# ⭐ 时间纪律(硬约束,违反即错)
记录里**包含诊所已排好、但还没到日子的未来任务**,这类条目已用【未来排程·尚未到期】标出。
- 【未来排程·尚未到期】的条目**绝不能**说成"未执行 / 未回访 / 漏做 / 缺失 / 需补做 / 需优先跟进该次"——
它只是**还没到时间**,不是任何人的疏漏。需要提及时只能说"已排 X 月 X 日复查洁牙"这类中性表述。
- 只有标了【已过期·未执行】的条目才算真正漏做。
- "最近一次""最新一次"这类说法**只能指已过期的条目**,不要用未来排程当"最近一次"。
- 判断哪条是未来、哪条已过期**已经替你算好了**,直接用标记,不要自己比日期。
# 示例(风格参考,不要照抄) # 示例(风格参考,不要照抄)
- "近 3 次活动邀约均未接通,建议换企微触达。" - "近 3 次活动邀约均未接通,建议换企微触达。"
- "术后回访已完成,有种植意向待跟进。" - "术后回访已完成,有种植意向待跟进。"
- "半年内 5 次回访无到诊,流失风险高。"`; - "半年内 5 次回访无到诊,流失风险高。"
- "4 月术后回访已完成,10 月 9 日已排复查洁牙,暂无需额外动作。" ← 未来排程的正确写法`;
export function buildDraftRecallSummaryPrompt(input: DraftRecallSummaryInput): string { export function buildDraftRecallSummaryPrompt(input: DraftRecallSummaryInput): string {
const lines = const lines =
input.items.length > 0 input.items.length > 0
? input.items ? input.items
.map((it, i) => { .map((it, i) => {
// 未来 / 过期 由 orchestrator 程序判定后传入(见 input.types.isFuture 注释),
// 这里只负责渲染成模型看得懂的显式标记 —— LLM 不做日期比较。
const when = it.isFuture ? '【未来排程·尚未到期】' : null;
const parts = [ const parts = [
it.taskDate ?? '—', it.taskDate ?? '—',
when,
it.type ?? '回访', it.type ?? '回访',
it.status, it.status,
// 「已预约」「创建新回访」跟「未完成」语义差很远,漏了会被当成没做
it.taskStatus ? `任务:${it.taskStatus}` : null,
it.treatmentItems ? `项目:${it.treatmentItems}` : null, it.treatmentItems ? `项目:${it.treatmentItems}` : null,
it.followContent ? `内容:${it.followContent.slice(0, 40)}` : null, it.followContent ? `内容:${it.followContent.slice(0, 40)}` : null,
it.result ? `结果:${it.result.slice(0, 40)}` : null, it.result ? `结果:${it.result.slice(0, 40)}` : null,
...@@ -34,9 +55,15 @@ export function buildDraftRecallSummaryPrompt(input: DraftRecallSummaryInput): s ...@@ -34,9 +55,15 @@ export function buildDraftRecallSummaryPrompt(input: DraftRecallSummaryInput): s
.join('\n') .join('\n')
: '(无历史联系记录)'; : '(无历史联系记录)';
return `患者:${input.patientNameMasked} const futureCount = input.items.filter((it) => it.isFuture).length;
const futureNote = futureCount
? `\n⚠️ 其中 ${futureCount} 条是**尚未到期的未来排程**(已标注),不得表述为未执行/漏做/需补做。`
: '';
return `今天:${input.today}
患者:${input.patientNameMasked}
历史联系 / 回访记录(时间倒序,共 ${input.items.length} 条): 历史联系 / 回访记录(时间倒序,共 ${input.items.length} 条):
${lines} ${lines}${futureNote}
请概括以上历史联系的重点。`; 请概括以上历史联系的重点。`;
} }
...@@ -59,7 +59,10 @@ export class RecallSummaryOrchestrator { ...@@ -59,7 +59,10 @@ export class RecallSummaryOrchestrator {
where: { hostId: scope.hostId, tenantId: scope.tenantId, patientId: plan.patientId }, where: { hostId: scope.hostId, tenantId: scope.tenantId, patientId: plan.patientId },
orderBy: { taskDate: 'desc' }, orderBy: { taskDate: 'desc' },
take: 12, take: 12,
select: { taskDate: true, type: true, status: true, treatmentItems: true, followContent: true, result: true }, select: {
taskDate: true, type: true, status: true, taskStatus: true,
treatmentItems: true, followContent: true, result: true,
},
}); });
if (visits.length === 0) return { summary: null, status: 'empty' }; if (visits.length === 0) return { summary: null, status: 'empty' };
...@@ -68,16 +71,28 @@ export class RecallSummaryOrchestrator { ...@@ -68,16 +71,28 @@ export class RecallSummaryOrchestrator {
select: { name: true }, select: { name: true },
}); });
// ⭐ 未来排程判定放这里(程序算),不交给 LLM —— 回访表含未来排程,
// 模型没有"今天"就分不清"还没到期"和"漏做了"(2026-07 生产事故)。
// 口径:按**日期**比(taskDate 是 @db.Date,无时分秒),taskDate > today 即未到期。
const today = new Date().toISOString().slice(0, 10);
const input: DraftRecallSummaryInput = { const input: DraftRecallSummaryInput = {
patientNameMasked: maskName(patient?.name ?? null) ?? '该患者', patientNameMasked: maskName(patient?.name ?? null) ?? '该患者',
items: visits.map((v) => ({ today,
taskDate: v.taskDate ? v.taskDate.toISOString().slice(0, 10) : null, items: visits.map((v) => {
const taskDate = v.taskDate ? v.taskDate.toISOString().slice(0, 10) : null;
return {
taskDate,
type: v.type, type: v.type,
status: v.status, status: v.status,
taskStatus: v.taskStatus,
// 无日期 → 不算未来(宁可当普通历史,也不要给出"已排期"的错误暗示)
isFuture: taskDate != null && taskDate > today,
treatmentItems: v.treatmentItems, treatmentItems: v.treatmentItems,
followContent: v.followContent, followContent: v.followContent,
result: v.result, result: v.result,
})), };
}),
}; };
// 3) 跑 AiCall → upsert // 3) 跑 AiCall → upsert
......
...@@ -9,9 +9,12 @@ import { ...@@ -9,9 +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 { 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 { assertPlanClaimedBy } from '../plan/claim-guard';
import { recordPlanEvent } from '../plan/plan-event.recorder';
import { import {
TenantScope, TenantScope,
TenantScopeContext, TenantScopeContext,
...@@ -67,6 +70,24 @@ export class PlansAggregateController { ...@@ -67,6 +70,24 @@ export class PlansAggregateController {
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
) {} ) {}
/**
* 认领闸(见 plan/claim-guard.ts)—— 「未认领只能看,不能操作」。
* 本控制器的写操作都是**详情页上的按钮**(重生成话术 / 打反馈),按钮就该受这道闸;
* 只读端点(:id/full、recall-brief 等)不过闸 —— "看"始终开放。
*/
private async assertClaimed(
scope: TenantScopeContext,
planId: string,
userId: string,
): Promise<void> {
const plan = await this.prisma.followupPlan.findFirst({
where: { id: planId, hostId: scope.hostId, tenantId: scope.tenantId },
select: { assigneeUserId: true },
});
if (!plan) throw new BizError(ApiCode.PLAN_NOT_FOUND, `Plan ${planId} not found`);
assertPlanClaimedBy(plan, userId);
}
// ───────────────────────────────────────────── // ─────────────────────────────────────────────
// 详情聚合 // 详情聚合
// ───────────────────────────────────────────── // ─────────────────────────────────────────────
...@@ -121,6 +142,7 @@ export class PlansAggregateController { ...@@ -121,6 +142,7 @@ export class PlansAggregateController {
@RequirePermission(Permission.PLAN_VIEW_OWN) @RequirePermission(Permission.PLAN_VIEW_OWN)
@ApiOperation({ summary: '重新生成 plan 话术(测试 / 调试用,输入不变)' }) @ApiOperation({ summary: '重新生成 plan 话术(测试 / 调试用,输入不变)' })
async regenerateScript( async regenerateScript(
@TenantScope() scope: TenantScopeContext,
@CurrentUser() user: AuthenticatedUser, @CurrentUser() user: AuthenticatedUser,
@Param('id') planId: string, @Param('id') planId: string,
@Query('bustCache') bustCache?: string, @Query('bustCache') bustCache?: string,
...@@ -128,6 +150,8 @@ export class PlansAggregateController { ...@@ -128,6 +150,8 @@ export class PlansAggregateController {
@Query('tier') tier?: string, @Query('tier') tier?: string,
@Body() body?: { dryRun?: boolean }, @Body() body?: { dryRun?: boolean },
) { ) {
// 认领闸:重生成要真花 AI 钱,不该让没认领的人点
await this.assertClaimed(scope, planId, user.sub);
const result = await this.planScript.generate(planId, { const result = await this.planScript.generate(planId, {
bustCache: bustCache === 'true' || bustCache === '1', bustCache: bustCache === 'true' || bustCache === '1',
modelIdOverride: model, modelIdOverride: model,
...@@ -163,12 +187,15 @@ export class PlansAggregateController { ...@@ -163,12 +187,15 @@ export class PlansAggregateController {
@RequirePermission(Permission.PLAN_VIEW_OWN) @RequirePermission(Permission.PLAN_VIEW_OWN)
@ApiOperation({ summary: '流式重新生成 plan 话术(SSE)' }) @ApiOperation({ summary: '流式重新生成 plan 话术(SSE)' })
async streamScript( async streamScript(
@TenantScope() scope: TenantScopeContext,
@CurrentUser() user: AuthenticatedUser, @CurrentUser() user: AuthenticatedUser,
@Param('id') planId: string, @Param('id') planId: string,
@Query('model') model: string | undefined, @Query('model') model: string | undefined,
@Query('tier') tier: string | undefined, @Query('tier') tier: string | undefined,
@Res() res: Response, @Res() res: Response,
): Promise<void> { ): Promise<void> {
// 认领闸 —— 在开 SSE 之前抛,让错误走正常 JSON envelope 而不是流里的 error 事件
await this.assertClaimed(scope, planId, user.sub);
await this.pipeSse(res, (signal) => await this.pipeSse(res, (signal) =>
renderAgentInStream( renderAgentInStream(
this.planScript.generateStream(planId, { this.planScript.generateStream(planId, {
...@@ -205,6 +232,7 @@ export class PlansAggregateController { ...@@ -205,6 +232,7 @@ export class PlansAggregateController {
@Param('id') planId: string, @Param('id') planId: string,
@Body() body: unknown, @Body() body: unknown,
) { ) {
await this.assertClaimed(scope, planId, user.sub); // 认领闸
const { feedback } = ScriptFeedbackSchema.parse(body); const { feedback } = ScriptFeedbackSchema.parse(body);
const script = await this.prisma.planScript.findFirst({ const script = await this.prisma.planScript.findFirst({
where: { planId, hostId: scope.hostId, tenantId: scope.tenantId }, where: { planId, hostId: scope.hostId, tenantId: scope.tenantId },
...@@ -230,19 +258,40 @@ export class PlansAggregateController { ...@@ -230,19 +258,40 @@ export class PlansAggregateController {
}) })
async submitRecallFeedback( async submitRecallFeedback(
@TenantScope() scope: TenantScopeContext, @TenantScope() scope: TenantScopeContext,
@CurrentUser() user: AuthenticatedUser,
@Param('id') planId: string, @Param('id') planId: string,
@Body() body: unknown, @Body() body: unknown,
) { ) {
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 },
select: { id: true, hostId: true, tenantId: true, patientId: true },
});
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: { data: {
recallFeedback: feedback, recallFeedback: feedback,
recallFeedbackNote: note?.trim() ? note.trim() : null, recallFeedbackNote: note?.trim() ? note.trim() : null,
}, },
}); });
if (res.count === 0) return { ok: false as const, reason: 'not_found' }; // ⭐ 同时落事件账本: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,
});
});
return { ok: true as const, feedback }; return { ok: true as const, feedback };
} }
......
import { ApiCode } from '@pac/types';
import { BizError } from '../../common/errors/biz-error';
/**
* 认领闸 —— 「未认领只能看,不能操作」的服务端硬约束。
*
* 为什么必须在服务端拦(不能只做前端禁用):
* 1. **执行必须有归属**。execution.service 特意保留 assigneeUserId 作业绩归属
* (见那里的注释:清了会让「我的已完成」/ 转化率永远为 0)。未认领就提交执行 =
* 这条执行没有任何归属人,统计里直接蒸发 —— 前端提示拦不住这个数据问题。
* 2. **防撞单**。两个客服同时打同一个患者,只有服务端能仲裁。
* 3. 生产实证(2026-07):缺牙召回有两条 plan 在 `assignee IS NULL` 状态下被直接关闭,
* 当时 submitExecution 只校验了 status、完全没看 assignee。
*
* ⚠️ 适用边界 —— 不是所有写操作都该过这道闸:
* 过闸:提交通话结果 / 关闭机会、重生成话术(含 SSE)、话术反馈、召回反馈
* 不过闸:
* · `assign`(认领本身就是入口,过闸会变成死锁)
* · `recycle`(PLAN_RECYCLE 是 **leader 独占**,语义就是"回收 staff 已认领的单",
* 按"必须自己认领"拦会把组长回收功能整个废掉)
* · `recompute`(ops/dev 工具,走 PLAN_ASSIGN 权限)
*
* 两种拒绝态分开报,文案不同:没人认领 → 引导去认领;别人认领了 → 说清是谁,
* 否则组员会以为是自己没点对。
*/
export interface ClaimablePlan {
assigneeUserId: string | null;
}
export function assertPlanClaimedBy(plan: ClaimablePlan, userId: string): void {
if (!plan.assigneeUserId) {
throw new BizError(ApiCode.PLAN_NOT_CLAIMED);
}
if (plan.assigneeUserId !== userId) {
// details 带上占用人,前端可查字典换成姓名展示("已被 张三 认领")
throw new BizError(ApiCode.PLAN_CLAIMED_BY_OTHER, undefined, {
assigneeUserId: plan.assigneeUserId,
});
}
}
...@@ -3,6 +3,7 @@ import { EXECUTION_OUTCOME_META, ExecutionOutcome } from '@pac/types'; ...@@ -3,6 +3,7 @@ import { EXECUTION_OUTCOME_META, ExecutionOutcome } from '@pac/types';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import { QueueProducer } from '../../queues/queue-producer.service'; import { QueueProducer } from '../../queues/queue-producer.service';
import { resolveSnoozedUntil } from './recall-suppression'; import { resolveSnoozedUntil } from './recall-suppression';
import { assertPlanClaimedBy } from './claim-guard';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator'; import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';
/** /**
...@@ -90,6 +91,7 @@ export class ExecutionService { ...@@ -90,6 +91,7 @@ export class ExecutionService {
status: true, status: true,
contactAttempts: true, contactAttempts: true,
targetClinicId: true, targetClinicId: true,
assigneeUserId: true,
}, },
}); });
if (!plan) throw new NotFoundException(`Plan ${planId} not found`); if (!plan) throw new NotFoundException(`Plan ${planId} not found`);
...@@ -101,6 +103,9 @@ export class ExecutionService { ...@@ -101,6 +103,9 @@ export class ExecutionService {
`Plan ${planId} 当前状态 ${plan.status},不可写 execution`, `Plan ${planId} 当前状态 ${plan.status},不可写 execution`,
); );
} }
// ⭐ 认领闸:未认领 / 被别人认领 → 拒绝(见 claim-guard.ts)。
// 放在状态校验之后 —— "这单已经结案了"比"你没认领"更该先说。
assertPlanClaimedBy(plan, operatorUserId);
// ─── 2. 解析 executorClinicId ─── // ─── 2. 解析 executorClinicId ───
const executorClinicId = const executorClinicId =
......
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,7 +469,9 @@ export class PlanService { ...@@ -459,7 +469,9 @@ export class PlanService {
} }
// 幂等:重复分配给同一人 → 只刷新 recycleAt // 幂等:重复分配给同一人 → 只刷新 recycleAt
const now = new Date(); const now = new Date();
await this.prisma.followupPlan.update({ const isRepeat = plan.assigneeUserId === assigneeUserId;
await this.prisma.$transaction(async (tx) => {
await tx.followupPlan.update({
where: { id: planId }, where: { id: planId },
data: { data: {
status: 'assigned', status: 'assigned',
...@@ -468,19 +480,40 @@ export class PlanService { ...@@ -468,19 +480,40 @@ export class PlanService {
recycleAt: new Date(now.getTime() + RECYCLE_TIMEOUT_HOURS * 3600 * 1000), 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,
});
}
});
} }
// ───────────────────────────────────────────── // ─────────────────────────────────────────────
// 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,7 +523,12 @@ export class PlanService { ...@@ -490,7 +523,12 @@ 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 的"返池"是空操作,记了是噪声
const hadAssignee = plan.assigneeUserId != null;
// ⭐ 在清空 assignedAt 之前算(见 computeHeldSeconds 注释)
const heldSeconds = computeHeldSeconds(plan.assignedAt, new Date());
await this.prisma.$transaction(async (tx) => {
await tx.followupPlan.update({
where: { id: planId }, where: { id: planId },
data: { data: {
status: 'active', status: 'active',
...@@ -499,6 +537,19 @@ export class PlanService { ...@@ -499,6 +537,19 @@ export class PlanService {
recycleAt: 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 { ApiCode } from '@pac/types';
import { assertPlanClaimedBy } from '../src/modules/plan/claim-guard';
import { BizError } from '../src/common/errors/biz-error';
/**
* 认领闸回归 —— 「未认领只能看,不能操作」。
*
* 背景(2026-07 生产实证):submitExecution 当时只校验 status、**完全没看 assignee**,
* 结果缺牙召回有两条 plan 在 `assignee IS NULL` 状态下被直接关闭。
* 危害不止"撞单":execution.service 特意保留 assigneeUserId 作**业绩归属**
* (那里的注释写明清空会让「我的已完成」/ 转化率永远为 0)—— 未认领就提交执行,
* 这条执行没有任何归属人,统计里直接蒸发。所以闸必须在服务端,前端提示替代不了。
*
* 三条红线(下面各有用例锁住):
* 1. 没人认领 → 拒绝,且码是 PLAN_NOT_CLAIMED(引导去认领)
* 2. 别人认领 → 拒绝,且码**不同**(PLAN_CLAIMED_BY_OTHER),否则组员以为自己没点对
* 3. 自己认领 → 放行(闸不能把正常作业也拦了)
*/
const ME = 'u-me';
function catchErr(fn: () => void): BizError {
try {
fn();
} catch (e) {
return e as BizError;
}
throw new Error('期望抛错,但没抛');
}
describe('assertPlanClaimedBy — 认领闸', () => {
test('⭐ 红线③:自己认领 → 放行', () => {
expect(() => assertPlanClaimedBy({ assigneeUserId: ME }, ME)).not.toThrow();
});
test('⭐ 红线①:没人认领 → PLAN_NOT_CLAIMED', () => {
const err = catchErr(() => assertPlanClaimedBy({ assigneeUserId: null }, ME));
expect(err).toBeInstanceOf(BizError);
expect(err.code).toBe(ApiCode.PLAN_NOT_CLAIMED);
expect(err.msg).toContain('认领');
});
test('⭐ 红线②:别人认领 → PLAN_CLAIMED_BY_OTHER(跟"没人认领"必须是不同的码)', () => {
const err = catchErr(() => assertPlanClaimedBy({ assigneeUserId: 'u-other' }, ME));
expect(err.code).toBe(ApiCode.PLAN_CLAIMED_BY_OTHER);
expect(err.code).not.toBe(ApiCode.PLAN_NOT_CLAIMED); // 两态文案不能合并
// details 带占用人 —— 前端据此查字典换姓名展示("已被 张三 认领")
expect(err.details).toEqual({ assigneeUserId: 'u-other' });
});
test('空串 assignee 当作未认领(不是"被叫空串的人认领了")', () => {
const err = catchErr(() => assertPlanClaimedBy({ assigneeUserId: '' }, ME));
expect(err.code).toBe(ApiCode.PLAN_NOT_CLAIMED);
});
test('用户 id 区分大小写 / 不做模糊匹配 —— 不是自己就是不行', () => {
expect(() => assertPlanClaimedBy({ assigneeUserId: 'U-ME' }, ME)).toThrow();
expect(() => assertPlanClaimedBy({ assigneeUserId: 'u-me-2' }, ME)).toThrow();
});
});
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();
});
});
import {
buildDraftRecallSummaryPrompt,
DRAFT_RECALL_SUMMARY_SYSTEM,
DRAFT_RECALL_SUMMARY_PROMPT_VERSION,
} from '../src/modules/ai/calls/draft-recall-summary/prompt';
import type { DraftRecallSummaryInput } from '../src/modules/ai/calls/draft-recall-summary/input.types';
/**
* 历史联系摘要 —— 「未来排程 ≠ 漏做」回归。
*
* 生产事故(2026-07-24 排查):详情页「历史联系」把**诊所已排好、还没到日子**的回访
* 说成"最新一次未执行,需优先补做"。实例:
* · 张学军 task_date=2026-10-09(77 天后)→ "10月9日最新一次洁牙复查回访未执行"
* · 江世琪 2026-09-17(55 天后)、史原 2026-08-25(32 天后)—— 同款措辞
* 当时 934 条已生成摘要里 196 条"最新回访是未来排程",其中 158 条(81%)带
* "未执行/补做/漏/尚未"措辞;对照组(最新在过去)只有 38%。
*
* 根因:prompt **没有今日锚点**,且按 taskDate desc 排序 → 未来排程恒排第一被当成"最新一次";
* taskStatus(已预约/已完成)也没喂给模型。
*
* 修复纪律(本文件锁住):日期比较是**确定性计算**,由 orchestrator 算好 isFuture 传入,
* prompt 只负责把它渲染成显式标记 —— 跟话术侧"能程序算的事实全算好、LLM 只润色"同一条规矩。
*/
const TODAY = '2026-07-24';
function item(over: Partial<DraftRecallSummaryInput['items'][0]> = {}) {
return {
taskDate: '2026-04-24',
type: '常规回访',
status: '已回访',
taskStatus: '已完成',
isFuture: false,
treatmentItems: null,
followContent: null,
result: '治疗后回访',
...over,
};
}
function build(items: DraftRecallSummaryInput['items']) {
return buildDraftRecallSummaryPrompt({ patientNameMasked: '张*', today: TODAY, items });
}
describe('buildDraftRecallSummaryPrompt — 时间锚 + 未来排程标记', () => {
test('⭐ 必须把"今天"写进 prompt(模型据此才能判断远近)', () => {
expect(build([item()])).toContain(`今天:${TODAY}`);
});
test('⭐ 未来排程条目带【未来排程·尚未到期】标记', () => {
const p = build([
item({ taskDate: '2026-10-09', status: '未回访', taskStatus: '未完成', isFuture: true, followContent: '复查洁牙' }),
]);
expect(p).toContain('【未来排程·尚未到期】');
expect(p).toContain('2026-10-09');
});
test('已过期条目**不带**未来标记(否则真漏做会被洗白)', () => {
const p = build([item({ taskDate: '2026-03-02', status: '未回访', taskStatus: '未完成' })]);
expect(p).not.toContain('【未来排程·尚未到期】');
});
test('⭐ 有未来排程时追加显式警告,并报出条数', () => {
const p = build([
item({ taskDate: '2026-10-09', isFuture: true }),
item({ taskDate: '2026-09-17', isFuture: true }),
item(),
]);
expect(p).toContain('其中 2 条');
expect(p).toMatch(/不得表述为未执行/);
});
test('全是历史条目 → 不出现未来排程警告(不给无谓噪声)', () => {
const p = build([item(), item({ taskDate: '2026-03-02' })]);
expect(p).not.toContain('尚未到期的未来排程');
});
test('⭐ taskStatus 必须进 prompt ——「已预约」不是「没做」', () => {
const p = build([item({ taskStatus: '已预约', result: '复诊邀约' })]);
expect(p).toContain('任务:已预约');
});
test('三位真实患者的原始形态:最新一条是未来排程 → 都被正确标注', () => {
// 生产实测数据(2026-07-24 当天)
const real: Array<[string, string]> = [
['2026-10-09', '复查洁牙'], // 张学军
['2026-09-17', '复查洁牙 看其他治疗是否要做,没有保险了'], // 江世琪
['2026-08-25', '46复查拍CT'], // 史原
];
for (const [date, content] of real) {
const p = build([
item({ taskDate: date, status: '未回访', taskStatus: '未完成', isFuture: true, followContent: content }),
]);
expect(p).toContain('【未来排程·尚未到期】');
}
});
});
describe('DRAFT_RECALL_SUMMARY_SYSTEM — 硬约束文本', () => {
test('⭐ system 明令未来排程不得说成未执行 / 漏做 / 需补做', () => {
for (const word of ['未执行', '漏做', '需补做', '未来排程']) {
expect(DRAFT_RECALL_SUMMARY_SYSTEM).toContain(word);
}
});
test('⭐ 明令"最近一次/最新一次"只能指已过期条目', () => {
expect(DRAFT_RECALL_SUMMARY_SYSTEM).toMatch(/最近一次|最新一次/);
expect(DRAFT_RECALL_SUMMARY_SYSTEM).toContain('已过期');
});
test('明令不要自己比日期(确定性计算已由程序完成)', () => {
expect(DRAFT_RECALL_SUMMARY_SYSTEM).toContain('不要自己比日期');
});
test('promptVersion 已 bump(改了 prompt 必须换版本,否则缓存/评估串档)', () => {
expect(DRAFT_RECALL_SUMMARY_PROMPT_VERSION).not.toBe('draft_recall_summary@2026-06-16-d');
expect(DRAFT_RECALL_SUMMARY_PROMPT_VERSION).toMatch(/^draft_recall_summary@\d{4}-\d{2}-\d{2}-[a-z]$/);
});
});
...@@ -4,7 +4,6 @@ import { ...@@ -4,7 +4,6 @@ import {
BREAKER_SUPPRESS_DAYS, BREAKER_SUPPRESS_DAYS,
ABANDON_REASON_META, ABANDON_REASON_META,
abandonReasonsFor, abandonReasonsFor,
WEAK_SIGNAL_SUPPRESS_DAYS,
} from '@pac/types'; } from '@pac/types';
import { resolveSnoozedUntil } from '../src/modules/plan/recall-suppression'; import { resolveSnoozedUntil } from '../src/modules/plan/recall-suppression';
import { import {
...@@ -212,29 +211,41 @@ describe('放弃原因细分抑制窗(ABANDON_REASON_META.suppressDays,多选取 ...@@ -212,29 +211,41 @@ describe('放弃原因细分抑制窗(ABANDON_REASON_META.suppressDays,多选取
expect(snooze([])).toBe(60); expect(snooze([])).toBe(60);
}); });
test('⭐ 弱语义原因单独勾选 → 短档 14d,不被 outcome 的 60d 盖掉', () => { test('⭐ 短档原因单独勾选 → 用自己的短档,不被 outcome 的 60d 盖掉', () => {
// 「识别不准确」是 PAC 自己的信号有问题,不该按「患者拒绝」的重档罚人。 // 这条锁住 max 的种子不能是 outcome 默认档(否则任何比 60d 短的档都永远生效不了)。
// 这条锁住 max 的种子不能是 outcome 默认档(否则 14d 永远生效不了)。 // 「无法联系客户」/「号码空号」是"暂时够不着",不是拒绝,短冷静期后该再试。
expect(snooze(['inaccurate'])).toBe(WEAK_SIGNAL_SUPPRESS_DAYS); expect(snooze(['unreachable'])).toBe(30);
expect(snooze(['treated'])).toBe(WEAK_SIGNAL_SUPPRESS_DAYS); expect(snooze(['wrong_number'])).toBe(30);
});
test('⭐ 「已完成治疗」+「机会识别不准确」= 永久(2026-07 由 14d 改)', () => {
// 原 14d 弱语义档押注「等一个 DW 同步周期 + 排除闸接手」,生产实测证伪:
// 缺牙两例(DW 无完成记录 / 义齿在上颌而召回在下颌)排除闸都接不住,到期只会
// 原样弹回来再挨一次投诉。「识别不准确」同理 —— 算法什么时候修好是我们的排期,
// 不该由患者兜着。两者都改永久后,WEAK_SIGNAL_SUPPRESS_DAYS 常量已删除。
expect(snooze(['treated'])).toBe(SUPPRESS_PERMANENT_DAYS);
expect(snooze(['inaccurate'])).toBe(SUPPRESS_PERMANENT_DAYS);
// 与「已在外院治疗」同档 —— 在别家做完 vs 在自己家做完,抑制强度应一致
expect(snooze(['treated'])).toBe(snooze(['treated_elsewhere']));
}); });
test('多选取 max —— 每个勾中的原因都独立成立,取最严的', () => { test('多选取 max —— 每个勾中的原因都独立成立,取最严的', () => {
// 同时勾「识别不准确(14d)」+「选择竞品机构(永久)」:人确实去别家了, // 勾中任一永久档就不能被短档拉回来
// 不能因为信号也有问题就 14 天后再召回。
expect(snooze(['inaccurate', 'competitor'])).toBe(SUPPRESS_PERMANENT_DAYS); expect(snooze(['inaccurate', 'competitor'])).toBe(SUPPRESS_PERMANENT_DAYS);
expect(snooze(['unreachable', 'inaccurate'])).toBe(30); expect(snooze(['unreachable', 'inaccurate'])).toBe(SUPPRESS_PERMANENT_DAYS);
// 两个非永久档之间仍取大的
expect(snooze(['unreachable', 'no_intent'])).toBe(60); // max(30, 60 默认)
}); });
test('没配 suppressDays 的原因 → 参与 max 时用 outcome 默认档', () => { test('没配 suppressDays 的原因 → 参与 max 时用 outcome 默认档', () => {
expect(snooze(['no_intent'])).toBe(60); // 关闭弹窗侧 expect(snooze(['no_intent'])).toBe(60); // 关闭弹窗侧
expect(snooze(['patient_refused'])).toBe(60); // PAC 表单侧 expect(snooze(['patient_refused'])).toBe(60); // PAC 表单侧
expect(snooze(['inaccurate', 'no_intent'])).toBe(60); // max(14, 60) expect(snooze(['wrong_number', 'no_intent'])).toBe(60); // max(30, 60)
}); });
test('未知 key 被忽略;全是未知 → 退回 outcome 默认档', () => { test('未知 key 被忽略;全是未知 → 退回 outcome 默认档', () => {
expect(snooze(['bogus'])).toBe(60); expect(snooze(['bogus'])).toBe(60);
expect(snooze(['bogus', 'inaccurate'])).toBe(WEAK_SIGNAL_SUPPRESS_DAYS); expect(snooze(['bogus', 'unreachable'])).toBe(30);
}); });
test('熔断优先级仍最高', () => { test('熔断优先级仍最高', () => {
......
...@@ -162,6 +162,9 @@ export function adaptData(real: PlanDetailData, dict?: TokenDictionary) { ...@@ -162,6 +162,9 @@ export function adaptData(real: PlanDetailData, dict?: TokenDictionary) {
name: real.plan?.assigneeUserId ?? '(未分配)', name: real.plan?.assigneeUserId ?? '(未分配)',
role: (real.plan?.recommendedRole as UserRole) ?? UserRole.STAFF, role: (real.plan?.recommendedRole as UserRole) ?? UserRole.STAFF,
}, },
/// 认领人**原始值**(null = 无人认领)—— 认领闸判定必须用这个,不能用上面的
/// assignee.id:那里未认领时兜底成 'u_unknown',分不出"没人认领"和"某人叫 u_unknown"。
assigneeUserId: real.plan?.assigneeUserId ?? null,
assignedAt: real.plan?.assignedAt ? new Date(real.plan.assignedAt) : now, assignedAt: real.plan?.assignedAt ? new Date(real.plan.assignedAt) : now,
recycleAt: real.plan?.recycleAt ? new Date(real.plan.recycleAt) : null, recycleAt: real.plan?.recycleAt ? new Date(real.plan.recycleAt) : null,
snoozedUntil: real.plan?.snoozedUntil ? new Date(real.plan.snoozedUntil) : null, snoozedUntil: real.plan?.snoozedUntil ? new Date(real.plan.snoozedUntil) : null,
......
...@@ -321,6 +321,8 @@ export const mockPlan = { ...@@ -321,6 +321,8 @@ export const mockPlan = {
executorClinic: '望京旗舰店', executorClinic: '望京旗舰店',
goal: '邀约本周种植面诊,避免邻牙倾斜 / 对颌伸长' as string | null, goal: '邀约本周种植面诊,避免邻牙倾斜 / 对颌伸长' as string | null,
assignee: { id: 'usr_csliu', name: '刘悦', role: UserRole.STAFF as UserRole }, assignee: { id: 'usr_csliu', name: '刘悦', role: UserRole.STAFF as UserRole },
/// 认领人原始值(null = 无人认领)—— 认领闸判定用,见 adapt-data 同名字段注释
assigneeUserId: 'usr_csliu' as string | null,
assignedAt: NOW, assignedAt: NOW,
recycleAt: new Date(NOW.getTime() + 4 * 3600_000) as Date | null, recycleAt: new Date(NOW.getTime() + 4 * 3600_000) as Date | null,
/// 召回冷静期 / 终态抑制窗到期时间(null=无抑制)— 头部"下次回访 / 已暂缓至 X"角标 /// 召回冷静期 / 终态抑制窗到期时间(null=无抑制)— 头部"下次回访 / 已暂缓至 X"角标
......
...@@ -21,6 +21,7 @@ import { ...@@ -21,6 +21,7 @@ import {
DropdownMenuRadioItem, DropdownMenuRadioItem,
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import { plansApi } from '@/components/plans/plans-api'; import { plansApi } from '@/components/plans/plans-api';
import { useAuthStore } from '@/stores/auth-store';
import { emitPetEvent } from '@/lib/pet-events'; import { emitPetEvent } from '@/lib/pet-events';
import { usePlanSyncStore } from '@/stores/plan-sync-store'; import { usePlanSyncStore } from '@/stores/plan-sync-store';
import { IdentityCluster } from '@/components/identity-cluster'; import { IdentityCluster } from '@/components/identity-cluster';
...@@ -129,6 +130,17 @@ const FALLBACK_DATA: PlanDetailAppData = { ...@@ -129,6 +130,17 @@ const FALLBACK_DATA: PlanDetailAppData = {
fmtRel: mockFmtRel, fmtRel: mockFmtRel,
}; };
/**
* 认领闸句柄 —— 在组件树里传递「能不能操作」+ 统一的拦截提示。
* 只传函数不传状态判断,保证提示文案单一来源(见 PlanDetailApp 里 gateCheck 的注释)。
*/
export interface ClaimGate {
canOperate: boolean;
claimState: 'mine' | 'unclaimed' | 'other';
/** 放行返 true;拦下则弹提示并返 false */
check: () => boolean;
}
export function PlanDetailApp({ export function PlanDetailApp({
data = FALLBACK_DATA, data = FALLBACK_DATA,
banner, banner,
...@@ -159,6 +171,8 @@ export function PlanDetailApp({ ...@@ -159,6 +171,8 @@ export function PlanDetailApp({
const [planOverride, setPlanOverride] = useState<{ const [planOverride, setPlanOverride] = useState<{
status?: string; status?: string;
contactAttempts?: number; contactAttempts?: number;
/// 认领成功后就地切到"我的",不等重拉 —— 否则按钮要等一个来回才解锁
assigneeUserId?: string | null;
}>({}); }>({});
const effectivePlan = { ...plan, ...planOverride }; const effectivePlan = { ...plan, ...planOverride };
...@@ -173,6 +187,45 @@ export function PlanDetailApp({ ...@@ -173,6 +187,45 @@ export function PlanDetailApp({
else toast(title, opts); else toast(title, opts);
}; };
// ── 认领闸:「未认领只能看,不能操作」──────────────────────────────────
// ⚠️ 真正的约束在**服务端**(plan/claim-guard.ts,拒绝码 20504/20505)—— 前端这层
// 只是把拒绝**前置**:点下去立刻给解释,而不是发一个注定失败的请求再报错。
// 所以这里绝不能用 disabled 了事:按钮保持可点,点了才讲原因,否则客服不知道为什么点不动。
// 两种拒绝态文案必须分开 —— "没人认领"要引导去认领(顶栏有按钮),
// "别人认领了"要说清占用人,否则组员会以为是自己没点对。
const meId = useAuthStore((s) => s.user?.sub ?? null);
const assigneeUserId = effectivePlan.assigneeUserId ?? null;
const claimState: 'mine' | 'unclaimed' | 'other' = !assigneeUserId
? 'unclaimed'
: assigneeUserId === meId
? 'mine'
: 'other';
const canOperate = claimState === 'mine';
/**
* 操作前置校验:放行返 true;拦下则弹提示并返 false(调用方 `if (!gateCheck()) return;`)。
* ⭐ 认领入口只留**列表页**那一处(左栏行内「认领」/ 批量认领)—— 详情页顶栏不再放认领按钮,
* 避免同一动作两个入口、两套状态同步。所以文案要把人**指回列表**,否则等于只说"不行"不说去哪。
*/
const gateCheck = (): boolean => {
if (canOperate) return true;
if (claimState === 'unclaimed') {
showToast('amber', '请先认领后再操作', '这条召回还没人认领,在左侧列表点该患者的「认领」接手后即可操作');
} else {
showToast('amber', '无法操作', `该召回已被其他同事认领(${assigneeUserId}),如需接手请让其返池`);
}
return false;
};
const claimGate: ClaimGate = { canOperate, claimState, check: gateCheck };
// 反向通道(列表 → 详情):在列表点了「认领」/「返池」→ 本页认领闸即时解锁 / 上锁,
// 不用等重拉。assigneeUserId===undefined 表示该事件跟认领无关(如提交执行),跳过。
const syncSeq = usePlanSyncStore((s) => s.seq);
useEffect(() => {
const { planId: changedId, assigneeUserId: nextAssignee } = usePlanSyncStore.getState();
if (changedId !== plan.id || nextAssignee === undefined) return;
setPlanOverride((p) => ({ ...p, assigneeUserId: nextAssignee }));
}, [syncSeq, plan.id]);
// 流式 done / error 时弹 toast // 流式 done / error 时弹 toast
useEffect(() => { useEffect(() => {
if (streamState.status === 'done') { if (streamState.status === 'done') {
...@@ -265,6 +318,7 @@ export function PlanDetailApp({ ...@@ -265,6 +318,7 @@ export function PlanDetailApp({
scheduledNextAt: string; scheduledNextAt: string;
abandonReasons: string[]; abandonReasons: string[];
}) => { }) => {
if (!gateCheck()) return; // 认领闸
try { try {
const result = await submitExecution(plan.id, { const result = await submitExecution(plan.id, {
channel: formData.channel, channel: formData.channel,
...@@ -308,11 +362,20 @@ export function PlanDetailApp({ ...@@ -308,11 +362,20 @@ export function PlanDetailApp({
clinicId: plan?.targetClinicId, clinicId: plan?.targetClinicId,
medicalRecordNumber: patient.medicalRecordNumber, medicalRecordNumber: patient.medicalRecordNumber,
}; };
// 顶栏跳宿主的三个动作(潜在 / 预约 / 回访)**全部过认领闸**:
// 它们都会把人带进宿主系统对这个患者动手(哪怕「潜在」只是看,看完顺手就在宿主侧操作了),
// 而 PAC 这边没认领 = 没有归属人,回头对不上账。口径统一比"哪个算查看"的细分更好维护。
const openPotential = hostActionMode('OPEN_POTENTIAL_TREATMENT') const openPotential = hostActionMode('OPEN_POTENTIAL_TREATMENT')
? () => openHostAction('OPEN_POTENTIAL_TREATMENT', hostActionCtx) ? () => {
if (!gateCheck()) return;
openHostAction('OPEN_POTENTIAL_TREATMENT', hostActionCtx);
}
: undefined; : undefined;
const openReturnVisit = hostActionMode('OPEN_RETURN_VISIT') const openReturnVisit = hostActionMode('OPEN_RETURN_VISIT')
? () => openHostAction('OPEN_RETURN_VISIT', hostActionCtx) ? () => {
if (!gateCheck()) return;
openHostAction('OPEN_RETURN_VISIT', hostActionCtx);
}
: undefined; : undefined;
// 宿主回访模式:回访动作在宿主侧完成 → 隐藏 PAC 通话结果区,顶栏补「关闭」入口(关闭机会弹窗)。 // 宿主回访模式:回访动作在宿主侧完成 → 隐藏 PAC 通话结果区,顶栏补「关闭」入口(关闭机会弹窗)。
const hasHostReturnVisit = !!openReturnVisit; const hasHostReturnVisit = !!openReturnVisit;
...@@ -326,6 +389,7 @@ export function PlanDetailApp({ ...@@ -326,6 +389,7 @@ export function PlanDetailApp({
// 成功后自动跳下一位:①我的进行中 → ②召回池(与 /plans 入口解析器同一规则)→ ③没人则回 /plans 空态。 // 成功后自动跳下一位:①我的进行中 → ②召回池(与 /plans 入口解析器同一规则)→ ③没人则回 /plans 空态。
const router = useRouter(); const router = useRouter();
const submitClose = async (reasons: AbandonReason[], note?: string) => { const submitClose = async (reasons: AbandonReason[], note?: string) => {
if (!gateCheck()) return; // 认领闸(弹窗入口已拦一次,这里兜底防绕过)
const labels = reasons.map((r) => ABANDON_REASON_META[r].labelZh).join('、'); const labels = reasons.map((r) => ABANDON_REASON_META[r].labelZh).join('、');
try { try {
const result = await submitExecution(plan.id, { const result = await submitExecution(plan.id, {
...@@ -355,6 +419,7 @@ export function PlanDetailApp({ ...@@ -355,6 +419,7 @@ export function PlanDetailApp({
} }
}; };
const createAppointment = () => { const createAppointment = () => {
if (!gateCheck()) return; // 认领闸:建预约是实打实的操作
// 宿主 actionUrls.CREATE_APPOINTMENT(会话下发)→ 通用占位替换 → 打开宿主页。 // 宿主 actionUrls.CREATE_APPOINTMENT(会话下发)→ 通用占位替换 → 打开宿主页。
// 未配置则提示去配,不写死任何 URL。缺失键清空。 // 未配置则提示去配,不写死任何 URL。缺失键清空。
const url = resolveActionUrl('CREATE_APPOINTMENT', { const url = resolveActionUrl('CREATE_APPOINTMENT', {
...@@ -431,7 +496,16 @@ export function PlanDetailApp({ ...@@ -431,7 +496,16 @@ export function PlanDetailApp({
onOpenReturnVisit={openReturnVisit} onOpenReturnVisit={openReturnVisit}
// 宿主回访模式下「召回反馈」控件隐藏,宠物引导跟着停(否则指向不存在的控件) // 宿主回访模式下「召回反馈」控件隐藏,宠物引导跟着停(否则指向不存在的控件)
onHoverReturnVisit={hasHostReturnVisit ? undefined : hintRecallFeedback} onHoverReturnVisit={hasHostReturnVisit ? undefined : hintRecallFeedback}
onCloseOpportunity={hasHostReturnVisit ? () => setCloseOpen(true) : undefined} // 认领闸拦在**打开弹窗前** —— 别让人填完一堆原因才被拒
onCloseOpportunity={
hasHostReturnVisit
? () => {
if (!gateCheck()) return;
setCloseOpen(true);
}
: undefined
}
claimGate={claimGate}
/> />
</HeaderSlotPortal> </HeaderSlotPortal>
...@@ -552,7 +626,11 @@ export function PlanDetailApp({ ...@@ -552,7 +626,11 @@ export function PlanDetailApp({
abort(); abort();
showToast('slate', '已停止', '本次 AI 生成被中断'); showToast('slate', '已停止', '本次 AI 生成被中断');
}} }}
onRegen={() => void regenerate(plan.id, { model: scriptModel, tier: scriptTier })} // 认领闸:重生成要真花 AI 钱,不该让没认领的人点(服务端同样会拒)
onRegen={() => {
if (!gateCheck()) return;
void regenerate(plan.id, { model: scriptModel, tier: scriptTier });
}}
/> />
{/* AI 时间戳 — 窄屏隐藏;未生成话术时不显示(无 generatedAt) */} {/* AI 时间戳 — 窄屏隐藏;未生成话术时不显示(无 generatedAt) */}
<span className="hidden md:inline-flex"> <span className="hidden md:inline-flex">
...@@ -594,6 +672,7 @@ export function PlanDetailApp({ ...@@ -594,6 +672,7 @@ export function PlanDetailApp({
</div> </div>
<AIDisclaimerFooter <AIDisclaimerFooter
onFeedback={async (v) => { onFeedback={async (v) => {
if (!gateCheck()) return; // 认领闸
try { try {
const r = await plansApi.submitScriptFeedback(plan.id, v); const r = await plansApi.submitScriptFeedback(plan.id, v);
if (!r.ok) { if (!r.ok) {
...@@ -727,11 +806,14 @@ function RecallFeedbackControl({ ...@@ -727,11 +806,14 @@ function RecallFeedbackControl({
initialFeedback, initialFeedback,
initialNote, initialNote,
showToast, showToast,
claimGate,
}: { }: {
planId: string; planId: string;
initialFeedback: 'up' | 'down' | null; initialFeedback: 'up' | 'down' | null;
initialNote: string | null; initialNote: string | null;
showToast?: (kind: string, title: string, msg: string) => void; showToast?: (kind: string, title: string, msg: string) => void;
/** 认领闸;未传 = 不约束(mock / 独立预览) */
claimGate?: ClaimGate;
}) { }) {
const [feedback, setFeedback] = useState<'up' | 'down' | null>(initialFeedback); const [feedback, setFeedback] = useState<'up' | 'down' | null>(initialFeedback);
const [note, setNote] = useState(initialNote ?? ''); const [note, setNote] = useState(initialNote ?? '');
...@@ -740,6 +822,7 @@ function RecallFeedbackControl({ ...@@ -740,6 +822,7 @@ function RecallFeedbackControl({
const submitUp = async () => { const submitUp = async () => {
if (busy) return; if (busy) return;
if (claimGate && !claimGate.check()) return; // 认领闸
setBusy(true); setBusy(true);
try { try {
await plansApi.submitRecallFeedback(planId, 'up'); await plansApi.submitRecallFeedback(planId, 'up');
...@@ -755,6 +838,7 @@ function RecallFeedbackControl({ ...@@ -755,6 +838,7 @@ function RecallFeedbackControl({
const submitDown = async () => { const submitDown = async () => {
if (busy) return; if (busy) return;
if (claimGate && !claimGate.check()) return; // 认领闸
setBusy(true); setBusy(true);
try { try {
await plansApi.submitRecallFeedback(planId, 'down', note.trim() || undefined); await plansApi.submitRecallFeedback(planId, 'down', note.trim() || undefined);
...@@ -800,7 +884,11 @@ function RecallFeedbackControl({ ...@@ -800,7 +884,11 @@ function RecallFeedbackControl({
</button> </button>
<button <button
type="button" type="button"
onClick={() => setOpen(true)} // 认领闸拦在开弹窗前 —— 别让人写完说明才被拒
onClick={() => {
if (claimGate && !claimGate.check()) return;
setOpen(true);
}}
disabled={busy} disabled={busy}
aria-label="召回有问题" aria-label="召回有问题"
className={cn( className={cn(
...@@ -888,10 +976,14 @@ function TopBar({ ...@@ -888,10 +976,14 @@ function TopBar({
onOpenReturnVisit, onOpenReturnVisit,
onHoverReturnVisit, onHoverReturnVisit,
onCloseOpportunity, onCloseOpportunity,
claimGate,
}: { }: {
plan: typeof mockPlan; plan: typeof mockPlan;
reason: typeof mockPlan.reasons[0]; reason: typeof mockPlan.reasons[0];
patientId?: string; patientId?: string;
/** 认领闸(见 ClaimGate);未传 = 不做认领约束(mock / 独立预览场景)。
* 顶栏只用它渲染**只读**徽标 + 传给召回反馈控件;认领动作在列表页,不在这。 */
claimGate?: ClaimGate;
onRefreshAggregate?: () => void | Promise<void>; onRefreshAggregate?: () => void | Promise<void>;
showToast?: (kind: string, title: string, msg: string) => void; showToast?: (kind: string, title: string, msg: string) => void;
fmtRel?: (d: Date) => string; fmtRel?: (d: Date) => string;
...@@ -977,8 +1069,28 @@ function TopBar({ ...@@ -977,8 +1069,28 @@ function TopBar({
initialFeedback={plan.recallFeedback} initialFeedback={plan.recallFeedback}
initialNote={plan.recallFeedbackNote} initialNote={plan.recallFeedbackNote}
showToast={showToast} showToast={showToast}
claimGate={claimGate}
/> />
)} )}
{/* 认领态徽标(**只读**,不是入口)—— 让"为什么点不动"在点之前就看得见。
认领动作统一收口到列表页,顶栏不放按钮:同一动作两个入口会让状态同步和归属口径都变复杂。 */}
{claimGate && claimGate.claimState !== 'mine' && (
<span
title={
claimGate.claimState === 'unclaimed'
? '未认领 —— 只能查看;在左侧列表点该患者的「认领」接手后才能操作'
: '该召回已被其他同事认领,你只能查看'
}
className={cn(
'inline-flex flex-none items-center rounded-md px-2 py-1 text-[11.5px] font-medium',
claimGate.claimState === 'unclaimed'
? 'bg-amber-50 text-amber-700'
: 'bg-slate-100 text-slate-500',
)}
>
{claimGate.claimState === 'unclaimed' ? '未认领 · 仅查看' : '仅查看'}
</span>
)}
</div> </div>
<div className="flex flex-none items-center gap-1.5 sm:gap-3 text-[11px] text-slate-600"> <div className="flex flex-none items-center gap-1.5 sm:gap-3 text-[11px] text-slate-600">
{/* 数据新鲜度 — 该 plan 版本重算时间;宽屏才显,跟刷新按钮成对 */} {/* 数据新鲜度 — 该 plan 版本重算时间;宽屏才显,跟刷新按钮成对 */}
......
...@@ -144,6 +144,8 @@ export function PatientPickerRail({ ...@@ -144,6 +144,8 @@ export function PatientPickerRail({
await plansApi.assign(planId, sub); await plansApi.assign(planId, sub);
if (view === 'pool') removeItem(planId); if (view === 'pool') removeItem(planId);
else patchItem(planId, { status: 'assigned', assigneeUserId: sub }); else patchItem(planId, { status: 'assigned', assigneeUserId: sub });
// 反向通知详情页:认领入口只在本列表,详情页的操作闸要靠这条即时解锁
usePlanSyncStore.getState().notifyClaim(planId, sub);
toast.success(`已认领 ${name}`); toast.success(`已认领 ${name}`);
} catch (err) { } catch (err) {
toast.error(err instanceof Error ? err.message : '认领失败'); toast.error(err instanceof Error ? err.message : '认领失败');
...@@ -158,6 +160,8 @@ export function PatientPickerRail({ ...@@ -158,6 +160,8 @@ export function PatientPickerRail({
await plansApi.recycle(planId); await plansApi.recycle(planId);
if (view === 'mine') removeItem(planId); if (view === 'mine') removeItem(planId);
else patchItem(planId, { status: 'active', assigneeUserId: null }); else patchItem(planId, { status: 'active', assigneeUserId: null });
// 反向通知详情页:返池后要把闸重新上锁,否则还停在详情页的人仍能操作已交还的单
usePlanSyncStore.getState().notifyClaim(planId, null);
toast.success(`已返池 ${name}`); toast.success(`已返池 ${name}`);
} catch (err) { } catch (err) {
toast.error(err instanceof Error ? err.message : '返池失败'); toast.error(err instanceof Error ? err.message : '返池失败');
......
...@@ -3,8 +3,12 @@ ...@@ -3,8 +3,12 @@
import { create } from 'zustand'; import { create } from 'zustand';
/** /**
* 工作台跨栏同步 — 详情页(右)动作改了 plan 状态后,通知左栏选患者列原地更新。 * 工作台跨栏同步 — 极简事件总线:seq 自增触发订阅;消费方按 planId patch 对应行
* 极简事件总线:seq 自增触发订阅;消费方按 planId patch 对应行(不重拉、不丢滚动)。 * (不重拉、不丢滚动)。**双向**:
* · 详情(右)→ 列表(左):`notify` —— 提交执行后同步行状态 / outcome 角标
* · 列表(左)→ 详情(右):`notifyClaim` —— 认领 / 返池后同步认领人,
* 让详情页的认领闸即时解锁或上锁。认领入口只在列表页,没这条反向通道的话,
* 刚认领完回详情点操作仍会被拦(要等重拉才生效)。
*/ */
interface PlanSyncState { interface PlanSyncState {
seq: number; seq: number;
...@@ -13,7 +17,13 @@ interface PlanSyncState { ...@@ -13,7 +17,13 @@ interface PlanSyncState {
status: string | null; status: string | null;
/** 本次提交的执行 outcome(左栏「成功/不成功/保持」角标即时反映);null = 未变 */ /** 本次提交的执行 outcome(左栏「成功/不成功/保持」角标即时反映);null = 未变 */
outcome: string | null; outcome: string | null;
/** 新认领人(认领=userId / 返池=null)。
* ⚠️ `undefined` = 本次事件跟认领无关,消费方**不要动**自己的认领态 ——
* 不能用 null 表达"无关",那会被当成"已返池"而误上锁。 */
assigneeUserId: string | null | undefined;
notify: (planId: string, status?: string, outcome?: string) => void; notify: (planId: string, status?: string, outcome?: string) => void;
/** 列表页认领 / 返池后调用(反向通道) */
notifyClaim: (planId: string, assigneeUserId: string | null) => void;
/** 当前工作台正在看的患者(详情页 ready 时发布;右下角助手按它出场景化开场建议) */ /** 当前工作台正在看的患者(详情页 ready 时发布;右下角助手按它出场景化开场建议) */
current: { planId: string; patientName: string } | null; current: { planId: string; patientName: string } | null;
setCurrent: (current: { planId: string; patientName: string } | null) => void; setCurrent: (current: { planId: string; patientName: string } | null) => void;
...@@ -24,8 +34,24 @@ export const usePlanSyncStore = create<PlanSyncState>((set) => ({ ...@@ -24,8 +34,24 @@ export const usePlanSyncStore = create<PlanSyncState>((set) => ({
planId: null, planId: null,
status: null, status: null,
outcome: null, outcome: null,
assigneeUserId: undefined,
notify: (planId, status, outcome) => notify: (planId, status, outcome) =>
set((s) => ({ seq: s.seq + 1, planId, status: status ?? null, outcome: outcome ?? null })), set((s) => ({
seq: s.seq + 1,
planId,
status: status ?? null,
outcome: outcome ?? null,
// 执行类事件不表态认领人 → undefined,详情页据此跳过认领态更新
assigneeUserId: undefined,
})),
notifyClaim: (planId, assigneeUserId) =>
set((s) => ({
seq: s.seq + 1,
planId,
status: assigneeUserId ? 'assigned' : 'active',
outcome: null,
assigneeUserId,
})),
current: null, current: null,
setCurrent: (current) => set({ current }), setCurrent: (current) => set({ current }),
})); }));
...@@ -528,10 +528,10 @@ export type ExecutionOutcomeTone = 'emerald' | 'amber' | 'sky' | 'slate' | 'rose ...@@ -528,10 +528,10 @@ export type ExecutionOutcomeTone = 'emerald' | 'amber' | 'sky' | 'slate' | 'rose
/// 用大值而非 Infinity,方便落库 / 算 cutoff;100 年足够覆盖业务生命周期。 /// 用大值而非 Infinity,方便落库 / 算 cutoff;100 年足够覆盖业务生命周期。
export const SUPPRESS_PERMANENT_DAYS = 36500; export const SUPPRESS_PERMANENT_DAYS = 36500;
/// 「弱语义」放弃原因的短抑制档(识别不准 / 已完成治疗):问题出在数据侧不在患者侧, /// ⚠️ 原「弱语义短抑制档」WEAK_SIGNAL_SUPPRESS_DAYS(14d)2026-07 已删除。
/// 一个 DW 同步周期 + 排除闸接手绰绰有余,不该按「患者拒绝」的重档罚人。 /// 它只服务过「已完成治疗」和「机会识别不准确」两个原因,两者现已全部改永久:
/// 介于「再考虑 7d」与「明确拒绝 90d」之间。 /// 该档押的注是「等一个 DW 同步周期 + 排除闸接手」,生产实测证伪(见 ABANDON_REASON_META
export const WEAK_SIGNAL_SUPPRESS_DAYS = 14; /// 对应两条注释)。留着只会引诱后来者把新原因挂上一个已被推翻的假设,故不保留。
/// 熔断(连续未接通累计达上限强制 abandoned)的抑制天数 —— 不是"拒绝",是"暂时联系不上", /// 熔断(连续未接通累计达上限强制 abandoned)的抑制天数 —— 不是"拒绝",是"暂时联系不上",
/// 短冷静期后允许重新进池(后续可接换渠道策略)。 /// 短冷静期后允许重新进池(后续可接换渠道策略)。
...@@ -708,10 +708,22 @@ export const ABANDON_REASON_META: Record< ...@@ -708,10 +708,22 @@ export const ABANDON_REASON_META: Record<
// ── 宿主关闭弹窗(文案按宿主口径)── // ── 宿主关闭弹窗(文案按宿主口径)──
no_intent: { labelZh: '客户无意愿', desc: '客户明确表示没有治疗意愿', shownIn: 'close' }, // 60d no_intent: { labelZh: '客户无意愿', desc: '客户明确表示没有治疗意愿', shownIn: 'close' }, // 60d
inaccurate: { labelZh: '机会识别不准确', desc: '识别的治疗机会与患者实际情况不符', shownIn: 'close', suppressDays: WEAK_SIGNAL_SUPPRESS_DAYS }, // ⭐ 永久(2026-07 由 14d 改):客服断言"PAC 这条召回identify错了"。原 14d 的理由是
// "算法修好后本就该重新召回",但那是**我们**的排期,不该由患者兜着 —— 两周后同一条
// 不准的召回原样弹回来,客服只会再关一次、再骂一次,而算法多半还没改。
// 真要在修复后放回来,应由「改完规则 → 定向重算/解除抑制」这条显式动作驱动,
// 而不是靠一个赌"两周内一定修好"的定时器。
inaccurate: { labelZh: '机会识别不准确', desc: '识别的治疗机会与患者实际情况不符', shownIn: 'close', suppressDays: SUPPRESS_PERMANENT_DAYS },
competitor: { labelZh: '选择竞品机构', desc: '客户已选择或倾向其他机构治疗', shownIn: 'close', suppressDays: SUPPRESS_PERMANENT_DAYS }, competitor: { labelZh: '选择竞品机构', desc: '客户已选择或倾向其他机构治疗', shownIn: 'close', suppressDays: SUPPRESS_PERMANENT_DAYS },
price: { labelZh: '价格因素', desc: '客户因价格原因暂不考虑治疗', shownIn: 'close' }, // 60d price: { labelZh: '价格因素', desc: '客户因价格原因暂不考虑治疗', shownIn: 'close' }, // 60d
treated: { labelZh: '已完成治疗', desc: '客户已完成该机会对应的治疗', shownIn: 'close', suppressDays: WEAK_SIGNAL_SUPPRESS_DAYS }, // ⭐ 永久(2026-07 由 14d 改):客服断言"这个机会的治疗已经做完了" —— 这是**临床事实判断**,
// 不是"暂时不想做",没有理由两周后再问一遍。原 14d 档押的注是「等 DW 同步 + 排除闸接手」,
// 生产实测证伪:缺牙召回里两例(DW 无完成记录 / 义齿在上颌而召回在下颌)排除闸都接不住,
// 到期只会原样弹回来再挨一次投诉。且档位本身不自洽 —— 「已在外院治疗」早就是永久,
// 在别家做完永久闭嘴、在自己家做完两周后再问,说不通。
// 误判不会把患者封死:抑制是**信号级**的(key=scenario|subKey,subKey 含牙位)且带时间锚,
// 结案后**新发**的同类信号照常放行(见 plan-engine.fetchSnoozedSignalKeys)。
treated: { labelZh: '已完成治疗', desc: '客户已完成该机会对应的治疗', shownIn: 'close', suppressDays: SUPPRESS_PERMANENT_DAYS },
unreachable: { labelZh: '无法联系客户', desc: '多次尝试后仍无法联系到客户', shownIn: 'close', suppressDays: 30 }, unreachable: { labelZh: '无法联系客户', desc: '多次尝试后仍无法联系到客户', shownIn: 'close', suppressDays: 30 },
// ── 共用 ── // ── 共用 ──
...@@ -727,6 +739,50 @@ export function abandonReasonsFor(panel: 'form' | 'close'): AbandonReason[] { ...@@ -727,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(异步生成,状态机)
// ============================================================= // =============================================================
......
...@@ -61,6 +61,11 @@ export const ApiCode = { ...@@ -61,6 +61,11 @@ export const ApiCode = {
PLAN_ALREADY_ASSIGNED: 20501, PLAN_ALREADY_ASSIGNED: 20501,
PLAN_ALREADY_COMPLETED: 20502, PLAN_ALREADY_COMPLETED: 20502,
PLAN_BREAKER_TRIPPED: 20503, PLAN_BREAKER_TRIPPED: 20503,
/// 未认领就想操作(提交通话结果 / 关闭机会 / 重生成话术 / 打反馈)—— 先认领再说。
/// 见 plan/claim-guard.ts:执行必须有归属,否则「我的已完成」/ 转化率统计里直接蒸发。
PLAN_NOT_CLAIMED: 20504,
/// 已被**别人**认领 —— 文案要跟"没人认领"区分开,否则组员以为自己没点对。
PLAN_CLAIMED_BY_OTHER: 20505,
// === 3xxxx Third-party / upstream ============================= // === 3xxxx Third-party / upstream =============================
// 308xx — sync engine (host data ingestion) // 308xx — sync engine (host data ingestion)
...@@ -107,6 +112,8 @@ export const API_CODE_MESSAGES: Record<number, string> = { ...@@ -107,6 +112,8 @@ export const API_CODE_MESSAGES: Record<number, string> = {
[ApiCode.PLAN_ALREADY_ASSIGNED]: '计划已被分配', [ApiCode.PLAN_ALREADY_ASSIGNED]: '计划已被分配',
[ApiCode.PLAN_ALREADY_COMPLETED]: '计划已完成,不能再修改', [ApiCode.PLAN_ALREADY_COMPLETED]: '计划已完成,不能再修改',
[ApiCode.PLAN_BREAKER_TRIPPED]: '计划联系次数超限,已熔断', [ApiCode.PLAN_BREAKER_TRIPPED]: '计划联系次数超限,已熔断',
[ApiCode.PLAN_NOT_CLAIMED]: '请先认领后再操作',
[ApiCode.PLAN_CLAIMED_BY_OTHER]: '该计划已被其他同事认领,你无法操作',
[ApiCode.SYNC_HOST_UNREACHABLE]: '宿主接口不可达', [ApiCode.SYNC_HOST_UNREACHABLE]: '宿主接口不可达',
[ApiCode.SYNC_CONTRACT_DRIFT]: '宿主字段映射漂移', [ApiCode.SYNC_CONTRACT_DRIFT]: '宿主字段映射漂移',
[ApiCode.SYNC_HOST_AUTH_FAILED]: '宿主登录失败 / 凭据失效', [ApiCode.SYNC_HOST_AUTH_FAILED]: '宿主登录失败 / 凭据失效',
......
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