Commit 73ad5150 by luoqi

merge: refactor/assembler-shared-dict → test(FRIDAY 病历摄入 + 共享字典 + cohort 过滤)

范围:字典分层(_shared/dict 7 份)、FRIDAY 15 资源摄入(assemblers+export.sh)、
时间/金额语义修正、游标统一(file 源同产 cursor)、fact 四时间锚、
患者主档 push(partial upsert)、模拟登录分宿主 tab、export.sh cohort 过滤(--clinics/--since)。
jvs-dw 兼容性:20/20 assembler deep-equal 与生产一致,增量摄入不受影响。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
parents ad73a12c 106edbac
...@@ -8,8 +8,9 @@ ...@@ -8,8 +8,9 @@
"channel-rest", "channel-rest",
"channel-clickhouse", "channel-clickhouse",
"channel-push", "channel-push",
"friday-push-payload",
"auth-login", "auth-login",
"execution-callback", "execution-callback",
"runbook" "runbook"
] ]
} }
\ No newline at end of file
# ═══════════════════════════════════════════════════════════
# 共享字典 · 影像类型名 → PAC modality 闭集(pa/bw/pano/cbct/intraoral_photo/scan/other)
# 【临床通用层】真影像类型名(FRIDAY std_check_class 与 jvs-dw check_name 实测同名)。
# 宿主单据/化验类名目(牙周大表/血常规/术前清单…→other)与 _default 留宿主 yaml。
# 源出处:jvs-dw image.yaml。
# ═══════════════════════════════════════════════════════════
enum_mapping:
modality:
"口内像照片": intraoral_photo
"面相照": intraoral_photo
"内窥镜": intraoral_photo
"小牙片": pa
"全景片/头颅正侧位片截图": pano
"全景片/侧位片/其他X线影像": pano
"CT影像截图": cbct
"口腔扫描": scan
"其他类型资料(非X影像)": other
# ═══════════════════════════════════════════════════════════
# 共享字典 · 医生建议文本 → PAC 推荐码(IMPLANT_RECOMMENDED 等)
# 【临床通用层】"建议种植/建议拔除"等标准表达(enum 精确 + keyword 含词兜底)。
# 宿主标点变体等痕迹词与 _default 留宿主 yaml。源出处:jvs-dw recommendation.yaml。
# ═══════════════════════════════════════════════════════════
enum_mapping:
code:
# ── 种植 ──
建议种植: IMPLANT_RECOMMENDED
建议种植修复: IMPLANT_RECOMMENDED
推荐种植: IMPLANT_RECOMMENDED
建议种植治疗: IMPLANT_RECOMMENDED
建议种植牙: IMPLANT_RECOMMENDED
# ── 戴冠 / 修复 ──
建议戴冠: CROWN_RECOMMENDED
建议冠修复: CROWN_RECOMMENDED
建议全冠: CROWN_RECOMMENDED
建议桩核冠: CROWN_RECOMMENDED
建议做冠: CROWN_RECOMMENDED
推荐冠修复: CROWN_RECOMMENDED
# ── 充填 ──
建议充填: FILLING_RECOMMENDED
建议树脂充填: FILLING_RECOMMENDED
建议补牙: FILLING_RECOMMENDED
# ── 牙周基础 ──
建议洁牙: SRP_RECOMMENDED
建议龈上洁治: SRP_RECOMMENDED
建议牙周治疗: SRP_RECOMMENDED
建议牙周基础治疗: SRP_RECOMMENDED
# ── 拔除 ──
建议拔除: EXTRACTION_RECOMMENDED
建议拔牙: EXTRACTION_RECOMMENDED
推荐拔除: EXTRACTION_RECOMMENDED
# ── 复查 ──
建议年度复查: ANNUAL_REVIEW_RECOMMENDED
建议定期复查: ANNUAL_REVIEW_RECOMMENDED
建议复查: ANNUAL_REVIEW_RECOMMENDED
# ── 正畸咨询 ──
建议正畸咨询: ORTHO_CONSULT_RECOMMENDED
建议正畸: ORTHO_CONSULT_RECOMMENDED
建议矫正: ORTHO_CONSULT_RECOMMENDED
# ─────────────────────────────────────────────────────────
# 含词兜底 —— 精确 enum 未命中时,按序含词匹配 → 召回码。
# 治本长尾:"建议X"变体太多(建议充填治疗/建议根管治疗后冠修复/建议拔除后种植…),
# 精确白名单永远漏(谢玉兰 44 "建议充填治疗" 漏配 → null → 44 漏召)。引擎按 canonicalKey 通用。
# 顺序=优先级:高价值/主操作在前(种植>冠、根管>冠、拔除独立)。错配最坏=_default(现状),无回归。
keyword_mapping:
code:
- { value: IMPLANT_RECOMMENDED, any: [种植, 植体] }
- { value: RCT_RECOMMENDED, any: [根管, RCT, 牙髓] }
- { value: EXTRACTION_RECOMMENDED, any: [拔除, 拔牙] }
- { value: CROWN_RECOMMENDED, any: [, , 桩核] }
- { value: FILLING_RECOMMENDED, any: [充填, 补牙, 树脂] }
- { value: HARD_TISSUE_REPAIR_RECOMMENDED, any: [楔状, 牙体, 嵌体, 缺损] }
- { value: SRP_RECOMMENDED, any: [洁治, 洗牙, 牙周, 龈下, 刮治] }
- { value: ORTHO_CONSULT_RECOMMENDED, any: [正畸, 矫正, 矫治] }
- { value: GUM_TREATMENT_RECOMMENDED, any: [牙龈, 牙槽嵴, 系带] }
- { value: JAW_CYST_REMOVAL_RECOMMENDED, any: [囊肿] }
- { value: ERUPTION_INTERVENTION_RECOMMENDED, any: [萌出, 乳牙滞留, 多生牙] }
# ═══════════════════════════════════════════════════════════
# 共享字典 · 治疗 stage 词 → PAC step enum(开髓→pulp_extirpation 等)
# 【临床通用层】牙科标准术式步骤词。宿主经 dict_includes 引入;_default 宿主本地声明。
# 治理:改动影响所有引入宿主 → reparse;宿主怪癖词留宿主 yaml(本地覆盖共享)。
# 源出处:jvs-dw treatment_actual.yaml(与 treatment_planned 完全相同,抽取消除同宿主重复)。
# ═══════════════════════════════════════════════════════════
enum_mapping:
treatStages:
开髓: pulp_extirpation
拔髓: pulp_extirpation
根备: canal_preparation
根管预备: canal_preparation
根充: canal_filling
根管充填: canal_filling
# 牙髓切断术 / 活髓保存(pulpotomy 类 — VPT 终末术式)
# 临床等价:活髓切断 / 部分活髓切断 / 部分牙髓切断 / 冠髓切断 / 干髓 / 盖髓 / 牙髓血运重建
活髓切断: pulpotomy
部分活髓切断: pulpotomy
部分牙髓切断: pulpotomy
冠髓切断: pulpotomy
干髓术: pulpotomy
干髓治疗: pulpotomy
盖髓术: pulpotomy
间接盖髓: pulpotomy
直接盖髓: pulpotomy
牙髓血运重建: pulpotomy
活髓保存: pulpotomy
# 种植
植入: implant_placement
种植体植入: implant_placement
种植一期: implant_placement
种植I期: implant_placement # 罗马数字版(host 实测)
简单种植: implant_placement
复杂种植: implant_placement
即刻种植: implant_placement
延期种植: implant_placement
基台: abutment_placement
种植二期: abutment_placement
种植II期: abutment_placement # 罗马数字版(host 实测)
上部修复: crown_placement
种植冠修复: crown_placement
种植戴牙: crown_placement
种植三期: crown_placement
# 牙周
龈上洁治: supragingival_scaling
龈下刮治: subgingival_scaling
刮治: subgingival_scaling
牙周维护: periodontal_maintenance
# 修复
充填: composite_filling
嵌体: inlay
冠修复: crown_restoration
桩核: post_core
# 正畸
粘附件: bracket_placement
托槽: bracket_placement
矫治器: bracket_placement
保持器: retainer
加力: ortho_adjustment
# 长尾兜底:host stage 词字典外 → 空(不进 PAC,treat_stages 数组减一元素)
# ═══════════════════════════════════════════════════════════
# 共享字典 · 治疗 category 关键词兜底 —— 【actual(本次已做)语义变体】
# 与 planned 变体(treatment-category-planned-rules.yaml)是有意语义分歧:
# actual 剥"建议/推荐/考虑/若"从句(那些=本次没做);planned 本就以建议措辞,只剥纯条件词。
# 规则顺序=优先级(首个命中即用);宿主本地规则(若有)自动排在本文件规则之前。
# 源出处:jvs-dw treatment_actual.yaml(含裁决顺序的全部推敲注释)。
# ═══════════════════════════════════════════════════════════
# 关键词分类兜底 —— 精确 enum_mapping 未命中时,按序含词匹配 → 赋 category。
# 治本长尾:treatName 2万+ distinct 精确字典覆盖不全,以前全靠 _default:'' 丢(→ 误召)。
# 引擎算法(切段 / 含词 / 按序裁决 / 剥离从句)host 无关;此处规则词 = 本 host 术式特征(host 差异)。
# 只覆盖【真治疗】10 大类;流程/无操作(观察/复诊/无治疗)不在此 → 落 _default 丢弃
# (review 噪音已由 transforms.route_by_pattern 上游分流,这里不重复)。
# 顺序 = 优先级:主操作/高价值在前 → 关键词赢复合(种植>冠、根管>冠、操作词>人群词)。
# ⚠️ 安全:只接管精确未命中的长尾,精确命中零影响;错配最坏退化=_default(现状),无回归。
keyword_mapping:
category:
# ⚠️ "二期" 必须限定到「种植二期」:裸"二期"会误吞"二期隐形矫正"(正畸)等 → 误判 implant。
# 种植语境的二期都带"种植"(种植二期);ortho 的"二期隐形矫正/二期矫正"靠下面 orthodontic 兜住。
- { value: implant, any: [种植, 即拔即种, 植体, 种植二期] }
- { value: endodontic, any: [根管, RCT, 牙髓, 开髓, 根备, 根充, 盖髓, 摘髓, 根尖] }
- { value: orthodontic, any: [正畸, 矫治, 矫正, 托槽, 保持器, 粘附件, 隐适美, 隐形矫, 扩弓, 错颌, 错合] }
- { value: cosmetic, any: [贴面, 漂白, 美白] }
# 修复/备牙/取模:收 C.10 治疗痕迹(修复后/修复术后/全瓷桥修复后/备牙后/重新取模后)。
# 排在 implant/endo/ortho/cosmetic 之后 → 种植修复→implant、根管…修复→endo 仍被前面抢走。
# "嵌体修复"等 restorative 已由上方精确 enum 先命中,不受裸"修复"影响。
- { value: prosthodontic, any: [, , 义齿, 修复体, 修复, 备牙, 取模, 桩核, 桩冠, 戴牙, 全瓷, 烤瓷, 重新粘接] }
# ⭐ 明确充填动作(充填/去腐/备洞/嵌体/垫底/补牙)压过 preventive:
# "去腐,备洞,...树脂充填,窝沟封闭"(dispose 散文)是补牙(restorative),不能因含"窝沟封闭"
# 被误判 preventive → 非 resolver → 误召(马思煦@46)。注:"开髓去腐"等根管语境已被上方 endodontic 先收。
- { value: restorative, any: [充填, 补牙, 去腐, 备洞, 嵌体, 垫底] }
# 纯封闭(无充填动作)→ preventive:玻璃离子常作封闭剂材料("玻璃离子窝沟封闭"≠充填)
- { value: preventive, any: [窝沟封闭, 封闭剂, 点隙封闭] }
# 材料弱词:放 preventive 后 —— "玻璃离子窝沟封闭" 已被上面 preventive 收;裸"树脂/玻璃离子"→ restorative
- { value: restorative, any: [树脂, 玻璃离子] }
# 牙周去裸"洁牙"(避开自由文本"清洁牙面"误命中);洁牙/全口洁牙等精确词由上方 enum_mapping 兜
- { value: periodontic, any: [洁治, 洗牙, 龈上, 龈下, 刮治, 牙周, 喷砂, 细洁] }
- { value: preventive, any: [涂氟, 防龋, OHI, 口腔卫生宣教] }
- { value: surgical, any: [拔除, 拔牙, 切开, 翻瓣, 切除, 系带, 脓肿, 囊肿, 植骨] }
- { value: pediatric, any: [乳牙, 儿童, 年轻恒牙] }
# actual 语义:这些词开头的从句 = 本次没做(条件/未来/建议)→ 切段丢弃后再匹配。
# 例 "充填,必要时根管治疗" → 丢"必要时根管治疗" → "充填" → restorative(不误判成 endodontic)。
keyword_strip_clauses: [必要时, 如需, 择期, 建议, 推荐, 考虑, ] # 若…=条件从句(若牙面脱矿,建议充填),非本次实际治疗
# ═══════════════════════════════════════════════════════════
# 共享字典 · 治疗名 → PAC category(11 类闭集)· 精确匹配临床核心
#
# 【临床通用层】只收"干净"的标准术式名(无标点残留/价格表项名/长句/宿主判例)。
# 取自 jvs-dw treatment_actual ∩ treatment_planned 同值交集再过滤宿主痕迹(2026-07)。
# 宿主价格表项名/标点变体/判例(拆线→surgical、全景片→preventive 等)留各宿主 yaml。
# _default 宿主本地声明。keyword 兜底规则见 treatment-category-{actual,planned}-rules.yaml。
# ═══════════════════════════════════════════════════════════
enum_mapping:
category:
# ── 牙周(periodontic)
龈上洁治: periodontic
龈上洁治术: periodontic
全口龈上洁治: periodontic
全口洁治: periodontic
全口洁牙: periodontic
全口超声洁治: periodontic
全口超声波洁治: periodontic
全口龈上超声洁治: periodontic
定期全口洁治: periodontic
定期洁牙: periodontic
洁牙: periodontic
洁治: periodontic
洗牙: periodontic
"洁治 OHI": periodontic
龈下细洁: periodontic
龈上超声洁治: periodontic
牙周基础治疗: periodontic
牙周系统治疗: periodontic
牙周治疗: periodontic
牙周维护: periodontic
牙周刮治: periodontic
牙周刮治术: periodontic
龈下刮治: periodontic
龈下刮治术: periodontic
根面平整术: periodontic
PMTC: periodontic
牙周序列治疗: periodontic
系统性牙周治疗: periodontic
# ── 充填修复(restorative)
充填: restorative
充填治疗: restorative
充填术: restorative
树脂充填: restorative
光固化树脂充填: restorative
非美学区树脂充填: restorative
美学区简单树脂充填: restorative
重新充填: restorative
预防性充填: restorative
树脂充填术: restorative
嵌体修复: restorative
嵌体: restorative
戴嵌体: restorative
龋齿充填: restorative
玻璃离子充填: restorative
玻璃离子充填术: restorative
进口玻璃离子充填: restorative
玻璃离子暂封: restorative
玻璃离子预充填: restorative
充填或嵌体修复: restorative
高嵌体修复: restorative
预防性树脂充填: restorative
# ── 牙髓/根管(endodontic)
根管治疗: endodontic
根管再治疗: endodontic
根管预备: endodontic
根管充填: endodontic
根充: endodontic
RCT: endodontic
牙髓治疗: endodontic
根尖诱导成形术: endodontic
拔髓: endodontic
干髓术: endodontic
盖髓术: endodontic
间接盖髓: endodontic
直接盖髓: endodontic
MTA盖髓: endodontic
冲洗上药: endodontic
根管治疗后冠修复: endodontic
活髓切断术: endodontic
部分活髓切断术: endodontic
部分牙髓切断术: endodontic
活髓切断: endodontic
乳牙活髓切断术: endodontic
乳牙活髓保存术: endodontic
冠髓切断术: endodontic
干髓治疗: endodontic
直接盖髓术: endodontic
间接盖髓术: endodontic
开髓引流: endodontic
开髓失活: endodontic
开髓开放: endodontic
开髓封药: endodontic
开髓封失活剂: endodontic
牙髓失活: endodontic
封失活剂: endodontic
放失活剂: endodontic
放置失活剂: endodontic
失活: endodontic
乳牙牙髓治疗: endodontic
髓腔消毒: endodontic
髓腔封药: endodontic
髓腔预备: endodontic
髓腔换药: endodontic
保髓治疗: endodontic
牙髓保留: endodontic
根管治疗术: endodontic
复杂根管治疗: endodontic
# ── 种植(implant)
种植: implant
种植手术: implant
种植体植入: implant
种植修复: implant
种植戴牙: implant
种植一期: implant
种植二期: implant
种植三期: implant
种植耗材: implant
种植基台: implant
种植取模: implant
种植拆线: implant
即刻种植术: implant
拔除后种植修复: implant
延期种植术: implant
拔除后种植: implant
# ── 修复(冠/桥/义齿/桩核)(prosthodontic)
冠修复: prosthodontic
全冠修复: prosthodontic
牙冠修复: prosthodontic
重新冠修复: prosthodontic
戴冠: prosthodontic
粘冠: prosthodontic
拆冠: prosthodontic
戴牙: prosthodontic
桩核: prosthodontic
桩核冠: prosthodontic
固定修复: prosthodontic
活动修复: prosthodontic
重新修复: prosthodontic
修复: prosthodontic
预成冠修复: prosthodontic
牙体预备: prosthodontic
种植上部修复: prosthodontic
种植体上部修复: prosthodontic
桩冠修复: prosthodontic
# ── 外科(拔除)(surgical)
拔除: surgical
拔除术: surgical
拔牙: surgical
牙拔除术: surgical
简单牙拔除术: surgical
松动恒牙拔除术: surgical
松动乳牙拔除术: surgical
择期拔除: surgical
阻生齿拔除术: surgical
阻生牙拔除: surgical
残根拔除术: surgical
# ── 正畸(orthodontic)
正畸: orthodontic
正畸治疗: orthodontic
隐形正畸: orthodontic
固定正畸: orthodontic
早期矫治: orthodontic
保持器: orthodontic
正畸保持器: orthodontic
正畸保持: orthodontic
正畸矫治器: orthodontic
正畸辅助治疗: orthodontic
正畸加力: orthodontic
加力: orthodontic
粘附件: orthodontic
粘接附件: orthodontic
重粘托槽: orthodontic
更换新矫治器: orthodontic
粘接上半口矫治器: orthodontic
粘接全口附件: orthodontic
精调粘接附件: orthodontic
发放新矫治器: orthodontic
精细调整: orthodontic
发放矫治器: orthodontic
# ── 预防(preventive)
涂氟: preventive
全口涂氟: preventive
窝沟封闭: preventive
窝沟封闭术: preventive
OHI: preventive
口腔卫生宣教: preventive
脱敏: preventive
抛光: preventive
# ── 美学(cosmetic)
美白: cosmetic
冷光美白: cosmetic
牙齿美白: cosmetic
牙位图美白: cosmetic
贴面: cosmetic
贴面修复: cosmetic
# ═══════════════════════════════════════════════════════════
# 共享字典 · 治疗 category 关键词兜底 —— 【planned(未来计划)语义变体】
# 与 actual 变体是有意语义分歧:planned 文本本就以"建议/拟/推荐"措辞 → 不剥这些词,
# 只剥纯条件从句(必要时/如需/择期)。
# ⚠️ TODO(继承自 jvs-dw 陈旧拷贝,行为保真原样搬入):implant 规则里裸「二期」会误吞
# "二期隐形矫正"(actual 变体已改「种植二期」)。修复属行为变更,须评估 + reparse,另行提交。
# 源出处:jvs-dw treatment_planned.yaml。
# ═══════════════════════════════════════════════════════════
keyword_mapping:
category:
- { value: implant, any: [种植, 即拔即种, 植体, 二期] }
- { value: endodontic, any: [根管, RCT, 牙髓, 开髓, 根备, 根充, 盖髓, 摘髓] }
- { value: orthodontic, any: [正畸, 矫治, 矫正, 托槽, 保持器, 粘附件, 隐适美, 扩弓] }
- { value: cosmetic, any: [贴面, 漂白, 美白] }
- { value: prosthodontic, any: [, , 义齿, 修复体, 桩核, 桩冠, 戴牙, 全瓷, 烤瓷, 重新粘接] }
- { value: restorative, any: [充填, 补牙, 树脂, 玻璃离子, 嵌体, 垫底] }
- { value: periodontic, any: [洁治, 洗牙, 洁牙, 龈上, 龈下, 刮治, 牙周, 喷砂, 细洁] }
- { value: preventive, any: [涂氟, 窝沟封闭, 封闭, 防龋, OHI, 口腔卫生宣教] }
- { value: surgical, any: [拔除, 拔牙, 切开, 翻瓣, 切除, 系带, 脓肿, 囊肿] }
- { value: pediatric, any: [乳牙, 儿童, 年轻恒牙] }
# planned 语义:计划本就以"建议/拟/推荐"措辞 → 不剥,只剥纯条件词(必要时/如需/择期)。
# 例 "建议全口洁治后,必要时正畸治疗" → 丢"必要时正畸治疗" → "建议全口洁治后" → periodontic。
keyword_strip_clauses: [必要时, 如需, 择期]
...@@ -3,5 +3,6 @@ ...@@ -3,5 +3,6 @@
* *
!.gitignore !.gitignore
!manifest.yaml !manifest.yaml
!export.sh
!assemblers !assemblers
!assemblers/*.yaml !assemblers/*.yaml
# consult — FRIDAY 咨询主体(customer_consult → consultation_record)
# 比 jvs-dw 富:除意向(potential_treatment)外还有 未成交原因(unsettled_reason)——
# 强召回信号(咨询未成交=有意愿没转化),passthrough 进 fact.content 供 scenario/LLM 消费。
# 测试库仅 45 行(结构真实);生产量待接入后观测。
canonical: consultation
emits:
action: consultation_created
subjectType: consultation
occurredAtField: occurredAt
primary:
table: customer_consult
key: id
dedup_by: id
field_mapping:
externalId: id
updatedAt: updated_gmt_at
createdAt: created_gmt_at
patientExternalId: customer_id
clinicId: organization_id
occurredAt: created_gmt_at
intentRaw: potential_treatment # 意向(自由文本;jvs-dw 同名语义 parser 解析)
taskDirector: task_director # 跟进人
# FRIDAY 独有信号(passthrough 进 content):
treatmentPlanText: treatment_plan # 咨询时聊到的治疗方案
unsettledReason: unsettled_reason # 未成交原因(分类值)
unsettledReasonDetail: unsettled_reason_description
preferentialWay: preferential_way # 优惠方式
# diagnosis — FRIDAY 诊断 fact(med_emr_info.diag[] 拆行)
# 消费 transforms 输出表 diagnosis_rows(split + std_diag lookup + normalize/substring/coalesce)
#
# code 解析规则(用户定,2026-07-18):
# linkCode 非空 → std_diag.std_code K 开头 → 截 K 大类(std_code_k 直通下方共享字典 passthrough);
# 非 K(DA0E.50 等 ICD-11)→ substring 产物("DA0")不在字典 → _default → code=null(自由文本);
# linkCode 空(99.7%)→ 归一中文名 → 共享 diagnosis-kcode 字典救码(与 jvs-dw 完全同一套)。
# 任何情况下 name=原文保留(展示 + LLM 输入),不丢 fact。
canonical: diagnosis
emits:
action: diagnosis_recorded
subjectType: diagnosis
occurredAtField: occurredAt
primary:
table: diagnosis_rows
key: diag_external_id
dedup_by: diag_external_id
field_mapping:
externalId: diag_external_id
updatedAt: updated_gmt_at
createdAt: created_gmt_at
patientExternalId: patient_id
clinicId: organization_id
occurredAt: clinic_time # 就诊时刻(100% 填充)
sourceEncounterExternalId: emr_sub_id # 同诊聚合锚(病历不普遍挂预约,用小病历号)
code: diag_code_src # K 码 或 归一中文名 → 共享字典翻译
stdCodeRaw: std_code # 溯源:字典 join 到的原始 std_code(可 null)
name: value # 原始中文名(展示 + LLM 输入)
toothPosition: tooth_position # "41;42" / "42 L;43 D"(FDI+牙面,分号分隔)
doctorId: user_id
doctorName: user_name
# ── 字典分层:临床通用层(K 直通 + 中文术语→K 码 + keyword 兜底)全在共享文件 ──
dict_includes:
- ../../_shared/dict/diagnosis-kcode.yaml
enum_mapping:
code:
# 兜底:共享 enum/keyword 都翻不出 → 空 → code=null(不丢 fact,name 保留)
_default: ""
# emr — FRIDAY 病历自由文本(诊断/治疗已拆独立 fact,本资源只装自由文本)
# 直接消费 med_emr_info(数组列不映射即不入 canonical;dispose_text/examine_text 是导出侧
# 序列化的 JSON 字符串形态,与 jvs-dw examine/dispose "JSON 原文" 同语义)
canonical: emr
emits:
action: emr_submitted
subjectType: emr
# occurred = clinic_time(就诊时刻,100% 填充);created_gmt_at 是录入时点,留幂等键用
occurredAtField: submittedAt
primary:
table: med_emr_info
key: emr_sub_id # 小病历号 uuid(两侧连接键 + 临床事实锚点)
dedup_by: emr_sub_id
field_mapping:
externalId: emr_sub_id
# updatedAt 进 source_event_id 幂等键,host UPDATE → 新 transaction(同 jvs-dw 纪律)
updatedAt: updated_gmt_at
createdAt: created_gmt_at
patientExternalId: patient_id
clinicId: organization_id
submittedAt: clinic_time # 就诊时刻(100%)
encounterExternalId: appointment_id # 56.5% 有值 → 挂预约/接诊链;临床事实锚用 emr_sub_id
doctorId: user_id
doctorName: user_name
# SOAP 自由文本段落
illnessDesc: illness_desc # 主诉
preIllness: pre_illness # 现病史
pastHistory: past_hist # 既往史
generalCondition: gen_cond # 全身情况
examFindings: examine_text # 检查所见(JSON 原文,带牙位)
auxExamFindings: auxi_exam # 辅助检查(自由文本)
disposal: dispose_text # 处置叙述(98% 填充,JSON 原文带牙位;actual 治疗主源是 treat[])
doctorAdvice: doc_order # 医嘱
# image — FRIDAY 影像 metadata(MySQL emr.med_check;PAC 不持文件本体)
# 影像不在 Mongo 病历文档内(全字段枚举证实),挂 emr_id(10 位病历号)回病历。
# 消费 transforms 输出表 image_rows(med_check lookup std_check_class 附 class_name)
canonical: image
emits:
action: image_uploaded
subjectType: image
occurredAtField: uploadedAt
primary:
table: image_rows
key: id
dedup_by: id
field_mapping:
externalId: id
updatedAt: updated_gmt_at
createdAt: created_gmt_at
patientExternalId: patient_id
clinicId: organization_id
uploadedAt: shooting_time # 拍摄时间
encounterExternalId: emr_id # 病历号级挂载(= med_emr_info.emr_id,非 emr_sub_id)
modality: class_name # lookup 自 std_check_class 字典
fileUrl: file_url # OSS 相对路径(passthrough)
fileName: file_name
# ── 字典分层:真影像类型名共享(与 jvs-dw 实测同名);FRIDAY 特有名目留本地 ──
dict_includes:
- ../../_shared/dict/image-modality.yaml
enum_mapping:
modality:
# FRIDAY 特有:胸片(987d743a 字典)→ 非口腔影像归 other;
# 3a9d9c68 字典乱录("测试"/"1"/"11")与 lookup 未命中(class_name=null)→ _default
胸片: other
_default: other
...@@ -11,6 +11,11 @@ field_mapping: ...@@ -11,6 +11,11 @@ field_mapping:
externalId: id # customer_basic_info 主键 = host 内部患者唯一 id(→ String 化) externalId: id # customer_basic_info 主键 = host 内部患者唯一 id(→ String 化)
name: name name: name
phone: phone # 由 transforms.lookup 从 customer_contacts 挑默认号附加 phone: phone # 由 transforms.lookup 从 customer_contacts 挑默认号附加
# 该号关系码(lookup 同源带出)→ preferences.contactPhone{relationshipCode, relationship, isSelf}。
# 官方枚举(源码实锤 friday-saas/customer …/enums/PhoneRelationshipEnum.java,2026-07-20):
# 1本人 2爸爸 3妈妈 4爷爷 5奶奶 6朋友 7配偶 8子女 9其他(语义=持号人是患者的X)
phoneRelationshipCode: phone_relationship
phoneRelationship: phone_relationship # 同源列解码成 PAC 关系词(下方 enum_mapping)
gender: sex # tinyint 1/2 → enum_mapping 归一;0/空 → 空 gender: sex # tinyint 1/2 → enum_mapping 归一;0/空 → 空
birthDate: birthday # date;空值入库为 null birthDate: birthday # date;空值入库为 null
medicalRecordNumber: file_number # 档案号(病历号,客服沟通用) medicalRecordNumber: file_number # 档案号(病历号,客服沟通用)
...@@ -25,3 +30,15 @@ enum_mapping: ...@@ -25,3 +30,15 @@ enum_mapping:
'1': male '1': male
'2': female '2': female
_default: '' # 0 / 空 / 未知 → 空(gender 可空,不阻挡入库) _default: '' # 0 / 空 / 未知 → 空(gender 可空,不阻挡入库)
# 默认联系号关系(PhoneRelationshipEnum 官方码 → PAC 关系词;语义=持号人是患者的X)
phoneRelationship:
'1': self
'2': father
'3': mother
'4': grandparent
'5': grandparent
'6': friend
'7': spouse
'8': child
'9': other
_default: '' # 空/未知码 → 空(preferences 里只留原码)
# patient_relation — FRIDAY 患者-患者关系边(customer_referee_circle → patient_relations 独立表)
#
# 源表双向成对存储(A→B 与 B→A 各一行,互为视角)。upsert 资源(无 emits),同 jvs-dw。
#
# ── 关系码 = 官方枚举(源码实锤,2026-07-20)──
# friday-saas/customer …/enums/RecommendRelationshipEnum.java:
# 1配偶 2爸爸 3子女 4兄弟 5爷爷 6孙子女 7朋友 8其他亲属 9姐妹 10妈妈 11奶奶
# 12姐弟 13兄妹 14其他 15外公 16外婆 17外孙子女
# ⚠️ 语义方向(该类 reverseValue() + 年龄差数据双证):存储的码 = **本人(customer)是
# 推荐人(referee)的 X**。PAC patient_relation 契约要"对方是本人的 X" → 此处映射 = 逆关系:
# 码2(本人是对方爸爸)→对方是本人的 child;码3(本人是对方子女)→对方是本人的父/母
# (按对方性别拆,transforms 已 lookup 对方 sex 拼 "码|性别" 键);余类推。
# (早期统计推断大multi命中,唯一错:码8 其他亲属曾误标 friend,本版修正为 other。)
canonical: patient_relation
primary:
table: patient_relation_rows
key: customer_id # 非真 PK;去重靠 PatientRelation @@unique(patientId, relatedExternalId, relationship)
field_mapping:
patientExternalId: customer_id
relatedExternalId: referee_patient_id
relationship: rel_sex_key # "码|对方性别" → 下方 enum_mapping 逆向归一
enum_mapping:
relationship:
# 1 配偶(对称)
"1|1": spouse
"1|2": spouse
"1|": spouse
# 2 本人是对方爸爸 / 10 妈妈 → 对方是本人的 child
"2|1": child
"2|2": child
"2|": child
"10|1": child
"10|2": child
"10|": child
# 3 本人是对方子女 → 对方是本人的父/母(按对方性别;性别缺 → other 不硬猜)
"3|1": father
"3|2": mother
# 4 兄弟 / 9 姐妹 / 12 姐弟 / 13 兄妹 → sibling(对称)
"4|1": sibling
"4|2": sibling
"4|": sibling
"9|1": sibling
"9|2": sibling
"9|": sibling
"12|1": sibling
"12|2": sibling
"12|": sibling
"13|1": sibling
"13|2": sibling
"13|": sibling
# 5 爷爷 / 11 奶奶 / 15 外公 / 16 外婆(本人是对方祖辈)→ 对方是本人的 grandchild
"5|1": grandchild
"5|2": grandchild
"5|": grandchild
"11|1": grandchild
"11|2": grandchild
"11|": grandchild
"15|1": grandchild
"15|2": grandchild
"15|": grandchild
"16|1": grandchild
"16|2": grandchild
"16|": grandchild
# 6 孙子女 / 17 外孙子女(本人是对方孙辈)→ 对方是本人的 grandparent
"6|1": grandparent
"6|2": grandparent
"6|": grandparent
"17|1": grandparent
"17|2": grandparent
"17|": grandparent
# 7 朋友
"7|1": friend
"7|2": friend
"7|": friend
# 8 其他亲属 / 14 其他 / NULL / 未知码
_default: other
# payment — FRIDAY 消费(patient_settlement 头单级,status∈{1,3})
# 消费 transforms 输出表 payment_rows(lookup settlement_modes 已附主导通道 method)
# status=3(含退费行的单)头单仍是原始消费 → 入 payment;其退费行走 refund_item.yaml,
# LTV = Σpayment − Σrefund 自然对账。
canonical: payment
emits:
action: payment_received
subjectType: payment
occurredAtField: paidAt
primary:
table: payment_rows
key: uuid
dedup_by: uuid
field_mapping:
externalId: uuid
updatedAt: updated_gmt_at
createdAt: created_gmt_at
patientExternalId: patient_id
clinicId: organization_id
paidAt: created_gmt_at # 结算入库时间(jvs-dw 同款决策:billing_date 有预付失真)
# amount = receivable_this(应收):LTV=患者带来的业务价值,非现金流(会员卡扣费单不清零)
# FieldMapper.normalize 按 amount_unit=yuan 转 cents
amount: receivable_this
method: method # lookup 主导支付通道(现金/微信/刷卡/医保…原样入 fact)
doctorId: doctor_id
encounterExternalId: registration_id # 关联接诊
settlementSerialNum: settlement_serial_num # 结算流水号(passthrough,对账用)
# recommendation — FRIDAY 医生建议("建议根管治疗"/"建议拔除"等;treat[] route 分流产物)
# 实测 153 行(建议充填/建议拔除/建议根管治疗…),经共享 recommendation-code 字典翻召回码。
# code 用归一后的 value_norm("建议充填," → "建议充填" 精确命中;长尾靠共享 keyword 兜底)。
canonical: recommendation
emits:
action: recommendation_extracted
subjectType: recommendation
occurredAtField: occurredAt
primary:
table: recommendation_rows
key: rec_external_id
dedup_by: rec_external_id
field_mapping:
externalId: rec_external_id
updatedAt: updated_gmt_at
createdAt: created_gmt_at
patientExternalId: patient_id
clinicId: organization_id
occurredAt: clinic_time
sourceEncounterExternalId: emr_sub_id
code: value_norm # "建议种植" 等 → 共享字典翻 PAC 推荐码
name: value # 医生原文(病历忠实展示;长尾建议靠它)
toothPosition: tooth_position
doctorId: user_id
doctorName: user_name
# ── 字典分层:标准"建议X"词典(enum+keyword)全在共享文件 ──
dict_includes:
- ../../_shared/dict/recommendation-code.yaml
enum_mapping:
code:
_default: "" # 未识别"建议X" → 共享 keyword 兜底;再不中 → 丢弃(不入 fact)
# refund_full — FRIDAY 整单冲减退费(status=4 反向单 + status=3 负金额单;导出侧 WHERE 切分)
# 实测:status=4 共 99 单全带 ref_settlement_id(挂原结算单)、金额全负;
# 2ac96f6d 品牌不用 status=4,整单冲减记 status=3 负金额(200 单,多无 ref → paymentExternalId=null)。
# ⭐ paymentExternalId = ref_settlement_id —— jvs-dw 缺此字段只能 registration_id 侧面对账,
# FRIDAY 直接兑现 refund.yaml 里预留的升级(退费精确挂回原 payment fact)。
# 金额负值由 refund parser Math.abs 归一为正 cents(语义=患者拿回的钱,host 无关)。
canonical: refund
emits:
action: refund_created
subjectType: refund
occurredAtField: refundedAt
kind: actual
primary:
table: patient_settlement_refund
key: uuid
dedup_by: uuid
field_mapping:
externalId: uuid
updatedAt: updated_gmt_at
createdAt: created_gmt_at
patientExternalId: patient_id
clinicId: organization_id
refundedAt: created_gmt_at
amount: net_receipts_this # 负值,parser abs 归一
paymentExternalId: ref_settlement_id # ⭐ 原结算单(= payment.externalId)
reason: reason
encounterExternalId: registration_id
# refund_item — FRIDAY 行级部分退费(patient_settlement_spec is_refund=1,导出已收窄)
# 实测:581 行全部归属 status=3 头单(部分退费轨道);金额正值 = 被退金额(同 jvs-dw ①语义)。
# 头单本身仍作 payment(原始消费),本资源扣减 → LTV 对账闭合。
# paymentExternalId = settlement_id(所属结算头单 = payment.externalId)。
canonical: refund
emits:
action: refund_created
subjectType: refund
occurredAtField: refundedAt
kind: actual
primary:
table: patient_settlement_spec_refund
key: id
dedup_by: id
field_mapping:
externalId: id
updatedAt: updated_gmt_at
createdAt: created_gmt_at
patientExternalId: patient_id
clinicId: organization_id
refundedAt: created_gmt_at
amount: net_receipts_this
paymentExternalId: settlement_id # 所属结算头单(部分退费挂回原 payment)
# 被退项目名(passthrough,UI 时间轴"退费 ¥X(项目 Y)")
refundProjectName: service_project_name
# treatment_actual — FRIDAY 本次治疗(med_emr_info.treat[] 拆行)
# 语义(用户确认,2026-07-18):treat[] = 本次治疗(actual);病历无计划字段(全字段枚举证实),
# 治疗计划走独立表 customer_treat_plan(见 treatment_planned.yaml)。
# 覆盖率注:treat 填充 25%(dispose 98% 是处置叙述 → emr_record.disposal 全文保留不丢);
# 若实践发现漏召,Phase 2 评估 dispose keyword 路由补 actual。
canonical: treatment
emits:
action: treatment_completed
subjectType: treatment
occurredAtField: occurredAt
kind: actual
primary:
table: treatment_actual_rows
key: treat_external_id
dedup_by: treat_external_id
field_mapping:
externalId: treat_external_id
updatedAt: updated_gmt_at
createdAt: created_gmt_at
patientExternalId: patient_id
clinicId: organization_id
occurredAt: clinic_time # 就诊时刻(100% 填充)
sourceEncounterExternalId: emr_sub_id # 同诊聚合锚
category: category_raw # 归一治疗名 → 共享字典翻 11 类闭集
subtype: value # 原始 treat 文本留作 subtype(chain milestone fallback)
toothPosition: tooth_position
doctorId: user_id
doctorName: user_name
# ── 字典分层:临床核心词 + keyword/strip(actual 语义变体)全在共享文件;FRIDAY 尚无宿主特有词 ──
# (测试库 treat 一半键盘乱敲 → 高频宿主词等生产数据回来后按 mapping-miss 巡检补这里)
dict_includes:
- ../../_shared/dict/treatment-category-core.yaml
- ../../_shared/dict/treatment-category-actual-rules.yaml
enum_mapping:
category:
# 拆线 = 术后护理 = 外科治疗动作(jvs-dw 2026-06-11 一线判例:归 review 不算治疗会误召;
# 判例与 route 分流语境耦合 → 留宿主本地,不入共享层)。FRIDAY 实测 50 行。
拆线: surgical
# 长尾兜底:未命中 → category='',parser 跳过该 fact(数据损失换质量,jvs-dw 同款纪律)
_default: ""
# treatment_planned — FRIDAY 治疗计划(customer_treat_plan + _item 独立表;用户确认此表为治疗计划)
# 消费 transforms 输出表 treatment_planned_rows(行表 lookup 头表拿 organization_id/plan_name)
# 行粒度 = 计划行项(牙位 + 术式 mode_name + 价格区间);externalId = 行表主键(host 稳定)
canonical: treatment
emits:
action: treatment_planned
subjectType: treatment
occurredAtField: occurredAt
kind: planned
primary:
table: treatment_planned_rows
key: id
dedup_by: id
field_mapping:
externalId: id
updatedAt: updated_gmt_at
createdAt: created_gmt_at
patientExternalId: customer_id
clinicId: organization_id # lookup 自头表(行表无诊所)
occurredAt: created_gmt_at # 计划制定时点
category: mode_norm # 归一术式名 → 共享字典翻 11 类闭集
subtype: mode_name # 原始术式名
toothPosition: tooth_position
planName: plan_name # 方案名(passthrough,展示用)
# ── 字典分层:planned 语义变体(不剥"建议/拟"措辞,只剥条件词)──
dict_includes:
- ../../_shared/dict/treatment-category-core.yaml
- ../../_shared/dict/treatment-category-planned-rules.yaml
enum_mapping:
category:
_default: ""
# treatment_review — FRIDAY 流程性事实(复查/暂观/观察等;treat[] route 分流产物)
# 全部 fact 打 category='review'(canonical-codes 第 11 类);chain S4"已复查"信号源。
# kind=actual:treat[] = 本次治疗(用户确认),复查/观察是"本次确实做的"流程事件。
# 注:拆线不入 review(route 词表已排除)—— 按 jvs-dw 2026-06-11 一线判例,
# 拆线=术后护理=外科治疗动作,归 actual→surgical(见 treatment_actual.yaml 本地 enum)。
canonical: treatment
emits:
action: treatment_completed
subjectType: treatment
occurredAtField: occurredAt
kind: actual
primary:
table: treatment_review_rows
key: treat_external_id
dedup_by: treat_external_id
field_mapping:
externalId: treat_external_id
updatedAt: updated_gmt_at
createdAt: created_gmt_at
patientExternalId: patient_id
clinicId: organization_id
occurredAt: clinic_time
sourceEncounterExternalId: emr_sub_id
category: category_raw # transforms 已固定填 _review_sentinel
subtype: value # 原始动作名("复查"/"暂观"等)
toothPosition: tooth_position
doctorId: user_id
doctorName: user_name
enum_mapping:
category:
_review_sentinel: review
...@@ -25,19 +25,14 @@ field_mapping: ...@@ -25,19 +25,14 @@ field_mapping:
doctorId: user_id # 接诊医生(从 emr 父级继承,影像拍摄/解读医生) doctorId: user_id # 接诊医生(从 emr 父级继承,影像拍摄/解读医生)
doctorName: doctor_name doctorName: doctor_name
# ── 字典分层(2026-07 重构):真影像类型在 _shared/dict;单据/化验类名目留本地 ──
dict_includes:
- ../../_shared/dict/image-modality.yaml
enum_mapping: enum_mapping:
# host check_name → PAC modality 闭集(pa/bw/pano/cbct/intraoral_photo/scan/other) # host check_name → PAC modality:化验/表单/记录类(无影像语义)归 other(本 host 名目)
# 真影像归对应类型;化验/表单/记录类(血常规/牙周大表/登记表…)无影像语义,归 other
modality: modality:
"口内像照片": intraoral_photo
"面相照": intraoral_photo
"内窥镜": intraoral_photo
"小牙片": pa
"全景片/头颅正侧位片截图": pano
"全景片/侧位片/其他X线影像": pano
"CT影像截图": cbct
"口腔扫描": scan
"其他类型资料(非X影像)": other
"牙周大表": other "牙周大表": other
"血常规": other "血常规": other
"拔牙术前清单": other "拔牙术前清单": other
......
...@@ -29,68 +29,14 @@ field_mapping: ...@@ -29,68 +29,14 @@ field_mapping:
# treat_name 自由文本,enum_mapping cover 高频"建议 XX"白名单 # treat_name 自由文本,enum_mapping cover 高频"建议 XX"白名单
# 长尾 _default: '' parser 跳过(不入 recommendation_record fact) # 长尾 _default: '' parser 跳过(不入 recommendation_record fact)
enum_mapping:
code:
# ── 种植 ──
建议种植: IMPLANT_RECOMMENDED
建议种植修复: IMPLANT_RECOMMENDED
推荐种植: IMPLANT_RECOMMENDED
建议种植治疗: IMPLANT_RECOMMENDED
建议种植牙: IMPLANT_RECOMMENDED
# ── 戴冠 / 修复 ── # ── 字典分层(2026-07 重构):标准"建议X"词典在 _shared/dict;本地留 host 标点变体 ──
建议戴冠: CROWN_RECOMMENDED dict_includes:
建议冠修复: CROWN_RECOMMENDED - ../../_shared/dict/recommendation-code.yaml
建议全冠: CROWN_RECOMMENDED
建议桩核冠: CROWN_RECOMMENDED
建议做冠: CROWN_RECOMMENDED
推荐冠修复: CROWN_RECOMMENDED
# ── 充填 ── enum_mapping:
建议充填: FILLING_RECOMMENDED code:
# host 数据尾标点残留(normalize 前旧形态,保真保留)
"建议充填。": FILLING_RECOMMENDED "建议充填。": FILLING_RECOMMENDED
建议树脂充填: FILLING_RECOMMENDED
建议补牙: FILLING_RECOMMENDED
# ── 牙周基础 ──
建议洁牙: SRP_RECOMMENDED
建议龈上洁治: SRP_RECOMMENDED
建议牙周治疗: SRP_RECOMMENDED
建议牙周基础治疗: SRP_RECOMMENDED
# ── 拔除 ──
建议拔除: EXTRACTION_RECOMMENDED
"建议拔除。": EXTRACTION_RECOMMENDED "建议拔除。": EXTRACTION_RECOMMENDED
建议拔牙: EXTRACTION_RECOMMENDED _default: "" # 未识别"建议X" → 共享 keyword 含词兜底;再不中 → 丢弃
推荐拔除: EXTRACTION_RECOMMENDED
# ── 复查 ──
建议年度复查: ANNUAL_REVIEW_RECOMMENDED
建议定期复查: ANNUAL_REVIEW_RECOMMENDED
建议复查: ANNUAL_REVIEW_RECOMMENDED
# ── 正畸咨询 ──
建议正畸咨询: ORTHO_CONSULT_RECOMMENDED
建议正畸: ORTHO_CONSULT_RECOMMENDED
建议矫正: ORTHO_CONSULT_RECOMMENDED
_default: "" # 未识别"建议X" → 交下面 keyword_mapping 含词兜底;再不中 → 丢弃
# ─────────────────────────────────────────────────────────
# 含词兜底 —— 精确 enum 未命中时,按序含词匹配 → 召回码。
# 治本长尾:"建议X"变体太多(建议充填治疗/建议根管治疗后冠修复/建议拔除后种植…),
# 精确白名单永远漏(谢玉兰 44 "建议充填治疗" 漏配 → null → 44 漏召)。引擎按 canonicalKey 通用。
# 顺序=优先级:高价值/主操作在前(种植>冠、根管>冠、拔除独立)。错配最坏=_default(现状),无回归。
keyword_mapping:
code:
- { value: IMPLANT_RECOMMENDED, any: [种植, 植体] }
- { value: RCT_RECOMMENDED, any: [根管, RCT, 牙髓] }
- { value: EXTRACTION_RECOMMENDED, any: [拔除, 拔牙] }
- { value: CROWN_RECOMMENDED, any: [, , 桩核] }
- { value: FILLING_RECOMMENDED, any: [充填, 补牙, 树脂] }
- { value: HARD_TISSUE_REPAIR_RECOMMENDED, any: [楔状, 牙体, 嵌体, 缺损] }
- { value: SRP_RECOMMENDED, any: [洁治, 洗牙, 牙周, 龈下, 刮治] }
- { value: ORTHO_CONSULT_RECOMMENDED, any: [正畸, 矫正, 矫治] }
- { value: GUM_TREATMENT_RECOMMENDED, any: [牙龈, 牙槽嵴, 系带] }
- { value: JAW_CYST_REMOVAL_RECOMMENDED, any: [囊肿] }
- { value: ERUPTION_INTERVENTION_RECOMMENDED, any: [萌出, 乳牙滞留, 多生牙] }
...@@ -11,7 +11,9 @@ ...@@ -11,7 +11,9 @@
* 用法: * 用法:
* pnpm refresh-clinic-names -- --dir=./data/jvs-dw [--host=<name 覆盖>] * pnpm refresh-clinic-names -- --dir=./data/jvs-dw [--host=<name 覆盖>]
* *
* 幂等:整表 upsert host.clinicNames(全量覆盖为最新派生结果)。可接 cron 定期刷。 * 幂等:派生结果**合并**进 host.clinicNames(同 key 新值胜,旧 key 保留)。可接 cron 定期刷。
* 合并而非覆盖:文件模式的导出可能是 cohort 子集(export.sh --clinics/--since),
* 覆盖会把子集外诊所的名字刷掉。
*/ */
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
...@@ -71,7 +73,7 @@ function deriveFromFile( ...@@ -71,7 +73,7 @@ function deriveFromFile(
dir: { table: string; id_field: string; name_field: string }, dir: { table: string; id_field: string; name_field: string },
): Record<string, string> { ): Record<string, string> {
const t = tables.find((x) => x.table === dir.table); const t = tables.find((x) => x.table === dir.table);
if (!t) throw new Error(`clinic_directory.table=${dir.table} 不在 tables[] 里`); if (!t) throw new Error(`directory.table=${dir.table} 不在 tables[] 里`);
const p = path.resolve(manifestDir, t.file); const p = path.resolve(manifestDir, t.file);
const raw = fs.readFileSync(p, 'utf-8'); const raw = fs.readFileSync(p, 'utf-8');
const rows: Array<Record<string, unknown>> = t.file.toLowerCase().endsWith('.json') const rows: Array<Record<string, unknown>> = t.file.toLowerCase().endsWith('.json')
...@@ -106,13 +108,12 @@ async function main(): Promise<void> { ...@@ -106,13 +108,12 @@ async function main(): Promise<void> {
process.exit(1); process.exit(1);
} }
const map = manifest.sql_source const clinicMap = manifest.sql_source
? await deriveFromClickhouse(manifest.sql_source, cd, logger) ? await deriveFromClickhouse(manifest.sql_source, cd, logger)
: deriveFromFile(manifestDir, manifest.tables ?? [], cd); : deriveFromFile(manifestDir, manifest.tables ?? [], cd);
const nc = Object.keys(clinicMap).length;
const n = Object.keys(map).length; logger.log(`派生诊所名 ${nc} 家`);
logger.log(`派生诊所名 ${n} 家`); if (nc === 0) {
if (n === 0) {
logger.warn('派生结果为空,跳过写入(不清空既有 clinicNames)'); logger.warn('派生结果为空,跳过写入(不清空既有 clinicNames)');
process.exit(0); process.exit(0);
} }
...@@ -122,8 +123,14 @@ async function main(): Promise<void> { ...@@ -122,8 +123,14 @@ async function main(): Promise<void> {
const prisma = app.get(PrismaService); const prisma = app.get(PrismaService);
const host = await prisma.host.findFirst({ where: { name: hostName } }); const host = await prisma.host.findFirst({ where: { name: hostName } });
if (!host) throw new Error(`host "${hostName}" 不存在`); if (!host) throw new Error(`host "${hostName}" 不存在`);
await prisma.host.update({ where: { id: host.id }, data: { clinicNames: map } }); // 合并而非整表覆盖:导出文件可能是 cohort 子集(export.sh --clinics/--since),
logger.log(`✓ 已写入 host="${hostName}" clinicNames ${n} 家(样例: ${Object.values(map).slice(0, 3).join(' / ')})`); // 覆盖会把子集外诊所的名字刷掉。同 key 新值胜(改名照常生效),旧 key 保留。
const merged = { ...((host.clinicNames as Record<string, string>) ?? {}), ...clinicMap };
await prisma.host.update({ where: { id: host.id }, data: { clinicNames: merged } });
logger.log(
`✓ host="${hostName}" clinicNames 本次派生 ${nc} 家,合并后 ${Object.keys(merged).length} 家` +
`(样例: ${Object.values(clinicMap).slice(0, 3).join(' / ')})`,
);
} finally { } finally {
await app.close(); await app.close();
} }
......
import { Body, Controller, Get, Post } from '@nestjs/common'; import { Body, Controller, Get, Post, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { ZodResponse } from 'nestjs-zod'; import { ZodResponse } from 'nestjs-zod';
import type { AccessTokenPayload } from '@pac/types'; import type { AccessTokenPayload } from '@pac/types';
...@@ -92,6 +92,14 @@ export class AuthController { ...@@ -92,6 +92,14 @@ export class AuthController {
return { users: this.auth.listMockUsers() }; return { users: this.auth.listMockUsers() };
} }
@Public()
@Get('mock-orgs')
@ApiOperation({ summary: '(dev) 真实组织结构(品牌→诊所),给快速登录选数据范围' })
async mockOrgs(@Query('host') host?: string, @Query('tenant') tenant?: string) {
// 默认 friday(多品牌宿主才需要选品牌/诊所);jvs-dw 走客服花名册路径
return this.auth.listMockOrgs(host || 'friday', tenant || 'friday-market');
}
@Get('session') @Get('session')
@ApiBearerAuth('accessToken') @ApiBearerAuth('accessToken')
@ZodResponse({ status: 200, type: SessionResponseDto }) @ZodResponse({ status: 200, type: SessionResponseDto })
......
...@@ -8,6 +8,7 @@ import { ...@@ -8,6 +8,7 @@ import {
type AccessTokenPayload, type AccessTokenPayload,
type ExchangeCodeResponse, type ExchangeCodeResponse,
type MockLoginRequest, type MockLoginRequest,
type MockOrgsResponse,
type MockUser, type MockUser,
type Permission, type Permission,
type RefreshTokenResponse, type RefreshTokenResponse,
...@@ -23,6 +24,7 @@ import { RedisService } from '../../redis/redis.service'; ...@@ -23,6 +24,7 @@ import { RedisService } from '../../redis/redis.service';
import { AlertService } from '../../common/alerting/alert.service'; import { AlertService } from '../../common/alerting/alert.service';
import { parseDurationToSeconds } from './duration'; import { parseDurationToSeconds } from './duration';
import { loadMockUsers } from './mock-users'; import { loadMockUsers } from './mock-users';
import { OrgTreeService } from './org-tree';
const CODE_PREFIX = 'pac:exchange-code:'; const CODE_PREFIX = 'pac:exchange-code:';
/// code 失效策略:不再"消费一次即删",改由 TTL(EXCHANGE_CODE_TTL_SECONDS)控制 —— /// code 失效策略:不再"消费一次即删",改由 TTL(EXCHANGE_CODE_TTL_SECONDS)控制 ——
...@@ -100,6 +102,7 @@ export class AuthService { ...@@ -100,6 +102,7 @@ export class AuthService {
private readonly jwt: JwtService, private readonly jwt: JwtService,
private readonly config: ConfigService, private readonly config: ConfigService,
private readonly alert: AlertService, private readonly alert: AlertService,
private readonly orgTree: OrgTreeService,
) {} ) {}
/** access token TTL(秒)— 进程内固定,memoize 一次(收口多处 parseDurationToSeconds) */ /** access token TTL(秒)— 进程内固定,memoize 一次(收口多处 parseDurationToSeconds) */
...@@ -571,6 +574,34 @@ export class AuthService { ...@@ -571,6 +574,34 @@ export class AuthService {
if (!this.isMockLoginEnabled()) { if (!this.isMockLoginEnabled()) {
throw new BizError(ApiCode.AUTH_PERMISSION_DENIED, '模拟登录已在当前环境禁用'); throw new BizError(ApiCode.AUTH_PERMISSION_DENIED, '模拟登录已在当前环境禁用');
} }
// ── FRIDAY 市场(第二宿主)— 集团级专用分支 ──
// 多品牌 SaaS(30+ 品牌 = source_unit),mock 只做集团级(orgScope=[friday-market] →
// 全部品牌全部诊所);品牌级/客服级没有在岗花名册数据,不做。
// 诊所名字典未摄入(manifest 无 clinic_directory)→ dictionary.clinics 空,UI 回退显示 id。
if (req.tenant === 'friday') {
const fridayHost = await this.prisma.host.findFirst({ where: { name: 'friday' } });
if (!fridayHost) {
throw new BizError(ApiCode.HOST_NOT_FOUND, '请先建 friday host(cold-import 会自动创建)');
}
const fSub = `mock-friday-${req.role}`;
const fName = `FRIDAY ${roleNameZh(req.role)}`;
// 数据范围:orgId(真实品牌 GUID / 诊所 id,来自 GET /auth/mock-orgs)优先;
// 否则集团根(全部品牌全部诊所)。⚠️ 身份可造,范围必须落在真实摄入数据上。
const fOrgScope = [req.orgLevel === 'group' || !req.orgId ? 'friday-market' : req.orgId];
const { accessToken, refreshToken } = await this.issueTokens(fridayHost, {
userId: fSub,
tenantId: 'friday-market',
role: req.role,
orgScope: fOrgScope,
// 诊所名由 session 从 host.clinicNames 补齐(派生自摄入数据);此处只带造的身份名。
dictionary: { users: { [fSub]: fName } },
});
this.logger.log(
`mock-login: tenant=friday(FRIDAY 市场) role=${req.role} sub=${fSub} orgScope=[${fOrgScope.join(',')}]`,
);
return { accessToken, refreshToken, expiresIn: this.accessExpiresInSeconds };
}
const preset = this.MOCK_PRESETS[req.tenant]; const preset = this.MOCK_PRESETS[req.tenant];
if (!preset) { if (!preset) {
throw new BizError(ApiCode.CLIENT_VALIDATION_FAILED, `unknown tenant: ${req.tenant}`); throw new BizError(ApiCode.CLIENT_VALIDATION_FAILED, `unknown tenant: ${req.tenant}`);
...@@ -646,6 +677,38 @@ export class AuthService { ...@@ -646,6 +677,38 @@ export class AuthService {
} }
/** /**
* **真实**组织结构(品牌 → 诊所)—— 给"快速登录"对话框选数据范围用。
*
* 结构取自 org 树(**从摄入数据 patient_transactions 派生**,零硬编码),
* 诊所名取自 host.clinicNames(refresh-clinic-names 从摄入源派生);品牌当前直接显示 id
* (无展示需求,不为此引入品牌主档数据源)。
* 多品牌宿主(FRIDAY)没有客服花名册 → 身份可造,但数据范围必须落在真实品牌/诊所上。
* mock 关闭时返空。
*/
async listMockOrgs(hostName: string, tenantId: string): Promise<MockOrgsResponse> {
if (!this.isMockLoginEnabled()) return { groupId: tenantId, brands: [] };
const host = await this.prisma.host.findFirst({ where: { name: hostName } });
if (!host) return { groupId: tenantId, brands: [] };
const [tree, clinicNames] = await Promise.all([
this.orgTree.getTree(host.id, tenantId),
this.getClinicNames(host.id),
]);
// 品牌名当前无展示需求(仅本对话框用)→ 直接显示 source_unit id,不额外引入品牌主档数据源;
// 诊所名有(病历表自带 organization_name 派生),缺失回退 id。
const brands = (tree.root.children ?? []).map((b) => ({
brandId: b.id,
name: b.id,
clinics: (b.children ?? []).map((c) => ({
clinicId: c.id,
name: clinicNames[c.id] ?? c.id,
})),
}));
// 诊所多的排前(数据丰富的先看到)
brands.sort((a, b) => b.clinics.length - a.clinics.length);
return { groupId: tenantId, brands };
}
/**
* 客服花名册(给"快速登录"对话框选人用)。 * 客服花名册(给"快速登录"对话框选人用)。
* 派生自 data/jvs-dw/users.json,补诊所中文名(从 MOCK_PRESETS)。mock 关闭时返空。 * 派生自 data/jvs-dw/users.json,补诊所中文名(从 MOCK_PRESETS)。mock 关闭时返空。
*/ */
......
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as yaml from 'js-yaml';
import { z } from 'zod';
/**
* 字典 include —— assembler yaml 的 `dict_includes:` 解析与合并。
*
* 动机(多宿主字典分层):enum/keyword 映射词分两类 ——
* ① 临床通用知识(慢性牙髓炎→K04、开髓→pulp_extirpation):跟宿主无关,拷贝会漂移
* (实证:jvs-dw 自己的 treatment_planned 字典已是 treatment_actual 的陈旧拷贝);
* ② 宿主怪癖(价格表项名、标点残留、host 用词判例):共用会互相污染。
* → ① 抽到 data/_shared/dict/*.yaml,宿主 yaml 用 `dict_includes:` 引入;② 留宿主本地。
*
* 用法(assembler yaml 顶层):
* dict_includes:
* - ../../_shared/dict/diagnosis-kcode.yaml # 路径相对本 yaml 文件
*
* 合并语义(⚠️ 三条规则,变更需同步 pac-docs data-ingestion 文档):
* - enum_mapping :同字段逐 key 合并;include 按声明序先铺,本地条目**覆盖**同 key
* (宿主可用本地 `词: ""` 墓碑禁用共享词条 —— "" 与 _default 空值同义)。
* - keyword_mapping:同字段规则数组拼接,**本地规则在前**(引擎首个命中即用 → 宿主优先),
* include 按声明序殿后兜底。
* - keyword_strip_clauses:去重并集,本地在前(引擎对全列表做 startsWith,序无语义)。
* - `_default` 约定留宿主本地声明(共享文件禁止携带,schema 强校验拦截)。
*
* 解析发生在 zod 校验【之前】(loadAllAssemblers),合并产物 = 现行 AssemblerConfig 形状
* → 引擎(assembler-engine)与 schema 零改动;不写 dict_includes 的 yaml 行为分毫不变。
*/
const EnumMapSchema = z.record(z.string(), z.record(z.string(), z.string()));
const KeywordRuleSchema = z.object({
value: z.string(),
any: z.array(z.string().min(1)).min(1),
none: z.array(z.string().min(1)).optional(),
});
const KeywordMapSchema = z.record(z.string(), z.array(KeywordRuleSchema));
/// 共享字典文件形状:只允许三个映射段(strict:打错字段名/夹带 field_mapping 等 fail-fast)。
const DictIncludeFileSchema = z
.object({
enum_mapping: EnumMapSchema.optional(),
keyword_mapping: KeywordMapSchema.optional(),
keyword_strip_clauses: z.array(z.string().min(1)).optional(),
})
.strict()
.superRefine((file, ctx) => {
// _default 是宿主决策(丢弃 or 空码兜底),共享层禁止携带 —— 防止共享文件悄悄改宿主兜底行为
for (const [field, m] of Object.entries(file.enum_mapping ?? {})) {
if (Object.prototype.hasOwnProperty.call(m, '_default')) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `共享字典不得含 _default(enum_mapping.${field});_default 由宿主 yaml 本地声明`,
});
}
}
});
export type DictIncludeFile = z.infer<typeof DictIncludeFileSchema>;
interface RawAssemblerYaml {
dict_includes?: unknown;
enum_mapping?: Record<string, Record<string, string>>;
keyword_mapping?: Record<string, unknown[]>;
keyword_strip_clauses?: string[];
[k: string]: unknown;
}
/**
* 解析 yaml 原始对象里的 dict_includes 并合并,返回可直接过 AssemblerConfigSchema 的对象。
* 无 dict_includes → 原样返回(零开销,老 yaml 完全不走新逻辑)。
* @param raw yaml.load 的原始产物
* @param yamlPath 本 yaml 的绝对路径(include 相对路径的锚点;也用于报错定位)
*/
export function resolveDictIncludes(raw: unknown, yamlPath: string): unknown {
if (raw === null || typeof raw !== 'object') return raw;
const cfg = raw as RawAssemblerYaml;
if (cfg.dict_includes === undefined) return raw;
const incPaths = z.array(z.string().min(1)).min(1).safeParse(cfg.dict_includes);
if (!incPaths.success) {
throw new Error(`${yamlPath}: dict_includes 须为非空字符串数组`);
}
// 1. 逐个加载 include 文件(相对本 yaml;strict 校验)
const includes: DictIncludeFile[] = incPaths.data.map((rel) => {
const abs = path.resolve(path.dirname(yamlPath), rel);
if (!fs.existsSync(abs)) {
throw new Error(`${yamlPath}: dict include 不存在: ${rel}${abs}`);
}
const parsed = yaml.load(fs.readFileSync(abs, 'utf-8'));
const r = DictIncludeFileSchema.safeParse(parsed);
if (!r.success) {
throw new Error(
`共享字典 ${rel} 校验失败(由 ${path.basename(yamlPath)} 引入):\n` +
JSON.stringify(r.error.issues, null, 2),
);
}
return r.data;
});
// 2. enum_mapping:include 按序先铺(后者覆盖前者),本地最后覆盖
const mergedEnum: Record<string, Record<string, string>> = {};
for (const inc of includes) {
for (const [field, m] of Object.entries(inc.enum_mapping ?? {})) {
mergedEnum[field] = { ...mergedEnum[field], ...m };
}
}
for (const [field, m] of Object.entries(cfg.enum_mapping ?? {})) {
mergedEnum[field] = { ...mergedEnum[field], ...m };
}
// 3. keyword_mapping:本地规则在前,include 按序殿后
const mergedKeyword: Record<string, unknown[]> = {};
for (const [field, rules] of Object.entries(cfg.keyword_mapping ?? {})) {
mergedKeyword[field] = [...rules];
}
for (const inc of includes) {
for (const [field, rules] of Object.entries(inc.keyword_mapping ?? {})) {
mergedKeyword[field] = [...(mergedKeyword[field] ?? []), ...rules];
}
}
// 4. keyword_strip_clauses:本地在前去重并集
const strip = [
...(cfg.keyword_strip_clauses ?? []),
...includes.flatMap((i) => i.keyword_strip_clauses ?? []),
];
const mergedStrip = [...new Set(strip)];
// 5. 拼回(剥掉 dict_includes;空段不落 key,与"没写该段"的老形状一致)
const { dict_includes: _drop, ...rest } = cfg;
return {
...rest,
...(Object.keys(mergedEnum).length > 0 ? { enum_mapping: mergedEnum } : {}),
...(Object.keys(mergedKeyword).length > 0 ? { keyword_mapping: mergedKeyword } : {}),
...(mergedStrip.length > 0 ? { keyword_strip_clauses: mergedStrip } : {}),
};
}
...@@ -47,6 +47,22 @@ export type AssemblerRef = z.infer<typeof AssemblerRefSchema>; ...@@ -47,6 +47,22 @@ export type AssemblerRef = z.infer<typeof AssemblerRefSchema>;
* *
* 密码:从环境变量读取(`password_env`),不允许 plaintext 进 yaml。 * 密码:从环境变量读取(`password_env`),不允许 plaintext 进 yaml。
*/ */
/// 增量水位声明:表名 → cursor 列。**与源类型解耦**(2026-07 统一):
/// - sql_source 内声明(jvs-dw 现状,位置不动):拉模式,读水位注 WHERE + 写水位
/// - manifest 顶层声明(file 源用):file 装载不消费水位(全量装载,幂等去重兜底),
/// 只做**记账**——同一段 finally 写 cursor_after = run_start,水位供 delta 导出
/// WHERE 模板 / push 回放起点使用。两处都写时 sql_source 内优先。
export const IncrementalDeclSchema = z.object({
/// per query/table 配置;表名 → cursor_column
/// 例:fact_emr_treatment_out: updated_date / med_emr_info: updated_gmt_at
per_query: z.record(
z.string(),
z.object({
cursor_column: z.string().min(1),
}),
),
});
export const ClickHouseSourceSchema = z.object({ export const ClickHouseSourceSchema = z.object({
kind: z.literal('clickhouse'), kind: z.literal('clickhouse'),
connection: z.object({ connection: z.object({
...@@ -65,20 +81,9 @@ export const ClickHouseSourceSchema = z.object({ ...@@ -65,20 +81,9 @@ export const ClickHouseSourceSchema = z.object({
default_limit: z.number().int().positive().default(100000).optional(), default_limit: z.number().int().positive().default(100000).optional(),
/// W4 末:DW 增量配置(per table cursor column) /// W4 末:DW 增量配置(per table cursor column)
/// 跑增量模式时 PAC 会读 sync_logs 上次 cursor_after,注入 WHERE cursor_column > '...' /// 跑增量模式时 PAC 会读 sync_logs 上次 cursor_after,注入 WHERE cursor_column > '...'
/// 跑完写新 cursor = max(cursor_column) 到 sync_logs /// 跑完写新 cursor = run_start 到 sync_logs
/// 首次跑(无 cursor)= 全量,后续 = 增量 /// 首次跑(无 cursor)= 全量,后续 = 增量
incremental: z incremental: IncrementalDeclSchema.optional(),
.object({
/// per query 配置;表名(query key)→ cursor_column
/// 例:fact_emr_treatment_out: updated_date / fact_client_out: last_visit_time
per_query: z.record(
z.string(),
z.object({
cursor_column: z.string().min(1),
}),
),
})
.optional(),
/// ⭐ Cohort 分批配置(PR2/PR4)— 宿主无关性的关键: /// ⭐ Cohort 分批配置(PR2/PR4)— 宿主无关性的关键:
/// patient 列表来源 + 主键列 + 租户区分列全部声明在这,代码不硬编码任何表名/列名。 /// patient 列表来源 + 主键列 + 租户区分列全部声明在这,代码不硬编码任何表名/列名。
/// 不配 cohort → 不分批(single-shot,文件源 / 小数据集)。 /// 不配 cohort → 不分批(single-shot,文件源 / 小数据集)。
...@@ -179,6 +184,11 @@ export const ColdImportManifestSchema = z ...@@ -179,6 +184,11 @@ export const ColdImportManifestSchema = z
/// ClickHouse SQL 数据源(跟 tables 二选一) /// ClickHouse SQL 数据源(跟 tables 二选一)
sql_source: ClickHouseSourceSchema.optional(), sql_source: ClickHouseSourceSchema.optional(),
/// 顶层增量水位声明 —— **file 源专用**(sql_source 宿主继续在 sql_source.incremental 声明,
/// 两处都写时 sql_source 内优先)。file 装载不消费水位,仅记账 cursor_after = run_start
/// (同 sql_source 宿主同一段 finally,逻辑一致);详见 IncrementalDeclSchema 注释。
incremental: IncrementalDeclSchema.optional(),
/// 诊所名字典来源(可选)—— 声明哪张表的哪两列 = 诊所 id → 名字。 /// 诊所名字典来源(可选)—— 声明哪张表的哪两列 = 诊所 id → 名字。
/// `refresh-clinic-names` CLI 据此 SELECT DISTINCT 出 id→名,写 host.clinicNames; /// `refresh-clinic-names` CLI 据此 SELECT DISTINCT 出 id→名,写 host.clinicNames;
/// /auth/session 再合并进 dictionary.clinics 下发,前端显示名字不依赖登录传。 /// /auth/session 再合并进 dictionary.clinics 下发,前端显示名字不依赖登录传。
......
import type { Prisma } from '@prisma/client';
import { mergePatientPreferences } from '../pipeline/patient-preferences.util';
/**
* 患者 upsert 数据构造 — 全量(cold-import)与部分更新(push 单表)两种语义。
*
* **full(默认,cold-import 存量/增量)**:行为与历史完全一致 —— 每字段显式落值
* (缺失 → null / 默认),phone 缺失合成 demo 假号(dev 造数便利,生产 host 给真值自动让位)。
*
* **partial(push 形态 A 单表患者主档)**:canonical **未提供**(undefined)的字段不进
* update 集合(prisma undefined = 不动该列)——解决 push 单表时跨表依赖缺席的覆盖事故:
* 宿主只推 customer_basic_info 时,phone 依赖的 contacts lookup 落空,full 语义会把
* 存量真号覆盖成合成假号;partial 语义下 phone 缺失 → 不动存量。
* phone 在 partial 下**永不合成假号**(显式提供才更新)。
* 注意:显式 null 与 undefined 同义(canonical zod nullable 归一),都视为"未提供"——
* push 通道没有"显式清空字段"语义(如需清空走全量通道)。
*/
export function buildPatientUpsertData(
c: Record<string, unknown>,
externalId: string,
opts: { partial: boolean },
): Record<string, unknown> {
if (!opts.partial) {
return {
name: (c.name as string | undefined) ?? null,
// ⚠️ DEMO 兜底:host 不给 phone(隐私) → 由 externalId hash 合成确定性假号
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: mergePatientPreferences(c),
active: ((c.status as string | undefined) ?? 'active') === 'active',
};
}
// partial:提供(非 null/undefined)才落
const given = (k: string) => c[k] !== undefined && c[k] !== null;
return {
...(given('name') ? { name: c.name as string } : {}),
...(given('phone') ? { phone: c.phone as string } : {}),
...(given('gender') ? { gender: c.gender as string } : {}),
...(given('birthDate') ? { birthDate: new Date(c.birthDate as string) } : {}),
...(given('medicalRecordNumber')
? { medicalRecordNumber: c.medicalRecordNumber as string }
: {}),
// preferences 本就是"有可写才返回,否则 undefined 不动列"语义,天然 partial-safe
preferences: mergePatientPreferences(c),
...(given('status') ? { active: (c.status as string) === 'active' } : {}),
};
}
/** 副表(profile)同款双语义。 */
export function buildProfileUpsertData(
c: Record<string, unknown>,
opts: { partial: boolean },
): Record<string, unknown> {
if (!opts.partial) {
return {
doNotContact: (c.doNotContact as boolean) ?? false,
deceased: (c.deceased as boolean) ?? false,
tags: Array.isArray(c.tags) ? (c.tags as string[]) : [],
notes: (c.notes as string | undefined) ?? null,
acquisitionChannel: (c.acquisitionChannel as string | undefined) ?? null,
acquisitionSub: (c.acquisitionSub as string | undefined) ?? null,
referralCount: c.referralCount != null ? Math.trunc(Number(c.referralCount)) : null,
referralAmountCents:
c.referralAmount != null ? Math.round(Number(c.referralAmount) * 100) : null,
};
}
const given = (k: string) => c[k] !== undefined && c[k] !== null;
return {
...(given('doNotContact') ? { doNotContact: c.doNotContact as boolean } : {}),
...(given('deceased') ? { deceased: c.deceased as boolean } : {}),
...(Array.isArray(c.tags) && c.tags.length > 0 ? { tags: c.tags as string[] } : {}),
...(given('notes') ? { notes: c.notes as string } : {}),
...(given('acquisitionChannel')
? { acquisitionChannel: c.acquisitionChannel as string }
: {}),
...(given('acquisitionSub') ? { acquisitionSub: c.acquisitionSub as string } : {}),
...(given('referralCount') ? { referralCount: Math.trunc(Number(c.referralCount)) } : {}),
...(given('referralAmount')
? { referralAmountCents: Math.round(Number(c.referralAmount) * 100) }
: {}),
};
}
/** dev 造数:externalId hash 合成确定性假号(138 前缀;同 id 恒同号)。生产 host 给真值自动让位。 */
export function synthesizeDemoPhone(externalId: string): string {
let hash = 0;
for (let i = 0; i < externalId.length; i++) {
hash = (hash * 31 + externalId.charCodeAt(i)) >>> 0;
}
const last8 = String(hash % 100_000_000).padStart(8, '0');
return `138${last8}`;
}
export type PatientUpsertData = ReturnType<typeof buildPatientUpsertData> &
Prisma.InputJsonValue;
...@@ -14,6 +14,11 @@ function factVersionKey(sourceUnit: string, subjectId: string): string { ...@@ -14,6 +14,11 @@ function factVersionKey(sourceUnit: string, subjectId: string): string {
return `${sourceUnit}#${subjectId}`; return `${sourceUnit}#${subjectId}`;
} }
/** 时刻相等 — 双方皆缺(null/undefined)视为等;毫秒精度(列是 timestamptz(3))。 */
function sameInstant(a: Date | null | undefined, b: Date | null | undefined): boolean {
return (a?.getTime() ?? null) === (b?.getTime() ?? null);
}
/** /**
* FactWriter — patient_facts 版本流写入器 * FactWriter — patient_facts 版本流写入器
* *
...@@ -21,9 +26,9 @@ function factVersionKey(sourceUnit: string, subjectId: string): string { ...@@ -21,9 +26,9 @@ function factVersionKey(sourceUnit: string, subjectId: string): string {
* *
* 行为(对单 subject_id): * 行为(对单 subject_id):
* 1. 找当前 active 版本(partial UNIQUE 保证唯一) * 1. 找当前 active 版本(partial UNIQUE 保证唯一)
* 2. 计算 draft.content 的稳定 hash; * 2. 计算 draft.content 的稳定 hash;比较 hash + status + 时间锚(occurredAt/plannedFor/validFrom/validUntil)
* ┌─ 跟 active 一致 → 幂等(把当前 transactionId 追加到 active.transactionIds,如未存在) * ┌─ 一致 → 幂等(把当前 transactionId 追加到 active.transactionIds,如未存在)
* └─ 不一致 / 无 active → supersede 旧版本 → 插入新版本 version = old.version + 1(无则 1) * └─ 任一不同 / 无 active → supersede 旧版本 → 插入新版本 version = old.version + 1(无则 1)
* *
* 并发说明: * 并发说明:
* - W2 是单进程顺序写,不需要锁 * - W2 是单进程顺序写,不需要锁
...@@ -117,8 +122,9 @@ export class FactWriter { ...@@ -117,8 +122,9 @@ export class FactWriter {
const latestHash = this.hashContent(latest.content as Prisma.InputJsonValue); const latestHash = this.hashContent(latest.content as Prisma.InputJsonValue);
const sameContent = latestHash === newHash; const sameContent = latestHash === newHash;
const sameStatus = latest.status === draftStatus; const sameStatus = latest.status === draftStatus;
const sameTemporal = this.sameTemporalAnchors(latest, validatedDraft);
if (sameContent && sameStatus) { if (sameContent && sameStatus && sameTemporal) {
// 完全一致 — 幂等。把当前 transaction 加进证据数组(去重) // 完全一致 — 幂等。把当前 transaction 加进证据数组(去重)
if (!latest.transactionIds.includes(transactionId)) { if (!latest.transactionIds.includes(transactionId)) {
await tx.patientFact.update({ await tx.patientFact.update({
...@@ -140,7 +146,7 @@ export class FactWriter { ...@@ -140,7 +146,7 @@ export class FactWriter {
}; };
} }
// 有变化(content 或 status)— 如果 latest 是 active,supersede 它; // 有变化(content / status / 时间锚)— 如果 latest 是 active,supersede 它;
// 终态版本(cancelled/fulfilled/expired/invalidated/superseded)不动,只追新版本。 // 终态版本(cancelled/fulfilled/expired/invalidated/superseded)不动,只追新版本。
if (latest.status === FactStatus.ACTIVE) { if (latest.status === FactStatus.ACTIVE) {
await tx.patientFact.update({ await tx.patientFact.update({
...@@ -258,6 +264,10 @@ export class FactWriter { ...@@ -258,6 +264,10 @@ export class FactWriter {
hash: string; hash: string;
transactionIds: string[]; transactionIds: string[];
sourceUpdatedAt: Date | null; sourceUpdatedAt: Date | null;
occurredAt: Date | null;
plannedFor: Date | null;
validFrom: Date | null;
validUntil: Date | null;
}>(); }>();
for (const [k, latest] of latestBySubject.entries()) { for (const [k, latest] of latestBySubject.entries()) {
liveLatest.set(k, { liveLatest.set(k, {
...@@ -267,6 +277,10 @@ export class FactWriter { ...@@ -267,6 +277,10 @@ export class FactWriter {
hash: this.hashContent(latest.content as Prisma.InputJsonValue), hash: this.hashContent(latest.content as Prisma.InputJsonValue),
transactionIds: latest.transactionIds, transactionIds: latest.transactionIds,
sourceUpdatedAt: latest.sourceUpdatedAt ?? null, sourceUpdatedAt: latest.sourceUpdatedAt ?? null,
occurredAt: latest.occurredAt ?? null,
plannedFor: latest.plannedFor ?? null,
validFrom: latest.validFrom ?? null,
validUntil: latest.validUntil ?? null,
}); });
} }
...@@ -294,7 +308,12 @@ export class FactWriter { ...@@ -294,7 +308,12 @@ export class FactWriter {
continue; continue;
} }
if (live && live.hash === entry.hash && live.status === draftStatus) { if (
live &&
live.hash === entry.hash &&
live.status === draftStatus &&
this.sameTemporalAnchors(live, entry.draft)
) {
// 内容一致 + 状态一致 // 内容一致 + 状态一致
if (live.id && !live.transactionIds.includes(entry.transactionId)) { if (live.id && !live.transactionIds.includes(entry.transactionId)) {
toEvidenceAppend.push({ factId: live.id, transactionId: entry.transactionId }); toEvidenceAppend.push({ factId: live.id, transactionId: entry.transactionId });
...@@ -354,6 +373,10 @@ export class FactWriter { ...@@ -354,6 +373,10 @@ export class FactWriter {
hash: entry.hash, hash: entry.hash,
transactionIds: [entry.transactionId], transactionIds: [entry.transactionId],
sourceUpdatedAt: entry.sourceUpdatedAt ?? null, sourceUpdatedAt: entry.sourceUpdatedAt ?? null,
occurredAt: entry.draft.occurredAt ?? null,
plannedFor: entry.draft.plannedFor ?? null,
validFrom: entry.draft.validFrom ?? null,
validUntil: entry.draft.validUntil ?? null,
}); });
results.push({ results.push({
action: live ? 'superseded' : 'created', action: live ? 'superseded' : 'created',
...@@ -394,6 +417,33 @@ export class FactWriter { ...@@ -394,6 +417,33 @@ export class FactWriter {
} }
/** /**
* 时间锚相等判定 — 变更检测的组成部分(与 content hash / status 并列)。
*
* occurredAt / plannedFor / validFrom / validUntil 是事实实质:时间轴排序、
* 召回窗口(COALESCE(occurred_at, planned_for))、过期 cron(valid_until)都直接消费。
* 宿主纠正事件时间(如时区修复整体平移)时,content 往往不变 — 只比 hash+status
* 会让纠正永远停在 evidence_appended,occurred_at 无法更新(时间锚不参与检测的旧 bug)。
*
* 不纳入:title / summary(展示层,规则引擎不读)、clinicId(如需纳入另行评估)。
*/
private sameTemporalAnchors(
latest: {
occurredAt: Date | null;
plannedFor: Date | null;
validFrom: Date | null;
validUntil: Date | null;
},
draft: Pick<FactDraft, 'occurredAt' | 'plannedFor' | 'validFrom' | 'validUntil'>,
): boolean {
return (
sameInstant(latest.occurredAt, draft.occurredAt) &&
sameInstant(latest.plannedFor, draft.plannedFor) &&
sameInstant(latest.validFrom, draft.validFrom) &&
sameInstant(latest.validUntil, draft.validUntil)
);
}
/**
* 稳定 JSON hash — 递归按 key 排序后 sha256。 * 稳定 JSON hash — 递归按 key 排序后 sha256。
* JSON.stringify 默认按 insertion order,key 顺序不同会算出不同 hash。 * JSON.stringify 默认按 insertion order,key 顺序不同会算出不同 hash。
*/ */
......
...@@ -6,6 +6,10 @@ import type { Prisma } from '@prisma/client'; ...@@ -6,6 +6,10 @@ import type { Prisma } from '@prisma/client';
* 当前承载: * 当前承载:
* - dedicatedCs 专属客服(current_task_director):路由/指派属性,非画像特征。 * - dedicatedCs 专属客服(current_task_director):路由/指派属性,非画像特征。
* "当前"会换 → upsert 覆盖(存当前值)。用于详情页展示 + 推送人匹配。 * "当前"会换 → upsert 覆盖(存当前值)。用于详情页展示 + 推送人匹配。
* - contactPhone 默认联系号的关系语义(FRIDAY customer_contacts.relationship):
* { relationshipCode: host 原码, isSelf: 码=='1'(统计自证:成人 78.7%=本人) }。
* 触达场景先能区分"本人号 vs 代接号"(儿童过半非本人号);完整关系标签待 host
* sys_dict 字典解码后由 assembler enum_mapping 补,此处零改动。
* *
* 返回 undefined 表示"无可写偏好"(让 prisma upsert 不动该列)。 * 返回 undefined 表示"无可写偏好"(让 prisma upsert 不动该列)。
*/ */
...@@ -24,5 +28,17 @@ export function mergePatientPreferences( ...@@ -24,5 +28,17 @@ export function mergePatientPreferences(
base.dedicatedCs = { id: csId, name: csName }; base.dedicatedCs = { id: csId, name: csName };
} }
const relCode = c.phoneRelationshipCode == null ? '' : String(c.phoneRelationshipCode).trim();
if (relCode) {
// relationship = assembler enum_mapping 解码后的 PAC 关系词(self/father/mother/…;
// FRIDAY 官方 PhoneRelationshipEnum 源码实锤)。解不出时留 null,原码始终保留。
const rel = ((c.phoneRelationship as string | undefined) ?? '').trim() || null;
base.contactPhone = {
relationshipCode: relCode,
relationship: rel,
isSelf: rel ? rel === 'self' : relCode === '1',
};
}
return Object.keys(base).length ? (base as Prisma.InputJsonValue) : undefined; return Object.keys(base).length ? (base as Prisma.InputJsonValue) : undefined;
} }
...@@ -98,13 +98,21 @@ export class TransactionSynthesizer { ...@@ -98,13 +98,21 @@ export class TransactionSynthesizer {
? input.patientIdResolver(patientExternalId) ? input.patientIdResolver(patientExternalId)
: undefined; : undefined;
// occurredAt — 从 yaml emits.occurredAtField 指定的字段拿 // occurredAt — 从 yaml emits.occurredAtField 指定的字段拿;
const occurredAt = this.pickOccurredAt(c, emits.occurredAtField); // 缺失/不可解析时确定性回退 updatedAt → createdAt(与 sourceEventId 的 updatedAtMark 同序)。
// 墙钟只在三者皆缺时兜底:否则同一源行每次摄入 occurredAt 抖动,
// 下游 fact 时间锚变更检测会产生伪 supersede 版本。
let occurredAt = this.pickOccurredAt(c, emits.occurredAtField);
if (!occurredAt) {
occurredAt =
this.pickOccurredAt(c, 'updatedAt') ?? this.pickOccurredAt(c, 'createdAt');
}
if (!occurredAt) { if (!occurredAt) {
this.logger.warn( this.logger.warn(
`Cannot determine occurredAt from field="${emits.occurredAtField}" ` + `Cannot determine occurredAt from field="${emits.occurredAtField}" ` +
`for ${input.resource} externalId=${subjectId};using now()`, `(updatedAt/createdAt 亦缺)for ${input.resource} externalId=${subjectId};using now()`,
); );
occurredAt = new Date();
} }
// sourceEventId 幂等键 // sourceEventId 幂等键
...@@ -128,7 +136,7 @@ export class TransactionSynthesizer { ...@@ -128,7 +136,7 @@ export class TransactionSynthesizer {
rawPayload: input.rawRow as Prisma.InputJsonValue, rawPayload: input.rawRow as Prisma.InputJsonValue,
canonicalPayload: input.canonicalRow as Prisma.InputJsonValue, canonicalPayload: input.canonicalRow as Prisma.InputJsonValue,
payloadHash, payloadHash,
occurredAt: occurredAt ?? new Date(), occurredAt,
}; };
} }
......
...@@ -28,6 +28,7 @@ import { ...@@ -28,6 +28,7 @@ import {
AssemblerConfigSchema, AssemblerConfigSchema,
type AssemblerConfig, type AssemblerConfig,
} from '../src/modules/sync/assembler/assembler.schema'; } from '../src/modules/sync/assembler/assembler.schema';
import { resolveDictIncludes } from '../src/modules/sync/assembler/dict-include';
// ───────────────────────────────────────────── // ─────────────────────────────────────────────
// 闸 1:FactType ↔ FactContentSchemas 完整性 // 闸 1:FactType ↔ FactContentSchemas 完整性
...@@ -109,8 +110,10 @@ function loadAllAssemblerYamls(): YamlFileInfo[] { ...@@ -109,8 +110,10 @@ function loadAllAssemblerYamls(): YamlFileInfo[] {
if (!fs.existsSync(asmDir)) continue; if (!fs.existsSync(asmDir)) continue;
for (const f of fs.readdirSync(asmDir)) { for (const f of fs.readdirSync(asmDir)) {
if (!f.endsWith('.yaml') && !f.endsWith('.yml')) continue; if (!f.endsWith('.yaml') && !f.endsWith('.yml')) continue;
const raw = fs.readFileSync(path.join(asmDir, f), 'utf-8'); const yamlPath = path.join(asmDir, f);
const parsed = yaml.load(raw); const raw = fs.readFileSync(yamlPath, 'utf-8');
// 走生产同款加载:dict_includes(共享字典分层)在 zod 校验前解析合并
const parsed = resolveDictIncludes(yaml.load(raw), yamlPath);
const r = AssemblerConfigSchema.safeParse(parsed); const r = AssemblerConfigSchema.safeParse(parsed);
if (!r.success) { if (!r.success) {
throw new Error( throw new Error(
...@@ -126,8 +129,10 @@ function loadAllAssemblerYamls(): YamlFileInfo[] { ...@@ -126,8 +129,10 @@ function loadAllAssemblerYamls(): YamlFileInfo[] {
describe('canonical-fact-layer | 闸 3:yaml emits 段合法', () => { describe('canonical-fact-layer | 闸 3:yaml emits 段合法', () => {
const yamls = loadAllAssemblerYamls(); const yamls = loadAllAssemblerYamls();
// patient assembler 走 upsert 路径无 emits;其他必须有 emits // upsert 资源(patient/patient_relation/patient_return_visit)走 upsert 路径无 emits
const subjectYamls = yamls.filter((y) => y.config.canonical !== 'patient'); // (与 cold-import importDirectory 排除 subjectCfgs 的三元组一致);其他必须有 emits
const UPSERT_CANONICALS = new Set(['patient', 'patient_relation', 'patient_return_visit']);
const subjectYamls = yamls.filter((y) => !UPSERT_CANONICALS.has(y.config.canonical));
test('扫到至少 1 份 yaml(否则 data/<host>/assemblers 没东西)', () => { test('扫到至少 1 份 yaml(否则 data/<host>/assemblers 没东西)', () => {
expect(yamls.length).toBeGreaterThan(0); expect(yamls.length).toBeGreaterThan(0);
......
/**
* 患者 upsert 双语义 — push 单表部分更新 vs cold-import 全量覆盖。
*
* 坐实(2026-07-20 patient push 支持的核心约束):
* - full(cold-import):历史行为 —— 缺失字段显式落 null/默认,phone 缺失合成 demo 假号;
* - partial(push 形态 A 单表):canonical 未提供的字段**不进 update 集合**(prisma
* undefined 不动列)—— 宿主只推 customer_basic_info 时 contacts lookup 缺席,
* full 语义会把存量真号覆盖成假号(此前 push 不支持患者主档的根因)。
*
* 纯函数测试,不跑 DB。
*/
import {
buildPatientUpsertData,
buildProfileUpsertData,
synthesizeDemoPhone,
} from '../src/modules/sync/cold-import/patient-upsert.util';
describe('patient upsert | full(cold-import 历史语义)', () => {
test('缺失字段显式落 null;phone 缺失合成确定性 demo 假号', () => {
const d = buildPatientUpsertData({}, 'P001', { partial: false });
expect(d.name).toBeNull();
expect(d.phone).toBe(synthesizeDemoPhone('P001'));
expect(String(d.phone)).toMatch(/^138\d{8}$/);
expect(d.gender).toBeNull();
expect(d.active).toBe(true); // status 缺省 = active
});
test('真值让位:host 给 phone 时不合成', () => {
const d = buildPatientUpsertData({ phone: '13900001111' }, 'P001', { partial: false });
expect(d.phone).toBe('13900001111');
});
test('profile 缺失字段落默认(tags=[] / notes=null)', () => {
const p = buildProfileUpsertData({}, { partial: false });
expect(p.tags).toEqual([]);
expect(p.notes).toBeNull();
expect(p.doNotContact).toBe(false);
});
});
describe('patient upsert | partial(push 单表)', () => {
test('未提供的字段不进 update 集合(不覆盖存量)', () => {
const d = buildPatientUpsertData({ name: '王五' }, 'P001', { partial: true });
expect(d.name).toBe('王五');
expect('phone' in d).toBe(false); // ⭐ 关键:phone 缺席 → 不动存量,更不合成假号
expect('gender' in d).toBe(false);
expect('birthDate' in d).toBe(false);
expect('active' in d).toBe(false); // status 未提供 → 不动 active
});
test('显式提供才更新;null 视为未提供(push 无清空语义)', () => {
const d = buildPatientUpsertData(
{ phone: '13800002222', gender: null },
'P001',
{ partial: true },
);
expect(d.phone).toBe('13800002222');
expect('gender' in d).toBe(false);
});
test('status 提供时归一 active', () => {
const d = buildPatientUpsertData({ status: 'archived' }, 'P001', { partial: true });
expect(d.active).toBe(false);
});
test('profile partial:tags 空数组不清空存量;提供才落', () => {
const p1 = buildProfileUpsertData({ tags: [] }, { partial: true });
expect('tags' in p1).toBe(false);
const p2 = buildProfileUpsertData({ tags: ['VIP'], notes: '备注' }, { partial: true });
expect(p2.tags).toEqual(['VIP']);
expect(p2.notes).toBe('备注');
expect('doNotContact' in p2).toBe(false);
});
test('preferences 天然 partial-safe:无可写偏好 → undefined 不动列', () => {
const d = buildPatientUpsertData({}, 'P001', { partial: true });
expect(d.preferences).toBeUndefined();
const d2 = buildPatientUpsertData({ phoneRelationshipCode: '3', phoneRelationship: 'mother' }, 'P001', {
partial: true,
});
expect(d2.preferences).toMatchObject({
contactPhone: { relationshipCode: '3', relationship: 'mother', isSelf: false },
});
});
});
/**
* TransactionSynthesizer occurredAt 三级确定性回退单元测试。
*
* 坐实:emits.occurredAtField 缺失/不可解析时,回退 updatedAt → createdAt,
* 仅三者皆缺才落墙钟。否则同一源行(新 sourceEventId、时间字段为空)每次摄入
* 得到不同 occurredAt,下游 fact 时间锚变更检测会产生伪 supersede 版本。
*/
import { TransactionSynthesizer } from '../src/modules/sync/pipeline/transaction-synthesizer';
import type { EmitsConfig } from '../src/modules/sync/assembler/assembler.schema';
describe('synthesizer | occurredAt 确定性回退', () => {
const synth = new TransactionSynthesizer();
const emits: EmitsConfig = {
action: 'appointment_created',
subjectType: 'appointment',
occurredAtField: 'occurredAt',
};
const make = (canonicalRow: Record<string, unknown>) => ({
rawRow: {},
emits,
resource: 'appointments',
hostId: 'h1',
tenantId: 'grp_001',
patientIdResolver: () => undefined,
sourceContext: 'test:occurred-at',
sourceUnit: '',
canonicalRow: { externalId: 'apt-1', clinicId: 'C001', ...canonicalRow },
});
test('occurredAtField 有值 → 直接用,不看 updatedAt/createdAt', () => {
const tx = synth.synthesize(
make({
occurredAt: '2026-01-01T10:00:00+08:00',
updatedAt: '2026-02-01T00:00:00+08:00',
createdAt: '2026-03-01T00:00:00+08:00',
}),
);
expect(tx!.occurredAt).toEqual(new Date('2026-01-01T10:00:00+08:00'));
});
test('occurredAtField 缺失 → 回退 updatedAt', () => {
const tx = synth.synthesize(
make({
updatedAt: '2026-02-01T00:00:00+08:00',
createdAt: '2026-03-01T00:00:00+08:00',
}),
);
expect(tx!.occurredAt).toEqual(new Date('2026-02-01T00:00:00+08:00'));
});
test('occurredAtField 不可解析 → 回退 updatedAt', () => {
const tx = synth.synthesize(
make({
occurredAt: 'not-a-date',
updatedAt: '2026-02-01T00:00:00+08:00',
}),
);
expect(tx!.occurredAt).toEqual(new Date('2026-02-01T00:00:00+08:00'));
});
test('occurredAtField/updatedAt 皆缺 → 回退 createdAt', () => {
const tx = synth.synthesize(make({ createdAt: '2026-03-01T00:00:00+08:00' }));
expect(tx!.occurredAt).toEqual(new Date('2026-03-01T00:00:00+08:00'));
});
test('时间字段有值 → 重复摄入 occurredAt 逐字节一致(确定性,不受墙钟影响)', () => {
const row = { updatedAt: '2026-02-01T00:00:00+08:00' };
const a = synth.synthesize(make(row));
const b = synth.synthesize(make(row));
expect(a!.occurredAt).toEqual(b!.occurredAt);
});
test('三者皆缺 → 落墙钟兜底(列非空,不能是 null)', () => {
const before = Date.now();
const tx = synth.synthesize(make({}));
const after = Date.now();
expect(tx!.occurredAt).toBeInstanceOf(Date);
const t = (tx!.occurredAt as Date).getTime();
expect(t).toBeGreaterThanOrEqual(before);
expect(t).toBeLessThanOrEqual(after);
});
});
...@@ -12,10 +12,12 @@ import * as path from 'path'; ...@@ -12,10 +12,12 @@ import * as path from 'path';
import * as yaml from 'js-yaml'; import * as yaml from 'js-yaml';
import { classifyByKeyword } from '../src/modules/sync/assembler/assembler-engine'; import { classifyByKeyword } from '../src/modules/sync/assembler/assembler-engine';
import { AssemblerConfigSchema } from '../src/modules/sync/assembler/assembler.schema'; import { AssemblerConfigSchema } from '../src/modules/sync/assembler/assembler.schema';
import { resolveDictIncludes } from '../src/modules/sync/assembler/dict-include';
function loadCfg(file: string) { function loadCfg(file: string) {
const p = path.join(__dirname, '..', 'data', 'jvs-dw', 'assemblers', file); const p = path.join(__dirname, '..', 'data', 'jvs-dw', 'assemblers', file);
const parsed = yaml.load(fs.readFileSync(p, 'utf-8')); // 走生产同款加载:dict_includes(共享字典分层)在 zod 校验前解析合并
const parsed = resolveDictIncludes(yaml.load(fs.readFileSync(p, 'utf-8')), p);
const r = AssemblerConfigSchema.safeParse(parsed); const r = AssemblerConfigSchema.safeParse(parsed);
if (!r.success) { if (!r.success) {
throw new Error(`${file} schema invalid:\n${JSON.stringify(r.error.issues, null, 2)}`); throw new Error(`${file} schema invalid:\n${JSON.stringify(r.error.issues, null, 2)}`);
......
...@@ -4,6 +4,7 @@ import type { ...@@ -4,6 +4,7 @@ import type {
ExchangeCodeResponse, ExchangeCodeResponse,
MockLoginRequest, MockLoginRequest,
MockLoginResponse, MockLoginResponse,
MockOrgsResponse,
MockUsersResponse, MockUsersResponse,
RefreshTokenResponse, RefreshTokenResponse,
SessionResponse, SessionResponse,
...@@ -41,3 +42,10 @@ export async function mockLogin(req: MockLoginRequest): Promise<MockLoginRespons ...@@ -41,3 +42,10 @@ export async function mockLogin(req: MockLoginRequest): Promise<MockLoginRespons
export async function getMockUsers(): Promise<MockUsersResponse> { export async function getMockUsers(): Promise<MockUsersResponse> {
return api.get<MockUsersResponse>('/pac/v1/auth/mock-users', { auth: false }); return api.get<MockUsersResponse>('/pac/v1/auth/mock-users', { auth: false });
} }
/// 真实组织结构(品牌→诊所,派生自摄入数据)— 多品牌宿主快速登录选数据范围用
export async function getMockOrgs(host = 'friday'): Promise<MockOrgsResponse> {
return api.get<MockOrgsResponse>(`/pac/v1/auth/mock-orgs?host=${encodeURIComponent(host)}`, {
auth: false,
});
}
...@@ -114,8 +114,8 @@ export type RefreshTokenResponse = z.infer<typeof RefreshTokenResponseSchema>; ...@@ -114,8 +114,8 @@ export type RefreshTokenResponse = z.infer<typeof RefreshTokenResponseSchema>;
// 安全:env 门控 — 只在 NODE_ENV != 'production' 或 PAC_ENABLE_MOCK_LOGIN=true 时启用, // 安全:env 门控 — 只在 NODE_ENV != 'production' 或 PAC_ENABLE_MOCK_LOGIN=true 时启用,
// 生产 host 真接入后置 false / 删该 endpoint // 生产 host 真接入后置 false / 删该 endpoint
export const MockLoginRequestSchema = z.strictObject({ export const MockLoginRequestSchema = z.strictObject({
/// 租户 slug — 'ruier'(瑞尔) / 'ruitai'(瑞泰),后端映射到 tenant UUID + clinics /// 租户 slug — 'ruier'(瑞尔) / 'ruitai'(瑞泰) → jvs-dw;'friday' → FRIDAY 市场(集团级)
tenant: z.enum(['ruier', 'ruitai']), tenant: z.enum(['ruier', 'ruitai', 'friday']),
/// PAC 角色 — staff / leader / admin /// PAC 角色 — staff / leader / admin
role: UserRoleSchema, role: UserRoleSchema,
/// 可选:登录为具体客服(externalId,来自 GET /auth/mock-users)。 /// 可选:登录为具体客服(externalId,来自 GET /auth/mock-users)。
...@@ -124,6 +124,10 @@ export const MockLoginRequestSchema = z.strictObject({ ...@@ -124,6 +124,10 @@ export const MockLoginRequestSchema = z.strictObject({
/// 数据范围层级(org-scope):'group'=集团(看全部品牌全部诊所,sourceUnits=[] 不限)。 /// 数据范围层级(org-scope):'group'=集团(看全部品牌全部诊所,sourceUnits=[] 不限)。
/// 不给:有 userExternalId=诊所级,否则=品牌级(整个 tenant 品牌)。 /// 不给:有 userExternalId=诊所级,否则=品牌级(整个 tenant 品牌)。
orgLevel: z.enum(['group']).optional(), orgLevel: z.enum(['group']).optional(),
/// 直接指定 org 节点 id(品牌 GUID / 诊所 id)作数据范围 —— 多品牌宿主(FRIDAY)用:
/// 它没有客服花名册,身份是造的,但**品牌/诊所必须是真实摄入数据**(见 GET /auth/mock-orgs)。
/// 给了 orgId 时 orgScope=[orgId];与 orgLevel 互斥(orgLevel=group 优先)。
orgId: z.string().min(1).optional(),
}); });
export type MockLoginRequest = z.infer<typeof MockLoginRequestSchema>; export type MockLoginRequest = z.infer<typeof MockLoginRequestSchema>;
...@@ -153,6 +157,31 @@ export const MockUsersResponseSchema = z.object({ users: z.array(MockUserSchema) ...@@ -153,6 +157,31 @@ export const MockUsersResponseSchema = z.object({ users: z.array(MockUserSchema)
export type MockUsersResponse = z.infer<typeof MockUsersResponseSchema>; export type MockUsersResponse = z.infer<typeof MockUsersResponseSchema>;
// ============================================================= // =============================================================
// Mock Orgs (GET /pac/v1/auth/mock-orgs) ⭐ 开发 / 试部署用
// =============================================================
//
// 给"快速登录"对话框的**真实**组织结构(品牌 → 诊所),从摄入数据派生的 org 树 +
// host 名字典(sourceUnitNames / clinicNames)。多品牌宿主(FRIDAY)没有客服花名册,
// 身份可造,但数据范围必须落在真实品牌/诊所上 —— 本端点提供可选项。
export const MockOrgClinicSchema = z.object({
clinicId: z.string(),
name: z.string(),
});
export const MockOrgBrandSchema = z.object({
/// 品牌节点 id(= patients.source_unit;多品牌宿主是 GUID)
brandId: z.string(),
name: z.string(),
clinics: z.array(MockOrgClinicSchema),
});
export type MockOrgBrand = z.infer<typeof MockOrgBrandSchema>;
export const MockOrgsResponseSchema = z.object({
/// 集团(租户)节点 id — 集团级登录传它
groupId: z.string(),
brands: z.array(MockOrgBrandSchema),
});
export type MockOrgsResponse = z.infer<typeof MockOrgsResponseSchema>;
// =============================================================
// JWT payload — server-only; never sent to host // JWT payload — server-only; never sent to host
// ============================================================= // =============================================================
......
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