Commit 4f176611 by luoqi

feat(sync/friday): 语义全实锤(源码+数据双证) + 患者主档 push 支持(partial upsert)

一、时间/金额实证(2026-07-20,契约文档同步):
- 金额=元(洁治单价均值 ¥310/客单 ¥1,730 双证);MySQL datetime=北京墙钟(_gmt_ 命名惯例误导)
- ️ Mongo Date=北京墙钟伪装 UTC(clinicTime/createdGmtAt 小时分布双峰直读=营业时段;按真 UTC
  解释一半病历落深夜)→ export.sh Date 改直读墙钟输出 naive 字符串;EMR 链 35.6 万 fact
  清层重灌校正(-8h),修后小时分布恢复营业双峰。契约文档明令宿主 push 勿 toISOString 带 Z

二、官方字典(friday-saas 源码考古,待确认清单清零):
- contacts.relationship = PhoneRelationshipEnum(1本人…9其他)→ patient.yaml 解码,
  preferences.contactPhone 带 relationship 标签(mother/father…)
- referee_relationship = RecommendRelationshipEnum 17 码;reverseValue+年龄差数据双证
  语义方向=customer 是 referee 的 X → patient_relation.yaml 按逆关系重写(码8 其他亲属
  纠正 friend→other;新增 5/6/11/12/13/15/16 映射;码3 按对方性别拆 father/mother)。
  关系表清层重灌:friend 3,511→541(纯码7),官方映射干净落地
- settlement status 全 9 值:实体注释 7=流程结束/8=欠款补缴克隆单(savePatientSettlementQk
  证实 receivable 原样复制→入消费必双计)→ 现行不收 0/2/5/6/7/8 全部获官方背书
- treat=本次治疗/dispose=处置(EmrInfoResDto 注释);预约 status 7=到诊患者变更

三、患者主档 push(此前 form A 仅支持 fact 资源):
- patient-upsert.util:upsert 数据构造抽纯函数,full(cold-import 历史语义,分毫不变)/
  partial(push 单表:未提供字段不进 update 集合、phone 永不合成假号)双语义 —— 解决
  push 单表时 contacts lookup 缺席导致存量真号被假号覆盖的根因
- ingestRawTables 路由 upsert 资源(patient partial/patient_relation/patient_return_visit);
  纯主档推送不产 txn 不触发重算(注释明示)
- 测试 8/8(partial 不覆盖/无清空语义/preferences 透传);E2E 冒烟:push 单行改名,
  存量 phone 15725591111 原样保留 ✓。契约文档待确认清单清零

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
parent 1d8a663b
......@@ -23,11 +23,10 @@ icon: FileJson
| 约定 | 说明 |
|---|---|
| **id 类字段一律字符串** | `patient_id` / `id` / `appointment_id` 等统一 String(数值型会被 PAC 校验拒收——存量摄入实测踩过的坑) |
| **时间格式** | `"YYYY-MM-DD HH:mm:ss"` 或 ISO8601;裸时间按 `Asia/Shanghai` 解释 |
| **`tenant_id`** | 宿主原生列,**不改名**。品牌 GUID,PAC 用它做集团内患者号消歧(与 PAC 自身租户概念的区分由 PAC 侧适配层负责) |
| **时间格式** | 统一传 naive 北京墙钟 `"YYYY-MM-DD HH:mm:ss"`(PAC 按 `Asia/Shanghai` 解释)。实证(2026-07-20):MySQL `datetime` 存的就是北京墙钟(`_gmt_` 命名系惯例误导),直出即可;**Mongo `Date` 是北京墙钟伪装成 UTC 存储**(小时分布双证)——从 Mongo 读出后**须直读 UTC 字段还原墙钟**,切勿 `toISOString()` 带 `Z` 推送(会被按真 UTC 多加 8 小时) |
| **`updated_gmt_at` 必须随行更新 bump** | 它是幂等键的一半:不 bump,该行的更新会被永久去重丢弃 |
| **空值** | 传 `null` 或省略字段;不要传 `"NULL"` 字符串 |
| **金额单位** | 元(decimal 原值);PAC 侧转分 |
| **金额单位** | **元**(decimal 原值,实证:洁治单价均值 ¥310/已结算客单均值 ¥1,730);PAC 侧转分 |
| **投递语义** | at-least-once:未收到 2xx 确认就重推;重复由 PAC 幂等键吸收 |
---
......@@ -36,7 +35,9 @@ icon: FileJson
### `customer_basic_info` — 患者主档
> ⚠️ 患者是 upsert 主体,push 通道对 upsert 主体的支持在 PAC 侧排期中;开通前患者主档走存量全量通道(周期性全表推送/导出均可,幂等覆盖)。
> ✅ 患者主档已支持 push(2026-07-20):**部分更新语义** —— 只更新本次提供的字段,
> 未提供的字段不覆盖存量(如只推 `customer_basic_info` 时 phone 不在场 → 保留库内号码);
> push 无"清空字段"语义,需要清空走全量通道。`customer_referee_circle`(关系边)同样可 push。
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
......@@ -59,7 +60,7 @@ icon: FileJson
| `is_default` | string/number | | 默认号标记(PAC 挑默认号:is_default↓ → contacts_type↑ → id↑) |
| `contacts_type` | string/number | | 联系类型(`1` 本人号候选优先) |
| `tel_type` | string/number | | 号码类型 |
| `relationship` | string/number | | 该号与患者的关系码(1-9;**枚举定义待 FRIDAY 提供**,现 PAC 按 `1`=本人判定本人号/代接号) |
| `relationship` | string/number | | 持号人与患者的关系(官方枚举 `PhoneRelationshipEnum`:`1`本人 `2`爸爸 `3`妈妈 `4`爷爷 `5`奶奶 `6`朋友 `7`配偶 `8`子女 `9`其他) |
### `customer_referee_circle` — 转介绍圈(患者-患者关系)
......@@ -70,7 +71,7 @@ icon: FileJson
| `organization_id` | string | | 诊所 |
| `customer_id` | string | ✅ | 本人患者 id |
| `referee_patient_id` | string | ✅ | 关系人患者 id(也是在册患者) |
| `referee_relationship` | string/number | | 关系字典 id(**官方枚举待 FRIDAY 提供**;现 PAC 按统计推断解码,如 1=配偶/3=父母/8=朋友) |
| `referee_relationship` | string/number | | 关系码(官方枚举 `RecommendRelationshipEnum`:`1`配偶 `2`爸爸 `3`子女 `4`兄弟 `5`爷爷 `6`孙子女 `7`朋友 `8`其他亲属 `9`姐妹 `10`妈妈 `11`奶奶 `12`姐弟 `13`兄妹 `14`其他 `15`外公 `16`外婆 `17`外孙子女;**语义=本行 customer 是 referee 的 X**) |
| `type` | string/number | | 关系类型标记 |
| `created_gmt_at` / `updated_gmt_at` | string(datetime) | | |
......@@ -88,7 +89,7 @@ icon: FileJson
| `tenant_id` | string | ✅ | 品牌 GUID |
| `appointment_date` | string(date) | ✅ | 预约日期 |
| `appointment_start` | string(time) | ✅ | 开始时刻(与 date 拼成预约时刻) |
| `appointment_status` | string | ✅ | `1`正常 `2`改约 `3`取消 `4`到诊 `5`接诊 `6`结算 `8`walkin `9`爽约;`10`草稿(推了也会被 PAC 丢弃) |
| `appointment_status` | string | ✅ | `1`预约正常 `2`改约 `3`取消 `4`已到诊 `5`已就诊 `6`已结算 `7`到诊患者变更 `8`walk-in变更 `9`已爽约 `10`草稿(官方注释;PAC 当前消费 1-6/8/9,7 与 10 丢弃) |
| `doctor_user_id` | string | | 医生 id |
| `appointment_time_length` | string/number | | 时长(分钟) |
| `in_time` | string(datetime) | | 到诊时刻(状态 4/5/6 时,PAC 据此产接诊事件) |
......@@ -157,8 +158,11 @@ icon: FileJson
## 5. 结算链(4 个 source)
> **拆分规则(宿主侧按 status/金额分流成两个 source 推)** —— 语义 2026-07 与 PAC 联合钉死:
> `status`:`1`=已结算 `3`=含退费行的单 `4`=整单反向冲减。
> **拆分规则(宿主侧按 status/金额分流成两个 source 推)**。`status` 官方语义
> (`PatientSettlementEntity` 实体注释,比枚举类更全):`0`未结算 `1`已结算 `2`只生成uuid
> `3`退款 `4`插入的退款数据 `5`重新结算数据(废弃历史行) `6`医生未提交 `7`流程结束
> `8`欠款补缴插入数据。**0/2/5/6/7/8 均不推**——其中 `8` 是原单克隆(receivable_this
> 原样复制,`payment_arrears`=本次补缴额,ref 挂原欠费单),入消费会双计应收;`7` 零金额流程关单。
### `patient_settlement` — 消费单
......@@ -264,12 +268,5 @@ icon: FileJson
---
## 7. 待 FRIDAY 确认清单
| 项 | 现状 | 需要 |
|---|---|---|
| `customer_contacts.relationship` 1-9 枚举 | PAC 按 `1`=本人统计推断 | 官方字典定义 |
| `customer_referee_circle.referee_relationship` 字典 | PAC 统计推断(配偶/父母/子女/朋友…) | 官方字典定义 |
| `med_emr_info.treat` vs `dispose` 语义 | 按 treat=本次治疗、dispose=处置叙述摄入 | 业务复核确认 |
| `patient_settlement.status` 0/2/5/6/7/8 语义 | 暂不摄入 | 枚举说明(决定是否纳入) |
| 患者主档 push(upsert 主体) | 走存量全量通道 | PAC 侧排期支持后切 push |
## 7. 语义确认状态
**待确认清单已清零**(患者主档 push 已于 2026-07-20 支持,见 §2;全部语义均有源码/实证依据)。
......@@ -11,10 +11,11 @@ field_mapping:
externalId: id # customer_basic_info 主键 = host 内部患者唯一 id(→ String 化)
name: name
phone: phone # 由 transforms.lookup 从 customer_contacts 挑默认号附加
# 该号关系码(lookup 同源带出;canonical passthrough → mergePatientPreferences 收进
# preferences.contactPhone{relationshipCode,isSelf})。1=本人(统计自证 78.7% 成人);
# 完整标签(3/2=父母类,8=子女类,9=其他)待 host sys_dict 可读后在此加 enum_mapping 解码。
# 该号关系码(lookup 同源带出)→ preferences.contactPhone{relationshipCode, relationship, isSelf}。
# 官方枚举(源码实锤 friday-saas/customer …/enums/PhoneRelationshipEnum.java,2026-07-20):
# 1本人 2爸爸 3妈妈 4爷爷 5奶奶 6朋友 7配偶 8子女 9其他(语义=持号人是患者的X)
phoneRelationshipCode: phone_relationship
phoneRelationship: phone_relationship # 同源列解码成 PAC 关系词(下方 enum_mapping)
gender: sex # tinyint 1/2 → enum_mapping 归一;0/空 → 空
birthDate: birthday # date;空值入库为 null
medicalRecordNumber: file_number # 档案号(病历号,客服沟通用)
......@@ -29,3 +30,15 @@ enum_mapping:
'1': male
'2': female
_default: '' # 0 / 空 / 未知 → 空(gender 可空,不阻挡入库)
# 默认联系号关系(PhoneRelationshipEnum 官方码 → PAC 关系词;语义=持号人是患者的X)
phoneRelationship:
'1': self
'2': father
'3': mother
'4': grandparent
'5': grandparent
'6': friend
'7': spouse
'8': child
'9': other
_default: '' # 空/未知码 → 空(preferences 里只留原码)
# patient_relation — FRIDAY 患者-患者关系边(customer_referee_circle → patient_relations 独立表)
#
# 源表双向成对存储(A→B 与 B→A 各一行,互为视角),referee_relationship 是宿主字典 id
# (DB 无对应字典表,疑似代码硬编码)。upsert 资源(无 emits),同 jvs-dw patient_relation。
# 源表双向成对存储(A→B 与 B→A 各一行,互为视角)。upsert 资源(无 emits),同 jvs-dw。
#
# ⚠️ 关系码解码 = 统计推断(2026-07-19,测试库 10,147 行,互逆对+年龄差+性别三重证据):
# 码 互逆 年龄差(本人-对方) 性别组合 → 推断
# 1 1↔1 +0.2 同龄 92% 异性 → spouse(高置信)
# 8 8↔8 +0.7 同龄 混合(最大量6118对) → friend(高置信)
# 3 3↔10/2 -26.8(对方大27) — → 对方是父母:按对方性别拆 father/mother
# 10 10↔3 +27.5(对方小27) 对方偏女 → child(女儿)
# 2 2↔3 +26.1(对方小26) 对方偏男 → child(儿子)
# 17 17↔16 -38.5(对方大38) — → grandparent
# 16 16↔17 (17 互逆) — → grandchild
# 4 4↔4 -0.6 同龄 偏双男 → sibling(兄弟)
# 9 9↔9 +0.6 同龄 偏双女 → sibling(姊妹)
# 7 7↔7 -0.1 同龄 偏同性 → friend(低置信,或 sibling 变体)
# 5/6/12/13/14/NULL(量小或证据不足) → other
# 待 FRIDAY 提供官方枚举后校正(改这里 enum_mapping 即可,重摄 upsert 覆盖)。
#
# transforms 已 lookup 对方性别拼成 "码|性别" 键(码 3 需性别消歧 father/mother)。
# ── 关系码 = 官方枚举(源码实锤,2026-07-20)──
# friday-saas/customer …/enums/RecommendRelationshipEnum.java:
# 1配偶 2爸爸 3子女 4兄弟 5爷爷 6孙子女 7朋友 8其他亲属 9姐妹 10妈妈 11奶奶
# 12姐弟 13兄妹 14其他 15外公 16外婆 17外孙子女
# ⚠️ 语义方向(该类 reverseValue() + 年龄差数据双证):存储的码 = **本人(customer)是
# 推荐人(referee)的 X**。PAC patient_relation 契约要"对方是本人的 X" → 此处映射 = 逆关系:
# 码2(本人是对方爸爸)→对方是本人的 child;码3(本人是对方子女)→对方是本人的父/母
# (按对方性别拆,transforms 已 lookup 对方 sex 拼 "码|性别" 键);余类推。
# (早期统计推断大multi命中,唯一错:码8 其他亲属曾误标 friend,本版修正为 other。)
canonical: patient_relation
primary:
......@@ -28,44 +20,60 @@ primary:
field_mapping:
patientExternalId: customer_id
relatedExternalId: referee_patient_id
relationship: rel_sex_key # "码|性别" → 下方 enum_mapping 归一
relationship: rel_sex_key # "码|对方性别" → 下方 enum_mapping 逆向归一
enum_mapping:
relationship:
# spouse(1:对称同龄 92% 异性)
# 1 配偶(对称)
"1|1": spouse
"1|2": spouse
"1|": spouse
# friend(8 高置信;7 低置信)
"8|1": friend
"8|2": friend
"8|": friend
"7|1": friend
"7|2": friend
"7|": friend
# 父母(3:对方大 27 岁,按对方性别拆;性别缺 → other 不硬猜)
"3|1": father
"3|2": mother
# 子女(10=女儿,2=儿子 → PAC 统一 child)
"10|1": child
"10|2": child
"10|": child
# 2 本人是对方爸爸 / 10 妈妈 → 对方是本人的 child
"2|1": child
"2|2": child
"2|": child
# 祖辈/孙辈(17:对方大 38;16 为其互逆)
"17|1": grandparent
"17|2": grandparent
"17|": grandparent
"16|1": grandchild
"16|2": grandchild
"16|": grandchild
# 兄弟姊妹(4 偏男/9 偏女,对称同龄)
"10|1": child
"10|2": child
"10|": child
# 3 本人是对方子女 → 对方是本人的父/母(按对方性别;性别缺 → other 不硬猜)
"3|1": father
"3|2": mother
# 4 兄弟 / 9 姐妹 / 12 姐弟 / 13 兄妹 → sibling(对称)
"4|1": sibling
"4|2": sibling
"4|": sibling
"9|1": sibling
"9|2": sibling
"9|": sibling
# 量小/证据不足/NULL 码 → other
"12|1": sibling
"12|2": sibling
"12|": sibling
"13|1": sibling
"13|2": sibling
"13|": sibling
# 5 爷爷 / 11 奶奶 / 15 外公 / 16 外婆(本人是对方祖辈)→ 对方是本人的 grandchild
"5|1": grandchild
"5|2": grandchild
"5|": grandchild
"11|1": grandchild
"11|2": grandchild
"11|": grandchild
"15|1": grandchild
"15|2": grandchild
"15|": grandchild
"16|1": grandchild
"16|2": grandchild
"16|": grandchild
# 6 孙子女 / 17 外孙子女(本人是对方孙辈)→ 对方是本人的 grandparent
"6|1": grandparent
"6|2": grandparent
"6|": grandparent
"17|1": grandparent
"17|2": grandparent
"17|": grandparent
# 7 朋友
"7|1": friend
"7|2": friend
"7|": friend
# 8 其他亲属 / 14 其他 / NULL / 未知码
_default: other
......@@ -73,10 +73,16 @@ mysql_csv "SELECT id,tenant_id,organization_id,customer_id,task_director_id,task
echo "── Mongo 导出(med_emr_info,status∈{3,4} 正式病历)──"
# 不用 mongoexport:Extended JSON 会把 Date/Long 包成 {$date}/{$numberLong} 对象,破坏平铺契约。
# mongosh 手动平铺:Date→ISO 字符串,Long→字符串,数组(diag/treat/dispose/examine)原样保留
# mongosh 手动平铺:Date→naive 北京墙钟字符串,Long→字符串,数组(diag/treat/dispose/examine)原样保留
# (PAC split_json_array 原生支持已 parse 数组)。产出 JSON 数组文件(cold-import json 格式)。
#
# ⚠️ Date 时区实证(2026-07-20,clinicTime/createdGmtAt 小时分布双证):FRIDAY 写库时把
# 北京墙钟"伪装成 UTC"存进 Mongo Date(直读 UTC 字段=营业时间双峰 上午7-10/下午13-17;
# 按真 UTC 解释则一半病历落深夜——荒谬)。故导出**不能** toISOString 带 Z(会被 PAC 当真
# UTC 再 +8,EMR 链时间全偏 8 小时,已踩):把 UTC 字段值直读为墙钟,输出 naive
# "YYYY-MM-DD HH:mm:ss",交给 manifest timezone=Asia/Shanghai 解释 → 瞬间正确。
docker run --rm mongo:7 mongosh "$FRIDAY_MONGO_URI" --quiet --eval '
const flat = (v) => v instanceof Date ? v.toISOString()
const flat = (v) => v instanceof Date ? v.toISOString().slice(0, 19).replace("T", " ")
: (v && v.constructor && v.constructor.name === "Long") ? String(v)
: v === undefined ? null : v;
// id 类字段字符串化:Mongo 存的是 number/Long 混型,canonical 契约要 string
......
......@@ -44,6 +44,7 @@ import {
type SourceUnitResolver,
} from './tenant-resolver';
import { mergePatientPreferences } from '../pipeline/patient-preferences.util';
import { buildPatientUpsertData, buildProfileUpsertData } from './patient-upsert.util';
/** 患者索引 key:集团内 external_id 可跨 source_unit 撞号,索引按 (source_unit, external_id) 建键。 */
function patientIndexKey(sourceUnit: string, externalId: string): string {
......@@ -301,14 +302,21 @@ export class ColdImportService {
const normalize = { amountUnit: manifest.amount_unit, timezone: manifest.timezone };
const transforms = manifest.transforms ?? [];
// 找出"终端原生表 === source"的所有 subject cfg(一张 EMR 源表可喂 诊断/治疗/emr/建议 多 cfg)
const subjectCfgs = this.loadAllAssemblers(absDir, manifest).filter((c) => !!c.emits);
// 找出"终端原生表 === source"的全部 cfg:
// subject(有 emits,走 transaction/fact 流)+ upsert(patient/patient_relation/
// patient_return_visit,无 emits,直接 upsert 主档/边表 —— 2026-07-20 补,此前
// push 不支持患者主档,新患者只能走存量全量通道)
const allCfgs = this.loadAllAssemblers(absDir, manifest);
const subjectCfgs = allCfgs.filter((c) => !!c.emits);
const targetCfgs = subjectCfgs.filter(
(cfg) => traceRawSourceTable(cfg.primary.table, transforms) === opts.source,
);
if (targetCfgs.length === 0) {
const upsertCfgs = allCfgs.filter(
(c) => !c.emits && traceRawSourceTable(c.primary.table, transforms) === opts.source,
);
if (targetCfgs.length === 0 && upsertCfgs.length === 0) {
const known = [
...new Set(subjectCfgs.map((c) => traceRawSourceTable(c.primary.table, transforms))),
...new Set(allCfgs.map((c) => traceRawSourceTable(c.primary.table, transforms))),
];
// 契约码 10001(而非裸 Error→90000):这是宿主填错 source 的永久性错误,
// 按文档"9xxxx 可重试"的规则裸抛会让宿主对配置错误无限重试。
......@@ -346,6 +354,30 @@ export class ColdImportService {
const perResource: PerResourceStats[] = [];
let firstError: string | null = null;
try {
// upsert 资源(patient 主档走 partial 语义:canonical 未提供的字段不覆盖存量、
// phone 不合成假号 —— push 单表时跨表依赖(如 contacts lookup)缺席,full 覆盖会
// 把存量真号打成假号;关系边/回访是完整行 upsert,无 partial 问题)
for (const cfg of upsertCfgs) {
if (cfg.canonical === 'patient') {
perResource.push(
await this.processPatients(
transformed, cfg, host.id, tenantResolver, seenTenants, normalize, false, true,
),
);
} else if (cfg.canonical === 'patient_relation') {
perResource.push(
await this.processPatientRelations(
transformed, cfg, host.id, tenantResolver, normalize, false,
),
);
} else if (cfg.canonical === 'patient_return_visit') {
perResource.push(
await this.processPatientReturnVisits(
transformed, cfg, host.id, tenantResolver, normalize, false,
),
);
}
}
for (const cfg of targetCfgs) {
const stats = await this.processSubject(
transformed,
......@@ -365,6 +397,8 @@ export class ColdImportService {
}
// touched 患者 = 本 syncLog 写入的 transaction 的 distinct (patientId, tenantId)
// 注:upsert 资源(patient/relation/return_visit)不产 transaction → 纯主档推送 touched 为空,
// 不触发 persona 重算(主档字段变更由后续事实事件驱动的重算自然吸收)。
const touchedRows = await this.prisma.patientTransaction.findMany({
where: { syncLogId: syncLog.id, patientId: { not: null } },
select: { patientId: true, tenantId: true },
......@@ -1358,6 +1392,9 @@ export class ColdImportService {
seenTenants: Set<string>,
normalize: { amountUnit: 'fen' | 'yuan'; timezone: string },
dryRun: boolean,
/// push 形态 A 单表患者主档:true = 部分更新语义(canonical 未提供的字段不覆盖,
/// phone 不合成假号)。cold-import 全量/增量路径不传(false)→ 行为与历史一致。
partial = false,
): Promise<PerResourceStats> {
const stats: PerResourceStats = this.zeroPerResource(config.canonical);
......@@ -1387,40 +1424,9 @@ export class ColdImportService {
continue;
}
// 主表数据(身份)
const patientData = {
name: (c.name as string | undefined) ?? null,
// ⚠️ DEMO 兜底:host 不给 phone(隐私) → 由 externalId hash 合成确定性假号
// (打 138 前缀,后 8 位是 externalId 数字 hash;同 externalId 多次跑得同号)
// 生产 host 接入时把 c.phone 真值传过来即可,这里 ?? 自动让位真值
phone: (c.phone as string | undefined) ?? synthesizeDemoPhone(externalId),
gender: (c.gender as string | undefined) ?? null,
birthDate: c.birthDate ? new Date(c.birthDate as string) : null,
medicalRecordNumber:
(c.medicalRecordNumber as string | undefined) ?? null,
// 专属客服(current_task_director)并入 preferences.dedicatedCs(零迁移,非画像特征)
preferences: mergePatientPreferences(c),
// status='archived'/'merged' → active=false;'active' / 缺省 → true
active: ((c.status as string | undefined) ?? 'active') === 'active',
};
// 副表数据(合规/标签/状态)— 全部可空
const profileData = {
doNotContact: (c.doNotContact as boolean) ?? false,
deceased: (c.deceased as boolean) ?? false,
// canonical 字段(若 host 没给 → null/默认)
// tags / notes 需要从 canonical 拿;host 通过 host_tags / host_notes → assembler.yaml 映射,此处兜底
// (primaryContactType 已废弃 → 联系人改走 PatientRelation 边表,processPatientRelations)
tags: Array.isArray(c.tags) ? (c.tags as string[]) : [],
notes: (c.notes as string | undefined) ?? null,
// 获客渠道(A.2.1):一级 PAC 标准枚举(assembler enum_mapping 已归一)/ 二级 host 原值
acquisitionChannel: (c.acquisitionChannel as string | undefined) ?? null,
acquisitionSub: (c.acquisitionSub as string | undefined) ?? null,
// 转介绍(B.1.2):recommend_num / recommend_amount(元→分)
referralCount: c.referralCount != null ? Math.trunc(Number(c.referralCount)) : null,
referralAmountCents:
c.referralAmount != null ? Math.round(Number(c.referralAmount) * 100) : null,
};
// 主/副表数据 — 构造逻辑抽 patient-upsert.util(full=历史语义;partial=push 单表不覆盖缺失字段)
const patientData = buildPatientUpsertData(c, externalId, { partial });
const profileData = buildProfileUpsertData(c, { partial });
try {
const patient = await this.withDbRetry(
......@@ -2334,15 +2340,6 @@ export function chunk<T>(arr: ReadonlyArray<T>, size: number): T[][] {
* 算法:138 + (externalId 转 number) mod 1e8,补 0 至 8 位 → 138 + 8 位 = 11 位
* 同 externalId 多次 import 出同号(便于 demo / debug)。
*/
function synthesizeDemoPhone(externalId: string): string {
let hash = 0;
for (let i = 0; i < externalId.length; i++) {
hash = (hash * 31 + externalId.charCodeAt(i)) >>> 0;
}
const last8 = String(hash % 100_000_000).padStart(8, '0');
return `138${last8}`;
}
/// 增量游标 → DW 字符串列可比的字面量。
/// DW 的 cursor 列是 String('YYYY-MM-DD HH:mm:ss[.SSS]',宿主本地时区)→ 比较走字典序;
/// 游标存的是 run_start ISO(带 'T'/'Z')→ 'T' 比 ' ' 字典序大,所有真实行都排在游标前
......
import type { Prisma } from '@prisma/client';
import { mergePatientPreferences } from '../pipeline/patient-preferences.util';
/**
* 患者 upsert 数据构造 — 全量(cold-import)与部分更新(push 单表)两种语义。
*
* **full(默认,cold-import 存量/增量)**:行为与历史完全一致 —— 每字段显式落值
* (缺失 → null / 默认),phone 缺失合成 demo 假号(dev 造数便利,生产 host 给真值自动让位)。
*
* **partial(push 形态 A 单表患者主档)**:canonical **未提供**(undefined)的字段不进
* update 集合(prisma undefined = 不动该列)——解决 push 单表时跨表依赖缺席的覆盖事故:
* 宿主只推 customer_basic_info 时,phone 依赖的 contacts lookup 落空,full 语义会把
* 存量真号覆盖成合成假号;partial 语义下 phone 缺失 → 不动存量。
* phone 在 partial 下**永不合成假号**(显式提供才更新)。
* 注意:显式 null 与 undefined 同义(canonical zod nullable 归一),都视为"未提供"——
* push 通道没有"显式清空字段"语义(如需清空走全量通道)。
*/
export function buildPatientUpsertData(
c: Record<string, unknown>,
externalId: string,
opts: { partial: boolean },
): Record<string, unknown> {
if (!opts.partial) {
return {
name: (c.name as string | undefined) ?? null,
// ⚠️ DEMO 兜底:host 不给 phone(隐私) → 由 externalId hash 合成确定性假号
phone: (c.phone as string | undefined) ?? synthesizeDemoPhone(externalId),
gender: (c.gender as string | undefined) ?? null,
birthDate: c.birthDate ? new Date(c.birthDate as string) : null,
medicalRecordNumber: (c.medicalRecordNumber as string | undefined) ?? null,
preferences: mergePatientPreferences(c),
active: ((c.status as string | undefined) ?? 'active') === 'active',
};
}
// partial:提供(非 null/undefined)才落
const given = (k: string) => c[k] !== undefined && c[k] !== null;
return {
...(given('name') ? { name: c.name as string } : {}),
...(given('phone') ? { phone: c.phone as string } : {}),
...(given('gender') ? { gender: c.gender as string } : {}),
...(given('birthDate') ? { birthDate: new Date(c.birthDate as string) } : {}),
...(given('medicalRecordNumber')
? { medicalRecordNumber: c.medicalRecordNumber as string }
: {}),
// preferences 本就是"有可写才返回,否则 undefined 不动列"语义,天然 partial-safe
preferences: mergePatientPreferences(c),
...(given('status') ? { active: (c.status as string) === 'active' } : {}),
};
}
/** 副表(profile)同款双语义。 */
export function buildProfileUpsertData(
c: Record<string, unknown>,
opts: { partial: boolean },
): Record<string, unknown> {
if (!opts.partial) {
return {
doNotContact: (c.doNotContact as boolean) ?? false,
deceased: (c.deceased as boolean) ?? false,
tags: Array.isArray(c.tags) ? (c.tags as string[]) : [],
notes: (c.notes as string | undefined) ?? null,
acquisitionChannel: (c.acquisitionChannel as string | undefined) ?? null,
acquisitionSub: (c.acquisitionSub as string | undefined) ?? null,
referralCount: c.referralCount != null ? Math.trunc(Number(c.referralCount)) : null,
referralAmountCents:
c.referralAmount != null ? Math.round(Number(c.referralAmount) * 100) : null,
};
}
const given = (k: string) => c[k] !== undefined && c[k] !== null;
return {
...(given('doNotContact') ? { doNotContact: c.doNotContact as boolean } : {}),
...(given('deceased') ? { deceased: c.deceased as boolean } : {}),
...(Array.isArray(c.tags) && c.tags.length > 0 ? { tags: c.tags as string[] } : {}),
...(given('notes') ? { notes: c.notes as string } : {}),
...(given('acquisitionChannel')
? { acquisitionChannel: c.acquisitionChannel as string }
: {}),
...(given('acquisitionSub') ? { acquisitionSub: c.acquisitionSub as string } : {}),
...(given('referralCount') ? { referralCount: Math.trunc(Number(c.referralCount)) } : {}),
...(given('referralAmount')
? { referralAmountCents: Math.round(Number(c.referralAmount) * 100) }
: {}),
};
}
/** dev 造数:externalId hash 合成确定性假号(138 前缀;同 id 恒同号)。生产 host 给真值自动让位。 */
export function synthesizeDemoPhone(externalId: string): string {
let hash = 0;
for (let i = 0; i < externalId.length; i++) {
hash = (hash * 31 + externalId.charCodeAt(i)) >>> 0;
}
const last8 = String(hash % 100_000_000).padStart(8, '0');
return `138${last8}`;
}
export type PatientUpsertData = ReturnType<typeof buildPatientUpsertData> &
Prisma.InputJsonValue;
......@@ -30,7 +30,14 @@ export function mergePatientPreferences(
const relCode = c.phoneRelationshipCode == null ? '' : String(c.phoneRelationshipCode).trim();
if (relCode) {
base.contactPhone = { relationshipCode: relCode, isSelf: relCode === '1' };
// relationship = assembler enum_mapping 解码后的 PAC 关系词(self/father/mother/…;
// FRIDAY 官方 PhoneRelationshipEnum 源码实锤)。解不出时留 null,原码始终保留。
const rel = ((c.phoneRelationship as string | undefined) ?? '').trim() || null;
base.contactPhone = {
relationshipCode: relCode,
relationship: rel,
isSelf: rel ? rel === 'self' : relCode === '1',
};
}
return Object.keys(base).length ? (base as Prisma.InputJsonValue) : undefined;
......
/**
* 患者 upsert 双语义 — push 单表部分更新 vs cold-import 全量覆盖。
*
* 坐实(2026-07-20 patient push 支持的核心约束):
* - full(cold-import):历史行为 —— 缺失字段显式落 null/默认,phone 缺失合成 demo 假号;
* - partial(push 形态 A 单表):canonical 未提供的字段**不进 update 集合**(prisma
* undefined 不动列)—— 宿主只推 customer_basic_info 时 contacts lookup 缺席,
* full 语义会把存量真号覆盖成假号(此前 push 不支持患者主档的根因)。
*
* 纯函数测试,不跑 DB。
*/
import {
buildPatientUpsertData,
buildProfileUpsertData,
synthesizeDemoPhone,
} from '../src/modules/sync/cold-import/patient-upsert.util';
describe('patient upsert | full(cold-import 历史语义)', () => {
test('缺失字段显式落 null;phone 缺失合成确定性 demo 假号', () => {
const d = buildPatientUpsertData({}, 'P001', { partial: false });
expect(d.name).toBeNull();
expect(d.phone).toBe(synthesizeDemoPhone('P001'));
expect(String(d.phone)).toMatch(/^138\d{8}$/);
expect(d.gender).toBeNull();
expect(d.active).toBe(true); // status 缺省 = active
});
test('真值让位:host 给 phone 时不合成', () => {
const d = buildPatientUpsertData({ phone: '13900001111' }, 'P001', { partial: false });
expect(d.phone).toBe('13900001111');
});
test('profile 缺失字段落默认(tags=[] / notes=null)', () => {
const p = buildProfileUpsertData({}, { partial: false });
expect(p.tags).toEqual([]);
expect(p.notes).toBeNull();
expect(p.doNotContact).toBe(false);
});
});
describe('patient upsert | partial(push 单表)', () => {
test('未提供的字段不进 update 集合(不覆盖存量)', () => {
const d = buildPatientUpsertData({ name: '王五' }, 'P001', { partial: true });
expect(d.name).toBe('王五');
expect('phone' in d).toBe(false); // ⭐ 关键:phone 缺席 → 不动存量,更不合成假号
expect('gender' in d).toBe(false);
expect('birthDate' in d).toBe(false);
expect('active' in d).toBe(false); // status 未提供 → 不动 active
});
test('显式提供才更新;null 视为未提供(push 无清空语义)', () => {
const d = buildPatientUpsertData(
{ phone: '13800002222', gender: null },
'P001',
{ partial: true },
);
expect(d.phone).toBe('13800002222');
expect('gender' in d).toBe(false);
});
test('status 提供时归一 active', () => {
const d = buildPatientUpsertData({ status: 'archived' }, 'P001', { partial: true });
expect(d.active).toBe(false);
});
test('profile partial:tags 空数组不清空存量;提供才落', () => {
const p1 = buildProfileUpsertData({ tags: [] }, { partial: true });
expect('tags' in p1).toBe(false);
const p2 = buildProfileUpsertData({ tags: ['VIP'], notes: '备注' }, { partial: true });
expect(p2.tags).toEqual(['VIP']);
expect(p2.notes).toBe('备注');
expect('doNotContact' in p2).toBe(false);
});
test('preferences 天然 partial-safe:无可写偏好 → undefined 不动列', () => {
const d = buildPatientUpsertData({}, 'P001', { partial: true });
expect(d.preferences).toBeUndefined();
const d2 = buildPatientUpsertData({ phoneRelationshipCode: '3', phoneRelationship: 'mother' }, 'P001', {
partial: true,
});
expect(d2.preferences).toMatchObject({
contactPhone: { relationshipCode: '3', relationship: 'mother', isSelf: false },
});
});
});
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