Commit e3d16433 by luoqi

merge: fix/age-aware-treatment-goal → main(召回侧一组修复)

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

含 DB 迁移:20260724091507_add_plan_event_log
已在测试服务器(47.251.104.47)部署验证通过。
parents f3d7ae77 3fd9af36
-- 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 / 同步账本
// ============================================================= // =============================================================
......
...@@ -15,6 +15,7 @@ import { smartDateDisplay, toothFriendly } from './script-facts'; ...@@ -15,6 +15,7 @@ import { smartDateDisplay, toothFriendly } from './script-facts';
import { resolveDiseaseLabel } from './disease-knowledge'; import { resolveDiseaseLabel } from './disease-knowledge';
import { deidentifyDoctor } from './pii'; import { deidentifyDoctor } from './pii';
import { AGENT_IDENTITY_PLACEHOLDER } from './agent-identity'; import { AGENT_IDENTITY_PLACEHOLDER } from './agent-identity';
import { DENTURE_FIRST_AGE } from '@pac/types';
export function buildRichFactBlock(input: DraftPlanScriptInput): string { export function buildRichFactBlock(input: DraftPlanScriptInput): string {
const { patient, clinicName, plan, clinicalContext } = input; const { patient, clinicName, plan, clinicalContext } = input;
...@@ -68,6 +69,10 @@ export function buildRichFactBlock(input: DraftPlanScriptInput): string { ...@@ -68,6 +69,10 @@ export function buildRichFactBlock(input: DraftPlanScriptInput): string {
clinicalContext.daysSinceLastVisit ?? '未知' clinicalContext.daysSinceLastVisit ?? '未知'
} `; } `;
const noXray = patient.age == null || patient.age <= 18; const noXray = patient.age == null || patient.age <= 18;
// 高龄介绍顺序:活动义齿在前、种植并行 —— 高龄**不是种植禁忌**(业务方口径:
// 70-80 能否种植看身体条件与患者意愿,由医生评估)。PAC 只调整介绍先后,不替医生下结论。
// 口径与召回建议治疗同源(DENTURE_FIRST_AGE)。
const dentureFirst = patient.age != null && patient.age > DENTURE_FIRST_AGE;
return `# 本次回访患者信息(只能用以下事实,不要编造或推断额外信息;下面是朴素事实,自然取用即可) return `# 本次回访患者信息(只能用以下事实,不要编造或推断额外信息;下面是朴素事实,自然取用即可)
...@@ -92,7 +97,7 @@ ${others.length ? `\n## 其他可一并关心的问题(以本次聚焦为主,自 ...@@ -92,7 +97,7 @@ ${others.length ? `\n## 其他可一并关心的问题(以本次聚焦为主,自
## 患者 ## 患者
- ${basics} - ${basics}
- 熟络度:${relationSignal}(语气怎么拿捏见沟通知识,你按这信号判断)${noXray ? '\n\n## 安全(硬约束)\n- 本患者未满 18 岁或年龄未知:**整篇严禁出现"拍片/拍个片/X光/牙片"等任何拍片表述**' : ''}`; - 熟络度:${relationSignal}(语气怎么拿捏见沟通知识,你按这信号判断)${noXray ? '\n\n## 安全(硬约束)\n- 本患者未满 18 岁或年龄未知:**整篇严禁出现"拍片/拍个片/X光/牙片"等任何拍片表述**' : ''}${dentureFirst ? `\n\n## 高龄沟通(约束)\n- 本患者 ${patient.age} 岁:缺牙修复**先讲活动义齿**(创伤小、周期短),种植可作为并行选项一起提,\n 但**不要主推手术、不要承诺能不能种** —— 统一落到"来院让医生按身体条件评估";措辞更耐心,可提示家属陪同。` : ''}`;
} }
/** /**
......
...@@ -67,7 +67,7 @@ export class DeepPlanCall implements AiCall<ScriptContext, DeepPlanZ> { ...@@ -67,7 +67,7 @@ export class DeepPlanCall implements AiCall<ScriptContext, DeepPlanZ> {
export class DeepWriteCall implements AiCall<DeepWriteInput, DeepWriteZ> { export class DeepWriteCall implements AiCall<DeepWriteInput, DeepWriteZ> {
readonly kind = 'script' as const; readonly kind = 'script' as const;
readonly callKey = 'draft_plan_script_write'; readonly callKey = 'draft_plan_script_write';
readonly promptVersion = 'draft_plan_script@2026-06-16-deep-write-v16'; // v16: 自报家门去人名 → 占位【回访客服】(渲染期按登录人回填,修串名 bug)。v15: 去掉钦定段式(给自由度);v14: 禁内部代码/术语(K08等)说大白话 readonly promptVersion = 'draft_plan_script@2026-06-16-deep-write-v17'; // v17: 高龄硬约束(≥75 缺牙禁推种植/手术)。v16: 自报家门去人名 → 占位【回访客服】(渲染期按登录人回填,修串名 bug)。v15: 去掉钦定段式(给自由度);v14: 禁内部代码/术语(K08等)说大白话
readonly defaultModelId = 'deepseek-v4-flash'; readonly defaultModelId = 'deepseek-v4-flash';
readonly outputSchema = DeepWriteSchema; readonly outputSchema = DeepWriteSchema;
constructor(private readonly skillRegistry: DraftPlanScriptSkillRegistry) {} constructor(private readonly skillRegistry: DraftPlanScriptSkillRegistry) {}
......
...@@ -4,6 +4,7 @@ import { resolveDisease } from './phrasing'; ...@@ -4,6 +4,7 @@ import { resolveDisease } from './phrasing';
import { deidentifyDoctor } from '../../shared/pii'; import { deidentifyDoctor } from '../../shared/pii';
import { renderTreatmentPlan, buildPersonaGuide } from '../../shared/fact-block'; import { renderTreatmentPlan, buildPersonaGuide } from '../../shared/fact-block';
import { AGENT_IDENTITY_PLACEHOLDER } from '../../shared/agent-identity'; import { AGENT_IDENTITY_PLACEHOLDER } from '../../shared/agent-identity';
import { DENTURE_FIRST_AGE } from '@pac/types';
/** /**
* Prompt 版本管理约定: * Prompt 版本管理约定:
...@@ -105,6 +106,8 @@ export function buildDraftPlanScriptPrompt(input: DraftPlanScriptInput): string ...@@ -105,6 +106,8 @@ export function buildDraftPlanScriptPrompt(input: DraftPlanScriptInput): string
} (语气怎么拿捏见沟通知识,你按这信号判断)`; } (语气怎么拿捏见沟通知识,你按这信号判断)`;
// ≤18 / 年龄未知 → 禁拍片 belt(青少年走成人模板时,成人模板含"拍片"句,这里硬提醒删) // ≤18 / 年龄未知 → 禁拍片 belt(青少年走成人模板时,成人模板含"拍片"句,这里硬提醒删)
const noXray = patient.age == null || patient.age <= 18; const noXray = patient.age == null || patient.age <= 18;
// 高龄介绍顺序(同 fact-block,口径共用 DENTURE_FIRST_AGE):义齿在前、种植并行,不下禁忌结论。
const dentureFirst = patient.age != null && patient.age > DENTURE_FIRST_AGE;
// 自报家门:姓名**不进 prompt** —— 给 【回访客服】 占位符,LLM 原样输出,读详情时按当前登录客服回填。 // 自报家门:姓名**不进 prompt** —— 给 【回访客服】 占位符,LLM 原样输出,读详情时按当前登录客服回填。
// (话术缓存是 per-plan、召回池共享,烤进人名会让后开的客服读到别人的名字。见 agent-identity.ts) // (话术缓存是 per-plan、召回池共享,烤进人名会让后开的客服读到别人的名字。见 agent-identity.ts)
...@@ -142,5 +145,5 @@ ${advLines} ...@@ -142,5 +145,5 @@ ${advLines}
- ${basics} - ${basics}
## 语气 ## 语气
- ${toneHint}${noXray ? '\n\n## 安全(硬约束)\n- 本患者未满 18 岁或年龄未知:**整篇严禁出现"拍片/拍个片/X光/牙片"等任何拍片表述**(删除模板里的拍片句)' : ''}${persona ? `\n\n${persona}` : ''}`; - ${toneHint}${noXray ? '\n\n## 安全(硬约束)\n- 本患者未满 18 岁或年龄未知:**整篇严禁出现"拍片/拍个片/X光/牙片"等任何拍片表述**(删除模板里的拍片句)' : ''}${dentureFirst ? `\n\n## 高龄沟通(约束)\n- 本患者 ${patient.age} 岁:缺牙修复**先讲活动义齿**,种植可并行提及,\n 但**不要主推手术、不要承诺能不能种** —— 落到"来院让医生按身体条件评估";措辞更耐心,可提示家属陪同。` : ''}${persona ? `\n\n${persona}` : ''}`;
} }
...@@ -65,7 +65,7 @@ export function stableTemplateFallback(input: DraftPlanScriptInput): DraftPlanSc ...@@ -65,7 +65,7 @@ export function stableTemplateFallback(input: DraftPlanScriptInput): DraftPlanSc
* 改 system/prompt 文本 → bump 字母;改 schema → bump 日期。 * 改 system/prompt 文本 → bump 字母;改 schema → bump 日期。
*/ */
const DRAFT_PLAN_SCRIPT_PROMPT_VERSION = const DRAFT_PLAN_SCRIPT_PROMPT_VERSION =
'draft_plan_script@2026-06-08-4module-v28'; // v28: 自报家门去人名 → 占位【回访客服】(生成期占位/渲染期按登录人回填;修「话术缓存 per-plan + 召回池共享 → 后开的客服读到别人名字」线上 bug);input 去 agent 字段,同 plan 不同客服共享 AI 缓存。v27: schema 去硬长度约束(.min/.max → describe 软引导,修 qwen too_small 必失败);v26: 加画像精简版(⚠禁忌/治疗敏感/特别关注 + rfm/生命周期定语气;切入点留深度档,不诱导推销);v24: 治疗计划补 plannedTreatments(treatment_record planned 结构化;原只读常空的 emr.treatment_plan → 话术缺治疗计划);v23: common.md + 人群共性 SKILL 去 brace(原 {应治未治项}/{诊断医生}/{智能称呼} 改朴素措辞;稳健自身句位模板的 {} 不变,填空机制照常,行为基本不变);v22: 新老客改'熟络度'(recency为主+次数为辅,去二分标签,交LLM);v21: 开场日期改锚【最近一次就诊】(原误用诊断日,患者后来又来过会错位)+'来过之后'/告知'之前那次'区分;v20: schema describe 收口(去与模板矛盾的开场顺序/'下周'/负面例,只留段用途+关键约束)+ prompt/兜底 软化'下周'+ 修陈旧注释;v19: stable format.md 精简(去 common/机器闸重复、自查砍到高风险3条)+ 成人模板优化(去重/换标题/软化结束语);v18: base-common 精简合并去重 + 软化治疗方案口径(可点名/不报价不定细化/落点复查);v17: 目录重组(shared/+tiers/stable/)— base 拆 common+format、人群拆共性知识+稳健句位、病种文案归 stable phrasing、安全单一源 safety-rules、composer tier-aware;修开场顺序冲突。v16: 儿童模板复查段修复(删写死"3个月常规涂氟检查"→对齐本次{应治未治项}+用{复查时长},涂氟降级顺带);child SKILL 1.4.0;v15: user prompt 加"医生那次交代"(医嘱/建议/治疗计划,来自聚焦病历,仅引用不演绎);medicalRecord 补 recommendations;v14: user prompt 加 {牙位}(FDI→俗称)+ 本次目标(plan.goal)+ ≤18 禁拍片 belt;占位收口 {牙位}(删 【缺失牙位】);adult/child 模板带牙位;v13: 撤 token,人名去名留称呼(徐女士/韩医生 直接给,非 token);开场白先称呼确认对方再自报家门;v12: user prompt 人名脱敏(称呼/诊断医生/客服 用 token,生成后回填;监护人全名不进 prompt);v11: 统一通话称呼(年龄+性别+监护人,修"9岁张先生");监护人触达提示;医生标签 最后一次就诊→诊断医生;v10: 病种知识走 disease-knowledge 单一访问源(subKey 优先+文本兜底),修 颌骨囊肿 拿不到风险/优势的 bug;v9: 自报家门用登录客服 岗位+姓名(agent);v8: 占位符统一({}=替换、【】=原样保留);v7: 清除 user prompt 污染;v6: 清 system 污染;v5: 还原原模板 'draft_plan_script@2026-06-08-4module-v29'; // v29: 高龄硬约束(≥75 缺牙禁推种植/手术,落活动义齿;口径同召回目标标签 ELDERLY_PROSTHESIS_AGE)。v28: 自报家门去人名 → 占位【回访客服】(生成期占位/渲染期按登录人回填;修「话术缓存 per-plan + 召回池共享 → 后开的客服读到别人名字」线上 bug);input 去 agent 字段,同 plan 不同客服共享 AI 缓存。v27: schema 去硬长度约束(.min/.max → describe 软引导,修 qwen too_small 必失败);v26: 加画像精简版(⚠禁忌/治疗敏感/特别关注 + rfm/生命周期定语气;切入点留深度档,不诱导推销);v24: 治疗计划补 plannedTreatments(treatment_record planned 结构化;原只读常空的 emr.treatment_plan → 话术缺治疗计划);v23: common.md + 人群共性 SKILL 去 brace(原 {应治未治项}/{诊断医生}/{智能称呼} 改朴素措辞;稳健自身句位模板的 {} 不变,填空机制照常,行为基本不变);v22: 新老客改'熟络度'(recency为主+次数为辅,去二分标签,交LLM);v21: 开场日期改锚【最近一次就诊】(原误用诊断日,患者后来又来过会错位)+'来过之后'/告知'之前那次'区分;v20: schema describe 收口(去与模板矛盾的开场顺序/'下周'/负面例,只留段用途+关键约束)+ prompt/兜底 软化'下周'+ 修陈旧注释;v19: stable format.md 精简(去 common/机器闸重复、自查砍到高风险3条)+ 成人模板优化(去重/换标题/软化结束语);v18: base-common 精简合并去重 + 软化治疗方案口径(可点名/不报价不定细化/落点复查);v17: 目录重组(shared/+tiers/stable/)— base 拆 common+format、人群拆共性知识+稳健句位、病种文案归 stable phrasing、安全单一源 safety-rules、composer tier-aware;修开场顺序冲突。v16: 儿童模板复查段修复(删写死"3个月常规涂氟检查"→对齐本次{应治未治项}+用{复查时长},涂氟降级顺带);child SKILL 1.4.0;v15: user prompt 加"医生那次交代"(医嘱/建议/治疗计划,来自聚焦病历,仅引用不演绎);medicalRecord 补 recommendations;v14: user prompt 加 {牙位}(FDI→俗称)+ 本次目标(plan.goal)+ ≤18 禁拍片 belt;占位收口 {牙位}(删 【缺失牙位】);adult/child 模板带牙位;v13: 撤 token,人名去名留称呼(徐女士/韩医生 直接给,非 token);开场白先称呼确认对方再自报家门;v12: user prompt 人名脱敏(称呼/诊断医生/客服 用 token,生成后回填;监护人全名不进 prompt);v11: 统一通话称呼(年龄+性别+监护人,修"9岁张先生");监护人触达提示;医生标签 最后一次就诊→诊断医生;v10: 病种知识走 disease-knowledge 单一访问源(subKey 优先+文本兜底),修 颌骨囊肿 拿不到风险/优势的 bug;v9: 自报家门用登录客服 岗位+姓名(agent);v8: 占位符统一({}=替换、【】=原样保留);v7: 清除 user prompt 污染;v6: 清 system 污染;v5: 还原原模板
@Injectable() @Injectable()
export class DraftPlanScriptCall export class DraftPlanScriptCall
......
...@@ -22,7 +22,7 @@ import { type DeepDraft, draftOutputToDeep } from '../deep/types'; ...@@ -22,7 +22,7 @@ import { type DeepDraft, draftOutputToDeep } from '../deep/types';
* *
* callKey 仍用 'draft_plan_script'(同一逻辑调用),档位差异落 promptVersion → eval 可按版本切档对比。 * callKey 仍用 'draft_plan_script'(同一逻辑调用),档位差异落 promptVersion → eval 可按版本切档对比。
*/ */
const STANDARD_PROMPT_VERSION = 'draft_plan_script@2026-06-08-standard-v15'; // v15: 自报家门去人名 → 占位【回访客服】(渲染期按登录人回填,修串名 bug)。v14: schema 去硬长度/段数约束(.min/.max/.length → describe 软引导);v13: 加画像精简版(⚠禁忌/治疗敏感/特别关注 + rfm/生命周期定语气;切入点留深度档);v11: 病历补全(治疗计划=plannedTreatments 结构化 + 一般情况/处置/诊断说明,对齐页面 emr-soap);v10: format.md 去污染(删 tier 名/去模板对比/见 common.md 等解释性 meta,纯指令);流式输出(段数组 partial 边出边渲染);v9: 真去模板 — 4 固定角色字段 → 自由 sections[];v8: 去 {} 替换占位;v7: format 瘦身 + opening 放开 + closing 软化 const STANDARD_PROMPT_VERSION = 'draft_plan_script@2026-06-08-standard-v16'; // v16: 高龄硬约束(≥75 缺牙禁推种植/手术)。v15: 自报家门去人名 → 占位【回访客服】(渲染期按登录人回填,修串名 bug)。v14: schema 去硬长度/段数约束(.min/.max/.length → describe 软引导);v13: 加画像精简版(⚠禁忌/治疗敏感/特别关注 + rfm/生命周期定语气;切入点留深度档);v11: 病历补全(治疗计划=plannedTreatments 结构化 + 一般情况/处置/诊断说明,对齐页面 emr-soap);v10: format.md 去污染(删 tier 名/去模板对比/见 common.md 等解释性 meta,纯指令);流式输出(段数组 partial 边出边渲染);v9: 真去模板 — 4 固定角色字段 → 自由 sections[];v8: 去 {} 替换占位;v7: format 瘦身 + opening 放开 + closing 软化
@Injectable() @Injectable()
export class StandardScriptCall implements AiCall<DraftPlanScriptInput, DeepDraft> { export class StandardScriptCall implements AiCall<DraftPlanScriptInput, DeepDraft> {
......
...@@ -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,
});
}
}
...@@ -7,6 +7,8 @@ import { ...@@ -7,6 +7,8 @@ import {
diagnosisCodeNameZh, diagnosisCodeNameZh,
treatmentCategoryNameZh, treatmentCategoryNameZh,
treatmentCategoryNameZhForTeeth, treatmentCategoryNameZhForTeeth,
treatmentCategoryNameZhFor,
recommendedCategoriesForAge,
} from '@pac/types'; } from '@pac/types';
import { PrismaService } from '../../../../prisma/prisma.service'; import { PrismaService } from '../../../../prisma/prisma.service';
import type { import type {
...@@ -124,6 +126,11 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin { ...@@ -124,6 +126,11 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
primaryCode: 'K08', // → DiagnosisTreatmentMap.K08(cooldownDays/windowDays/urgencyDayThreshold/categories) primaryCode: 'K08', // → DiagnosisTreatmentMap.K08(cooldownDays/windowDays/urgencyDayThreshold/categories)
label: '缺失牙未启动修复', label: '缺失牙未启动修复',
goal: '邀约启动缺失牙修复(种植/桥/义齿),避免邻牙倾斜 / 对颌伸长', goal: '邀约启动缺失牙修复(种植/桥/义齿),避免邻牙倾斜 / 对颌伸长',
// 高龄版目标(>DENTURE_FIRST_AGE):义齿在前、种植并行 —— 高龄**不是种植禁忌**,
// 能否种植取决于骨量骨质/全身状况/患者意愿,由医生评估;PAC 只调整介绍顺序,不下结论。
// ⚠️ 只改"建议做什么",不改要不要召回 —— 缺牙影响咀嚼营养,高龄同样该管。
goalElderly:
'邀约评估缺牙修复方案(活动义齿为主、种植可并行考虑,由医生按身体条件评估),改善咀嚼、避免邻牙倾斜 / 对颌伸长',
// ⭐ §E gap 修正 flag(乳牙/智齿/正畸减数位/先天缺失剔除)→ 单一真理源 GAP_FLAGS_BY_PRIMARY['K08'] // ⭐ §E gap 修正 flag(乳牙/智齿/正畸减数位/先天缺失剔除)→ 单一真理源 GAP_FLAGS_BY_PRIMARY['K08']
// (potential-treatment-gap.sql),召回与潜在治疗画像共用,口径一致。 // (potential-treatment-gap.sql),召回与潜在治疗画像共用,口径一致。
}, },
...@@ -339,6 +346,8 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin { ...@@ -339,6 +346,8 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
SELECT SELECT
p.id AS patient_id, p.id AS patient_id,
p.external_id AS patient_external_id, p.external_id AS patient_external_id,
-- 建议治疗的年龄适配用(只影响"建议做什么",不参与召回筛选/排除;无生日 → NULL 走默认)
date_part('year', age(${scope.now}::timestamptz, p.birth_date))::int AS patient_age,
sig.id AS signal_fact_id, sig.id AS signal_fact_id,
sig.type AS signal_type, sig.type AS signal_type,
sig.content->>'code' AS signal_code, sig.content->>'code' AS signal_code,
...@@ -456,7 +465,13 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin { ...@@ -456,7 +465,13 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
// lead=建议(即无诊断,仅医生建议)→ 用建议名(如"建议种植")替代子场景诊断词"缺失牙未启动修复"。 // lead=建议(即无诊断,仅医生建议)→ 用建议名(如"建议种植")替代子场景诊断词"缺失牙未启动修复"。
const leadTrig = r.cluster_triggers?.[0] ?? { type: triggerType, code: r.signal_code }; const leadTrig = r.cluster_triggers?.[0] ?? { type: triggerType, code: r.signal_code };
const isRecLead = leadTrig.type === 'recommendation'; const isRecLead = leadTrig.type === 'recommendation';
const catsStr = expectedCats.map((c) => treatmentCategoryNameZhForTeeth(c, r.tooth)).join(' / '); // ⭐ 建议治疗:走**源头**的年龄排序(recommendedCategoriesForAge),此处不做任何年龄分支。
// >70 岁缺牙 → 活动义齿排在种植前(并行保留,不删种植 —— 高龄非种植禁忌,由医生评身体条件)。
// ⚠️ 只影响"建议什么"的顺序与措辞;排除闸仍用完整的 expectedCats(见上方 excludeCats)。
const age = r.patient_age ?? null;
const recCats = recommendedCategoriesForAge(expectedCats, age);
const focusCategory = recCats[0] ?? null;
const catsStr = recCats.map((c) => treatmentCategoryNameZhFor(c, r.tooth, age)).join(' / ');
hits.push({ hits.push({
patientId, patientId,
patientExternalId: r.patient_external_id, patientExternalId: r.patient_external_id,
...@@ -466,7 +481,11 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin { ...@@ -466,7 +481,11 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
? `${diagnosisCodeNameZh(leadTrig.code)}${toothStr}${sourceStr} ${r.days_since} 天前,未启动${catsStr}` ? `${diagnosisCodeNameZh(leadTrig.code)}${toothStr}${sourceStr} ${r.days_since} 天前,未启动${catsStr}`
: `${cfg.label}${toothStr}${diagnosisCodeNameZh(r.signal_code)}${sourceStr} ${r.days_since} 天前,未启动${catsStr}`, : `${cfg.label}${toothStr}${diagnosisCodeNameZh(r.signal_code)}${sourceStr} ${r.days_since} 天前,未启动${catsStr}`,
priorityScore: score, priorityScore: score,
goal: cfg.goal, // 高龄目标文案(义齿在前、种植并行);判据同源头 —— recCats 首项被换成义齿即为高龄路径
goal:
(focusCategory === 'prosthodontic' && expectedCats[0] === 'implant'
? (cfg as { goalElderly?: string }).goalElderly
: null) ?? cfg.goal,
recommendedRole: 'staff', recommendedRole: 'staff',
recommendedChannel: 'phone', recommendedChannel: 'phone',
// recommendedAt 不赋值(留 null):v1 没有"最佳触达时间"算法,全填当天无意义, // recommendedAt 不赋值(留 null):v1 没有"最佳触达时间"算法,全填当天无意义,
...@@ -494,6 +513,11 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin { ...@@ -494,6 +513,11 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
// 不可变锚点:展示层据此实时算天数,跟治疗链断口同源(避免快照随天数陈旧 → "388/389"漂移) // 不可变锚点:展示层据此实时算天数,跟治疗链断口同源(避免快照随天数陈旧 → "388/389"漂移)
signalOccurredAt: r.signal_occurred_at?.toISOString() ?? null, signalOccurredAt: r.signal_occurred_at?.toISOString() ?? null,
expectedCategories: [...expectedCats], expectedCategories: [...expectedCats],
// ⭐ 建议治疗主类目(已按年龄适配)—— 前端「目标 · X」标签据此渲染,
// 不再直接取 expectedCategories[0](那会让 90 岁老人也显示"目标 · 种植")。
focusCategory,
// 生成当时的年龄快照(话术 / 展示按此措辞;null = 无生日不适配)
patientAge: age,
}, },
priorityBreakdown: breakdown, priorityBreakdown: breakdown,
}); });
...@@ -568,6 +592,7 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin { ...@@ -568,6 +592,7 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
interface HitRow { interface HitRow {
patient_id: string; patient_id: string;
patient_external_id: string; patient_external_id: string;
patient_age: number | null; // 无生日 → null(不做年龄适配)
signal_fact_id: string; signal_fact_id: string;
signal_type: string; // 'diagnosis_record' | 'recommendation_record' signal_type: string; // 'diagnosis_record' | 'recommendation_record'
signal_code: string; signal_code: string;
......
...@@ -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 {
DENTURE_FIRST_AGE,
recommendedCategoriesForAge,
treatmentCategoryNameZhFor,
treatmentCategoryNameZhForTeeth,
lookupDxTreatment,
} from '@pac/types';
/**
* 建议治疗的年龄适配回归(源头层:诊断 → 建议治疗的**排序**)。
*
* 背景(2026-07 业务反馈):90 岁 / 78 岁患者的缺牙召回,界面目标一律「目标 · 种植」——
* 因为 UI 取 expectedCategories[0],而 K08 的 categories 首项恒为 implant。
*
* 业务方口径(对齐后):**70-80 不是种植禁忌**,能否种植看骨量骨质 / 全身状况 / 患者意愿,
* 由医生评估。PAC 该做的是**调整推荐顺序**(活动义齿在前、种植并行),不是禁推种植。
*
* 设计边界(三条红线,下面各有用例锁住):
* 1. 只改顺序,不增删类目 —— 返回集合恒等于入参集合(种植不能被抹掉)
* 2. 不碰排除闸(DiagnosisTreatmentMap[].categories 仍是完整候选集)
* 3. 不碰"要不要召回"(缺牙影响咀嚼营养,高龄同样该管)
*/
describe('recommendedCategoriesForAge — 源头:建议治疗排序', () => {
const K08 = lookupDxTreatment('K08')!.categories; // ['implant','prosthodontic']
test('⭐ >70 岁缺牙 → 活动义齿在前,种植并行保留(不是二选一)', () => {
const r = recommendedCategoriesForAge(K08, 90);
expect(r[0]).toBe('prosthodontic'); // 义齿在前
expect(r).toContain('implant'); // ⭐ 种植仍在 —— 高龄非禁忌,只是排后面
expect(r).toHaveLength(K08.length); // 只挪位,不增删
});
test('边界:恰好 70 岁不变(口径是「大于 70」)', () => {
expect(recommendedCategoriesForAge(K08, DENTURE_FIRST_AGE)[0]).toBe('implant');
expect(recommendedCategoriesForAge(K08, DENTURE_FIRST_AGE + 1)[0]).toBe('prosthodontic');
});
test('未达线 / 无年龄 → 原顺序', () => {
expect(recommendedCategoriesForAge(K08, 35)[0]).toBe('implant');
expect(recommendedCategoriesForAge(K08, null)[0]).toBe('implant');
expect(recommendedCategoriesForAge(K08, undefined)[0]).toBe('implant');
});
test('只在「种植 + 义齿」并存时排序;单一类目不受年龄影响', () => {
// 龋齿 90 岁也还是补牙,不该被改写
expect(recommendedCategoriesForAge(['restorative'], 90)).toEqual(['restorative']);
expect(recommendedCategoriesForAge(['endodontic'], 92)).toEqual(['endodontic']);
expect(recommendedCategoriesForAge(['periodontic'], 88)).toEqual(['periodontic']);
});
test('⭐ 红线①:永不增删类目 —— 任何年龄下集合恒等', () => {
for (const age of [null, 30, 70, 71, 90, 120]) {
const r = recommendedCategoriesForAge(K08, age);
expect([...r].sort()).toEqual([...K08].sort());
}
});
test('空候选 → 空', () => {
expect(recommendedCategoriesForAge([], 90)).toEqual([]);
});
});
describe('treatmentCategoryNameZhFor — 展示标签', () => {
test('⭐ >70 岁的 prosthodontic → 「活动义齿」(而非泛化"修复 / 冠桥")', () => {
// 冠桥需邻牙条件,高龄同样受限;说清是活动义齿更贴合实际推荐
expect(treatmentCategoryNameZhFor('prosthodontic', null, 90)).toBe('活动义齿');
});
test('未达线 → 原标签', () => {
expect(treatmentCategoryNameZhFor('prosthodontic', null, 40)).toBe(
treatmentCategoryNameZhForTeeth('prosthodontic', null),
);
});
test('高龄的种植标签不变(仍叫"种植",只是排在义齿后)', () => {
expect(treatmentCategoryNameZhFor('implant', null, 90)).toBe(
treatmentCategoryNameZhForTeeth('implant', null),
);
});
test('乳牙口径不被破坏(既有行为保留)', () => {
expect(treatmentCategoryNameZhFor('restorative', '51;52', 8)).toBe('充填');
expect(treatmentCategoryNameZhFor('restorative', '51;52', null)).toBe('充填');
});
});
describe('组合:高龄缺牙的完整展示串', () => {
test('⭐ 90 岁 → 「活动义齿 / 种植」(并行,义齿在前)', () => {
const K08 = lookupDxTreatment('K08')!.categories;
const s = recommendedCategoriesForAge(K08, 90)
.map((c) => treatmentCategoryNameZhFor(c, null, 90))
.join(' / ');
expect(s).toBe('活动义齿 / 种植');
});
test('40 岁 → 原样(种植在前)', () => {
const K08 = lookupDxTreatment('K08')!.categories;
const s = recommendedCategoriesForAge(K08, 40)
.map((c) => treatmentCategoryNameZhFor(c, null, 40))
.join(' / ');
expect(s.startsWith('种植')).toBe(true);
});
});
describe('红线②:排除闸不受年龄影响', () => {
test('K08 的 categories 仍是完整候选集(做过任一都算已解决)', () => {
// 年龄适配只改"建议",绝不能收窄这个集合 ——
// 否则高龄患者做过种植反而排除不掉(会重复召回)。
const cats = lookupDxTreatment('K08')!.categories;
expect(cats).toContain('implant');
expect(cats).toContain('prosthodontic');
});
});
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,
......
...@@ -275,6 +275,8 @@ export type PlanReason = { ...@@ -275,6 +275,8 @@ export type PlanReason = {
toothPosition?: string | null; toothPosition?: string | null;
daysSince: number; daysSince: number;
expectedCategories: string[]; expectedCategories: string[];
focusCategory?: string | null;
patientAge?: number | null;
} | null; } | null;
scenarioLabel: string; scenarioLabel: string;
priorityScore: number; priorityScore: number;
...@@ -319,6 +321,8 @@ export const mockPlan = { ...@@ -319,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"角标
......
...@@ -89,6 +89,10 @@ export type PlanDetailData = { ...@@ -89,6 +89,10 @@ export type PlanDetailData = {
toothPosition?: string | null; toothPosition?: string | null;
daysSince: number; daysSince: number;
expectedCategories: string[]; expectedCategories: string[];
/** 按年龄适配后的建议治疗主类目;旧 plan 无此字段 → 回落 expectedCategories[0] */
focusCategory?: string | null;
/** plan 生成时的年龄快照(无生日 → null);展示按此措辞 */
patientAge?: number | null;
} | null; } | null;
priorityScore: number; priorityScore: number;
reason: string; reason: string;
......
...@@ -4,7 +4,8 @@ import { ...@@ -4,7 +4,8 @@ import {
subLabelZh, subLabelZh,
triggerTypeLabelZh, triggerTypeLabelZh,
diagnosisCodeNameZh, diagnosisCodeNameZh,
treatmentCategoryNameZhForTeeth, treatmentCategoryNameZhFor,
recommendedCategoriesForAge,
} from '@pac/types'; } from '@pac/types';
import { formatToothPosition, formatDaysReadable } from '@/lib/utils'; import { formatToothPosition, formatDaysReadable } from '@/lib/utils';
...@@ -27,6 +28,10 @@ export interface ReasonLineInput { ...@@ -27,6 +28,10 @@ export interface ReasonLineInput {
toothPosition?: string | null; toothPosition?: string | null;
daysSince: number; daysSince: number;
expectedCategories: string[]; expectedCategories: string[];
/** 按年龄适配后的建议治疗主类目;旧 plan 无此字段 → 回落 expectedCategories[0] */
focusCategory?: string | null;
/** plan 生成时的年龄快照(无生日 → null);展示按此措辞 */
patientAge?: number | null;
} | null; } | null;
} }
...@@ -49,9 +54,11 @@ export function ReasonLine({ reason }: { reason: ReasonLineInput }) { ...@@ -49,9 +54,11 @@ export function ReasonLine({ reason }: { reason: ReasonLineInput }) {
const isRecLead = trig?.type === 'recommendation'; const isRecLead = trig?.type === 'recommendation';
const subLabel = const subLabel =
isRecLead && trig?.code ? diagnosisCodeNameZh(trig.code) : subLabelZh(reason.scenario, s.subKey); isRecLead && trig?.code ? diagnosisCodeNameZh(trig.code) : subLabelZh(reason.scenario, s.subKey);
// 乳牙 restorative 只显「充填」(去嵌体)— 共享口径 treatmentCategoryNameZhForTeeth(前后端一致) // 建议治疗:走**源头**的年龄排序(与后端同一个 recommendedCategoriesForAge),
const cats = s.expectedCategories // >70 岁缺牙 → 「活动义齿 / 种植」(义齿在前,种植并行保留 —— 高龄非种植禁忌);
.map((c) => treatmentCategoryNameZhForTeeth(c, s.toothPosition)) // 乳牙 restorative → 「充填」(去嵌体)。旧 plan 无 patientAge → 原顺序,行为不变。
const cats = recommendedCategoriesForAge(s.expectedCategories, s.patientAge ?? null)
.map((c) => treatmentCategoryNameZhFor(c, s.toothPosition, s.patientAge ?? null))
.join(' / '); .join(' / ');
return ( return (
<span> <span>
......
...@@ -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 }),
})); }));
...@@ -740,6 +740,66 @@ export function treatmentCategoryNameZhForTeeth( ...@@ -740,6 +740,66 @@ export function treatmentCategoryNameZhForTeeth(
} }
// ============================================================= // =============================================================
// 建议治疗的年龄适配(源头:诊断 → 建议治疗的**排序**;不改排除闸、不改是否召回)
// =============================================================
/**
* 「活动义齿优先」年龄线 —— **> 该年龄**时,缺牙修复的建议顺序把活动义齿提到种植之前。
*
* 临床口径(2026-07 与业务方对齐):
* - 70-80 岁**不是种植禁忌** —— 能不能种要看骨量骨质、全身状况与患者意愿,由医生评估;
* - 但高龄群体整体上创伤更小、周期更短的活动义齿更稳妥,**推荐时应先讲义齿**;
* - 两者**并行提供**,不替患者做决定,也不替医生下禁忌结论。
*/
export const DENTURE_FIRST_AGE = 70;
/**
* 诊断 → 建议治疗类目(**按年龄排序**)—— 建议侧的单一真理源。
*
* ⚠️ 边界:只调整**顺序**,不增删类目 —— 返回集合恒等于入参集合。
* 排除闸(`DiagnosisTreatmentMap[].categories`:做过其中任一即算已解决)完全不受影响。
*
* 规则:候选同时含 implant + prosthodontic(缺牙 K08 这类"种植或义齿"并行可选)时,
* 年龄 > DENTURE_FIRST_AGE → prosthodontic 提到 implant 之前(义齿在前,种植并行保留);
* 其余情况原样返回。
*
* @param categories 该诊断的候选治疗类目
* @param age 患者年龄;null/undefined(无生日)→ 不排序,原样返回
*/
export function recommendedCategoriesForAge(
categories: readonly string[],
age: number | null | undefined,
): readonly string[] {
const dentureFirst =
age != null &&
age > DENTURE_FIRST_AGE &&
categories.includes('implant') &&
categories.includes('prosthodontic');
if (!dentureFirst) return categories;
// 只挪位:prosthodontic 提到最前,其余保持原相对顺序(种植仍在,作并行选项)
return [
'prosthodontic',
...categories.filter((c) => c !== 'prosthodontic'),
];
}
/**
* 建议治疗的中文标签(年龄 + 牙位双适配)—— 前后端共用,保证目标标签 / 理由 / 话术一致。
* 高龄的 prosthodontic 明确说「活动义齿」(而非泛化的"修复 / 冠桥" —— 冠桥需邻牙条件,
* 高龄同样受限,说清是活动义齿更贴合实际推荐)。
*/
export function treatmentCategoryNameZhFor(
category: string,
toothPosition: string | null | undefined,
age: number | null | undefined,
): string {
if (category === 'prosthodontic' && age != null && age > DENTURE_FIRST_AGE) {
return '活动义齿';
}
return treatmentCategoryNameZhForTeeth(category, toothPosition);
}
// =============================================================
// scenario 子规则 / 触发类型 → 中文标签(前后端共用) // scenario 子规则 / 触发类型 → 中文标签(前后端共用)
// ============================================================= // =============================================================
......
...@@ -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(异步生成,状态机)
// ============================================================= // =============================================================
......
...@@ -48,6 +48,18 @@ export const ReasonSignalsSchema = z.object({ ...@@ -48,6 +48,18 @@ export const ReasonSignalsSchema = z.object({
/// - 召回类:期望启动哪类治疗(种植 / 充填 / 牙周基础 / 根管) /// - 召回类:期望启动哪类治疗(种植 / 充填 / 牙周基础 / 根管)
/// - 沉睡激活:可能是 [recall_visit] 等 /// - 沉睡激活:可能是 [recall_visit] 等
expectedCategories: z.array(z.string()), expectedCategories: z.array(z.string()),
/// 建议治疗**主类目**(已按患者年龄适配)—— 展示层「目标 · X」标签据此渲染。
/// 为什么不直接取 expectedCategories[0]:那是排除闸的完整候选集(做过其中任一都算已解决),
/// 首项恒为 implant,会让高龄患者也显示「目标 · 种植」(临床不合适,业务实测反弹)。
/// 本字段由 scenario 用 primaryTreatmentCategoryForAge 算好;
/// optional/nullable:旧 plan 无此字段 → 展示层回落 expectedCategories[0](无回归)。
focusCategory: z.string().nullable().optional(),
/// plan 生成时的患者年龄快照(无生日 → null)。
/// 用途:展示 / 话术按年龄措辞(如高龄缺牙说「活动义齿」而非「种植」)。
/// 存快照而非实时算:与 focusCategory 同源同刻,避免生日跨阈值后标签与理由文案打架。
patientAge: z.number().int().nullable().optional(),
}); });
export type ReasonSignals = z.infer<typeof ReasonSignalsSchema>; export type ReasonSignals = z.infer<typeof ReasonSignalsSchema>;
......
...@@ -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