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
5b80c042
Commit
5b80c042
authored
Oct 21, 2026
by
luoqi
Browse files
Options
Browse Files
Download
Plain Diff
Merge open patient contract and source time fixes into test
parents
d3834189
b502e1b3
Pipeline
#3855
failed in 0 seconds
Changes
63
Pipelines
1
Hide whitespace changes
Inline
Side-by-side
Showing
63 changed files
with
2708 additions
and
403 deletions
+2708
-403
apps/pac-docs/content/docs/api/open/PatientFactsLookupController_lookup.mdx
+149
-98
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
+5
-1
apps/pac-service/src/modules/open/lookup-content.schema.ts
+298
-0
apps/pac-service/src/modules/open/lookup-content.ts
+237
-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
+196
-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 @
5b80c042
...
@@ -19,7 +19,10 @@ description: 获取应用令牌,查询患者资料、画像、关系与事实
...
@@ -19,7 +19,10 @@ description: 获取应用令牌,查询患者资料、画像、关系与事实
| `appSecret` | string | 是 | 应用密钥 |
| `appSecret` | string | 是 | 应用密钥 |
```json
```json
{ "appId": "your-app-id", "appSecret": "your-app-secret" }
{
"appId": "your-app-id",
"appSecret": "your-app-secret"
}
```
```
```json
```json
...
@@ -127,7 +130,7 @@ curl "$PAC_BASE_URL/pac/v1/open/patient-facts/lookup" \
...
@@ -127,7 +130,7 @@ curl "$PAC_BASE_URL/pac/v1/open/patient-facts/lookup" \
| `items[].relations` | object[] | 患者关系;无记录为 `[]` |
| `items[].relations` | object[] | 患者关系;无记录为 `[]` |
| `items[].facts` | object[] | 全部当前有效事实;无记录为 `[]`,不截断 |
| `items[].facts` | object[] | 全部当前有效事实;无记录为 `[]`,不截断 |
返回字段可为 `null` 时在下表标明;`content` 字段还可能缺失。空值或空数组不代表无疾病、无风险或已完成。调用方应兼容新增字段、事实类型和开放字典值。手机号及自由文本已脱敏。
返回字段可为 `null` 时在下表标明;`content`
内的可选
字段还可能缺失。空值或空数组不代表无疾病、无风险或已完成。调用方应兼容新增字段、事实类型和开放字典值。手机号及自由文本已脱敏。
<details>
<details>
<summary>完整响应示例</summary>
<summary>完整响应示例</summary>
...
@@ -148,8 +151,8 @@ curl "$PAC_BASE_URL/pac/v1/open/patient-facts/lookup" \
...
@@ -148,8 +151,8 @@ curl "$PAC_BASE_URL/pac/v1/open/patient-facts/lookup" \
"gender": "男",
"gender": "男",
"birthDate": "1990-01-02",
"birthDate": "1990-01-02",
"dedicatedCs": {
"dedicatedCs": {
"
id": "source-staff-123
",
"
name": "李客服
",
"
name": "李客服
"
"
externalId": "source-staff-123
"
},
},
"acquisitionChannel": null,
"acquisitionChannel": null,
"acquisitionSub": null
"acquisitionSub": null
...
@@ -163,12 +166,17 @@ curl "$PAC_BASE_URL/pac/v1/open/patient-facts/lookup" \
...
@@ -163,12 +166,17 @@ curl "$PAC_BASE_URL/pac/v1/open/patient-facts/lookup" \
{
{
"type": "diagnosis_record",
"type": "diagnosis_record",
"kind": "actual",
"kind": "actual",
"clinicId": "来源诊所标识",
"clinic
External
Id": "来源诊所标识",
"clinicName": "示例诊所",
"clinicName": "示例诊所",
"occurredAt": "2026-10-19T02:00:00.000Z",
"occurredAt": "2026-10-19T02:00:00.000Z",
"plannedFor": null,
"plannedFor": null,
"content": {
"content": {
"code": "K02"
"code": "K02",
"externalId": null,
"emrExternalId": null,
"encounterExternalId": null,
"imageExternalId": null,
"analysisExternalId": null
},
},
"id": "33333333-3333-4333-8333-333333333333"
"id": "33333333-3333-4333-8333-333333333333"
}
}
...
@@ -186,14 +194,14 @@ curl "$PAC_BASE_URL/pac/v1/open/patient-facts/lookup" \
...
@@ -186,14 +194,14 @@ curl "$PAC_BASE_URL/pac/v1/open/patient-facts/lookup" \
| 字段 | 类型 | 可空 | 说明 |
| 字段 | 类型 | 可空 | 说明 |
| --- | --- | --- | --- |
| --- | --- | --- | --- |
| `id` | string | 否 | PAC 患者 UUID,用于区分患者、缓存结果 |
| `id` | string | 否 | PAC 患者 UUID,用于区分患者、缓存结果 |
| `externalId` | string | 否 | 宿主患者 ID;不是病历号,不保证跨品牌唯一,回链宿主需结合自身上下文 |
| `externalId` | string
/null
| 否 | 宿主患者 ID;不是病历号,不保证跨品牌唯一,回链宿主需结合自身上下文 |
| `name` | string | 是 | 姓名 |
| `name` | string | 是 | 姓名 |
| `medicalRecordNumber` | string | 是 | 病历号 |
| `medicalRecordNumber` | string | 是 | 病历号 |
| `phone` | string | 是 | 脱敏手机号 |
| `phone` | string | 是 | 脱敏手机号 |
| `gender` | string | 是 | 开放文本,常见 male=男、female=女,也可能为宿主原文“男/女” |
| `gender` | string | 是 | 开放文本,常见 male=男、female=女,也可能为宿主原文“男/女” |
| `birthDate` | string | 是 | 出生日期,YYYY-MM-DD |
| `birthDate` | string | 是 | 出生日期,YYYY-MM-DD |
| `dedicatedCs` | object | 是 | 专属客服;无信息时为 null |
| `dedicatedCs` | object | 是 | 专属客服;无信息时为 null |
| `dedicatedCs.
i
d` | string | 是 | 宿主侧客服标识 |
| `dedicatedCs.
externalI
d` | string | 是 | 宿主侧客服标识 |
| `dedicatedCs.name` | string | 是 | 客服姓名 |
| `dedicatedCs.name` | string | 是 | 客服姓名 |
| `acquisitionChannel` | string | 是 | 获客一级渠道,见下方字典 |
| `acquisitionChannel` | string | 是 | 获客一级渠道,见下方字典 |
| `acquisitionSub` | string | 是 | 宿主二级渠道原文,非固定枚举 |
| `acquisitionSub` | string | 是 | 宿主二级渠道原文,非固定枚举 |
...
@@ -256,11 +264,11 @@ curl "$PAC_BASE_URL/pac/v1/open/patient-facts/lookup" \
...
@@ -256,11 +264,11 @@ curl "$PAC_BASE_URL/pac/v1/open/patient-facts/lookup" \
| `id` | string | 否 | PAC 事实版本 UUID;同一业务记录产生新版本时会变化 |
| `id` | string | 否 | PAC 事实版本 UUID;同一业务记录产生新版本时会变化 |
| `type` | string | 否 | 事实类型,决定 content 结构,见第 5 节 |
| `type` | string | 否 | 事实类型,决定 content 结构,见第 5 节 |
| `kind` | string | 否 | `actual` 实际记录;`planned` 计划或建议。actual 不等于治疗完成 |
| `kind` | string | 否 | `actual` 实际记录;`planned` 计划或建议。actual 不等于治疗完成 |
| `clinicId` | string | 是 | 宿主诊所标识 |
| `clinic
External
Id` | string | 是 | 宿主诊所标识 |
| `clinicName` | string | 是 | 诊所名称 |
| `clinicName` | string | 是 | 诊所名称 |
| `occurredAt` | string | 是 | 业务发生时间,UTC ISO 8601;按下表理解 |
| `occurredAt` | string | 是 | 业务发生时间,UTC ISO 8601;按下表理解 |
| `plannedFor` | string | 是 | 明确约定的执行时间,UTC ISO 8601;目前预约返回约定到诊时间,其他类型为 null |
| `plannedFor` | string | 是 | 明确约定的执行时间,UTC ISO 8601;目前预约返回约定到诊时间,其他类型为 null |
| `content` |
JSON | 是 | 通常为对应类型的对象,字段、枚举及示例见第 5 节;兼容历史空值或非对象值
|
| `content` |
object | 否 | 按 type 定义的内容对象,字段、枚举及示例见第 5 节
|
| 事实 | occurredAt 的含义 |
| 事实 | occurredAt 的含义 |
| --- | --- |
| --- | --- |
...
@@ -272,34 +280,30 @@ curl "$PAC_BASE_URL/pac/v1/open/patient-facts/lookup" \
...
@@ -272,34 +280,30 @@ curl "$PAC_BASE_URL/pac/v1/open/patient-facts/lookup" \
- **范围**:每条业务记录只返回最新有效版本;取消、过期、作废及被替换的版本不返回。结果是当前快照,记录消失不等于删除通知;不含召回结果。
- **范围**:每条业务记录只返回最新有效版本;取消、过期、作废及被替换的版本不返回。结果是当前快照,记录消失不等于删除通知;不含召回结果。
- **排序**:按 occurredAt 倒序,缺失时使用 plannedFor;两者皆空排最后,同刻按 id 倒序。
- **排序**:按 occurredAt 倒序,缺失时使用 plannedFor;两者皆空排最后,同刻按 id 倒序。
- **精度**:时间戳统一 UTC;源时间只有日期时可按宿主当地零点表达,不代表精确时刻。
- **精度**:时间戳统一 UTC;源时间只有日期时可按宿主当地零点表达,不代表精确时刻。
- **引用**:content.externalId 为本条记录的
源标识;关联引用与 PAC 版本 id 不同,不保证能在本次结果中直接关联
。
- **引用**:content.externalId 为本条记录的
来源标识;缺少可靠独立标识为 null。…ExternalId 均为来源系统标识,不是 PAC UUID;按不透明字符串使用,不保证跨租户/品牌唯一。关联未经确认或存在冲突时为 null;有引用也不保证目标在本次结果中可见
。
## 5. 事实 content 字段
## 5. 事实 content 字段
按类型展开查看,
表中 `/null` 表示允许显式空值;所有 content 字段均应兼容缺失。各类型的 status 使用各自字典;未列枚举的字段为开放文本
。
按类型展开查看,
`/null` 表示允许空值。各类 externalId、病历的 11 个段落、影像 analysis,以及表内关联引用和投诉 sourceStatus 必返,未知为 null;其余字段可缺失。各类型 status 使用各自字典;枚举说明为当前值,须兼容历史值和未来扩展
。
金额单位以字段说明为准
;amountCents、amount、totalAmount 不合并、不换算。外层已承接的同义时间会省略;内层仍有时间时,可能是独立业务时点、精度不足或与外层不同。未知扩展和异值别名可能保留原名,不应强行合并
。
金额单位以字段说明为准
,不换算。外层已承接的同义时间省略;内层保留独立时点或未被完整承接的时间。仅返回本页定义的业务字段,内部扩展不透传;同义非关联字段值冲突时返回规范化失败
。
本页列出已接入数据的 11 类事实;各宿主覆盖不同,示例仅说明结构。调用方应兼容未来新增的 type。
本页列出已接入数据的 11 类事实;各宿主覆盖不同,示例仅说明结构。调用方应兼容未来新增的 type。
<details id="consultation_record">
<details id="consultation_record">
<summary>咨询 · consultation_record</summary>
<summary>咨询 · consultation_record</summary>
意向是患者主观意愿,不等于医生诊断。
意向是患者主观意愿,不等于医生诊断。
历史记录仅有意向原文时作为一项返回,不按分隔符猜拆。
| 字段 | 类型 | 含义 |
| 字段 | 类型 | 含义 |
| --- | --- | --- |
| --- | --- | --- |
| `externalId` | string | 源咨询记录标识 |
| `externalId` | string
/null
| 源咨询记录标识 |
| `consultDate` | string/null | 咨询日期或时刻,旧记录可能保留日期文本;通常省略;未被外层承接或有差异时保留 |
| `consultDate` | string/null | 咨询日期或时刻,旧记录可能保留日期文本;通常省略;未被外层承接或有差异时保留 |
| `sourceChannel` | string/null | 来源渠道 |
| `intents` | string[] | 患者意向原文列表 |
| `intents` | string[] | 患者意向原文列表 |
| `intentCategories` | string[] | PAC 治疗类别列表,取值与 treatment_record.category 一致 |
| `intentCategories` | string[] | PAC 治疗类别列表,取值与 treatment_record.category 一致 |
| `isFirstVisit` | boolean/null | 源侧初诊标记;未知为 null |
| `isFirstVisit` | boolean/null | 源侧初诊标记;未知为 null |
| `taskDirector` | string/null | 跟进人原值 |
| `doctorExternalId` | string/null | 源医生标识 |
| `doctorId` | string/null | 源医生标识 |
| `appointmentExternalId` | string/null | 关联源预约标识 |
| `appointmentExternalId` | string/null | 关联源预约标识 |
| `intent` | string/null | 仅保留无法由 intents 拼接还原的历史原文;重复拼接串省略 |
| `text` | string/null | 咨询正文 |
**示例**
**示例**
...
@@ -316,7 +320,8 @@ curl "$PAC_BASE_URL/pac/v1/open/patient-facts/lookup" \
...
@@ -316,7 +320,8 @@ curl "$PAC_BASE_URL/pac/v1/open/patient-facts/lookup" \
"intentCategories": [
"intentCategories": [
"preventive"
"preventive"
],
],
"isFirstVisit": true
"isFirstVisit": true,
"appointmentExternalId": null
}
}
}
}
```
```
...
@@ -330,8 +335,8 @@ curl "$PAC_BASE_URL/pac/v1/open/patient-facts/lookup" \
...
@@ -330,8 +335,8 @@ curl "$PAC_BASE_URL/pac/v1/open/patient-facts/lookup" \
| 字段 | 类型 | 含义 |
| 字段 | 类型 | 含义 |
| --- | --- | --- |
| --- | --- | --- |
| `externalId` | string | 源接诊标识 |
| `externalId` | string
/null
| 源接诊标识 |
| `doctorId / doctorName` | string/null | 源医生标识 / 姓名 |
| `doctor
External
Id / doctorName` | string/null | 源医生标识 / 姓名 |
| `chiefComplaint` | string/null | 主诉 |
| `chiefComplaint` | string/null | 主诉 |
| `encounterType` | string/null | 接诊类型原值 |
| `encounterType` | string/null | 接诊类型原值 |
...
@@ -359,15 +364,16 @@ curl "$PAC_BASE_URL/pac/v1/open/patient-facts/lookup" \
...
@@ -359,15 +364,16 @@ curl "$PAC_BASE_URL/pac/v1/open/patient-facts/lookup" \
| 字段 | 类型 | 含义 |
| 字段 | 类型 | 含义 |
| --- | --- | --- |
| --- | --- | --- |
| `externalId` | string/null | 来源独立记录标识;没有可靠独立标识时 null |
| `code` | string/null | PAC 标准诊断或建议码,见“公共代码字典”;未映射时 null,不据此推断无病 |
| `code` | string/null | PAC 标准诊断或建议码,见“公共代码字典”;未映射时 null,不据此推断无病 |
| `codeSource` | string/null | `std_code` 源标准码、`name_map` 名称映射、`llm` 模型抽取、`image_ai` 影像分析;未知 null |
| `codeSource` | string/null | `std_code` 源标准码、`name_map` 名称映射、`llm` 模型抽取、`image_ai` 影像分析;未知 null |
| `name` | string/null | 原诊断中文名称 |
| `name` | string/null | 原诊断中文名称 |
| `toothPosition` | string/null | 牙位文本,可能多牙位及牙面标记 |
| `toothPosition` | string/null | 牙位文本,可能多牙位及牙面标记 |
| `severity` | string/null | `mild` 轻、`moderate` 中、`severe` 重 |
| `severity` | string/null | `mild` 轻、`moderate` 中、`severe` 重 |
| `onsetDate` | string/null | 起病日期原值 |
| `onsetDate` | string/null | 起病日期原值 |
| `doctorId / doctorName` | string/null | 源医生标识 / 姓名 |
| `doctor
External
Id / doctorName` | string/null | 源医生标识 / 姓名 |
| `
sourceEncounterExternalId` | string | 来源接诊引用;历史值不保证指向实际接诊记录
|
| `
emrExternalId / encounterExternalId` | string/null | 承载该事实的来源病历 / 来源接诊标识;各自独立,不能互换
|
| `
sourceImageExternalId / sourceAnalysisExternalId` | string | 可选的影像 / 分析来源标识
|
| `
imageExternalId / analysisExternalId` | string/null | 来源影像 / 独立分析记录标识;未知 null,美亚当前无独立分析记录 ID
|
| `clinicalSignals` | object[] | 可选临床信号,见公共嵌套结构 |
| `clinicalSignals` | object[] | 可选临床信号,见公共嵌套结构 |
**示例**
**示例**
...
@@ -382,7 +388,11 @@ curl "$PAC_BASE_URL/pac/v1/open/patient-facts/lookup" \
...
@@ -382,7 +388,11 @@ curl "$PAC_BASE_URL/pac/v1/open/patient-facts/lookup" \
"codeSource": "std_code",
"codeSource": "std_code",
"name": "龋病",
"name": "龋病",
"toothPosition": "36",
"toothPosition": "36",
"sourceEncounterExternalId": "enc-1"
"emrExternalId": "emr-1",
"externalId": null,
"encounterExternalId": null,
"imageExternalId": null,
"analysisExternalId": null
}
}
}
}
```
```
...
@@ -394,7 +404,7 @@ curl "$PAC_BASE_URL/pac/v1/open/patient-facts/lookup" \
...
@@ -394,7 +404,7 @@ curl "$PAC_BASE_URL/pac/v1/open/patient-facts/lookup" \
美亚补充采集使用已有类型:影像信息在 `image_record`,分析快照在其 `content.analysis`,牙位诊断信号仍在 `diagnosis_record`,以 `codeSource=image_ai` 区分影像分析来源。当前这条路线不生成独立的分析报告记录。
美亚补充采集使用已有类型:影像信息在 `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` 是影像检查时间,均不表示分析完成或采集时间。
- **时间**:当前美亚路线中,影像的 `occurredAt` 是美亚影像创建时间;诊断的 `occurredAt` 是影像检查时间,均不表示分析完成或采集时间。
- **读取**:展示一张影像的分析时读取 `analysis`;它与关联的影像来源诊断表达同一批病症,不应重复计数。
- **读取**:展示一张影像的分析时读取 `analysis`;它与关联的影像来源诊断表达同一批病症,不应重复计数。
- **病症**:`name`、`toothPosition` 保留病症和牙位;`code` 未能可靠映射时为 null,仍返回该发现,不代表无异常,也不等同医生确诊。
- **病症**:`name`、`toothPosition` 保留病症和牙位;`code` 未能可靠映射时为 null,仍返回该发现,不代表无异常,也不等同医生确诊。
...
@@ -410,7 +420,7 @@ curl "$PAC_BASE_URL/pac/v1/open/patient-facts/lookup" \
...
@@ -410,7 +420,7 @@ curl "$PAC_BASE_URL/pac/v1/open/patient-facts/lookup" \
"id": "11111111-1111-4111-8111-111111111111",
"id": "11111111-1111-4111-8111-111111111111",
"type": "image_record",
"type": "image_record",
"kind": "actual",
"kind": "actual",
"clinicId": "clinic-a",
"clinic
External
Id": "clinic-a",
"clinicName": "示例诊所",
"clinicName": "示例诊所",
"occurredAt": "2026-10-10T02:00:00.000Z",
"occurredAt": "2026-10-10T02:00:00.000Z",
"plannedFor": null,
"plannedFor": null,
...
@@ -423,17 +433,27 @@ curl "$PAC_BASE_URL/pac/v1/open/patient-facts/lookup" \
...
@@ -423,17 +433,27 @@ curl "$PAC_BASE_URL/pac/v1/open/patient-facts/lookup" \
"description": "描述",
"description": "描述",
"analyzedAt": null,
"analyzedAt": null,
"findings": [
"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",
"id": "22222222-2222-4222-8222-222222222222",
"type": "diagnosis_record",
"type": "diagnosis_record",
"kind": "actual",
"kind": "actual",
"clinicId": "clinic-a",
"clinic
External
Id": "clinic-a",
"clinicName": "示例诊所",
"clinicName": "示例诊所",
"occurredAt": "2026-10-09T01:00:00.000Z",
"occurredAt": "2026-10-09T01:00:00.000Z",
"plannedFor": null,
"plannedFor": null,
...
@@ -442,7 +462,11 @@ curl "$PAC_BASE_URL/pac/v1/open/patient-facts/lookup" \
...
@@ -442,7 +462,11 @@ curl "$PAC_BASE_URL/pac/v1/open/patient-facts/lookup" \
"codeSource": "image_ai",
"codeSource": "image_ai",
"name": "龋齿",
"name": "龋齿",
"toothPosition": "16",
"toothPosition": "16",
"sourceImageExternalId": "meyer:test:image-1"
"imageExternalId": "meyer:test:image-1",
"externalId": null,
"emrExternalId": null,
"encounterExternalId": null,
"analysisExternalId": null
}
}
}
}
]
]
...
@@ -458,17 +482,17 @@ kind=planned 表示计划,actual 表示实际记录。实际记录也可能仍
...
@@ -458,17 +482,17 @@ kind=planned 表示计划,actual 表示实际记录。实际记录也可能仍
| 字段 | 类型 | 含义 |
| 字段 | 类型 | 含义 |
| --- | --- | --- |
| --- | --- | --- |
| `externalId` | string/null | 来源独立记录标识;没有可靠独立标识时 null |
| `category` | string | `endodontic` 牙髓 / 根管;`restorative` 充填 / 嵌体;`prosthodontic` 修复 / 冠桥;`implant` 种植;`periodontic` 牙周;`orthodontic` 正畸;`surgical` 外科;`pediatric` 儿牙;`preventive` 预防;`cosmetic` 美学;`review` 复查 / 流程 |
| `category` | string | `endodontic` 牙髓 / 根管;`restorative` 充填 / 嵌体;`prosthodontic` 修复 / 冠桥;`implant` 种植;`periodontic` 牙周;`orthodontic` 正畸;`surgical` 外科;`pediatric` 儿牙;`preventive` 预防;`cosmetic` 美学;`review` 复查 / 流程 |
| `subtype` | string/null | 治疗子类或原文 |
| `subtype` | string/null | 治疗子类或原文 |
| `toothPosition` | string/null | 牙位文本 |
| `toothPosition` | string/null | 牙位文本 |
| `status` | string | 业务进度:`planned` 计划中、`in_progress` 进行中、`completed` 已完成、`cancelled` 已取消、`failed` 失败 |
| `status` | string | 业务进度:`planned` 计划中、`in_progress` 进行中、`completed` 已完成、`cancelled` 已取消、`failed` 失败 |
| `startedAt / completedAt` | string/null | 开始 / 完成时刻 |
| `startedAt / completedAt` | string/null | 开始 / 完成时刻 |
| `doctorId / doctorName` | string/null | 源医生标识 / 姓名 |
| `doctor
External
Id / doctorName` | string/null | 源医生标识 / 姓名 |
| `quantity` | number/null | 数量 |
| `quantity` | number/null | 数量 |
| `unitName` | string/null | 计量单位,如颗、次、区 |
| `unitName` | string/null | 计量单位,如颗、次、区 |
| `treatStages` | string[]/null | 源归一化治疗阶段;步骤码见公共代码字典。为空不代表没有治疗,未提供时为空 |
| `stages` | string[]/null | 源归一化治疗阶段;步骤码见公共代码字典。为空不代表没有治疗,未提供时为空 |
| `sourceEncounterExternalId` | string/null | 来源接诊引用 |
| `emrExternalId / encounterExternalId` | string/null | 承载该事实的来源病历 / 来源接诊标识;未确认 null |
| `relatedDiagnosisSubjectId` | string/null | 关联诊断主体引用 |
| `reviewWindowDays` | number/null | 复查窗口天数 |
| `reviewWindowDays` | number/null | 复查窗口天数 |
**示例**
**示例**
...
@@ -484,9 +508,12 @@ kind=planned 表示计划,actual 表示实际记录。实际记录也可能仍
...
@@ -484,9 +508,12 @@ kind=planned 表示计划,actual 表示实际记录。实际记录也可能仍
"status": "completed",
"status": "completed",
"toothPosition": "36",
"toothPosition": "36",
"completedAt": "2026-10-19T02:00:00.000Z",
"completedAt": "2026-10-19T02:00:00.000Z",
"
treatS
tages": [
"
s
tages": [
"canal_filling"
"canal_filling"
]
],
"externalId": null,
"emrExternalId": null,
"encounterExternalId": null
}
}
}
}
```
```
...
@@ -500,15 +527,15 @@ kind=planned 表示计划,actual 表示实际记录。实际记录也可能仍
...
@@ -500,15 +527,15 @@ kind=planned 表示计划,actual 表示实际记录。实际记录也可能仍
| 字段 | 类型 | 含义 |
| 字段 | 类型 | 含义 |
| --- | --- | --- |
| --- | --- | --- |
| `externalId` | string/null | 来源独立记录标识;没有可靠独立标识时 null |
| `code` | string/null | PAC 归一建议码,见“公共代码字典”;未映射 null |
| `code` | string/null | PAC 归一建议码,见“公共代码字典”;未映射 null |
| `
name
` | string/null | 建议原文 |
| `
recommendation
` | string/null | 建议原文 |
| `
sourceEncounterExternalId` | string/null | 来源接诊引用
|
| `
emrExternalId / encounterExternalId` | string/null | 承载该事实的来源病历 / 来源接诊标识;未确认 null
|
| `toothPosition` | string/null | 牙位文本 |
| `toothPosition` | string/null | 牙位文本 |
| `doctorId / doctorName` | string/null | 源医生标识 / 姓名 |
| `doctor
External
Id / doctorName` | string/null | 源医生标识 / 姓名 |
| `extractionMethod` | string | `rule` 规则抽取、`llm` 模型抽取、`doctor_input` 医生录入 |
| `extractionMethod` | string | `rule` 规则抽取、`llm` 模型抽取、`doctor_input` 医生录入 |
| `confidence` | number/null | 0–1 置信度,未提供 null |
| `confidence` | number/null | 0–1 置信度,未提供 null |
| `sourceFactSubjectId` | string/null | 来源事实主体引用 |
| `suggestedAt` | string/null | 建议提出时刻;通常省略;未被外层承接或有差异时保留 |
| `doctorSuggestedAt` | string/null | 建议提出时刻;通常省略;未被外层承接或有差异时保留 |
| `windowDays` | number/null | 建议时间窗,单位天 |
| `windowDays` | number/null | 建议时间窗,单位天 |
**示例**
**示例**
...
@@ -520,10 +547,13 @@ kind=planned 表示计划,actual 表示实际记录。实际记录也可能仍
...
@@ -520,10 +547,13 @@ kind=planned 表示计划,actual 表示实际记录。实际记录也可能仍
"plannedFor": null,
"plannedFor": null,
"content": {
"content": {
"code": null,
"code": null,
"
name
": "建议复查",
"
recommendation
": "建议复查",
"toothPosition": "36",
"toothPosition": "36",
"extractionMethod": "doctor_input",
"extractionMethod": "doctor_input",
"windowDays": 30
"windowDays": 30,
"externalId": null,
"emrExternalId": null,
"encounterExternalId": null
}
}
}
}
```
```
...
@@ -533,25 +563,25 @@ kind=planned 表示计划,actual 表示实际记录。实际记录也可能仍
...
@@ -533,25 +563,25 @@ kind=planned 表示计划,actual 表示实际记录。实际记录也可能仍
<details id="emr_record">
<details id="emr_record">
<summary>病历 · emr_record</summary>
<summary>病历 · emr_record</summary>
段落统一以字符串返回;历史字符串可能含序列化 JSON,不保证都可直接 JSON.parse。结构化诊断和治疗在其他事实中,勿将文本再次当作新增事件
。
病历 11 个段落统一返回可读文本或 null,无需 JSON.parse。牙位条目按原顺序逐行展示;保留原文、否定词、单位和重复条目,不归纳改写。牙位编辑器的序列化备份不作为正文或牙位来源。未知非空结构使本次查询明确失败,不返回残缺正文。结构化诊断、治疗仍在各自事实中,勿重复计数
。
| 字段 | 类型 | 含义 |
| 字段 | 类型 | 含义 |
| --- | --- | --- |
| --- | --- | --- |
| `externalId` | string | 源病历标识 |
| `externalId` | string
/null
| 源病历标识 |
| `encounterExternalId` | string/null | 关联源接诊标识 |
| `encounterExternalId` | string/null | 关联源接诊标识 |
| `doctorId / doctorName` | string/null | 源医生标识 / 姓名 |
| `doctor
External
Id / doctorName` | string/null | 源医生标识 / 姓名 |
| `illnessDesc
` | string/null | 主诉或疾病描述
|
| `illnessDesc
ription` | string/null | 主诉、疾病描述或混合病史原文,不保证是纯主诉
|
| `preIllness` | string/null | 现病史 |
| `pre
sent
Illness` | string/null | 现病史 |
| `pastHistory` | string/null | 既往史 |
| `pastHistory` | string/null | 既往史 |
| `generalCondition` | string/null | 一般情况 |
| `generalCondition` | string/null | 一般情况 |
| `examFindings` | string/null | 检查所见 |
| `examFindings` | string/null | 检查所见 |
| `aux
ExamFindings` | string/null | 可选辅助检查
|
| `aux
iliaryExamFindings` | string/null | 辅助检查原文
|
| `diagnosis
Text
` | string/null | 诊断原文 |
| `diagnosis` | string/null | 诊断原文 |
| `treatmentPlan` | string/null | 治疗计划原文 |
| `treatmentPlan` | string/null | 治疗计划原文 |
| `treatment
Notes` | string/null | 可选
本次治疗原文 |
| `treatment
` | string/null |
本次治疗原文 |
| `
disposal
` | string/null | 本次处置原文 |
| `
procedure
` | string/null | 本次处置原文 |
| `doctorAdvice` | string/null | 医嘱 |
| `doctorAdvice` | string/null | 医嘱 |
| `qualityCheck` | object/null | 可选质检对象
:status(源状态字符串)、rejected(布尔)、rejectReason(原原因,可空)
|
| `qualityCheck` | object/null | 可选质检对象
,字段见下文
|
| `clinicalSignals` | object[] | 可选临床信号,见公共嵌套结构 |
| `clinicalSignals` | object[] | 可选临床信号,见公共嵌套结构 |
**示例**
**示例**
...
@@ -564,9 +594,17 @@ kind=planned 表示计划,actual 表示实际记录。实际记录也可能仍
...
@@ -564,9 +594,17 @@ kind=planned 表示计划,actual 表示实际记录。实际记录也可能仍
"content": {
"content": {
"externalId": "emr-1",
"externalId": "emr-1",
"encounterExternalId": "enc-1",
"encounterExternalId": "enc-1",
"illnessDesc": "右下牙疼痛",
"illnessDesc
ription
": "右下牙疼痛",
"examFindings": "36 牙检查所见",
"examFindings": "36 牙检查所见",
"doctorAdvice": "按医嘱复诊"
"doctorAdvice": "按医嘱复诊",
"presentIllness": null,
"pastHistory": null,
"generalCondition": null,
"auxiliaryExamFindings": null,
"diagnosis": null,
"treatmentPlan": null,
"treatment": null,
"procedure": null
}
}
}
}
```
```
...
@@ -576,21 +614,21 @@ kind=planned 表示计划,actual 表示实际记录。实际记录也可能仍
...
@@ -576,21 +614,21 @@ kind=planned 表示计划,actual 表示实际记录。实际记录也可能仍
<details id="image_record">
<details id="image_record">
<summary>影像 · image_record</summary>
<summary>影像 · image_record</summary>
`fileRefs` 是稳定
的文件引用,访问地址通过文件访问接口获取。`fileUrl` 保留宿主原值,可能是相对路径或临时地址;没有文件引用或
位置不等于没有影像。
`fileRefs` 是稳定
文件引用,访问地址通过文件访问接口获取。fileLocation 不是 PAC 保证可访问的原片下载授权。查看原片走宿主授权入口;没有文件
位置不等于没有影像。
| 字段 | 类型 | 含义 |
| 字段 | 类型 | 含义 |
| --- | --- | --- |
| --- | --- | --- |
| `externalId` | string | 源影像标识 |
| `externalId` | string
/null
| 源影像标识 |
| `e
ncounterExternalId` | string/null | 关联源接诊标识
|
| `e
mrExternalId / encounterExternalId` | string/null | 来源父病历 / 接诊标识;有明确证据才提供
|
| `modality` | string | `pa` 根尖片;`bw` 咬翼片;`pano` 曲面断层;`cbct` CBCT 锥形束 CT;`intraoral_photo` 口内照片;`scan` 口扫模型;`other` 其他 |
| `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 | 影像所见原文 |
| `finding` | string/null | 影像所见原文 |
| `analysis` | object/null |
可选的
本张影像分析快照;字段见下表 |
| `analysis` | object/null | 本张影像分析快照;字段见下表 |
| `capturedAt` | string/null | 拍摄时刻,来源未提供时为空;通常省略;未被外层承接或有差异时保留 |
| `capturedAt` | string/null | 拍摄时刻,来源未提供时为空;通常省略;未被外层承接或有差异时保留 |
| `file
Url` | string
| 可选宿主文件位置原值,可能相对路径或临时地址 |
| `file
Location` | string/null
| 可选宿主文件位置原值,可能相对路径或临时地址 |
| `file
Name / fileType` | string | 可选文件名 / 文件类型
|
| `file
Refs` | object[]/null | 稳定文件引用,顺序按来源保留;访问时使用数组下标
|
| `file
Refs` | object[]/null | 可选稳定文件引用;顺序按来源保留,访问时使用数组下标
|
| `file
Name / fileType` | string/null | 可选文件名 / 来源文件类型;不承诺为 MIME 类型
|
| `doctorId / doctorName` | string/null | 源医生标识 / 姓名 |
| `doctor
External
Id / doctorName` | string/null | 源医生标识 / 姓名 |
**fileRefs[](存在时)**
**fileRefs[](存在时)**
...
@@ -614,10 +652,10 @@ kind=planned 表示计划,actual 表示实际记录。实际记录也可能仍
...
@@ -614,10 +652,10 @@ kind=planned 表示计划,actual 表示实际记录。实际记录也可能仍
| `analyzedAt` | string/null | 来源明确提供的分析完成时刻(UTC ISO 8601);未知 null,不用影像创建或检查时间代替 |
| `analyzedAt` | string/null | 来源明确提供的分析完成时刻(UTC ISO 8601);未知 null,不用影像创建或检查时间代替 |
| `findings` | object[] | 本张影像的结构化病症列表;空数组不表示已确认正常 |
| `findings` | object[] | 本张影像的结构化病症列表;空数组不表示已确认正常 |
| `findings[].name` | string | 病症或所见原文 |
| `findings[].name` | string | 病症或所见原文 |
| `findings[].toothPosition` | string/null | 牙位文本
;标准 FDI 编码见上表
|
| `findings[].toothPosition` | string/null | 牙位文本
,可能包含多牙位及牙面
|
| `findings[].code` | string/null | PAC 标准诊断码,值域见本页“诊断码与治疗阶段字典”;无法可靠映射为 null,不代表无异常 |
| `findings[].code` | string/null | PAC 标准诊断码,值域见本页“诊断码与治疗阶段字典”;无法可靠映射为 null,不代表无异常 |
`analysis`
缺失或
null 只表示本张影像没有可提供的分析快照,可能尚未取得结果或无法可靠关联;不能据此判断源端无分析或检查正常。美亚分析示例见上方“影像分析结果”。
`analysis`
为
null 只表示本张影像没有可提供的分析快照,可能尚未取得结果或无法可靠关联;不能据此判断源端无分析或检查正常。美亚分析示例见上方“影像分析结果”。
**示例**
**示例**
...
@@ -633,8 +671,11 @@ kind=planned 表示计划,actual 表示实际记录。实际记录也可能仍
...
@@ -633,8 +671,11 @@ kind=planned 表示计划,actual 表示实际记录。实际记录也可能仍
"toothPositions": [
"toothPositions": [
"36"
"36"
],
],
"fileUrl": "images/example.jpg",
"fileLocation": "images/example.jpg",
"fileName": "example.jpg"
"fileName": "example.jpg",
"emrExternalId": null,
"encounterExternalId": null,
"analysis": null
}
}
}
}
```
```
...
@@ -650,12 +691,12 @@ kind=planned 表示计划,actual 表示实际记录。实际记录也可能仍
...
@@ -650,12 +691,12 @@ kind=planned 表示计划,actual 表示实际记录。实际记录也可能仍
| 字段 | 类型 | 含义 |
| 字段 | 类型 | 含义 |
| --- | --- | --- |
| --- | --- | --- |
| `externalId` | string | 源预约标识 |
| `externalId` | string
/null
| 源预约标识 |
| `scheduledAt` | string/null | 约定到诊时间;被外层 plannedFor 承接后省略,仅未承接或冲突时保留 |
| `scheduledAt` | string/null | 约定到诊时间;被外层 plannedFor 承接后省略,仅未承接或冲突时保留 |
| `appointmentType` | string/null | 预约类型 |
| `appointmentType` | string/null | 预约类型 |
| `doctorId / doctorName` | string/null | 预约医生标识 / 姓名,不保证是实际接诊医生 |
| `doctor
External
Id / doctorName` | string/null | 预约医生标识 / 姓名,不保证是实际接诊医生 |
| `resourceName` | string/null | 源排班资源名,也可能是房间或服务 |
| `resourceName` | string/null | 源排班资源名,也可能是房间或服务 |
| `
complaintCategory / complaintTex
t` | string/null | 预约主诉类别 / 原文 |
| `
visitReasonCategory / chiefComplain
t` | string/null | 预约主诉类别 / 原文 |
| `durationMinutes` | number/null | 预约时长,分钟 |
| `durationMinutes` | number/null | 预约时长,分钟 |
| `cancellationReason` | string/null | 取消原因(若有) |
| `cancellationReason` | string/null | 取消原因(若有) |
| `status` | string/null | `scheduled` 已预约、`rescheduled` 已改约、`cancelled` 已取消、`arrived` 已到诊、`in_treatment` 诊疗中、`completed` 已完成、`no_show` 未赴约、`walk_in` 无预约到诊 |
| `status` | string/null | `scheduled` 已预约、`rescheduled` 已改约、`cancelled` 已取消、`arrived` 已到诊、`in_treatment` 诊疗中、`completed` 已完成、`no_show` 未赴约、`walk_in` 无预约到诊 |
...
@@ -685,7 +726,7 @@ kind=planned 表示计划,actual 表示实际记录。实际记录也可能仍
...
@@ -685,7 +726,7 @@ kind=planned 表示计划,actual 表示实际记录。实际记录也可能仍
| 字段 | 类型 | 含义 |
| 字段 | 类型 | 含义 |
| --- | --- | --- |
| --- | --- | --- |
| `externalId` | string | 源收款标识 |
| `externalId` | string
/null
| 源收款标识 |
| `amountCents` | number | 实付金额,整数分 |
| `amountCents` | number | 实付金额,整数分 |
| `receivableCents` | number/null | 可选应收金额,分 |
| `receivableCents` | number/null | 可选应收金额,分 |
| `discountCents` | number/null | 可选折扣金额,分 |
| `discountCents` | number/null | 可选折扣金额,分 |
...
@@ -693,8 +734,8 @@ kind=planned 表示计划,actual 表示实际记录。实际记录也可能仍
...
@@ -693,8 +734,8 @@ kind=planned 表示计划,actual 表示实际记录。实际记录也可能仍
| `insuranceName` | string/null | 商业保险公司名称 |
| `insuranceName` | string/null | 商业保险公司名称 |
| `cardTypeName / cardName` | string/null | 卡券类型名 / 名称 |
| `cardTypeName / cardName` | string/null | 卡券类型名 / 名称 |
| `settlementProject` | string/null | 结算项目名称 |
| `settlementProject` | string/null | 结算项目名称 |
| `doctorId` | string/null | 收费医生标识 |
| `doctor
External
Id` | string/null | 收费医生标识 |
| `encounterExternalId /
relatedOrderExternalId` | string/null | 关联接诊 / 医嘱单据引用
|
| `encounterExternalId /
orderExternalId` | string/null | 来源接诊 / 医嘱单据引用;当前来源未能可靠对应时为 null
|
**示例**
**示例**
...
@@ -708,7 +749,9 @@ kind=planned 表示计划,actual 表示实际记录。实际记录也可能仍
...
@@ -708,7 +749,9 @@ kind=planned 表示计划,actual 表示实际记录。实际记录也可能仍
"externalId": "payment-1",
"externalId": "payment-1",
"amountCents": 10000,
"amountCents": 10000,
"receivableCents": 12000,
"receivableCents": 12000,
"discountCents": 2000
"discountCents": 2000,
"encounterExternalId": null,
"orderExternalId": null
}
}
}
}
```
```
...
@@ -720,10 +763,10 @@ kind=planned 表示计划,actual 表示实际记录。实际记录也可能仍
...
@@ -720,10 +763,10 @@ kind=planned 表示计划,actual 表示实际记录。实际记录也可能仍
| 字段 | 类型 | 含义 |
| 字段 | 类型 | 含义 |
| --- | --- | --- |
| --- | --- | --- |
| `externalId` | string | 源退费标识 |
| `externalId` | string
/null
| 源退费标识 |
| `amountCents` | number | 退费金额,非负整数分,不用负数表达退费 |
| `amountCents` | number | 退费金额,非负整数分,不用负数表达退费 |
| `reason` | string/null | 退费原因 |
| `reason` | string/null | 退费原因 |
| `
relatedP
aymentExternalId` | string/null | 关联源收款引用 |
| `
p
aymentExternalId` | string/null | 关联源收款引用 |
**示例**
**示例**
...
@@ -736,7 +779,7 @@ kind=planned 表示计划,actual 表示实际记录。实际记录也可能仍
...
@@ -736,7 +779,7 @@ kind=planned 表示计划,actual 表示实际记录。实际记录也可能仍
"reason": "项目调整",
"reason": "项目调整",
"externalId": "refund-1",
"externalId": "refund-1",
"amountCents": 2000,
"amountCents": 2000,
"
relatedP
aymentExternalId": "payment-1"
"
p
aymentExternalId": "payment-1"
}
}
}
}
```
```
...
@@ -750,19 +793,17 @@ kind=planned 表示计划,actual 表示实际记录。实际记录也可能仍
...
@@ -750,19 +793,17 @@ kind=planned 表示计划,actual 表示实际记录。实际记录也可能仍
| 字段 | 类型 | 含义 |
| 字段 | 类型 | 含义 |
| --- | --- | --- |
| --- | --- | --- |
| `externalId / number` | string
/ string或
null | 源记录标识 / 投诉编号 |
| `externalId / number` | string
/
null | 源记录标识 / 投诉编号 |
| `feedbackKind` | string | `complaint` 投诉、`praise` 表扬、`neutral` 中性反馈、`unknown` 未知 |
| `feedbackKind` | string | `complaint` 投诉、`praise` 表扬、`neutral` 中性反馈、`unknown` 未知 |
| `status` | string/null | `open` 待处理、`in_progress` 处理中、`resolved` 已解决、`draft` 草稿、`unknown` 未知 |
| `status` | string/null | `open` 待处理、`in_progress` 处理中、`resolved` 已解决、`draft` 草稿、`unknown` 未知 |
| `category` | string | `service` 服务、`clinical` 诊疗、`billing` 收费、`environment` 环境、`waiting` 等候、`other` 其他、`unknown` 未知 |
| `category` | string | `service` 服务、`clinical` 诊疗、`billing` 收费、`environment` 环境、`waiting` 等候、`other` 其他、`unknown` 未知 |
| `channel` | string | `sms_survey` 短信回访、`hotline` 热线、`third_party` 第三方、`sales` 销售、`mini_program` 小程序、`unknown` 未知 |
| `channel` | string | `sms_survey` 短信回访、`hotline` 热线、`third_party` 第三方、`sales` 销售、`mini_program` 小程序、`unknown` 未知 |
| `signalEligible` | boolean | 是否具备有效投诉信号资格 |
| `description` | string/null | 投诉正文 |
| `text` | string/null | 投诉正文;即 fact.content.text |
| `severity` | string/null | `low` 低、`medium` 中、`high` 高;未知 null |
| `severity` | string/null | `low` 低、`medium` 中、`high` 高;未知 null |
| `reviewReason` | string/null | `classification_conflict` 分类冲突、`unknown_feedback_kind` 反馈类型未知、`unknown_status` 状态未知;无复核原因时 null |
| `settlementNumber` | string/null | 来源结算业务编号;不是收款或医嘱单 ID,不据此直接关联事实 |
| `relatedTreatmentSubjectId / relatedOrderExternalId` | string/null | 关联治疗 / 医嘱引用 |
| `resolvedAt / sourceUpdatedAt` | string/null | 解决 / 来源主单更新时间(不等同整条事实的跟进水位) |
| `resolvedAt / sourceUpdatedAt` | string/null | 解决 / 源更新时刻 |
| `followUps` | object[] | 跟进列表,字段见下文 |
| `followUps` | object[] | 跟进列表,字段见下文 |
| `
host
Status / sourceCategory / sourceConcern / sourceChannel` | string/null | 源状态、分类、关注点、渠道原值 |
| `
source
Status / sourceCategory / sourceConcern / sourceChannel` | string/null | 源状态、分类、关注点、渠道原值 |
**示例**
**示例**
...
@@ -777,10 +818,11 @@ kind=planned 表示计划,actual 表示实际记录。实际记录也可能仍
...
@@ -777,10 +818,11 @@ kind=planned 表示计划,actual 表示实际记录。实际记录也可能仍
"channel": "hotline",
"channel": "hotline",
"externalId": "complaint-1",
"externalId": "complaint-1",
"feedbackKind": "complaint",
"feedbackKind": "complaint",
"signalEligible": true,
"resolvedAt": "2026-10-19T03:00:00.000Z",
"resolvedAt": "2026-10-19T03:00:00.000Z",
"followUps": [],
"followUps": [],
"text": "服务反馈已处理"
"description": "服务反馈已处理",
"settlementNumber": null,
"sourceStatus": null
}
}
}
}
```
```
...
@@ -788,18 +830,26 @@ kind=planned 表示计划,actual 表示实际记录。实际记录也可能仍
...
@@ -788,18 +830,26 @@ kind=planned 表示计划,actual 表示实际记录。实际记录也可能仍
</details>
</details>
<details>
<details>
<summary>emr_record.qualityCheck 字段</summary>
| 字段 | 类型与含义 |
| --- | --- |
| `sourceStatus` | string/null;来源质控状态,开放文本,不等于统一审核结论 |
| `rejectionReason` | string/null;可读的来源拒绝原因。无原因不能推断审核通过 |
</details>
<details>
<summary>clinicalSignals[](病历、诊断) 字段</summary>
<summary>clinicalSignals[](病历、诊断) 字段</summary>
| 字段 | 类型与含义 |
| 字段 | 类型与含义 |
| --- | --- |
| --- | --- |
| `code / label` | 字符串;信号码 / 中文标签。code 是可扩展字典值,不是封闭枚举,以 label 展示 |
| `code / label` | 字符串;信号码 / 中文标签。code 是可扩展字典值,不是封闭枚举,以 label 展示 |
| `phrase / field` | 字符串;命中原文 / 所在 content 字段名 |
| `evidence / sourceField` | 可空字符串;命中原文 / 对应的公开段落名。无法定位到公开正文时 sourceField=null |
| `span` | 两个整数;原文字符区间 [start,end)。出站脱敏可能改变文本长度,不能保证在脱敏文本上仍精确定位 |
| `negated` | false=阳性陈述;true=明确否认;null=不可判 |
| `negated` | false=阳性陈述;true=明确否认;null=不可判 |
| `severity` | `absolute` 绝对禁忌、`relative` 相对禁忌、`check` 需核查;信号等级,不等于事实严重度或医生最终诊断 |
| `severity` | `absolute` 绝对禁忌、`relative` 相对禁忌、`check` 需核查;信号等级,不等于事实严重度或医生最终诊断 |
| `
contraindicate
s` | 治疗域数组:`implant` 种植、`surgery` 外科、`anesthesia` 麻醉、`orthodontic` 正畸、`xray` 放射检查 |
| `
treatmentDomain
s` | 治疗域数组:`implant` 种植、`surgery` 外科、`anesthesia` 麻醉、`orthodontic` 正畸、`xray` 放射检查 |
| `validUntil / reviewAfter` | 可空 ISO 日期;信号失效 / 需复评日期,属于临床信号自身,不与外层业务发生时间去重 |
| `validUntil / reviewAfter` | 可空 ISO 日期;信号失效 / 需复评日期,属于临床信号自身,不与外层业务发生时间去重 |
| `extractorVersion` | 抽取器版本字符串;区别于建议的 extractionMethod(抽取方式) |
没有该字段不意味着没有禁忌;历史信号可能已过期或需复评,不能仅凭查询返回该事实就当作当前风险。
没有该字段不意味着没有禁忌;历史信号可能已过期或需复评,不能仅凭查询返回该事实就当作当前风险。
...
@@ -813,8 +863,8 @@ kind=planned 表示计划,actual 表示实际记录。实际记录也可能仍
...
@@ -813,8 +863,8 @@ kind=planned 表示计划,actual 表示实际记录。实际记录也可能仍
| `occurredAt` | 可空跟进时刻 |
| `occurredAt` | 可空跟进时刻 |
| `action` | `transferred` 转交、`completed` 完成、`edited` 修改、`followed_up` 跟进、`paused` 暂停、`unknown` 未知 |
| `action` | `transferred` 转交、`completed` 完成、`edited` 修改、`followed_up` 跟进、`paused` 暂停、`unknown` 未知 |
| `sourceAction` | 可空源操作原值 |
| `sourceAction` | 可空源操作原值 |
| `
text
` | 可空跟进正文,已脱敏 |
| `
description
` | 可空跟进正文,已脱敏 |
| `actorId / actorName` | 可空操作人源标识 / 姓名 |
| `actor
External
Id / actorName` | 可空操作人源标识 / 姓名 |
</details>
</details>
...
@@ -861,7 +911,7 @@ kind=planned 表示计划,actual 表示实际记录。实际记录也可能仍
...
@@ -861,7 +911,7 @@ kind=planned 表示计划,actual 表示实际记录。实际记录也可能仍
</details>
</details>
<details>
<details>
<summary>治疗阶段:content.
treatS
tages</summary>
<summary>治疗阶段:content.
s
tages</summary>
| 值 | 含义 |
| 值 | 含义 |
| --- | --- |
| --- | --- |
...
@@ -901,5 +951,6 @@ kind=planned 表示计划,actual 表示实际记录。实际记录也可能仍
...
@@ -901,5 +951,6 @@ kind=planned 表示计划,actual 表示实际记录。实际记录也可能仍
| `10101` | 应用凭据无效 | 核对 appId/appSecret 及所属环境 |
| `10101` | 应用凭据无效 | 核对 appId/appSecret 及所属环境 |
| `10106` | 令牌缺失、无效、过期或已撤销 | 重新获取应用令牌;仍失败则核对授权 |
| `10106` | 令牌缺失、无效、过期或已撤销 | 重新获取应用令牌;仍失败则核对授权 |
| `10107` | 未开通查询权限 | 联系 PAC 管理方确认应用授权 |
| `10107` | 未开通查询权限 | 联系 PAC 管理方确认应用授权 |
| `90000` | 内容规范化失败或服务异常 | details.reason 为 FACT_CONTENT_NORMALIZATION_FAILED 时,联系服务方核查;data=null,不能当作无患者或无病历 |
其他非零 code 按失败处理,保留 code/msg 用于排查;不要将失败当成“没有患者”。
其他非零 code 按失败处理,保留 code/msg 用于排查;不要将失败当成“没有患者”。
apps/pac-service/data/friday/assemblers/appointment.yaml
View file @
5b80c042
...
@@ -11,6 +11,9 @@ primary:
...
@@ -11,6 +11,9 @@ primary:
table
:
appointment_all_rows
table
:
appointment_all_rows
key
:
id
key
:
id
# 来源创建时间对应 appointment 记录;派生事实不把父记录时间解释为自身创建。
source_created_at
:
{
field
:
createdAt
}
field_mapping
:
field_mapping
:
externalId
:
id
externalId
:
id
patientExternalId
:
patient_appointment_id
# 预约患者 = customer_basic_info.id
patientExternalId
:
patient_appointment_id
# 预约患者 = customer_basic_info.id
...
...
apps/pac-service/data/friday/assemblers/consult.yaml
View file @
5b80c042
...
@@ -14,6 +14,9 @@ primary:
...
@@ -14,6 +14,9 @@ primary:
key
:
id
key
:
id
dedup_by
:
id
dedup_by
:
id
# 来源创建时间对应 consultation 记录;派生事实不把父记录时间解释为自身创建。
source_created_at
:
{
field
:
createdAt
}
field_mapping
:
field_mapping
:
externalId
:
id
externalId
:
id
updatedAt
:
updated_gmt_at
updatedAt
:
updated_gmt_at
...
...
apps/pac-service/data/friday/assemblers/diagnosis.yaml
View file @
5b80c042
...
@@ -18,6 +18,9 @@ primary:
...
@@ -18,6 +18,9 @@ primary:
key
:
diag_external_id
key
:
diag_external_id
dedup_by
:
diag_external_id
dedup_by
:
diag_external_id
# 来源创建时间对应 emr 记录;派生事实不把父记录时间解释为自身创建。
source_created_at
:
{
field
:
createdAt
}
field_mapping
:
field_mapping
:
externalId
:
diag_external_id
externalId
:
diag_external_id
updatedAt
:
updated_gmt_at
updatedAt
:
updated_gmt_at
...
...
apps/pac-service/data/friday/assemblers/emr.yaml
View file @
5b80c042
...
@@ -13,6 +13,9 @@ primary:
...
@@ -13,6 +13,9 @@ primary:
key
:
emr_sub_id
# 小病历号 uuid(两侧连接键 + 临床事实锚点)
key
:
emr_sub_id
# 小病历号 uuid(两侧连接键 + 临床事实锚点)
dedup_by
:
[
tenant_id
,
emr_sub_id
]
# 源组织 + 病历号;同一身份仍按原规则取首行
dedup_by
:
[
tenant_id
,
emr_sub_id
]
# 源组织 + 病历号;同一身份仍按原规则取首行
# 来源创建时间对应 emr 记录;派生事实不把父记录时间解释为自身创建。
source_created_at
:
{
field
:
createdAt
}
field_mapping
:
field_mapping
:
externalId
:
emr_sub_id
externalId
:
emr_sub_id
# updatedAt 进 source_event_id 幂等键,host UPDATE → 新 transaction(同 jvs-dw 纪律)
# updatedAt 进 source_event_id 幂等键,host UPDATE → 新 transaction(同 jvs-dw 纪律)
...
...
apps/pac-service/data/friday/assemblers/encounter.yaml
View file @
5b80c042
...
@@ -13,6 +13,9 @@ primary:
...
@@ -13,6 +13,9 @@ primary:
table
:
appointment_encounter_rows
table
:
appointment_encounter_rows
key
:
id
key
:
id
# 来源创建时间对应 appointment 记录;派生事实不把父记录时间解释为自身创建。
source_created_at
:
{
field
:
createdAt
}
field_mapping
:
field_mapping
:
externalId
:
id
# = 预约 id;EMR 侧 appointmentId 将据此回挂
externalId
:
id
# = 预约 id;EMR 侧 appointmentId 将据此回挂
patientExternalId
:
patient_appointment_id
patientExternalId
:
patient_appointment_id
...
...
apps/pac-service/data/friday/assemblers/image.yaml
View file @
5b80c042
...
@@ -14,6 +14,9 @@ primary:
...
@@ -14,6 +14,9 @@ primary:
key
:
id
key
:
id
dedup_by
:
id
dedup_by
:
id
# 来源创建时间对应 image 记录;派生事实不把父记录时间解释为自身创建。
source_created_at
:
{
field
:
createdAt
}
field_mapping
:
field_mapping
:
externalId
:
id
externalId
:
id
updatedAt
:
updated_gmt_at
updatedAt
:
updated_gmt_at
...
...
apps/pac-service/data/friday/assemblers/image_analysis.yaml
View file @
5b80c042
...
@@ -8,6 +8,9 @@ emits:
...
@@ -8,6 +8,9 @@ emits:
primary
:
primary
:
table
:
med_check_analysis_req_record
table
:
med_check_analysis_req_record
key
:
id
key
:
id
# 来源创建时间对应 image_analysis 记录;派生事实不把父记录时间解释为自身创建。
source_created_at
:
{
field
:
createdAt
}
field_mapping
:
field_mapping
:
externalId
:
id
externalId
:
id
patientExternalId
:
patient_id
patientExternalId
:
patient_id
...
...
apps/pac-service/data/friday/assemblers/payment.yaml
View file @
5b80c042
...
@@ -14,6 +14,9 @@ primary:
...
@@ -14,6 +14,9 @@ primary:
key
:
uuid
key
:
uuid
dedup_by
:
uuid
dedup_by
:
uuid
# 来源创建时间对应 settlement 记录;派生事实不把父记录时间解释为自身创建。
source_created_at
:
{
field
:
createdAt
}
field_mapping
:
field_mapping
:
externalId
:
uuid
externalId
:
uuid
updatedAt
:
updated_gmt_at
updatedAt
:
updated_gmt_at
...
...
apps/pac-service/data/friday/assemblers/recommendation.yaml
View file @
5b80c042
...
@@ -13,6 +13,9 @@ primary:
...
@@ -13,6 +13,9 @@ primary:
key
:
rec_external_id
key
:
rec_external_id
dedup_by
:
rec_external_id
dedup_by
:
rec_external_id
# 来源创建时间对应 emr 记录;派生事实不把父记录时间解释为自身创建。
source_created_at
:
{
field
:
createdAt
}
field_mapping
:
field_mapping
:
externalId
:
rec_external_id
externalId
:
rec_external_id
updatedAt
:
updated_gmt_at
updatedAt
:
updated_gmt_at
...
...
apps/pac-service/data/friday/assemblers/refund_full.yaml
View file @
5b80c042
...
@@ -17,6 +17,9 @@ primary:
...
@@ -17,6 +17,9 @@ primary:
key
:
uuid
key
:
uuid
dedup_by
:
uuid
dedup_by
:
uuid
# 来源创建时间对应 settlement 记录;派生事实不把父记录时间解释为自身创建。
source_created_at
:
{
field
:
createdAt
}
field_mapping
:
field_mapping
:
externalId
:
uuid
externalId
:
uuid
updatedAt
:
updated_gmt_at
updatedAt
:
updated_gmt_at
...
...
apps/pac-service/data/friday/assemblers/refund_item.yaml
View file @
5b80c042
...
@@ -15,6 +15,9 @@ primary:
...
@@ -15,6 +15,9 @@ primary:
key
:
id
key
:
id
dedup_by
:
id
dedup_by
:
id
# 来源创建时间对应 settlement_item 记录;派生事实不把父记录时间解释为自身创建。
source_created_at
:
{
field
:
createdAt
}
field_mapping
:
field_mapping
:
externalId
:
id
externalId
:
id
updatedAt
:
updated_gmt_at
updatedAt
:
updated_gmt_at
...
...
apps/pac-service/data/friday/assemblers/treatment_actual.yaml
View file @
5b80c042
...
@@ -16,6 +16,9 @@ primary:
...
@@ -16,6 +16,9 @@ primary:
key
:
treat_external_id
key
:
treat_external_id
dedup_by
:
treat_external_id
dedup_by
:
treat_external_id
# 来源创建时间对应 emr 记录;派生事实不把父记录时间解释为自身创建。
source_created_at
:
{
field
:
createdAt
}
field_mapping
:
field_mapping
:
externalId
:
treat_external_id
externalId
:
treat_external_id
updatedAt
:
updated_gmt_at
updatedAt
:
updated_gmt_at
...
...
apps/pac-service/data/friday/assemblers/treatment_planned.yaml
View file @
5b80c042
...
@@ -14,6 +14,9 @@ primary:
...
@@ -14,6 +14,9 @@ primary:
key
:
id
key
:
id
dedup_by
:
id
dedup_by
:
id
# 来源创建时间对应 treatment_plan_item 记录;派生事实不把父记录时间解释为自身创建。
source_created_at
:
{
field
:
createdAt
}
field_mapping
:
field_mapping
:
externalId
:
id
externalId
:
id
updatedAt
:
updated_gmt_at
updatedAt
:
updated_gmt_at
...
...
apps/pac-service/data/friday/assemblers/treatment_review.yaml
View file @
5b80c042
...
@@ -16,6 +16,9 @@ primary:
...
@@ -16,6 +16,9 @@ primary:
key
:
treat_external_id
key
:
treat_external_id
dedup_by
:
treat_external_id
dedup_by
:
treat_external_id
# 来源创建时间对应 emr 记录;派生事实不把父记录时间解释为自身创建。
source_created_at
:
{
field
:
createdAt
}
field_mapping
:
field_mapping
:
externalId
:
treat_external_id
externalId
:
treat_external_id
updatedAt
:
updated_gmt_at
updatedAt
:
updated_gmt_at
...
...
apps/pac-service/data/jvs-dw/assemblers/appointment.yaml
View file @
5b80c042
...
@@ -11,6 +11,9 @@ primary:
...
@@ -11,6 +11,9 @@ primary:
table
:
appointment_all_rows
table
:
appointment_all_rows
key
:
id
key
:
id
# 来源创建时间对应 appointment 记录;派生事实不把父记录时间解释为自身创建。
source_created_at
:
{
field
:
createdAt
}
field_mapping
:
field_mapping
:
externalId
:
id
externalId
:
id
# W4 末 DW upsert:updatedAt 进 source_event_id 幂等键,host UPDATE → 新 transaction(否则永久 dedup 漏 update)
# W4 末 DW upsert:updatedAt 进 source_event_id 幂等键,host UPDATE → 新 transaction(否则永久 dedup 漏 update)
...
...
apps/pac-service/data/jvs-dw/assemblers/complaint.yaml
View file @
5b80c042
...
@@ -9,6 +9,9 @@ primary:
...
@@ -9,6 +9,9 @@ primary:
table
:
complaint_rows
table
:
complaint_rows
key
:
id
key
:
id
# group_rows 已按 [brand,id] 聚合,不再取首行丢弃跟进。
# group_rows 已按 [brand,id] 聚合,不再取首行丢弃跟进。
# 来源创建时间对应 complaint 记录;派生事实不把父记录时间解释为自身创建。
source_created_at
:
{
field
:
createdAt
}
field_mapping
:
field_mapping
:
externalId
:
id
externalId
:
id
patientExternalId
:
patient_id
patientExternalId
:
patient_id
...
...
apps/pac-service/data/jvs-dw/assemblers/diagnosis.yaml
View file @
5b80c042
...
@@ -14,6 +14,9 @@ primary:
...
@@ -14,6 +14,9 @@ primary:
table
:
diagnosis_rows
table
:
diagnosis_rows
key
:
diag_external_id
key
:
diag_external_id
# 来源创建时间对应 emr 记录;派生事实不把父记录时间解释为自身创建。
source_created_at
:
{
field
:
createdAt
}
field_mapping
:
field_mapping
:
externalId
:
diag_external_id
externalId
:
diag_external_id
# W4 末 DW upsert:updatedAt 进 source_event_id 幂等键,host UPDATE → 新 transaction(否则永久 dedup 漏 update)
# W4 末 DW upsert:updatedAt 进 source_event_id 幂等键,host UPDATE → 新 transaction(否则永久 dedup 漏 update)
...
...
apps/pac-service/data/jvs-dw/assemblers/emr.yaml
View file @
5b80c042
...
@@ -15,6 +15,9 @@ primary:
...
@@ -15,6 +15,9 @@ primary:
key
:
id
key
:
id
dedup_by
:
[
brand
,
id
]
# 源组织 + 病历号;同一身份仍按原规则取首行
dedup_by
:
[
brand
,
id
]
# 源组织 + 病历号;同一身份仍按原规则取首行
# 来源创建时间对应 emr 记录;派生事实不把父记录时间解释为自身创建。
source_created_at
:
{
field
:
createdAt
}
field_mapping
:
field_mapping
:
externalId
:
id
externalId
:
id
# W4 末 DW upsert:updatedAt 进 source_event_id 幂等键,host UPDATE → 新 transaction(否则永久 dedup 漏 update)
# W4 末 DW upsert:updatedAt 进 source_event_id 幂等键,host UPDATE → 新 transaction(否则永久 dedup 漏 update)
...
...
apps/pac-service/data/jvs-dw/assemblers/encounter.yaml
View file @
5b80c042
...
@@ -11,6 +11,9 @@ primary:
...
@@ -11,6 +11,9 @@ primary:
table
:
appointment_encounter_rows
table
:
appointment_encounter_rows
key
:
id
key
:
id
# 来源创建时间对应 appointment 记录;派生事实不把父记录时间解释为自身创建。
source_created_at
:
{
field
:
createdAt
}
field_mapping
:
field_mapping
:
externalId
:
id
externalId
:
id
# W4 末 DW upsert:updatedAt 进 source_event_id 幂等键,host UPDATE → 新 transaction(否则永久 dedup 漏 update)
# W4 末 DW upsert:updatedAt 进 source_event_id 幂等键,host UPDATE → 新 transaction(否则永久 dedup 漏 update)
...
...
apps/pac-service/data/jvs-dw/assemblers/image.yaml
View file @
5b80c042
...
@@ -12,6 +12,9 @@ primary:
...
@@ -12,6 +12,9 @@ primary:
key
:
image_external_id
key
:
image_external_id
dedup_by
:
image_external_id
# 同一影像跨 emr 行重复兜底
dedup_by
:
image_external_id
# 同一影像跨 emr 行重复兜底
# 来源创建时间对应 emr 记录;派生事实不把父记录时间解释为自身创建。
source_created_at
:
{
field
:
createdAt
}
field_mapping
:
field_mapping
:
externalId
:
image_external_id
externalId
:
image_external_id
# W4 末 DW upsert:updatedAt 进 source_event_id 幂等键,host UPDATE → 新 transaction(否则永久 dedup 漏 update)
# W4 末 DW upsert:updatedAt 进 source_event_id 幂等键,host UPDATE → 新 transaction(否则永久 dedup 漏 update)
...
...
apps/pac-service/data/jvs-dw/assemblers/payment.yaml
View file @
5b80c042
...
@@ -11,6 +11,9 @@ primary:
...
@@ -11,6 +11,9 @@ primary:
table
:
payment_rows
table
:
payment_rows
key
:
id
key
:
id
# 来源创建时间对应 settlement 记录;派生事实不把父记录时间解释为自身创建。
source_created_at
:
{
field
:
createdAt
}
field_mapping
:
field_mapping
:
externalId
:
id
externalId
:
id
# W4 末 DW upsert:updatedAt 进 source_event_id 幂等键,host UPDATE → 新 transaction(否则永久 dedup 漏 update)
# W4 末 DW upsert:updatedAt 进 source_event_id 幂等键,host UPDATE → 新 transaction(否则永久 dedup 漏 update)
...
...
apps/pac-service/data/jvs-dw/assemblers/recommendation.yaml
View file @
5b80c042
...
@@ -12,6 +12,9 @@ primary:
...
@@ -12,6 +12,9 @@ primary:
key
:
rec_external_id
key
:
rec_external_id
dedup_by
:
rec_external_id
dedup_by
:
rec_external_id
# 来源创建时间对应 emr 记录;派生事实不把父记录时间解释为自身创建。
source_created_at
:
{
field
:
createdAt
}
field_mapping
:
field_mapping
:
externalId
:
rec_external_id
externalId
:
rec_external_id
# W4 末 DW upsert:updatedAt 进 source_event_id 幂等键,host UPDATE → 新 transaction(否则永久 dedup 漏 update)
# W4 末 DW upsert:updatedAt 进 source_event_id 幂等键,host UPDATE → 新 transaction(否则永久 dedup 漏 update)
...
...
apps/pac-service/data/jvs-dw/assemblers/refund.yaml
View file @
5b80c042
...
@@ -23,6 +23,9 @@ primary:
...
@@ -23,6 +23,9 @@ primary:
key
:
id
key
:
id
dedup_by
:
id
# 同 settlement_id 不应重复(SQL 已去重)
dedup_by
:
id
# 同 settlement_id 不应重复(SQL 已去重)
# 来源创建时间对应 settlement 记录;派生事实不把父记录时间解释为自身创建。
source_created_at
:
{
field
:
createdAt
}
field_mapping
:
field_mapping
:
externalId
:
id
externalId
:
id
# W4 末 DW upsert:updatedAt 进 source_event_id 幂等键,host UPDATE → 新 transaction(否则永久 dedup 漏 update)
# W4 末 DW upsert:updatedAt 进 source_event_id 幂等键,host UPDATE → 新 transaction(否则永久 dedup 漏 update)
...
...
apps/pac-service/data/jvs-dw/assemblers/treatment_actual.yaml
View file @
5b80c042
...
@@ -23,6 +23,9 @@ primary:
...
@@ -23,6 +23,9 @@ primary:
key
:
treat_external_id
key
:
treat_external_id
dedup_by
:
treat_external_id
# 炸行兜底:同 emr × treatName × tooth_position 因影像 JOIN 重复
dedup_by
:
treat_external_id
# 炸行兜底:同 emr × treatName × tooth_position 因影像 JOIN 重复
# 来源创建时间对应 emr 记录;派生事实不把父记录时间解释为自身创建。
source_created_at
:
{
field
:
createdAt
}
field_mapping
:
field_mapping
:
externalId
:
treat_external_id
externalId
:
treat_external_id
# W4 末 DW upsert:updatedAt 进 source_event_id 幂等键,host UPDATE → 新 transaction(否则永久 dedup 漏 update)
# W4 末 DW upsert:updatedAt 进 source_event_id 幂等键,host UPDATE → 新 transaction(否则永久 dedup 漏 update)
...
...
apps/pac-service/data/jvs-dw/assemblers/treatment_planned.yaml
View file @
5b80c042
...
@@ -22,6 +22,9 @@ primary:
...
@@ -22,6 +22,9 @@ primary:
key
:
treat_external_id
key
:
treat_external_id
dedup_by
:
treat_external_id
# 炸行兜底:同 emr × treatName × tooth_position 因影像 JOIN 重复
dedup_by
:
treat_external_id
# 炸行兜底:同 emr × treatName × tooth_position 因影像 JOIN 重复
# 来源创建时间对应 emr 记录;派生事实不把父记录时间解释为自身创建。
source_created_at
:
{
field
:
createdAt
}
field_mapping
:
field_mapping
:
externalId
:
treat_external_id
externalId
:
treat_external_id
# W4 末 DW upsert:updatedAt 进 source_event_id 幂等键,host UPDATE → 新 transaction(否则永久 dedup 漏 update)
# W4 末 DW upsert:updatedAt 进 source_event_id 幂等键,host UPDATE → 新 transaction(否则永久 dedup 漏 update)
...
...
apps/pac-service/data/jvs-dw/assemblers/treatment_review.yaml
View file @
5b80c042
...
@@ -20,6 +20,9 @@ primary:
...
@@ -20,6 +20,9 @@ primary:
key
:
treat_external_id
key
:
treat_external_id
dedup_by
:
treat_external_id
dedup_by
:
treat_external_id
# 来源创建时间对应 emr 记录;派生事实不把父记录时间解释为自身创建。
source_created_at
:
{
field
:
createdAt
}
field_mapping
:
field_mapping
:
externalId
:
treat_external_id
externalId
:
treat_external_id
# W4 末 DW upsert:updatedAt 进 source_event_id 幂等键,host UPDATE → 新 transaction(否则永久 dedup 漏 update)
# W4 末 DW upsert:updatedAt 进 source_event_id 幂等键,host UPDATE → 新 transaction(否则永久 dedup 漏 update)
...
...
apps/pac-service/data/jvs-dw/manifest.yaml
View file @
5b80c042
...
@@ -942,7 +942,7 @@ transforms:
...
@@ -942,7 +942,7 @@ transforms:
-
kind
:
project
-
kind
:
project
input
:
fact_settlement_out_refund
input
:
fact_settlement_out_refund
output
:
refund_rows
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
,
settlement_type
,
doctor_id
,
settlement_project_name
,
settlement_money
,
registration_id
,
is_refund
,
settlement_status
]
registration_id
,
is_refund
,
settlement_status
]
...
...
apps/pac-service/prisma/migrations/20261021140000_fact_source_created_time/migration.sql
0 → 100644
View file @
5b80c042
-- 仅新增可空元数据,不更新任何历史行,不改变业务时间或已有乱序水位。
ALTER
TABLE
"patient_facts"
ADD
COLUMN
"source_created_at"
TIMESTAMPTZ
(
3
);
apps/pac-service/prisma/schema.prisma
View file @
5b80c042
...
@@ -576,7 +576,7 @@ model PatientTransaction {
...
@@ -576,7 +576,7 @@ model PatientTransaction {
///
宿主侧实际发生时间
(
adapter
按
platform
.
pullConfig
.
timezone
解析无时区字符串后转
UTC
)
。
///
宿主侧实际发生时间
(
adapter
按
platform
.
pullConfig
.
timezone
解析无时区字符串后转
UTC
)
。
///
跟
patient_facts
.
occurredAt
字段名一致
(
同一语义在事件账本和事实视图复用
)
///
跟
patient_facts
.
occurredAt
字段名一致
(
同一语义在事件账本和事实视图复用
)
occurredAt
DateTime
@
map
(
"occurred_at"
)
@
db
.
Timestamptz
(
3
)
occurredAt
DateTime
@
map
(
"occurred_at"
)
@
db
.
Timestamptz
(
3
)
///
PAC
接收时间
(
同步监控用
)
///
PAC
事件落库时间;当前与本表
createdAt
均由数据库默认
now
()
生成,未独立采样请求到达时刻。
receivedAt
DateTime
@
default
(
now
())
@
map
(
"received_at"
)
@
db
.
Timestamptz
(
3
)
receivedAt
DateTime
@
default
(
now
())
@
map
(
"received_at"
)
@
db
.
Timestamptz
(
3
)
///
关联
sync_logs
.
id
;
一次
run
把若干
transaction
一并落库
,
可反查
"哪批拉进来的"
///
关联
sync_logs
.
id
;
一次
run
把若干
transaction
一并落库
,
可反查
"哪批拉进来的"
syncLogId
String
?
@
map
(
"sync_log_id"
)
@
db
.
Uuid
syncLogId
String
?
@
map
(
"sync_log_id"
)
@
db
.
Uuid
...
@@ -724,10 +724,14 @@ model PatientFact {
...
@@ -724,10 +724,14 @@ model PatientFact {
///
actual
应填
—
事实在宿主侧实际发生时间
///
actual
应填
—
事实在宿主侧实际发生时间
occurredAt
DateTime
?
@
map
(
"occurred_at"
)
@
db
.
Timestamptz
(
3
)
occurredAt
DateTime
?
@
map
(
"occurred_at"
)
@
db
.
Timestamptz
(
3
)
///
源记录末次修改时间
(=
canonicalRow
.
updatedAt
,
宿主侧口径
)
。乱序防护用
:
///
来源记录更新时间
(=
canonicalRow
.
updatedAt
,
宿主侧口径
)
,不是
PAC
入库时间。乱序防护用
:
///
现有少数映射使用聚合快照或业务日期代理值,须按来源解释,不据此推断诊断等动作时间。
///
携带更旧
updatedAt
的版本晚到
(
补推快照
vs
实时推送并发
)
→
fact
-
writer
按此裁决跳过
,
不旧覆新。
///
携带更旧
updatedAt
的版本晚到
(
补推快照
vs
实时推送并发
)
→
fact
-
writer
按此裁决跳过
,
不旧覆新。
///
null
=
源没给
updatedAt
→
退回
"到达顺序"
语义
(
向后兼容
,
老数据
/
无该列的
host
不受影响
)
。
///
null
=
源没给
updatedAt
→
退回
"到达顺序"
语义
(
向后兼容
,
老数据
/
无该列的
host
不受影响
)
。
sourceUpdatedAt
DateTime
?
@
map
(
"source_updated_at"
)
@
db
.
Timestamptz
(
3
)
sourceUpdatedAt
DateTime
?
@
map
(
"source_updated_at"
)
@
db
.
Timestamptz
(
3
)
///
承载事实的来源记录创建时刻。仅新事实版本写入,不回填历史;取不到可靠时间则为空。
///
例如诊断继承
emr
、接诊继承
appointment
;不表示衍生条目首次出现或业务实际发生时间。
sourceCreatedAt
DateTime
?
@
map
(
"source_created_at"
)
@
db
.
Timestamptz
(
3
)
///
planned
推荐填
—
未来安排的目标时间
(
预约时间
/
复查时间
/
计划治疗时间
)
///
planned
推荐填
—
未来安排的目标时间
(
预约时间
/
复查时间
/
计划治疗时间
)
plannedFor
DateTime
?
@
map
(
"planned_for"
)
@
db
.
Timestamptz
(
3
)
plannedFor
DateTime
?
@
map
(
"planned_for"
)
@
db
.
Timestamptz
(
3
)
///
planned
时间窗起
;
部分场景
(
医嘱
"1-2 周内复查"
)
需要区间
///
planned
时间窗起
;
部分场景
(
医嘱
"1-2 周内复查"
)
需要区间
...
...
apps/pac-service/src/integrations/meyer/pac-sink.ts
View file @
5b80c042
...
@@ -77,7 +77,11 @@ export class MeyerPacSink implements MeyerSink {
...
@@ -77,7 +77,11 @@ export class MeyerPacSink implements MeyerSink {
}
}
const
metrics
=
await
this
.
pipeline
.
runForTransaction
({
const
metrics
=
await
this
.
pipeline
.
runForTransaction
({
transaction
:
{
...
transaction
,
action
:
item
.
action
},
transaction
:
{
...
transaction
,
action
:
item
.
action
},
canonicalRow
:
item
.
resource
===
'image_study'
?
projectedImage
:
item
.
row
,
// 来源元数据仅交给事实写入,不改变既有 bundle/hash/原始事件幂等身份。
// 美亚只给影像创建时间,诊断的分析创建时间未知,⛔ 不借用影像创建时间。
canonicalRow
:
item
.
resource
===
'image_study'
?
{
...
projectedImage
,
sourceCreatedAt
:
image
.
uploadedAt
}
:
item
.
row
,
});
});
if
(
!
metrics
.
parserMatched
||
metrics
.
factsFailed
)
throw
new
Error
(
'PAC fact write failed; retry retained bundle'
);
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 @
5b80c042
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
,
fileRefs
:
z
.
array
(
z
.
object
({
provider
:
z
.
string
().
min
(
1
),
connectionId
:
s
,
fileId
:
z
.
string
().
min
(
1
),
role
:
z
.
enum
([
'original'
,
'thumbnail'
,
'slice'
]),
index
:
z
.
number
().
int
().
nonnegative
().
optional
(),
})
.
strict
(),
)
.
nullable
()
.
optional
(),
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 @
5b80c042
import
{
z
}
from
'zod'
;
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
>
=
{
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'
,
consultation_record
:
visit_registration_record
:
'registration_external_id visit_registration_external_id registered_at doctor_id appointment_external_id'
,
'consultation_external_id consult_date source_channel intent_categories is_first_visit task_director doctor_id appointment_external_id'
,
visit_reception_record
:
'reception_external_id visit_reception_external_id reception_type received_at receptionist_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'
,
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'
,
diagnosis_record
:
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'
,
'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'
,
recommendation_record
:
'source_encounter_external_id tooth_position doctor_id doctor_name extracted_by source_fact_subject_id doctor_suggested_at window_days'
,
treatment_record
:
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'
,
'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'
,
image_record
:
'image_external_id encounter_external_id tooth_positions captured_at file_url file_name file_type file_refs doctor_id doctor_name'
,
recommendation_record
:
image_analysis_record
:
'analysis_external_id image_external_id code_source analyzed_at source_updated_at analysis_result'
,
'source_encounter_external_id tooth_position doctor_id doctor_name extracted_by source_fact_subject_id doctor_suggested_at window_days'
,
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'
,
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 file_refs 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'
,
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'
,
refund_record
:
'refund_external_id amount_cents related_payment_external_id'
,
recharge_record
:
'recharge_external_id amount_cents card_type recharge_type'
,
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'
,
complaint_record
:
referral_record
:
'referral_external_id referrer_external_id referrer_name referrer_chart_number referrer_type'
,
'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
>
=
{
const
ownIds
:
Record
<
string
,
string
>
=
{
consultation_record
:
'consultation_external_id'
,
visit_registration_record
:
'registration_external_id'
,
consultation_record
:
'consultation_external_id'
,
visit_reception_record
:
'reception_external_id'
,
encounter_record
:
'encounter_external_id'
,
visit_registration_record
:
'registration_external_id'
,
emr_record
:
'emr_external_id'
,
image_record
:
'image_external_id'
,
image_analysis_record
:
'analysis_external_id'
,
visit_reception_record
:
'reception_external_id'
,
appointment_record
:
'appointment_external_id'
,
order_record
:
'order_external_id'
,
payment_record
:
'payment_external_id'
,
encounter_record
:
'encounter_external_id'
,
refund_record
:
'refund_external_id'
,
recharge_record
:
'recharge_external_id'
,
complaint_record
:
'complaint_external_id'
,
referral_record
:
'referral_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
instantSchema
=
z
.
iso
.
datetime
({
offset
:
true
});
const
camel
=
(
key
:
string
)
=>
key
.
replace
(
/_
([
a-z
])
/g
,
(
_
,
ch
:
string
)
=>
ch
.
toUpperCase
());
const
camel
=
(
key
:
string
)
=>
key
.
replace
(
/_
([
a-z
])
/g
,
(
_
,
ch
:
string
)
=>
ch
.
toUpperCase
());
export
const
lookupContentNames
:
Record
<
string
,
Record
<
string
,
string
>>
=
Object
.
fromEntries
(
export
const
lookupContentNames
:
Record
<
string
,
Record
<
string
,
string
>>
=
Object
.
fromEntries
(
Object
.
entries
(
fields
).
map
(([
type
,
names
])
=>
[
type
,
{
Object
.
entries
(
fields
).
map
(([
type
,
names
])
=>
[
...
Object
.
fromEntries
(
names
.
split
(
' '
).
map
(
key
=>
[
key
,
camel
(
key
)])),
type
,
...(
ownIds
[
type
]
?
{
[
ownIds
[
type
]]:
'externalId'
}
:
{}),
{
...(
type
===
'diagnosis_record'
?
{
name_zh
:
'name'
}
:
{}),
...
Object
.
fromEntries
(
names
.
split
(
' '
).
map
((
key
)
=>
[
key
,
camel
(
key
)])),
...(
type
===
'recommendation_record'
?
{
extracted_by
:
'extractionMethod'
}
:
{}),
doctor_id
:
'doctorExternalId'
,
...([
'consultation_record'
,
'complaint_record'
].
includes
(
type
)
?
{
content
:
'text'
}
:
{}),
...(
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
project
(
}
value
:
Record
<
string
,
unknown
>
,
/** 有异值别名时保留两份,⛔ 不静默覆盖来源数据。 */
shape
:
Record
<
string
,
unknown
>
,
function
rename
(
value
:
Record
<
string
,
unknown
>
,
names
:
Record
<
string
,
string
>
):
Record
<
string
,
unknown
>
{
names
:
Record
<
string
,
string
>
=
{},
const
out
=
{
...
value
};
)
{
for
(
const
[
from
,
to
]
of
Object
.
entries
(
names
))
{
const
out
:
Record
<
string
,
unknown
>
=
{};
if
(
!
(
from
in
value
)
||
from
===
to
)
continue
;
for
(
const
target
of
Object
.
keys
(
shape
))
{
if
(
to
in
value
&&
JSON
.
stringify
(
value
[
to
])
!==
JSON
.
stringify
(
value
[
from
]))
continue
;
const
keys
=
[
target
,
...
Object
.
keys
(
names
).
filter
((
k
)
=>
names
[
k
]
===
target
&&
k
!==
target
)];
out
[
to
]
=
value
[
from
];
delete
out
[
from
];
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
;
return
out
;
}
}
export
function
lookupContent
(
type
:
string
,
value
:
unknown
,
occurredAt
:
string
|
null
,
plannedFor
:
string
|
null
):
unknown
{
function
nested
(
if
(
!
record
(
value
)
||
!
lookupContentNames
[
type
])
return
value
;
value
:
unknown
,
const
c
=
rename
(
value
,
lookupContentNames
[
type
]);
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
)
=>
{
const
sameTime
=
(
key
:
string
,
at
:
string
|
null
)
=>
{
if
(
!
(
key
in
c
))
return
;
const
v
=
c
[
key
];
const
v
=
c
[
key
];
if
(
v
==
null
||
(
at
&&
typeof
v
===
'string'
&&
/
(?:
Z|
[
+-
]\d{2}
:
\d{2})
$/
.
test
(
v
)
if
(
&&
instantSchema
.
safeParse
(
v
).
success
&&
Date
.
parse
(
v
)
===
Date
.
parse
(
at
)))
delete
c
[
key
];
v
==
null
||
(
at
&&
typeof
v
===
'string'
&&
instantSchema
.
safeParse
(
v
).
success
&&
Date
.
parse
(
v
)
===
Date
.
parse
(
at
))
)
delete
c
[
key
];
};
};
const
occurrenceKeys
:
Record
<
string
,
string
>
=
{
const
keys
:
Record
<
string
,
string
>
=
{
recommendation_record
:
'doctorSuggestedAt'
,
recommendation_record
:
'suggestedAt'
,
image_analysis_record
:
'analyzedAt'
,
visit_registration_record
:
'registeredAt'
,
image_analysis_record
:
'analyzedAt'
,
visit_reception_record
:
'receivedAt'
,
image_record
:
'capturedAt'
,
visit_registration_record
:
'registeredAt'
,
visit_reception_record
:
'receivedAt'
,
image_record
:
'capturedAt'
,
};
};
if
(
occurrenceKeys
[
type
])
sameTime
(
occurrenceKeys
[
type
],
occurredAt
);
if
(
keys
[
type
])
sameTime
(
keys
[
type
],
occurredAt
);
if
(
type
===
'appointment_record'
)
{
if
(
type
===
'appointment_record'
)
sameTime
(
'scheduledAt'
,
plannedFor
);
sameTime
(
'scheduledAt'
,
plannedFor
);
// 实际到诊由 encounter_record 表达,预约只提供创建时间、约定时间及履约状态。
delete
c
.
arrivedAt
;
delete
c
.
arrived_at
;
}
if
(
type
===
'consultation_record'
)
{
if
(
type
===
'consultation_record'
)
{
if
(
c
.
consultDate
==
null
||
(
occurredAt
&&
c
.
consultDate
===
occurredAt
.
slice
(
0
,
10
)))
delete
c
.
consultDate
;
if
(
c
.
consultDate
==
null
||
(
occurredAt
&&
c
.
consultDate
===
occurredAt
.
slice
(
0
,
10
)))
if
(
c
.
intent
==
null
||
(
Array
.
isArray
(
c
.
intents
)
&&
c
.
intents
.
length
>
0
&&
c
.
intent
===
c
.
intents
.
join
(
'/'
)))
delete
c
.
intent
;
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'
)
{
if
(
Array
.
isArray
(
c
.
fileRefs
))
c
.
fileRefs
=
c
.
fileRefs
.
map
((
v
)
=>
nested
(
v
,
LookupContentSchemas
.
image_record
.
shape
.
fileRefs
.
unwrap
().
unwrap
().
element
.
shape
),
);
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
(
c
.
qualityCheck
!=
null
)
{
if
(
record
(
c
.
qualityCheck
))
c
.
qualityCheck
=
rename
(
c
.
qualityCheck
,
{
reject_reason
:
'rejectReason'
});
const
q
=
nested
(
c
.
qualityCheck
,
LookupQualitySchema
.
shape
,
{
if
(
Array
.
isArray
(
c
.
clinicalSignals
))
c
.
clinicalSignals
=
c
.
clinicalSignals
.
map
(
v
=>
{
status
:
'sourceStatus'
,
if
(
!
record
(
v
))
return
v
;
reject_reason
:
'rejectionReason'
,
const
signal
=
rename
(
v
,
{
extractedBy
:
'extractorVersion'
});
rejectReason
:
'rejectionReason'
,
const
target
=
typeof
signal
.
field
===
'string'
?
lookupContentNames
[
type
]?.[
signal
.
field
]
:
undefined
;
});
// 引用随已成功改名的字段更新;冲突时仍指向保留的原字段。
q
.
rejectionReason
=
lookupNarrative
(
q
.
rejectionReason
,
''
,
'rejectionReason'
);
if
(
target
&&
!
(
String
(
signal
.
field
)
in
c
)
&&
target
in
c
)
signal
.
field
=
target
;
c
.
qualityCheck
=
q
;
return
signal
;
}
});
if
(
Array
.
isArray
(
c
.
clinicalSignals
))
if
(
type
===
'order_record'
&&
Array
.
isArray
(
c
.
items
))
c
.
items
=
c
.
items
.
map
(
v
=>
record
(
v
)
?
rename
(
v
,
{
c
.
clinicalSignals
=
c
.
clinicalSignals
.
map
((
v
)
=>
{
item_type
:
'itemType'
,
item_code
:
'itemCode'
,
item_name
:
'itemName'
,
unit_price_cents
:
'unitPriceCents'
,
amount_cents
:
'amountCents'
,
treatment_category
:
'treatmentCategory'
,
const
signal
=
nested
(
v
,
LookupClinicalSignalSchema
.
shape
,
{
})
:
v
);
phrase
:
'evidence'
,
return
c
;
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 @
5b80c042
import
{
z
}
from
'zod'
;
import
{
z
}
from
'zod'
;
import
type
{
OpenFactRow
}
from
'./open-fact'
;
import
type
{
OpenFactRow
}
from
'./open-fact'
;
import
{
redactJson
}
from
'./open-redact'
;
import
{
lookupContent
}
from
'./lookup-content'
;
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
)
{
export
function
toLookupFact
(
row
:
LookupFactRow
,
clinicName
:
string
|
null
)
{
const
c
=
row
.
content
&&
typeof
row
.
content
===
'object'
&&
!
Array
.
isArray
(
row
.
content
)
const
c
=
?
row
.
content
as
Record
<
string
,
unknown
>
:
{};
row
.
content
&&
typeof
row
.
content
===
'object'
&&
!
Array
.
isArray
(
row
.
content
)
?
(
row
.
content
as
Record
<
string
,
unknown
>
)
:
{};
let
occurredAt
=
row
.
occurred_at
?.
toISOString
()
??
null
;
let
occurredAt
=
row
.
occurred_at
?.
toISOString
()
??
null
;
let
plannedFor
:
string
|
null
=
null
;
let
plannedFor
:
string
|
null
=
null
;
const
knownSource
=
row
.
host_name
===
'friday'
||
row
.
host_name
===
'jvs-dw'
;
const
knownSource
=
row
.
host_name
===
'friday'
||
row
.
host_name
===
'jvs-dw'
;
...
@@ -17,24 +25,42 @@ export function toLookupFact(row: LookupFactRow, clinicName: string | null) {
...
@@ -17,24 +25,42 @@ export function toLookupFact(row: LookupFactRow, clinicName: string | null) {
occurredAt
=
appointmentCreatedAt
(
row
.
host_name
,
row
.
appointment_created_values
);
occurredAt
=
appointmentCreatedAt
(
row
.
host_name
,
row
.
appointment_created_values
);
plannedFor
=
row
.
planned_for
?.
toISOString
()
??
explicitInstant
(
c
.
scheduled_at
);
plannedFor
=
row
.
planned_for
?.
toISOString
()
??
explicitInstant
(
c
.
scheduled_at
);
}
else
if
(
row
.
type
===
'recommendation_record'
)
{
}
else
if
(
row
.
type
===
'recommendation_record'
)
{
occurredAt
=
explicitInstant
(
c
.
doctor_suggested_at
)
??
occurredAt
occurredAt
=
??
(
knownSource
?
row
.
planned_for
?.
toISOString
()
??
null
:
null
);
explicitInstant
(
c
.
doctor_suggested_at
)
??
occurredAt
??
(
knownSource
?
(
row
.
planned_for
?.
toISOString
()
??
null
)
:
null
);
}
else
if
(
row
.
type
===
'treatment_record'
&&
row
.
kind
===
'planned'
)
{
}
else
if
(
row
.
type
===
'treatment_record'
&&
row
.
kind
===
'planned'
)
{
// 两宿主现行映射均将制定时间写在 planned_for;没有明确执行日期。
// 两宿主现行映射均将制定时间写在 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
>
=
{
const
occurrenceKey
:
Record
<
string
,
string
>
=
{
image_analysis_record
:
'analyzed_at'
,
visit_registration_record
:
'registered_at'
,
image_analysis_record
:
'analyzed_at'
,
visit_reception_record
:
'received_at'
,
image_record
:
'captured_at'
,
visit_registration_record
:
'registered_at'
,
visit_reception_record
:
'received_at'
,
image_record
:
'captured_at'
,
};
};
const
key
=
occurrenceKey
[
row
.
type
];
const
key
=
occurrenceKey
[
row
.
type
];
if
(
!
occurredAt
&&
key
)
occurredAt
=
explicitInstant
(
c
[
key
]);
if
(
!
occurredAt
&&
key
)
occurredAt
=
explicitInstant
(
c
[
key
]);
return
{
const
parsed
=
LookupFactSchema
.
safeParse
({
id
:
row
.
fact_id
,
type
:
row
.
type
,
kind
:
row
.
kind
,
id
:
row
.
fact_id
,
clinicId
:
row
.
clinic_id
,
clinicName
,
occurredAt
,
plannedFor
,
type
:
row
.
type
,
content
:
lookupContent
(
row
.
type
,
redactJson
(
row
.
content
),
occurredAt
,
plannedFor
),
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 @
5b80c042
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 @
5b80c042
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 @
5b80c042
...
@@ -25,7 +25,7 @@ export class PatientFactsLookupController {
...
@@ -25,7 +25,7 @@ export class PatientFactsLookupController {
description
:
[
description
:
[
'使用应用 Bearer 令牌,需开通宿主查询权限。病历号、姓名、手机号至少一项,多项精确匹配。'
,
'使用应用 Bearer 令牌,需开通宿主查询权限。病历号、姓名、手机号至少一项,多项精确匹配。'
,
'返回患者数组:`patient`(资料)、`persona`(画像)、`relations`(关系)、`facts`(全部当前有效事实)。无匹配返回空数组,不分页。'
,
'返回患者数组:`patient`(资料)、`persona`(画像)、`relations`(关系)、`facts`(全部当前有效事实)。无匹配返回空数组,不分页。'
,
'[
调用说明与示例](/docs/integration/patient-facts-lookup) · [事实 content 字段](/docs/integration/patient-fact-content
)'
,
'[
完整接口说明、字段与示例](/docs/api/open/PatientFactsLookupController_lookup
)'
,
].
join
(
'
\
n
\
n'
),
].
join
(
'
\
n
\
n'
),
})
})
lookup
(@
Req
()
req
:
ApplicationRequest
,
@
Body
()
body
:
PatientFactsLookupRequestDto
)
{
lookup
(@
Req
()
req
:
ApplicationRequest
,
@
Body
()
body
:
PatientFactsLookupRequestDto
)
{
...
...
apps/pac-service/src/modules/open/patient-facts-lookup.schema.ts
View file @
5b80c042
import
{
z
}
from
'zod'
;
import
{
z
}
from
'zod'
;
import
{
createZodDto
}
from
'nestjs-zod'
;
import
{
createZodDto
}
from
'nestjs-zod'
;
import
{
LookupContentSchemas
}
from
'./lookup-content.schema'
;
export
const
PatientFactsLookupSchema
=
z
export
const
PatientFactsLookupSchema
=
z
.
strictObject
({
.
strictObject
({
...
@@ -28,13 +29,149 @@ const PatientBasicSchema = z.object({
...
@@ -28,13 +29,149 @@ const PatientBasicSchema = z.object({
birthDate
:
z
.
string
().
nullable
().
describe
(
'YYYY-MM-DD 业务日期'
),
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
({
export
const
PatientFactsLookupResponseSchema
=
z
.
object
({
items
:
z
.
array
(
items
:
z
.
array
(
z
.
object
({
z
.
object
({
patient
:
PatientBasicSchema
.
extend
({
patient
:
PatientBasicSchema
.
extend
({
dedicatedCs
:
z
dedicatedCs
:
z
.
object
({
.
object
({
id
:
z
.
string
().
nullable
().
describe
(
'宿主侧专属客服标识,不是 PAC 用户 ID
'
),
externalId
:
z
.
string
().
nullable
().
describe
(
'宿主侧专属客服标识
'
),
name
:
z
.
string
().
nullable
().
describe
(
'专属客服姓名'
),
name
:
z
.
string
().
nullable
().
describe
(
'专属客服姓名'
),
})
})
.
nullable
()
.
nullable
()
...
@@ -67,20 +204,7 @@ export const PatientFactsLookupResponseSchema = z.object({
...
@@ -67,20 +204,7 @@ export const PatientFactsLookupResponseSchema = z.object({
),
),
}),
}),
),
),
facts
:
z
.
array
(
facts
:
z
.
array
(
LookupFactSchema
),
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 字段参考'
),
}),
),
}),
}),
),
),
});
});
...
...
apps/pac-service/src/modules/open/patient-facts-lookup.service.ts
View file @
5b80c042
...
@@ -3,6 +3,7 @@ import { Prisma } from '@prisma/client';
...
@@ -3,6 +3,7 @@ import { Prisma } from '@prisma/client';
import
{
PrismaService
}
from
'../../prisma/prisma.service'
;
import
{
PrismaService
}
from
'../../prisma/prisma.service'
;
import
{
currentFactHeadSql
}
from
'../facts/current-fact'
;
import
{
currentFactHeadSql
}
from
'../facts/current-fact'
;
import
type
{
OpenFactRow
}
from
'./open-fact'
;
import
type
{
OpenFactRow
}
from
'./open-fact'
;
import
{
LookupContentError
}
from
'./lookup-narrative'
;
import
{
toLookupFact
}
from
'./lookup-fact'
;
import
{
toLookupFact
}
from
'./lookup-fact'
;
import
{
redactText
}
from
'./open-redact'
;
import
{
redactText
}
from
'./open-redact'
;
import
{
toOpenPersona
}
from
'./open-persona'
;
import
{
toOpenPersona
}
from
'./open-persona'
;
...
@@ -18,6 +19,7 @@ type LookupRow = OpenFactRow & {
...
@@ -18,6 +19,7 @@ type LookupRow = OpenFactRow & {
host_name
:
string
;
host_name
:
string
;
fact_id
:
string
|
null
;
fact_id
:
string
|
null
;
appointment_created_values
?:
unknown
;
appointment_created_values
?:
unknown
;
source_evidence
?:
unknown
;
};
};
type
LookupItem
=
PatientFactsLookupItem
;
type
LookupItem
=
PatientFactsLookupItem
;
...
@@ -55,7 +57,9 @@ function basicPatient(p: {
...
@@ -55,7 +57,9 @@ function basicPatient(p: {
}
}
/** 只投影专属客服的稳定字段,避免把 preferences 中的其他内部属性透出。 */
/** 只投影专属客服的稳定字段,避免把 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
;
if
(
!
preferences
||
typeof
preferences
!==
'object'
||
Array
.
isArray
(
preferences
))
return
null
;
const
value
=
(
preferences
as
Record
<
string
,
unknown
>
).
dedicatedCs
;
const
value
=
(
preferences
as
Record
<
string
,
unknown
>
).
dedicatedCs
;
if
(
!
value
||
typeof
value
!==
'object'
||
Array
.
isArray
(
value
))
return
null
;
if
(
!
value
||
typeof
value
!==
'object'
||
Array
.
isArray
(
value
))
return
null
;
...
@@ -63,7 +67,7 @@ function dedicatedCsOf(preferences: unknown): { id: string | null; name: string
...
@@ -63,7 +67,7 @@ function dedicatedCsOf(preferences: unknown): { id: string | null; name: string
const
rawId
=
typeof
cs
.
id
===
'string'
?
cs
.
id
.
trim
()
:
''
;
const
rawId
=
typeof
cs
.
id
===
'string'
?
cs
.
id
.
trim
()
:
''
;
const
id
=
rawId
&&
rawId
!==
'0'
?
rawId
:
null
;
const
id
=
rawId
&&
rawId
!==
'0'
?
rawId
:
null
;
const
name
=
typeof
cs
.
name
===
'string'
?
redactText
(
cs
.
name
.
trim
())
||
null
:
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
()
@
Injectable
()
...
@@ -96,7 +100,25 @@ export class PatientFactsLookupService {
...
@@ -96,7 +100,25 @@ export class PatientFactsLookupService {
AND t.subject_type = 'appointment'
AND t.subject_type = 'appointment'
AND t.subject_id = f.content ->> 'appointment_external_id'
AND t.subject_id = f.content ->> 'appointment_external_id'
AND (f.clinic_id IS NULL OR t.clinic_id = f.clinic_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
FROM patients p
JOIN hosts h ON h.id = p.host_id
JOIN hosts h ON h.id = p.host_id
LEFT JOIN patient_facts f ON f.patient_id = p.id
LEFT JOIN patient_facts f ON f.patient_id = p.id
...
@@ -190,18 +212,35 @@ export class PatientFactsLookupService {
...
@@ -190,18 +212,35 @@ export class PatientFactsLookupService {
const
item
=
items
.
get
(
row
.
patient_id
);
const
item
=
items
.
get
(
row
.
patient_id
);
if
(
!
item
)
continue
;
if
(
!
item
)
continue
;
if
(
row
.
fact_id
!==
null
)
if
(
row
.
fact_id
!==
null
)
item
.
facts
.
push
(
toLookupFact
(
try
{
{
...
row
,
fact_id
:
row
.
fact_id
},
item
.
facts
.
push
(
row
.
clinic_id
?
(
application
.
clinicNames
[
row
.
clinic_id
]
??
null
)
:
null
,
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
)
=>
{
for
(
const
item
of
items
.
values
())
const
at
=
a
.
occurredAt
??
a
.
plannedFor
;
item
.
facts
.
sort
((
a
,
b
)
=>
{
const
bt
=
b
.
occurredAt
??
b
.
plannedFor
;
const
at
=
a
.
occurredAt
??
a
.
plannedFor
;
return
(
bt
?
Date
.
parse
(
bt
)
:
-
Infinity
)
-
(
at
?
Date
.
parse
(
at
)
:
-
Infinity
)
const
bt
=
b
.
occurredAt
??
b
.
plannedFor
;
||
b
.
id
.
localeCompare
(
a
.
id
);
return
(
});
(
bt
?
Date
.
parse
(
bt
)
:
-
Infinity
)
-
(
at
?
Date
.
parse
(
at
)
:
-
Infinity
)
||
b
.
id
.
localeCompare
(
a
.
id
)
);
});
return
items
;
return
items
;
},
},
{
isolationLevel
:
Prisma
.
TransactionIsolationLevel
.
RepeatableRead
,
timeout
:
15000
},
{
isolationLevel
:
Prisma
.
TransactionIsolationLevel
.
RepeatableRead
,
timeout
:
15000
},
...
...
apps/pac-service/src/modules/sync/assembler/assembler-engine.ts
View file @
5b80c042
No preview for this file type
apps/pac-service/src/modules/sync/assembler/assembler.schema.ts
View file @
5b80c042
...
@@ -111,6 +111,11 @@ export const AssemblerConfigSchema = z.object({
...
@@ -111,6 +111,11 @@ export const AssemblerConfigSchema = z.object({
/// 主表字段 → canonical 字段映射(canonical key ← host field)
/// 主表字段 → canonical 字段映射(canonical key ← host field)
field_mapping
:
fieldMapping
,
field_mapping
:
fieldMapping
,
/// 经核实的来源记录创建字段(canonical 名);不配置时不猜测 createdAt 的语义。
source_created_at
:
z
.
object
({
field
:
z
.
string
().
min
(
1
),
}).
optional
(),
/// 显式兼容无正文的病历数组元素;仅白名单元数据为空时补 message:null。
/// 显式兼容无正文的病历数组元素;仅白名单元数据为空时补 message:null。
empty_narrative_entries
:
z
.
record
(
z
.
string
(),
z
.
object
({
empty_narrative_entries
:
z
.
record
(
z
.
string
(),
z
.
object
({
empty_metadata_keys
:
z
.
array
(
z
.
string
().
min
(
1
)),
empty_metadata_keys
:
z
.
array
(
z
.
string
().
min
(
1
)),
...
...
apps/pac-service/src/modules/sync/assembler/field-mapper.ts
View file @
5b80c042
...
@@ -3,6 +3,7 @@ import {
...
@@ -3,6 +3,7 @@ import {
CanonicalResourceKey
,
CanonicalResourceKey
,
CanonicalResourceMeta
,
CanonicalResourceMeta
,
CanonicalResourceSchemas
,
CanonicalResourceSchemas
,
CanonicalDateTimeSchema
,
}
from
'@pac/types'
;
}
from
'@pac/types'
;
/**
/**
...
@@ -184,7 +185,9 @@ export function normalizeCanonical(
...
@@ -184,7 +185,9 @@ export function normalizeCanonical(
// birthDate/taskDate 等纯日期仍只按各自契约处理,不按字段后缀猜测时间类型。
// birthDate/taskDate 等纯日期仍只按各自契约处理,不按字段后缀猜测时间类型。
for
(
const
k
of
new
Set
([
'createdAt'
,
'updatedAt'
,
...
meta
.
datetimeFields
]))
{
for
(
const
k
of
new
Set
([
'createdAt'
,
'updatedAt'
,
...
meta
.
datetimeFields
]))
{
if
(
k
in
out
&&
typeof
out
[
k
]
===
'string'
)
{
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(
...
@@ -198,6 +201,15 @@ export function normalizeCanonical(
?
{
...
row
,
[
field
]:
normalizeDatetime
(
row
[
field
]
as
string
,
ctx
.
timezone
)
}
:
item
;
?
{
...
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
;
return
out
;
}
}
...
@@ -296,7 +308,7 @@ export function normalizeDatetime(s: string, timezone: string): string {
...
@@ -296,7 +308,7 @@ export function normalizeDatetime(s: string, timezone: string): string {
const
trimmed
=
s
.
trim
();
const
trimmed
=
s
.
trim
();
// 已有 tz
// 已有 tz
if
(
/
[
Zz
]
$/
.
test
(
trimmed
)
||
/
[
+-
]\d{2}
:
?\d{2}
$/
.
test
(
trimmed
))
{
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 零点)
// 仅日期 → 当地零点(见上方注释:直通会被 JS 当成 UTC 零点)
if
(
/^
\d{4}
-
\d{2}
-
\d{2}
$/
.
test
(
trimmed
))
{
if
(
/^
\d{4}
-
\d{2}
-
\d{2}
$/
.
test
(
trimmed
))
{
...
@@ -311,9 +323,8 @@ export function normalizeDatetime(s: string, timezone: string): string {
...
@@ -311,9 +323,8 @@ export function normalizeDatetime(s: string, timezone: string): string {
}
}
/**
/**
* IANA 时区 → 当前时刻的 UTC offset suffix(如 '+08:00')。
* 当前宿主约定使用固定偏移。未支持的时区须显式报错,不能静默当作 UTC。
* 简化版:对常用东亚时区直接映射;真要严谨可用 Intl.DateTimeFormat 推导,但
* 接入需要夏令时的宿主时,应按源日期解析偏移,再扩展此处。
* v1 不处理 DST(中国大陆不实行;若接欧美 host 再迭代)。
*/
*/
export
function
timezoneToOffsetSuffix
(
tz
:
string
):
string
{
export
function
timezoneToOffsetSuffix
(
tz
:
string
):
string
{
const
fixed
:
Record
<
string
,
string
>
=
{
const
fixed
:
Record
<
string
,
string
>
=
{
...
@@ -326,9 +337,18 @@ export function timezoneToOffsetSuffix(tz: string): string {
...
@@ -326,9 +337,18 @@ export function timezoneToOffsetSuffix(tz: string): string {
UTC
:
'Z'
,
UTC
:
'Z'
,
'Etc/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 校验 + 漂移抛错
// Stage ③:zod 校验 + 漂移抛错
// ─────────────────────────────────────────────────────────────
// ─────────────────────────────────────────────────────────────
...
@@ -340,6 +360,7 @@ export function validateCanonical<T>(opts: {
...
@@ -340,6 +360,7 @@ export function validateCanonical<T>(opts: {
resource
:
string
;
resource
:
string
;
}):
T
{
}):
T
{
try
{
try
{
commonTimesSchema
.
parse
(
opts
.
raw
);
return
opts
.
schema
.
parse
(
opts
.
raw
);
return
opts
.
schema
.
parse
(
opts
.
raw
);
}
catch
(
err
)
{
}
catch
(
err
)
{
if
(
err
instanceof
ZodError
)
{
if
(
err
instanceof
ZodError
)
{
...
...
apps/pac-service/src/modules/sync/canonical-time.ts
0 → 100644
View file @
5b80c042
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 @
5b80c042
import
{
readSourceCreatedTime
,
type
SourceCreatedTime
}
from
'../source-created-time'
;
import
{
Injectable
,
Logger
}
from
'@nestjs/common'
;
import
{
Injectable
,
Logger
}
from
'@nestjs/common'
;
import
{
createHash
}
from
'node:crypto'
;
import
{
createHash
}
from
'node:crypto'
;
import
{
FactStatus
,
type
FactType
,
type
FactKind
}
from
'@pac/types'
;
import
{
FactStatus
,
type
FactType
,
type
FactKind
}
from
'@pac/types'
;
...
@@ -182,7 +183,7 @@ export class FactWriter {
...
@@ -182,7 +183,7 @@ export class FactWriter {
},
{
maxWait
:
30
_000
,
timeout
:
120
_000
});
},
{
maxWait
:
30
_000
,
timeout
:
120
_000
});
}
}
async
writeDraft
(
input
:
{
async
writeDraft
(
input
:
Partial
<
SourceCreatedTime
>
&
{
draft
:
FactDraft
;
draft
:
FactDraft
;
hostId
:
string
;
hostId
:
string
;
tenantId
:
string
;
tenantId
:
string
;
...
@@ -322,6 +323,7 @@ export class FactWriter {
...
@@ -322,6 +323,7 @@ export class FactWriter {
clinicId
:
validatedDraft
.
clinicId
??
null
,
clinicId
:
validatedDraft
.
clinicId
??
null
,
occurredAt
:
validatedDraft
.
occurredAt
??
null
,
occurredAt
:
validatedDraft
.
occurredAt
??
null
,
sourceUpdatedAt
:
sourceUpdatedAt
??
null
,
sourceUpdatedAt
:
sourceUpdatedAt
??
null
,
...
readSourceCreatedTime
({
...
input
}),
plannedFor
:
validatedDraft
.
plannedFor
??
null
,
plannedFor
:
validatedDraft
.
plannedFor
??
null
,
validFrom
:
validatedDraft
.
validFrom
??
null
,
validFrom
:
validatedDraft
.
validFrom
??
null
,
validUntil
:
validatedDraft
.
validUntil
??
null
,
validUntil
:
validatedDraft
.
validUntil
??
null
,
...
@@ -558,6 +560,7 @@ export class FactWriter {
...
@@ -558,6 +560,7 @@ export class FactWriter {
clinicId
:
entry
.
draft
.
clinicId
??
null
,
clinicId
:
entry
.
draft
.
clinicId
??
null
,
occurredAt
:
entry
.
draft
.
occurredAt
??
null
,
occurredAt
:
entry
.
draft
.
occurredAt
??
null
,
sourceUpdatedAt
:
entry
.
sourceUpdatedAt
??
null
,
sourceUpdatedAt
:
entry
.
sourceUpdatedAt
??
null
,
...
readSourceCreatedTime
({
...
entry
}),
plannedFor
:
entry
.
draft
.
plannedFor
??
null
,
plannedFor
:
entry
.
draft
.
plannedFor
??
null
,
validFrom
:
entry
.
draft
.
validFrom
??
null
,
validFrom
:
entry
.
draft
.
validFrom
??
null
,
validUntil
:
entry
.
draft
.
validUntil
??
null
,
validUntil
:
entry
.
draft
.
validUntil
??
null
,
...
@@ -752,7 +755,7 @@ export interface BulkWriteOutcome {
...
@@ -752,7 +755,7 @@ export interface BulkWriteOutcome {
}
}
/// FactWriter.bulkWrite 入参条目 — 一个 draft + 它所属的 patient / host / tenant / tx 上下文
/// FactWriter.bulkWrite 入参条目 — 一个 draft + 它所属的 patient / host / tenant / tx 上下文
export
interface
BulkEntry
{
export
interface
BulkEntry
extends
Partial
<
SourceCreatedTime
>
{
draft
:
FactDraft
;
draft
:
FactDraft
;
hostId
:
string
;
hostId
:
string
;
tenantId
:
string
;
tenantId
:
string
;
...
...
apps/pac-service/src/modules/sync/pipeline/parser-pipeline.service.ts
View file @
5b80c042
import
{
readSourceCreatedTime
}
from
'../source-created-time'
;
import
{
readCanonicalTime
}
from
'../canonical-time'
;
import
{
Injectable
,
Logger
}
from
'@nestjs/common'
;
import
{
Injectable
,
Logger
}
from
'@nestjs/common'
;
import
type
{
Action
}
from
'@pac/types'
;
import
type
{
Action
}
from
'@pac/types'
;
import
{
PrismaService
}
from
'../../../prisma/prisma.service'
;
import
{
PrismaService
}
from
'../../../prisma/prisma.service'
;
...
@@ -112,7 +114,7 @@ export class ParserPipeline {
...
@@ -112,7 +114,7 @@ export class ParserPipeline {
try
{
try
{
const
entries
=
drafts
.
map
(
draft
=>
({
draft
,
hostId
:
transaction
.
hostId
,
const
entries
=
drafts
.
map
(
draft
=>
({
draft
,
hostId
:
transaction
.
hostId
,
tenantId
:
transaction
.
tenantId
,
sourceUnit
:
patientSourceUnit
,
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
}]));
applyOutcome
(
metrics
,
await
this
.
writer
.
writeSnapshots
([{
...
entries
[
0
]
!
,
scope
,
entries
}]));
}
catch
(
err
)
{
}
catch
(
err
)
{
metrics
.
factsFailed
++
;
metrics
.
factsFailed
++
;
...
@@ -131,6 +133,7 @@ export class ParserPipeline {
...
@@ -131,6 +133,7 @@ export class ParserPipeline {
patientId
:
transaction
.
patientId
,
patientId
:
transaction
.
patientId
,
transactionId
:
transaction
.
id
,
transactionId
:
transaction
.
id
,
sourceUpdatedAt
,
sourceUpdatedAt
,
...
readSourceCreatedTime
(
canonicalRow
),
});
});
metrics
.
writes
.
push
(
result
);
metrics
.
writes
.
push
(
result
);
switch
(
result
.
action
)
{
switch
(
result
.
action
)
{
...
@@ -224,6 +227,7 @@ export class ParserPipeline {
...
@@ -224,6 +227,7 @@ export class ParserPipeline {
patientId
:
item
.
transaction
.
patientId
,
patientId
:
item
.
transaction
.
patientId
,
transactionId
:
item
.
transaction
.
id
,
transactionId
:
item
.
transaction
.
id
,
sourceUpdatedAt
,
sourceUpdatedAt
,
...
readSourceCreatedTime
(
item
.
canonicalRow
),
});
});
}
}
const
scope
=
parser
.
snapshotScope
?.({
transaction
:
item
.
transaction
,
canonicalRow
:
item
.
canonicalRow
});
const
scope
=
parser
.
snapshotScope
?.({
transaction
:
item
.
transaction
,
canonicalRow
:
item
.
canonicalRow
});
...
@@ -296,6 +300,7 @@ export class ParserPipeline {
...
@@ -296,6 +300,7 @@ export class ParserPipeline {
patientId
:
e
.
patientId
,
patientId
:
e
.
patientId
,
transactionId
:
e
.
transactionId
,
transactionId
:
e
.
transactionId
,
sourceUpdatedAt
:
e
.
sourceUpdatedAt
,
sourceUpdatedAt
:
e
.
sourceUpdatedAt
,
sourceCreatedAt
:
e
.
sourceCreatedAt
,
});
});
metrics
.
writes
.
push
(
r
);
metrics
.
writes
.
push
(
r
);
switch
(
r
.
action
)
{
switch
(
r
.
action
)
{
...
@@ -383,13 +388,7 @@ export interface PipelineRunMetrics {
...
@@ -383,13 +388,7 @@ export interface PipelineRunMetrics {
writes
:
FactWriteResult
[];
writes
:
FactWriteResult
[];
}
}
///
canonicalRow.updatedAt(宿主末次修改时间,string / Date)→ Date;缺失或不可解析 → null(退回到达顺序语义)
。
///
只接受已归一的来源时间;仅缺失时为 null,有值但非法不能偷偷降为到达顺序
。
function
parseSourceUpdatedAt
(
canonicalRow
:
Record
<
string
,
unknown
>
):
Date
|
null
{
function
parseSourceUpdatedAt
(
canonicalRow
:
Record
<
string
,
unknown
>
):
Date
|
null
{
const
v
=
canonicalRow
.
updatedAt
;
return
readCanonicalTime
(
canonicalRow
.
updatedAt
,
'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
;
}
}
apps/pac-service/src/modules/sync/pipeline/transaction-synthesizer.ts
View file @
5b80c042
...
@@ -3,6 +3,7 @@ import { createHash } from 'node:crypto';
...
@@ -3,6 +3,7 @@ import { createHash } from 'node:crypto';
import
{
Action
,
ActionSchema
,
SubjectType
,
SubjectTypeSchema
}
from
'@pac/types'
;
import
{
Action
,
ActionSchema
,
SubjectType
,
SubjectTypeSchema
}
from
'@pac/types'
;
import
type
{
Prisma
}
from
'@prisma/client'
;
import
type
{
Prisma
}
from
'@prisma/client'
;
import
type
{
EmitsConfig
}
from
'../assembler/assembler.schema'
;
import
type
{
EmitsConfig
}
from
'../assembler/assembler.schema'
;
import
{
readCanonicalTime
}
from
'../canonical-time'
;
/**
/**
* Transaction Synthesizer(v2.1 重塑)
* Transaction Synthesizer(v2.1 重塑)
...
@@ -77,6 +78,18 @@ export class TransactionSynthesizer {
...
@@ -77,6 +78,18 @@ export class TransactionSynthesizer {
// canonical 字段提取
// canonical 字段提取
const
c
=
input
.
canonicalRow
;
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
();
const
subjectId
=
String
(
c
.
externalId
??
''
).
trim
();
if
(
!
subjectId
)
{
if
(
!
subjectId
)
{
this
.
logger
.
warn
(
this
.
logger
.
warn
(
...
@@ -99,14 +112,9 @@ export class TransactionSynthesizer {
...
@@ -99,14 +112,9 @@ export class TransactionSynthesizer {
:
undefined
;
:
undefined
;
// occurredAt — 从 yaml emits.occurredAtField 指定的字段拿;
// occurredAt — 从 yaml emits.occurredAtField 指定的字段拿;
// 缺失
/不可解析时确定性回退 updatedAt → createdAt(与 sourceEventId 的 updatedAtMark 同序)
。
// 缺失
时确定性回退 updatedAt → createdAt;有值但不合法则拒收,不能隐式猜测时区
。
// 墙钟只在三者皆缺时兜底:否则同一源行每次摄入 occurredAt 抖动,
// 墙钟只在三者皆缺时兜底:否则同一源行每次摄入 occurredAt 抖动,
// 下游 fact 时间锚变更检测会产生伪 supersede 版本。
// 下游 fact 时间锚变更检测会产生伪 supersede 版本。
let
occurredAt
=
this
.
pickOccurredAt
(
c
,
emits
.
occurredAtField
);
if
(
!
occurredAt
)
{
occurredAt
=
this
.
pickOccurredAt
(
c
,
'updatedAt'
)
??
this
.
pickOccurredAt
(
c
,
'createdAt'
);
}
if
(
!
occurredAt
)
{
if
(
!
occurredAt
)
{
this
.
logger
.
warn
(
this
.
logger
.
warn
(
`Cannot determine occurredAt from field="
${
emits
.
occurredAtField
}
" `
+
`Cannot determine occurredAt from field="
${
emits
.
occurredAtField
}
" `
+
...
@@ -143,20 +151,6 @@ export class TransactionSynthesizer {
...
@@ -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
{
private
sha256
(
s
:
string
):
string
{
return
createHash
(
'sha256'
).
update
(
s
).
digest
(
'hex'
);
return
createHash
(
'sha256'
).
update
(
s
).
digest
(
'hex'
);
}
}
...
...
apps/pac-service/src/modules/sync/source-created-time.ts
0 → 100644
View file @
5b80c042
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 @
5b80c042
import
{
readFileSync
,
readdirSync
}
from
'node:fs'
;
import
{
readFileSync
,
readdirSync
}
from
'node:fs'
;
import
{
join
}
from
'node:path'
;
import
{
join
}
from
'node:path'
;
import
*
as
yaml
from
'js-yaml'
;
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
{
AssemblerEngine
}
from
'../src/modules/sync/assembler/assembler-engine'
;
import
{
AssemblerConfigSchema
}
from
'../src/modules/sync/assembler/assembler.schema'
;
import
{
AssemblerConfigSchema
}
from
'../src/modules/sync/assembler/assembler.schema'
;
import
{
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
{
resolveDictIncludes
}
from
'../src/modules/sync/assembler/dict-include'
;
import
{
TransactionSynthesizer
}
from
'../src/modules/sync/pipeline/transaction-synthesizer'
;
import
{
TransactionSynthesizer
}
from
'../src/modules/sync/pipeline/transaction-synthesizer'
;
import
{
ParserPipeline
}
from
'../src/modules/sync/pipeline/parser-pipeline.service'
;
import
{
ParserPipeline
}
from
'../src/modules/sync/pipeline/parser-pipeline.service'
;
...
@@ -65,6 +67,65 @@ test.each(configs)('$host/$file 源创建/修改时间统一归一', ({ config }
...
@@ -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
()
{
function
encounter
()
{
const
config
=
configs
.
find
(
c
=>
c
.
host
===
'friday'
&&
c
.
config
.
canonical
===
'encounter'
)
!
.
config
;
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'
,
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,乱序裁
...
@@ -109,3 +170,20 @@ test.each(['single', 'batch'] as const)('归一后进入 %s parser,乱序裁
expect
(
metrics
.
factsFailed
).
toBe
(
0
);
expect
(
metrics
.
factsFailed
).
toBe
(
0
);
expect
(
metrics
.
factsCreated
).
toBe
(
1
);
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 @
5b80c042
...
@@ -38,6 +38,7 @@ interface Row {
...
@@ -38,6 +38,7 @@ interface Row {
clinicId
:
string
|
null
;
clinicId
:
string
|
null
;
occurredAt
:
Date
|
null
;
occurredAt
:
Date
|
null
;
sourceUpdatedAt
:
Date
|
null
;
sourceUpdatedAt
:
Date
|
null
;
sourceCreatedAt
:
Date
|
null
;
plannedFor
:
Date
|
null
;
plannedFor
:
Date
|
null
;
validFrom
:
Date
|
null
;
validFrom
:
Date
|
null
;
validUntil
:
Date
|
null
;
validUntil
:
Date
|
null
;
...
@@ -70,6 +71,7 @@ function makeStore(seed: Partial<Row>[] = []) {
...
@@ -70,6 +71,7 @@ function makeStore(seed: Partial<Row>[] = []) {
clinicId
:
(
data
.
clinicId
as
string
|
null
)
??
null
,
clinicId
:
(
data
.
clinicId
as
string
|
null
)
??
null
,
occurredAt
:
(
data
.
occurredAt
as
Date
|
null
)
??
null
,
occurredAt
:
(
data
.
occurredAt
as
Date
|
null
)
??
null
,
sourceUpdatedAt
:
(
data
.
sourceUpdatedAt
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
,
plannedFor
:
(
data
.
plannedFor
as
Date
|
null
)
??
null
,
validFrom
:
(
data
.
validFrom
as
Date
|
null
)
??
null
,
validFrom
:
(
data
.
validFrom
as
Date
|
null
)
??
null
,
validUntil
:
(
data
.
validUntil
as
Date
|
null
)
??
null
,
validUntil
:
(
data
.
validUntil
as
Date
|
null
)
??
null
,
...
@@ -605,3 +607,31 @@ describe('患者归属更正版本与旧患者水位', () => {
...
@@ -605,3 +607,31 @@ describe('患者归属更正版本与旧患者水位', () => {
expect
(
rows
[
0
]
!
.
updatedAt
).
toEqual
(
T0
);
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 @
5b80c042
...
@@ -41,7 +41,7 @@
...
@@ -41,7 +41,7 @@
"code_source"
:
"std_code"
,
"code_source"
:
"std_code"
,
"name_zh"
:
"龋病"
,
"name_zh"
:
"龋病"
,
"tooth_position"
:
"36"
,
"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 @
5b80c042
import
{
lookupContent
}
from
'../src/modules/open/lookup-content'
;
import
{
toLookupFact
,
LookupFactRow
}
from
'../src/modules/open/lookup-fact'
;
import
{
toLookupFact
,
LookupFactRow
}
from
'../src/modules/open/lookup-fact'
;
import
{
toOpenFact
}
from
'../src/modules/open/open-fact'
;
import
{
toOpenFact
}
from
'../src/modules/open/open-fact'
;
const
at
=
'2026-10-19T03:00:00.000Z'
;
import
{
LookupContentError
}
from
'../src/modules/open/lookup-narrative'
;
const
other
=
'2026-10-20T03:00:00.000Z'
;
import
{
LookupFactSchema
}
from
'../src/modules/open/patient-facts-lookup.schema'
;
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
at
=
'2026-10-19T03:00:00.000Z'
,
const
run
=
(
type
:
string
,
content
:
any
,
row
:
Partial
<
LookupFactRow
>
=
{})
=>
toLookupFact
({
...
base
,
type
,
content
,
...
row
},
null
);
other
=
'2026-10-20T03:00:00.000Z'
;
describe
(
'lookup content contract'
,
()
=>
{
const
base
:
LookupFactRow
=
{
it
(
'names own source ids without inventing absent ids or conflating relation references'
,
()
=>
{
fact_id
:
'11111111-1111-4111-8111-111111111111'
,
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
:
'医生'
});
host_name
:
'friday'
,
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'
});
subject_id
:
'source'
,
expect
(
run
(
'treatment_record'
,
{}).
content
).
not
.
toHaveProperty
(
'externalId'
);
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
([
test
.
each
([
[
'recommendation_record'
,
'doctor_suggested_at'
,
'
doctorS
uggestedAt'
],
[
'recommendation_record'
,
'doctor_suggested_at'
,
'
s
uggestedAt'
],
[
'image_analysis_record'
,
'analyzed_at'
,
'analyzedAt'
],
[
'image_analysis_record'
,
'analyzed_at'
,
'analyzedAt'
],
[
'visit_registration_record'
,
'registered_at'
,
'registeredAt'
],
[
'visit_registration_record'
,
'registered_at'
,
'registeredAt'
],
[
'visit_reception_record'
,
'received_at'
,
'receivedAt'
],
[
'visit_reception_record'
,
'received_at'
,
'receivedAt'
],
[
'image_record'
,
'captured_at'
,
'capturedAt'
],
[
'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
});
const
result
=
run
(
type
,
{
[
key
]:
'2026-10-19T11:00:00+08:00'
},
{
occurred_at
:
null
});
expect
(
result
.
occurredAt
).
toBe
(
at
);
expect
(
result
.
occurredAt
).
toBe
(
at
);
expect
(
result
.
content
).
not
.
toHaveProperty
(
renamed
);
expect
(
result
.
content
).
not
.
toHaveProperty
(
renamed
);
const
vague
=
run
(
type
,
{
[
key
]:
'2026-10-19 11:00:00'
},
{
occurred_at
:
null
});
expect
(
run
(
type
,
{
[
key
]:
'2026-10-19 11:00:00'
},
{
occurred_at
:
null
})).
toMatchObject
({
expect
(
vague
.
occurredAt
).
toBeNull
();
occurredAt
:
null
,
expect
(
vague
.
content
).
toHaveProperty
(
renamed
,
'2026-10-19 11:00:00'
);
content
:
{
[
renamed
]:
'2026-10-19 11:00:00'
},
});
});
});
it
(
'retains conflicting or invalid inner time, and meaningful process dates'
,
()
=>
{
it
(
'keeps meaningful process dates, distinct amounts and consultation intent'
,
()
=>
{
expect
(
run
(
'image_record'
,
{
captured_at
:
other
}).
content
).
toEqual
({
capturedAt
:
other
});
expect
(
expect
(
run
(
'image_record'
,
{
captured_at
:
'2026-02-30T00:00:00Z'
},
{
occurred_at
:
null
})).
toMatchObject
({
occurredAt
:
null
,
content
:
{
capturedAt
:
'2026-02-30T00:00:00Z'
}
});
run
(
'treatment_record'
,
{
expect
(
run
(
'treatment_record'
,
{
started_at
:
at
,
completed_at
:
other
}).
content
).
toEqual
({
startedAt
:
at
,
completedAt
:
other
});
started_at
:
at
,
expect
(
run
(
'diagnosis_record'
,
{
onset_date
:
'2026-10-01'
}).
content
).
toEqual
({
onsetDate
:
'2026-10-01'
});
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'
,
()
=>
{
it
(
'fails ambiguous non-identity aliases and unsupported content; never passes unknown types through'
,
()
=>
{
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
(
'emr_record'
,
{
illness_desc
:
'a'
,
illnessDescription
:
'b'
})).
toThrow
(
expect
(
run
(
'appointment_record'
,
{
scheduled_at
:
other
,
arrived_at
:
null
})).
toMatchObject
({
occurredAt
:
null
,
plannedFor
:
other
,
content
:
{}
});
LookupContentError
,
expect
(
run
(
'appointment_record'
,
{
scheduled_at
:
other
},
{
planned_for
:
new
Date
(
at
)
}).
content
).
toEqual
({
scheduledAt
:
other
});
);
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'
,
()
=>
{
it
(
'projects nested business extensions without extractor metadata or private fields'
,
()
=>
{
expect
(
run
(
'consultation_record'
,
{
consult_date
:
'2026-10-19'
,
intents
:
[
'洁牙'
,
'种植'
],
intent
:
'洁牙/种植'
,
content
:
'咨询正文'
}).
content
).
toEqual
({
intents
:
[
'洁牙'
,
'种植'
],
text
:
'咨询正文'
});
const
c
=
run
(
'emr_record'
,
{
expect
(
run
(
'consultation_record'
,
{
consult_date
:
'2026-10-18'
,
intents
:
[],
intent
:
'只剩历史意向'
}).
content
).
toEqual
({
consultDate
:
'2026-10-18'
,
intents
:
[],
intent
:
'只剩历史意向'
});
illness_desc
:
'病情13900139000'
,
expect
(
run
(
'consultation_record'
,
{
consult_date
:
'2026-10-19'
},
{
occurred_at
:
null
}).
content
).
toHaveProperty
(
'consultDate'
);
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'
,
()
=>
{
it
(
'projects image findings and complaint child events; removes arbitrary nested fields'
,
()
=>
{
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
}]
});
const
image
=
run
(
'image_record'
,
{
expect
(
run
(
'recharge_record'
,
{
amount_cents
:
100
,
amount
:
200
,
card_type
:
'储值'
,
recharge_type
:
'充值'
}).
content
).
toEqual
({
amountCents
:
100
,
amount
:
200
,
cardType
:
'储值'
,
rechargeType
:
'充值'
});
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'
,
()
=>
{
it
(
'does not mutate stored data or old API output'
,
()
=>
{
expect
(
run
(
'emr_record'
,
{
doctor_id
:
'a'
,
doctorId
:
'b'
,
extension
:
{
some_key
:
'untouched'
}
}).
content
).
toEqual
({
doctor_id
:
'a'
,
doctorId
:
'b'
,
extension
:
{
some_key
:
'untouched'
}
});
const
row
=
{
expect
(
run
(
'future_type'
,
{
doctor_id
:
'a'
}).
content
).
toEqual
({
doctor_id
:
'a'
});
...
base
,
});
content
:
{
it
(
'distinguishes extraction method from extractor version and preserves child-event times'
,
()
=>
{
emr_external_id
:
'e'
,
expect
(
run
(
'recommendation_record'
,
{
extracted_by
:
'rule'
}).
content
).
toEqual
({
extractionMethod
:
'rule'
});
illness_desc
:
'13900139000'
,
expect
(
run
(
'emr_record'
,
{
clinical_signals
:
[{
extractedBy
:
'v1'
,
validUntil
:
other
}],
quality_check
:
{
reject_reason
:
'原因'
}
}).
content
).
toEqual
({
clinicalSignals
:
[{
extractorVersion
:
'v1'
,
validUntil
:
other
}],
qualityCheck
:
{
rejectReason
:
'原因'
}
});
staff_notes
:
'内部'
,
expect
(
run
(
'complaint_record'
,
{
content
:
'投诉'
,
resolved_at
:
other
,
follow_ups
:
[{
occurredAt
:
at
,
content
:
'跟进'
}]
}).
content
).
toEqual
({
text
:
'投诉'
,
resolvedAt
:
other
,
followUps
:
[{
occurredAt
:
at
,
text
:
'跟进'
}]
});
extensions
:
{
key
:
'hidden'
},
});
_meyer
:
{
secret
:
'hidden'
},
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'
}]
});
const
snapshot
=
JSON
.
stringify
(
row
),
});
old
=
toOpenFact
(
row
),
it
(
'does not mutate source and keeps old API output and redaction unchanged'
,
()
=>
{
output
=
toLookupFact
(
row
,
null
);
const
row
=
{
...
base
,
content
:
{
emr_external_id
:
'e'
,
illness_desc
:
'13900139000'
,
staff_notes
:
'内部'
,
extension
:
{
phone
:
'13900139000'
}
}
};
expect
(
output
.
content
).
toMatchObject
({
externalId
:
'e'
,
illnessDescription
:
'139****9000'
});
const
snapshot
=
JSON
.
stringify
(
row
);
const
before
=
toOpenFact
(
row
);
expect
(
JSON
.
stringify
(
output
)).
not
.
toMatch
(
/extensions|_meyer|staff_notes/
);
expect
(
toLookupFact
(
row
,
null
).
content
).
toEqual
({
externalId
:
'e'
,
illnessDesc
:
'139****9000'
,
extension
:
{}
});
expect
(
JSON
.
stringify
(
row
)).
toBe
(
snapshot
);
expect
(
JSON
.
stringify
(
row
)).
toBe
(
snapshot
);
expect
(
toOpenFact
(
row
)).
toEqual
(
before
);
expect
(
toOpenFact
(
row
)).
toEqual
(
old
);
expect
(
before
.
content
).
toHaveProperty
(
'emr_external_id'
,
'e'
);
expect
(
LookupFactSchema
.
safeParse
({
...
output
,
content
:
{
...
output
.
content
,
unknown
:
1
}
}).
success
,
).
toBe
(
false
);
});
});
});
});
test
(
'image file references keep the existing access contract with a nested whitelist'
,
()
=>
{
const
ref
=
{
provider
:
'meyer'
,
connectionId
:
'test'
,
fileId
:
'oss-1'
,
role
:
'original'
,
secret
:
'excluded'
,
};
const
result
=
lookupContent
(
'image_record'
,
{
image_external_id
:
'i-1'
,
file_refs
:
[
ref
]
},
null
,
null
,
'friday'
,
);
expect
(
result
.
fileRefs
).
toEqual
([
{
provider
:
'meyer'
,
connectionId
:
'test'
,
fileId
:
'oss-1'
,
role
:
'original'
},
]);
});
apps/pac-service/tests/lookup-contract-postgres.spec.ts
0 → 100644
View file @
5b80c042
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 @
5b80c042
...
@@ -4,46 +4,138 @@ import { PatientFactsLookupResponseSchema } from '../src/modules/open/patient-fa
...
@@ -4,46 +4,138 @@ import { PatientFactsLookupResponseSchema } from '../src/modules/open/patient-fa
const
early
=
'2026-10-01T02:00:00.000Z'
;
const
early
=
'2026-10-01T02:00:00.000Z'
;
const
late
=
'2026-10-20T02:00:00.000Z'
;
const
late
=
'2026-10-20T02:00:00.000Z'
;
const
base
:
LookupFactRow
=
{
const
base
:
LookupFactRow
=
{
fact_id
:
'11111111-1111-4111-8111-111111111111'
,
host_name
:
'friday'
,
fact_id
:
'11111111-1111-4111-8111-111111111111'
,
subject_id
:
'treatment_record:source-1'
,
type
:
'treatment_record'
,
kind
:
'planned'
,
host_name
:
'friday'
,
status
:
'active'
,
clinic_id
:
'c'
,
occurred_at
:
null
,
planned_for
:
new
Date
(
early
),
subject_id
:
'treatment_record:source-1'
,
valid_until
:
null
,
title
:
'治疗'
,
summary
:
'摘要'
,
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'
},
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'
,
()
=>
{
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
});
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
(
result
).
toEqual
({
expect
(
PatientFactsLookupResponseSchema
.
shape
.
items
.
element
.
shape
.
facts
.
element
.
safeParse
(
result
).
success
).
toBe
(
true
);
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'
,
()
=>
{
it
(
'prefers stored occurrence over old planned fallback'
,
()
=>
{
expect
(
convert
({
occurred_at
:
new
Date
(
late
)
}).
occurredAt
).
toBe
(
late
);
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
}).
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
});
expect
(
});
convert
({
test
.
each
([
'treatment_record'
,
'recommendation_record'
])(
'does not reinterpret unknown host %s plan time'
,
type
=>
{
type
:
'recommendation_record'
,
expect
(
convert
({
host_name
:
'new-host'
,
type
})).
toMatchObject
({
occurredAt
:
null
,
plannedFor
:
null
});
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'
,
()
=>
{
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
);
expect
(
});
convert
({
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
=>
{
host_name
:
'new-host'
,
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
});
type
:
'recommendation_record'
,
});
content
:
{
doctor_suggested_at
:
early
},
test
.
each
([
'friday'
,
'jvs-dw'
])(
'%s uses source creation for both scheduled and fulfilled appointments'
,
host_name
=>
{
}).
occurredAt
,
for
(
const
kind
of
[
'planned'
,
'actual'
])
{
).
toBe
(
early
);
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
:
{}
});
}
});
});
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'
,
()
=>
{
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
(
expect
(
convert
({
type
:
'appointment_record'
,
host_name
:
'unknown'
,
appointment_created_values
:
[
early
]
}).
occurredAt
).
toBeNull
();
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'
,
()
=>
{
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
);
const
snapshot
=
JSON
.
stringify
(
row
);
expect
(
toLookupFact
(
row
,
null
)).
toMatchObject
({
occurredAt
:
early
,
content
:
{}
});
expect
(
toLookupFact
(
row
,
null
)).
toMatchObject
({
occurredAt
:
early
,
content
:
{}
});
expect
(
toLookupFact
({
...
row
,
type
:
'encounter_record'
},
null
).
occurredAt
).
toBe
(
late
);
expect
(
toLookupFact
({
...
row
,
type
:
'encounter_record'
},
null
).
occurredAt
).
toBe
(
late
);
...
@@ -51,9 +143,24 @@ describe('new lookup fact projection only', () => {
...
@@ -51,9 +143,24 @@ describe('new lookup fact projection only', () => {
expect
(
JSON
.
stringify
(
row
)).
toBe
(
snapshot
);
expect
(
JSON
.
stringify
(
row
)).
toBe
(
snapshot
);
});
});
it
(
'retains other stored business dates and redaction without mutating the row'
,
()
=>
{
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
);
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
(
toLookupFact
(
row
,
null
).
content
).
not
.
toHaveProperty
(
'staff_notes'
);
expect
(
JSON
.
stringify
(
row
)).
toBe
(
snapshot
);
expect
(
JSON
.
stringify
(
row
)).
toBe
(
snapshot
);
});
});
...
@@ -61,7 +168,15 @@ describe('new lookup fact projection only', () => {
...
@@ -61,7 +168,15 @@ describe('new lookup fact projection only', () => {
const
before
=
toOpenFact
(
base
);
const
before
=
toOpenFact
(
base
);
convert
();
convert
();
expect
(
toOpenFact
(
base
)).
toEqual
(
before
);
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'
);
expect
(
before
).
not
.
toHaveProperty
(
'id'
);
});
});
});
});
apps/pac-service/tests/lookup-narrative.spec.ts
0 → 100644
View file @
5b80c042
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 @
5b80c042
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 @
5b80c042
...
@@ -425,18 +425,21 @@ describePg('Meyer | isolated PostgreSQL + actual minimal CLI module', () => {
...
@@ -425,18 +425,21 @@ describePg('Meyer | isolated PostgreSQL + actual minimal CLI module', () => {
expect
(
patient
.
facts
).
toHaveLength
(
3
);
expect
(
patient
.
facts
).
toHaveLength
(
3
);
expect
(
patient
.
facts
.
some
((
f
:
any
)
=>
f
.
type
===
'image_analysis_record'
)).
toBe
(
false
);
expect
(
patient
.
facts
.
some
((
f
:
any
)
=>
f
.
type
===
'image_analysis_record'
)).
toBe
(
false
);
const
image
=
patient
.
facts
.
find
((
f
:
any
)
=>
f
.
type
===
'image_record'
);
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
,
occurredAt
:
'2026-10-10T02:00:00.000Z'
,
plannedFor
:
null
,
content
:
{
externalId
:
'meyer:test:image-1'
,
modality
:
'pano'
}
});
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
);
expect
(
image
.
content
.
analysis
).
toEqual
(
meyerBundle
().
image
.
analysis
);
const
storedImage
=
await
prisma
.
patientFact
.
findUniqueOrThrow
({
where
:
{
id
:
image
.
id
}
});
const
storedImage
=
await
prisma
.
patientFact
.
findUniqueOrThrow
({
where
:
{
id
:
image
.
id
}
});
expect
((
storedImage
.
content
as
any
).
analysis
).
toEqual
(
image
.
content
.
analysis
);
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'
);
const
diagnoses
=
patient
.
facts
.
filter
((
f
:
any
)
=>
f
.
type
===
'diagnosis_record'
);
expect
(
diagnoses
).
toHaveLength
(
2
);
expect
(
diagnoses
).
toHaveLength
(
2
);
for
(
const
diagnosis
of
diagnoses
)
{
for
(
const
diagnosis
of
diagnoses
)
{
expect
(
diagnosis
).
toMatchObject
({
occurredAt
:
'2026-10-09T01:00:00.000Z'
,
plannedFor
:
null
,
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
(
'sourceAnalysisExternalId'
);
expect
(
diagnosis
.
content
).
not
.
toHaveProperty
(
'code_source'
);
expect
(
diagnosis
.
content
).
not
.
toHaveProperty
(
'code_source'
);
}
}
...
...
apps/pac-service/tests/open-fact-content-doc.spec.ts
View file @
5b80c042
...
@@ -6,22 +6,40 @@ import { readFileSync } from 'node:fs';
...
@@ -6,22 +6,40 @@ import { readFileSync } from 'node:fs';
import
{
resolve
}
from
'node:path'
;
import
{
resolve
}
from
'node:path'
;
import
{
FactType
}
from
'@pac/types'
;
import
{
FactType
}
from
'@pac/types'
;
import
{
FactContentSchemas
}
from
'../src/modules/sync/pipeline/fact-content-schemas'
;
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
{
toLookupFact
}
from
'../src/modules/open/lookup-fact'
;
import
{
redactJson
}
from
'../src/modules/open/open-redact'
;
import
{
redactJson
}
from
'../src/modules/open/open-redact'
;
import
{
PatientFactsLookupResponseSchema
}
from
'../src/modules/open/patient-facts-lookup.schema'
;
import
{
PatientFactsLookupResponseSchema
}
from
'../src/modules/open/patient-facts-lookup.schema'
;
const
text
=
readFileSync
(
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'
,
'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
=
[
const
documentedTypes
=
[
'consultation_record'
,
'encounter_record'
,
'diagnosis_record'
,
'treatment_record'
,
'consultation_record'
,
'recommendation_record'
,
'emr_record'
,
'image_record'
,
'appointment_record'
,
'encounter_record'
,
'payment_record'
,
'refund_record'
,
'complaint_record'
,
'diagnosis_record'
,
'treatment_record'
,
'recommendation_record'
,
'emr_record'
,
'image_record'
,
'appointment_record'
,
'payment_record'
,
'refund_record'
,
'complaint_record'
,
];
];
describe
(
'public fact content reference'
,
()
=>
{
describe
(
'public fact content reference'
,
()
=>
{
...
@@ -33,102 +51,178 @@ describe('public fact content reference', () => {
...
@@ -33,102 +51,178 @@ describe('public fact content reference', () => {
'$type example passes canonical validation and open serialization'
,
'$type example passes canonical validation and open serialization'
,
({
type
,
content
,
occurredAt
,
plannedFor
})
=>
{
({
type
,
content
,
occurredAt
,
plannedFor
})
=>
{
const
schema
=
FactContentSchemas
[
type
as
keyof
typeof
FactContentSchemas
];
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
stored
=
schema
.
parse
(
sources
.
find
((
r
:
any
)
=>
r
.
type
===
type
).
content
);
const
out
=
toLookupFact
({
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
,
fact_id
:
'33333333-3333-4333-8333-333333333333'
,
subject_id
:
`
${
type
}
:fixture`
,
host_name
:
'friday'
,
type
,
appointment_created_values
:
kind
:
'actual'
,
type
===
'appointment_record'
?
[
'2026-10-19 09:00:00'
]
:
null
,
status
:
'active'
,
source_evidence
:
[
clinic_id
:
null
,
{
occurred_at
:
null
,
sourceEventId
:
`cold-import:
${
type
===
'emr_record'
?
'emr'
:
type
===
'diagnosis_record'
?
'diagnosis'
:
'refund'
}
:brand:own:v1`
,
planned_for
:
null
,
subjectType
:
type
.
replace
(
'_record'
,
''
),
valid_until
:
null
,
subjectId
:
'own'
,
title
:
null
,
raw
:
{
emr_sub_id
:
'emr-1'
,
appointment_id
:
'enc-1'
,
ref_settlement_id
:
'payment-1'
},
summary
:
null
,
},
content
:
stored
,
],
},
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
factSchema
=
PatientFactsLookupResponseSchema
.
shape
.
items
.
element
.
shape
.
facts
.
element
;
const
factSchema
=
PatientFactsLookupResponseSchema
.
shape
.
items
.
element
.
shape
.
facts
.
element
;
expect
(
factSchema
.
safeParse
({
...
out
,
clinicName
:
null
}).
success
).
toBe
(
true
);
expect
(
factSchema
.
safeParse
({
...
out
,
clinicName
:
null
}).
success
).
toBe
(
true
);
// 文档不展示会在出站被移除的键,也不依赖未说明的存储默认值。
// 文档不展示会在出站被移除的键,也不依赖未说明的存储默认值。
expect
(
redactJson
(
content
)).
toEqual
(
content
);
expect
(
redactJson
(
content
)).
toEqual
(
content
);
expect
(
out
.
content
).
toMatchObject
(
content
);
expect
(
out
.
content
).
toMatchObject
(
content
);
expect
(
out
).
toMatchObject
({
occurredAt
,
plannedFor
});
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'
,
()
=>
{
it
(
'documents every
whitelisted top-level content fiel
d'
,
()
=>
{
for
(
const
[
type
,
schema
]
of
Object
.
entries
(
Fact
ContentSchemas
))
{
for
(
const
[
type
,
schema
]
of
Object
.
entries
(
Lookup
ContentSchemas
))
{
if
(
!
documentedTypes
.
includes
(
type
))
continue
;
if
(
!
documentedTypes
.
includes
(
type
))
continue
;
const
section
=
text
.
split
(
`<details id="
${
type
}
">`
)[
1
]
!
.
split
(
'</details>'
)[
0
]
!
;
const
section
=
text
.
split
(
`<details id="
${
type
}
">`
)[
1
]
!
.
split
(
'</details>'
)[
0
]
!
;
const
fields
=
[...
section
.
matchAll
(
/
\|
`
([^
`
]
+
)
`
\|
/g
)].
flatMap
((
m
)
=>
const
fields
=
[...
section
.
matchAll
(
/
\|
`
([^
`
]
+
)
`
\|
/g
)].
flatMap
((
m
)
=>
m
[
1
]
!
.
split
(
' / '
).
map
((
f
)
=>
f
.
split
(
/
[
.[
]
/
)[
0
]
!
),
m
[
1
]
!
.
split
(
' / '
).
map
((
f
)
=>
f
.
split
(
/
[
.[
]
/
)[
0
]
!
),
);
);
for
(
const
field
of
Object
.
keys
(
schema
.
shape
))
{
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
(
field
)
}).
toEqual
({
expect
({
type
,
field
,
documented
:
fields
.
includes
(
lookupContentNames
[
type
]?.[
field
]
??
field
)
}).
toEqual
({
type
,
type
,
field
,
field
,
documented
:
true
,
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'
,
()
=>
{
it
(
'documents every embedded analysis field and validates the nested example'
,
()
=>
{
const
section
=
text
.
split
(
'<details id="image_record">'
)[
1
]
!
.
split
(
'</details>'
)[
0
]
!
;
const
section
=
text
.
split
(
'<details id="image_record">'
)[
1
]
!
.
split
(
'</details>'
)[
0
]
!
;
const
fields
=
[...
section
.
matchAll
(
/
\|
`
([^
`
]
+
)
`
\|
/g
)].
map
(
m
=>
m
[
1
]);
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
))
expect
(
fields
).
toContain
(
field
);
for
(
const
field
of
Object
.
keys
(
ImageEmbeddedAnalysisSchema
.
shape
.
findings
.
element
.
shape
))
{
for
(
const
field
of
Object
.
keys
(
ImageEmbeddedAnalysisSchema
.
shape
.
findings
.
element
.
shape
))
{
expect
(
fields
).
toContain
(
`findings[].
${
field
}
`
);
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
;
const
analysis
=
example
.
facts
.
find
((
f
:
any
)
=>
f
.
type
===
'image_record'
).
content
.
analysis
;
expect
(
ImageEmbeddedAnalysisSchema
.
parse
(
analysis
)).
toEqual
(
analysis
);
expect
(
ImageEmbeddedAnalysisSchema
.
parse
(
analysis
)).
toEqual
(
analysis
);
});
});
it
(
'Meyer image and diagnosis example matches the actual adapter and public serializer'
,
()
=>
{
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
);
expect
(
example
).
toHaveLength
(
2
);
const
bundle
=
meyerBundle
();
const
bundle
=
meyerBundle
();
const
diagnosis
=
bundle
.
diagnoses
.
find
(
d
=>
d
.
name
===
'龋齿'
)
!
;
const
diagnosis
=
bundle
.
diagnoses
.
find
(
(
d
)
=>
d
.
name
===
'龋齿'
)
!
;
for
(
const
item
of
example
)
{
for
(
const
item
of
example
)
{
const
image
=
item
.
type
===
'image_record'
;
const
image
=
item
.
type
===
'image_record'
;
const
canonicalRow
=
image
?
bundle
.
image
:
diagnosis
;
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
,
action
:
image
?
Action
.
IMAGE_UPLOADED
:
Action
.
DIAGNOSIS_RECORDED
,
subjectType
:
image
?
'image_study'
:
'diagnosis'
,
subjectId
:
canonicalRow
.
externalId
,
subjectType
:
image
?
'image_study'
:
'diagnosis'
,
clinicId
:
'clinic-a'
,
occurredAt
:
new
Date
(
image
?
bundle
.
image
.
uploadedAt
:
diagnosis
.
occurredAt
)
};
subjectId
:
canonicalRow
.
externalId
,
clinicId
:
'clinic-a'
,
occurredAt
:
new
Date
(
image
?
bundle
.
image
.
uploadedAt
:
diagnosis
.
occurredAt
),
};
const
parser
=
image
?
new
ImageParser
()
:
new
DiagnosisParser
();
const
parser
=
image
?
new
ImageParser
()
:
new
DiagnosisParser
();
const
draft
=
parser
.
parse
({
transaction
,
canonicalRow
})[
0
]
!
;
const
draft
=
parser
.
parse
({
transaction
,
canonicalRow
})[
0
]
!
;
const
out
=
toLookupFact
({
fact_id
:
item
.
id
,
host_name
:
'friday'
,
subject_id
:
draft
.
subjectId
,
const
out
=
toLookupFact
(
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
,
fact_id
:
item
.
id
,
title
:
draft
.
title
??
null
,
summary
:
draft
.
summary
??
null
,
content
:
draft
.
content
},
'示例诊所'
);
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
(
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'
,
()
=>
{
it
(
'keeps lifecycle state, business state and treatment kind distinct'
,
()
=>
{
const
planned
=
toLookupFact
({
const
planned
=
toLookupFact
(
fact_id
:
'33333333-3333-4333-8333-333333333333'
,
host_name
:
'friday'
,
{
subject_id
:
'treatment:1'
,
fact_id
:
'33333333-3333-4333-8333-333333333333'
,
type
:
'treatment_record'
,
host_name
:
'friday'
,
kind
:
'planned'
,
subject_id
:
'treatment:1'
,
status
:
'active'
,
type
:
'treatment_record'
,
clinic_id
:
null
,
kind
:
'planned'
,
occurred_at
:
null
,
status
:
'active'
,
planned_for
:
new
Date
(
'2026-11-01T00:00:00Z'
),
clinic_id
:
null
,
valid_until
:
null
,
occurred_at
:
null
,
title
:
null
,
planned_for
:
new
Date
(
'2026-11-01T00:00:00Z'
),
summary
:
null
,
valid_until
:
null
,
content
:
{
category
:
'endodontic'
,
status
:
'planned'
,
treat_stages
:
[
'canal_filling'
]
},
title
:
null
,
},
null
);
summary
:
null
,
content
:
{
category
:
'endodontic'
,
status
:
'planned'
,
treat_stages
:
[
'canal_filling'
]
},
},
null
,
);
expect
(
planned
).
toMatchObject
({
expect
(
planned
).
toMatchObject
({
kind
:
'planned'
,
kind
:
'planned'
,
occurredAt
:
'2026-11-01T00:00:00.000Z'
,
occurredAt
:
'2026-11-01T00:00:00.000Z'
,
plannedFor
:
null
,
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 @
5b80c042
...
@@ -370,7 +370,7 @@ suite('local HTTP + PostgreSQL patient facts acceptance', () => {
...
@@ -370,7 +370,7 @@ suite('local HTTP + PostgreSQL patient facts acceptance', () => {
),
),
).
toBe
(
false
);
).
toBe
(
false
);
expect
(
new
Set
(
facts
.
map
((
f
:
any
)
=>
f
.
id
)).
size
).
toBe
(
facts
.
length
);
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
;
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
[
0
].
occurredAt
).
toBe
(
'2026-10-02T00:00:00.000Z'
);
expect
(
long
.
at
(
-
1
).
content
.
externalId
).
toBe
(
'no-time'
);
expect
(
long
.
at
(
-
1
).
content
.
externalId
).
toBe
(
'no-time'
);
...
@@ -389,7 +389,7 @@ suite('local HTTP + PostgreSQL patient facts acceptance', () => {
...
@@ -389,7 +389,7 @@ suite('local HTTP + PostgreSQL patient facts acceptance', () => {
expect
(
a
.
patient
).
toMatchObject
({
expect
(
a
.
patient
).
toMatchObject
({
gender
:
'女'
,
gender
:
'女'
,
birthDate
:
'2010-01-02'
,
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
({
expect
(
a
.
patient
).
toMatchObject
({
acquisitionChannel
:
'word_of_mouth'
,
acquisitionChannel
:
'word_of_mouth'
,
...
...
apps/pac-service/tests/patient-facts-lookup.spec.ts
View file @
5b80c042
...
@@ -30,7 +30,7 @@ describe('patient facts lookup contract', () => {
...
@@ -30,7 +30,7 @@ describe('patient facts lookup contract', () => {
patient_name
:
'同名'
,
patient_name
:
'同名'
,
medical_record_number
:
'001'
,
medical_record_number
:
'001'
,
phone
:
'13800138000'
,
phone
:
'13800138000'
,
fact_id
:
`
${
i
}
`
,
fact_id
:
`
11111111-1111-4111-8111-
${
String
(
i
).
padStart
(
12
,
'0'
)
}
`
,
subject_id
:
`s
${
i
}
`
,
subject_id
:
`s
${
i
}
`
,
type
:
'emr_record'
,
type
:
'emr_record'
,
kind
:
'actual'
,
kind
:
'actual'
,
...
@@ -90,7 +90,7 @@ describe('patient facts lookup contract', () => {
...
@@ -90,7 +90,7 @@ describe('patient facts lookup contract', () => {
expect
(
result
.
items
[
0
]
!
.
facts
).
toHaveLength
(
251
);
expect
(
result
.
items
[
0
]
!
.
facts
).
toHaveLength
(
251
);
expect
(
result
.
items
[
1
]
!
.
facts
).
toEqual
([]);
expect
(
result
.
items
[
1
]
!
.
facts
).
toEqual
([]);
expect
(
result
.
items
[
0
]
!
.
patient
.
phone
).
toBe
(
'138****8000'
);
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
item
of
result
.
items
)
{
for
(
const
field
of
[
for
(
const
field
of
[
'doNotContact'
,
'doNotContact'
,
...
@@ -109,13 +109,18 @@ describe('patient facts lookup contract', () => {
...
@@ -109,13 +109,18 @@ describe('patient facts lookup contract', () => {
expect
(
result
.
items
[
0
]
!
.
facts
[
0
]).
toMatchObject
({
expect
(
result
.
items
[
0
]
!
.
facts
[
0
]).
toMatchObject
({
clinicName
:
'测试诊所'
,
clinicName
:
'测试诊所'
,
id
:
expect
.
any
(
String
),
id
:
expect
.
any
(
String
),
content
:
{
illnessDesc
:
'电话139****9000'
},
content
:
{
illnessDesc
ription
:
'电话139****9000'
},
});
});
expect
(
JSON
.
stringify
(
result
)).
not
.
toContain
(
'staff_notes'
);
expect
(
JSON
.
stringify
(
result
)).
not
.
toContain
(
'staff_notes'
);
const
sql
=
prisma
.
$queryRaw
.
mock
.
calls
[
0
][
0
];
const
sql
=
prisma
.
$queryRaw
.
mock
.
calls
[
0
][
0
];
expect
(
sql
.
sql
).
not
.
toMatch
(
/LIMIT|ILIKE/
);
expect
(
sql
.
sql
).
not
.
toMatch
(
/LIMIT|ILIKE/
);
expect
(
sql
.
sql
).
toContain
(
'AND p.name ='
);
expect
(
sql
.
sql
).
toContain
(
'AND p.name ='
);
expect
(
sql
.
sql
).
toContain
(
'AND p.phone ='
);
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'
);
expect
(
sql
.
values
).
toContain
(
'h'
);
});
});
test
.
each
([
test
.
each
([
...
@@ -126,10 +131,10 @@ describe('patient facts lookup contract', () => {
...
@@ -126,10 +131,10 @@ describe('patient facts lookup contract', () => {
[{
dedicatedCs
:
'invalid'
},
null
],
[{
dedicatedCs
:
'invalid'
},
null
],
[{
dedicatedCs
:
[]
},
null
],
[{
dedicatedCs
:
[]
},
null
],
[{
dedicatedCs
:
{
id
:
{},
name
:
[]
}
},
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'
}
},
{
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
)
=>
{
])(
'projects only dedicated customer service from %j'
,
async
(
preferences
,
expected
)
=>
{
const
prisma
:
any
=
{
const
prisma
:
any
=
{
...
...
apps/pac-service/tests/source-created-time-postgres.spec.ts
0 → 100644
View file @
5b80c042
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 @
5b80c042
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 @
5b80c042
/**
/**
* TransactionSynthesizer occurredAt 三级确定性回退单元测试。
* TransactionSynthesizer occurredAt 三级确定性回退单元测试。
*
*
* 坐实:emits.occurredAtField 缺失
/不可解析时,回退 updatedAt → createdAt,
* 坐实:emits.occurredAtField 缺失
时,回退 updatedAt → createdAt;非法非空时间拒收。
* 仅三者皆缺才落墙钟。否则同一源行(新 sourceEventId、时间字段为空)每次摄入
* 仅三者皆缺才落墙钟。否则同一源行(新 sourceEventId、时间字段为空)每次摄入
* 得到不同 occurredAt,下游 fact 时间锚变更检测会产生伪 supersede 版本。
* 得到不同 occurredAt,下游 fact 时间锚变更检测会产生伪 supersede 版本。
*/
*/
...
@@ -48,14 +48,19 @@ describe('synthesizer | occurredAt 确定性回退', () => {
...
@@ -48,14 +48,19 @@ describe('synthesizer | occurredAt 确定性回退', () => {
expect
(
tx
!
.
occurredAt
).
toEqual
(
new
Date
(
'2026-02-01T00:00:00+08:00'
));
expect
(
tx
!
.
occurredAt
).
toEqual
(
new
Date
(
'2026-02-01T00:00:00+08:00'
));
});
});
test
(
'occurredAtField
不可解析 → 回退 updatedAt
'
,
()
=>
{
test
(
'occurredAtField
非空但非法 → 拒收,不以其他时间掩盖错误
'
,
()
=>
{
const
tx
=
synth
.
synthesize
(
const
tx
=
synth
.
synthesize
(
make
({
make
({
occurredAt
:
'not-a-date'
,
occurredAt
:
'not-a-date'
,
updatedAt
:
'2026-02-01T00:00:00+08:00'
,
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'
,
()
=>
{
test
(
'occurredAtField/updatedAt 皆缺 → 回退 createdAt'
,
()
=>
{
...
...
packages/types/src/schemas/canonical.ts
View file @
5b80c042
...
@@ -25,7 +25,9 @@ import { ImageEmbeddedAnalysisSchema } from './image-embedded-analysis';
...
@@ -25,7 +25,9 @@ import { ImageEmbeddedAnalysisSchema } from './image-embedded-analysis';
// Datetime 工具 schema — ISO 8601 字符串(经 normalize 后必带 offset)
// 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 入口空字段是 `""`,统一接受空串等同于"未提供"
/// CSV 入口空字段是 `""`,统一接受空串等同于"未提供"
const
optionalIsoDateTime
=
z
.
union
([
isoDateTime
,
z
.
literal
(
''
),
z
.
null
()]).
optional
()
const
optionalIsoDateTime
=
z
.
union
([
isoDateTime
,
z
.
literal
(
''
),
z
.
null
()]).
optional
()
.
transform
((
v
)
=>
(
v
===
''
||
v
===
null
?
null
:
v
));
.
transform
((
v
)
=>
(
v
===
''
||
v
===
null
?
null
:
v
));
...
@@ -623,6 +625,7 @@ export const CanonicalResourceMeta: Record<
...
@@ -623,6 +625,7 @@ export const CanonicalResourceMeta: Record<
moneyArrayFields
:
readonly
{
array
:
string
;
field
:
string
}[];
// 数组里嵌套 money 字段
moneyArrayFields
:
readonly
{
array
:
string
;
field
:
string
}[];
// 数组里嵌套 money 字段
datetimeFields
:
readonly
string
[];
datetimeFields
:
readonly
string
[];
datetimeArrayFields
?:
readonly
{
array
:
string
;
field
:
string
}[];
datetimeArrayFields
?:
readonly
{
array
:
string
;
field
:
string
}[];
datetimeObjectFields
?:
readonly
{
object
:
string
;
field
:
string
}[];
booleanFields
:
readonly
string
[];
booleanFields
:
readonly
string
[];
}
}
>
=
{
>
=
{
...
@@ -726,6 +729,7 @@ export const CanonicalResourceMeta: Record<
...
@@ -726,6 +729,7 @@ export const CanonicalResourceMeta: Record<
moneyFields
:
[],
moneyFields
:
[],
moneyArrayFields
:
[],
moneyArrayFields
:
[],
datetimeFields
:
[
'uploadedAt'
],
datetimeFields
:
[
'uploadedAt'
],
datetimeObjectFields
:
[{
object
:
'analysis'
,
field
:
'analyzedAt'
}],
booleanFields
:
[],
booleanFields
:
[],
},
},
image_analysis
:
{
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