Commit 36686f66 by luoqi

W4: 治疗链 5 阶段 + AI 话术 + DB 持久化 + 真实诊所多 brand 接入

主要工作(自 W3 末快照以来):

数据层(canonical-fact-layer):
- 治疗链 5 阶段模型(chain-composer S1/S2/S3/S4/S5)+ TreatmentMilestones + TreatmentLifecycles 字典
- alternative-closed 闸:同位置后续替代治疗覆盖原诊断 → 标 closed
- 同 (category, code) 桶按 tooth set overlap 合并(union-find);wholeMouth 桶 S2 修正
- chain.target = SQL 为准(按 plan_reasons.signals.triggers 对齐,plan-aggregate 注入)
- diagnosis name_zh 末尾标点清理;cooldown 内不标 ★ 潜在新链

召回算法:
- DiagnosisTreatmentMap K00-K09 全覆盖(加 K00/K01/K03/K06/K07/K09)
- treatment_initiation_recall 10 个 sub-scenarios + 配套 *_RECOMMENDED 推荐码
- 移除 INTAKE_MAX_DAYS 上界;scenario SQL 加预约排除(任何 sig 后预约即排)
- S2 改用预约主诉类别;S2 fallback 显示 planned 治疗

画像 Persona:
- treatment_chain_status feature 直接复用 ChainComposerService
- value/recall_risk/dnc feature 切到独立 fact_type(v2.1)
- status: in ['active', 'fulfilled'] 加载兼容已完成 actual

Plan 详情聚合:
- /plans/:id/full 接 personas + chains + facts + 话术
- script 从 plan_scripts DB 加载,markdown 反 parse 4 段 sections,前端零适配
- 列表页搜索/过滤改服务端(W3 末)

AI 话术(B 方案 重写):
- DeepSeek V4 Pro → Flash 切换(call defaultModelId + config defaultModel)
- schema 4 段 markdown 字符串(opening/followup/objection/close)对齐前端 mock
- prompt @2026-05-24-d:few-shot demo + / 反例 + 事实约束硬要求
- 事实漂移防护:诊所名 JVS_DW_CLINIC_NAMES 字典翻译、牙位 FDI→俗称、
  主诊医生从 facts 抽、通话称呼 nameSpokenForm(姓+先生/女士)
- scenario 内部 label 禁外露,opening 必须用临床事实开场

鉴权:
- A 方案 refresh token 真实实现(Redis jti rotation,无 host SSO 回调)
- 详情页电话查看 icon + reveal 接口

UI:
- 详情页 TopBar 跟列表页 PageHeader 风格统一
- 治疗链 5 阶段词表(chainStatusVisual)
- 闭环链去"建议下一步" + 闭环时间
- WhyCard 过滤 alternative-closed reasons

数据源接入:
- 5 家试点诊所 JVS DW 实接(瑞尔/瑞泰双 brand,tenant_id 路由)
- yaml transforms(Layer A.5)6 operator 白名单:split/route/derive/filter/project/pick_first_nonzero
- 实测 5000 患者 cohort,408K facts / 2207 plans / 0 failed

待办:
- task #46 cold-import --reparse mode(yaml 改后强制重 parse)
- EMR.treat_plan 语义错位(标记为 planned 实际是 actual);EMR.plan 字段未消费
- 双源 actual 去重(EMR vs settlement)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
parent 30196953
......@@ -27,3 +27,5 @@ pnpm-debug.log*
*.tsbuildinfo
prisma/migrations/dev
.history
# appointment — 全部预约(含未到诊)
# 消费 transforms 输出表 appointment_all_rows(等同 source fact_appointment_out,project 直通)
# 消费 transforms 输出表 appointment_all_rows(derive 拼好精确预约时刻 scheduled_at_ts)
canonical: appointment
emits:
......@@ -15,21 +15,30 @@ field_mapping:
externalId: id
patientExternalId: patient_id
clinicId: organization_id
scheduledAt: appo_time
scheduledAt: scheduled_at_ts # transforms 拼好的精确时刻(日期 + 起始时分)
arrivedAt: in_time
appointmentType: appo_type
# ⚠️ appointmentType 不映射 appo_type:appo_type 实际值是"复诊/初诊"(=visit_category),
# 不是 PAC appointment_type 通用语义"预约目的"(咨询/治疗/检查/拔牙等);
# 且"初诊/复诊"是衍生信号,PAC 自己从 encounter_record 聚合算更可信(单一源 = PAC)。
doctorId: appo_doc_id
status: appo_status
complaintCategory: appo_complaint_category # 预约科目 / 就诊意向(种植/正畸/…)
complaintText: appo_complaint # 预约主诉自由文本(跟 category 配对)
durationMinutes: appo_time_period # 预约时长(分钟)
cancellationReason: cancel_reason # 取消原因(自由文本:临时有事/再约/去了其他诊所…)
staffNotes: appo_remark # 预约备注(运营内部 / Layer C 备料)
# host appo_status:1=正常预约 / 2=已改约 / 3=已取消 / 4=到诊 / 5=接诊 / 6=结算 /
# 8=walk in变更 / 9=爽约
# host appo_status → PAC 8 态(1:1 细分,不再把已改约/结算都塞 scheduled/arrived):
# 1=正常→scheduled(唯一有效预约)/ 2=已改约→rescheduled(历史被取代,非有效)/ 3=已取消→cancelled
# 4=到诊→arrived / 5=接诊→in_treatment / 6=结算→completed(到诊+消费完成)
# 8=walk in 变更→walk_in / 9=爽约→no_show
enum_mapping:
status:
"1": scheduled
"2": scheduled
"2": rescheduled
"3": cancelled
"4": arrived
"5": arrived
"6": arrived
"8": scheduled # walk in 改约,等同新建
"9": no_show # 爽约
"5": in_treatment
"6": completed
"8": walk_in
"9": no_show
......@@ -24,6 +24,8 @@ field_mapping:
stdCodeRaw: std_code # 溯源:判 code_source(std_code / name_map / null)
name: message # 原始中文名(展示 + LLM 输入)
toothPosition: tooth_position
doctorId: user_id # 诊断医生(从 emr 父级继承)
doctorName: doctor_name # 诊断医生名(快照)
# ── code 翻译 ──
# ① ICD K 大类直通(已 substring 的 stdCode);② 中文标准术语 → K 码白名单(草案,⚠️ 待牙医 review)
......@@ -101,5 +103,155 @@ enum_mapping:
缺失牙: K08
牙列缺损: K08
牙列缺失: K08
后天性牙齿缺失: K08
全口牙列缺失: K08
上颌牙列缺损: K08
下颌牙列缺损: K08
废用牙: K08 # 临床功能性缺失
无功能牙: K08
# ── W3 末扩 K00-K08 全覆盖(源 DW 数据驱动,见 dw-data-source-issues #15)──
# K00 牙发育 / 萌出障碍 — 只收"真病种",不收"乳牙列 / 混合牙列"等正常生理态(走 _default 不入诊断)
乳牙滞留: K00
滞留乳牙: K00
乳牙早失: K00
乳牙松动: K00 # 替换期非自然脱落
替换期乳牙松动: K00
松动乳牙: K00
多生牙: K00
额外牙: K00
埋伏多生牙: K00
根方多生牙: K00
间多生牙: K00
腭侧多生牙: K00
前牙区埋伏多生牙: K00
先天缺失: K00
先天缺牙: K00
# ⭐ "牙齿缺少" 实际是 host 通用术语,大多用于成人后天缺失(临床路径=种植/修复,跟 K08 完全一致)
# 之前误归 K00 导致陆伟根(62 岁)等成人患者出现"先天/萌出处置"链 — 不合临床
# 改归 K08 — 跟 "牙缺失"/"后天性牙齿缺失" 同处理
牙齿缺少: K08
釉质发育不全: K00 # = K00.2 釉质形成
釉质发育不良: K00
牙釉质发育不全: K00
牙釉质发育不良: K00
氟斑牙: K00 # K00.3 牙齿氟中毒着色
四环素牙: K00 # K00.3 内源性着色
迟萌: K00 # K00.6 萌出障碍
恒牙迟萌: K00
萌出迟缓: K00
萌出不全: K00
异位萌出: K00
萌出间隙不足: K00
部分萌出: K00
未萌出: K00
牙萌出障碍: K00
牙萌出延迟: K00
# K01 阻生 / 埋伏(补 host 真实用词)
智齿: K01 # host 大量直接录"智齿"作为诊断,实际是阻生齿
阻生智齿: K01
近中阻生: K01
垂直位阻生齿: K01
垂直位智齿: K01
异位阻生: K01
# K03 牙体硬组织其他(补复合 / 不良修复体 / 牙齿磨耗)
牙齿楔状缺损: K03
牙体磨耗: K03
牙磨耗: K03
重度磨耗: K03
不良修复体: K03 # 已存在 host 数据,主要走 restorative / prosthodontic 重做
不良充填体: K03
死髓牙: K03 # 牙体已失活,临床走 prosthodontic 桩冠 / surgical 拔除
修复体脱落: K03
充填脱落: K03
冠折: K03
简单冠折: K03
复杂冠折: K03
根折: K03
牙外伤: K03
脱矿: K03 # 早期牙齿硬组织脱矿
牙本质敏感: K03
# K05 牙周 / 牙龈(补 host 真实高频用词)
牙周病: K05
全口慢性牙周炎: K05
萌出性龈炎: K05 # 实际牙龈炎,临床路径同 K05
牙菌斑堆积: K05
# K06 牙龈 / 牙槽嵴疾患(原本走 _default → code=null,W3 末归 K06)
牙周脓肿: K06
慢性牙周脓肿: K06
急性牙周脓肿: K06
冠周炎: K06 # K05.2 / K06 边缘,临床归 K06 (智齿冠周特化)
智齿冠周炎: K06
急性冠周炎: K06
慢性冠周炎: K06
牙龈萎缩: K06
龈萎缩: K06
牙龈增生: K06
龈增生: K06
药物性牙龈增生: K06
牙龈瘤: K06
龈瘤: K06
龈息肉: K06
根分叉病变: K06 # K05.x 并发,但临床上独立标
重度根分叉病变: K06
食物嵌塞: K06 # ICD 实际归 K07.6,但临床走 periodontic 处置 → 归 K06 路径
附着丧失: K06
附着龈缺如: K06
牙槽骨缺损: K06
牙槽骨骨尖: K06
牙槽嵴: K06
唇系带附着低: K06
唇系带附着过低: K06
唇系带附丽过低: K06
唇系带附着异常: K06
上唇系带附着低: K06
上唇系带附着异常: K06
舌系带过短: K06
舌系带短: K06
舌系带附着低: K06
舌系带附着异常: K06
舌系带附着过前: K06
系带异常: K06
# K09 牙发生囊肿(根尖囊肿/含牙囊肿/颌骨囊肿)— DW 真实数据 ~170 条,临床必摘除
根尖囊肿: K09
根尖周囊肿: K09
慢性根尖囊肿: K09
含牙囊肿: K09
颌骨囊肿: K09
上颌骨囊肿: K09
下颌骨囊肿: K09
牙源性囊肿: K09
# K07 错颌畸形(补"合"字版本 + 安氏分类异写)
错合畸形: K07 # host 用"合"而非"颌"也很多
错合畸形安氏Ⅰ类: K07
错合畸形安氏Ⅱ类: K07
错合畸形安氏Ⅲ类: K07
错合畸形安氏II类: K07 # 阿拉伯数字版本
错合畸形安氏III类: K07
安氏I类: K07
安氏II类: K07
安氏III类: K07
安氏Ⅰ类: K07
安氏Ⅱ类: K07
安氏Ⅲ类: K07
前牙反合: K07
前牙反颌: K07
深覆合: K07
深覆颌: K07
牙位置异常: K07
骨性I类: K07
骨性II类: K07
骨性III类: K07
骨性Ⅰ类: K07
骨性Ⅱ类: K07
骨性Ⅲ类: K07
# 兜底:翻不出 → 空 → parser 落 code=null(不丢 fact)
_default: ""
......@@ -18,3 +18,13 @@ field_mapping:
clinicId: organization_id
submittedAt: rq
encounterExternalId: registration_id
doctorId: user_id # 医生 id(与 settlement.doctor_id 同源 99.7%)
doctorName: doctor_name # 接诊医生名(快照)
# SOAP 自由文本段落(之前漏映射 → emr_record content 全 null,Layer C 无源)
illnessDesc: illness_desc # 主诉 / 现病史
preIllness: pre_illness # 现病史
pastHistory: past_hist # 既往史
generalCondition: gen_cond # 全身情况
examFindings: examine # 检查所见(JSON 原文,带牙位)
disposal: dispose # 处置(JSON 原文)
doctorAdvice: doc_order # 医嘱
......@@ -17,4 +17,6 @@ field_mapping:
clinicId: organization_id
startedAt: in_time
doctorId: appo_doc_id
notes: appo_complaint
# 主诉:归 chief_complaint 字段(原 notes 装的就是主诉,纠正命名)。
# notes 字段保留为通用备注,jvs-dw 暂无对应留 null;encounter_type 同理(host 无结构化)。
chiefComplaint: appo_complaint
# image — 影像 metadata(EMR.file_url 拆行而来,PAC 不持文件本体)
# 消费 transforms 输出表 image_rows(split_json_array file_url + derive external_id)
canonical: image
emits:
action: image_uploaded
subjectType: image
occurredAtField: uploadedAt
primary:
table: image_rows
key: image_external_id
dedup_by: image_external_id # 同一影像跨 emr 行重复兜底
field_mapping:
externalId: image_external_id
patientExternalId: patient_id
clinicId: organization_id
uploadedAt: captured_at
encounterExternalId: registration_id
modality: check_name
doctorId: user_id # 接诊医生(从 emr 父级继承,影像拍摄/解读医生)
doctorName: doctor_name
enum_mapping:
# host check_name → PAC modality 闭集(pa/bw/pano/cbct/intraoral_photo/scan/other)
# 真影像归对应类型;化验/表单/记录类(血常规/牙周大表/登记表…)无影像语义,归 other
modality:
"口内像照片": intraoral_photo
"面相照": intraoral_photo
"内窥镜": intraoral_photo
"小牙片": pa
"全景片/头颅正侧位片截图": pano
"全景片/侧位片/其他X线影像": pano
"CT影像截图": cbct
"口腔扫描": scan
"其他类型资料(非X影像)": other
"牙周大表": other
"血常规": other
"拔牙术前清单": other
"全身检查": other
"凝血四项": other
"术前讨论记录": other
"个人健康状况登记表": other
"分析结果": other
_default: other
......@@ -7,9 +7,10 @@ primary:
key: patient_id
field_mapping:
externalId: patient_id # Int64 → ColdImportService 入库时 String 化
name: client_name
gender: client_gender
birthDate: birthday
notes: follow_content
externalId: patient_id # Int64 → ColdImportService 入库时 String 化
name: client_name
gender: client_gender
birthDate: birthday
medicalRecordNumber: file_num # 病历号(辅助标识,客服沟通用)
notes: follow_content
# doNotContact / deceased 不映射 — 走 PatientCanonicalSchema default false
......@@ -15,7 +15,18 @@ field_mapping:
externalId: id
patientExternalId: patient_id
clinicId: organization_id
paidAt: rq
amount: settlement_money # FieldMapper.normalize 按 amount_unit=yuan 转 cents
paidAt: created_date # W3 末从 billing_date 升级 — 跟 treatment_actual 口径一致。
# - billing_date = 开单/收费动作时间(团购预付等失真)
# - created_date = 结算入库时间(接近实际治疗时点)
# - 100% created_date >= billing_date(9.4 万行 settlement_mode 实测,0 反向)
# ⭐ W3 末改 settlement_money → receivable_this(应收金额,不是实收)
# - settlement_money = 实际收到现金(会员卡扣费/套餐/团购/员工免费时 = 0)
# - receivable_this = 服务应收金额(反映患者带来的真实业务价值)
# - 例:王辉 5 次洁牙 settlement_money=0(扣会员卡)→ 改前 LTV=0 不合理
# receivable_this=¥760+350+200×N(应收)→ 改后真实反映"患者价值"
# - 跟 design.md 患者价值定义对齐:LTV = 患者带来的业务量,不是现金流
amount: receivable_this # FieldMapper.normalize 按 amount_unit=yuan 转 cents
method: payment_channel # transforms.pick_first_nonzero 推断的主导支付通道
orderExternalId: registration_id
doctorId: doctor_id # 收费医生(医患关系信号)
encounterExternalId: registration_id # 关联接诊(语义准:registration_id = 接诊号)
# orderExternalId 不映射:host 没真正的"医嘱单 id"概念,留 null 等其他 host(charge_order)填
......@@ -19,6 +19,8 @@ field_mapping:
occurredAt: rq
code: treat_name # "建议种植" 等 → enum_mapping 翻译到 PAC 推荐码
toothPosition: tooth_position
doctorId: user_id # 建议医生(从 emr 父级继承)
doctorName: doctor_name # 建议医生名(快照)
# treat_name 自由文本,enum_mapping cover 高频"建议 XX"白名单
# 长尾 _default: '' parser 跳过(不入 recommendation_record fact)
......
# referral — 转介绍(老带新 / 渠道归因)
# fact 挂在被推荐人(referee = patient_id);推荐人信息进 content 快照
# 消费 transforms 输出表 referral_rows(从 fact_client_out 过滤有 recommend_id 的行 + derive 外部 id)
canonical: referral
emits:
action: referral_recorded
subjectType: referral
occurredAtField: recordedAt
primary:
table: referral_rows
key: referral_external_id
dedup_by: referral_external_id
field_mapping:
externalId: referral_external_id
patientExternalId: patient_id # 被推荐人(referee)= 该行的 patient_id
# clinicId 不映射:fact_client_out(ADS 聚合)无 organization_id 列,referral 来自主档无明确 clinic
recordedAt: first_visit_time # 近似:推荐发生时间 ≈ 被推荐人首次到诊
referrerExternalId: recommend_id # 推荐人 host id
referrerName: recommend_name # 推荐人名(快照)
referrerChartNumber: recommend_file_num # 推荐人病历号(辅助)
# referrerType / channel:host 没明确字段,留 null(parser 兜底 'patient' 是潜在做法,这里先保真)
# refund — fact_settlement_out 退费明细(独立 fact_type refund_record)
# 消费 transforms 输出表 refund_rows(SQL WHERE is_refund=1 OR settlement_status=4)
#
# 双轨判定 — host 退费两种表达方式都收:
# ① is_refund=1 (显式标记的退费单,settlement_money 通常 > 0 表示"被退金额")
# ② settlement_status=4 (反向结算单,settlement_money 通常 < 0 表示"金额冲减")
# refund.parser 侧 Math.abs(amount) 统一成正 cents(语义 = 患者拿回的钱)
#
# 用途:
# - LTV 算法:value.feature.ts 已 SUM(refund.amount) 扣减 LTV
# - 5 阶段闭环风控:chain-composer.hasPostS3Refund 查"S3 后是否有退费"决定 S5
# - UI 时间轴:详情页"2026-03-15 退费 ¥3000(原因 X)" 独立事件
canonical: refund
emits:
action: refund_created
subjectType: refund
occurredAtField: refundedAt
kind: actual
primary:
table: refund_rows
key: id
dedup_by: id # 同 settlement_id 不应重复(SQL 已去重)
field_mapping:
externalId: id
patientExternalId: patient_id
clinicId: organization_id
# 退费时间 = created_date(结算记录入库时间)— 跟 payment / treatment 口径一致
refundedAt: created_date
# 金额 — yaml 直接映射 settlement_money(可能正可能负);parser 侧 Math.abs() 归一为正 cents
amount: settlement_money
# 反查关联的接诊(同 registration_id 找到对应 payment)
# 注:fact_settlement_out 没有"原 payment_id" 字段,只能靠 registration_id + 业务 reconcile
# 后续 host 若提供 original_settlement_id 再升级 paymentExternalId 映射
# reason 字段 host 暂无 — 待 DW 补"退费原因" 字段后映射
......@@ -18,10 +18,22 @@ field_mapping:
externalId: id
patientExternalId: patient_id
clinicId: organization_id
occurredAt: rq
occurredAt: created_date # W3 末从 billing_date 升级 — 治疗实际发生时间最佳代理。
# 真实语义对比:
# - billing_date = 开单/收费动作时间(团购券预付等场景在治疗前几小时,失真)
# - created_date = 结算记录入库时间(治疗后医生录入,接近实际治疗时点)
# 全量 244 万对验证(瑞泰):
# - 100% created_date >= billing_date(永远是治疗时间下界)
# - 77% 完全相同,23% created 晚于 billing(预付场景)
# - settlement.created vs EMR.created p50 差 5min,51% 在 5min 内
# null/parse 失败:0(271 万行实测)。0.8% case 偏差 > 1 天是 host 后补录,本就该按 created 算。
category: category_raw # transforms.derive 已把 settlement_type 复制到这
subtype: settlement_project_name
status: settlement_type # 留原 settlement_type 文字作 status(parser 不强消费)
doctorId: doctor_id # 执行医生(医患关系信号,100% 充实)
sourceEncounterExternalId: registration_id # 反查接诊(99.999% 充实,之前漏接导致 source_encounter null)
quantity: settlement_num # 治疗份数(种植 4 颗 / 套餐 5 套…)
unitName: settlement_unit_name # 计量单位(颗/次/区/套)
# settlement_type 全枚举(实测 distinct ~45)→ PAC category
# 维护:DW 加新 settlement_type 时跑 SQL `SELECT DISTINCT settlement_type FROM fact_settlement_out`
......
......@@ -23,6 +23,8 @@ field_mapping:
category: category_raw # transforms 已固定填 _review_sentinel
subtype: treat_name # 原始动作名("常规复查"/"拆线"等)
toothPosition: tooth_position
doctorId: user_id # 计划医生(从 emr 父级继承)
doctorName: doctor_name # 计划医生名(快照)
enum_mapping:
category:
......
-- AlterTable
ALTER TABLE "patients" ADD COLUMN "medical_record_number" TEXT;
......@@ -179,6 +179,11 @@ model Patient {
gender String?
/// 出生日期
birthDate DateTime? @map("birth_date") @db.Date
/// 病历号 host 病历主键(纸质档案号),辅助标识。跟 externalId 互补:
/// externalId = host 内部业务 id(系统主键,稳定但对人无意义)
/// medicalRecordNumber = 纸质档案 / 客服沟通用的可读编号( "BA43016")
/// 普适字段(任何医疗系统都有病历号)host 不提供时留 null
medicalRecordNumber String? @map("medical_record_number")
/// 偏好(沟通时间、语言、就诊偏好等)。形状自由,应用层 zod 校验:
/// { contactWindow?: {start,end}, language?, preferredChannel?, ... }
preferences Json?
......@@ -798,6 +803,11 @@ model PlanReason {
/// 应用层用 @pac/types PlanScenario enum 校验。后续扩展时在 enum 加新值即可。
scenario String
/// 子规则 key(scenario 内细分):missing_tooth / caries_no_filling / perio_no_srp / endo_no_rct
/// plan scenario 下不同 sub_key 1 (每个独立临床缺口独立 evidence)
/// nullable 兼容历史: plan_reasons scenario 级合并的(sub_key=null)
subKey String? @map("sub_key")
/// reason 在本次 inclusion 的得分(子场景基线 + value/risk/recency bonus 加权)
/// plan.priorityScore = MAX over plan_reasons.priority_score(客服列表排序冗余字段)
priorityScore Float @map("priority_score")
......@@ -807,8 +817,16 @@ model PlanReason {
/// 至今 60 天未见 crown 治疗记录或冠修复预约。"
reason String @db.Text
/// 证据:patient_facts.id 数组(决策依据)MIGRATION:GIN 索引
evidence String[] @map("evidence_fact_ids") @db.Uuid
/// 证据(W3 末改 JSON 统一, PersonaFeature.evidence 对齐):
/// {
/// factIds: string[], // 必填,patient_facts.id 数组
/// agentInvocationIds?: string[], // LLM 抽取来源(留位,W5+ recommendation )
/// ruleIds?: string[] // scenario 版本(留位)
/// }
/// UUID[] @db.Uuid 已淘汰 单一 factIds 形态限制了多源融合表达; jsonb 一统。
/// 反查"哪些 plan 因 X fact 命中"频率低 + plan_reasons 行级小,不再立 GIN(真要查可建 jsonb GIN)
/// 应用层 zod 校验形状(对齐 PersonaFeature.evidence 结构)
evidence Json @map("evidence")
/// **v2.1 算法可解释性** 6 因子优先级 breakdown + 规则版本号 + 抽取置信度。
/// 详情页用本字段渲染"为什么 73 分":
......@@ -823,6 +841,19 @@ model PlanReason {
/// 应用层 zod 校验(`ScenarioHit.priorityBreakdown` 类型);ScenarioHit 没产 breakdown 时为 null
breakdown Json? @map("breakdown")
/// **W3 末新增** 结构化召回信号(前端富文本渲染数据源)
/// DB 存原始 enum / canonical code,**不语义化**(中文 label 走前端字典翻译):
/// {
/// subKey: "missing_tooth",
/// triggers: [{ type: "diagnosis", code: "K08" }], // 数组支持多源融合
/// toothPosition: "21" | null,
/// daysSince: 149,
/// expectedCategories: ["implant", "prosthodontic"]
/// }
/// factId evidence_fact_ids 立柱字段(GIN 索引),避免重复存。
/// schema @pac/types ReasonSignalsSchema 强校验。
signals Json? @map("signals")
/// 产品收集(回访中心信息字段.docx):reason 生命周期类型 决定何时自动失效。
/// one_shot 短效 成因消失即应 close(待治疗治了 / 余款付了 / 续治完成)
/// recurring 长效 周期性触发(年度复查 / 半年定期洁牙 / 牙周维护)
......@@ -857,8 +888,9 @@ model PlanReason {
plan FollowupPlan @relation(fields: [planId], references: [id], onDelete: Cascade)
/// plan 内每个 scenario 最多一条 reason(避免重复 inclusion)
@@unique([planId, scenario])
/// plan 内每个 (scenario, sub_key) 最多一条 reason
/// (PG 默认 nullable UNIQUE 里多个 null 算不同 兼容老数据 sub_key=null;新数据都填)
@@unique([planId, scenario, subKey])
/// 反查"哪些 plan 因 X scenario 命中"
@@index([scenario])
/// 产品收集:运营批量场景的查询(找该 batch 涉及哪些 plan)
......
......@@ -47,7 +47,7 @@ export function loadConfig(): AppConfig {
ai: {
deepseekApiKey: process.env.DEEPSEEK_API_KEY ?? '',
deepseekBaseUrl: process.env.DEEPSEEK_BASE_URL ?? 'https://api.deepseek.com',
defaultModel: process.env.AI_DEFAULT_MODEL ?? 'deepseek-v4-pro',
defaultModel: process.env.AI_DEFAULT_MODEL ?? 'deepseek-v4-flash',
requestTimeoutSec: Number(process.env.AI_REQUEST_TIMEOUT_SEC ?? 60),
priceTable: parsePriceTable(process.env.AI_PRICE_TABLE_JSON),
},
......
......@@ -12,6 +12,7 @@ import {
/**
* Safety rules — 后置硬约束。
* LLM 偶尔会越过 schema 约束塞禁词,这里再补一道(防御性)。
* B 方案重写后,output 4 段都是 markdown 字符串,直接全文 join 扫禁词。
*/
const FORBIDDEN_PHRASES = [
'一定能', '保证', '绝对', '百分百', '100%',
......@@ -24,13 +25,7 @@ const safetyRules: ReadonlyArray<SafetyRule<DraftPlanScriptOutput>> = [
name: 'no_forbidden_phrases',
severity: 'block',
check(output) {
const fullText = [
output.opening,
output.keyMessage,
output.followup,
output.callToAction,
...output.objectionHandling.flatMap((o) => [o.objection, o.response]),
].join('\n');
const fullText = [output.opening, output.followup, output.objection, output.close].join('\n');
const hit = FORBIDDEN_PHRASES.filter((p) => fullText.includes(p));
return {
pass: hit.length === 0,
......@@ -39,36 +34,70 @@ const safetyRules: ReadonlyArray<SafetyRule<DraftPlanScriptOutput>> = [
},
},
{
name: 'call_to_action_has_time',
severity: 'warn', // warn 不阻断,只记日志,够多了再升 block
name: 'close_has_concrete_time',
severity: 'warn', // warn 不阻断,只记日志
check(output) {
// 简单启发:callToAction 应该包含数字(时间点)
const hasDigit = /\d/.test(output.callToAction);
return { pass: hasDigit, message: hasDigit ? undefined : 'callToAction 未含具体时间数字' };
// 启发式:结束段 markdown 必须含数字(具体时间点 / 日期)
const hasDigit = /\d/.test(output.close);
return { pass: hasDigit, message: hasDigit ? undefined : 'close 段未含具体时间数字' };
},
},
{
name: 'objection_uses_h3_blocks',
severity: 'warn',
check(output) {
// 启发式:objection 段必须按 ### A./B./C. 子标题切分
const hasH3 = /^###\s+[A-D]\./m.test(output.objection);
return { pass: hasH3, message: hasH3 ? undefined : 'objection 段未按 ### A./B. 子标题切分' };
},
},
];
/**
* 降级 fallback —— LLM 失败 / safety 拒收时用。
* 用 input 直接拼一份模板话术,保证客服一定有东西可用。
* 用 input 直接拼一份 4 段 markdown 模板话术,保证客服一定有东西可用。
*/
function fallback(input: DraftPlanScriptInput): DraftPlanScriptOutput {
const { patient, clinicName, plan, clinicalContext } = input;
const dayPart = clinicalContext.daysSinceLastVisit
? `已经 ${clinicalContext.daysSinceLastVisit} 天没见您了`
: '有段时间没见您了';
const lastSummary = clinicalContext.lastVisitSummary ?? '复查相关安排';
return {
opening: `${patient.nameMasked}您好`,
keyMessage: `我是${clinicName}的客服,${dayPart},想跟您约个时间复查一下。`,
followup: `根据病历记录,本次想跟您沟通的是:${plan.primaryScenarioLabel}。复查不需要太久,大概 20 分钟就能完成。您看本周末或下周初哪个时间方便?`,
objectionHandling: [
{ objection: '最近没时间', response: '理解,可以约到下个月,提前预约能避开排队。' },
{ objection: '再考虑一下', response: '好的,不过早处理对后续治疗效果更好,有问题随时联系我。' },
],
callToAction: '本周六上午 10 点或下周一晚上 7 点,您看哪个更合适?',
tone: 'warm',
opening: `**目的**:亲切自然地建立通话,围绕「${plan.primaryScenarioLabel}」开场,避免推销感。
> "${patient.nameMasked}您好,我是${clinicName}的客服。${dayPart},今天给您打电话,主要是想跟您同步上次的${lastSummary},您现在方便聊几分钟吗?"
**注意**
- 称呼用「${patient.nameMasked}」,不用全名
- 若不方便,主动询问合适回拨时间`,
followup: `**目的**:把诊所记录的临床事实,自然引到「该回来做点什么」。
> "根据病历记录,本次想跟您沟通的是:${plan.primaryScenarioLabel}。复查不需要太久,大概 20 分钟就能完成。"
> "我们这边可以帮您安排医生面诊评估,**这次只是评估和确认方案,不做任何操作**,您看本周末或下周初哪个时间方便?"
**异议预判**
- "最近没时间" → 提供更灵活的时间窗(早班/晚班)
- "再考虑一下" → 强调诊后复查窗口期,过期可能要重新评估
- "已在外院看过" → 提交「已在外院治疗」并关闭召回`,
objection: `### A. "我再考虑考虑"
> "完全理解。这样,我先帮您把医生的面诊时间留出来,**本周六上午 10 点或下周一晚上 7 点**,您选一个?到现场看了方案再决定也不晚。"
### B. "最近真的没时间"
> "理解,可以约到下个月,提前预约能避开排队。您下周或下下周哪天比较方便?我先帮您预留。"
### C. "已在别的医院看了"
> "好的${patient.nameMasked},那我这边帮您把这条记录关一下,日常护理还是按原来的周期回来就行,**祝您一切顺利**。"
> → 提交结果选「已在外院治疗」`,
close: `> "好的${patient.nameMasked},那我帮您约 **本周六上午 10 点**,到时候提前 10 分钟到前台就行。我会给您发个短信提醒,您注意接收。还有别的需要么?"
**回写要点**
- 成功约上面诊 → 提交结果选「成功转化为新预约」,填预约时间 + 医生
- 同意但未定日期 → 选「约定下次回访」,填预计时间
- 考虑中 → 选「考虑中近期再跟进」,7 天后系统提醒二次跟进`,
};
}
......@@ -79,7 +108,7 @@ export class DraftPlanScriptCall
readonly kind = 'script' as const;
readonly callKey = 'draft_plan_script';
readonly promptVersion = DRAFT_PLAN_SCRIPT_PROMPT_VERSION;
readonly defaultModelId = 'deepseek-v4-pro';
readonly defaultModelId = 'deepseek-v4-flash';
readonly outputSchema = DraftPlanScriptSchema;
readonly safetyRules = safetyRules;
......
......@@ -41,36 +41,40 @@ export interface DraftPlanScriptInput {
clinicalContext: {
daysSinceLastVisit: number | null;
lastVisitSummary: string | null; // 上次到店做了什么(一句话)
pendingTreatments: string[]; // 待做治疗(简短描述,如"右上 16 缺失牙待修复")
pendingTreatments: string[]; // 待做治疗(简短描述,牙位已转患者俗称,如"缺失牙(下门牙)")
treatmentChainSummary: string | null; // 治疗链当前阶段一句话
/** 主诊医生名(从最近 treatment/diagnosis fact 抽);LLM 必须用此名,不可编造 */
primaryDoctorName: string | null;
};
}
/**
* AiCall 输出契约。
* AiCall 输出契约(B 方案 · 2026-05-24 重写)
*
* 注意:这是 LLM 直接输出的结构;orchestrator 会把它**渲染成 Markdown**
* 写入 PlanScript.content(schema 是 String? 存 Markdown,不存 JSON)。
* LLM 直接输出 4 段 Markdown 字符串 + tone tag。
* orchestrator 把每段直接映射到前端 ScriptSection,**不再做模板拼接**;
* 段内子结构(`**目的**:` / `**注意**` / `**异议预判**` / `**回写要点**` / `### A.` ...)
* 由 LLM 按 schema.ts 的 .describe() 契约自己写。
*
* 为什么不让 LLM 直接出 Markdown:
* 1. 结构化输出便于 safety gate 按字段校验(opening 是否含禁词 / objection 是否 ≤3 条等)
* 2. 渲染层可以独立调整模板(改格式不动 LLM)
* 3. 调试页可以分字段展示 / 对比版本差异
* 为什么从"结构化字段+后端拼模板" 改成"LLM 直出 Markdown":
* - 段内子结构需求(目的/正文/注意)模板化后失去个性化,套话感重
* - 4 段独立 markdown 字段对 zod 仍是强校验(长度 + .describe() 格式契约)
* - 流式仍然可行(generateObject partial 阶段每段 string 逐字符流出)
* - Flash 模型友好 — schema 字段少,LLM 单字段长 markdown 比多字段嵌套对象更稳
*/
export interface DraftPlanScriptOutput {
/** 开场白(15 字内,带称呼) */
/** 整体语气标签(给客服参考) */
tone: 'warm' | 'professional' | 'urgent';
/** 开场段 markdown:**目的** + > 通话片段 + **注意** bullet */
opening: string;
/** 核心传达信息(60 字内,说明为什么联系) */
keyMessage: string;
/** 跟进话术(完整对话片段,200 字内) */
/** 切入话题段 markdown:**目的** + 1-2 段 > 对话 + **异议预判** bullet */
followup: string;
/** 异议处理(最多 3 条) */
objectionHandling: Array<{
objection: string;
response: string;
}>;
/** 明确下一步动作(给 2 个具体时间选项) */
callToAction: string;
/** 语气标签(给客服参考) */
tone: 'warm' | 'professional' | 'urgent';
/** 异议处理段 markdown:3-4 个 ### A./B./C./D. 子标题 + > 应对 */
objection: string;
/** 结束·信息确认段 markdown:> 确认话术 + **回写要点** bullet */
close: string;
}
......@@ -3,48 +3,75 @@ import { z } from 'zod';
/**
* DraftPlanScript AiCall 的 Zod 输出 schema。
*
* generateObject 会把 schema 转 JSON Schema 注入 prompt,LLM 强制按此 shape 输出。
* .describe() 是给 LLM 的字段语义提示 — 写清楚能显著降低偏差,务必维护好。
* 设计原则(B 方案 · 2026-05-24):
* - 4 段直出 markdown,每段一个字段,LLM 自由写 markdown 但格式契约由 .describe() 强约束
* - generateObject 会把 schema 转 JSON Schema 注入 prompt;LLM 强制按此 shape 输出
* - 流式时每段是 string 字段,partial 阶段 LLM 还没写完的段为 undefined → orchestrator 透 ''
* - .describe() 写清楚段内 markdown 子结构格式(目的 / 正文 / 注意 / 异议预判 / 回写要点)
*
* 跟前端 mock-data.ts mockScript.sections 完全对齐(4 段 id),前端零改动接 4 段。
*/
export const DraftPlanScriptSchema = z.object({
opening: z
.string()
.min(2)
.max(30)
.describe('开场白,中文不超过 15 字,带患者称呼(如"王女士您好")'),
tone: z
.enum(['warm', 'professional', 'urgent'])
.describe('整体语气标签:warm=温和家常 / professional=专业稳重 / urgent=有时效紧迫'),
keyMessage: z
opening: z
.string()
.min(10)
.max(120)
.describe('核心传达信息,中文不超过 60 字。必须提到具体临床事实(治疗阶段/牙位/上次内容),让患者感受到诊所记得 ta。不得承诺疗效。'),
.min(60)
.max(700)
.describe(
[
'【开场段 markdown,必须严格按以下 3 部分格式】',
'第 1 部分:`**目的**:xxx` — 一句话讲清开场目的(15-40 字)',
'第 2 部分:空行后 `> "通话片段..."` blockquote — 客服开场白完整一段(80-200 字),包含:自报家门(诊所+岗位)+ 来电原因(必须提具体临床事实)+ 礼貌征询是否方便',
'第 3 部分:空行后 `**注意**` 标题 + 2-3 条 `- xxx` bullet — 客服执行注意点(称呼/时段/口吻)',
'禁止:加 ### 标题、加表情符号、blockquote 里使用排比抒情体',
].join('\n'),
),
followup: z
.string()
.min(20)
.max(400)
.describe('跟进话术完整片段,中文不超过 200 字。语气温和专业,避免推销感。可以包含 1-2 个开放性问题。'),
objectionHandling: z
.array(
z.object({
objection: z.string().min(2).max(40).describe('患者可能的拒绝理由,如"最近没时间"'),
response: z.string().min(10).max(80).describe('应对话术,中文不超过 30 字'),
}),
)
.min(1)
.max(3)
.describe('最多 3 条异议处理'),
.min(120)
.max(1000)
.describe(
[
'【切入话题段 markdown,必须严格按以下 3 部分格式】',
'第 1 部分:`**目的**:xxx` — 一句话讲本段任务(15-40 字)',
'第 2 部分:空行后 1-2 段 `> "..."` blockquote — 先讲临床事实+风险(治疗阶段/牙位号/上次诊断),再给具体行动选项(必须含"这次只是评估,30 分钟"这类降低门槛话术,可加粗关键信息)',
'第 3 部分:空行后 `**异议预判**` 标题 + 2-4 条 `- "trigger" → action` bullet — 列患者可能的反对+对应处理思路',
'禁止:在本段写完整异议应对话术(那是 objection 段的事)',
].join('\n'),
),
callToAction: z
objection: z
.string()
.min(10)
.max(80)
.describe('明确下一步动作,必须可执行 — 给出 2 个具体时间选项(如"本周六上午 10 点或下周一晚上 7 点")'),
.min(150)
.max(1400)
.describe(
[
'【异议处理段 markdown,必须严格按以下格式】',
'3-4 个子条目,每个子条目:',
' - `### A. "我再考虑考虑"` 子标题(A/B/C/D 顺序)',
' - 紧跟一行 blockquote `> "应对话术..."`(30-100 字,要给具体时间选项或具体方案)',
' - 可选追加一行 `> → 提交结果选「xxx」` 指明客服回写动作',
'常见异议优先覆盖:再考虑 / 价格贵 / 没时间 / 已在外院 / 治疗冲突等',
'禁止:把所有异议合并成一个长段、用 bullet 列举(必须 ### 子标题分块)',
].join('\n'),
),
tone: z
.enum(['warm', 'professional', 'urgent'])
.describe('整体语气标签:warm=温和家常 / professional=专业稳重 / urgent=有时效紧迫'),
close: z
.string()
.min(80)
.max(700)
.describe(
[
'【结束·信息确认段 markdown,必须严格按以下 2 部分格式】',
'第 1 部分:`> "确认话术..."` blockquote — 复述敲定的安排:具体时间(周X晚上X点)+ 医生名 + 提醒方式(短信/企微)+ 礼貌结尾',
'第 2 部分:空行后 `**回写要点**` 标题 + 2-4 条 `- xxx → 提交结果选「xxx」` bullet — 列不同通话结果对应的客服回写动作',
'禁止:省略具体时间敲定、省略回写要点',
].join('\n'),
),
});
export type DraftPlanScriptOutputZ = z.infer<typeof DraftPlanScriptSchema>;
......@@ -76,7 +76,7 @@ export class DraftPlanSummaryCall
readonly kind = 'summary' as const;
readonly callKey = 'draft_plan_summary';
readonly promptVersion = DRAFT_PLAN_SUMMARY_PROMPT_VERSION;
readonly defaultModelId = 'deepseek-v4-pro';
readonly defaultModelId = 'deepseek-v4-flash';
readonly outputSchema = DraftPlanSummarySchema;
readonly safetyRules = safetyRules;
......
......@@ -43,11 +43,12 @@ export interface DraftPlanSummaryInput {
chains: Array<{
name: string; // 如 "缺失牙修复链(36)"
category: string; // 治疗类别
status: 'closed' | 'ongoing' | 'uninitiated';
/// 5 阶段链状态(W3 末从 3 态升级)— 详见 chain-composer.service.ts
status: 'discovered' | 'entered' | 'ongoing' | 'closed';
currentStage: number;
diagnosedAt?: string;
gapDays?: number;
nodes: Array<{ stage: number; label: string; at: string; done?: boolean; current?: boolean }>;
nodes: Array<{ stage: number; label?: string; title?: string; detail?: string; doctor?: string; at: string; done?: boolean; current?: boolean }>;
}>;
}
......
......@@ -277,7 +277,9 @@ export class PlanSummaryOrchestrator {
id: f.id,
type: f.type,
kind: f.kind,
status: f.status,
occurredAt: f.occurredAt,
plannedFor: (f as { plannedFor?: Date | null }).plannedFor ?? null,
content: f.content,
})),
);
......
......@@ -8,6 +8,7 @@ import {
type ExchangeCodeResponse,
type Permission,
type RefreshTokenResponse,
type TokenDictionary,
type TokenExchangeResponse,
type TokenExchangeRequest,
type UserRole,
......@@ -21,10 +22,25 @@ import { parseDurationToSeconds } from './duration';
const CODE_PREFIX = 'pac:exchange-code:';
const REFRESH_PREFIX = 'pac:refresh:';
/**
* Refresh token payload(W3 末 A 方案降级版)
*
* 容纳重签 access token 所需的全部 user 上下文(role/clinicIds/dictionary),
* **无需** refresh 时回调 host SSO 拉权限即可重发 access。
*
* 安全考量:
* - refresh token 用独立 secret(JWT_REFRESH_SECRET),access 烂不代表 refresh 烂
* - Redis jti 白名单(7 天 TTL),被偷的 refresh 不会无限刷(rotation 后老 jti 立即作废)
* - 权限变更/禁用最长 7 天(refresh TTL)才生效 — POC 阶段可接受;
* 生产前要加 host SSO `/users/{id}` 回调拉最新 role/clinics(见下方 TODO)
*/
interface RefreshPayload {
sub: string;
hostId: string;
tenantId: string;
clinicIds: string[];
role: UserRole;
dictionary?: TokenDictionary;
jti: string;
}
......@@ -67,6 +83,9 @@ export class AuthService {
sub: req.user.userId,
hostId: host.id,
tenantId,
clinicIds,
role: req.user.role,
dictionary: req.user.dictionary,
});
const code = randomCode(24);
......@@ -100,6 +119,21 @@ export class AuthService {
};
}
/**
* Refresh access token(W3 末 A 方案 — 本地降级,无 host SSO 回调)
*
* 流程:
* 1. 验证 refresh token 签名(独立 secret JWT_REFRESH_SECRET)
* 2. Redis 校验 jti 是否还在白名单(未被 rotation 作废、未被人工撤销)
* 3. 用 refresh payload 里的 sub/role/clinicIds/dictionary 重签新 access token
* 4. **Rotation**:作废老 jti,签发新 refresh token(新 jti 入 Redis),
* 防 refresh token 被偷后无限刷;被偷者一旦用了,真主人下次刷新就因老 jti 被撤销而失败 → 早发现
*
* TODO(host SSO 集成,W5+):
* 生产前需在步骤 3 前回调 host `/users/{userId}` 拉最新 role/clinicIds —
* 防"用户已禁用 / 权限已降级" 还能继续刷 token 用 7 天。
* 联调 host SSO 后,把 refresh payload 里的 role/clinicIds 改为"仅缓存,以 host 返回为准"。
*/
async refresh(refreshToken: string): Promise<RefreshTokenResponse> {
let payload: RefreshPayload;
try {
......@@ -113,13 +147,31 @@ export class AuthService {
const stored = await this.redis.get(REFRESH_PREFIX + payload.jti);
if (!stored) throw new BizError(ApiCode.AUTH_INVALID_REFRESH_TOKEN, 'refresh token revoked');
// Real impl needs to re-derive role/permissions/clinics from a host SSO or
// a local user table. The scaffold deliberately fails closed so the host
// backend re-runs token exchange. See architecture §3.6.
throw new BizError(
ApiCode.AUTH_INVALID_REFRESH_TOKEN,
'Refresh requires re-exchange via host backend (scaffold placeholder)',
);
// ── 步骤 3:重签 access token(payload 里有完整 user 上下文)──
const permissions = this.resolvePermissions(payload.role);
const accessToken = await this.signAccessToken({
sub: payload.sub,
hostId: payload.hostId,
tenantId: payload.tenantId,
clinicIds: payload.clinicIds,
role: payload.role,
permissions,
...(payload.dictionary ? { dictionary: payload.dictionary } : {}),
});
// ── 步骤 4:rotation — 作废老 jti,签发新 refresh token ──
await this.redis.del(REFRESH_PREFIX + payload.jti);
const newRefreshToken = await this.signRefreshToken({
sub: payload.sub,
hostId: payload.hostId,
tenantId: payload.tenantId,
clinicIds: payload.clinicIds,
role: payload.role,
dictionary: payload.dictionary,
});
const expiresIn = parseDurationToSeconds(this.config.getOrThrow<string>('jwt.expiresIn'));
return { accessToken, refreshToken: newRefreshToken, expiresIn };
}
async signAccessToken(
......@@ -135,6 +187,9 @@ export class AuthService {
sub: string;
hostId: string;
tenantId: string;
clinicIds: string[];
role: UserRole;
dictionary?: TokenDictionary;
}): Promise<string> {
const jti = randomCode(16);
const ttl = parseDurationToSeconds(this.config.getOrThrow<string>('jwt.refreshExpiresIn'));
......
......@@ -28,4 +28,14 @@ export class PatientController {
const limit = Math.min(Math.max(Number(limitRaw ?? 50), 1), 500);
return this.patient.getTimeline(scope, id, limit);
}
@Get(':id/phone-reveal')
@RequirePermission(Permission.PATIENT_VIEW)
@ApiOperation({ summary: 'Reveal masked phone — 拉患者明文手机号(需 PATIENT_VIEW 权限)' })
revealPhone(
@TenantScope() scope: TenantScopeContext,
@Param('id') id: string,
) {
return this.patient.revealPhone(scope, id);
}
}
......@@ -17,6 +17,33 @@ import type { TenantScopeContext } from '../../common/decorators/tenant-scope.de
export class PatientService {
constructor(private readonly prisma: PrismaService) {}
/**
* Reveal — 拉患者真实手机号(掩码 138****3405 → 13812343405)。
*
* 仅 PATIENT_VIEW 权限的用户可调,且强制 tenant scope 校验。
*
* TODO(W5+ 生产前):落 audit log(patient_pii_reveal_logs 表)
* 记录 { userId, patientId, field='phone', revealedAt, scope:plan/timeline/manual };
* 合规审计能反查"谁何时看了哪位患者的电话"。POC 阶段先口头记录。
*/
async revealPhone(
scope: TenantScopeContext,
patientId: string,
): Promise<{ phone: string | null }> {
const patient = await this.prisma.patient.findFirst({
where: {
id: patientId,
hostId: scope.hostId,
tenantId: scope.tenantId,
},
select: { phone: true },
});
if (!patient) {
throw new NotFoundException(`patient ${patientId} not found in scope`);
}
return { phone: patient.phone };
}
async getTimeline(
scope: TenantScopeContext,
patientId: string,
......
import { Injectable } from '@nestjs/common';
import { PersonaFeatureKey, FactType, FactKind, lookupDxTreatment } from '@pac/types';
import {
PersonaFeatureKey,
FactType,
FactKind,
lookupDxTreatment,
treatmentCategoryNameZh,
} from '@pac/types';
import type {
FeatureExtractor,
FeatureExtractorContext,
......@@ -75,6 +81,9 @@ export class TreatmentChainStatusFeatureExtractor implements FeatureExtractor {
}
// 找出"诊断 + 推荐但无对应治疗"的缺口
// gap 字符串短化(旧版含全口 26 颗牙位 + raw enum,UI 严重溢出):
// "K05/11;12;13;...37 → 未做 periodontic"(60+ 字符)
// → "牙周治疗 全口 26 牙"(短 + 用 chainLabel 中文,牙位 formatTooth 截断)
const gaps: string[] = [];
const factIds: string[] = [];
const signalSources = [...diagnoses, ...recommendations];
......@@ -83,11 +92,18 @@ export class TreatmentChainStatusFeatureExtractor implements FeatureExtractor {
const c = sig.content as Record<string, unknown>;
const code = String(c.code ?? '');
const tooth = String(c.tooth_position ?? '');
const expectedCats = lookupDxTreatment(code)?.categories;
const rule = lookupDxTreatment(code);
const expectedCats = rule?.categories;
if (!expectedCats) continue;
const hasFollowup = expectedCats.some((cat) => actualByCat.has(cat));
if (!hasFollowup) {
gaps.push(`${code}${tooth ? '/' + tooth : ''} → 未做 ${expectedCats.join('或')}`);
const chainLabel = rule?.chainLabel ?? treatmentCategoryNameZh(expectedCats[0]!) ?? code;
// normalize 后再生成 gap 文本 — 跟 chain-composer 桶分一致:
// 同 N 颗牙不同顺序("17;47;37" / "47;17;37")算 1 个 gap,不重复
const normalizedTooth = rule?.wholeMouth
? '全口'
: formatToothShort(normalizeTooth(tooth));
gaps.push(`${chainLabel} ${normalizedTooth}`);
}
}
for (const tx of treatments) factIds.push(tx.id);
......@@ -97,7 +113,9 @@ export class TreatmentChainStatusFeatureExtractor implements FeatureExtractor {
let score: number;
if (gaps.length > 0) {
status = 'gap';
description = `治疗链有 ${gaps.length} 处缺口 — ${gaps.slice(0, 2).join(';')}`;
// 去重(同 K 码 + 牙位的多次诊断 / 推荐 → 同一 gap;UI 只显示 distinct)
const dedupGaps = [...new Set(gaps)];
description = `${dedupGaps.length} 处缺口 — ${dedupGaps.slice(0, 3).join('、')}`;
score = 3;
} else if (inProgressCount > 0) {
status = 'in_progress';
......@@ -122,3 +140,22 @@ export class TreatmentChainStatusFeatureExtractor implements FeatureExtractor {
};
}
}
/// 牙位规整化(去 trim + 去重 + 字典序)— 跟 chain-composer.normalizeTooth 同语义,
/// 让"17;47;37" / "47;17;37" 算同一个 gap,不因 host 录入顺序差异重复
function normalizeTooth(s: string): string {
if (!s.trim()) return '';
return Array.from(new Set(s.split(';').map((t) => t.trim()).filter(Boolean)))
.sort()
.join(';');
}
/// 牙位短化:>20 颗 → 全口 N 牙;≤4 颗 → 完整列;否则截前 4 + 等 N 颗
function formatToothShort(tooth: string, wholeMouthDefault?: boolean): string {
if (!tooth.trim()) return wholeMouthDefault ? '全口' : '未标注牙位';
const list = tooth.split(';').map((s) => s.trim()).filter(Boolean);
if (list.length === 0) return '未标注牙位';
if (list.length >= 20) return `全口 ${list.length} 牙`;
if (list.length <= 4) return list.join(';');
return `${list.slice(0, 4).join(';')}${list.length} 颗`;
}
......@@ -58,9 +58,11 @@ export class ValueFeatureExtractor implements FeatureExtractor {
const score = tier?.score ?? 0;
const yuan = (total / 100).toFixed(2);
// description 简化 — UI 紧凑展示;笔数/退款明细走 facts 时间轴 / profile.paymentCount 等
// (旧版 "新客/未消费(累计净消费 ¥58.00,含付款 1 笔 / 充值 0 笔 / 退款 0 笔)" UI 严重溢出)
return {
key: this.key,
description: `${label}(累计净消费 ¥${yuan},含付款 ${payments.length} 笔 / 充值 ${recharges.length} 笔 / 退款 ${refunds.length} 笔)`,
description: `${label} · 累计 ¥${yuan}`,
score,
evidence: { factIds },
};
......
......@@ -120,7 +120,9 @@ export class PersonaService {
hostId: patient.hostId,
tenantId: patient.tenantId,
patientId: patient.id,
status: 'active',
// 跟 chain-composer / scenario SQL 对齐:actual treatment 完成后 status='fulfilled',
// 不能只取 'active' 否则已完成治疗在 persona 看不到 → treatment_chain_status 误算 gap
status: { in: ['active', 'fulfilled'] },
},
select: {
id: true,
......
......@@ -52,9 +52,28 @@ export class PlanAggregateService {
) {
const persona = await this.loadCurrentPersona(patient.id);
const facts = await this.loadActiveFacts(scope, patient.id);
// W4:话术从 DB 加载(LLM 流式生成完会 upsert 到 plan_scripts)
// 没生成过 → script=null,前端走 mock 兜底
const script = plan ? await this.loadPlanScript(plan.id) : null;
// v2.1:chain-composer 读独立 diagnosis_record / treatment_record / recommendation_record,
// 传所有 facts,内部按 type 分组(encounter_record 已只元数据)
const chains = this.chainComposer.compose(facts);
// ⭐ SQL 为准:chain ★ 严格按 plan_reasons.signal_code 对齐(W3 末)
// chain-composer 只产链客观状态;真正"该召回 ★" 由 scenario SQL 决定
// 规则:discovered chain.code === reason.signals.triggers[0].code(K00/K01/.../K09 严格匹配)
// ★ 集合 = SQL reason 集合,数量完全一致(陆伟根 K07 reason → 只 K07 chain ★;K08 chain 不 ★)
const reasonCodes = new Set<string>();
for (const r of plan?.reasons ?? []) {
const sigs = (r.signals ?? {}) as { triggers?: Array<{ code?: string }> };
for (const t of sigs.triggers ?? []) {
if (t.code) reasonCodes.add(t.code);
}
}
for (const c of chains) {
if (c.status === 'discovered') {
c.target = !!(c.code && reasonCodes.has(c.code));
}
}
return {
patient: serializePatient(patient),
......@@ -63,9 +82,20 @@ export class PlanAggregateService {
persona: persona ? serializePersona(persona) : null,
chains,
facts: facts.map(serializeFact),
script: script ? serializeScript(script) : null,
};
}
/**
* W4:加载该 plan 的最新 ready 话术(LLM 生成完会 upsert 进 plan_scripts)。
* pending/failed 的不返回 — 前端走 mock 兜底,客服点"重新生成"再触发 LLM。
*/
private loadPlanScript(planId: string) {
return this.prisma.planScript.findUnique({
where: { planId },
});
}
// ─────────────────────────────────────────────
// 数据加载(分小函数 — 便于测试 + 单一职责)
// ─────────────────────────────────────────────
......@@ -201,12 +231,14 @@ function serializePlan(plan: {
reasons: Array<{
id: string;
scenario: string;
subKey: string | null;
priorityScore: number;
reason: string;
lifecycle: string;
source: string;
evidence: string[];
evidence: Prisma.JsonValue;
breakdown: Prisma.JsonValue;
signals: Prisma.JsonValue;
}>;
}) {
return {
......@@ -227,14 +259,18 @@ function serializePlan(plan: {
reasons: plan.reasons.map((r) => ({
id: r.id,
scenario: r.scenario,
subKey: r.subKey,
priorityScore: r.priorityScore,
reason: r.reason,
lifecycle: r.lifecycle,
source: r.source,
evidenceFactIds: r.evidence,
/// W3 末:evidence 改 JSON 统一(跟 PersonaFeature.evidence 对齐),透 JSON 整体
evidence: r.evidence,
/// v2.1:6 因子算法 breakdown + subKey,UI 详情页"为什么 73 分"渲染数据来源
/// (不含 ruleIds — 算法版本追溯靠 git + plan.createdAt)
breakdown: r.breakdown,
/// W3 末:结构化召回信号(raw enum / canonical code,前端字典翻译富文本)
signals: r.signals,
})),
};
}
......@@ -258,12 +294,97 @@ function serializePersona(persona: {
};
}
/**
* W4 新加:PlanScript serializer + markdown → sections 反 parse。
*
* 后端写库时 plan-script.orchestrator.renderMarkdown 把 4 段拼成:
* > 患者:xxx · 语气:xxx
* ## 开场\n{opening md}\n## 切入话题\n{followup md}\n## 异议处理\n{objection md}\n## 结束 · 信息确认\n{close md}
* 这里反向用 regex 按 H2 标题切回 4 段,前端拿到的 sections shape 跟 mockScript 完全一致。
*
* 设计决策:**前端单一消费 sections 接口**(mock / 真实 / 流式三路同 shape),
* 反 parse 集中在后端做,避免前端多套渲染分支。
*/
function serializeScript(s: {
id: string;
content: string | null;
source: string | null;
status: string;
generatedAt: Date | null;
updatedAt: Date;
}) {
const sections = parseScriptMarkdownToSections(s.content ?? '');
return {
id: s.id,
status: s.status,
source: (s.source as 'agent' | 'template_fallback' | 'manual' | null) ?? 'agent',
generatedAt: (s.generatedAt ?? s.updatedAt).toISOString(),
sections,
};
}
const SECTION_HEAD_TO_ID: Record<string, 'opening' | 'followup' | 'objection' | 'close'> = {
开场: 'opening',
切入话题: 'followup',
异议处理: 'objection',
'结束 · 信息确认': 'close',
};
const SECTION_META: Record<
'opening' | 'followup' | 'objection' | 'close',
{ label: string; durationHint: string }
> = {
opening: { label: '开场', durationHint: '30 秒' },
followup: { label: '切入话题', durationHint: '1–2 分钟' },
objection: { label: '异议处理', durationHint: '按需' },
close: { label: '结束 · 信息确认', durationHint: '30 秒' },
};
function parseScriptMarkdownToSections(md: string) {
// 按 H2 切分:`^## (xxx)$` 之后到下一个 H2 之间为内容
const ids: Array<'opening' | 'followup' | 'objection' | 'close'> = [
'opening',
'followup',
'objection',
'close',
];
const result = ids.map((id) => ({
id,
label: SECTION_META[id].label,
durationHint: SECTION_META[id].durationHint,
markdown: '',
}));
if (!md) return result;
const lines = md.split('\n');
let currentId: 'opening' | 'followup' | 'objection' | 'close' | null = null;
const buf: Record<string, string[]> = {};
for (const line of lines) {
const m = /^##\s+(.+?)\s*$/.exec(line);
if (m) {
const head = (m[1] ?? '').trim();
const matched = SECTION_HEAD_TO_ID[head];
currentId = matched ?? null;
if (currentId) buf[currentId] = [];
continue;
}
if (currentId) {
buf[currentId] = buf[currentId] ?? [];
buf[currentId]!.push(line);
}
}
for (const sec of result) {
sec.markdown = (buf[sec.id] ?? []).join('\n').trim();
}
return result;
}
function serializeFact(f: {
id: string;
type: string;
kind: string;
status: string;
occurredAt: Date | null;
plannedFor: Date | null;
clinicId: string | null;
title: string | null;
summary: string | null;
......@@ -275,6 +396,8 @@ function serializeFact(f: {
kind: f.kind,
status: f.status,
occurredAt: f.occurredAt?.toISOString() ?? null,
// planned 类(预约/计划治疗)时间在 plannedFor;透传给前端时间轴排序兜底
plannedFor: f.plannedFor?.toISOString() ?? null,
clinicId: f.clinicId,
title: f.title,
summary: f.summary,
......@@ -285,11 +408,13 @@ function serializeFact(f: {
// ─────────────────────────────────────────────
// 小工具
// ─────────────────────────────────────────────
/// payment/recharge/refund.content 实际字段是 `amount_cents`(parser 写库时归一为 cents int);
/// 老代码读 `c.amount` 是字段名错配,导致 LTV 永远 0(实测路遥 ¥58 显示成 0)。W3 末修正。
function sumAmount(facts: Array<{ content: Prisma.JsonValue }>): number {
let total = 0;
for (const f of facts) {
const c = f.content as Record<string, unknown>;
total += Number(c?.amount ?? 0);
total += Number(c?.amount_cents ?? c?.amount ?? 0);
}
return total;
}
......
......@@ -4,6 +4,7 @@ import {
ListExecutionsResponseSchema,
ListPlansQuerySchema,
ListPlansResponseSchema,
PlanCountsResponseSchema,
PlanActionAckSchema,
PlanDetailResponseSchema,
RecomputeAckSchema,
......@@ -14,6 +15,7 @@ import {
export class ListPlansQueryDto extends createZodDto(ListPlansQuerySchema) {}
export class ListPlansResponseDto extends createZodDto(ListPlansResponseSchema) {}
export class PlanCountsResponseDto extends createZodDto(PlanCountsResponseSchema) {}
export class PlanDetailResponseDto extends createZodDto(PlanDetailResponseSchema) {}
export class AssignPlanRequestDto extends createZodDto(AssignPlanRequestSchema) {}
export class RecyclePlanRequestDto extends createZodDto(RecyclePlanRequestSchema) {}
......
......@@ -161,22 +161,19 @@ export class PlanEngineService {
const newReasonSet = new Set(hits.map((h) => h.scenarioKey + ':' + (h.subKey ?? '')));
// 比对 active plan 的 reason 是否变化
// ⭐ W3 末修:比对维度 = (scenario, subKey) 二元组集合,**不能只看 scenario**
// 旧 bug:加新 SUB_SCENARIOS(K01/K07/K00 等)后,oldScenarios={treatment_initiation_recall}
// 跟 newScenarios={treatment_initiation_recall} 相等 → 判 unchanged → 新 subKey 丢弃
// 实际只刷了 priorityScore,plan_reasons 残留老的 1 条 missing_tooth
const isActive = latest && (latest.status === 'active' || latest.status === 'assigned');
if (isActive) {
const oldReasonSet = new Set(
latest.reasons.map((r) => {
const ev = r.evidence as string[] | undefined;
// 简化对比:scenario 集合是否一致
return r.scenario + ':' + (Array.isArray(ev) ? '' : '');
}),
);
// 详细对比:基于 reason 文本(简化用 set 长度 + scenario 集合)
const oldScenarios = new Set(latest.reasons.map((r) => r.scenario));
const newScenarios = new Set(hits.map((h) => h.scenarioKey));
const sameScenarios =
oldScenarios.size === newScenarios.size &&
[...oldScenarios].every((s) => newScenarios.has(s));
if (sameScenarios) {
const key = (sc: string, sk: string | null | undefined) => `${sc}|${sk ?? ''}`;
const oldSubScenarios = new Set(latest.reasons.map((r) => key(r.scenario, r.subKey)));
const newSubScenarios = new Set(hits.map((h) => key(h.scenarioKey, h.subKey)));
const sameSubScenarios =
oldSubScenarios.size === newSubScenarios.size &&
[...oldSubScenarios].every((k) => newSubScenarios.has(k));
if (sameSubScenarios) {
// 只刷 priorityScore,不创新版本
if (latest.priorityScore !== newPriorityScore) {
await this.prisma.followupPlan.update({
......@@ -222,24 +219,29 @@ export class PlanEngineService {
recommendedChannel: head.recommendedChannel ?? null,
status: 'active',
reasons: {
// 同 patient 同 scenario 多 sub-rule 命中(如 K02 龋 + K04 根尖 + K01 阻生
// 都触发 treatment_initiation_recall)时合并为单条 reason:
// priorityScore = MAX,reason 文本拼接,subKey 数组 / 取首 subKey,
// evidence factIds 合集
// UNIQUE(plan_id, scenario)约束限制每 plan 内 scenario 唯一。
// W3 末改:plan_reasons 维度 = (plan, scenario, sub_key) — 每独立临床缺口一行,
// 不再合并子规则(K08 缺牙 / K05 牙周 / K04 根管 是不同治疗体系,合并 reason
// 长且生硬、evidence 粒度丢)。话术 / 摘要 / 执行回写仍 plan 级(展示细 / 触达粗)。
// UNIQUE(plan_id, scenario, sub_key)约束保证同子规则不重复。
createMany: {
data: this.mergeHitsByScenario(hits).map((h) => ({
data: hits.map((h) => ({
scenario: h.scenarioKey,
subKey: h.subKey ?? null,
priorityScore: h.priorityScore,
reason: h.reason,
evidence: h.evidence.factIds,
evidence: {
factIds: h.evidence.factIds,
} as Prisma.InputJsonValue,
breakdown: h.priorityBreakdown
? ({
priority: { ...h.priorityBreakdown },
subKey: h.subKey ?? null,
mergedSubKeys: h.mergedSubKeys ?? undefined,
} as Prisma.InputJsonValue)
: Prisma.JsonNull,
// 结构化召回信号(DB 存 raw enum,前端字典翻译富文本渲染)
signals: h.signals
? (h.signals as unknown as Prisma.InputJsonValue)
: Prisma.JsonNull,
lifecycle: this.lifecycleFor(h.scenarioKey, h.subKey),
source: 'algorithm',
})),
......@@ -252,52 +254,6 @@ export class PlanEngineService {
return latest ? 'superseded' : 'created';
}
/**
* 合并同 scenario 的多 sub-rule 命中(满足 UNIQUE(plan_id, scenario) 约束)
*
* 场景:K02 + K04 + K01 多病并发,3 个 sub-rule 都命中
* treatment_initiation_recall scenario → 必须合并为单条 reason。
*
* 合并策略:
* - priorityScore = MAX over hits
* - reason 文本拼接(用 " | " 分隔)
* - subKey = 首个 hit 的 subKey;额外塞 mergedSubKeys 数组到 breakdown
* - evidence.factIds = union
* - priorityBreakdown = 取分最高那条 hit 的 breakdown(细节最有代表性)
* - recommendedAt/Role/Channel = 取首个 hit
*/
private mergeHitsByScenario(
hits: ScenarioHitWithKey[],
): MergedHit[] {
const byScenario = new Map<string, ScenarioHitWithKey[]>();
for (const h of hits) {
const arr = byScenario.get(h.scenarioKey) ?? [];
arr.push(h);
byScenario.set(h.scenarioKey, arr);
}
const merged: MergedHit[] = [];
for (const [, group] of byScenario.entries()) {
if (group.length === 1) {
merged.push({ ...group[0]!, mergedSubKeys: undefined });
continue;
}
const top = [...group].sort((a, b) => b.priorityScore - a.priorityScore)[0]!;
const allFactIds = Array.from(
new Set(group.flatMap((h) => h.evidence.factIds)),
);
const reasonText = group.map((h) => h.reason).join(' | ');
const subKeys = group.map((h) => h.subKey).filter(Boolean) as string[];
merged.push({
...top,
priorityScore: top.priorityScore, // = MAX(已是排序后取首)
reason: reasonText,
evidence: { factIds: allFactIds },
mergedSubKeys: subKeys,
});
}
return merged;
}
/// 子场景 → 生命周期类型(one_shot 短效 / recurring 长效)
private lifecycleFor(scenario: string, subKey?: string): string {
// 复查类长效(种植年度 / 牙周维护),其余短效
......@@ -307,7 +263,6 @@ export class PlanEngineService {
}
type ScenarioHitWithKey = ScenarioHit & { scenarioKey: string };
type MergedHit = ScenarioHitWithKey & { mergedSubKeys?: string[] };
export interface EngineRunResult {
scenariosRun: number;
......
......@@ -40,8 +40,8 @@ export interface ScenarioHit {
/// 期望执行诊所(null = 集团池)
targetClinicId?: string | null;
/// 证据 fact.ids(plan_reasons.evidence_fact_ids UUID[] 落库)
/// **不再装 ruleIds** — 规则版本靠 git + plan.createdAt 追溯,DB 不冗余存字符串常量
/// 证据 fact.ids(plan_reasons.evidence JSON 落库,W3 末从 UUID[] 改 jsonb 统一跟 PersonaFeature 对齐)
/// scenario 输出 { factIds[] };后端 plan-engine 写入时包成 { factIds: [...] } JSON
evidence: {
factIds: string[];
};
......@@ -49,6 +49,11 @@ export interface ScenarioHit {
/// 子场景标识(reason 文本内已含,落 plan_reasons.breakdown.subKey 便于 metrics 检索)
subKey?: string;
/// **W3 末新增**:结构化召回信号(plan_reasons.signals JSON 落库)。
/// DB 存原始 enum / canonical code(不语义化),前端用 @pac/types 字典翻译富文本渲染。
/// 详见 packages/types/src/schemas/reason-signals.ts
signals?: import('@pac/types').ReasonSignals;
/// 6 因子优先级 breakdown(算法 v2);可空兼容旧 scenario
/// 详情页用本字段渲染"客服可解释性"。
priorityBreakdown?: import('./priority-scorer').PriorityBreakdown;
......
......@@ -22,6 +22,7 @@ import {
AssignPlanRequestDto,
ListPlansQueryDto,
ListPlansResponseDto,
PlanCountsResponseDto,
PlanActionAckDto,
PlanDetailResponseDto,
RecomputeAckDto,
......@@ -59,6 +60,19 @@ export class PlanController {
return this.plans.list(scope, query, user.permissions);
}
@Get('counts')
@ZodResponse({ status: 200, type: PlanCountsResponseDto })
@RequirePermission(Permission.PLAN_VIEW_OWN)
@ApiOperation({
summary: 'Plan counts for KPI / tab badges (one SQL roundtrip, replaces 5 list calls)',
})
counts(
@TenantScope() scope: TenantScopeContext,
@CurrentUser() user: AuthenticatedUser,
) {
return this.plans.counts(scope, user.permissions);
}
@Get(':id')
@ZodResponse({ status: 200, type: PlanDetailResponseDto })
@RequirePermission(Permission.PLAN_VIEW_OWN)
......
......@@ -10,7 +10,9 @@ import {
Permission,
planScenarioLabel,
type ListPlansResponse,
type PlanCountsResponse,
type PlanDetailResponse,
type PlanReasonBrief,
} from '@pac/types';
import { maskName, maskPhone } from '@pac/utils';
import { PrismaService } from '../../prisma/prisma.service';
......@@ -87,13 +89,33 @@ export class PlanService {
];
}
// W3 末:服务端 keyword 模糊匹配 — patient.name / phone / externalId 任一命中
// (替代前端本页 .filter,改成跨全表搜索,真实接入万人级数据后才能用)
if (query.keyword) {
where.patient = {
OR: [
{ name: { contains: query.keyword, mode: 'insensitive' } },
{ phone: { contains: query.keyword } },
{ externalId: { contains: query.keyword, mode: 'insensitive' } },
],
};
}
// W3 末:服务端 sort —— 替代前端 .sort,跨页排序正确
const orderBy: Prisma.FollowupPlanOrderByWithRelationInput[] =
query.sort === 'priority_asc'
? [{ priorityScore: 'asc' }, { createdAt: 'asc' }]
: query.sort === 'created_desc'
? [{ createdAt: 'desc' }]
: [{ priorityScore: 'desc' }, { createdAt: 'asc' }]; // priority_desc 默认
const skip = (query.page - 1) * query.pageSize;
const [total, rows] = await Promise.all([
this.prisma.followupPlan.count({ where }),
this.prisma.followupPlan.findMany({
where,
include: { reasons: { orderBy: { priorityScore: 'desc' } } },
orderBy: [{ priorityScore: 'desc' }, { createdAt: 'asc' }],
orderBy,
skip,
take: query.pageSize,
}),
......@@ -125,11 +147,15 @@ export class PlanService {
scenarioLabel: planScenarioLabel(base.scenario),
patient: {
externalId: patient?.externalId ?? '',
/// name 原值透出(权限内可见);nameMasked 保留作脱敏视图兜底
name: patient?.name ?? null,
nameMasked: maskName(patient?.name ?? null),
phoneMasked: maskPhone(patient?.phone ?? null),
gender: patient?.gender ?? null,
age: patient?.birthDate ? calcAge(patient.birthDate) : null,
},
/// reasons 透 signals JSON,前端 ReasonLine 用字典翻译富文本(跟详情页同源)
reasons: p.reasons.map(toPlanReasonBrief),
};
}),
page: query.page,
......@@ -139,6 +165,46 @@ export class PlanService {
}
// ─────────────────────────────────────────────
// counts — KPI / Tab badge 聚合(一次 SQL 5 个 count,避免前端 5 个并发 list 请求)
// ─────────────────────────────────────────────
async counts(
scope: TenantScopeContext,
permissions: readonly string[],
): Promise<PlanCountsResponse> {
const base: Prisma.FollowupPlanWhereInput = {
hostId: scope.hostId,
tenantId: scope.tenantId,
supersededAt: null,
};
// staff 受 clinic 隔离(跟 list 同口径)
const clinicScope: Prisma.FollowupPlanWhereInput =
scope.clinicIds.length > 0
? { OR: [{ targetClinicId: { in: scope.clinicIds } }, { targetClinicId: null }] }
: {};
const canViewAll = permissions.includes(Permission.PLAN_VIEW_ALL);
const [mine, mineAssigned, mineCompleted, pool, all] = await Promise.all([
this.prisma.followupPlan.count({
where: { ...base, assigneeUserId: scope.userId, ...clinicScope },
}),
this.prisma.followupPlan.count({
where: { ...base, assigneeUserId: scope.userId, status: 'assigned', ...clinicScope },
}),
this.prisma.followupPlan.count({
where: { ...base, assigneeUserId: scope.userId, status: 'completed', ...clinicScope },
}),
this.prisma.followupPlan.count({
where: { ...base, status: 'active', assigneeUserId: null, ...clinicScope },
}),
canViewAll
? this.prisma.followupPlan.count({ where: base })
: Promise.resolve(0),
]);
return { mine, mineAssigned, mineCompleted, pool, all };
}
// ─────────────────────────────────────────────
// detail
// ─────────────────────────────────────────────
......@@ -303,6 +369,20 @@ type PlanScriptRow = Prisma.PlanScriptGetPayload<{}>;
type PlanSummaryRow = Prisma.PlanSummaryGetPayload<{}>;
type PlanExecutionRow = Prisma.PlanExecutionGetPayload<{}>;
/// reasons 行 → PlanReasonBrief(给列表用)。signals 透 raw JSON,前端字典翻译富文本。
function toPlanReasonBrief(
r: PlanRow['reasons'][number],
): PlanReasonBrief {
return {
id: r.id,
scenario: r.scenario,
subKey: r.subKey,
signals: (r.signals as PlanReasonBrief['signals']) ?? null,
priorityScore: r.priorityScore,
reason: r.reason,
};
}
function serializePlan(p: PlanRow): import('@pac/types').FollowupPlan {
// FollowupPlanSchema 需要顶层 scenario / inclusionReason / evidence
// DB 这些在 PlanReason 子表;取 primary reason(MAX priorityScore)填充顶层
......@@ -310,7 +390,7 @@ function serializePlan(p: PlanRow): import('@pac/types').FollowupPlan {
const evidence = {
factIds: Array.from(
new Set(
p.reasons.flatMap((r) => (r.evidence as unknown as string[] | null) ?? []),
p.reasons.flatMap((r) => ((r.evidence as { factIds?: string[] } | null)?.factIds ?? [])),
),
),
personaVersion: undefined, // 让 caller 在 detail 里通过 persona.version 补全
......@@ -455,7 +535,7 @@ function computePlanEvidence(
return {
factIds: Array.from(
new Set(
plan.reasons.flatMap((r) => (r.evidence as unknown as string[] | null) ?? []),
plan.reasons.flatMap((r) => ((r.evidence as { factIds?: string[] } | null)?.factIds ?? [])),
),
),
personaVersion: persona?.version,
......
......@@ -281,6 +281,8 @@ export class ColdImportService {
phone: (c.phone as string | undefined) ?? synthesizeDemoPhone(externalId),
gender: (c.gender as string | undefined) ?? null,
birthDate: c.birthDate ? new Date(c.birthDate as string) : null,
medicalRecordNumber:
(c.medicalRecordNumber as string | undefined) ?? null,
preferences:
c.preferences && typeof c.preferences === 'object'
? (c.preferences as Prisma.InputJsonValue)
......
......@@ -61,6 +61,13 @@ const EncounterRecordContent = z
encounter_external_id: z.string().min(1),
doctor_id: nullableString(),
doctor_name: nullableString(),
/// 主诉(SOAP 的 S)— 通用临床字段,任何接诊都有
chief_complaint: nullableString(),
/// 接诊类型(初诊 / 复诊 / 急诊 / 常规检查 等)— 通用临床分类。
/// ⚠️ 不接 host 的"复诊/初诊"标记(那是衍生,PAC 自己从 encounter 集合算更可信);
/// 真正"急诊/常规"等结构化分类 host 多无,该字段大多 null,等其他 host 提供再填。
encounter_type: nullableString(),
/// 一般备注(自由文本)— 跟 chief_complaint 互补
notes: nullableString(),
})
.passthrough();
......@@ -88,6 +95,9 @@ const DiagnosisRecordContent = z
.optional()
.default(null),
onset_date: nullableString(),
/// 诊断医生(继承 emr 父级 user_id;医患关系信号)
doctor_id: nullableString(),
doctor_name: nullableString(),
/// 反查源接诊;算法 JOIN 凭这字段
source_encounter_external_id: z.string().min(1),
})
......@@ -105,6 +115,13 @@ const TreatmentRecordContent = z
status: PACTreatmentStatusSchema,
started_at: nullableString(),
completed_at: nullableString(),
/// 执行医生 id(host 侧)— 医患关系信号(主治医生绑定),供 doctor_relation persona / 话术个性化
doctor_id: nullableString(),
doctor_name: nullableString(),
/// 治疗份数(种植 4 颗 / 龈下刮治 3 区 / 套餐 5 套 …)— 通用临床计量
quantity: nullableNumber(),
/// 计量单位(颗/次/区/套 等)— 跟 quantity 配对
unit_name: nullableString(),
/// 反查源接诊
source_encounter_external_id: nullableString(),
/// 关联诊断 fact(可空 — 临床上治疗也可独立存在)
......@@ -121,6 +138,9 @@ const RecommendationRecordContent = z
.object({
code: PACDiagnosisCodeSchema, // IMPLANT_RECOMMENDED 等"推荐码"
tooth_position: toothPositionText(),
/// 建议医生(继承 emr 父级 user_id;话术个性化用)
doctor_id: nullableString(),
doctor_name: nullableString(),
/// 抽取来源(规则 / LLM / 医生录入)
extracted_by: RecommendationExtractedBySchema,
/// LLM 置信度(0-1);rule / doctor_input 为 null
......@@ -134,17 +154,40 @@ const RecommendationRecordContent = z
.passthrough();
/**
* emr_record — 电子病历(自由文本保留)
* emr_record — 电子病历(PAC 标准病历语义模型,覆盖完整 SOAP 环节)
*
* ⭐ **这是 PAC 自己定的标准,不被任何单一宿主形态牵着走**:
* - 字段 = 临床病历的通用语义全集(主诉/现病史/既往史/检查/诊断/治疗计划/处置/医嘱…),
* 是所有宿主病历的统一投影目标。
* - 某宿主映射不上某字段 → 留 null,**绝不因此从标准里删字段**。
* 例:瑞尔 DW 把诊断/治疗拆成结构化数组(→ diagnosis_record / treatment_record),
* 其 emr 的 diagnosis_text / treatment_plan 自然留 null;
* 但小诊所宿主病历常是"一段诊断描述 + 一段治疗计划自由文本",这俩字段就有值。
* - 宿主有什么、怎么翻译 = yaml field_mapping 的活,宿主形态不反向裁剪 PAC 标准。
*
* 字段是 Layer C(LLM/规则抽取)的源:exam_findings 常带牙位 + 临床描述,可补诊断码覆盖 / 牙位短板。
*/
const EmrRecordContent = z
.object({
emr_external_id: z.string().min(1),
encounter_external_id: nullableString(),
illness_desc: nullableString(),
pre_illness: nullableString(),
// 接诊医生(PAC 不建医生表,医生名作为 fact 快照随病历落库;
// doctor_id 与 settlement.doctor_id 同源 → treatment/payment 可借此映射到医生名)
doctor_id: nullableString(),
doctor_name: nullableString(),
// S 主观:主诉 / 现病史 / 既往史 / 全身情况
illness_desc: nullableString(), // 主诉 / 疾病描述
pre_illness: nullableString(), // 现病史
past_history: nullableString(), // 既往史
general_condition: nullableString(), // 全身情况 / 系统回顾
// O 客观:检查所见
exam_findings: nullableString(), // 检查所见(可带牙位的临床观察)
// A 评估:诊断自由文本(结构化诊断走 diagnosis_record;宿主无结构化时落这里)
diagnosis_text: nullableString(),
// P 计划:治疗计划自由文本(结构化治疗走 treatment_record)+ 处置 + 医嘱
treatment_plan: nullableString(),
doctor_advice: nullableString(),
disposal: nullableString(), // 处置(本次实操)
doctor_advice: nullableString(), // 医嘱
})
.passthrough(); // 自由文本 fact 允许 host 多带字段(算法不消费,Layer C 抽取消费)
......@@ -159,6 +202,9 @@ const ImageRecordContent = z
tooth_positions: z.array(z.string()).nullable().optional().default(null),
finding: nullableString(),
captured_at: nullableString(),
/// 拍摄/解读医生(继承 emr 父级 user_id,通常 = 接诊医生)
doctor_id: nullableString(),
doctor_name: nullableString(),
})
.passthrough();
......@@ -183,8 +229,31 @@ const AppointmentRecordContent = z
arrived_at: isoDateString.nullable().optional().default(null),
appointment_type: nullableString(),
doctor_id: nullableString(),
// 预约科目/就诊意向(常规/正畸/种植/修复/拔牙/牙周…),host appo_complaint_category
complaint_category: nullableString(),
// 预约主诉自由文本(跟 complaint_category 配对:分类 + 原文)— Layer C 源
complaint_text: nullableString(),
// 预约时长(分钟),host appo_time_period
duration_minutes: nullableNumber(),
// 取消原因(自由文本):"临时有事 / 不用回访 / 再约 / 去了其他诊所…"— 爽约/取消分析
cancellation_reason: nullableString(),
// 预约备注(运营内部自由文本):"转 X 医 / 后续修复 / …"— Layer C 备料
staff_notes: nullableString(),
// 8 态细分(host 8 状态 1:1):
// scheduled(正常,唯一有效预约)/ rescheduled(已改约,历史被取代)/ cancelled(已取消)
// arrived(到诊)/ in_treatment(接诊)/ completed(结算,到诊+消费完成)
// no_show(爽约)/ walk_in(walk-in 变更)
status: z
.enum(['scheduled', 'arrived', 'cancelled', 'no_show'])
.enum([
'scheduled',
'rescheduled',
'cancelled',
'arrived',
'in_treatment',
'completed',
'no_show',
'walk_in',
])
.nullable()
.optional()
.default(null),
......@@ -258,6 +327,11 @@ const PaymentRecordContent = z
payment_external_id: z.string().min(1),
amount_cents: z.number().int().nonnegative(),
channel: nullableString(),
/// 收费医生 id(host 侧)— 医患关系信号(患者主要花钱给哪个医生)
doctor_id: nullableString(),
/// 关联接诊 id(反查"这次收款关联哪次接诊")
encounter_external_id: nullableString(),
/// 关联医嘱单 id(charge_order)— 其他 host 可能提供;host 不区分 order/encounter 时留 null
related_order_external_id: nullableString(),
})
.passthrough();
......@@ -288,10 +362,29 @@ const ComplaintRecordContent = z
})
.passthrough();
/**
* referral_record — 转介绍(老带新 / 渠道归因)
*
* fact 挂在**被推荐人**(referee = fact.patient_id),表达"我是被 XXX 推荐来的"。
* 推荐人(referrer)信息作为**快照**进 content(PAC 不维护医生/员工/患者主数据外的 referrer 表;
* referrer 可能跨 host / 跨诊所 / 是员工,统一存名 + 类型即可,跟 emr.doctor_name 模式一致)。
*/
const ReferralRecordContent = z
.object({
referral_external_id: z.string().min(1),
referrer_patient_external_id: nullableString(),
/// 推荐人 host id(可能是 patient_id / employee_id);仅作标识,PAC 不强约束类型
referrer_external_id: nullableString(),
/// 推荐人姓名(快照)
referrer_name: nullableString(),
/// 推荐人病历号(若 referrer 是患者;辅助标识)
referrer_chart_number: nullableString(),
/// 推荐人类型:patient(老带新)/ staff(员工)/ partner(合作渠道)/ other
referrer_type: z
.enum(['patient', 'staff', 'partner', 'other'])
.nullable()
.optional()
.default(null),
/// 推荐渠道(host 可能不提供)
channel: nullableString(),
})
.passthrough();
......
......@@ -35,8 +35,10 @@ export class AppointmentParser implements Parser {
const hostStatus = String(c.status ?? 'scheduled').toLowerCase();
const factStatus = AppointmentParser.STATUS_MAP[hostStatus] ?? FactStatus.ACTIVE;
// arrived → 该预约已变成"已发生事实";其他状态保持 planned
const kind = hostStatus === 'arrived' ? FactKind.ACTUAL : FactKind.PLANNED;
// 到诊/接诊/结算 = 已发生事实(actual);其余(含已改约/取消/爽约)= planned
const kind = AppointmentParser.ACTUAL_STATUSES.has(hostStatus)
? FactKind.ACTUAL
: FactKind.PLANNED;
const scheduledAt = c.scheduledAt ? new Date(c.scheduledAt as string) : null;
const arrivedAt = c.arrivedAt ? new Date(c.arrivedAt as string) : null;
......@@ -51,9 +53,13 @@ export class AppointmentParser implements Parser {
type: FactType.APPOINTMENT_RECORD,
status: factStatus,
clinicId: ctx.transaction.clinicId,
// kind=actual 时 occurredAt = arrivedAt;kind=planned 时 plannedFor = scheduledAt
occurredAt: kind === FactKind.ACTUAL ? arrivedAt : null,
plannedFor: kind === FactKind.PLANNED ? scheduledAt : null,
// 时间语义(两列不同):
// scheduledAt ← appo_time(约定来诊"日期",Date,100% 有)
// arrivedAt ← in_time(实际到诊"时刻",DateTime,仅到诊类填,且~25% 到诊缺失)
// occurredAt(已发生):到诊时刻优先,缺失时兜底约定日(否则到诊无 in_time 会两时间皆 null 沉底)
// plannedFor(约定):始终 = 约定日(预约固有属性,不因 kind 丢失)
occurredAt: kind === FactKind.ACTUAL ? (arrivedAt ?? scheduledAt) : null,
plannedFor: scheduledAt,
validFrom: scheduledAt,
title: appointmentType
? `预约 ${appointmentType}(${this.formatDate(scheduledAt)})`
......@@ -67,30 +73,58 @@ export class AppointmentParser implements Parser {
: null,
content: {
appointment_external_id: externalId,
scheduled_at: c.scheduledAt ?? null,
arrived_at: c.arrivedAt ?? null,
scheduled_at: scheduledAt ? scheduledAt.toISOString() : null,
arrived_at: arrivedAt ? arrivedAt.toISOString() : null,
appointment_type: appointmentType,
doctor_id: doctorId,
// host 业务状态(scheduled/arrived/cancelled/no_show)— 跟 fact.status(PAC 版本流)互补
complaint_category: (c.complaintCategory as string | undefined) ?? null,
complaint_text: (c.complaintText as string | undefined) ?? null,
duration_minutes:
c.durationMinutes != null && c.durationMinutes !== ''
? Number(c.durationMinutes)
: null,
cancellation_reason:
(c.cancellationReason as string | undefined) ?? null,
staff_notes: (c.staffNotes as string | undefined) ?? null,
// host 业务状态(8 态细分)— 跟 fact.status(PAC 版本流)互补
status: AppointmentParser.HOST_STATUS_ENUM[hostStatus] ?? null,
},
},
];
}
// host status → FactStatus(PAC 版本流状态)
// 已发生事实(actual)的 host 状态:到诊 / 接诊 / 结算 + walk-in 变更(已到店)
private static readonly ACTUAL_STATUSES = new Set([
'arrived',
'in_treatment',
'completed',
'walk_in',
]);
// host status(8 态)→ FactStatus(PAC 版本流状态)
// scheduled → active(唯一有效预约)
// rescheduled → superseded(已被改约取代的历史,不当有效预约,默认时间轴/召回不计)
// arrived/in_treatment/completed/walk_in → fulfilled(已发生)
// cancelled → cancelled / no_show → expired
private static readonly STATUS_MAP: Record<string, FactStatus> = {
scheduled: FactStatus.ACTIVE,
rescheduled: FactStatus.SUPERSEDED,
arrived: FactStatus.FULFILLED,
in_treatment: FactStatus.FULFILLED,
completed: FactStatus.FULFILLED,
walk_in: FactStatus.FULFILLED,
cancelled: FactStatus.CANCELLED,
no_show: FactStatus.EXPIRED,
};
// host_status → content.status(host 业务状态闭集,zod 强校验)
// 防御性映射:任何未识别的 host 值 → null,fact.status 仍能由 STATUS_MAP fallback
// host_status → content.status(8 态闭集,zod 强校验);未识别 → null
private static readonly HOST_STATUS_ENUM: Record<string, string | null> = {
scheduled: 'scheduled',
rescheduled: 'rescheduled',
arrived: 'arrived',
in_treatment: 'in_treatment',
completed: 'completed',
walk_in: 'walk_in',
cancelled: 'cancelled',
no_show: 'no_show',
};
......
......@@ -53,7 +53,9 @@ export class DiagnosisParser implements Parser {
const toothPosition = (c.toothPosition as string | undefined) ?? null;
const sourceEncounter = (c.sourceEncounterExternalId as string | undefined) ?? null;
const nameZh = (c.name as string | undefined) ?? null;
// host 录入习惯尾部带标点("慢性牙龈炎;" / "牙周炎。" / "牙龈炎," / "龋齿?" / "缺失?"...)
// 在 parser 层一次性清洗:首尾空白 + 尾部中英文标点 → 下游 chain-composer / WhyCard / persona 全部受益
const nameZh = cleanName((c.name as string | undefined) ?? null);
return [
{
......@@ -72,9 +74,24 @@ export class DiagnosisParser implements Parser {
tooth_position: toothPosition,
severity: (c.severity as string | undefined) ?? null,
onset_date: (c.onsetDate as string | undefined) ?? null,
doctor_id: c.doctorId ? String(c.doctorId) : null, // host user_id Int64 → string
doctor_name: (c.doctorName as string | undefined) ?? null,
source_encounter_external_id: sourceEncounter ?? externalId,
},
},
];
}
}
/// 诊断名清洗:首尾空白 + 尾部标点(中英文,含 host 常见的 ;。,;,??!! 等)
/// 例:" 慢性牙龈炎;" → "慢性牙龈炎"
function cleanName(s: string | null): string | null {
if (!s) return null;
// 1. 首尾空白(含全角空格)
// 2. 尾部标点 — 中英文混合:;。,;.,??!!::、
const cleaned = s
.trim()
.replace(/[\s ;。,;.,??!!::、]+$/g, '')
.trim();
return cleaned || null;
}
......@@ -15,6 +15,15 @@ export class EmrParser implements Parser {
readonly name = Action.EMR_SUBMITTED;
private readonly logger = new Logger(EmrParser.name);
/// 规整为文本:examine/dispose 在 host 是 JSON 文本,但 CH 读取层会把像 JSON 的
/// String 自动 parse 成 object/array。这里统一存回 JSON 原文字符串(content schema 要 string,
/// Layer C 后续自己 parse)。纯文本字段经此原样返回。
private asText(v: unknown): string | null {
if (v == null) return null;
if (typeof v === 'string') return v;
return JSON.stringify(v);
}
parse(ctx: ParserContext): FactDraft[] {
const c = ctx.canonicalRow;
const externalId = String(c.externalId ?? ctx.transaction.subjectId).trim();
......@@ -43,11 +52,19 @@ export class EmrParser implements Parser {
content: {
emr_external_id: externalId,
encounter_external_id: encounterExternalId,
diagnosis_text: c.diagnosisText ?? null,
diagnosis_codes: c.diagnosisCodes ?? null,
treatment_plan: c.treatmentPlan ?? null,
doctor_advice: c.doctorAdvice ?? null,
image_ids: c.imageIds ?? [],
doctor_id: this.asText(c.doctorId), // host user_id(医生),与 settlement.doctor_id 同源
doctor_name: this.asText(c.doctorName), // 接诊医生名(快照,PAC 不建医生表)
// SOAP 自由文本段落(canonical 字段由 emr.yaml field_mapping 翻译)
// PAC 标准病历字段全集(宿主映射不上的自然 null,如瑞尔的 diagnosis_text/treatment_plan)
illness_desc: this.asText(c.illnessDesc),
pre_illness: this.asText(c.preIllness),
past_history: this.asText(c.pastHistory),
general_condition: this.asText(c.generalCondition),
exam_findings: this.asText(c.examFindings), // host examine 原文(JSON,带牙位)
diagnosis_text: this.asText(c.diagnosisText), // 结构化宿主留 null,自由文本宿主有值
treatment_plan: this.asText(c.treatmentPlan), // 同上
disposal: this.asText(c.disposal), // host dispose 原文(JSON)
doctor_advice: this.asText(c.doctorAdvice), // host doc_order
quality_check: qualityStatus
? {
status: qualityStatus,
......
......@@ -31,6 +31,8 @@ export class EncounterParser implements Parser {
const doctorId = (c.doctorId as string | undefined) ?? null;
const doctorName = (c.doctorName as string | undefined) ?? null;
const chiefComplaint = (c.chiefComplaint as string | undefined) ?? null;
const encounterType = (c.encounterType as string | undefined) ?? null;
const notes = (c.notes as string | undefined) ?? null;
return [
......@@ -42,11 +44,13 @@ export class EncounterParser implements Parser {
clinicId: ctx.transaction.clinicId,
occurredAt: ctx.transaction.occurredAt,
title: `接诊 ${encExternalId}`,
summary: notes ?? null,
summary: chiefComplaint ?? notes ?? null,
content: {
encounter_external_id: encExternalId,
doctor_id: doctorId,
doctor_name: doctorName,
chief_complaint: chiefComplaint,
encounter_type: encounterType,
notes,
},
},
......
......@@ -45,6 +45,8 @@ export class ImageParser implements Parser {
tooth_positions: toothPositions,
finding,
encounter_external_id: encounterExternalId,
doctor_id: c.doctorId ? String(c.doctorId) : null,
doctor_name: (c.doctorName as string | undefined) ?? null,
},
},
];
......
......@@ -38,8 +38,11 @@ export class PaymentParser implements Parser {
const amount = Number(c.amount ?? 0); // cents
const paidAt = c.paidAt ? new Date(c.paidAt as string) : null;
const encounterExternalId =
(c.encounterExternalId as string | undefined) ?? null;
const orderExternalId = (c.orderExternalId as string | undefined) ?? null;
const method = (c.method as string | undefined) ?? null;
const doctorId = c.doctorId ? String(c.doctorId) : null; // host Int64,0/null → null
const currency = (c.currency as string | undefined) ?? 'CNY';
return [
......@@ -56,6 +59,8 @@ export class PaymentParser implements Parser {
payment_external_id: externalId,
amount_cents: amount,
channel: method,
doctor_id: doctorId,
encounter_external_id: encounterExternalId,
related_order_external_id: orderExternalId,
},
},
......
......@@ -67,6 +67,8 @@ export class RecommendationParser implements Parser {
content: {
code: codeParsed.data,
tooth_position: (c.toothPosition as string | undefined) ?? null,
doctor_id: c.doctorId ? String(c.doctorId) : null,
doctor_name: (c.doctorName as string | undefined) ?? null,
extracted_by: extractedBy,
confidence,
source_fact_subject_id:
......
......@@ -6,8 +6,9 @@ import type { FactDraft, Parser, ParserContext } from './parser.interface';
* ReferralParser — `referral_recorded` 解析器
*
* 产 1 个 `referral_record`(actual)— 老带新 / 渠道归因事实。
* fact 挂在**推荐人**(referrer)patient 上;被推荐人(referee)的 fact 由
* 应用层按需聚合(persona.engagement / 渠道分析时反查)。
* fact 挂在**被推荐人**(referee = fact.patient_id),表达"我是被 XXX 推荐来的"。
* 推荐人(referrer)作为快照进 content(PAC 不维护 referrer 主数据;
* referrer 可能跨 host / 是员工,统一存名 + 类型即可,跟 emr.doctor_name 模式一致)。
*/
@Injectable()
export class ReferralParser implements Parser {
......@@ -18,15 +19,26 @@ export class ReferralParser implements Parser {
const c = ctx.canonicalRow;
const externalId = String(c.externalId ?? ctx.transaction.subjectId).trim();
if (!externalId) {
this.logger.warn(`referral parser: missing externalId; tx=${ctx.transaction.id};skip`);
this.logger.warn(
`referral parser: missing externalId; tx=${ctx.transaction.id};skip`,
);
return [];
}
const recordedAt = c.recordedAt ? new Date(c.recordedAt as string) : null;
const referrerExternalId = c.referrerExternalId
? String(c.referrerExternalId)
: null;
const referrerName = (c.referrerName as string | undefined) ?? null;
const referrerChartNumber =
(c.referrerChartNumber as string | undefined) ?? null;
const referrerTypeRaw = (c.referrerType as string | undefined) ?? null;
const referrerType = (
['patient', 'staff', 'partner', 'other'] as const
).includes(referrerTypeRaw as never)
? (referrerTypeRaw as 'patient' | 'staff' | 'partner' | 'other')
: null;
const channel = (c.channel as string | undefined) ?? null;
const conversionAmount = c.conversionAmount != null ? Number(c.conversionAmount) : null;
const referrerExternalId = (c.referrerPatientExternalId as string | undefined) ?? null;
const refereeExternalId = (c.refereePatientExternalId as string | undefined) ?? null;
return [
{
......@@ -36,14 +48,15 @@ export class ReferralParser implements Parser {
status: FactStatus.ACTIVE,
clinicId: ctx.transaction.clinicId,
occurredAt: recordedAt,
title: `转介绍 ${externalId}${channel ? '(' + channel + ')' : ''}`,
summary: refereeExternalId ? `推荐对象:${refereeExternalId}` : null,
title: `转介绍来源 ${referrerName ?? referrerExternalId ?? '?'}${channel ? '(' + channel + ')' : ''}`,
summary: null,
content: {
referral_external_id: externalId,
referrer_external_id: referrerExternalId,
referrer_name: referrerName,
referrer_chart_number: referrerChartNumber,
referrer_type: referrerType,
channel,
referrer_patient_external_id: referrerExternalId,
referee_patient_external_id: refereeExternalId,
conversion_amount: conversionAmount,
},
},
];
......
......@@ -21,7 +21,11 @@ export class RefundParser implements Parser {
return [];
}
const amount = Number(c.amount ?? 0); // cents
// Math.abs 归一:host 退费两种表达
// ① is_refund=1 → settlement_money 通常 > 0(被退金额本身)
// ② settlement_status=4 → settlement_money 通常 < 0(反向冲减)
// 业务语义统一为正 cents = "患者拿回的钱"
const amount = Math.abs(Number(c.amount ?? 0)); // cents
const refundedAt = c.refundedAt ? new Date(c.refundedAt as string) : null;
const paymentExternalId = (c.paymentExternalId as string | undefined) ?? null;
const reason = (c.reason as string | undefined) ?? null;
......@@ -38,8 +42,8 @@ export class RefundParser implements Parser {
summary: reason,
content: {
refund_external_id: externalId,
amount,
payment_external_id: paymentExternalId,
amount_cents: amount, // schema 要 amount_cents(W3 末 jvs-dw 接入时修正)
related_payment_external_id: paymentExternalId, // schema 要 related_payment_external_id
reason,
},
},
......
......@@ -55,6 +55,7 @@ export class TreatmentParser implements Parser {
const subtype = (c.subtype as string | undefined) ?? null;
const startedAt = c.startedAt ? new Date(c.startedAt as string) : null;
const completedAt = c.completedAt ? new Date(c.completedAt as string) : null;
const doctorId = c.doctorId ? String(c.doctorId) : null; // host Int64,0/null → null
const sourceEncounter = (c.sourceEncounterExternalId as string | undefined) ?? null;
const relatedDx = (c.relatedDiagnosisExternalId as string | undefined) ?? null;
const reviewWindow =
......@@ -85,6 +86,13 @@ export class TreatmentParser implements Parser {
status: contentStatus,
started_at: startedAt ? startedAt.toISOString() : null,
completed_at: completedAt ? completedAt.toISOString() : null,
doctor_id: doctorId,
doctor_name: (c.doctorName as string | undefined) ?? null,
quantity:
c.quantity != null && c.quantity !== ''
? Number(c.quantity)
: null,
unit_name: (c.unitName as string | undefined) ?? null,
source_encounter_external_id: sourceEncounter,
related_diagnosis_subject_id: relatedDx,
review_window_days: reviewWindow,
......
......@@ -159,7 +159,17 @@ describe('canonical-fact-layer | 闸 4:yaml enum_mapping 目标 ∈ canonical-co
// appointment.status 是 host-side enum(scheduled/arrived/cancelled/no_show),不在 PAC canonical-codes
// 不强约束 — 校验跳过该字段
};
const appointmentStatusEnum = new Set(['scheduled', 'arrived', 'cancelled', 'no_show']);
// 8 态细分(跟 fact-content-schemas AppointmentRecordContent.status 对齐)
const appointmentStatusEnum = new Set([
'scheduled',
'rescheduled',
'cancelled',
'arrived',
'in_treatment',
'completed',
'no_show',
'walk_in',
]);
for (const { host, file, config } of yamls) {
const mappings = config.enum_mapping ?? {};
......
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
......@@ -124,3 +124,10 @@ body {
font-variant-numeric: tabular-nums;
font-feature-settings: "tnum";
}
/* fadeIn — EmrSoapView carousel 切换淡入 */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(2px); }
to { opacity: 1; transform: translateY(0); }
}
......@@ -41,7 +41,8 @@ export function adaptData(real: PlanDetailData, dict?: TokenDictionary) {
profile: {
doNotContact: real.profile?.doNotContact ?? false,
deceased: real.profile?.deceased ?? false,
primaryContactType: real.profile?.primaryContactType ?? '本人',
// host 没给 → null,UI 用 '—' 占位;不再前端硬兜底 '本人'(误导客服以为是本人)
primaryContactType: real.profile?.primaryContactType ?? null,
ltv: Math.round((real.profile?.ltvCents ?? 0) / 100),
firstVisit: real.profile?.firstVisit ?? '',
lastVisit: real.profile?.lastVisit ?? '',
......@@ -60,13 +61,21 @@ export function adaptData(real: PlanDetailData, dict?: TokenDictionary) {
target: c.target,
diagnosedAt: c.diagnosedAt,
gapDays: c.gapDays,
lifecycleNoteZh: c.lifecycleNoteZh,
toothPosition: c.toothPosition,
alternativeClosedBy: c.alternativeClosedBy,
nodes: c.nodes.map((n) => ({
stage: n.stage as 1 | 2 | 3 | 4 | 5,
label: n.label,
title: n.title,
detail: n.detail,
doctor: n.doctor,
at: n.at,
hint: n.hint,
done: n.done,
current: n.current,
missing: n.missing,
// 旧字段兼容透传(老调用方)
label: n.label,
note: n.note,
})),
}));
......@@ -92,12 +101,14 @@ export function adaptData(real: PlanDetailData, dict?: TokenDictionary) {
const planReasons: PlanReason[] = (real.plan?.reasons ?? []).map((r) => ({
id: r.id,
scenario: r.scenario,
subKey: r.subKey,
signals: r.signals as PlanReason['signals'],
scenarioLabel: planScenarioLabel(r.scenario),
priorityScore: r.priorityScore,
lifecycle: r.lifecycle as 'one_shot' | 'recurring',
source: r.source as PlanReason['source'],
reason: r.reason,
evidence: r.evidenceFactIds.map((id) => {
evidence: (r.evidence?.factIds ?? []).map((id) => {
const f = real.facts.find((x) => x.id === id);
return {
id,
......@@ -140,6 +151,7 @@ export function adaptData(real: PlanDetailData, dict?: TokenDictionary) {
kind: f.kind,
status: f.status,
occurredAt: f.occurredAt ?? null,
plannedFor: f.plannedFor ?? null,
clinicId: f.clinicId ?? null,
title: f.title ?? null,
summary: f.summary ?? null,
......@@ -153,7 +165,17 @@ export function adaptData(real: PlanDetailData, dict?: TokenDictionary) {
plan,
facts,
summaries: mockSummaries, // AI 假数据 — 接入 Vercel AI SDK 时换
script: mockScript, // 同上
// W4:话术 DB 持久化 — LLM 生成完会落 plan_scripts;real.script 命中即用真实数据
// 没生成过(real.script=null) → mock 兜底,客服点"重新生成"触发首次生成
// shape 跟 mockScript 完全一致(status/source/generatedAt/sections),用 as 转把后端宽 string 收窄到 mock 字面量类型
script: real.script && real.script.status === 'ready'
? ({
status: 'ready',
source: real.script.source,
generatedAt: new Date(real.script.generatedAt),
sections: real.script.sections,
} as typeof mockScript)
: mockScript,
outcomeOptions: mockOutcomes as OutcomeOption[],
fmtRel,
};
......@@ -166,6 +188,7 @@ export type AdaptedFact = {
kind: string;
status: string;
occurredAt: string | null;
plannedFor: string | null;
clinicId: string | null;
title: string | null;
summary: string | null;
......
......@@ -48,7 +48,7 @@ export const mockPatient = {
profile: {
doNotContact: false,
deceased: false,
primaryContactType: '本人',
primaryContactType: '本人' as string | null,
ltv: 48200,
firstVisit: '2022-06-08',
lastVisit: fmtDate(daysAgo(38)),
......@@ -58,21 +58,41 @@ export const mockPatient = {
// ────────────────────────────────────────────────
// Treatment Chains(治疗链 5 阶段,锚定 pac-goal-alignment §0)
// ────────────────────────────────────────────────
/// 5 阶段节点结构(W3 末重塑,跟后端 chain-composer ChainNode 同源)
/// 每节点最多 4 信息位 + 视觉状态:
/// - title : 具体动作(如 慢性牙周炎 / *全口洁治 / 牙周刮治术)
/// - detail : 关键事实(如 全口 26 牙 / 1/3 步骤 / ¥58)
/// - doctor : 主治 / 计划 医生名
/// - at : 日期 YYYY.MM.DD
/// - hint : discovered/entered/未闭环 显示的"建议下一步" 友好提示
/// label/note 保留兼容(旧前端调用)— 优先用 title/detail/doctor/hint
export type ChainNode = {
stage: 1 | 2 | 3 | 4 | 5;
label: string;
title?: string;
detail?: string;
doctor?: string;
at: string;
note?: string;
hint?: string;
done?: boolean;
current?: boolean;
missing?: boolean;
/// 兼容旧字段(逐步淘汰)
label?: string;
note?: string;
};
/// 5 阶段链状态(W3 末从 3 态升级,跟后端 chain-composer 同源)
/// - discovered: 仅 S1 命中(诊断/推荐)— 召回算法关注的"潜在新链"
/// - entered: S2 命中(已挂号/预约/付款但未真正治疗)
/// - ongoing: S3 或 S4 命中(治疗中 / 复查中)
/// - closed: S5 全条件满足 — 真正闭环(lifelong_maintenance 类永不到此)
export type ChainStatus = 'discovered' | 'entered' | 'ongoing' | 'closed';
export type Chain = {
id: string;
name: string;
category: string;
status: 'closed' | 'ongoing' | 'uninitiated';
status: ChainStatus;
currentStage: 1 | 2 | 3 | 4 | 5;
value?: number;
estimatedValue?: number;
......@@ -80,6 +100,12 @@ export type Chain = {
target?: boolean;
diagnosedAt?: string;
gapDays?: number;
/// 生命周期提示("终身维护(永不闭环)" 等)— UI tooltip
lifecycleNoteZh?: string;
/// 桶牙位(cross-chain alternative-closed 判定用,UI 不直接展示)
toothPosition?: string;
/// "替代治疗已覆盖"原因 chain.name(K04 后续 K08+种植覆盖等)
alternativeClosedBy?: string;
nodes: ChainNode[];
};
......@@ -125,7 +151,7 @@ export const mockChains: Chain[] = [
id: 'chain_implant_NEW',
name: '种植修复 · 36 号牙',
category: 'implant',
status: 'uninitiated',
status: 'discovered',
currentStage: 1,
target: true,
estimatedValue: 22000,
......@@ -208,6 +234,16 @@ export const mockPersona = {
export type PlanReason = {
id: string;
scenario: string;
/// 子规则 key(missing_tooth / perio_no_srp / endo_no_rct …)— W3 末 plan_reasons 拆细后每行带
subKey?: string | null;
/// W3 末:结构化召回信号(DB 存 raw enum,前端字典翻译富文本)
signals?: {
subKey: string;
triggers: Array<{ type: string; code?: string | null }>;
toothPosition?: string | null;
daysSince: number;
expectedCategories: string[];
} | null;
scenarioLabel: string;
priorityScore: number;
lifecycle: 'one_shot' | 'recurring';
......@@ -371,7 +407,7 @@ export const mockScript = {
},
{
id: 'followup',
label: '切入种植话题',
label: '切入话题',
durationHint: '1–2 分钟',
markdown: `**目的**:把 5 个月前的诊断结论,自然回到"该启动了"。
......
/**
* persona-display — persona feature.description 展示辅助
*
* 后端 persona feature description 故意带 `[英文 enum]` 前缀(给 LLM prompt 当
* machine-readable hint,例 `[gap] 治疗链有 3 处缺口`),前端展示要中文化或分离成 chip。
*
* 用法:
* const { tag, text } = cleanPersonaValue(feature.value);
* // tag 渲染成小灰 chip(可空);text 主描述
*/
const PERSONA_STATUS_ZH: Record<string, string> = {
// treatment_chain_status
gap: '缺口',
ongoing: '在管',
closed: '闭环',
in_progress: '在管',
no_chain: '无链',
// recall_risk
high: '高',
medium: '中',
low: '低',
none: '无',
// do_not_contact_status
contactable: '可触达',
DNC: '拒不打扰',
// value
diamond: '钻卡',
gold: '金卡',
silver: '银卡',
normal: '普通',
new: '新客',
};
export function cleanPersonaValue(raw: string | null | undefined): {
tag: string | null;
text: string;
} {
if (!raw) return { tag: null, text: '—' };
const m = /^\s*\[([A-Za-z_]+)\]\s*(.*)$/.exec(raw);
if (!m) return { tag: null, text: raw };
const enumKey = m[1]!;
const text = m[2]!.trim();
const tag = PERSONA_STATUS_ZH[enumKey] ?? enumKey;
// 文本完全等同 tag 翻译时(例 "[contactable] 可触达") 只显示 tag,避免重复
if (text === PERSONA_STATUS_ZH[enumKey]) return { tag, text: '' };
return { tag, text };
}
/**
* 取 persona description 的"短标签" — 用于"关键事实"等紧凑位置(右侧 hint 列)
*
* 后端 description 常带详细括号(给客服详情看),紧凑位放不下,要短词:
* "新客/未消费(累计净消费 ¥58.00,含 ...)" → "新客"
* "钻卡(LTV ¥48,200,含 ...)" → "钻卡"
* "[gap] 治疗链有 3 处缺口 — K05..." → "缺口"(取 [enum] 翻译)
*
* 规则:
* ① 有 [enum] 前缀 → 用翻译后的 tag(简洁)
* ② 否则 → 截首段(到第一个 "/" 或 "(" 或 "(" 前)
*/
export function shortPersonaValueLabel(raw: string | null | undefined): string {
if (!raw) return '—';
// ① [enum] 前缀模式 — 直接用 tag
const m = /^\s*\[([A-Za-z_]+)\]/.exec(raw);
if (m) return PERSONA_STATUS_ZH[m[1]!] ?? m[1]!;
// ② 截首段:遇到 · / 或 ( 或 ( 停(分隔符语义都是"主词 · 详情")
const stop = raw.search(/[·\/(()]/);
return (stop > 0 ? raw.slice(0, stop) : raw).trim();
}
......@@ -51,11 +51,25 @@ export type PlanDetailData = {
reasons: Array<{
id: string;
scenario: string;
subKey: string | null;
/// 结构化召回信号(DB 存 raw enum,前端用 @pac/types 字典翻译富文本)
signals: {
subKey: string;
triggers: Array<{ type: string; code?: string | null }>;
toothPosition?: string | null;
daysSince: number;
expectedCategories: string[];
} | null;
priorityScore: number;
reason: string;
lifecycle: string;
source: string;
evidenceFactIds: string[];
/// W3 末:evidence 改 JSON 统一(跟 PersonaFeature 对齐),前端读 evidence.factIds
evidence: {
factIds: string[];
agentInvocationIds?: string[];
ruleIds?: string[];
} | null;
/// v2.1 算法可解释性 — 6 因子算分 breakdown(详情页"为什么 X 分"渲染数据)
/// null = scenario plugin 未产 breakdown(老 scenario / 手动添加)
breakdown:
......@@ -92,19 +106,31 @@ export type PlanDetailData = {
id: string;
name: string;
category: string;
status: 'closed' | 'ongoing' | 'uninitiated';
/// 5 阶段链状态(W3 末从 3 态升级,跟后端 chain-composer 同源)
status: 'discovered' | 'entered' | 'ongoing' | 'closed';
currentStage: number;
target?: boolean;
estimatedValueCents?: number;
diagnosedAt?: string;
gapDays?: number;
/// 生命周期提示("终身维护(永不闭环)" 等)
lifecycleNoteZh?: string;
/// 桶牙位(cross-chain alternative-closed 判定用,UI 不直接展示)
toothPosition?: string;
/// "替代治疗已覆盖"原因 chain.name(K04 后续 K08+种植覆盖等)
alternativeClosedBy?: string;
nodes: Array<{
stage: number;
label: string;
title?: string;
detail?: string;
doctor?: string;
at: string;
hint?: string;
done?: boolean;
current?: boolean;
missing?: boolean;
/// 旧兼容字段(逐步淘汰)
label?: string;
note?: string;
}>;
}>;
......@@ -114,9 +140,24 @@ export type PlanDetailData = {
kind: string;
status: string;
occurredAt: string | null;
plannedFor: string | null;
clinicId: string | null;
title: string | null;
summary: string | null;
content: unknown;
}>;
/// W4:LLM 生成完会 upsert 到 DB(plan_scripts),此处反 parse 4 段 sections;
/// null = 该 plan 还未生成话术,前端走 mockScript 兜底 + 引导客服点"重新生成"
script: {
id: string;
status: string; // ready / pending / failed
source: 'agent' | 'template_fallback' | 'manual';
generatedAt: string;
sections: Array<{
id: string;
label: string;
durationHint: string;
markdown: string;
}>;
} | null;
};
'use client';
import {
subLabelZh,
triggerTypeLabelZh,
diagnosisCodeNameZh,
treatmentCategoryNameZh,
} from '@pac/types';
import { formatToothPosition, formatDaysReadable } from '@/lib/utils';
/**
* ReasonLine — 召回理由富文本渲染。
*
* 数据契约:后端只回原始 enum / canonical code(reason.signals JSON),
* 前端用 @pac/types 字典翻译 + 富文本组合(加粗 + 玫红高亮,色彩克制)。
*
* 同源:列表页 PatientPlanCard + 详情页 WhyCard 共用。
*
* 老数据/异常兜底:signals 缺失时落回 reason 文本。
*/
export interface ReasonLineInput {
scenario: string;
reason: string;
signals?: {
subKey: string;
triggers: Array<{ type: string; code?: string | null }>;
toothPosition?: string | null;
daysSince: number;
expectedCategories: string[];
} | null;
}
export function ReasonLine({ reason }: { reason: ReasonLineInput }) {
if (!reason.signals) return <span>{reason.reason}</span>;
const s = reason.signals;
const trig = s.triggers[0]; // 当前单源;多源 W5+ 再调
const subLabel = subLabelZh(reason.scenario, s.subKey);
const cats = s.expectedCategories.map(treatmentCategoryNameZh).join(' / ');
return (
<span>
<strong className="text-slate-900">{subLabel}</strong>
{s.toothPosition && ` · 牙位 ${formatToothPosition(s.toothPosition)}`}
{trig?.code && ` — ${diagnosisCodeNameZh(trig.code)}`}
{trig && `(${triggerTypeLabelZh(trig.type)})`}{' '}
<strong className="text-rose-600 tabular-nums">{formatDaysReadable(s.daysSince)}</strong>
{cats && `,未启动 ${cats}`}
</span>
);
}
......@@ -56,7 +56,7 @@ export function TaskDrawer({ currentPlanId }: { currentPlanId: string }) {
const q = search.trim().toLowerCase();
arr = arr.filter(
(p) =>
(p.patient.nameMasked ?? '').toLowerCase().includes(q) ||
(p.patient.name ?? p.patient.nameMasked ?? '').toLowerCase().includes(q) ||
(p.patient.phoneMasked ?? '').includes(q) ||
p.patient.externalId.toLowerCase().includes(q),
);
......@@ -246,7 +246,7 @@ function TaskRow({
clinicName: string | null;
onSelect: () => void;
}) {
const name = item.patient.nameMasked ?? item.patient.externalId;
const name = item.patient.name ?? item.patient.nameMasked ?? item.patient.externalId;
const done = isDone(item.status);
return (
<li>
......
......@@ -21,7 +21,8 @@ import type { ScriptSection } from './mock-data';
*/
interface ServerSection {
id: 'opening' | 'key' | 'followup' | 'objection' | 'cta';
// B 方案:4 段对齐前端 mock(opening / followup / objection / close)
id: 'opening' | 'followup' | 'objection' | 'close';
label: string;
durationHint: string;
markdown: string;
......@@ -51,12 +52,18 @@ export interface UseScriptStream {
reset: () => void;
}
// B 方案(2026-05-24):后端 4 段对齐 mockScript — opening / followup / objection / close
const LABEL_FALLBACK: Record<ServerSection['id'], string> = {
opening: '开场',
key: '核心信息',
followup: '跟进话术',
followup: '切入话题',
objection: '异议处理',
cta: '行动呼吁',
close: '结束 · 信息确认',
};
const DURATION_FALLBACK: Record<ServerSection['id'], string> = {
opening: '30 秒',
followup: '1–2 分钟',
objection: '按需',
close: '30 秒',
};
export function useScriptStream(): UseScriptStream {
......@@ -242,10 +249,10 @@ function parseSseFrame(frame: string): SseEvent | null {
}
function makeEmptySections(): ScriptSection[] {
return (['opening', 'key', 'followup', 'objection', 'cta'] as const).map((id) => ({
return (['opening', 'followup', 'objection', 'close'] as const).map((id) => ({
id,
label: LABEL_FALLBACK[id],
durationHint: '',
durationHint: DURATION_FALLBACK[id],
markdown: '',
}));
}
......
'use client';
import type { ListPlansQuery, ListPlansResponse, PlanActionAck } from '@pac/types';
import type {
ListPlansQuery,
ListPlansResponse,
PlanActionAck,
PlanCountsResponse,
} from '@pac/types';
import { api } from '@/lib/api-client';
import type { PlanDetailData } from '@/components/plan-detail/plan-detail-types';
......@@ -13,11 +18,16 @@ export const plansApi = {
status: q.status,
targetClinicId: q.targetClinicId,
assigneeUserId: q.assigneeUserId,
keyword: q.keyword,
sort: q.sort,
page: q.page ?? 1,
pageSize: q.pageSize ?? 20,
},
}),
/** KPI / Tab badge 聚合计数 — 一次拉齐(替代之前 5 个并发 list 请求)*/
counts: () => api.get<PlanCountsResponse>('/pac/v1/plans/counts'),
/** 聚合详情:plan + patient + profile + persona + chains + facts(给 PlanDetailApp 用)*/
getAggregate: (planId: string) =>
api.get<PlanDetailData>(`/pac/v1/plans/${encodeURIComponent(planId)}/full`),
......@@ -33,4 +43,10 @@ export const plansApi = {
api.post<PlanActionAck>(`/pac/v1/plans/${encodeURIComponent(planId)}/recycle`, {
reason,
}),
/** 拉患者明文手机号(reveal,需 PATIENT_VIEW 权限) — GET /patients/{id}/phone-reveal */
revealPhone: (patientId: string) =>
api.get<{ phone: string | null }>(
`/pac/v1/patients/${encodeURIComponent(patientId)}/phone-reveal`,
),
};
'use client';
import Link from 'next/link';
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { toast } from 'sonner';
import {
ChevronLeft,
......@@ -28,10 +28,11 @@ import {
import { useAuthStore } from '@/stores/auth-store';
import { useHasPermission } from '@/hooks/use-permission';
import { ApiError } from '@/lib/api-client';
import { cn } from '@/lib/utils';
import { cn, formatGender } from '@/lib/utils';
import { plansApi } from './plans-api';
import { usePlansList } from './use-plans-list';
import { usePlanCounts } from './use-plan-counts';
import { ReasonLine } from '@/components/plan-detail/reason-line';
// ─────────────────────────────────────────────────────────────────
// 召回任务工作台(/plans)
......@@ -120,24 +121,24 @@ export function PlansListApp() {
void reloadCounts();
};
// 客户端二次过滤 + 排序(搜索 / 排序后端暂不支持)
const visible = useMemo(() => {
if (state.status !== 'ready') return [] as PlanListItem[];
let items = state.data.items;
if (q.trim()) {
const lq = q.trim().toLowerCase();
items = items.filter(
(p) =>
(p.patient.nameMasked ?? '').toLowerCase().includes(lq) ||
(p.patient.phoneMasked ?? '').includes(lq) ||
p.patient.externalId.toLowerCase().includes(lq),
);
}
const arr = [...items];
if (sort === 'priority_desc') arr.sort((a, b) => b.priorityScore - a.priorityScore);
if (sort === 'priority_asc') arr.sort((a, b) => a.priorityScore - b.priorityScore);
return arr;
}, [state, q, sort]);
// W3 末:keyword / sort 改服务端(usePlansList → /pac/v1/plans?keyword=&sort=),
// 跨全表搜索 + 跨页排序;前端不再 .filter / .sort 本页 items
// - keyword 300ms debounce 防抖,避免每键一次请求
// - sort 变化立刻 setQuery
useEffect(() => {
const trimmed = q.trim();
const handle = setTimeout(() => {
setQuery({ keyword: trimmed || undefined });
}, 300);
return () => clearTimeout(handle);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [q]);
useEffect(() => {
setQuery({ sort });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sort]);
const visible: PlanListItem[] = state.status === 'ready' ? state.data.items : [];
const toggleSelect = (id: string) =>
setSelected((prev) => {
......@@ -161,7 +162,7 @@ export function PlansListApp() {
if (!user) return;
try {
await plansApi.assign(plan.id, user.sub);
toast.success('已认领', { description: plan.patient.nameMasked ?? plan.patient.externalId });
toast.success('已认领', { description: plan.patient.name ?? plan.patient.nameMasked ?? plan.patient.externalId });
refreshAll();
} catch (e) {
toast.error('认领失败', { description: errMsg(e) });
......@@ -170,7 +171,7 @@ export function PlansListApp() {
const handleRecycleOne = async (plan: PlanListItem) => {
try {
await plansApi.recycle(plan.id);
toast.success('已回收到池', { description: plan.patient.nameMasked ?? plan.patient.externalId });
toast.success('已回收到池', { description: plan.patient.name ?? plan.patient.nameMasked ?? plan.patient.externalId });
refreshAll();
} catch (e) {
toast.error('回收失败', { description: errMsg(e) });
......@@ -325,7 +326,7 @@ function PageHeader({
<div className="flex min-w-0 items-center gap-3">
<div className="inline-flex items-center gap-2">
<span className="inline-flex h-7 w-7 items-center justify-center rounded-md bg-teal-600 text-[12px] font-bold text-white">PAC</span>
<span className="text-[13px] font-semibold text-slate-900">召回平台</span>
<span className="text-[13px] font-semibold text-slate-900">疗效保障</span>
</div>
<span className="text-slate-300">/</span>
<div className="min-w-0">
......@@ -489,7 +490,7 @@ function FilterBar({
<Input
compact
type="search"
placeholder="搜索 姓名 / 手机 / 外部 ID(本页)"
placeholder="搜索 姓名 / 手机 / 外部 ID"
value={q}
onChange={(e) => setQ(e.target.value)}
className="w-64 pl-8"
......@@ -605,7 +606,7 @@ function PatientPlanCard({
const isMine = p.assigneeUserId === me;
const isPool = p.status === 'active' && !p.assigneeUserId;
const isClosed = p.status === 'completed' || p.status === 'abandoned';
const displayName = p.patient.nameMasked ?? p.patient.externalId;
const displayName = p.patient.name ?? p.patient.nameMasked ?? p.patient.externalId;
const d = DENSITY[density];
const pad = d.pad;
......@@ -640,7 +641,7 @@ function PatientPlanCard({
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
<span className={cn('truncate font-semibold leading-tight text-slate-900', titleSize)}>{displayName}</span>
<span className="text-[10.5px] text-slate-500 nums">
{p.patient.gender ?? '?'} · {p.patient.age ?? '?'}
{formatGender(p.patient.gender)} · {p.patient.age ?? '?'}
</span>
</div>
<div className="mt-0.5 flex items-center gap-1.5 text-[11px] text-slate-500">
......@@ -671,9 +672,14 @@ function PatientPlanCard({
<StatusPill status={p.status} />
</div>
<p className={cn('leading-snug text-slate-700', d.reason, pad, 'pb-3 pt-0')}>
{p.inclusionReason}
</p>
{/* 召回理由富文本(跟详情页 WhyCard 同源 ReasonLine):后端给 signals raw JSON,前端字典翻译。
列表只展示 primary(MAX priorityScore)一条,卡片保持紧凑;多 reason 全量在详情页看。 */}
<div className={cn('leading-snug text-slate-700', d.reason, pad, 'pb-3 pt-0')}>
{p.reasons[0] ? <ReasonLine reason={p.reasons[0]} /> : <span>{p.inclusionReason}</span>}
{p.reasons.length > 1 && (
<span className="ml-1.5 text-[10.5px] text-slate-400">+{p.reasons.length - 1}</span>
)}
</div>
{/* 底:meta + 操作 */}
<div className={cn('mt-auto flex items-center justify-between gap-2 border-t border-slate-100', d.foot)}>
......
'use client';
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { ApiError } from '@/lib/api-client';
import type { PlanDetailData } from '@/components/plan-detail/plan-detail-types';
import { plansApi } from './plans-api';
......@@ -28,9 +28,14 @@ export function usePlanAggregate(planId: string) {
}
}, [planId]);
// 去重守卫:同一 planId 自动加载只触发一次 —— 规避 React 18 StrictMode(dev)
// 双调 effect 导致 /full 请求两次。planId 变化时重置;手动 refresh 不受限。
const loadedFor = useRef<string | null>(null);
useEffect(() => {
if (loadedFor.current === planId) return;
loadedFor.current = planId;
void load();
}, [load]);
}, [planId, load]);
return { state, refresh: load };
}
......@@ -4,8 +4,11 @@ import { useCallback, useEffect, useState } from 'react';
import { plansApi } from './plans-api';
/**
* KPI / Tab badge 用的轻量计数:并行 5 个 pageSize=1 的 list 拉 total,
* KPI / Tab badge 用的轻量计数:走后端 /plans/counts 单端点(一次 SQL 聚合 5 个 count),
* 不动主列表 query。任何主列表刷新时,通过 token (`refreshToken`) 触发同步。
*
* 历史:早期是前端并行 5 个 pageSize=1 list 请求,列表页刷新会产生 6+ 个 HTTP 请求 +
* StrictMode dev 双挂翻倍,网络面板看着很吓人。W3 末改为后端聚合。
*/
export interface PlanCounts {
mine: number;
......@@ -24,22 +27,10 @@ export function usePlanCounts(refreshToken: number) {
const load = useCallback(async () => {
setLoading(true);
try {
const [mine, mineAssigned, mineCompleted, pool, all] = await Promise.all([
plansApi.list({ view: 'mine', page: 1, pageSize: 1 }),
plansApi.list({ view: 'mine', status: 'assigned', page: 1, pageSize: 1 }),
plansApi.list({ view: 'mine', status: 'completed', page: 1, pageSize: 1 }),
plansApi.list({ view: 'pool', page: 1, pageSize: 1 }),
plansApi.list({ view: 'all', page: 1, pageSize: 1 }).catch(() => ({ total: 0 })),
]);
setCounts({
mine: mine.total,
mineAssigned: mineAssigned.total,
mineCompleted: mineCompleted.total,
pool: pool.total,
all: (all as { total: number }).total,
});
const data = await plansApi.counts();
setCounts(data);
} catch {
// 单个失败不影响整体,保持上次值
// 失败保持上次值,不打断 UI
} finally {
setLoading(false);
}
......
......@@ -4,3 +4,61 @@ import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}
/**
* 天数可读性增强:"149" → "149 天(4 个月)";"427" → "427 天(1 年 2 个月)"。
* 只有跨过月/年阈值才显示括号(避免"5 天(0 个月)"这种废话)。
*
* 规则:
* < 30 天 → 仅"X 天"
* 30-364 天 → "X 天(N 个月)" — N = floor(days/30)
* ≥ 365 天 → "X 天(N 年 M 个月)";M=0 时简化为"X 天(N 年)"
*
* 公共 helper:reason / chain "X 天断口" / persona / fact timeline 都能用。
*/
export function formatDaysReadable(days: number | null | undefined): string {
if (days == null || days < 0) return '—';
const d = Math.floor(days);
if (d < 30) return `${d} 天`;
if (d < 365) {
const m = Math.floor(d / 30);
return `${d} 天(${m} 个月)`;
}
const y = Math.floor(d / 365);
const m = Math.floor((d % 365) / 30);
return m > 0 ? `${d} 天(${y}${m} 个月)` : `${d} 天(${y} 年)`;
}
/**
* 牙位显示:原文用 ; 分隔多颗,超过 5 颗显示前 5 + "等 N 颗"。
* 例:"14;15;16;17;47" → "14;15;16;17;47"(5 颗内,原样)
* "11;12;13;…23 颗" → "11;12;13;21;22 等 23 颗"
* 空 / null → "—"
*/
export function formatToothPosition(
raw: string | null | undefined,
maxShow = 5,
): string {
if (!raw) return '—';
const parts = raw
.split(';')
.map((s) => s.trim())
.filter(Boolean);
if (parts.length <= maxShow) return parts.join(';');
return `${parts.slice(0, maxShow).join(';')}${parts.length} 颗`;
}
/**
* 性别 canonical 值 → 中文显示 label。
*
* 后端存的是 canonical 值(理想为 M/F,但摄入侧"自由文本直通"可能漏出
* 男/女/male/female/1/2 等长尾),这里宽容兜底,任何识别不了的回退到 dash。
* 显示是 i18n 关注点,归前端 —— 接口只负责给 canonical 值。
*/
export function formatGender(raw: string | null | undefined): string {
if (!raw) return '—';
const g = raw.trim().toLowerCase();
if (['m', 'male', '男', '1'].includes(g)) return '男';
if (['f', 'female', '女', '2'].includes(g)) return '女';
return '—';
}
......@@ -17,6 +17,31 @@ services:
timeout: 5s
retries: 10
# 本地 DW 镜像 —— 把瑞尔 DW(dw_group.fact_*_out)全量复制到本地,
# cold-import 连本地即可,不依赖远程 DW 可用性。仅本地开发用(含真实 PII,勿外传)。
clickhouse:
image: clickhouse/clickhouse-server:24.3-alpine
container_name: pac-clickhouse
restart: unless-stopped
environment:
CLICKHOUSE_USER: default
CLICKHOUSE_PASSWORD: pac
CLICKHOUSE_DB: dw_group
ports:
- "8123:8123" # HTTP(PAC @clickhouse/client 连这个)
- "9100:9000" # native(避开常见 9000 占用;remote() 复制用)
ulimits:
nofile:
soft: 262144
hard: 262144
volumes:
- clickhouse_data:/var/lib/clickhouse
# 本地开发关闭 healthcheck —— alpine wget 走 localhost IPv6 失败
# ("Connection refused" 循环) → 每 5s spawn 失败 wget 持续占 1 核 CPU(实测 110%)。
# 生产用时改回:test: ["CMD-SHELL", "wget --spider -q http://127.0.0.1:8123/ping || exit 1"]
healthcheck:
disable: true
redis:
image: redis:7-alpine
container_name: pac-redis
......@@ -88,3 +113,4 @@ services:
volumes:
postgres_data:
redis_data:
clickhouse_data:
......@@ -186,6 +186,7 @@
},
"active": "5c26df11b9d3d65c",
"lastOpenFiles": [
"dw-data-source-issues.md",
"db-suggest-after-review.md",
"algorithm/potential-treatment-recall-flow.md",
"algorithm/potential-treatment-recall.md",
......@@ -214,7 +215,6 @@
"demo-page-prompt.md",
"db-design-v2.md",
"architecture.md",
"db-review-confirm.md",
"scenarios",
"db-review-confirm.pdf"
]
......
......@@ -754,6 +754,54 @@ WHERE p.active AND NOT pp.do_not_contact AND NOT pp.deceased
- 数仓常见坑:**只给中文 name(利于显示),没给可计算的语义 key**;即便有"码",也可能是每条记录的随机主键 hash(防重用),不是类别码。
- 影响:PAC 只能退到"中文 name → enum_mapping 白名单"+ W5+ LLM 兜底,白名单需持续维护、永远有长尾。
- 反馈方向:推动宿主在明细层补**稳定语义码**(诊断规范填 ICD;治疗归一到集团项目字典),从源头解决。
8. **写入是 upsert 当前态,还是 append 事件流?状态变更留不留痕?**(W3 末瑞尔 DW 预约表实战)
- 瑞尔 DW 预约表是**混合**:① 同一 `appointment_id` 的状态流转(预约→到诊→结算)是**就地 upsert**——只剩终态,中间态丢失;② **改约**则是**新建一条新 id + 把旧 id 标"已改约"**——改约历史以"已改约"行留痕。
- 影响:`created/arrived/cancelled` 等 in-place 流转,**cold-import 只看终态**(动作流要靠增量 pull 多次比对前后态,或 host push / append 事件日志);改约历史可见但**"已改约"≠"有效预约"**,务必映射成独立状态(rescheduled),否则虚高有效预约(瑞尔已改约占 44.7%)。
- 反馈方向:行为分析(爽约率/改约频率/漏斗)需 **append 事件流**;当前态表只够"看现状"。
9. **时间字段的粒度与拆分**(W3 末瑞尔预约时间实战)
- 坑:约定"日期"与"时分"可能分列存(瑞尔 `appo_time` 只到天 Date,真正时分在 `appo_datetime_str` "13:00:00-14:00:00" + `appo_time_period` 时长);只取日期会让同日多次改约"看着重复"。
- 处理:transforms 拼出精确 Timestamptz(日期 + 起始时分);区分"约定时刻"(scheduled)与"实际发生时刻"(arrived=签到 in_time),两者落 `plannedFor` / `occurredAt`
- 顺带:`appo_complaint_category`(种植/正畸/…)这类"预约科目"是**就诊意向强信号**,值得标准化进 fact,别只当展示字段丢掉。
10. **"已做治疗"的凭据表与牙位粒度**(W3 末潜在召回 SQL 审计)
- 坑:同一概念(治疗)在 host 有**两个口径**——"治疗计划"(`treat_plan`,含牙位,但≠已做)和"已结算收费"(`fact_settlement_out`,花钱才算做,但**按收费项记账,无牙位列**)。
- 影响:潜在治疗召回的"排除已做治疗"必须信结算(actual),而结算无牙位 → **牙位级排除做不了,只能 patient 级**(同 patient 做过同类治疗即排,会粗化:A 牙做过种植会挡掉 B 牙缺口)。
- 缓解:排除条件加**时间方向**(`treatment.occurred_at >= 诊断时间`),只排"诊断后才启动"的治疗,避免历史旧治疗误排新诊断(实测 K08 误伤 33%)。
- 反馈方向:推动 DW 在结算明细补 `tooth_position`,或用 `treat_plan.planCode` 关联结算行,才能升级到牙位级排除。
11. **时间窗别一杆当两用:入池边界 vs 优先级权重要分开**(W3 末同审计)
- 坑:把"黄金窗"(算优先级用,如缺牙 30-180 天)直接当**入池硬边界**(SQL `BETWEEN`),会把超上界的患者直接踢出池 —— 缺牙拖 1 年比拖 3 个月更该召,却因"超 180 天"漏掉(实测超窗 3 个全 >360 天,窗内仅 2 个)。
- 处理:入池上界放宽(如 730 天),黄金窗只喂给打分器算 `timeWindowFactor`(过晚衰减到 0.4);超窗患者照常入池、分数低、排后面。下界保留(刚诊断 <start 天还在考虑期,不急召)。
12. **fact 标准主体由 PAC 定,别被单一宿主牵着走 + 摄入断链体检**(W3 末审计 emr/影像)
- 原则:`fact.content` schema = PAC 自己定的临床通用语义全集(所有宿主的统一投影目标)。
某宿主映射不上某字段 → 留 null,**绝不因"这个 host 没有 / 拆走了"从标准删字段**
例:emr_record 必须保留 `diagnosis_text` / `treatment_plan`(小诊所病历常是自由文本),
哪怕瑞尔 DW 把诊断 / 治疗拆成了结构化数组(这俩字段对瑞尔留 null)。
- 判断依据 = **语义普适性(跨宿主通用),不是来源**:host 的设计若普适就大方采纳为标准
(如瑞尔预约 8 态多为通用就诊生命周期 scheduled/arrived/completed/cancelled/no_show/rescheduled,
配得上 PAC 标准);PAC 自拍的若不普适也要砍。来源是 host 还是 PAC 不重要。
- 真正的反模式:为贴合某 host 而**删通用字段**(如删 diagnosis_text)或**把 host 私有字段塞进标准**
正确是逐项问"是否跨宿主普适" —— 这是 PAC 团队(懂业务 + 临床)的持续判断活,非机械规则。
- **摄入断链体检**(三处任一断 → 主体静默变空壳,无报错):
① SQL 没 SELECT 该列;② yaml `field_mapping` 没映射(parser 读 canonical 名 → undefined → null);
③ assembler 没注册进 `manifest.assemblers`(整个主体不进 dispatcher)。
- 实测踩坑:emr_record 803 条自由文本**全 null**(②yaml 漏映射 + parser 字段名对不上);
image_record **0 条**(①file_url 无 transform + ③无 image.yaml 注册)。修复后 emr 文本全有、影像 756 条。
- 体检方法:逐 fact_type 抽查 `content` 各字段非空率;非空率为 0 的字段顺管道三处查断点。
13. **字段名 + 注释只能参考,真实含义靠数据推**(W3 末 billing_date 实战)
- 坑:host 字段名/comment 经常误导,顾名思义会选错字段。
- 实战:settlement 4 个时间字段 `rq` / `settlement_time` / `billing_date` / `created_date`,
字面看 `billing_date` 像"开单日"(以为偏行政),`rq` 像"日期"(以为主用)。PAC 起初选 rq → 治疗时间 vs 诊断时间口径不一致(rq 是结算操作日,可能延后 1-64 天)。
- 用数据推真实含义:
- billing_date **99.96%** 等于同 encounter 的 emr.rq(接诊日)→ **真实含义 = 治疗发生当天**
- 同 encounter 的多行 settlement 共享同一 billing_date(distinct=1)→ 一次接诊一次开单(治疗事件级)
- rq 跟 billing_date **97% 同天**,但偶有延后 → 是结算操作日
- 结论:PAC 治疗时间应该用 `billing_date`(跟诊断时间同口径 = 接诊日)。
- 推导方法(可复用):① 字段类型 + 取值范围 + null 率;② 同行多字段对比(rq vs billing_date 差异分布);
③ 跨表 join 验证语义(settlement.billing_date vs emr.rq 是否相等);④ 多行同 key 聚合(同 encounter 多行 distinct);
⑤ 拒绝从命名猜结论。
- 文档化:每个时间/枚举字段在 yaml field_mapping 注释处记录"数据推论 + 充实度 + 选择理由",
避免下任接手又被命名误导。
### 7.3 PAC 字典 vs 宿主码的关系
......@@ -795,6 +843,7 @@ WHERE p.active AND NOT pp.do_not_contact AND NOT pp.deceased
| **D16**(W3) | **enum_mapping `_default` 兜底键**:长尾值统一映射,parser 跳过 | ✅ |
| **D17**(W3) | **SQL 朴素化**:SQL 退化为"等价 csv 单表 dump",形态改造全归 transforms | ✅ |
| **D18**(W3) | **4 入口统一管道**:cold/DW/pull/push 共用 transforms + assembler + parser,dispatcher 接 `emitsResolver`(§十)| ✅ |
| **D19**(W3) | **fact 标准主体由 PAC 自己定,不被任何单一宿主形态裁剪** | ✅ |
---
......
# 治疗链 5 阶段判定模型
> **版本:** v1.0 · W3 末确立
> **状态:** ✅ 落地(chain-composer.service.ts + canonical-codes.ts TreatmentMilestones)
>
> **重要边界**:本文档讲的是**治疗链展示层**模型 — 给客服详情页看清每个 patient 的治疗链真实进度。
> **跟召回算法解耦**:treatment_initiation_recall scenario 排除口径不变(诊断后有任何同类 actual treatment → 排除),
> 那是召回 S1(发现机会)且未启动治疗的患者。两件事别混。
---
## 0. 5 阶段定义(对齐 design 文档 §0)
```mermaid
flowchart LR
S1["① 发现治疗机会<br/>影像 / EMR / 医嘱 / 收费"]
S2["② 进入治疗链<br/>治疗计划 / 收费单 / 收款 / 预约 / 接诊"]
S3["③ 治疗执行<br/>处置 / 用药 / EMR / 收费 / 退费 / 补费"]
S4["④ 术后与复查管理<br/>术后医嘱 / 复查计划 / 术后随访"]
S5["⑤ 治疗链闭环<br/>处置完成 + 复查完成 + 无风险信号"]
S1 --> S2 --> S3 --> S4 --> S5
```
---
## 1. 信号 → 阶段映射(PAC fact 来源)
| 阶段 | PAC fact 信号 | 实现位置 |
|---|---|---|
| **S1 发现机会** | `diagnosis_record`(K0x 命中 category)、`recommendation_record`(*_RECOMMENDED 命中) | `collectS1Facts` |
| **S2 进入治疗链** | ① `treatment_record(kind=planned)`(同 category)<br/>`payment_record`(大额,>= S2_PAYMENT_THRESHOLD_CENTS[category]) | `collectS2Facts` |
| **S3 治疗执行** | `treatment_record(kind=actual)`,subtype 关键词命中 `TreatmentMilestones[category].steps`,匹配数 >= `minSteps` | `matchMilestoneSteps` |
| **S4 术后复查** | ① review category 的 `treatment_record`<br/>② 同 category planned review<br/>③ S3 之后任意 `encounter_record`(复诊接诊)<br/>`recommendation_record` 含 'REVIEW' 关键词 | `collectS4Facts` |
| **S5 闭环** | S3 reached + S4 hit + lifecycle.maxStage=5 + 无 S3 后 refund + 无同位置反弹诊断 | inferChainStage 内 |
**故意排除的信号**(避免噪音):
- S2 不算 `appointment` / `encounter`(粒度太粗,任一回访都算"进入种植链"会错)
- S2 不算小额 payment(挂号费 58 元被算"进入种植链"会错)
- S5 不要求 `matchedSteps.allSatisfied`(preventive 字典 ['洁牙','涂氟','封闭'] 做一项即可闭环;
种植 minSteps=2 严格要求"植入+修复"都做)
---
## 2. 字典(canonical-codes.ts 单一真理源)
### TreatmentLifecycles — 决定能否闭环
| key | maxStage | expectedSpanMonths | 说明 |
|---|---|---|---|
| `one_shot` | 5 | 3 | 一次治疗即闭环(充填/拔除/美容) |
| `linear` | 5 | 6 | 多步线性(种植 2 步 / 根管 2 步) |
| `long_term` | 5 | 24 | 长周期(正畸) |
| `periodic` | 5 | 12 | 周期性(预防/年度洁牙) |
| `lifelong_maintenance` | **4** | — | **永不闭环**(牙周炎慢性病,做完一次仍需终身维护) |
### TreatmentMilestones — category × 关键步骤
| category | steps | minSteps | lifecycle |
|---|---|---|---|
| implant | 种植体植入 → 种植上部修复 | 2 | linear |
| endodontic | 开髓 → 根管充填 | 2 | linear |
| orthodontic | 矫治器 → 保持器 | 1 | long_term |
| periodontic | 全口洁治 / 龈下刮治 / 牙周维护 | 1 | lifelong_maintenance |
| restorative | 充填 / 嵌体 | 1 | one_shot |
| prosthodontic | 冠 / 桩核 / 修复 | 1 | one_shot |
| surgical | 拔除 / 拔牙 / 手术 | 1 | one_shot |
| preventive | 洁牙 / 涂氟 / 封闭 | 1 | periodic |
| cosmetic | 美白 / 贴面 | 1 | one_shot |
| pediatric | (无具体步骤,有 actual 即满足) | 1 | one_shot |
**匹配规则**:`treatment_record.content.subtype.includes(step)` 任一即算该步完成。
host subtype 文本含关键词即可(例 "种植体植入(进口)" includes "种植体植入" → 命中)。
---
## 3. 状态(ChainStatus)
5 阶段对应 4 个 status(uninitiated 已废弃合并入 discovered):
| status | currentStage | 含义 | UI 颜色 |
|---|---|---|---|
| `discovered` | 1 | 仅 S1 命中(诊断/推荐)— **召回算法关注的"潜在新链"** | rose ★ 发现机会 |
| `entered` | 2 | S2 命中(已挂号/预约/付款/有计划) | amber 已进入 |
| `ongoing` | 3 / 4 | S3 治疗中 或 S4 复查中 | sky 在管 |
| `closed` | 5 | 闭环 — lifelong_maintenance 永不到此 | emerald 已闭环 |
`target?: boolean` 标记 = `status IN ('discovered','entered')` — 召回算法关注度高的链。
---
## 4. 验证样本(W3 末 1000-cohort)
| 患者 | 链分布(简化) |
|---|---|
| **路遥** | discovered 种植修复·21;41(K08 73 天) / **ongoing** 牙周治疗·全口(lifelong_maintenance,3-11 洁治 + 5-19 复评) |
| 罗国标 | discovered 预防保健 / ongoing 根管/牙周/种植 / **closed** 外科+充填(one_shot) |
| 陆磊 | entered 牙周/种植(付款进入未启动) / closed 预防+外科 |
| 李忠林 | entered 牙周/根管 / ongoing 种植(植入未修复 — minSteps=2 未满) / closed 龋齿充填+外科 |
---
## 5. 关键设计决策
### D1:跟召回算法解耦
治疗链 5 阶段**纯展示用**。召回算法继续用"诊断后有任何同类 actual" 简单规则,不读 TreatmentMilestones。
未来若要做"治疗链内召回"(例:种植已植入未修复 stage=3 患者催完成),再写新 scenario 读字典。
### D2:S2 收紧 — 大额 payment 阈值
appointment / 小额 payment / encounter 不算 S2(避免挂号费 58 元被算成"进入种植链")。
阈值字典 `S2_PAYMENT_THRESHOLD_CENTS`(种植 ¥5000 / 充填 ¥300 / 等),后续可挪到 host config。
### D3:闭环不要求 milestone 全 steps 满足
preventive 字典 ['洁牙','涂氟','封闭'] 做一项即满 minSteps=1 → 可闭环。
种植 minSteps=2 严格要求"植入+修复"都做才可能 closed。
"全 steps 满足才能闭环" 太严,会导致几乎所有 multi-step category 永远 ongoing。
### D4:同 chainLabel 去重
K08 → primary='implant' 立 1 条链;患者做的"种植上部修复" actual.category='prosthodontic' 也立 1 条。
两条 chainLabel 都叫"种植修复·xx" — 临床上是同一条链。
compose() 末尾去重 pass:同 chainLabel 保留 stage 最高的那条。
### D5:牙周 lifelong_maintenance
牙周炎是慢性病,做完一次洁治仍需终身维护复查。**永远停留 stage=4 ongoing**,不到 closed。
UI 显示"维护期 · 上次维护 YYYY-MM-DD",客服可周期触达。
---
## 6. 待完善(后续迭代)
- **planned treatment 字典扩展**:目前路遥的 K08 EMR.treat_plan 里有"延期种植术"但
`treatment_planned.yaml` enum_mapping 未覆盖 → 该患者 implant planned 全部丢 → S2 严格判定下 stage=1 discovered
应补 implant 类 treatName 到字典。
- **risk signals 完整化**:目前 S5 检查 refund 和反弹诊断;`complaint_record` 字段已定义但 DW 暂无源,W5+ 接入后补
- **正畸 closed 精细化**:保持器交付 + 12 月内无新调整 才算 closed;目前简化为有 actual + S4 命中即可
- **Layer C LLM 抽影像/EMR finding** 补 S1 信号源 — W5+ 跟其他 LLM 任务一起做
# DW 数据源问题清单(给 DW 团队反馈)
> **背景**:PAC W3 末系统审计 5 张 CH 表(fact_client_out / fact_appointment_out / fact_emr_treatment_out / fact_settlement_out / fact_settlement_mode_out)与 PAC 标准主体的映射,发现以下需 DW 改进的问题。
> **目的**:不是 PAC 单方面诉求,而是数据源质量提升 — 双方达成共识后由 DW 团队评估实施排期。
---
## 🔴 P0 — 直接影响业务
### 1. fact_client_out 没 phone
- **现状**:无 phone / mobile / tel 字段
- **影响**:PAC 召回硬过滤要求 `phone IS NOT NULL`,DW 不给 → 当前用假手机号兜底(无法真实触达)
- **诉求**:补 phone(合规允许范围内)
### 2. 字典完整性问题(根本性)
覆盖多种表现:
- **id 缺人类可读 name**:doctor_id / user_id 等数字 id 各事件表不带 name 快照(只 emr 表 doctor_name 有)
- **value 没 key**:settlement_type / treatName / check_name / card_type_name 全是中文名,无稳定语义码 → PAC 只能维护中文白名单(98% 命中+长尾兜底)
- **枚举值无语义文档**:status (1/2/3) / is_first (0/1/2) / recomend_type / appo_status 各码含义无文档
- **看似有 code 实际不可用**:treat_plan.planCode 是 hash(每条独立,非类别码)
**诉求**:
1. 所有 id 字段在事件表带 name 快照(doctor_name 进 settlement/appointment 表)
2. 类别字段补稳定语义码(诊断填 ICD;治疗归集团项目字典;card 拆 VIP/医保/活动/团购 独立字段)
3. 枚举值含义文档化(status 1=? 2=? 3=?)
### 3. fact_client_out 是 ADS 聚合层(应为 DWD)
| 表 | 实际层级 | 评价 |
|---|---|---|
| fact_client_out(12.9万=患者数)| **ADS 聚合** ❌ | is_dental_loss / first_visit_time / customer_level / favor_doctor 一堆衍生 |
| fact_appointment_out / fact_emr_treatment_out / fact_settlement_out / fact_settlement_mode_out | **DWD 明细** ✅ | 一事件一行 |
- **影响**:PAC 不敢用聚合衍生字段(只取 name/gender/birthday/file_num 原子字段)
- **诉求**:fact_client_out 提供 DWD 版(只给身份原子字段,衍生聚合 PAC 自己算)
### 4. 写入方式:appointment 是混合 upsert + 改约新建,其他未明
- **appointment**:状态流转(scheduled→arrived→completed)是 in-place upsert(只看终态);改约 = 新建 + 老的标"已改约"(部分留痕)。**已改约占 44.7%** 已映射 rescheduled
- **emr / settlement / settlement_mode**:写入方式未明确(append 还是 upsert?)
- **影响**:cold-import 只能看到终态,无法监控 appointment_created/changed/cancelled/fulfilled 等动作事件;爽约率/改约频率/漏斗分析需事件流
- **诉求**:除患者外用 **append 事件流**(状态变更新增行而不是改原行),或提供独立"状态变更日志表"
---
## 🟡 P1 — 数据质量
### 5. settlement 在"按牙类"治疗项目上缺 tooth_position
- **现状**:settlement_out 无任何 tooth 字段(完整字段扫描确认);按牙治疗(种植/拔牙/根管/充填)的项目本应带牙位,实际只在 EMR 自由文本(emr.dispose 39% / treat_plan 46% 带 toothPosition)
- **临床合理**:洁牙/X光检查/全身检查等**全口/检查类项目本就无牙位**,不强求
- **影响**:PAC 牙位级排除做不了 — 36 牙做过种植会挡掉 21 牙缺口召回(只能 patient 级)
- **诉求**(收窄):**只对"按牙类"治疗项目** 在结算明细补 tooth_position(种植 36/拔牙 48 这种);全口类继续无牙位 OK
- **关联键不强求**(临床多对多本质,host 端也做不到 1:1 link;诊断/治疗/结算之间只能"同 encounter + 同 tooth + 名字模糊匹配")
### 6. fact_emr_treatment_out 子表炸行
- **现状**:file_url 子表 JOIN 无 groupArray,**193万 → 39.5万真实 EMR**(5 倍膨胀)
- **PAC 侧**:已用 dedup_by 兜底
- **诉求**:DW 修(groupArray)
### 7. plan 字段 vs treat_plan 语义未文档化
- **现状**:fact_emr_treatment_out 两个治疗计划字段,**65% 完全不同**(395656 行只 4 行相同)
- **影响**:PAC 暂不接 plan(避免重复/过时数据)
- **诉求**:DW 说明两字段语义区别
### 8. settlement 4 个时间字段语义需文档化(W3 末数据推论)
| 字段 | 实测语义 | PAC 选用 |
|---|---|---|
| `billing_date` | **99.96% = emr.rq(接诊日)= 治疗发生当天** | ⭐ PAC treatment.occurredAt / payment.paidAt 用这个 |
| `rq` / `settlement_time` | 结算操作日(97% 与 billing_date 同天,偶有延后 1-64 天) | 不用 |
| `created_date` | 记录创建时间 | 不用(元数据) |
- **教训**:字段名"billing_date"字面像"开单日"以为偏行政,实际是治疗发生时的开单 = 治疗日。PAC 起初误选 rq → 治疗时间 vs 诊断时间口径不一致。
- **诉求**:DW 文档化四个时间字段定义,推荐"治疗实际时间"用 billing_date
---
## 🟢 P2 — 业务覆盖缺口
### 9. 缺投诉 / 转介绍事实表
- **现状**:DW 没 complaint 表;转介绍只有 client_out.recommend_*(聚合,17% 充实)
- **影响**:PAC complaint_record / referral_record fact_type 已定但数据源缺
- **诉求**:DW 提供这两张明细表
### 10. 接诊结束时间缺失
- **现状**:in_time 有(接诊开始),无 out_time(结束)
- **影响**:无法算接诊时长、医生效率
### 11. 多 brand ID 重复(已知坑)
- patient_id / appointment_id 等跨 brand 重复
- PAC 用 brand → tenant_id 解决,接入新 brand 复用此模式
---
## 工程契约
### 12. updated_at 字段需保证(PAC 增量同步 cursor 基础)
- 每张表需有 updated_at 列单调递增刷新(每次写入/修改都更新)
- 这是 PAC 增量 pull 的 cursor 基础
- **诉求**:确认 DW 各表都有此列且 ETL 写入时刷新
### 13. ETL 频率需说明
- 表是 view 还是物理实表?ETL 频率(实时/小时/T+1)?
- 影响 PAC 增量 pull 节奏 + 数据新鲜度
- **诉求**:DW 说明各表 ETL 时延
---
## 沟通方法论(本次审计的收获)
1. **字段名 + 注释只能参考,真实含义靠数据推**(实战:billing_date 字面像"开单日",数据推实际是"治疗发生日")
2. **临床多对多是本质**:诊断/治疗/结算 1:1 强关联是不切实际的诉求(host 也做不到),PAC 接受隐式关联(同 encounter + 同 tooth + 名字模糊)
3. **不要让 host 形态裁剪 PAC 标准**:PAC fact 标准应基于临床通用语义,某 host 没的字段留 null,不删
4. **衍生 vs 事实**:host 给的聚合(is_dental_loss / customer_level / first_visit_time)PAC 不接,PAC 自己从事实算更可信(单一真理源)
### 14. 生产 host 数据混入测试账户
- **现状**:`fact_client_out``client_name='束乾凤test'` 测试账户,跨 18 个月(2024-09→2026-04)
累积 1531 条 `fact_settlement_out` 退费记录(净退费 ¥489.66)
- **影响**:PAC 100-cohort 把它纳入 → refund_record 99% 集中在 1 个测试账户,真实业务退费观察被掩盖
- **诉求**:DW 侧标记 / 过滤测试账户(如加 `is_test` 字段或专用 organization_id 隔离),否则
PAC 这边只能"按 client_name LIKE '%test%'" 拍黑名单,跨 host 不可移植
......@@ -6,9 +6,9 @@
```mermaid
flowchart LR
S1["① 发现治疗机会<br/>影像 / EMR / 医嘱 / 收费"]
S2["② 进入治疗链<br/>治疗计划形成 / 收费单提交 / 收款完成 / 预约计划形成 / 患者被接诊"]
S3["③ 治疗执行<br/>处置 / 用药 / EMR / 收费 / 退费 / 补费"]
S1["① 发现治疗机会<br/>影像 / EMR / 医嘱 / 收费"]
S2["② 进入治疗链<br/>治疗计划形成 / 收费单提交 / 收款完成 / 预约计划形成 / 患者被接诊"]
S3["③ 治疗执行<br/>处置 / 用药 / EMR / 收费 / 退费 / 补费"]
S4["④ 术后与复查管理<br/>术后医嘱 / 用药提醒 / 复查计划 / 术后随访"]
S5["⑤ 治疗链闭环<br/>处置完成 + 复查完成 + 无风险信号"]
......
......@@ -48,6 +48,8 @@ export const PatientCanonicalSchema = z
phone: z.string().optional().nullable(),
gender: z.string().optional().nullable(), // free text per §3.4.1
birthDate: optionalDate,
/// 病历号(host 病历主键,如 "BA43016");跟 externalId 互补 — 客服沟通用。
medicalRecordNumber: z.string().optional().nullable(),
preferences: z.record(z.string(), z.unknown()).optional().nullable(),
/// 'active' / 'archived'(v2:'merged' 已删) — host raw 值,PAC 入库时映射成 active boolean
status: z.string().optional().default('active'),
......@@ -79,9 +81,21 @@ export const AppointmentCanonicalSchema = z
patientExternalId: z.string().min(1),
clinicId: z.string().min(1),
scheduledAt: isoDateTime,
status: z.enum(['scheduled', 'arrived', 'cancelled', 'no_show']),
// 8 态细分(跟 fact-content AppointmentRecordContent.status / jvs-dw enum_mapping 对齐)
status: z.enum([
'scheduled',
'rescheduled',
'cancelled',
'arrived',
'in_treatment',
'completed',
'no_show',
'walk_in',
]),
appointmentType: z.string().optional().nullable(),
doctorId: z.string().optional().nullable(),
complaintCategory: z.string().optional().nullable(),
durationMinutes: z.coerce.number().optional().nullable(),
arrivedAt: optionalIsoDateTime,
createdAt: optionalIsoDateTime,
updatedAt: optionalIsoDateTime,
......@@ -374,19 +388,25 @@ export type ComplaintCanonical = z.infer<typeof ComplaintCanonicalSchema>;
/// 转介绍记录(老客带新)
/// 产品收集
/// referral_record:fact 挂在**被推荐人**(referee = patientExternalId),
/// 推荐人(referrer)信息进 content 快照(PAC 不建推荐人主数据表,
/// referrer 可能是别 host 患者 / 员工,放 content 更通用)。
export const ReferralCanonicalSchema = z
.object({
externalId: z.string().min(1),
/// 推荐人(老客)— 关联 patient
referrerPatientExternalId: z.string().min(1),
/// 被推荐人(新客)— 关联 patient(如该新客已建档),否则 nullable
refereePatientExternalId: z.string().optional().nullable(),
clinicId: z.string().min(1),
recordedAt: isoDateTime,
/// 被推荐人(referee)= fact 挂的 patient
patientExternalId: z.string().optional().nullable(),
/// clinic 可空:referral 来自 patient 主档时无明确 clinic
clinicId: z.string().optional().nullable(),
recordedAt: optionalIsoDateTime,
/// 推荐人(referrer)信息 — 快照,不依赖 referrer 是否在 PAC patient 库
referrerExternalId: z.string().optional().nullable(),
referrerName: z.string().optional().nullable(),
referrerChartNumber: z.string().optional().nullable(),
/// 推荐人类型:patient(老带新)/ staff(员工)/ partner / other
referrerType: z.string().optional().nullable(),
/// 渠道:wechat / face_to_face / event / ...(host 自定义)
channel: z.string().optional().nullable(),
/// 转化金额(被推荐人首单消费,cents);未转化为 0 / null
conversionAmount: coerceIntOptional,
})
.passthrough();
export type ReferralCanonical = z.infer<typeof ReferralCanonicalSchema>;
......
......@@ -9,3 +9,4 @@ export * from './agent';
export * from './sync';
export * from './admin';
export * from './canonical';
export * from './reason-signals';
......@@ -10,6 +10,7 @@ import {
import { PagingResponseSchema } from './common';
import { PatientSchema } from './patient';
import { PersonaSchema } from './persona';
import { ReasonSignalsSchema } from './reason-signals';
// =============================================================
// Layer 3 — FollowupPlan
......@@ -126,14 +127,20 @@ export const ListPlansQuerySchema = z.object({
assigneeUserId: z.string().optional(),
view: z.enum(['pool', 'mine', 'all']).optional()
.describe('"pool" = active+unassigned; "mine" = assigned to caller; "all" = no extra filter'),
/// 关键字搜索:服务端按 patient.name / phone / externalId 模糊匹配(W3 末加,替代前端本页 filter)
keyword: z.string().trim().min(1).optional(),
/// 排序:服务端 ORDER BY(W3 末加,替代前端 .sort)
sort: z.enum(['priority_desc', 'priority_asc', 'created_desc']).default('priority_desc'),
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(20),
});
export type ListPlansQuery = z.infer<typeof ListPlansQuerySchema>;
/// 列表行用的患者简介(已脱敏)— 避免列表每行再发请求
/// 列表行用的患者简介 — 避免列表每行再发请求。
/// name 是原值(权限内客服可见),nameMasked 仅作脱敏展示场景备用。
export const PlanPatientBriefSchema = z.object({
externalId: z.string(),
name: z.string().nullable(),
nameMasked: z.string().nullable(),
phoneMasked: z.string().nullable(),
gender: z.string().nullable(),
......@@ -141,10 +148,24 @@ export const PlanPatientBriefSchema = z.object({
});
export type PlanPatientBrief = z.infer<typeof PlanPatientBriefSchema>;
/// 列表行 = FollowupPlan + patient brief + 中文 scenarioLabel(后端预翻译,前端零计算)
/// 列表行用的 reason 简介 — 富文本由前端 ReasonLine 用 signals 组装。
/// 跟 plan-aggregate 详情页 reasons 同构(字段子集),保证前端可共享 ReasonLine 组件。
export const PlanReasonBriefSchema = z.object({
id: z.string().uuid(),
scenario: z.string(),
subKey: z.string().nullable(),
signals: ReasonSignalsSchema.nullable(),
priorityScore: z.number(),
/// 老数据 / signals 缺失时的兜底自然语言
reason: z.string(),
});
export type PlanReasonBrief = z.infer<typeof PlanReasonBriefSchema>;
/// 列表行 = FollowupPlan + patient brief + reasons brief + 中文 scenarioLabel(后端预翻译,前端零计算)
export const PlanListItemSchema = FollowupPlanSchema.extend({
patient: PlanPatientBriefSchema,
scenarioLabel: z.string(),
reasons: z.array(PlanReasonBriefSchema),
});
export type PlanListItem = z.infer<typeof PlanListItemSchema>;
......@@ -153,6 +174,16 @@ export const ListPlansResponseSchema = z
.extend(PagingResponseSchema.shape);
export type ListPlansResponse = z.infer<typeof ListPlansResponseSchema>;
/// KPI / Tab badge 用的聚合计数 — 后端一次 SQL 给齐,避免列表页 5 个并发 list 请求。
export const PlanCountsResponseSchema = z.object({
mine: z.number().int(),
mineAssigned: z.number().int(),
mineCompleted: z.number().int(),
pool: z.number().int(),
all: z.number().int(),
});
export type PlanCountsResponse = z.infer<typeof PlanCountsResponseSchema>;
export const PlanDetailResponseSchema = z.object({
plan: FollowupPlanSchema,
patient: PatientSchema,
......
import { z } from 'zod';
/**
* ReasonSignals — plan_reasons.signals JSON 字段的强校验 schema。
*
* 设计原则(W3 末):
* - DB 存 **原始 enum / canonical code**,不存中文(name_zh 翻译归前端 i18n)
* - 字段命名 PAC 标准 camelCase,跨 scenario 通用
* - triggers 是**数组**(留多信号源融合余地;当前 scenario 单源 → 长度 1)
* - factId 不存这里 — 走 plan_reasons.evidence JSON(`{ factIds[] }`,跟 PersonaFeature.evidence 同形),避免重复
*
* 前端用 PACScenarioSubLabels / PACTriggerTypeLabels / diagnosisCodeNameZh /
* treatmentCategoryNameZh 翻译展示。
*/
export const ReasonSignalsSchema = z.object({
/// 子规则 key(对齐 scenario 内 subKey:missing_tooth / caries_no_filling / perio_no_srp / endo_no_rct …)
subKey: z.string().min(1),
/// 触发信号数组(多信号源融合留余地)
/// 当前 scenario 单源命中 → 长度 1;未来 K08 + IMPLANT_RECOMMENDED + CBCT 三源合并 → 长度 3
triggers: z
.array(
z.object({
/// 触发类型 enum:diagnosis / recommendation / image_finding / emr_signal / visit_gap / ...
/// 留 z.string() 不强 enum,未来扩展不动 schema(canonical labels 字典翻译)
type: z.string().min(1),
/// 触发码(PAC canonical):K08 / IMPLANT_RECOMMENDED 等;visit_gap 等无 code 场景为 null
code: z.string().nullable().optional(),
}),
)
.min(1),
/// 牙位原文(可空 — 全口治疗 / 沉睡客户激活 等场景无牙位)
toothPosition: z.string().nullable().optional(),
/// 紧迫维度:触发信号距今多少天(跨场景通用)
daysSince: z.number().int().nonnegative(),
/// 期望待启动类别(raw PAC treatment category enum;前端用 treatmentCategoryNameZh 翻译)
/// 不同场景含义:
/// - 召回类:期望启动哪类治疗(种植 / 充填 / 牙周基础 / 根管)
/// - 沉睡激活:可能是 [recall_visit] 等
expectedCategories: z.array(z.string()),
});
export type ReasonSignals = z.infer<typeof ReasonSignalsSchema>;
......@@ -57,3 +57,11 @@ export function fmtYearMonth(d: Date | string): string {
const target = typeof d === 'string' ? new Date(d) : d;
return `${target.getFullYear()}.${String(target.getMonth() + 1).padStart(2, '0')}`;
}
/**
* `YYYY.MM.DD`(year + month + day,治疗链节点 / fact 时间轴精确日期)
*/
export function fmtYearMonthDay(d: Date | string): string {
const target = typeof d === 'string' ? new Date(d) : d;
return `${target.getFullYear()}.${String(target.getMonth() + 1).padStart(2, '0')}.${String(target.getDate()).padStart(2, '0')}`;
}
......@@ -48,7 +48,7 @@ export function verifySecret(secret: string, stored: string): boolean {
}
// 格式化 / 掩码 / 相对时间 — 浏览器 + Node 双端可用
export { maskPhone, maskName, fmtRel, fmtDate, fmtYearMonth } from './format';
export { maskPhone, maskName, fmtRel, fmtDate, fmtYearMonth, fmtYearMonthDay } from './format';
export function chunk<T>(arr: T[], size: number): T[][] {
if (size <= 0) return [arr];
......
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