Commit f3d7ae77 by luoqi

merge: feat/friday-ingestion → main(FRIDAY 摄入契约收敛 + 推送自助反馈)

- 摄入契约收敛为 9 张自洽表(宿主 inline 自己的引用,全路径一致)
- 结算 WHERE 迁移单一真理源 + 冷导入网页通道 + 数据对账
- push 预检(dryRun)+ 推送记录查询(HMAC 免登录)
- 修 traceRawSourceTable union 回溯(refund_full 在 push/reparse 静默跳过)

阶段性完成,业务确认可合。
parents a91f8a37 ec026cb2
---
title: 数据对账(设计)
description: 独立核对 PAC 侧数据与各宿主源是否一致 —— 同闸口径、去重患者数、多宿主 provider、复用企微日报。
icon: Scale
---
<Callout type="warn">
**本文是设计文档,尚未实现**。记录对账机制的口径与方案,供评审 / 落地参照。
</Callout>
## 为什么要独立对账
现有的几层防线都是**单向补漏 / 水位监控**,抓不到"该有的数据到底在不在":
| 现有机制 | 作用 | 抓不到 |
|---|---|---|
| 48h 回看窗 | 容忍 DW 落库迟到 | 超 48h / 时间戳不跳的漏拉 |
| 主档反向强拉 | 补 cohort 的主档 | 非主档信号的积压 |
| ReconcileOrchestrator | 时间窗重拉补漏(手动) | 不告诉你"差了多少" |
| DwLagMonitor | cursor 距今 >24/48h 告警 | 只看水位新旧,不看行在不在 |
尤其前述三个已知漏点 —— [无 cursor 表积压](/docs/ingestion#21-增量-cohort-与无-cursor-表)、`updatedAt` 不跳漏拉、事务/fact 不一致 —— **都是补漏和水位监控抓不到的**。缺的是一个**双向数量核对**:定期比"源侧应有 vs PAC 实有",差超阈值告警。
## 三个难点与解法
对账看似简单(两边 count 一比),实则有三个坑,逐个解:
### 难点 1:多宿主 —— 源计数从哪来因宿主而异
PAC 是多宿主,不能绑死 DW —— 但对账**只覆盖 PAC 能直连源库的宿主**:
| 宿主类型 | 例 | 能直连源? | 对账 |
|---|---|---|---|
| **pull**(直连) | jvs-dw(ClickHouse) | ✅ | ✅ PAC 主动查源计数 |
| **push**(无直连) | friday(SaaS) | ❌ PAC 够不到其库 | **不对账** |
**push 宿主不对账**(定):PAC 够不到源库,唯一办法是宿主自报,但那要它维护一套"按 PAC 口径过滤后的计数",负担与可信度都不划算 —— 直接排除。push 宿主的数据质量靠既有的 push 路径告警(疑似改表 / 失败率超阈)兜。
抽象成:**PAC 只负责「拿到 `(scope, sourceCount)` → 对比 `pacCount` → 报 diff」;`sourceCount` 由每个可直连宿主的 provider 供给**(查询源库)。将来任何可直连的新宿主接入只需实现 provider,对账主逻辑不动。
### 难点 2:摄入是筛选子集 —— 不能数原表全量
PAC 摄入的**不是原表全量**,有大量故意筛选(结算 `status IN (1,3)`、瑞泰不摄、cohort 限有就诊、诊所范围…)。拿原表 count 比,diff 里全是"故意的差",没法用。
**解法:源计数复用摄入查询本身,只换投影。** manifest 的 `sql_source` 查询**就是"PAC 应该有什么"的定义**,把 `SELECT *` 换成 `count(distinct patient)`:
```sql
sourceCount = count(distinct patient) FROM ( «摄入用的那条 sql_source 查询,原样» )
```
两边都过了同一道筛选闸,diff 只剩**真漂移**。且筛选口径**零重复维护** —— 改了 `sql_source` 的 WHERE,对账自动跟着变(用的同一条查询),不会两份逻辑慢慢跑偏。
(push 宿主不对账,见难点 1,不涉及此。)
### 难点 3:fan-out 噪声 —— 不能比行数
一条源行可能拆成 N 条 fact(诊断+治疗+建议),加上版本流,行数根本不可比。
**解法:只比「去重患者数」,不比行数。** 干净、可比、且直接对应业务关切("多少患者的 X 信号 PAC 没跟上")。
## 对账口径:同闸 + 去重患者数 + 两层
只看总患者数**不够** —— 无 cursor 表积压时患者还在 PAC 里(只是他的回访/咨询行旧了),总量层 diff=0 会漏检。故分两层:
| 层 | sourceCount | pacCount | 抓什么 |
|---|---|---|---|
| **总量** | cohort 查询的 `count(distinct patient)` | PAC 该 host 的 `count(distinct patient)` | 整体漏人 |
| **信号** | 各资源 sql_source(含各自过滤)的 `count(distinct patient)` | PAC 里有对应 `fact_type` 的 `count(distinct patient)` | 某类信号积压(定位到无 cursor 表) |
**信号层示例**:`DW 有影像分析的患者 5.0 万 vs PAC 有 image_record 的患者 4.8 万,diff 2000` → 既可比又定位到"影像信号积压"。
**覆盖范围:全 fact 类型**(定)。不挑重点,每类都对(诊断 / 治疗 / 影像 / 回访 / 咨询 / 结算 / 转介…),一眼看全哪条信号在漂;性能由下节"每日一次 + 秒级 count"兜住,全覆盖不构成负担。
## 性能
对账要跑一堆 `count(distinct patient)`,得算清楚成本再上:
- **频率:每日一次**(不是每轮增量)。跟健康日报同一时刻(09:00 沪、DW 刷新后)跑一次即可 —— 漂移是慢变量,日粒度足够。
- **DW 侧**:每类信号一条 `count(distinct patient)`,全覆盖约 10 条。ClickHouse 的 `count(distinct)` 对 13M 行通常秒级;精度不敏感处可用 `uniq`(HyperLogLog,~0.5% 误差)换更低开销,要精确用 `uniqExact`。**上线前实测这 10 条的总耗时**,确认 < 分钟级。
- **PAC 侧**:`patient_facts` 按 `fact_type` 分组 `count(distinct patient_id)`,一条走索引的聚合查询,廉价。
- **不常驻**:对账是日报里的一个步骤,跑完即释放,无常驻连接 / 内存。
每日一次 + 秒级查询 → 全覆盖的性能开销可忽略,不影响在线摄入 / 重算。
## 基线:压掉"已知的正常差"
有些差**本来就该存在**,不压会天天误报 → 告警疲劳:
- 瑞泰品牌不摄(恒差)
- 无 cursor 表短期积压(会自愈)
- 源侧脏数据 / 时间窗边界
**基线放 manifest 配置**(每宿主声明"哪些 scope 差多少以内算正常"),只有**偏离基线**才告警,不是有差就报。
```yaml
# data/<host>/manifest.yaml(拟)
reconcile:
baseline:
total_patient: { tolerance_pct: 2 } # 总量差 2% 内正常
fact.image_record: { tolerance_pct: 5 } # 影像积压容忍高些
fact.payment_record: { tolerance_abs: 200 }
```
## 汇报:复用企微,折进每日健康日报
**不新起消息流** —— 复用 `AlertService`(企微群机器人),把「数据对账」折进已有的 [每日健康日报](/docs/monitoring)(每天 09:00 沪、DW 刷新后跑)。对账项超基线时,把日报整体级别顶成 🟡/🔴(现有逻辑:取最差项)。
```
每日 09:00 健康日报(已有)增加一段「数据对账」:
for each host:
for each scope in [总量患者, 各 fact 类型患者]:
sourceCount = provider(host, scope) # pull 查询 / push 自报
pacCount = PAC 同口径 count(distinct patient)
diff, diffPct
超 manifest.reconcile.baseline → 标 🟡/🔴
推企微(复用 AlertService,超基线才顶级别)
```
## 分期落地
**先只观察企微,不建表**(定)。
| 版本 | 做什么 | 不做 |
|---|---|---|
| **v1(先做)** | 同闸口径算 diff(全 fact 类型)+ 基线压噪 + 折进健康日报推企微 | **不建表**;趋势靠每日日报肉眼比 |
| v2(以后) | 若肉眼比不够,再建 `reconcile_checks` 表存快照 → 自动趋势 + 审计历史 | — |
先跑 v1 观察企微:口径准不准、会不会噪声、diff 稳不稳。够用就不上表。
## 已定 / 待决
**已定**:push 宿主(无直连)不对账 · 全 fact 类型覆盖 · 每日一次跑 · 先不建表只观察企微。
**待决**:基线初值怎么定 —— 建议**先不设容忍、原样把 diff 报进日报观察一两周**,摸清各 scope 的"正常差"后再回填 `manifest.reconcile.baseline`,避免拍脑袋定阈值。
...@@ -93,6 +93,46 @@ pnpm sync -- --dir=./data/<host> --dry-run # 不写库,预览游标注入 ...@@ -93,6 +93,46 @@ pnpm sync -- --dir=./data/<host> --dry-run # 不写库,预览游标注入
pnpm sync -- --dir=./data/<host> --full # 忽略游标强制全量(灾后) pnpm sync -- --dir=./data/<host> --full # 忽略游标强制全量(灾后)
``` ```
### 2.1 增量 cohort 与「无 cursor 表」
增量分两步,理解成本要分开看:
1. **选 cohort**:`manifest.incremental.per_query` 声明的表,各按自己 `cursor_column`
取 `WHERE cursor > 水位` 的患者,**并集**成本轮 cohort(UNION —— 任一表有变更就纳入,
避免"预约变了但人没来"被漏)。
2. **拉资源**:对 cohort 患者,**所有**源表按 `(patient_id, brand) IN (cohort)` 拉行。
没进 `per_query` 的表(无 cursor)也在此步被拉 —— **按患者收窄,不全表扫**。
**关键推论**:无 cursor 表**不能独立把患者拉进增量**。若某患者只有无 cursor 表变了
(且 cursor 表都没动)→ 不进 cohort → 该变更**积压到该患者下次因 cursor 表再进 cohort 才补上**。
所以无 cursor 表适合**弱信号 / 展示类**(容忍延迟),不适合驱动召回的核心信号。
以 **jvs-dw(瑞尔)** 为例(2026-07 DW 实测行数):
| 源表 | 行数 | cursor | CH 排序键 | 说明 |
|---|---:|---|---|---|
| fact_client_out | 5.7M | `last_visit_time` | patient_id | 患者主档 |
| fact_emr_treatment_out | 5.1M | `updated_date` | patient_id… | 治疗/病历(召回核心) |
| fact_appointment_out | 9.1M | `updated_date` | id | 预约 |
| fact_settlement_out | 9.1M | `updated_date` | id | 结算 |
| fact_settlement_mode_out | 4.4M | `updated_date` | — | 支付通道 |
| **fact_returnvisit_out** | **13.4M** | **无** | 无 | 回访任务(展示) |
| **fact_consult_out** | **10.8M** | **无** | org… | 咨询(意向,弱信号) |
| fact_emr_image_analysis_out | 553K | 无 | emr_id | 影像 AI |
| fact_customer_referee_out | 499K | 无 | 无 | 转介 |
<Callout type="info">
**提频(如 2h 一轮)的成本**——由**空轮短路**决定,不是 cohort 大小:
- **cohort 选择每轮都跑**(含空轮):cursor 列(`updated_date` 等)通常**不是 CH 排序键
→ 无索引 → 每轮全列扫**。但只扫单列,9M 行亚秒级,几张合计几秒/轮,绝对量可忽略。
- **资源拉取只在 cohort 非空时跑**:上游多为日更 → 日内多数轮 cohort 为空 →
在选 cohort 后即**短路**(跳过资源拉取 + persona/plan 重算),不触碰上面两张最大的无 cursor 表。
- 所以提频主要增加的是**廉价的空轮探查**,重活(资源全表扫 + 重算)仍是刷新后 1–2 轮,不被放大。
真要压这点空轮扫描,治本是给 cursor 表的 `updated_date` 加 CH 数据跳过索引;收益很小,通常不值当。
</Callout>
--- ---
## 三、数据完整性 ## 三、数据完整性
...@@ -102,7 +142,15 @@ pnpm sync -- --dir=./data/<host> --full # 忽略游标强制全量(灾 ...@@ -102,7 +142,15 @@ pnpm sync -- --dir=./data/<host> --full # 忽略游标强制全量(灾
- `patient_transactions`:partial UNIQUE `(host_id, tenant_id, source_event_id) WHERE source_event_id NOT NULL` - `patient_transactions`:partial UNIQUE `(host_id, tenant_id, source_event_id) WHERE source_event_id NOT NULL`
- `patient_facts`:UNIQUE `(host_id, tenant_id, subject_id, version)` + active 行 partial UNIQUE - `patient_facts`:UNIQUE `(host_id, tenant_id, subject_id, version)` + active 行 partial UNIQUE
`source_event_id` 内含 `updatedAt`,因此源数据行级 in-place 更新会自然产生新 tx 与新 fact 版本(旧版 supersede);同行同 `updatedAt` 则幂等跳过。 `source_event_id` 内含 `updatedAt`(缺失时确定性回退 `createdAt`),因此源数据行级 in-place 更新会自然产生新 tx 与新 fact 版本(旧版 supersede);同行同 `updatedAt` 则幂等跳过。fact 层的变更判定是**内容驱动**的(比对 content hash + 状态 + 时间锚,不看 `updatedAt`),所以即使事务被幂等跳过,只要行被重新处理,内容真变了 fact 仍会升版本——变更不会因时间戳丢失而漏掉。
<Callout type="warn">
**`updatedAt` 维护缺失的两个后果**(如某些源表主档 `updated_gmt_at` 大量为 null)。当前设计有意保留 `updatedAt` 在幂等键中,**不改**;接入时按下面两点评估:
1. **pull/增量取数够不到**:增量按 `WHERE updated_date > cursor` 拉数,**内容变了但 `updatedAt` 没随之更新的行根本不会被选出来**——这道过滤在幂等之前,fact 层内容比对再准也无行可比。file 全量装载 / push(宿主自选推送)不受此限(不按 cursor 过滤)。治本靠源表维护 `updatedAt`;主档另有定期全量反拉兜底(见上「主档补全」)。
2. **事务与 fact 可能不一致 → reparse 会打回**:若内容变了但 `updatedAt` 没变,事务因 `source_event_id` 未变被幂等跳过(`rawPayload` 停在旧内容),但 fact 层仍按新内容升版本 —— 二者内容不再一致。此后 [reparse](#52-reparse--字典改了补存量无需全量重摄) 从 `transaction.rawPayload`(旧值)离线重放,会把 fact 打回旧内容。**规避**:对 `updatedAt` 不可靠的源表,不要依赖 reparse 修其存量,改走 DW 重摄 / 重推。
</Callout>
**主档补全(两条保障,确保 patient_id 不丢)**: **主档补全(两条保障,确保 patient_id 不丢)**:
...@@ -148,6 +196,7 @@ pnpm sync -- --dir=./data/<host> --full # 忽略游标强制全量(灾 ...@@ -148,6 +196,7 @@ pnpm sync -- --dir=./data/<host> --full # 忽略游标强制全量(灾
- **不连数据源**:rawPayload 在本地,比全量重摄快一个量级; - **不连数据源**:rawPayload 在本地,比全量重摄快一个量级;
- **可圈定**:按 host / subject-type / 患者集 / dry-run / 是否重算; - **可圈定**:按 host / subject-type / 患者集 / dry-run / 是否重算;
- **覆盖范围**:field/enum/keyword_mapping、transforms 算子、parser 改动;**不覆盖**改了 `sql_source` 的 SELECT 本身(需从数据源多拉列/表,那才需真重摄)。非 transform 产出的资源(如 SQL 视图类)会自动跳过。 - **覆盖范围**:field/enum/keyword_mapping、transforms 算子、parser 改动;**不覆盖**改了 `sql_source` 的 SELECT 本身(需从数据源多拉列/表,那才需真重摄)。非 transform 产出的资源(如 SQL 视图类)会自动跳过。
- **前提:rawPayload 是最新的**。reparse 只能和源行的最后一次落库一样新。若某源表 `updatedAt` 维护缺失,曾出现「内容变了但事务被幂等跳过」(见 §三 Callout),其 `rawPayload` 已滞后 —— 对这类表 reparse 会把 fact 打回旧内容,应改走重摄 / 重推而非 reparse。
```bash ```bash
# dry-run:报 scope(患者数 / txn 数),不写库 # dry-run:报 scope(患者数 / txn 数),不写库
......
--- ---
title: FRIDAY 推送数据契约 title: FRIDAY 推送数据契约
description: FRIDAY SaaS 按形态 A 推送的 15 个 source 及字段定义;与已验证的存量导出形状一致,push 无缝衔接 description: FRIDAY SaaS 按形态 A 推送的 9 个 source 及字段定义;宿主推自洽的业务表(自己的引用 inline 进相关行),PAC 做跨宿主临床归一
icon: FileJson icon: FileJson
--- ---
> 本文是 [Push 通道](/docs/integration/channel-push)**形态 A** 在 FRIDAY SaaS 宿主的具体化:每个 `source` 的 JSON 字段清单。 > 本文是 [Push 通道](/docs/integration/channel-push)**形态 A** 在 FRIDAY SaaS 宿主的具体化:每个 `source` 的 JSON 字段清单。
> 字段形状与 PAC 已完成的存量摄入**完全一致**——宿主按此推送,与存量自动衔接: > 字段形状与 PAC 已完成的存量摄入**完全一致**——宿主按此推送,与存量自动衔接:
> 同行同 `updated_gmt_at` 幂等去重,行更新(`updated_gmt_at` 变)自动版本演进。**重叠无害,宁多勿漏**。 > 同行按幂等键去重,行更新(`updated_gmt_at` 变)自动版本演进。**重叠无害,宁多勿漏**。
> 时间字段的可空语义与 `created_gmt_at` 兜底见 §1 通用约定。
--- ---
...@@ -24,20 +25,33 @@ icon: FileJson ...@@ -24,20 +25,33 @@ icon: FileJson
|---|---| |---|---|
| **id 类字段一律字符串** | `patient_id` / `id` / `appointment_id` 等统一 String(数值型会被 PAC 校验拒收——存量摄入实测踩过的坑) | | **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 小时) | | **时间格式** | 统一传 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,该行的更新会被永久去重丢弃 | | **`updated_gmt_at`** | **可空,缺失不影响变更捕获**。PAC 的变更识别是**内容驱动**的:按 `(subjectId + 内容 hash + 状态 + 时间锚)` 判定,不看 `updated_gmt_at`——同一 `subjectId` 内容变了就升版本,没变就幂等去重。所以只要 `subjectId` 稳定、内容如实推,时间字段缺不缺都不丢更新。`updated_gmt_at` 仅用于**乱序防护**(补推的旧快照晚到时不覆盖新版本);为 null 时该保护退回"按到达顺序",对正常单向推送无影响。**无需宿主侧 COALESCE 兜底,重推安全。** |
| **空值** | 传 `null` 或省略字段;不要传 `"NULL"` 字符串 | | **空值** | 传 `null` 或省略字段;不要传 `"NULL"` 字符串 |
| **金额单位** | **元**(decimal 原值,实证:洁治单价均值 ¥310/已结算客单均值 ¥1,730);PAC 侧转分 | | **金额单位** | **元**(decimal 原值,实证:洁治单价均值 ¥310/已结算客单均值 ¥1,730);PAC 侧转分 |
| **投递语义** | at-least-once:未收到 2xx 确认就重推;重复由 PAC 幂等键吸收 | | **投递语义** | at-least-once:未收到 2xx 确认就重推;重复由 PAC 幂等键吸收 |
> **职责边界(重要)**:宿主**在推送前解析好自己的引用**,把结果 inline 进相关业务行 ——
> ① 私有字典码(诊断 `linkCode→std_code`、影像 `class_code→类型名`)② 1:N 派生标量(默认联系号 + 归属)
> ③ 头-行属性(计划头的诊所/方案名下放到计划行)。这些**都是宿主自己的 within-host join**,数据在它手里、成本极低。
> PAC 只做**跨宿主的临床归一**(共享 K 码/modality/治疗类别字典、金额单位、时区、结算 status 切分、变更检测/版本)。
> 好处:每张 source 自洽,增量推变更行天然成立,无跨表依赖。
>
> **两条配套纪律**:① **整表照推,宿主不做任何行过滤**——status/金额范围等切分全部由 PAC 完成
> (单一真理源,与 file / cold-import 同口径),故本文各表**不再规定「推送范围」**;
> ② **列名一律沿用宿主原表列名**——包括 inline 进来的列(`contacts_tel`/`relationship`/`std_code`/
> `class_name`/`organization_id`/`plan_name`),PAC 不发明新名,宿主无需为 PAC 做字段改名。
--- ---
## 2. 患者与关系(3 个 source) ## 2. 患者(2 个 source)
### `customer_basic_info` — 患者主档 ### `customer_basic_info` — 患者主档
> ✅ 患者主档已支持 push(2026-07-20):**部分更新语义** —— 只更新本次提供的字段, > **部分更新语义**:只更新本次提供的字段,未提供的不覆盖存量;push 无"清空字段"语义,需清空走全量通道。
> 未提供的字段不覆盖存量(如只推 `customer_basic_info` 时 phone 不在场 → 保留库内号码); >
> push 无"清空字段"语义,需要清空走全量通道。`customer_referee_circle`(关系边)同样可 push。 > **联系电话已 inline(不再单独推 customer_contacts)**:宿主按 `is_default↓ → contacts_type↑(1=本人优先) → id↑`
> **挑出默认号**,把号码与归属 inline 进本表的 `contacts_tel` / `relationship` 两列(沿用 customer_contacts 原列名)。PAC 现只消费这一个有效号 + 归属
> (存 `patient.phone` 与 `contactPhone.isSelf`),不需要完整通讯录 → 联系方式 1:N 表无需摄入。改号 = 重推本表。
| 字段 | 类型 | 必填 | 说明 | | 字段 | 类型 | 必填 | 说明 |
|---|---|---|---| |---|---|---|---|
...@@ -48,20 +62,10 @@ icon: FileJson ...@@ -48,20 +62,10 @@ icon: FileJson
| `file_number` | string | | 病历号/档案号(客服沟通用) | | `file_number` | string | | 病历号/档案号(客服沟通用) |
| `tenant_id` | string | ✅ | 品牌 GUID(宿主原生列) | | `tenant_id` | string | ✅ | 品牌 GUID(宿主原生列) |
| `organization_id` | string | | 建档诊所 | | `organization_id` | string | | 建档诊所 |
| `contacts_tel` | string | | **默认联系号(宿主 inline)**——按上述规则挑出的有效号码(= customer_contacts 原列名) |
| `relationship` | string/number | | **该号归属(宿主 inline,= customer_contacts 原列名)**——`PhoneRelationshipEnum`:`1`本人 `2`爸爸 `3`妈妈 `4`爷爷 `5`奶奶 `6`朋友 `7`配偶 `8`子女 `9`其他(PAC 存 `isSelf`=码=='1') |
| `created_gmt_at` / `updated_gmt_at` | string(datetime) | ✅ | 建档/更新时间 | | `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` — 转介绍圈(患者-患者关系) ### `customer_referee_circle` — 转介绍圈(患者-患者关系)
| 字段 | 类型 | 必填 | 说明 | | 字段 | 类型 | 必填 | 说明 |
...@@ -97,12 +101,10 @@ icon: FileJson ...@@ -97,12 +101,10 @@ icon: FileJson
--- ---
## 4. 病历链(3 个 source) ## 4. 病历链(2 个 source)
### `med_emr_info` — 病历正文(Mongo 平铺;一张表喂诊断/治疗/建议/复查/病历全链) ### `med_emr_info` — 病历正文(Mongo 平铺;一张表喂诊断/治疗/建议/复查/病历全链)
**推送范围:`status ∈ {3, 4}`(已完成/归档);`status=2` 未完成草稿不推。**
| 字段 | 类型 | 必填 | 说明 | | 字段 | 类型 | 必填 | 说明 |
|---|---|---|---| |---|---|---|---|
| `emr_sub_id` | string | ✅ | 小病历号 uuid(病历主键;临床事实同诊聚合锚) | | `emr_sub_id` | string | ✅ | 小病历号 uuid(病历主键;临床事实同诊聚合锚) |
...@@ -115,8 +117,8 @@ icon: FileJson ...@@ -115,8 +117,8 @@ icon: FileJson
| `user_id` / `user_name` | string | | 接诊医生 id / 姓名 | | `user_id` / `user_name` | string | | 接诊医生 id / 姓名 |
| `clinic_time` | string(datetime) | ✅ | 就诊时刻 | | `clinic_time` | string(datetime) | ✅ | 就诊时刻 |
| `visit_indicator` | number | | `1`初诊 `2`复诊 | | `visit_indicator` | number | | `1`初诊 `2`复诊 |
| `status` | number | ✅ | `3`已完成 `4`归档(推送范围) | | `status` | number | ✅ | `3`已完成 `4`归档 `2`未完成草稿(整表照推,PAC 侧只取 3/4) |
| `diag` | array | | 诊断数组,元素 `{ "toothPosition": "41;42", "value": "慢性牙髓炎", "linkCode": "<std_diag.diag_code 或空>" }` | | `diag` | array | | 诊断数组,元素 `{ "toothPosition": "41;42", "value": "慢性牙髓炎", "std_code": "<宿主已解析的标准码或空>" }`。**`std_code` 由宿主 inline**——原 `linkCode` 经自家 `std_diag` 解析所得,沿用 std_diag 原列名;K 开头 PAC 截 K 大类,非 K/空按自由文本 |
| `treat` | array | | 本次治疗数组,元素同上结构(`value`=治疗名) | | `treat` | array | | 本次治疗数组,元素同上结构(`value`=治疗名) |
| `dispose` | array | | 处置叙述数组,元素 `{ "toothPosition", "value" }` | | `dispose` | array | | 处置叙述数组,元素 `{ "toothPosition", "value" }` |
| `examine` | array | | 检查所见数组,元素同上 | | `examine` | array | | 检查所见数组,元素同上 |
...@@ -129,13 +131,8 @@ icon: FileJson ...@@ -129,13 +131,8 @@ icon: FileJson
> 牙位格式:FDI 牙位号,多牙分号分隔,可带牙面字母(`"42 L;43 D"` / `"11;12;13"`)。 > 牙位格式:FDI 牙位号,多牙分号分隔,可带牙面字母(`"42 L;43 D"` / `"11;12;13"`)。
### `std_diag` — 诊断字典(低频;变更时全量重推) > **`std_diag` 不再单独推**——它是纯翻译字典(`linkCode→std_code`),离开诊断行无独立意义。
> 宿主在导出/推 `med_emr_info` 时,把 `diag[].linkCode` 用自家 `std_diag` 解析成 `std_code` **inline 进 diag 元素**即可。
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `diag_code` | string | ✅ | 字典主键(= `diag[].linkCode` 指向) |
| `diag_name` | string | ✅ | 诊断名 |
| `std_code` | string | | 标准码(`K02.901` 等;K 开头 PAC 截 K 大类,非 K 按自由文本处理) |
### `med_check` — 影像档案(metadata,不含文件本体) ### `med_check` — 影像档案(metadata,不含文件本体)
...@@ -147,28 +144,33 @@ icon: FileJson ...@@ -147,28 +144,33 @@ icon: FileJson
| `organization_name` | string | | 诊所名称(同上,诊所名字典来源之一) | | `organization_name` | string | | 诊所名称(同上,诊所名字典来源之一) |
| `patient_id` | string | ✅ | → 患者 id | | `patient_id` | string | ✅ | → 患者 id |
| `emr_id` | string | | → 病历号(`med_emr_info.emr_id`) | | `emr_id` | string | | → 病历号(`med_emr_info.emr_id`) |
| `class_code` | string | | 影像类型 → `std_check_class` | | `class_name` | string | | **影像类型名(宿主 inline)**——原 `class_code` 经自家 `std_check_class` 解析成类型名(口内像照片/小牙片/全景片/CT影像截图/口腔扫描…);PAC 再映射成 modality |
| `file_name` / `file_type` | string | | 文件名 / 类型 | | `file_name` / `file_type` | string | | 文件名 / 类型 |
| `file_url` | string | | OSS 相对路径 | | `file_url` | string | | OSS 相对路径 |
| `files_size` | number | | 字节数 | | `files_size` | number | | 字节数 |
| `shooting_time` | string(datetime) | ✅ | 拍摄时间 | | `shooting_time` | string(datetime) | ✅ | 拍摄时间 |
| `created_gmt_at` / `updated_gmt_at` | string(datetime) | ✅ | | | `created_gmt_at` / `updated_gmt_at` | string(datetime) | ✅ | |
配套字典 **`std_check_class`**(低频):`class_code`(主键) / `class_name`(口内像照片、小牙片、全景片…) / `tenant_id`。 > **`std_check_class` 不再单独推**——同 `std_diag`,纯翻译字典。宿主把 `med_check.class_code`
> 用自家字典解析成 `class_name` **inline 进 med_check 行**即可。
--- ---
## 5. 结算链(4 个 source) ## 5. 结算链(2 个 source)
> **拆分规则(宿主侧按 status/金额分流成两个 source 推)**。`status` 官方语义 > **结算原表照抄推送,宿主不做任何 status/金额过滤或分流**——消费 / 整单退费 / 行级退费明细的
> (`PatientSettlementEntity` 实体注释,比枚举类更全):`0`未结算 `1`已结算 `2`只生成uuid > 切分全部由 PAC transforms 完成(单一真理源,与 file / cold-import 同口径)。宿主推 2 张原表:
> `3`退款 `4`插入的退款数据 `5`重新结算数据(废弃历史行) `6`医生未提交 `7`流程结束 > `patient_settlement`(结算头,全 status)、`patient_settlement_spec`(结算明细,全量)。
> `8`欠款补缴插入数据。**0/2/5/6/7/8 均不推**——其中 `8` 是原单克隆(receivable_this > **支付金额取结算头 `net_receipts_this`(实收);支付通道明细(`settlement_modes`)暂不摄入**
> 原样复制,`payment_arrears`=本次补缴额,ref 挂原欠费单),入消费会双计应收;`7` 零金额流程关单。 > (总值在结算头,PAC 当前无需通道拆分;若后续要"支付方式分析"再加)。
>
> `status` 全值照推,PAC 侧识别(`PatientSettlementEntity` 实体注释):`0`未结算 `1`已结算
> `2`只生成uuid `3`含退费行 `4`整单反向冲减 `5`重新结算(废弃历史) `6`医生未提交 `7`流程结束
> `8`欠款补缴克隆单。PAC 取:**消费**=`status∈{1,3}` 且 `receivable_this≥0`;**整单退费**=`status=4`
> 或(`status=3` 且 `receivable_this<0`);**退费明细**=`patient_settlement_spec.is_refund=1`。
> 其余(`0/2/5/6/7/8`)PAC 丢弃(`8` 原单克隆入消费会双计应收、`7` 零金额流程关单)。
### `patient_settlement` — 消费单 ### `patient_settlement` — 结算头原表(全 status 照推,含消费与整单退费)
**推送范围:`status ∈ {1, 3}` 且 `receivable_this >= 0`。**
| 字段 | 类型 | 必填 | 说明 | | 字段 | 类型 | 必填 | 说明 |
|---|---|---|---| |---|---|---|---|
...@@ -177,74 +179,47 @@ icon: FileJson ...@@ -177,74 +179,47 @@ icon: FileJson
| `organization_id` | string | ✅ | 诊所 | | `organization_id` | string | ✅ | 诊所 |
| `patient_id` | string | ✅ | → 患者 id | | `patient_id` | string | ✅ | → 患者 id |
| `doctor_id` | string | | 开单医生 | | `doctor_id` | string | | 开单医生 |
| `status` | string/number | ✅ | 见上 | | `status` | string/number | ✅ | 全值照推(见 §5 引言;PAC 侧切分) |
| `receivable_this` | number(元) | ✅ | 应收(PAC 计患者价值/LTV 用) | | `receivable_this` | number(元) | ✅ | 应收(PAC 计患者价值/LTV 用);**可为负 = 退费表达**(status=4 或部分品牌 status=3 负额) |
| `net_receipts_this` | number(元) | | 实收 | | `net_receipts_this` | number(元) | | 实收(退费行为负,PAC 取绝对值) |
| `billing_date` | string(datetime) | | 开单时间 | | `billing_date` | string(datetime) | | 开单时间 |
| `registration_id` | string | | 关联接诊号 | | `registration_id` | string | | 关联接诊号 |
| `ref_settlement_id` | string | | (消费单一般为空) | | `ref_settlement_id` | string | | **退费行(status=4)= 被退的原结算单 uuid**(PAC 据此把退费挂回原消费);消费行一般空 |
| `settlement_serial_num` | string | | 结算流水号 | | `settlement_serial_num` | string | | 结算流水号 |
| `reason` | string | | 备注/原因 | | `reason` | string | | 备注/原因 |
| `created_gmt_at` / `updated_gmt_at` | string(datetime) | ✅ | | | `created_gmt_at` / `updated_gmt_at` | string(datetime) | ✅ | |
### `patient_settlement_refund` — 整单退费 ### `patient_settlement_spec` — 结算明细原表(全量照推,含行级退费)
**推送范围:`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 | ✅ | 明细行主键 | | `id` | string | ✅ | 明细行主键 |
| `tenant_id` / `organization_id` / `patient_id` | string | ✅ | 同上 | | `tenant_id` / `organization_id` | string | ✅ | 品牌 / 诊所 |
| `settlement_id` | string | ✅ | 所属结算单 uuid(退费挂回原消费) | | `settlement_id` | string | ✅ | 所属结算单 uuid(退费挂回原消费) |
| `cure_name` / `service_project_name` | string | | 被退项目名 | | `patient_id` | string | ✅ | **宿主已反填真 patient_id(inline)**——原生 spec.patient_id 部分品牌是诊所本地 id、不可信(实测元和王永全列与结算头不符),导出时按 `settlement_id` 从结算头取真值反填;PAC 直接用 |
| `receivable_this` / `net_receipts_this` | number(元) | ✅ | 被退金额(正值) | | `cure_name` / `service_project_name` | string | | 项目名(退费行=被退项目) |
| `is_refund` | string/number | ✅ | 恒为 `1` | | `receivable_this` / `net_receipts_this` | number(元) | | 金额(`is_refund=1` 时为被退金额,正值) |
| `is_refund` | string/number | ✅ | `1`=退费行(PAC 只取此值切退费明细) `0`=正常明细 `2`=其他 |
| `created_gmt_at` / `updated_gmt_at` | string(datetime) | ✅ | | | `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) ## 6. 治疗计划与咨询(2 个 source)
### `customer_treat_plan` — 治疗计划(头) ### `customer_treat_plan_item` — 治疗计划(行;头属性已 inline)
| 字段 | 类型 | 必填 | 说明 | > **计划头 `customer_treat_plan` 不再单独推**——PAC 只从头取"诊所 + 方案名"两个属性给计划行,
|---|---|---|---| > 头不是 PAC 实体。宿主把头的 `organization_id` / `plan_name` **inline 进每条计划行**即可;
| `id` | string | ✅ | 计划主键 | > 头的 `expect_cost`/`desired_effect` PAC 暂不消费(将来做计划价值/LTV 再约定加)。
| `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 | ✅ | 行主键 | | `id` | string | ✅ | 行主键 |
| `tenant_id` | string | ✅ | 品牌 GUID | | `tenant_id` | string | ✅ | 品牌 GUID |
| `customer_id` | string | ✅ | → 患者 id | | `customer_id` | string | ✅ | → 患者 id |
| `treat_plan_id` | string | ✅ | → 计划头 id | | `organization_id` | string | ✅ | **诊所(宿主 inline,取自计划头)**——缺失该行会被 PAC 丢弃(计划事实必须归属诊所) |
| `plan_name` | string | | **方案名(宿主 inline,取自计划头)** |
| `treat_plan_id` | string | | → 计划头 id(留作溯源,PAC 不再靠它 lookup) |
| `tooth_position` | string | | 牙位 | | `tooth_position` | string | | 牙位 |
| `mode_name` | string | ✅ | 术式名(PAC 翻 11 类治疗类别) | | `mode_name` | string | ✅ | 术式名(PAC 翻 11 类治疗类别) |
| `charge_min` / `charge_max` | number(元) | | 价格区间 | | `charge_min` / `charge_max` | number(元) | | 价格区间 |
...@@ -270,7 +245,67 @@ icon: FileJson ...@@ -270,7 +245,67 @@ icon: FileJson
--- ---
## 7. 语义澄清记录(2026-07,源码 + 数据双证) ## 7. 对接自测(预检 + 推送记录)
对接期不必盲推 —— PAC 提供两个自助工具,**测试环境** `https://pac.jarvismedical.asia`:
### 7.1 预检:`?dryRun=1`(强烈建议先跑)
```
POST /pac/v1/push/rows?dryRun=1
```
跑**完整管线**(transforms + 装配 + 归一 + 租户解析)但**不落任何库、不触发重算**,响应告诉你"若正式推会发生什么",并带**样本 canonical** —— 直接核对 PAC 把你的原生行翻译成了什么:
```jsonc
{"code":0,"data":{
"dryRun": true,
"accepted": 6, // 收到 6 行
"transactionsWritten": 5, // 其中 5 行会落库(差值 = 被 PAC 规则过滤,如噪音 status)
"failed": 0,
"mappingMisses": 0, // ⚠️ >0:有原值没映射上(落 _default)
"suspectFields": 0, // ⚠️ >0:疑似列改名/缺列
"samples": [ { "resource": "payment", "canonical": { "externalId": "…", "amount": 40000, … } } ]
}}
```
**自测通过标准**:`failed=0` 且 `mappingMisses=0` 且 `suspectFields=0`,再看 `samples` 里的字段值对不对 —— 都 OK 才去掉 `?dryRun=1` 正式推。
### 7.2 推送记录:事后可查(两种查法)
**① 免登录 —— 用已有 push 凭据验签(联调推荐)**
```
GET /pac/v1/push/logs?limit=50
Headers: X-PAC-Host-Id / X-PAC-Timestamp / X-PAC-Signature
```
鉴权方式与推送完全一致,**不必为联调另开账号**。GET 无 body,签名对**空串**计算:
`hex(HMAC-SHA256("<timestamp>.", secret))`(注意 timestamp 后那个点)。
这**不是公开接口** —— 没有正确签名拿不到任何数据。
**② 登录态 —— 宿主自助页同源接口**
```
GET /pac/v1/admin/host/self/push-logs?limit=50
```
两者返回**同一份数据**:按时间倒序最近 N 批(默认 50、上限 200),每批含
`source / status / dryRun / fetched / transactionsWritten / duplicates / failed / errorMessage`。
当时的同步响应没留存也能事后查:**哪批错了、错在哪**。预检批次由 `dryRun:true` 标识,不与正式推送混淆。
### 7.3 其他可用面
| 用途 | 地址 |
|---|---|
| 交互式 API 文档(可试调) | `/api/docs` |
| 本契约文档 | `/docs/integration/friday-push-payload` |
| 宿主自助页(患者数 / 24h 交易 / 上次 push / 失败率) | `/admin/host` |
| 队列面板(看 push 触发的画像重算) | `/admin/queues` |
> **常见错误码**:`10106` 缺 header / 签名不匹配(或时间戳偏差 >5min)· `10001` source 拼错
> (响应会列出可选源表名)· `10002` body 校验失败 · `10003` 并发超限(退避重发即可,幂等安全)
> · `30802` 整批失败,疑似关键字段缺名/改名。
---
## 8. 语义澄清记录(2026-07,源码 + 数据双证)
以下曾是开放问题,现已定案 —— 推送方无需再确认,列此备查: 以下曾是开放问题,现已定案 —— 推送方无需再确认,列此备查:
...@@ -278,7 +313,7 @@ icon: FileJson ...@@ -278,7 +313,7 @@ icon: FileJson
|---|---|---| |---|---|---|
| 时间口径 | MySQL `datetime` = 北京墙钟(`_gmt_` 命名系惯例误导);**Mongo `Date` 是北京墙钟伪装成 UTC** | `clinicTime`/`createdGmtAt` 小时分布呈营业双峰;按真 UTC 解释则半数病历落深夜 | | 时间口径 | MySQL `datetime` = 北京墙钟(`_gmt_` 命名系惯例误导);**Mongo `Date` 是北京墙钟伪装成 UTC** | `clinicTime`/`createdGmtAt` 小时分布呈营业双峰;按真 UTC 解释则半数病历落深夜 |
| 金额单位 | 元(`decimal(9,2)`) | 洁治单价均值 ¥310、已结算客单均值 ¥1,730 | | 金额单位 | 元(`decimal(9,2)`) | 洁治单价均值 ¥310、已结算客单均值 ¥1,730 |
| `contacts.relationship` | `PhoneRelationshipEnum` 1-9(见 §2) | customer 服务枚举类 | | `relationship`(默认号归属) | `PhoneRelationshipEnum` 1-9(见 §2;宿主挑默认号后 inline) | customer 服务枚举类 |
| `referee_relationship` | `RecommendRelationshipEnum` 17 码,**语义 = 本行 customer 是 referee 的 X**(PAC 侧按逆关系映射) | 枚举类 `reverseValue()` + 双方年龄差数据 | | `referee_relationship` | `RecommendRelationshipEnum` 17 码,**语义 = 本行 customer 是 referee 的 X**(PAC 侧按逆关系映射) | 枚举类 `reverseValue()` + 双方年龄差数据 |
| `med_emr_info.treat` vs `dispose` | `treat` = 本次治疗(→ 结构化治疗事实);`dispose` = 处置叙述(→ 病历自由文本) | EMR 接口 DTO 字段注释 | | `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()` 实现 | | `patient_settlement.status` 全 9 值 | `0`未结算 `1`已结算 `2`只生成uuid `3`含退费行 `4`整单反向冲减 `5`重新结算(废弃历史) `6`医生未提交 `7`流程结束 `8`欠款补缴克隆单 | 实体注释 + `savePatientSettlementQk()` 实现 |
...@@ -290,5 +325,6 @@ icon: FileJson ...@@ -290,5 +325,6 @@ icon: FileJson
|---|---| |---|---|
| `organization_name` 覆盖 | 目前仅病历/影像表带诊所名,**只覆盖有病历记录的诊所**;其余诊所前端回退显示 GUID。若贵方有组织树接口(如 `regional_nodes_structure`),提供 `诊所 id → 名称` 全量表可补齐 | | `organization_name` 覆盖 | 目前仅病历/影像表带诊所名,**只覆盖有病历记录的诊所**;其余诊所前端回退显示 GUID。若贵方有组织树接口(如 `regional_nodes_structure`),提供 `诊所 id → 名称` 全量表可补齐 |
| 品牌名 | PAC 当前**不需要**品牌中文名(无展示位,内部按品牌 id 标识)。将来若有展示需求,再约定推品牌主档(`tenant_apply.tenant_info` 的 `tenant_id`/`name`)——贵方 `tenant_name` 未冗余进业务表,只能走主档 | | 品牌名 | PAC 当前**不需要**品牌中文名(无展示位,内部按品牌 id 标识)。将来若有展示需求,再约定推品牌主档(`tenant_apply.tenant_info` 的 `tenant_id`/`name`)——贵方 `tenant_name` 未冗余进业务表,只能走主档 |
| 字典表推送频率 | `std_diag` / `std_check_class` 变更不频繁,按"变更时全量重推"即可,无需增量 | | 私有字典不单独推 | `std_diag`(生产多品牌合计 1.5万~3万+行)/ `std_check_class`:纯翻译字典,**由宿主解析好 inline** 进 `diag[].std_code` / `med_check.class_name`。既省掉大表全量重推,也省掉 PAC 侧持久化字典存储——PAC 只留跨宿主的 `_shared` 临床字典 |
| 支付通道 | `settlement_modes`(约 24% 结算单多通道拆付)暂不摄入:总值已在结算头 `net_receipts_this`,PAC 当前无支付方式分析需求。将来要"医保占比/分次支付"再摄入通道明细 |
| 测试环境数据特征 | 部分租户为开发沙盒(诊所名如"XX专用诊所勿动"),字典类映射(治疗类别关键词等)待**生产数据**回流后再校准一轮 | | 测试环境数据特征 | 部分租户为开发沙盒(诊所名如"XX专用诊所勿动"),字典类映射(治疗类别关键词等)待**生产数据**回流后再校准一轮 |
...@@ -14,6 +14,7 @@ ...@@ -14,6 +14,7 @@
"deployment", "deployment",
"deploy-runbook", "deploy-runbook",
"ingestion", "ingestion",
"data-reconciliation",
"monitoring", "monitoring",
"troubleshooting", "troubleshooting",
"---参考---", "---参考---",
......
This source diff could not be displayed because it is too large. You can view the blob instead.
# diagnosis — FRIDAY 诊断 fact(med_emr_info.diag[] 拆行) # diagnosis — FRIDAY 诊断 fact(med_emr_info.diag[] 拆行)
# 消费 transforms 输出表 diagnosis_rows(split + std_diag lookup + normalize/substring/coalesce) # 消费 transforms 输出表 diagnosis_rows(split + normalize/substring/coalesce;std_code 由宿主 inline 进 diag 元素)
# #
# code 解析规则(用户定,2026-07-18): # code 解析规则(用户定,2026-07-18):
# linkCode 非空 → std_diag.std_code K 开头 → 截 K 大类(std_code_k 直通下方共享字典 passthrough); # linkCode 非空 → std_diag.std_code K 开头 → 截 K 大类(std_code_k 直通下方共享字典 passthrough);
......
...@@ -10,7 +10,7 @@ emits: ...@@ -10,7 +10,7 @@ emits:
occurredAtField: submittedAt occurredAtField: submittedAt
primary: primary:
table: med_emr_info table: _emr_official # transforms 产出:med_emr_info status∈{3,4}(排草稿;迁自导出侧过滤)
key: emr_sub_id # 小病历号 uuid(两侧连接键 + 临床事实锚点) key: emr_sub_id # 小病历号 uuid(两侧连接键 + 临床事实锚点)
dedup_by: emr_sub_id dedup_by: emr_sub_id
......
# image — FRIDAY 影像 metadata(MySQL emr.med_check;PAC 不持文件本体) # image — FRIDAY 影像 metadata(MySQL emr.med_check;PAC 不持文件本体)
# 影像不在 Mongo 病历文档内(全字段枚举证实),挂 emr_id(10 位病历号)回病历。 # 影像不在 Mongo 病历文档内(全字段枚举证实),挂 emr_id(10 位病历号)回病历。
# 消费 transforms 输出表 image_rows(med_check lookup std_check_class 附 class_name) # 直接消费 med_check(class_name 由宿主 inline:class_code→std_check_class 已解析);modality 经共享字典翻
canonical: image canonical: image
emits: emits:
...@@ -9,7 +9,7 @@ emits: ...@@ -9,7 +9,7 @@ emits:
occurredAtField: uploadedAt occurredAtField: uploadedAt
primary: primary:
table: image_rows table: med_check
key: id key: id
dedup_by: id dedup_by: id
...@@ -21,7 +21,7 @@ field_mapping: ...@@ -21,7 +21,7 @@ field_mapping:
clinicId: organization_id clinicId: organization_id
uploadedAt: shooting_time # 拍摄时间 uploadedAt: shooting_time # 拍摄时间
encounterExternalId: emr_id # 病历号级挂载(= med_emr_info.emr_id,非 emr_sub_id) encounterExternalId: emr_id # 病历号级挂载(= med_emr_info.emr_id,非 emr_sub_id)
modality: class_name # lookup 自 std_check_class 字典 modality: class_name # 宿主 inline(class_code→std_check_class 已解析)→ 共享字典翻
fileUrl: file_url # OSS 相对路径(passthrough) fileUrl: file_url # OSS 相对路径(passthrough)
fileName: file_name fileName: file_name
......
# patient — 主档 upsert(不进 transaction,无 emits) # patient — 主档 upsert(不进 transaction,无 emits)
# 源:customer.customer_basic_info(原始表);phone 由 manifest.transforms.lookup # 源:customer.customer_basic_info;contacts_tel / relationship 由宿主导出时挑默认号 inline 进主档
# 从 customer_contacts 挑默认号附加进来,故这里直接映射标量 phone # (不再 PAC 侧 lookup customer_contacts),故这里直接映射标量
canonical: patient canonical: patient
primary: primary:
...@@ -10,12 +10,12 @@ primary: ...@@ -10,12 +10,12 @@ primary:
field_mapping: 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: contacts_tel # 宿主 inline 的默认号(沿用 customer_contacts 原列名)
# 该号关系码(lookup 同源带出)→ preferences.contactPhone{relationshipCode, relationship, isSelf}。 # 该号关系码(lookup 同源带出)→ preferences.contactPhone{relationshipCode, relationship, isSelf}。
# 官方枚举(源码实锤 friday-saas/customer …/enums/PhoneRelationshipEnum.java,2026-07-20): # 官方枚举(源码实锤 friday-saas/customer …/enums/PhoneRelationshipEnum.java,2026-07-20):
# 1本人 2爸爸 3妈妈 4爷爷 5奶奶 6朋友 7配偶 8子女 9其他(语义=持号人是患者的X) # 1本人 2爸爸 3妈妈 4爷爷 5奶奶 6朋友 7配偶 8子女 9其他(语义=持号人是患者的X)
phoneRelationshipCode: phone_relationship phoneRelationshipCode: relationship
phoneRelationship: phone_relationship # 同源列解码成 PAC 关系词(下方 enum_mapping) phoneRelationship: 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 # 档案号(病历号,客服沟通用)
......
...@@ -26,7 +26,8 @@ field_mapping: ...@@ -26,7 +26,8 @@ field_mapping:
# 应收降级 receivableAmount(折扣率分母/权益客识别)。 # 应收降级 receivableAmount(折扣率分母/权益客识别)。
amount: net_receipts_this amount: net_receipts_this
receivableAmount: receivable_this receivableAmount: receivable_this
method: method # lookup 主导支付通道(现金/微信/刷卡/医保…原样入 fact) # method 暂不接:支付通道明细(settlement_modes)不摄入,金额取结算头 net_receipts_this。
# 将来要"支付方式分析"(医保占比/分次支付)再摄入 settlement_modes、恢复 method。
doctorId: doctor_id doctorId: doctor_id
encounterExternalId: registration_id # 关联接诊 encounterExternalId: registration_id # 关联接诊
settlementSerialNum: settlement_serial_num # 结算流水号(passthrough,对账用) settlementSerialNum: settlement_serial_num # 结算流水号(passthrough,对账用)
...@@ -13,7 +13,7 @@ emits: ...@@ -13,7 +13,7 @@ emits:
kind: actual kind: actual
primary: primary:
table: patient_settlement_refund table: refund_full_rows # transforms 产出:status=4 ∪ (status=3 且 receivable<0)
key: uuid key: uuid
dedup_by: uuid dedup_by: uuid
......
...@@ -11,7 +11,7 @@ emits: ...@@ -11,7 +11,7 @@ emits:
kind: actual kind: actual
primary: primary:
table: patient_settlement_spec_refund table: refund_item_rows # transforms 产出:patient_settlement_spec 里 is_refund=1
key: id key: id
dedup_by: id dedup_by: id
...@@ -19,6 +19,7 @@ field_mapping: ...@@ -19,6 +19,7 @@ field_mapping:
externalId: id externalId: id
updatedAt: updated_gmt_at updatedAt: updated_gmt_at
createdAt: created_gmt_at createdAt: created_gmt_at
# spec.patient_id 部分品牌原是诊所本地 id → 宿主导出时已从结算头反填真 patient_id(inline);PAC 直接用
patientExternalId: patient_id patientExternalId: patient_id
clinicId: organization_id clinicId: organization_id
refundedAt: created_gmt_at refundedAt: created_gmt_at
......
# treatment_planned — FRIDAY 治疗计划(customer_treat_plan + _item 独立表;用户确认此表为治疗计划) # treatment_planned — FRIDAY 治疗计划(消费 customer_treat_plan_item;头属性 organization_id/plan_name 由宿主 inline)
# 消费 transforms 输出表 treatment_planned_rows(行表 lookup 头表拿 organization_id/plan_name) # 消费 transforms 输出表 treatment_planned_rows(item 过滤无诊所行 + normalize 术式名)
# 行粒度 = 计划行项(牙位 + 术式 mode_name + 价格区间);externalId = 行表主键(host 稳定) # 行粒度 = 计划行项(牙位 + 术式 mode_name + 价格区间);externalId = 行表主键(host 稳定)
canonical: treatment canonical: treatment
...@@ -19,7 +19,7 @@ field_mapping: ...@@ -19,7 +19,7 @@ field_mapping:
updatedAt: updated_gmt_at updatedAt: updated_gmt_at
createdAt: created_gmt_at createdAt: created_gmt_at
patientExternalId: customer_id patientExternalId: customer_id
clinicId: organization_id # lookup 自头表(行表无诊所) clinicId: organization_id # 宿主 inline 自计划头(行表原生无诊所)
occurredAt: created_gmt_at # 计划制定时点 occurredAt: created_gmt_at # 计划制定时点
category: mode_norm # 归一术式名 → 共享字典翻 11 类闭集 category: mode_norm # 归一术式名 → 共享字典翻 11 类闭集
subtype: mode_name # 原始术式名 subtype: mode_name # 原始术式名
......
#!/usr/bin/env bash #!/usr/bin/env bash
# FRIDAY SaaS 测试环境 → PAC cold-import 导出(等价"host 侧单表 dump") # FRIDAY SaaS 测试环境 → PAC cold-import 导出(host 侧「自洽业务表」参考实现)
# #
# 纪律(同 jvs-dw SQL 朴素化):每条导出 = 列选 + WHERE,零 join/零变换; # 纪律(2026-07 对齐,契约见 docs/integration/friday-push-payload):
# (2026-07 对齐:一列都不改名 —— 宿主原生字段直出;与 PAC 租户概念的区分由 PAC 适配层消化。) # - 业务 WHERE / status 切分 / K 码截取 / 数组拆行 → 全在 PAC transforms 层(不在导出侧)。
# 形态改造(拆 JSON 数组/字典 join/K 码截取)全部在 PAC transforms 层做。 # - 但**宿主自己的引用**在导出时 within-host join 后 inline 进相关行(PAC 无从做,数据在宿主手里):
# 私有字典码(diag linkCode→std_code、影像 class_code→类型名)、1:N 派生(默认联系号+归属)、
# 头-行属性(计划头 诊所/方案名 下放到计划行)。故不再导出纯字典表/联系方式/计划头/支付通道。
# - **列名一律沿用宿主原表列名**(含 inline 进来的:contacts_tel/relationship/std_code/class_name/
# organization_id/plan_name)—— PAC 不发明新名,宿主无需为 PAC 做字段改名。
# #
# 用法(cohort 过滤,语义与 jvs-dw cold-import --clinics/--since 完全一致): # 用法(cohort 过滤,语义与 jvs-dw cold-import --clinics/--since 完全一致):
# ./export.sh # 全量(默认行为不变) # ./export.sh # 全量(默认行为不变)
...@@ -123,31 +127,25 @@ mysql_csv() { ...@@ -123,31 +127,25 @@ mysql_csv() {
} }
echo "── MySQL 导出 ──" echo "── MySQL 导出 ──"
# 患者主档(全量;既有 450 行样本 → 扩全量,EMR 覆盖 ~1.65 万患者的前置) # ⭐ 自洽形态:宿主 within-host join 把私有字典码 / 1:N 默认号 / 计划头属性 inline 进相关表
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 # (契约见 docs/integration/friday-push-payload)。纯字典表 / 联系方式 1:N / 计划头 / 支付通道
# 联系方式 1:N(lookup 挑默认号 → patient.phone) # 不再单独导出成文件——PAC 只摄入自洽业务行(所有摄入路径同形态)。
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 # 患者主档 + inline 默认联系号(挑:非空号 → is_default↓ → contacts_type↑ → id↑;phone 与归属同源同一条)
mysql_csv "SELECT cbi.id,cbi.name,cbi.sex,cbi.birthday,cbi.file_number,cbi.tenant_id,cbi.organization_id,cbi.created_gmt_at,cbi.updated_gmt_at,
(SELECT c.contacts_tel FROM customer.customer_contacts c WHERE c.customer_id=cbi.id AND c.contacts_tel<>'' ORDER BY c.is_default DESC,c.contacts_type ASC,c.id ASC LIMIT 1) AS contacts_tel,
(SELECT c.relationship FROM customer.customer_contacts c WHERE c.customer_id=cbi.id AND c.contacts_tel<>'' ORDER BY c.is_default DESC,c.contacts_type ASC,c.id ASC LIMIT 1) AS relationship
FROM customer.customer_basic_info cbi $(pf cbi.id)" customer_basic_info.csv
# 预约(全状态;transforms 丢草稿 10) # 预约(全状态;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 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 字典翻) # 影像档案 + inline class_name(class_code→std_check_class,宿主解析;PAC 再翻 modality)
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 mysql_csv "SELECT mc.id,mc.tenant_id,mc.organization_id,mc.organization_name,mc.patient_id,mc.emr_id,mc.class_code,sc.class_name,mc.file_name,mc.file_type,mc.file_url,mc.files_size,mc.shooting_time,mc.created_gmt_at,mc.updated_gmt_at FROM emr.med_check mc LEFT JOIN emr.std_check_class sc ON sc.class_code=mc.class_code $(pf mc.patient_id)" med_check.csv
# 影像类型字典(class_code UUID 全局唯一,lookup 不需 tenant 限定) # 治疗计划行 + inline 头属性(organization_id/plan_name 取自计划头;头不单独导出)
mysql_csv "SELECT class_code,class_name,tenant_id FROM emr.std_check_class" std_check_class.csv mysql_csv "SELECT it.id,it.tenant_id,it.customer_id,it.treat_plan_id,it.tooth_position,it.mode_name,it.charge_min,it.charge_max,it.created_gmt_at,it.updated_gmt_at,h.organization_id,h.plan_name FROM customer.customer_treat_plan_item it LEFT JOIN customer.customer_treat_plan h ON h.id=it.treat_plan_id $(pf it.customer_id)" customer_treat_plan_item.csv
# 诊断字典(linkCode → std_code;K 码用,非 K 落自由文本) # 结算头单原表(全 status,导出侧不做业务 WHERE;消费/退费切分全在 PAC)
mysql_csv "SELECT diag_code,diag_name,std_code FROM emr.std_diag" std_diag.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 $(pf patient_id)" patient_settlement.csv
# 治疗计划(头:方案名/期望;行:牙位/术式/价格区间)→ treatment_planned # 结算明细原表(全量含 is_refund,PAC 侧切退费明细)。JOIN 结算头:① 挂靠限定 cohort
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 # ② 反填真 patient_id(spec.patient_id 部分品牌是诊所本地 id,不可信 → 取结算头 ps.patient_id inline)
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 mysql_csv "SELECT sp.id,sp.tenant_id,sp.organization_id,ps.patient_id,sp.settlement_id,sp.cure_name,sp.service_project_name,sp.receivable_this,sp.net_receipts_this,sp.is_refund,sp.created_gmt_at,sp.updated_gmt_at FROM \`arrail-settlement-server\`.patient_settlement_spec sp JOIN \`arrail-settlement-server\`.patient_settlement ps ON ps.uuid=sp.settlement_id $(pf ps.patient_id)" patient_settlement_spec.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 侧统计推断解码) # 转介绍圈(患者-患者关系边,双向成对存;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 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 行,结构真实) # 咨询(意向 potential_treatment/未成交原因;测试库仅 45 行,结构真实)
...@@ -163,9 +161,22 @@ echo "── Mongo 导出(med_emr_info,status∈{3,4} 正式病历)──" ...@@ -163,9 +161,22 @@ echo "── Mongo 导出(med_emr_info,status∈{3,4} 正式病历)──"
# 按真 UTC 解释则一半病历落深夜——荒谬)。故导出**不能** toISOString 带 Z(会被 PAC 当真 # 按真 UTC 解释则一半病历落深夜——荒谬)。故导出**不能** toISOString 带 Z(会被 PAC 当真
# UTC 再 +8,EMR 链时间全偏 8 小时,已踩):把 UTC 字段值直读为墙钟,输出 naive # UTC 再 +8,EMR 链时间全偏 8 小时,已踩):把 UTC 字段值直读为墙钟,输出 naive
# "YYYY-MM-DD HH:mm:ss",交给 manifest timezone=Asia/Shanghai 解释 → 瞬间正确。 # "YYYY-MM-DD HH:mm:ss",交给 manifest timezone=Asia/Shanghai 解释 → 瞬间正确。
# 诊断字典 map(diag_code→std_code)—— diag[] inline std_code 的 within-host join;linkCode 在 Mongo、
# 字典在 MySQL,故预取成 map 挂进 mongo 容器,eval 里逐元素解析(不再 PAC 侧 lookup std_diag)。
printf 'SELECT diag_code,std_code FROM emr.std_diag\n' | 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 m = {};
for (let i = 1; i < lines.length; i++) { const l = lines[i]; if (!l) continue; const t = l.split("\t");
if (t[0] && t[0] !== "NULL") m[t[0]] = (t[1] && t[1] !== "NULL") ? t[1] : ""; }
process.stdout.write(JSON.stringify(m));
' > std_diag_map.json
echo " std_diag_map.json: $(node -e 'process.stdout.write(String(Object.keys(JSON.parse(require("fs").readFileSync("std_diag_map.json","utf-8"))).length))') 码"
# cohort 模式:挂载 id 文件供 $in 过滤(bash 3.2 + set -u 下空数组要用 ${arr[@]+...} 展开) # cohort 模式:挂载 id 文件供 $in 过滤(bash 3.2 + set -u 下空数组要用 ${arr[@]+...} 展开)
MONGO_ARGS=() MONGO_ARGS=(-v "$PWD/std_diag_map.json:/std_diag_map.json:ro")
if [ -n "$COHORT_IN" ]; then MONGO_ARGS=(-v "$PWD/cohort_ids.json:/cohort_ids.json:ro" -e "COHORT=1"); fi 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 ' 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", " ") const flat = (v) => v instanceof Date ? v.toISOString().slice(0, 19).replace("T", " ")
: (v && v.constructor && v.constructor.name === "Long") ? String(v) : (v && v.constructor && v.constructor.name === "Long") ? String(v)
...@@ -173,13 +184,15 @@ docker run --rm ${MONGO_ARGS[@]+"${MONGO_ARGS[@]}"} mongo:7 mongosh "$FRIDAY_MON ...@@ -173,13 +184,15 @@ docker run --rm ${MONGO_ARGS[@]+"${MONGO_ARGS[@]}"} mongo:7 mongosh "$FRIDAY_MON
// id 类字段字符串化:Mongo 存的是 number/Long 混型,canonical 契约要 string // id 类字段字符串化:Mongo 存的是 number/Long 混型,canonical 契约要 string
// (CSV 源天然全字符串;JSON 导出须显式对齐,否则 patientExternalId 等校验失败) // (CSV 源天然全字符串;JSON 导出须显式对齐,否则 patientExternalId 等校验失败)
const sid = (v) => (v === undefined || v === null || v === "") ? null : String(flat(v)); const sid = (v) => (v === undefined || v === null || v === "") ? null : String(flat(v));
// 诊断字典 map(within-host join):diag[].linkCode → std_code inline 进元素(沿用宿主列名 std_code)
let DIAG = {}; try { DIAG = JSON.parse(require("fs").readFileSync("/std_diag_map.json", "utf-8")); } catch (e) {}
const q = { status: { $in: [3, 4] } }; const q = { status: { $in: [3, 4] } };
if (process.env.COHORT === "1") { try {
// cohort 激活但文件读不到 → 硬失败(宁可让 .tmp 校验拒绝,不能静默退化成全量导出) // cohort 激活但文件读不到 → 硬失败(宁可让 .tmp 校验拒绝,不能静默退化成全量导出)
const ids = JSON.parse(require("fs").readFileSync("/cohort_ids.json", "utf-8")); 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]; }; const both = (s) => { const n = Number(s); return Number.isFinite(n) && String(n) === s ? [s, n] : [s]; };
q.patientId = { $in: ids.flatMap(both) }; q.patientId = { $in: ids.flatMap(both) };
} } catch (e) {}
let first = true; let first = true;
print("["); print("[");
db.med_emr_info.find(q).forEach((d) => { db.med_emr_info.find(q).forEach((d) => {
...@@ -190,7 +203,10 @@ docker run --rm ${MONGO_ARGS[@]+"${MONGO_ARGS[@]}"} mongo:7 mongosh "$FRIDAY_MON ...@@ -190,7 +203,10 @@ docker run --rm ${MONGO_ARGS[@]+"${MONGO_ARGS[@]}"} mongo:7 mongosh "$FRIDAY_MON
organization_name: flat(d.organizationName), // 诊所名(clinic_directory 派生 host.clinicNames) organization_name: flat(d.organizationName), // 诊所名(clinic_directory 派生 host.clinicNames)
patient_id: sid(d.patientId), user_id: sid(d.userId), user_name: flat(d.userName), 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), 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 ?? [], // diag[] inline std_code(宿主 within-host join);treat/dispose/examine 原生数组原样
diag: (d.diag ?? []).map((el) => Object.assign({}, el,
{ std_code: (el && el.linkCode != null && el.linkCode !== "") ? (DIAG[String(el.linkCode)] ?? "") : "" })),
treat: d.treat ?? [], dispose: d.dispose ?? [], examine: d.examine ?? [],
// emr_record 自由文本位要字符串(fact 层 zod nullableString);数组原样给 split_json_array 拆行 // emr_record 自由文本位要字符串(fact 层 zod nullableString);数组原样给 split_json_array 拆行
dispose_text: (d.dispose ?? []).length ? JSON.stringify(d.dispose) : null, dispose_text: (d.dispose ?? []).length ? JSON.stringify(d.dispose) : null,
examine_text: (d.examine ?? []).length ? JSON.stringify(d.examine) : null, examine_text: (d.examine ?? []).length ? JSON.stringify(d.examine) : null,
......
# FRIDAY SaaS Cold Import Manifest(首版 — 仅 patient 试跑) # FRIDAY SaaS Cold Import Manifest(首版 — 仅 patient 试跑)
# #
# 数据源:FRIDAY SaaS MySQL(customer 库)。cold-import 不支持 MySQL 直连, # 数据源:FRIDAY SaaS MySQL(customer 库)。cold-import 不支持 MySQL 直连,
# 走 CSV 文件模式:host 端**纯导原始表**(SELECT * FROM tb),不做任何 join。 # 走 CSV 文件模式。宿主推「自洽业务表」:自己的引用(私有字典码 / 1:N 默认号 / 头-行属性)
# 电话在 1:N 的 customer_contacts 里 → 由 transforms.lookup 按 customer_id join、 # 在导出时 within-host join 后 inline 进相关行,**列名沿用宿主原表列名**(见 tables 上方说明);
# 挑默认号(is_default→本人→最早)填成 patient.phone 标量。宿主零特殊处理 # 业务 WHERE / status 切分等全部留在 PAC transforms(单一真理源)
# #
# ── 方案 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)。
...@@ -39,7 +39,7 @@ clinic_directory: ...@@ -39,7 +39,7 @@ clinic_directory:
# ── 增量水位声明(2026-07 与 jvs-dw 统一;file 源放 manifest 顶层)── # ── 增量水位声明(2026-07 与 jvs-dw 统一;file 源放 manifest 顶层)──
# file 装载不消费水位(每轮全量装载,幂等去重兜底);每轮跑完与 jvs-dw 走同一段 finally # file 装载不消费水位(每轮全量装载,幂等去重兜底);每轮跑完与 jvs-dw 走同一段 finally
# 记账 cursor_after = run_start(sync_logs)。用途:delta 导出 WHERE 模板 / push 回放起点。 # 记账 cursor_after = run_start(sync_logs)。用途:delta 导出 WHERE 模板 / push 回放起点。
# 只列有 updated_gmt_at 的事件表;字典表(std_*)与 customer_contacts(无更新列)恒全量 # 只列有 updated_gmt_at 的事件表(字典/联系方式/计划头等已 inline,不再单独摄入)
incremental: incremental:
per_query: per_query:
customer_basic_info: { cursor_column: updated_gmt_at } customer_basic_info: { cursor_column: updated_gmt_at }
...@@ -47,53 +47,32 @@ incremental: ...@@ -47,53 +47,32 @@ incremental:
med_emr_info: { cursor_column: updated_gmt_at } med_emr_info: { cursor_column: updated_gmt_at }
med_check: { cursor_column: updated_gmt_at } med_check: { cursor_column: updated_gmt_at }
patient_settlement: { cursor_column: updated_gmt_at } patient_settlement: { cursor_column: updated_gmt_at }
patient_settlement_refund: { cursor_column: updated_gmt_at } patient_settlement_spec: { 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_treat_plan_item: { cursor_column: updated_gmt_at }
customer_referee_circle: { cursor_column: updated_gmt_at } customer_referee_circle: { cursor_column: updated_gmt_at }
customer_consult: { cursor_column: updated_gmt_at } customer_consult: { cursor_column: updated_gmt_at }
# ⭐ 宿主推「自洽的业务表」:私有字典码 / 1:N 派生 / 头-行属性 都由宿主 within-host join 后 inline 进相关行
# (契约见 docs/integration/friday-push-payload)。故不再摄入纯字典表(std_diag/std_check_class)、
# 联系方式 1:N(customer_contacts→inline 默认号进主档)、计划头(customer_treat_plan→inline 进行)、
# 支付通道(settlement_modes,金额已在结算头 net_receipts_this)。所有摄入路径(push/file/pull/reparse)同此形态。
tables: tables:
- { table: customer_basic_info, file: customer_basic_info.csv } # 患者主档(原始) - { table: customer_basic_info, file: customer_basic_info.csv } # 患者主档(含 inline contacts_tel/relationship)
- { 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 平铺导出)── # ── EMR 病历链(W FRIDAY-EMR:Mongo med_emr_info status∈{3,4} 正式病历;export.sh 平铺导出)──
# 数组字段(diag/treat/dispose/examine)导出保留原生数组(split_json_array 原生支持); # diag[] 元素含 inline std_code(宿主解析 linkCode→std_diag.std_code);treat/dispose/examine 原生数组。
# dispose_text/examine_text 是同数据的 JSON 字符串形态(emr_record 自由文本位要 string)。
- { table: med_emr_info, file: med_emr_info.json } # 病历正文(Mongo,含 diag/treat 数组) - { table: med_emr_info, file: med_emr_info.json } # 病历正文(Mongo,含 diag/treat 数组)
- { table: med_check, file: med_check.csv } # 影像档案(挂 emr_id 病历号) - { table: med_check, file: med_check.csv } # 影像档案(含 inline class_name;挂 emr_id)
- { table: std_check_class, file: std_check_class.csv } # 影像类型字典(class_code→class_name) - { table: customer_treat_plan_item, file: customer_treat_plan_item.csv } # 治疗计划行(含 inline organization_id/plan_name)
- { table: std_diag, file: std_diag.csv } # 诊断字典(linkCode→std_code;K 码用) # 结算原表(全 status,导出侧不做业务 WHERE)—— 消费/退费/明细全部由 PAC transforms 切分(单一真理源)
- { table: customer_treat_plan, file: customer_treat_plan.csv } # 治疗计划头(方案名/期望) - { table: patient_settlement, file: patient_settlement.csv } # 结算头单原表(全 status)
- { table: customer_treat_plan_item, file: customer_treat_plan_item.csv } # 治疗计划行(牙位/术式/价格) - { table: patient_settlement_spec, file: patient_settlement_spec.csv } # 结算明细原表(全量,含 is_refund)
- { 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_referee_circle, file: customer_referee_circle.csv } # 转介绍圈(患者-患者边)
- { table: customer_consult, file: customer_consult.csv } # 咨询(意向/未成交原因) - { table: customer_consult, file: customer_consult.csv } # 咨询(意向/未成交原因)
transforms: transforms:
# ── 患者:把 1:N 联系方式归约成 patient.phone 标量(无需源侧 join)── # ── 患者 phone:宿主已按「默认号→本人→最早」挑好、inline 进 customer_basic_info 的
- kind: lookup # contacts_tel / relationship 两列(沿用宿主源列名;不再 lookup customer_contacts)。patient.yaml 直接读。──
input: customer_basic_info
output: customer_basic_info # in-place 附加 phone 列
from: customer_contacts
left_key: id # 患者主键
right_key: customer_id # 联系方式指向患者的外键
where:
contacts_tel: { not_empty: true } # 空号候选剔除
order_by: # 挑一条:默认号 → 本人(type=1) → 最早 id
- { field: is_default, dir: desc }
- { field: contacts_type, dir: asc }
- { field: id, dir: asc }
select:
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)。
...@@ -126,9 +105,16 @@ transforms: ...@@ -126,9 +105,16 @@ transforms:
# ── E.1 病历自由文本行(emr.yaml 直接消费 med_emr_info,无需 project: # ── E.1 病历自由文本行(emr.yaml 直接消费 med_emr_info,无需 project:
# assembler field_mapping 天然只取所需列,数组列不映射即不入 canonical)── # assembler field_mapping 天然只取所需列,数组列不映射即不入 canonical)──
# ── E.0 病历排草稿:status∈{3,4} 正式病历(迁自 mongo 导出侧过滤;PAC 侧统一)──
- kind: filter
input: med_emr_info
output: _emr_official
where:
status: { in: ['3', '4'] }
# ── E.2 diag[] 拆行 → 诊断候选 ── # ── E.2 diag[] 拆行 → 诊断候选 ──
- kind: split_json_array - kind: split_json_array
input: med_emr_info input: _emr_official
output: _diag_raw output: _diag_raw
array_field: diag array_field: diag
parent_keys: parent_keys:
...@@ -145,19 +131,9 @@ transforms: ...@@ -145,19 +131,9 @@ transforms:
element_fields: element_fields:
value: value value: value
tooth_position: toothPosition tooth_position: toothPosition
link_code: linkCode std_code: std_code # 宿主已解析 linkCode→std_diag.std_code、inline 进元素(沿用宿主列名;不再 PAC 侧 lookup)
where: where:
value: { not_empty: true } # 只要求有诊断名;linkCode 可空(仅 0.3% 有) value: { not_empty: true } # 只要求有诊断名;std_code 可空(非 K/未解析 → 走自由文本)
# 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 # E.2.2 归一 + K 码截取 + coalesce
# 规则(用户定):std_code K 开头 → 截 K 大类;非 K(DA0E.50 等 ICD-11)/join 不上/linkCode 空 # 规则(用户定):std_code K 开头 → 截 K 大类;非 K(DA0E.50 等 ICD-11)/join 不上/linkCode 空
...@@ -190,7 +166,7 @@ transforms: ...@@ -190,7 +166,7 @@ transforms:
# 注:treat 填充仅 25%(dispose 98% 是处置叙述,整段进 emr_record.disposal 不丢); # 注:treat 填充仅 25%(dispose 98% 是处置叙述,整段进 emr_record.disposal 不丢);
# 若后续漏召,可加 dispose keyword 路由补 actual(Phase 2 评估)。 # 若后续漏召,可加 dispose keyword 路由补 actual(Phase 2 评估)。
- kind: split_json_array - kind: split_json_array
input: med_emr_info input: _emr_official
output: _treat_raw output: _treat_raw
array_field: treat array_field: treat
parent_keys: parent_keys:
...@@ -296,30 +272,13 @@ transforms: ...@@ -296,30 +272,13 @@ transforms:
op: concat op: concat
parts: ['${emr_sub_id}', '|rec|', '${value}', '|', '${tooth_position}'] parts: ['${emr_sub_id}', '|rec|', '${value}', '|', '${tooth_position}']
# ── E.4 影像:med_check + 类型字典 → class_name(modality 经共享字典翻)── # ── E.4 影像:med_check 已含 inline class_name(宿主解析 class_code→std_check_class);
- kind: lookup # image.yaml 直接以 med_check 为 primary、读 class_name 翻 modality(不再 PAC 侧 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 候选 ── # ── E.5 治疗计划:计划行已含 inline organization_id/plan_name(宿主取自计划头);
- kind: lookup # 仅丢弃无诊所归属的行(canonical clinicId 必填;头无诊所 → 该行产 failed 噪音,提前剔除)。──
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 - kind: filter
input: _plan_item_joined input: customer_treat_plan_item
output: _plan_item_with_org output: _plan_item_with_org
where: where:
organization_id: { not_empty: true } organization_id: { not_empty: true }
...@@ -336,25 +295,43 @@ transforms: ...@@ -336,25 +295,43 @@ transforms:
# 消费 = status∈{1,3};退费双轨 = ①status=4 整单(ref_settlement_id 挂原单)②spec is_refund=1 行级。 # 消费 = status∈{1,3};退费双轨 = ①status=4 整单(ref_settlement_id 挂原单)②spec is_refund=1 行级。
# LTV = Σpayment − Σrefund 自然对账;不从 settlement 反推治疗(财务≠临床,jvs-dw 已验证的教训)。 # LTV = Σpayment − Σrefund 自然对账;不从 settlement 反推治疗(财务≠临床,jvs-dw 已验证的教训)。
# ── S.1 消费:status 1/3 → 主导支付通道 lookup(金额最大者)── # ── S.1 消费:status∈{1,3} 且应收≥0 → payment(金额取结算头 net_receipts_this)。
# 支付通道(settlement_modes)暂不摄入 → payment 无 method(总值在结算头,无需通道拆分)。──
- kind: filter - kind: filter
input: patient_settlement input: patient_settlement
output: _pay_hdr output: payment_rows
where: where:
status: { in: ['1', '3'] } status: { in: ['1', '3'] }
- kind: lookup receivable_this: { gte: 0 } # 应收≥0(迁自导出侧 WHERE;数值算子)
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 直读 # ── S.2 退费①:整单冲减 —— PAC 侧切分(不再靠导出 WHERE)。两种表达 union:
# patient_settlement_refund 表;金额负值由 refund parser Math.abs 归一)── # ① status=4 整单反向;② status=3 且 receivable_this<0(2ac96f6d 品牌用负额表达退费)。
# 金额负值由 refund parser Math.abs 归一为正 cents。
- kind: filter
input: patient_settlement
output: _refund_status4
where:
status: { equals: '4' }
- kind: filter
input: patient_settlement
output: _refund_neg3
where:
status: { equals: '3' }
receivable_this: { lt: 0 } # 数值算子:负额退费表达
- kind: union
inputs: ['_refund_status4', '_refund_neg3']
output: refund_full_rows
# ── S.3 退费明细②:行级退费(patient_settlement_spec is_refund=1)——PAC 侧 filter ──
# ⚠️ spec.patient_id 部分品牌是「诊所本地 id」而非全局 customer id(元和王永 691828a5 实测
# 全 26421 行不符,spec=2/头=666716)。退费明细是结算头子行 → 宿主导出时已按 settlement_id
# 从结算头把真 patient_id **反填进 spec.patient_id**(within-host join,inline),PAC 直接信;
# 故此处仅 filter,不再跨表 lookup 结算头(单表 push 自洽)。
- kind: filter
input: patient_settlement_spec
output: refund_item_rows
where:
is_refund: { equals: '1' }
# ═══════════ 患者关系链(customer_referee_circle → patient_relations 独立表)═══════════ # ═══════════ 患者关系链(customer_referee_circle → patient_relations 独立表)═══════════
# 宿主 referee_relationship 是字典 id(DB 无字典表,疑似代码硬编码)。解码 = 统计推断 # 宿主 referee_relationship 是字典 id(DB 无字典表,疑似代码硬编码)。解码 = 统计推断
......
...@@ -90,6 +90,7 @@ ...@@ -90,6 +90,7 @@
"socket.io": "^4.8.3", "socket.io": "^4.8.3",
"winston": "^3.19.0", "winston": "^3.19.0",
"ws": "^8.21.0", "ws": "^8.21.0",
"yauzl": "^3.3.0",
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
......
...@@ -7,6 +7,7 @@ import { MappingMissAdminController } from './mapping-miss.controller'; ...@@ -7,6 +7,7 @@ import { MappingMissAdminController } from './mapping-miss.controller';
import { HostsService } from './hosts.service'; import { HostsService } from './hosts.service';
import { AiInvocationsService } from './ai-invocations.service'; import { AiInvocationsService } from './ai-invocations.service';
import { MappingMissService } from './mapping-miss.service'; import { MappingMissService } from './mapping-miss.service';
import { ImportUploadService } from './import-upload.service';
@Module({ @Module({
controllers: [ controllers: [
...@@ -14,7 +15,7 @@ import { MappingMissService } from './mapping-miss.service'; ...@@ -14,7 +15,7 @@ import { MappingMissService } from './mapping-miss.service';
AiInvocationsAdminController, AiInvocationsAdminController,
MappingMissAdminController, MappingMissAdminController,
], ],
providers: [HostsService, AiInvocationsService, MappingMissService], providers: [HostsService, AiInvocationsService, MappingMissService, ImportUploadService],
exports: [HostsService, AiInvocationsService], exports: [HostsService, AiInvocationsService],
}) })
export class AdminModule {} export class AdminModule {}
import { createZodDto } from 'nestjs-zod'; import { createZodDto } from 'nestjs-zod';
import { z } from 'zod';
import { import {
ListHostsResponseSchema, ListHostsResponseSchema,
HostSummarySchema, HostSummarySchema,
...@@ -32,3 +33,35 @@ export class AiInvocationListQueryDto extends createZodDto(AiInvocationListQuery ...@@ -32,3 +33,35 @@ export class AiInvocationListQueryDto extends createZodDto(AiInvocationListQuery
export class AiInvocationDetailDto extends createZodDto(AiInvocationDetailSchema) {} export class AiInvocationDetailDto extends createZodDto(AiInvocationDetailSchema) {}
export class AiInvocationStatsDto extends createZodDto(AiInvocationStatsSchema) {} export class AiInvocationStatsDto extends createZodDto(AiInvocationStatsSchema) {}
export class ListAiInvocationsResponseDto extends createZodDto(ListAiInvocationsResponseSchema) {} export class ListAiInvocationsResponseDto extends createZodDto(ListAiInvocationsResponseSchema) {}
// Cold-import 网页上传(host-self)
const ColdImportUploadResponseSchema = z.object({ jobId: z.string() });
export class ColdImportUploadResponseDto extends createZodDto(ColdImportUploadResponseSchema) {}
// 进度:BullMQ job 状态 + 完成时的 ImportRunResult(含 totals);job 已清理/不存在 → state='not_found'
const ColdImportStatusSchema = z.object({
state: z.string(),
result: z.any().nullable(),
failedReason: z.string().nullable(),
// 运行中的真实进度(cohortDone/cohortTotal 批次);非运行态或 dryRun 为 null
progress: z.object({ done: z.number(), total: z.number() }).nullable(),
});
export class ColdImportStatusDto extends createZodDto(ColdImportStatusSchema) {}
/// 宿主推送记录(sync_logs direction=push 的对宿主视图)—— 对接期自助排查:
/// 哪一批、什么时候、收了几行、落了几条、去重几条、失败几条、错在哪。
const PushLogSchema = z.object({
id: z.string(),
source: z.string().describe('推送的源表名'),
status: z.string().describe('success / partial / failed'),
dryRun: z.boolean().describe('true = 预检批次(未落库)'),
fetched: z.number().int().describe('收到的行数'),
transactionsWritten: z.number().int().describe('实际落库事件数(与 fetched 的差 = 被 PAC 规则过滤)'),
duplicates: z.number().int().describe('幂等命中数(重推安全的证据)'),
failed: z.number().int(),
errorMessage: z.string().nullable(),
startedAt: z.string(),
endedAt: z.string().nullable(),
});
const PushLogsSchema = z.object({ items: z.array(PushLogSchema) });
export class PushLogsDto extends createZodDto(PushLogsSchema) {}
...@@ -6,19 +6,28 @@ import { ...@@ -6,19 +6,28 @@ import {
Param, Param,
Patch, Patch,
Post, Post,
Query,
UploadedFile,
UseInterceptors,
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { FileInterceptor } from '@nestjs/platform-express';
import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger';
import { ZodResponse } from 'nestjs-zod'; import { ZodResponse } from 'nestjs-zod';
import { Permission } from '@pac/types'; import { ApiCode, Permission } from '@pac/types';
import { RequirePermission } from '../../common/decorators/permissions.decorator'; import { RequirePermission } from '../../common/decorators/permissions.decorator';
import { CurrentUser, AuthenticatedUser } from '../../common/decorators/current-user.decorator';
import { BizError } from '../../common/errors/biz-error';
import { import {
TenantScope, TenantScope,
TenantScopeContext, TenantScopeContext,
} from '../../common/decorators/tenant-scope.decorator'; } from '../../common/decorators/tenant-scope.decorator';
import { import {
ColdImportStatusDto,
ColdImportUploadResponseDto,
HostDetailDto, HostDetailDto,
HostStatsDto, HostStatsDto,
HostSummaryDto, HostSummaryDto,
PushLogsDto,
RemovePushSecretResponseDto, RemovePushSecretResponseDto,
RotateCallbackSecretResponseDto, RotateCallbackSecretResponseDto,
RotatePushSecretResponseDto, RotatePushSecretResponseDto,
...@@ -26,6 +35,16 @@ import { ...@@ -26,6 +35,16 @@ import {
UpdateHostRequestDto, UpdateHostRequestDto,
} from './dto/admin.dto'; } from './dto/admin.dto';
import { HostsService } from './hosts.service'; import { HostsService } from './hosts.service';
import { ImportUploadService } from './import-upload.service';
/// multer 内存模式的最小文件形状(不引 @types/multer,同 assistant.controller)
interface UploadedZip {
buffer: Buffer;
originalname: string;
mimetype: string;
}
/// 上传大小上限:存量分批包压缩后通常几十 MB;留 500MB 余量
const MAX_IMPORT_ZIP_BYTES = 500 * 1024 * 1024;
/** /**
* Host Self Admin — 宿主自己的 admin 用户管理"自己这个 host"的配置页。 * Host Self Admin — 宿主自己的 admin 用户管理"自己这个 host"的配置页。
...@@ -42,7 +61,10 @@ import { HostsService } from './hosts.service'; ...@@ -42,7 +61,10 @@ import { HostsService } from './hosts.service';
@Controller('admin/host/self') @Controller('admin/host/self')
@RequirePermission(Permission.PLATFORM_MANAGE) @RequirePermission(Permission.PLATFORM_MANAGE)
export class HostSelfAdminController { export class HostSelfAdminController {
constructor(private readonly hosts: HostsService) {} constructor(
private readonly hosts: HostsService,
private readonly importUpload: ImportUploadService,
) {}
@Get() @Get()
@ZodResponse({ status: 200, type: HostDetailDto }) @ZodResponse({ status: 200, type: HostDetailDto })
...@@ -58,6 +80,20 @@ export class HostSelfAdminController { ...@@ -58,6 +80,20 @@ export class HostSelfAdminController {
return this.hosts.getStats(scope.hostId); return this.hosts.getStats(scope.hostId);
} }
@Get('push-logs')
@ZodResponse({ status: 200, type: PushLogsDto })
@ApiOperation({
summary: '当前宿主的推送记录(对接自助排查)',
description:
'按时间倒序列最近 N 批 push(默认 50,上限 200)。每批含 source / 收行数 / 落库数 / ' +
'去重数 / 失败数 / 报错原因;dryRun=true 的是 ?dryRun=1 预检批次。' +
'用途:当时的同步响应没留存也能事后查——对接期定位"哪批错了、错在哪"。',
})
getPushLogs(@TenantScope() scope: TenantScopeContext, @Query('limit') limit?: string) {
const n = Number.parseInt(limit ?? '', 10);
return this.hosts.getPushLogs(scope.hostId, Number.isFinite(n) && n > 0 ? n : 50);
}
@Patch() @Patch()
@ZodResponse({ status: 200, type: HostSummaryDto }) @ZodResponse({ status: 200, type: HostSummaryDto })
@ApiOperation({ @ApiOperation({
...@@ -125,4 +161,38 @@ export class HostSelfAdminController { ...@@ -125,4 +161,38 @@ export class HostSelfAdminController {
) { ) {
return this.hosts.removeCallbackSecretBySuffix(scope.hostId, suffix); return this.hosts.removeCallbackSecretBySuffix(scope.hostId, suffix);
} }
// ─── 数据导入(上传压缩包 → 后台 cold-import,方向:宿主 → PAC)───
@Post('cold-import')
@ApiConsumes('multipart/form-data')
@UseInterceptors(
FileInterceptor('file', { limits: { fileSize: MAX_IMPORT_ZIP_BYTES } }),
)
@ZodResponse({ status: 202, type: ColdImportUploadResponseDto })
@ApiOperation({
summary: '上传数据压缩包触发整包 cold-import',
description:
'zip 内含 manifest.tables 声明的数据文件(CSV/JSON);manifest+assemblers 取自 PAC 代码库不接受上传。' +
'校验通过后落后台队列,返回 jobId 供轮询进度。',
})
async coldImport(
@TenantScope() scope: TenantScopeContext,
@CurrentUser() user: AuthenticatedUser,
@UploadedFile() file: UploadedZip | undefined,
@Body('dryRun') dryRun?: string,
) {
if (!file?.buffer?.length) {
throw new BizError(ApiCode.CLIENT_BAD_REQUEST, '未收到上传文件(字段名应为 file)');
}
// multipart 文本字段是字符串;'true' → dry-run 预检(不落库)
return this.importUpload.prepareAndEnqueue(scope, file.buffer, user.sub, dryRun === 'true');
}
@Get('cold-import/:jobId')
@ZodResponse({ status: 200, type: ColdImportStatusDto })
@ApiOperation({ summary: '查询一次 cold-import 进度(前端轮询,含运行中百分比)' })
async coldImportStatus(@Param('jobId') jobId: string) {
return this.importUpload.getProgress(jobId);
}
} }
...@@ -17,6 +17,7 @@ import type { ...@@ -17,6 +17,7 @@ import type {
PullConfig, PullConfig,
ActionUrlsConfig, ActionUrlsConfig,
} from '@pac/types'; } from '@pac/types';
import { SyncDirection } from '@pac/types';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
/** /**
...@@ -300,6 +301,37 @@ export class HostsService { ...@@ -300,6 +301,37 @@ export class HostsService {
}; };
} }
/**
* 宿主推送记录(自助排查)—— 列最近 N 批 push 的结果。
*
* 对接期宿主开发最需要的"事后可查":当时的同步响应若没打进日志,这里仍能看到
* 每批 source / 收行数 / 落库数 / 去重 / 失败 / 报错原因。`?dryRun=1` 的预检批次
* 由 triggeredBy 前缀 `push-dryrun:` 标识,单独打标,不与正式推送混淆。
*/
async getPushLogs(hostId: string, limit = 50) {
const take = Math.min(Math.max(limit, 1), 200);
const rows = await this.prisma.syncLog.findMany({
where: { hostId, direction: SyncDirection.PUSH },
orderBy: { startedAt: 'desc' },
take,
});
return {
items: rows.map((r) => ({
id: r.id,
source: r.resource,
status: r.status,
dryRun: (r.triggeredBy ?? '').startsWith('push-dryrun:'),
fetched: r.fetched ?? 0,
transactionsWritten: r.transactionsWritten ?? 0,
duplicates: r.duplicates ?? 0,
failed: r.failed ?? 0,
errorMessage: r.errorMessage,
startedAt: r.startedAt.toISOString(),
endedAt: r.endedAt?.toISOString() ?? null,
})),
};
}
// ───────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────
// Remove push secret(按 suffix 定位,精确移除某个 grace key) // Remove push secret(按 suffix 定位,精确移除某个 grace key)
// ───────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────
......
import { Injectable, Logger } from '@nestjs/common';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { randomUUID } from 'node:crypto';
import * as yaml from 'js-yaml';
import * as yauzl from 'yauzl';
import { ApiCode } from '@pac/types';
import { BizError } from '../../common/errors/biz-error';
import { PrismaService } from '../../prisma/prisma.service';
import { QueueProducer } from '../../queues/queue-producer.service';
/**
* ImportUploadService — 网页上传压缩包 → 触发 cold-import 的编排层。
*
* 安全核心:manifest.yaml + assemblers/ **永远取自 PAC 代码库** `data/<host>/`,不接受上传。
* importDirectory 从 manifest 的 host_name 决定写哪个 host,若让上传者带 manifest 即可篡改
* 写入目标。故:
* - 按 scope.hostId 反查 host.name → 只认代码库 data/<name>/ 那份 manifest+assemblers
* - 从 zip 里**仅白名单提取** manifest.tables[].file 声明的文件名(basename 匹配,
* 落 join(tmpDir, basename) → 天然挡 zip-slip 路径穿越)
* - manifest.host_name 天然 == 反查的 name → 不可能写到别的 host
*
* 长任务不在 HTTP 里同步跑:落盘就绪后 enqueue cold-import 队列,返回 jobId 供前端轮询。
*/
@Injectable()
export class ImportUploadService {
private readonly logger = new Logger(ImportUploadService.name);
constructor(
private readonly prisma: PrismaService,
private readonly queue: QueueProducer,
) {}
/** 代码库 host 数据根(与 ColdImportService.resolveHostDir 同约定)。 */
private dataRoot(): string {
return process.env.PAC_INCREMENTAL_DATA_DIR ?? path.resolve(process.cwd(), 'data');
}
/** 临时解压工作根;跑完由 processor 清理。 */
private workRoot(): string {
return process.env.PAC_IMPORT_WORK_DIR ?? path.join(this.dataRoot(), '.imports');
}
/**
* 校验 + 落盘 + 入队。返回前端轮询用的 jobId。
* @param scope 调用者(宿主)scope,来自 token
* @param zipBuffer 上传的 zip 内容(multer memoryStorage buffer)
* @param userId 触发人(进 triggeredBy)
*/
async prepareAndEnqueue(
scope: { hostId: string; tenantId: string },
zipBuffer: Buffer,
userId: string,
dryRun = false,
): Promise<{ jobId: string }> {
// 上传内容先落盘成临时 zip(yauzl 从文件流式解压);finally 删除
await fs.promises.mkdir(this.workRoot(), { recursive: true });
const zipPath = path.join(this.workRoot(), `upload-${randomUUID()}.zip`);
await fs.promises.writeFile(zipPath, zipBuffer);
try {
const host = await this.prisma.host.findUnique({ where: { id: scope.hostId } });
if (!host) {
throw new BizError(ApiCode.CLIENT_BAD_REQUEST, `host 不存在: ${scope.hostId}`);
}
// 1. 代码库 data/<name>/ 必须已配置 manifest(否则该 host 不支持文件导入)
const srcDir = path.join(this.dataRoot(), host.name);
const manifestPath = path.join(srcDir, 'manifest.yaml');
if (!fs.existsSync(manifestPath)) {
throw new BizError(
ApiCode.CLIENT_BAD_REQUEST,
`该宿主未配置文件导入(缺 data/${host.name}/manifest.yaml)`,
);
}
// 2. 白名单 = manifest.tables[].file(都是根级 basename,如 customer_basic_info.csv)
const manifest = yaml.load(fs.readFileSync(manifestPath, 'utf-8')) as {
tables?: Array<{ file?: string; group?: string }>;
};
const whitelist = new Set(
(manifest.tables ?? [])
.map((t) => t.file)
.filter((f): f is string => !!f)
.map((f) => path.basename(f)),
);
if (whitelist.size === 0) {
throw new BizError(
ApiCode.CLIENT_BAD_REQUEST,
`manifest.tables 为空,无可导入文件声明`,
);
}
// 3. 临时目录做成"迷你 data 根"(与 data/ 同构):
// tmpRoot/<host>/ ← manifest + assemblers + 解压的数据文件
// tmpRoot/_shared/ ← 共享字典层副本
// assembler 的 dict_includes 用 ../../_shared/dict/ 相对引用(相对 assemblers/ 上两级),
// 必须保住这个相对位置,否则共享字典找不到(CLI dry-run 实测抓到)。
const tmpRoot = path.join(this.workRoot(), `import-${randomUUID()}`);
const runDir = path.join(tmpRoot, host.name);
await fs.promises.mkdir(runDir, { recursive: true });
await fs.promises.copyFile(manifestPath, path.join(runDir, 'manifest.yaml'));
const assemblersSrc = path.join(srcDir, 'assemblers');
if (fs.existsSync(assemblersSrc)) {
await fs.promises.cp(assemblersSrc, path.join(runDir, 'assemblers'), { recursive: true });
}
const sharedSrc = path.join(this.dataRoot(), '_shared');
if (fs.existsSync(sharedSrc)) {
await fs.promises.cp(sharedSrc, path.join(tmpRoot, '_shared'), { recursive: true });
}
// 4. 流式解压 —— 仅提取白名单 basename(挡 zip-slip)。
// yauzl 对损坏包 / 路径穿越条目会 fail-closed(整包拒绝)→ 转友好 400,不冒泡成 500。
let extracted: Set<string>;
try {
extracted = await extractWhitelisted(zipPath, runDir, whitelist);
} catch (err) {
await fs.promises.rm(tmpRoot, { recursive: true, force: true });
throw new BizError(
ApiCode.CLIENT_BAD_REQUEST,
`压缩包解析失败(可能损坏或含非法路径条目): ${err instanceof Error ? err.message : err}`,
);
}
// 5. 宽容 + 成组完整性校验(不再要求全表齐 —— PAC 各主体独立,装载层已宽容跳过):
// ① 非空:压缩包至少含一个可识别数据文件
// ② 成组:manifest 声明 group 的表,组内要么全在、要么整组不在;部分在 → 报错
// (半组产不出主体,如 treatment_plan 头+行缺一 → planned 产 0 行)
// ③ 独立表(无 group)缺失:放行,只少那部分 facts
if (extracted.size === 0) {
await fs.promises.rm(tmpRoot, { recursive: true, force: true });
throw new BizError(
ApiCode.CLIENT_BAD_REQUEST,
`压缩包不含任何可识别的数据文件(需匹配 manifest.tables 声明的文件名)`,
);
}
const incompleteGroups = findIncompleteGroups(manifest.tables ?? [], extracted);
if (incompleteGroups.length > 0) {
await fs.promises.rm(tmpRoot, { recursive: true, force: true });
throw new BizError(
ApiCode.CLIENT_VALIDATION_FAILED,
`成组主体不完整:${incompleteGroups
.map((g) => `"${g.group}" 缺 ${g.missing.join(',')}`)
.join(';')}(该组表须一起上传,或整组都不传)`,
{ incompleteGroups },
);
}
this.logger.log(
`import 就绪 host=${host.name} runDir=${runDir} 文件=${extracted.size}/${whitelist.size}` +
(extracted.size < whitelist.size
? `(缺 ${whitelist.size - extracted.size} 张独立表,宽容跳过)`
: ''),
);
// 6. 入队,返回 jobId。dir=runDir(跑),cleanupDir=tmpRoot(processor 跑完清整个迷你根)
const { jobId } = await this.queue.enqueueColdImport({
hostId: host.id,
tenantId: scope.tenantId,
hostName: host.name,
dir: runDir,
cleanupDir: tmpRoot,
dryRun,
triggeredBy: `upload:${userId}`,
});
return { jobId };
} finally {
// 上传的原始 zip 用完即删(已解压到 runDir);迷你根由 processor 跑完清理
await fs.promises.rm(zipPath, { force: true }).catch(() => undefined);
}
}
/**
* 查一次进度(前端轮询)。job active 时补查运行中的 sync_log.metadata.cohortDone/cohortTotal
* → 前端算真实百分比(importDirectory 每批实时写)。dryRun 不建 sync_log,只有 state。
*/
async getProgress(jobId: string): Promise<{
state: string;
result: unknown;
failedReason: string | null;
progress: { done: number; total: number } | null;
}> {
const job = await this.queue.getColdImportJob(jobId);
if (!job) return { state: 'not_found', result: null, failedReason: null, progress: null };
let progress: { done: number; total: number } | null = null;
if (job.state === 'active' && job.hostId) {
const log = await this.prisma.syncLog.findFirst({
where: { hostId: job.hostId, status: 'running' },
orderBy: { startedAt: 'desc' },
select: { metadata: true },
});
const m = log?.metadata as { cohortDone?: number; cohortTotal?: number } | null;
if (m?.cohortTotal) progress = { done: m.cohortDone ?? 0, total: m.cohortTotal };
}
return {
state: job.state,
result: job.returnValue,
failedReason: job.failedReason,
progress,
};
}
}
/**
* yauzl 流式解压,只提取 basename 命中白名单的条目。
* 只用 basename + join(destDir, ...) → 无法逃逸目标目录(zip-slip 防护)。
* 返回实际提取到的 basename 集合(供缺文件校验)。
* export 供单测(tests/cold-import-extract.spec.ts)。
*/
export function extractWhitelisted(
zipPath: string,
destDir: string,
whitelist: Set<string>,
): Promise<Set<string>> {
return new Promise((resolve, reject) => {
const extracted = new Set<string>();
yauzl.open(zipPath, { lazyEntries: true }, (err, zipfile) => {
if (err || !zipfile) return reject(err ?? new Error('无法打开压缩包'));
zipfile.on('error', reject);
zipfile.on('end', () => resolve(extracted));
zipfile.on('entry', (entry: YauzlEntry) => {
const base = path.basename(entry.fileName);
// 目录项 / 白名单外 → 跳过。
// (路径穿越 `../` 由 yauzl 自身 fail-closed 直接 emit error;此处 basename+join 是纵深防御)
if (entry.fileName.endsWith('/') || base.includes('..') || !whitelist.has(base)) {
return zipfile.readEntry();
}
zipfile.openReadStream(entry, (e, readStream) => {
if (e || !readStream) return reject(e ?? new Error('读取压缩条目失败'));
const ws = fs.createWriteStream(path.join(destDir, base));
readStream.on('error', reject);
ws.on('error', reject);
ws.on('close', () => {
extracted.add(base);
zipfile.readEntry();
});
readStream.pipe(ws);
});
});
zipfile.readEntry();
});
});
}
/**
* 成组完整性校验(通用,读宿主 manifest 的 group 声明):
* 返回"部分在但不全"的组(半组产不出主体 → 上传应拒)。组内全在或整组都不在 → 不返回(放行)。
* export 供单测(tests/cold-import-groups.spec.ts)。
*/
export function findIncompleteGroups(
tables: Array<{ file?: string; group?: string }>,
extracted: Set<string>,
): Array<{ group: string; present: string[]; missing: string[] }> {
const groups = new Map<string, string[]>(); // group → [basename...]
for (const t of tables) {
if (t.group && t.file) {
const arr = groups.get(t.group) ?? [];
arr.push(path.basename(t.file));
groups.set(t.group, arr);
}
}
return [...groups.entries()]
.map(([group, members]) => ({
group,
present: members.filter((f) => extracted.has(f)),
missing: members.filter((f) => !extracted.has(f)),
}))
.filter((g) => g.present.length > 0 && g.missing.length > 0);
}
// ── yauzl 最小类型(遵循仓库"不引 @types"的先例,如 assistant.controller UploadedAudio)──
interface YauzlEntry {
fileName: string;
}
...@@ -134,6 +134,35 @@ export class ClickHouseSourceService { ...@@ -134,6 +134,35 @@ export class ClickHouseSourceService {
/// 用途:增量 fetched=0 时分辨「DW 真没新数据(静默)」vs「查询条件失效在空转(告警)」 /// 用途:增量 fetched=0 时分辨「DW 真没新数据(静默)」vs「查询条件失效在空转(告警)」
/// —— 2026-06-10 游标 ISO 格式 bug 让增量空转三天才被人工发现,此探针让它当天就暴露。 /// —— 2026-06-10 游标 ISO 格式 bug 让增量空转三天才被人工发现,此探针让它当天就暴露。
/// 探针绝不能影响同步主流程:任何异常吞掉返回 {}。 /// 探针绝不能影响同步主流程:任何异常吞掉返回 {}。
/**
* 对账用:把资源查询原样包一层,统计**去重患者数**(patient_id + brand 元组 —— 集团内
* 同 patient_id 跨品牌是两个人,必须带 brand)。复用摄入查询本身 → 筛选口径零重复维护。
* 失败返回 null(对账降级,不阻断日报)。查询超时放宽到 120s(大表 count distinct)。
*/
async countDistinctPatients(source: ClickHouseSource, innerSql: string): Promise<number | null> {
let client: ReturnType<typeof createClient> | null = null;
try {
const conn = this.resolveConnection(source);
client = createClient({
url: conn.url,
database: conn.database,
username: conn.username,
password: conn.password,
request_timeout: 120_000,
compression: { response: true, request: false },
});
const q = `SELECT uniqExact(patient_id, brand) AS c FROM (\n${innerSql}\n)`;
const rs = await client.query({ query: q, format: 'JSONEachRow' });
const rows = (await rs.json()) as Array<{ c: string | number }>;
return Number(rows[0]?.c ?? 0);
} catch (e) {
this.logger.warn(`[reconcile] countDistinctPatients 失败: ${e instanceof Error ? e.message : String(e)}`);
return null;
} finally {
if (client) await client.close();
}
}
async probeNewRowCounts( async probeNewRowCounts(
source: ClickHouseSource, source: ClickHouseSource,
perQuery: IncrementalConfig['perQuery'], perQuery: IncrementalConfig['perQuery'],
......
...@@ -286,6 +286,9 @@ export class ColdImportService { ...@@ -286,6 +286,9 @@ export class ColdImportService {
source: string; // 原生源表名(= manifest tables[].table / transform 终端表) source: string; // 原生源表名(= manifest tables[].table / transform 终端表)
rows: Record<string, unknown>[]; rows: Record<string, unknown>[];
triggeredBy?: string; triggeredBy?: string;
/// 预检:跑完整管线(transforms + 装配 + 归一 + tenant 解析)但**不落任何库**,
/// 用于宿主对接自测——看 accepted/失败数与样本 canonical,确认 PAC 理解对了再正式推。
dryRun?: boolean;
}): Promise<{ }): Promise<{
syncLogId: string; syncLogId: string;
perResource: PerResourceStats[]; perResource: PerResourceStats[];
...@@ -333,7 +336,9 @@ export class ColdImportService { ...@@ -333,7 +336,9 @@ export class ColdImportService {
tenantId: 't_pending', // push 多 tenant 由行决定;finalize 时回填首个 seen tenant tenantId: 't_pending', // push 多 tenant 由行决定;finalize 时回填首个 seen tenant
direction: SyncDirection.PUSH, direction: SyncDirection.PUSH,
resource: opts.source, resource: opts.source,
triggeredBy: opts.triggeredBy ?? `push:${opts.hostName}:${opts.source}`, triggeredBy:
opts.triggeredBy ??
`${opts.dryRun ? 'push-dryrun' : 'push'}:${opts.hostName}:${opts.source}`,
status: SyncStatus.RUNNING, status: SyncStatus.RUNNING,
}, },
}); });
...@@ -362,19 +367,19 @@ export class ColdImportService { ...@@ -362,19 +367,19 @@ export class ColdImportService {
if (cfg.canonical === 'patient') { if (cfg.canonical === 'patient') {
perResource.push( perResource.push(
await this.processPatients( await this.processPatients(
transformed, cfg, host.id, tenantResolver, seenTenants, normalize, false, true, transformed, cfg, host.id, tenantResolver, seenTenants, normalize, !!opts.dryRun, true,
), ),
); );
} else if (cfg.canonical === 'patient_relation') { } else if (cfg.canonical === 'patient_relation') {
perResource.push( perResource.push(
await this.processPatientRelations( await this.processPatientRelations(
transformed, cfg, host.id, tenantResolver, normalize, false, transformed, cfg, host.id, tenantResolver, normalize, !!opts.dryRun,
), ),
); );
} else if (cfg.canonical === 'patient_return_visit') { } else if (cfg.canonical === 'patient_return_visit') {
perResource.push( perResource.push(
await this.processPatientReturnVisits( await this.processPatientReturnVisits(
transformed, cfg, host.id, tenantResolver, normalize, false, transformed, cfg, host.id, tenantResolver, normalize, !!opts.dryRun,
), ),
); );
} }
...@@ -388,7 +393,7 @@ export class ColdImportService { ...@@ -388,7 +393,7 @@ export class ColdImportService {
seenTenants, seenTenants,
normalize, normalize,
syncLog.id, syncLog.id,
false, !!opts.dryRun,
); );
perResource.push(stats); perResource.push(stats);
} }
...@@ -570,6 +575,37 @@ export class ColdImportService { ...@@ -570,6 +575,37 @@ export class ColdImportService {
} }
/** /**
* 数据对账 —— DW 源侧「去重患者数」(设计见 /docs/data-reconciliation)。
*
* 复用摄入用的 sql_source 查询本身(同一道筛选闸)→ diff 只剩真漂移,零重复维护。
* 只算 DW 侧;PAC 侧计数 + diff + 渲染由调用方(日报)做(它持 prisma + 按 fact_type)。
*
* @param queryKeys 要对账的 sql_source.queries key(如 fact_emr_treatment_out / image_finding_rows)
* @returns { queryKey: 去重患者数 | null(查失败) };无 sql_source(file/push 宿主)→ 空 {}
*/
async reconcileDwPatientCounts(
hostName: string,
queryKeys: string[],
): Promise<Record<string, number | null>> {
const out: Record<string, number | null> = {};
let manifest: ColdImportManifest;
try {
manifest = this.readManifest(this.resolveHostDir(hostName));
} catch (e) {
this.logger.warn(`[reconcile] 读 manifest 失败 host=${hostName}: ${e instanceof Error ? e.message : String(e)}`);
return out;
}
const source = manifest.sql_source;
if (!source) return out; // file/push 宿主无直连源,不对账
for (const key of queryKeys) {
const sql = source.queries[key];
if (!sql) { out[key] = null; continue; }
out[key] = await this.chSource.countDistinctPatients(source, sql);
}
return out;
}
/**
* raw 层列漂移检测(form A):本次推送行的列集 vs 历史同源 rawPayload 的列集。 * raw 层列漂移检测(form A):本次推送行的列集 vs 历史同源 rawPayload 的列集。
* - 历史有、本次没有 → column_removed(疑似宿主删列/改名) * - 历史有、本次没有 → column_removed(疑似宿主删列/改名)
* - 本次有、历史没有 → column_added(疑似宿主加列/改名) * - 本次有、历史没有 → column_added(疑似宿主加列/改名)
...@@ -596,10 +632,25 @@ export class ColdImportService { ...@@ -596,10 +632,25 @@ export class ColdImportService {
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
}); });
if (sample.length < MIN_BASELINE) return []; if (sample.length < MIN_BASELINE) return [];
const baseline = new Set<string>(); // 同 subjectType 可能混多源(如 refund = 结算头 patient_settlement ∪ 结算明细
for (const s of sample) { // patient_settlement_spec,列集不同)。按「与本批列签名的 Jaccard 相似度」自选**同源**样本,
// 否则另一源的专属列会被误报成"本源删列"(suspectFields 虚警)。跨源如 settlement/spec 交并比
// 仅 ~0.35,≥0.5 阈值可干净分离;同源 ≈1.0。
const pushed = args.pushedCols;
const sameSource = sample.filter((s) => {
const rp = s.rawPayload as Record<string, unknown> | null; const rp = s.rawPayload as Record<string, unknown> | null;
if (rp) for (const k of Object.keys(rp)) if (!k.startsWith('_')) baseline.add(k); if (!rp) return false;
const cols = Object.keys(rp).filter((k) => !k.startsWith('_'));
if (cols.length === 0) return false;
const inter = cols.filter((c) => pushed.has(c)).length;
const union = new Set([...cols, ...pushed]).size;
return union > 0 && inter / union >= 0.5;
});
if (sameSource.length < MIN_BASELINE) return []; // 同源基线不足 → 不可靠,宁可不报
const baseline = new Set<string>();
for (const s of sameSource) {
const rp = s.rawPayload as Record<string, unknown>;
for (const k of Object.keys(rp)) if (!k.startsWith('_')) baseline.add(k);
} }
const out: SuspectField[] = []; const out: SuspectField[] = [];
...@@ -2150,16 +2201,28 @@ export class ColdImportService { ...@@ -2150,16 +2201,28 @@ export class ColdImportService {
'manifest 缺数据源:既无 sql_source 也无 tables[](schema refine 本该拦住,兜底报错)', 'manifest 缺数据源:既无 sql_source 也无 tables[](schema refine 本该拦住,兜底报错)',
); );
} }
// 宽容装载:声明表文件缺失 → warn + 跳过(不 throw)。
// PAC 各主体 assembler 独立:缺某表只少那部分 facts。下游已宽容——
// transform lookup/from 缺表当空表、project input 缺表跳过、assembler primary.table
// 缺表产 0 行。成组主体的完整性由上传层(ImportUploadService)按 manifest group 声明前置守卫,
// 不在此处判(此处也服务 CLI 全量导入,不宜硬拦)。
const tables: Record<string, unknown[]> = {}; const tables: Record<string, unknown[]> = {};
const skipped: string[] = [];
for (const t of manifest.tables) { for (const t of manifest.tables) {
const filePath = path.join(dir, t.file); const filePath = path.join(dir, t.file);
if (!fs.existsSync(filePath)) { if (!fs.existsSync(filePath)) {
throw new Error(`Raw table file not found: ${filePath}`); skipped.push(t.table);
continue;
} }
const format = t.format ?? inferFileFormat(t.file); const format = t.format ?? inferFileFormat(t.file);
tables[t.table] = this.readRows(filePath, format); tables[t.table] = this.readRows(filePath, format);
this.logger.log(` loaded raw table "${t.table}" (${tables[t.table]!.length} rows)`); this.logger.log(` loaded raw table "${t.table}" (${tables[t.table]!.length} rows)`);
} }
if (skipped.length > 0) {
this.logger.warn(
` ${skipped.length} 张声明表文件缺失,已跳过(依赖它们的 assembler/transform 将当空表处理): ${skipped.join(', ')}`,
);
}
return tables; return tables;
} }
...@@ -2262,24 +2325,46 @@ interface TotalsBlock { ...@@ -2262,24 +2325,46 @@ interface TotalsBlock {
/// 沿 transforms 的 output→input 链回溯,找到非任何 transform 产出的"原始源表"名。 /// 沿 transforms 的 output→input 链回溯,找到非任何 transform 产出的"原始源表"名。
/// 例:diagnosis_rows ← _diagnosis_norm ← _diagnosis_raw ← fact_emr_treatment_out(终点=源表)。 /// 例:diagnosis_rows ← _diagnosis_norm ← _diagnosis_raw ← fact_emr_treatment_out(终点=源表)。
/// 用于 reparse:transaction.rawPayload 存的就是这张源表的行。 /// 用于 reparse(rawPayload 存的就是这张源表的行)+ push(source→匹配 assembler)。
function traceRawSourceTable(primaryTable: string, transforms: ReadonlyArray<unknown>): string { ///
const byOutput = new Map<string, string>(); // output 表名 → input 表名 /// union 多输入:各输入独立回溯,**全部收敛到同一根表**才返回该根(如 refund_full_rows
/// ← union[_refund_status4, _refund_neg3] 两路都回到 patient_settlement → 返回它);
/// 若发散到多个根(语义歧义)→ 停在 union 输出本身(维持旧行为,不误判)。
/// 修复前 union 不被追 → refund_full 在 push/reparse 里回溯不到源表被静默跳过(丢财务数据)。
export function traceRawSourceTable(primaryTable: string, transforms: ReadonlyArray<unknown>): string {
const byOutput = new Map<string, string>(); // output → input(单输入算子)
const unionInputs = new Map<string, string[]>(); // union output → inputs[]
for (const tf of transforms) { for (const tf of transforms) {
const t = tf as { input?: string; output?: string; outputs?: Array<{ output?: string }> }; const t = tf as {
kind?: string;
input?: string;
output?: string;
inputs?: string[];
outputs?: Array<{ output?: string }>;
};
if (t.kind === 'union' && t.output && Array.isArray(t.inputs)) {
unionInputs.set(t.output, t.inputs);
continue;
}
if (t.output && t.input) byOutput.set(t.output, t.input); if (t.output && t.input) byOutput.set(t.output, t.input);
// route_by_pattern 多 output:每个 output 都回到同一 input // route_by_pattern 多 output:每个 output 都回到同一 input
if (Array.isArray(t.outputs) && t.input) { if (Array.isArray(t.outputs) && t.input) {
for (const o of t.outputs) if (o?.output) byOutput.set(o.output, t.input); for (const o of t.outputs) if (o?.output) byOutput.set(o.output, t.input);
} }
} }
let cur = primaryTable; const resolve = (tbl: string, seen: Set<string>): string => {
const seen = new Set<string>(); if (seen.has(tbl)) return tbl; // 环保护
while (byOutput.has(cur) && !seen.has(cur)) { seen.add(tbl);
seen.add(cur); if (byOutput.has(tbl)) return resolve(byOutput.get(tbl)!, seen);
cur = byOutput.get(cur)!; const ins = unionInputs.get(tbl);
if (ins && ins.length > 0) {
// 各输入独立回溯(各带 seen 副本,兄弟分支互不干扰);全一致才收敛,否则停在 union 输出
const roots = new Set(ins.map((i) => resolve(i, new Set(seen))));
return roots.size === 1 ? [...roots][0]! : tbl;
} }
return cur; return tbl; // 已是根
};
return resolve(primaryTable, new Set<string>());
} }
export interface PerResourceStats extends TotalsBlock { export interface PerResourceStats extends TotalsBlock {
......
...@@ -26,6 +26,12 @@ export const RawTableSchema = z.object({ ...@@ -26,6 +26,12 @@ export const RawTableSchema = z.object({
file: z.string().min(1).describe('e.g. tb_encounter.csv / tb_patient.json'), file: z.string().min(1).describe('e.g. tb_encounter.csv / tb_patient.json'),
/// 文件格式(默认按扩展名推断) /// 文件格式(默认按扩展名推断)
format: z.enum(['csv', 'json']).optional(), format: z.enum(['csv', 'json']).optional(),
/// 成组主体标签(宿主特有,可选)。同 group 的表在**网页上传**时要么全在、要么整组跳过
/// —— 由 ImportUploadService 通用地读此声明做完整性校验。用于"半组产不出主体"的强依赖
/// (如 treatment_planned = customer_treat_plan[头] + _item[行],缺任一整个资源产 0 行)。
/// 不标 = 独立表:缺失仅少那部分 facts(装载层已宽容跳过)。
/// 注:CLI 全量导入不受此约束(只 warn 跳过);仅上传路径按 group 前置守卫。
group: z.string().min(1).optional(),
}); });
export type RawTable = z.infer<typeof RawTableSchema>; export type RawTable = z.infer<typeof RawTableSchema>;
......
...@@ -49,14 +49,18 @@ export class PushReceiverService { ...@@ -49,14 +49,18 @@ export class PushReceiverService {
hostName: string; hostName: string;
source: string; source: string;
rows: Record<string, unknown>[]; rows: Record<string, unknown>[];
/// 预检(?dryRun=1):跑完整管线但不落库,也不触发重算。宿主对接自测用。
dryRun?: boolean;
}): Promise<PushRowsResponse> { }): Promise<PushRowsResponse> {
const { hostId, hostName, source, rows } = input; const { hostId, hostName, source, rows } = input;
const dryRun = !!input.dryRun;
const r = await this.coldImport.ingestRawTables({ const r = await this.coldImport.ingestRawTables({
hostName, hostName,
source, source,
rows, rows,
triggeredBy: `push:${hostName}:${source}`, dryRun,
triggeredBy: `${dryRun ? 'push-dryrun' : 'push'}:${hostName}:${source}`,
}); });
const agg = r.perResource.reduce( const agg = r.perResource.reduce(
...@@ -81,8 +85,9 @@ export class PushReceiverService { ...@@ -81,8 +85,9 @@ export class PushReceiverService {
// 事件驱动:touched 患者入队 persona-recompute(去抖:存量批次重放时窗内合并,见 QueueProducer) // 事件驱动:touched 患者入队 persona-recompute(去抖:存量批次重放时窗内合并,见 QueueProducer)
// ⚠️ plan 不在此触发(persona 完成后链式 enqueue),与单患者刷新路径有意区分。 // ⚠️ plan 不在此触发(persona 完成后链式 enqueue),与单患者刷新路径有意区分。
// 预检不产生任何副作用 → 不入队(touched 本就为空,此处显式短路,语义更清楚)。
let personaEnqueued = 0; let personaEnqueued = 0;
for (const { patientId, tenantId } of r.touched) { for (const { patientId, tenantId } of dryRun ? [] : r.touched) {
try { try {
await this.queue.enqueuePersonaRecompute( await this.queue.enqueuePersonaRecompute(
{ hostId, tenantId, patientId, triggeredBy: `push:${hostName}` }, { hostId, tenantId, patientId, triggeredBy: `push:${hostName}` },
...@@ -101,6 +106,13 @@ export class PushReceiverService { ...@@ -101,6 +106,13 @@ export class PushReceiverService {
`txn=${agg.txn} dup=${agg.dup} failed=${agg.failed} personaEnqueued=${personaEnqueued}`, `txn=${agg.txn} dup=${agg.dup} failed=${agg.failed} personaEnqueued=${personaEnqueued}`,
); );
// 预检:回样本 canonical(各资源首行),供宿主核对 PAC 把原生行翻译成了什么
const samples = dryRun
? r.perResource
.filter((s) => (s.sampleCanonical?.length ?? 0) > 0)
.map((s) => ({ resource: s.resource, canonical: s.sampleCanonical[0] }))
: [];
return { return {
syncLogId: r.syncLogId, syncLogId: r.syncLogId,
source, source,
...@@ -111,6 +123,8 @@ export class PushReceiverService { ...@@ -111,6 +123,8 @@ export class PushReceiverService {
personaEnqueued, personaEnqueued,
mappingMisses: r.mappingMisses.length, mappingMisses: r.mappingMisses.length,
suspectFields: r.suspectFields.length, suspectFields: r.suspectFields.length,
dryRun,
samples,
}; };
} }
......
import { import {
Body, Body,
Controller, Controller,
Get,
Headers, Headers,
HttpCode, HttpCode,
Post, Post,
Query,
Req, Req,
} from '@nestjs/common'; } from '@nestjs/common';
import type { Request } from 'express'; import type { Request } from 'express';
import { ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
import { ApiCode } from '@pac/types'; import { ApiCode } from '@pac/types';
import { BizError } from '../../../common/errors/biz-error'; import { BizError } from '../../../common/errors/biz-error';
import { Public } from '../../../common/decorators/public.decorator'; import { Public } from '../../../common/decorators/public.decorator';
import { HmacVerifier } from './hmac-verifier.service'; import { HmacVerifier } from './hmac-verifier.service';
import { PushReceiverService } from './push-receiver.service'; import { PushReceiverService } from './push-receiver.service';
import { HostsService } from '../../admin/hosts.service';
import { import {
PushEventsRequestSchema, PushEventsRequestSchema,
type PushEventsRequest, type PushEventsRequest,
...@@ -44,6 +47,7 @@ export class PushController { ...@@ -44,6 +47,7 @@ export class PushController {
constructor( constructor(
private readonly verifier: HmacVerifier, private readonly verifier: HmacVerifier,
private readonly receiver: PushReceiverService, private readonly receiver: PushReceiverService,
private readonly hosts: HostsService,
) {} ) {}
/** /**
...@@ -137,14 +141,23 @@ export class PushController { ...@@ -137,14 +141,23 @@ export class PushController {
@Public() @Public()
@HttpCode(200) @HttpCode(200)
@ApiOperation({ @ApiOperation({
summary: '宿主推送原生表行 webhook(形态 A,HMAC 验签)', summary: '宿主推送原生表行 webhook(形态 A,HMAC 验签;?dryRun=1 预检不落库)',
description: 'Headers 必填 X-PAC-Host-Id / X-PAC-Timestamp / X-PAC-Signature。Body: { tenantId?, source, rows[] }。', description:
'Headers 必填 X-PAC-Host-Id / X-PAC-Timestamp / X-PAC-Signature。Body: { source, rows[] }。' +
' 加 ?dryRun=1 走**预检**:跑完整管线(transforms+装配+归一)但不落库、不触发重算,' +
'响应带各资源样本 canonical —— 对接自测先预检到 failed=0 且 mappingMisses/suspectFields 为 0,再正式推。',
})
@ApiQuery({
name: 'dryRun',
required: false,
description: "预检开关:'1' / 'true' / 'yes' = 跑完整管线但不落库、不触发重算,响应带样本 canonical",
}) })
async receiveRows( async receiveRows(
@Req() req: Request & { rawBody?: Buffer }, @Req() req: Request & { rawBody?: Buffer },
@Headers('x-pac-host-id') hostIdHeader: string | undefined, @Headers('x-pac-host-id') hostIdHeader: string | undefined,
@Headers('x-pac-timestamp') timestampHeader: string | undefined, @Headers('x-pac-timestamp') timestampHeader: string | undefined,
@Headers('x-pac-signature') signatureHeader: string | undefined, @Headers('x-pac-signature') signatureHeader: string | undefined,
@Query('dryRun') dryRunQuery: string | undefined,
@Body() body: PushRowsRequest, @Body() body: PushRowsRequest,
) { ) {
const rawBody = req.rawBody ? req.rawBody.toString('utf8') : JSON.stringify(body); const rawBody = req.rawBody ? req.rawBody.toString('utf8') : JSON.stringify(body);
...@@ -158,13 +171,53 @@ export class PushController { ...@@ -158,13 +171,53 @@ export class PushController {
const parsed = this.parseOrReject(() => PushRowsRequestSchema.parse(body)); const parsed = this.parseOrReject(() => PushRowsRequestSchema.parse(body));
// ?dryRun=1 / true / yes 均视为预检(宿主客户端拼 query 习惯不一)
const dryRun = ['1', 'true', 'yes'].includes((dryRunQuery ?? '').trim().toLowerCase());
return this.withHostLock(hostId, hostName, () => return this.withHostLock(hostId, hostName, () =>
this.receiver.receiveRows({ this.receiver.receiveRows({
hostId, hostId,
hostName, hostName,
source: parsed.source, source: parsed.source,
rows: parsed.rows, rows: parsed.rows,
dryRun,
}), }),
); );
} }
/**
* 推送记录查询 —— **用已有 push 凭据验签,无需另外登录**(对接自测友好)。
*
* 与登录态的 `GET /admin/host/self/push-logs` 返回同一份数据(同一实现),区别只在鉴权方式:
* 这里复用宿主推送时已经在用的 HMAC 三件套,故不必为联调再开账号;
* 也**不是公开接口** —— 没有正确签名拿不到任何数据,不会泄露宿主推送情况。
*
* GET 无 body,签名对**空串**计算:`hex(HMAC-SHA256(`${timestamp}.`, secret))`。
*/
@Get('logs')
@Public()
@HttpCode(200)
@ApiOperation({
summary: '查询本宿主推送记录(HMAC 验签,免登录)',
description:
'Headers 同 push:X-PAC-Host-Id / X-PAC-Timestamp / X-PAC-Signature。' +
'GET 无 body,签名对空串算:hex(HMAC-SHA256(`<timestamp>.`, secret))。' +
'返回最近 N 批(默认 50、上限 200):source / status / dryRun / 收行数 / 落库数 / 去重 / 失败 / 报错。',
})
@ApiQuery({ name: 'limit', required: false, description: '返回条数,默认 50、上限 200' })
async logs(
@Headers('x-pac-host-id') hostIdHeader: string | undefined,
@Headers('x-pac-timestamp') timestampHeader: string | undefined,
@Headers('x-pac-signature') signatureHeader: string | undefined,
@Query('limit') limit?: string,
) {
const { hostId } = await this.verifier.verify({
hostIdHeader,
timestampHeader,
signatureHeader,
rawBody: '', // GET 无 body
});
const n = Number.parseInt(limit ?? '', 10);
return this.hosts.getPushLogs(hostId, Number.isFinite(n) && n > 0 ? n : 50);
}
} }
...@@ -87,9 +87,15 @@ export const PushRowsResponseSchema = z.object({ ...@@ -87,9 +87,15 @@ export const PushRowsResponseSchema = z.object({
duplicates: z.number().int(), duplicates: z.number().int(),
failed: z.number().int(), failed: z.number().int(),
personaEnqueued: z.number().int().describe('入队画像重算的患者数(plan 不在此触发,由定时任务保)'), personaEnqueued: z.number().int().describe('入队画像重算的患者数(plan 不在此触发,由定时任务保)'),
/// 宿主自检信号(代替 cold-import 的 dry-run):样本批推完看这两个数,>0 先停下联系 PAC。 /// 宿主自检信号:样本批推完看这两个数,>0 先停下联系 PAC。
mappingMisses: z.number().int().describe('映射覆盖缺口种类数(有原值落 _default)'), mappingMisses: z.number().int().describe('映射覆盖缺口种类数(有原值落 _default)'),
suspectFields: z.number().int().describe('疑似宿主改表项数(列缺席/新增,vs 历史基线)'), suspectFields: z.number().int().describe('疑似宿主改表项数(列缺席/新增,vs 历史基线)'),
/// 预检模式(?dryRun=1):跑完整管线(transforms+装配+归一+tenant 解析)但**不落任何库**,
/// 数字表示"若正式推会发生什么"。对接自测用:先预检到 failed=0 且两个自检信号为 0,再正式推。
dryRun: z.boolean().describe('true = 本次为预检,未落库'),
samples: z
.array(z.object({ resource: z.string(), canonical: z.unknown() }))
.describe('预检样本:各 canonical 资源首行的翻译结果(仅 dryRun 返回,供宿主核对 PAC 是否理解正确)'),
}); });
export type PushRowsResponse = z.infer<typeof PushRowsResponseSchema>; export type PushRowsResponse = z.infer<typeof PushRowsResponseSchema>;
...@@ -32,8 +32,11 @@ import { HmacVerifier } from './push/hmac-verifier.service'; ...@@ -32,8 +32,11 @@ import { HmacVerifier } from './push/hmac-verifier.service';
* - AssemblerModule(raw → canonical;真实 HTTP adapter 才需要;mock/push 跳过) * - AssemblerModule(raw → canonical;真实 HTTP adapter 才需要;mock/push 跳过)
* - PipelineModule(synthesizer + parser pipeline) * - PipelineModule(synthesizer + parser pipeline)
*/ */
import { AdminModule } from '../admin/admin.module';
@Module({ @Module({
imports: [ imports: [
AdminModule, // push/logs 复用 HostsService.getPushLogs(与登录态自助页同一实现)
FactsModule, FactsModule,
AssemblerModule, AssemblerModule,
TransformsModule, TransformsModule,
......
...@@ -10,6 +10,7 @@ import { isEmptyText, type Row } from '../row'; ...@@ -10,6 +10,7 @@ import { isEmptyText, type Row } from '../row';
* - not_empty: true — 字段非 null/undefined/空字符串 * - not_empty: true — 字段非 null/undefined/空字符串
* - in: [...] — 字段值在列表中 * - in: [...] — 字段值在列表中
* - equals: x — 字段值等于 x * - equals: x — 字段值等于 x
* - lt/lte/gt/gte: n — 数值比较(字段解析为数字;非数值/空 → 不通过)
*/ */
export function runFilter(op: FilterOp, rows: Row[]): { rows: Row[]; dropped: number } { export function runFilter(op: FilterOp, rows: Row[]): { rows: Row[]; dropped: number } {
const out: Row[] = []; const out: Row[] = [];
...@@ -127,7 +128,26 @@ function passAll(row: Row, where: Record<string, WhereRule>): boolean { ...@@ -127,7 +128,26 @@ function passAll(row: Row, where: Record<string, WhereRule>): boolean {
if (!set.has(String(v))) return false; if (!set.has(String(v))) return false;
} else if ('equals' in rule) { } else if ('equals' in rule) {
if (String(v) !== String(rule.equals)) return false; if (String(v) !== String(rule.equals)) return false;
} else if ('lt' in rule) {
const n = toNumber(v);
if (n === null || n >= rule.lt) return false;
} else if ('lte' in rule) {
const n = toNumber(v);
if (n === null || n > rule.lte) return false;
} else if ('gt' in rule) {
const n = toNumber(v);
if (n === null || n <= rule.gt) return false;
} else if ('gte' in rule) {
const n = toNumber(v);
if (n === null || n < rule.gte) return false;
} }
} }
return true; return true;
} }
/// 数值解析:空/非数字 → null(数值比较一律不通过)。CSV 字段是字符串,金额如 "-30.00"。
function toNumber(v: unknown): number | null {
if (v === null || v === undefined || (typeof v === 'string' && v.trim() === '')) return null;
const n = Number(v);
return Number.isFinite(n) ? n : null;
}
...@@ -69,6 +69,8 @@ export type ProjectOp = z.infer<typeof ProjectOpSchema>; ...@@ -69,6 +69,8 @@ export type ProjectOp = z.infer<typeof ProjectOpSchema>;
* - `not_empty: true` — 字符串非空 / null 过滤 * - `not_empty: true` — 字符串非空 / null 过滤
* - `in: [...]` — 值在列表中 * - `in: [...]` — 值在列表中
* - `equals: x` — 值等于 x * - `equals: x` — 值等于 x
* - `lt/lte/gt/gte: n` — 数值比较(字段解析为数字;非数值/空 → 不通过)。
* 让退费正负切分等"业务口径 WHERE"能在 PAC 侧做(宿主导原表,PAC transforms 过滤)。
*/ */
export const WhereRuleSchema = z.union([ export const WhereRuleSchema = z.union([
z.object({ not_empty: z.literal(true) }), z.object({ not_empty: z.literal(true) }),
...@@ -82,6 +84,11 @@ export const WhereRuleSchema = z.union([ ...@@ -82,6 +84,11 @@ export const WhereRuleSchema = z.union([
z.object({ real_no_tooth: z.array(z.union([z.string(), z.number()])).min(1) }), z.object({ real_no_tooth: z.array(z.union([z.string(), z.number()])).min(1) }),
z.object({ in: z.array(z.union([z.string(), z.number()])).min(1) }), z.object({ in: z.array(z.union([z.string(), z.number()])).min(1) }),
z.object({ equals: z.union([z.string(), z.number()]) }), z.object({ equals: z.union([z.string(), z.number()]) }),
// 数值比较:字段解析为数字后比较;非数值/空值一律不通过(退费 receivable_this < 0 等)。
z.object({ lt: z.number() }),
z.object({ lte: z.number() }),
z.object({ gt: z.number() }),
z.object({ gte: z.number() }),
]); ]);
export type WhereRule = z.infer<typeof WhereRuleSchema>; export type WhereRule = z.infer<typeof WhereRuleSchema>;
......
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { Cron } from '@nestjs/schedule'; import { Cron } from '@nestjs/schedule';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { AlertService, type AlertLevel } from '../common/alerting/alert.service'; import { AlertService, type AlertLevel } from '../common/alerting/alert.service';
import { ColdImportService } from '../modules/sync/cold-import/cold-import.service';
/**
* 数据对账 scope —— DW 源(sql_source.queries key)↔ PAC 侧计数口径。
* 复用摄入查询做源计数(同闸),PAC 侧按对应 fact_type / 表数去重患者。设计见 /docs/data-reconciliation。
*/
type PacCountKind =
| { kind: 'patient' }
| { kind: 'fact'; types: string[] }
| { kind: 'return_visit' }
| { kind: 'image_ai' };
const RECONCILE_SCOPES: Array<{ label: string; dwKey: string; pac: PacCountKind }> = [
{ label: '总量', dwKey: 'fact_client_out', pac: { kind: 'patient' } },
{ label: '治疗病历', dwKey: 'fact_emr_treatment_out', pac: { kind: 'fact', types: ['encounter_record', 'emr_record', 'diagnosis_record', 'treatment_record', 'recommendation_record'] } },
{ label: '预约', dwKey: 'fact_appointment_out', pac: { kind: 'fact', types: ['appointment_record'] } },
{ label: '结算', dwKey: 'fact_settlement_out', pac: { kind: 'fact', types: ['payment_record'] } },
{ label: '影像', dwKey: 'image_finding_rows', pac: { kind: 'image_ai' } },
{ label: '咨询', dwKey: 'fact_consult_out', pac: { kind: 'fact', types: ['consultation_record'] } },
{ label: '回访', dwKey: 'fact_returnvisit_out', pac: { kind: 'return_visit' } },
{ label: '转介', dwKey: 'fact_customer_referee_out', pac: { kind: 'fact', types: ['referral_record'] } },
];
/** /**
* DailyHealthReportService — 每日 09:00(沪)给企微推一份**简洁的线上健康日报**。 * DailyHealthReportService — 每日 09:00(沪)给企微推一份**简洁的线上健康日报**。
...@@ -21,6 +43,7 @@ export class DailyHealthReportService { ...@@ -21,6 +43,7 @@ export class DailyHealthReportService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly alerter: AlertService, private readonly alerter: AlertService,
private readonly coldImport: ColdImportService,
) {} ) {}
@Cron(process.env.PAC_HEALTH_REPORT_CRON || '0 9 * * *', { @Cron(process.env.PAC_HEALTH_REPORT_CRON || '0 9 * * *', {
...@@ -68,6 +91,31 @@ export class DailyHealthReportService { ...@@ -68,6 +91,31 @@ export class DailyHealthReportService {
const lagHours = lastSync?.cursorAfter ? cursorLagHours(lastSync.cursorAfter, now) : null; const lagHours = lastSync?.cursorAfter ? cursorLagHours(lastSync.cursorAfter, now) : null;
const lagTag = lagHours == null ? '⚪' : lagHours > errorH ? '🔴' : lagHours > warnH ? '🟡' : '🟢'; const lagTag = lagHours == null ? '⚪' : lagHours > errorH ? '🔴' : lagHours > warnH ? '🟡' : '🟢';
// ── 每轮增量明细(近 24h)──
// DW 现为 2h 刷新 → 每轮理应有数据;**空轮(fetched=0)大概率是异常**(DW 没刷 / 游标失效),
// 计入 warning。失败轮同样计入。逐轮列出让"每次 pull 的情况"一眼可见。
const rounds = await this.prisma.syncLog.findMany({
where: { resource: 'incremental_bundle', startedAt: { gte: since24h } },
orderBy: { startedAt: 'desc' },
select: {
startedAt: true, endedAt: true, status: true,
fetched: true, transactionsWritten: true, factsEmitted: true, duplicates: true, failed: true,
},
take: 20,
});
const roundEmpty = rounds.filter((r) => r.status === 'success' && r.fetched === 0).length;
const roundFailed = rounds.filter((r) => r.status !== 'success' && r.status !== 'running').length;
const roundLines = rounds.map((r) => {
const t = fmtClock(r.startedAt);
const mins = r.endedAt ? Math.round((+new Date(r.endedAt) - +new Date(r.startedAt)) / 60000) : null;
const dur = mins == null ? '' : mins >= 60 ? ` ${(mins / 60).toFixed(1)}h` : ` ${mins}m`;
if (r.status === 'running') return ` ${t} ⏳ 进行中`;
if (r.status !== 'success') return ` ${t} ❌ 失败${dur}`;
if (r.fetched === 0) return ` ${t} ⚠️ 空轮(0行)${dur}`;
return ` ${t} ✅ 拉${fmtNum(r.fetched)} tx${fmtNum(r.transactionsWritten)}/dup${fmtNum(r.duplicates)}/失${r.failed} f+${fmtNum(r.factsEmitted)}${dur}`;
});
const roundTag = roundFailed > 0 ? '🔴' : roundEmpty > 0 ? '🟡' : '🟢';
// ── 重算:画像 / 召回 最近一次 ── // ── 重算:画像 / 召回 最近一次 ──
const [lastPersona, lastPlanGen] = await Promise.all([ const [lastPersona, lastPlanGen] = await Promise.all([
this.prisma.personaRecomputeLog.findFirst({ orderBy: { startedAt: 'desc' }, select: { startedAt: true, status: true } }), this.prisma.personaRecomputeLog.findFirst({ orderBy: { startedAt: 'desc' }, select: { startedAt: true, status: true } }),
...@@ -143,13 +191,23 @@ export class DailyHealthReportService { ...@@ -143,13 +191,23 @@ export class DailyHealthReportService {
if (fail7d > 0 && lastSync == null) level = 'critical'; if (fail7d > 0 && lastSync == null) level = 'critical';
if (lagHours != null && lagHours > warnH) level = 'warning'; if (lagHours != null && lagHours > warnH) level = 'warning';
if (lastPersona?.status === 'failed' || (lastPlanGen == null)) level = 'warning'; if (lastPersona?.status === 'failed' || (lastPlanGen == null)) level = 'warning';
// DW 2h 刷新下,空轮/失败轮是异常信号
if (roundEmpty > 0) level = 'warning';
if (lagHours != null && lagHours > errorH) level = 'critical'; if (lagHours != null && lagHours > errorH) level = 'critical';
if (roundFailed > 0) level = 'critical';
// ── 数据对账(DW 源 vs PAC,去重患者数;设计见 /docs/data-reconciliation)──
// v1 观察模式:原样报 diff,不设基线、不驱动报告级别(diff 含已知差如瑞泰未全摄,
// 先看一两周摸清"正常差"再回填基线)。DW 不可达 → 该行降级 "—",不阻断日报。
const reconcileLines = await this.buildReconcile();
const dateStr = new Date(now).toLocaleDateString('zh-CN', { timeZone: 'Asia/Shanghai' }).replace(/\//g, '-'); const dateStr = new Date(now).toLocaleDateString('zh-CN', { timeZone: 'Asia/Shanghai' }).replace(/\//g, '-');
const title = `每日健康报告 · ${dateStr}`; const title = `每日健康报告 · ${dateStr}`;
const body = [ const body = [
`📥 摄入 ${lagTag} 上次 ${fmtTime(lastSync?.startedAt)} · DW延迟 ${lagHours == null ? '—' : lagHours.toFixed(1) + 'h'} · 7日失败 ${fail7d}`, `📥 摄入 ${lagTag} 上次 ${fmtTime(lastSync?.startedAt)} · DW延迟 ${lagHours == null ? '—' : lagHours.toFixed(1) + 'h'} · 7日失败 ${fail7d}`,
`📥 增量每轮(24h · ${roundTag} ${rounds.length}${roundEmpty ? ` · 空${roundEmpty}` : ''}${roundFailed ? ` · 失败${roundFailed}` : ''})` +
(roundLines.length ? '\n' + roundLines.join('\n') : ' 无'),
`🔄 重算 画像 ${fmtTime(lastPersona?.startedAt)}${lastPersona?.status && lastPersona.status !== 'success' ? `(${lastPersona.status})` : ''} · 召回 ${fmtTime(lastPlanGen?.startedAt)}`, `🔄 重算 画像 ${fmtTime(lastPersona?.startedAt)}${lastPersona?.status && lastPersona.status !== 'success' ? `(${lastPersona.status})` : ''} · 召回 ${fmtTime(lastPlanGen?.startedAt)}`,
`📈 数据(24h) 患者 ${fmtNum(patientsTotal)}(+${patients24h}) · 事实 ${fmtWan(factsTotal)}(+${fmtNum(facts24h)})`, `📈 数据(24h) 患者 ${fmtNum(patientsTotal)}(+${patients24h}) · 事实 ${fmtWan(factsTotal)}(+${fmtNum(facts24h)})`,
`🖼 画像(24h) 重算 ${fmtNum(personaRuns24h)} (${personaRunsZh}) · 覆盖 ${fmtNum(personaPatients)}/${fmtNum(patientsTotal)}(${coveragePct}%)`, `🖼 画像(24h) 重算 ${fmtNum(personaRuns24h)} (${personaRunsZh}) · 覆盖 ${fmtNum(personaPatients)}/${fmtNum(patientsTotal)}(${coveragePct}%)`,
...@@ -157,10 +215,66 @@ export class DailyHealthReportService { ...@@ -157,10 +215,66 @@ export class DailyHealthReportService {
`📋 召回池 待办 ${fmtNum(plansActive)} · 进行中 ${fmtNum(plansAssigned)}`, `📋 召回池 待办 ${fmtNum(plansActive)} · 进行中 ${fmtNum(plansAssigned)}`,
`📞 客服执行(24h) ${execTotal}${execBreakdown ? ` · ${execBreakdown}` : ''}`, `📞 客服执行(24h) ${execTotal}${execBreakdown ? ` · ${execBreakdown}` : ''}`,
`🤖 AI(24h) ${inv24h} 次 · ¥${cost24h.toFixed(2)}`, `🤖 AI(24h) ${inv24h} 次 · ¥${cost24h.toFixed(2)}`,
`⚖️ 对账(去重患者·DW源vsPAC)${reconcileLines.length ? '\n' + reconcileLines.join('\n') : ' —'}`,
].join('\n'); ].join('\n');
return { level, title, body }; return { level, title, body };
} }
/**
* 数据对账 —— 逐 scope 比 DW 源 vs PAC 的去重患者数。
* DW 侧复用摄入 sql_source 查询(同闸口径);PAC 侧按对应 fact_type / 表数去重患者。
* v1 只渲染原始 diff(不设基线、不驱动级别),供观察。失败降级 "—",绝不阻断日报。
*/
private async buildReconcile(): Promise<string[]> {
try {
const host = await this.prisma.host.findFirst({ where: { name: 'jvs-dw' }, select: { id: true } });
if (!host) return [];
const dwCounts = await this.coldImport.reconcileDwPatientCounts('jvs-dw', RECONCILE_SCOPES.map((s) => s.dwKey));
if (Object.keys(dwCounts).length === 0) return []; // 无 sql_source(file/push 宿主)
const lines: string[] = [];
for (const s of RECONCILE_SCOPES) {
const dw = dwCounts[s.dwKey] ?? null;
const pac = await this.pacPatientCount(host.id, s.pac);
lines.push(fmtReconcileLine(s.label, dw, pac));
}
return lines;
} catch (err) {
this.logger.warn(`对账段生成失败(降级,不阻断日报): ${err instanceof Error ? err.message : String(err)}`);
return [];
}
}
/** PAC 侧去重患者数(当前版本:superseded_at IS NULL)。 */
private async pacPatientCount(hostId: string, pac: PacCountKind): Promise<number | null> {
try {
if (pac.kind === 'patient') {
return this.prisma.patient.count({ where: { hostId } });
}
if (pac.kind === 'return_visit') {
const r = await this.prisma.$queryRaw<Array<{ c: bigint }>>`
SELECT count(distinct patient_id) AS c FROM patient_return_visit WHERE host_id = ${hostId}::uuid`;
return Number(r[0]?.c ?? 0);
}
if (pac.kind === 'image_ai') {
// 影像 AI → diagnosis_record 且 content.code_source='image_ai'(image_finding 装配打的标)
const r = await this.prisma.$queryRaw<Array<{ c: bigint }>>`
SELECT count(distinct patient_id) AS c FROM patient_facts
WHERE host_id = ${hostId}::uuid AND superseded_at IS NULL
AND type = 'diagnosis_record' AND content->>'code_source' = 'image_ai'`;
return Number(r[0]?.c ?? 0);
}
// fact 类型:跨多类型取去重患者并集(不是各类型之和)
const r = await this.prisma.$queryRaw<Array<{ c: bigint }>>`
SELECT count(distinct patient_id) AS c FROM patient_facts
WHERE host_id = ${hostId}::uuid AND superseded_at IS NULL
AND type IN (${Prisma.join(pac.types)})`;
return Number(r[0]?.c ?? 0);
} catch (err) {
this.logger.warn(`PAC 计数失败(${JSON.stringify(pac)}): ${err instanceof Error ? err.message : String(err)}`);
return null;
}
}
} }
/** cursor_after JSON({resource: "2026-06-21 19:30:00", ...})→ 距今小时数(取最大游标)。 */ /** cursor_after JSON({resource: "2026-06-21 19:30:00", ...})→ 距今小时数(取最大游标)。 */
...@@ -198,3 +312,18 @@ function fmtTime(d: Date | null | undefined): string { ...@@ -198,3 +312,18 @@ function fmtTime(d: Date | null | undefined): string {
if (!d) return '—'; if (!d) return '—';
return new Date(d).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false }); return new Date(d).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false });
} }
/** 只要 HH:mm(每轮明细用,同日多轮不必重复日期)。 */
function fmtClock(d: Date | null | undefined): string {
if (!d) return '—';
return new Date(d).toLocaleTimeString('zh-CN', { timeZone: 'Asia/Shanghai', hour: '2-digit', minute: '2-digit', hour12: false });
}
/** 对账单行:DW / PAC / 差(+ = PAC 缺,源比 PAC 多)。v1 只报数不判级,大差多为已知基线(如瑞泰未全摄)。 */
function fmtReconcileLine(label: string, dw: number | null, pac: number | null): string {
if (dw == null || pac == null) return ` ${label} DW ${dw == null ? '—' : fmtNum(dw)} / PAC ${pac == null ? '—' : fmtNum(pac)}`;
const diff = dw - pac;
const pct = dw > 0 ? (diff / dw) * 100 : 0;
const sign = diff > 0 ? '+' : '';
return ` ${label} DW${fmtNum(dw)}/PAC${fmtNum(pac)}${sign}${fmtNum(diff)}(${pct.toFixed(0)}%)`;
}
...@@ -40,3 +40,18 @@ export interface ExecutionCallbackJob { ...@@ -40,3 +40,18 @@ export interface ExecutionCallbackJob {
executionId: string; executionId: string;
triggeredBy: string; triggeredBy: string;
} }
export interface ColdImportJob {
hostId: string;
tenantId: string;
/** manifest.host_name;仅日志用(importDirectory 从 dir 内 manifest 自定位 host) */
hostName: string;
/** cold-import 的 --dir:迷你 data 根下的 <host>/ 子目录(含 manifest+assemblers+数据文件) */
dir: string;
/** 清理根:迷你 data 根(dir 的父,含 <host>/ 和 _shared/);worker 跑完整个删掉 */
cleanupDir: string;
/** true = dry-run 预检:跑完整校验+翻译但不落库,返回 totals(failed/mappingMiss)供确认 */
dryRun?: boolean;
/** `upload:<userId>` */
triggeredBy: string;
}
import { Logger } from '@nestjs/common';
import { Processor, WorkerHost } from '@nestjs/bullmq';
import { Job } from 'bullmq';
import * as fs from 'node:fs';
import { QueueName } from '../queue-names';
import type { ColdImportJob } from '../job-payloads';
import { ColdImportService } from '../../modules/sync/cold-import/cold-import.service';
/**
* cold-import worker — 网页上传压缩包触发的整包导入。
*
* 流程:
* 1. 调 ColdImportService.importDirectory(job.dir) — 与 CLI `pnpm cold-import` 同一入口;
* 内部自建 syncLog(审计/最近事件)、cohort 分批、幂等落库。返回 ImportRunResult。
* 2. finally 清理临时解压目录(含 PII 的上传数据文件不残留);失败时保留供排查。
*
* concurrency=1:整包导入是重任务,且 importDirectory 有 partial UNIQUE(host_id)
* WHERE status='running' 并发锁 —— 串行天然避免撞锁。attempts=1 不自动重试。
*
* 返回值即 job.returnvalue,前端按 jobId 轮询时读其中 totals 展示结果。
*/
@Processor(QueueName.COLD_IMPORT, { concurrency: 1 })
export class ColdImportProcessor extends WorkerHost {
private readonly logger = new Logger(ColdImportProcessor.name);
constructor(private readonly coldImport: ColdImportService) {
super();
}
async process(job: Job<ColdImportJob>) {
const { hostName, dir, cleanupDir, dryRun, triggeredBy } = job.data;
this.logger.log(
`cold-import START job=${job.id} host=${hostName} dir=${dir} dryRun=${!!dryRun} triggeredBy=${triggeredBy}`,
);
try {
const result = await this.coldImport.importDirectory(dir, { dryRun: !!dryRun });
this.logger.log(
`cold-import DONE job=${job.id} host=${hostName} status=${result.status} ` +
`patients=${result.totals.patientsUpserted} txn=${result.totals.transactionsWritten} ` +
`dup=${result.totals.duplicates} failed=${result.totals.failed} ` +
`factsCreated=${result.totals.factsCreated} factsSuperseded=${result.totals.factsSuperseded}`,
);
return result;
} finally {
// 成功/失败都清理整个迷你 data 根(cleanupDir,含 <host>/ 数据 + _shared/ 副本)。
// 始终清理(PII 优先),失败原因已在 syncLog.errorMessage + job.failedReason 里。
const target = cleanupDir ?? dir; // 兼容旧 job(无 cleanupDir)
try {
await fs.promises.rm(target, { recursive: true, force: true });
this.logger.debug(`cold-import 清理临时目录 job=${job.id} dir=${target}`);
} catch (err) {
this.logger.warn(
`cold-import 清理临时目录失败 job=${job.id} dir=${target}: ${err instanceof Error ? err.message : err}`,
);
}
}
}
}
...@@ -15,6 +15,8 @@ export const QueueName = { ...@@ -15,6 +15,8 @@ export const QueueName = {
PLAN_ASSET_GENERATE: 'plan-asset-generate', PLAN_ASSET_GENERATE: 'plan-asset-generate',
/// 执行结果回执投递(PAC → 宿主 callbackUrl,HMAC 签名 + 退避重试;jobName = executionId) /// 执行结果回执投递(PAC → 宿主 callbackUrl,HMAC 签名 + 退避重试;jobName = executionId)
EXECUTION_CALLBACK: 'execution-callback', EXECUTION_CALLBACK: 'execution-callback',
/// 网页上传压缩包触发的整包 cold-import(长任务,jobName = jobId;concurrency=1 串行)
COLD_IMPORT: 'cold-import',
} as const; } as const;
export type QueueName = (typeof QueueName)[keyof typeof QueueName]; export type QueueName = (typeof QueueName)[keyof typeof QueueName];
...@@ -53,4 +55,14 @@ export const QueueDefaults = { ...@@ -53,4 +55,14 @@ export const QueueDefaults = {
removeOnComplete: { count: 500 }, removeOnComplete: { count: 500 },
removeOnFail: { count: 5_000 }, removeOnFail: { count: 5_000 },
}, },
[QueueName.COLD_IMPORT]: {
/**
* 整包导入是几十分钟的重任务,且 importDirectory 有 partial UNIQUE(host_id) WHERE status='running'
* 并发锁(重试会撞锁)→ **不自动重试**(attempts:1),失败就留 failed 供人工排查。
* 完成的 job 保留久一点(前端靠 jobId 轮询进度,别在读到前被清)。
*/
attempts: 1,
removeOnComplete: { age: 24 * 3_600, count: 100 },
removeOnFail: { count: 1_000 },
},
} as const; } as const;
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { InjectQueue } from '@nestjs/bullmq'; import { InjectQueue } from '@nestjs/bullmq';
import { randomUUID } from 'node:crypto';
import type { Queue } from 'bullmq'; import type { Queue } from 'bullmq';
import { QueueName, QueueDefaults } from './queue-names'; import { QueueName, QueueDefaults } from './queue-names';
import type { import type {
...@@ -7,6 +8,7 @@ import type { ...@@ -7,6 +8,7 @@ import type {
PlanRecomputeJob, PlanRecomputeJob,
PlanAssetGenerateJob, PlanAssetGenerateJob,
ExecutionCallbackJob, ExecutionCallbackJob,
ColdImportJob,
} from './job-payloads'; } from './job-payloads';
/** /**
...@@ -29,6 +31,7 @@ export class QueueProducer { ...@@ -29,6 +31,7 @@ export class QueueProducer {
@InjectQueue(QueueName.PLAN_RECOMPUTE) private readonly planQ: Queue, @InjectQueue(QueueName.PLAN_RECOMPUTE) private readonly planQ: Queue,
@InjectQueue(QueueName.PLAN_ASSET_GENERATE) private readonly assetQ: Queue, @InjectQueue(QueueName.PLAN_ASSET_GENERATE) private readonly assetQ: Queue,
@InjectQueue(QueueName.EXECUTION_CALLBACK) private readonly callbackQ: Queue, @InjectQueue(QueueName.EXECUTION_CALLBACK) private readonly callbackQ: Queue,
@InjectQueue(QueueName.COLD_IMPORT) private readonly coldImportQ: Queue,
) {} ) {}
async enqueuePersonaRecompute( async enqueuePersonaRecompute(
...@@ -90,6 +93,41 @@ export class QueueProducer { ...@@ -90,6 +93,41 @@ export class QueueProducer {
); );
} }
/**
* 整包 cold-import 入队。返回 jobId 作前端轮询句柄(不落 syncLog —— importDirectory 自己会写
* 自己的 syncLog 供审计/最近事件;前端进度直接查 BullMQ job 的 state + returnvalue)。
* concurrency=1 串行 + importDirectory 自带 running 锁 → 同 host 不会并发跑。
*/
async enqueueColdImport(job: ColdImportJob): Promise<{ jobId: string }> {
const jobId = randomUUID();
await this.coldImportQ.add(job.hostName, job, {
jobId,
...QueueDefaults[QueueName.COLD_IMPORT],
});
this.logger.log(
`enqueue cold-import job=${jobId} host=${job.hostName} triggeredBy=${job.triggeredBy}`,
);
return { jobId };
}
/** 查一次 cold-import job 进度(前端轮询用)。job 不存在返回 null(可能已被清理)。 */
async getColdImportJob(jobId: string): Promise<{
state: string;
hostId: string | null;
returnValue: unknown;
failedReason: string | null;
} | null> {
const job = await this.coldImportQ.getJob(jobId);
if (!job) return null;
const state = await job.getState();
return {
state,
hostId: (job.data as { hostId?: string } | undefined)?.hostId ?? null,
returnValue: job.returnvalue ?? null,
failedReason: job.failedReason ?? null,
};
}
async enqueueExecutionCallback(job: ExecutionCallbackJob): Promise<void> { async enqueueExecutionCallback(job: ExecutionCallbackJob): Promise<void> {
// jobId = executionId(append-only 唯一)→ 同一 execution 不会重复入队 // jobId = executionId(append-only 唯一)→ 同一 execution 不会重复入队
await this.callbackQ.add(job.executionId, job, { await this.callbackQ.add(job.executionId, job, {
......
...@@ -17,6 +17,7 @@ import { PersonaRecomputeProcessor } from './processors/persona-recompute.proces ...@@ -17,6 +17,7 @@ import { PersonaRecomputeProcessor } from './processors/persona-recompute.proces
import { PlanRecomputeProcessor } from './processors/plan-recompute.processor'; import { PlanRecomputeProcessor } from './processors/plan-recompute.processor';
import { PlanAssetGenerateProcessor } from './processors/plan-asset-generate.processor'; import { PlanAssetGenerateProcessor } from './processors/plan-asset-generate.processor';
import { ExecutionCallbackProcessor } from './processors/execution-callback.processor'; import { ExecutionCallbackProcessor } from './processors/execution-callback.processor';
import { ColdImportProcessor } from './processors/cold-import.processor';
/** /**
* Queues — BullMQ 三个队列的统一注册入口。 * Queues — BullMQ 三个队列的统一注册入口。
...@@ -56,6 +57,7 @@ import { ExecutionCallbackProcessor } from './processors/execution-callback.proc ...@@ -56,6 +57,7 @@ import { ExecutionCallbackProcessor } from './processors/execution-callback.proc
{ name: QueueName.PLAN_RECOMPUTE }, { name: QueueName.PLAN_RECOMPUTE },
{ name: QueueName.PLAN_ASSET_GENERATE }, { name: QueueName.PLAN_ASSET_GENERATE },
{ name: QueueName.EXECUTION_CALLBACK }, { name: QueueName.EXECUTION_CALLBACK },
{ name: QueueName.COLD_IMPORT },
), ),
PersonaModule, PersonaModule,
PlanModule, PlanModule,
...@@ -73,6 +75,7 @@ import { ExecutionCallbackProcessor } from './processors/execution-callback.proc ...@@ -73,6 +75,7 @@ import { ExecutionCallbackProcessor } from './processors/execution-callback.proc
PlanRecomputeProcessor, PlanRecomputeProcessor,
PlanAssetGenerateProcessor, PlanAssetGenerateProcessor,
ExecutionCallbackProcessor, ExecutionCallbackProcessor,
ColdImportProcessor,
], ],
exports: [BullModule, QueueProducer, StaleScanService, SyncIncrementalSchedulerService], exports: [BullModule, QueueProducer, StaleScanService, SyncIncrementalSchedulerService],
}) })
......
// yauzl 无官方 @types —— 遵循仓库"不引额外类型包"的先例(见 assistant.controller、mcp-sdk.d.ts),
// 只声明本项目实际用到的最小 API 面。
declare module 'yauzl' {
import type { EventEmitter } from 'node:events';
import type { Readable } from 'node:stream';
export interface Entry {
fileName: string;
}
export interface ZipFile extends EventEmitter {
readEntry(): void;
openReadStream(
entry: Entry,
callback: (err: Error | null, stream?: Readable) => void,
): void;
}
export interface OpenOptions {
lazyEntries?: boolean;
}
export function open(
path: string,
options: OpenOptions,
callback: (err: Error | null, zipfile?: ZipFile) => void,
): void;
}
/**
* cold-import 上传解压 — extractWhitelisted 白名单 + zip-slip 防护
*
* 覆盖:
* - 白名单过滤:zip 里白名单外的文件(c.csv)不提取
* - 子目录条目按 basename 落平(sub/nested.csv → nested.csv,若在白名单)
* - 缺文件:zip 缺白名单声明的文件 → extracted 集合据此可算出缺哪些
* - zip-slip:含 `../evil.csv` 路径穿越条目的包被 yauzl fail-closed **整包拒绝**(reject)
*
* fixtures 由 tests/fixtures/cold-import/*.zip 提供(python3 zipfile 预造,含合法 arcname `../`)。
*
* 跑:pnpm --filter @pac/service test -- cold-import-extract
*/
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { extractWhitelisted } from '../src/modules/admin/import-upload.service';
const FIX = path.join(__dirname, 'fixtures', 'cold-import');
function tmpDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), 'cold-import-test-'));
}
describe('extractWhitelisted', () => {
it('只提取白名单命中的文件,忽略白名单外条目', async () => {
const dest = tmpDir();
try {
const got = await extractWhitelisted(
path.join(FIX, 'good.zip'),
dest,
new Set(['a.csv', 'b.csv']),
);
expect([...got].sort()).toEqual(['a.csv', 'b.csv']);
expect(fs.existsSync(path.join(dest, 'a.csv'))).toBe(true);
expect(fs.existsSync(path.join(dest, 'b.csv'))).toBe(true);
} finally {
fs.rmSync(dest, { recursive: true, force: true });
}
});
it('缺文件:zip 缺声明文件 → extracted 不含它(供缺文件校验)', async () => {
const dest = tmpDir();
try {
const whitelist = new Set(['a.csv', 'b.csv']);
const got = await extractWhitelisted(path.join(FIX, 'partial.zip'), dest, whitelist);
const missing = [...whitelist].filter((f) => !got.has(f));
expect(missing).toEqual(['b.csv']);
} finally {
fs.rmSync(dest, { recursive: true, force: true });
}
});
it('zip-slip:含 `../` 路径穿越条目的包被整包拒绝,且父目录零写入', async () => {
const parent = tmpDir();
const dest = path.join(parent, 'inner');
fs.mkdirSync(dest);
try {
await expect(
extractWhitelisted(path.join(FIX, 'evil.zip'), dest, new Set(['a.csv', 'evil.csv'])),
).rejects.toThrow(/relative path|invalid/i);
// 关键:父目录绝无 evil.csv 写入(未逃逸)
expect(fs.existsSync(path.join(parent, 'evil.csv'))).toBe(false);
} finally {
fs.rmSync(parent, { recursive: true, force: true });
}
});
it('白名单外文件(c.csv)不落盘,子目录条目按 basename 落平', async () => {
const dest = tmpDir();
try {
const got = await extractWhitelisted(
path.join(FIX, 'extra.zip'),
dest,
new Set(['a.csv', 'nested.csv']),
);
expect([...got].sort()).toEqual(['a.csv', 'nested.csv']);
expect(fs.existsSync(path.join(dest, 'a.csv'))).toBe(true);
expect(fs.existsSync(path.join(dest, 'nested.csv'))).toBe(true); // sub/nested.csv → nested.csv
expect(fs.existsSync(path.join(dest, 'c.csv'))).toBe(false); // 白名单外
} finally {
fs.rmSync(dest, { recursive: true, force: true });
}
});
});
/**
* cold-import 成组完整性 — findIncompleteGroups
*
* 规则(用户定):同 manifest group 的表在网页上传时要么全在、要么整组不在;部分在 → 拒(半组无意义)。
* 独立表(无 group)缺失不影响(装载层宽容跳过)。
*
* 跑:pnpm --filter @pac/service test -- cold-import-groups
*/
import { findIncompleteGroups } from '../src/modules/admin/import-upload.service';
// 模拟 FRIDAY manifest.tables 的关键子集:treatment_plan 组(头+行)+ 若干独立表
const TABLES = [
{ file: 'customer_basic_info.csv' }, // 独立
{ file: 'appointment_base.csv' }, // 独立
{ file: 'customer_treat_plan.csv', group: 'treatment_plan' },
{ file: 'customer_treat_plan_item.csv', group: 'treatment_plan' },
];
describe('findIncompleteGroups', () => {
it('组内全在 → 不报(通过)', () => {
const got = findIncompleteGroups(
TABLES,
new Set(['customer_basic_info.csv', 'customer_treat_plan.csv', 'customer_treat_plan_item.csv']),
);
expect(got).toEqual([]);
});
it('整组都不在 → 不报(宽容:该主体不导)', () => {
const got = findIncompleteGroups(TABLES, new Set(['customer_basic_info.csv']));
expect(got).toEqual([]);
});
it('组内部分在(缺行表)→ 报不完整', () => {
const got = findIncompleteGroups(
TABLES,
new Set(['customer_basic_info.csv', 'customer_treat_plan.csv']), // 缺 _item
);
expect(got).toHaveLength(1);
expect(got[0]!.group).toBe('treatment_plan');
expect(got[0]!.missing).toEqual(['customer_treat_plan_item.csv']);
expect(got[0]!.present).toEqual(['customer_treat_plan.csv']);
});
it('独立表缺失不影响(无 group 不参与成组)', () => {
const got = findIncompleteGroups(
TABLES,
new Set(['customer_treat_plan.csv', 'customer_treat_plan_item.csv']), // 缺两张独立表,但组齐
);
expect(got).toEqual([]);
});
});
/**
* filter 数值比较算子 — lt/lte/gt/gte
*
* 动机:让"业务口径 WHERE"(如退费正负切分 receivable_this < 0)能在 PAC transforms 做,
* 宿主只需导原表。非数值/空值一律不通过(空金额不算负数)。
*
* 跑:pnpm --filter @pac/service test -- filter-numeric
*/
import { runFilter } from '../src/modules/sync/transforms/operators/filter.op';
import type { FilterOp } from '../src/modules/sync/transforms/transforms.schema';
const rows = [
{ m: '-30.00' },
{ m: '0' },
{ m: '5' },
{ m: '' }, // 空
{ m: 'abc' }, // 非数值
{ m: null as unknown as string },
];
function filt(where: FilterOp['where']) {
const op = { kind: 'filter', input: 'x', output: 'y', where } as FilterOp;
return runFilter(op, rows).rows.map((r) => r.m);
}
describe('filter 数值算子', () => {
it('lt:< 0 只保留负数,空/非数值不通过', () => {
expect(filt({ m: { lt: 0 } })).toEqual(['-30.00']);
});
it('gte:>= 0 保留 0 和正数', () => {
expect(filt({ m: { gte: 0 } })).toEqual(['0', '5']);
});
it('gt:> 0 只保留正数', () => {
expect(filt({ m: { gt: 0 } })).toEqual(['5']);
});
it('lte:<= 0 保留 0 和负数', () => {
expect(filt({ m: { lte: 0 } })).toEqual(['-30.00', '0']);
});
it('空值 / 非数值 / null 一律不通过数值比较', () => {
// 用一个恒真下界也不会把空/非数值放进来
expect(filt({ m: { gte: -1e9 } })).toEqual(['-30.00', '0', '5']);
});
});
/**
* FRIDAY 结算/病历 WHERE 迁移到 PAC transforms —— 验证"导原表 → PAC 侧 filter 切分"正确。
*
* 背景:业务 WHERE 从 export.sh 全部搬进 manifest transforms(单一真理源)。
* 本测用合成的**原表**数据(全 status + 正负金额)喂 TransformEngine,断言四处切分:
* ① 消费 status∈{1,3} 且 receivable≥0(负额被 gte 挡)
* ② 退费① status=4 ∪ (status=3 且 receivable<0) ← filter×2 + union
* ③ 退费明细 is_refund=1
* ④ 病历 status∈{3,4} 排草稿
*
* 跑:pnpm --filter @pac/service test -- friday-settlement-where
*/
import { TransformEngine } from '../src/modules/sync/transforms/transform-engine';
import type { TransformsConfig } from '../src/modules/sync/transforms/transforms.schema';
const engine = new TransformEngine();
// 合成原表(CSV 源天然全字符串;med_emr_info 是 JSON,status 用数字测 in 的 String 归一)
const patient_settlement = [
{ uuid: 's1', status: '1', receivable_this: '100', patient_id: '666716' }, // 消费
{ uuid: 's3', status: '3', receivable_this: '50', patient_id: '666716' }, // 消费(status3 正额)
{ uuid: 's1neg', status: '1', receivable_this: '-5', patient_id: '666716' }, // 消费但金额<0 → 被 gte 挡
{ uuid: 's4', status: '4', receivable_this: '-30', patient_id: '666716' }, // 退费(status4 整单反向)
{ uuid: 's3neg', status: '3', receivable_this: '-20', patient_id: '666716' }, // 退费(status3 负额表达)
{ uuid: 's2', status: '2', receivable_this: '10', patient_id: '666716' }, // 草稿/其他 → 都不收
];
// spec.patient_id 部分品牌原是「诊所本地垃圾 id」→ 宿主导出时已从结算头反填真 patient_id(inline);PAC 直用
const patient_settlement_spec = [
{ id: 'sp1', is_refund: '1', settlement_id: 's3', patient_id: '666716' }, // 退费明细(宿主已反填真 patient_id)
{ id: 'sp2', is_refund: '0', settlement_id: 's1', patient_id: '666716' }, // 非退费
];
const med_emr_info = [
{ emr_sub_id: 'e3', status: 3 }, // 正式
{ emr_sub_id: 'e4', status: 4 }, // 正式
{ emr_sub_id: 'e2', status: 2 }, // 草稿 → 排除
];
const transforms = [
{ kind: 'filter', input: 'patient_settlement', output: '_pay_hdr', where: { status: { in: ['1', '3'] }, receivable_this: { gte: 0 } } },
{ kind: 'filter', input: 'patient_settlement', output: '_refund_status4', where: { status: { equals: '4' } } },
{ kind: 'filter', input: 'patient_settlement', output: '_refund_neg3', where: { status: { equals: '3' }, receivable_this: { lt: 0 } } },
{ kind: 'union', inputs: ['_refund_status4', '_refund_neg3'], output: 'refund_full_rows' },
{ kind: 'filter', input: 'patient_settlement_spec', output: 'refund_item_rows', where: { is_refund: { equals: '1' } } },
{ kind: 'filter', input: 'med_emr_info', output: '_emr_official', where: { status: { in: ['3', '4'] } } },
] as unknown as TransformsConfig;
describe('FRIDAY 结算/病历 WHERE 迁移(导原表 + PAC 切分)', () => {
const out = engine.run({
tables: { patient_settlement, patient_settlement_spec, med_emr_info },
transforms,
});
const pick = (rows: unknown[], key: string) =>
(rows as Array<Record<string, unknown>>).map((r) => r[key]).sort();
it('① 消费:status∈{1,3} 且 receivable≥0(负额 s1neg 被挡,s2/s4 不在)', () => {
expect(pick(out['_pay_hdr'] ?? [], 'uuid')).toEqual(['s1', 's3']);
});
it('② 退费①:status=4 ∪ (status=3 且金额<0)', () => {
expect(pick(out['refund_full_rows'] ?? [], 'uuid')).toEqual(['s3neg', 's4']);
});
it('③ 退费明细:is_refund=1', () => {
expect(pick(out['refund_item_rows'] ?? [], 'id')).toEqual(['sp1']);
});
it('③.1 退费明细患者 = 宿主反填的真 patient_id(spec.patient_id 直用,不再跨表 lookup)', () => {
const rows = (out['refund_item_rows'] ?? []) as Array<Record<string, unknown>>;
expect(rows.map((r) => r['patient_id'])).toEqual(['666716']);
});
it('④ 病历:status∈{3,4} 排草稿 e2', () => {
expect(pick(out['_emr_official'] ?? [], 'emr_sub_id')).toEqual(['e3', 'e4']);
});
});
/**
* traceRawSourceTable —— 回溯 assembler primary 表到「原始源表」,用于 push(source→匹配 assembler)
* 与 reparse(rawPayload 属哪张源表)。重点回归:**union 多输入必须能收敛到同一根**,
* 否则 union-primary 资源(refund_full)在 push/reparse 里被静默跳过(丢财务数据)。
*
* 跑:pnpm --filter @pac/service test -- trace-raw-source
*/
import { traceRawSourceTable } from '../src/modules/sync/cold-import/cold-import.service';
// 结算链最小 transforms:消费(filter 链)+ 退费整单(filter×2 + union)+ 退费明细(filter+lookup)
const transforms = [
{ kind: 'filter', input: 'patient_settlement', output: '_pay_hdr', where: {} },
{ kind: 'lookup', input: '_pay_hdr', output: 'payment_rows', from: 'settlement_modes', select: {} },
{ kind: 'filter', input: 'patient_settlement', output: '_refund_status4', where: {} },
{ kind: 'filter', input: 'patient_settlement', output: '_refund_neg3', where: {} },
{ kind: 'union', inputs: ['_refund_status4', '_refund_neg3'], output: 'refund_full_rows' },
{ kind: 'filter', input: 'patient_settlement_spec', output: '_refund_item_raw', where: {} },
{ kind: 'lookup', input: '_refund_item_raw', output: 'refund_item_rows', from: 'patient_settlement', select: {} },
// route_by_pattern 多输出回同一 input
{ kind: 'route_by_pattern', input: '_treat_raw', outputs: [{ output: '_actual_raw' }, { output: '_rec_raw' }] },
];
describe('traceRawSourceTable', () => {
it('单输入链:payment_rows → patient_settlement', () => {
expect(traceRawSourceTable('payment_rows', transforms)).toBe('patient_settlement');
});
it('union 收敛:refund_full_rows 两路都回到 patient_settlement(修复点)', () => {
expect(traceRawSourceTable('refund_full_rows', transforms)).toBe('patient_settlement');
});
it('lookup 链取 input(不取 from):refund_item_rows → patient_settlement_spec', () => {
expect(traceRawSourceTable('refund_item_rows', transforms)).toBe('patient_settlement_spec');
});
it('route 多输出回同一 input:_actual_raw → _treat_raw', () => {
expect(traceRawSourceTable('_actual_raw', transforms)).toBe('_treat_raw');
});
it('已是根表:原样返回', () => {
expect(traceRawSourceTable('patient_settlement', transforms)).toBe('patient_settlement');
});
it('union 输入发散到多根 → 停在 union 输出(不误判)', () => {
const diverge = [
{ kind: 'filter', input: 'table_a', output: '_a', where: {} },
{ kind: 'filter', input: 'table_b', output: '_b', where: {} },
{ kind: 'union', inputs: ['_a', '_b'], output: 'merged' },
];
expect(traceRawSourceTable('merged', diverge)).toBe('merged');
});
});
'use client';
import { useEffect, useRef, useState } from 'react';
import { Upload, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { hostApi, type ColdImportStatus } from './host-api';
/** BullMQ 终态:轮询到这些状态就停。 */
const TERMINAL = new Set(['completed', 'failed', 'not_found']);
const POLL_MS = 3000;
const STATE_LABEL: Record<string, string> = {
waiting: '排队中',
delayed: '排队中',
active: '导入中',
completed: '完成',
failed: '失败',
not_found: '已结束',
};
function fmtElapsed(sec: number): string {
const m = Math.floor(sec / 60);
const s = sec % 60;
return m > 0 ? `${m}${s}秒` : `${s}秒`;
}
/**
* ColdImportCard — 宿主自助上传数据压缩包触发 cold-import。
*
* 流程:选 zip → 上传拿 jobId → 每 3s 轮询进度 → 终态停 + 展示 totals + 刷新最近事件。
* manifest/assemblers 取自 PAC 代码库,上传包只含数据文件(见后端 ImportUploadService)。
*/
export function ColdImportCard({ onDone }: { onDone: () => void }) {
const inputRef = useRef<HTMLInputElement>(null);
const [file, setFile] = useState<File | null>(null);
const [dryRun, setDryRun] = useState(true); // 默认先预检,更安全
const [uploading, setUploading] = useState(false);
const [jobId, setJobId] = useState<string | null>(null);
const [status, setStatus] = useState<ColdImportStatus | null>(null);
const [startedAt, setStartedAt] = useState<number | null>(null);
const [elapsed, setElapsed] = useState(0);
// onDone 存 ref,避免父组件重渲染导致轮询 effect 被重置
const onDoneRef = useRef(onDone);
useEffect(() => {
onDoneRef.current = onDone;
}, [onDone]);
// 轮询:仅依赖 jobId
useEffect(() => {
if (!jobId) return;
let timer: ReturnType<typeof setInterval> | null = null;
let stopped = false;
const poll = async () => {
try {
const s = await hostApi.getImportStatus(jobId);
if (stopped) return;
setStatus(s);
if (TERMINAL.has(s.state)) {
if (timer) clearInterval(timer);
timer = null;
if (s.state === 'completed') {
if (s.result?.dryRun) {
// 预检:未落库,不刷新最近事件
toast.success('预检完成(未落库),检查下方 failed / 数量再正式导入');
} else {
toast.success('导入完成');
onDoneRef.current();
}
} else if (s.state === 'failed') {
toast.error(`导入失败:${s.failedReason ?? '未知原因'}`);
} else {
toast.message('任务已不在队列(可能已完成并被清理)');
}
}
} catch {
// 单次轮询失败不打断,等下一轮
}
};
void poll();
timer = setInterval(() => void poll(), POLL_MS);
return () => {
stopped = true;
if (timer) clearInterval(timer);
};
}, [jobId]);
const running = jobId != null && (!status || !TERMINAL.has(status.state));
// 已用时长:running 时每秒 tick
useEffect(() => {
if (!running || startedAt == null) return;
const t = setInterval(() => setElapsed(Math.floor((Date.now() - startedAt) / 1000)), 1000);
return () => clearInterval(t);
}, [running, startedAt]);
const onUpload = async () => {
if (!file) return;
setUploading(true);
setStatus(null);
setJobId(null);
try {
const form = new FormData();
form.append('file', file);
form.append('dryRun', String(dryRun));
const { jobId: id } = await hostApi.importArchive(form);
setJobId(id);
setStartedAt(Date.now());
setElapsed(0);
toast.success(dryRun ? '已上传,开始预检(不落库)' : '已上传,开始后台导入');
} catch (e) {
toast.error(e instanceof Error ? e.message : '上传失败');
} finally {
setUploading(false);
}
};
const t = status?.result?.totals;
return (
<Card>
<CardHeader>
<CardTitle>数据导入</CardTitle>
<CardDescription>
上传数据压缩包(zip,含 manifest 声明的 CSV/JSON)触发整包 cold-import。manifest / assemblers
取自 PAC 侧无需上传。重复上传同一批数据是安全的(幂等空转:去重高、fact 新建≈0)。
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<input
ref={inputRef}
type="file"
accept=".zip"
className="hidden"
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
/>
<div className="flex flex-wrap items-center gap-3">
<Button
variant="outline"
onClick={() => inputRef.current?.click()}
disabled={uploading || running}
>
选择 zip
</Button>
<span className="min-w-0 flex-1 truncate text-sm text-muted-foreground">
{file ? file.name : '未选择文件'}
</span>
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
<input
type="checkbox"
checked={dryRun}
onChange={(e) => setDryRun(e.target.checked)}
disabled={uploading || running}
/>
仅预检(不落库)
</label>
<Button onClick={onUpload} disabled={!file || uploading || running}>
{uploading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Upload className="mr-2 h-4 w-4" />
)}
{dryRun ? '预检' : '上传并导入'}
</Button>
</div>
{jobId && (
<div className="rounded-md border p-3 text-sm">
<div className="flex items-center gap-2">
{running && <Loader2 className="h-4 w-4 animate-spin" />}
<span>
状态:{STATE_LABEL[status?.state ?? ''] ?? status?.state ?? '排队中'}
{status?.result?.dryRun ? '(预检)' : ''}
</span>
{(running || elapsed > 0) && (
<span className="text-xs text-muted-foreground">· 用时 {fmtElapsed(elapsed)}</span>
)}
</div>
{/* 运行中真实进度条(cohortDone/cohortTotal 批次) */}
{status?.progress && status.progress.total > 0 && (
<div className="mt-2">
<div className="h-1.5 w-full overflow-hidden rounded bg-muted">
<div
className="h-full bg-primary transition-all"
style={{
width: `${Math.round((status.progress.done / status.progress.total) * 100)}%`,
}}
/>
</div>
<div className="mt-1 text-xs text-muted-foreground">
已处理 {status.progress.done}/{status.progress.total} 批
</div>
</div>
)}
{/* 长任务提示:后台跑,可离开 */}
{running && (
<p className="mt-1.5 text-xs text-muted-foreground">
导入在后台进行,可离开此页,稍后回来查看结果。
</p>
)}
{status?.state === 'failed' && status.failedReason && (
<p className="mt-1 text-destructive">失败原因:{status.failedReason}</p>
)}
{t && (
<div className="mt-2 grid grid-cols-2 gap-x-4 gap-y-1 text-muted-foreground">
<span>患者 upsert:{t.patientsUpserted}</span>
<span>事务写入:{t.transactionsWritten}</span>
<span>幂等去重:{t.duplicates}</span>
<span>失败:{t.failed}</span>
<span>fact 新建:{t.factsCreated}</span>
<span>fact 更新:{t.factsSuperseded}</span>
<span>fact 未变:{t.factsUnchanged}</span>
<span>证据追加:{t.factsEvidenceAppended}</span>
</div>
)}
</div>
)}
</CardContent>
</Card>
);
}
...@@ -24,6 +24,7 @@ import { isPostMessageSentinel } from '@/lib/host-message'; ...@@ -24,6 +24,7 @@ import { isPostMessageSentinel } from '@/lib/host-message';
import { hostApi } from './host-api'; import { hostApi } from './host-api';
import { useConfirm } from './use-confirm'; import { useConfirm } from './use-confirm';
import { SecretRevealDialog } from './secret-reveal-dialog'; import { SecretRevealDialog } from './secret-reveal-dialog';
import { ColdImportCard } from './cold-import-card';
/** /**
* HostAdminApp — 宿主自己的 admin 配置页主视图。 * HostAdminApp — 宿主自己的 admin 配置页主视图。
...@@ -248,6 +249,9 @@ export function HostAdminApp({ data, onRefresh }: { data: HostDetail; onRefresh: ...@@ -248,6 +249,9 @@ export function HostAdminApp({ data, onRefresh }: { data: HostDetail; onRefresh:
<StatsCard data={data} /> <StatsCard data={data} />
{/* 数据导入(上传压缩包触发 cold-import) */}
<ColdImportCard onDone={onRefresh} />
{/* 凭据 */} {/* 凭据 */}
<Card> <Card>
<CardHeader> <CardHeader>
......
...@@ -13,6 +13,28 @@ import { api } from '@/lib/api-client'; ...@@ -13,6 +13,28 @@ import { api } from '@/lib/api-client';
const BASE = '/pac/v1/admin/host/self'; const BASE = '/pac/v1/admin/host/self';
export interface ColdImportTotals {
patientsUpserted: number;
transactionsWritten: number;
duplicates: number;
failed: number;
factsCreated: number;
factsSuperseded: number;
factsUnchanged: number;
factsEvidenceAppended: number;
factsFailed: number;
}
export interface ColdImportStatus {
/** BullMQ job 状态:waiting/active/completed/failed/...;'not_found' = 已清理或不存在 */
state: string;
/** 完成时的 ImportRunResult(含 totals + dryRun 标志);进行中为 null */
result: { status?: string; dryRun?: boolean; totals?: ColdImportTotals } | null;
failedReason: string | null;
/** 运行中的真实进度(cohortDone/cohortTotal 批次);非运行态或 dryRun 为 null */
progress: { done: number; total: number } | null;
}
export const hostApi = { export const hostApi = {
getSelf: () => api.get<HostDetail>(BASE), getSelf: () => api.get<HostDetail>(BASE),
getStats: () => api.get<HostStats>(`${BASE}/stats`), getStats: () => api.get<HostStats>(`${BASE}/stats`),
...@@ -28,4 +50,9 @@ export const hostApi = { ...@@ -28,4 +50,9 @@ export const hostApi = {
api.delete<{ totalKeysRemaining: number }>( api.delete<{ totalKeysRemaining: number }>(
`${BASE}/callback-secrets/${encodeURIComponent(suffix)}`, `${BASE}/callback-secrets/${encodeURIComponent(suffix)}`,
), ),
// 数据导入:上传压缩包(FormData 字段名 file)→ 返回 jobId;按 jobId 轮询进度
importArchive: (form: FormData) => api.post<{ jobId: string }>(`${BASE}/cold-import`, form),
getImportStatus: (jobId: string) =>
api.get<ColdImportStatus>(`${BASE}/cold-import/${encodeURIComponent(jobId)}`),
}; };
...@@ -235,6 +235,9 @@ importers: ...@@ -235,6 +235,9 @@ importers:
ws: ws:
specifier: ^8.21.0 specifier: ^8.21.0
version: 8.21.0 version: 8.21.0
yauzl:
specifier: ^3.3.0
version: 3.3.0
zod: zod:
specifier: ^4.4.3 specifier: ^4.4.3
version: 4.4.3 version: 4.4.3
......
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