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
---
title: FRIDAY 推送数据契约
description: FRIDAY SaaS 按形态 A 推送的 15 个 source 及字段定义;与已验证的存量导出形状一致,push 无缝衔接。
icon: FileJson
---
> 本文是 [Push 通道](/docs/integration/channel-push)**形态 A** 在 FRIDAY SaaS 宿主的具体化:每个 `source` 的 JSON 字段清单。
> 字段形状与 PAC 已完成的存量摄入(2026-07,10.7 万患者 / 36 万事实)**完全一致**——宿主按此推送,与存量自动衔接:
> 同行同 `updated_gmt_at` 幂等去重,行更新(`updated_gmt_at` 变)自动版本演进。**重叠无害,宁多勿漏**。
---
## 1. 通用约定(所有 source 适用)
```jsonc
// POST /pac/v1/push/rows
{
"source": "med_emr_info", // 本文各节的 source 名
"rows": [ { /* 原生行,字段见各节 */ } ]
}
```
| 约定 | 说明 |
|---|---|
| **id 类字段一律字符串** | `patient_id` / `id` / `appointment_id` 等统一 String(数值型会被 PAC 校验拒收——存量摄入实测踩过的坑) |
| **时间格式** | 统一传 naive 北京墙钟 `"YYYY-MM-DD HH:mm:ss"`(PAC 按 `Asia/Shanghai` 解释)。实证(2026-07-20):MySQL `datetime` 存的就是北京墙钟(`_gmt_` 命名系惯例误导),直出即可;**Mongo `Date` 是北京墙钟伪装成 UTC 存储**(小时分布双证)——从 Mongo 读出后**须直读 UTC 字段还原墙钟**,切勿 `toISOString()` 带 `Z` 推送(会被按真 UTC 多加 8 小时) |
| **`updated_gmt_at` 必须随行更新 bump** | 它是幂等键的一半:不 bump,该行的更新会被永久去重丢弃 |
| **空值** | 传 `null` 或省略字段;不要传 `"NULL"` 字符串 |
| **金额单位** | **元**(decimal 原值,实证:洁治单价均值 ¥310/已结算客单均值 ¥1,730);PAC 侧转分 |
| **投递语义** | at-least-once:未收到 2xx 确认就重推;重复由 PAC 幂等键吸收 |
---
## 2. 患者与关系(3 个 source)
### `customer_basic_info` — 患者主档
> ✅ 患者主档已支持 push(2026-07-20):**部分更新语义** —— 只更新本次提供的字段,
> 未提供的字段不覆盖存量(如只推 `customer_basic_info` 时 phone 不在场 → 保留库内号码);
> push 无"清空字段"语义,需要清空走全量通道。`customer_referee_circle`(关系边)同样可 push。
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `id` | string | ✅ | 患者主键(= PAC externalId;其他表 `patient_id`/`customer_id` 指向它) |
| `name` | string | | 姓名 |
| `sex` | string | | `1`=男 `2`=女,其余置空 |
| `birthday` | string(date) | | 生日 |
| `file_number` | string | | 病历号/档案号(客服沟通用) |
| `tenant_id` | string | ✅ | 品牌 GUID(宿主原生列) |
| `organization_id` | string | | 建档诊所 |
| `created_gmt_at` / `updated_gmt_at` | string(datetime) | ✅ | 建档/更新时间 |
### `customer_contacts` — 联系方式(1:N)
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `id` | string | ✅ | 行主键 |
| `customer_id` | string | ✅ | → 患者 id |
| `contacts_tel` | string | ✅ | 电话号码 |
| `is_default` | string/number | | 默认号标记(PAC 挑默认号:is_default↓ → contacts_type↑ → id↑) |
| `contacts_type` | string/number | | 联系类型(`1` 本人号候选优先) |
| `tel_type` | string/number | | 号码类型 |
| `relationship` | string/number | | 持号人与患者的关系(官方枚举 `PhoneRelationshipEnum`:`1`本人 `2`爸爸 `3`妈妈 `4`爷爷 `5`奶奶 `6`朋友 `7`配偶 `8`子女 `9`其他) |
### `customer_referee_circle` — 转介绍圈(患者-患者关系)
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `id` | string | ✅ | 行主键 |
| `tenant_id` | string | ✅ | 品牌 GUID |
| `organization_id` | string | | 诊所 |
| `customer_id` | string | ✅ | 本人患者 id |
| `referee_patient_id` | string | ✅ | 关系人患者 id(也是在册患者) |
| `referee_relationship` | string/number | | 关系码(官方枚举 `RecommendRelationshipEnum`:`1`配偶 `2`爸爸 `3`子女 `4`兄弟 `5`爷爷 `6`孙子女 `7`朋友 `8`其他亲属 `9`姐妹 `10`妈妈 `11`奶奶 `12`姐弟 `13`兄妹 `14`其他 `15`外公 `16`外婆 `17`外孙子女;**语义=本行 customer 是 referee 的 X**) |
| `type` | string/number | | 关系类型标记 |
| `created_gmt_at` / `updated_gmt_at` | string(datetime) | | |
---
## 3. 预约(1 个 source)
### `appointment_base` — 预约(全状态推,PAC 侧过滤)
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `id` | string | ✅ | 预约主键 |
| `patient_appointment_id` | string | ✅ | → 患者 id |
| `organization_id` | string | ✅ | 诊所 |
| `tenant_id` | string | ✅ | 品牌 GUID |
| `appointment_date` | string(date) | ✅ | 预约日期 |
| `appointment_start` | string(time) | ✅ | 开始时刻(与 date 拼成预约时刻) |
| `appointment_status` | string | ✅ | `1`预约正常 `2`改约 `3`取消 `4`已到诊 `5`已就诊 `6`已结算 `7`到诊患者变更 `8`walk-in变更 `9`已爽约 `10`草稿(官方注释;PAC 当前消费 1-6/8/9,7 与 10 丢弃) |
| `doctor_user_id` | string | | 医生 id |
| `appointment_time_length` | string/number | | 时长(分钟) |
| `in_time` | string(datetime) | | 到诊时刻(状态 4/5/6 时,PAC 据此产接诊事件) |
| `created_gmt_at` / `updated_gmt_at` | string(datetime) | ✅ | |
---
## 4. 病历链(3 个 source)
### `med_emr_info` — 病历正文(Mongo 平铺;一张表喂诊断/治疗/建议/复查/病历全链)
**推送范围:`status ∈ {3, 4}`(已完成/归档);`status=2` 未完成草稿不推。**
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `emr_sub_id` | string | ✅ | 小病历号 uuid(病历主键;临床事实同诊聚合锚) |
| `emr_id` | string | ✅ | 病历号(如 `JY0A000231`;影像 `med_check.emr_id` 指向它) |
| `appointment_id` | string | | 关联预约 id(有则挂回预约链) |
| `tenant_id` | string | ✅ | 品牌 GUID(宿主原生 tenantId) |
| `organization_id` | string | ✅ | 诊所 |
| `organization_name` | string | | 诊所名称 —— **诊所展示名的主要来源**(PAC 据此派生 id→名字典;缺失则前端显示 GUID) |
| `patient_id` | string | ✅ | → 患者 id |
| `user_id` / `user_name` | string | | 接诊医生 id / 姓名 |
| `clinic_time` | string(datetime) | ✅ | 就诊时刻 |
| `visit_indicator` | number | | `1`初诊 `2`复诊 |
| `status` | number | ✅ | `3`已完成 `4`归档(推送范围) |
| `diag` | array | | 诊断数组,元素 `{ "toothPosition": "41;42", "value": "慢性牙髓炎", "linkCode": "<std_diag.diag_code 或空>" }` |
| `treat` | array | | 本次治疗数组,元素同上结构(`value`=治疗名) |
| `dispose` | array | | 处置叙述数组,元素 `{ "toothPosition", "value" }` |
| `examine` | array | | 检查所见数组,元素同上 |
| `dispose_text` / `examine_text` | string | | 上两数组的 JSON 字符串形态(病历自由文本位;数组非空时必带) |
| `illness_desc` | string | | 主诉 |
| `pre_illness` / `past_hist` / `gen_cond` | string | | 现病史 / 既往史 / 全身情况 |
| `auxi_exam` | string | | 辅助检查 |
| `doc_order` | string | | 医嘱 |
| `created_gmt_at` / `updated_gmt_at` | string(datetime) | ✅ | |
> 牙位格式:FDI 牙位号,多牙分号分隔,可带牙面字母(`"42 L;43 D"` / `"11;12;13"`)。
### `std_diag` — 诊断字典(低频;变更时全量重推)
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `diag_code` | string | ✅ | 字典主键(= `diag[].linkCode` 指向) |
| `diag_name` | string | ✅ | 诊断名 |
| `std_code` | string | | 标准码(`K02.901` 等;K 开头 PAC 截 K 大类,非 K 按自由文本处理) |
### `med_check` — 影像档案(metadata,不含文件本体)
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `id` | string | ✅ | 行主键 |
| `tenant_id` | string | ✅ | 品牌 GUID |
| `organization_id` | string | ✅ | 诊所 |
| `organization_name` | string | | 诊所名称(同上,诊所名字典来源之一) |
| `patient_id` | string | ✅ | → 患者 id |
| `emr_id` | string | | → 病历号(`med_emr_info.emr_id`) |
| `class_code` | string | | 影像类型 → `std_check_class` |
| `file_name` / `file_type` | string | | 文件名 / 类型 |
| `file_url` | string | | OSS 相对路径 |
| `files_size` | number | | 字节数 |
| `shooting_time` | string(datetime) | ✅ | 拍摄时间 |
| `created_gmt_at` / `updated_gmt_at` | string(datetime) | ✅ | |
配套字典 **`std_check_class`**(低频):`class_code`(主键) / `class_name`(口内像照片、小牙片、全景片…) / `tenant_id`。
---
## 5. 结算链(4 个 source)
> **拆分规则(宿主侧按 status/金额分流成两个 source 推)**。`status` 官方语义
> (`PatientSettlementEntity` 实体注释,比枚举类更全):`0`未结算 `1`已结算 `2`只生成uuid
> `3`退款 `4`插入的退款数据 `5`重新结算数据(废弃历史行) `6`医生未提交 `7`流程结束
> `8`欠款补缴插入数据。**0/2/5/6/7/8 均不推**——其中 `8` 是原单克隆(receivable_this
> 原样复制,`payment_arrears`=本次补缴额,ref 挂原欠费单),入消费会双计应收;`7` 零金额流程关单。
### `patient_settlement` — 消费单
**推送范围:`status ∈ {1, 3}` 且 `receivable_this >= 0`。**
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `uuid` | string | ✅ | 结算单主键 |
| `tenant_id` | string | ✅ | 品牌 GUID |
| `organization_id` | string | ✅ | 诊所 |
| `patient_id` | string | ✅ | → 患者 id |
| `doctor_id` | string | | 开单医生 |
| `status` | string/number | ✅ | 见上 |
| `receivable_this` | number(元) | ✅ | 应收(PAC 计患者价值/LTV 用) |
| `net_receipts_this` | number(元) | | 实收 |
| `billing_date` | string(datetime) | | 开单时间 |
| `registration_id` | string | | 关联接诊号 |
| `ref_settlement_id` | string | | (消费单一般为空) |
| `settlement_serial_num` | string | | 结算流水号 |
| `reason` | string | | 备注/原因 |
| `created_gmt_at` / `updated_gmt_at` | string(datetime) | ✅ | |
### `patient_settlement_refund` — 整单退费
**推送范围:`status = 4` 或(`status = 3` 且 `receivable_this < 0`)。** 字段同 `patient_settlement`;其中:
- `net_receipts_this` 负值 = 冲减金额(PAC 取绝对值)
- `ref_settlement_id` = **被退的原结算单 uuid**(status=4 必带;PAC 据此把退费挂回原消费)
### `patient_settlement_spec_refund` — 行级部分退费
**推送范围:结算明细行中 `is_refund = 1` 的行。**
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `id` | string | ✅ | 明细行主键 |
| `tenant_id` / `organization_id` / `patient_id` | string | ✅ | 同上 |
| `settlement_id` | string | ✅ | 所属结算单 uuid(退费挂回原消费) |
| `cure_name` / `service_project_name` | string | | 被退项目名 |
| `receivable_this` / `net_receipts_this` | number(元) | ✅ | 被退金额(正值) |
| `is_refund` | string/number | ✅ | 恒为 `1` |
| `created_gmt_at` / `updated_gmt_at` | string(datetime) | ✅ | |
### `settlement_modes` — 支付通道明细(每单×通道一行)
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `id` | string | ✅ | 行主键 |
| `tenant_id` | string | ✅ | 品牌 GUID |
| `settlement_id` | string | ✅ | → 结算单 uuid |
| `modes_name` | string | ✅ | 通道名(现金支付/微信支付/刷卡支付/医保支付…原样即可) |
| `money` | number(元) | ✅ | 该通道金额(PAC 取金额最大者为主导通道) |
| `created_gmt_at` / `updated_gmt_at` | string(datetime) | | |
---
## 6. 治疗计划与咨询(3 个 source)
### `customer_treat_plan` — 治疗计划(头)
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `id` | string | ✅ | 计划主键 |
| `tenant_id` | string | ✅ | 品牌 GUID |
| `organization_id` | string | ✅ | 诊所(**行项靠头表拿诊所,缺失该行会被丢弃**) |
| `customer_id` | string | ✅ | → 患者 id |
| `plan_group_id` / `plan_group_num` | string | | 方案分组(多方案对比) |
| `plan_name` | string | | 方案名 |
| `desired_effect` / `emergency_urgency` | string/number | | 期望效果 / 紧迫度 |
| `expect_cost` | string | | 期望费用 |
| `created_gmt_at` / `updated_gmt_at` | string(datetime) | ✅ | |
### `customer_treat_plan_item` — 治疗计划(行)
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `id` | string | ✅ | 行主键 |
| `tenant_id` | string | ✅ | 品牌 GUID |
| `customer_id` | string | ✅ | → 患者 id |
| `treat_plan_id` | string | ✅ | → 计划头 id |
| `tooth_position` | string | | 牙位 |
| `mode_name` | string | ✅ | 术式名(PAC 翻 11 类治疗类别) |
| `charge_min` / `charge_max` | number(元) | | 价格区间 |
| `created_gmt_at` / `updated_gmt_at` | string(datetime) | ✅ | |
### `customer_consult` — 咨询
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `id` | string | ✅ | 咨询主键 |
| `tenant_id` | string | ✅ | 品牌 GUID |
| `organization_id` | string | ✅ | 诊所 |
| `customer_id` | string | ✅ | → 患者 id |
| `task_director_id` / `task_director` | string | | 跟进人 id / 姓名 |
| `treatment_plan` | string | | 咨询中聊到的治疗方案 |
| `preferential_way` | string | | 优惠方式 |
| `unsettled_reason` | string | | **未成交原因**(强召回信号) |
| `unsettled_reason_description` | string | | 未成交原因补充 |
| `other_conditions` | string | | 其他情况 |
| `potential_treatment` | string | | 患者意向(潜在治疗) |
| `data_sources` | string/number | | 数据来源标记 |
| `created_gmt_at` / `updated_gmt_at` | string(datetime) | ✅ | |
---
## 7. 语义澄清记录(2026-07,源码 + 数据双证)
以下曾是开放问题,现已定案 —— 推送方无需再确认,列此备查:
| 项 | 结论 | 依据 |
|---|---|---|
| 时间口径 | MySQL `datetime` = 北京墙钟(`_gmt_` 命名系惯例误导);**Mongo `Date` 是北京墙钟伪装成 UTC** | `clinicTime`/`createdGmtAt` 小时分布呈营业双峰;按真 UTC 解释则半数病历落深夜 |
| 金额单位 | 元(`decimal(9,2)`) | 洁治单价均值 ¥310、已结算客单均值 ¥1,730 |
| `contacts.relationship` | `PhoneRelationshipEnum` 1-9(见 §2) | customer 服务枚举类 |
| `referee_relationship` | `RecommendRelationshipEnum` 17 码,**语义 = 本行 customer 是 referee 的 X**(PAC 侧按逆关系映射) | 枚举类 `reverseValue()` + 双方年龄差数据 |
| `med_emr_info.treat` vs `dispose` | `treat` = 本次治疗(→ 结构化治疗事实);`dispose` = 处置叙述(→ 病历自由文本) | EMR 接口 DTO 字段注释 |
| `patient_settlement.status` 全 9 值 | `0`未结算 `1`已结算 `2`只生成uuid `3`含退费行 `4`整单反向冲减 `5`重新结算(废弃历史) `6`医生未提交 `7`流程结束 `8`欠款补缴克隆单 | 实体注释 + `savePatientSettlementQk()` 实现 |
| 为何不收 `7`/`8` | `8` 是原单克隆(`receivable_this` 原样复制、`payment_arrears` 才是本次补缴额),入消费会**双计应收**;`7` 为零金额流程关单 | 同上 |
### 仍需推送方留意(非阻塞)
| 项 | 说明 |
|---|---|
| `organization_name` 覆盖 | 目前仅病历/影像表带诊所名,**只覆盖有病历记录的诊所**;其余诊所前端回退显示 GUID。若贵方有组织树接口(如 `regional_nodes_structure`),提供 `诊所 id → 名称` 全量表可补齐 |
| 品牌名 | PAC 当前**不需要**品牌中文名(无展示位,内部按品牌 id 标识)。将来若有展示需求,再约定推品牌主档(`tenant_apply.tenant_info` 的 `tenant_id`/`name`)——贵方 `tenant_name` 未冗余进业务表,只能走主档 |
| 字典表推送频率 | `std_diag` / `std_check_class` 变更不频繁,按"变更时全量重推"即可,无需增量 |
| 测试环境数据特征 | 部分租户为开发沙盒(诊所名如"XX专用诊所勿动"),字典类映射(治疗类别关键词等)待**生产数据**回流后再校准一轮 |
...@@ -8,6 +8,7 @@ ...@@ -8,6 +8,7 @@
"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"
......
# ═══════════════════════════════════════════════════════════
# 共享字典 · 诊断中文术语 → ICD K 码(enum 精确 + keyword 含词兜底)
#
# 【临床通用层】口腔标准术语与 K 码的对应是医学知识,不随宿主变 —— 所有宿主经
# `dict_includes` 引入本文件;宿主用词怪癖/判例(如 jvs-dw "牙齿缺少"→K08)留各宿主
# yaml 本地(本地同 key 覆盖共享,本地 keyword 规则优先于共享)。
#
# ⚠️ 治理纪律:
# - 改本文件影响【所有】引入它的宿主 → 改完对受影响宿主跑 `pnpm reparse` 重衍生;
# - 新词归层判断:标准临床术语 → 这里;某宿主的用词判例/价格表名 → 该宿主 yaml;
# - `_default` 禁止出现在本文件(loader 强校验),由宿主本地声明。
# 源出处:抽取自 jvs-dw diagnosis.yaml(2026-07,含全部临床注释与故意不映射决策)。
# ═══════════════════════════════════════════════════════════
enum_mapping:
code:
# K 大类直通
K00: K00
K01: K01
K02: K02
K03: K03
K04: K04
K05: K05
K06: K06
K07: K07
K08: K08
K09: K09
K10: K10
K11: K11
K12: K12
K13: K13
K14: K14
# ── 中文术语 → K 码(高置信草案,trim 已去尾部标点)──
# K05 牙周 / 牙龈
慢性牙龈炎: K05
牙龈炎: K05
慢性龈炎: K05
龈炎: K05
菌斑性龈炎: K05
菌斑性牙龈炎: K05 # W4 末补,DW top 200 漏配("牙龈"vs"龈"长写)
慢性牙周炎: K05
牙周炎: K05
重度牙周炎: K05
中度牙周炎: K05
侵袭性牙周炎: K05
# K02 龋
: K02
龋齿: K02
龋坏: K02
浅龋: K02
中龋: K02
深龋: K02
继发龋: K02
# ⚠️ 深窝沟 故意不映射(落 _default → code=null,不召、病历照原文显示):
# 它是龋【风险】(窝沟深易蛀,无龋洞),对症是窝沟封闭(preventive 预防),
# 不属于"应治未治(龋洞没补)"召回范畴。归任何疾病码都会误召(预防治疗满足不了"已治"闸)。
# K04 牙髓 / 根尖周
牙髓炎: K04
慢性牙髓炎: K04
急性牙髓炎: K04
根尖周炎: K04
慢性根尖周炎: K04
慢性根尖炎: K04
急性根尖周炎: K04
牙髓坏死: K04
# K03 牙体硬组织其他(楔缺 / 缺损 / 残根冠)
楔状缺损: K03
楔形缺损: K03
楔缺: K03
牙体缺损: K03
残根: K03
残冠: K03
# K01 阻生 / 埋伏
阻生齿: K01
阻生牙: K01
阻生: K01
埋伏牙: K01
# K07 牙颌面异常 / 错颌
错颌畸形: K07
牙列不齐: K07
牙列拥挤: K07
安氏I类错颌畸形: K07
安氏II类错颌畸形: K07
安氏III类错颌畸形: K07
# K08 缺牙 / 缺失
牙缺失: K08
缺失: K08
缺失牙: 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
釉质发育不全: 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
# ⚠️ 脱矿 / 牙本质敏感 故意不映射(落 _default → code=null):
# 脱矿=早期龋【风险】(对症涂氟,预防)、牙本质敏感=【症状】(对症脱敏,非修复缺损)。
# 跟已 null 的"牙釉质脱矿/釉质脱矿"口径统一,均不作"应治未治"召回信号。
# 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
# ⚠️ 食物嵌塞 不映射(【症状】,对症调牙合/牙周处置,非"应治未治"病灶)→ 落 _default code=null
附着丧失: 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
# ─────────────────────────────────────────────────────────
# 含词兜底 —— 精确 enum 未命中时,按序含词匹配 → K 码。
# 治本长尾:诊断常是复合/变体("埋伏阻生;"、"前倾位阻生,颌面龋"、"牙齿缺失"、"窝沟龋"…),
# 精确白名单永远漏(沈建锋 28/38 阻生智齿 → null → 漏召)。引擎按 canonicalKey 通用。
# 顺序=优先级:阻生/缺失/牙髓 在 龋 之前 → 复合"阻生+龋"判 K01(智齿该评估拔除,非补)。
# ⚠️ 龋:any=[龋] + none=[风险,可疑,脱矿] —— 排除"高龋风险/可疑龋"(=风险,非龋洞,同深窝沟不召);
# 深窝沟/食物嵌塞/牙本质敏感 不含上述关键词 → 仍落 null,故意不召(无回归)。
keyword_mapping:
code:
- { value: K08, any: [缺失, 缺牙, 牙列缺损] }
- { value: K01, any: [阻生, 埋伏, 智齿] }
- { value: K09, any: [颌骨囊肿, 含牙囊肿, 根尖囊肿] }
- { value: K04, any: [牙髓炎, 牙髓坏死, 根尖周, 根尖炎, 牙髓] }
- { value: K03, any: [楔状, 残根, 残冠, 隐裂, 牙体缺损, 牙折] }
- { value: K05, any: [牙周炎, 龈炎, 牙周袋] }
- { value: K02, any: [], none: [风险, 可疑, 脱矿] }
# 牙磨耗 → 牙体损伤 K03(过度/中度/重度/牙齿磨耗… 一次收全 host 变体,补 enum 精确词漏的)。
# 放 K02/K05 之后:复合名"龋+磨耗""磨耗+牙周炎"让位给更主要的龋/牙周。
# none 排:生理性/轻度磨耗(正常生理,无需修复)+ 活动义齿/人工牙磨耗(假牙磨损=修复体重做,非牙体)。
- { value: K03, any: [磨耗], none: [生理, 轻度, 义齿, 人工牙] }
# K07 错颌/正畸族 含词兜底 —— 精确白名单总漏变体(错牙合/开颌/反颌/深覆/间隙/拥挤/安氏…)。
# 放最后:K08(缺牙间隙)/K09(颌骨囊肿)等已在前面先吃;none 再兜一层防"缺牙间隙"误判正畸。
# 注:反合/开合/对刃合 = 反牙合/开牙合 的"合"简写(host 高频:反合 120+),crossbite/openbite 都是正畸。
- { value: K07, any: [错颌, 错合, 错牙合, 锁颌, 偏颌, 开颌, 反颌, 开牙合, 反牙合, 反合, 开合, 对刃合, 深覆, , 前突, 拥挤, 安氏, 间隙], none: [缺牙, 缺失, 牙列缺损, 嵌塞] }
# ═══════════════════════════════════════════════════════════
# 共享字典 · 影像类型名 → 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
#!/usr/bin/env bash
# FRIDAY SaaS 测试环境 → PAC cold-import 导出(等价"host 侧单表 dump")
#
# 纪律(同 jvs-dw SQL 朴素化):每条导出 = 列选 + WHERE,零 join/零变换;
# (2026-07 对齐:一列都不改名 —— 宿主原生字段直出;与 PAC 租户概念的区分由 PAC 适配层消化。)
# 形态改造(拆 JSON 数组/字典 join/K 码截取)全部在 PAC transforms 层做。
#
# 用法(cohort 过滤,语义与 jvs-dw cold-import --clinics/--since 完全一致):
# ./export.sh # 全量(默认行为不变)
# ./export.sh --clinics=<orgId,orgId> # 只导"在这些诊所看过(有正式病历)"的患者
# ./export.sh --since=YYYY-MM-DD # 只导"该日期后有来诊"的患者
# ./export.sh --months=N # 最近 N 个月有来诊(换算成 --since;--since 显式优先)
# ⚠️ 过滤只收窄「患者名单」(cohort 源 = med_emr_info 正式病历,对齐 jvs-dw clinic_scope
# 挂 EMR 事实表):入选患者的**全部主体、跨所有诊所、全部历史**照常导出,不按行截断。
# --clinics 与 --since 可叠加(按患者求交,非行级 AND)。导入幂等追加,分批补摄安全。
#
# 凭据走 env(不入 git):
# export FRIDAY_MYSQL_HOST=… FRIDAY_MYSQL_PORT=3309 FRIDAY_MYSQL_USER=… FRIDAY_MYSQL_PASSWORD=…
# export FRIDAY_MONGO_URI='mongodb://user:pass@host:27017/emr_market' # 密码含 & 等须 URL 编码
#
# 依赖:docker(mysql:8 / mongo:7 客户端镜像),产物落本目录(.gitignore 已挡,不入 git)。
set -euo pipefail
cd "$(dirname "$0")"
# ── cohort 过滤参数(在凭据校验前解析:--help 不需要凭据)──
CLINICS="" SINCE="" MONTHS=""
for a in "$@"; do
case "$a" in
--clinics=*) CLINICS="${a#--clinics=}" ;;
--since=*) SINCE="${a#--since=}" ;;
--months=*) MONTHS="${a#--months=}" ;;
--help|-h) sed -n '/^# 用法/,/^#$/p' "$0"; exit 0 ;;
*) echo "未知参数: $a(支持 --clinics= --since= --months=)" >&2; exit 1 ;;
esac
done
# --since 显式优先;否则 --months=N 换算(BSD date 优先,GNU date 兜底)
if [ -z "$SINCE" ] && [ -n "$MONTHS" ]; then
SINCE=$(date -v-"${MONTHS}"m +%F 2>/dev/null || date -d "-${MONTHS} months" +%F)
fi
: "${FRIDAY_MYSQL_HOST:?need FRIDAY_MYSQL_HOST}"
: "${FRIDAY_MYSQL_PORT:=3309}"
: "${FRIDAY_MYSQL_USER:?need FRIDAY_MYSQL_USER}"
: "${FRIDAY_MYSQL_PASSWORD:?need FRIDAY_MYSQL_PASSWORD}"
: "${FRIDAY_MONGO_URI:?need FRIDAY_MONGO_URI}"
COHORT_IN="" # 非空 = cohort 模式:MySQL 谓词 "IN ('id',...)"
if [ -n "$CLINICS$SINCE" ]; then
echo "── cohort 计算(med_emr_info:clinics=${CLINICS:-*} since=${SINCE:-*})──"
# 谓词按「患者」求交(与 jvs-dw 同):在指定诊所看过 ∩ 该日期后有来诊;各谓词独立,非同行 AND。
docker run --rm -e "CL=$CLINICS" -e "SN=$SINCE" mongo:7 mongosh "$FRIDAY_MONGO_URI" --quiet --eval '
const cl = (process.env.CL || "").split(",").map((s) => s.trim()).filter(Boolean);
const sn = process.env.SN || "";
const base = { status: { $in: [3, 4] } };
// 存储类型 string/number 混杂可能 → 同值双形态都进 $in
const both = (s) => { const n = Number(s); return Number.isFinite(n) && String(n) === s ? [s, n] : [s]; };
const S = (v) => String(v);
let ids = null;
if (cl.length) ids = db.med_emr_info.distinct("patientId", Object.assign({ organizationId: { $in: cl.flatMap(both) } }, base)).map(S);
if (sn) {
// clinicTime 是"北京墙钟伪装成 UTC"的 Date(见下方 EMR 导出注释)→ cutoff 按 Z 构造即对齐存储
const set = new Set(db.med_emr_info.distinct("patientId", Object.assign({ clinicTime: { $gte: new Date(sn + "T00:00:00Z") } }, base)).map(S));
ids = ids === null ? Array.from(set) : ids.filter((x) => set.has(x));
}
print(JSON.stringify(ids ?? []));
' > cohort_ids.json.tmp
node -e "
let a; try { a = JSON.parse(require('fs').readFileSync('cohort_ids.json.tmp','utf-8')); } catch (e) {
console.error(' ✗ cohort 计算失败(Mongo 不可达?)'); process.exit(1); }
if (!Array.isArray(a) || a.length === 0) {
console.error(' ✗ 过滤后 cohort 为空(诊所 id 拼错?日期太晚?)— 拒绝继续(否则会退化成全量导出)'); process.exit(1); }
console.log(' cohort:', a.length, '患者');
" || { rm -f cohort_ids.json.tmp; exit 1; }
mv cohort_ids.json.tmp cohort_ids.json
COHORT_IN=$(node -e '
const ids = JSON.parse(require("fs").readFileSync("cohort_ids.json", "utf-8"));
process.stdout.write("(" + ids.map((s) => "\x27" + String(s).replace(/\x27/g, "") + "\x27").join(",") + ")");
')
fi
# cohort 谓词拼接:pf = 查询无 WHERE 时用;pfa = 已有 WHERE 时接 AND。无 cohort → 空串(全量不变)。
pf() { if [ -n "$COHORT_IN" ]; then printf 'WHERE %s IN %s' "$1" "$COHORT_IN"; fi; }
pfa() { if [ -n "$COHORT_IN" ]; then printf 'AND %s IN %s' "$1" "$COHORT_IN"; fi; }
# settlement_modes 无患者列 → 经结算头单挂靠(IN 子查询是 jvs-dw SQL 纪律明确放行的 cohort 限定形态)
pfs() { if [ -n "$COHORT_IN" ]; then printf 'WHERE settlement_id IN (SELECT uuid FROM `arrail-settlement-server`.patient_settlement WHERE patient_id IN %s)' "$COHORT_IN"; fi; }
# ── MySQL → CSV(TSV 中转,node 处理 mysql --batch 的 \t \n \\ 转义再按 RFC4180 引号)──
mysql_csv() {
local sql="$1" out="$2"
# SQL 走 stdin 而非 -e:cohort IN 列表可达数十万字符,作为 docker 参数会撞 ARG_MAX
printf '%s\n' "$sql" | docker run -i --rm mysql:8 mysql \
-h"$FRIDAY_MYSQL_HOST" -P"$FRIDAY_MYSQL_PORT" -u"$FRIDAY_MYSQL_USER" -p"$FRIDAY_MYSQL_PASSWORD" \
--default-character-set=utf8mb4 --batch --raw=FALSE --connect-timeout=20 \
| node -e '
const lines = require("fs").readFileSync(0, "utf-8").split("\n");
const unesc = (s) => { let r = ""; for (let i = 0; i < s.length; i++) { const c = s[i];
if (c === "\\" && i + 1 < s.length) { const n = s[++i];
r += n === "n" ? "\n" : n === "t" ? "\t" : n === "0" ? "\0" : n === "N" ? "" : n; }
else r += c; } return r; };
const q = (s) => /[",\n\r]/.test(s) ? `"${s.replace(/"/g, `""`)}"` : s;
const out = [];
for (const line of lines) { if (line === "") continue;
out.push(line.split("\t").map((f) => q(f === "NULL" ? "" : unesc(f))).join(",")); }
process.stdout.write(out.join("\n") + "\n");
' > "$out.tmp"
# ⚠️ 先写 .tmp,校验有数据才替换正式文件 —— 测试库抖动时裸重定向会把好文件清空(2026-07-20 踩)
local n=$(($(wc -l < "$out.tmp") - 1))
if [ "$n" -lt 1 ]; then
# cohort 模式下 0 行是合法结果(该批患者恰无此类数据;mysql --batch 空结果连表头都不出)
# → 从 SELECT 列表合成表头,产出 0 行 CSV(cold-import 仍需列名行)。全量模式维持拒绝覆盖。
if [ -n "$COHORT_IN" ]; then
printf '%s\n' "$sql" | sed -E 's/^SELECT ([^ ]+) FROM .*$/\1/' > "$out.tmp"
mv "$out.tmp" "$out"
echo " $out: 0 rows(cohort 内无此类数据,合成表头)"
return 0
fi
rm -f "$out.tmp"
echo " ✗ $out: 导出为空(库不可达?),保留原文件不覆盖" >&2
return 1
fi
mv "$out.tmp" "$out"
echo " $out: $n rows"
}
echo "── MySQL 导出 ──"
# 患者主档(全量;既有 450 行样本 → 扩全量,EMR 覆盖 ~1.65 万患者的前置)
mysql_csv "SELECT id,name,sex,birthday,file_number,tenant_id,organization_id,created_gmt_at,updated_gmt_at FROM customer.customer_basic_info $(pf id)" customer_basic_info.csv
# 联系方式 1:N(lookup 挑默认号 → patient.phone)
mysql_csv "SELECT id,customer_id,contacts_tel,is_default,contacts_type,tel_type,relationship FROM customer.customer_contacts $(pf customer_id)" customer_contacts.csv
# 预约(全状态;transforms 丢草稿 10)
mysql_csv "SELECT id,patient_appointment_id,organization_id,tenant_id,appointment_date,appointment_start,appointment_status,doctor_user_id,appointment_time_length,in_time,created_gmt_at,updated_gmt_at FROM \`arrail-appointment-server\`.appointment_base $(pf patient_appointment_id)" appointment_base.csv
# 影像档案(挂 emr_id 病历号;modality 经 std_check_class 字典翻)
mysql_csv "SELECT id,tenant_id,organization_id,organization_name,patient_id,emr_id,class_code,file_name,file_type,file_url,files_size,shooting_time,created_gmt_at,updated_gmt_at FROM emr.med_check $(pf patient_id)" med_check.csv
# 影像类型字典(class_code UUID 全局唯一,lookup 不需 tenant 限定)
mysql_csv "SELECT class_code,class_name,tenant_id FROM emr.std_check_class" std_check_class.csv
# 诊断字典(linkCode → std_code;K 码用,非 K 落自由文本)
mysql_csv "SELECT diag_code,diag_name,std_code FROM emr.std_diag" std_diag.csv
# 治疗计划(头:方案名/期望;行:牙位/术式/价格区间)→ treatment_planned
mysql_csv "SELECT id,tenant_id,organization_id,customer_id,plan_group_id,plan_name,desired_effect,emergency_urgency,expect_cost,created_gmt_at,updated_gmt_at FROM customer.customer_treat_plan $(pf customer_id)" customer_treat_plan.csv
mysql_csv "SELECT id,tenant_id,customer_id,treat_plan_id,tooth_position,mode_name,charge_min,charge_max,created_gmt_at,updated_gmt_at FROM customer.customer_treat_plan_item $(pf customer_id)" customer_treat_plan_item.csv
# 结算(消费/退费)。status 语义(2026-07-18 实测):1=已结算 3=含退费行的单 4=整单反向冲减(负额+ref 挂原单)。
# ⚠️ 2ac96f6d 品牌不用 status=4:整单冲减直接记 status=3 负金额(实测 200 行)→ 按金额正负切分:
# 消费 = status∈{1,3} 且金额≥0;整单退费 = status=4 或 status=3 负金额(退费第三表达)。
# 其余 status(0 草稿/2/5/6/7/8)语义未明,先不收 → WHERE 收窄(host 等价 dump 允许的过滤)
mysql_csv "SELECT uuid,tenant_id,organization_id,patient_id,doctor_id,status,receivable_this,net_receipts_this,billing_date,registration_id,ref_settlement_id,settlement_serial_num,reason,created_gmt_at,updated_gmt_at FROM \`arrail-settlement-server\`.patient_settlement WHERE status IN (1,3) AND receivable_this >= 0 $(pfa patient_id)" patient_settlement.csv
mysql_csv "SELECT uuid,tenant_id,organization_id,patient_id,doctor_id,status,receivable_this,net_receipts_this,billing_date,registration_id,ref_settlement_id,settlement_serial_num,reason,created_gmt_at,updated_gmt_at FROM \`arrail-settlement-server\`.patient_settlement WHERE (status=4 OR (status=3 AND receivable_this < 0)) $(pfa patient_id)" patient_settlement_refund.csv
# 退费行级明细(部分退费轨道:is_refund=1 全部归属 status=3 头单)
mysql_csv "SELECT id,tenant_id,organization_id,patient_id,settlement_id,cure_name,service_project_name,receivable_this,net_receipts_this,is_refund,created_gmt_at,updated_gmt_at FROM \`arrail-settlement-server\`.patient_settlement_spec WHERE is_refund=1 $(pfa patient_id)" patient_settlement_spec_refund.csv
# 支付通道长表(每单×通道一行;lookup 挑金额最大者为主导通道)
mysql_csv "SELECT id,tenant_id,settlement_id,modes_name,money,created_gmt_at,updated_gmt_at FROM \`arrail-settlement-server\`.settlement_modes $(pfs)" settlement_modes.csv
# 转介绍圈(患者-患者关系边,双向成对存;referee_relationship 为宿主字典 id,PAC 侧统计推断解码)
mysql_csv "SELECT id,tenant_id,organization_id,customer_id,referee_patient_id,referee_relationship,type,created_gmt_at,updated_gmt_at FROM customer.customer_referee_circle $(pf customer_id)" customer_referee_circle.csv
# 咨询(意向 potential_treatment/未成交原因;测试库仅 45 行,结构真实)
mysql_csv "SELECT id,tenant_id,organization_id,customer_id,task_director_id,task_director,treatment_plan,preferential_way,unsettled_reason,unsettled_reason_description,other_conditions,potential_treatment,data_sources,created_gmt_at,updated_gmt_at FROM customer.customer_consult $(pf customer_id)" customer_consult.csv
echo "── Mongo 导出(med_emr_info,status∈{3,4} 正式病历)──"
# 不用 mongoexport:Extended JSON 会把 Date/Long 包成 {$date}/{$numberLong} 对象,破坏平铺契约。
# mongosh 手动平铺:Date→naive 北京墙钟字符串,Long→字符串,数组(diag/treat/dispose/examine)原样保留
# (PAC split_json_array 原生支持已 parse 数组)。产出 JSON 数组文件(cold-import json 格式)。
#
# ⚠️ Date 时区实证(2026-07-20,clinicTime/createdGmtAt 小时分布双证):FRIDAY 写库时把
# 北京墙钟"伪装成 UTC"存进 Mongo Date(直读 UTC 字段=营业时间双峰 上午7-10/下午13-17;
# 按真 UTC 解释则一半病历落深夜——荒谬)。故导出**不能** toISOString 带 Z(会被 PAC 当真
# UTC 再 +8,EMR 链时间全偏 8 小时,已踩):把 UTC 字段值直读为墙钟,输出 naive
# "YYYY-MM-DD HH:mm:ss",交给 manifest timezone=Asia/Shanghai 解释 → 瞬间正确。
# cohort 模式:挂载 id 文件供 $in 过滤(bash 3.2 + set -u 下空数组要用 ${arr[@]+...} 展开)
MONGO_ARGS=()
if [ -n "$COHORT_IN" ]; then MONGO_ARGS=(-v "$PWD/cohort_ids.json:/cohort_ids.json:ro" -e "COHORT=1"); fi
docker run --rm ${MONGO_ARGS[@]+"${MONGO_ARGS[@]}"} mongo:7 mongosh "$FRIDAY_MONGO_URI" --quiet --eval '
const flat = (v) => v instanceof Date ? v.toISOString().slice(0, 19).replace("T", " ")
: (v && v.constructor && v.constructor.name === "Long") ? String(v)
: v === undefined ? null : v;
// id 类字段字符串化:Mongo 存的是 number/Long 混型,canonical 契约要 string
// (CSV 源天然全字符串;JSON 导出须显式对齐,否则 patientExternalId 等校验失败)
const sid = (v) => (v === undefined || v === null || v === "") ? null : String(flat(v));
const q = { status: { $in: [3, 4] } };
if (process.env.COHORT === "1") {
// cohort 激活但文件读不到 → 硬失败(宁可让 .tmp 校验拒绝,不能静默退化成全量导出)
const ids = JSON.parse(require("fs").readFileSync("/cohort_ids.json", "utf-8"));
const both = (s) => { const n = Number(s); return Number.isFinite(n) && String(n) === s ? [s, n] : [s]; };
q.patientId = { $in: ids.flatMap(both) };
}
let first = true;
print("[");
db.med_emr_info.find(q).forEach((d) => {
const o = {
emr_sub_id: sid(d.emrSubId), emr_id: sid(d.emrId),
appointment_id: sid(d.appointmentId),
tenant_id: sid(d.tenantId), organization_id: sid(d.organizationId),
organization_name: flat(d.organizationName), // 诊所名(clinic_directory 派生 host.clinicNames)
patient_id: sid(d.patientId), user_id: sid(d.userId), user_name: flat(d.userName),
clinic_time: flat(d.clinicTime), visit_indicator: flat(d.visitIndicator), status: flat(d.status),
diag: d.diag ?? [], treat: d.treat ?? [], dispose: d.dispose ?? [], examine: d.examine ?? [],
// emr_record 自由文本位要字符串(fact 层 zod nullableString);数组原样给 split_json_array 拆行
dispose_text: (d.dispose ?? []).length ? JSON.stringify(d.dispose) : null,
examine_text: (d.examine ?? []).length ? JSON.stringify(d.examine) : null,
illness_desc: flat(d.illnessDesc), pre_illness: flat(d.preIllness), past_hist: flat(d.pastHist),
gen_cond: flat(d.genCond), auxi_exam: flat(d.auxiExam), doc_order: flat(d.docOrder),
created_gmt_at: flat(d.createdGmtAt), updated_gmt_at: flat(d.updatedGmtAt),
};
print((first ? "" : ",") + JSON.stringify(o)); first = false;
});
print("]");
' > med_emr_info.json.tmp
# 同款保护:解析成功且非空才替换(mongosh 连接失败会产出空/半截文件)
node -e "
const fs=require('fs');
let a; try { a = JSON.parse(fs.readFileSync('med_emr_info.json.tmp','utf-8')); } catch (e) {
fs.unlinkSync('med_emr_info.json.tmp');
console.error(' ✗ med_emr_info.json: 导出无效(库不可达?),保留原文件不覆盖'); process.exit(1);
}
if (!Array.isArray(a) || a.length === 0) {
fs.unlinkSync('med_emr_info.json.tmp');
console.error(' ✗ med_emr_info.json: 导出为空,保留原文件不覆盖'); process.exit(1);
}
fs.renameSync('med_emr_info.json.tmp','med_emr_info.json');
console.log(' med_emr_info.json:', a.length, 'docs');
"
echo "── 导出完成 ──"
ls -la *.csv *.json | awk '{print " " $NF, $5}'
...@@ -8,10 +8,10 @@ ...@@ -8,10 +8,10 @@
# ── 方案 B:合成"市场"集团 tenant;品牌降为 source_unit 命名空间 ── # ── 方案 B:合成"市场"集团 tenant;品牌降为 source_unit 命名空间 ──
# FRIDAY 是多品牌 SaaS(30+ 独立品牌 tenant_id,各含 1-8 诊所 organization_id)。 # FRIDAY 是多品牌 SaaS(30+ 独立品牌 tenant_id,各含 1-8 诊所 organization_id)。
# PAC tenant = 合成 friday-market(整个市场一个租户,便于 FRIDAY 作为运营方跨品牌召回); # PAC tenant = 合成 friday-market(整个市场一个租户,便于 FRIDAY 作为运营方跨品牌召回);
# 品牌(源 brand_id 列)→ patients.source_unit(集团内给 external_id 消歧:同号不同品牌=不同人); # 品牌(源 tenant_id 列,宿主原生名)→ patients.source_unit(集团内给 external_id 消歧:同号不同品牌=不同人);
# 诊所 organization_id → 后续 orgScope 隔离维度。 # 诊所 organization_id → 后续 orgScope 隔离维度。
# ⚠️ 隔离靠 orgScope/source_unit,非 tenant 边界 → 生产必须「禁止空 orgScope」。 # ⚠️ 隔离靠 orgScope/source_unit,非 tenant 边界 → 生产必须「禁止空 orgScope」。
# 若要品牌级硬隔离,改为 tenant_field: brand_id(每品牌一个 PAC tenant)。 # 若要品牌级硬隔离,改为 tenant_field: tenant_id + tenant_map(每品牌一个 PAC tenant)。
# #
# 跑法: # 跑法:
# pnpm sync -- --dir=./data/friday --dry-run # 看翻译效果,不写库 # pnpm sync -- --dir=./data/friday --dry-run # 看翻译效果,不写库
...@@ -22,15 +22,58 @@ display_name: FRIDAY SaaS(多品牌市场) ...@@ -22,15 +22,58 @@ display_name: FRIDAY SaaS(多品牌市场)
auto_sync: false auto_sync: false
tenant_id: friday-market # PAC 租户 = 合成市场根(静态) tenant_id: friday-market # PAC 租户 = 合成市场根(静态)
identity_namespace_field: brand_id # patients.source_unit ← 源品牌列(customer.tenant_id 导出别名) identity_namespace_field: tenant_id # patients.source_unit ← 源品牌列(宿主原生列名;与 PAC 租户概念的区分在 PAC 侧,宿主不改名)
amount_unit: yuan amount_unit: yuan
timezone: Asia/Shanghai timezone: Asia/Shanghai
# 诊所名字典来源:病历表自带 organization_name(宿主原生列)。
# `pnpm refresh-clinic-names -- --dir=./data/friday` 据此 DISTINCT 出 id→名,写 host.clinicNames;
# /auth/session 合并进 dictionary.clinics 下发 → 工作台显示诊所中文名而非 GUID。
clinic_directory:
table: med_emr_info
id_field: organization_id
name_field: organization_name
# ── 增量水位声明(2026-07 与 jvs-dw 统一;file 源放 manifest 顶层)──
# file 装载不消费水位(每轮全量装载,幂等去重兜底);每轮跑完与 jvs-dw 走同一段 finally
# 记账 cursor_after = run_start(sync_logs)。用途:delta 导出 WHERE 模板 / push 回放起点。
# 只列有 updated_gmt_at 的事件表;字典表(std_*)与 customer_contacts(无更新列)恒全量。
incremental:
per_query:
customer_basic_info: { cursor_column: updated_gmt_at }
appointment_base: { cursor_column: updated_gmt_at }
med_emr_info: { cursor_column: updated_gmt_at }
med_check: { cursor_column: updated_gmt_at }
patient_settlement: { cursor_column: updated_gmt_at }
patient_settlement_refund: { cursor_column: updated_gmt_at }
patient_settlement_spec_refund: { cursor_column: updated_gmt_at }
settlement_modes: { cursor_column: updated_gmt_at }
customer_treat_plan: { cursor_column: updated_gmt_at }
customer_treat_plan_item: { cursor_column: updated_gmt_at }
customer_referee_circle: { cursor_column: updated_gmt_at }
customer_consult: { cursor_column: updated_gmt_at }
tables: tables:
- { table: customer_basic_info, file: customer_basic_info.csv } # 患者主档(原始) - { table: customer_basic_info, file: customer_basic_info.csv } # 患者主档(原始)
- { table: customer_contacts, file: customer_contacts.csv } # 联系方式 1:N(原始) - { table: customer_contacts, file: customer_contacts.csv } # 联系方式 1:N(原始)
- { table: appointment_base, file: appointment_base.csv } # 预约(原始,全状态) - { table: appointment_base, file: appointment_base.csv } # 预约(原始,全状态)
# ── EMR 病历链(W FRIDAY-EMR:Mongo med_emr_info status∈{3,4} 正式病历;export.sh 平铺导出)──
# 数组字段(diag/treat/dispose/examine)导出保留原生数组(split_json_array 原生支持);
# dispose_text/examine_text 是同数据的 JSON 字符串形态(emr_record 自由文本位要 string)。
- { table: med_emr_info, file: med_emr_info.json } # 病历正文(Mongo,含 diag/treat 数组)
- { table: med_check, file: med_check.csv } # 影像档案(挂 emr_id 病历号)
- { table: std_check_class, file: std_check_class.csv } # 影像类型字典(class_code→class_name)
- { table: std_diag, file: std_diag.csv } # 诊断字典(linkCode→std_code;K 码用)
- { table: customer_treat_plan, file: customer_treat_plan.csv } # 治疗计划头(方案名/期望)
- { table: customer_treat_plan_item, file: customer_treat_plan_item.csv } # 治疗计划行(牙位/术式/价格)
- { table: patient_settlement, file: patient_settlement.csv } # 消费头单(status 1/3 且金额≥0)
- { table: patient_settlement_refund, file: patient_settlement_refund.csv } # 整单退费(status=4 或 3 负额)
- { table: patient_settlement_spec_refund, file: patient_settlement_spec_refund.csv } # 行级退费明细(is_refund=1)
- { table: settlement_modes, file: settlement_modes.csv } # 支付通道长表
- { table: customer_referee_circle, file: customer_referee_circle.csv } # 转介绍圈(患者-患者边)
- { table: customer_consult, file: customer_consult.csv } # 咨询(意向/未成交原因)
transforms: transforms:
# ── 患者:把 1:N 联系方式归约成 patient.phone 标量(无需源侧 join)── # ── 患者:把 1:N 联系方式归约成 patient.phone 标量(无需源侧 join)──
...@@ -48,6 +91,9 @@ transforms: ...@@ -48,6 +91,9 @@ transforms:
- { field: id, dir: asc } - { field: id, dir: asc }
select: select:
phone: contacts_tel # patient.phone ← 选中候选的 contacts_tel phone: contacts_tel # patient.phone ← 选中候选的 contacts_tel
# 该号与患者的关系码(host sys_dict 编码;实测 1=本人 78.7%(成人),儿童过半非本人号)。
# 先落原码(preferences.contactPhone,isSelf=码=='1');完整标签字典待 sys_dict 可读后补。
phone_relationship: relationship
# ── 预约:丢草稿 → 全部预约(拼 scheduledAt)+ 已到诊拆成接诊事件(→ encounter)── # ── 预约:丢草稿 → 全部预约(拼 scheduledAt)+ 已到诊拆成接诊事件(→ encounter)──
# 与 jvs-dw 同款设计:一个预约源喂两张 canonical(appointment / encounter)。 # 与 jvs-dw 同款设计:一个预约源喂两张 canonical(appointment / encounter)。
...@@ -73,7 +119,276 @@ transforms: ...@@ -73,7 +119,276 @@ transforms:
appointment_status: { in: ['4', '5', '6'] } appointment_status: { in: ['4', '5', '6'] }
in_time: { not_empty: true } in_time: { not_empty: true }
# ═══════════ EMR 病历链(同 jvs-dw 分化模式:一张病历源喂多 canonical)═══════════
# med_emr_info → emr_record(自由文本) + diagnosis(diag[] 拆行) + treatment_actual(treat[] 拆行)
# 锚点:临床事实 sourceEncounterExternalId=emr_sub_id(同诊聚合);emr 自身挂 appointment_id(56.5%)。
# ── E.1 病历自由文本行(emr.yaml 直接消费 med_emr_info,无需 project:
# assembler field_mapping 天然只取所需列,数组列不映射即不入 canonical)──
# ── E.2 diag[] 拆行 → 诊断候选 ──
- kind: split_json_array
input: med_emr_info
output: _diag_raw
array_field: diag
parent_keys:
emr_sub_id: emr_sub_id
emr_id: emr_id
tenant_id: tenant_id
organization_id: organization_id
patient_id: patient_id
user_id: user_id
user_name: user_name
clinic_time: clinic_time
created_gmt_at: created_gmt_at
updated_gmt_at: updated_gmt_at
element_fields:
value: value
tooth_position: toothPosition
link_code: linkCode
where:
value: { not_empty: true } # 只要求有诊断名;linkCode 可空(仅 0.3% 有)
# E.2.1 linkCode → std_diag 字典(diag_code UUID 全局唯一,LEFT JOIN 未命中 → std_code=null)
- kind: lookup
input: _diag_raw
output: _diag_raw # in-place 附加 std_code
from: std_diag
left_key: link_code
right_key: diag_code
select:
std_code: std_code
# E.2.2 归一 + K 码截取 + coalesce
# 规则(用户定):std_code K 开头 → 截 K 大类;非 K(DA0E.50 等 ICD-11)/join 不上/linkCode 空
# → 自由文本处理:substring 产物("DA0"等)不在共享 K 码字典 → 落 _default → code=null,name 保留。
# linkCode 空(99.7%)→ coalesce 落到归一中文名 → 共享 diagnosis-kcode 字典救码。
- kind: derive
input: _diag_raw
output: _diag_norm
fields:
value_norm:
op: normalize
from: value
std_code_k:
op: substring
from: std_code
start: 0
end: 3
- kind: derive
input: _diag_norm
output: diagnosis_rows
fields:
diag_code_src:
op: coalesce
from: ['std_code_k', 'value_norm']
diag_external_id:
op: concat
parts: ['${emr_sub_id}', '|', '${std_code_k}', '|', '${value_norm}', '|', '${tooth_position}']
# ── E.3 treat[] 拆行 → 本次治疗(actual;用户确认 treat=本次治疗,病历无计划字段)──
# 注:treat 填充仅 25%(dispose 98% 是处置叙述,整段进 emr_record.disposal 不丢);
# 若后续漏召,可加 dispose keyword 路由补 actual(Phase 2 评估)。
- kind: split_json_array
input: med_emr_info
output: _treat_raw
array_field: treat
parent_keys:
emr_sub_id: emr_sub_id
emr_id: emr_id
tenant_id: tenant_id
organization_id: organization_id
patient_id: patient_id
user_id: user_id
user_name: user_name
clinic_time: clinic_time
created_gmt_at: created_gmt_at
updated_gmt_at: updated_gmt_at
element_fields:
value: value
tooth_position: toothPosition
where:
value: { not_empty: true }
# E.3.1 归一(新列 value_norm;external_id 仍用原始 value,保持与已摄入数据的幂等键连续)
- kind: derive
input: _treat_raw
output: _treat_raw
fields:
value_norm:
op: normalize
from: value
# E.3.2 三路分流(同 jvs-dw C.3 语义):建议→recommendation / 流程复查→review / 其余→actual
# 实测(2026-07-18 全量):建议/推荐开头 153 行、复查类 60 行(其中拆线 50——不入 review,
# 按 jvs-dw 2026-06-11 一线判例 拆线=术后护理=外科治疗动作,走 default→actual→enum 拆线→surgical)
- kind: route_by_pattern
input: _treat_raw
field: value_norm
routes:
- output: _rec_raw
when:
starts_with: ['建议', '推荐']
# 流程/复查/行政词表(取自 jvs-dw review_terms 核心,normalize 后 ASCII 形态)
- output: _review_raw
when:
equals:
- 常规复查
- 复查
- 定期复查
- 检查
- 初诊检查
- 正畸复查
- 正畸检查
- 正畸复诊
- 种植复查
- 牙周复查
- 保持器复诊
- 暂观
- 观察
- 观察随访
- 随诊观察
- 随访观察
- 无治疗
- 重复
- 取资料
- 转诊
- 正畸咨询
- 正畸会诊
- 方案沟通
- 沟通治疗方案
- 听方案
- 缴费
- 未拍片
- output: _actual_raw
when:
default: true
# E.3.3 真治疗 → actual
- kind: derive
input: _actual_raw
output: treatment_actual_rows
fields:
category_raw:
op: coalesce
from: ['value_norm']
treat_external_id:
op: concat
parts: ['${emr_sub_id}', '|treat|', '${value}', '|', '${tooth_position}']
# E.3.4 流程复查 → review(category 固定哨兵,assembler 翻成 review)
- kind: derive
input: _review_raw
output: treatment_review_rows
fields:
category_raw:
op: concat
parts: ['_review_sentinel']
treat_external_id:
op: concat
parts: ['${emr_sub_id}', '|treat|', '${value}', '|', '${tooth_position}']
# E.3.5 建议/推荐 → recommendation(共享 recommendation-code 字典翻召回码)
- kind: derive
input: _rec_raw
output: recommendation_rows
fields:
rec_external_id:
op: concat
parts: ['${emr_sub_id}', '|rec|', '${value}', '|', '${tooth_position}']
# ── E.4 影像:med_check + 类型字典 → class_name(modality 经共享字典翻)──
- kind: lookup
input: med_check
output: image_rows
from: std_check_class
left_key: class_code
right_key: class_code
select:
class_name: class_name
# ── E.5 治疗计划:行表 join 头表拿 诊所/方案名 → planned 候选 ──
- kind: lookup
input: customer_treat_plan_item
output: _plan_item_joined
from: customer_treat_plan
left_key: treat_plan_id
right_key: id
select:
organization_id: organization_id
plan_name: plan_name
# 头表 lookup 未命中 / 头表无诊所 → 计划行无法归属诊所,显式丢弃(canonical clinicId 必填;
# 留着只会变 failed 噪音。测试库实测 75/187 行如此)
- kind: filter
input: _plan_item_joined
output: _plan_item_with_org
where:
organization_id: { not_empty: true }
- kind: derive
input: _plan_item_with_org
output: treatment_planned_rows
fields:
mode_norm:
op: normalize
from: mode_name
# ═══════════ 结算链(消费/退费;对齐 jvs-dw payment/refund 契约)═══════════
# status 语义(实测):1=已结算 3=含退费行的单(头单仍是原始消费) 4=整单反向冲减。
# 消费 = status∈{1,3};退费双轨 = ①status=4 整单(ref_settlement_id 挂原单)②spec is_refund=1 行级。
# LTV = Σpayment − Σrefund 自然对账;不从 settlement 反推治疗(财务≠临床,jvs-dw 已验证的教训)。
# ── S.1 消费:status 1/3 → 主导支付通道 lookup(金额最大者)──
- kind: filter
input: patient_settlement
output: _pay_hdr
where:
status: { in: ['1', '3'] }
- kind: lookup
input: _pay_hdr
output: payment_rows
from: settlement_modes
left_key: uuid
right_key: settlement_id
order_by:
- { field: money, dir: desc }
select:
method: modes_name # 主导支付通道(现金/微信/刷卡/医保…干净中文名,原样入 fact)
# ── S.2 退费①:整单冲减(status=4 或 status=3 负金额;导出侧 WHERE 已切分,assembler 直读
# patient_settlement_refund 表;金额负值由 refund parser Math.abs 归一)──
# ═══════════ 患者关系链(customer_referee_circle → patient_relations 独立表)═══════════
# 宿主 referee_relationship 是字典 id(DB 无字典表,疑似代码硬编码)。解码 = 统计推断
# (2026-07-19,互逆对+年龄差+性别三重证据,详见 patient_relation.yaml)——需要对方性别
# 参与消歧(码 3=父母,father/mother 按对方性别拆)→ lookup 对方 sex 后拼 "码|性别" 键。
- kind: lookup
input: customer_referee_circle
output: _referee_with_sex
from: customer_basic_info
left_key: referee_patient_id
right_key: id
select:
referee_sex: sex
- kind: derive
input: _referee_with_sex
output: patient_relation_rows
fields:
rel_sex_key:
op: concat
parts: ['${referee_relationship}', '|', '${referee_sex}']
assemblers: assemblers:
- { file: assemblers/patient.yaml } - { file: assemblers/patient.yaml }
- { file: assemblers/appointment.yaml } - { file: assemblers/appointment.yaml }
- { file: assemblers/encounter.yaml } - { file: assemblers/encounter.yaml }
- { file: assemblers/emr.yaml }
- { file: assemblers/diagnosis.yaml }
- { file: assemblers/treatment_actual.yaml }
- { file: assemblers/treatment_planned.yaml }
- { file: assemblers/image.yaml }
- { file: assemblers/treatment_review.yaml }
- { file: assemblers/recommendation.yaml }
- { file: assemblers/payment.yaml }
- { file: assemblers/refund_full.yaml }
- { file: assemblers/refund_item.yaml }
- { file: assemblers/patient_relation.yaml }
- { file: assemblers/consult.yaml }
...@@ -34,257 +34,18 @@ field_mapping: ...@@ -34,257 +34,18 @@ field_mapping:
# ① ICD K 大类直通(已 substring 的 stdCode);② 中文标准术语 → K 码白名单(草案,⚠️ 待牙医 review) # ① ICD K 大类直通(已 substring 的 stdCode);② 中文标准术语 → K 码白名单(草案,⚠️ 待牙医 review)
# 实测 ~98% 诊断没填 stdCode,只给标准中文名,这张表把高频高置信术语救回 K 码。 # 实测 ~98% 诊断没填 stdCode,只给标准中文名,这张表把高频高置信术语救回 K 码。
# 不在白名单的(复合 / 不确定 / 流程性,如"龋(16.继发龋)"、"种植术后")→ _default → code=null。 # 不在白名单的(复合 / 不确定 / 流程性,如"龋(16.继发龋)"、"种植术后")→ _default → code=null。
enum_mapping:
code:
# K 大类直通
K00: K00
K01: K01
K02: K02
K03: K03
K04: K04
K05: K05
K06: K06
K07: K07
K08: K08
K09: K09
K10: K10
K11: K11
K12: K12
K13: K13
K14: K14
# ── 中文术语 → K 码(高置信草案,trim 已去尾部标点)──
# K05 牙周 / 牙龈
慢性牙龈炎: K05
牙龈炎: K05
慢性龈炎: K05
龈炎: K05
菌斑性龈炎: K05
菌斑性牙龈炎: K05 # W4 末补,DW top 200 漏配("牙龈"vs"龈"长写)
慢性牙周炎: K05
牙周炎: K05
重度牙周炎: K05
中度牙周炎: K05
侵袭性牙周炎: K05
# K02 龋
: K02
龋齿: K02
龋坏: K02
浅龋: K02
中龋: K02
深龋: K02
继发龋: K02
# ⚠️ 深窝沟 故意不映射(落 _default → code=null,不召、病历照原文显示):
# 它是龋【风险】(窝沟深易蛀,无龋洞),对症是窝沟封闭(preventive 预防),
# 不属于"应治未治(龋洞没补)"召回范畴。归任何疾病码都会误召(预防治疗满足不了"已治"闸)。
# K04 牙髓 / 根尖周
牙髓炎: K04
慢性牙髓炎: K04
急性牙髓炎: K04
根尖周炎: K04
慢性根尖周炎: K04
慢性根尖炎: K04
急性根尖周炎: K04
牙髓坏死: K04
# K03 牙体硬组织其他(楔缺 / 缺损 / 残根冠)
楔状缺损: K03
楔形缺损: K03
楔缺: K03
牙体缺损: K03
残根: K03
残冠: K03
# K01 阻生 / 埋伏
阻生齿: K01
阻生牙: K01
阻生: K01
埋伏牙: K01
# K07 牙颌面异常 / 错颌
错颌畸形: K07
牙列不齐: K07
牙列拥挤: K07
安氏I类错颌畸形: K07
安氏II类错颌畸形: K07
安氏III类错颌畸形: K07
# K08 缺牙 / 缺失
牙缺失: K08
缺失: K08
缺失牙: K08
牙列缺损: K08
牙列缺失: K08
后天性牙齿缺失: K08
全口牙列缺失: K08
上颌牙列缺损: K08
下颌牙列缺损: K08
废用牙: K08 # 临床功能性缺失
无功能牙: K08
# ── W3 末扩 K00-K08 全覆盖(源 DW 数据驱动,见 dw-data-source-issues #15)── # ── 字典分层(2026-07 重构):临床通用层抽到 _shared/dict,本地只留 jvs-dw 判例 ──
# 合并语义:本地 enum 同 key 覆盖共享;本地 keyword 规则优先;_default 本地声明。
dict_includes:
- ../../_shared/dict/diagnosis-kcode.yaml
# K00 牙发育 / 萌出障碍 — 只收"真病种",不收"乳牙列 / 混合牙列"等正常生理态(走 _default 不入诊断) enum_mapping:
乳牙滞留: K00 code:
滞留乳牙: K00
乳牙早失: K00
乳牙松动: K00 # 替换期非自然脱落
替换期乳牙松动: K00
松动乳牙: K00
多生牙: K00
额外牙: K00
埋伏多生牙: K00
根方多生牙: K00
间多生牙: K00
腭侧多生牙: K00
前牙区埋伏多生牙: K00
先天缺失: K00
先天缺牙: K00
# ⭐ "牙齿缺少" 实际是 host 通用术语,大多用于成人后天缺失(临床路径=种植/修复,跟 K08 完全一致) # ⭐ "牙齿缺少" 实际是 host 通用术语,大多用于成人后天缺失(临床路径=种植/修复,跟 K08 完全一致)
# 之前误归 K00 导致陆伟根(62 岁)等成人患者出现"先天/萌出处置"链 — 不合临床 # 之前误归 K00 导致陆伟根(62 岁)等成人患者出现"先天/萌出处置"链 — 不合临床
# 改归 K08 — 跟 "牙缺失"/"后天性牙齿缺失" 同处理 # 改归 K08 — 跟 "牙缺失"/"后天性牙齿缺失" 同处理。【宿主判例:jvs-dw 用词语义,不入共享层】
牙齿缺少: 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
# ⚠️ 脱矿 / 牙本质敏感 故意不映射(落 _default → code=null):
# 脱矿=早期龋【风险】(对症涂氟,预防)、牙本质敏感=【症状】(对症脱敏,非修复缺损)。
# 跟已 null 的"牙釉质脱矿/釉质脱矿"口径统一,均不作"应治未治"召回信号。
# K05 牙周 / 牙龈(补 host 真实高频用词) # 兜底:翻不出 → 共享层 keyword_mapping 含词兜底;再不中 → 空 → code=null(不丢 fact)
牙周病: 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
# ⚠️ 食物嵌塞 不映射(【症状】,对症调牙合/牙周处置,非"应治未治"病灶)→ 落 _default code=null
附着丧失: 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
# 兜底:翻不出 → 交下面 keyword_mapping 含词兜底;再不中 → 空 → code=null(不丢 fact)
_default: "" _default: ""
# ─────────────────────────────────────────────────────────
# 含词兜底 —— 精确 enum 未命中时,按序含词匹配 → K 码。
# 治本长尾:诊断常是复合/变体("埋伏阻生;"、"前倾位阻生,颌面龋"、"牙齿缺失"、"窝沟龋"…),
# 精确白名单永远漏(沈建锋 28/38 阻生智齿 → null → 漏召)。引擎按 canonicalKey 通用。
# 顺序=优先级:阻生/缺失/牙髓 在 龋 之前 → 复合"阻生+龋"判 K01(智齿该评估拔除,非补)。
# ⚠️ 龋:any=[龋] + none=[风险,可疑,脱矿] —— 排除"高龋风险/可疑龋"(=风险,非龋洞,同深窝沟不召);
# 深窝沟/食物嵌塞/牙本质敏感 不含上述关键词 → 仍落 null,故意不召(无回归)。
keyword_mapping:
code:
- { value: K08, any: [缺失, 缺牙, 牙列缺损] }
- { value: K01, any: [阻生, 埋伏, 智齿] }
- { value: K09, any: [颌骨囊肿, 含牙囊肿, 根尖囊肿] }
- { value: K04, any: [牙髓炎, 牙髓坏死, 根尖周, 根尖炎, 牙髓] }
- { value: K03, any: [楔状, 残根, 残冠, 隐裂, 牙体缺损, 牙折] }
- { value: K05, any: [牙周炎, 龈炎, 牙周袋] }
- { value: K02, any: [], none: [风险, 可疑, 脱矿] }
# 牙磨耗 → 牙体损伤 K03(过度/中度/重度/牙齿磨耗… 一次收全 host 变体,补 enum 精确词漏的)。
# 放 K02/K05 之后:复合名"龋+磨耗""磨耗+牙周炎"让位给更主要的龋/牙周。
# none 排:生理性/轻度磨耗(正常生理,无需修复)+ 活动义齿/人工牙磨耗(假牙磨损=修复体重做,非牙体)。
- { value: K03, any: [磨耗], none: [生理, 轻度, 义齿, 人工牙] }
# K07 错颌/正畸族 含词兜底 —— 精确白名单总漏变体(错牙合/开颌/反颌/深覆/间隙/拥挤/安氏…)。
# 放最后:K08(缺牙间隙)/K09(颌骨囊肿)等已在前面先吃;none 再兜一层防"缺牙间隙"误判正畸。
# 注:反合/开合/对刃合 = 反牙合/开牙合 的"合"简写(host 高频:反合 120+),crossbite/openbite 都是正畸。
- { value: K07, any: [错颌, 错合, 错牙合, 锁颌, 偏颌, 开颌, 反颌, 开牙合, 反牙合, 反合, 开合, 对刃合, 深覆, , 前突, 拥挤, 安氏, 间隙], none: [缺牙, 缺失, 牙列缺损, 嵌塞] }
...@@ -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: [萌出, 乳牙滞留, 多生牙] }
...@@ -47,190 +47,65 @@ field_mapping: ...@@ -47,190 +47,65 @@ field_mapping:
# fact_emr_treatment_out ARRAY JOIN JSONExtractArrayRaw(coalesce(treat_plan,'[]')) AS t # fact_emr_treatment_out ARRAY JOIN JSONExtractArrayRaw(coalesce(treat_plan,'[]')) AS t
# WHERE brand IN ('瑞泰','瑞尔') GROUP BY 1 ORDER BY 2 DESC LIMIT 200` # WHERE brand IN ('瑞泰','瑞尔') GROUP BY 1 ORDER BY 2 DESC LIMIT 200`
# 看新出现的高频 treat_name,补到对应 category。 # 看新出现的高频 treat_name,补到对应 category。
# ── 字典分层(2026-07 重构):临床通用层在 _shared/dict,本地只留 jvs-dw 特有词 ──
# 共享:treatStages 全量 / category 干净临床核心 / keyword+strip(actual 语义变体)。
# 本地:价格表项名·标点变体·长句(host 数据痕迹) + 判例词(拆线→surgical 绑本 host route
# 分流语境;全景片/CBCT 等影像项计作 preventive 是本 host 数据实用主义)。
dict_includes:
- ../../_shared/dict/treat-stages.yaml
- ../../_shared/dict/treatment-category-core.yaml
- ../../_shared/dict/treatment-category-actual-rules.yaml
enum_mapping: enum_mapping:
# ── treat_stages (W4 末加) — host step 词 → PAC 标准 enum ──
# 字段名 treatStages 是 PAC 标准(英文 camelCase),内容也是 PAC enum(snake_case)
# host 字面值(开髓/根备/根充等)只在这里出现一次,翻译完进 fact 就是 PAC 标准
# 其他宿主接入只改 yaml 这一段,代码不动
treatStages: 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 数组减一元素) # 长尾兜底:host stage 词字典外 → 空(不进 PAC,treat_stages 数组减一元素)
_default: '' _default: ''
category: category:
# ── 牙周(periodontic) # ── 牙周(periodontic)
龈上洁治: periodontic
"龈上洁治。": periodontic "龈上洁治。": periodontic
龈上洁治术: periodontic
"龈上洁治术。": periodontic "龈上洁治术。": periodontic
"龈上洁治术/预防性洁治/洁牙/洗牙": periodontic "龈上洁治术/预防性洁治/洁牙/洗牙": periodontic
龈上洁治+牙周治疗: periodontic "龈上洁治+牙周治疗": periodontic
全口龈上洁治: periodontic
# 注:W4 末加 transforms.normalize op 后,DW 中文标点(、,();)已统一到 ASCII,
# 字典只需 ASCII 形态(英文逗号 + 英括号);中文标点条目已被 normalize 折叠到这些
"全口龈上洁治,抛光。": periodontic "全口龈上洁治,抛光。": periodontic
"全口龈上洁治,抛光": periodontic "全口龈上洁治,抛光": periodontic
"全口龈上洁治。": periodontic "全口龈上洁治。": periodontic
"全口龈上洁治术。": periodontic "全口龈上洁治术。": periodontic
全口洁治: periodontic
"全口洁治。": periodontic "全口洁治。": periodontic
全口洁牙: periodontic
全口超声洁治: periodontic
全口超声波洁治: periodontic
全口龈上超声洁治: periodontic
定期全口洁治: periodontic
"定期全口洁治\r\n": periodontic "定期全口洁治\r\n": periodontic
"全口洁治\r\n": periodontic "全口洁治\r\n": periodontic
定期洁牙: periodontic
洁牙: periodontic
洁治: periodontic
"洁治。": periodontic "洁治。": periodontic
洗牙: periodontic
洁治 OHI: periodontic
"洁治+OHI": periodontic "洁治+OHI": periodontic
"龈上洁治;细洁": periodontic "龈上洁治;细洁": periodontic
"龈上洁治+细洁": periodontic "龈上洁治+细洁": periodontic
"龈上洁治+龈下细洁": periodontic "龈上洁治+龈下细洁": periodontic
龈下细洁: periodontic
龈上超声洁治: periodontic
牙周基础治疗: periodontic
牙周系统治疗: periodontic
"牙周系统治疗。": periodontic "牙周系统治疗。": periodontic
"OHI\r\n牙周系统治疗": periodontic "OHI\r\n牙周系统治疗": periodontic
牙周治疗: periodontic
牙周维护: periodontic
牙周刮治: periodontic
牙周刮治术: periodontic
龈下刮治: periodontic
龈下刮治术: periodontic
"龈下刮治及根面平整术(>5mm牙周袋)/牙周刮治术/超声龈下清创术": periodontic "龈下刮治及根面平整术(>5mm牙周袋)/牙周刮治术/超声龈下清创术": periodontic
"龈下刮治及根面平整术(<5mm牙周袋)/牙周刮治术/超声龈下清创术": periodontic "龈下刮治及根面平整术(<5mm牙周袋)/牙周刮治术/超声龈下清创术": periodontic
根面平整术: periodontic
PMTC: periodontic
"PMTC+全口涂氟": periodontic "PMTC+全口涂氟": periodontic
# W4 末补 DW top 200 漏配 "龈上洁治术/预防性洁治/洗牙/洁牙": periodontic
牙周序列治疗: periodontic
系统性牙周治疗: periodontic
"龈上洁治术/预防性洁治/洗牙/洁牙": periodontic # yaml 早期写的是"/洁牙/洗牙"顺序,这里补常见的反顺序
"定期洁牙,牙周系统治疗": periodontic "定期洁牙,牙周系统治疗": periodontic
"全口洁治+OHI": periodontic "全口洁治+OHI": periodontic
"龈上洁治术/预防性洁治/洁牙/洗牙全口龈上洁治": periodontic # host 行内炸行残留,本质洁牙 "龈上洁治术/预防性洁治/洁牙/洗牙全口龈上洁治": periodontic
# ── 充填修复(restorative) # ── 充填修复(restorative)
充填: restorative
"充填。": restorative "充填。": restorative
充填治疗: restorative
"充填治疗(乳牙单面洞)": restorative "充填治疗(乳牙单面洞)": restorative
充填术: restorative
树脂充填: restorative
光固化树脂充填: restorative
非美学区树脂充填: restorative
美学区简单树脂充填: restorative
"垫底+充填": restorative "垫底+充填": restorative
重新充填: restorative
预防性充填: restorative
树脂充填术: restorative
嵌体修复: restorative
嵌体: restorative
戴嵌体: restorative
龋齿充填: restorative
# W4 末补 — 玻璃离子(GIC)类充填(临床: 等价于树脂充填,主要用于乳牙/年轻恒牙/暂封)
玻璃离子充填: restorative
玻璃离子充填术: restorative
"玻璃离子充填术。": restorative "玻璃离子充填术。": restorative
"玻璃离子充填。": restorative "玻璃离子充填。": restorative
进口玻璃离子充填: restorative
"进口玻璃离子充填术。": restorative "进口玻璃离子充填术。": restorative
"充填治疗(玻璃离子)": restorative "充填治疗(玻璃离子)": restorative
玻璃离子暂封: restorative
玻璃离子预充填: restorative
# 其他高频补全
充填或嵌体修复: restorative
"充填治疗(乳牙复面洞)": restorative "充填治疗(乳牙复面洞)": restorative
"恒牙充填治疗(儿科)": restorative "恒牙充填治疗(儿科)": restorative
高嵌体修复: restorative # ── 牙髓/根管(endodontic)
预防性树脂充填: restorative
# ── 牙髓 / 根管(endodontic)
根管治疗: endodontic
"根管治疗(磨牙)": endodontic "根管治疗(磨牙)": endodontic
"根管治疗(前牙及双尖牙单根管)": endodontic "根管治疗(前牙及双尖牙单根管)": endodontic
"根管治疗(前牙及双尖牙多根管/显微根管)": endodontic "根管治疗(前牙及双尖牙多根管/显微根管)": endodontic
"根管治疗+冠修复": endodontic "根管治疗+冠修复": endodontic
"根管治疗+充填": endodontic "根管治疗+充填": endodontic
根管再治疗: endodontic
根管预备: endodontic
根管充填: endodontic
根充: endodontic
RCT: endodontic
牙髓治疗: endodontic
根尖诱导成形术: endodontic
拔髓: endodontic
干髓术: endodontic
盖髓术: endodontic
间接盖髓: endodontic
直接盖髓: endodontic
MTA盖髓: endodontic
冲洗上药: endodontic
# W4 末补
根管治疗后冠修复: endodontic
"RCT+冠修复": endodontic "RCT+冠修复": endodontic
# W4 末补 — pulpotomy 类(VPT 终末术式,等价于完整 RCT;chain-composer 走 terminalSteps 走闭环)
# 已在前面列出的 干髓术 / 盖髓术 / 直接盖髓 / 间接盖髓 / MTA盖髓 / 拔髓 / 开髓 / 牙髓治疗 不再重复
活髓切断术: endodontic
部分活髓切断术: endodontic
部分牙髓切断术: endodontic
"活髓切断术+预成冠修复": endodontic "活髓切断术+预成冠修复": endodontic
"活髓切断术+预成冠": endodontic "活髓切断术+预成冠": endodontic
"活髓切断+预成冠修复": endodontic "活髓切断+预成冠修复": endodontic
...@@ -238,134 +113,48 @@ enum_mapping: ...@@ -238,134 +113,48 @@ enum_mapping:
"活髓切断术+全瓷冠冠修复": 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 牙髓血运重建: endodontic
# 牙髓相关其他术式(中间步骤,subtype 词根 fallback,无终末效力)
开髓引流: 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
"根管治疗术(乳前牙直接拔髓多步法)": endodontic "根管治疗术(乳前牙直接拔髓多步法)": endodontic
"根管治疗(封失活剂)": endodontic "根管治疗(封失活剂)": endodontic
复杂根管治疗: endodontic
# ── 种植(implant) # ── 种植(implant)
种植: implant
种植手术: implant
种植体植入: implant
种植修复: implant
种植戴牙: implant
种植一期: implant
种植二期: implant
种植三期: implant
种植耗材: implant
种植基台: implant
种植取模: implant
种植拆线: implant
# 光杆"拆线"= 术后护理(外科动作);此前被 route 分流到 review 不算治疗 →
# 术后复诊日 EMR 复述缺失诊断时,同日拆线无法证明"已启动"→ 误召(一线反馈①)
拆线: surgical
"种植冠修复(非美学区单颗)": implant "种植冠修复(非美学区单颗)": implant
"简单种植术(非美学区,单颗)": implant "简单种植术(非美学区,单颗)": implant
"复杂种植术(非美学区,连续缺失/多颗)": implant "复杂种植术(非美学区,连续缺失/多颗)": implant
"复杂种植术(非美学区,伴同期上颌窦底内提升/同期GBR术)": implant "复杂种植术(非美学区,伴同期上颌窦底内提升/同期GBR术)": implant
"复杂种植术(同期上颌窦底外提升术)": implant "复杂种植术(同期上颌窦底外提升术)": implant
"复杂种植术(美学区连续2颗以上或伴骨量不足及GBR术)": implant "复杂种植术(美学区连续2颗以上或伴骨量不足及GBR术)": implant
即刻种植术: implant
拔除后种植修复: implant
延期种植术: implant
"延期种植术(美学区,单颗)": implant "延期种植术(美学区,单颗)": implant
# W4 末补 # ── 修复(冠/桥/义齿/桩核)(prosthodontic)
拔除后种植: implant
# ── 修复(prostho)— 冠 / 桥 / 义齿 / 桩核
冠修复: prosthodontic
"冠修复(非美学区)": prosthodontic "冠修复(非美学区)": prosthodontic
"冠修复(简单美学区)": prosthodontic "冠修复(简单美学区)": prosthodontic
全冠修复: prosthodontic
牙冠修复: prosthodontic
重新冠修复: prosthodontic
戴冠: prosthodontic
粘冠: prosthodontic
拆冠: prosthodontic
戴牙: prosthodontic
桩核: prosthodontic
桩核冠: prosthodontic
固定修复: prosthodontic
活动修复: prosthodontic
重新修复: prosthodontic
修复: prosthodontic
预成冠修复: prosthodontic
牙体预备: prosthodontic
取模: prosthodontic 取模: prosthodontic
调合: prosthodontic 调合: prosthodontic
调颌: prosthodontic 调颌: prosthodontic
种植上部修复: prosthodontic # ── 外科(拔除)(surgical)
种植体上部修复: prosthodontic 拆线: surgical
# W4 末补
桩冠修复: prosthodontic
# ── 外科(surgical)— 拔除 / 智齿 / 残根残冠
拔除: surgical
"拔除。": surgical "拔除。": surgical
拔除术: surgical
拔牙: surgical
牙拔除术: surgical
"牙拔除术(松动乳牙)": surgical "牙拔除术(松动乳牙)": surgical
简单牙拔除术: surgical
松动恒牙拔除术: surgical
松动乳牙拔除术: surgical
择期拔除: surgical
阻生齿拔除术: surgical
阻生牙拔除: surgical
"智齿拔除术(正位)": surgical "智齿拔除术(正位)": surgical
"智齿拔除术(复杂)": surgical "智齿拔除术(复杂)": surgical
残根拔除术: surgical
"残根(残冠)拔除术": surgical "残根(残冠)拔除术": surgical
"正畸牙拔除术(正位)": surgical "正畸牙拔除术(正位)": surgical
# ── 正畸(orthodontic) # ── 正畸(orthodontic)
正畸: orthodontic
正畸治疗: orthodontic
隐形正畸: orthodontic
固定正畸: orthodontic
早期矫治: orthodontic
"合垫/预防性矫治/功能矫治/早期阻断性矫治": orthodontic "合垫/预防性矫治/功能矫治/早期阻断性矫治": orthodontic
"简单前牙排齐/安氏I类非拔牙矫治": orthodontic "简单前牙排齐/安氏I类非拔牙矫治": orthodontic
"安氏I类拔牙矫治(轻中度支抗)": orthodontic "安氏I类拔牙矫治(轻中度支抗)": orthodontic
...@@ -374,101 +163,27 @@ enum_mapping: ...@@ -374,101 +163,27 @@ enum_mapping:
"安氏II类拔牙矫治(强支抗/种植支抗)": orthodontic "安氏II类拔牙矫治(强支抗/种植支抗)": orthodontic
"安氏III类非拔牙矫治-涉及后牙咬合关系调整": orthodontic "安氏III类非拔牙矫治-涉及后牙咬合关系调整": orthodontic
"安氏III类拔牙矫治(轻中度支抗)": orthodontic "安氏III类拔牙矫治(轻中度支抗)": orthodontic
保持器: orthodontic
正畸保持器: orthodontic
正畸保持: orthodontic
正畸矫治器: orthodontic
正畸辅助治疗: orthodontic
正畸加力: orthodontic
"正畸加力。": orthodontic "正畸加力。": orthodontic
加力: orthodontic
粘附件: orthodontic
粘接附件: orthodontic
"粘接全口附件。": orthodontic "粘接全口附件。": orthodontic
重粘托槽: orthodontic
"重粘托槽。": orthodontic "重粘托槽。": orthodontic
# W4 末补 "去除矫正器,配戴保持器保持现有咬合关系": orthodontic
更换新矫治器: orthodontic
粘接上半口矫治器: orthodontic
粘接全口附件: orthodontic
精调粘接附件: orthodontic
发放新矫治器: orthodontic
"去除矫正器,配戴保持器保持现有咬合关系": orthodontic # 长句结尾去保持器
精细调整: orthodontic
"精细调整。": orthodontic "精细调整。": orthodontic
"精调粘接附件。": orthodontic "精调粘接附件。": orthodontic
发放矫治器: orthodontic
"发放新矫治器。": orthodontic "发放新矫治器。": orthodontic
"去除矫正器,配戴保持器保持现有咬合关系。": orthodontic "去除矫正器,配戴保持器保持现有咬合关系。": orthodontic
# ── 预防(preventive) # ── 预防(preventive)
涂氟: preventive
全口涂氟: preventive
"牙面清洁,涂氟": preventive "牙面清洁,涂氟": preventive
窝沟封闭: preventive
"窝沟封闭。": preventive "窝沟封闭。": preventive
窝沟封闭术: preventive
OHI: preventive
"OHI,涂氟": preventive "OHI,涂氟": preventive
口腔卫生宣教: preventive
口腔检查: preventive 口腔检查: preventive
全口检查: preventive 全口检查: preventive
常规检查: preventive 常规检查: preventive
脱敏: preventive
抛光: preventive
全景片: preventive 全景片: preventive
CBCT: preventive CBCT: preventive
曲面断层: preventive 曲面断层: preventive
根尖片: preventive 根尖片: preventive
# ── 美学(cosmetic)
美白: cosmetic
冷光美白: cosmetic
牙齿美白: cosmetic
牙位图美白: cosmetic
贴面: cosmetic
贴面修复: cosmetic
# 长尾兜底:未命中 → category='',parser 跳过该 fact(silently warn) # 长尾兜底:未命中 → category='',parser 跳过该 fact(silently warn)
# 故意不纳入(已被 transforms.route_by_pattern 分流走): # 故意不纳入(已被 transforms.route_by_pattern 分流走):
# 常规复查 / 复查 / 定期复查 / 检查(无具体内容) / 暂观 / 观察 / 无治疗 / 重复 / 取资料 / 拆线 # 常规复查 / 复查 / 定期复查 / 检查(无具体内容) / 暂观 / 观察 / 无治疗 / 重复 / 取资料 / 拆线
# 建议XX / 推荐XX(走 recommendation_record) # 建议XX / 推荐XX(走 recommendation_record)
_default: "" _default: ""
# ─────────────────────────────────────────────────────────
# 关键词分类兜底 —— 精确 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: [必要时, 如需, 择期, 建议, 推荐, 考虑, ] # 若…=条件从句(若牙面脱矿,建议充填),非本次实际治疗
...@@ -43,178 +43,64 @@ field_mapping: ...@@ -43,178 +43,64 @@ field_mapping:
# #
# 维护:跑 SQL `SELECT DISTINCT treatName, count() FROM ... GROUP BY 1 ORDER BY 2 DESC LIMIT 200` # 维护:跑 SQL `SELECT DISTINCT treatName, count() FROM ... GROUP BY 1 ORDER BY 2 DESC LIMIT 200`
# 看新出现的高频项,补到本表。 # 看新出现的高频项,补到本表。
# ── 字典分层(2026-07 重构):同 treatment_actual,keyword 用 planned 语义变体 ──
# 本地残差含 normalize 前的中文标点死条目(输入已归一不会再命中;行为保真原样保留,
# 清理属行为无关但需单独验证,另行提交)。
dict_includes:
- ../../_shared/dict/treat-stages.yaml
- ../../_shared/dict/treatment-category-core.yaml
- ../../_shared/dict/treatment-category-planned-rules.yaml
enum_mapping: enum_mapping:
# treat_stages 跟 treatment_actual.yaml 完全相同(W4 末加)
treatStages: 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
_default: '' _default: ''
category: category:
# ── 牙周(periodontic)— 龈上洁治 / 牙周治疗 各种写法 # ── 牙周(periodontic)
龈上洁治: periodontic
"龈上洁治。": periodontic "龈上洁治。": periodontic
龈上洁治术: periodontic
"龈上洁治术。": periodontic "龈上洁治术。": periodontic
"龈上洁治术/预防性洁治/洁牙/洗牙": periodontic "龈上洁治术/预防性洁治/洁牙/洗牙": periodontic
龈上洁治+牙周治疗: periodontic "龈上洁治+牙周治疗": periodontic
全口龈上洁治: periodontic
"全口龈上洁治、抛光。": periodontic "全口龈上洁治、抛光。": periodontic
"全口龈上洁治,抛光。": periodontic "全口龈上洁治,抛光。": periodontic
"全口龈上洁治,抛光": periodontic "全口龈上洁治,抛光": periodontic
"全口龈上洁治。": periodontic "全口龈上洁治。": periodontic
"全口龈上洁治术。": periodontic "全口龈上洁治术。": periodontic
全口洁治: periodontic
"全口洁治。": periodontic "全口洁治。": periodontic
全口洁牙: periodontic
全口超声洁治: periodontic
全口超声波洁治: periodontic
全口龈上超声洁治: periodontic
定期全口洁治: periodontic
"定期全口洁治\r\n": periodontic "定期全口洁治\r\n": periodontic
"全口洁治\r\n": periodontic "全口洁治\r\n": periodontic
定期洁牙: periodontic
洁牙: periodontic
洁治: periodontic
"洁治。": periodontic "洁治。": periodontic
洗牙: periodontic
洁治 OHI: periodontic
"洁治+OHI": periodontic "洁治+OHI": periodontic
"龈上洁治;细洁": periodontic "龈上洁治;细洁": periodontic
"龈上洁治+细洁": periodontic "龈上洁治+细洁": periodontic
"龈上洁治+龈下细洁": periodontic "龈上洁治+龈下细洁": periodontic
龈下细洁: periodontic
龈上超声洁治: periodontic
牙周基础治疗: periodontic
牙周系统治疗: periodontic
"牙周系统治疗。": periodontic "牙周系统治疗。": periodontic
"OHI\r\n牙周系统治疗": periodontic "OHI\r\n牙周系统治疗": periodontic
牙周治疗: periodontic
牙周维护: periodontic
牙周刮治: periodontic
牙周刮治术: periodontic
龈下刮治: periodontic
龈下刮治术: periodontic
"龈下刮治及根面平整术(>5mm牙周袋)/牙周刮治术/超声龈下清创术": periodontic "龈下刮治及根面平整术(>5mm牙周袋)/牙周刮治术/超声龈下清创术": periodontic
"龈下刮治及根面平整术(<5mm牙周袋)/牙周刮治术/超声龈下清创术": periodontic "龈下刮治及根面平整术(<5mm牙周袋)/牙周刮治术/超声龈下清创术": periodontic
根面平整术: periodontic
PMTC: periodontic
"PMTC+全口涂氟": periodontic "PMTC+全口涂氟": periodontic
# W4 末补
牙周序列治疗: periodontic
系统性牙周治疗: periodontic
"龈上洁治术/预防性洁治/洗牙/洁牙": periodontic "龈上洁治术/预防性洁治/洗牙/洁牙": periodontic
"定期洁牙,牙周系统治疗": periodontic "定期洁牙,牙周系统治疗": periodontic
"全口洁治+OHI": periodontic "全口洁治+OHI": 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
# W4 末补 — 玻璃离子(GIC)类充填(临床: 等价于树脂充填,主要用于乳牙/年轻恒牙/暂封)
玻璃离子充填: restorative
玻璃离子充填术: restorative
"玻璃离子充填术。": restorative "玻璃离子充填术。": restorative
"玻璃离子充填。": restorative "玻璃离子充填。": restorative
进口玻璃离子充填: restorative
"进口玻璃离子充填术。": restorative "进口玻璃离子充填术。": restorative
"充填治疗(玻璃离子)": restorative "充填治疗(玻璃离子)": restorative
玻璃离子暂封: restorative
玻璃离子预充填: restorative
# 其他高频补全
充填或嵌体修复: restorative
"充填治疗(乳牙复面洞)": restorative "充填治疗(乳牙复面洞)": restorative
"恒牙充填治疗(儿科)": restorative "恒牙充填治疗(儿科)": restorative
高嵌体修复: restorative # ── 牙髓/根管(endodontic)
预防性树脂充填: restorative
# ── 牙髓 / 根管(endodontic)
根管治疗: endodontic
"根管治疗(磨牙)": endodontic "根管治疗(磨牙)": endodontic
"根管治疗(前牙及双尖牙单根管)": endodontic "根管治疗(前牙及双尖牙单根管)": endodontic
"根管治疗(前牙及双尖牙多根管/显微根管)": endodontic "根管治疗(前牙及双尖牙多根管/显微根管)": endodontic
"根管治疗+冠修复": endodontic "根管治疗+冠修复": endodontic
"根管治疗+充填": endodontic "根管治疗+充填": endodontic
根管再治疗: endodontic
根管预备: endodontic
根管充填: endodontic
根充: endodontic
RCT: endodontic
牙髓治疗: endodontic
根尖诱导成形术: endodontic
拔髓: endodontic
干髓术: endodontic
盖髓术: endodontic
间接盖髓: endodontic
直接盖髓: endodontic
MTA盖髓: endodontic
冲洗上药: endodontic
# W4 末补
根管治疗后冠修复: endodontic
"RCT+冠修复": endodontic "RCT+冠修复": endodontic
# W4 末补 — pulpotomy 类 + 牙髓相关术式(跟 treatment_actual.yaml 同源)
活髓切断术: endodontic
部分活髓切断术: endodontic
部分牙髓切断术: endodontic
"活髓切断术+预成冠修复": endodontic "活髓切断术+预成冠修复": endodontic
"活髓切断术+预成冠": endodontic "活髓切断术+预成冠": endodontic
"活髓切断+预成冠修复": endodontic "活髓切断+预成冠修复": endodontic
...@@ -222,129 +108,46 @@ enum_mapping: ...@@ -222,129 +108,46 @@ enum_mapping:
"活髓切断术+全瓷冠冠修复": 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
开髓失活: 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 "根管治疗术(乳前牙直接拔髓多步法)": endodontic
"根管治疗(封失活剂)": endodontic "根管治疗(封失活剂)": endodontic
复杂根管治疗: endodontic # ── 种植(implant)
# ── 种植(implant)— 注意"种植上部修复"算 prostho;"种植修复"在 host 文本是合并语,归 implant
种植: implant
种植手术: implant
种植体植入: implant
种植修复: implant
种植戴牙: implant
种植一期: implant
种植二期: implant
种植三期: implant
种植耗材: implant
种植基台: implant
种植取模: implant
种植拆线: implant
"种植冠修复(非美学区单颗)": implant "种植冠修复(非美学区单颗)": implant
"简单种植术(非美学区,单颗)": implant "简单种植术(非美学区,单颗)": implant
"复杂种植术(非美学区,连续缺失/多颗)": implant "复杂种植术(非美学区,连续缺失/多颗)": implant
"复杂种植术(非美学区,伴同期上颌窦底内提升/同期GBR术)": implant "复杂种植术(非美学区,伴同期上颌窦底内提升/同期GBR术)": implant
"复杂种植术(同期上颌窦底外提升术)": implant "复杂种植术(同期上颌窦底外提升术)": implant
"复杂种植术(美学区连续2颗以上或伴骨量不足及GBR术)": implant "复杂种植术(美学区连续2颗以上或伴骨量不足及GBR术)": implant
即刻种植术: implant
拔除后种植修复: implant
延期种植术: implant
"延期种植术(美学区,单颗)": implant "延期种植术(美学区,单颗)": implant
# W4 末补 # ── 修复(冠/桥/义齿/桩核)(prosthodontic)
拔除后种植: implant
# ── 修复(prostho)— 冠 / 桥 / 义齿 / 桩核
冠修复: prosthodontic
"冠修复(非美学区)": prosthodontic "冠修复(非美学区)": prosthodontic
"冠修复(简单美学区)": prosthodontic "冠修复(简单美学区)": prosthodontic
全冠修复: prosthodontic
牙冠修复: prosthodontic
重新冠修复: prosthodontic
戴冠: prosthodontic
粘冠: prosthodontic
拆冠: prosthodontic
戴牙: prosthodontic
桩核: prosthodontic
桩核冠: prosthodontic
固定修复: prosthodontic
活动修复: prosthodontic
重新修复: prosthodontic
修复: prosthodontic
预成冠修复: prosthodontic
牙体预备: prosthodontic
取模: prosthodontic 取模: prosthodontic
调合: prosthodontic 调合: prosthodontic
调颌: prosthodontic 调颌: prosthodontic
种植上部修复: prosthodontic # ── 外科(拔除)(surgical)
种植体上部修复: prosthodontic
# W4 末补
桩冠修复: prosthodontic
# ── 外科(surgical)— 拔除 / 智齿 / 残根残冠
拔除: surgical
"拔除。": surgical "拔除。": surgical
拔除术: surgical
拔牙: surgical
牙拔除术: surgical
"牙拔除术(松动乳牙)": surgical "牙拔除术(松动乳牙)": surgical
简单牙拔除术: surgical
松动恒牙拔除术: surgical
松动乳牙拔除术: surgical
择期拔除: surgical
阻生齿拔除术: surgical
阻生牙拔除: surgical
"智齿拔除术(正位)": surgical "智齿拔除术(正位)": surgical
"智齿拔除术(复杂)": surgical "智齿拔除术(复杂)": surgical
残根拔除术: surgical
"残根(残冠)拔除术": surgical "残根(残冠)拔除术": surgical
"正畸牙拔除术(正位)": surgical "正畸牙拔除术(正位)": surgical
# ── 正畸(orthodontic)
# ── 正畸(orthodontic)— 实测 host treatName 覆盖了"加力 / 安氏分类 / 矫治器/保持器" 全流程
正畸: orthodontic
正畸治疗: orthodontic
隐形正畸: orthodontic
固定正畸: orthodontic
早期矫治: orthodontic
"合垫/预防性矫治/功能矫治/早期阻断性矫治": orthodontic "合垫/预防性矫治/功能矫治/早期阻断性矫治": orthodontic
"简单前牙排齐/安氏I类非拔牙矫治": orthodontic "简单前牙排齐/安氏I类非拔牙矫治": orthodontic
"安氏I类拔牙矫治(轻中度支抗)": orthodontic "安氏I类拔牙矫治(轻中度支抗)": orthodontic
...@@ -353,87 +156,24 @@ enum_mapping: ...@@ -353,87 +156,24 @@ enum_mapping:
"安氏II类拔牙矫治(强支抗/种植支抗)": orthodontic "安氏II类拔牙矫治(强支抗/种植支抗)": orthodontic
"安氏III类非拔牙矫治-涉及后牙咬合关系调整": orthodontic "安氏III类非拔牙矫治-涉及后牙咬合关系调整": orthodontic
"安氏III类拔牙矫治(轻中度支抗)": orthodontic "安氏III类拔牙矫治(轻中度支抗)": orthodontic
保持器: orthodontic
正畸保持器: orthodontic
正畸保持: orthodontic
正畸矫治器: orthodontic
正畸辅助治疗: orthodontic
正畸加力: orthodontic
"正畸加力。": orthodontic "正畸加力。": orthodontic
加力: orthodontic
粘附件: orthodontic
粘接附件: orthodontic
"粘接全口附件。": orthodontic "粘接全口附件。": orthodontic
重粘托槽: orthodontic
"重粘托槽。": orthodontic "重粘托槽。": orthodontic
# W4 末补
更换新矫治器: orthodontic
粘接上半口矫治器: orthodontic
粘接全口附件: orthodontic
精调粘接附件: orthodontic
发放新矫治器: orthodontic
"去除矫正器,配戴保持器保持现有咬合关系": orthodontic "去除矫正器,配戴保持器保持现有咬合关系": orthodontic
精细调整: orthodontic
"精细调整。": orthodontic "精细调整。": orthodontic
"精调粘接附件。": orthodontic "精调粘接附件。": orthodontic
发放矫治器: orthodontic
"发放新矫治器。": orthodontic "发放新矫治器。": orthodontic
"去除矫正器,配戴保持器保持现有咬合关系。": orthodontic "去除矫正器,配戴保持器保持现有咬合关系。": orthodontic
# ── 预防(preventive)
# ── 预防(preventive)— 涂氟 / 封闭 / 检查
涂氟: preventive
全口涂氟: preventive
"牙面清洁,涂氟": preventive "牙面清洁,涂氟": preventive
窝沟封闭: preventive
"窝沟封闭。": preventive "窝沟封闭。": preventive
窝沟封闭术: preventive
OHI: preventive
"OHI,涂氟": preventive "OHI,涂氟": preventive
口腔卫生宣教: preventive
口腔检查: preventive 口腔检查: preventive
全口检查: preventive 全口检查: preventive
常规检查: preventive 常规检查: preventive
脱敏: preventive
抛光: preventive
全景片: preventive 全景片: preventive
CBCT: preventive CBCT: preventive
曲面断层: preventive 曲面断层: preventive
根尖片: preventive 根尖片: preventive
# 长尾兜底:未命中 → category='',parser 跳过该 fact
# ── 美学(cosmetic)
美白: cosmetic
冷光美白: cosmetic
牙齿美白: cosmetic
牙位图美白: cosmetic
贴面: cosmetic
贴面修复: cosmetic
# 长尾兜底:enum_mapping 未命中 → category='',parser 跳过该 fact(silently warn)
# 维护:跑 SQL `SELECT JSONExtractString(t, 'treatName') treat_name, count() FROM
# fact_emr_treatment_out ARRAY JOIN JSONExtractArrayRaw(assumeNotNull(treat_plan)) AS t
# WHERE brand IN ('瑞泰','瑞尔') GROUP BY 1 ORDER BY 2 DESC LIMIT 200`
# 看新出现的高频 treat_name,补到上方对应 category。
# 故意不纳入(归 review / 非治疗 / 流程性 — 已被 transforms.route_by_pattern 分流走):
# 常规复查 / 复查 / 定期复查 / 检查(无具体内容)/ 已交付纸质病历 / 缴费 / 咨询 /
# 听方案 / 沟通治疗方案 / 方案沟通 / 拒绝拍片 / 未拍片 / 治疗中 / 未指定治疗项 / 转诊
_default: "" _default: ""
# ─────────────────────────────────────────────────────────
# 关键词分类兜底 —— 跟 treatment_actual 同一套真治疗规则(精确未命中按序含词匹配)。
# 引擎算法 host 无关;规则词 = 本 host 术式特征。只覆盖真治疗 10 类,流程/无操作落 _default。
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: [必要时, 如需, 择期]
...@@ -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 } : {}),
};
}
...@@ -23,6 +23,7 @@ import { ...@@ -23,6 +23,7 @@ import {
AssemblerConfigSchema, AssemblerConfigSchema,
type AssemblerConfig, type AssemblerConfig,
} from '../assembler/assembler.schema'; } from '../assembler/assembler.schema';
import { resolveDictIncludes } from '../assembler/dict-include';
import { import {
ColdImportManifest, ColdImportManifest,
ColdImportManifestSchema, ColdImportManifestSchema,
...@@ -43,6 +44,7 @@ import { ...@@ -43,6 +44,7 @@ import {
type SourceUnitResolver, type SourceUnitResolver,
} from './tenant-resolver'; } from './tenant-resolver';
import { mergePatientPreferences } from '../pipeline/patient-preferences.util'; import { mergePatientPreferences } from '../pipeline/patient-preferences.util';
import { buildPatientUpsertData, buildProfileUpsertData } from './patient-upsert.util';
/** 患者索引 key:集团内 external_id 可跨 source_unit 撞号,索引按 (source_unit, external_id) 建键。 */ /** 患者索引 key:集团内 external_id 可跨 source_unit 撞号,索引按 (source_unit, external_id) 建键。 */
function patientIndexKey(sourceUnit: string, externalId: string): string { function patientIndexKey(sourceUnit: string, externalId: string): string {
...@@ -300,14 +302,21 @@ export class ColdImportService { ...@@ -300,14 +302,21 @@ export class ColdImportService {
const normalize = { amountUnit: manifest.amount_unit, timezone: manifest.timezone }; const normalize = { amountUnit: manifest.amount_unit, timezone: manifest.timezone };
const transforms = manifest.transforms ?? []; const transforms = manifest.transforms ?? [];
// 找出"终端原生表 === source"的所有 subject cfg(一张 EMR 源表可喂 诊断/治疗/emr/建议 多 cfg) // 找出"终端原生表 === source"的全部 cfg:
const subjectCfgs = this.loadAllAssemblers(absDir, manifest).filter((c) => !!c.emits); // subject(有 emits,走 transaction/fact 流)+ upsert(patient/patient_relation/
// patient_return_visit,无 emits,直接 upsert 主档/边表 —— 2026-07-20 补,此前
// push 不支持患者主档,新患者只能走存量全量通道)
const allCfgs = this.loadAllAssemblers(absDir, manifest);
const subjectCfgs = allCfgs.filter((c) => !!c.emits);
const targetCfgs = subjectCfgs.filter( const targetCfgs = subjectCfgs.filter(
(cfg) => traceRawSourceTable(cfg.primary.table, transforms) === opts.source, (cfg) => traceRawSourceTable(cfg.primary.table, transforms) === opts.source,
); );
if (targetCfgs.length === 0) { const upsertCfgs = allCfgs.filter(
(c) => !c.emits && traceRawSourceTable(c.primary.table, transforms) === opts.source,
);
if (targetCfgs.length === 0 && upsertCfgs.length === 0) {
const known = [ const known = [
...new Set(subjectCfgs.map((c) => traceRawSourceTable(c.primary.table, transforms))), ...new Set(allCfgs.map((c) => traceRawSourceTable(c.primary.table, transforms))),
]; ];
// 契约码 10001(而非裸 Error→90000):这是宿主填错 source 的永久性错误, // 契约码 10001(而非裸 Error→90000):这是宿主填错 source 的永久性错误,
// 按文档"9xxxx 可重试"的规则裸抛会让宿主对配置错误无限重试。 // 按文档"9xxxx 可重试"的规则裸抛会让宿主对配置错误无限重试。
...@@ -345,6 +354,30 @@ export class ColdImportService { ...@@ -345,6 +354,30 @@ export class ColdImportService {
const perResource: PerResourceStats[] = []; const perResource: PerResourceStats[] = [];
let firstError: string | null = null; let firstError: string | null = null;
try { try {
// upsert 资源(patient 主档走 partial 语义:canonical 未提供的字段不覆盖存量、
// phone 不合成假号 —— push 单表时跨表依赖(如 contacts lookup)缺席,full 覆盖会
// 把存量真号打成假号;关系边/回访是完整行 upsert,无 partial 问题)
for (const cfg of upsertCfgs) {
if (cfg.canonical === 'patient') {
perResource.push(
await this.processPatients(
transformed, cfg, host.id, tenantResolver, seenTenants, normalize, false, true,
),
);
} else if (cfg.canonical === 'patient_relation') {
perResource.push(
await this.processPatientRelations(
transformed, cfg, host.id, tenantResolver, normalize, false,
),
);
} else if (cfg.canonical === 'patient_return_visit') {
perResource.push(
await this.processPatientReturnVisits(
transformed, cfg, host.id, tenantResolver, normalize, false,
),
);
}
}
for (const cfg of targetCfgs) { for (const cfg of targetCfgs) {
const stats = await this.processSubject( const stats = await this.processSubject(
transformed, transformed,
...@@ -364,6 +397,8 @@ export class ColdImportService { ...@@ -364,6 +397,8 @@ export class ColdImportService {
} }
// touched 患者 = 本 syncLog 写入的 transaction 的 distinct (patientId, tenantId) // touched 患者 = 本 syncLog 写入的 transaction 的 distinct (patientId, tenantId)
// 注:upsert 资源(patient/relation/return_visit)不产 transaction → 纯主档推送 touched 为空,
// 不触发 persona 重算(主档字段变更由后续事实事件驱动的重算自然吸收)。
const touchedRows = await this.prisma.patientTransaction.findMany({ const touchedRows = await this.prisma.patientTransaction.findMany({
where: { syncLogId: syncLog.id, patientId: { not: null } }, where: { syncLogId: syncLog.id, patientId: { not: null } },
select: { patientId: true, tenantId: true }, select: { patientId: true, tenantId: true },
...@@ -638,7 +673,12 @@ export class ColdImportService { ...@@ -638,7 +673,12 @@ export class ColdImportService {
// 避免推进到 run_start 后把 DW 迟到的旧 updated_date 行永久埋在游标下方(静默漏拉)。 // 避免推进到 run_start 后把 DW 迟到的旧 updated_date 行永久埋在游标下方(静默漏拉)。
let incrementalNoData = false; let incrementalNoData = false;
let incrementalBaselineCursor: Record<string, string> = {}; let incrementalBaselineCursor: Record<string, string> = {};
const perQueryCfg = manifest.sql_source?.incremental?.per_query; // 水位声明与源类型解耦(2026-07 统一):sql_source 内优先(jvs-dw 现状不动),
// file 源用 manifest 顶层声明 —— 之后读写 cursor 走完全同一段逻辑(finally 记账
// cursor_after = run_start)。file 装载路径不消费水位(loadAllTables 文件分支忽略
// incremental 参数,全量装载靠幂等去重),水位仅供 delta 导出 / push 回放起点。
const perQueryCfg =
manifest.sql_source?.incremental?.per_query ?? manifest.incremental?.per_query;
if (perQueryCfg) { if (perQueryCfg) {
// 默认读 cursor;options.incremental === false 时 (强制 full) 不读 // 默认读 cursor;options.incremental === false 时 (强制 full) 不读
const ignoreCursor = options.incremental === false; const ignoreCursor = options.incremental === false;
...@@ -680,7 +720,7 @@ export class ColdImportService { ...@@ -680,7 +720,7 @@ export class ColdImportService {
} else if (options.incremental) { } else if (options.incremental) {
// 老调用方明确要 incremental 但 manifest 没配 cursor → fast-fail // 老调用方明确要 incremental 但 manifest 没配 cursor → fast-fail
throw new Error( throw new Error(
`incremental 模式需要 manifest.sql_source.incremental.per_query;请补 cursor 配置`, `incremental 模式需要 incremental.per_query 声明(sql_source 内或 manifest 顶层);请补 cursor 配置`,
); );
} }
...@@ -863,6 +903,15 @@ export class ColdImportService { ...@@ -863,6 +903,15 @@ export class ColdImportService {
} }
} else { } else {
// ── single-shot 模式(向后兼容 / 文件源)── // ── single-shot 模式(向后兼容 / 文件源)──
// --clinics/--since 挂在 sql_source.cohort(PAC 生成 WHERE);文件源等价物在导出侧
// (如 data/friday/export.sh --clinics/--since,同语义:只收窄患者名单,选中者全史全摄)。
// 这里显式警告而非静默忽略 —— 否则误以为过滤生效,实际全量导入。
if (options.clinics?.length || options.since) {
this.logger.warn(
`--clinics/--since 仅 sql_source.cohort(ClickHouse 直连)支持,当前 manifest 是文件源 → 忽略。` +
`文件源请在导出侧过滤(export.sh --clinics/--since),CSV 落地即已收窄。`,
);
}
await this.processCohort({ await this.processCohort({
absDir, absDir,
manifest, manifest,
...@@ -1352,6 +1401,9 @@ export class ColdImportService { ...@@ -1352,6 +1401,9 @@ export class ColdImportService {
seenTenants: Set<string>, seenTenants: Set<string>,
normalize: { amountUnit: 'fen' | 'yuan'; timezone: string }, normalize: { amountUnit: 'fen' | 'yuan'; timezone: string },
dryRun: boolean, dryRun: boolean,
/// push 形态 A 单表患者主档:true = 部分更新语义(canonical 未提供的字段不覆盖,
/// phone 不合成假号)。cold-import 全量/增量路径不传(false)→ 行为与历史一致。
partial = false,
): Promise<PerResourceStats> { ): Promise<PerResourceStats> {
const stats: PerResourceStats = this.zeroPerResource(config.canonical); const stats: PerResourceStats = this.zeroPerResource(config.canonical);
...@@ -1381,40 +1433,9 @@ export class ColdImportService { ...@@ -1381,40 +1433,9 @@ export class ColdImportService {
continue; continue;
} }
// 主表数据(身份) // 主/副表数据 — 构造逻辑抽 patient-upsert.util(full=历史语义;partial=push 单表不覆盖缺失字段)
const patientData = { const patientData = buildPatientUpsertData(c, externalId, { partial });
name: (c.name as string | undefined) ?? null, const profileData = buildProfileUpsertData(c, { partial });
// ⚠️ DEMO 兜底:host 不给 phone(隐私) → 由 externalId hash 合成确定性假号
// (打 138 前缀,后 8 位是 externalId 数字 hash;同 externalId 多次跑得同号)
// 生产 host 接入时把 c.phone 真值传过来即可,这里 ?? 自动让位真值
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,
// 专属客服(current_task_director)并入 preferences.dedicatedCs(零迁移,非画像特征)
preferences: mergePatientPreferences(c),
// status='archived'/'merged' → active=false;'active' / 缺省 → true
active: ((c.status as string | undefined) ?? 'active') === 'active',
};
// 副表数据(合规/标签/状态)— 全部可空
const profileData = {
doNotContact: (c.doNotContact as boolean) ?? false,
deceased: (c.deceased as boolean) ?? false,
// canonical 字段(若 host 没给 → null/默认)
// tags / notes 需要从 canonical 拿;host 通过 host_tags / host_notes → assembler.yaml 映射,此处兜底
// (primaryContactType 已废弃 → 联系人改走 PatientRelation 边表,processPatientRelations)
tags: Array.isArray(c.tags) ? (c.tags as string[]) : [],
notes: (c.notes as string | undefined) ?? null,
// 获客渠道(A.2.1):一级 PAC 标准枚举(assembler enum_mapping 已归一)/ 二级 host 原值
acquisitionChannel: (c.acquisitionChannel as string | undefined) ?? null,
acquisitionSub: (c.acquisitionSub as string | undefined) ?? null,
// 转介绍(B.1.2):recommend_num / recommend_amount(元→分)
referralCount: c.referralCount != null ? Math.trunc(Number(c.referralCount)) : null,
referralAmountCents:
c.referralAmount != null ? Math.round(Number(c.referralAmount) * 100) : null,
};
try { try {
const patient = await this.withDbRetry( const patient = await this.withDbRetry(
...@@ -2139,7 +2160,10 @@ export class ColdImportService { ...@@ -2139,7 +2160,10 @@ export class ColdImportService {
if (!fs.existsSync(filePath)) { if (!fs.existsSync(filePath)) {
throw new Error(`Assembler config not found: ${filePath}`); throw new Error(`Assembler config not found: ${filePath}`);
} }
const parsed = yaml.load(fs.readFileSync(filePath, 'utf-8')); const parsed = resolveDictIncludes(
yaml.load(fs.readFileSync(filePath, 'utf-8')),
filePath,
);
const r = AssemblerConfigSchema.safeParse(parsed); const r = AssemblerConfigSchema.safeParse(parsed);
if (!r.success) { if (!r.success) {
throw new Error( throw new Error(
...@@ -2325,15 +2349,6 @@ export function chunk<T>(arr: ReadonlyArray<T>, size: number): T[][] { ...@@ -2325,15 +2349,6 @@ export function chunk<T>(arr: ReadonlyArray<T>, size: number): T[][] {
* 算法:138 + (externalId 转 number) mod 1e8,补 0 至 8 位 → 138 + 8 位 = 11 位 * 算法:138 + (externalId 转 number) mod 1e8,补 0 至 8 位 → 138 + 8 位 = 11 位
* 同 externalId 多次 import 出同号(便于 demo / debug)。 * 同 externalId 多次 import 出同号(便于 demo / debug)。
*/ */
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}`;
}
/// 增量游标 → DW 字符串列可比的字面量。 /// 增量游标 → DW 字符串列可比的字面量。
/// DW 的 cursor 列是 String('YYYY-MM-DD HH:mm:ss[.SSS]',宿主本地时区)→ 比较走字典序; /// DW 的 cursor 列是 String('YYYY-MM-DD HH:mm:ss[.SSS]',宿主本地时区)→ 比较走字典序;
/// 游标存的是 run_start ISO(带 'T'/'Z')→ 'T' 比 ' ' 字典序大,所有真实行都排在游标前 /// 游标存的是 run_start ISO(带 'T'/'Z')→ 'T' 比 ' ' 字典序大,所有真实行都排在游标前
......
...@@ -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);
......
/**
* FactWriter 时间锚变更检测 — occurredAt/plannedFor/validFrom/validUntil 纳入 supersede 判定
*
* 背景 bug:变更检测原来只比 content hash + status,宿主纠正事件时间
* (如 FRIDAY Mongo 伪 UTC 修复,occurredAt 整体 -8h)时 content 不变 →
* 重摄永远 evidence_appended,occurred_at 无法更新。
*
* 覆盖:
* - 回归:content+status+时间锚全同 → evidence_appended / unchanged(幂等语义不变)
* - occurredAt 平移(-8h)→ superseded,新版本携带纠正后时间
* - plannedFor / null↔值 变化 → superseded(planned 事实的时间锚在 plannedFor)
* - stale gate 优先:sourceUpdatedAt 更旧 → stale_skipped,时间锚差异不放行
* - 终态版本不动:latest 是 fulfilled + 时间差 → 追新版本,终态行保持原状
* - bulkWrite 与 writeDraft 同语义(混合批 + batch 内链式)
*
* 跑:pnpm test -- fact-writer-temporal
*/
import { FactKind, FactStatus, FactType } from '@pac/types';
import { FactWriter } from '../src/modules/sync/pipeline/fact-writer.service';
import type { FactDraft } from '../src/modules/sync/pipeline/parsers/parser.interface';
// ─────────────────────────────────────────────
// 忠实内存 mock:findFirst/findMany 真按 where 过滤、orderBy version desc、
// $transaction 直接跑 callback,update/create/createMany/updateMany 真改内存行
// ─────────────────────────────────────────────
interface Row {
id: string;
hostId: string;
tenantId: string;
sourceUnit: string;
patientId: string;
subjectId: string;
kind: string;
type: string;
status: string;
version: number;
clinicId: string | null;
occurredAt: Date | null;
sourceUpdatedAt: Date | null;
plannedFor: Date | null;
validFrom: Date | null;
validUntil: Date | null;
title: string | null;
summary: string | null;
content: unknown;
transactionIds: string[];
supersededAt: Date | null;
}
function makeStore(seed: Partial<Row>[] = []) {
let nextId = 1;
const rows: Row[] = [];
const insert = (data: Record<string, unknown>): Row => {
const row: Row = {
id: `fact-${nextId++}`,
hostId: data.hostId as string,
tenantId: data.tenantId as string,
sourceUnit: (data.sourceUnit as string) ?? '',
patientId: data.patientId as string,
subjectId: data.subjectId as string,
kind: data.kind as string,
type: data.type as string,
status: data.status as string,
version: data.version as number,
clinicId: (data.clinicId as string | null) ?? null,
occurredAt: (data.occurredAt as Date | null) ?? null,
sourceUpdatedAt: (data.sourceUpdatedAt as Date | null) ?? null,
plannedFor: (data.plannedFor as Date | null) ?? null,
validFrom: (data.validFrom as Date | null) ?? null,
validUntil: (data.validUntil as Date | null) ?? null,
title: (data.title as string | null) ?? null,
summary: (data.summary as string | null) ?? null,
content: data.content,
transactionIds: (data.transactionIds as string[]) ?? [],
supersededAt: null,
};
rows.push(row);
return row;
};
for (const s of seed) insert(s as Record<string, unknown>);
const matches = (row: Row, where: Record<string, unknown>): boolean => {
for (const [k, v] of Object.entries(where)) {
const rv = (row as unknown as Record<string, unknown>)[k];
if (v !== null && typeof v === 'object' && 'in' in (v as object)) {
if (!(v as { in: unknown[] }).in.includes(rv)) return false;
} else if (rv !== v) {
return false;
}
}
return true;
};
const model = {
findFirst: async (q: { where: Record<string, unknown> }) => {
const hit = rows
.filter((r) => matches(r, q.where))
.sort((a, b) => b.version - a.version)[0];
return hit ? { ...hit } : null;
},
findMany: async (q: { where: Record<string, unknown> }) => {
return rows
.filter((r) => matches(r, q.where))
.sort((a, b) => a.subjectId.localeCompare(b.subjectId) || b.version - a.version)
.map((r) => ({ ...r }));
},
update: async (q: { where: { id: string }; data: Record<string, unknown> }) => {
const row = rows.find((r) => r.id === q.where.id)!;
Object.assign(row, q.data);
return { ...row };
},
updateMany: async (q: { where: { id: { in: string[] } }; data: Record<string, unknown> }) => {
for (const r of rows) if (q.where.id.in.includes(r.id)) Object.assign(r, q.data);
},
create: async (q: { data: Record<string, unknown> }) => ({ ...insert(q.data) }),
createMany: async (q: { data: Record<string, unknown>[] }) => {
for (const d of q.data) insert(d);
},
};
const tx = {
patientFact: model,
// bulkWrite 的 evidence append 走 raw SQL(array_append 去重);内存 mock 等价实现
$executeRaw: async (strings: TemplateStringsArray, ...values: unknown[]) => {
const [transactionId, factId] = values as [string, string];
const row = rows.find((r) => r.id === factId);
if (row && !row.transactionIds.includes(transactionId)) {
row.transactionIds = [...row.transactionIds, transactionId];
}
},
};
const prisma = {
patientFact: model,
$transaction: async (cb: (t: typeof tx) => Promise<unknown>) => cb(tx),
};
return { prisma, rows };
}
// ─────────────────────────────────────────────
// 夹具
// ─────────────────────────────────────────────
const HOST = 'host-1';
const TENANT = 'tenant-1';
const UNIT = 'unit-a';
const PATIENT = 'patient-1';
const T0 = new Date('2026-07-01T10:00:00.000Z');
const T0_MINUS_8H = new Date('2026-07-01T02:00:00.000Z');
/// 库里存的是 zod 校验后的 content(nullableString().default(null) 会补齐 null 字段)
/// — 种子必须用同一形态,否则 hash 恒不等,测不出幂等路径
const VALIDATED_CONTENT = {
encounter_external_id: 'enc-1',
doctor_id: null,
doctor_name: null,
chief_complaint: null,
encounter_type: null,
notes: null,
};
function encounterDraft(overrides: Partial<FactDraft> = {}): FactDraft {
return {
subjectId: 'enc-1',
kind: FactKind.ACTUAL,
type: FactType.ENCOUNTER_RECORD,
occurredAt: T0,
content: { encounter_external_id: 'enc-1' },
...overrides,
};
}
function writer(prisma: unknown): FactWriter {
return new FactWriter(prisma as never);
}
function seedActive(overrides: Partial<Row> = {}): Partial<Row> {
return {
hostId: HOST,
tenantId: TENANT,
sourceUnit: UNIT,
patientId: PATIENT,
subjectId: 'enc-1',
kind: FactKind.ACTUAL,
type: FactType.ENCOUNTER_RECORD,
status: FactStatus.ACTIVE,
version: 1,
occurredAt: T0,
content: { ...VALIDATED_CONTENT },
transactionIds: ['tx-1'],
...overrides,
};
}
const baseInput = {
hostId: HOST,
tenantId: TENANT,
sourceUnit: UNIT,
patientId: PATIENT,
};
// ─────────────────────────────────────────────
// writeDraft
// ─────────────────────────────────────────────
describe('FactWriter.writeDraft | 时间锚变更检测', () => {
test('回归:content+status+时间锚全同 → evidence_appended(新 tx)/ unchanged(同 tx)', async () => {
const { prisma, rows } = makeStore([seedActive()]);
const w = writer(prisma);
const r1 = await w.writeDraft({ ...baseInput, draft: encounterDraft(), transactionId: 'tx-2' });
expect(r1.action).toBe('evidence_appended');
expect(rows).toHaveLength(1);
expect(rows[0]!.transactionIds).toEqual(['tx-1', 'tx-2']);
const r2 = await w.writeDraft({ ...baseInput, draft: encounterDraft(), transactionId: 'tx-2' });
expect(r2.action).toBe('unchanged');
expect(rows).toHaveLength(1);
});
test('occurredAt 平移 -8h(content 不变)→ superseded,新版本带纠正后时间', async () => {
const { prisma, rows } = makeStore([seedActive()]);
const w = writer(prisma);
const r = await w.writeDraft({
...baseInput,
draft: encounterDraft({ occurredAt: T0_MINUS_8H }),
transactionId: 'tx-2',
});
expect(r.action).toBe('superseded');
expect(r.version).toBe(2);
expect(rows).toHaveLength(2);
const [old, next] = rows;
expect(old!.status).toBe(FactStatus.SUPERSEDED);
expect(old!.supersededAt).toBeInstanceOf(Date);
expect(next!.status).toBe(FactStatus.ACTIVE);
expect(next!.occurredAt?.getTime()).toBe(T0_MINUS_8H.getTime());
});
test('plannedFor 变化(occurredAt 双 null)→ superseded — planned 事实的时间锚', async () => {
const { prisma, rows } = makeStore([
seedActive({ occurredAt: null, plannedFor: T0 }),
]);
const w = writer(prisma);
const r = await w.writeDraft({
...baseInput,
draft: encounterDraft({ occurredAt: null, plannedFor: T0_MINUS_8H }),
transactionId: 'tx-2',
});
expect(r.action).toBe('superseded');
expect(rows[1]!.plannedFor?.getTime()).toBe(T0_MINUS_8H.getTime());
});
test('occurredAt null → 值 / 值 → null 都算变化 → superseded', async () => {
const s1 = makeStore([seedActive({ occurredAt: null })]);
const r1 = await writer(s1.prisma).writeDraft({
...baseInput,
draft: encounterDraft({ occurredAt: T0 }),
transactionId: 'tx-2',
});
expect(r1.action).toBe('superseded');
const s2 = makeStore([seedActive({ occurredAt: T0 })]);
const r2 = await writer(s2.prisma).writeDraft({
...baseInput,
draft: encounterDraft({ occurredAt: null }),
transactionId: 'tx-2',
});
expect(r2.action).toBe('superseded');
});
test('stale gate 优先:sourceUpdatedAt 更旧 → stale_skipped,时间锚差异不放行', async () => {
const { prisma, rows } = makeStore([
seedActive({ sourceUpdatedAt: new Date('2026-07-02T00:00:00Z') }),
]);
const r = await writer(prisma).writeDraft({
...baseInput,
draft: encounterDraft({ occurredAt: T0_MINUS_8H }),
transactionId: 'tx-2',
sourceUpdatedAt: new Date('2026-07-01T00:00:00Z'),
});
expect(r.action).toBe('stale_skipped');
expect(rows).toHaveLength(1);
expect(rows[0]!.occurredAt?.getTime()).toBe(T0.getTime());
});
test('终态 latest(fulfilled)+ 时间差 → 追新版本,终态行不动', async () => {
const { prisma, rows } = makeStore([
seedActive({ status: FactStatus.FULFILLED }),
]);
const r = await writer(prisma).writeDraft({
...baseInput,
draft: encounterDraft({ status: FactStatus.FULFILLED, occurredAt: T0_MINUS_8H }),
transactionId: 'tx-2',
});
expect(r.action).toBe('superseded');
expect(rows[0]!.status).toBe(FactStatus.FULFILLED); // 终态版本保持原状
expect(rows[1]!.version).toBe(2);
expect(rows[1]!.occurredAt?.getTime()).toBe(T0_MINUS_8H.getTime());
});
});
// ─────────────────────────────────────────────
// bulkWrite — 与 writeDraft 同语义
// ─────────────────────────────────────────────
describe('FactWriter.bulkWrite | 时间锚变更检测', () => {
test('混合批:全同 → evidence_appended;occurredAt 平移 → superseded', async () => {
const { prisma, rows } = makeStore([
seedActive({ subjectId: 'enc-1' }),
seedActive({ subjectId: 'enc-2', version: 3 }),
]);
const w = writer(prisma);
const results = await w.bulkWrite([
{ ...baseInput, draft: encounterDraft({ subjectId: 'enc-1', content: { encounter_external_id: 'enc-1' } }), transactionId: 'tx-9' },
{ ...baseInput, draft: encounterDraft({ subjectId: 'enc-2', content: { encounter_external_id: 'enc-1' }, occurredAt: T0_MINUS_8H }), transactionId: 'tx-9' },
]);
expect(results.map((r) => r.action)).toEqual(['evidence_appended', 'superseded']);
const enc1 = rows.filter((r) => r.subjectId === 'enc-1');
expect(enc1).toHaveLength(1);
expect(enc1[0]!.transactionIds).toContain('tx-9');
const enc2 = rows.filter((r) => r.subjectId === 'enc-2').sort((a, b) => a.version - b.version);
expect(enc2).toHaveLength(2);
expect(enc2[0]!.status).toBe(FactStatus.SUPERSEDED);
expect(enc2[1]!.version).toBe(4);
expect(enc2[1]!.occurredAt?.getTime()).toBe(T0_MINUS_8H.getTime());
});
test('batch 内链式:同 subject 两 draft 仅时间不同 → 连升两版', async () => {
const { prisma, rows } = makeStore([seedActive()]);
const w = writer(prisma);
const results = await w.bulkWrite([
{ ...baseInput, draft: encounterDraft({ occurredAt: T0_MINUS_8H }), transactionId: 'tx-2' },
{ ...baseInput, draft: encounterDraft({ occurredAt: new Date('2026-07-01T03:00:00Z') }), transactionId: 'tx-3' },
]);
expect(results.map((r) => r.action)).toEqual(['superseded', 'superseded']);
expect(results.map((r) => r.version)).toEqual([2, 3]);
expect(rows).toHaveLength(3);
});
test('bulk stale gate 优先于时间锚差异', async () => {
const { prisma, rows } = makeStore([
seedActive({ sourceUpdatedAt: new Date('2026-07-02T00:00:00Z') }),
]);
const results = await writer(prisma).bulkWrite([
{
...baseInput,
draft: encounterDraft({ occurredAt: T0_MINUS_8H }),
transactionId: 'tx-2',
sourceUpdatedAt: new Date('2026-07-01T00:00:00Z'),
},
]);
expect(results[0]!.action).toBe('stale_skipped');
expect(rows).toHaveLength(1);
});
});
/**
* 患者 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)}`);
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import type { MockUser, UserRole } from '@pac/types'; import type { MockOrgBrand, MockUser, UserRole } from '@pac/types';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
...@@ -11,7 +11,7 @@ import { ...@@ -11,7 +11,7 @@ import {
DialogTitle, DialogTitle,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { useAuthStore } from '@/stores/auth-store'; import { useAuthStore } from '@/stores/auth-store';
import { getMockUsers, mockLogin } from '@/lib/auth-api'; import { getMockOrgs, getMockUsers, mockLogin } from '@/lib/auth-api';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
/** /**
...@@ -62,6 +62,10 @@ export function MockLoginDialog({ open }: { open: boolean }) { ...@@ -62,6 +62,10 @@ export function MockLoginDialog({ open }: { open: boolean }) {
const [role, setRole] = useState<UserRole>('staff'); // 登录用的 RBAC 权限(默认员工) const [role, setRole] = useState<UserRole>('staff'); // 登录用的 RBAC 权限(默认员工)
const [tenantFilter, setTenantFilter] = useState<'all' | BrandSlug>('all'); const [tenantFilter, setTenantFilter] = useState<'all' | BrandSlug>('all');
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
/// 宿主 tab —— 两宿主数据完全分开,互不影响(jvs-dw 有客服花名册;friday 只有真实品牌/诊所)
const [hostTab, setHostTab] = useState<'jvs-dw' | 'friday'>('jvs-dw');
const [fridayBrands, setFridayBrands] = useState<MockOrgBrand[]>([]);
const [brandQuery, setBrandQuery] = useState('');
useEffect(() => { useEffect(() => {
getMockUsers() getMockUsers()
...@@ -69,6 +73,14 @@ export function MockLoginDialog({ open }: { open: boolean }) { ...@@ -69,6 +73,14 @@ export function MockLoginDialog({ open }: { open: boolean }) {
.catch(() => {}); // mock 关闭 / 无文件 → 空 → 走降级网格 .catch(() => {}); // mock 关闭 / 无文件 → 空 → 走降级网格
}, []); }, []);
// FRIDAY 真实组织结构(品牌→诊所,派生自摄入数据);切到该 tab 时按需拉
useEffect(() => {
if (hostTab !== 'friday' || fridayBrands.length > 0) return;
getMockOrgs('friday')
.then((r) => setFridayBrands(r.brands))
.catch(() => {});
}, [hostTab, fridayBrands.length]);
const applyTokens = (r: { accessToken: string; refreshToken: string; expiresIn: number }) => const applyTokens = (r: { accessToken: string; refreshToken: string; expiresIn: number }) =>
setTokens({ accessToken: r.accessToken, refreshToken: r.refreshToken, expiresIn: r.expiresIn }); setTokens({ accessToken: r.accessToken, refreshToken: r.refreshToken, expiresIn: r.expiresIn });
...@@ -118,6 +130,22 @@ export function MockLoginDialog({ open }: { open: boolean }) { ...@@ -118,6 +130,22 @@ export function MockLoginDialog({ open }: { open: boolean }) {
} }
}; };
// 第二宿主 FRIDAY 市场:身份是造的(无花名册),但数据范围落在**真实**品牌/诊所上。
// orgId 不传 = 集团级(全部品牌全部诊所)。
const pickFriday = async (roleKey: UserRole, orgId?: string) => {
const key = `friday:${orgId ?? 'group'}:${roleKey}`;
if (busy) return;
setBusy(key);
try {
const r = await mockLogin({ tenant: 'friday', role: roleKey, ...(orgId ? { orgId } : {}) });
applyTokens(r);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
toast.error('登录失败', { description: msg.slice(0, 120) });
setBusy(null);
}
};
const q = query.trim(); const q = query.trim();
const filteredCs = csUsers const filteredCs = csUsers
.filter((u) => (tenantFilter === 'all' ? true : u.tenant === tenantFilter)) .filter((u) => (tenantFilter === 'all' ? true : u.tenant === tenantFilter))
...@@ -151,7 +179,40 @@ export function MockLoginDialog({ open }: { open: boolean }) { ...@@ -151,7 +179,40 @@ export function MockLoginDialog({ open }: { open: boolean }) {
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
{csUsers.length > 0 ? ( {/* 宿主 tab —— 两宿主数据完全隔离(不同 host/tenant/诊所),切换互不影响 */}
<div className="flex gap-1 border-b border-slate-100">
{([
{ key: 'jvs-dw' as const, label: '瑞尔集团', sub: 'jvs-dw · 客服花名册' },
{ key: 'friday' as const, label: 'FRIDAY 市场', sub: 'friday · 多品牌 SaaS' },
]).map((t) => (
<button
key={t.key}
type="button"
onClick={() => setHostTab(t.key)}
className={cn(
'flex flex-col items-start rounded-t-md px-3 py-1.5 text-left transition-colors',
hostTab === t.key
? 'border-b-2 border-teal-600 font-semibold text-teal-700'
: 'text-slate-500 hover:text-slate-800',
)}
>
<span className="text-[12.5px]">{t.label}</span>
<span className="text-[10px] font-normal text-slate-400">{t.sub}</span>
</button>
))}
</div>
{hostTab === 'friday' ? (
<FridayPanel
role={role}
setRole={setRole}
brands={fridayBrands}
query={brandQuery}
setQuery={setBrandQuery}
busy={busy}
onPick={pickFriday}
/>
) : csUsers.length > 0 ? (
<div className="grid gap-3 pt-1"> <div className="grid gap-3 pt-1">
{/* ① 登录权限(角色)*/} {/* ① 登录权限(角色)*/}
<div> <div>
...@@ -312,3 +373,141 @@ export function MockLoginDialog({ open }: { open: boolean }) { ...@@ -312,3 +373,141 @@ export function MockLoginDialog({ open }: { open: boolean }) {
</Dialog> </Dialog>
); );
} }
// ──────────────────────────────────────────────
// FridayPanel — 第二宿主(FRIDAY 市场)登录面板
//
// 与瑞尔集团面板**数据完全隔离**:不同 host / tenant / 品牌 / 诊所,互不影响。
// 身份:FRIDAY 无客服花名册 → 用造的通用身份(FRIDAY 员工/主管/管理员);
// 数据范围:**必须真实** —— 品牌与诊所来自 GET /auth/mock-orgs(org 树派生自摄入数据,
// 名字来自 host.sourceUnitNames / clinicNames,同样派生自摄入源)。
// ──────────────────────────────────────────────
function FridayPanel({
role,
setRole,
brands,
query,
setQuery,
busy,
onPick,
}: {
role: UserRole;
setRole: (r: UserRole) => void;
brands: MockOrgBrand[];
query: string;
setQuery: (v: string) => void;
busy: string | null;
onPick: (role: UserRole, orgId?: string) => void;
}) {
const q = query.trim();
const filtered = q
? brands
.map((b) => ({
...b,
clinics: b.clinics.filter((c) => c.name.includes(q)),
}))
.filter((b) => b.name.includes(q) || b.clinics.length > 0)
: brands;
const totalClinics = brands.reduce((n, b) => n + b.clinics.length, 0);
return (
<div className="grid gap-3 pt-1">
{/* ① 角色(功能权限)*/}
<div>
<div className="mb-1.5 text-[12px] font-medium text-slate-600">
① 角色 · 能做什么(功能权限)
<span className="ml-1 text-[10.5px] font-normal text-slate-400">
FRIDAY 无客服花名册,身份用造的;数据范围下方选真实品牌/诊所
</span>
</div>
<div className="grid grid-cols-3 gap-2">
{ROLES.map((r) => (
<button
key={r.key}
type="button"
onClick={() => setRole(r.key)}
className={cn(
'flex flex-col items-start gap-0.5 rounded-md border px-2.5 py-1.5 text-left transition-all',
role === r.key
? 'border-teal-400 bg-teal-50/60 ring-1 ring-teal-200'
: 'border-slate-100 hover:border-teal-300',
)}
>
<span className="text-[12.5px] font-semibold text-slate-800">{r.nameZh}</span>
<span className="text-[10px] leading-tight text-slate-500">{r.desc}</span>
</button>
))}
</div>
{/* 集团级:全部品牌全部诊所 */}
<button
type="button"
onClick={() => onPick(role)}
disabled={!!busy}
className="mt-2 w-full rounded-md border border-dashed border-amber-300 bg-amber-50/40 px-2.5 py-1.5 text-left text-[11.5px] text-amber-800 transition-colors hover:border-amber-400 disabled:opacity-50"
>
数据范围 · <strong>市场级</strong> —— 看<strong>全部品牌全部诊所</strong>(最上一层)。角色沿用上方「
{roleNameZh(role)}」。
</button>
</div>
{/* ② 真实品牌 / 诊所 */}
<div className="rounded-lg border border-slate-100 p-3">
<div className="mb-2 flex items-center justify-between">
<div className="text-[12px] font-medium text-slate-600">
② 数据范围 · 选<strong>品牌</strong><strong>诊所</strong>(真实摄入数据)+ 上方「
{roleNameZh(role)}」角色
</div>
<span className="text-[10.5px] text-slate-500">
{brands.length} 品牌 · {totalClinics} 诊所
</span>
</div>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="搜品牌 / 诊所名…"
className="mb-2 h-7 w-full rounded-md border border-slate-100 bg-slate-50 px-2 text-[12px] outline-none placeholder:text-slate-400 focus:border-teal-300 focus:bg-white"
/>
<div className="max-h-[280px] space-y-2 overflow-y-auto">
{filtered.length === 0 && (
<div className="py-6 text-center text-[11.5px] text-slate-400">
{brands.length === 0 ? '组织结构加载中 / 无摄入数据' : '没有匹配的品牌或诊所'}
</div>
)}
{filtered.map((b) => (
<div key={b.brandId} className="rounded-md border border-slate-100">
{/* 品牌级:该品牌全部诊所 */}
<button
type="button"
onClick={() => onPick(role, b.brandId)}
disabled={!!busy}
className="flex w-full items-center justify-between gap-2 rounded-t-md bg-slate-50/60 px-2.5 py-1.5 text-left transition-colors hover:bg-teal-50/60 disabled:opacity-50"
>
<span className="truncate text-[12.5px] font-semibold text-slate-800">{b.name}</span>
<span className="flex-none text-[10.5px] text-slate-500">
品牌级 · {b.clinics.length} 诊所
</span>
</button>
{/* 诊所级 */}
<div className="flex flex-wrap gap-1 px-2.5 py-1.5">
{b.clinics.map((c) => (
<button
key={c.clinicId}
type="button"
onClick={() => onPick(role, c.clinicId)}
disabled={!!busy}
className="rounded-full border border-slate-100 px-2 py-0.5 text-[11px] text-slate-600 transition-colors hover:border-teal-300 hover:bg-teal-50 hover:text-teal-700 disabled:opacity-50"
>
{c.name}
</button>
))}
{b.clinics.length === 0 && (
<span className="text-[10.5px] text-slate-400">该品牌暂无诊所数据</span>
)}
</div>
</div>
))}
</div>
</div>
</div>
);
}
...@@ -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