Commit 5b80c042 by luoqi

Merge open patient contract and source time fixes into test

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