Skip to content
Projects
Groups
Snippets
Help
This project
Loading...
Sign in / Register
Toggle navigation
P
pac
Overview
Overview
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
ai-tools
pac
Commits
b502e1b3
Commit
b502e1b3
authored
Oct 21, 2026
by
luoqi
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat(open): standardize patient fact content and preserve source timestamps
parent
355a7550
Pipeline
#3854
failed in 0 seconds
Changes
63
Pipelines
1
Hide whitespace changes
Inline
Side-by-side
Showing
63 changed files
with
2669 additions
and
402 deletions
+2669
-402
apps/pac-docs/content/docs/api/open/PatientFactsLookupController_lookup.mdx
+148
-97
apps/pac-service/data/friday/assemblers/appointment.yaml
+3
-0
apps/pac-service/data/friday/assemblers/consult.yaml
+3
-0
apps/pac-service/data/friday/assemblers/diagnosis.yaml
+3
-0
apps/pac-service/data/friday/assemblers/emr.yaml
+3
-0
apps/pac-service/data/friday/assemblers/encounter.yaml
+3
-0
apps/pac-service/data/friday/assemblers/image.yaml
+3
-0
apps/pac-service/data/friday/assemblers/image_analysis.yaml
+3
-0
apps/pac-service/data/friday/assemblers/payment.yaml
+3
-0
apps/pac-service/data/friday/assemblers/recommendation.yaml
+3
-0
apps/pac-service/data/friday/assemblers/refund_full.yaml
+3
-0
apps/pac-service/data/friday/assemblers/refund_item.yaml
+3
-0
apps/pac-service/data/friday/assemblers/treatment_actual.yaml
+3
-0
apps/pac-service/data/friday/assemblers/treatment_planned.yaml
+3
-0
apps/pac-service/data/friday/assemblers/treatment_review.yaml
+3
-0
apps/pac-service/data/jvs-dw/assemblers/appointment.yaml
+3
-0
apps/pac-service/data/jvs-dw/assemblers/complaint.yaml
+3
-0
apps/pac-service/data/jvs-dw/assemblers/diagnosis.yaml
+3
-0
apps/pac-service/data/jvs-dw/assemblers/emr.yaml
+3
-0
apps/pac-service/data/jvs-dw/assemblers/encounter.yaml
+3
-0
apps/pac-service/data/jvs-dw/assemblers/image.yaml
+3
-0
apps/pac-service/data/jvs-dw/assemblers/payment.yaml
+3
-0
apps/pac-service/data/jvs-dw/assemblers/recommendation.yaml
+3
-0
apps/pac-service/data/jvs-dw/assemblers/refund.yaml
+3
-0
apps/pac-service/data/jvs-dw/assemblers/treatment_actual.yaml
+3
-0
apps/pac-service/data/jvs-dw/assemblers/treatment_planned.yaml
+3
-0
apps/pac-service/data/jvs-dw/assemblers/treatment_review.yaml
+3
-0
apps/pac-service/data/jvs-dw/manifest.yaml
+1
-1
apps/pac-service/prisma/migrations/20261021140000_fact_source_created_time/migration.sql
+3
-0
apps/pac-service/prisma/schema.prisma
+6
-2
apps/pac-service/src/integrations/meyer/pac-sink.ts
+6
-1
apps/pac-service/src/modules/open/lookup-content.schema.ts
+284
-0
apps/pac-service/src/modules/open/lookup-content.ts
+233
-70
apps/pac-service/src/modules/open/lookup-fact.ts
+40
-14
apps/pac-service/src/modules/open/lookup-narrative.ts
+112
-0
apps/pac-service/src/modules/open/lookup-relations.ts
+119
-0
apps/pac-service/src/modules/open/patient-facts-lookup.controller.ts
+1
-1
apps/pac-service/src/modules/open/patient-facts-lookup.schema.ts
+139
-15
apps/pac-service/src/modules/open/patient-facts-lookup.service.ts
+52
-13
apps/pac-service/src/modules/sync/assembler/assembler-engine.ts
+8
-0
apps/pac-service/src/modules/sync/assembler/assembler.schema.ts
+5
-0
apps/pac-service/src/modules/sync/assembler/field-mapper.ts
+27
-6
apps/pac-service/src/modules/sync/canonical-time.ts
+11
-0
apps/pac-service/src/modules/sync/pipeline/fact-writer.service.ts
+5
-2
apps/pac-service/src/modules/sync/pipeline/parser-pipeline.service.ts
+8
-9
apps/pac-service/src/modules/sync/pipeline/transaction-synthesizer.ts
+14
-20
apps/pac-service/src/modules/sync/source-created-time.ts
+28
-0
apps/pac-service/tests/canonical-source-timezone.spec.ts
+80
-2
apps/pac-service/tests/fact-writer-temporal-supersede.spec.ts
+30
-0
apps/pac-service/tests/fixtures/lookup-content-source.json
+1
-1
apps/pac-service/tests/lookup-content.spec.ts
+175
-49
apps/pac-service/tests/lookup-contract-postgres.spec.ts
+215
-0
apps/pac-service/tests/lookup-fact.spec.ts
+143
-28
apps/pac-service/tests/lookup-narrative.spec.ts
+117
-0
apps/pac-service/tests/lookup-relations.spec.ts
+178
-0
apps/pac-service/tests/meyer-imaging-postgres.spec.ts
+6
-3
apps/pac-service/tests/open-fact-content-doc.spec.ts
+151
-57
apps/pac-service/tests/open-patient-facts-local.spec.ts
+2
-2
apps/pac-service/tests/patient-facts-lookup.spec.ts
+10
-5
apps/pac-service/tests/source-created-time-postgres.spec.ts
+93
-0
apps/pac-service/tests/source-created-time.spec.ts
+127
-0
apps/pac-service/tests/transaction-synthesizer-occurred-at.spec.ts
+8
-3
packages/types/src/schemas/canonical.ts
+5
-1
No files found.
apps/pac-docs/content/docs/api/open/PatientFactsLookupController_lookup.mdx
View file @
b502e1b3
...
...
@@ -19,7 +19,10 @@ description: 获取应用令牌,查询患者资料、画像、关系与事实
| `appSecret` | string | 是 | 应用密钥 |
```json
{ "appId": "your-app-id", "appSecret": "your-app-secret" }
{
"appId": "your-app-id",
"appSecret": "your-app-secret"
}
```
```json
...
...
@@ -74,7 +77,7 @@ curl "$PAC_BASE_URL/pac/v1/open/patient-facts/lookup" \
| `items[].relations` | object[] | 患者关系;无记录为 `[]` |
| `items[].facts` | object[] | 全部当前有效事实;无记录为 `[]`,不截断 |
返回字段可为 `null` 时在下表标明;`content` 字段还可能缺失。空值或空数组不代表无疾病、无风险或已完成。调用方应兼容新增字段、事实类型和开放字典值。手机号及自由文本已脱敏。
返回字段可为 `null` 时在下表标明;`content`
内的可选
字段还可能缺失。空值或空数组不代表无疾病、无风险或已完成。调用方应兼容新增字段、事实类型和开放字典值。手机号及自由文本已脱敏。
<details>
<summary>完整响应示例</summary>
...
...
@@ -95,8 +98,8 @@ curl "$PAC_BASE_URL/pac/v1/open/patient-facts/lookup" \
"gender": "男",
"birthDate": "1990-01-02",
"dedicatedCs": {
"
id": "source-staff-123
",
"
name": "李客服
"
"
name": "李客服
",
"
externalId": "source-staff-123
"
},
"acquisitionChannel": null,
"acquisitionSub": null
...
...
@@ -110,12 +113,17 @@ curl "$PAC_BASE_URL/pac/v1/open/patient-facts/lookup" \
{
"type": "diagnosis_record",
"kind": "actual",
"clinicId": "来源诊所标识",
"clinic
External
Id": "来源诊所标识",
"clinicName": "示例诊所",
"occurredAt": "2026-10-19T02:00:00.000Z",
"plannedFor": null,
"content": {
"code": "K02"
"code": "K02",
"externalId": null,
"emrExternalId": null,
"encounterExternalId": null,
"imageExternalId": null,
"analysisExternalId": null
},
"id": "33333333-3333-4333-8333-333333333333"
}
...
...
@@ -133,14 +141,14 @@ curl "$PAC_BASE_URL/pac/v1/open/patient-facts/lookup" \
| 字段 | 类型 | 可空 | 说明 |
| --- | --- | --- | --- |
| `id` | string | 否 | PAC 患者 UUID,用于区分患者、缓存结果 |
| `externalId` | string | 否 | 宿主患者 ID;不是病历号,不保证跨品牌唯一,回链宿主需结合自身上下文 |
| `externalId` | string
/null
| 否 | 宿主患者 ID;不是病历号,不保证跨品牌唯一,回链宿主需结合自身上下文 |
| `name` | string | 是 | 姓名 |
| `medicalRecordNumber` | string | 是 | 病历号 |
| `phone` | string | 是 | 脱敏手机号 |
| `gender` | string | 是 | 开放文本,常见 male=男、female=女,也可能为宿主原文“男/女” |
| `birthDate` | string | 是 | 出生日期,YYYY-MM-DD |
| `dedicatedCs` | object | 是 | 专属客服;无信息时为 null |
| `dedicatedCs.
i
d` | string | 是 | 宿主侧客服标识 |
| `dedicatedCs.
externalI
d` | string | 是 | 宿主侧客服标识 |
| `dedicatedCs.name` | string | 是 | 客服姓名 |
| `acquisitionChannel` | string | 是 | 获客一级渠道,见下方字典 |
| `acquisitionSub` | string | 是 | 宿主二级渠道原文,非固定枚举 |
...
...
@@ -203,11 +211,11 @@ curl "$PAC_BASE_URL/pac/v1/open/patient-facts/lookup" \
| `id` | string | 否 | PAC 事实版本 UUID;同一业务记录产生新版本时会变化 |
| `type` | string | 否 | 事实类型,决定 content 结构,见第 5 节 |
| `kind` | string | 否 | `actual` 实际记录;`planned` 计划或建议。actual 不等于治疗完成 |
| `clinicId` | string | 是 | 宿主诊所标识 |
| `clinic
External
Id` | string | 是 | 宿主诊所标识 |
| `clinicName` | string | 是 | 诊所名称 |
| `occurredAt` | string | 是 | 业务发生时间,UTC ISO 8601;按下表理解 |
| `plannedFor` | string | 是 | 明确约定的执行时间,UTC ISO 8601;目前预约返回约定到诊时间,其他类型为 null |
| `content` |
JSON | 是 | 通常为对应类型的对象,字段、枚举及示例见第 5 节;兼容历史空值或非对象值
|
| `content` |
object | 否 | 按 type 定义的内容对象,字段、枚举及示例见第 5 节
|
| 事实 | occurredAt 的含义 |
| --- | --- |
...
...
@@ -219,34 +227,30 @@ curl "$PAC_BASE_URL/pac/v1/open/patient-facts/lookup" \
- **范围**:每条业务记录只返回最新有效版本;取消、过期、作废及被替换的版本不返回。结果是当前快照,记录消失不等于删除通知;不含召回结果。
- **排序**:按 occurredAt 倒序,缺失时使用 plannedFor;两者皆空排最后,同刻按 id 倒序。
- **精度**:时间戳统一 UTC;源时间只有日期时可按宿主当地零点表达,不代表精确时刻。
- **引用**:content.externalId 为本条记录的
源标识;关联引用与 PAC 版本 id 不同,不保证能在本次结果中直接关联
。
- **引用**:content.externalId 为本条记录的
来源标识;缺少可靠独立标识为 null。…ExternalId 均为来源系统标识,不是 PAC UUID;按不透明字符串使用,不保证跨租户/品牌唯一。关联未经确认或存在冲突时为 null;有引用也不保证目标在本次结果中可见
。
## 5. 事实 content 字段
按类型展开查看,
表中 `/null` 表示允许显式空值;所有 content 字段均应兼容缺失。各类型的 status 使用各自字典;未列枚举的字段为开放文本
。
按类型展开查看,
`/null` 表示允许空值。各类 externalId、病历的 11 个段落、影像 analysis,以及表内关联引用和投诉 sourceStatus 必返,未知为 null;其余字段可缺失。各类型 status 使用各自字典;枚举说明为当前值,须兼容历史值和未来扩展
。
金额单位以字段说明为准
;amountCents、amount、totalAmount 不合并、不换算。外层已承接的同义时间会省略;内层仍有时间时,可能是独立业务时点、精度不足或与外层不同。未知扩展和异值别名可能保留原名,不应强行合并
。
金额单位以字段说明为准
,不换算。外层已承接的同义时间省略;内层保留独立时点或未被完整承接的时间。仅返回本页定义的业务字段,内部扩展不透传;同义非关联字段值冲突时返回规范化失败
。
本页列出已接入数据的 11 类事实;各宿主覆盖不同,示例仅说明结构。调用方应兼容未来新增的 type。
<details id="consultation_record">
<summary>咨询 · consultation_record</summary>
意向是患者主观意愿,不等于医生诊断。
意向是患者主观意愿,不等于医生诊断。
历史记录仅有意向原文时作为一项返回,不按分隔符猜拆。
| 字段 | 类型 | 含义 |
| --- | --- | --- |
| `externalId` | string | 源咨询记录标识 |
| `externalId` | string
/null
| 源咨询记录标识 |
| `consultDate` | string/null | 咨询日期或时刻,旧记录可能保留日期文本;通常省略;未被外层承接或有差异时保留 |
| `sourceChannel` | string/null | 来源渠道 |
| `intents` | string[] | 患者意向原文列表 |
| `intentCategories` | string[] | PAC 治疗类别列表,取值与 treatment_record.category 一致 |
| `isFirstVisit` | boolean/null | 源侧初诊标记;未知为 null |
| `taskDirector` | string/null | 跟进人原值 |
| `doctorId` | string/null | 源医生标识 |
| `doctorExternalId` | string/null | 源医生标识 |
| `appointmentExternalId` | string/null | 关联源预约标识 |
| `intent` | string/null | 仅保留无法由 intents 拼接还原的历史原文;重复拼接串省略 |
| `text` | string/null | 咨询正文 |
**示例**
...
...
@@ -263,7 +267,8 @@ curl "$PAC_BASE_URL/pac/v1/open/patient-facts/lookup" \
"intentCategories": [
"preventive"
],
"isFirstVisit": true
"isFirstVisit": true,
"appointmentExternalId": null
}
}
```
...
...
@@ -277,8 +282,8 @@ curl "$PAC_BASE_URL/pac/v1/open/patient-facts/lookup" \
| 字段 | 类型 | 含义 |
| --- | --- | --- |
| `externalId` | string | 源接诊标识 |
| `doctorId / doctorName` | string/null | 源医生标识 / 姓名 |
| `externalId` | string
/null
| 源接诊标识 |
| `doctor
External
Id / doctorName` | string/null | 源医生标识 / 姓名 |
| `chiefComplaint` | string/null | 主诉 |
| `encounterType` | string/null | 接诊类型原值 |
...
...
@@ -306,15 +311,16 @@ curl "$PAC_BASE_URL/pac/v1/open/patient-facts/lookup" \
| 字段 | 类型 | 含义 |
| --- | --- | --- |
| `externalId` | string/null | 来源独立记录标识;没有可靠独立标识时 null |
| `code` | string/null | PAC 标准诊断或建议码,见“公共代码字典”;未映射时 null,不据此推断无病 |
| `codeSource` | string/null | `std_code` 源标准码、`name_map` 名称映射、`llm` 模型抽取、`image_ai` 影像分析;未知 null |
| `name` | string/null | 原诊断中文名称 |
| `toothPosition` | string/null | 牙位文本,可能多牙位及牙面标记 |
| `severity` | string/null | `mild` 轻、`moderate` 中、`severe` 重 |
| `onsetDate` | string/null | 起病日期原值 |
| `doctorId / doctorName` | string/null | 源医生标识 / 姓名 |
| `
sourceEncounterExternalId` | string | 来源接诊引用;历史值不保证指向实际接诊记录
|
| `
sourceImageExternalId / sourceAnalysisExternalId` | string | 可选的影像 / 分析来源标识
|
| `doctor
External
Id / doctorName` | string/null | 源医生标识 / 姓名 |
| `
emrExternalId / encounterExternalId` | string/null | 承载该事实的来源病历 / 来源接诊标识;各自独立,不能互换
|
| `
imageExternalId / analysisExternalId` | string/null | 来源影像 / 独立分析记录标识;未知 null,美亚当前无独立分析记录 ID
|
| `clinicalSignals` | object[] | 可选临床信号,见公共嵌套结构 |
**示例**
...
...
@@ -329,7 +335,11 @@ curl "$PAC_BASE_URL/pac/v1/open/patient-facts/lookup" \
"codeSource": "std_code",
"name": "龋病",
"toothPosition": "36",
"sourceEncounterExternalId": "enc-1"
"emrExternalId": "emr-1",
"externalId": null,
"encounterExternalId": null,
"imageExternalId": null,
"analysisExternalId": null
}
}
```
...
...
@@ -341,7 +351,7 @@ curl "$PAC_BASE_URL/pac/v1/open/patient-facts/lookup" \
美亚补充采集使用已有类型:影像信息在 `image_record`,分析快照在其 `content.analysis`,牙位诊断信号仍在 `diagnosis_record`,以 `codeSource=image_ai` 区分影像分析来源。当前这条路线不生成独立的分析报告记录。
- **关联影像**:诊断的 `content.
sourceI
mageExternalId` 对应影像的 `content.externalId`,不是外层 PAC `id`;源标识按不透明字符串使用。
- **关联影像**:诊断的 `content.
i
mageExternalId` 对应影像的 `content.externalId`,不是外层 PAC `id`;源标识按不透明字符串使用。
- **时间**:当前美亚路线中,影像的 `occurredAt` 是美亚影像创建时间;诊断的 `occurredAt` 是影像检查时间,均不表示分析完成或采集时间。
- **读取**:展示一张影像的分析时读取 `analysis`;它与关联的影像来源诊断表达同一批病症,不应重复计数。
- **病症**:`name`、`toothPosition` 保留病症和牙位;`code` 未能可靠映射时为 null,仍返回该发现,不代表无异常,也不等同医生确诊。
...
...
@@ -357,7 +367,7 @@ curl "$PAC_BASE_URL/pac/v1/open/patient-facts/lookup" \
"id": "11111111-1111-4111-8111-111111111111",
"type": "image_record",
"kind": "actual",
"clinicId": "clinic-a",
"clinic
External
Id": "clinic-a",
"clinicName": "示例诊所",
"occurredAt": "2026-10-10T02:00:00.000Z",
"plannedFor": null,
...
...
@@ -370,17 +380,27 @@ curl "$PAC_BASE_URL/pac/v1/open/patient-facts/lookup" \
"description": "描述",
"analyzedAt": null,
"findings": [
{ "name": "龋齿", "toothPosition": "16", "code": "K02" },
{ "name": "可疑龋", "toothPosition": "51", "code": null }
{
"name": "龋齿",
"toothPosition": "16",
"code": "K02"
},
{
"name": "可疑龋",
"toothPosition": "51",
"code": null
}
]
}
},
"emrExternalId": null,
"encounterExternalId": null
}
},
{
"id": "22222222-2222-4222-8222-222222222222",
"type": "diagnosis_record",
"kind": "actual",
"clinicId": "clinic-a",
"clinic
External
Id": "clinic-a",
"clinicName": "示例诊所",
"occurredAt": "2026-10-09T01:00:00.000Z",
"plannedFor": null,
...
...
@@ -389,7 +409,11 @@ curl "$PAC_BASE_URL/pac/v1/open/patient-facts/lookup" \
"codeSource": "image_ai",
"name": "龋齿",
"toothPosition": "16",
"sourceImageExternalId": "meyer:test:image-1"
"imageExternalId": "meyer:test:image-1",
"externalId": null,
"emrExternalId": null,
"encounterExternalId": null,
"analysisExternalId": null
}
}
]
...
...
@@ -405,17 +429,17 @@ kind=planned 表示计划,actual 表示实际记录。实际记录也可能仍
| 字段 | 类型 | 含义 |
| --- | --- | --- |
| `externalId` | string/null | 来源独立记录标识;没有可靠独立标识时 null |
| `category` | string | `endodontic` 牙髓 / 根管;`restorative` 充填 / 嵌体;`prosthodontic` 修复 / 冠桥;`implant` 种植;`periodontic` 牙周;`orthodontic` 正畸;`surgical` 外科;`pediatric` 儿牙;`preventive` 预防;`cosmetic` 美学;`review` 复查 / 流程 |
| `subtype` | string/null | 治疗子类或原文 |
| `toothPosition` | string/null | 牙位文本 |
| `status` | string | 业务进度:`planned` 计划中、`in_progress` 进行中、`completed` 已完成、`cancelled` 已取消、`failed` 失败 |
| `startedAt / completedAt` | string/null | 开始 / 完成时刻 |
| `doctorId / doctorName` | string/null | 源医生标识 / 姓名 |
| `doctor
External
Id / doctorName` | string/null | 源医生标识 / 姓名 |
| `quantity` | number/null | 数量 |
| `unitName` | string/null | 计量单位,如颗、次、区 |
| `treatStages` | string[]/null | 源归一化治疗阶段;步骤码见公共代码字典。为空不代表没有治疗,未提供时为空 |
| `sourceEncounterExternalId` | string/null | 来源接诊引用 |
| `relatedDiagnosisSubjectId` | string/null | 关联诊断主体引用 |
| `stages` | string[]/null | 源归一化治疗阶段;步骤码见公共代码字典。为空不代表没有治疗,未提供时为空 |
| `emrExternalId / encounterExternalId` | string/null | 承载该事实的来源病历 / 来源接诊标识;未确认 null |
| `reviewWindowDays` | number/null | 复查窗口天数 |
**示例**
...
...
@@ -431,9 +455,12 @@ kind=planned 表示计划,actual 表示实际记录。实际记录也可能仍
"status": "completed",
"toothPosition": "36",
"completedAt": "2026-10-19T02:00:00.000Z",
"
treatS
tages": [
"
s
tages": [
"canal_filling"
]
],
"externalId": null,
"emrExternalId": null,
"encounterExternalId": null
}
}
```
...
...
@@ -447,15 +474,15 @@ kind=planned 表示计划,actual 表示实际记录。实际记录也可能仍
| 字段 | 类型 | 含义 |
| --- | --- | --- |
| `externalId` | string/null | 来源独立记录标识;没有可靠独立标识时 null |
| `code` | string/null | PAC 归一建议码,见“公共代码字典”;未映射 null |
| `
name
` | string/null | 建议原文 |
| `
sourceEncounterExternalId` | string/null | 来源接诊引用
|
| `
recommendation
` | string/null | 建议原文 |
| `
emrExternalId / encounterExternalId` | string/null | 承载该事实的来源病历 / 来源接诊标识;未确认 null
|
| `toothPosition` | string/null | 牙位文本 |
| `doctorId / doctorName` | string/null | 源医生标识 / 姓名 |
| `doctor
External
Id / doctorName` | string/null | 源医生标识 / 姓名 |
| `extractionMethod` | string | `rule` 规则抽取、`llm` 模型抽取、`doctor_input` 医生录入 |
| `confidence` | number/null | 0–1 置信度,未提供 null |
| `sourceFactSubjectId` | string/null | 来源事实主体引用 |
| `doctorSuggestedAt` | string/null | 建议提出时刻;通常省略;未被外层承接或有差异时保留 |
| `suggestedAt` | string/null | 建议提出时刻;通常省略;未被外层承接或有差异时保留 |
| `windowDays` | number/null | 建议时间窗,单位天 |
**示例**
...
...
@@ -467,10 +494,13 @@ kind=planned 表示计划,actual 表示实际记录。实际记录也可能仍
"plannedFor": null,
"content": {
"code": null,
"
name
": "建议复查",
"
recommendation
": "建议复查",
"toothPosition": "36",
"extractionMethod": "doctor_input",
"windowDays": 30
"windowDays": 30,
"externalId": null,
"emrExternalId": null,
"encounterExternalId": null
}
}
```
...
...
@@ -480,25 +510,25 @@ kind=planned 表示计划,actual 表示实际记录。实际记录也可能仍
<details id="emr_record">
<summary>病历 · emr_record</summary>
段落统一以字符串返回;历史字符串可能含序列化 JSON,不保证都可直接 JSON.parse。结构化诊断和治疗在其他事实中,勿将文本再次当作新增事件
。
病历 11 个段落统一返回可读文本或 null,无需 JSON.parse。牙位条目按原顺序逐行展示;保留原文、否定词、单位和重复条目,不归纳改写。未知非空结构使本次查询明确失败,不返回残缺正文。结构化诊断、治疗仍在各自事实中,勿重复计数
。
| 字段 | 类型 | 含义 |
| --- | --- | --- |
| `externalId` | string | 源病历标识 |
| `externalId` | string
/null
| 源病历标识 |
| `encounterExternalId` | string/null | 关联源接诊标识 |
| `doctorId / doctorName` | string/null | 源医生标识 / 姓名 |
| `illnessDesc
` | string/null | 主诉或疾病描述
|
| `preIllness` | string/null | 现病史 |
| `doctor
External
Id / doctorName` | string/null | 源医生标识 / 姓名 |
| `illnessDesc
ription` | string/null | 主诉、疾病描述或混合病史原文,不保证是纯主诉
|
| `pre
sent
Illness` | string/null | 现病史 |
| `pastHistory` | string/null | 既往史 |
| `generalCondition` | string/null | 一般情况 |
| `examFindings` | string/null | 检查所见 |
| `aux
ExamFindings` | string/null | 可选辅助检查
|
| `diagnosis
Text
` | string/null | 诊断原文 |
| `aux
iliaryExamFindings` | string/null | 辅助检查原文
|
| `diagnosis` | string/null | 诊断原文 |
| `treatmentPlan` | string/null | 治疗计划原文 |
| `treatment
Notes` | string/null | 可选
本次治疗原文 |
| `
disposal
` | string/null | 本次处置原文 |
| `treatment
` | string/null |
本次治疗原文 |
| `
procedure
` | string/null | 本次处置原文 |
| `doctorAdvice` | string/null | 医嘱 |
| `qualityCheck` | object/null | 可选质检对象
:status(源状态字符串)、rejected(布尔)、rejectReason(原原因,可空)
|
| `qualityCheck` | object/null | 可选质检对象
,字段见下文
|
| `clinicalSignals` | object[] | 可选临床信号,见公共嵌套结构 |
**示例**
...
...
@@ -511,9 +541,17 @@ kind=planned 表示计划,actual 表示实际记录。实际记录也可能仍
"content": {
"externalId": "emr-1",
"encounterExternalId": "enc-1",
"illnessDesc": "右下牙疼痛",
"illnessDesc
ription
": "右下牙疼痛",
"examFindings": "36 牙检查所见",
"doctorAdvice": "按医嘱复诊"
"doctorAdvice": "按医嘱复诊",
"presentIllness": null,
"pastHistory": null,
"generalCondition": null,
"auxiliaryExamFindings": null,
"diagnosis": null,
"treatmentPlan": null,
"treatment": null,
"procedure": null
}
}
```
...
...
@@ -523,20 +561,20 @@ kind=planned 表示计划,actual 表示实际记录。实际记录也可能仍
<details id="image_record">
<summary>影像 · image_record</summary>
file
Url
不是 PAC 保证可访问的原片下载授权。查看原片走宿主授权入口;没有文件位置不等于没有影像。
file
Location
不是 PAC 保证可访问的原片下载授权。查看原片走宿主授权入口;没有文件位置不等于没有影像。
| 字段 | 类型 | 含义 |
| --- | --- | --- |
| `externalId` | string | 源影像标识 |
| `e
ncounterExternalId` | string/null | 关联源接诊标识
|
| `externalId` | string
/null
| 源影像标识 |
| `e
mrExternalId / encounterExternalId` | string/null | 来源父病历 / 接诊标识;有明确证据才提供
|
| `modality` | string | `pa` 根尖片;`bw` 咬翼片;`pano` 曲面断层;`cbct` CBCT 锥形束 CT;`intraoral_photo` 口内照片;`scan` 口扫模型;`other` 其他 |
| `toothPositions` | string[]/null | 牙位列表;
标准 FDI:恒牙 11–18、21–28、31–38、41–48;乳牙 51–55、61–65、71–75、81–85
|
| `toothPositions` | string[]/null | 牙位列表;
来源牙位文本;常见 FDI,也可能含牙面或来源编码,不作格式保证
|
| `finding` | string/null | 影像所见原文 |
| `analysis` | object/null |
可选的
本张影像分析快照;字段见下表 |
| `analysis` | object/null | 本张影像分析快照;字段见下表 |
| `capturedAt` | string/null | 拍摄时刻,来源未提供时为空;通常省略;未被外层承接或有差异时保留 |
| `file
Url` | string
| 可选宿主文件位置原值,可能相对路径或临时地址 |
| `fileName / fileType` | string
| 可选文件名 / 文件
类型 |
| `doctorId / doctorName` | string/null | 源医生标识 / 姓名 |
| `file
Location` | string/null
| 可选宿主文件位置原值,可能相对路径或临时地址 |
| `fileName / fileType` | string
/null | 可选文件名 / 来源文件类型;不承诺为 MIME
类型 |
| `doctor
External
Id / doctorName` | string/null | 源医生标识 / 姓名 |
**analysis(存在时)**
...
...
@@ -548,10 +586,10 @@ fileUrl 不是 PAC 保证可访问的原片下载授权。查看原片走宿主
| `analyzedAt` | string/null | 来源明确提供的分析完成时刻(UTC ISO 8601);未知 null,不用影像创建或检查时间代替 |
| `findings` | object[] | 本张影像的结构化病症列表;空数组不表示已确认正常 |
| `findings[].name` | string | 病症或所见原文 |
| `findings[].toothPosition` | string/null | 牙位文本
;标准 FDI 编码见上表
|
| `findings[].toothPosition` | string/null | 牙位文本
,可能包含多牙位及牙面
|
| `findings[].code` | string/null | PAC 标准诊断码,值域见本页“诊断码与治疗阶段字典”;无法可靠映射为 null,不代表无异常 |
`analysis`
缺失或
null 只表示本张影像没有可提供的分析快照,可能尚未取得结果或无法可靠关联;不能据此判断源端无分析或检查正常。美亚分析示例见上方“影像分析结果”。
`analysis`
为
null 只表示本张影像没有可提供的分析快照,可能尚未取得结果或无法可靠关联;不能据此判断源端无分析或检查正常。美亚分析示例见上方“影像分析结果”。
**示例**
...
...
@@ -567,8 +605,11 @@ fileUrl 不是 PAC 保证可访问的原片下载授权。查看原片走宿主
"toothPositions": [
"36"
],
"fileUrl": "images/example.jpg",
"fileName": "example.jpg"
"fileLocation": "images/example.jpg",
"fileName": "example.jpg",
"emrExternalId": null,
"encounterExternalId": null,
"analysis": null
}
}
```
...
...
@@ -584,12 +625,12 @@ fileUrl 不是 PAC 保证可访问的原片下载授权。查看原片走宿主
| 字段 | 类型 | 含义 |
| --- | --- | --- |
| `externalId` | string | 源预约标识 |
| `externalId` | string
/null
| 源预约标识 |
| `scheduledAt` | string/null | 约定到诊时间;被外层 plannedFor 承接后省略,仅未承接或冲突时保留 |
| `appointmentType` | string/null | 预约类型 |
| `doctorId / doctorName` | string/null | 预约医生标识 / 姓名,不保证是实际接诊医生 |
| `doctor
External
Id / doctorName` | string/null | 预约医生标识 / 姓名,不保证是实际接诊医生 |
| `resourceName` | string/null | 源排班资源名,也可能是房间或服务 |
| `
complaintCategory / complaintTex
t` | string/null | 预约主诉类别 / 原文 |
| `
visitReasonCategory / chiefComplain
t` | string/null | 预约主诉类别 / 原文 |
| `durationMinutes` | number/null | 预约时长,分钟 |
| `cancellationReason` | string/null | 取消原因(若有) |
| `status` | string/null | `scheduled` 已预约、`rescheduled` 已改约、`cancelled` 已取消、`arrived` 已到诊、`in_treatment` 诊疗中、`completed` 已完成、`no_show` 未赴约、`walk_in` 无预约到诊 |
...
...
@@ -619,7 +660,7 @@ fileUrl 不是 PAC 保证可访问的原片下载授权。查看原片走宿主
| 字段 | 类型 | 含义 |
| --- | --- | --- |
| `externalId` | string | 源收款标识 |
| `externalId` | string
/null
| 源收款标识 |
| `amountCents` | number | 实付金额,整数分 |
| `receivableCents` | number/null | 可选应收金额,分 |
| `discountCents` | number/null | 可选折扣金额,分 |
...
...
@@ -627,8 +668,8 @@ fileUrl 不是 PAC 保证可访问的原片下载授权。查看原片走宿主
| `insuranceName` | string/null | 商业保险公司名称 |
| `cardTypeName / cardName` | string/null | 卡券类型名 / 名称 |
| `settlementProject` | string/null | 结算项目名称 |
| `doctorId` | string/null | 收费医生标识 |
| `encounterExternalId /
relatedOrderExternalId` | string/null | 关联接诊 / 医嘱单据引用
|
| `doctor
External
Id` | string/null | 收费医生标识 |
| `encounterExternalId /
orderExternalId` | string/null | 来源接诊 / 医嘱单据引用;当前来源未能可靠对应时为 null
|
**示例**
...
...
@@ -642,7 +683,9 @@ fileUrl 不是 PAC 保证可访问的原片下载授权。查看原片走宿主
"externalId": "payment-1",
"amountCents": 10000,
"receivableCents": 12000,
"discountCents": 2000
"discountCents": 2000,
"encounterExternalId": null,
"orderExternalId": null
}
}
```
...
...
@@ -654,10 +697,10 @@ fileUrl 不是 PAC 保证可访问的原片下载授权。查看原片走宿主
| 字段 | 类型 | 含义 |
| --- | --- | --- |
| `externalId` | string | 源退费标识 |
| `externalId` | string
/null
| 源退费标识 |
| `amountCents` | number | 退费金额,非负整数分,不用负数表达退费 |
| `reason` | string/null | 退费原因 |
| `
relatedP
aymentExternalId` | string/null | 关联源收款引用 |
| `
p
aymentExternalId` | string/null | 关联源收款引用 |
**示例**
...
...
@@ -670,7 +713,7 @@ fileUrl 不是 PAC 保证可访问的原片下载授权。查看原片走宿主
"reason": "项目调整",
"externalId": "refund-1",
"amountCents": 2000,
"
relatedP
aymentExternalId": "payment-1"
"
p
aymentExternalId": "payment-1"
}
}
```
...
...
@@ -684,19 +727,17 @@ fileUrl 不是 PAC 保证可访问的原片下载授权。查看原片走宿主
| 字段 | 类型 | 含义 |
| --- | --- | --- |
| `externalId / number` | string
/ string或
null | 源记录标识 / 投诉编号 |
| `externalId / number` | string
/
null | 源记录标识 / 投诉编号 |
| `feedbackKind` | string | `complaint` 投诉、`praise` 表扬、`neutral` 中性反馈、`unknown` 未知 |
| `status` | string/null | `open` 待处理、`in_progress` 处理中、`resolved` 已解决、`draft` 草稿、`unknown` 未知 |
| `category` | string | `service` 服务、`clinical` 诊疗、`billing` 收费、`environment` 环境、`waiting` 等候、`other` 其他、`unknown` 未知 |
| `channel` | string | `sms_survey` 短信回访、`hotline` 热线、`third_party` 第三方、`sales` 销售、`mini_program` 小程序、`unknown` 未知 |
| `signalEligible` | boolean | 是否具备有效投诉信号资格 |
| `text` | string/null | 投诉正文;即 fact.content.text |
| `description` | string/null | 投诉正文 |
| `severity` | string/null | `low` 低、`medium` 中、`high` 高;未知 null |
| `reviewReason` | string/null | `classification_conflict` 分类冲突、`unknown_feedback_kind` 反馈类型未知、`unknown_status` 状态未知;无复核原因时 null |
| `relatedTreatmentSubjectId / relatedOrderExternalId` | string/null | 关联治疗 / 医嘱引用 |
| `resolvedAt / sourceUpdatedAt` | string/null | 解决 / 源更新时刻 |
| `settlementNumber` | string/null | 来源结算业务编号;不是收款或医嘱单 ID,不据此直接关联事实 |
| `resolvedAt / sourceUpdatedAt` | string/null | 解决 / 来源主单更新时间(不等同整条事实的跟进水位) |
| `followUps` | object[] | 跟进列表,字段见下文 |
| `
host
Status / sourceCategory / sourceConcern / sourceChannel` | string/null | 源状态、分类、关注点、渠道原值 |
| `
source
Status / sourceCategory / sourceConcern / sourceChannel` | string/null | 源状态、分类、关注点、渠道原值 |
**示例**
...
...
@@ -711,10 +752,11 @@ fileUrl 不是 PAC 保证可访问的原片下载授权。查看原片走宿主
"channel": "hotline",
"externalId": "complaint-1",
"feedbackKind": "complaint",
"signalEligible": true,
"resolvedAt": "2026-10-19T03:00:00.000Z",
"followUps": [],
"text": "服务反馈已处理"
"description": "服务反馈已处理",
"settlementNumber": null,
"sourceStatus": null
}
}
```
...
...
@@ -722,18 +764,26 @@ fileUrl 不是 PAC 保证可访问的原片下载授权。查看原片走宿主
</details>
<details>
<summary>emr_record.qualityCheck 字段</summary>
| 字段 | 类型与含义 |
| --- | --- |
| `sourceStatus` | string/null;来源质控状态,开放文本,不等于统一审核结论 |
| `rejectionReason` | string/null;可读的来源拒绝原因。无原因不能推断审核通过 |
</details>
<details>
<summary>clinicalSignals[](病历、诊断) 字段</summary>
| 字段 | 类型与含义 |
| --- | --- |
| `code / label` | 字符串;信号码 / 中文标签。code 是可扩展字典值,不是封闭枚举,以 label 展示 |
| `phrase / field` | 字符串;命中原文 / 所在 content 字段名 |
| `span` | 两个整数;原文字符区间 [start,end)。出站脱敏可能改变文本长度,不能保证在脱敏文本上仍精确定位 |
| `evidence / sourceField` | 可空字符串;命中原文 / 对应的公开段落名。无法定位到公开正文时 sourceField=null |
| `negated` | false=阳性陈述;true=明确否认;null=不可判 |
| `severity` | `absolute` 绝对禁忌、`relative` 相对禁忌、`check` 需核查;信号等级,不等于事实严重度或医生最终诊断 |
| `
contraindicate
s` | 治疗域数组:`implant` 种植、`surgery` 外科、`anesthesia` 麻醉、`orthodontic` 正畸、`xray` 放射检查 |
| `
treatmentDomain
s` | 治疗域数组:`implant` 种植、`surgery` 外科、`anesthesia` 麻醉、`orthodontic` 正畸、`xray` 放射检查 |
| `validUntil / reviewAfter` | 可空 ISO 日期;信号失效 / 需复评日期,属于临床信号自身,不与外层业务发生时间去重 |
| `extractorVersion` | 抽取器版本字符串;区别于建议的 extractionMethod(抽取方式) |
没有该字段不意味着没有禁忌;历史信号可能已过期或需复评,不能仅凭查询返回该事实就当作当前风险。
...
...
@@ -747,8 +797,8 @@ fileUrl 不是 PAC 保证可访问的原片下载授权。查看原片走宿主
| `occurredAt` | 可空跟进时刻 |
| `action` | `transferred` 转交、`completed` 完成、`edited` 修改、`followed_up` 跟进、`paused` 暂停、`unknown` 未知 |
| `sourceAction` | 可空源操作原值 |
| `
text
` | 可空跟进正文,已脱敏 |
| `actorId / actorName` | 可空操作人源标识 / 姓名 |
| `
description
` | 可空跟进正文,已脱敏 |
| `actor
External
Id / actorName` | 可空操作人源标识 / 姓名 |
</details>
...
...
@@ -795,7 +845,7 @@ fileUrl 不是 PAC 保证可访问的原片下载授权。查看原片走宿主
</details>
<details>
<summary>治疗阶段:content.
treatS
tages</summary>
<summary>治疗阶段:content.
s
tages</summary>
| 值 | 含义 |
| --- | --- |
...
...
@@ -835,5 +885,6 @@ fileUrl 不是 PAC 保证可访问的原片下载授权。查看原片走宿主
| `10101` | 应用凭据无效 | 核对 appId/appSecret 及所属环境 |
| `10106` | 令牌缺失、无效、过期或已撤销 | 重新获取应用令牌;仍失败则核对授权 |
| `10107` | 未开通查询权限 | 联系 PAC 管理方确认应用授权 |
| `90000` | 内容规范化失败或服务异常 | details.reason 为 FACT_CONTENT_NORMALIZATION_FAILED 时,联系服务方核查;data=null,不能当作无患者或无病历 |
其他非零 code 按失败处理,保留 code/msg 用于排查;不要将失败当成“没有患者”。
apps/pac-service/data/friday/assemblers/appointment.yaml
View file @
b502e1b3
...
...
@@ -11,6 +11,9 @@ primary:
table
:
appointment_all_rows
key
:
id
# 来源创建时间对应 appointment 记录;派生事实不把父记录时间解释为自身创建。
source_created_at
:
{
field
:
createdAt
}
field_mapping
:
externalId
:
id
patientExternalId
:
patient_appointment_id
# 预约患者 = customer_basic_info.id
...
...
apps/pac-service/data/friday/assemblers/consult.yaml
View file @
b502e1b3
...
...
@@ -14,6 +14,9 @@ primary:
key
:
id
dedup_by
:
id
# 来源创建时间对应 consultation 记录;派生事实不把父记录时间解释为自身创建。
source_created_at
:
{
field
:
createdAt
}
field_mapping
:
externalId
:
id
updatedAt
:
updated_gmt_at
...
...
apps/pac-service/data/friday/assemblers/diagnosis.yaml
View file @
b502e1b3
...
...
@@ -18,6 +18,9 @@ primary:
key
:
diag_external_id
dedup_by
:
diag_external_id
# 来源创建时间对应 emr 记录;派生事实不把父记录时间解释为自身创建。
source_created_at
:
{
field
:
createdAt
}
field_mapping
:
externalId
:
diag_external_id
updatedAt
:
updated_gmt_at
...
...
apps/pac-service/data/friday/assemblers/emr.yaml
View file @
b502e1b3
...
...
@@ -13,6 +13,9 @@ primary:
key
:
emr_sub_id
# 小病历号 uuid(两侧连接键 + 临床事实锚点)
dedup_by
:
[
tenant_id
,
emr_sub_id
]
# 源组织 + 病历号;同一身份仍按原规则取首行
# 来源创建时间对应 emr 记录;派生事实不把父记录时间解释为自身创建。
source_created_at
:
{
field
:
createdAt
}
field_mapping
:
externalId
:
emr_sub_id
# updatedAt 进 source_event_id 幂等键,host UPDATE → 新 transaction(同 jvs-dw 纪律)
...
...
apps/pac-service/data/friday/assemblers/encounter.yaml
View file @
b502e1b3
...
...
@@ -13,6 +13,9 @@ primary:
table
:
appointment_encounter_rows
key
:
id
# 来源创建时间对应 appointment 记录;派生事实不把父记录时间解释为自身创建。
source_created_at
:
{
field
:
createdAt
}
field_mapping
:
externalId
:
id
# = 预约 id;EMR 侧 appointmentId 将据此回挂
patientExternalId
:
patient_appointment_id
...
...
apps/pac-service/data/friday/assemblers/image.yaml
View file @
b502e1b3
...
...
@@ -14,6 +14,9 @@ primary:
key
:
id
dedup_by
:
id
# 来源创建时间对应 image 记录;派生事实不把父记录时间解释为自身创建。
source_created_at
:
{
field
:
createdAt
}
field_mapping
:
externalId
:
id
updatedAt
:
updated_gmt_at
...
...
apps/pac-service/data/friday/assemblers/image_analysis.yaml
View file @
b502e1b3
...
...
@@ -8,6 +8,9 @@ emits:
primary
:
table
:
med_check_analysis_req_record
key
:
id
# 来源创建时间对应 image_analysis 记录;派生事实不把父记录时间解释为自身创建。
source_created_at
:
{
field
:
createdAt
}
field_mapping
:
externalId
:
id
patientExternalId
:
patient_id
...
...
apps/pac-service/data/friday/assemblers/payment.yaml
View file @
b502e1b3
...
...
@@ -14,6 +14,9 @@ primary:
key
:
uuid
dedup_by
:
uuid
# 来源创建时间对应 settlement 记录;派生事实不把父记录时间解释为自身创建。
source_created_at
:
{
field
:
createdAt
}
field_mapping
:
externalId
:
uuid
updatedAt
:
updated_gmt_at
...
...
apps/pac-service/data/friday/assemblers/recommendation.yaml
View file @
b502e1b3
...
...
@@ -13,6 +13,9 @@ primary:
key
:
rec_external_id
dedup_by
:
rec_external_id
# 来源创建时间对应 emr 记录;派生事实不把父记录时间解释为自身创建。
source_created_at
:
{
field
:
createdAt
}
field_mapping
:
externalId
:
rec_external_id
updatedAt
:
updated_gmt_at
...
...
apps/pac-service/data/friday/assemblers/refund_full.yaml
View file @
b502e1b3
...
...
@@ -17,6 +17,9 @@ primary:
key
:
uuid
dedup_by
:
uuid
# 来源创建时间对应 settlement 记录;派生事实不把父记录时间解释为自身创建。
source_created_at
:
{
field
:
createdAt
}
field_mapping
:
externalId
:
uuid
updatedAt
:
updated_gmt_at
...
...
apps/pac-service/data/friday/assemblers/refund_item.yaml
View file @
b502e1b3
...
...
@@ -15,6 +15,9 @@ primary:
key
:
id
dedup_by
:
id
# 来源创建时间对应 settlement_item 记录;派生事实不把父记录时间解释为自身创建。
source_created_at
:
{
field
:
createdAt
}
field_mapping
:
externalId
:
id
updatedAt
:
updated_gmt_at
...
...
apps/pac-service/data/friday/assemblers/treatment_actual.yaml
View file @
b502e1b3
...
...
@@ -16,6 +16,9 @@ primary:
key
:
treat_external_id
dedup_by
:
treat_external_id
# 来源创建时间对应 emr 记录;派生事实不把父记录时间解释为自身创建。
source_created_at
:
{
field
:
createdAt
}
field_mapping
:
externalId
:
treat_external_id
updatedAt
:
updated_gmt_at
...
...
apps/pac-service/data/friday/assemblers/treatment_planned.yaml
View file @
b502e1b3
...
...
@@ -14,6 +14,9 @@ primary:
key
:
id
dedup_by
:
id
# 来源创建时间对应 treatment_plan_item 记录;派生事实不把父记录时间解释为自身创建。
source_created_at
:
{
field
:
createdAt
}
field_mapping
:
externalId
:
id
updatedAt
:
updated_gmt_at
...
...
apps/pac-service/data/friday/assemblers/treatment_review.yaml
View file @
b502e1b3
...
...
@@ -16,6 +16,9 @@ primary:
key
:
treat_external_id
dedup_by
:
treat_external_id
# 来源创建时间对应 emr 记录;派生事实不把父记录时间解释为自身创建。
source_created_at
:
{
field
:
createdAt
}
field_mapping
:
externalId
:
treat_external_id
updatedAt
:
updated_gmt_at
...
...
apps/pac-service/data/jvs-dw/assemblers/appointment.yaml
View file @
b502e1b3
...
...
@@ -11,6 +11,9 @@ primary:
table
:
appointment_all_rows
key
:
id
# 来源创建时间对应 appointment 记录;派生事实不把父记录时间解释为自身创建。
source_created_at
:
{
field
:
createdAt
}
field_mapping
:
externalId
:
id
# W4 末 DW upsert:updatedAt 进 source_event_id 幂等键,host UPDATE → 新 transaction(否则永久 dedup 漏 update)
...
...
apps/pac-service/data/jvs-dw/assemblers/complaint.yaml
View file @
b502e1b3
...
...
@@ -9,6 +9,9 @@ primary:
table
:
complaint_rows
key
:
id
# group_rows 已按 [brand,id] 聚合,不再取首行丢弃跟进。
# 来源创建时间对应 complaint 记录;派生事实不把父记录时间解释为自身创建。
source_created_at
:
{
field
:
createdAt
}
field_mapping
:
externalId
:
id
patientExternalId
:
patient_id
...
...
apps/pac-service/data/jvs-dw/assemblers/diagnosis.yaml
View file @
b502e1b3
...
...
@@ -14,6 +14,9 @@ primary:
table
:
diagnosis_rows
key
:
diag_external_id
# 来源创建时间对应 emr 记录;派生事实不把父记录时间解释为自身创建。
source_created_at
:
{
field
:
createdAt
}
field_mapping
:
externalId
:
diag_external_id
# W4 末 DW upsert:updatedAt 进 source_event_id 幂等键,host UPDATE → 新 transaction(否则永久 dedup 漏 update)
...
...
apps/pac-service/data/jvs-dw/assemblers/emr.yaml
View file @
b502e1b3
...
...
@@ -15,6 +15,9 @@ primary:
key
:
id
dedup_by
:
[
brand
,
id
]
# 源组织 + 病历号;同一身份仍按原规则取首行
# 来源创建时间对应 emr 记录;派生事实不把父记录时间解释为自身创建。
source_created_at
:
{
field
:
createdAt
}
field_mapping
:
externalId
:
id
# W4 末 DW upsert:updatedAt 进 source_event_id 幂等键,host UPDATE → 新 transaction(否则永久 dedup 漏 update)
...
...
apps/pac-service/data/jvs-dw/assemblers/encounter.yaml
View file @
b502e1b3
...
...
@@ -11,6 +11,9 @@ primary:
table
:
appointment_encounter_rows
key
:
id
# 来源创建时间对应 appointment 记录;派生事实不把父记录时间解释为自身创建。
source_created_at
:
{
field
:
createdAt
}
field_mapping
:
externalId
:
id
# W4 末 DW upsert:updatedAt 进 source_event_id 幂等键,host UPDATE → 新 transaction(否则永久 dedup 漏 update)
...
...
apps/pac-service/data/jvs-dw/assemblers/image.yaml
View file @
b502e1b3
...
...
@@ -12,6 +12,9 @@ primary:
key
:
image_external_id
dedup_by
:
image_external_id
# 同一影像跨 emr 行重复兜底
# 来源创建时间对应 emr 记录;派生事实不把父记录时间解释为自身创建。
source_created_at
:
{
field
:
createdAt
}
field_mapping
:
externalId
:
image_external_id
# W4 末 DW upsert:updatedAt 进 source_event_id 幂等键,host UPDATE → 新 transaction(否则永久 dedup 漏 update)
...
...
apps/pac-service/data/jvs-dw/assemblers/payment.yaml
View file @
b502e1b3
...
...
@@ -11,6 +11,9 @@ primary:
table
:
payment_rows
key
:
id
# 来源创建时间对应 settlement 记录;派生事实不把父记录时间解释为自身创建。
source_created_at
:
{
field
:
createdAt
}
field_mapping
:
externalId
:
id
# W4 末 DW upsert:updatedAt 进 source_event_id 幂等键,host UPDATE → 新 transaction(否则永久 dedup 漏 update)
...
...
apps/pac-service/data/jvs-dw/assemblers/recommendation.yaml
View file @
b502e1b3
...
...
@@ -12,6 +12,9 @@ primary:
key
:
rec_external_id
dedup_by
:
rec_external_id
# 来源创建时间对应 emr 记录;派生事实不把父记录时间解释为自身创建。
source_created_at
:
{
field
:
createdAt
}
field_mapping
:
externalId
:
rec_external_id
# W4 末 DW upsert:updatedAt 进 source_event_id 幂等键,host UPDATE → 新 transaction(否则永久 dedup 漏 update)
...
...
apps/pac-service/data/jvs-dw/assemblers/refund.yaml
View file @
b502e1b3
...
...
@@ -23,6 +23,9 @@ primary:
key
:
id
dedup_by
:
id
# 同 settlement_id 不应重复(SQL 已去重)
# 来源创建时间对应 settlement 记录;派生事实不把父记录时间解释为自身创建。
source_created_at
:
{
field
:
createdAt
}
field_mapping
:
externalId
:
id
# W4 末 DW upsert:updatedAt 进 source_event_id 幂等键,host UPDATE → 新 transaction(否则永久 dedup 漏 update)
...
...
apps/pac-service/data/jvs-dw/assemblers/treatment_actual.yaml
View file @
b502e1b3
...
...
@@ -23,6 +23,9 @@ primary:
key
:
treat_external_id
dedup_by
:
treat_external_id
# 炸行兜底:同 emr × treatName × tooth_position 因影像 JOIN 重复
# 来源创建时间对应 emr 记录;派生事实不把父记录时间解释为自身创建。
source_created_at
:
{
field
:
createdAt
}
field_mapping
:
externalId
:
treat_external_id
# W4 末 DW upsert:updatedAt 进 source_event_id 幂等键,host UPDATE → 新 transaction(否则永久 dedup 漏 update)
...
...
apps/pac-service/data/jvs-dw/assemblers/treatment_planned.yaml
View file @
b502e1b3
...
...
@@ -22,6 +22,9 @@ primary:
key
:
treat_external_id
dedup_by
:
treat_external_id
# 炸行兜底:同 emr × treatName × tooth_position 因影像 JOIN 重复
# 来源创建时间对应 emr 记录;派生事实不把父记录时间解释为自身创建。
source_created_at
:
{
field
:
createdAt
}
field_mapping
:
externalId
:
treat_external_id
# W4 末 DW upsert:updatedAt 进 source_event_id 幂等键,host UPDATE → 新 transaction(否则永久 dedup 漏 update)
...
...
apps/pac-service/data/jvs-dw/assemblers/treatment_review.yaml
View file @
b502e1b3
...
...
@@ -20,6 +20,9 @@ primary:
key
:
treat_external_id
dedup_by
:
treat_external_id
# 来源创建时间对应 emr 记录;派生事实不把父记录时间解释为自身创建。
source_created_at
:
{
field
:
createdAt
}
field_mapping
:
externalId
:
treat_external_id
# W4 末 DW upsert:updatedAt 进 source_event_id 幂等键,host UPDATE → 新 transaction(否则永久 dedup 漏 update)
...
...
apps/pac-service/data/jvs-dw/manifest.yaml
View file @
b502e1b3
...
...
@@ -942,7 +942,7 @@ transforms:
-
kind
:
project
input
:
fact_settlement_out_refund
output
:
refund_rows
keep
:
[
id
,
organization_id
,
brand
,
patient_id
,
rq
,
billing_date
,
created_date
,
keep
:
[
id
,
organization_id
,
brand
,
patient_id
,
rq
,
billing_date
,
created_date
,
updated_date
,
settlement_type
,
doctor_id
,
settlement_project_name
,
settlement_money
,
registration_id
,
is_refund
,
settlement_status
]
...
...
apps/pac-service/prisma/migrations/20261021140000_fact_source_created_time/migration.sql
0 → 100644
View file @
b502e1b3
-- 仅新增可空元数据,不更新任何历史行,不改变业务时间或已有乱序水位。
ALTER
TABLE
"patient_facts"
ADD
COLUMN
"source_created_at"
TIMESTAMPTZ
(
3
);
apps/pac-service/prisma/schema.prisma
View file @
b502e1b3
...
...
@@ -576,7 +576,7 @@ model PatientTransaction {
///
宿主侧实际发生时间
(
adapter
按
platform
.
pullConfig
.
timezone
解析无时区字符串后转
UTC
)
。
///
跟
patient_facts
.
occurredAt
字段名一致
(
同一语义在事件账本和事实视图复用
)
occurredAt
DateTime
@
map
(
"occurred_at"
)
@
db
.
Timestamptz
(
3
)
///
PAC
接收时间
(
同步监控用
)
///
PAC
事件落库时间;当前与本表
createdAt
均由数据库默认
now
()
生成,未独立采样请求到达时刻。
receivedAt
DateTime
@
default
(
now
())
@
map
(
"received_at"
)
@
db
.
Timestamptz
(
3
)
///
关联
sync_logs
.
id
;
一次
run
把若干
transaction
一并落库
,
可反查
"哪批拉进来的"
syncLogId
String
?
@
map
(
"sync_log_id"
)
@
db
.
Uuid
...
...
@@ -724,10 +724,14 @@ model PatientFact {
///
actual
应填
—
事实在宿主侧实际发生时间
occurredAt
DateTime
?
@
map
(
"occurred_at"
)
@
db
.
Timestamptz
(
3
)
///
源记录末次修改时间
(=
canonicalRow
.
updatedAt
,
宿主侧口径
)
。乱序防护用
:
///
来源记录更新时间
(=
canonicalRow
.
updatedAt
,
宿主侧口径
)
,不是
PAC
入库时间。乱序防护用
:
///
现有少数映射使用聚合快照或业务日期代理值,须按来源解释,不据此推断诊断等动作时间。
///
携带更旧
updatedAt
的版本晚到
(
补推快照
vs
实时推送并发
)
→
fact
-
writer
按此裁决跳过
,
不旧覆新。
///
null
=
源没给
updatedAt
→
退回
"到达顺序"
语义
(
向后兼容
,
老数据
/
无该列的
host
不受影响
)
。
sourceUpdatedAt
DateTime
?
@
map
(
"source_updated_at"
)
@
db
.
Timestamptz
(
3
)
///
承载事实的来源记录创建时刻。仅新事实版本写入,不回填历史;取不到可靠时间则为空。
///
例如诊断继承
emr
、接诊继承
appointment
;不表示衍生条目首次出现或业务实际发生时间。
sourceCreatedAt
DateTime
?
@
map
(
"source_created_at"
)
@
db
.
Timestamptz
(
3
)
///
planned
推荐填
—
未来安排的目标时间
(
预约时间
/
复查时间
/
计划治疗时间
)
plannedFor
DateTime
?
@
map
(
"planned_for"
)
@
db
.
Timestamptz
(
3
)
///
planned
时间窗起
;
部分场景
(
医嘱
"1-2 周内复查"
)
需要区间
...
...
apps/pac-service/src/integrations/meyer/pac-sink.ts
View file @
b502e1b3
...
...
@@ -50,7 +50,12 @@ export class MeyerPacSink implements MeyerSink {
if
(
transaction
.
patientId
!==
patient
.
id
||
transaction
.
clinicId
!==
clinicId
||
previous
?.
_meyer
?.
canonicalHash
!==
canonicalHash
)
throw
new
Error
(
'existing supplemental snapshot differs; reconciliation is not supported'
);
const
metrics
=
await
this
.
pipeline
.
runForTransaction
({
transaction
:
{
...
transaction
,
action
:
item
.
action
},
canonicalRow
:
item
.
row
,
transaction
:
{
...
transaction
,
action
:
item
.
action
},
// 来源元数据仅交给事实写入,不改变既有 bundle/hash/原始事件幂等身份。
// 美亚只给影像创建时间,诊断的分析创建时间未知,⛔ 不借用影像创建时间。
canonicalRow
:
item
.
resource
===
'image_study'
?
{
...
item
.
row
,
sourceCreatedAt
:
image
.
uploadedAt
}
:
item
.
row
,
});
if
(
!
metrics
.
parserMatched
||
metrics
.
factsFailed
)
throw
new
Error
(
'PAC fact write failed; retry retained bundle'
);
}
...
...
apps/pac-service/src/modules/open/lookup-content.schema.ts
0 → 100644
View file @
b502e1b3
import
{
z
}
from
'zod'
;
// 新接口专属白名单;不复用入库 passthrough,内部增加字段不会自动扩大公开契约。
const
s
=
z
.
string
().
nullable
().
optional
();
const
n
=
z
.
number
().
nullable
().
optional
();
const
cents
=
z
.
number
().
int
().
nonnegative
().
nullable
().
optional
();
const
b
=
z
.
boolean
().
nullable
().
optional
();
const
a
=
z
.
array
(
z
.
string
()).
nullable
().
optional
();
const
identity
=
{
externalId
:
z
.
string
().
nullable
()
};
const
doctor
=
{
doctorExternalId
:
s
,
doctorName
:
s
};
const
emrRef
=
{
emrExternalId
:
z
.
string
().
nullable
(),
encounterExternalId
:
z
.
string
().
nullable
()
};
export
const
LookupClinicalSignalSchema
=
z
.
object
({
code
:
s
,
label
:
s
,
evidence
:
s
,
sourceField
:
s
,
negated
:
b
,
severity
:
s
,
treatmentDomains
:
z
.
array
(
z
.
string
()).
optional
(),
validUntil
:
s
,
reviewAfter
:
s
,
})
.
strict
();
export
const
LookupAnalysisSchema
=
z
.
object
({
source
:
z
.
string
().
min
(
1
),
summary
:
z
.
string
().
nullable
().
default
(
null
),
description
:
z
.
string
().
nullable
().
default
(
null
),
analyzedAt
:
z
.
string
().
nullable
().
default
(
null
),
findings
:
z
.
array
(
z
.
object
({
name
:
z
.
string
().
min
(
1
),
toothPosition
:
z
.
string
().
nullable
().
default
(
null
),
code
:
z
.
string
().
nullable
().
default
(
null
),
})
.
strict
(),
),
})
.
strict
();
export
const
LookupQualitySchema
=
z
.
object
({
sourceStatus
:
s
,
rejectionReason
:
s
}).
strict
();
export
const
LookupFollowUpSchema
=
z
.
object
({
occurredAt
:
s
,
action
:
s
,
sourceAction
:
s
,
description
:
s
,
actorExternalId
:
s
,
actorName
:
s
,
})
.
strict
();
export
const
narrativeNames
:
Record
<
string
,
string
>
=
{
illness_desc
:
'illnessDescription'
,
pre_illness
:
'presentIllness'
,
past_history
:
'pastHistory'
,
general_condition
:
'generalCondition'
,
exam_findings
:
'examFindings'
,
aux_exam_findings
:
'auxiliaryExamFindings'
,
diagnosis_text
:
'diagnosis'
,
treatment_plan
:
'treatmentPlan'
,
treatment_notes
:
'treatment'
,
disposal
:
'procedure'
,
doctor_advice
:
'doctorAdvice'
,
};
const
narrativeShape
=
Object
.
fromEntries
(
Object
.
values
(
narrativeNames
).
map
((
key
)
=>
[
key
,
z
.
string
().
nullable
()]),
);
export
const
LookupContentSchemas
=
{
consultation_record
:
z
.
object
({
...
identity
,
doctorExternalId
:
s
,
appointmentExternalId
:
z
.
string
().
nullable
(),
consultDate
:
s
,
intents
:
z
.
array
(
z
.
string
()).
optional
(),
intentCategories
:
z
.
array
(
z
.
string
()).
optional
(),
isFirstVisit
:
b
,
})
.
strict
(),
encounter_record
:
z
.
object
({
...
identity
,
...
doctor
,
chiefComplaint
:
s
,
encounterType
:
s
})
.
strict
(),
diagnosis_record
:
z
.
object
({
...
identity
,
...
doctor
,
...
emrRef
,
imageExternalId
:
z
.
string
().
nullable
(),
analysisExternalId
:
z
.
string
().
nullable
(),
code
:
s
,
codeSource
:
s
,
name
:
s
,
toothPosition
:
s
,
severity
:
s
,
onsetDate
:
s
,
clinicalSignals
:
z
.
array
(
LookupClinicalSignalSchema
).
optional
(),
})
.
strict
(),
treatment_record
:
z
.
object
({
...
identity
,
...
doctor
,
...
emrRef
,
category
:
s
,
subtype
:
s
,
toothPosition
:
s
,
status
:
s
,
startedAt
:
s
,
completedAt
:
s
,
quantity
:
n
,
unitName
:
s
,
stages
:
a
,
reviewWindowDays
:
n
,
})
.
strict
(),
recommendation_record
:
z
.
object
({
...
identity
,
...
doctor
,
...
emrRef
,
code
:
s
,
recommendation
:
s
,
toothPosition
:
s
,
extractionMethod
:
s
,
confidence
:
z
.
number
().
min
(
0
).
max
(
1
).
nullable
().
optional
(),
suggestedAt
:
s
,
windowDays
:
n
,
})
.
strict
(),
emr_record
:
z
.
object
({
...
identity
,
...
doctor
,
encounterExternalId
:
z
.
string
().
nullable
(),
...
narrativeShape
,
qualityCheck
:
LookupQualitySchema
.
nullable
().
optional
(),
clinicalSignals
:
z
.
array
(
LookupClinicalSignalSchema
).
optional
(),
})
.
strict
(),
image_record
:
z
.
object
({
...
identity
,
...
doctor
,
...
emrRef
,
modality
:
s
,
toothPositions
:
a
,
finding
:
s
,
capturedAt
:
s
,
fileLocation
:
s
,
fileName
:
s
,
fileType
:
s
,
analysis
:
LookupAnalysisSchema
.
nullable
(),
})
.
strict
(),
appointment_record
:
z
.
object
({
...
identity
,
...
doctor
,
scheduledAt
:
s
,
appointmentType
:
s
,
resourceName
:
s
,
visitReasonCategory
:
s
,
chiefComplaint
:
s
,
durationMinutes
:
n
,
cancellationReason
:
s
,
status
:
s
,
})
.
strict
(),
payment_record
:
z
.
object
({
...
identity
,
doctorExternalId
:
s
,
amountCents
:
cents
,
receivableCents
:
n
,
discountCents
:
n
,
channel
:
s
,
insuranceName
:
s
,
cardTypeName
:
s
,
cardName
:
s
,
settlementProject
:
s
,
encounterExternalId
:
z
.
string
().
nullable
(),
orderExternalId
:
z
.
string
().
nullable
(),
})
.
strict
(),
refund_record
:
z
.
object
({
...
identity
,
amountCents
:
cents
,
reason
:
s
,
paymentExternalId
:
z
.
string
().
nullable
(),
})
.
strict
(),
complaint_record
:
z
.
object
({
...
identity
,
number
:
s
,
severity
:
s
,
status
:
s
,
feedbackKind
:
s
,
category
:
s
,
channel
:
s
,
description
:
s
,
settlementNumber
:
z
.
string
().
nullable
(),
resolvedAt
:
s
,
sourceUpdatedAt
:
s
,
sourceStatus
:
z
.
string
().
nullable
(),
sourceCategory
:
s
,
sourceConcern
:
s
,
sourceChannel
:
s
,
followUps
:
z
.
array
(
LookupFollowUpSchema
).
optional
(),
})
.
strict
(),
// 保留已注册但尚无对外样例的类型;同样封闭投影,禁止自由 JSON 出口。
visit_registration_record
:
z
.
object
({
...
identity
,
doctorExternalId
:
s
,
registeredAt
:
s
})
.
strict
(),
visit_reception_record
:
z
.
object
({
...
identity
,
receptionType
:
s
,
receivedAt
:
s
}).
strict
(),
order_record
:
z
.
object
({
...
identity
,
encounterExternalId
:
z
.
string
().
nullable
(),
amountCents
:
cents
,
totalAmount
:
n
,
hostStatus
:
s
,
items
:
z
.
array
(
z
.
object
({
itemType
:
s
,
itemCode
:
s
,
itemName
:
s
,
quantity
:
n
,
unitPriceCents
:
n
,
amountCents
:
cents
,
amount
:
n
,
name
:
s
,
treatmentCategory
:
s
,
})
.
strict
(),
)
.
nullable
()
.
optional
(),
})
.
strict
(),
recharge_record
:
z
.
object
({
...
identity
,
amountCents
:
cents
,
amount
:
n
,
cardType
:
s
,
rechargeType
:
s
})
.
strict
(),
referral_record
:
z
.
object
({
...
identity
,
referrerExternalId
:
s
,
referrerName
:
s
,
referrerChartNumber
:
s
,
referrerType
:
s
,
channel
:
s
,
})
.
strict
(),
image_analysis_record
:
z
.
object
({
...
identity
,
imageExternalId
:
s
,
codeSource
:
s
,
status
:
s
,
analyzedAt
:
s
,
sourceUpdatedAt
:
s
,
analysisResult
:
s
,
findings
:
z
.
array
(
z
.
object
({
externalId
:
s
,
name
:
s
,
code
:
s
,
toothPosition
:
s
,
excluded
:
b
,
})
.
strict
(),
)
.
optional
(),
})
.
strict
(),
}
as
const
;
export
type
LookupContentType
=
keyof
typeof
LookupContentSchemas
;
apps/pac-service/src/modules/open/lookup-content.ts
View file @
b502e1b3
import
{
z
}
from
'zod'
;
/** 新增患者查询的显式字段映射;未知扩展内容原样保留,不递归猜测命名。 */
import
{
isDeepStrictEqual
}
from
'node:util'
;
import
{
LookupContentSchemas
,
LookupContentType
,
narrativeNames
,
LookupAnalysisSchema
,
LookupClinicalSignalSchema
,
LookupQualitySchema
,
LookupFollowUpSchema
,
}
from
'./lookup-content.schema'
;
import
{
isRecord
,
lookupNarrative
,
LookupContentError
}
from
'./lookup-narrative'
;
import
{
lookupRelations
}
from
'./lookup-relations'
;
import
{
redactJson
}
from
'./open-redact'
;
/** 新增查询专属字段投影;所有嵌套扩展也按白名单。 */
const
fields
:
Record
<
string
,
string
>
=
{
consultation_record
:
'consultation_external_id consult_date source_channel intent_categories is_first_visit task_director doctor_id appointment_external_id'
,
visit_registration_record
:
'registration_external_id visit_registration_external_id registered_at doctor_id appointment_external_id'
,
visit_reception_record
:
'reception_external_id visit_reception_external_id reception_type received_at receptionist_id'
,
consultation_record
:
'consultation_external_id consult_date source_channel intent_categories is_first_visit task_director doctor_id appointment_external_id'
,
visit_registration_record
:
'registration_external_id visit_registration_external_id registered_at doctor_id appointment_external_id'
,
visit_reception_record
:
'reception_external_id visit_reception_external_id reception_type received_at receptionist_id'
,
encounter_record
:
'encounter_external_id doctor_id doctor_name chief_complaint encounter_type'
,
diagnosis_record
:
'code_source name_zh tooth_position onset_date doctor_id doctor_name source_encounter_external_id source_image_external_id source_analysis_external_id clinical_signals'
,
treatment_record
:
'tooth_position started_at completed_at doctor_id doctor_name unit_name treat_stages source_encounter_external_id related_diagnosis_subject_id review_window_days'
,
recommendation_record
:
'source_encounter_external_id tooth_position doctor_id doctor_name extracted_by source_fact_subject_id doctor_suggested_at window_days'
,
emr_record
:
'emr_external_id encounter_external_id doctor_id doctor_name illness_desc pre_illness past_history general_condition exam_findings aux_exam_findings diagnosis_text treatment_plan treatment_notes doctor_advice quality_check clinical_signals'
,
image_record
:
'image_external_id encounter_external_id tooth_positions captured_at file_url file_name file_type doctor_id doctor_name'
,
image_analysis_record
:
'analysis_external_id image_external_id code_source analyzed_at source_updated_at analysis_result'
,
appointment_record
:
'appointment_external_id scheduled_at arrived_at appointment_type doctor_id doctor_name resource_name complaint_category complaint_text duration_minutes cancellation_reason'
,
diagnosis_record
:
'code_source name_zh tooth_position onset_date doctor_id doctor_name source_encounter_external_id source_image_external_id source_analysis_external_id clinical_signals'
,
treatment_record
:
'tooth_position started_at completed_at doctor_id doctor_name unit_name treat_stages source_encounter_external_id related_diagnosis_subject_id review_window_days'
,
recommendation_record
:
'source_encounter_external_id tooth_position doctor_id doctor_name extracted_by source_fact_subject_id doctor_suggested_at window_days'
,
emr_record
:
'emr_external_id encounter_external_id doctor_id doctor_name illness_desc pre_illness past_history general_condition exam_findings aux_exam_findings diagnosis_text treatment_plan treatment_notes doctor_advice quality_check clinical_signals'
,
image_record
:
'image_external_id encounter_external_id tooth_positions captured_at file_url file_name file_type doctor_id doctor_name'
,
image_analysis_record
:
'analysis_external_id image_external_id code_source analyzed_at source_updated_at analysis_result'
,
appointment_record
:
'appointment_external_id scheduled_at arrived_at appointment_type doctor_id doctor_name resource_name complaint_category complaint_text duration_minutes cancellation_reason'
,
order_record
:
'order_external_id encounter_external_id amount_cents total_amount host_status'
,
payment_record
:
'payment_external_id amount_cents receivable_cents discount_cents insurance_name card_type_name card_name settlement_project doctor_id encounter_external_id related_order_external_id'
,
payment_record
:
'payment_external_id amount_cents receivable_cents discount_cents insurance_name card_type_name card_name settlement_project doctor_id encounter_external_id related_order_external_id'
,
refund_record
:
'refund_external_id amount_cents related_payment_external_id'
,
recharge_record
:
'recharge_external_id amount_cents card_type recharge_type'
,
complaint_record
:
'complaint_external_id feedback_kind signal_eligible review_reason related_treatment_subject_id related_order_external_id resolved_at source_updated_at follow_ups host_status source_category source_concern source_channel'
,
referral_record
:
'referral_external_id referrer_external_id referrer_name referrer_chart_number referrer_type'
,
complaint_record
:
'complaint_external_id feedback_kind signal_eligible review_reason related_treatment_subject_id related_order_external_id resolved_at source_updated_at follow_ups host_status source_category source_concern source_channel'
,
referral_record
:
'referral_external_id referrer_external_id referrer_name referrer_chart_number referrer_type'
,
};
const
ownIds
:
Record
<
string
,
string
>
=
{
consultation_record
:
'consultation_external_id'
,
visit_registration_record
:
'registration_external_id'
,
visit_reception_record
:
'reception_external_id'
,
encounter_record
:
'encounter_external_id'
,
emr_record
:
'emr_external_id'
,
image_record
:
'image_external_id'
,
image_analysis_record
:
'analysis_external_id'
,
appointment_record
:
'appointment_external_id'
,
order_record
:
'order_external_id'
,
payment_record
:
'payment_external_id'
,
refund_record
:
'refund_external_id'
,
recharge_record
:
'recharge_external_id'
,
complaint_record
:
'complaint_external_id'
,
referral_record
:
'referral_external_id'
,
consultation_record
:
'consultation_external_id'
,
visit_registration_record
:
'registration_external_id'
,
visit_reception_record
:
'reception_external_id'
,
encounter_record
:
'encounter_external_id'
,
emr_record
:
'emr_external_id'
,
image_record
:
'image_external_id'
,
image_analysis_record
:
'analysis_external_id'
,
appointment_record
:
'appointment_external_id'
,
order_record
:
'order_external_id'
,
payment_record
:
'payment_external_id'
,
refund_record
:
'refund_external_id'
,
recharge_record
:
'recharge_external_id'
,
complaint_record
:
'complaint_external_id'
,
referral_record
:
'referral_external_id'
,
};
const
instantSchema
=
z
.
iso
.
datetime
({
offset
:
true
});
const
camel
=
(
key
:
string
)
=>
key
.
replace
(
/_
([
a-z
])
/g
,
(
_
,
ch
:
string
)
=>
ch
.
toUpperCase
());
export
const
lookupContentNames
:
Record
<
string
,
Record
<
string
,
string
>>
=
Object
.
fromEntries
(
Object
.
entries
(
fields
).
map
(([
type
,
names
])
=>
[
type
,
{
...
Object
.
fromEntries
(
names
.
split
(
' '
).
map
(
key
=>
[
key
,
camel
(
key
)])),
...(
ownIds
[
type
]
?
{
[
ownIds
[
type
]]:
'externalId'
}
:
{}),
...(
type
===
'diagnosis_record'
?
{
name_zh
:
'name'
}
:
{}),
...(
type
===
'recommendation_record'
?
{
extracted_by
:
'extractionMethod'
}
:
{}),
...([
'consultation_record'
,
'complaint_record'
].
includes
(
type
)
?
{
content
:
'text'
}
:
{}),
}]),
Object
.
entries
(
fields
).
map
(([
type
,
names
])
=>
[
type
,
{
...
Object
.
fromEntries
(
names
.
split
(
' '
).
map
((
key
)
=>
[
key
,
camel
(
key
)])),
doctor_id
:
'doctorExternalId'
,
...(
type
===
'emr_record'
?
narrativeNames
:
{}),
...(
type
===
'image_record'
?
{
file_url
:
'fileLocation'
}
:
{}),
...(
type
===
'treatment_record'
?
{
treat_stages
:
'stages'
}
:
{}),
...(
type
===
'appointment_record'
?
{
complaint_text
:
'chiefComplaint'
,
complaint_category
:
'visitReasonCategory'
}
:
{}),
...(
ownIds
[
type
]
?
{
[
ownIds
[
type
]]:
'externalId'
}
:
{}),
...(
type
===
'diagnosis_record'
?
{
name_zh
:
'name'
}
:
{}),
...(
type
===
'recommendation_record'
?
{
extracted_by
:
'extractionMethod'
,
name
:
'recommendation'
,
doctor_suggested_at
:
'suggestedAt'
,
}
:
{}),
...([
'consultation_record'
,
'complaint_record'
].
includes
(
type
)
?
{
content
:
'description'
}
:
{}),
},
]),
);
function
record
(
value
:
unknown
):
value
is
Record
<
string
,
unknown
>
{
return
!!
value
&&
typeof
value
===
'object'
&&
!
Array
.
isArray
(
value
);
}
/** 有异值别名时保留两份,⛔ 不静默覆盖来源数据。 */
function
rename
(
value
:
Record
<
string
,
unknown
>
,
names
:
Record
<
string
,
string
>
):
Record
<
string
,
unknown
>
{
const
out
=
{
...
value
};
for
(
const
[
from
,
to
]
of
Object
.
entries
(
names
))
{
if
(
!
(
from
in
value
)
||
from
===
to
)
continue
;
if
(
to
in
value
&&
JSON
.
stringify
(
value
[
to
])
!==
JSON
.
stringify
(
value
[
from
]))
continue
;
out
[
to
]
=
value
[
from
];
delete
out
[
from
];
/** 只读取已批准键,别名不同值明确失败;标识冲突交由关联层留空。 */
function
project
(
value
:
Record
<
string
,
unknown
>
,
shape
:
Record
<
string
,
unknown
>
,
names
:
Record
<
string
,
string
>
=
{},
)
{
const
out
:
Record
<
string
,
unknown
>
=
{};
for
(
const
target
of
Object
.
keys
(
shape
))
{
const
keys
=
[
target
,
...
Object
.
keys
(
names
).
filter
((
k
)
=>
names
[
k
]
===
target
&&
k
!==
target
)];
const
present
=
keys
.
filter
((
k
)
=>
value
[
k
]
!==
undefined
);
if
(
!
present
.
length
)
continue
;
const
first
=
value
[
present
[
0
]
!
];
if
(
present
.
some
((
k
)
=>
!
isDeepStrictEqual
(
value
[
k
],
first
)))
{
if
(
target
.
endsWith
(
'ExternalId'
)
||
target
===
'externalId'
)
{
out
[
target
]
=
null
;
continue
;
}
throw
new
LookupContentError
(
target
);
}
out
[
target
]
=
first
;
}
return
out
;
}
export
function
lookupContent
(
type
:
string
,
value
:
unknown
,
occurredAt
:
string
|
null
,
plannedFor
:
string
|
null
):
unknown
{
if
(
!
record
(
value
)
||
!
lookupContentNames
[
type
])
return
value
;
const
c
=
rename
(
value
,
lookupContentNames
[
type
]);
// 仅移除已由外层承接的同义时间。冲突、日期精度或未知时区原文仍保留。
function
nested
(
value
:
unknown
,
shape
:
Record
<
string
,
unknown
>
,
names
:
Record
<
string
,
string
>
=
{},
)
{
if
(
!
isRecord
(
value
))
throw
new
LookupContentError
(
'nested'
);
return
project
(
value
,
shape
,
names
);
}
export
function
lookupContent
(
type
:
string
,
value
:
unknown
,
occurredAt
:
string
|
null
,
plannedFor
:
string
|
null
,
host
=
''
,
evidence
?:
unknown
,
):
Record
<
string
,
unknown
>
{
if
(
!
Object
.
hasOwn
(
LookupContentSchemas
,
type
)
||
!
isRecord
(
value
))
throw
new
LookupContentError
(
'content'
);
const
schema
=
LookupContentSchemas
[
type
as
LookupContentType
];
const
c
=
project
(
value
,
schema
.
shape
,
lookupContentNames
[
type
]);
c
.
externalId
??
=
null
;
const
refs
=
lookupRelations
(
type
,
value
,
host
,
evidence
);
for
(
const
[
key
,
ref
]
of
Object
.
entries
(
refs
))
{
// 已存在公开别名也必须与证据一致;冲突不择一。
c
[
key
]
=
value
[
key
]
!==
undefined
&&
value
[
key
]
!==
ref
?
null
:
ref
;
}
if
(
type
===
'emr_record'
)
{
for
(
const
field
of
Object
.
values
(
narrativeNames
))
c
[
field
]
=
lookupNarrative
(
c
[
field
],
host
,
field
);
}
// 仅移除已经被外层完整承接的时间;精度不同或冲突仍显式保留。
const
sameTime
=
(
key
:
string
,
at
:
string
|
null
)
=>
{
if
(
!
(
key
in
c
))
return
;
const
v
=
c
[
key
];
if
(
v
==
null
||
(
at
&&
typeof
v
===
'string'
&&
/
(?:
Z|
[
+-
]\d{2}
:
\d{2})
$/
.
test
(
v
)
&&
instantSchema
.
safeParse
(
v
).
success
&&
Date
.
parse
(
v
)
===
Date
.
parse
(
at
)))
delete
c
[
key
];
if
(
v
==
null
||
(
at
&&
typeof
v
===
'string'
&&
instantSchema
.
safeParse
(
v
).
success
&&
Date
.
parse
(
v
)
===
Date
.
parse
(
at
))
)
delete
c
[
key
];
};
const
occurrenceKeys
:
Record
<
string
,
string
>
=
{
recommendation_record
:
'doctorSuggestedAt'
,
image_analysis_record
:
'analyzedAt'
,
visit_registration_record
:
'registeredAt'
,
visit_reception_record
:
'receivedAt'
,
image_record
:
'capturedAt'
,
const
keys
:
Record
<
string
,
string
>
=
{
recommendation_record
:
'suggestedAt'
,
image_analysis_record
:
'analyzedAt'
,
visit_registration_record
:
'registeredAt'
,
visit_reception_record
:
'receivedAt'
,
image_record
:
'capturedAt'
,
};
if
(
occurrenceKeys
[
type
])
sameTime
(
occurrenceKeys
[
type
],
occurredAt
);
if
(
type
===
'appointment_record'
)
{
sameTime
(
'scheduledAt'
,
plannedFor
);
// 实际到诊由 encounter_record 表达,预约只提供创建时间、约定时间及履约状态。
delete
c
.
arrivedAt
;
delete
c
.
arrived_at
;
}
if
(
keys
[
type
])
sameTime
(
keys
[
type
],
occurredAt
);
if
(
type
===
'appointment_record'
)
sameTime
(
'scheduledAt'
,
plannedFor
);
if
(
type
===
'consultation_record'
)
{
if
(
c
.
consultDate
==
null
||
(
occurredAt
&&
c
.
consultDate
===
occurredAt
.
slice
(
0
,
10
)))
delete
c
.
consultDate
;
if
(
c
.
intent
==
null
||
(
Array
.
isArray
(
c
.
intents
)
&&
c
.
intents
.
length
>
0
&&
c
.
intent
===
c
.
intents
.
join
(
'/'
)))
delete
c
.
intent
;
if
(
c
.
consultDate
==
null
||
(
occurredAt
&&
c
.
consultDate
===
occurredAt
.
slice
(
0
,
10
)))
delete
c
.
consultDate
;
const
intent
=
value
.
intent
;
if
(
intent
!=
null
&&
intent
!==
''
)
{
if
(
typeof
intent
!==
'string'
)
throw
new
LookupContentError
(
'intents'
);
if
(
c
.
intents
==
null
||
(
Array
.
isArray
(
c
.
intents
)
&&
!
c
.
intents
.
length
))
c
.
intents
=
[
intent
];
else
if
(
!
Array
.
isArray
(
c
.
intents
)
||
intent
!==
c
.
intents
.
join
(
'/'
))
throw
new
LookupContentError
(
'intents'
);
}
}
if
(
type
===
'image_record'
)
{
c
.
analysis
??
=
null
;
if
(
c
.
analysis
!==
null
)
{
const
analysis
=
nested
(
c
.
analysis
,
LookupAnalysisSchema
.
shape
);
if
(
Array
.
isArray
(
analysis
.
findings
))
analysis
.
findings
=
analysis
.
findings
.
map
((
v
)
=>
nested
(
v
,
LookupAnalysisSchema
.
shape
.
findings
.
element
.
shape
),
);
c
.
analysis
=
analysis
;
}
}
if
(
type
===
'complaint_record'
&&
Array
.
isArray
(
c
.
followUps
))
c
.
followUps
=
c
.
followUps
.
map
(
v
=>
record
(
v
)
?
rename
(
v
,
{
content
:
'text'
})
:
v
);
if
(
record
(
c
.
qualityCheck
))
c
.
qualityCheck
=
rename
(
c
.
qualityCheck
,
{
reject_reason
:
'rejectReason'
});
if
(
Array
.
isArray
(
c
.
clinicalSignals
))
c
.
clinicalSignals
=
c
.
clinicalSignals
.
map
(
v
=>
{
if
(
!
record
(
v
))
return
v
;
const
signal
=
rename
(
v
,
{
extractedBy
:
'extractorVersion'
});
const
target
=
typeof
signal
.
field
===
'string'
?
lookupContentNames
[
type
]?.[
signal
.
field
]
:
undefined
;
// 引用随已成功改名的字段更新;冲突时仍指向保留的原字段。
if
(
target
&&
!
(
String
(
signal
.
field
)
in
c
)
&&
target
in
c
)
signal
.
field
=
target
;
return
signal
;
});
if
(
type
===
'order_record'
&&
Array
.
isArray
(
c
.
items
))
c
.
items
=
c
.
items
.
map
(
v
=>
record
(
v
)
?
rename
(
v
,
{
item_type
:
'itemType'
,
item_code
:
'itemCode'
,
item_name
:
'itemName'
,
unit_price_cents
:
'unitPriceCents'
,
amount_cents
:
'amountCents'
,
treatment_category
:
'treatmentCategory'
,
})
:
v
);
return
c
;
if
(
c
.
qualityCheck
!=
null
)
{
const
q
=
nested
(
c
.
qualityCheck
,
LookupQualitySchema
.
shape
,
{
status
:
'sourceStatus'
,
reject_reason
:
'rejectionReason'
,
rejectReason
:
'rejectionReason'
,
});
q
.
rejectionReason
=
lookupNarrative
(
q
.
rejectionReason
,
''
,
'rejectionReason'
);
c
.
qualityCheck
=
q
;
}
if
(
Array
.
isArray
(
c
.
clinicalSignals
))
c
.
clinicalSignals
=
c
.
clinicalSignals
.
map
((
v
)
=>
{
const
signal
=
nested
(
v
,
LookupClinicalSignalSchema
.
shape
,
{
phrase
:
'evidence'
,
field
:
'sourceField'
,
contraindicates
:
'treatmentDomains'
,
});
const
field
=
typeof
signal
.
sourceField
===
'string'
?
signal
.
sourceField
:
''
;
const
target
=
lookupContentNames
[
type
]?.[
field
]
??
field
;
const
paragraphs
=
type
===
'emr_record'
?
Object
.
values
(
narrativeNames
)
:
[
'name'
];
signal
.
sourceField
=
paragraphs
.
includes
(
target
)
&&
typeof
c
[
target
]
===
'string'
&&
c
[
target
]
!==
''
?
target
:
null
;
return
signal
;
});
if
(
Array
.
isArray
(
c
.
followUps
))
c
.
followUps
=
c
.
followUps
.
map
((
v
)
=>
nested
(
v
,
LookupFollowUpSchema
.
shape
,
{
content
:
'description'
,
text
:
'description'
,
actorId
:
'actorExternalId'
,
}),
);
if
(
type
===
'order_record'
&&
Array
.
isArray
(
c
.
items
))
c
.
items
=
c
.
items
.
map
((
v
)
=>
nested
(
v
,
LookupContentSchemas
.
order_record
.
shape
.
items
.
unwrap
().
unwrap
().
element
.
shape
,
{
item_type
:
'itemType'
,
item_code
:
'itemCode'
,
item_name
:
'itemName'
,
unit_price_cents
:
'unitPriceCents'
,
amount_cents
:
'amountCents'
,
treatment_category
:
'treatmentCategory'
,
}),
);
if
(
type
===
'image_analysis_record'
&&
Array
.
isArray
(
c
.
findings
))
c
.
findings
=
c
.
findings
.
map
((
v
)
=>
nested
(
v
,
LookupContentSchemas
.
image_analysis_record
.
shape
.
findings
.
unwrap
().
element
.
shape
),
);
const
parsed
=
schema
.
safeParse
(
c
);
if
(
!
parsed
.
success
)
throw
new
LookupContentError
(
String
(
parsed
.
error
.
issues
[
0
]?.
path
[
0
]
??
'content'
));
// 标识是不透明键,不能把恰好 11 位数字的合法 ID 当手机号改写。
const
redacted
=
redactJson
(
parsed
.
data
)
as
Record
<
string
,
unknown
>
;
const
restoreIds
=
(
source
:
unknown
,
target
:
unknown
):
void
=>
{
if
(
Array
.
isArray
(
source
)
&&
Array
.
isArray
(
target
))
{
source
.
forEach
((
v
,
i
)
=>
restoreIds
(
v
,
target
[
i
]));
return
;
}
if
(
!
isRecord
(
source
)
||
!
isRecord
(
target
))
return
;
for
(
const
[
key
,
v
]
of
Object
.
entries
(
source
))
{
if
(
key
===
'externalId'
||
key
.
endsWith
(
'ExternalId'
))
target
[
key
]
=
v
;
else
restoreIds
(
v
,
target
[
key
]);
}
};
restoreIds
(
parsed
.
data
,
redacted
);
return
redacted
;
}
apps/pac-service/src/modules/open/lookup-fact.ts
View file @
b502e1b3
import
{
z
}
from
'zod'
;
import
type
{
OpenFactRow
}
from
'./open-fact'
;
import
{
redactJson
}
from
'./open-redact'
;
import
{
lookupContent
}
from
'./lookup-content'
;
import
{
LookupContentError
}
from
'./lookup-narrative'
;
import
{
LookupFactSchema
}
from
'./patient-facts-lookup.schema'
;
export
type
LookupFactRow
=
OpenFactRow
&
{
fact_id
:
string
;
host_name
:
string
;
appointment_created_values
?:
unknown
};
export
type
LookupFactRow
=
OpenFactRow
&
{
fact_id
:
string
;
host_name
:
string
;
appointment_created_values
?:
unknown
;
source_evidence
?:
unknown
;
};
/** 仅用于新增患者查询;不改变存储事实或原有开放接口的时间口径。 */
export
function
toLookupFact
(
row
:
LookupFactRow
,
clinicName
:
string
|
null
)
{
const
c
=
row
.
content
&&
typeof
row
.
content
===
'object'
&&
!
Array
.
isArray
(
row
.
content
)
?
row
.
content
as
Record
<
string
,
unknown
>
:
{};
const
c
=
row
.
content
&&
typeof
row
.
content
===
'object'
&&
!
Array
.
isArray
(
row
.
content
)
?
(
row
.
content
as
Record
<
string
,
unknown
>
)
:
{};
let
occurredAt
=
row
.
occurred_at
?.
toISOString
()
??
null
;
let
plannedFor
:
string
|
null
=
null
;
const
knownSource
=
row
.
host_name
===
'friday'
||
row
.
host_name
===
'jvs-dw'
;
...
...
@@ -17,24 +25,42 @@ export function toLookupFact(row: LookupFactRow, clinicName: string | null) {
occurredAt
=
appointmentCreatedAt
(
row
.
host_name
,
row
.
appointment_created_values
);
plannedFor
=
row
.
planned_for
?.
toISOString
()
??
explicitInstant
(
c
.
scheduled_at
);
}
else
if
(
row
.
type
===
'recommendation_record'
)
{
occurredAt
=
explicitInstant
(
c
.
doctor_suggested_at
)
??
occurredAt
??
(
knownSource
?
row
.
planned_for
?.
toISOString
()
??
null
:
null
);
occurredAt
=
explicitInstant
(
c
.
doctor_suggested_at
)
??
occurredAt
??
(
knownSource
?
(
row
.
planned_for
?.
toISOString
()
??
null
)
:
null
);
}
else
if
(
row
.
type
===
'treatment_record'
&&
row
.
kind
===
'planned'
)
{
// 两宿主现行映射均将制定时间写在 planned_for;没有明确执行日期。
occurredAt
=
occurredAt
??
(
knownSource
?
row
.
planned_for
?.
toISOString
()
??
null
:
null
);
occurredAt
=
occurredAt
??
(
knownSource
?
(
row
.
planned_for
?.
toISOString
()
??
null
)
:
null
);
}
// 内层明确时间可以补齐空外层,已有不同时间不覆盖,内层保留供辨识。
const
occurrenceKey
:
Record
<
string
,
string
>
=
{
image_analysis_record
:
'analyzed_at'
,
visit_registration_record
:
'registered_at'
,
visit_reception_record
:
'received_at'
,
image_record
:
'captured_at'
,
image_analysis_record
:
'analyzed_at'
,
visit_registration_record
:
'registered_at'
,
visit_reception_record
:
'received_at'
,
image_record
:
'captured_at'
,
};
const
key
=
occurrenceKey
[
row
.
type
];
if
(
!
occurredAt
&&
key
)
occurredAt
=
explicitInstant
(
c
[
key
]);
return
{
id
:
row
.
fact_id
,
type
:
row
.
type
,
kind
:
row
.
kind
,
clinicId
:
row
.
clinic_id
,
clinicName
,
occurredAt
,
plannedFor
,
content
:
lookupContent
(
row
.
type
,
redactJson
(
row
.
content
),
occurredAt
,
plannedFor
),
};
const
parsed
=
LookupFactSchema
.
safeParse
({
id
:
row
.
fact_id
,
type
:
row
.
type
,
kind
:
row
.
kind
,
clinicExternalId
:
row
.
clinic_id
,
clinicName
,
occurredAt
,
plannedFor
,
content
:
lookupContent
(
row
.
type
,
row
.
content
,
occurredAt
,
plannedFor
,
row
.
host_name
,
row
.
source_evidence
,
),
});
if
(
!
parsed
.
success
)
throw
new
LookupContentError
(
'fact'
);
return
parsed
.
data
;
}
/** 无时区历史文本不能按服务运行时区猜测;只接受明确时区的有效时刻。 */
...
...
apps/pac-service/src/modules/open/lookup-narrative.ts
0 → 100644
View file @
b502e1b3
import
{
InternalServerErrorException
}
from
'@nestjs/common'
;
/** 仅用于新增查询返回;错误信息不带病历正文或来源值。 */
export
class
LookupContentError
extends
InternalServerErrorException
{
constructor
(
readonly
field
:
string
)
{
super
({
code
:
'FACT_CONTENT_NORMALIZATION_FAILED'
,
message
:
'事实内容无法可靠转换,请联系服务方核查'
,
details
:
{
reason
:
'FACT_CONTENT_NORMALIZATION_FAILED'
},
});
}
}
export
function
isRecord
(
v
:
unknown
):
v
is
Record
<
string
,
unknown
>
{
return
v
!==
null
&&
typeof
v
===
'object'
&&
!
Array
.
isArray
(
v
);
}
const
empty
=
(
v
:
unknown
):
boolean
=>
v
==
null
||
(
typeof
v
===
'string'
&&
!
v
.
trim
())
||
(
Array
.
isArray
(
v
)
&&
v
.
length
===
0
)
||
(
isRecord
(
v
)
&&
Object
.
keys
(
v
).
length
===
0
);
const
editorKeys
=
new
Set
([
'englishVal'
,
'englishValTopRight'
,
'englishValBotRight'
,
'englishValBotLeft'
,
]);
/** 有限、可核实的正文形态;⛔ 不递归搜寻未知对象里的“像正文”的值。 */
export
function
lookupNarrative
(
value
:
unknown
,
host
:
string
,
field
:
string
):
string
|
null
{
const
fail
=
():
never
=>
{
throw
new
LookupContentError
(
field
);
};
let
v
=
value
;
if
(
typeof
v
===
'string'
)
{
const
s
=
v
.
trim
();
if
(
!
s
||
s
===
'null'
)
return
null
;
if
(
/^
[
{[
]
/
.
test
(
s
))
{
try
{
v
=
JSON
.
parse
(
s
);
}
catch
{
// [36] 叩痛是普通牙位文本;以对象/条目数组开头的破损结构不能冒充正文。
if
(
!
/^
\[[^\[\]
{}"
]
+
\]\s
*
\S
/
.
test
(
s
))
return
fail
();
return
value
as
string
;
}
}
else
return
v
;
}
if
(
empty
(
v
))
return
null
;
const
legacy
=
host
===
'friday'
&&
[
'examFindings'
,
'procedure'
,
'treatment'
].
includes
(
field
)
?
'value'
:
host
===
'jvs-dw'
&&
[
'treatment'
,
'treatmentPlan'
].
includes
(
field
)
?
'treatName'
:
null
;
const
render
=
(
item
:
unknown
):
string
|
null
=>
{
if
(
!
isRecord
(
item
))
return
fail
();
// 5i5ya 旧编辑器同时保存可读牙位、四象限选择和序列化备份。
// 四象限选择与正文牙位必须一致;备份包含未选中的旧牙面状态,不能据此重建牙位。
const
legacyEditor
=
host
===
'jvs-dw'
&&
[
'examFindings'
,
'procedure'
].
includes
(
field
);
if
(
legacyEditor
)
{
const
quadrants
=
[
'englishVal'
,
'englishValTopRight'
,
'englishValBotRight'
,
'englishValBotLeft'
,
];
const
selected
:
string
[]
=
[];
quadrants
.
forEach
((
key
,
i
)
=>
{
const
values
=
item
[
key
];
if
(
empty
(
values
))
return
;
if
(
!
Array
.
isArray
(
values
)
||
values
.
some
((
v
)
=>
typeof
v
!==
'string'
||
!
v
.
trim
()))
fail
();
for
(
const
v
of
values
as
string
[])
selected
.
push
(
`
${
i
+
1
}${
v
.
trim
()}
`
);
});
if
(
selected
.
length
)
{
if
(
typeof
item
.
toothPosition
!==
'string'
)
return
fail
();
const
teeth
=
item
.
toothPosition
.
split
(
';'
)
.
map
((
s
)
=>
s
.
trim
())
.
filter
(
Boolean
);
if
(
JSON
.
stringify
([...
new
Set
(
teeth
)].
sort
())
!==
JSON
.
stringify
([...
new
Set
(
selected
)].
sort
())
)
return
fail
();
}
const
backup
=
item
.
toothPositionBak
;
if
(
!
empty
(
backup
)
&&
backup
!==
'AAAAAA=='
)
{
if
(
typeof
backup
!==
'string'
||
!
/^
[
A-Za-z0-9+
/]
+=
{0,2}
$/
.
test
(
backup
))
return
fail
();
}
}
for
(
const
[
key
,
val
]
of
Object
.
entries
(
item
))
{
if
(
key
===
'message'
||
key
===
'toothPosition'
||
key
===
legacy
)
continue
;
if
(
legacyEditor
&&
(
editorKeys
.
has
(
key
)
||
key
===
'toothPositionBak'
))
continue
;
return
fail
();
}
const
text
=
(
x
:
unknown
):
string
|
null
=>
{
if
(
x
==
null
)
return
null
;
if
(
typeof
x
!==
'string'
)
return
fail
();
if
(
/^
\s
*
[
{[
]
/
.
test
(
x
)
&&
!
/^
\s
*
\[\d
+
\]\s
*
[^\s\]
,}
]
/
.
test
(
x
))
return
fail
();
return
x
.
trim
()
?
x
:
null
;
};
const
message
=
text
(
item
.
message
),
alias
=
legacy
?
text
(
item
[
legacy
])
:
null
;
if
(
message
!==
null
&&
alias
!==
null
&&
message
!==
alias
)
return
fail
();
const
body
=
message
??
alias
,
tooth
=
text
(
item
.
toothPosition
);
if
(
!
body
)
return
tooth
?
`牙位
${
tooth
}
(未提供正文)`
:
null
;
return
tooth
?
`
${
tooth
}
:
${
body
}
`
:
body
;
};
const
lines
=
(
Array
.
isArray
(
v
)
?
v
:
[
v
]).
map
(
render
).
filter
((
s
):
s
is
string
=>
s
!==
null
);
return
lines
.
length
?
lines
.
join
(
'
\
n'
)
:
null
;
}
apps/pac-service/src/modules/open/lookup-relations.ts
0 → 100644
View file @
b502e1b3
import
{
isRecord
}
from
'./lookup-narrative'
;
/** 仅包含本事实绑定、同宿主/租户/患者/诊所的流水证据摘要。 */
export
interface
LookupEvidence
{
sourceEventId
:
string
|
null
;
subjectType
:
string
;
subjectId
:
string
;
raw
:
Record
<
string
,
unknown
>
;
}
const
id
=
(
v
:
unknown
):
string
|
null
=>
(
typeof
v
===
'string'
&&
v
.
trim
()
?
v
:
null
);
function
route
(
host
:
string
,
e
:
LookupEvidence
):
string
|
null
{
const
event
=
e
.
sourceEventId
??
''
;
if
(
host
===
'friday'
&&
event
.
startsWith
(
'supplement:meyer:'
)
&&
e
.
raw
.
meyer
===
true
)
return
'meyer'
;
if
(
!
[
'friday'
,
'jvs-dw'
].
includes
(
host
))
return
null
;
for
(
const
prefix
of
[
'cold-import:'
,
`pull:
${
host
}
:`
,
`push:
${
host
}
:`
,
`reconcile:
${
host
}
:`
])
{
if
(
event
.
startsWith
(
prefix
))
return
event
.
slice
(
prefix
.
length
).
split
(
':'
)[
0
]
||
null
;
}
return
null
;
}
export
function
lookupRelations
(
type
:
string
,
c
:
Record
<
string
,
unknown
>
,
host
:
string
,
evidence
:
unknown
,
)
{
const
values
=
Array
.
isArray
(
evidence
)
?
evidence
:
[];
const
all
=
(
resolve
:
(
e
:
LookupEvidence
,
resource
:
string
)
=>
string
|
null
):
string
|
null
=>
{
if
(
!
values
.
length
)
return
null
;
const
result
=
new
Set
<
string
>
();
for
(
const
e
of
values
)
{
if
(
!
isRecord
(
e
)
||
!
isRecord
(
e
.
raw
)
||
typeof
e
.
subjectType
!==
'string'
||
typeof
e
.
subjectId
!==
'string'
)
return
null
;
const
r
=
route
(
host
,
e
as
unknown
as
LookupEvidence
);
const
value
=
r
&&
resolve
(
e
as
unknown
as
LookupEvidence
,
r
);
if
(
!
value
)
return
null
;
result
.
add
(
value
);
}
return
result
.
size
===
1
?
[...
result
][
0
]
!
:
null
;
};
const
equal
=
(
stored
:
unknown
,
...
sources
:
unknown
[]):
string
|
null
=>
{
const
value
=
id
(
stored
),
present
=
sources
.
filter
((
v
)
=>
v
!=
null
&&
v
!==
''
);
return
value
&&
present
.
length
&&
present
.
every
((
v
)
=>
id
(
v
)
===
value
)
?
value
:
null
;
};
const
clinical
=
[
'diagnosis_record'
,
'treatment_record'
,
'recommendation_record'
].
includes
(
type
);
const
result
:
Record
<
string
,
string
|
null
>
=
{};
if
(
clinical
||
type
===
'image_record'
)
{
result
.
emrExternalId
=
all
((
e
,
r
)
=>
{
if
(
r
===
'meyer'
||
r
===
'image_analysis'
)
return
null
;
if
(
type
===
'image_record'
)
{
if
(
host
!==
'jvs-dw'
||
r
!==
'image'
||
e
.
subjectType
!==
'image'
)
return
null
;
// 原始父病历行,或保留独立 emr_id 的拆行;绝不切割 image_external_id。
if
(
e
.
raw
.
emr_id
!=
null
)
return
id
(
e
.
raw
.
emr_id
);
return
e
.
raw
.
hasFileUrl
===
true
?
id
(
e
.
raw
.
id
)
:
null
;
}
const
expected
=
type
.
replace
(
'_record'
,
''
);
if
(
r
!==
expected
||
e
.
subjectType
!==
expected
)
return
null
;
if
(
host
===
'friday'
)
return
equal
(
c
.
source_encounter_external_id
,
e
.
raw
.
emr_sub_id
);
// 5i5ya 拆行保留父 EMR 原文;旧 image_finding 源行直接带 emr_id。
return
equal
(
c
.
source_encounter_external_id
,
e
.
raw
.
emr_id
??
e
.
raw
.
id
);
});
}
if
(
clinical
||
[
'emr_record'
,
'image_record'
,
'payment_record'
,
'order_record'
].
includes
(
type
))
{
result
.
encounterExternalId
=
type
===
'emr_record'
&&
host
===
'friday'
?
all
((
e
,
r
)
=>
r
===
'emr'
&&
e
.
subjectType
===
'emr'
&&
equal
(
c
.
emr_external_id
,
e
.
raw
.
emr_sub_id
)
?
equal
(
c
.
encounter_external_id
,
e
.
raw
.
appointment_id
)
:
null
,
)
:
null
;
}
if
(
type
===
'diagnosis_record'
)
{
result
.
imageExternalId
=
all
((
e
,
r
)
=>
{
if
(
r
===
'meyer'
&&
e
.
subjectType
===
'diagnosis'
)
return
equal
(
c
.
source_image_external_id
,
e
.
raw
.
sourceImageExternalId
);
if
(
host
===
'friday'
&&
r
===
'image_analysis'
&&
e
.
subjectType
===
'image_analysis'
)
return
equal
(
c
.
source_image_external_id
,
e
.
raw
.
check_id
);
return
null
;
});
result
.
analysisExternalId
=
all
((
e
,
r
)
=>
host
===
'friday'
&&
r
===
'image_analysis'
&&
e
.
subjectType
===
'image_analysis'
?
equal
(
c
.
source_analysis_external_id
,
e
.
raw
.
id
,
e
.
subjectId
)
:
null
,
);
}
if
(
type
===
'consultation_record'
)
result
.
appointmentExternalId
=
all
((
e
,
r
)
=>
host
===
'jvs-dw'
&&
r
===
'consult'
&&
e
.
subjectType
===
'consultation'
?
equal
(
c
.
appointment_external_id
,
e
.
raw
.
appo_id
)
:
null
,
);
if
(
type
===
'refund_record'
)
result
.
paymentExternalId
=
all
((
e
,
r
)
=>
host
===
'friday'
&&
r
===
'refund'
&&
e
.
subjectType
===
'refund'
?
equal
(
c
.
related_payment_external_id
,
e
.
raw
.
ref_settlement_id
??
e
.
raw
.
settlement_id
)
:
null
,
);
if
(
type
===
'payment_record'
)
result
.
orderExternalId
=
null
;
if
(
type
===
'complaint_record'
)
{
result
.
settlementNumber
=
all
((
e
,
r
)
=>
host
===
'jvs-dw'
&&
r
===
'complaint'
&&
e
.
subjectType
===
'complaint'
?
equal
(
c
.
related_order_external_id
,
e
.
raw
.
settlement_number
)
:
null
,
);
result
.
sourceStatus
=
all
((
e
,
r
)
=>
host
===
'jvs-dw'
&&
r
===
'complaint'
&&
e
.
subjectType
===
'complaint'
?
equal
(
c
.
host_status
,
e
.
raw
.
complain_type_name
)
:
null
,
);
}
return
result
;
}
apps/pac-service/src/modules/open/patient-facts-lookup.controller.ts
View file @
b502e1b3
...
...
@@ -25,7 +25,7 @@ export class PatientFactsLookupController {
description
:
[
'使用应用 Bearer 令牌,需开通宿主查询权限。病历号、姓名、手机号至少一项,多项精确匹配。'
,
'返回患者数组:`patient`(资料)、`persona`(画像)、`relations`(关系)、`facts`(全部当前有效事实)。无匹配返回空数组,不分页。'
,
'[
调用说明与示例](/docs/integration/patient-facts-lookup) · [事实 content 字段](/docs/integration/patient-fact-content
)'
,
'[
完整接口说明、字段与示例](/docs/api/open/PatientFactsLookupController_lookup
)'
,
].
join
(
'
\
n
\
n'
),
})
lookup
(@
Req
()
req
:
ApplicationRequest
,
@
Body
()
body
:
PatientFactsLookupRequestDto
)
{
...
...
apps/pac-service/src/modules/open/patient-facts-lookup.schema.ts
View file @
b502e1b3
import
{
z
}
from
'zod'
;
import
{
createZodDto
}
from
'nestjs-zod'
;
import
{
LookupContentSchemas
}
from
'./lookup-content.schema'
;
export
const
PatientFactsLookupSchema
=
z
.
strictObject
({
...
...
@@ -28,13 +29,149 @@ const PatientBasicSchema = z.object({
birthDate
:
z
.
string
().
nullable
().
describe
(
'YYYY-MM-DD 业务日期'
),
});
const
factBase
=
{
id
:
z
.
string
().
uuid
().
describe
(
'PAC 事实版本 ID;新版本会变化'
),
kind
:
z
.
enum
([
'actual'
,
'planned'
]).
describe
(
'actual 实际记录;planned 计划或建议'
),
clinicExternalId
:
z
.
string
().
nullable
().
describe
(
'宿主诊所标识'
),
clinicName
:
z
.
string
().
nullable
(),
occurredAt
:
z
.
string
()
.
nullable
()
.
describe
(
'业务发生时间;预约为宿主创建时间,未知 null;UTC ISO 8601'
),
plannedFor
:
z
.
string
()
.
nullable
()
.
describe
(
'明确约定的执行时间;目前为预约约定到诊时间,其他为 null'
),
};
export
const
LookupFactSchema
=
z
.
discriminatedUnion
(
'type'
,
[
z
.
object
({
...
factBase
,
type
:
z
.
literal
(
'consultation_record'
),
content
:
LookupContentSchemas
.
consultation_record
,
})
.
strict
(),
z
.
object
({
...
factBase
,
type
:
z
.
literal
(
'encounter_record'
),
content
:
LookupContentSchemas
.
encounter_record
,
})
.
strict
(),
z
.
object
({
...
factBase
,
type
:
z
.
literal
(
'diagnosis_record'
),
content
:
LookupContentSchemas
.
diagnosis_record
,
})
.
strict
(),
z
.
object
({
...
factBase
,
type
:
z
.
literal
(
'treatment_record'
),
content
:
LookupContentSchemas
.
treatment_record
,
})
.
strict
(),
z
.
object
({
...
factBase
,
type
:
z
.
literal
(
'recommendation_record'
),
content
:
LookupContentSchemas
.
recommendation_record
,
})
.
strict
(),
z
.
object
({
...
factBase
,
type
:
z
.
literal
(
'emr_record'
),
content
:
LookupContentSchemas
.
emr_record
,
})
.
strict
(),
z
.
object
({
...
factBase
,
type
:
z
.
literal
(
'image_record'
),
content
:
LookupContentSchemas
.
image_record
,
})
.
strict
(),
z
.
object
({
...
factBase
,
type
:
z
.
literal
(
'appointment_record'
),
content
:
LookupContentSchemas
.
appointment_record
,
})
.
strict
(),
z
.
object
({
...
factBase
,
type
:
z
.
literal
(
'payment_record'
),
content
:
LookupContentSchemas
.
payment_record
,
})
.
strict
(),
z
.
object
({
...
factBase
,
type
:
z
.
literal
(
'refund_record'
),
content
:
LookupContentSchemas
.
refund_record
,
})
.
strict
(),
z
.
object
({
...
factBase
,
type
:
z
.
literal
(
'complaint_record'
),
content
:
LookupContentSchemas
.
complaint_record
,
})
.
strict
(),
z
.
object
({
...
factBase
,
type
:
z
.
literal
(
'visit_registration_record'
),
content
:
LookupContentSchemas
.
visit_registration_record
,
})
.
strict
(),
z
.
object
({
...
factBase
,
type
:
z
.
literal
(
'visit_reception_record'
),
content
:
LookupContentSchemas
.
visit_reception_record
,
})
.
strict
(),
z
.
object
({
...
factBase
,
type
:
z
.
literal
(
'order_record'
),
content
:
LookupContentSchemas
.
order_record
,
})
.
strict
(),
z
.
object
({
...
factBase
,
type
:
z
.
literal
(
'recharge_record'
),
content
:
LookupContentSchemas
.
recharge_record
,
})
.
strict
(),
z
.
object
({
...
factBase
,
type
:
z
.
literal
(
'referral_record'
),
content
:
LookupContentSchemas
.
referral_record
,
})
.
strict
(),
z
.
object
({
...
factBase
,
type
:
z
.
literal
(
'image_analysis_record'
),
content
:
LookupContentSchemas
.
image_analysis_record
,
})
.
strict
(),
]);
export
const
PatientFactsLookupResponseSchema
=
z
.
object
({
items
:
z
.
array
(
z
.
object
({
patient
:
PatientBasicSchema
.
extend
({
dedicatedCs
:
z
.
object
({
id
:
z
.
string
().
nullable
().
describe
(
'宿主侧专属客服标识,不是 PAC 用户 ID
'
),
externalId
:
z
.
string
().
nullable
().
describe
(
'宿主侧专属客服标识
'
),
name
:
z
.
string
().
nullable
().
describe
(
'专属客服姓名'
),
})
.
nullable
()
...
...
@@ -67,20 +204,7 @@ export const PatientFactsLookupResponseSchema = z.object({
),
}),
),
facts
:
z
.
array
(
z
.
object
({
id
:
z
.
string
().
uuid
().
describe
(
'PAC 事实版本 ID;同一业务事实产生新版本时 ID 会变化'
),
type
:
z
.
string
()
.
describe
(
'决定 content 结构的 PAC 事实类型;详见事实内容参考,允许未来新增类型'
),
kind
:
z
.
string
().
describe
(
'actual=实际记录;planned=计划或建议'
),
clinicId
:
z
.
string
().
nullable
(),
clinicName
:
z
.
string
().
nullable
(),
occurredAt
:
z
.
string
().
nullable
().
describe
(
'业务发生时间;建议/治疗计划为提出或制定时间,预约为宿主创建时间,取不到留 null;实际到诊由 encounter_record 表达。UTC ISO 8601,未知 null;其他类型沿用已存业务时间,不保证等于源记录创建时间'
),
plannedFor
:
z
.
string
().
nullable
().
describe
(
'明确约定的执行时间;当前仅预约返回约定时间,建议和治疗计划无明确执行日期时为 null。UTC ISO 8601'
),
content
:
z
.
unknown
().
describe
(
'按 type 定义的脱敏内容;已知字段统一 camelCase、本记录源标识为 externalId、完整承接的重复时间省略。歧义关联/金额不合并,未知扩展及冲突原值保留;详见 content 字段参考'
),
}),
),
facts
:
z
.
array
(
LookupFactSchema
),
}),
),
});
...
...
apps/pac-service/src/modules/open/patient-facts-lookup.service.ts
View file @
b502e1b3
...
...
@@ -3,6 +3,7 @@ import { Prisma } from '@prisma/client';
import
{
PrismaService
}
from
'../../prisma/prisma.service'
;
import
{
currentFactHeadSql
}
from
'../facts/current-fact'
;
import
type
{
OpenFactRow
}
from
'./open-fact'
;
import
{
LookupContentError
}
from
'./lookup-narrative'
;
import
{
toLookupFact
}
from
'./lookup-fact'
;
import
{
redactText
}
from
'./open-redact'
;
import
{
toOpenPersona
}
from
'./open-persona'
;
...
...
@@ -18,6 +19,7 @@ type LookupRow = OpenFactRow & {
host_name
:
string
;
fact_id
:
string
|
null
;
appointment_created_values
?:
unknown
;
source_evidence
?:
unknown
;
};
type
LookupItem
=
PatientFactsLookupItem
;
...
...
@@ -55,7 +57,9 @@ function basicPatient(p: {
}
/** 只投影专属客服的稳定字段,避免把 preferences 中的其他内部属性透出。 */
function
dedicatedCsOf
(
preferences
:
unknown
):
{
id
:
string
|
null
;
name
:
string
|
null
}
|
null
{
function
dedicatedCsOf
(
preferences
:
unknown
,
):
{
externalId
:
string
|
null
;
name
:
string
|
null
}
|
null
{
if
(
!
preferences
||
typeof
preferences
!==
'object'
||
Array
.
isArray
(
preferences
))
return
null
;
const
value
=
(
preferences
as
Record
<
string
,
unknown
>
).
dedicatedCs
;
if
(
!
value
||
typeof
value
!==
'object'
||
Array
.
isArray
(
value
))
return
null
;
...
...
@@ -63,7 +67,7 @@ function dedicatedCsOf(preferences: unknown): { id: string | null; name: string
const
rawId
=
typeof
cs
.
id
===
'string'
?
cs
.
id
.
trim
()
:
''
;
const
id
=
rawId
&&
rawId
!==
'0'
?
rawId
:
null
;
const
name
=
typeof
cs
.
name
===
'string'
?
redactText
(
cs
.
name
.
trim
())
||
null
:
null
;
return
id
||
name
?
{
id
,
name
}
:
null
;
return
id
||
name
?
{
externalId
:
id
,
name
}
:
null
;
}
@
Injectable
()
...
...
@@ -96,7 +100,25 @@ export class PatientFactsLookupService {
AND t.subject_type = 'appointment'
AND t.subject_id = f.content ->> 'appointment_external_id'
AND (f.clinic_id IS NULL OR t.clinic_id = f.clinic_id)
) ELSE NULL END AS appointment_created_values
) ELSE NULL END AS appointment_created_values,
(SELECT CASE WHEN count(*) = cardinality(f.transaction_ids) THEN jsonb_agg(jsonb_build_object(
'sourceEventId', t.source_event_id, 'subjectType', t.subject_type, 'subjectId', t.subject_id,
'raw', jsonb_build_object(
'id', t.raw_payload->'id', 'emr_sub_id', t.raw_payload->'emr_sub_id',
'emr_id', t.raw_payload->'emr_id', 'appointment_id', t.raw_payload->'appointment_id',
'appo_id', t.raw_payload->'appo_id', 'check_id', t.raw_payload->'check_id',
'ref_settlement_id', t.raw_payload->'ref_settlement_id', 'settlement_id', t.raw_payload->'settlement_id',
'settlement_number', t.raw_payload->'settlement_number',
'complain_type_name', t.raw_payload->'complain_type_name',
'sourceImageExternalId', t.raw_payload->'sourceImageExternalId',
'meyer', jsonb_typeof(t.raw_payload->'_meyer') = 'object',
'hasFileUrl', t.raw_payload ? 'file_url'))) ELSE NULL END
FROM patient_transactions t
WHERE t.id = ANY(f.transaction_ids)
AND t.host_id = f.host_id AND t.tenant_id = f.tenant_id
AND t.patient_id = f.patient_id
AND t.clinic_id IS NOT DISTINCT FROM f.clinic_id
) AS source_evidence
FROM patients p
JOIN hosts h ON h.id = p.host_id
LEFT JOIN patient_facts f ON f.patient_id = p.id
...
...
@@ -190,18 +212,35 @@ export class PatientFactsLookupService {
const
item
=
items
.
get
(
row
.
patient_id
);
if
(
!
item
)
continue
;
if
(
row
.
fact_id
!==
null
)
item
.
facts
.
push
(
toLookupFact
(
{
...
row
,
fact_id
:
row
.
fact_id
},
row
.
clinic_id
?
(
application
.
clinicNames
[
row
.
clinic_id
]
??
null
)
:
null
,
));
try
{
item
.
facts
.
push
(
toLookupFact
(
{
...
row
,
fact_id
:
row
.
fact_id
},
row
.
clinic_id
?
(
application
.
clinicNames
[
row
.
clinic_id
]
??
null
)
:
null
,
),
);
}
catch
(
error
)
{
if
(
error
instanceof
LookupContentError
)
this
.
logger
.
error
(
JSON
.
stringify
({
event
:
'open_fact_content_normalization_failed'
,
factId
:
row
.
fact_id
,
field
:
error
.
field
,
}),
);
throw
error
;
}
}
// 按实际返回的时间排序,避免预约旧兜底值改变对外时间轴。
for
(
const
item
of
items
.
values
())
item
.
facts
.
sort
((
a
,
b
)
=>
{
const
at
=
a
.
occurredAt
??
a
.
plannedFor
;
const
bt
=
b
.
occurredAt
??
b
.
plannedFor
;
return
(
bt
?
Date
.
parse
(
bt
)
:
-
Infinity
)
-
(
at
?
Date
.
parse
(
at
)
:
-
Infinity
)
||
b
.
id
.
localeCompare
(
a
.
id
);
});
for
(
const
item
of
items
.
values
())
item
.
facts
.
sort
((
a
,
b
)
=>
{
const
at
=
a
.
occurredAt
??
a
.
plannedFor
;
const
bt
=
b
.
occurredAt
??
b
.
plannedFor
;
return
(
(
bt
?
Date
.
parse
(
bt
)
:
-
Infinity
)
-
(
at
?
Date
.
parse
(
at
)
:
-
Infinity
)
||
b
.
id
.
localeCompare
(
a
.
id
)
);
});
return
items
;
},
{
isolationLevel
:
Prisma
.
TransactionIsolationLevel
.
RepeatableRead
,
timeout
:
15000
},
...
...
apps/pac-service/src/modules/sync/assembler/assembler-engine.ts
View file @
b502e1b3
No preview for this file type
apps/pac-service/src/modules/sync/assembler/assembler.schema.ts
View file @
b502e1b3
...
...
@@ -111,6 +111,11 @@ export const AssemblerConfigSchema = z.object({
/// 主表字段 → canonical 字段映射(canonical key ← host field)
field_mapping
:
fieldMapping
,
/// 经核实的来源记录创建字段(canonical 名);不配置时不猜测 createdAt 的语义。
source_created_at
:
z
.
object
({
field
:
z
.
string
().
min
(
1
),
}).
optional
(),
/// 显式兼容无正文的病历数组元素;仅白名单元数据为空时补 message:null。
empty_narrative_entries
:
z
.
record
(
z
.
string
(),
z
.
object
({
empty_metadata_keys
:
z
.
array
(
z
.
string
().
min
(
1
)),
...
...
apps/pac-service/src/modules/sync/assembler/field-mapper.ts
View file @
b502e1b3
...
...
@@ -3,6 +3,7 @@ import {
CanonicalResourceKey
,
CanonicalResourceMeta
,
CanonicalResourceSchemas
,
CanonicalDateTimeSchema
,
}
from
'@pac/types'
;
/**
...
...
@@ -184,7 +185,9 @@ export function normalizeCanonical(
// birthDate/taskDate 等纯日期仍只按各自契约处理,不按字段后缀猜测时间类型。
for
(
const
k
of
new
Set
([
'createdAt'
,
'updatedAt'
,
...
meta
.
datetimeFields
]))
{
if
(
k
in
out
&&
typeof
out
[
k
]
===
'string'
)
{
out
[
k
]
=
normalizeDatetime
(
out
[
k
]
as
string
,
ctx
.
timezone
);
// CSV 的 null 占位不是时间;必填业务字段仍由后续 schema 拒收。
out
[
k
]
=
(
out
[
k
]
as
string
).
trim
().
toLowerCase
()
===
'null'
?
null
:
normalizeDatetime
(
out
[
k
]
as
string
,
ctx
.
timezone
);
}
}
...
...
@@ -198,6 +201,15 @@ export function normalizeCanonical(
?
{
...
row
,
[
field
]:
normalizeDatetime
(
row
[
field
]
as
string
,
ctx
.
timezone
)
}
:
item
;
});
}
for
(
const
{
object
,
field
}
of
meta
.
datetimeObjectFields
??
[])
{
const
value
=
out
[
object
];
if
(
value
&&
typeof
value
===
'object'
&&
!
Array
.
isArray
(
value
))
{
const
row
=
value
as
Record
<
string
,
unknown
>
;
if
(
typeof
row
[
field
]
===
'string'
)
{
out
[
object
]
=
{
...
row
,
[
field
]:
normalizeDatetime
(
row
[
field
]
as
string
,
ctx
.
timezone
)
};
}
}
}
return
out
;
}
...
...
@@ -296,7 +308,7 @@ export function normalizeDatetime(s: string, timezone: string): string {
const
trimmed
=
s
.
trim
();
// 已有 tz
if
(
/
[
Zz
]
$/
.
test
(
trimmed
)
||
/
[
+-
]\d{2}
:
?\d{2}
$/
.
test
(
trimmed
))
{
return
trimmed
.
replace
(
' '
,
'T'
);
return
trimmed
.
replace
(
' '
,
'T'
)
.
replace
(
/z$/
,
'Z'
).
replace
(
/
([
+-
]\d{2})(\d{2})
$/
,
'$1:$2'
)
;
}
// 仅日期 → 当地零点(见上方注释:直通会被 JS 当成 UTC 零点)
if
(
/^
\d{4}
-
\d{2}
-
\d{2}
$/
.
test
(
trimmed
))
{
...
...
@@ -311,9 +323,8 @@ export function normalizeDatetime(s: string, timezone: string): string {
}
/**
* IANA 时区 → 当前时刻的 UTC offset suffix(如 '+08:00')。
* 简化版:对常用东亚时区直接映射;真要严谨可用 Intl.DateTimeFormat 推导,但
* v1 不处理 DST(中国大陆不实行;若接欧美 host 再迭代)。
* 当前宿主约定使用固定偏移。未支持的时区须显式报错,不能静默当作 UTC。
* 接入需要夏令时的宿主时,应按源日期解析偏移,再扩展此处。
*/
export
function
timezoneToOffsetSuffix
(
tz
:
string
):
string
{
const
fixed
:
Record
<
string
,
string
>
=
{
...
...
@@ -326,9 +337,18 @@ export function timezoneToOffsetSuffix(tz: string): string {
UTC
:
'Z'
,
'Etc/UTC'
:
'Z'
,
};
return
fixed
[
tz
]
??
'+00:00'
;
const
offset
=
fixed
[
tz
];
if
(
!
offset
)
throw
new
Error
(
`Unsupported host timezone:
${
tz
}
`
);
return
offset
;
}
// passthrough 字段也参与事件幂等/乱序裁决,不能绕过时间契约。
const
optionalCanonicalTime
=
z
.
union
([
CanonicalDateTimeSchema
,
z
.
date
(),
z
.
literal
(
''
)]).
nullish
();
const
commonTimesSchema
=
z
.
object
({
createdAt
:
optionalCanonicalTime
,
updatedAt
:
optionalCanonicalTime
,
});
// ─────────────────────────────────────────────────────────────
// Stage ③:zod 校验 + 漂移抛错
// ─────────────────────────────────────────────────────────────
...
...
@@ -340,6 +360,7 @@ export function validateCanonical<T>(opts: {
resource
:
string
;
}):
T
{
try
{
commonTimesSchema
.
parse
(
opts
.
raw
);
return
opts
.
schema
.
parse
(
opts
.
raw
);
}
catch
(
err
)
{
if
(
err
instanceof
ZodError
)
{
...
...
apps/pac-service/src/modules/sync/canonical-time.ts
0 → 100644
View file @
b502e1b3
import
{
CanonicalDateTimeSchema
}
from
'@pac/types'
;
/** canonical 已完成宿主时区归一;⛔ 不在下游按进程时区猜测裸时间。 */
export
function
readCanonicalTime
(
value
:
unknown
,
field
:
string
):
Date
|
null
{
if
(
value
==
null
||
value
===
''
)
return
null
;
if
(
value
instanceof
Date
&&
Number
.
isFinite
(
value
.
getTime
()))
return
value
;
const
parsed
=
CanonicalDateTimeSchema
.
safeParse
(
value
);
if
(
parsed
.
success
)
return
new
Date
(
parsed
.
data
);
// 不带源值,避免诊疗原文进入错误日志;错误须由现有逐行拒收机制处理。
throw
new
Error
(
`Invalid canonical time at
${
field
}
: expected a valid datetime with timezone`
);
}
apps/pac-service/src/modules/sync/pipeline/fact-writer.service.ts
View file @
b502e1b3
import
{
readSourceCreatedTime
,
type
SourceCreatedTime
}
from
'../source-created-time'
;
import
{
Injectable
,
Logger
}
from
'@nestjs/common'
;
import
{
createHash
}
from
'node:crypto'
;
import
{
FactStatus
,
type
FactType
,
type
FactKind
}
from
'@pac/types'
;
...
...
@@ -182,7 +183,7 @@ export class FactWriter {
},
{
maxWait
:
30
_000
,
timeout
:
120
_000
});
}
async
writeDraft
(
input
:
{
async
writeDraft
(
input
:
Partial
<
SourceCreatedTime
>
&
{
draft
:
FactDraft
;
hostId
:
string
;
tenantId
:
string
;
...
...
@@ -322,6 +323,7 @@ export class FactWriter {
clinicId
:
validatedDraft
.
clinicId
??
null
,
occurredAt
:
validatedDraft
.
occurredAt
??
null
,
sourceUpdatedAt
:
sourceUpdatedAt
??
null
,
...
readSourceCreatedTime
({
...
input
}),
plannedFor
:
validatedDraft
.
plannedFor
??
null
,
validFrom
:
validatedDraft
.
validFrom
??
null
,
validUntil
:
validatedDraft
.
validUntil
??
null
,
...
...
@@ -558,6 +560,7 @@ export class FactWriter {
clinicId
:
entry
.
draft
.
clinicId
??
null
,
occurredAt
:
entry
.
draft
.
occurredAt
??
null
,
sourceUpdatedAt
:
entry
.
sourceUpdatedAt
??
null
,
...
readSourceCreatedTime
({
...
entry
}),
plannedFor
:
entry
.
draft
.
plannedFor
??
null
,
validFrom
:
entry
.
draft
.
validFrom
??
null
,
validUntil
:
entry
.
draft
.
validUntil
??
null
,
...
...
@@ -752,7 +755,7 @@ export interface BulkWriteOutcome {
}
/// FactWriter.bulkWrite 入参条目 — 一个 draft + 它所属的 patient / host / tenant / tx 上下文
export
interface
BulkEntry
{
export
interface
BulkEntry
extends
Partial
<
SourceCreatedTime
>
{
draft
:
FactDraft
;
hostId
:
string
;
tenantId
:
string
;
...
...
apps/pac-service/src/modules/sync/pipeline/parser-pipeline.service.ts
View file @
b502e1b3
import
{
readSourceCreatedTime
}
from
'../source-created-time'
;
import
{
readCanonicalTime
}
from
'../canonical-time'
;
import
{
Injectable
,
Logger
}
from
'@nestjs/common'
;
import
type
{
Action
}
from
'@pac/types'
;
import
{
PrismaService
}
from
'../../../prisma/prisma.service'
;
...
...
@@ -112,7 +114,7 @@ export class ParserPipeline {
try
{
const
entries
=
drafts
.
map
(
draft
=>
({
draft
,
hostId
:
transaction
.
hostId
,
tenantId
:
transaction
.
tenantId
,
sourceUnit
:
patientSourceUnit
,
patientId
:
transaction
.
patientId
!
,
transactionId
:
transaction
.
id
,
sourceUpdatedAt
}));
patientId
:
transaction
.
patientId
!
,
transactionId
:
transaction
.
id
,
sourceUpdatedAt
,
...
readSourceCreatedTime
(
canonicalRow
)
}));
applyOutcome
(
metrics
,
await
this
.
writer
.
writeSnapshots
([{
...
entries
[
0
]
!
,
scope
,
entries
}]));
}
catch
(
err
)
{
metrics
.
factsFailed
++
;
...
...
@@ -131,6 +133,7 @@ export class ParserPipeline {
patientId
:
transaction
.
patientId
,
transactionId
:
transaction
.
id
,
sourceUpdatedAt
,
...
readSourceCreatedTime
(
canonicalRow
),
});
metrics
.
writes
.
push
(
result
);
switch
(
result
.
action
)
{
...
...
@@ -224,6 +227,7 @@ export class ParserPipeline {
patientId
:
item
.
transaction
.
patientId
,
transactionId
:
item
.
transaction
.
id
,
sourceUpdatedAt
,
...
readSourceCreatedTime
(
item
.
canonicalRow
),
});
}
const
scope
=
parser
.
snapshotScope
?.({
transaction
:
item
.
transaction
,
canonicalRow
:
item
.
canonicalRow
});
...
...
@@ -296,6 +300,7 @@ export class ParserPipeline {
patientId
:
e
.
patientId
,
transactionId
:
e
.
transactionId
,
sourceUpdatedAt
:
e
.
sourceUpdatedAt
,
sourceCreatedAt
:
e
.
sourceCreatedAt
,
});
metrics
.
writes
.
push
(
r
);
switch
(
r
.
action
)
{
...
...
@@ -383,13 +388,7 @@ export interface PipelineRunMetrics {
writes
:
FactWriteResult
[];
}
///
canonicalRow.updatedAt(宿主末次修改时间,string / Date)→ Date;缺失或不可解析 → null(退回到达顺序语义)
。
///
只接受已归一的来源时间;仅缺失时为 null,有值但非法不能偷偷降为到达顺序
。
function
parseSourceUpdatedAt
(
canonicalRow
:
Record
<
string
,
unknown
>
):
Date
|
null
{
const
v
=
canonicalRow
.
updatedAt
;
if
(
v
instanceof
Date
)
return
isNaN
(
v
.
getTime
())
?
null
:
v
;
if
(
typeof
v
===
'string'
&&
v
.
trim
())
{
const
d
=
new
Date
(
v
);
return
isNaN
(
d
.
getTime
())
?
null
:
d
;
}
return
null
;
return
readCanonicalTime
(
canonicalRow
.
updatedAt
,
'updatedAt'
);
}
apps/pac-service/src/modules/sync/pipeline/transaction-synthesizer.ts
View file @
b502e1b3
...
...
@@ -3,6 +3,7 @@ import { createHash } from 'node:crypto';
import
{
Action
,
ActionSchema
,
SubjectType
,
SubjectTypeSchema
}
from
'@pac/types'
;
import
type
{
Prisma
}
from
'@prisma/client'
;
import
type
{
EmitsConfig
}
from
'../assembler/assembler.schema'
;
import
{
readCanonicalTime
}
from
'../canonical-time'
;
/**
* Transaction Synthesizer(v2.1 重塑)
...
...
@@ -77,6 +78,18 @@ export class TransactionSynthesizer {
// canonical 字段提取
const
c
=
input
.
canonicalRow
;
// 即使业务时间有效,幂等键使用的来源时间也必须已经过统一归一。
// 与身份拒收一样返回 null,由调用方计失败;不让单条异常终止整批。
let
occurredAt
:
Date
|
null
;
try
{
const
sourceCreatedAt
=
readCanonicalTime
(
c
.
createdAt
,
'createdAt'
);
const
sourceUpdatedAt
=
readCanonicalTime
(
c
.
updatedAt
,
'updatedAt'
);
occurredAt
=
readCanonicalTime
(
c
[
emits
.
occurredAtField
],
emits
.
occurredAtField
)
??
sourceUpdatedAt
??
sourceCreatedAt
;
}
catch
(
err
)
{
this
.
logger
.
warn
(
`
${
input
.
resource
}
:
${
err
instanceof
Error
?
err
.
message
:
String
(
err
)}
; skipping`
);
return
null
;
}
const
subjectId
=
String
(
c
.
externalId
??
''
).
trim
();
if
(
!
subjectId
)
{
this
.
logger
.
warn
(
...
...
@@ -99,14 +112,9 @@ export class TransactionSynthesizer {
:
undefined
;
// occurredAt — 从 yaml emits.occurredAtField 指定的字段拿;
// 缺失
/不可解析时确定性回退 updatedAt → createdAt(与 sourceEventId 的 updatedAtMark 同序)
。
// 缺失
时确定性回退 updatedAt → createdAt;有值但不合法则拒收,不能隐式猜测时区
。
// 墙钟只在三者皆缺时兜底:否则同一源行每次摄入 occurredAt 抖动,
// 下游 fact 时间锚变更检测会产生伪 supersede 版本。
let
occurredAt
=
this
.
pickOccurredAt
(
c
,
emits
.
occurredAtField
);
if
(
!
occurredAt
)
{
occurredAt
=
this
.
pickOccurredAt
(
c
,
'updatedAt'
)
??
this
.
pickOccurredAt
(
c
,
'createdAt'
);
}
if
(
!
occurredAt
)
{
this
.
logger
.
warn
(
`Cannot determine occurredAt from field="
${
emits
.
occurredAtField
}
" `
+
...
...
@@ -143,20 +151,6 @@ export class TransactionSynthesizer {
};
}
/** 按 yaml 指定字段名取 occurredAt;字段值需是 ISO 字符串 / Date */
private
pickOccurredAt
(
c
:
Record
<
string
,
unknown
>
,
fieldName
:
string
,
):
Date
|
null
{
const
v
=
c
[
fieldName
];
if
(
typeof
v
===
'string'
&&
v
)
{
const
d
=
new
Date
(
v
);
return
Number
.
isNaN
(
d
.
getTime
())
?
null
:
d
;
}
if
(
v
instanceof
Date
)
return
v
;
return
null
;
}
private
sha256
(
s
:
string
):
string
{
return
createHash
(
'sha256'
).
update
(
s
).
digest
(
'hex'
);
}
...
...
apps/pac-service/src/modules/sync/source-created-time.ts
0 → 100644
View file @
b502e1b3
import
{
z
}
from
'zod'
;
/** 承载事实的来源记录创建时间;不表示诊断等业务动作发生时间。 */
export
interface
SourceCreatedTime
{
sourceCreatedAt
:
Date
|
null
;
}
/** 纯日期没有创建时刻精度;⛔ 不把归一化补出的零点当作源创建时刻。 */
export
function
hasSourceTimePrecision
(
value
:
unknown
):
boolean
{
return
value
instanceof
Date
?
Number
.
isFinite
(
value
.
getTime
())
:
typeof
value
===
'string'
&&
/^
\d{4}
-
\d{2}
-
\d{2}[
T
]\d{2}
:
\d{2}
/
.
test
(
value
.
trim
());
}
/** 只读取显式声明的来源元数据;⛔ 不回退到 occurredAt、updatedAt 或 PAC 当前时间。 */
export
function
readSourceCreatedTime
(
row
:
Record
<
string
,
unknown
>
):
SourceCreatedTime
{
const
empty
:
SourceCreatedTime
=
{
sourceCreatedAt
:
null
};
const
value
=
row
.
sourceCreatedAt
;
if
(
value
instanceof
Date
)
{
return
Number
.
isFinite
(
value
.
getTime
())
?
{
sourceCreatedAt
:
new
Date
(
value
)
}
:
empty
;
}
if
(
typeof
value
!==
'string'
)
return
empty
;
const
parsed
=
z
.
iso
.
datetime
({
offset
:
true
}).
safeParse
(
value
.
trim
());
if
(
!
parsed
.
success
)
return
empty
;
const
date
=
new
Date
(
parsed
.
data
);
return
Number
.
isFinite
(
date
.
getTime
())
?
{
sourceCreatedAt
:
date
}
:
empty
;
}
apps/pac-service/tests/canonical-source-timezone.spec.ts
View file @
b502e1b3
import
{
readFileSync
,
readdirSync
}
from
'node:fs'
;
import
{
join
}
from
'node:path'
;
import
*
as
yaml
from
'js-yaml'
;
import
{
CanonicalResourceMeta
,
type
CanonicalResourceKey
}
from
'@pac/types'
;
import
{
z
}
from
'zod'
;
import
{
CanonicalResourceMeta
,
CanonicalResourceSchemas
,
type
CanonicalResourceKey
}
from
'@pac/types'
;
import
{
AssemblerEngine
}
from
'../src/modules/sync/assembler/assembler-engine'
;
import
{
AssemblerConfigSchema
}
from
'../src/modules/sync/assembler/assembler.schema'
;
import
{
applyMapping
,
normalizeCanonical
}
from
'../src/modules/sync/assembler/field-mapper'
;
import
{
applyMapping
,
normalizeCanonical
,
validateCanonical
,
ContractDriftError
,
mapNormalizeValidate
,
normalizeDatetime
}
from
'../src/modules/sync/assembler/field-mapper'
;
import
{
resolveDictIncludes
}
from
'../src/modules/sync/assembler/dict-include'
;
import
{
TransactionSynthesizer
}
from
'../src/modules/sync/pipeline/transaction-synthesizer'
;
import
{
ParserPipeline
}
from
'../src/modules/sync/pipeline/parser-pipeline.service'
;
...
...
@@ -65,6 +67,65 @@ test.each(configs)('$host/$file 源创建/修改时间统一归一', ({ config }
}
});
test
.
each
(
configs
)(
'$host/$file 事件时间和来源创建字段不可漏配归一'
,
({
config
})
=>
{
const
fields
=
new
Set
([
'createdAt'
,
'updatedAt'
,
...
CanonicalResourceMeta
[
config
.
canonical
as
CanonicalResourceKey
].
datetimeFields
]);
if
(
config
.
emits
)
expect
(
fields
.
has
(
config
.
emits
.
occurredAtField
)).
toBe
(
true
);
if
(
config
.
source_created_at
)
expect
(
fields
.
has
(
config
.
source_created_at
.
field
)).
toBe
(
true
);
});
// 从实际契约反查归一清单,新增 datetime 字段却忘了配置时立即失败。
function
datetimePaths
(
schema
:
unknown
,
path
=
''
):
string
[]
{
if
(
!
schema
||
typeof
schema
!==
'object'
)
return
[];
const
node
=
schema
as
Record
<
string
,
unknown
>
;
const
result
=
node
.
format
===
'date-time'
?
[
path
]
:
[];
for
(
const
[
key
,
child
]
of
Object
.
entries
((
node
.
properties
??
{})
as
Record
<
string
,
unknown
>
))
{
result
.
push
(...
datetimePaths
(
child
,
path
?
`
${
path
}
.
${
key
}
`
:
key
));
}
if
(
node
.
items
)
result
.
push
(...
datetimePaths
(
node
.
items
,
`
${
path
}
[]`
));
for
(
const
key
of
[
'anyOf'
,
'allOf'
,
'oneOf'
])
{
if
(
Array
.
isArray
(
node
[
key
]))
for
(
const
child
of
node
[
key
])
result
.
push
(...
datetimePaths
(
child
,
path
));
}
return
result
;
}
test
.
each
(
resources
)(
'%s 契约中的时刻字段全部纳入统一归一'
,
resource
=>
{
const
meta
=
CanonicalResourceMeta
[
resource
];
const
covered
=
new
Set
([
'createdAt'
,
'updatedAt'
,
...
meta
.
datetimeFields
,
...(
meta
.
datetimeArrayFields
??
[]).
map
(
item
=>
`
${
item
.
array
}
[].
${
item
.
field
}
`
),
...(
meta
.
datetimeObjectFields
??
[]).
map
(
item
=>
`
${
item
.
object
}
.
${
item
.
field
}
`
)]);
const
jsonSchema
=
z
.
toJSONSchema
(
CanonicalResourceSchemas
[
resource
],
{
io
:
'input'
});
expect
(
datetimePaths
(
jsonSchema
).
filter
(
path
=>
!
covered
.
has
(
path
))).
toEqual
([]);
});
test
(
'内嵌影像分析时间走同一归一入口,保留原始快照'
,
()
=>
{
const
raw
=
Object
.
freeze
({
analysis
:
Object
.
freeze
({
analyzedAt
:
wall
})
});
const
result
=
normalizeCanonical
(
raw
,
'image'
,
context
);
expect
(
new
Date
((
result
.
analysis
as
{
analyzedAt
:
string
}).
analyzedAt
).
toISOString
()).
toBe
(
utc
);
expect
(
raw
.
analysis
.
analyzedAt
).
toBe
(
wall
);
expect
(
normalizeCanonical
(
result
,
'image'
,
context
)).
toEqual
(
result
);
});
test
.
each
([
'createdAt'
,
'updatedAt'
])(
'passthrough 的 %s 也不能绕过时区校验'
,
field
=>
{
expect
(()
=>
validateCanonical
({
raw
:
{
[
field
]:
wall
},
schema
:
z
.
object
({}).
passthrough
(),
hostId
:
'host'
,
resource
:
'encounter'
})).
toThrow
(
ContractDriftError
);
});
test
.
each
([
'10/21/2026 12:30:00'
,
'2026-02-30 12:30:00'
,
'2026-10-21T12:00:00+25:00'
])(
'含糊或非法时间 %s 不能靠 JS 宽松解析蒙混入库'
,
value
=>
{
expect
(()
=>
mapNormalizeValidate
({
raw
:
{
externalId
:
'visit'
,
patientExternalId
:
'patient'
,
clinicId
:
'clinic'
,
startedAt
:
value
},
resource
:
'encounter'
,
mapping
:
{},
normalize
:
context
,
hostId
:
'host'
})).
toThrow
(
ContractDriftError
);
});
test
(
'源空值占位留空、显式偏移格式归一、未知时区不默认 UTC'
,
()
=>
{
expect
(
normalizeCanonical
({
updatedAt
:
'null'
,
createdAt
:
' NULL '
},
'encounter'
,
context
))
.
toEqual
({
updatedAt
:
null
,
createdAt
:
null
});
expect
(
normalizeDatetime
(
'2026-10-16T10:47:34+0800'
,
context
.
timezone
)).
toBe
(
'2026-10-16T10:47:34+08:00'
);
expect
(
normalizeDatetime
(
'2026-10-16T02:47:34z'
,
context
.
timezone
)).
toBe
(
'2026-10-16T02:47:34Z'
);
expect
(()
=>
normalizeDatetime
(
wall
,
'Asia/ShangHai'
)).
toThrow
(
'Unsupported host timezone'
);
});
function
encounter
()
{
const
config
=
configs
.
find
(
c
=>
c
.
host
===
'friday'
&&
c
.
config
.
canonical
===
'encounter'
)
!
.
config
;
const
raw
=
{
id
:
'visit-1'
,
patient_appointment_id
:
'patient-1'
,
organization_id
:
'clinic-1'
,
...
...
@@ -109,3 +170,20 @@ test.each(['single', 'batch'] as const)('归一后进入 %s parser,乱序裁
expect
(
metrics
.
factsFailed
).
toBe
(
0
);
expect
(
metrics
.
factsCreated
).
toBe
(
1
);
});
test
.
each
([
'single'
,
'batch'
]
as
const
)(
'%s parser 拒绝未归一的版本时间,不降级成无水位写入'
,
async
mode
=>
{
const
{
canonicalRow
}
=
encounter
();
const
writer
=
{
writeDraft
:
jest
.
fn
(),
bulkWrite
:
jest
.
fn
()
};
const
prisma
=
{
patient
:
{
findUnique
:
async
()
=>
({
sourceUnit
:
'brand-1'
}),
findMany
:
async
()
=>
[{
id
:
'patient-uuid'
,
sourceUnit
:
'brand-1'
}]
}
};
const
pipeline
=
new
ParserPipeline
({
get
:
()
=>
new
EncounterParser
()
}
as
never
,
writer
as
never
,
prisma
as
never
,
{
enrichAll
:
(
drafts
:
unknown
)
=>
drafts
}
as
never
);
const
item
=
{
canonicalRow
:
{
...
canonicalRow
,
updatedAt
:
wall
},
transaction
:
{
id
:
'tx-1'
,
hostId
:
'host-1'
,
tenantId
:
'tenant-1'
,
patientId
:
'patient-uuid'
,
action
:
'doctor_encounter_started'
as
const
,
subjectType
:
'encounter'
,
subjectId
:
'visit-1'
,
clinicId
:
'clinic-1'
,
occurredAt
:
new
Date
(
utc
)
}
};
if
(
mode
===
'single'
)
await
expect
(
pipeline
.
runForTransaction
(
item
)).
rejects
.
toThrow
(
'Invalid canonical time'
);
else
expect
((
await
pipeline
.
runForBatch
([
item
])).
factsFailed
).
toBe
(
1
);
expect
(
writer
.
writeDraft
).
not
.
toHaveBeenCalled
();
expect
(
writer
.
bulkWrite
).
not
.
toHaveBeenCalled
();
});
apps/pac-service/tests/fact-writer-temporal-supersede.spec.ts
View file @
b502e1b3
...
...
@@ -38,6 +38,7 @@ interface Row {
clinicId
:
string
|
null
;
occurredAt
:
Date
|
null
;
sourceUpdatedAt
:
Date
|
null
;
sourceCreatedAt
:
Date
|
null
;
plannedFor
:
Date
|
null
;
validFrom
:
Date
|
null
;
validUntil
:
Date
|
null
;
...
...
@@ -70,6 +71,7 @@ function makeStore(seed: Partial<Row>[] = []) {
clinicId
:
(
data
.
clinicId
as
string
|
null
)
??
null
,
occurredAt
:
(
data
.
occurredAt
as
Date
|
null
)
??
null
,
sourceUpdatedAt
:
(
data
.
sourceUpdatedAt
as
Date
|
null
)
??
null
,
sourceCreatedAt
:
(
data
.
sourceCreatedAt
as
Date
|
null
)
??
null
,
plannedFor
:
(
data
.
plannedFor
as
Date
|
null
)
??
null
,
validFrom
:
(
data
.
validFrom
as
Date
|
null
)
??
null
,
validUntil
:
(
data
.
validUntil
as
Date
|
null
)
??
null
,
...
...
@@ -605,3 +607,31 @@ describe('患者归属更正版本与旧患者水位', () => {
expect
(
rows
[
0
]
!
.
updatedAt
).
toEqual
(
T0
);
});
});
describe
.
each
([
'single'
,
'bulk'
]
as
const
)(
'来源创建时间 %s'
,
mode
=>
{
const
submit
=
async
(
w
:
FactWriter
,
input
:
Parameters
<
FactWriter
[
'writeDraft'
]
>
[
0
])
=>
mode
===
'single'
?
w
.
writeDraft
(
input
)
:
(
await
w
.
bulkWrite
([
input
])).
results
[
0
]
!
;
test
(
'新事实版本保存已校验的来源记录时间,业务时间不变'
,
async
()
=>
{
const
{
prisma
,
rows
}
=
makeStore
();
await
submit
(
writer
(
prisma
),
{
...
baseInput
,
draft
:
encounterDraft
(),
transactionId
:
'tx-created'
,
sourceCreatedAt
:
new
Date
(
'2026-01-01T00:00:00Z'
)
});
expect
(
rows
[
0
]).
toMatchObject
({
sourceCreatedAt
:
new
Date
(
'2026-01-01T00:00:00Z'
),
occurredAt
:
T0
});
});
test
(
'同事实元数据不同不制造新版本、不补写已有版本;正常新版本携带本次来源值'
,
async
()
=>
{
const
{
prisma
,
rows
}
=
makeStore
([
seedActive
()]);
const
w
=
writer
(
prisma
);
const
input
=
{
...
baseInput
,
draft
:
encounterDraft
(),
transactionId
:
'tx-created'
,
sourceCreatedAt
:
new
Date
(
'2026-01-01T00:00:00Z'
)
};
await
submit
(
w
,
input
);
expect
(
rows
).
toHaveLength
(
1
);
expect
(
rows
[
0
]
!
.
sourceCreatedAt
).
toBeNull
();
await
submit
(
w
,
{
...
input
,
transactionId
:
'tx-next'
,
draft
:
{
...
encounterDraft
(),
occurredAt
:
new
Date
(
T0
.
getTime
()
+
60000
)
}
});
expect
(
rows
).
toHaveLength
(
2
);
expect
(
rows
[
0
]
!
.
sourceCreatedAt
).
toBeNull
();
expect
(
rows
[
1
]
!
.
sourceCreatedAt
).
toEqual
(
input
.
sourceCreatedAt
);
});
test
(
'缺字段、非法日期不能写入创建元数据'
,
async
()
=>
{
const
{
prisma
,
rows
}
=
makeStore
();
await
submit
(
writer
(
prisma
),
{
...
baseInput
,
draft
:
encounterDraft
(),
transactionId
:
'tx-unknown'
,
sourceCreatedAt
:
new
Date
(
'bad'
)
});
expect
(
rows
[
0
]).
toMatchObject
({
sourceCreatedAt
:
null
});
});
});
apps/pac-service/tests/fixtures/lookup-content-source.json
View file @
b502e1b3
...
...
@@ -41,7 +41,7 @@
"code_source"
:
"std_code"
,
"name_zh"
:
"龋病"
,
"tooth_position"
:
"36"
,
"source_encounter_external_id"
:
"e
nc
-1"
"source_encounter_external_id"
:
"e
mr
-1"
}
},
{
...
...
apps/pac-service/tests/lookup-content.spec.ts
View file @
b502e1b3
import
{
toLookupFact
,
LookupFactRow
}
from
'../src/modules/open/lookup-fact'
;
import
{
toOpenFact
}
from
'../src/modules/open/open-fact'
;
const
at
=
'2026-10-19T03:00:00.000Z'
;
const
other
=
'2026-10-20T03:00:00.000Z'
;
const
base
:
LookupFactRow
=
{
fact_id
:
'11111111-1111-4111-8111-111111111111'
,
host_name
:
'friday'
,
subject_id
:
'source'
,
type
:
'emr_record'
,
kind
:
'actual'
,
status
:
'active'
,
clinic_id
:
null
,
occurred_at
:
new
Date
(
at
),
planned_for
:
null
,
valid_until
:
null
,
title
:
null
,
summary
:
null
,
content
:
{}
};
const
run
=
(
type
:
string
,
content
:
any
,
row
:
Partial
<
LookupFactRow
>
=
{})
=>
toLookupFact
({
...
base
,
type
,
content
,
...
row
},
null
);
describe
(
'lookup content contract'
,
()
=>
{
it
(
'names own source ids without inventing absent ids or conflating relation references'
,
()
=>
{
expect
(
run
(
'emr_record'
,
{
emr_external_id
:
'source-emr'
,
encounter_external_id
:
'visit'
,
doctor_id
:
'doctor'
,
doctor_name
:
'医生'
}).
content
).
toEqual
({
externalId
:
'source-emr'
,
encounterExternalId
:
'visit'
,
doctorId
:
'doctor'
,
doctorName
:
'医生'
});
expect
(
run
(
'diagnosis_record'
,
{
name_zh
:
'龋病'
,
source_encounter_external_id
:
'maybe-emr'
,
encounter_external_id
:
'extension'
}).
content
).
toEqual
({
name
:
'龋病'
,
sourceEncounterExternalId
:
'maybe-emr'
,
encounter_external_id
:
'extension'
});
expect
(
run
(
'treatment_record'
,
{}).
content
).
not
.
toHaveProperty
(
'externalId'
);
import
{
LookupContentError
}
from
'../src/modules/open/lookup-narrative'
;
import
{
LookupFactSchema
}
from
'../src/modules/open/patient-facts-lookup.schema'
;
const
at
=
'2026-10-19T03:00:00.000Z'
,
other
=
'2026-10-20T03:00:00.000Z'
;
const
base
:
LookupFactRow
=
{
fact_id
:
'11111111-1111-4111-8111-111111111111'
,
host_name
:
'friday'
,
subject_id
:
'source'
,
type
:
'emr_record'
,
kind
:
'actual'
,
status
:
'active'
,
clinic_id
:
null
,
occurred_at
:
new
Date
(
at
),
planned_for
:
null
,
valid_until
:
null
,
title
:
null
,
summary
:
null
,
content
:
{},
};
const
run
=
(
type
:
string
,
content
:
any
,
row
:
Partial
<
LookupFactRow
>
=
{})
=>
toLookupFact
({
...
base
,
type
,
content
,
...
row
},
null
);
describe
(
'lookup content whitelist'
,
()
=>
{
it
(
'names identities explicitly and never derives absent identity from subjectId'
,
()
=>
{
expect
(
run
(
'emr_record'
,
{
emr_external_id
:
'00013900139000'
,
doctor_id
:
'13900139000'
}).
content
,
).
toMatchObject
({
externalId
:
'00013900139000'
,
doctorExternalId
:
'13900139000'
,
encounterExternalId
:
null
,
});
expect
(
run
(
'diagnosis_record'
,
{
source_encounter_external_id
:
'self-fallback'
}).
content
,
).
toMatchObject
({
externalId
:
null
,
emrExternalId
:
null
,
encounterExternalId
:
null
});
expect
(
run
(
'emr_record'
,
{},
{
clinic_id
:
'source-clinic'
})).
toHaveProperty
(
'clinicExternalId'
,
'source-clinic'
,
);
});
test
.
each
([
[
'recommendation_record'
,
'doctor_suggested_at'
,
'
doctorS
uggestedAt'
],
[
'recommendation_record'
,
'doctor_suggested_at'
,
'
s
uggestedAt'
],
[
'image_analysis_record'
,
'analyzed_at'
,
'analyzedAt'
],
[
'visit_registration_record'
,
'registered_at'
,
'registeredAt'
],
[
'visit_reception_record'
,
'received_at'
,
'receivedAt'
],
[
'image_record'
,
'captured_at'
,
'capturedAt'
],
])(
'%s
moves precise inner time to an empty outer field, without losing ambiguous text
'
,
(
type
,
key
,
renamed
)
=>
{
])(
'%s
deduplicates only equivalent precise time
'
,
(
type
,
key
,
renamed
)
=>
{
const
result
=
run
(
type
,
{
[
key
]:
'2026-10-19T11:00:00+08:00'
},
{
occurred_at
:
null
});
expect
(
result
.
occurredAt
).
toBe
(
at
);
expect
(
result
.
content
).
not
.
toHaveProperty
(
renamed
);
const
vague
=
run
(
type
,
{
[
key
]:
'2026-10-19 11:00:00'
},
{
occurred_at
:
null
});
expect
(
vague
.
occurredAt
).
toBeNull
();
expect
(
vague
.
content
).
toHaveProperty
(
renamed
,
'2026-10-19 11:00:00'
);
expect
(
run
(
type
,
{
[
key
]:
'2026-10-19 11:00:00'
},
{
occurred_at
:
null
})).
toMatchObject
({
occurredAt
:
null
,
content
:
{
[
renamed
]:
'2026-10-19 11:00:00'
},
});
});
it
(
'retains conflicting or invalid inner time, and meaningful process dates'
,
()
=>
{
expect
(
run
(
'image_record'
,
{
captured_at
:
other
}).
content
).
toEqual
({
capturedAt
:
other
});
expect
(
run
(
'image_record'
,
{
captured_at
:
'2026-02-30T00:00:00Z'
},
{
occurred_at
:
null
})).
toMatchObject
({
occurredAt
:
null
,
content
:
{
capturedAt
:
'2026-02-30T00:00:00Z'
}
});
expect
(
run
(
'treatment_record'
,
{
started_at
:
at
,
completed_at
:
other
}).
content
).
toEqual
({
startedAt
:
at
,
completedAt
:
other
});
expect
(
run
(
'diagnosis_record'
,
{
onset_date
:
'2026-10-01'
}).
content
).
toEqual
({
onsetDate
:
'2026-10-01'
});
it
(
'keeps meaningful process dates, distinct amounts and consultation intent'
,
()
=>
{
expect
(
run
(
'treatment_record'
,
{
started_at
:
at
,
completed_at
:
other
,
treat_stages
:
[
'canal_filling'
],
}).
content
,
).
toMatchObject
({
startedAt
:
at
,
completedAt
:
other
,
stages
:
[
'canal_filling'
]
});
expect
(
run
(
'order_record'
,
{
amount_cents
:
100
,
total_amount
:
200
,
items
:
[{
item_name
:
'标准'
,
name
:
'旧'
,
amount_cents
:
10
,
amount
:
20
,
private
:
'hidden'
}],
}).
content
,
).
toMatchObject
({
amountCents
:
100
,
totalAmount
:
200
,
items
:
[{
itemName
:
'标准'
,
name
:
'旧'
,
amountCents
:
10
,
amount
:
20
}],
});
const
consult
=
run
(
'consultation_record'
,
{
consult_date
:
'2026-10-19'
,
intents
:
[
'洁牙'
],
intent
:
'洁牙'
,
content
:
'咨询'
,
}).
content
;
expect
(
consult
).
toMatchObject
({
intents
:
[
'洁牙'
]
});
expect
(
consult
).
not
.
toHaveProperty
(
'intent'
);
expect
(
consult
).
not
.
toHaveProperty
(
'consultDate'
);
expect
(
consult
).
not
.
toHaveProperty
(
'description'
);
expect
(
run
(
'consultation_record'
,
{
intent
:
'历史原文/不拆分'
}).
content
).
toHaveProperty
(
'intents'
,
[
'历史原文/不拆分'
],
);
expect
(()
=>
run
(
'consultation_record'
,
{
intents
:
[
'洁牙'
],
intent
:
'冲突'
})).
toThrow
(
LookupContentError
,
);
});
it
(
'appointment uses host creation while arrival is reserved for encounter'
,
()
=>
{
expect
(
run
(
'appointment_record'
,
{
scheduled_at
:
other
,
arrived_at
:
at
},
{
planned_for
:
new
Date
(
other
),
appointment_created_values
:
[
at
]
})).
toMatchObject
({
occurredAt
:
at
,
plannedFor
:
other
,
content
:
{}
});
expect
(
run
(
'appointment_record'
,
{
scheduled_at
:
other
,
arrived_at
:
null
})).
toMatchObject
({
occurredAt
:
null
,
plannedFor
:
other
,
content
:
{}
});
expect
(
run
(
'appointment_record'
,
{
scheduled_at
:
other
},
{
planned_for
:
new
Date
(
at
)
}).
content
).
toEqual
({
scheduledAt
:
other
});
it
(
'fails ambiguous non-identity aliases and unsupported content; never passes unknown types through'
,
()
=>
{
expect
(()
=>
run
(
'emr_record'
,
{
illness_desc
:
'a'
,
illnessDescription
:
'b'
})).
toThrow
(
LookupContentError
,
);
expect
(
run
(
'emr_record'
,
{
doctor_id
:
'a'
,
doctorExternalId
:
'b'
}).
content
).
toHaveProperty
(
'doctorExternalId'
,
null
,
);
expect
(()
=>
run
(
'future_type'
,
{
secret
:
'private'
})).
toThrow
(
LookupContentError
);
expect
(()
=>
run
(
'emr_record'
,
[])).
toThrow
(
LookupContentError
);
expect
(()
=>
run
(
'payment_record'
,
{
amount_cents
:
'bad'
})).
toThrow
(
LookupContentError
);
});
it
(
'deduplicates only reproducible consultation date and joined intent, keeping original differences'
,
()
=>
{
expect
(
run
(
'consultation_record'
,
{
consult_date
:
'2026-10-19'
,
intents
:
[
'洁牙'
,
'种植'
],
intent
:
'洁牙/种植'
,
content
:
'咨询正文'
}).
content
).
toEqual
({
intents
:
[
'洁牙'
,
'种植'
],
text
:
'咨询正文'
});
expect
(
run
(
'consultation_record'
,
{
consult_date
:
'2026-10-18'
,
intents
:
[],
intent
:
'只剩历史意向'
}).
content
).
toEqual
({
consultDate
:
'2026-10-18'
,
intents
:
[],
intent
:
'只剩历史意向'
});
expect
(
run
(
'consultation_record'
,
{
consult_date
:
'2026-10-19'
},
{
occurred_at
:
null
}).
content
).
toHaveProperty
(
'consultDate'
);
it
(
'projects nested business extensions without extractor metadata or private fields'
,
()
=>
{
const
c
=
run
(
'emr_record'
,
{
illness_desc
:
'病情13900139000'
,
quality_check
:
{
status
:
'passed'
,
rejected
:
false
,
reject_reason
:
'原因'
,
secret
:
'hidden'
},
clinical_signals
:
[
{
code
:
'test'
,
phrase
:
'13900139000'
,
field
:
'illness_desc'
,
negated
:
null
,
contraindicates
:
[
'surgery'
],
span
:
[
0
,
3
],
extractedBy
:
'internal'
,
extra
:
true
,
},
],
}).
content
;
expect
(
c
).
toMatchObject
({
illnessDescription
:
'病情139****9000'
,
qualityCheck
:
{
sourceStatus
:
'passed'
,
rejectionReason
:
'原因'
},
clinicalSignals
:
[
{
code
:
'test'
,
evidence
:
'139****9000'
,
sourceField
:
'illnessDescription'
,
negated
:
null
,
treatmentDomains
:
[
'surgery'
],
},
],
});
for
(
const
key
of
[
'secret'
,
'rejected'
,
'span'
,
'extractedBy'
,
'extra'
])
expect
(
JSON
.
stringify
(
c
)).
not
.
toContain
(
`"
${
key
}
"`
);
expect
(
run
(
'emr_record'
,
{
clinical_signals
:
[{
field
:
'staff_notes'
}]
}).
content
,
).
toMatchObject
({
clinicalSignals
:
[{
sourceField
:
null
}]
});
});
it
(
'keeps financial amounts and ambiguous references separate without unit conversion'
,
()
=>
{
expect
(
run
(
'order_record'
,
{
amount_cents
:
100
,
total_amount
:
200
,
items
:
[{
item_name
:
'标准'
,
name
:
'旧'
,
amount_cents
:
10
,
amount
:
20
}]
}).
content
).
toEqual
({
amountCents
:
100
,
totalAmount
:
200
,
items
:
[{
itemName
:
'标准'
,
name
:
'旧'
,
amountCents
:
10
,
amount
:
20
}]
});
expect
(
run
(
'recharge_record'
,
{
amount_cents
:
100
,
amount
:
200
,
card_type
:
'储值'
,
recharge_type
:
'充值'
}).
content
).
toEqual
({
amountCents
:
100
,
amount
:
200
,
cardType
:
'储值'
,
rechargeType
:
'充值'
});
it
(
'projects image findings and complaint child events; removes arbitrary nested fields'
,
()
=>
{
const
image
=
run
(
'image_record'
,
{
analysis
:
{
source
:
'meyer'
,
findings
:
[{
name
:
'龋病'
,
toothPosition
:
'36'
,
code
:
'K02'
,
internal
:
1
}],
raw
:
'secret'
,
},
file_url
:
'images/x'
,
}).
content
;
expect
(
image
).
toMatchObject
({
fileLocation
:
'images/x'
,
analysis
:
{
source
:
'meyer'
,
findings
:
[{
name
:
'龋病'
,
toothPosition
:
'36'
,
code
:
'K02'
}]
},
});
expect
(
JSON
.
stringify
(
image
)).
not
.
toMatch
(
/internal|secret/
);
const
complaint
=
run
(
'complaint_record'
,
{
content
:
'投诉'
,
signal_eligible
:
true
,
review_reason
:
'internal'
,
follow_ups
:
[
{
occurredAt
:
at
,
content
:
'联系13900139000'
,
actorId
:
'13900139000'
,
private
:
'x'
},
],
}).
content
;
expect
(
complaint
).
toMatchObject
({
description
:
'投诉'
,
followUps
:
[
{
occurredAt
:
at
,
description
:
'联系139****9000'
,
actorExternalId
:
'13900139000'
},
],
});
expect
(
JSON
.
stringify
(
complaint
)).
not
.
toMatch
(
/signalEligible|reviewReason|private/
);
});
it
(
'preserves alias conflicts and unknown nested extension JSON'
,
()
=>
{
expect
(
run
(
'emr_record'
,
{
doctor_id
:
'a'
,
doctorId
:
'b'
,
extension
:
{
some_key
:
'untouched'
}
}).
content
).
toEqual
({
doctor_id
:
'a'
,
doctorId
:
'b'
,
extension
:
{
some_key
:
'untouched'
}
});
expect
(
run
(
'future_type'
,
{
doctor_id
:
'a'
}).
content
).
toEqual
({
doctor_id
:
'a'
});
});
it
(
'distinguishes extraction method from extractor version and preserves child-event times'
,
()
=>
{
expect
(
run
(
'recommendation_record'
,
{
extracted_by
:
'rule'
}).
content
).
toEqual
({
extractionMethod
:
'rule'
});
expect
(
run
(
'emr_record'
,
{
clinical_signals
:
[{
extractedBy
:
'v1'
,
validUntil
:
other
}],
quality_check
:
{
reject_reason
:
'原因'
}
}).
content
).
toEqual
({
clinicalSignals
:
[{
extractorVersion
:
'v1'
,
validUntil
:
other
}],
qualityCheck
:
{
rejectReason
:
'原因'
}
});
expect
(
run
(
'complaint_record'
,
{
content
:
'投诉'
,
resolved_at
:
other
,
follow_ups
:
[{
occurredAt
:
at
,
content
:
'跟进'
}]
}).
content
).
toEqual
({
text
:
'投诉'
,
resolvedAt
:
other
,
followUps
:
[{
occurredAt
:
at
,
text
:
'跟进'
}]
});
});
it
(
'updates signal field references only when the target field was renamed successfully'
,
()
=>
{
expect
(
run
(
'emr_record'
,
{
illness_desc
:
'病情'
,
clinical_signals
:
[{
field
:
'illness_desc'
}]
}).
content
).
toEqual
({
illnessDesc
:
'病情'
,
clinicalSignals
:
[{
field
:
'illnessDesc'
}]
});
expect
(
run
(
'emr_record'
,
{
illness_desc
:
'原字段'
,
illnessDesc
:
'冲突字段'
,
clinical_signals
:
[{
field
:
'illness_desc'
},
{
field
:
'unknown_key'
}]
}).
content
).
toEqual
({
illness_desc
:
'原字段'
,
illnessDesc
:
'冲突字段'
,
clinicalSignals
:
[{
field
:
'illness_desc'
},
{
field
:
'unknown_key'
}]
});
});
it
(
'does not mutate source and keeps old API output and redaction unchanged'
,
()
=>
{
const
row
=
{
...
base
,
content
:
{
emr_external_id
:
'e'
,
illness_desc
:
'13900139000'
,
staff_notes
:
'内部'
,
extension
:
{
phone
:
'13900139000'
}
}
};
const
snapshot
=
JSON
.
stringify
(
row
);
const
before
=
toOpenFact
(
row
);
expect
(
toLookupFact
(
row
,
null
).
content
).
toEqual
({
externalId
:
'e'
,
illnessDesc
:
'139****9000'
,
extension
:
{}
});
expect
(
JSON
.
stringify
(
row
)).
toBe
(
snapshot
);
expect
(
toOpenFact
(
row
)).
toEqual
(
before
);
expect
(
before
.
content
).
toHaveProperty
(
'emr_external_id'
,
'e'
);
it
(
'does not mutate stored data or old API output'
,
()
=>
{
const
row
=
{
...
base
,
content
:
{
emr_external_id
:
'e'
,
illness_desc
:
'13900139000'
,
staff_notes
:
'内部'
,
extensions
:
{
key
:
'hidden'
},
_meyer
:
{
secret
:
'hidden'
},
},
};
const
snapshot
=
JSON
.
stringify
(
row
),
old
=
toOpenFact
(
row
),
output
=
toLookupFact
(
row
,
null
);
expect
(
output
.
content
).
toMatchObject
({
externalId
:
'e'
,
illnessDescription
:
'139****9000'
});
expect
(
JSON
.
stringify
(
output
)).
not
.
toMatch
(
/extensions|_meyer|staff_notes/
);
expect
(
JSON
.
stringify
(
row
)).
toBe
(
snapshot
);
expect
(
toOpenFact
(
row
)).
toEqual
(
old
);
expect
(
LookupFactSchema
.
safeParse
({
...
output
,
content
:
{
...
output
.
content
,
unknown
:
1
}
}).
success
,
).
toBe
(
false
);
});
});
apps/pac-service/tests/lookup-contract-postgres.spec.ts
0 → 100644
View file @
b502e1b3
import
{
PrismaClient
,
Prisma
}
from
'@prisma/client'
;
import
{
Logger
}
from
'@nestjs/common'
;
import
{
randomUUID
}
from
'node:crypto'
;
import
{
hashSecret
}
from
'@pac/utils'
;
import
{
PatientFactsLookupService
}
from
'../src/modules/open/patient-facts-lookup.service'
;
import
{
createMeyerOpenHttp
}
from
'./helpers/meyer-open-http'
;
const
url
=
process
.
env
.
PAC_MEYER_TEST_DATABASE_URL
;
const
describePg
=
url
?
describe
:
describe
.
skip
;
describePg
(
'lookup contract | isolated PostgreSQL and real HTTP'
,
()
=>
{
let
db
:
PrismaClient
,
service
:
PatientFactsLookupService
;
let
friday
:
string
,
jvs
:
string
,
patient
:
string
,
other
:
string
;
const
tenant
=
'lookup-contract'
,
unit
=
'brand-a'
,
clinic
=
'clinic-a'
;
beforeAll
(
async
()
=>
{
const
u
=
new
URL
(
url
!
);
if
(
!
[
'localhost'
,
'127.0.0.1'
].
includes
(
u
.
hostname
)
||
!
u
.
pathname
.
startsWith
(
'/pac_meyer_ci_'
)
)
throw
new
Error
(
'isolated local database required'
);
Logger
.
overrideLogger
(
false
);
db
=
new
PrismaClient
({
datasources
:
{
db
:
{
url
}
}
});
friday
=
(
await
db
.
host
.
create
({
data
:
{
name
:
'friday'
,
appId
:
'lookup-ci'
,
appSecretHash
:
hashSecret
(
'lookup-ci-secret'
),
openFactsReadEnabled
:
true
,
},
})
).
id
;
jvs
=
(
await
db
.
host
.
create
({
data
:
{
name
:
'jvs-dw'
,
appId
:
'lookup-dw'
,
appSecretHash
:
'test'
}
})
).
id
;
patient
=
(
await
db
.
patient
.
create
({
data
:
{
hostId
:
friday
,
tenantId
:
tenant
,
sourceUnit
:
unit
,
externalId
:
'P'
,
medicalRecordNumber
:
'LOOKUP-CI'
,
},
})
).
id
;
other
=
(
await
db
.
patient
.
create
({
data
:
{
hostId
:
friday
,
tenantId
:
tenant
,
sourceUnit
:
'brand-b'
,
externalId
:
'P'
,
medicalRecordNumber
:
'OTHER-CI'
,
},
})
).
id
;
service
=
new
PatientFactsLookupService
(
db
as
any
);
});
beforeEach
(
async
()
=>
{
await
db
.
patientFact
.
deleteMany
({
where
:
{
hostId
:
{
in
:
[
friday
,
jvs
]
}
}
});
await
db
.
patientTransaction
.
deleteMany
({
where
:
{
hostId
:
{
in
:
[
friday
,
jvs
]
}
}
});
});
afterAll
(
async
()
=>
{
if
(
!
db
)
return
;
await
db
.
patientFact
.
deleteMany
({
where
:
{
hostId
:
{
in
:
[
friday
,
jvs
]
}
}
});
await
db
.
patientTransaction
.
deleteMany
({
where
:
{
hostId
:
{
in
:
[
friday
,
jvs
]
}
}
});
await
db
.
patient
.
deleteMany
({
where
:
{
hostId
:
{
in
:
[
friday
,
jvs
]
}
}
});
await
db
.
host
.
deleteMany
({
where
:
{
id
:
{
in
:
[
friday
,
jvs
]
}
}
});
await
db
.
$disconnect
();
});
const
lookup
=
()
=>
service
.
lookup
(
{
hostId
:
friday
,
appId
:
'lookup-ci'
,
clinicNames
:
{
[
clinic
]:
'合成诊所'
}
},
{
medicalRecordNumber
:
'LOOKUP-CI'
},
);
async
function
tx
(
extra
:
Partial
<
Prisma
.
PatientTransactionUncheckedCreateInput
>
=
{})
{
return
db
.
patientTransaction
.
create
({
data
:
{
hostId
:
friday
,
tenantId
:
tenant
,
patientId
:
patient
,
clinicId
:
clinic
,
subjectId
:
'DX'
,
subjectType
:
'diagnosis'
,
action
:
'diagnosis_recorded'
,
occurredAt
:
new
Date
(
'2026-10-21T01:00:00Z'
),
sourceEventId
:
`cold-import:diagnosis:
${
unit
}
:DX:
${
randomUUID
()}
`
,
rawPayload
:
{
emr_sub_id
:
'EMR-A'
},
payloadHash
:
'synthetic'
,
...
extra
,
},
});
}
async
function
fact
(
ids
:
string
[],
extra
:
Partial
<
Prisma
.
PatientFactUncheckedCreateInput
>
=
{})
{
return
db
.
patientFact
.
create
({
data
:
{
hostId
:
friday
,
tenantId
:
tenant
,
sourceUnit
:
unit
,
patientId
:
patient
,
clinicId
:
clinic
,
subjectId
:
`diagnosis_record:
${
randomUUID
()}
`
,
kind
:
'actual'
,
type
:
'diagnosis_record'
,
status
:
'active'
,
version
:
1
,
content
:
{
name_zh
:
'合成诊断'
,
source_encounter_external_id
:
'EMR-A'
},
transactionIds
:
ids
,
...
extra
,
},
});
}
it
(
'queries only bound evidence and leaves stored facts/transactions unchanged'
,
async
()
=>
{
const
good
=
await
tx
();
await
tx
({
rawPayload
:
{
emr_sub_id
:
'UNBOUND'
}
});
// 同患者未绑定证据不可污染当前版本。
await
fact
([
good
.
id
]);
const
before
=
await
db
.
patientFact
.
findMany
({
where
:
{
patientId
:
patient
}
});
const
txBefore
=
await
db
.
patientTransaction
.
findMany
({
where
:
{
patientId
:
patient
},
orderBy
:
{
id
:
'asc'
},
});
const
result
=
await
lookup
();
expect
(
result
.
items
[
0
]
!
.
facts
[
0
]
!
.
content
).
toMatchObject
({
emrExternalId
:
'EMR-A'
,
encounterExternalId
:
null
,
});
expect
(
await
db
.
patientFact
.
findMany
({
where
:
{
patientId
:
patient
}
})).
toEqual
(
before
);
expect
(
await
db
.
patientTransaction
.
findMany
({
where
:
{
patientId
:
patient
},
orderBy
:
{
id
:
'asc'
},
}),
).
toEqual
(
txBefore
);
});
test
.
each
([
'patient'
,
'tenant'
,
'host'
,
'clinic'
,
'missing'
,
'conflict'
])(
'rejects partially valid evidence when another bound item has %s mismatch'
,
async
(
mismatch
)
=>
{
const
good
=
await
tx
();
let
badId
:
string
=
randomUUID
();
if
(
mismatch
!==
'missing'
)
{
const
extra
:
Partial
<
Prisma
.
PatientTransactionUncheckedCreateInput
>
=
mismatch
===
'patient'
?
{
patientId
:
other
}
:
mismatch
===
'tenant'
?
{
tenantId
:
'foreign'
}
:
mismatch
===
'host'
?
{
hostId
:
jvs
}
:
mismatch
===
'clinic'
?
{
clinicId
:
'foreign'
}
:
{
rawPayload
:
{
emr_sub_id
:
'OTHER-EMR'
}
};
badId
=
(
await
tx
(
extra
)).
id
;
}
await
fact
([
good
.
id
,
badId
]);
expect
((
await
lookup
()).
items
[
0
]
!
.
facts
[
0
]
!
.
content
).
toHaveProperty
(
'emrExternalId'
,
null
);
},
);
it
(
'HTTP returns readable paragraphs and explicit whole-request failure without leaking source text'
,
async
()
=>
{
const
evidence
=
await
tx
({
subjectType
:
'emr'
,
subjectId
:
'E'
,
sourceEventId
:
'cold-import:emr:brand-a:E:v'
,
rawPayload
:
{
emr_sub_id
:
'E'
,
appointment_id
:
'A'
},
});
const
emr
=
await
fact
([
evidence
.
id
],
{
type
:
'emr_record'
,
content
:
{
emr_external_id
:
'E'
,
encounter_external_id
:
'A'
,
exam_findings
:
'[{"toothPosition":"36","value":"未见异常13900139000"}]'
,
staff_notes
:
'private-note'
,
},
});
const
http
=
await
createMeyerOpenHttp
(
db
as
any
);
try
{
const
auth
=
await
http
.
request
(
'/auth/token'
,
{
appId
:
'lookup-ci'
,
appSecret
:
'lookup-ci-secret'
,
});
expect
(
auth
.
code
).
toBe
(
0
);
const
result
=
await
http
.
request
(
'/open/patient-facts/lookup'
,
{
medicalRecordNumber
:
'LOOKUP-CI'
},
auth
.
data
.
accessToken
,
);
expect
(
result
.
code
).
toBe
(
0
);
expect
(
result
.
data
.
items
[
0
].
facts
[
0
].
content
).
toMatchObject
({
encounterExternalId
:
'A'
,
examFindings
:
'36:未见异常139****9000'
,
treatment
:
null
,
procedure
:
null
,
});
expect
(
JSON
.
stringify
(
result
)).
not
.
toContain
(
'private-note'
);
await
db
.
patientFact
.
update
({
where
:
{
id
:
emr
.
id
},
data
:
{
content
:
{
exam_findings
:
'{"unknown":"private-patient"}'
}
},
});
const
bad
=
await
http
.
request
(
'/open/patient-facts/lookup'
,
{
medicalRecordNumber
:
'LOOKUP-CI'
},
auth
.
data
.
accessToken
,
);
expect
(
bad
).
toMatchObject
({
code
:
90000
,
data
:
null
,
details
:
{
reason
:
'FACT_CONTENT_NORMALIZATION_FAILED'
},
});
expect
(
JSON
.
stringify
(
bad
)).
not
.
toContain
(
'private-patient'
);
}
finally
{
await
http
.
close
();
}
});
});
apps/pac-service/tests/lookup-fact.spec.ts
View file @
b502e1b3
...
...
@@ -4,46 +4,138 @@ import { PatientFactsLookupResponseSchema } from '../src/modules/open/patient-fa
const
early
=
'2026-10-01T02:00:00.000Z'
;
const
late
=
'2026-10-20T02:00:00.000Z'
;
const
base
:
LookupFactRow
=
{
fact_id
:
'11111111-1111-4111-8111-111111111111'
,
host_name
:
'friday'
,
subject_id
:
'treatment_record:source-1'
,
type
:
'treatment_record'
,
kind
:
'planned'
,
status
:
'active'
,
clinic_id
:
'c'
,
occurred_at
:
null
,
planned_for
:
new
Date
(
early
),
valid_until
:
null
,
title
:
'治疗'
,
summary
:
'摘要'
,
fact_id
:
'11111111-1111-4111-8111-111111111111'
,
host_name
:
'friday'
,
subject_id
:
'treatment_record:source-1'
,
type
:
'treatment_record'
,
kind
:
'planned'
,
status
:
'active'
,
clinic_id
:
'c'
,
occurred_at
:
null
,
planned_for
:
new
Date
(
early
),
valid_until
:
null
,
title
:
'治疗'
,
summary
:
'摘要'
,
content
:
{
category
:
'restorative'
,
subtype
:
'充填'
,
status
:
'planned'
},
};
const
convert
=
(
changes
:
Partial
<
LookupFactRow
>
=
{})
=>
toLookupFact
({
...
base
,
...
changes
},
'诊所'
);
const
convert
=
(
changes
:
Partial
<
LookupFactRow
>
=
{})
=>
toLookupFact
({
...
base
,
...
changes
},
'诊所'
);
describe
(
'new lookup fact projection only'
,
()
=>
{
test
.
each
([
'friday'
,
'jvs-dw'
])(
'%s plan creation is not its execution date'
,
host_name
=>
{
test
.
each
([
'friday'
,
'jvs-dw'
])(
'%s plan creation is not its execution date'
,
(
host_name
)
=>
{
const
result
=
convert
({
host_name
});
expect
(
result
).
toEqual
({
id
:
base
.
fact_id
,
type
:
base
.
type
,
kind
:
'planned'
,
clinicId
:
'c'
,
clinicName
:
'诊所'
,
occurredAt
:
early
,
plannedFor
:
null
,
content
:
base
.
content
});
expect
(
PatientFactsLookupResponseSchema
.
shape
.
items
.
element
.
shape
.
facts
.
element
.
safeParse
(
result
).
success
).
toBe
(
true
);
expect
(
result
).
toEqual
({
id
:
base
.
fact_id
,
type
:
base
.
type
,
kind
:
'planned'
,
clinicExternalId
:
'c'
,
clinicName
:
'诊所'
,
occurredAt
:
early
,
plannedFor
:
null
,
content
:
{
...(
base
.
content
as
Record
<
string
,
unknown
>
),
externalId
:
null
,
emrExternalId
:
null
,
encounterExternalId
:
null
,
},
});
expect
(
PatientFactsLookupResponseSchema
.
shape
.
items
.
element
.
shape
.
facts
.
element
.
safeParse
(
result
)
.
success
,
).
toBe
(
true
);
});
it
(
'prefers stored occurrence over old planned fallback'
,
()
=>
{
expect
(
convert
({
occurred_at
:
new
Date
(
late
)
}).
occurredAt
).
toBe
(
late
);
});
test
.
each
([
'friday'
,
'jvs-dw'
])(
'%s recommendation uses its proposal time'
,
host_name
=>
{
test
.
each
([
'friday'
,
'jvs-dw'
])(
'%s recommendation uses its proposal time'
,
(
host_name
)
=>
{
expect
(
convert
({
type
:
'recommendation_record'
,
host_name
}).
occurredAt
).
toBe
(
early
);
expect
(
convert
({
type
:
'recommendation_record'
,
host_name
,
content
:
{
doctor_suggested_at
:
'2026-10-20T10:00:00+08:00'
}
})).
toMatchObject
({
occurredAt
:
late
,
plannedFor
:
null
});
});
test
.
each
([
'treatment_record'
,
'recommendation_record'
])(
'does not reinterpret unknown host %s plan time'
,
type
=>
{
expect
(
convert
({
host_name
:
'new-host'
,
type
})).
toMatchObject
({
occurredAt
:
null
,
plannedFor
:
null
});
expect
(
convert
({
type
:
'recommendation_record'
,
host_name
,
content
:
{
doctor_suggested_at
:
'2026-10-20T10:00:00+08:00'
},
}),
).
toMatchObject
({
occurredAt
:
late
,
plannedFor
:
null
});
});
test
.
each
([
'treatment_record'
,
'recommendation_record'
])(
'does not reinterpret unknown host %s plan time'
,
(
type
)
=>
{
expect
(
convert
({
host_name
:
'new-host'
,
type
})).
toMatchObject
({
occurredAt
:
null
,
plannedFor
:
null
,
});
},
);
it
(
'can use an explicit proposal timestamp even for another host'
,
()
=>
{
expect
(
convert
({
host_name
:
'new-host'
,
type
:
'recommendation_record'
,
content
:
{
doctor_suggested_at
:
early
}
}).
occurredAt
).
toBe
(
early
);
});
test
.
each
([
null
,
[],
[
null
],
[
''
],
[
'invalid'
],
[
'2026-02-30T01:00:00Z'
],
[
12345
],
[
'2026-10-01T00:00:00Z'
,
'2026-10-02T00:00:00Z'
]])(
'missing, invalid or conflicting source creation %j stays null'
,
appointment_created_values
=>
{
expect
(
convert
({
type
:
'appointment_record'
,
kind
:
'actual'
,
occurred_at
:
new
Date
(
early
),
planned_for
:
new
Date
(
late
),
content
:
{
arrived_at
:
early
},
appointment_created_values
})).
toMatchObject
({
occurredAt
:
null
,
plannedFor
:
late
});
});
test
.
each
([
'friday'
,
'jvs-dw'
])(
'%s uses source creation for both scheduled and fulfilled appointments'
,
host_name
=>
{
for
(
const
kind
of
[
'planned'
,
'actual'
])
{
expect
(
convert
({
host_name
,
type
:
'appointment_record'
,
kind
,
content
:
{
arrived_at
:
late
},
appointment_created_values
:
[
'2026-10-01 10:00:00'
,
'2026-10-01T02:00:00Z'
,
null
]
})).
toMatchObject
({
occurredAt
:
early
,
plannedFor
:
early
,
content
:
{}
});
}
expect
(
convert
({
host_name
:
'new-host'
,
type
:
'recommendation_record'
,
content
:
{
doctor_suggested_at
:
early
},
}).
occurredAt
,
).
toBe
(
early
);
});
test
.
each
([
null
,
[],
[
null
],
[
''
],
[
'invalid'
],
[
'2026-02-30T01:00:00Z'
],
[
12345
],
[
'2026-10-01T00:00:00Z'
,
'2026-10-02T00:00:00Z'
],
])(
'missing, invalid or conflicting source creation %j stays null'
,
(
appointment_created_values
)
=>
{
expect
(
convert
({
type
:
'appointment_record'
,
kind
:
'actual'
,
occurred_at
:
new
Date
(
early
),
planned_for
:
new
Date
(
late
),
content
:
{
arrived_at
:
early
},
appointment_created_values
,
}),
).
toMatchObject
({
occurredAt
:
null
,
plannedFor
:
late
});
},
);
test
.
each
([
'friday'
,
'jvs-dw'
])(
'%s uses source creation for both scheduled and fulfilled appointments'
,
(
host_name
)
=>
{
for
(
const
kind
of
[
'planned'
,
'actual'
])
{
expect
(
convert
({
host_name
,
type
:
'appointment_record'
,
kind
,
content
:
{
arrived_at
:
late
},
appointment_created_values
:
[
'2026-10-01 10:00:00'
,
'2026-10-01T02:00:00Z'
,
null
],
}),
).
toMatchObject
({
occurredAt
:
early
,
plannedFor
:
early
,
content
:
{}
});
}
},
);
it
(
'converts date-only source creation at Beijing midnight and rejects unknown hosts'
,
()
=>
{
expect
(
convert
({
type
:
'appointment_record'
,
appointment_created_values
:
[
'2026-10-01'
]
}).
occurredAt
).
toBe
(
'2026-09-30T16:00:00.000Z'
);
expect
(
convert
({
type
:
'appointment_record'
,
host_name
:
'unknown'
,
appointment_created_values
:
[
early
]
}).
occurredAt
).
toBeNull
();
expect
(
convert
({
type
:
'appointment_record'
,
appointment_created_values
:
[
'2026-10-01'
]
})
.
occurredAt
,
).
toBe
(
'2026-09-30T16:00:00.000Z'
);
expect
(
convert
({
type
:
'appointment_record'
,
host_name
:
'unknown'
,
appointment_created_values
:
[
early
],
}).
occurredAt
,
).
toBeNull
();
});
it
(
'keeps actual arrival on encounter without changing source data or old appointment APIs'
,
()
=>
{
const
row
=
{
...
base
,
type
:
'appointment_record'
,
kind
:
'actual'
,
occurred_at
:
new
Date
(
late
),
content
:
{
arrived_at
:
late
},
appointment_created_values
:
[
early
]
};
const
row
=
{
...
base
,
type
:
'appointment_record'
,
kind
:
'actual'
,
occurred_at
:
new
Date
(
late
),
content
:
{
arrived_at
:
late
},
appointment_created_values
:
[
early
],
};
const
snapshot
=
JSON
.
stringify
(
row
);
expect
(
toLookupFact
(
row
,
null
)).
toMatchObject
({
occurredAt
:
early
,
content
:
{}
});
expect
(
toLookupFact
({
...
row
,
type
:
'encounter_record'
},
null
).
occurredAt
).
toBe
(
late
);
...
...
@@ -51,9 +143,24 @@ describe('new lookup fact projection only', () => {
expect
(
JSON
.
stringify
(
row
)).
toBe
(
snapshot
);
});
it
(
'retains other stored business dates and redaction without mutating the row'
,
()
=>
{
const
row
=
{
...
base
,
type
:
'emr_record'
,
kind
:
'actual'
,
occurred_at
:
new
Date
(
early
),
planned_for
:
null
,
content
:
{
illness_desc
:
'13900139000'
,
staff_notes
:
'内部'
,
source_fact_subject_id
:
'source:1'
}
};
const
row
=
{
...
base
,
type
:
'emr_record'
,
kind
:
'actual'
,
occurred_at
:
new
Date
(
early
),
planned_for
:
null
,
content
:
{
illness_desc
:
'13900139000'
,
staff_notes
:
'内部'
,
source_fact_subject_id
:
'source:1'
,
},
};
const
snapshot
=
JSON
.
stringify
(
row
);
expect
(
toLookupFact
(
row
,
null
)).
toMatchObject
({
occurredAt
:
early
,
plannedFor
:
null
,
content
:
{
illnessDesc
:
'139****9000'
,
source_fact_subject_id
:
'source:1'
}
});
expect
(
toLookupFact
(
row
,
null
)).
toMatchObject
({
occurredAt
:
early
,
plannedFor
:
null
,
content
:
{
illnessDescription
:
'139****9000'
},
});
expect
(
toLookupFact
(
row
,
null
).
content
).
not
.
toHaveProperty
(
'staff_notes'
);
expect
(
JSON
.
stringify
(
row
)).
toBe
(
snapshot
);
});
...
...
@@ -61,7 +168,15 @@ describe('new lookup fact projection only', () => {
const
before
=
toOpenFact
(
base
);
convert
();
expect
(
toOpenFact
(
base
)).
toEqual
(
before
);
expect
(
before
).
toMatchObject
({
subjectId
:
base
.
subject_id
,
status
:
'active'
,
occurredAt
:
null
,
plannedFor
:
early
,
title
:
'治疗'
,
summary
:
'摘要'
,
steps
:
[
'composite_filling'
]
});
expect
(
before
).
toMatchObject
({
subjectId
:
base
.
subject_id
,
status
:
'active'
,
occurredAt
:
null
,
plannedFor
:
early
,
title
:
'治疗'
,
summary
:
'摘要'
,
steps
:
[
'composite_filling'
],
});
expect
(
before
).
not
.
toHaveProperty
(
'id'
);
});
});
apps/pac-service/tests/lookup-narrative.spec.ts
0 → 100644
View file @
b502e1b3
import
{
lookupNarrative
,
LookupContentError
}
from
'../src/modules/open/lookup-narrative'
;
import
{
lookupContent
}
from
'../src/modules/open/lookup-content'
;
describe
(
'lookup-only readable clinical paragraphs'
,
()
=>
{
test
.
each
([
'原文
\
n否认疼痛,1.5mm'
,
'[36] 叩痛'
,
'36 MOD 无明显异常'
])(
'preserves ordinary text: %s'
,
(
text
)
=>
{
expect
(
lookupNarrative
(
text
,
'friday'
,
'examFindings'
)).
toBe
(
text
);
},
);
test
.
each
([
'friday'
,
'jvs-dw'
])(
'%s standard entries preserve order, duplicate entries, teeth and negation'
,
(
host
)
=>
{
const
entries
=
[
{
toothPosition
:
'36 MOD'
,
message
:
'未见根尖影'
},
{
toothPosition
:
'37'
,
message
:
null
},
{
message
:
'复查'
},
{
message
:
'复查'
},
];
for
(
const
input
of
[
entries
,
JSON
.
stringify
(
entries
)])
expect
(
lookupNarrative
(
input
,
host
,
'examFindings'
)).
toBe
(
'36 MOD:未见根尖影
\
n牙位 37(未提供正文)
\
n复查
\
n复查'
,
);
expect
(
lookupNarrative
({
message
:
'正文'
},
host
,
'diagnosis'
)).
toBe
(
'正文'
);
},
);
test
.
each
([
null
,
''
,
' '
,
'null'
,
[],
{},
'[]'
,
'{}'
])(
'empty %j remains unknown'
,
(
input
)
=>
expect
(
lookupNarrative
(
input
,
'friday'
,
'examFindings'
)).
toBeNull
(),
);
test
.
each
([
[
'friday'
,
'examFindings'
,
'value'
],
[
'friday'
,
'procedure'
,
'value'
],
[
'friday'
,
'treatment'
,
'value'
],
[
'jvs-dw'
,
'treatment'
,
'treatName'
],
[
'jvs-dw'
,
'treatmentPlan'
,
'treatName'
],
])(
'known %s %s legacy %s only'
,
(
host
,
field
,
alias
)
=>
{
expect
(
lookupNarrative
([{
toothPosition
:
'36'
,
[
alias
]:
'未治疗'
}],
host
,
field
)).
toBe
(
'36:未治疗'
,
);
expect
(
lookupNarrative
([{
message
:
'相同'
,
[
alias
]:
'相同'
}],
host
,
field
)).
toBe
(
'相同'
);
expect
(()
=>
lookupNarrative
([{
message
:
'不同'
,
[
alias
]:
'原值'
}],
host
,
field
)).
toThrow
(
LookupContentError
,
);
});
it
(
'recognizes only confirmed empty editor metadata'
,
()
=>
{
const
empty
=
{
englishVal
:
[],
englishValTopRight
:
[],
englishValBotRight
:
[],
englishValBotLeft
:
[],
};
expect
(
lookupNarrative
(
[
empty
,
{
toothPosition
:
'16'
},
{},
{
message
:
'原文'
}],
'jvs-dw'
,
'examFindings'
,
),
).
toBe
(
'牙位 16(未提供正文)
\
n原文'
);
expect
(()
=>
lookupNarrative
({
englishVal
:
[
'16'
]
},
'jvs-dw'
,
'examFindings'
)).
toThrow
(
LookupContentError
,
);
});
it
(
'renders legacy quadrant selections only when they agree with the explicit tooth text'
,
()
=>
{
const
old
=
{
toothPosition
:
'1A BLM;2B;3C;4D'
,
message
:
'未见异常'
,
toothPositionBak
:
'bGVnYWN5'
,
englishVal
:
[
'A BLM'
],
englishValTopRight
:
[
'B'
],
englishValBotRight
:
[
'C'
],
englishValBotLeft
:
[
'D'
],
};
expect
(
lookupNarrative
([
old
],
'jvs-dw'
,
'examFindings'
)).
toBe
(
'1A BLM;2B;3C;4D:未见异常'
);
expect
(
lookupNarrative
({
message
:
'复查'
,
toothPositionBak
:
'AAAAAA=='
},
'jvs-dw'
,
'procedure'
),
).
toBe
(
'复查'
);
expect
(()
=>
lookupNarrative
({
...
old
,
toothPosition
:
'1A'
},
'jvs-dw'
,
'examFindings'
),
).
toThrow
(
LookupContentError
);
expect
(
lookupNarrative
({
toothPositionBak
:
'bGVnYWN5'
},
'jvs-dw'
,
'examFindings'
)).
toBeNull
();
});
test
.
each
([
{
value
:
'不猜'
},
{
unknown
:
'秘密'
},
{
message
:
'正文'
,
notes
:
'内部备注'
},
{
message
:
{
nested
:
'正文'
}
},
[{
message
:
'正常'
},
12
],
'[{"message":"断裂"'
,
'{broken}'
,
'["text"]'
,
{
toothPosition
:
36
},
])(
'rejects unsupported nonempty structure without partial success: %j'
,
(
value
)
=>
{
expect
(()
=>
lookupNarrative
(
value
,
'unknown'
,
'examFindings'
)).
toThrow
(
LookupContentError
);
});
it
(
'applies final redaction to decoded JSON and returns all eleven paragraphs'
,
()
=>
{
const
output
=
lookupContent
(
'emr_record'
,
{
exam_findings
:
'[{"toothPosition":"36","message":"联系13900139000"}]'
},
null
,
null
,
'friday'
,
);
expect
(
output
.
examFindings
).
toBe
(
'36:联系139****9000'
);
expect
(
output
.
procedure
).
toBeNull
();
expect
(
output
.
treatment
).
toBeNull
();
});
it
(
'does not include clinical text in the error'
,
()
=>
{
try
{
lookupNarrative
({
secret
:
'patient-private'
},
''
,
'diagnosis'
);
fail
(
'expected error'
);
}
catch
(
e
)
{
expect
(
JSON
.
stringify
(
e
)).
not
.
toContain
(
'patient-private'
);
expect
((
e
as
LookupContentError
).
getResponse
()).
toMatchObject
({
code
:
'FACT_CONTENT_NORMALIZATION_FAILED'
,
});
}
});
});
apps/pac-service/tests/lookup-relations.spec.ts
0 → 100644
View file @
b502e1b3
import
{
lookupContent
}
from
'../src/modules/open/lookup-content'
;
import
{
LookupEvidence
}
from
'../src/modules/open/lookup-relations'
;
const
evidence
=
(
resource
:
string
,
raw
:
Record
<
string
,
unknown
>
,
subjectType
=
resource
,
host
=
'friday'
,
):
LookupEvidence
=>
({
sourceEventId
:
`push:
${
host
}
:
${
resource
}
:brand:opaque:with:colon:v1`
,
subjectType
,
subjectId
:
'own'
,
raw
,
});
const
run
=
(
type
:
string
,
content
:
any
,
proofs
:
unknown
,
host
=
'friday'
)
=>
lookupContent
(
type
,
content
,
null
,
null
,
host
,
proofs
);
describe
(
'source-proven lookup relations'
,
()
=>
{
test
.
each
([
'diagnosis'
,
'treatment'
,
'recommendation'
])(
'%s derived from EMR is an EMR reference, not an encounter'
,
(
resource
)
=>
{
for
(
const
host
of
[
'friday'
,
'jvs-dw'
])
{
const
parent
=
host
===
'friday'
?
{
emr_sub_id
:
'EMR'
}
:
{
id
:
'EMR'
};
expect
(
run
(
`
${
resource
}
_record`
,
{
source_encounter_external_id
:
'EMR'
},
[
evidence
(
resource
,
parent
,
resource
,
host
)],
host
,
),
).
toMatchObject
({
emrExternalId
:
'EMR'
,
encounterExternalId
:
null
});
}
},
);
it
(
'does not turn Friday independent treatment plans into EMR references'
,
()
=>
{
expect
(
run
(
'treatment_record'
,
{
source_encounter_external_id
:
'same-string'
},
[
evidence
(
'treatment'
,
{
id
:
'same-string'
}),
]),
).
toMatchObject
({
emrExternalId
:
null
,
encounterExternalId
:
null
});
});
it
(
'Friday EMR appointment is in encounter domain; 5i5ya registration is not'
,
()
=>
{
const
c
=
{
emr_external_id
:
'EMR'
,
encounter_external_id
:
'APPT'
};
expect
(
run
(
'emr_record'
,
c
,
[
evidence
(
'emr'
,
{
emr_sub_id
:
'EMR'
,
appointment_id
:
'APPT'
})]),
).
toHaveProperty
(
'encounterExternalId'
,
'APPT'
);
expect
(
run
(
'emr_record'
,
c
,
[
evidence
(
'emr'
,
{
id
:
'EMR'
,
registration_id
:
'APPT'
},
'emr'
,
'jvs-dw'
)],
'jvs-dw'
,
),
).
toHaveProperty
(
'encounterExternalId'
,
null
);
});
it
(
'uses only bound, consistent source evidence and rejects wrong routes and conflicting aliases'
,
()
=>
{
const
good
=
evidence
(
'diagnosis'
,
{
emr_sub_id
:
'E'
});
const
c
=
{
source_encounter_external_id
:
'E'
};
for
(
const
proofs
of
[
null
,
[],
[
good
,
evidence
(
'diagnosis'
,
{
emr_sub_id
:
'other'
})],
[
good
,
{
...
good
,
sourceEventId
:
null
}],
[
evidence
(
'image'
,
{
emr_sub_id
:
'E'
})],
])
{
expect
(
run
(
'diagnosis_record'
,
c
,
proofs
)).
toHaveProperty
(
'emrExternalId'
,
null
);
}
expect
(
run
(
'diagnosis_record'
,
{
...
c
,
emrExternalId
:
'other'
},
[
good
])).
toHaveProperty
(
'emrExternalId'
,
null
,
);
expect
(
run
(
'diagnosis_record'
,
c
,
[
good
,
good
])).
toHaveProperty
(
'emrExternalId'
,
'E'
);
expect
(
run
(
'diagnosis_record'
,
c
,
[
good
],
'new-host'
)).
toHaveProperty
(
'emrExternalId'
,
null
);
});
it
(
'distinguishes Meyer from Friday despite shared host and image_ai code source'
,
()
=>
{
const
proof
=
{
...
evidence
(
'diagnosis'
,
{
meyer
:
true
,
sourceImageExternalId
:
'meyer:n:img'
,
emr_sub_id
:
'fake'
,
}),
sourceEventId
:
'supplement:meyer:n:diagnosis:b:d:v'
,
};
expect
(
run
(
'diagnosis_record'
,
{
source_encounter_external_id
:
'fake'
,
source_image_external_id
:
'meyer:n:img'
,
source_analysis_external_id
:
'fake'
,
code_source
:
'image_ai'
,
},
[
proof
],
),
).
toMatchObject
({
emrExternalId
:
null
,
encounterExternalId
:
null
,
imageExternalId
:
'meyer:n:img'
,
analysisExternalId
:
null
,
});
expect
(
run
(
'diagnosis_record'
,
{
source_image_external_id
:
'wrong'
},
[
proof
])).
toHaveProperty
(
'imageExternalId'
,
null
,
);
});
it
(
'JVS image parent uses bound raw parent rather than splitting an ID, Friday old image header stays unknown'
,
()
=>
{
expect
(
run
(
'image_record'
,
{
image_external_id
:
'arbitrary'
,
encounter_external_id
:
'REG'
},
[
evidence
(
'image'
,
{
id
:
'EMR'
,
hasFileUrl
:
true
},
'image'
,
'jvs-dw'
)],
'jvs-dw'
,
),
).
toMatchObject
({
emrExternalId
:
'EMR'
,
encounterExternalId
:
null
});
expect
(
run
(
'image_record'
,
{
image_external_id
:
'EMR|img|x'
},
[],
'jvs-dw'
)).
toHaveProperty
(
'emrExternalId'
,
null
,
);
expect
(
run
(
'image_record'
,
{
encounter_external_id
:
'HEADER'
},
[
evidence
(
'image'
,
{
emr_id
:
'HEADER'
}),
]),
).
toMatchObject
({
emrExternalId
:
null
,
encounterExternalId
:
null
});
});
it
(
'JVS old image diagnosis keeps EMR only, generic Friday analysis can retain explicit analysis/image references'
,
()
=>
{
expect
(
run
(
'diagnosis_record'
,
{
source_encounter_external_id
:
'E'
,
code_source
:
'image_ai'
},
[
evidence
(
'diagnosis'
,
{
emr_id
:
'E'
},
'diagnosis'
,
'jvs-dw'
)],
'jvs-dw'
,
),
).
toMatchObject
({
emrExternalId
:
'E'
,
imageExternalId
:
null
,
analysisExternalId
:
null
});
const
e
=
{
...
evidence
(
'image_analysis'
,
{
id
:
'A'
,
check_id
:
'I'
}),
subjectId
:
'A'
};
expect
(
run
(
'diagnosis_record'
,
{
source_analysis_external_id
:
'A'
,
source_image_external_id
:
'I'
},
[
e
,
]),
).
toMatchObject
({
analysisExternalId
:
'A'
,
imageExternalId
:
'I'
,
emrExternalId
:
null
});
});
it
(
'keeps only verified consultation/refund references and treats settlement number as a number, not an ID'
,
()
=>
{
expect
(
run
(
'consultation_record'
,
{
appointment_external_id
:
'A'
},
[
evidence
(
'consult'
,
{
appo_id
:
'A'
},
'consultation'
,
'jvs-dw'
)],
'jvs-dw'
,
),
).
toHaveProperty
(
'appointmentExternalId'
,
'A'
);
for
(
const
key
of
[
'settlement_id'
,
'ref_settlement_id'
])
expect
(
run
(
'refund_record'
,
{
related_payment_external_id
:
'P'
},
[
evidence
(
'refund'
,
{
[
key
]:
'P'
}),
]),
).
toHaveProperty
(
'paymentExternalId'
,
'P'
);
expect
(
run
(
'refund_record'
,
{
related_payment_external_id
:
'P'
},
[],
'jvs-dw'
)).
toHaveProperty
(
'paymentExternalId'
,
null
,
);
const
c
=
run
(
'complaint_record'
,
{
related_order_external_id
:
'0001'
,
host_status
:
'原文'
},
[
evidence
(
'complaint'
,
{
settlement_number
:
'0001'
,
complain_type_name
:
'原文'
},
'complaint'
,
'jvs-dw'
,
),
],
'jvs-dw'
,
);
expect
(
c
).
toMatchObject
({
settlementNumber
:
'0001'
,
sourceStatus
:
'原文'
});
expect
(
c
).
not
.
toHaveProperty
(
'orderExternalId'
);
expect
(
run
(
'complaint_record'
,
{
host_status
:
'resolved'
},
[],
'jvs-dw'
)).
toHaveProperty
(
'sourceStatus'
,
null
,
);
});
});
apps/pac-service/tests/meyer-imaging-postgres.spec.ts
View file @
b502e1b3
...
...
@@ -207,18 +207,21 @@ describePg('Meyer | isolated PostgreSQL + actual minimal CLI module', () => {
expect
(
patient
.
facts
).
toHaveLength
(
3
);
expect
(
patient
.
facts
.
some
((
f
:
any
)
=>
f
.
type
===
'image_analysis_record'
)).
toBe
(
false
);
const
image
=
patient
.
facts
.
find
((
f
:
any
)
=>
f
.
type
===
'image_record'
);
expect
(
image
).
toMatchObject
({
clinicId
:
'clinic-a'
,
clinicName
:
'本地影像验收诊所'
,
expect
(
image
).
toMatchObject
({
clinic
External
Id
:
'clinic-a'
,
clinicName
:
'本地影像验收诊所'
,
occurredAt
:
'2026-10-10T02:00:00.000Z'
,
plannedFor
:
null
,
content
:
{
externalId
:
'meyer:test:image-1'
,
modality
:
'pano'
}
});
expect
(
image
.
content
).
not
.
toHaveProperty
(
'file
Url
'
);
expect
(
image
.
content
).
not
.
toHaveProperty
(
'file
Location
'
);
expect
(
image
.
content
.
analysis
).
toEqual
(
meyerBundle
().
image
.
analysis
);
const
storedImage
=
await
prisma
.
patientFact
.
findUniqueOrThrow
({
where
:
{
id
:
image
.
id
}
});
expect
((
storedImage
.
content
as
any
).
analysis
).
toEqual
(
image
.
content
.
analysis
);
expect
(
storedImage
.
sourceCreatedAt
?.
toISOString
()).
toBe
(
'2026-10-10T02:00:00.000Z'
);
const
storedDiagnoses
=
await
prisma
.
patientFact
.
findMany
({
where
:
{
hostId
,
patientId
,
type
:
'diagnosis_record'
}
});
expect
(
storedDiagnoses
.
every
(
d
=>
d
.
sourceCreatedAt
===
null
)).
toBe
(
true
);
const
diagnoses
=
patient
.
facts
.
filter
((
f
:
any
)
=>
f
.
type
===
'diagnosis_record'
);
expect
(
diagnoses
).
toHaveLength
(
2
);
for
(
const
diagnosis
of
diagnoses
)
{
expect
(
diagnosis
).
toMatchObject
({
occurredAt
:
'2026-10-09T01:00:00.000Z'
,
plannedFor
:
null
,
content
:
{
codeSource
:
'image_ai'
,
sourceI
mageExternalId
:
image
.
content
.
externalId
}
});
content
:
{
codeSource
:
'image_ai'
,
i
mageExternalId
:
image
.
content
.
externalId
}
});
expect
(
diagnosis
.
content
).
not
.
toHaveProperty
(
'sourceAnalysisExternalId'
);
expect
(
diagnosis
.
content
).
not
.
toHaveProperty
(
'code_source'
);
}
...
...
apps/pac-service/tests/open-fact-content-doc.spec.ts
View file @
b502e1b3
...
...
@@ -6,22 +6,40 @@ import { readFileSync } from 'node:fs';
import
{
resolve
}
from
'node:path'
;
import
{
FactType
}
from
'@pac/types'
;
import
{
FactContentSchemas
}
from
'../src/modules/sync/pipeline/fact-content-schemas'
;
import
{
lookupContentNames
}
from
'../src/modules/open/lookup-content'
;
import
{
LookupContentSchemas
,
LookupClinicalSignalSchema
,
LookupQualitySchema
,
LookupFollowUpSchema
,
}
from
'../src/modules/open/lookup-content.schema'
;
import
{
toLookupFact
}
from
'../src/modules/open/lookup-fact'
;
import
{
redactJson
}
from
'../src/modules/open/open-redact'
;
import
{
PatientFactsLookupResponseSchema
}
from
'../src/modules/open/patient-facts-lookup.schema'
;
const
text
=
readFileSync
(
resolve
(
__dirname
,
'../../pac-docs/content/docs/api/open/PatientFactsLookupController_lookup.mdx'
),
resolve
(
__dirname
,
'../../pac-docs/content/docs/api/open/PatientFactsLookupController_lookup.mdx'
,
),
'utf8'
,
);
const
examples
=
[...
text
.
matchAll
(
/```json
\n([\s\S]
*
?)\n
```/g
)].
map
((
m
)
=>
JSON
.
parse
(
m
[
1
]
!
)).
filter
((
v
)
=>
typeof
v
.
type
===
'string'
&&
v
.
content
);
const
examples
=
[...
text
.
matchAll
(
/```json
\n([\s\S]
*
?)\n
```/g
)]
.
map
((
m
)
=>
JSON
.
parse
(
m
[
1
]
!
))
.
filter
((
v
)
=>
typeof
v
.
type
===
'string'
&&
v
.
content
);
// 当前外部文档仅列已接入数据的类型;不收窄后端支持的契约。
const
documentedTypes
=
[
'consultation_record'
,
'encounter_record'
,
'diagnosis_record'
,
'treatment_record'
,
'recommendation_record'
,
'emr_record'
,
'image_record'
,
'appointment_record'
,
'payment_record'
,
'refund_record'
,
'complaint_record'
,
'consultation_record'
,
'encounter_record'
,
'diagnosis_record'
,
'treatment_record'
,
'recommendation_record'
,
'emr_record'
,
'image_record'
,
'appointment_record'
,
'payment_record'
,
'refund_record'
,
'complaint_record'
,
];
describe
(
'public fact content reference'
,
()
=>
{
...
...
@@ -33,102 +51,178 @@ describe('public fact content reference', () => {
'$type example passes canonical validation and open serialization'
,
({
type
,
content
,
occurredAt
,
plannedFor
})
=>
{
const
schema
=
FactContentSchemas
[
type
as
keyof
typeof
FactContentSchemas
];
const
sources
=
JSON
.
parse
(
readFileSync
(
resolve
(
__dirname
,
'fixtures/lookup-content-source.json'
),
'utf8'
));
const
sources
=
JSON
.
parse
(
readFileSync
(
resolve
(
__dirname
,
'fixtures/lookup-content-source.json'
),
'utf8'
),
);
const
stored
=
schema
.
parse
(
sources
.
find
((
r
:
any
)
=>
r
.
type
===
type
).
content
);
const
out
=
toLookupFact
({
fact_id
:
'33333333-3333-4333-8333-333333333333'
,
host_name
:
'friday'
,
appointment_created_values
:
type
===
'appointment_record'
?
[
'2026-10-19 09:00:00'
]
:
null
,
subject_id
:
`
${
type
}
:fixture`
,
type
,
kind
:
'actual'
,
status
:
'active'
,
clinic_id
:
null
,
occurred_at
:
null
,
planned_for
:
null
,
valid_until
:
null
,
title
:
null
,
summary
:
null
,
content
:
stored
,
},
null
);
const
out
=
toLookupFact
(
{
fact_id
:
'33333333-3333-4333-8333-333333333333'
,
host_name
:
'friday'
,
appointment_created_values
:
type
===
'appointment_record'
?
[
'2026-10-19 09:00:00'
]
:
null
,
source_evidence
:
[
{
sourceEventId
:
`cold-import:
${
type
===
'emr_record'
?
'emr'
:
type
===
'diagnosis_record'
?
'diagnosis'
:
'refund'
}
:brand:own:v1`
,
subjectType
:
type
.
replace
(
'_record'
,
''
),
subjectId
:
'own'
,
raw
:
{
emr_sub_id
:
'emr-1'
,
appointment_id
:
'enc-1'
,
ref_settlement_id
:
'payment-1'
},
},
],
subject_id
:
`
${
type
}
:fixture`
,
type
,
kind
:
'actual'
,
status
:
'active'
,
clinic_id
:
null
,
occurred_at
:
null
,
planned_for
:
null
,
valid_until
:
null
,
title
:
null
,
summary
:
null
,
content
:
stored
,
},
null
,
);
const
factSchema
=
PatientFactsLookupResponseSchema
.
shape
.
items
.
element
.
shape
.
facts
.
element
;
expect
(
factSchema
.
safeParse
({
...
out
,
clinicName
:
null
}).
success
).
toBe
(
true
);
// 文档不展示会在出站被移除的键,也不依赖未说明的存储默认值。
expect
(
redactJson
(
content
)).
toEqual
(
content
);
expect
(
out
.
content
).
toMatchObject
(
content
);
expect
(
out
).
toMatchObject
({
occurredAt
,
plannedFor
});
expect
(
Object
.
keys
(
out
).
sort
()).
toEqual
([
'id'
,
'type'
,
'kind'
,
'clinicId'
,
'clinicName'
,
'occurredAt'
,
'plannedFor'
,
'content'
].
sort
());
expect
(
Object
.
keys
(
out
).
sort
()).
toEqual
(
[
'id'
,
'type'
,
'kind'
,
'clinicExternalId'
,
'clinicName'
,
'occurredAt'
,
'plannedFor'
,
'content'
,
].
sort
(),
);
},
);
it
(
'documents every
standard top-level content field except keys intentionally redacte
d'
,
()
=>
{
for
(
const
[
type
,
schema
]
of
Object
.
entries
(
Fact
ContentSchemas
))
{
it
(
'documents every
whitelisted top-level content fiel
d'
,
()
=>
{
for
(
const
[
type
,
schema
]
of
Object
.
entries
(
Lookup
ContentSchemas
))
{
if
(
!
documentedTypes
.
includes
(
type
))
continue
;
const
section
=
text
.
split
(
`<details id="
${
type
}
">`
)[
1
]
!
.
split
(
'</details>'
)[
0
]
!
;
const
fields
=
[...
section
.
matchAll
(
/
\|
`
([^
`
]
+
)
`
\|
/g
)].
flatMap
((
m
)
=>
m
[
1
]
!
.
split
(
' / '
).
map
((
f
)
=>
f
.
split
(
/
[
.[
]
/
)[
0
]
!
),
);
for
(
const
field
of
Object
.
keys
(
schema
.
shape
))
{
if
([
'notes'
,
'staff_notes'
].
includes
(
field
)
||
(
type
===
'appointment_record'
&&
field
===
'arrived_at'
))
continue
;
expect
({
type
,
field
,
documented
:
fields
.
includes
(
lookupContentNames
[
type
]?.[
field
]
??
field
)
}).
toEqual
({
for
(
const
field
of
Object
.
keys
(
schema
.
shape
))
expect
({
type
,
field
,
documented
:
fields
.
includes
(
field
)
}).
toEqual
({
type
,
field
,
documented
:
true
,
});
}
}
});
it
(
'covers nested business fields and has no private passthrough descriptions'
,
()
=>
{
for
(
const
schema
of
[
LookupClinicalSignalSchema
,
LookupQualitySchema
,
LookupFollowUpSchema
])
{
for
(
const
key
of
Object
.
keys
(
schema
.
shape
))
expect
(
text
).
toContain
(
key
);
}
expect
(
text
).
not
.
toMatch
(
/未知扩展和异值别名可能保留|历史字符串可能含序列化 JSON/
);
});
it
(
'the complete response example validates against the public schema'
,
()
=>
{
const
all
=
[...
text
.
matchAll
(
/```json
\n([\s\S]
*
?)\n
```/g
)].
map
((
m
)
=>
JSON
.
parse
(
m
[
1
]
!
));
const
response
=
all
.
find
((
v
)
=>
v
.
data
?.
items
);
expect
(
PatientFactsLookupResponseSchema
.
safeParse
(
response
.
data
).
success
).
toBe
(
true
);
});
it
(
'documents every embedded analysis field and validates the nested example'
,
()
=>
{
const
section
=
text
.
split
(
'<details id="image_record">'
)[
1
]
!
.
split
(
'</details>'
)[
0
]
!
;
const
fields
=
[...
section
.
matchAll
(
/
\|
`
([^
`
]
+
)
`
\|
/g
)].
map
(
m
=>
m
[
1
]);
for
(
const
field
of
Object
.
keys
(
ImageEmbeddedAnalysisSchema
.
shape
))
expect
(
fields
).
toContain
(
field
);
const
fields
=
[...
section
.
matchAll
(
/
\|
`
([^
`
]
+
)
`
\|
/g
)].
map
((
m
)
=>
m
[
1
]);
for
(
const
field
of
Object
.
keys
(
ImageEmbeddedAnalysisSchema
.
shape
))
expect
(
fields
).
toContain
(
field
);
for
(
const
field
of
Object
.
keys
(
ImageEmbeddedAnalysisSchema
.
shape
.
findings
.
element
.
shape
))
{
expect
(
fields
).
toContain
(
`findings[].
${
field
}
`
);
}
const
example
=
[...
text
.
matchAll
(
/```json
\n([\s\S]
*
?)\n
```/g
)].
map
(
m
=>
JSON
.
parse
(
m
[
1
]
!
)).
find
(
v
=>
v
.
facts
);
const
example
=
[...
text
.
matchAll
(
/```json
\n([\s\S]
*
?)\n
```/g
)]
.
map
((
m
)
=>
JSON
.
parse
(
m
[
1
]
!
))
.
find
((
v
)
=>
v
.
facts
);
const
analysis
=
example
.
facts
.
find
((
f
:
any
)
=>
f
.
type
===
'image_record'
).
content
.
analysis
;
expect
(
ImageEmbeddedAnalysisSchema
.
parse
(
analysis
)).
toEqual
(
analysis
);
});
it
(
'Meyer image and diagnosis example matches the actual adapter and public serializer'
,
()
=>
{
const
example
=
[...
text
.
matchAll
(
/```json
\n([\s\S]
*
?)\n
```/g
)].
map
(
m
=>
JSON
.
parse
(
m
[
1
]
!
)).
find
(
v
=>
v
.
facts
)?.
facts
;
const
example
=
[...
text
.
matchAll
(
/```json
\n([\s\S]
*
?)\n
```/g
)]
.
map
((
m
)
=>
JSON
.
parse
(
m
[
1
]
!
))
.
find
((
v
)
=>
v
.
facts
)?.
facts
;
expect
(
example
).
toHaveLength
(
2
);
const
bundle
=
meyerBundle
();
const
diagnosis
=
bundle
.
diagnoses
.
find
(
d
=>
d
.
name
===
'龋齿'
)
!
;
const
diagnosis
=
bundle
.
diagnoses
.
find
(
(
d
)
=>
d
.
name
===
'龋齿'
)
!
;
for
(
const
item
of
example
)
{
const
image
=
item
.
type
===
'image_record'
;
const
canonicalRow
=
image
?
bundle
.
image
:
diagnosis
;
const
transaction
=
{
id
:
'tx'
,
hostId
:
'host'
,
tenantId
:
'tenant'
,
patientId
:
'patient'
,
const
transaction
=
{
id
:
'tx'
,
hostId
:
'host'
,
tenantId
:
'tenant'
,
patientId
:
'patient'
,
action
:
image
?
Action
.
IMAGE_UPLOADED
:
Action
.
DIAGNOSIS_RECORDED
,
subjectType
:
image
?
'image_study'
:
'diagnosis'
,
subjectId
:
canonicalRow
.
externalId
,
clinicId
:
'clinic-a'
,
occurredAt
:
new
Date
(
image
?
bundle
.
image
.
uploadedAt
:
diagnosis
.
occurredAt
)
};
subjectType
:
image
?
'image_study'
:
'diagnosis'
,
subjectId
:
canonicalRow
.
externalId
,
clinicId
:
'clinic-a'
,
occurredAt
:
new
Date
(
image
?
bundle
.
image
.
uploadedAt
:
diagnosis
.
occurredAt
),
};
const
parser
=
image
?
new
ImageParser
()
:
new
DiagnosisParser
();
const
draft
=
parser
.
parse
({
transaction
,
canonicalRow
})[
0
]
!
;
const
out
=
toLookupFact
({
fact_id
:
item
.
id
,
host_name
:
'friday'
,
subject_id
:
draft
.
subjectId
,
type
:
draft
.
type
,
kind
:
draft
.
kind
,
status
:
draft
.
status
??
'active'
,
clinic_id
:
draft
.
clinicId
??
null
,
occurred_at
:
draft
.
occurredAt
??
null
,
planned_for
:
null
,
valid_until
:
null
,
title
:
draft
.
title
??
null
,
summary
:
draft
.
summary
??
null
,
content
:
draft
.
content
},
'示例诊所'
);
const
out
=
toLookupFact
(
{
fact_id
:
item
.
id
,
host_name
:
'friday'
,
subject_id
:
draft
.
subjectId
,
type
:
draft
.
type
,
kind
:
draft
.
kind
,
status
:
draft
.
status
??
'active'
,
clinic_id
:
draft
.
clinicId
??
null
,
occurred_at
:
draft
.
occurredAt
??
null
,
planned_for
:
null
,
valid_until
:
null
,
title
:
draft
.
title
??
null
,
summary
:
draft
.
summary
??
null
,
content
:
draft
.
content
,
source_evidence
:
[
{
sourceEventId
:
'supplement:meyer:test:diagnosis:brand:own:v1'
,
subjectType
:
transaction
.
subjectType
,
subjectId
:
canonicalRow
.
externalId
,
raw
:
{
meyer
:
true
,
sourceImageExternalId
:
bundle
.
image
.
externalId
},
},
],
},
'示例诊所'
,
);
expect
(
out
).
toMatchObject
(
item
);
expect
(
PatientFactsLookupResponseSchema
.
shape
.
items
.
element
.
shape
.
facts
.
element
.
safeParse
(
item
).
success
).
toBe
(
true
);
expect
(
PatientFactsLookupResponseSchema
.
shape
.
items
.
element
.
shape
.
facts
.
element
.
safeParse
(
item
)
.
success
,
).
toBe
(
true
);
}
});
it
(
'keeps lifecycle state, business state and treatment kind distinct'
,
()
=>
{
const
planned
=
toLookupFact
({
fact_id
:
'33333333-3333-4333-8333-333333333333'
,
host_name
:
'friday'
,
subject_id
:
'treatment:1'
,
type
:
'treatment_record'
,
kind
:
'planned'
,
status
:
'active'
,
clinic_id
:
null
,
occurred_at
:
null
,
planned_for
:
new
Date
(
'2026-11-01T00:00:00Z'
),
valid_until
:
null
,
title
:
null
,
summary
:
null
,
content
:
{
category
:
'endodontic'
,
status
:
'planned'
,
treat_stages
:
[
'canal_filling'
]
},
},
null
);
const
planned
=
toLookupFact
(
{
fact_id
:
'33333333-3333-4333-8333-333333333333'
,
host_name
:
'friday'
,
subject_id
:
'treatment:1'
,
type
:
'treatment_record'
,
kind
:
'planned'
,
status
:
'active'
,
clinic_id
:
null
,
occurred_at
:
null
,
planned_for
:
new
Date
(
'2026-11-01T00:00:00Z'
),
valid_until
:
null
,
title
:
null
,
summary
:
null
,
content
:
{
category
:
'endodontic'
,
status
:
'planned'
,
treat_stages
:
[
'canal_filling'
]
},
},
null
,
);
expect
(
planned
).
toMatchObject
({
kind
:
'planned'
,
occurredAt
:
'2026-11-01T00:00:00.000Z'
,
plannedFor
:
null
,
content
:
{
status
:
'planned'
,
treatS
tages
:
[
'canal_filling'
]
},
content
:
{
status
:
'planned'
,
s
tages
:
[
'canal_filling'
]
},
});
});
});
apps/pac-service/tests/open-patient-facts-local.spec.ts
View file @
b502e1b3
...
...
@@ -370,7 +370,7 @@ suite('local HTTP + PostgreSQL patient facts acceptance', () => {
),
).
toBe
(
false
);
expect
(
new
Set
(
facts
.
map
((
f
:
any
)
=>
f
.
id
)).
size
).
toBe
(
facts
.
length
);
expect
(
Object
.
keys
(
facts
[
0
]).
sort
()).
toEqual
([
'id'
,
'type'
,
'kind'
,
'clinicId'
,
'clinicName'
,
'occurredAt'
,
'plannedFor'
,
'content'
].
sort
());
expect
(
Object
.
keys
(
facts
[
0
]).
sort
()).
toEqual
([
'id'
,
'type'
,
'kind'
,
'clinic
External
Id'
,
'clinicName'
,
'occurredAt'
,
'plannedFor'
,
'content'
].
sort
());
const
long
=
r
.
data
.
items
.
find
((
i
:
any
)
=>
i
.
facts
.
length
>
200
).
facts
;
expect
(
long
[
0
].
occurredAt
).
toBe
(
'2026-10-02T00:00:00.000Z'
);
expect
(
long
.
at
(
-
1
).
content
.
externalId
).
toBe
(
'no-time'
);
...
...
@@ -389,7 +389,7 @@ suite('local HTTP + PostgreSQL patient facts acceptance', () => {
expect
(
a
.
patient
).
toMatchObject
({
gender
:
'女'
,
birthDate
:
'2010-01-02'
,
dedicatedCs
:
{
i
d
:
'source-staff-123'
,
name
:
'客服139****9000'
},
dedicatedCs
:
{
externalI
d
:
'source-staff-123'
,
name
:
'客服139****9000'
},
});
expect
(
a
.
patient
).
toMatchObject
({
acquisitionChannel
:
'word_of_mouth'
,
...
...
apps/pac-service/tests/patient-facts-lookup.spec.ts
View file @
b502e1b3
...
...
@@ -30,7 +30,7 @@ describe('patient facts lookup contract', () => {
patient_name
:
'同名'
,
medical_record_number
:
'001'
,
phone
:
'13800138000'
,
fact_id
:
`
${
i
}
`
,
fact_id
:
`
11111111-1111-4111-8111-
${
String
(
i
).
padStart
(
12
,
'0'
)
}
`
,
subject_id
:
`s
${
i
}
`
,
type
:
'emr_record'
,
kind
:
'actual'
,
...
...
@@ -90,7 +90,7 @@ describe('patient facts lookup contract', () => {
expect
(
result
.
items
[
0
]
!
.
facts
).
toHaveLength
(
251
);
expect
(
result
.
items
[
1
]
!
.
facts
).
toEqual
([]);
expect
(
result
.
items
[
0
]
!
.
patient
.
phone
).
toBe
(
'138****8000'
);
expect
(
result
.
items
[
0
]
!
.
patient
.
dedicatedCs
).
toEqual
({
i
d
:
'staff-id'
,
name
:
'客服'
});
expect
(
result
.
items
[
0
]
!
.
patient
.
dedicatedCs
).
toEqual
({
externalI
d
:
'staff-id'
,
name
:
'客服'
});
for
(
const
item
of
result
.
items
)
{
for
(
const
field
of
[
'doNotContact'
,
...
...
@@ -109,13 +109,18 @@ describe('patient facts lookup contract', () => {
expect
(
result
.
items
[
0
]
!
.
facts
[
0
]).
toMatchObject
({
clinicName
:
'测试诊所'
,
id
:
expect
.
any
(
String
),
content
:
{
illnessDesc
:
'电话139****9000'
},
content
:
{
illnessDesc
ription
:
'电话139****9000'
},
});
expect
(
JSON
.
stringify
(
result
)).
not
.
toContain
(
'staff_notes'
);
const
sql
=
prisma
.
$queryRaw
.
mock
.
calls
[
0
][
0
];
expect
(
sql
.
sql
).
not
.
toMatch
(
/LIMIT|ILIKE/
);
expect
(
sql
.
sql
).
toContain
(
'AND p.name ='
);
expect
(
sql
.
sql
).
toContain
(
'AND p.phone ='
);
expect
(
sql
.
sql
).
toContain
(
't.id = ANY(f.transaction_ids)'
);
expect
(
sql
.
sql
).
toContain
(
't.host_id = f.host_id AND t.tenant_id = f.tenant_id'
);
expect
(
sql
.
sql
).
toContain
(
't.patient_id = f.patient_id'
);
expect
(
sql
.
sql
).
toContain
(
't.clinic_id IS NOT DISTINCT FROM f.clinic_id'
);
expect
(
sql
.
sql
).
toContain
(
'count(*) = cardinality(f.transaction_ids)'
);
expect
(
sql
.
values
).
toContain
(
'h'
);
});
test
.
each
([
...
...
@@ -126,10 +131,10 @@ describe('patient facts lookup contract', () => {
[{
dedicatedCs
:
'invalid'
},
null
],
[{
dedicatedCs
:
[]
},
null
],
[{
dedicatedCs
:
{
id
:
{},
name
:
[]
}
},
null
],
[{
dedicatedCs
:
{
id
:
'13900139000'
}
},
{
i
d
:
'13900139000'
,
name
:
null
}],
[{
dedicatedCs
:
{
id
:
'13900139000'
}
},
{
externalI
d
:
'13900139000'
,
name
:
null
}],
[
{
dedicatedCs
:
{
name
:
' 客服13900139000 '
,
internal
:
'hidden'
}
},
{
i
d
:
null
,
name
:
'客服139****9000'
},
{
externalI
d
:
null
,
name
:
'客服139****9000'
},
],
])(
'projects only dedicated customer service from %j'
,
async
(
preferences
,
expected
)
=>
{
const
prisma
:
any
=
{
...
...
apps/pac-service/tests/source-created-time-postgres.spec.ts
0 → 100644
View file @
b502e1b3
import
{
readFileSync
}
from
'node:fs'
;
import
{
join
}
from
'node:path'
;
import
*
as
yaml
from
'js-yaml'
;
import
{
PrismaClient
}
from
'@prisma/client'
;
import
{
Logger
}
from
'@nestjs/common'
;
import
{
ColdImportManifestSchema
}
from
'../src/modules/sync/cold-import/manifest.schema'
;
import
{
AssemblerConfigSchema
}
from
'../src/modules/sync/assembler/assembler.schema'
;
import
{
AssemblerEngine
}
from
'../src/modules/sync/assembler/assembler-engine'
;
import
{
TransformEngine
}
from
'../src/modules/sync/transforms/transform-engine'
;
import
{
TransactionSynthesizer
}
from
'../src/modules/sync/pipeline/transaction-synthesizer'
;
import
{
ParserPipeline
}
from
'../src/modules/sync/pipeline/parser-pipeline.service'
;
import
{
FactWriter
}
from
'../src/modules/sync/pipeline/fact-writer.service'
;
import
{
RefundParser
}
from
'../src/modules/sync/pipeline/parsers/refund.parser'
;
const
url
=
process
.
env
.
PAC_MEYER_TEST_DATABASE_URL
;
const
describePg
=
url
?
describe
:
describe
.
skip
;
describePg
(
'来源创建时间 | 独立本地 PostgreSQL'
,
()
=>
{
let
db
:
PrismaClient
,
hostId
:
string
,
patientId
:
string
,
pipeline
:
ParserPipeline
;
const
tenantId
=
'source-time-ci'
,
sourceUnit
=
'brand-ci'
;
const
manifest
=
ColdImportManifestSchema
.
parse
(
yaml
.
load
(
readFileSync
(
join
(
__dirname
,
'../data/jvs-dw/manifest.yaml'
),
'utf8'
)));
const
config
=
AssemblerConfigSchema
.
parse
(
yaml
.
load
(
readFileSync
(
join
(
__dirname
,
'../data/jvs-dw/assemblers/refund.yaml'
),
'utf8'
)));
beforeAll
(
async
()
=>
{
const
u
=
new
URL
(
url
!
);
if
(
!
[
'127.0.0.1'
,
'localhost'
].
includes
(
u
.
hostname
)
||
!
u
.
pathname
.
startsWith
(
'/pac_meyer_ci_'
))
throw
new
Error
(
'isolated local database required'
);
Logger
.
overrideLogger
(
false
);
db
=
new
PrismaClient
({
datasources
:
{
db
:
{
url
}
}
});
hostId
=
(
await
db
.
host
.
create
({
data
:
{
name
:
'source-time-ci'
,
appId
:
'source-time-ci'
,
appSecretHash
:
'test'
}
})).
id
;
patientId
=
(
await
db
.
patient
.
create
({
data
:
{
hostId
,
tenantId
,
sourceUnit
,
externalId
:
'patient-ci'
}
})).
id
;
pipeline
=
new
ParserPipeline
({
get
:
()
=>
new
RefundParser
()
}
as
never
,
new
FactWriter
(
db
as
never
),
db
as
never
,
{
enrichAll
:
(
drafts
:
unknown
)
=>
drafts
}
as
never
);
});
beforeEach
(
async
()
=>
{
await
db
.
patientFact
.
deleteMany
({
where
:
{
hostId
}
});
await
db
.
patientTransaction
.
deleteMany
({
where
:
{
hostId
}
});
});
afterAll
(
async
()
=>
{
if
(
!
db
)
return
;
await
db
.
patientFact
.
deleteMany
({
where
:
{
hostId
}
});
await
db
.
patientTransaction
.
deleteMany
({
where
:
{
hostId
}
});
await
db
.
patient
.
deleteMany
({
where
:
{
hostId
}
});
await
db
.
host
.
delete
({
where
:
{
id
:
hostId
}
});
await
db
.
$disconnect
();
});
async
function
prepare
(
extra
:
Record
<
string
,
unknown
>
=
{})
{
const
raw
=
{
id
:
'refund-ci'
,
patient_id
:
'patient-ci'
,
organization_id
:
'clinic-ci'
,
brand
:
sourceUnit
,
created_date
:
'2026-10-21 08:15:00'
,
updated_date
:
'2026-10-21 09:00:00'
,
settlement_money
:
100
,
...
extra
};
const
tables
=
new
TransformEngine
().
run
({
tables
:
{
fact_settlement_out_refund
:
[
raw
]
},
transforms
:
manifest
.
transforms
!
.
filter
(
t
=>
'output'
in
t
&&
t
.
output
===
'refund_rows'
)
});
const
result
=
new
AssemblerEngine
().
assemble
({
tables
,
config
,
hostId
,
normalize
:
{
timezone
:
manifest
.
timezone
,
amountUnit
:
'fen'
}
});
expect
(
result
.
errors
).
toEqual
([]);
const
row
=
result
.
rows
[
0
]
!
,
canonicalRow
=
row
.
canonical
as
Record
<
string
,
unknown
>
;
const
data
=
new
TransactionSynthesizer
().
synthesize
({
rawRow
:
row
.
rawSource
,
canonicalRow
,
emits
:
config
.
emits
,
resource
:
'refund'
,
hostId
,
tenantId
,
sourceUnit
,
sourceContext
:
'source-time-ci'
,
patientIdResolver
:
()
=>
patientId
})
!
;
const
tx
=
await
db
.
patientTransaction
.
create
({
data
});
return
{
canonicalRow
,
transaction
:
{
...
tx
,
action
:
'refund_created'
as
const
}
};
}
test
(
'事实表仅保留来源创建、更新时间与原有来源组织字段'
,
async
()
=>
{
const
columns
=
await
db
.
$queryRaw
<
Array
<
{
column_name
:
string
}
>>
`
SELECT column_name FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'patient_facts'
AND column_name LIKE 'source\\_%' ESCAPE '\\'
ORDER BY column_name`
;
expect
(
columns
.
map
(
c
=>
c
.
column_name
)).
toEqual
([
'source_created_at'
,
'source_unit'
,
'source_updated_at'
]);
});
test
.
each
([
'single'
,
'batch'
]
as
const
)(
'%s 从原始退费到事实写入 UTC 来源时间,业务内容不变'
,
async
mode
=>
{
const
item
=
await
prepare
();
const
metrics
=
mode
===
'single'
?
await
pipeline
.
runForTransaction
(
item
)
:
await
pipeline
.
runForBatch
([
item
]);
expect
(
metrics
.
factsFailed
).
toBe
(
0
);
const
fact
=
await
db
.
patientFact
.
findFirstOrThrow
({
where
:
{
hostId
}
});
expect
(
fact
.
sourceCreatedAt
?.
toISOString
()).
toBe
(
'2026-10-21T00:15:00.000Z'
);
expect
(
fact
.
sourceUpdatedAt
?.
toISOString
()).
toBe
(
'2026-10-21T01:00:00.000Z'
);
expect
(
fact
.
occurredAt
?.
toISOString
()).
toBe
(
'2026-10-21T00:15:00.000Z'
);
expect
(
fact
.
content
).
toMatchObject
({
amount_cents
:
100
});
});
test
(
'旧版本不补写;重复不造版本,正常业务更新的新版本才带来源创建时间'
,
async
()
=>
{
const
first
=
await
prepare
();
delete
first
.
canonicalRow
.
sourceCreatedAt
;
await
pipeline
.
runForTransaction
(
first
);
const
before
=
await
db
.
patientFact
.
findFirstOrThrow
({
where
:
{
hostId
}
});
expect
(
before
.
sourceCreatedAt
).
toBeNull
();
first
.
canonicalRow
.
sourceCreatedAt
=
'2026-10-21T00:15:00.000Z'
;
await
pipeline
.
runForBatch
([
first
]);
expect
(
await
db
.
patientFact
.
count
({
where
:
{
hostId
}
})).
toBe
(
1
);
expect
((
await
db
.
patientFact
.
findUniqueOrThrow
({
where
:
{
id
:
before
.
id
}
})).
sourceCreatedAt
).
toBeNull
();
await
pipeline
.
runForBatch
([
await
prepare
({
updated_date
:
'2026-10-21 10:00:00'
,
settlement_money
:
200
})]);
const
facts
=
await
db
.
patientFact
.
findMany
({
where
:
{
hostId
},
orderBy
:
{
version
:
'asc'
}
});
expect
(
facts
).
toHaveLength
(
2
);
expect
(
facts
[
0
]
!
.
sourceCreatedAt
).
toBeNull
();
expect
(
facts
[
1
]
!
.
sourceCreatedAt
?.
toISOString
()).
toBe
(
'2026-10-21T00:15:00.000Z'
);
expect
(
facts
[
1
]
!
.
sourceUpdatedAt
?.
toISOString
()).
toBe
(
'2026-10-21T02:00:00.000Z'
);
});
test
(
'没有精确创建时间时不借用 PAC/更新时间'
,
async
()
=>
{
const
item
=
await
prepare
({
created_date
:
'2026-10-21'
});
await
pipeline
.
runForTransaction
(
item
);
const
fact
=
await
db
.
patientFact
.
findFirstOrThrow
({
where
:
{
hostId
}
});
expect
(
fact
.
sourceCreatedAt
).
toBeNull
();
});
});
apps/pac-service/tests/source-created-time.spec.ts
0 → 100644
View file @
b502e1b3
import
{
readFileSync
}
from
'node:fs'
;
import
{
join
}
from
'node:path'
;
import
*
as
yaml
from
'js-yaml'
;
import
{
Logger
}
from
'@nestjs/common'
;
import
{
AssemblerConfigSchema
}
from
'../src/modules/sync/assembler/assembler.schema'
;
import
{
AssemblerEngine
}
from
'../src/modules/sync/assembler/assembler-engine'
;
import
{
TransformEngine
}
from
'../src/modules/sync/transforms/transform-engine'
;
import
{
ColdImportManifestSchema
}
from
'../src/modules/sync/cold-import/manifest.schema'
;
import
{
TransactionSynthesizer
}
from
'../src/modules/sync/pipeline/transaction-synthesizer'
;
import
{
RefundParser
}
from
'../src/modules/sync/pipeline/parsers/refund.parser'
;
import
{
ParserPipeline
}
from
'../src/modules/sync/pipeline/parser-pipeline.service'
;
import
{
readSourceCreatedTime
}
from
'../src/modules/sync/source-created-time'
;
import
{
ImageAnalysisParser
}
from
'../src/modules/sync/pipeline/parsers/image-analysis.parser'
;
import
{
DiagnosisParser
}
from
'../src/modules/sync/pipeline/parsers/diagnosis.parser'
;
import
{
canonicalAnalysis
,
parserContext
}
from
'./image-analysis-fixtures'
;
const
file
=
(
p
:
string
)
=>
yaml
.
load
(
readFileSync
(
join
(
__dirname
,
'../data'
,
p
),
'utf8'
));
const
manifest
=
ColdImportManifestSchema
.
parse
(
file
(
'jvs-dw/manifest.yaml'
));
const
config
=
AssemblerConfigSchema
.
parse
(
file
(
'jvs-dw/assemblers/refund.yaml'
));
const
created
=
'2026-10-21T00:15:00.000Z'
;
const
updated
=
'2026-10-21T01:00:00.000Z'
;
const
raw
=
(
overrides
=
{})
=>
({
id
:
'refund-1'
,
patient_id
:
'patient-1'
,
organization_id
:
'clinic-1'
,
brand
:
'brand-1'
,
created_date
:
'2026-10-21 08:15:00'
,
updated_date
:
'2026-10-21 09:00:00'
,
settlement_money
:
100
,
settlement_status
:
4
,
is_refund
:
1
,
...
overrides
});
function
assemble
(
input
=
raw
())
{
const
tables
=
new
TransformEngine
().
run
({
tables
:
{
fact_settlement_out_refund
:
[
input
]
},
transforms
:
manifest
.
transforms
!
.
filter
(
t
=>
'output'
in
t
&&
t
.
output
===
'refund_rows'
)
});
return
new
AssemblerEngine
().
assemble
({
tables
,
config
,
normalize
:
{
timezone
:
manifest
.
timezone
,
amountUnit
:
'fen'
},
hostId
:
'host'
});
}
beforeAll
(()
=>
Logger
.
overrideLogger
(
false
));
test
(
'真实退费选列、映射、事件、解析链保留源更新时间和原文;更新能形成新事件'
,
()
=>
{
const
input
=
raw
();
const
r
=
assemble
(
input
);
expect
(
r
.
errors
).
toEqual
([]);
const
c
=
r
.
rows
[
0
]
!
.
canonical
as
Record
<
string
,
unknown
>
;
expect
(
readSourceCreatedTime
(
c
)).
toEqual
({
sourceCreatedAt
:
new
Date
(
created
)
});
expect
(
new
Date
(
c
.
updatedAt
as
string
).
toISOString
()).
toBe
(
updated
);
expect
(
r
.
rows
[
0
]
!
.
rawSource
).
toEqual
(
input
);
const
synthesize
=
(
row
:
typeof
r
.
rows
[
number
])
=>
new
TransactionSynthesizer
().
synthesize
({
rawRow
:
row
.
rawSource
,
canonicalRow
:
row
.
canonical
as
Record
<
string
,
unknown
>
,
emits
:
config
.
emits
,
resource
:
'refund'
,
hostId
:
'host'
,
tenantId
:
'tenant'
,
sourceUnit
:
'brand-1'
,
sourceContext
:
'test'
,
patientIdResolver
:
()
=>
'patient'
,
})
!
;
const
tx
=
synthesize
(
r
.
rows
[
0
]
!
);
expect
(
tx
.
rawPayload
).
toEqual
(
input
);
const
later
=
assemble
(
raw
({
updated_date
:
'2026-10-21 10:00:00'
}));
expect
(
synthesize
(
later
.
rows
[
0
]
!
).
sourceEventId
).
not
.
toBe
(
tx
.
sourceEventId
);
expect
(
synthesize
(
assemble
(
input
).
rows
[
0
]
!
).
sourceEventId
).
toBe
(
tx
.
sourceEventId
);
});
test
.
each
([
null
,
''
,
'null'
,
'2026-10-21'
])(
'未提供精确源创建时刻 %p 时留空'
,
value
=>
{
// refund 的业务时间必填;这里用与其相同字段映射的独立元数据字段验证可空语义。
const
custom
=
{
...
config
,
field_mapping
:
{
...
config
.
field_mapping
,
metadataCreatedAt
:
'metadata_created'
},
source_created_at
:
{
field
:
'metadataCreatedAt'
}
};
const
result
=
new
AssemblerEngine
().
assemble
({
tables
:
{
refund_rows
:
[{
...
raw
(),
metadata_created
:
value
}]
},
config
:
custom
,
normalize
:
{
timezone
:
manifest
.
timezone
,
amountUnit
:
'fen'
},
hostId
:
'host'
});
expect
(
result
.
errors
).
toEqual
([]);
expect
(
readSourceCreatedTime
(
result
.
rows
[
0
]
!
.
canonical
as
Record
<
string
,
unknown
>
))
.
toEqual
({
sourceCreatedAt
:
null
});
});
test
.
each
([
{
sourceCreatedAt
:
'2026-02-30T00:00:00Z'
},
{
sourceCreatedAt
:
'2026-10-21 08:15:00'
},
{
sourceCreatedAt
:
'2026-10-21'
},
{
sourceCreatedAt
:
new
Date
(
'bad'
)
},
{
createdAt
:
created
,
occurredAt
:
created
,
updatedAt
:
updated
},
])(
'未知/非法/没有显式来源声明不能被猜成创建时间 %#'
,
row
=>
{
expect
(
readSourceCreatedTime
(
row
)).
toEqual
({
sourceCreatedAt
:
null
});
});
test
.
each
([
[
'friday'
,
'diagnosis'
],
[
'jvs-dw'
,
'image'
],
[
'friday'
,
'encounter'
],
[
'jvs-dw'
,
'encounter'
],
[
'friday'
,
'treatment_planned'
],
[
'jvs-dw'
,
'treatment_planned'
],
[
'friday'
,
'refund_item'
],
])(
'%s/%s 保留经核实的来源创建字段映射'
,
(
host
,
name
)
=>
{
// 这里只核对配置声明;端到端入库行为另由 pipeline/writer 与 PostgreSQL 测试覆盖。
const
rawConfig
=
file
(
`
${
host
}
/assemblers/
${
name
}
.yaml`
)
as
Record
<
string
,
unknown
>
;
expect
(
rawConfig
.
source_created_at
).
toEqual
({
field
:
'createdAt'
});
});
test
.
each
([
created
,
new
Date
(
created
)])(
'来源创建时刻可独立保存,无需附加类别 %p'
,
value
=>
{
expect
(
readSourceCreatedTime
({
sourceCreatedAt
:
value
})).
toEqual
({
sourceCreatedAt
:
new
Date
(
created
)
});
});
test
.
each
([
'consult'
,
'image_finding'
,
'referral'
])(
'5i5ya %s 的业务日期代理不声明为源创建时间'
,
name
=>
{
const
c
=
file
(
`jvs-dw/assemblers/
${
name
}
.yaml`
)
as
Record
<
string
,
unknown
>
;
expect
(
c
.
source_created_at
).
toBeUndefined
();
});
test
.
each
([
'single'
,
'batch'
,
'fallback'
]
as
const
)(
'%s 管道把创建元数据和更新水位一起交给 writer'
,
async
mode
=>
{
const
canonicalRow
=
assemble
().
rows
[
0
]
!
.
canonical
as
Record
<
string
,
unknown
>
;
const
writer
=
{
writeDraft
:
jest
.
fn
().
mockResolvedValue
({
action
:
'created'
}),
bulkWrite
:
jest
.
fn
().
mockResolvedValue
({
results
:
[],
rejected
:
[]
}),
writeSnapshots
:
jest
.
fn
()
};
if
(
mode
===
'fallback'
)
writer
.
bulkWrite
.
mockRejectedValueOnce
(
new
Error
(
'simulated batch failure'
));
const
pipeline
=
new
ParserPipeline
({
get
:
()
=>
new
RefundParser
()
}
as
never
,
writer
as
never
,
{
patient
:
{
findUnique
:
async
()
=>
({
sourceUnit
:
'brand-1'
}),
findMany
:
async
()
=>
[{
id
:
'patient'
,
sourceUnit
:
'brand-1'
}]
}
}
as
never
,
{
enrichAll
:
(
drafts
:
unknown
)
=>
drafts
}
as
never
);
const
input
=
{
canonicalRow
,
transaction
:
{
id
:
'tx'
,
hostId
:
'host'
,
tenantId
:
'tenant'
,
patientId
:
'patient'
,
action
:
'refund_created'
as
const
,
subjectType
:
'refund'
,
subjectId
:
'refund-1'
,
occurredAt
:
new
Date
(
created
),
clinicId
:
'clinic-1'
}
};
if
(
mode
===
'single'
)
await
pipeline
.
runForTransaction
(
input
);
else
await
pipeline
.
runForBatch
([
input
]);
const
entry
=
mode
===
'batch'
?
writer
.
bulkWrite
.
mock
.
calls
[
0
]
!
[
0
][
0
]
:
writer
.
writeDraft
.
mock
.
calls
[
0
]
!
[
0
];
expect
(
entry
).
toMatchObject
({
sourceCreatedAt
:
new
Date
(
created
),
sourceUpdatedAt
:
new
Date
(
updated
)
});
});
test
.
each
([
'single'
,
'batch'
]
as
const
)(
'%s 分析快照及派生诊断均声明为父分析记录时间'
,
async
mode
=>
{
const
writer
=
{
writeDraft
:
jest
.
fn
(),
bulkWrite
:
jest
.
fn
(),
writeSnapshots
:
jest
.
fn
().
mockResolvedValue
({
results
:
[],
rejected
:
[]
})
};
const
pipeline
=
new
ParserPipeline
({
get
:
()
=>
new
ImageAnalysisParser
(
new
DiagnosisParser
())
}
as
never
,
writer
as
never
,
{
patient
:
{
findUnique
:
async
()
=>
({
sourceUnit
:
'brand-a'
}),
findMany
:
async
()
=>
[{
id
:
'patient-demo'
,
sourceUnit
:
'brand-a'
}]
}
}
as
never
,
{
enrichAll
:
(
drafts
:
unknown
)
=>
drafts
}
as
never
);
const
input
=
parserContext
(
canonicalAnalysis
());
const
result
=
mode
===
'single'
?
await
pipeline
.
runForTransaction
(
input
)
:
await
pipeline
.
runForBatch
([
input
]);
expect
(
result
.
factsFailed
).
toBe
(
0
);
expect
(
writer
.
writeSnapshots
).
toHaveBeenCalledTimes
(
1
);
const
entries
=
writer
.
writeSnapshots
.
mock
.
calls
[
0
]
!
[
0
][
0
].
entries
;
expect
(
entries
).
toHaveLength
(
2
);
for
(
const
entry
of
entries
)
expect
(
entry
).
toMatchObject
({
sourceCreatedAt
:
new
Date
(
'2026-10-12T02:01:00Z'
),
});
expect
(
writer
.
writeDraft
).
not
.
toHaveBeenCalled
();
});
apps/pac-service/tests/transaction-synthesizer-occurred-at.spec.ts
View file @
b502e1b3
/**
* TransactionSynthesizer occurredAt 三级确定性回退单元测试。
*
* 坐实:emits.occurredAtField 缺失
/不可解析时,回退 updatedAt → createdAt,
* 坐实:emits.occurredAtField 缺失
时,回退 updatedAt → createdAt;非法非空时间拒收。
* 仅三者皆缺才落墙钟。否则同一源行(新 sourceEventId、时间字段为空)每次摄入
* 得到不同 occurredAt,下游 fact 时间锚变更检测会产生伪 supersede 版本。
*/
...
...
@@ -48,14 +48,19 @@ describe('synthesizer | occurredAt 确定性回退', () => {
expect
(
tx
!
.
occurredAt
).
toEqual
(
new
Date
(
'2026-02-01T00:00:00+08:00'
));
});
test
(
'occurredAtField
不可解析 → 回退 updatedAt
'
,
()
=>
{
test
(
'occurredAtField
非空但非法 → 拒收,不以其他时间掩盖错误
'
,
()
=>
{
const
tx
=
synth
.
synthesize
(
make
({
occurredAt
:
'not-a-date'
,
updatedAt
:
'2026-02-01T00:00:00+08:00'
,
}),
);
expect
(
tx
!
.
occurredAt
).
toEqual
(
new
Date
(
'2026-02-01T00:00:00+08:00'
));
expect
(
tx
).
toBeNull
();
});
test
.
each
([
'occurredAt'
,
'createdAt'
,
'updatedAt'
])(
'%s 绕过入口传入裸时间 → 拒收'
,
field
=>
{
expect
(
synth
.
synthesize
(
make
({
occurredAt
:
'2026-01-01T00:00:00Z'
,
[
field
]:
'2026-10-21 12:30:00'
}))).
toBeNull
();
});
test
(
'occurredAtField/updatedAt 皆缺 → 回退 createdAt'
,
()
=>
{
...
...
packages/types/src/schemas/canonical.ts
View file @
b502e1b3
...
...
@@ -24,7 +24,9 @@ import { ImageEmbeddedAnalysisSchema } from './image-embedded-analysis';
// Datetime 工具 schema — ISO 8601 字符串(经 normalize 后必带 offset)
// ─────────────────────────────────────────────────────────
const
isoDateTime
=
z
.
string
().
min
(
1
).
describe
(
'ISO 8601 datetime with timezone offset'
);
export
const
CanonicalDateTimeSchema
=
z
.
iso
.
datetime
({
offset
:
true
})
.
describe
(
'ISO 8601 datetime with timezone offset'
);
const
isoDateTime
=
CanonicalDateTimeSchema
;
/// CSV 入口空字段是 `""`,统一接受空串等同于"未提供"
const
optionalIsoDateTime
=
z
.
union
([
isoDateTime
,
z
.
literal
(
''
),
z
.
null
()]).
optional
()
.
transform
((
v
)
=>
(
v
===
''
||
v
===
null
?
null
:
v
));
...
...
@@ -621,6 +623,7 @@ export const CanonicalResourceMeta: Record<
moneyArrayFields
:
readonly
{
array
:
string
;
field
:
string
}[];
// 数组里嵌套 money 字段
datetimeFields
:
readonly
string
[];
datetimeArrayFields
?:
readonly
{
array
:
string
;
field
:
string
}[];
datetimeObjectFields
?:
readonly
{
object
:
string
;
field
:
string
}[];
booleanFields
:
readonly
string
[];
}
>
=
{
...
...
@@ -724,6 +727,7 @@ export const CanonicalResourceMeta: Record<
moneyFields
:
[],
moneyArrayFields
:
[],
datetimeFields
:
[
'uploadedAt'
],
datetimeObjectFields
:
[{
object
:
'analysis'
,
field
:
'analyzedAt'
}],
booleanFields
:
[],
},
image_analysis
:
{
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment