Commit 355a7550 by luoqi

feat(open): refine patient facts and integrate Meyer image analysis

parent 7bdc1a5c
Pipeline #3832 failed in 0 seconds
---
title: 患者信息与事实查询 API
description: 获取应用令牌,查询患者资料、画像、关系与事实。请求、响应、字段、枚举及示例均在本页。
---
## 1. 接入准备
服务端使用已有 `appId/appSecret`,由 PAC 开通查询权限。`PAC_BASE_URL` 使用接入时提供的测试或生产地址。
**权限覆盖应用所属宿主的全部有效患者,含不同租户、品牌。** 调用方负责用户权限校验;密钥及令牌仅保存在服务端。
## 2. 获取应用令牌
`POST /pac/v1/auth/token` · `Content-Type: application/json`
| 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `appId` | string | 是 | 应用标识 |
| `appSecret` | string | 是 | 应用密钥 |
```json
{ "appId": "your-app-id", "appSecret": "your-app-secret" }
```
```json
{
"code": 0,
"msg": "ok",
"data": {
"accessToken": "your-access-token",
"tokenType": "Bearer",
"expiresIn": 3600
}
}
```
`accessToken` 为字符串,`tokenType` 固定为 `Bearer`,`expiresIn` 为有效秒数。令牌有效期 1 小时,到期重新获取;权限撤销或密钥轮换后需重新授权。
本调用不传 `user` 或 `grantType`。应用令牌只用于应用查询,不能与工作台用户登录令牌互换。
## 3. 查询患者
`POST /pac/v1/open/patient-facts/lookup`
| 请求头 | 值 |
| --- | --- |
| `Authorization` | `Bearer <accessToken>` |
| `Content-Type` | `application/json` |
| 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `medicalRecordNumber` | string | 三选一 | 病历号,1–128 字符,保留前导零 |
| `name` | string | 三选一 | 姓名,1–128 字符 |
| `phone` | string | 三选一 | 患者主档手机号,1–64 字符 |
**至少提供一项,多项取交集,精确匹配。** 去除首尾空白;不做模糊查询、号码格式转换或家属扩展。不分页,不接受其他参数。
```bash
curl "$PAC_BASE_URL/pac/v1/open/patient-facts/lookup" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"medicalRecordNumber":"000123"}'
```
## 4. 返回结果
以响应体 `code` 判断成功:`0` 成功,其他值按本页“错误处理”处理,不能只看 HTTP 状态。`msg` 为提示字符串,成功数据在 `data` 中。
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `data.items` | object[] | 匹配患者列表;无匹配为 `[]`,病历号或姓名重号时返回多项 |
| `items[].patient` | object | 患者基本信息及业务属性 |
| `items[].persona` | string[] | 可直接展示的画像标签;无标签为 `[]` |
| `items[].relations` | object[] | 患者关系;无记录为 `[]` |
| `items[].facts` | object[] | 全部当前有效事实;无记录为 `[]`,不截断 |
返回字段可为 `null` 时在下表标明;`content` 字段还可能缺失。空值或空数组不代表无疾病、无风险或已完成。调用方应兼容新增字段、事实类型和开放字典值。手机号及自由文本已脱敏。
<details>
<summary>完整响应示例</summary>
```json
{
"code": 0,
"msg": "ok",
"data": {
"items": [
{
"patient": {
"id": "11111111-1111-4111-8111-111111111111",
"externalId": "source-patient-123",
"name": "张三",
"medicalRecordNumber": "000123",
"phone": "138****8000",
"gender": "男",
"birthDate": "1990-01-02",
"dedicatedCs": {
"id": "source-staff-123",
"name": "李客服"
},
"acquisitionChannel": null,
"acquisitionSub": null
},
"persona": [
"充填治疗",
"重要价值"
],
"relations": [],
"facts": [
{
"type": "diagnosis_record",
"kind": "actual",
"clinicId": "来源诊所标识",
"clinicName": "示例诊所",
"occurredAt": "2026-10-19T02:00:00.000Z",
"plannedFor": null,
"content": {
"code": "K02"
},
"id": "33333333-3333-4333-8333-333333333333"
}
]
}
]
}
}
```
</details>
### 患者 patient
| 字段 | 类型 | 可空 | 说明 |
| --- | --- | --- | --- |
| `id` | string | 否 | PAC 患者 UUID,用于区分患者、缓存结果 |
| `externalId` | string | 否 | 宿主患者 ID;不是病历号,不保证跨品牌唯一,回链宿主需结合自身上下文 |
| `name` | string | 是 | 姓名 |
| `medicalRecordNumber` | string | 是 | 病历号 |
| `phone` | string | 是 | 脱敏手机号 |
| `gender` | string | 是 | 开放文本,常见 male=男、female=女,也可能为宿主原文“男/女” |
| `birthDate` | string | 是 | 出生日期,YYYY-MM-DD |
| `dedicatedCs` | object | 是 | 专属客服;无信息时为 null |
| `dedicatedCs.id` | string | 是 | 宿主侧客服标识 |
| `dedicatedCs.name` | string | 是 | 客服姓名 |
| `acquisitionChannel` | string | 是 | 获客一级渠道,见下方字典 |
| `acquisitionSub` | string | 是 | 宿主二级渠道原文,非固定枚举 |
<details>
<summary>获客渠道字典 acquisitionChannel</summary>
| 值 | 含义 |
| --- | --- |
| `walk_in` | 走入 |
| `word_of_mouth` | 口碑客户 |
| `group_sales` | 集团销售渠道 |
| `group_marketing` | 集团营销渠道 |
| `regional_marketing` | 地区营销渠道 |
| `ecommerce` | 电商平台 |
| `social_media` | 自媒体网络 |
| `employee` | 内部员工 |
| `other` | 其他 |
开放字符串,须兼容未列值;null 表示未提供,不等于 `other`。
</details>
### 画像 persona
返回标签数组,例如 `["充填治疗", "重要价值"]`。标签可能滞后于事实;标签是展示文案,不作为业务代码,也不要依赖数组位置判断业务。
### 患者关系 relations
| 字段 | 类型 | 可空 | 说明 |
| --- | --- | --- | --- |
| `relationship` | string | 否 | 对方是当前患者的什么人,见下方字典 |
| `patient` | object | 是 | 关系人基本信息;未关联、归档或无权返回时为 null |
关系人的 `patient` 包含 `id/externalId/name/medicalRecordNumber/phone/gender/birthDate`,类型及含义同上。仅返回所列方向,不推断反向关系,不展开关系人的事实和画像。
<details>
<summary>患者关系字典 relationship</summary>
| 值 | 含义 |
| --- | --- |
| `spouse` | 配偶 |
| `father` | 父亲 |
| `mother` | 母亲 |
| `child` | 子女 |
| `grandparent` | 祖父母/外祖父母 |
| `grandchild` | 孙辈/外孙辈 |
| `sibling` | 兄弟姐妹 |
| `friend` | 朋友 |
| `other` | 其他关系,不能直接当作亲属 |
开放字符串,须兼容未列值。`mother` 表示对方是当前患者的母亲。
</details>
### 事实 facts
| 字段 | 类型 | 可空 | 说明 |
| --- | --- | --- | --- |
| `id` | string | 否 | PAC 事实版本 UUID;同一业务记录产生新版本时会变化 |
| `type` | string | 否 | 事实类型,决定 content 结构,见第 5 节 |
| `kind` | string | 否 | `actual` 实际记录;`planned` 计划或建议。actual 不等于治疗完成 |
| `clinicId` | string | 是 | 宿主诊所标识 |
| `clinicName` | string | 是 | 诊所名称 |
| `occurredAt` | string | 是 | 业务发生时间,UTC ISO 8601;按下表理解 |
| `plannedFor` | string | 是 | 明确约定的执行时间,UTC ISO 8601;目前预约返回约定到诊时间,其他类型为 null |
| `content` | JSON | 是 | 通常为对应类型的对象,字段、枚举及示例见第 5 节;兼容历史空值或非对象值 |
| 事实 | occurredAt 的含义 |
| --- | --- |
| 预约 appointment_record | 宿主创建预约的时间;取不到留 null,不用约定或到诊时间替代 |
| 接诊 encounter_record | 实际到诊/接诊时间;不要将同一预约和接诊计作两次到诊 |
| 医生建议、治疗计划 | 提出建议/制定计划的时间 |
| 其他类型 | 该类业务的发生时间,不统一等于源记录创建时间 |
- **范围**:每条业务记录只返回最新有效版本;取消、过期、作废及被替换的版本不返回。结果是当前快照,记录消失不等于删除通知;不含召回结果。
- **排序**:按 occurredAt 倒序,缺失时使用 plannedFor;两者皆空排最后,同刻按 id 倒序。
- **精度**:时间戳统一 UTC;源时间只有日期时可按宿主当地零点表达,不代表精确时刻。
- **引用**:content.externalId 为本条记录的源标识;关联引用与 PAC 版本 id 不同,不保证能在本次结果中直接关联。
## 5. 事实 content 字段
按类型展开查看,表中 `/null` 表示允许显式空值;所有 content 字段均应兼容缺失。各类型的 status 使用各自字典;未列枚举的字段为开放文本。
金额单位以字段说明为准;amountCents、amount、totalAmount 不合并、不换算。外层已承接的同义时间会省略;内层仍有时间时,可能是独立业务时点、精度不足或与外层不同。未知扩展和异值别名可能保留原名,不应强行合并。
本页列出已接入数据的 11 类事实;各宿主覆盖不同,示例仅说明结构。调用方应兼容未来新增的 type。
<details id="consultation_record">
<summary>咨询 · consultation_record</summary>
意向是患者主观意愿,不等于医生诊断。
| 字段 | 类型 | 含义 |
| --- | --- | --- |
| `externalId` | string | 源咨询记录标识 |
| `consultDate` | string/null | 咨询日期或时刻,旧记录可能保留日期文本;通常省略;未被外层承接或有差异时保留 |
| `sourceChannel` | string/null | 来源渠道 |
| `intents` | string[] | 患者意向原文列表 |
| `intentCategories` | string[] | PAC 治疗类别列表,取值与 treatment_record.category 一致 |
| `isFirstVisit` | boolean/null | 源侧初诊标记;未知为 null |
| `taskDirector` | string/null | 跟进人原值 |
| `doctorId` | string/null | 源医生标识 |
| `appointmentExternalId` | string/null | 关联源预约标识 |
| `intent` | string/null | 仅保留无法由 intents 拼接还原的历史原文;重复拼接串省略 |
| `text` | string/null | 咨询正文 |
**示例**
```json
{
"type": "consultation_record",
"occurredAt": null,
"plannedFor": null,
"content": {
"intents": [
"洁牙"
],
"externalId": "consult-1",
"intentCategories": [
"preventive"
],
"isFirstVisit": true
}
}
```
</details>
<details id="encounter_record">
<summary>医生接诊 · encounter_record</summary>
诊断、治疗分别由对应事实表达。
| 字段 | 类型 | 含义 |
| --- | --- | --- |
| `externalId` | string | 源接诊标识 |
| `doctorId / doctorName` | string/null | 源医生标识 / 姓名 |
| `chiefComplaint` | string/null | 主诉 |
| `encounterType` | string/null | 接诊类型原值 |
**示例**
```json
{
"type": "encounter_record",
"occurredAt": null,
"plannedFor": null,
"content": {
"externalId": "enc-1",
"doctorName": "示例医生",
"chiefComplaint": "右下牙疼痛"
}
}
```
</details>
<details id="diagnosis_record">
<summary>诊断 · diagnosis_record</summary>
`image_ai` 表示影像分析来源,不等于医生确认。
| 字段 | 类型 | 含义 |
| --- | --- | --- |
| `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 | 可选的影像 / 分析来源标识 |
| `clinicalSignals` | object[] | 可选临床信号,见公共嵌套结构 |
**示例**
```json
{
"type": "diagnosis_record",
"occurredAt": null,
"plannedFor": null,
"content": {
"code": "K02",
"codeSource": "std_code",
"name": "龋病",
"toothPosition": "36",
"sourceEncounterExternalId": "enc-1"
}
}
```
</details>
<details>
<summary>影像分析结果:影像与诊断关联示例</summary>
美亚补充采集使用已有类型:影像信息在 `image_record`,分析快照在其 `content.analysis`,牙位诊断信号仍在 `diagnosis_record`,以 `codeSource=image_ai` 区分影像分析来源。当前这条路线不生成独立的分析报告记录。
- **关联影像**:诊断的 `content.sourceImageExternalId` 对应影像的 `content.externalId`,不是外层 PAC `id`;源标识按不透明字符串使用。
- **时间**:当前美亚路线中,影像的 `occurredAt` 是美亚影像创建时间;诊断的 `occurredAt` 是影像检查时间,均不表示分析完成或采集时间。
- **读取**:展示一张影像的分析时读取 `analysis`;它与关联的影像来源诊断表达同一批病症,不应重复计数。
- **病症**:`name`、`toothPosition` 保留病症和牙位;`code` 未能可靠映射时为 null,仍返回该发现,不代表无异常,也不等同医生确诊。
- **空结果**:分析尚未取得时可能没有对应诊断,不能推断检查正常。当前美亚补充路线在取得有效牙位结果后才一并提供影像与诊断。
- **访问原片**:本示例不提供原片 URL;查看影像仍使用宿主影像入口。`analysis` 返回标准化的综合结论、描述和病症列表,不返回供应商原始响应、OSS ID 或签名凭据。
以下截取 `facts` 中的一张影像与一条分析信号;同张影像可对应多条诊断,字段省略不表示其他字段不存在。
```json
{
"facts": [
{
"id": "11111111-1111-4111-8111-111111111111",
"type": "image_record",
"kind": "actual",
"clinicId": "clinic-a",
"clinicName": "示例诊所",
"occurredAt": "2026-10-10T02:00:00.000Z",
"plannedFor": null,
"content": {
"externalId": "meyer:test:image-1",
"modality": "pano",
"analysis": {
"source": "meyer",
"summary": "原始综合诊断",
"description": "描述",
"analyzedAt": null,
"findings": [
{ "name": "龋齿", "toothPosition": "16", "code": "K02" },
{ "name": "可疑龋", "toothPosition": "51", "code": null }
]
}
}
},
{
"id": "22222222-2222-4222-8222-222222222222",
"type": "diagnosis_record",
"kind": "actual",
"clinicId": "clinic-a",
"clinicName": "示例诊所",
"occurredAt": "2026-10-09T01:00:00.000Z",
"plannedFor": null,
"content": {
"code": "K02",
"codeSource": "image_ai",
"name": "龋齿",
"toothPosition": "16",
"sourceImageExternalId": "meyer:test:image-1"
}
}
]
}
```
</details>
<details id="treatment_record">
<summary>治疗与治疗计划 · treatment_record</summary>
kind=planned 表示计划,actual 表示实际记录。实际记录也可能仍在进行,不能只看 kind 推断治疗完成。
| 字段 | 类型 | 含义 |
| --- | --- | --- |
| `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 | 源医生标识 / 姓名 |
| `quantity` | number/null | 数量 |
| `unitName` | string/null | 计量单位,如颗、次、区 |
| `treatStages` | string[]/null | 源归一化治疗阶段;步骤码见公共代码字典。为空不代表没有治疗,未提供时为空 |
| `sourceEncounterExternalId` | string/null | 来源接诊引用 |
| `relatedDiagnosisSubjectId` | string/null | 关联诊断主体引用 |
| `reviewWindowDays` | number/null | 复查窗口天数 |
**示例**
```json
{
"type": "treatment_record",
"occurredAt": null,
"plannedFor": null,
"content": {
"category": "endodontic",
"subtype": "根管充填",
"status": "completed",
"toothPosition": "36",
"completedAt": "2026-10-19T02:00:00.000Z",
"treatStages": [
"canal_filling"
]
}
}
```
</details>
<details id="recommendation_record">
<summary>医生建议 · recommendation_record</summary>
通常 kind=planned;本查询以 occurredAt 表示建议提出时间,无明确执行日期时 plannedFor=null。
| 字段 | 类型 | 含义 |
| --- | --- | --- |
| `code` | string/null | PAC 归一建议码,见“公共代码字典”;未映射 null |
| `name` | string/null | 建议原文 |
| `sourceEncounterExternalId` | string/null | 来源接诊引用 |
| `toothPosition` | string/null | 牙位文本 |
| `doctorId / doctorName` | string/null | 源医生标识 / 姓名 |
| `extractionMethod` | string | `rule` 规则抽取、`llm` 模型抽取、`doctor_input` 医生录入 |
| `confidence` | number/null | 0–1 置信度,未提供 null |
| `sourceFactSubjectId` | string/null | 来源事实主体引用 |
| `doctorSuggestedAt` | string/null | 建议提出时刻;通常省略;未被外层承接或有差异时保留 |
| `windowDays` | number/null | 建议时间窗,单位天 |
**示例**
```json
{
"type": "recommendation_record",
"occurredAt": null,
"plannedFor": null,
"content": {
"code": null,
"name": "建议复查",
"toothPosition": "36",
"extractionMethod": "doctor_input",
"windowDays": 30
}
}
```
</details>
<details id="emr_record">
<summary>病历 · emr_record</summary>
段落统一以字符串返回;历史字符串可能含序列化 JSON,不保证都可直接 JSON.parse。结构化诊断和治疗在其他事实中,勿将文本再次当作新增事件。
| 字段 | 类型 | 含义 |
| --- | --- | --- |
| `externalId` | string | 源病历标识 |
| `encounterExternalId` | string/null | 关联源接诊标识 |
| `doctorId / doctorName` | string/null | 源医生标识 / 姓名 |
| `illnessDesc` | string/null | 主诉或疾病描述 |
| `preIllness` | string/null | 现病史 |
| `pastHistory` | string/null | 既往史 |
| `generalCondition` | string/null | 一般情况 |
| `examFindings` | string/null | 检查所见 |
| `auxExamFindings` | string/null | 可选辅助检查 |
| `diagnosisText` | string/null | 诊断原文 |
| `treatmentPlan` | string/null | 治疗计划原文 |
| `treatmentNotes` | string/null | 可选本次治疗原文 |
| `disposal` | string/null | 本次处置原文 |
| `doctorAdvice` | string/null | 医嘱 |
| `qualityCheck` | object/null | 可选质检对象:status(源状态字符串)、rejected(布尔)、rejectReason(原原因,可空) |
| `clinicalSignals` | object[] | 可选临床信号,见公共嵌套结构 |
**示例**
```json
{
"type": "emr_record",
"occurredAt": null,
"plannedFor": null,
"content": {
"externalId": "emr-1",
"encounterExternalId": "enc-1",
"illnessDesc": "右下牙疼痛",
"examFindings": "36 牙检查所见",
"doctorAdvice": "按医嘱复诊"
}
}
```
</details>
<details id="image_record">
<summary>影像 · image_record</summary>
fileUrl 不是 PAC 保证可访问的原片下载授权。查看原片走宿主授权入口;没有文件位置不等于没有影像。
| 字段 | 类型 | 含义 |
| --- | --- | --- |
| `externalId` | string | 源影像标识 |
| `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 |
| `finding` | string/null | 影像所见原文 |
| `analysis` | object/null | 可选的本张影像分析快照;字段见下表 |
| `capturedAt` | string/null | 拍摄时刻,来源未提供时为空;通常省略;未被外层承接或有差异时保留 |
| `fileUrl` | string | 可选宿主文件位置原值,可能相对路径或临时地址 |
| `fileName / fileType` | string | 可选文件名 / 文件类型 |
| `doctorId / doctorName` | string/null | 源医生标识 / 姓名 |
**analysis(存在时)**
| 字段 | 类型 | 含义 |
| --- | --- | --- |
| `source` | string | 分析来源,如 `meyer`;开放标识,不是枚举 |
| `summary` | string/null | 来源综合结论原文,不表示医生确诊 |
| `description` | string/null | 来源描述原文 |
| `analyzedAt` | string/null | 来源明确提供的分析完成时刻(UTC ISO 8601);未知 null,不用影像创建或检查时间代替 |
| `findings` | object[] | 本张影像的结构化病症列表;空数组不表示已确认正常 |
| `findings[].name` | string | 病症或所见原文 |
| `findings[].toothPosition` | string/null | 牙位文本;标准 FDI 编码见上表 |
| `findings[].code` | string/null | PAC 标准诊断码,值域见本页“诊断码与治疗阶段字典”;无法可靠映射为 null,不代表无异常 |
`analysis` 缺失或 null 只表示本张影像没有可提供的分析快照,可能尚未取得结果或无法可靠关联;不能据此判断源端无分析或检查正常。美亚分析示例见上方“影像分析结果”。
**示例**
```json
{
"type": "image_record",
"occurredAt": null,
"plannedFor": null,
"content": {
"modality": "pano",
"finding": "影像所见",
"externalId": "image-1",
"toothPositions": [
"36"
],
"fileUrl": "images/example.jpg",
"fileName": "example.jpg"
}
}
```
</details>
<details id="appointment_record">
<summary>预约 · appointment_record</summary>
外层 `occurredAt` 为宿主预约创建时间,取不到为 null;`plannedFor` 为约定到诊时间。实际到诊由 `encounter_record` 表达,预约不再返回 `arrivedAt`。
下表列出完整业务状态;本查询不返回取消、失效及被替换的预约。
| 字段 | 类型 | 含义 |
| --- | --- | --- |
| `externalId` | string | 源预约标识 |
| `scheduledAt` | string/null | 约定到诊时间;被外层 plannedFor 承接后省略,仅未承接或冲突时保留 |
| `appointmentType` | string/null | 预约类型 |
| `doctorId / doctorName` | string/null | 预约医生标识 / 姓名,不保证是实际接诊医生 |
| `resourceName` | string/null | 源排班资源名,也可能是房间或服务 |
| `complaintCategory / complaintText` | string/null | 预约主诉类别 / 原文 |
| `durationMinutes` | number/null | 预约时长,分钟 |
| `cancellationReason` | string/null | 取消原因(若有) |
| `status` | string/null | `scheduled` 已预约、`rescheduled` 已改约、`cancelled` 已取消、`arrived` 已到诊、`in_treatment` 诊疗中、`completed` 已完成、`no_show` 未赴约、`walk_in` 无预约到诊 |
**示例**
```json
{
"type": "appointment_record",
"occurredAt": "2026-10-19T01:00:00.000Z",
"plannedFor": "2026-10-20T01:00:00.000Z",
"content": {
"status": "scheduled",
"externalId": "appointment-1",
"doctorName": "示例医生",
"durationMinutes": 30
}
}
```
</details>
<details id="payment_record">
<summary>收款 · payment_record</summary>
应收金额缺失时,不能据此推断折扣或纯权益支付。
| 字段 | 类型 | 含义 |
| --- | --- | --- |
| `externalId` | string | 源收款标识 |
| `amountCents` | number | 实付金额,整数分 |
| `receivableCents` | number/null | 可选应收金额,分 |
| `discountCents` | number/null | 可选折扣金额,分 |
| `channel` | string/null | 支付渠道 |
| `insuranceName` | string/null | 商业保险公司名称 |
| `cardTypeName / cardName` | string/null | 卡券类型名 / 名称 |
| `settlementProject` | string/null | 结算项目名称 |
| `doctorId` | string/null | 收费医生标识 |
| `encounterExternalId / relatedOrderExternalId` | string/null | 关联接诊 / 医嘱单据引用 |
**示例**
```json
{
"type": "payment_record",
"occurredAt": null,
"plannedFor": null,
"content": {
"channel": "银行卡",
"externalId": "payment-1",
"amountCents": 10000,
"receivableCents": 12000,
"discountCents": 2000
}
}
```
</details>
<details id="refund_record">
<summary>退费 · refund_record</summary>
| 字段 | 类型 | 含义 |
| --- | --- | --- |
| `externalId` | string | 源退费标识 |
| `amountCents` | number | 退费金额,非负整数分,不用负数表达退费 |
| `reason` | string/null | 退费原因 |
| `relatedPaymentExternalId` | string/null | 关联源收款引用 |
**示例**
```json
{
"type": "refund_record",
"occurredAt": null,
"plannedFor": null,
"content": {
"reason": "项目调整",
"externalId": "refund-1",
"amountCents": 2000,
"relatedPaymentExternalId": "payment-1"
}
}
```
</details>
<details id="complaint_record">
<summary>投诉 · complaint_record</summary>
已解决的投诉仍可返回,不等于删除或免打扰。
| 字段 | 类型 | 含义 |
| --- | --- | --- |
| `externalId / number` | string / 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 |
| `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 | 解决 / 源更新时刻 |
| `followUps` | object[] | 跟进列表,字段见下文 |
| `hostStatus / sourceCategory / sourceConcern / sourceChannel` | string/null | 源状态、分类、关注点、渠道原值 |
**示例**
```json
{
"type": "complaint_record",
"occurredAt": null,
"plannedFor": null,
"content": {
"status": "resolved",
"category": "service",
"channel": "hotline",
"externalId": "complaint-1",
"feedbackKind": "complaint",
"signalEligible": true,
"resolvedAt": "2026-10-19T03:00:00.000Z",
"followUps": [],
"text": "服务反馈已处理"
}
}
```
</details>
<details>
<summary>clinicalSignals[](病历、诊断) 字段</summary>
| 字段 | 类型与含义 |
| --- | --- |
| `code / label` | 字符串;信号码 / 中文标签。code 是可扩展字典值,不是封闭枚举,以 label 展示 |
| `phrase / field` | 字符串;命中原文 / 所在 content 字段名 |
| `span` | 两个整数;原文字符区间 [start,end)。出站脱敏可能改变文本长度,不能保证在脱敏文本上仍精确定位 |
| `negated` | false=阳性陈述;true=明确否认;null=不可判 |
| `severity` | `absolute` 绝对禁忌、`relative` 相对禁忌、`check` 需核查;信号等级,不等于事实严重度或医生最终诊断 |
| `contraindicates` | 治疗域数组:`implant` 种植、`surgery` 外科、`anesthesia` 麻醉、`orthodontic` 正畸、`xray` 放射检查 |
| `validUntil / reviewAfter` | 可空 ISO 日期;信号失效 / 需复评日期,属于临床信号自身,不与外层业务发生时间去重 |
| `extractorVersion` | 抽取器版本字符串;区别于建议的 extractionMethod(抽取方式) |
没有该字段不意味着没有禁忌;历史信号可能已过期或需复评,不能仅凭查询返回该事实就当作当前风险。
</details>
<details>
<summary>complaint_record.followUps[] 字段</summary>
| 字段 | 类型与含义 |
| --- | --- |
| `occurredAt` | 可空跟进时刻 |
| `action` | `transferred` 转交、`completed` 完成、`edited` 修改、`followed_up` 跟进、`paused` 暂停、`unknown` 未知 |
| `sourceAction` | 可空源操作原值 |
| `text` | 可空跟进正文,已脱敏 |
| `actorId / actorName` | 可空操作人源标识 / 姓名 |
</details>
### 诊断码与治疗阶段字典
以下为当前标准值;响应按已存数据返回,调用方须兼容历史值及未来扩展。诊断/建议码是 PAC 归一分类,不能将所有码都当作 ICD 编码或医生确认的诊断。
<details>
<summary>诊断与建议码:code</summary>
| 值 | 含义 |
| --- | --- |
| `K00` | 牙发育和萌出障碍 |
| `K01` | 阻生 / 埋伏牙 |
| `K02` | 龋病 |
| `K03` | 牙体硬组织其他疾病 |
| `K04` | 牙髓 / 根尖周炎 |
| `K05` | 牙周炎 / 牙龈炎 |
| `K06` | 牙龈 / 牙槽骨疾患 |
| `K07` | 牙颌面异常 |
| `K08` | 牙列丢失 / 缺牙 |
| `K09` | 颌骨囊肿 |
| `K10` | 颌骨其他疾病 |
| `K11` | 唾液腺疾患 |
| `K12` | 口腔黏膜疾患 |
| `K13` | 唇 / 口腔黏膜其他疾患 |
| `K14` | 舌疾患 |
| `IMPLANT_FAILURE` | 种植失败 / 周围炎 |
| `ORTHO_RELAPSE` | 正畸反弹 |
| `ENDO_FRACTURE` | 根管牙折裂 |
| `IMPLANT_RECOMMENDED` | 建议种植 |
| `CROWN_RECOMMENDED` | 建议戴冠 |
| `FILLING_RECOMMENDED` | 建议充填 |
| `SRP_RECOMMENDED` | 建议牙周基础治疗 |
| `EXTRACTION_RECOMMENDED` | 建议拔除 |
| `ANNUAL_REVIEW_RECOMMENDED` | 建议年度复查 |
| `ORTHO_CONSULT_RECOMMENDED` | 建议正畸咨询 |
| `RCT_RECOMMENDED` | 建议根管治疗 |
| `HARD_TISSUE_REPAIR_RECOMMENDED` | 建议牙体修复 |
| `GUM_TREATMENT_RECOMMENDED` | 建议牙龈/牙槽嵴处置 |
| `ERUPTION_INTERVENTION_RECOMMENDED` | 建议萌出干预 |
| `JAW_CYST_REMOVAL_RECOMMENDED` | 建议囊肿摘除术 |
</details>
<details>
<summary>治疗阶段:content.treatStages</summary>
| 值 | 含义 |
| --- | --- |
| `pulp_extirpation` | 开髓 / 拔髓 |
| `canal_preparation` | 根管预备 |
| `canal_filling` | 根管充填 |
| `post_endo_filling` | 根管后充填 |
| `pulpotomy` | 牙髓切断术(活髓 / 部分 / 干髓) |
| `implant_placement` | 种植体植入 |
| `abutment_placement` | 基台连接 |
| `crown_placement` | 种植上部修复(冠) |
| `supragingival_scaling` | 龈上洁治 |
| `subgingival_scaling` | 龈下刮治 |
| `periodontal_maintenance` | 牙周维护 |
| `composite_filling` | 树脂充填 |
| `inlay` | 嵌体 |
| `crown_restoration` | 冠修复 |
| `post_core` | 桩核 |
| `tooth_extraction` | 拔除 |
| `bracket_placement` | 矫治器(粘附件) |
| `retainer` | 保持器 |
| `ortho_adjustment` | 正畸加力调整 |
| `fluoride_application` | 涂氟 |
| `pit_fissure_sealant` | 窝沟封闭 |
| `professional_cleaning` | 洁牙 |
| `whitening` | 美白 |
| `veneer` | 贴面 |
</details>
## 6. 错误处理
| code | 含义 | 调用方处理 |
| --- | --- | --- |
| `0` | 成功 | 读取 data;items 为空表示无匹配 |
| `10002` | 参数不合法 | 检查必填项、长度及未知参数后再请求 |
| `10101` | 应用凭据无效 | 核对 appId/appSecret 及所属环境 |
| `10106` | 令牌缺失、无效、过期或已撤销 | 重新获取应用令牌;仍失败则核对授权 |
| `10107` | 未开通查询权限 | 联系 PAC 管理方确认应用授权 |
其他非零 code 按失败处理,保留 code/msg 用于排查;不要将失败当成“没有患者”。
......@@ -4,7 +4,7 @@ description: 宿主把用户登录到内嵌 PAC 的换票接口、请求参数
icon: KeyRound
---
同一 `/pac/v1/auth/token` 也支持应用只读查询:仅传 `appId/appSecret`、不传 `user` 即进入应用模式,需事先开通授权。原用户登录流程保持不变,详见[患者事实查询](/docs/integration/patient-facts-lookup)。
同一 `/pac/v1/auth/token` 也支持应用只读查询:仅传 `appId/appSecret`、不传 `user` 即进入应用模式,需事先开通授权。原用户登录流程保持不变,详见[患者事实查询](/docs/api/open/PatientFactsLookupController_lookup)。
> PAC 工作台以 iframe 嵌入宿主系统。用户在宿主侧登录后,宿主**换票**给 PAC —— 传入「身份 + 角色 + 可见范围」,换取一次性 code 打开 PAC 页面。
......
......@@ -10,8 +10,6 @@
"channel-push",
"friday-push-payload",
"auth-login",
"patient-facts-lookup",
"patient-fact-content",
"friday-ai-host",
"postmessage-actions",
"execution-callback",
......
---
title: 事实 content 字段参考
description: 患者查询接口 17 类事实的输出字段、业务状态、空值和示例。
---
本页配合 [患者查询接口](/docs/integration/patient-facts-lookup) 使用。这里说明的是 **返回的 fact.content**,不是宿主摄入请求。
## 阅读约定
按 `type` 查对应章节;字段表默认展示,JSON 示例点击展开。
- `content` 保留原字段名;外层 `status` 是事实生命周期,`content.status` 是业务状态。
- 兼容字段缺失、null 及未知字段/类型;缺失不等于 false、0、无病或已完成。
- 金额以**分**计;牙位保留文本。来源 ID 仅在对应患者、来源范围内使用。
- 时间轴优先使用事实外层时间;旧 content 的无时区文本不能直接当 UTC。
- 示例为虚构结构,不代表宿主已提供该类数据。内部备注、联系方式及证件类键剔除,自由文本脱敏。
## `consultation_record` 咨询
意向是患者主观意愿,不等于医生诊断。
| 字段 | 类型 | 含义 |
| --- | --- | --- |
| `consultation_external_id` | string | 源咨询记录标识 |
| `consult_date` | string/null | 咨询日期或时刻,旧记录可能保留日期文本 |
| `source_channel` | string/null | 来源渠道 |
| `intents` | string[] | 患者意向原文列表 |
| `intent_categories` | string[] | PAC 治疗类别列表 |
| `is_first_visit` | boolean/null | 源侧初诊标记;未知为 null |
| `task_director` | string/null | 跟进人原值 |
| `doctor_id` | string/null | 源医生标识 |
| `appointment_external_id` | string/null | 关联源预约标识 |
| `intent` | string/null | 兼容的意向展示串 |
| `content` | string/null | 咨询正文 |
<details>
<summary>JSON 示例</summary>
```json
{
"type": "consultation_record",
"content": {
"consultation_external_id": "consult-1",
"intents": [
"洁牙"
],
"intent_categories": [
"preventive"
],
"is_first_visit": true
}
}
```
</details>
## `visit_registration_record` 挂号
标准写入校验使用 registration_external_id,但当前解析器使用 visit_registration_external_id;接口按已存字段返回,不承诺两者同时存在。该摄入差异未在本次修改。
| 字段 | 类型 | 含义 |
| --- | --- | --- |
| `registration_external_id` | string | 标准挂号标识 |
| `registered_at` | string/null | 挂号时刻 |
| `doctor_id` | string/null | 源医生标识 |
| `visit_registration_external_id` | string | 兼容挂号标识(若已存) |
| `appointment_external_id` | string/null | 关联源预约标识(若已存) |
<details>
<summary>JSON 示例</summary>
```json
{
"type": "visit_registration_record",
"content": {
"registration_external_id": "reg-1",
"registered_at": "2026-10-19T01:00:00.000Z"
}
}
```
</details>
## `visit_reception_record` 接待登记
标准写入校验使用 reception_external_id,当前解析器使用 visit_reception_external_id;返回已有值,不在查询时补造或转换。
| 字段 | 类型 | 含义 |
| --- | --- | --- |
| `reception_external_id` | string | 标准接待标识 |
| `reception_type` | string/null | 接待类型 |
| `received_at` | string/null | 接待时刻 |
| `visit_reception_external_id` | string | 兼容接待标识(若已存) |
| `receptionist_id` | string/null | 接待人标识(若已存) |
<details>
<summary>JSON 示例</summary>
```json
{
"type": "visit_reception_record",
"content": {
"reception_external_id": "reception-1",
"received_at": "2026-10-19T01:05:00.000Z"
}
}
```
</details>
## `encounter_record` 医生接诊
只表达接诊元信息;诊断、治疗分别使用对应事实。内部 notes 不输出。
| 字段 | 类型 | 含义 |
| --- | --- | --- |
| `encounter_external_id` | string | 源接诊标识 |
| `doctor_id / doctor_name` | string/null | 源医生标识 / 姓名 |
| `chief_complaint` | string/null | 主诉 |
| `encounter_type` | string/null | 接诊类型原值 |
<details>
<summary>JSON 示例</summary>
```json
{
"type": "encounter_record",
"content": {
"encounter_external_id": "enc-1",
"doctor_name": "示例医生",
"chief_complaint": "右下牙疼痛"
}
}
```
</details>
## `diagnosis_record` 诊断
image_ai 表示影像分析来源,不能当作医生确认;content 不会因 kind/status 变化而改成另一种模型。
| 字段 | 类型 | 含义 |
| --- | --- | --- |
| `code` | string/null | PAC 标准诊断或建议码;未映射时 null,不据此推断无病 |
| `code_source` | string/null | std_code / name_map / llm / image_ai;未知 null |
| `name_zh` | string/null | 原诊断中文名称 |
| `tooth_position` | string/null | 牙位文本,可能多牙位及牙面标记 |
| `severity` | string/null | mild / moderate / severe |
| `onset_date` | string/null | 起病日期原值 |
| `doctor_id / doctor_name` | string/null | 源医生标识 / 姓名 |
| `source_encounter_external_id` | string | 来源接诊引用;源未提供时解析器可能用本记录标识兜底,不保证真实关联 |
| `source_image_external_id / source_analysis_external_id` | string | 可选的影像 / 分析来源标识 |
| `clinical_signals` | object[] | 可选临床信号,见公共嵌套结构 |
<details>
<summary>JSON 示例</summary>
```json
{
"type": "diagnosis_record",
"content": {
"code": "K02",
"code_source": "std_code",
"name_zh": "龋病",
"tooth_position": "36",
"source_encounter_external_id": "enc-1"
}
}
```
</details>
## `treatment_record` 治疗与治疗计划
kind=planned 表示计划,actual 表示实际记录。实际记录也可能仍在进行,不能只看 kind 推断治疗完成。
| 字段 | 类型 | 含义 |
| --- | --- | --- |
| `category` | string | endodontic / restorative / prosthodontic / implant / periodontic / orthodontic / surgical / pediatric / preventive / cosmetic / review |
| `subtype` | string/null | 治疗子类或原文 |
| `tooth_position` | string/null | 牙位文本 |
| `status` | string | 业务进度:planned / in_progress / completed / cancelled / failed |
| `started_at / completed_at` | string/null | 开始 / 完成时刻 |
| `doctor_id / doctor_name` | string/null | 源医生标识 / 姓名 |
| `quantity` | number/null | 数量 |
| `unit_name` | string/null | 计量单位,如颗、次、区 |
| `treat_stages` | string[]/null | 源归一化治疗阶段;对外统一步骤优先使用事实外层 steps |
| `source_encounter_external_id` | string/null | 来源接诊引用 |
| `related_diagnosis_subject_id` | string/null | 关联诊断主体引用 |
| `review_window_days` | number/null | 复查窗口天数 |
<details>
<summary>JSON 示例</summary>
```json
{
"type": "treatment_record",
"content": {
"category": "endodontic",
"subtype": "根管充填",
"tooth_position": "36",
"status": "completed",
"completed_at": "2026-10-19T02:00:00.000Z",
"treat_stages": [
"canal_filling"
]
}
}
```
</details>
## `recommendation_record` 医生建议
通常 kind=planned;plannedFor 在当前解析器中可为建议提出时刻,不保证是已预约的复诊日期。
| 字段 | 类型 | 含义 |
| --- | --- | --- |
| `code` | string/null | PAC 归一建议码;未映射 null |
| `name` | string/null | 建议原文 |
| `source_encounter_external_id` | string/null | 来源接诊引用 |
| `tooth_position` | string/null | 牙位文本 |
| `doctor_id / doctor_name` | string/null | 源医生标识 / 姓名 |
| `extracted_by` | string | rule / llm / doctor_input |
| `confidence` | number/null | 0–1 置信度,未提供 null |
| `source_fact_subject_id` | string/null | 来源事实主体引用 |
| `doctor_suggested_at` | string/null | 建议提出时刻 |
| `window_days` | number/null | 建议时间窗,单位天 |
<details>
<summary>JSON 示例</summary>
```json
{
"type": "recommendation_record",
"content": {
"code": null,
"name": "建议复查",
"tooth_position": "36",
"extracted_by": "doctor_input",
"window_days": 30
}
}
```
</details>
## `emr_record` 病历
段落统一以字符串返回;历史字符串可能含序列化 JSON,不保证都可直接 JSON.parse。结构化诊断和治疗在其他事实中,勿将文本再次当作新增事件。
| 字段 | 类型 | 含义 |
| --- | --- | --- |
| `emr_external_id` | string | 源病历标识 |
| `encounter_external_id` | string/null | 关联源接诊标识 |
| `doctor_id / doctor_name` | string/null | 源医生标识 / 姓名 |
| `illness_desc` | string/null | 主诉或疾病描述 |
| `pre_illness` | string/null | 现病史 |
| `past_history` | string/null | 既往史 |
| `general_condition` | string/null | 一般情况 |
| `exam_findings` | string/null | 检查所见 |
| `aux_exam_findings` | string/null | 可选辅助检查 |
| `diagnosis_text` | string/null | 诊断原文 |
| `treatment_plan` | string/null | 治疗计划原文 |
| `treatment_notes` | string/null | 可选本次治疗原文 |
| `disposal` | string/null | 本次处置原文 |
| `doctor_advice` | string/null | 医嘱 |
| `quality_check` | object/null | 可选质检对象:status(源状态字符串)、rejected(布尔)、reject_reason(原原因,可空) |
| `clinical_signals` | object[] | 可选临床信号,见公共嵌套结构 |
<details>
<summary>JSON 示例</summary>
```json
{
"type": "emr_record",
"content": {
"emr_external_id": "emr-1",
"encounter_external_id": "enc-1",
"illness_desc": "右下牙疼痛",
"exam_findings": "36 牙检查所见",
"doctor_advice": "按医嘱复诊"
}
}
```
</details>
## `image_record` 影像
file_url 不是 PAC 保证可访问的原片下载授权。查看原片走宿主授权入口;没有文件位置不等于没有影像。
| 字段 | 类型 | 含义 |
| --- | --- | --- |
| `image_external_id` | string | 源影像标识 |
| `encounter_external_id` | string/null | 关联源接诊标识 |
| `modality` | string | pa / bw / pano / cbct / intraoral_photo / scan / other |
| `tooth_positions` | string[]/null | 牙位列表 |
| `finding` | string/null | 影像所见原文 |
| `captured_at` | string/null | 拍摄时刻,来源未提供时为空 |
| `file_url` | string | 可选宿主文件位置原值,可能相对路径或临时地址 |
| `file_name / file_type` | string | 可选文件名 / 文件类型 |
| `doctor_id / doctor_name` | string/null | 源医生标识 / 姓名 |
<details>
<summary>JSON 示例</summary>
```json
{
"type": "image_record",
"content": {
"image_external_id": "image-1",
"modality": "pano",
"tooth_positions": [
"36"
],
"finding": "影像所见",
"file_name": "example.jpg",
"file_url": "images/example.jpg"
}
}
```
</details>
## `image_analysis_record` 影像分析
外层 active 只说明此快照有效,不代表分析已完成。只有 content.status=completed 时,空 findings 才可解释为该次完成报告无发现;不代表患者没有其他疾病。
| 字段 | 类型 | 含义 |
| --- | --- | --- |
| `analysis_external_id / image_external_id` | string | 分析 / 对应影像的源标识 |
| `code_source` | string | 固定 image_ai |
| `status` | string | 分析业务状态 pending / completed / failed |
| `analyzed_at` | string/null | 实际分析时刻;未知 null |
| `source_updated_at` | string/null | 源更新时间 |
| `analysis_result` | string/null | 报告原文,可能是 JSON 字符串 |
| `findings` | object[] | 问题快照,字段见下文 |
<details>
<summary>JSON 示例</summary>
```json
{
"type": "image_analysis_record",
"content": {
"analysis_external_id": "analysis-1",
"image_external_id": "image-1",
"code_source": "image_ai",
"status": "completed",
"analyzed_at": "2026-10-19T02:05:00.000Z",
"findings": [
{
"externalId": "finding-1",
"code": "K02",
"name": "龋病",
"toothPosition": "36",
"excluded": false
}
]
}
}
```
</details>
## `appointment_record` 预约
这是内容的完整业务枚举,不代表本查询会返回所有状态的记录:外层 cancelled/expired/superseded 已被过滤。staff_notes 不输出。
| 字段 | 类型 | 含义 |
| --- | --- | --- |
| `appointment_external_id` | string | 源预约标识 |
| `scheduled_at / arrived_at` | string/null | 计划预约 / 实际到诊时刻 |
| `appointment_type` | string/null | 预约类型 |
| `doctor_id / doctor_name` | string/null | 预约医生标识 / 姓名,不保证是实际接诊医生 |
| `resource_name` | string/null | 源排班资源名,也可能是房间或服务 |
| `complaint_category / complaint_text` | string/null | 预约主诉类别 / 原文 |
| `duration_minutes` | number/null | 预约时长,分钟 |
| `cancellation_reason` | string/null | 取消原因(若有) |
| `status` | string/null | scheduled / rescheduled / cancelled / arrived / in_treatment / completed / no_show / walk_in |
<details>
<summary>JSON 示例</summary>
```json
{
"type": "appointment_record",
"content": {
"appointment_external_id": "appointment-1",
"scheduled_at": "2026-10-20T01:00:00.000Z",
"arrived_at": null,
"status": "scheduled",
"doctor_name": "示例医生",
"duration_minutes": 30
}
}
```
</details>
## `order_record` 医嘱单据
当前解析器 total_amount/items[].name 与标准写入校验 amount_cents/items[].item_name 存在差异。本接口不换算、不把缺失当 0;示例是通过标准校验的内容形态,不证明各宿主已成功摄入此类单据。
| 字段 | 类型 | 含义 |
| --- | --- | --- |
| `order_external_id` | string | 源单据标识 |
| `encounter_external_id` | string/null | 关联源接诊标识 |
| `amount_cents` | number | 标准总金额,整数分 |
| `items` | object[]/null | 标准明细:item_type(technical/material/drug,可缺)、item_code/item_name(字符串,可空)、quantity/unit_price_cents/amount_cents(数字,可空);金额为分 |
| `total_amount / currency / host_status` | number / string / string | 兼容解析器字段(若已存):金额为分、币种、源业务状态 |
| `items[].name / type / treatment_category / amount` | 字符串或数字,可空 | 兼容解析器明细(若已存):项目名、treatment/consumable/medication/other、治疗类别、金额分 |
<details>
<summary>JSON 示例</summary>
```json
{
"type": "order_record",
"content": {
"order_external_id": "order-1",
"amount_cents": 12000,
"items": [
{
"item_type": "technical",
"item_name": "治疗项目",
"quantity": 1,
"amount_cents": 12000
}
]
}
}
```
</details>
## `payment_record` 收款
历史旧解析版本的金额口径可能不同;不能仅据缺少 receivable_cents 自动判定折扣或纯权益支付。查询不重写历史事实。
| 字段 | 类型 | 含义 |
| --- | --- | --- |
| `payment_external_id` | string | 源收款标识 |
| `amount_cents` | number | 实付金额,整数分 |
| `receivable_cents` | number/null | 可选应收金额,分 |
| `discount_cents` | number/null | 可选折扣金额,分 |
| `channel` | string/null | 支付渠道 |
| `insurance_name` | string/null | 商业保险公司名称 |
| `card_type_name / card_name` | string/null | 卡券类型名 / 名称 |
| `settlement_project` | string/null | 结算项目名称 |
| `doctor_id` | string/null | 收费医生标识 |
| `encounter_external_id / related_order_external_id` | string/null | 关联接诊 / 医嘱单据引用 |
<details>
<summary>JSON 示例</summary>
```json
{
"type": "payment_record",
"content": {
"payment_external_id": "payment-1",
"amount_cents": 10000,
"receivable_cents": 12000,
"discount_cents": 2000,
"channel": "银行卡"
}
}
```
</details>
## `refund_record` 退费
| 字段 | 类型 | 含义 |
| --- | --- | --- |
| `refund_external_id` | string | 源退费标识 |
| `amount_cents` | number | 退费金额,非负整数分,不用负数表达退费 |
| `reason` | string/null | 退费原因 |
| `related_payment_external_id` | string/null | 关联源收款引用 |
<details>
<summary>JSON 示例</summary>
```json
{
"type": "refund_record",
"content": {
"refund_external_id": "refund-1",
"amount_cents": 2000,
"reason": "项目调整",
"related_payment_external_id": "payment-1"
}
}
```
</details>
## `recharge_record` 充值
当前解析器 amount/recharge_type 与标准写入校验 amount_cents/card_type 存在差异;兼容字段只说明已存数据如何读,不承诺该摄入分支已对齐。
| 字段 | 类型 | 含义 |
| --- | --- | --- |
| `recharge_external_id` | string | 源充值标识 |
| `amount_cents` | number | 标准充值金额,整数分 |
| `card_type` | string/null | 标准卡类型 |
| `amount / currency / recharge_type / benefits` | number / string / string或null / JSON | 兼容解析器字段(若已存):金额分、币种、充值类型、权益原值 |
<details>
<summary>JSON 示例</summary>
```json
{
"type": "recharge_record",
"content": {
"recharge_external_id": "recharge-1",
"amount_cents": 50000,
"card_type": "储值卡"
}
}
```
</details>
## `complaint_record` 投诉
当前解析器将非有效投诉标为 invalidated,本查询不返回这些快照;解决的投诉仍是历史事实,不等于删除,也不自动意味着免打扰。
| 字段 | 类型 | 含义 |
| --- | --- | --- |
| `complaint_external_id / number` | string / string或null | 源记录标识 / 投诉编号 |
| `feedback_kind` | 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 |
| `signal_eligible` | boolean | 是否具备有效投诉信号资格 |
| `content` | string/null | 投诉正文;即 fact.content.content |
| `severity` | string/null | low / medium / high;当前解析器不推测,留 null |
| `review_reason` | string/null | classification_conflict / unknown_feedback_kind / unknown_status 或 null |
| `related_treatment_subject_id / related_order_external_id` | string/null | 关联治疗 / 医嘱引用 |
| `resolved_at / source_updated_at` | string/null | 解决 / 源更新时刻 |
| `follow_ups` | object[] | 跟进列表,字段见下文 |
| `host_status / source_category / source_concern / source_channel` | string/null | 源状态、分类、关注点、渠道原值 |
<details>
<summary>JSON 示例</summary>
```json
{
"type": "complaint_record",
"content": {
"complaint_external_id": "complaint-1",
"feedback_kind": "complaint",
"status": "resolved",
"category": "service",
"channel": "hotline",
"signal_eligible": true,
"content": "服务反馈已处理",
"resolved_at": "2026-10-19T03:00:00.000Z",
"follow_ups": []
}
}
```
</details>
## `referral_record` 转介绍
事实挂在被推荐人名下,表示“我是被谁推荐来的”。推荐人不必是患者,不等同于 relations 的亲属关系。
| 字段 | 类型 | 含义 |
| --- | --- | --- |
| `referral_external_id` | string | 源转介绍标识 |
| `referrer_external_id` | string/null | 推荐人源标识 |
| `referrer_name / referrer_chart_number` | string/null | 推荐人姓名 / 病历号快照 |
| `referrer_type` | string/null | patient / staff / partner / other |
| `channel` | string/null | 推荐渠道 |
<details>
<summary>JSON 示例</summary>
```json
{
"type": "referral_record",
"content": {
"referral_external_id": "referral-1",
"referrer_name": "示例推荐人",
"referrer_type": "patient",
"referrer_chart_number": "000456"
}
}
```
</details>
## 公共嵌套结构
### `clinical_signals[]`(病历、诊断)
| 字段 | 类型与含义 |
| --- | --- |
| `code / label` | 字符串;信号码 / 中文标签 |
| `phrase / field` | 字符串;命中原文 / 所在 content 字段名 |
| `span` | 两个整数;原文字符区间 [start,end)。出站脱敏可能改变文本长度,不能保证在脱敏文本上仍精确定位 |
| `negated` | false=阳性陈述;true=明确否认;null=不可判 |
| `severity` | absolute / relative / check;信号等级,不等于事实严重度或医生最终诊断 |
| `contraindicates` | 治疗域数组:implant / surgery / anesthesia / orthodontic / xray |
| `validUntil / reviewAfter` | 可空 ISO 日期;信号失效 / 需复评日期,独立于外层事实有效期 |
| `extractedBy` | 抽取器版本字符串 |
没有该字段不意味着没有禁忌;历史信号可能已过期或需复评,不能仅凭事实仍 active 就当作当前风险。
### `image_analysis_record.findings[]`
| 字段 | 类型与含义 |
| --- | --- |
| `externalId` | 问题源标识,字符串 |
| `code / name` | 可空标准码 / 问题原名称字符串 |
| `patientExternalId / imageExternalId` | 可选来源患者 / 影像引用 |
| `toothPosition / excludedToothPositions` | 可空牙位文本 / 排除牙位文本 |
| `excluded` | 布尔;是否排除整项 |
这是分析报告问题快照,可能含已排除项;可用诊断信号另由 `diagnosis_record` 表达,不能把两者重复计数。
### `complaint_record.follow_ups[]`
| 字段 | 类型与含义 |
| --- | --- |
| `occurredAt` | 可空跟进时刻 |
| `action` | transferred / completed / edited / followed_up / paused / unknown |
| `sourceAction` | 可空源操作原值 |
| `content` | 可空跟进正文,已脱敏 |
| `actorId / actorName` | 可空操作人源标识 / 姓名 |
## 兼容边界
本页按当前内容 schema、解析器和出站脱敏共同核对。挂号、接待、医嘱、充值存在上文标注的解析器/写入 schema 差异;查询只读已落库内容,不修复入库、不伪造缺少字段,也不能用“查不到”证明源系统不存在记录。接入方应以本宿主实际可用数据验收;摄入差异另行修复。
---
title: 患者查询
description: 获取应用令牌,精确查询患者资料、画像、关系和事实。
---
**服务端调用**:使用宿主已有的 `appId/appSecret`,由 PAC 开通查询权限。
权限覆盖**应用所属宿主的全部有效患者(含不同租户/品牌)**。宿主后端须校验使用者权限,密钥和应用令牌不可下发浏览器。
## 1. 获取令牌
`POST /pac/v1/auth/token`
```json
{ "appId": "应用标识", "appSecret": "应用密钥" }
```
成功响应:
```json
{
"code": 0,
"msg": "ok",
"data": { "accessToken": "访问令牌", "tokenType": "Bearer", "expiresIn": 3600 }
}
```
不传 `user`、不需要 `grantType`。令牌有效期 **1 小时**,到期重新获取;仅用于应用查询,不能登录工作台。
<details>
<summary>与原登录的区别</summary>
- 传完整 `user`:原用户登录流程,返回用户令牌、刷新令牌和登录 code。
- `user` 为空、null 或不完整:参数错误,不转为应用访问。
- 用户登录令牌不能调用本查询;FridayAI 原 HMAC 鉴权独立。
- 宿主停用、撤销查询授权或轮换应用密钥后,旧应用令牌失效。
[登录接入](/docs/integration/auth-login) · [鉴权 API 参考](/docs/api/auth/AuthController_exchangeToken)
</details>
## 2. 查询患者
`POST /pac/v1/open/patient-facts/lookup`
```http
Authorization: Bearer <accessToken>
Content-Type: application/json
```
```json
{ "medicalRecordNumber": "000123" }
```
| 可选条件 | 含义 |
| --- | --- |
| `medicalRecordNumber` | 病历号,字符串,保留前导零 |
| `name` | 姓名 |
| `phone` | 患者主档手机号 |
**至少一项,多项取交集,精确匹配。** 只去除首尾空白,不做模糊查询、号码格式转换或家属扩展。无需系统 ID;不支持分页及其他筛选参数,未知参数会拒绝。
## 3. 读取结果
`data.items` 为患者数组;无匹配返回 `[]`。病历号可能重号,不同患者分别返回。
| 每项内容 | 说明 |
| --- | --- |
| `patient` | 基本信息和业务属性;`id` 为 PAC 患者 ID,`externalId` 为宿主患者 ID |
| `persona` | 已保存的当前画像,无画像为 null;查询不触发重算 |
| `relations` | 已存患者关系,无记录为 [];关系人同样带 id、externalId |
| `facts` | 全部当前有效事实,无记录为 [];不分页、不截断 |
- **标识**:用 `patient.id` 区分或缓存结果;`externalId` 不是病历号,也不保证跨品牌唯一,回链宿主须结合自身上下文。
- **事实**:只返回最新有效版本(active / fulfilled),按发生时间倒序,缺失时取计划时间;取消、失效、被替换的版本不返回。
- **状态**:`type` 决定 `content` 结构;外层 `status` 是事实生命周期,`content.status` 才是该类型的业务状态。
- **空值与时间**:未知保持 null,不等于 false 或无风险;时间戳为 UTC,生日和最近到诊日期保持 `YYYY-MM-DD`。
- **数据范围**:返回 PAC 已有数据,不含召回结果;手机号和自由文本脱敏。画像可能滞后于事实。
<details>
<summary>完整响应示例</summary>
```json
{
"code": 0,
"msg": "ok",
"data": {
"items": [
{
"patient": {
"id": "11111111-1111-4111-8111-111111111111",
"externalId": "source-patient-123",
"name": "张三",
"medicalRecordNumber": "000123",
"phone": "138****8000",
"gender": "男",
"birthDate": "1990-01-02",
"preferences": null,
"lastVisitAt": null,
"lastVisitDoctor": null,
"preferredDoctor": null,
"doNotContact": null,
"doNotContactReason": null,
"deceased": null,
"hostFollowUpActive": null,
"tags": null,
"acquisitionChannel": null,
"acquisitionSub": null
},
"persona": null,
"relations": [],
"facts": [
{
"subjectId": "diagnosis:example",
"type": "diagnosis_record",
"kind": "actual",
"status": "active",
"clinicId": "来源诊所标识",
"clinicName": "示例诊所",
"occurredAt": "2026-10-19T02:00:00.000Z",
"plannedFor": null,
"validUntil": null,
"title": "诊断记录",
"summary": null,
"content": { "code": "K02" },
"steps": []
}
]
}
]
}
}
```
</details>
<details>
<summary>患者、画像与关系字段</summary>
| 字段 | 内容 |
| --- | --- |
| `patient` | PAC 患者 ID `id`、宿主患者 ID `externalId`、姓名、病历号、脱敏手机号、性别、出生日期、已脱敏的偏好 `preferences` |
| `patient`(业务属性) | 最近到诊日期 `lastVisitAt`、上次就诊医生 `lastVisitDoctor`、偏好医生 `preferredDoctor`、禁止联系 `doNotContact` 及原因、已故 `deceased`、宿主跟进状态 `hostFollowUpActive`、标签 `tags`、获客渠道 `acquisitionChannel/acquisitionSub` |
| `persona` | 当前已保存画像:`version`、`computedAt`、`refreshedAt`,以及 `features` 数组(`key/description/score/data`) |
| `relations` | 已存患者关系数组,每项为 `relationship` 和关系人基本信息 `patient` |
关系方向为“对方是本人的什么人”:`mother` 表示对方是当前患者的母亲。不推断反向关系,也不展开家属事实。
关系人未解析、已归档或越界时为 `patient: null`;异常跨宿主/租户的关系边不输出。关系人字段为 `id/externalId/name/medicalRecordNumber/phone/gender/birthDate`。
患者业务属性未建立时为 null;已有记录的 false 和空数组保持原值。画像取未被取代的最高版本,`computedAt` 为生成时间,`refreshedAt` 为最近刷新时间。
</details>
<details>
<summary>事实公共字段</summary>
| 字段 | 含义与读取规则 |
| --- | --- |
| `subjectId` | 来源主体的稳定引用,用于溯源。不能单独当全局唯一键,也不要解析其拼接格式作为业务逻辑 |
| `type` | 决定 `content` 的内容结构;完整 17 类及示例见 [事实内容参考](/docs/integration/patient-fact-content) |
| `kind` | `actual` 实际记录;`planned` 计划或建议。actual 不等于治疗已完成 |
| `status` | 事实生命周期,本接口仅 `active`(当前有效)、`fulfilled`(已履行)。不是病情是否存在/治愈,也不等于 content 的业务状态 |
| `clinicId / clinicName` | 来源诊所标识 / 字典名称,可空 |
| `occurredAt` | 实际发生时刻,UTC ISO 8601;未知为 null |
| `plannedFor` | 计划/建议的时间锚,UTC ISO 8601;不构成已发生的证明,建议事实也可能取建议提出时间 |
| `validUntil` | 有效期截止时刻,可空。null 表示未提供,不能推断永久有效;查询不额外按当前时间过滤此字段 |
| `title / summary` | 展示标题 / 摘要,可空,不能依靠文本代替结构化业务判断 |
| `content` | 按 type 定义的内容,保留原标准字段名、历史空值/缺失;已脱敏 |
| `steps` | PAC 标准治疗步骤,仅 `treatment_record` 有值;其他类型为空数组,未知步骤应兼容 |
时间均为空的事实排在最后,同一时间按稳定记录标识排序。此接口提供当前快照;相邻查询中记录缺失,不等于源端删除通知。
</details>
[各类 content 字段与示例](/docs/integration/patient-fact-content) · [查询 API 参考](/docs/api/open/PatientFactsLookupController_lookup)
## 错误处理
**以响应体 `code` 判断成功,不能只看 HTTP 200。** 成功为 `0`。
| code | 含义 |
| --- | --- |
| `10002` | 参数不合法 |
| `10101` | 应用凭据无效 |
| `10106` | 令牌缺失、无效、过期或已撤销 |
| `10107` | 未开通查询权限 |
<details>
<summary>PAC 运维:开通与撤销</summary>
先应用数据库迁移 `20261019170000_open_patient_facts`,再部署对应服务版本。迁移只新增默认关闭的授权字段和患者精确查询索引,不自动开通任何宿主。
```bash
pnpm --filter @pac/service pac:host grant-facts-read <宿主名称>
pnpm --filter @pac/service pac:host revoke-facts-read <宿主名称>
```
开通的是**本宿主全范围只读事实查询**。已有 FridayAI `grant-read/revoke-read` 不受影响。
</details>
......@@ -7,10 +7,15 @@ import { docsContentRoute, docsImageRoute, docsRoute } from './shared';
// 多源 loader:手写 MDX(docs)+ 由 OpenAPI spec 派生的虚拟 API 参考页(openapi)。
// openapi 页挂在 /docs/api 下,按 tag(≈ controller)分组。loaderPlugin 给侧栏加
// GET/POST 方法标记。See https://fumadocs.dev/docs/headless/source-api
const apiSource = await openapi.staticSource({ groupBy: 'tag', baseDir: 'api' });
// 此端点使用单页对接文档;其余端点仍按 OpenAPI 自动渲染,避免同 URL 出现两份目录项。
apiSource.files = apiSource.files.filter(
(file) => file.path !== 'api/open/PatientFactsLookupController_lookup.mdx',
);
export const source = loader(
{
docs: docs.toFumadocsSource(),
openapi: await openapi.staticSource({ groupBy: 'tag', baseDir: 'api' }),
openapi: apiSource,
},
{
baseUrl: docsRoute,
......
......@@ -16,6 +16,8 @@ const config = {
// 没有营销首页 / 文档首页 —— 根路径直接进文档,/docs 再进第一篇内容
async redirects() {
return [
{ source: '/docs/integration/patient-facts-lookup', destination: '/docs/api/open/PatientFactsLookupController_lookup', permanent: true },
{ source: '/docs/integration/patient-fact-content', destination: '/docs/api/open/PatientFactsLookupController_lookup', permanent: true },
{ source: '/', destination: '/docs', permanent: false },
{ source: '/docs', destination: '/docs/start/what-is-pac', permanent: false },
];
......
......@@ -9384,6 +9384,7 @@
},
"gender": {
"type": "string",
"description": "开放文本;常见 male=男、female=女,也可能保留男/女等宿主原值;未知为 null",
"nullable": true
},
"birthDate": {
......@@ -9391,51 +9392,36 @@
"description": "YYYY-MM-DD 业务日期",
"nullable": true
},
"preferences": {},
"lastVisitAt": {
"type": "string",
"description": "最近到诊业务日期 YYYY-MM-DD",
"nullable": true
},
"lastVisitDoctor": {
"type": "string",
"nullable": true
},
"preferredDoctor": {
"type": "string",
"nullable": true
},
"doNotContact": {
"type": "boolean",
"description": "无业务属性记录时为 null,不能当成 false",
"nullable": true
},
"doNotContactReason": {
"type": "string",
"nullable": true
},
"deceased": {
"type": "boolean",
"nullable": true
},
"hostFollowUpActive": {
"type": "boolean",
"nullable": true
},
"tags": {
"type": "array",
"items": {
"type": "string"
"dedicatedCs": {
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "宿主侧专属客服标识,不是 PAC 用户 ID",
"nullable": true
},
"name": {
"type": "string",
"description": "专属客服姓名",
"nullable": true
}
},
"description": "null=无业务属性记录;[]=已存标签为空",
"required": [
"id",
"name"
],
"additionalProperties": false,
"description": "当前专属客服;未提供有效标识或姓名时为 null",
"nullable": true
},
"acquisitionChannel": {
"type": "string",
"description": "获客一级渠道:walk_in=走入、word_of_mouth=口碑客户、group_sales=集团销售、group_marketing=集团营销、regional_marketing=地区营销、ecommerce=电商、social_media=自媒体网络、employee=内部员工、other=其他;开放字符串,未提供为 null",
"nullable": true
},
"acquisitionSub": {
"type": "string",
"description": "获客二级渠道,保留宿主原值;未提供时为 null",
"nullable": true
}
},
......@@ -9447,71 +9433,18 @@
"phone",
"gender",
"birthDate",
"preferences",
"lastVisitAt",
"lastVisitDoctor",
"preferredDoctor",
"doNotContact",
"doNotContactReason",
"deceased",
"hostFollowUpActive",
"tags",
"dedicatedCs",
"acquisitionChannel",
"acquisitionSub"
],
"additionalProperties": false
},
"persona": {
"type": "object",
"properties": {
"version": {
"type": "integer",
"minimum": -9007199254740991,
"maximum": 9007199254740991
},
"computedAt": {
"type": "string"
},
"refreshedAt": {
"type": "string",
"nullable": true
},
"features": {
"type": "array",
"items": {
"type": "object",
"properties": {
"key": {
"type": "string"
},
"description": {
"type": "string"
},
"score": {
"type": "number",
"nullable": true
},
"data": {}
},
"required": [
"key",
"description",
"score",
"data"
],
"additionalProperties": false
}
}
"type": "array",
"items": {
"type": "string"
},
"required": [
"version",
"computedAt",
"refreshedAt",
"features"
],
"additionalProperties": false,
"description": "最近已保存的当前画像,不触发重算;未形成时为 null",
"nullable": true
"description": "当前已保存画像的展示标签;多值展开、去空去重,按展示顺序返回;无画像或无可展示标签为 [],查询不重算。文案不是枚举,不用于业务判断"
},
"relations": {
"type": "array",
......@@ -9520,7 +9453,7 @@
"properties": {
"relationship": {
"type": "string",
"description": "对方是本人的什么关系,按 PAC 已存方向返回"
"description": "对方是本人的关系:spouse=配偶、father=父亲、mother=母亲、child=子女、grandparent=祖父母/外祖父母、grandchild=孙辈/外孙辈、sibling=兄弟姐妹、friend=朋友、other=其他;开放字符串,兼容未知值,不推断反向关系"
},
"patient": {
"type": "object",
......@@ -9550,6 +9483,7 @@
},
"gender": {
"type": "string",
"description": "开放文本;常见 male=男、female=女,也可能保留男/女等宿主原值;未知为 null",
"nullable": true
},
"birthDate": {
......@@ -9584,9 +9518,11 @@
"items": {
"type": "object",
"properties": {
"subjectId": {
"id": {
"type": "string",
"description": "来源事实主体标识,跨版本稳定;仅作溯源,不能单独当全局唯一键"
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
"description": "PAC 事实版本 ID;同一业务事实产生新版本时 ID 会变化"
},
"type": {
"type": "string",
......@@ -9596,10 +9532,6 @@
"type": "string",
"description": "actual=实际记录;planned=计划或建议"
},
"status": {
"type": "string",
"description": "事实生命周期:本接口仅 active/fulfilled;业务进度看对应 content 字段,不代表疾病是否治愈"
},
"clinicId": {
"type": "string",
"nullable": true
......@@ -9610,52 +9542,27 @@
},
"occurredAt": {
"type": "string",
"description": "实际发生时刻,UTC ISO 8601;未知为 null",
"description": "业务发生时间;建议/治疗计划为提出或制定时间,预约为宿主创建时间,取不到留 null;实际到诊由 encounter_record 表达。UTC ISO 8601,未知 null;其他类型沿用已存业务时间,不保证等于源记录创建时间",
"nullable": true
},
"plannedFor": {
"type": "string",
"description": "计划或建议的时间锚,非完成证明;UTC ISO 8601",
"nullable": true
},
"validUntil": {
"type": "string",
"description": "有效期截止时刻;null=未提供,不能推断永久有效",
"nullable": true
},
"title": {
"type": "string",
"nullable": true
},
"summary": {
"type": "string",
"description": "明确约定的执行时间;当前仅预约返回约定时间,建议和治疗计划无明确执行日期时为 null。UTC ISO 8601",
"nullable": true
},
"content": {
"description": "按事实类型定义的标准化内容,已脱敏"
},
"steps": {
"type": "array",
"items": {
"type": "string"
},
"description": "PAC 标准治疗步骤,仅 treatment_record 有值;其他类型 [],允许新增步骤"
"description": "按 type 定义的脱敏内容;已知字段统一 camelCase、本记录源标识为 externalId、完整承接的重复时间省略。歧义关联/金额不合并,未知扩展及冲突原值保留;详见 content 字段参考"
}
},
"required": [
"subjectId",
"id",
"type",
"kind",
"status",
"clinicId",
"clinicName",
"occurredAt",
"plannedFor",
"validUntil",
"title",
"summary",
"content",
"steps"
"content"
],
"additionalProperties": false
}
......
......@@ -16,6 +16,8 @@
"prisma:deploy": "prisma migrate deploy",
"prisma:studio": "prisma studio",
"prisma:seed": "ts-node --transpile-only prisma/seed.ts",
"meyer-imaging": "ts-node --transpile-only src/cli/meyer-imaging.cli.ts",
"meyer-imaging:prod": "node dist/cli/meyer-imaging.cli.js",
"sync": "ts-node --transpile-only src/cli/sync-incremental.cli.ts",
"sync:prod": "node --max-old-space-size=8192 dist/cli/sync-incremental.cli.js",
"sync:once": "ts-node --transpile-only src/cli/sync-incremental.cli.ts",
......
import 'reflect-metadata';
import { watchMeyer, withRoundDeadline } from '../integrations/meyer/watch';
import { runMeyerTick } from '../integrations/meyer/scheduler';
import { readFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { NestFactory } from '@nestjs/core';
import { loadMeyerScopes } from '../integrations/meyer/org-mapping';
import { ConfigSchema, InputConfigSchema, hash, imageKey } from '../integrations/meyer/contracts';
import { MeyerClient } from '../integrations/meyer/client';
import { collectMeyer } from '../integrations/meyer/collector';
import { loadDiagnosisCodes } from '../integrations/meyer/mapper';
import { MeyerStore, type SavedBundle } from '../integrations/meyer/store';
import type { MeyerSink } from '../integrations/meyer/contracts';
export function parseMeyerArgs(argv: string[]) {
const args = { config: '', state: '', apply: false, help: false, tick: false, watch: false, imageFiles: '' };
for (const a of argv) {
if (a === '--') continue;
if (a === '--help' || a === '-h') args.help = true;
else if (a.startsWith('--image-files=')) args.imageFiles = a.slice(14);
else if (a === '--tick') args.tick = true;
else if (a === '--watch') { args.watch = true; args.tick = true; }
else if (a === '--apply') args.apply = true;
else if (a.startsWith('--config=')) args.config = a.slice(9);
else if (a.startsWith('--state-dir=')) args.state = a.slice(12);
else throw new Error('unknown Meyer CLI argument');
}
if (!args.help && (!args.config || !args.state)) throw new Error('--config and --state-dir are required');
if (args.imageFiles && (args.tick || args.apply)) throw new Error('--image-files is read-only and cannot combine with apply/tick');
if (args.tick && !args.apply) throw new Error('--tick/--watch requires --apply; preview cannot advance cursor');
return args;
}
export async function runMeyerCli(argv: string[]): Promise<number> {
const args = parseMeyerArgs(argv);
if (args.help) {
console.log('meyer-imaging --config=<json> --state-dir=<private-directory> [--apply] [--tick | --watch] [--image-files=<imageId>]\n' +
'Default: collect and validate only. --apply writes existing Friday patients through canonical fact parsers.\n' +
'Required env: MEYER_APP_KEY, MEYER_APP_SECRET; DATABASE_URL is required for automatic organization mapping, including preview.\n' +
'Default fixed window; --tick advances private cursor, --watch uses configured interval (default 15 minutes). No reconciliation, deletion, main scheduler, or recall recomputation.');
return 0;
}
const parsed = InputConfigSchema.safeParse(JSON.parse(await readFile(resolve(args.config), 'utf8')));
if (!parsed.success) throw new Error('invalid Meyer config; check required fields, scope, dates and HTTPS base URL');
const input = parsed.data;
// watch 的每轮重新读取组织数据;映射变化会被既有 state identity 拦截。
const { clinicIds, ...fields } = input;
const config = ConfigSchema.parse({ ...fields, scopes: await loadMeyerScopes(clinicIds) });
if (args.watch) {
const controller = new AbortController();
const stop = () => controller.abort();
process.once('SIGINT', stop); process.once('SIGTERM', stop);
try {
await watchMeyer(config, () => withRoundDeadline(() => runMeyerCli([...argv.filter(a => a !== '--watch'), '--tick']), config.roundTimeoutMs), controller.signal);
return 0;
} finally { process.removeListener('SIGINT', stop); process.removeListener('SIGTERM', stop); }
}
const appKey = process.env.MEYER_APP_KEY ?? '';
const client = new MeyerClient(config, { appKey, secret: process.env.MEYER_APP_SECRET ?? '' });
const codes = loadDiagnosisCodes(resolve(__dirname, '../../data/_shared/dict/diagnosis-kcode.yaml'));
const store = new MeyerStore(resolve(args.state));
const release = await store.acquire({ baseUrl: config.baseUrl.replace(/\/$/, ''), appKey,
namespace: config.namespace, host: config.host, timezone: config.timezone,
scopes: [...config.scopes].sort((a, b) => a.clinicId.localeCompare(b.clinicId)) });
let app: Awaited<ReturnType<typeof NestFactory.createApplicationContext>> | undefined;
try {
if (!process.env.DATABASE_URL) throw new Error('DATABASE_URL required for organization mapping');
const db = new URL(process.env.DATABASE_URL);
const target = hash([db.hostname, db.port, db.pathname, db.searchParams.get('schema') ?? 'public']);
const previous = await store.read<{ target: string }>('database');
if (previous && previous.target !== target) throw new Error('state directory belongs to another PAC database');
if (!previous) await store.write('database', { target });
if (args.imageFiles) {
const matches: Array<{ key: string; saved: SavedBundle }> = [];
for (const scope of config.scopes) {
const key = imageKey(scope, args.imageFiles);
const saved = await store.read<SavedBundle>(`bundle-${key}`);
if (saved) matches.push({ key, saved });
}
if (matches.length !== 1) throw new Error('requires exactly one collected image in configured scope');
const { key, saved } = matches[0]!;
const image = saved.bundle.raw.image;
if (image.imageId !== args.imageFiles || saved.bundle.namespace !== config.namespace ||
!config.scopes.some(s => hash(s) === hash(saved.bundle.scope))) throw new Error('saved file scope mismatch');
if (!image.imageOssId) throw new Error('original file ID unavailable');
const urls = await client.fileUrls(image.clinicId, [image.imageOssId]);
await store.write(`access-${key}`, { fetchedAt: new Date().toISOString(), urls });
console.log(JSON.stringify({ mode: 'image-files', found: urls[0]!.found, expiresAt: urls[0]!.expiresAt,
privateStateKey: `access-${key}` }));
return urls[0]!.found ? 0 : 2;
}
let sink: MeyerSink | undefined;
if (args.apply) {
const { MeyerCliModule } = await import('../integrations/meyer/meyer-cli.module');
const { MeyerPacSink } = await import('../integrations/meyer/pac-sink');
app = await NestFactory.createApplicationContext(MeyerCliModule, { logger: false, abortOnError: false });
sink = app.get(MeyerPacSink);
}
if (args.tick) {
const status = await runMeyerTick(config, c => client.forWindow(c), store, codes, sink!);
console.log(JSON.stringify({ mode: 'tick', pending: status.pending, retryAttempted: status.retryAttempted,
retryFailed: status.retryFailed, scanned: status.scan?.examined ?? 0, scanComplete: status.scan?.complete ?? false, scanFailed: status.scan?.failed ?? 0 }));
return status.retryFailed || status.scan?.failed ? 1 : status.pending ? 2 : 0;
}
const result = await collectMeyer(config, client, store, codes, sink);
// 控制台只输出数量;原文、患者标识和逐条错误留在私有状态目录。
const { errors: _errors, ...counts } = result;
console.log(JSON.stringify({ mode: args.apply ? 'apply' : 'preview', ...counts }));
return result.failed ? 1 : result.deferred ? 2 : 0;
} finally {
try { await app?.close(); } finally { await release(); }
}
}
if (require.main === module) {
runMeyerCli(process.argv.slice(2)).then(code => { process.exitCode = code; }).catch(() => {
console.error('Meyer collector failed; check configuration/connectivity and private state directory.');
process.exitCode = 1;
});
}
## 组织映射(当前行为)
配置只填写 `clinicIds` 采集范围,不再填写 `scopes`、`tenantId` 或 `sourceUnit`;旧格式直接拒绝。
每轮从 PAC Friday 宿主的既有业务记录自动解析 `tenantId + sourceUnit`。使用与 PAC 组织树相同的
`patient_transactions.clinic_id → patients.source_unit` 数据来源,但检查所有不同候选,不沿用组织树的单条抽样。
查询限定 Friday 宿主,患者关联同时校验宿主和租户;排除美亚自身产物。名字典不参与归属判断。
缺失、空值、孤立关联、跨品牌或跨租户冲突均停止本轮,不调用美亚、不推进游标、不猜测匹配。
需先让正常宿主摄入提供有效组织证据。这里验证的是 PAC 已有数据的一致性,不能替代 SaaS 权威组织表证明其本身正确。
映射每轮重新读取;与已有状态目录的映射不一致时拒绝继续,保护旧快照和断点,需核查原因后处理,不能自动迁移患者。
只读查询有 10 秒语句超时和 15 秒事务上限,失败保留断点供后续重试。
预览和原片链接查询也需要 `DATABASE_URL`(组织查询只读);状态目录同时绑定数据库身份。
账号密钥、采集诊所范围仍需配置,这是授权范围,不是人工维护品牌映射。
# FridaySaaS 美亚影像补充采集(首期)
## 设计边界
独立 CLI → 美亚只读接口 → 私有目录留底与标准化 → PAC 已有事实解析/写入内核。
- 只补 FridaySaaS 宿主现有患者的影像诊断信号,不新增宿主、患者或归属规则。
- 独立进程、凭据、诊所范围和状态目录,不注册到 AppModule,不修改 Push/Pull、主摄入游标、画像算法或召回规则。
- 本期不做对账:只接入首次读到的有效牙位结论;已成功接入的影像不复查分析、不处理修改/删除/撤回/改绑。发现已留底影像改绑则报错,等待后续对账能力。
- 默认固定窗口;`--tick` 执行一轮独立增量任务,`--watch` 每轮完成后按配置间隔(默认 15 分钟)再运行。逐诊所分页、串行限速,使用独立水位和待处理队列。
- 不直接触发画像或召回计算;新事实进入现有事务/事实水位,由已启用的 PAC 既有 stale-scan 等任务消费。该任务未启用的本地环境不会自动重算。
## 标准字段与时间
| 美亚 | PAC 标准字段 |
|---|---|
| PAC 业务记录自动解析诊所 → tenantId/sourceUnit | Friday 既有隔离范围;患者按完整身份键匹配 |
| patientId / clinicId | patientExternalId / clinicId;空患者不猜测 |
| imageId | `meyer:<namespace>:<imageId>`,避免与旧影像 ID 撞号 |
| imageCreateTime / modality | image_study.uploadedAt / modality |
| teeth[].position / content | diagnosis.toothPosition / name |
| studyTime | diagnosis.occurredAt,表示影像证据的检查时刻 |
| 美亚整词补充映射 + 所见保护 + 共享诊断字典精确命中 | diagnosis.code;未知、可疑或长文本保留原文和空码,不使用含词推断 |
| 影像来源 | diagnosis.codeSource=image_ai、sourceImageExternalId |
病症映射仅在本适配器内补充:缺牙→K08,龋病/牙龋坏→K02,牙折裂→K03。裸词“智齿”保留空码,不能沿用共享字典中医生录入习惯的“智齿→阻生”;明确的阻生智齿仍按原字典处理。修复物、治疗痕迹及暂不适合现有召回类别的所见保留原文,不推断为疾病或已完成治疗。完整 28 项盘点及风险见 [映射核对](../../../../../docs/qa/2026-10-19-meyer-diagnosis-mapping.md)。
映射更新只影响新生成的 bundle;已经留底或已应用的快照仍按原样重试/跳过,不会自动改写历史事实。不要删除旧状态目录来强制重跑;存量修正需另行设计并核对事务哈希。
美亚没有提供分析完成状态、分析时间和版本。本期不伪造 `completed/analyzedAt`,不绕过既有 image_analysis 的完整快照要求;将已观察到的牙位结论转换为现有 `image_study`、`diagnosis`,使用原有解析器和 FactWriter。**这是检查时刻的影像发现,不是医生诊断或美亚分析完成时刻。** 原文、观察时间与 `timeBasis=studyTime` 保存在适配器文件和事务 rawPayload 中。
没有检查时间、没有入库时间、空 teeth 或未绑定患者只留底待处理。综合诊断/描述不拆分为新诊断。临时 URL 不写入长期影像地址;原片沿用宿主患者级入口。本期不新增 UI;若宿主使用单张影像模板,须先确认其能识别此来源的影像标识。
## 运行
从 `apps/pac-service` 执行。凭据只从 `MEYER_APP_KEY`、`MEYER_APP_SECRET` 环境变量读取;必须具备 SaaS 已同步诊所及患者所属应用的读取权限。`baseUrl` 使用 HTTPS,localhost HTTP 仅供本地模拟服务测试。
复制 `config.example.json` 到仓库外,填入允许采集的 clinicIds 和时间范围;namespace 是稳定的应用来源名,不要随运行改变。状态目录按环境和应用隔离,包含患者数据,应受权限及备份策略保护。
```sh
# 默认只读 PAC 组织关系,访问美亚、留底并生成标准字段;不写 PAC 业务表。
pnpm meyer-imaging -- --config=/absolute/meyer.json --state-dir=/absolute/private/meyer-test
# 明确指定本地 PAC 的 DATABASE_URL 后才可写入。
pnpm meyer-imaging -- --config=/absolute/meyer.json --state-dir=/absolute/private/meyer-test --apply
```
退出码:0=所扫范围无失败或待处理;1=请求/校验/写入失败;2=仍有待处理记录。0 不代表美亚全部历史数据或所有分析均已同步。终端只输出计数;细项见 `last-run.json`,原始响应见 `observed-*`,标准字段及应用状态见 `bundle-*`。
成功标记在所有事实落库后才写入。部分失败重跑同窗,使用已留底快照恢复;事务身份稳定,重复写入不新增事实。状态目录有进程锁;明确属于本机已退出进程的锁可自动回收。存活进程、其他机器、未知 owner 或回收操作本身中断的锁不自动抢占。更换范围映射、应用或 PAC 数据库须使用独立目录;单独扩大时间窗可复用原目录。
不要把补充记录交给 Friday 原表 YAML 的通用 reparse;本路线以自己的留底快照重试。首次 preview 留底后,apply 使用同一快照,不重新解释为最新结果。已应用文件不可直接改写或删除来模拟对账。
## 本期取舍与上线前核实
- 已用测试患者完成真实只读联调:PDF 三项身份参数可用,分析可落库,文件 ID 可换取 OSS 签名地址。详见 QA 记录。
- 无更新游标、完整快照分页和删除标记,无法承诺完整生命周期同步;分页数量变化、重复/缺页会报错,重跑固定窗口。只在空响应和缺字段时延后,不能把它们当作阴性结论。
- 宿主若以后推送同一批美亚影像,必须先统一来源标识和接入责任,避免与本路线重复。首期应只开明确授权的试点诊所。
- 本期不部署。开发回归使用本地 HTTP 与独立 PostgreSQL 数据库;真实联调只读源端、仅写隔离的本地验证库。
## 独立增量与待分析重试
```sh
# 一轮任务,可由外部定时器调用;必须 --apply 才允许推进水位。
pnpm meyer-imaging -- --config=/absolute/meyer.json --state-dir=/absolute/private/meyer-test --apply --tick
# 独立进程,默认每轮结束后等 15 分钟;不会注册 PAC 主服务定时器。
pnpm meyer-imaging -- --config=/absolute/meyer.json --state-dir=/absolute/private/meyer-test --apply --watch
```
首次从配置 timeStart 开始,后续使用私有 cursor。tick/watch 的结束时间由当前时间决定(固定窗口 timeEnd 仅用于默认单次采集),每轮最多推进 1 小时,落后当前时刻至少 1 分钟,前后窗口重叠 1 分钟。时间窗口不是分析更新时间,不保证捕获任意历史修订。
- 新影像没有分析时先持久化到 pending/,之后允许扫描水位前进。分页、身份校验或落库失败时不推进。
- 每轮先处理最早到期的最多 10 个(可配置)待处理项,按 1 分钟起指数退避、最多 1 小时;不会因重试次数多而自动丢弃。
- 重试通过原影像入库时间前后 1 秒的窄窗重新定位 imageId,再获取分析。不会扫描整个诊所历史。身份改绑、影像不可见均保留待处理并报错,不自动撤销或迁移事实。
- 因接口没有按 imageId 直接查询元数据的能力,缺少入库时间的条目不能自动定位,须人工处理;这类情况会有 retryFailed。
- scheduler-status.json 提供扫描结果、水位、待处理数、重试失败数。watch 遇网络、美亚或数据库异常会记录失败计数并退避,默认从 15 分钟增长到最多 1 小时;依赖恢复后继续,不丢弃失败数据。配置错误、损坏状态或归属冲突需要修复后才能恢复。SIGINT/SIGTERM 会等当前一轮收尾再停止。
- 已成功影像不重新查询分析。这是首次接入及晚到分析重试,不是历史对账;删除、撤回和成功后的修改仍不覆盖。
- 状态目录必须持久化。先前仅有 deferred-* 的旧目录没有完整队列,升级后先按原窗口重跑一次再启动 tick/watch。不要用曾做远期手工采集的目录直接推断历史已覆盖。
## 原图地址按需刷新
```sh
# 针对已经留底的影像,只读校验 PAC 组织映射并获取原图临时地址;不下载原图。
pnpm meyer-imaging -- --config=/absolute/meyer.json --state-dir=/absolute/private/meyer-test --image-files=<美亚imageId>
```
从 bundle 中取原图 imageOssId,并使用对应诊所调用美亚 images/urls。签名 URL 写入私有 access-*.json,终端只显示 found、expiresAt 和文件键,不输出签名。每次执行重新获取,不能把过期地址当作长期业务数据。当前命令只刷新原图,不批量获取 CT 切片。
这是运维 CLI 能力,不是公开下载接口;未来若接前端,必须先按宿主患者权限授权,再调用访问解析。美亚 URL 接口只校验诊所文件归属,不能替代患者级权限。
## 保守配置、断点与自愈
| 配置 | 默认值 | 含义 |
|---|---:|---|
| roundTimeoutMs | 600000 | watch 单轮最多 10 分钟;超时退出由外部守护重启 |
| pollIntervalMs | 900000 | 一轮结束后等 15 分钟 |
| maxNewImagesPerTick | 100 | 所有配置诊所合计最多处理 100 条扫描记录;含重复/待处理项 |
| maxRetriesPerTick | 10 | 每轮最多重试 10 张待处理影像 |
| requestIntervalMs | 500 | 串行请求间隔至少 500ms |
| maxListPagesPerTick | 10 | 新影像扫描每轮最多查询 10 页(含续跑核对);待处理项逐项也受此上限约束 |
| errorBackoffMaxMs | 3600000 | 连续异常最长等待 1 小时 |
| pageSize | 100 | 每次列表响应最多 100 张;不是每轮处理限额 |
`--tick/--watch` 使用上述每轮限额;默认固定窗口单次命令仍用于明确范围的手动采集,不受每轮限额控制。修改频率后重启 watch 生效;扫描限额会在每轮重新读取配置。所有调用仍由主进程串行执行,没有并发轮次。
scan-progress 保存固定窗口、诊所、页码、页内位置和当前页原文;scan-ledger 保存已完成页的影像 ID。达到限额不推进 cursor,下一轮从断点继续。写文件先 sync 再原子替换,并同步目录;记录事实成功后才移动行断点。若数据库成功而进程在记断点前退出,下次幂等重试,不重复生成事实。
续跑会重新核对第一页和当前未完成页的数量/身份指纹。检测到插入、删除或顺序变化就重扫当前诊所的固定窗口;跨页重复也阻止推进并重扫。签名 URL 变化不影响身份指纹。**美亚没有快照游标:无法承诺任意历史回填、窗口完成后更早时间的插入或不可见删除都不遗漏。** 当前机制保证自身限额和中断不跳过数据,历史任意变化仍属于尚未实现的对账范围。
| 故障 | 处理 |
|---|---|
| 美亚/数据库暂不可用、请求超时 | 本轮保留断点;watch 退避后重试,恢复后续跑 |
| 进程被杀、机器重启 | 由 systemd/容器重启策略拉起;本机明确死亡的进程锁自动回收 |
| 患者尚未进入 PAC | 不创建患者,等待源患者准备好后重试 |
| 改绑、字段不合法、权限失效 | 不跳过或篡改;记录状态,需修复根因后恢复 |
| 状态文件损坏、磁盘满 | 不推进水位;修复磁盘/恢复备份后继续 |
提供 `meyer-imaging.service.example` 作为 Linux 进程守护模板,本次没有安装或启用。进程自身无法在被 SIGKILL/OOM 杀死后自我重启,必须有外部守护。watch 还有单轮看门狗:数据库请求等卡住超过默认 10 分钟时退出,避免旧轮尚未停止就并发启动新轮;重启后使用持久断点恢复。
### 存储分工
- **独立状态目录**:identity、数据库目标指纹、cursor、scan-progress、scan-ledger、pending、bundle、observed、scheduler-status、access 文件;本期不新增数据库采集表。状态目录须放持久磁盘、限制权限、备份。只支持单机单写者,不用于多机器共享抢任务。
- **PAC 共用表**:标准化事件写 patient_transactions,标准影像/诊断写 patient_facts,按 Friday 宿主、租户、品牌与患者隔离;patients 只查找,不创建或移动。原始美亚依据还保留在事务 rawPayload。
- **原片**:仍在美亚/OSS,本地只留文件 ID 和业务证据,不复制文件。
独立目录丢失不能靠自动重启恢复采集进度;可恢复备份或人工重扫明确窗口。PAC 已落库事实不因此删除。
## 影像内的分析快照
新采集的影像在 `image_record.content.analysis` 留下 `source`、`summary`、`description`、`analyzedAt`、`findings`。这是 PAC 通用可选结构,不是供应商原始响应。美亚未提供分析完成时间,`analyzedAt` 为 null;综合结论与描述保留原文,不拆分或推断成新诊断。
`findings` 与原有 `diagnosis_record(code_source=image_ai)` 使用同一份去重及标准码映射结果;后者继续作为既有诊断信号。开放患者查询会返回影像分析,诊断事实也继续返回,两处不能重复计数。不新增独立分析事实,不更改画像或召回规则。
5i5ya 可使用同一结构,但当前映射只把分析关联到病历,没有可靠的单张影像标识,因此不填影像 `analysis`。缺失/null 不等于源端没有分析数据。
本次不补写历史:旧 bundle 缺失 `analysis` 时继续按原快照重试,已应用的快照继续跳过;旧事实不自动增加字段。丢失状态后对已入库影像生成不同快照,仍按原有哈希检查拒绝覆盖。不得删除状态目录来强制升级历史。
import { setTimeout as delay } from 'node:timers/promises';
import { z } from 'zod';
import type { MeyerConfig, MeyerImage, MeyerScope, MeyerSource } from './contracts';
const PageSchema = z.object({ total: z.number().int().nonnegative(), page: z.number().int().positive(),
size: z.number().int().min(1).max(100), images: z.array(z.unknown()).max(100) });
const TokenSchema = z.object({ accessToken: z.string().min(1), accessExpiresAt: z.string().datetime({ offset: true }) });
/** 只实现三种只读操作;不接入 PAC 主服务,不调用美亚写接口。 */
export class MeyerClient implements MeyerSource {
constructor(private readonly config: MeyerConfig,
private readonly credentials: { appKey: string; secret: string },
private readonly session: { token?: { value: string; expires: number } } = {}) {
if (!credentials.appKey.trim() || !credentials.secret.trim()) throw new Error('Meyer credentials required');
}
forWindow(config: MeyerConfig): MeyerClient {
if (config.baseUrl !== this.config.baseUrl) throw new Error('cannot reuse auth across endpoints');
return new MeyerClient(config, this.credentials, this.session);
}
private async request(path: string, body: unknown, token?: string): Promise<{ code: number; data?: unknown }> {
await delay(this.config.requestIntervalMs);
let response: Response;
let text: string;
try {
response = await fetch(`${this.config.baseUrl.replace(/\/$/, '')}${path}`, {
method: 'POST', redirect: 'error', signal: AbortSignal.timeout(this.config.timeoutMs),
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
body: JSON.stringify(body),
});
// 限制异常响应大小,日志不得输出令牌、原文或患者信息。
const reader = response.body?.getReader();
const chunks: Uint8Array[] = [];
let length = 0;
if (reader) for (;;) {
const next = await reader.read();
if (next.done) break;
length += next.value.byteLength;
if (length > 4 * 1024 * 1024) { await reader.cancel(); throw new Error('oversized response'); }
chunks.push(next.value);
}
text = Buffer.concat(chunks).toString('utf8');
} catch { throw new Error(`Meyer transport failed: ${path}`); }
if (response.status === 401) return { code: 401 };
if (!response.ok) throw new Error(`Meyer HTTP ${response.status}: ${path}`);
try {
return z.object({ code: z.number().int(), data: z.unknown().optional() }).parse(JSON.parse(text));
} catch { throw new Error(`Meyer invalid envelope: ${path}`); }
}
private async getToken(): Promise<string> {
if (this.session.token && this.session.token.expires > Date.now() + 30000) return this.session.token.value;
const result = await this.request('/iam/external/oauth/tokens/pub', {
appKey: this.credentials.appKey, secret: this.credentials.secret, clientType: 'EXTERNAL_APP',
});
if (result.code !== 200) throw new Error(`Meyer authentication code=${result.code}`);
const token = TokenSchema.safeParse(result.data);
if (!token.success || Date.parse(token.data.accessExpiresAt) <= Date.now()) throw new Error('Meyer invalid token response');
this.session.token = { value: token.data.accessToken, expires: Date.parse(token.data.accessExpiresAt) };
return this.session.token.value;
}
private async query(path: string, body: unknown): Promise<unknown> {
let result = await this.request(path, body, await this.getToken());
if (result.code === 401) {
this.session.token = undefined;
result = await this.request(path, body, await this.getToken());
}
if (result.code !== 200) throw new Error(`Meyer business failure code=${result.code}: ${path}`);
return result.data;
}
async list(scope: MeyerScope, page: number) {
const raw = await this.query('/imaging/external/images/query-by-clinic', {
clinicId: scope.clinicId, timeStart: this.config.timeStart, timeEnd: this.config.timeEnd,
page, size: this.config.pageSize,
});
const result = PageSchema.safeParse(raw);
if (!result.success) throw new Error('Meyer invalid page response');
return result.data;
}
async fileUrls(clinicId: string, ossIds: string[]) {
if (!this.config.scopes.some(s => s.clinicId === clinicId)) throw new Error('file clinic outside configured scope');
const ids = z.array(z.string().trim().min(1).max(256)).min(1).max(100).parse(ossIds);
const raw = await this.query('/imaging/external/images/urls', { clinicId, ossIds: [...new Set(ids)] });
const result = z.object({ urls: z.array(z.object({ ossId: z.string(), found: z.boolean(),
url: z.string().url().nullable(), expiresAt: z.string().nullable() })) }).parse(raw);
if (result.urls.length !== new Set(ids).size || result.urls.some((x, i) => x.ossId !== [...new Set(ids)][i] ||
(x.found && (!x.url || new URL(x.url).protocol !== 'https:' || !x.expiresAt)))) {
throw new Error('invalid file URL response');
}
return result.urls;
}
symptom(image: MeyerImage): Promise<unknown> {
// PDF 比旧 SDK 多 patientId,按文档携带完整归属;上线前需实测服务端版本。
return this.query('/imaging/external/images/symptom', {
imageClinicId: image.clinicId, patientId: image.patientId, imageId: image.imageId,
});
}
}
import { join } from 'node:path';
import { ImageSchema, SymptomSchema, hash, imageKey, sourceTime,
type MeyerConfig, type MeyerSink, type MeyerSource, type MeyerImage, type MeyerScope } from './contracts';
import { mapMeyer } from './mapper';
import { MeyerStore, type SavedBundle } from './store';
export interface PendingImage {
scope: MeyerScope; image: MeyerImage; attempts: number; nextRetryAt: string;
}
export interface CollectResult {
pages: number; seen: number; collected: number; applied: number; alreadyApplied: number;
deferred: number; failed: number; errors: Array<{ key: string; reason: string }>;
}
/** 固定时间窗单次运行;不推进主流程游标,不删除、不改绑、不复查已完成影像。 */
export async function collectMeyer(config: MeyerConfig, source: MeyerSource, store: MeyerStore,
codes: Record<string, string>, sink?: MeyerSink, respectRetryAt = false): Promise<CollectResult> {
const pending = new MeyerStore(join(store.directory, 'pending'));
const result: CollectResult = { pages: 0, seen: 0, collected: 0, applied: 0,
alreadyApplied: 0, deferred: 0, failed: 0, errors: [] };
for (const scope of config.scopes) {
const seen = new Set<string>();
let total: number | undefined;
try {
for (let page = 1; ; page++) {
if (page > config.maxPages) throw new Error('page limit reached; narrow collection window');
const batch = await source.list(scope, page);
result.pages++;
if (batch.page !== page || batch.size !== config.pageSize || batch.images.length > batch.size) {
throw new Error('unexpected pagination contract');
}
if (total !== undefined && total !== batch.total) throw new Error('image count changed during pagination; rerun fixed window');
total = batch.total;
const expected = Math.min(batch.size, Math.max(0, total - (page - 1) * batch.size));
if (batch.images.length !== expected) throw new Error('incomplete page; no successful checkpoint');
for (const raw of batch.images) {
result.seen++;
let key = `page-${page}-row-${result.seen}`;
let retry: PendingImage | undefined;
let completed = false;
try {
const parsed = ImageSchema.safeParse(raw);
if (!parsed.success) throw new Error('invalid image contract');
const image = parsed.data;
key = imageKey(scope, image.imageId);
if (seen.has(image.imageId)) throw new Error('duplicate image across pages; rerun fixed window');
seen.add(image.imageId);
if (image.clinicId !== scope.clinicId) throw new Error('image clinic mismatch');
if (image.imageCreateTime && (sourceTime(image.imageCreateTime) < sourceTime(config.timeStart) ||
sourceTime(image.imageCreateTime) > sourceTime(config.timeEnd))) throw new Error('image outside requested window');
const previousRetry = await pending.read<PendingImage>(key);
if (respectRetryAt && previousRetry && Date.parse(previousRetry.nextRetryAt) > Date.now()) {
result.deferred++; continue;
}
retry = { scope, image, attempts: (previousRetry?.attempts ?? 0) + 1, nextRetryAt: '' };
const saved = await store.read<SavedBundle>(`bundle-${key}`);
if (saved && (hash(saved.bundle.scope) !== hash(scope) || saved.bundle.namespace !== config.namespace ||
saved.bundle.raw.image.imageId !== image.imageId || saved.bundle.version !== 1)) {
throw new Error('saved bundle scope mismatch');
}
if (saved && saved.bundle.image.patientExternalId !== image.patientId) {
throw new Error('image ownership changed; reconciliation is not supported');
}
if (saved?.applied) { completed = true; result.alreadyApplied++; continue; }
let bundle = saved?.bundle;
if (!bundle) {
const observedAt = new Date().toISOString();
if (!image.patientId) {
await store.write(`deferred-${key}`, { reason: 'unbound patient', observedAt, image });
result.deferred++; continue;
}
const rawSymptom = await source.symptom(image);
// 错误/空/无时间都留底,但不发布阴性结论,也不宣称分析完成。
await store.write(`observed-${key}`, { observedAt, image, symptom: rawSymptom });
const symptom = SymptomSchema.safeParse(rawSymptom);
if (!symptom.success) throw new Error('invalid symptom contract');
bundle = mapMeyer(config, scope, image, symptom.data, codes, observedAt) ?? undefined;
if (!bundle) {
await store.write(`deferred-${key}`, { observedAt,
reason: 'no tooth findings or missing image/study time' });
result.deferred++; continue;
}
// 写入前固定完整快照;失败重试使用同一快照,不混入后来变化的远端结果。
await store.write(`bundle-${key}`, { bundle, applied: false } satisfies SavedBundle);
}
result.collected++;
if (sink) {
await sink.apply(bundle);
await store.write(`bundle-${key}`, { bundle, applied: true } satisfies SavedBundle);
result.applied++;
}
completed = !!sink;
} catch (error) {
result.failed++;
result.errors.push({ key, reason: error instanceof Error ? error.message : 'collection failed' });
} finally {
if (retry) {
if (completed) await pending.remove(key);
else {
retry.nextRetryAt = new Date(Date.now() + Math.min(3600000, 60000 * 2 ** Math.min(6, retry.attempts - 1))).toISOString();
await pending.write(key, retry);
}
}
}
}
if (page * batch.size >= total) break;
}
} catch (error) {
result.failed++;
result.errors.push({ key: 'page-scan', reason: error instanceof Error ? error.message : 'scan failed' });
}
}
await store.write('last-run', { finishedAt: new Date().toISOString(), apply: !!sink,
window: [config.timeStart, config.timeEnd], ...result });
return result;
}
{
"baseUrl": "https://pacs.example.com/api/v1",
"namespace": "friday-meyer",
"host": "friday",
"timezone": "Asia/Shanghai",
"timeStart": "2026-10-01 00:00:00",
"timeEnd": "2026-10-16 23:59:59",
"pageSize": 100,
"maxPages": 1000,
"timeoutMs": 10000,
"requestIntervalMs": 500,
"roundTimeoutMs": 600000,
"pollIntervalMs": 900000,
"maxNewImagesPerTick": 100,
"maxRetriesPerTick": 10,
"maxListPagesPerTick": 10,
"errorBackoffMaxMs": 3600000,
"clinicIds": [
"replace-clinic-id"
]
}
import { createHash } from 'node:crypto';
import { z } from 'zod';
import type { DiagnosisCanonical, ImageStudyCanonical } from '@pac/types';
const id = z.string().trim().min(1).max(256);
export const ScopeSchema = z.object({ clinicId: id, tenantId: id, sourceUnit: id }).strict();
export type MeyerScope = z.infer<typeof ScopeSchema>;
// 本期只支持中国部署的裸时间格式;拒绝依赖执行机器的本地时区。
export function sourceTime(value: string): string {
const match = /^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}:\d{2})$/.exec(value);
if (!match) throw new Error('invalid Meyer datetime; expected YYYY-MM-DD HH:mm:ss');
const iso = `${match[1]}T${match[2]}+08:00`;
const date = new Date(iso);
if (!Number.isFinite(date.getTime()) ||
new Date(date.getTime() + 8 * 3600_000).toISOString().slice(0, 19) !== `${match[1]}T${match[2]}`) {
throw new Error('invalid Meyer calendar date');
}
return date.toISOString();
}
const sourceDate = z.string().refine(v => { try { sourceTime(v); return true; } catch { return false; } });
const ConfigFields = z.object({
baseUrl: z.string().url().refine(v => {
const u = new URL(v);
return !u.username && !u.password && !u.search && !u.hash &&
/\/api\/v1\/?$/.test(u.pathname) && (u.protocol === 'https:' ||
(u.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(u.hostname)));
}, 'requires HTTPS URL ending /api/v1 (HTTP only on localhost)'),
namespace: z.string().regex(/^[a-z0-9][a-z0-9_-]{0,63}$/),
host: z.literal('friday'),
timezone: z.literal('Asia/Shanghai'),
scopes: z.array(ScopeSchema).min(1).max(100),
timeStart: sourceDate,
timeEnd: sourceDate,
pageSize: z.number().int().min(1).max(100).default(100),
maxPages: z.number().int().min(1).max(10000).default(1000),
timeoutMs: z.number().int().min(100).max(60000).default(10000),
roundTimeoutMs: z.number().int().min(1000).max(86400000).default(600000),
pollIntervalMs: z.number().int().min(1000).max(86400000).default(900000),
maxNewImagesPerTick: z.number().int().min(1).max(10000).default(100),
maxRetriesPerTick: z.number().int().min(0).max(1000).default(10),
maxListPagesPerTick: z.number().int().min(2).max(1000).default(10),
errorBackoffMaxMs: z.number().int().min(1000).max(86400000).default(3600000),
requestIntervalMs: z.number().int().min(0).max(60000).default(500),
}).strict();
export const ConfigSchema = ConfigFields.superRefine((v, ctx) => {
if (v.errorBackoffMaxMs < v.pollIntervalMs) ctx.addIssue({ code: 'custom', message: 'errorBackoffMaxMs must be >= pollIntervalMs' });
try {
if (sourceTime(v.timeStart) > sourceTime(v.timeEnd)) ctx.addIssue({ code: 'custom', message: 'timeStart must not exceed timeEnd' });
} catch { /* 字段校验已报告无效时间。 */ }
if (new Set(v.scopes.map(s => s.clinicId)).size !== v.scopes.length) {
ctx.addIssue({ code: 'custom', message: 'each Meyer clinic must map to exactly one Friday scope' });
}
});
// 用户只选择采集诊所;租户和品牌必须由 PAC 现有数据解析,拒绝手填映射。
export const InputConfigSchema = ConfigFields.omit({ scopes: true }).extend({
clinicIds: z.array(id).min(1).max(100).refine(v => new Set(v).size === v.length, 'duplicate clinicIds'),
}).strict();
export type MeyerConfig = z.infer<typeof ConfigSchema>;
export const ImageSchema = z.object({
imageId: id, clinicId: id, patientId: id.nullable(),
imageCreateTime: sourceDate.nullable(), studyTime: sourceDate.nullable(),
studyId: id.optional().nullable(), bindingStatus: z.string().optional().nullable(),
modality: z.string().optional().nullable(), imageKind: z.string().optional().nullable(),
imageExt: z.string().optional().nullable(), imageOssId: z.string().optional().nullable(),
}).passthrough();
export type MeyerImage = z.infer<typeof ImageSchema>;
export const SymptomSchema = z.object({
teeth: z.array(z.object({
position: z.string().regex(/^(?:[1-4][1-8]|[5-8][1-5])$/),
content: z.string().trim().min(1).max(10000),
confidence: z.number().min(0).max(1).optional().nullable(),
}).passthrough()).max(1000),
diagnosis: z.string(), description: z.string(),
}).passthrough();
export type MeyerSymptom = z.infer<typeof SymptomSchema>;
export interface MeyerBundle {
version: 1;
namespace: string;
scope: MeyerScope;
observedAt: string;
// 分析状态/分析时刻未提供。诊断时间是影像检查时刻,绝不以采集时刻代替。
timeBasis: 'studyTime';
image: ImageStudyCanonical;
diagnoses: DiagnosisCanonical[];
raw: { image: MeyerImage; symptom: MeyerSymptom };
}
export interface MeyerSink { apply(bundle: MeyerBundle): Promise<void> }
export interface MeyerSource {
list(scope: MeyerScope, page: number): Promise<{ total: number; page: number; size: number; images: unknown[] }>;
symptom(image: MeyerImage): Promise<unknown>;
}
export function hash(value: unknown): string {
const stable = (v: unknown): unknown => Array.isArray(v) ? v.map(stable) :
v && typeof v === 'object' ? Object.fromEntries(Object.entries(v).sort(([a], [b]) => a.localeCompare(b))
.map(([k, x]) => [k, stable(x)])) : v;
return createHash('sha256').update(JSON.stringify(stable(value))).digest('hex');
}
export function imageKey(scope: MeyerScope, imageId: string): string { return hash([scope, imageId]); }
/**
* 美亚牙位识别标签 → PAC 现有业务 K 类别,不是完整 ICD 编码。
* 只做整词映射;影像所见不能套用医生诊断的含词推断。
* null 表示保留原文但不新增 K 码信号,不表示正常或无须处理。
*/
const MEYER_CODE_OVERRIDES: Readonly<Record<string, string | null>> = {
// 与既有缺失牙、龋齿、牙体损伤类别对齐,不进一步推断病因或严重度。
缺牙: 'K08',
龋病: 'K02',
牙龋坏: 'K02',
牙折裂: 'K03',
// 共享字典把医生记录的“智齿”按阻生处理;影像标签只证明识别到智齿。
智齿: null,
// 存在修复物/治疗痕迹,既不等于当前疾病,也不能推定治疗已完成。
修复体牙冠: null,
金属冠: null,
种植体: null,
充填体: null,
根充: null,
嵌体: null,
牙齿矫正: null,
// 所见或复合病变不足以安全进入当前按 K 类别组织的治疗召回路径。
牙槽骨吸收: null,
牙髓石: null,
种植体周围炎: null,
牙周牙髓联合病变: null,
颌骨病变: null,
骨结构不良: null,
骨岛: null,
};
export function resolveMeyerDiagnosisCode(content: string, sharedCodes: Record<string, string>): string | null {
// ⛔ 不去掉问号、不拆复合文本,避免把可疑/否定描述转成确定诊断。
const term = content.trim().replace(/[\s;。,;]+$/g, '');
if (Object.prototype.hasOwnProperty.call(MEYER_CODE_OVERRIDES, term)) {
return MEYER_CODE_OVERRIDES[term] ?? null;
}
return Object.prototype.hasOwnProperty.call(sharedCodes, term) ? sharedCodes[term]! : null;
}
import { readFileSync } from 'node:fs';
import { load } from 'js-yaml';
import { DiagnosisCanonicalSchema, ImageStudyCanonicalSchema, PACDiagnosisCodeSchema } from '@pac/types';
import { resolveMeyerDiagnosisCode } from './diagnosis-mapping';
import { hash, sourceTime, type MeyerBundle, type MeyerConfig, type MeyerImage, type MeyerScope,
type MeyerSymptom } from './contracts';
/** 加载共享临床精确字典;美亚的补充映射与所见保护在独立适配层处理。 */
export function loadDiagnosisCodes(file: string): Record<string, string> {
const raw = load(readFileSync(file, 'utf8')) as { enum_mapping?: { code?: Record<string, string> } };
const codes = raw?.enum_mapping?.code;
if (!codes || !Object.values(codes).every(c => PACDiagnosisCodeSchema.safeParse(c).success)) {
throw new Error('invalid shared diagnosis dictionary');
}
return codes;
}
export function mapMeyer(config: MeyerConfig, scope: MeyerScope, image: MeyerImage,
symptom: MeyerSymptom, codes: Record<string, string>, observedAt: string): MeyerBundle | null {
if (image.clinicId !== scope.clinicId) throw new Error('Meyer clinic differs from configured scope');
if (!image.patientId) return null;
const ext = `meyer:${config.namespace}:${encodeURIComponent(image.imageId)}`;
// 无检查时间不发临床信号;入库时间也不能冒充检查或分析时间。
const occurredAt = image.studyTime ? sourceTime(image.studyTime) : null;
if (!occurredAt || !image.imageCreateTime || !symptom.teeth.length) return null;
const uploadedAt = sourceTime(image.imageCreateTime);
if (occurredAt > observedAt || uploadedAt > observedAt) throw new Error('Meyer image has future time');
// 同牙位同内容去重,数组顺序不构成新身份。没有问题 ID,采用内容身份且只做首次接入。
const findings = [...new Map(symptom.teeth.map(t => [hash([t.position, t.content]), t])).entries()]
.sort(([a], [b]) => a.localeCompare(b));
const diagnoses = findings.map(([key, tooth]) => DiagnosisCanonicalSchema.parse({
externalId: `${ext}:finding:${key}`, patientExternalId: image.patientId, clinicId: scope.clinicId,
occurredAt, codeSource: 'image_ai', name: tooth.content, toothPosition: tooth.position,
code: resolveMeyerDiagnosisCode(tooth.content, codes),
sourceImageExternalId: ext,
// 不制造不存在的 image_analysis_record,也不把未知完成状态写成 completed。
}));
return { version: 1, namespace: config.namespace, scope, observedAt, timeBasis: 'studyTime',
image: ImageStudyCanonicalSchema.parse({ externalId: ext, patientExternalId: image.patientId,
// 美亚 IMAGE_2D / IMAGE 是文件类别,不能直接写入 PAC 影像类型枚举。
// 未明确识别的类型归 other,供应商原值完整保留在 raw.image 中。
clinicId: scope.clinicId, uploadedAt,
modality: ['pa', 'bw', 'pano', 'cbct', 'intraoral_photo', 'scan', 'other'].includes(image.modality ?? '')
? image.modality : 'other',
fileType: image.imageExt ?? null,
analysis: {
source: 'meyer', summary: symptom.diagnosis || null, description: symptom.description || null,
// 美亚当前没有提供分析完成时间,⛔ 不用检查时间或采集时间代替。
analyzedAt: null,
// 与既有诊断使用同一份去重、映射结果;不新增一套临床判断。
findings: diagnoses.map(d => ({ name: d.name, toothPosition: d.toothPosition ?? null, code: d.code ?? null })),
} }),
diagnoses, raw: { image, symptom } };
}
import { Module } from '@nestjs/common';
import { PrismaModule } from '../../prisma/prisma.module';
import { PipelineModule } from '../../modules/sync/pipeline/pipeline.module';
import { ClinicalSignalsModule } from '../../modules/clinical-signals/clinical-signals.module';
import { MeyerPacSink } from './pac-sink';
// 只供独立 CLI 使用;不得导入 AppModule,以免启动服务端 scheduler/queue workers。
@Module({ imports: [PrismaModule, ClinicalSignalsModule, PipelineModule], providers: [MeyerPacSink] })
export class MeyerCliModule {}
# 示例:部署时按实际路径/用户修改;本次未安装、未启用。
[Unit]
Description=PAC Meyer supplemental image collector
After=network-online.target
Wants=network-online.target
StartLimitIntervalSec=0
[Service]
Type=simple
User=pac-collector
WorkingDirectory=/opt/pac/apps/pac-service
EnvironmentFile=/etc/pac-meyer.env
ExecStart=/usr/bin/node dist/cli/meyer-imaging.cli.js --config=/etc/pac-meyer.json --state-dir=/var/lib/pac-meyer --apply --watch
Restart=on-failure
RestartSec=60
TimeoutStopSec=120
UMask=0077
NoNewPrivileges=true
MemoryMax=512M
CPUQuota=25%
[Install]
WantedBy=multi-user.target
import { PrismaClient, Prisma } from '@prisma/client';
import { ScopeSchema, type MeyerScope } from './contracts';
class MappingError extends Error {}
/** 与 PAC 组织树使用相同的数据来源,但检查所有候选,不采用 LIMIT 1 采样。 */
export async function resolveMeyerScopes(db: PrismaClient, clinicIds: string[]): Promise<MeyerScope[]> {
return db.$transaction(async tx => {
await tx.$executeRaw`SET TRANSACTION READ ONLY`;
await tx.$executeRaw`SET LOCAL statement_timeout = '10000ms'`;
const host = await tx.host.findUnique({ where: { name: 'friday' }, select: { id: true, active: true } });
if (!host?.active) throw new MappingError('Friday host missing or inactive');
const result: MeyerScope[] = [];
for (const clinicId of clinicIds) {
// LEFT JOIN 保留孤立/跨租户脏数据,让它们阻断解析而不是被悄悄忽略。
// 排除补充路线自己的输出,避免映射靠自身产物循环确认。
const rows = await tx.$queryRaw<MeyerScope[]>`
SELECT DISTINCT pt.tenant_id AS "tenantId", p.source_unit AS "sourceUnit", pt.clinic_id AS "clinicId"
FROM patient_transactions pt
LEFT JOIN patients p ON p.id = pt.patient_id AND p.host_id = pt.host_id AND p.tenant_id = pt.tenant_id
WHERE pt.host_id = ${host.id}::uuid AND pt.clinic_id = ${clinicId}
AND NOT (pt.raw_payload ? '_meyer')
AND COALESCE(pt.source_event_id, '') NOT LIKE 'supplement:%'
LIMIT 2
`;
if (rows.length !== 1 || !ScopeSchema.safeParse(rows[0]).success ||
rows[0]!.tenantId !== rows[0]!.tenantId.trim() || rows[0]!.sourceUnit !== rows[0]!.sourceUnit.trim()) {
throw new MappingError(`clinic mapping ${rows.length === 0 ? 'missing' : 'conflicting or invalid'}: ${clinicId}`);
}
result.push(rows[0]!);
}
return result;
}, { isolationLevel: Prisma.TransactionIsolationLevel.RepeatableRead, timeout: 15000 });
}
export async function loadMeyerScopes(clinicIds: string[]): Promise<MeyerScope[]> {
if (!process.env.DATABASE_URL) throw new Error('DATABASE_URL required for automatic clinic mapping (including preview)');
const db = new PrismaClient();
try { return await resolveMeyerScopes(db, clinicIds); }
catch (error) {
// 仅报告本模块生成的组织错误,不输出数据库连接串或原始异常。
console.error(JSON.stringify({ stage: 'organization-mapping', error:
error instanceof MappingError ? error.message : 'PAC organization lookup failed; retry after database recovery' }));
throw error;
} finally { await db.$disconnect(); }
}
import { Injectable } from '@nestjs/common';
import { Action, SubjectType, DiagnosisCanonicalSchema, ImageStudyCanonicalSchema } from '@pac/types';
import { PrismaService } from '../../prisma/prisma.service';
import { ParserPipeline } from '../../modules/sync/pipeline/parser-pipeline.service';
import { TransactionSynthesizer } from '../../modules/sync/pipeline/transaction-synthesizer';
import { hash, type MeyerBundle, type MeyerSink } from './contracts';
/** 标准字段边界之后才调用已有事实内核;不启动 Push/Pull、主档写入或召回任务。 */
@Injectable()
export class MeyerPacSink implements MeyerSink {
constructor(private readonly prisma: PrismaService, private readonly synthesizer: TransactionSynthesizer,
private readonly pipeline: ParserPipeline) {}
async apply(bundle: MeyerBundle): Promise<void> {
const host = await this.prisma.host.findUnique({ where: { name: 'friday' }, select: { id: true } });
if (!host) throw new Error('Friday host does not exist');
const { tenantId, sourceUnit, clinicId } = bundle.scope;
const image = ImageStudyCanonicalSchema.parse(bundle.image);
const diagnoses = bundle.diagnoses.map(d => DiagnosisCanonicalSchema.parse(d));
if (!diagnoses.length || diagnoses.some(d => d.patientExternalId !== image.patientExternalId ||
d.clinicId !== clinicId || d.sourceImageExternalId !== image.externalId) || image.clinicId !== clinicId) {
throw new Error('canonical bundle ownership mismatch');
}
const patient = await this.prisma.patient.findUnique({ where: {
hostId_tenantId_sourceUnit_externalId: { hostId: host.id, tenantId, sourceUnit,
externalId: image.patientExternalId },
}, select: { id: true } });
// 补充路线只补既有宿主患者,不凭美亚数据创建或移动患者。
if (!patient) throw new Error('patient not present in configured Friday scope');
const rows = [{ resource: 'image_study', row: image, action: Action.IMAGE_UPLOADED,
subjectType: SubjectType.IMAGE_STUDY, timeField: 'uploadedAt' },
...diagnoses.map(row => ({ resource: 'diagnosis', row, action: Action.DIAGNOSIS_RECORDED,
subjectType: SubjectType.DIAGNOSIS, timeField: 'occurredAt' }))];
for (const item of rows) {
const canonicalHash = hash(item.resource === 'image_study' ? { image, diagnoses } : item.row);
const rawRow = { ...item.row, _meyer: { namespace: bundle.namespace,
timeBasis: bundle.timeBasis, observedAt: bundle.observedAt, sourceUnit, canonicalHash,
image: bundle.raw.image, symptom: bundle.raw.symptom } };
const data = this.synthesizer.synthesize({ rawRow, canonicalRow: item.row,
emits: { action: item.action, subjectType: item.subjectType, occurredAtField: item.timeField, contentVersion: false },
resource: item.resource, hostId: host.id, tenantId, sourceUnit,
patientIdResolver: () => patient.id, sourceContext: `supplement:meyer:${bundle.namespace}` });
if (!data) throw new Error('canonical transaction rejected');
// 固定业务身份;重复提交只恢复未完成事实,不把不同内容静默当作重试。
await this.prisma.patientTransaction.createMany({ data: [data], skipDuplicates: true });
const transaction = await this.prisma.patientTransaction.findFirstOrThrow({ where: {
hostId: host.id, tenantId, sourceEventId: data.sourceEventId,
} });
const previous = transaction.rawPayload as { _meyer?: { canonicalHash?: string } };
if (transaction.patientId !== patient.id || transaction.clinicId !== clinicId ||
previous?._meyer?.canonicalHash !== canonicalHash) throw new Error('existing supplemental snapshot differs; reconciliation is not supported');
const metrics = await this.pipeline.runForTransaction({
transaction: { ...transaction, action: item.action }, canonicalRow: item.row,
});
if (!metrics.parserMatched || metrics.factsFailed) throw new Error('PAC fact write failed; retry retained bundle');
}
}
}
import { randomUUID } from 'node:crypto';
import { join } from 'node:path';
import { collectMeyer, type CollectResult } from './collector';
import { hash, type MeyerConfig, type MeyerScope, type MeyerSource, type MeyerSink } from './contracts';
import { MeyerStore } from './store';
type Page = Awaited<ReturnType<MeyerSource['list']>>;
export interface ScanProgress {
version: 1; generation: string; start: string; end: string; scopeIndex: number; page: number; row: number;
pageSize: number; total?: number; firstHash?: string; pageData?: Page;
}
const pageIdentity = (page: Page) => hash(page.images.map(raw => { const x = raw as Record<string, unknown>;
return [x.imageId, x.patientId, x.clinicId, x.imageCreateTime]; }));
function checkPage(batch: Page, page: number, size: number) {
if (batch.page !== page || batch.size !== size || !Number.isInteger(batch.total) || batch.total < 0 ||
batch.images.length !== Math.min(size, Math.max(0, batch.total - (page - 1) * size))) throw new Error('invalid scan page');
}
/** 已读取页先落盘;逐行成功/已持久化待处理后才移动页内位置。 */
export async function scanWindow(config: MeyerConfig, sourceFor: (c: MeyerConfig) => MeyerSource,
store: MeyerStore, codes: Record<string, string>, sink: MeyerSink, start: string, end: string) {
let progress = await store.read<ScanProgress>('scan-progress');
if (!progress) {
progress = { version: 1, generation: randomUUID(), start, end, scopeIndex: 0, page: 1, row: 0, pageSize: config.pageSize };
await store.write('scan-progress', progress);
}
if (progress.version !== 1 || !/^[a-f0-9-]{36}$/.test(progress.generation ?? '') || progress.start !== start || progress.end !== end ||
!Number.isInteger(progress.scopeIndex) || progress.scopeIndex < 0 || progress.scopeIndex > config.scopes.length ||
!Number.isInteger(progress.pageSize) || progress.pageSize < 1 || progress.pageSize > 100 ||
!Number.isInteger(progress.row) || progress.row < 0 || !Number.isInteger(progress.page) || progress.page < 1) {
throw new Error('invalid scan checkpoint; do not discard it');
}
// 固定窗口尚未结束时保留原分页大小,允许运维调整下一窗口的配置。
const window = { ...config, timeStart: start, timeEnd: end, pageSize: progress.pageSize };
const source = sourceFor(window);
const result: CollectResult & { complete: boolean; examined: number } = { pages: 0, seen: 0, collected: 0,
applied: 0, alreadyApplied: 0, deferred: 0, failed: 0, errors: [], complete: false, examined: 0 };
const limit = config.maxListPagesPerTick;
let validatedScope = -1;
const fetchPage = async (scope: MeyerScope, page: number) => {
result.pages++;
const batch = await source.list(scope, page); checkPage(batch, page, window.pageSize); return batch;
};
try {
while (progress.scopeIndex < config.scopes.length && result.examined < config.maxNewImagesPerTick) {
const scope = config.scopes[progress.scopeIndex]!;
if (validatedScope !== progress.scopeIndex) {
if (result.pages >= limit) break;
const first = await fetchPage(scope, 1);
const fingerprint = pageIdentity(first);
if (progress.total !== undefined && (progress.total !== first.total || progress.firstHash !== fingerprint)) {
// 新插入/删除/顺序变化可能使 offset 漂移;重扫当前诊所,不跳过变动行。
progress.generation = randomUUID(); progress.page = 1; progress.row = 0; progress.pageData = undefined;
}
if (progress.page > 1 && progress.pageData) {
if (result.pages >= limit) break;
const current = await fetchPage(scope, progress.page);
if (current.total !== first.total || pageIdentity(current) !== pageIdentity(progress.pageData)) {
progress.generation = randomUUID(); progress.page = 1; progress.row = 0; progress.pageData = undefined;
}
}
progress.total = first.total; progress.firstHash = fingerprint;
if (progress.page === 1 && !progress.pageData) progress.pageData = first;
await store.write('scan-progress', progress);
validatedScope = progress.scopeIndex;
}
if (!progress.pageData) {
if (result.pages >= limit) break;
if (progress.page > config.maxPages) throw new Error('scan maxPages reached; window retained');
const batch = await fetchPage(scope, progress.page);
if (batch.total !== progress.total) {
progress.generation = randomUUID(); progress.page = 1; progress.row = 0; progress.total = undefined; progress.firstHash = undefined;
await store.write('scan-progress', progress);
throw new Error('scan count changed; retry window');
}
progress.pageData = batch; await store.write('scan-progress', progress);
}
const page = progress.pageData;
checkPage(page, progress.page, window.pageSize);
if (progress.row > page.images.length) throw new Error('invalid scan row checkpoint');
const ledger = new MeyerStore(join(store.directory, 'scan-ledger', progress.generation, String(progress.scopeIndex)));
const ids = (raw: unknown) => String((raw as { imageId?: unknown }).imageId ?? '');
const seen = new Set<string>();
for (let n = 1; n < progress.page; n++) {
const prior = await ledger.read<string[]>(`page-${n}`);
if (!prior) throw new Error('missing scan page ledger; checkpoint retained');
for (const id of prior) seen.add(id);
}
for (const raw of page.images.slice(0, progress.row)) seen.add(ids(raw));
while (progress.row < page.images.length && result.examined < config.maxNewImagesPerTick) {
const image = page.images[progress.row]; result.examined++;
if (seen.has(ids(image))) {
progress.generation = randomUUID(); progress.page = 1; progress.row = 0;
progress.pageData = undefined; progress.total = undefined; progress.firstHash = undefined;
await store.write('scan-progress', progress);
throw new Error('duplicate image across scan pages; restart fixed window');
}
seen.add(ids(image));
const one = await collectMeyer({ ...window, scopes: [scope] }, {
list: async () => ({ total: 1, page: 1, size: window.pageSize, images: [image] }),
symptom: im => source.symptom(im),
}, store, codes, sink, true);
for (const key of ['seen', 'collected', 'applied', 'alreadyApplied', 'deferred', 'failed'] as const) result[key] += one[key];
result.errors.push(...one.errors);
if (one.failed) return result; // 不跳过未知失败;重试从本行恢复。
progress.row++; await store.write('scan-progress', progress);
}
if (progress.row === page.images.length) {
await ledger.write(`page-${progress.page}`, page.images.map(ids));
const last = progress.page * window.pageSize >= page.total;
progress.pageData = undefined; progress.row = 0;
if (last) {
progress.scopeIndex++; progress.page = 1; progress.total = undefined; progress.firstHash = undefined;
} else progress.page++;
await store.write('scan-progress', progress);
}
}
result.complete = progress.scopeIndex === config.scopes.length;
} catch (e) {
result.failed++; result.errors.push({ key: 'page-scan', reason: e instanceof Error ? e.message : 'scan failed' });
}
return result;
}
import { scanWindow, type ScanProgress } from './scan';
import { rm } from 'node:fs/promises';
import { join } from 'node:path';
import { collectMeyer, type PendingImage } from './collector';
import { MeyerStore } from './store';
import { sourceTime, type MeyerConfig, type MeyerSource, type MeyerSink } from './contracts';
export const chinaTime = (ms: number) => new Date(ms + 8 * 3600000).toISOString().slice(0, 19).replace('T', ' ');
/** 一轮独立任务:先限量重试,再扫描一个窗口;只推进本适配器水位。 */
export async function runMeyerTick(config: MeyerConfig, sourceFor: (c: MeyerConfig) => MeyerSource,
store: MeyerStore, codes: Record<string, string>, sink: MeyerSink, now = Date.now()) {
const cursor = await store.read<{ through: string }>('cursor');
const initial = Date.parse(sourceTime(config.timeStart));
const through = cursor ? Date.parse(sourceTime(cursor.through)) : initial;
if (!Number.isFinite(through) || through < initial) throw new Error('invalid collector cursor');
// 留出一分钟稳定区,每轮最多前进一小时,边界重叠一分钟。
const end = Math.min(now - 60000, through + 3600000);
const pending = new MeyerStore(join(store.directory, 'pending'));
const due: Array<{ key: string; value: PendingImage }> = [];
for (const key of await pending.keys()) {
const value = await pending.read<PendingImage>(key);
if (value && Date.parse(value.nextRetryAt) <= now) due.push({ key, value });
}
due.sort((a, b) => a.value.nextRetryAt.localeCompare(b.value.nextRetryAt));
let retryFailed = 0;
for (const { key, value } of due.slice(0, config.maxRetriesPerTick)) {
if (!config.scopes.some(s => JSON.stringify(s) === JSON.stringify(value.scope))) throw new Error('pending scope mismatch');
if (!value.image.imageCreateTime) { retryFailed++; continue; }
const t = Date.parse(sourceTime(value.image.imageCreateTime));
const retryConfig = { ...config, scopes: [value.scope], timeStart: chinaTime(t - 1000), timeEnd: chinaTime(t + 1000) };
const client = sourceFor(retryConfig);
// 每次重试重新读取影像身份,避免拿旧 patientId 给后来改绑的影像写事实。
try {
let found: unknown;
let total: number | undefined;
for (let page = 1; ; page++) {
if (page > Math.min(config.maxPages, config.maxListPagesPerTick)) throw new Error('pending page limit');
const batch = await client.list(value.scope, page);
if (batch.page !== page || batch.size !== config.pageSize || (total !== undefined && total !== batch.total) ||
batch.images.length !== Math.min(batch.size, Math.max(0, batch.total - (page - 1) * batch.size))) throw new Error('pending page changed');
total = batch.total;
for (const image of batch.images) if ((image as { imageId?: string }).imageId === value.image.imageId) {
if (found) throw new Error('duplicate pending image'); found = image;
}
if (page * batch.size >= total) break;
}
if (!found) throw new Error('pending image no longer visible');
if (value.image.patientId && (found as { patientId?: string }).patientId !== value.image.patientId) {
throw new Error('pending image ownership changed');
}
const result = await collectMeyer(retryConfig, {
list: async () => ({ total: 1, page: 1, size: config.pageSize, images: [found] }),
symptom: image => client.symptom(image),
}, store, codes, sink);
retryFailed += result.failed;
} catch {
retryFailed++;
await pending.write(key, { ...value, attempts: value.attempts + 1,
nextRetryAt: new Date(now + 3600000).toISOString() });
}
}
let scan: Awaited<ReturnType<typeof scanWindow>> | undefined;
let progress = await store.read<ScanProgress>('scan-progress');
// 上轮水位已提交但进程尚未删 checkpoint 就退出,可安全收尾。
if (progress && Date.parse(sourceTime(progress.end)) <= through) {
await store.remove('scan-progress');
await rm(join(store.directory, 'scan-ledger'), { recursive: true, force: true });
progress = null;
}
if (progress || end > through) {
const start = progress?.start ?? chinaTime(Math.max(initial, through - 60000));
const finish = progress?.end ?? chinaTime(end);
scan = await scanWindow(config, sourceFor, store, codes, sink, start, finish);
if (scan.complete && !scan.failed) {
await store.write('cursor', { through: finish });
await store.remove('scan-progress');
await rm(join(store.directory, 'scan-ledger'), { recursive: true, force: true });
}
}
const status = { checkedAt: new Date(now).toISOString(), retryAttempted: Math.min(config.maxRetriesPerTick, due.length), retryFailed,
pending: (await pending.keys()).length, scan, cursor: await store.read('cursor') };
await store.write('scheduler-status', status);
return status;
}
import { mkdir, readFile, writeFile, rename, rm, readdir, open } from 'node:fs/promises';
import { hostname } from 'node:os';
import { join } from 'node:path';
import { randomUUID } from 'node:crypto';
import { hash, type MeyerBundle } from './contracts';
/** 独立磁盘留底,不写主摄入游标。目录只能被单个 CLI 占用,文件默认仅当前用户可读。 */
export class MeyerStore {
constructor(readonly directory: string) {}
async acquire(identity: unknown): Promise<() => Promise<void>> {
await mkdir(this.directory, { recursive: true, mode: 0o700 });
const lock = join(this.directory, '.lock');
try { await mkdir(lock, { mode: 0o700 }); } catch {
// 只回收本机、明确已退出进程的锁;PID 复用/未知 owner/其他机器均保守拒绝。
let ownerText: string;
try { ownerText = await readFile(join(lock, 'owner.json'), 'utf8'); }
catch { throw new Error('Meyer state locked; owner unknown, inspect before recovery'); }
const owner = JSON.parse(ownerText) as { host: string; pid: number };
if (owner.host !== hostname() || !Number.isInteger(owner.pid) || owner.pid < 1) throw new Error('Meyer state locked');
try { process.kill(owner.pid, 0); throw new Error('Meyer state locked by live process'); }
catch (e) { if ((e as NodeJS.ErrnoException).code !== 'ESRCH') throw e; }
// 独占回收标记避免两个启动者同时删除锁。回收过程本身崩溃时留给人工检查。
try { await writeFile(join(lock, 'recovering'), String(process.pid), { flag: 'wx', mode: 0o600 }); }
catch { throw new Error('Meyer state lock recovery already claimed; inspect owner'); }
if (await readFile(join(lock, 'owner.json'), 'utf8') !== ownerText) throw new Error('Meyer lock owner changed');
const retired = `${lock}.retired-${randomUUID()}`;
await rename(lock, retired);
await rm(retired, { recursive: true });
try { await mkdir(lock, { mode: 0o700 }); } catch { throw new Error('Meyer state locked by another starter'); }
}
try {
await writeFile(join(lock, 'owner.json'), JSON.stringify({ host: hostname(), pid: process.pid }), { mode: 0o600 });
await writeFile(join(lock, 'pid'), String(process.pid), { mode: 0o600 });
const expected = hash(identity);
const existing = await this.read<{ identity: string }>('identity');
if (existing && existing.identity !== expected) throw new Error('Meyer state scope/config mismatch; use its original configuration');
if (!existing) await this.write('identity', { identity: expected });
return () => rm(lock, { recursive: true });
} catch (error) { await rm(lock, { recursive: true }); throw error; }
}
async read<T>(key: string): Promise<T | null> {
try { return JSON.parse(await readFile(this.path(key), 'utf8')) as T; }
catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
throw new Error('Meyer state file is invalid; do not overwrite it');
}
}
async write(key: string, value: unknown): Promise<void> {
await mkdir(this.directory, { recursive: true, mode: 0o700 });
const dest = this.path(key);
const temp = `${dest}.${randomUUID()}.tmp`;
const file = await open(temp, 'wx', 0o600);
try { await file.writeFile(JSON.stringify(value, null, 2) + '\n'); await file.sync(); }
finally { await file.close(); }
await rename(temp, dest);
const directory = await open(this.directory, 'r');
try { await directory.sync(); } finally { await directory.close(); }
}
async remove(key: string): Promise<void> { await rm(this.path(key), { force: true }); }
async keys(): Promise<string[]> {
try { return (await readdir(this.directory)).filter(n => /^[a-zA-Z0-9_-]+\.json$/.test(n)).map(n => n.slice(0, -5)); }
catch (e) { if ((e as NodeJS.ErrnoException).code === 'ENOENT') return []; throw e; }
}
private path(key: string) {
if (!/^[a-zA-Z0-9_-]+$/.test(key)) throw new Error('invalid state key');
return join(this.directory, `${key}.json`);
}
}
export interface SavedBundle {
bundle: MeyerBundle;
applied: boolean;
}
import { setTimeout as delay } from 'node:timers/promises';
import type { MeyerConfig } from './contracts';
/** 网络、鉴权、数据库故障可等待恢复;不修改业务数据来绕过错误。 */
export async function watchMeyer(config: MeyerConfig, tick: () => Promise<number>, signal: AbortSignal,
sleep: (ms: number, signal: AbortSignal) => Promise<void> = async (ms, s) => { await delay(ms, undefined, { signal: s }); },
report: (state: { consecutiveFailures: number; nextDelayMs: number; failed: boolean }) => void = state => console.log(JSON.stringify({ mode: 'watch', ...state }))) {
let failures = 0;
while (!signal.aborted) {
let failed = false;
try { failed = await tick() === 1; } catch { failed = true; }
failures = failed ? failures + 1 : 0;
const nextDelayMs = failed ? Math.min(config.errorBackoffMaxMs,
config.pollIntervalMs * 2 ** Math.min(failures - 1, 10)) : config.pollIntervalMs;
report({ consecutiveFailures: failures, nextDelayMs, failed });
if (signal.aborted) break;
try { await sleep(nextDelayMs, signal); } catch { if (!signal.aborted) throw new Error('watch timer failed'); }
}
}
/** 超时后退出进程交给外部守护,不能 Promise.race 后再开一轮并发写同一状态。 */
export async function withRoundDeadline<T>(work: () => Promise<T>, ms: number,
stop: () => void = () => { console.error('Meyer round deadline exceeded; supervisor must restart from checkpoint'); process.exit(1); }): Promise<T> {
const timer = setTimeout(stop, ms);
try { return await work(); } finally { clearTimeout(timer); }
}
import { z } from 'zod';
/** 新增患者查询的显式字段映射;未知扩展内容原样保留,不递归猜测命名。 */
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',
encounter_record: 'encounter_external_id doctor_id doctor_name chief_complaint encounter_type',
diagnosis_record: 'code_source name_zh tooth_position onset_date doctor_id doctor_name source_encounter_external_id source_image_external_id source_analysis_external_id clinical_signals',
treatment_record: 'tooth_position started_at completed_at doctor_id doctor_name unit_name treat_stages source_encounter_external_id related_diagnosis_subject_id review_window_days',
recommendation_record: 'source_encounter_external_id tooth_position doctor_id doctor_name extracted_by source_fact_subject_id doctor_suggested_at window_days',
emr_record: 'emr_external_id encounter_external_id doctor_id doctor_name illness_desc pre_illness past_history general_condition exam_findings aux_exam_findings diagnosis_text treatment_plan treatment_notes doctor_advice quality_check clinical_signals',
image_record: 'image_external_id encounter_external_id tooth_positions captured_at file_url file_name file_type doctor_id doctor_name',
image_analysis_record: 'analysis_external_id image_external_id code_source analyzed_at source_updated_at analysis_result',
appointment_record: 'appointment_external_id scheduled_at arrived_at appointment_type doctor_id doctor_name resource_name complaint_category complaint_text duration_minutes cancellation_reason',
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',
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',
};
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',
};
const instantSchema = z.iso.datetime({ offset: true });
const camel = (key: string) => key.replace(/_([a-z])/g, (_, ch: string) => ch.toUpperCase());
export const lookupContentNames: Record<string, Record<string, string>> = Object.fromEntries(
Object.entries(fields).map(([type, names]) => [type, {
...Object.fromEntries(names.split(' ').map(key => [key, camel(key)])),
...(ownIds[type] ? { [ownIds[type]]: 'externalId' } : {}),
...(type === 'diagnosis_record' ? { name_zh: 'name' } : {}),
...(type === 'recommendation_record' ? { extracted_by: 'extractionMethod' } : {}),
...(['consultation_record', 'complaint_record'].includes(type) ? { content: 'text' } : {}),
}]),
);
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];
}
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]);
// 仅移除已由外层承接的同义时间。冲突、日期精度或未知时区原文仍保留。
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];
};
const occurrenceKeys: Record<string, string> = {
recommendation_record: 'doctorSuggestedAt',
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 (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 (type === 'complaint_record' && Array.isArray(c.followUps)) c.followUps = c.followUps.map(v => record(v) ? rename(v, { content: 'text' }) : v);
if (record(c.qualityCheck)) c.qualityCheck = rename(c.qualityCheck, { reject_reason: 'rejectReason' });
if (Array.isArray(c.clinicalSignals)) c.clinicalSignals = c.clinicalSignals.map(v => {
if (!record(v)) return v;
const signal = rename(v, { extractedBy: 'extractorVersion' });
const target = typeof signal.field === 'string' ? lookupContentNames[type]?.[signal.field] : undefined;
// 引用随已成功改名的字段更新;冲突时仍指向保留的原字段。
if (target && !(String(signal.field) in c) && target in c) signal.field = target;
return signal;
});
if (type === 'order_record' && Array.isArray(c.items)) c.items = c.items.map(v => record(v) ? rename(v, {
item_type: 'itemType', item_code: 'itemCode', item_name: 'itemName', unit_price_cents: 'unitPriceCents', amount_cents: 'amountCents', treatment_category: 'treatmentCategory',
}) : v);
return c;
}
import { z } from 'zod';
import type { OpenFactRow } from './open-fact';
import { redactJson } from './open-redact';
import { lookupContent } from './lookup-content';
export type LookupFactRow = OpenFactRow & { fact_id: string; host_name: string; appointment_created_values?: 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> : {};
let occurredAt = row.occurred_at?.toISOString() ?? null;
let plannedFor: string | null = null;
const knownSource = row.host_name === 'friday' || row.host_name === 'jvs-dw';
if (row.type === 'appointment_record') {
// ⛔ 不用到诊、约定、PAC 入库时间代替宿主创建时间;实际到诊由 encounter 表达。
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);
} else if (row.type === 'treatment_record' && row.kind === 'planned') {
// 两宿主现行映射均将制定时间写在 planned_for;没有明确执行日期。
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',
};
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 instantSchema = z.iso.datetime({ offset: true });
function explicitInstant(value: unknown): string | null {
if (typeof value !== 'string' || !instantSchema.safeParse(value).success) return null;
const time = new Date(value);
return Number.isFinite(time.getTime()) ? time.toISOString() : null;
}
/** 已核实两宿主原始字段均为北京时间;只读本事实证据中的创建时间,不改摄入。 */
function appointmentCreatedAt(host: string, values: unknown): string | null {
if (!['friday', 'jvs-dw'].includes(host) || !Array.isArray(values)) return null;
const times = new Set<string>();
for (const value of values) {
if (value == null || value === '') continue;
if (typeof value !== 'string') return null;
let text = value.trim().replace(' ', 'T');
if (!text) continue;
// 源字段仅有日期时沿用宿主当地零点口径;无时区时刻按北京时间转 UTC。
if (/^\d{4}-\d{2}-\d{2}$/.test(text)) text += 'T00:00:00+08:00';
else if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?$/.test(text)) text += '+08:00';
const instant = explicitInstant(text);
if (!instant) return null;
times.add(instant);
}
// 多条证据冲突时不挑一个猜测,也不回退到其他业务时间。
return times.size === 1 ? [...times][0]! : null;
}
import { personaFeatureSortKey, personaValueLabels, shortPersonaValueLabel } from '@pac/types';
import { redactText } from './open-redact';
interface PersonaDisplayFeature {
key: string;
description: string | null;
data: unknown;
}
/** 读取已存画像,展开全部展示标签;不触发计算,也不按工作台首屏白名单截断。 */
export function toOpenPersona(
features: readonly PersonaDisplayFeature[] | null | undefined,
): string[] {
const ordered = [...(features ?? [])].sort((a, b) => {
const [ga, oa] = personaFeatureSortKey(a.key);
const [gb, ob] = personaFeatureSortKey(b.key);
return ga - gb || oa - ob || a.key.localeCompare(b.key);
});
const tags = new Set<string>();
for (const feature of ordered) {
const labels = personaValueLabels(feature.data, feature.key) ?? [
shortPersonaValueLabel(feature.description),
];
for (const label of labels) {
const text = redactText(label)?.trim();
if (text && text !== '—') tags.add(text);
}
}
return [...tags];
}
......@@ -21,7 +21,10 @@ const PatientBasicSchema = z.object({
name: z.string().nullable(),
medicalRecordNumber: z.string().nullable(),
phone: z.string().nullable().describe('脱敏手机号'),
gender: z.string().nullable(),
gender: z
.string()
.nullable()
.describe('开放文本;常见 male=男、female=女,也可能保留男/女等宿主原值;未知为 null'),
birthDate: z.string().nullable().describe('YYYY-MM-DD 业务日期'),
});
......@@ -29,37 +32,36 @@ export const PatientFactsLookupResponseSchema = z.object({
items: z.array(
z.object({
patient: PatientBasicSchema.extend({
preferences: z.unknown(),
lastVisitAt: z.string().nullable().describe('最近到诊业务日期 YYYY-MM-DD'),
lastVisitDoctor: z.string().nullable(),
preferredDoctor: z.string().nullable(),
doNotContact: z.boolean().nullable().describe('无业务属性记录时为 null,不能当成 false'),
doNotContactReason: z.string().nullable(),
deceased: z.boolean().nullable(),
hostFollowUpActive: z.boolean().nullable(),
tags: z.array(z.string()).nullable().describe('null=无业务属性记录;[]=已存标签为空'),
acquisitionChannel: z.string().nullable(),
acquisitionSub: z.string().nullable(),
dedicatedCs: z
.object({
id: z.string().nullable().describe('宿主侧专属客服标识,不是 PAC 用户 ID'),
name: z.string().nullable().describe('专属客服姓名'),
})
.nullable()
.describe('当前专属客服;未提供有效标识或姓名时为 null'),
acquisitionChannel: z
.string()
.nullable()
.describe(
'获客一级渠道:walk_in=走入、word_of_mouth=口碑客户、group_sales=集团销售、group_marketing=集团营销、regional_marketing=地区营销、ecommerce=电商、social_media=自媒体网络、employee=内部员工、other=其他;开放字符串,未提供为 null',
),
acquisitionSub: z
.string()
.nullable()
.describe('获客二级渠道,保留宿主原值;未提供时为 null'),
}),
persona: z
.object({
version: z.number().int(),
computedAt: z.string(),
refreshedAt: z.string().nullable(),
features: z.array(
z.object({
key: z.string(),
description: z.string(),
score: z.number().nullable(),
data: z.unknown(),
}),
),
})
.nullable()
.describe('最近已保存的当前画像,不触发重算;未形成时为 null'),
.array(z.string())
.describe(
'当前已保存画像的展示标签;多值展开、去空去重,按展示顺序返回;无画像或无可展示标签为 [],查询不重算。文案不是枚举,不用于业务判断',
),
relations: z.array(
z.object({
relationship: z.string().describe('对方是本人的什么关系,按 PAC 已存方向返回'),
relationship: z
.string()
.describe(
'对方是本人的关系:spouse=配偶、father=父亲、mother=母亲、child=子女、grandparent=祖父母/外祖父母、grandchild=孙辈/外孙辈、sibling=兄弟姐妹、friend=朋友、other=其他;开放字符串,兼容未知值,不推断反向关系',
),
patient: PatientBasicSchema.nullable().describe(
'关系人未解析、归档或越界时为 null;不展开其事实和画像',
),
......@@ -67,35 +69,16 @@ export const PatientFactsLookupResponseSchema = z.object({
),
facts: z.array(
z.object({
subjectId: z
.string()
.describe('来源事实主体标识,跨版本稳定;仅作溯源,不能单独当全局唯一键'),
id: z.string().uuid().describe('PAC 事实版本 ID;同一业务事实产生新版本时 ID 会变化'),
type: z
.string()
.describe('决定 content 结构的 PAC 事实类型;详见事实内容参考,允许未来新增类型'),
kind: z.string().describe('actual=实际记录;planned=计划或建议'),
status: z
.string()
.describe(
'事实生命周期:本接口仅 active/fulfilled;业务进度看对应 content 字段,不代表疾病是否治愈',
),
clinicId: z.string().nullable(),
clinicName: z.string().nullable(),
occurredAt: z.string().nullable().describe('实际发生时刻,UTC ISO 8601;未知为 null'),
plannedFor: z
.string()
.nullable()
.describe('计划或建议的时间锚,非完成证明;UTC ISO 8601'),
validUntil: z
.string()
.nullable()
.describe('有效期截止时刻;null=未提供,不能推断永久有效'),
title: z.string().nullable(),
summary: z.string().nullable(),
content: z.unknown().describe('按事实类型定义的标准化内容,已脱敏'),
steps: z
.array(z.string())
.describe('PAC 标准治疗步骤,仅 treatment_record 有值;其他类型 [],允许新增步骤'),
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 字段参考'),
}),
),
}),
......
......@@ -2,8 +2,10 @@ import { Injectable, Logger } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { currentFactHeadSql } from '../facts/current-fact';
import { toOpenFact, type OpenFactRow } from './open-fact';
import { redactText, redactJson } from './open-redact';
import type { OpenFactRow } from './open-fact';
import { toLookupFact } from './lookup-fact';
import { redactText } from './open-redact';
import { toOpenPersona } from './open-persona';
import {
PatientFactsLookupSchema,
type PatientFactsLookupRequest,
......@@ -13,7 +15,9 @@ import type { ApplicationPrincipal } from './application-token.guard';
type LookupRow = OpenFactRow & {
patient_id: string;
host_name: string;
fact_id: string | null;
appointment_created_values?: unknown;
};
type LookupItem = PatientFactsLookupItem;
......@@ -50,6 +54,18 @@ function basicPatient(p: {
};
}
/** 只投影专属客服的稳定字段,避免把 preferences 中的其他内部属性透出。 */
function dedicatedCsOf(preferences: unknown): { id: 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;
const cs = value as Record<string, unknown>;
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;
}
@Injectable()
export class PatientFactsLookupService {
private readonly logger = new Logger(PatientFactsLookupService.name);
......@@ -65,11 +81,24 @@ export class PatientFactsLookupService {
const items = await this.prisma.$transaction(
async (tx) => {
// 不设 LIMIT,不静默截断;以 PAC 患者主键分组,⛔ 不按姓名/电话/病历号合并不同人。
// 预约创建时间只查本版本绑定的证据主键,⛔ 不按姓名或预约号跨患者补配、不回扫账本。
const rows = await tx.$queryRaw<LookupRow[]>(Prisma.sql`
SELECT p.id AS patient_id,
SELECT p.id AS patient_id, h.name AS host_name,
f.id AS fact_id, f.subject_id, f.type, f.kind, f.status, f.clinic_id,
f.occurred_at, f.planned_for, f.valid_until, f.title, f.summary, f.content
f.occurred_at, f.planned_for, f.valid_until, f.title, f.summary, f.content,
CASE WHEN f.type = 'appointment_record' AND h.name IN ('friday', 'jvs-dw') THEN (
SELECT jsonb_agg(t.raw_payload -> CASE h.name
WHEN 'friday' THEN 'created_gmt_at' ELSE 'created_date' 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.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
FROM patients p
JOIN hosts h ON h.id = p.host_id
LEFT JOIN patient_facts f ON f.patient_id = p.id
AND f.host_id = p.host_id AND f.tenant_id = p.tenant_id AND f.source_unit = p.source_unit
AND ${currentFactHeadSql('f')}
......@@ -94,14 +123,6 @@ export class PatientFactsLookupService {
preferences: true,
profile: {
select: {
lastVisitAt: true,
lastVisitDoctor: true,
preferredDoctor: true,
doNotContact: true,
doNotContactReason: true,
deceased: true,
hostFollowUpActive: true,
tags: true,
acquisitionChannel: true,
acquisitionSub: true,
},
......@@ -111,12 +132,9 @@ export class PatientFactsLookupService {
orderBy: [{ version: 'desc' }, { id: 'desc' }],
select: {
tenantId: true,
version: true,
computedAt: true,
refreshedAt: true,
features: {
orderBy: { key: 'asc' },
select: { key: true, description: true, score: true, data: true },
select: { key: true, description: true, data: true },
},
},
},
......@@ -145,31 +163,11 @@ export class PatientFactsLookupService {
items.set(p.id, {
patient: {
...basicPatient(p),
preferences: redactJson(p.preferences),
lastVisitAt: p.profile?.lastVisitAt?.toISOString().slice(0, 10) ?? null,
lastVisitDoctor: redactText(p.profile?.lastVisitDoctor),
preferredDoctor: redactText(p.profile?.preferredDoctor),
doNotContact: p.profile?.doNotContact ?? null,
doNotContactReason: redactText(p.profile?.doNotContactReason),
deceased: p.profile?.deceased ?? null,
hostFollowUpActive: p.profile?.hostFollowUpActive ?? null,
tags: p.profile ? p.profile.tags.map((t) => redactText(t) ?? '') : null,
dedicatedCs: dedicatedCsOf(p.preferences),
acquisitionChannel: redactText(p.profile?.acquisitionChannel),
acquisitionSub: redactText(p.profile?.acquisitionSub),
},
persona: persona
? {
version: persona.version,
computedAt: persona.computedAt.toISOString(),
refreshedAt: persona.refreshedAt?.toISOString() ?? null,
features: persona.features.map((f) => ({
key: f.key,
description: redactText(f.description) ?? '',
score: f.score,
data: redactJson(f.data),
})),
}
: null,
persona: toOpenPersona(persona?.features),
relations: p.relationsOut
.filter((rel) => rel.tenantId === p.tenantId)
.map((rel) => {
......@@ -192,11 +190,18 @@ export class PatientFactsLookupService {
const item = items.get(row.patient_id);
if (!item) continue;
if (row.fact_id !== null)
item.facts.push({
...toOpenFact(row),
clinicName: row.clinic_id ? (application.clinicNames[row.clinic_id] ?? null) : null,
});
item.facts.push(toLookupFact(
{ ...row, fact_id: row.fact_id },
row.clinic_id ? (application.clinicNames[row.clinic_id] ?? null) : null,
));
}
// 按实际返回的时间排序,避免预约旧兜底值改变对外时间轴。
for (const item of items.values()) item.facts.sort((a, b) => {
const at = a.occurredAt ?? a.plannedFor;
const bt = b.occurredAt ?? b.plannedFor;
return (bt ? Date.parse(bt) : -Infinity) - (at ? Date.parse(at) : -Infinity)
|| b.id.localeCompare(a.id);
});
return items;
},
{ isolationLevel: Prisma.TransactionIsolationLevel.RepeatableRead, timeout: 15000 },
......
......@@ -19,6 +19,7 @@ import { z } from 'zod';
import {
ComplaintStatusSchema, ComplaintFeedbackKindSchema, ComplaintCategorySchema, ComplaintChannelSchema,
ClinicalSignalsSchema,
ImageEmbeddedAnalysisSchema,
FactType,
PACDiagnosisCodeSchema,
PACTreatmentCategorySchema,
......@@ -230,11 +231,12 @@ const EmrRecordContent = z
.passthrough(); // 自由文本 fact 允许 host 多带字段(算法不消费,Layer C 抽取消费)
/**
* image_record — 影像 metadata + finding(自由文本)
* image_record — 影像 metadata + finding(自由文本) + 可选的单张影像分析快照
*/
const ImageRecordContent = z
.object({
image_external_id: z.string().min(1),
analysis: ImageEmbeddedAnalysisSchema.nullable().optional(),
/// 原样留存宿主文件位置,不在摄入层要求完整链接。
file_url: z.string().optional(),
file_name: z.string().optional(),
......
import { Injectable, Logger } from '@nestjs/common';
import { Action, FactKind, FactStatus, FactType } from '@pac/types';
import { Action, FactKind, FactStatus, FactType, ImageEmbeddedAnalysisSchema } from '@pac/types';
import type { FactDraft, Parser, ParserContext } from './parser.interface';
import { normalizeToothPosition } from './tooth-position.util';
/**
* ImageParser — `image_uploaded` 解析器
*
* 产 1 个 `image_record`(actual),只装 metadata + 医生 finding,**不持文件本体**。
* 宿主分析另走通用 image_analysis;PAC 自身模型产物归 agent_invocations.output。
* 产 1 个 `image_record`(actual),保留 metadata、finding 与可选 analysis,不持文件本体。
* analysis 仅接收已明确关联本张影像的快照;诊断信号由原有诊断摄入链路独立处理。
*/
@Injectable()
export class ImageParser implements Parser {
......@@ -45,6 +45,7 @@ export class ImageParser implements Parser {
modality,
tooth_positions: toothPositions,
finding,
...(c.analysis !== undefined ? { analysis: ImageEmbeddedAnalysisSchema.nullable().parse(c.analysis) } : {}),
encounter_external_id: encounterExternalId,
doctor_id: c.doctorId ? String(c.doctorId) : null,
doctor_name: (c.doctorName as string | undefined) ?? null,
......
[
{
"type": "consultation_record",
"content": {
"consultation_external_id": "consult-1",
"intents": [
"洁牙"
],
"intent_categories": [
"preventive"
],
"is_first_visit": true
}
},
{
"type": "visit_registration_record",
"content": {
"registration_external_id": "reg-1",
"registered_at": "2026-10-19T01:00:00.000Z"
}
},
{
"type": "visit_reception_record",
"content": {
"reception_external_id": "reception-1",
"received_at": "2026-10-19T01:05:00.000Z"
}
},
{
"type": "encounter_record",
"content": {
"encounter_external_id": "enc-1",
"doctor_name": "示例医生",
"chief_complaint": "右下牙疼痛"
}
},
{
"type": "diagnosis_record",
"content": {
"code": "K02",
"code_source": "std_code",
"name_zh": "龋病",
"tooth_position": "36",
"source_encounter_external_id": "enc-1"
}
},
{
"type": "treatment_record",
"content": {
"category": "endodontic",
"subtype": "根管充填",
"tooth_position": "36",
"status": "completed",
"completed_at": "2026-10-19T02:00:00.000Z",
"treat_stages": [
"canal_filling"
]
}
},
{
"type": "recommendation_record",
"content": {
"code": null,
"name": "建议复查",
"tooth_position": "36",
"extracted_by": "doctor_input",
"window_days": 30
}
},
{
"type": "emr_record",
"content": {
"emr_external_id": "emr-1",
"encounter_external_id": "enc-1",
"illness_desc": "右下牙疼痛",
"exam_findings": "36 牙检查所见",
"doctor_advice": "按医嘱复诊"
}
},
{
"type": "image_record",
"content": {
"image_external_id": "image-1",
"modality": "pano",
"tooth_positions": [
"36"
],
"finding": "影像所见",
"file_name": "example.jpg",
"file_url": "images/example.jpg"
}
},
{
"type": "image_analysis_record",
"content": {
"analysis_external_id": "analysis-1",
"image_external_id": "image-1",
"code_source": "image_ai",
"status": "completed",
"analyzed_at": "2026-10-19T02:05:00.000Z",
"findings": [
{
"externalId": "finding-1",
"code": "K02",
"name": "龋病",
"toothPosition": "36",
"excluded": false
}
]
}
},
{
"type": "appointment_record",
"content": {
"appointment_external_id": "appointment-1",
"scheduled_at": "2026-10-20T01:00:00.000Z",
"arrived_at": null,
"status": "scheduled",
"doctor_name": "示例医生",
"duration_minutes": 30
}
},
{
"type": "order_record",
"content": {
"order_external_id": "order-1",
"amount_cents": 12000,
"items": [
{
"item_type": "technical",
"item_name": "治疗项目",
"quantity": 1,
"amount_cents": 12000
}
]
}
},
{
"type": "payment_record",
"content": {
"payment_external_id": "payment-1",
"amount_cents": 10000,
"receivable_cents": 12000,
"discount_cents": 2000,
"channel": "银行卡"
}
},
{
"type": "refund_record",
"content": {
"refund_external_id": "refund-1",
"amount_cents": 2000,
"reason": "项目调整",
"related_payment_external_id": "payment-1"
}
},
{
"type": "recharge_record",
"content": {
"recharge_external_id": "recharge-1",
"amount_cents": 50000,
"card_type": "储值卡"
}
},
{
"type": "complaint_record",
"content": {
"complaint_external_id": "complaint-1",
"feedback_kind": "complaint",
"status": "resolved",
"category": "service",
"channel": "hotline",
"signal_eligible": true,
"content": "服务反馈已处理",
"resolved_at": "2026-10-19T03:00:00.000Z",
"follow_ups": []
}
},
{
"type": "referral_record",
"content": {
"referral_external_id": "referral-1",
"referrer_name": "示例推荐人",
"referrer_type": "patient",
"referrer_chart_number": "000456"
}
}
]
import { Test } from '@nestjs/testing';
import { Reflector } from '@nestjs/core';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { ZodSerializerInterceptor, ZodValidationPipe } from 'nestjs-zod';
import { AuthController } from '../../src/modules/auth/auth.controller';
import { AuthService } from '../../src/modules/auth/auth.service';
import { JwtStrategy } from '../../src/modules/auth/jwt.strategy';
import { OrgTreeService } from '../../src/modules/auth/org-tree';
import { PatientFactsLookupController } from '../../src/modules/open/patient-facts-lookup.controller';
import { PatientFactsLookupService } from '../../src/modules/open/patient-facts-lookup.service';
import { ApplicationTokenGuard } from '../../src/modules/open/application-token.guard';
import { PrismaService } from '../../src/prisma/prisma.service';
import { JwtAuthGuard } from '../../src/common/guards/jwt-auth.guard';
import { PermissionsGuard } from '../../src/common/guards/permissions.guard';
import { WrapResponseInterceptor } from '../../src/common/interceptors/wrap-response.interceptor';
import { TenantScopeInterceptor } from '../../src/common/interceptors/tenant-scope.interceptor';
import { AllExceptionsFilter } from '../../src/common/filters/all-exceptions.filter';
/** 真实鉴权和查询控制器;只替代未走到的 Redis/组织服务,不加载主应用或后台任务。 */
export async function createMeyerOpenHttp(prisma: PrismaService) {
const config = { getOrThrow: (key: string) => key === 'jwt.secret' ? 'local-meyer-jwt-secret' : 60 };
const orgTree = { expandScope: jest.fn() };
const jwt = new JwtService();
const auth = new AuthService(prisma, {} as any, jwt, config as any, {} as any,
orgTree as any, { trackServerSide: jest.fn().mockResolvedValue(null) } as any);
const module = await Test.createTestingModule({
imports: [PassportModule.register({ defaultStrategy: 'jwt' })],
controllers: [AuthController, PatientFactsLookupController],
providers: [JwtStrategy, ApplicationTokenGuard, PatientFactsLookupService,
{ provide: AuthService, useValue: auth }, { provide: JwtService, useValue: jwt },
{ provide: ConfigService, useValue: config }, { provide: PrismaService, useValue: prisma },
{ provide: OrgTreeService, useValue: orgTree }],
}).compile();
const app = module.createNestApplication({ logger: false });
app.setGlobalPrefix('pac/v1');
app.useGlobalPipes(new ZodValidationPipe());
const reflector = app.get(Reflector);
app.useGlobalGuards(new JwtAuthGuard(reflector), new PermissionsGuard(reflector));
app.useGlobalInterceptors(new TenantScopeInterceptor(orgTree as any),
new WrapResponseInterceptor(), new ZodSerializerInterceptor(reflector));
app.useGlobalFilters(new AllExceptionsFilter());
await app.listen(0, '127.0.0.1');
const base = await app.getUrl();
return {
close: () => app.close(),
request: async (path: string, body: unknown, token?: string): Promise<any> => {
const res = await fetch(base + '/pac/v1' + path, { method: 'POST',
headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}) },
body: JSON.stringify(body) });
return res.json();
},
};
}
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');
});
test.each([
['recommendation_record', 'doctor_suggested_at', 'doctorSuggestedAt'],
['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) => {
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');
});
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('appointment uses host creation while arrival is reserved for encounter', () => {
expect(run('appointment_record', { scheduled_at: other, arrived_at: at }, { planned_for: new Date(other), appointment_created_values: [at] })).toMatchObject({ occurredAt: at, plannedFor: other, content: {} });
expect(run('appointment_record', { scheduled_at: other, arrived_at: null })).toMatchObject({ occurredAt: null, plannedFor: other, content: {} });
expect(run('appointment_record', { scheduled_at: other }, { planned_for: new Date(at) }).content).toEqual({ scheduledAt: other });
});
it('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('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('preserves alias conflicts and unknown nested extension JSON', () => {
expect(run('emr_record', { doctor_id: 'a', doctorId: 'b', extension: { some_key: 'untouched' } }).content).toEqual({ doctor_id: 'a', doctorId: 'b', extension: { some_key: 'untouched' } });
expect(run('future_type', { doctor_id: 'a' }).content).toEqual({ doctor_id: 'a' });
});
it('distinguishes extraction method from extractor version and preserves child-event times', () => {
expect(run('recommendation_record', { extracted_by: 'rule' }).content).toEqual({ extractionMethod: 'rule' });
expect(run('emr_record', { clinical_signals: [{ extractedBy: 'v1', validUntil: other }], quality_check: { reject_reason: '原因' } }).content).toEqual({ clinicalSignals: [{ extractorVersion: 'v1', validUntil: other }], qualityCheck: { rejectReason: '原因' } });
expect(run('complaint_record', { content: '投诉', resolved_at: other, follow_ups: [{ occurredAt: at, content: '跟进' }] }).content).toEqual({ text: '投诉', resolvedAt: other, followUps: [{ occurredAt: at, text: '跟进' }] });
});
it('updates signal field references only when the target field was renamed successfully', () => {
expect(run('emr_record', { illness_desc: '病情', clinical_signals: [{ field: 'illness_desc' }] }).content).toEqual({ illnessDesc: '病情', clinicalSignals: [{ field: 'illnessDesc' }] });
expect(run('emr_record', { illness_desc: '原字段', illnessDesc: '冲突字段', clinical_signals: [{ field: 'illness_desc' }, { field: 'unknown_key' }] }).content).toEqual({ illness_desc: '原字段', illnessDesc: '冲突字段', clinicalSignals: [{ field: 'illness_desc' }, { field: 'unknown_key' }] });
});
it('does not mutate source and keeps old API output and redaction unchanged', () => {
const row = { ...base, content: { emr_external_id: 'e', illness_desc: '13900139000', staff_notes: '内部', extension: { phone: '13900139000' } } };
const snapshot = JSON.stringify(row); const before = toOpenFact(row);
expect(toLookupFact(row, null).content).toEqual({ externalId: 'e', illnessDesc: '139****9000', extension: {} });
expect(JSON.stringify(row)).toBe(snapshot); expect(toOpenFact(row)).toEqual(before);
expect(before.content).toHaveProperty('emr_external_id', 'e');
});
});
import { toLookupFact, LookupFactRow } from '../src/modules/open/lookup-fact';
import { toOpenFact } from '../src/modules/open/open-fact';
import { PatientFactsLookupResponseSchema } from '../src/modules/open/patient-facts-lookup.schema';
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: '摘要',
content: { category: 'restorative', subtype: '充填', status: 'planned' },
};
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 => {
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);
});
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 => {
expect(convert({ type: 'recommendation_record', host_name }).occurredAt).toBe(early);
expect(convert({ type: 'recommendation_record', host_name, content: { doctor_suggested_at: '2026-10-20T10:00:00+08:00' } })).toMatchObject({ occurredAt: late, plannedFor: null });
});
test.each(['treatment_record', 'recommendation_record'])('does not reinterpret unknown host %s plan time', type => {
expect(convert({ host_name: 'new-host', type })).toMatchObject({ occurredAt: null, plannedFor: null });
});
it('can use an explicit proposal timestamp even for another host', () => {
expect(convert({ host_name: 'new-host', type: 'recommendation_record', content: { doctor_suggested_at: early } }).occurredAt).toBe(early);
});
test.each([null, [], [null], [''], ['invalid'], ['2026-02-30T01:00:00Z'], [12345], ['2026-10-01T00:00:00Z', '2026-10-02T00:00:00Z']])('missing, invalid or conflicting source creation %j stays null', appointment_created_values => {
expect(convert({ type: 'appointment_record', kind: 'actual', occurred_at: new Date(early), planned_for: new Date(late), content: { arrived_at: early }, appointment_created_values })).toMatchObject({ occurredAt: null, plannedFor: late });
});
test.each(['friday', 'jvs-dw'])('%s uses source creation for both scheduled and fulfilled appointments', host_name => {
for (const kind of ['planned', 'actual']) {
expect(convert({ host_name, type: 'appointment_record', kind, content: { arrived_at: late }, appointment_created_values: ['2026-10-01 10:00:00', '2026-10-01T02:00:00Z', null] })).toMatchObject({ occurredAt: early, plannedFor: early, content: {} });
}
});
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();
});
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 snapshot = JSON.stringify(row);
expect(toLookupFact(row, null)).toMatchObject({ occurredAt: early, content: {} });
expect(toLookupFact({ ...row, type: 'encounter_record' }, null).occurredAt).toBe(late);
expect(toOpenFact(row)).toMatchObject({ occurredAt: late, content: { arrived_at: late } });
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 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).content).not.toHaveProperty('staff_notes');
expect(JSON.stringify(row)).toBe(snapshot);
});
it('leaves old API serialization, steps, lifecycle and time fields unchanged', () => {
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).not.toHaveProperty('id');
});
});
import { Action } from '@pac/types';
import { resolveMeyerDiagnosisCode } from '../src/integrations/meyer/diagnosis-mapping';
import { mapMeyer } from '../src/integrations/meyer/mapper';
import { DiagnosisParser } from '../src/modules/sync/pipeline/parsers/diagnosis.parser';
import { validateFactContent } from '../src/modules/sync/pipeline/fact-content-schemas';
import { codes, meyerConfig, meyerImage } from './meyer-imaging-fixtures';
// 独立列出用户提供的全部去重术语,防止将未定码的所见无意扩成疾病。
const cases: Array<[string, string | null]> = [
['智齿', null], ['缺牙', 'K08'], ['多生牙', 'K00'], ['牙槽骨吸收', null],
['残根', 'K03'], ['残冠', 'K03'], ['埋伏牙', 'K01'], ['乳牙滞留', 'K00'],
['龋病', 'K02'], ['修复体牙冠', null], ['种植体', null], ['充填体', null],
['根尖周炎', 'K04'], ['牙龋坏', 'K02'], ['根充', null], ['金属冠', null],
['嵌体', null], ['牙髓石', null], ['楔状缺损', 'K03'], ['牙折裂', 'K03'],
['种植体周围炎', null], ['牙周牙髓联合病变', null], ['根分叉病变', 'K06'],
['颌骨病变', null], ['牙体缺损', 'K03'], ['骨结构不良', null], ['骨岛', null], ['牙齿矫正', null],
];
describe('Meyer finding mapping | existing API, isolated terminology policy', () => {
test.each(cases)('%s → %s; canonical and fact retain source evidence', (term, code) => {
const config = meyerConfig();
const symptom = { teeth: [{ position: '16', content: term }], diagnosis: '不展开综合诊断', description: '' };
const bundle = mapMeyer(config, config.scopes[0]!, meyerImage(), symptom, codes, '2026-10-17T00:00:00.000Z')!;
expect(bundle.raw.symptom).toEqual(symptom);
expect(bundle.diagnoses).toHaveLength(1);
const row = bundle.diagnoses[0]!;
expect(row).toMatchObject({ code, name: term, codeSource: 'image_ai', toothPosition: '16' });
const facts = new DiagnosisParser().parse({
canonicalRow: row,
transaction: { id: 'tx', hostId: 'host', tenantId: 'friday-market', patientId: 'patient',
clinicId: 'clinic-a', action: Action.DIAGNOSIS_RECORDED, subjectType: 'diagnosis',
subjectId: row.externalId, occurredAt: new Date(row.occurredAt) },
});
expect(facts).toHaveLength(1);
expect(facts[0]!.content).toMatchObject({ code, name_zh: term, code_source: 'image_ai',
source_image_external_id: bundle.image.externalId });
expect(() => validateFactContent(facts[0]!.type, facts[0]!.subjectId, facts[0]!.content)).not.toThrow();
});
test.each(['龋病?', '牙龋坏?', '未见龋病', '可疑牙折裂', '缺牙待排', '根充、根尖周炎',
'种植体周围炎;龋病', '未见牙齿矫正', '__proto__', 'toString'])('%s stays uncoded', term => {
expect(resolveMeyerDiagnosisCode(term, codes)).toBeNull();
});
test('format whitespace is harmless; shared host dictionary is unchanged', () => {
const before = { ...codes };
expect(resolveMeyerDiagnosisCode(' 龋病; ', codes)).toBe('K02');
expect(resolveMeyerDiagnosisCode(' 阻生智齿。 ', codes)).toBe('K01');
expect(resolveMeyerDiagnosisCode('智齿', codes)).toBeNull();
expect(codes).toEqual(before);
expect(codes['智齿']).toBe('K01');
});
test('observation protections take precedence over future shared dictionary additions', () => {
expect(resolveMeyerDiagnosisCode('牙髓石', { ...codes, 牙髓石: 'K04' })).toBeNull();
expect(resolveMeyerDiagnosisCode('牙周牙髓联合病变', { ...codes, 牙周牙髓联合病变: 'K04' })).toBeNull();
expect(resolveMeyerDiagnosisCode('种植体', { ...codes, 种植体: 'K08' })).toBeNull();
});
});
import { resolve } from 'node:path';
import { ConfigSchema, ImageSchema, SymptomSchema, type MeyerBundle, type MeyerConfig } from '../src/integrations/meyer/contracts';
import { loadDiagnosisCodes, mapMeyer } from '../src/integrations/meyer/mapper';
export const meyerConfig = (): MeyerConfig => ConfigSchema.parse({ baseUrl: 'https://example.com/api/v1',
namespace: 'test', host: 'friday', timezone: 'Asia/Shanghai', scopes: [
{ clinicId: 'clinic-a', tenantId: 'friday-market', sourceUnit: 'brand-a' }],
timeStart: '2026-10-01 00:00:00', timeEnd: '2026-10-16 23:59:59', requestIntervalMs: 0 });
export const meyerImage = () => ImageSchema.parse({ imageId: 'image-1', clinicId: 'clinic-a', patientId: 'patient-1',
imageCreateTime: '2026-10-10 10:00:00', studyTime: '2026-10-09 09:00:00', modality: 'pano',
imageUrl: 'https://example.com/temporary?signature=abc', imageOssId: 'oss-1' });
export const meyerSymptom = () => SymptomSchema.parse({ teeth: [
{ position: '16', content: '龋齿', confidence: 0.9 },
{ position: '51', content: '可疑龋' }], diagnosis: '原始综合诊断', description: '描述' });
export const codes = loadDiagnosisCodes(resolve(__dirname, '../data/_shared/dict/diagnosis-kcode.yaml'));
export const meyerBundle = (): MeyerBundle => mapMeyer(meyerConfig(), meyerConfig().scopes[0]!,
meyerImage(), meyerSymptom(), codes, '2026-10-17T00:00:00.000Z')!;
import { createMeyerOpenHttp } from './helpers/meyer-open-http';
import { hashSecret } from '@pac/utils';
import { PatientFactsLookupResponseSchema } from '../src/modules/open/patient-facts-lookup.schema';
import { resolveMeyerScopes } from '../src/integrations/meyer/org-mapping';
import { InputConfigSchema } from '../src/integrations/meyer/contracts';
import { NestFactory } from '@nestjs/core';
import type { INestApplicationContext } from '@nestjs/common';
import { Logger } from '@nestjs/common';
import { FactType } from '@pac/types';
import { PrismaService } from '../src/prisma/prisma.service';
import { MeyerCliModule } from '../src/integrations/meyer/meyer-cli.module';
import { MeyerPacSink } from '../src/integrations/meyer/pac-sink';
import { ParserPipeline } from '../src/modules/sync/pipeline/parser-pipeline.service';
import { meyerBundle } from './meyer-imaging-fixtures';
import { meyerConfig, meyerImage, meyerSymptom } from './meyer-imaging-fixtures';
import { runMeyerCli } from '../src/cli/meyer-imaging.cli';
import { mkdtemp, writeFile, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { createServer } from 'node:http';
const url = process.env.PAC_MEYER_TEST_DATABASE_URL;
const describePg = url ? describe : describe.skip;
describePg('Meyer | isolated PostgreSQL + actual minimal CLI module', () => {
let app: INestApplicationContext;
let prisma: PrismaService;
let sink: MeyerPacSink;
let hostId: string;
let patientId: string;
let otherPatientId: string;
let foreignHostId: string;
const previousUrl = process.env.DATABASE_URL;
beforeAll(async () => {
const parsed = new URL(url!);
if (!['127.0.0.1', 'localhost'].includes(parsed.hostname) || !parsed.pathname.startsWith('/pac_meyer_ci_')) {
throw new Error('requires isolated local pac_meyer_ci_ database');
}
process.env.DATABASE_URL = url;
Logger.overrideLogger(false);
app = await NestFactory.createApplicationContext(MeyerCliModule, { logger: false, abortOnError: false });
prisma = app.get(PrismaService); sink = app.get(MeyerPacSink);
hostId = (await prisma.host.create({ data: { name: 'friday', appId: 'meyer-test', appSecretHash: 'test' } })).id;
foreignHostId = (await prisma.host.create({ data: { name: 'jvs-dw', appId: 'other', appSecretHash: 'test' } })).id;
patientId = (await prisma.patient.create({ data: { hostId, tenantId: 'friday-market', sourceUnit: 'brand-a', externalId: 'patient-1' } })).id;
otherPatientId = (await prisma.patient.create({ data: { hostId, tenantId: 'friday-market', sourceUnit: 'brand-b', externalId: 'patient-1' } })).id;
await prisma.patient.create({ data: { hostId: foreignHostId, tenantId: 'friday-market', sourceUnit: 'brand-a', externalId: 'patient-1' } });
await prisma.$executeRawUnsafe("CREATE UNIQUE INDEX meyer_ci_active ON patient_facts(host_id,tenant_id,source_unit,subject_id) WHERE status='active'");
await prisma.$executeRawUnsafe("CREATE UNIQUE INDEX meyer_ci_event ON patient_transactions(host_id,tenant_id,source_event_id) WHERE source_event_id IS NOT NULL");
});
beforeEach(async () => {
await prisma.patientFact.deleteMany({ where: { hostId } });
await prisma.patientTransaction.deleteMany({ where: { hostId } });
await prisma.patient.deleteMany({ where: { hostId, externalId: 'patient-2' } });
});
afterAll(async () => {
await app?.close();
if (previousUrl === undefined) delete process.env.DATABASE_URL; else process.env.DATABASE_URL = previousUrl;
});
async function hostRecord(patient = patientId, tenantId = 'friday-market') {
return prisma.patientTransaction.create({ data: { hostId, tenantId, patientId: patient,
clinicId: 'clinic-a', action: 'visit_registered', subjectType: 'visit', subjectId: 'host-visit',
rawPayload: {}, payloadHash: 'test', occurredAt: new Date() } });
}
test('automatic mapping rejects absent, conflicting and cross-tenant records; excludes supplemental evidence', async () => {
await expect(resolveMeyerScopes(prisma, ['clinic-a'])).rejects.toThrow('missing');
await sink.apply(meyerBundle());
await expect(resolveMeyerScopes(prisma, ['clinic-a'])).rejects.toThrow('missing');
const a = await hostRecord();
expect(await resolveMeyerScopes(prisma, ['clinic-a'])).toEqual(meyerConfig().scopes);
await expect(resolveMeyerScopes(prisma, ['clinic-a', 'absent-clinic'])).rejects.toThrow('missing');
const b = await hostRecord(otherPatientId);
await expect(resolveMeyerScopes(prisma, ['clinic-a'])).rejects.toThrow('conflicting');
await prisma.patientTransaction.delete({ where: { id: b.id } });
const wrongTenant = await hostRecord(patientId, 'other-tenant');
await expect(resolveMeyerScopes(prisma, ['clinic-a'])).rejects.toThrow('conflicting');
await prisma.patientTransaction.delete({ where: { id: wrongTenant.id } });
await prisma.patientTransaction.delete({ where: { id: a.id } });
await prisma.host.update({ where: { id: hostId }, data: { active: false } });
await expect(resolveMeyerScopes(prisma, ['clinic-a'])).rejects.toThrow('inactive');
await prisma.host.update({ where: { id: hostId }, data: { active: true } });
expect(InputConfigSchema.safeParse(meyerConfig()).success).toBe(false);
});
test('standard image + K02 diagnosis reach facts, repeat does not create versions, original evidence retained', async () => {
const bundle = meyerBundle();
await sink.apply(bundle); await sink.apply(bundle);
const facts = await prisma.patientFact.findMany({ where: { hostId, patientId } });
expect(facts).toHaveLength(3);
expect((facts.find(f => f.type === FactType.IMAGE_RECORD)!.content as any).analysis).toEqual(bundle.image.analysis);
expect(facts.every(f => f.version === 1 && f.sourceUnit === 'brand-a')).toBe(true);
const decay = facts.find(f => (f.content as Record<string, unknown>).code === 'K02')!;
expect(decay).toMatchObject({ type: FactType.DIAGNOSIS_RECORD, kind: 'actual', status: 'active' });
expect(decay.occurredAt?.toISOString()).toBe('2026-10-09T01:00:00.000Z');
expect(decay.content).toMatchObject({ code_source: 'image_ai', tooth_position: '16', source_image_external_id: bundle.image.externalId });
const tx = await prisma.patientTransaction.findFirstOrThrow({ where: { id: decay.transactionIds[0] } });
expect(tx.rawPayload).toMatchObject({ _meyer: { timeBasis: 'studyTime', observedAt: bundle.observedAt,
symptom: { diagnosis: '原始综合诊断' } } });
expect(await prisma.patientTransaction.count({ where: { hostId } })).toBe(3);
expect(await prisma.followupPlan.count({ where: { hostId } })).toBe(0);
expect(await prisma.persona.count({ where: { hostId } })).toBe(0);
});
test('old bundles without analysis still replay without changing existing image versions', async () => {
const legacy = meyerBundle(); delete legacy.image.analysis;
await sink.apply(legacy); await sink.apply(legacy);
const facts = await prisma.patientFact.findMany({ where: { hostId, patientId } });
expect(facts).toHaveLength(3);
expect(facts.every(f => f.version === 1)).toBe(true);
expect(facts.find(f => f.type === FactType.IMAGE_RECORD)!.content).not.toHaveProperty('analysis');
// 本次不启用历史重写;丢失旧快照后重新映射也不得悄悄覆盖既有事实。
await expect(sink.apply(meyerBundle())).rejects.toThrow('snapshot differs');
expect(await prisma.patientFact.count({ where: { hostId } })).toBe(3);
});
test('same external patient/image IDs stay isolated across Friday brands; other host untouched', async () => {
const a = meyerBundle(); const b = meyerBundle(); b.scope.sourceUnit = 'brand-b';
await sink.apply(a); await sink.apply(b);
expect(await prisma.patientFact.count({ where: { hostId, patientId } })).toBe(3);
expect(await prisma.patientFact.count({ where: { hostId, patientId: otherPatientId } })).toBe(3);
expect(await prisma.patientFact.count({ where: { hostId: foreignHostId } })).toBe(0);
expect(await prisma.patientTransaction.count({ where: { hostId: foreignHostId } })).toBe(0);
expect(await prisma.patient.count()).toBe(3);
});
test('unknown scoped patient and mismatched canonical bundle fail without creating patients', async () => {
const b = meyerBundle(); b.scope.sourceUnit = 'not-present';
await expect(sink.apply(b)).rejects.toThrow('patient not present');
const mixed = meyerBundle(); mixed.diagnoses[0]!.patientExternalId = 'another';
await expect(sink.apply(mixed)).rejects.toThrow('ownership mismatch');
expect(await prisma.patientTransaction.count({ where: { hostId } })).toBe(0);
expect(await prisma.patient.count()).toBe(3);
});
test('fact failure after transaction insertion recovers through same event without duplicate facts', async () => {
const pipeline = app.get(ParserPipeline);
const original = pipeline.runForTransaction.bind(pipeline);
let calls = 0;
const spy = jest.spyOn(pipeline, 'runForTransaction').mockImplementation(async input => {
if (++calls === 2) throw new Error('simulated failure after transaction commit');
return original(input);
});
try {
await expect(sink.apply(meyerBundle())).rejects.toThrow('simulated');
expect(await prisma.patientFact.count({ where: { hostId } })).toBe(1);
} finally { spy.mockRestore(); }
await sink.apply(meyerBundle());
expect(await prisma.patientFact.count({ where: { hostId } })).toBe(3);
expect(await prisma.patientTransaction.count({ where: { hostId } })).toBe(3);
});
test('lost local state cannot silently append a revised analysis or move ownership', async () => {
await sink.apply(meyerBundle());
const revised = meyerBundle(); revised.diagnoses[0]!.name = 'different';
await expect(sink.apply(revised)).rejects.toThrow('snapshot differs');
expect(await prisma.patientFact.count({ where: { hostId } })).toBe(3);
const moved = meyerBundle(); moved.image.patientExternalId = 'patient-2';
moved.diagnoses.forEach(d => { d.patientExternalId = 'patient-2'; });
await prisma.patient.create({ data: { hostId, tenantId: 'friday-market', sourceUnit: 'brand-a', externalId: 'patient-2' } });
await expect(sink.apply(moved)).rejects.toThrow('snapshot differs');
expect(await prisma.patientFact.count({ where: { hostId, patientId } })).toBe(3);
});
test('CLI collection → PostgreSQL → application token → patient facts HTTP, with replay and isolation', async () => {
await hostRecord();
const directory = await mkdtemp(join(tmpdir(), 'pac-meyer-e2e-'));
const server = createServer(async (req, res) => {
for await (const _ of req) { /* drain */ }
const data = req.url!.endsWith('/tokens/pub') ? {
accessToken: 'test-token', accessExpiresAt: new Date(Date.now() + 600000).toISOString(),
} : req.url!.endsWith('/query-by-clinic') ? {
total: 1, page: 1, size: 100, images: [meyerImage()],
} : meyerSymptom();
res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ code: 200, data }));
});
const previousKey = process.env.MEYER_APP_KEY; const previousSecret = process.env.MEYER_APP_SECRET;
const output = jest.spyOn(console, 'log').mockImplementation(() => {});
let open: Awaited<ReturnType<typeof createMeyerOpenHttp>> | undefined;
try {
await new Promise<void>(r => server.listen(0, '127.0.0.1', r));
const port = (server.address() as { port: number }).port;
const config = { ...meyerConfig(), timeStart: '2026-10-10 09:00:00', baseUrl: `http://127.0.0.1:${port}/api/v1` };
await writeFile(join(directory, 'config.json'), JSON.stringify((({ scopes, ...rest }) => ({ ...rest, clinicIds: scopes.map(s => s.clinicId) }))(config)));
process.env.MEYER_APP_KEY = 'key'; process.env.MEYER_APP_SECRET = 'secret';
const args = [`--config=${join(directory, 'config.json')}`, `--state-dir=${join(directory, 'state')}`, '--apply'];
expect(await runMeyerCli([...args, '--tick'])).toBe(0);
expect(await runMeyerCli([...args, '--tick'])).toBe(0);
expect(await prisma.patientFact.count({ where: { hostId, patientId } })).toBe(3);
expect(await prisma.patientTransaction.count({ where: { hostId } })).toBe(4);
await prisma.host.update({ where: { id: hostId }, data: {
appSecretHash: hashSecret('local-meyer-open'), openFactsReadEnabled: true,
clinicNames: { 'clinic-a': '本地影像验收诊所' },
} });
await prisma.host.update({ where: { id: foreignHostId }, data: {
appSecretHash: hashSecret('local-other-open'), openFactsReadEnabled: true,
} });
await prisma.patient.updateMany({ where: { hostId }, data: { medicalRecordNumber: 'MEYER-001' } });
open = await createMeyerOpenHttp(prisma);
const login = await open.request('/auth/token', { appId: 'meyer-test', appSecret: 'local-meyer-open' });
expect(login.code).toBe(0);
const token = login.data.accessToken;
const query = { medicalRecordNumber: 'MEYER-001' };
expect((await open.request('/open/patient-facts/lookup', query)).code).toBe(10106);
const result = await open.request('/open/patient-facts/lookup', query, token);
expect(result.code).toBe(0);
expect(PatientFactsLookupResponseSchema.safeParse(result.data).success).toBe(true);
expect(result.data.items).toHaveLength(2); // 同宿主跨品牌同病历号,患者仍各自隔离。
const patient = result.data.items.find((i: any) => i.patient.id === patientId);
const brandB = result.data.items.find((i: any) => i.patient.id === otherPatientId);
expect(brandB.facts).toEqual([]);
expect(patient.patient.externalId).toBe('patient-1');
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: '本地影像验收诊所',
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.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);
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 } });
expect(diagnosis.content).not.toHaveProperty('sourceAnalysisExternalId');
expect(diagnosis.content).not.toHaveProperty('code_source');
}
expect(diagnoses.find((d: any) => d.content.name === '龋齿').content).toMatchObject({ code: 'K02', toothPosition: '16' });
expect(diagnoses.find((d: any) => d.content.name === '可疑龋').content).toMatchObject({ code: null, toothPosition: '51' });
expect(JSON.stringify(result)).not.toMatch(/signature=|oss-1|confidence|_meyer/);
const other = await open.request('/auth/token', { appId: 'other', appSecret: 'local-other-open' });
expect(other.code).toBe(0);
expect((await open.request('/open/patient-facts/lookup', query, other.data.accessToken)).data.items).toEqual([]);
// 固定窗口重放;静态源样本不冒充后续游标窗口的新影像。
expect(await runMeyerCli(args)).toBe(0);
expect((await open.request('/open/patient-facts/lookup', query, token)).data).toEqual(result.data);
expect(await prisma.followupPlan.count()).toBe(0);
expect(await prisma.persona.count()).toBe(0);
await hostRecord(otherPatientId);
await expect(runMeyerCli([...args, '--tick'])).rejects.toThrow('conflicting');
expect(await prisma.patientFact.count({ where: { hostId, patientId } })).toBe(3);
} finally {
await open?.close();
output.mockRestore();
if (previousKey === undefined) delete process.env.MEYER_APP_KEY; else process.env.MEYER_APP_KEY = previousKey;
if (previousSecret === undefined) delete process.env.MEYER_APP_SECRET; else process.env.MEYER_APP_SECRET = previousSecret;
server.closeAllConnections(); await new Promise<void>(r => server.close(() => r()));
await rm(directory, { recursive: true, force: true });
}
});
});
import * as orgMapping from '../src/integrations/meyer/org-mapping';
import { mkdtemp, readFile, rm, stat } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { createServer, type Server } from 'node:http';
import { ConfigSchema, ImageSchema, SymptomSchema, imageKey, sourceTime,
type MeyerBundle, type MeyerConfig, type MeyerSource } from '../src/integrations/meyer/contracts';
import { MeyerClient } from '../src/integrations/meyer/client';
import { collectMeyer } from '../src/integrations/meyer/collector';
import { loadDiagnosisCodes, mapMeyer } from '../src/integrations/meyer/mapper';
import { MeyerStore, type SavedBundle } from '../src/integrations/meyer/store';
import { parseMeyerArgs, runMeyerCli } from '../src/cli/meyer-imaging.cli';
import { ImageParser } from '../src/modules/sync/pipeline/parsers/image.parser';
import { DiagnosisParser } from '../src/modules/sync/pipeline/parsers/diagnosis.parser';
import { validateFactContent } from '../src/modules/sync/pipeline/fact-content-schemas';
import type { ParserContext } from '../src/modules/sync/pipeline/parsers/parser.interface';
import { Action, FactType, ImageStudyCanonicalSchema, ImageEmbeddedAnalysisSchema } from '@pac/types';
import { meyerConfig, meyerImage, meyerSymptom, meyerBundle, codes } from './meyer-imaging-fixtures';
describe('Meyer canonical boundary', () => {
test('vendor IMAGE_2D is normalized before the PAC fact schema boundary', () => {
const config = meyerConfig();
const bundle = mapMeyer(config, config.scopes[0]!, { ...meyerImage(), modality: 'IMAGE_2D', imageKind: 'IMAGE' },
meyerSymptom(), codes, '2026-10-17T00:00:00.000Z')!;
expect(bundle.image.modality).toBe('other');
expect(bundle.raw.image.modality).toBe('IMAGE_2D');
const facts = new ImageParser().parse({ transaction: { id: 'tx', hostId: 'host', tenantId: 'tenant',
patientId: 'patient', action: Action.IMAGE_UPLOADED, subjectType: 'image_study', subjectId: bundle.image.externalId,
clinicId: 'clinic-a', occurredAt: new Date(bundle.image.uploadedAt) }, canonicalRow: bundle.image });
for (const fact of facts) expect(() => validateFactContent(fact.type, fact.subjectId, fact.content)).not.toThrow();
});
test('FDI + Chinese shared dictionary → existing PAC image/diagnosis facts', () => {
const bundle = meyerBundle();
expect(bundle.image.uploadedAt).toBe('2026-10-10T02:00:00.000Z');
expect(bundle.image).not.toHaveProperty('fileUrl');
const decay = bundle.diagnoses.find(d => d.name === '龋齿')!;
expect(decay).toMatchObject({ code: 'K02', codeSource: 'image_ai', toothPosition: '16',
occurredAt: '2026-10-09T01:00:00.000Z', sourceImageExternalId: bundle.image.externalId });
expect(bundle.diagnoses.find(d => d.name === '可疑龋')!.code).toBeNull();
const ctx: ParserContext = { transaction: { id: 'tx', hostId: 'host', tenantId: 'tenant', patientId: 'patient',
action: Action.DIAGNOSIS_RECORDED, subjectType: 'diagnosis', subjectId: decay.externalId,
clinicId: 'clinic-a', occurredAt: new Date(decay.occurredAt) }, canonicalRow: decay };
const facts = [...new DiagnosisParser().parse(ctx), ...new ImageParser().parse({ ...ctx, canonicalRow: bundle.image })];
for (const fact of facts) expect(() => validateFactContent(fact.type, fact.subjectId, fact.content)).not.toThrow();
expect(facts[0]!.content).toMatchObject({ code: 'K02', code_source: 'image_ai', tooth_position: '16' });
expect(bundle.raw.symptom.diagnosis).toBe('原始综合诊断');
expect(bundle.image.analysis).toEqual({ source: 'meyer', summary: '原始综合诊断', description: '描述',
analyzedAt: null, findings: bundle.diagnoses.map(d => ({ name: d.name, toothPosition: d.toothPosition, code: d.code })) });
expect(facts.find(f => f.type === FactType.IMAGE_RECORD)!.content).toMatchObject({ analysis: bundle.image.analysis });
expect(JSON.stringify(bundle.image.analysis)).not.toMatch(/confidence|imageOssId|signature/);
});
test('array ordering and duplicates do not change finding identities; namespace isolates sources', () => {
const config = meyerConfig();
const first = meyerBundle();
const symptom = meyerSymptom();
symptom.teeth.reverse(); symptom.teeth.push(symptom.teeth[0]!);
const next = mapMeyer(config, config.scopes[0]!, meyerImage(), symptom, codes, first.observedAt)!;
expect(next.diagnoses).toEqual(first.diagnoses);
expect(next.image.analysis).toEqual(first.image.analysis);
expect(mapMeyer({ ...config, namespace: 'other' }, config.scopes[0]!, meyerImage(), symptom, codes,
first.observedAt)!.image.externalId).not.toBe(first.image.externalId);
});
test('unknown time / empty result / unbound image never fabricate completion or clinical time', () => {
const config = meyerConfig();
const project = (image: ReturnType<typeof meyerImage>, symptom = meyerSymptom()) =>
mapMeyer(config, config.scopes[0]!, image, symptom, codes, '2026-10-17T00:00:00.000Z');
expect(project({ ...meyerImage(), studyTime: null })).toBeNull();
expect(project({ ...meyerImage(), imageCreateTime: null })).toBeNull();
expect(project({ ...meyerImage(), patientId: null })).toBeNull();
expect(project(meyerImage(), { teeth: [], diagnosis: '', description: '' })).toBeNull();
expect(() => project({ ...meyerImage(), clinicId: 'wrong' })).toThrow('clinic');
expect(() => project({ ...meyerImage(), studyTime: '2099-01-01 00:00:00' })).toThrow('future');
});
test('no inferred disease code for uncertain or negative text; malformed teeth fail closed', () => {
const c = meyerConfig();
const bundle = mapMeyer(c, c.scopes[0]!, meyerImage(), { teeth: [
{ position: '16', content: '未见龋齿' }, { position: '26', content: '龋齿?' }], diagnosis: '', description: '' },
codes, '2026-10-17T00:00:00.000Z')!;
expect(bundle.diagnoses.every(d => d.code === null)).toBe(true);
expect(SymptomSchema.safeParse({ teeth: null, diagnosis: '', description: '' }).success).toBe(false);
expect(SymptomSchema.safeParse({ teeth: [{ position: '99', content: '龋齿' }], diagnosis: '', description: '' }).success).toBe(false);
});
test('shared analysis contract validates known fields without leaking vendor extensions', () => {
const analysis = meyerBundle().image.analysis!;
expect(ImageEmbeddedAnalysisSchema.parse({ ...analysis, source: 'another-provider', raw: { secret: 'private' },
findings: analysis.findings.map(f => ({ ...f, confidence: 0.9 })) })).toEqual({ ...analysis, source: 'another-provider' });
for (const invalid of [
{ ...analysis, analyzedAt: '2026-10-19 10:00:00' },
{ ...analysis, analyzedAt: '2026-02-30T00:00:00Z' },
{ ...analysis, findings: [{ name: 'unknown', toothPosition: null, code: 'vendor:unknown' }] },
{ ...analysis, findings: null },
]) expect(ImageEmbeddedAnalysisSchema.safeParse(invalid).success).toBe(false);
expect(ImageEmbeddedAnalysisSchema.parse({ ...analysis, analyzedAt: '2026-10-19T10:00:00+08:00' }).analyzedAt)
.toBe('2026-10-19T02:00:00.000Z');
});
test('legacy images keep finding and never manufacture analysis from a patient or encounter', () => {
const { analysis: _analysis, ...legacy } = meyerBundle().image;
const canonical = ImageStudyCanonicalSchema.parse({ ...legacy, encounterExternalId: 'emr-1', finding: '源所见' });
expect(canonical).not.toHaveProperty('analysis');
const transaction = { id: 'tx', hostId: 'host', tenantId: 'tenant', patientId: 'patient',
action: Action.IMAGE_UPLOADED, subjectType: 'image_study', subjectId: canonical.externalId,
clinicId: 'clinic-a', occurredAt: new Date(canonical.uploadedAt) };
const parse = (canonicalRow: Record<string, unknown>) => new ImageParser().parse({ transaction, canonicalRow })[0]!;
const legacyFact = parse(canonical);
expect(legacyFact.content).toMatchObject({ finding: '源所见', encounter_external_id: 'emr-1' });
expect(validateFactContent(legacyFact.type, legacyFact.subjectId, legacyFact.content)).not.toHaveProperty('analysis');
expect(parse({ ...canonical, analysis: null }).content).toMatchObject({ analysis: null });
const enriched = parse({ ...canonical, analysis: meyerBundle().image.analysis });
expect(enriched.content).toMatchObject({ finding: '源所见' });
expect(enriched.summary).toBe('源所见');
expect(() => parse({ ...canonical, analysis: { source: 'invalid' } })).toThrow();
});
test('strict source time and configuration', () => {
expect(sourceTime('2026-01-01 00:00:00')).toBe('2025-12-31T16:00:00.000Z');
expect(() => sourceTime('2026-02-30 00:00:00')).toThrow();
expect(() => sourceTime('2026-01-01')).toThrow();
const c = meyerConfig();
expect(ConfigSchema.safeParse({ ...c, host: 'jvs-dw' }).success).toBe(false);
expect(ConfigSchema.safeParse({ ...c, scopes: [...c.scopes, ...c.scopes] }).success).toBe(false);
expect(ConfigSchema.safeParse({ ...c, timeStart: '2026-10-17 00:00:00' }).success).toBe(false);
expect(ConfigSchema.safeParse({ ...c, baseUrl: 'http://remote.example/api/v1' }).success).toBe(false);
expect(() => parseMeyerArgs(['--unknown'])).toThrow();
expect(() => parseMeyerArgs(['--apply'])).toThrow();
expect(parseMeyerArgs(['--config=a', '--state-dir=b']).apply).toBe(false);
});
});
describe('Meyer collector durable retry and isolation', () => {
let directory: string;
let store: MeyerStore;
beforeEach(async () => { directory = await mkdtemp(join(tmpdir(), 'pac-meyer-')); store = new MeyerStore(directory); });
afterEach(async () => { await rm(directory, { recursive: true, force: true }); });
const source = (): MeyerSource => ({
list: jest.fn(async (_scope, page) => ({ total: 1, page, size: 100, images: [meyerImage()] })),
symptom: jest.fn(async () => meyerSymptom()),
});
test('preview does not mark applied; partial sink failure retries the retained canonical snapshot', async () => {
const c = meyerConfig(); const api = source();
expect((await collectMeyer(c, api, store, codes)).collected).toBe(1);
const retained = await store.read<SavedBundle>(`bundle-${imageKey(c.scopes[0]!, 'image-1')}`);
expect(retained?.applied).toBe(false);
const failedSink = { apply: jest.fn(async () => { throw new Error('partial fact failure'); }) };
expect((await collectMeyer(c, api, store, codes, failedSink)).failed).toBe(1);
const sink = { apply: jest.fn(async () => {}) };
expect((await collectMeyer(c, api, store, codes, sink)).applied).toBe(1);
expect(sink.apply).toHaveBeenCalledWith(retained!.bundle);
expect((await collectMeyer(c, api, store, codes, sink)).alreadyApplied).toBe(1);
expect(sink.apply).toHaveBeenCalledTimes(1);
expect(api.symptom).toHaveBeenCalledTimes(1);
const file = join(directory, `bundle-${imageKey(c.scopes[0]!, 'image-1')}.json`);
expect((await stat(file)).mode & 0o777).toBe(0o600);
});
test('retrying an older retained bundle does not rewrite its canonical snapshot', async () => {
const c = meyerConfig(); const api = source();
await collectMeyer(c, api, store, codes);
const key = `bundle-${imageKey(c.scopes[0]!, 'image-1')}`;
const saved = (await store.read<SavedBundle>(key))!;
delete saved.bundle.image.analysis;
await store.write(key, saved);
const sink = { apply: jest.fn(async (_bundle: MeyerBundle) => {}) };
expect((await collectMeyer(c, api, store, codes, sink)).applied).toBe(1);
expect(sink.apply).toHaveBeenCalledWith(saved.bundle);
expect(sink.apply.mock.calls[0]![0].image).not.toHaveProperty('analysis');
expect(api.symptom).toHaveBeenCalledTimes(1);
});
test('empty results remain eligible on rerun, never claim a zero-finding completed analysis', async () => {
const api = source(); api.symptom = jest.fn(async () => ({ teeth: [], diagnosis: '', description: '' }));
expect((await collectMeyer(meyerConfig(), api, store, codes)).deferred).toBe(1);
api.symptom = jest.fn(async () => meyerSymptom());
expect((await collectMeyer(meyerConfig(), api, store, codes)).collected).toBe(1);
});
test('wrong clinic and changed owner do not produce writes', async () => {
const api = source(); const c = meyerConfig();
await collectMeyer(c, api, store, codes);
api.list = jest.fn(async (_s, page) => ({ total: 1, size: 100, page,
images: [{ ...meyerImage(), patientId: 'another' }] }));
const sink = { apply: jest.fn(async () => {}) };
expect((await collectMeyer(c, api, store, codes, sink)).failed).toBe(1);
expect(sink.apply).not.toHaveBeenCalled();
api.list = jest.fn(async (_s, page) => ({ total: 1, size: 100, page,
images: [{ ...meyerImage(), clinicId: 'another' }] }));
expect((await collectMeyer(c, api, store, codes, sink)).failed).toBe(1);
});
test('pagination change / incomplete page / limit return failure instead of truncation success', async () => {
const c = { ...meyerConfig(), pageSize: 1 };
const api = source();
api.list = jest.fn(async (_s, page) => ({ total: page === 1 ? 2 : 3, size: 1, page, images: [meyerImage()] }));
expect((await collectMeyer(c, api, store, codes)).errors[0]?.reason).toContain('count changed');
api.list = jest.fn(async (_s, page) => ({ total: 2, size: 1, page, images: [] }));
expect((await collectMeyer(c, api, store, codes)).failed).toBe(1);
api.list = jest.fn(async (_s, page) => ({ total: 2, size: 1, page, images: [meyerImage()] }));
expect((await collectMeyer({ ...c, maxPages: 1 }, api, store, codes)).errors[0]?.reason).toContain('page limit');
});
test('state locks exclude concurrent runs and prohibit remapping state to other scope', async () => {
const release = await store.acquire({ app: 'a' });
await expect(store.acquire({ app: 'a' })).rejects.toThrow('locked');
await release();
await expect(store.acquire({ app: 'b' })).rejects.toThrow('scope/config');
const again = await store.acquire({ app: 'a' }); await again();
});
});
describe('Meyer actual HTTP contract', () => {
let server: Server;
let config: MeyerConfig;
let handler: (url: string, body: any, authorization?: string) => { status?: number; body: unknown; delay?: number };
beforeEach(async () => {
server = createServer(async (req, res) => {
const chunks: Buffer[] = []; for await (const c of req) chunks.push(Buffer.from(c));
const response = handler(req.url!, JSON.parse(Buffer.concat(chunks).toString()), req.headers.authorization);
if (response.delay) await new Promise(r => setTimeout(r, response.delay));
res.writeHead(response.status ?? 200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(response.body));
});
await new Promise<void>(r => server.listen(0, '127.0.0.1', r));
const addr = server.address() as { port: number };
config = { ...meyerConfig(), baseUrl: `http://127.0.0.1:${addr.port}/api/v1` };
});
afterEach(async () => { server.closeAllConnections(); await new Promise<void>(r => server.close(() => r())); });
const auth = () => ({ code: 200, data: { accessToken: 'test-token', accessExpiresAt: new Date(Date.now() + 600000).toISOString() } });
test('authentication, complete patient ownership request, and HTTP-200 business errors', async () => {
const calls: string[] = [];
handler = (url, body, token) => {
calls.push(url);
if (url.endsWith('/tokens/pub')) {
expect(body).toEqual({ appKey: 'key', secret: 'secret', clientType: 'EXTERNAL_APP' });
return { body: auth() };
}
expect(token).toBe('Bearer test-token');
if (url.endsWith('/query-by-clinic')) {
expect(body).toMatchObject({ clinicId: 'clinic-a', page: 1, size: 100, timeStart: config.timeStart });
return { body: { code: 200, data: { total: 1, page: 1, size: 100, images: [meyerImage()] } } };
}
expect(body).toEqual({ imageClinicId: 'clinic-a', patientId: 'patient-1', imageId: 'image-1' });
return { body: { code: 500, msg: 'private patient info' } };
};
const client = new MeyerClient(config, { appKey: 'key', secret: 'secret' });
expect((await client.list(config.scopes[0]!, 1)).total).toBe(1);
await expect(client.symptom(meyerImage())).rejects.toThrow('business failure code=500');
expect(calls.filter(c => c.endsWith('/tokens/pub'))).toHaveLength(1);
});
test('file URLs use file IDs, reject foreign clinic and preserve expiry without fetching files', async () => {
let calls = 0;
handler = (url, body) => {
if (url.endsWith('/tokens/pub')) return { body: auth() };
calls++; expect(url.endsWith('/images/urls')).toBe(true);
expect(body).toEqual({ clinicId: 'clinic-a', ossIds: ['file-id'] });
return { body: { code: 200, data: { urls: [{ ossId: 'file-id', found: true,
url: 'https://example.com/object.png?signature=private', expiresAt: '2026-10-17 13:40:36' }] } } };
};
const client = new MeyerClient(config, { appKey: 'key', secret: 'secret' });
await expect(client.fileUrls('foreign', ['file-id'])).rejects.toThrow('outside');
expect((await client.fileUrls('clinic-a', ['file-id']))[0]!.expiresAt).toBe('2026-10-17 13:40:36');
expect(calls).toBe(1);
handler = url => ({ body: url.endsWith('/tokens/pub') ? auth() : { code: 200,
data: { urls: [{ ossId: 'wrong-id', found: false, url: null, expiresAt: null }] } } });
await expect(client.fileUrls('clinic-a', ['file-id'])).rejects.toThrow('invalid file URL');
});
test('401 retries authentication once; timeout is bounded and secrets absent from errors', async () => {
let failures = 0; let logins = 0;
handler = url => {
if (url.endsWith('/tokens/pub')) { logins++; return { body: auth() }; }
failures++; return { body: { code: 401, msg: 'secret' } };
};
const client = new MeyerClient(config, { appKey: 'key', secret: 'secret' });
await expect(client.symptom(meyerImage())).rejects.toThrow('code=401');
expect(failures).toBe(2); expect(logins).toBe(2);
handler = () => ({ body: auth(), delay: 200 });
const slow = new MeyerClient({ ...config, timeoutMs: 100 }, { appKey: 'key', secret: 'secret' });
await expect(slow.symptom(meyerImage())).rejects.toThrow('transport failed');
});
test('CLI preview uses resolved organization mapping and persists canonical bundle', async () => {
const dir = await mkdtemp(join(tmpdir(), 'pac-meyer-cli-'));
const oldKey = process.env.MEYER_APP_KEY; const oldSecret = process.env.MEYER_APP_SECRET;
const mapping = jest.spyOn(orgMapping, 'loadMeyerScopes').mockResolvedValue(config.scopes);
const oldDb = process.env.DATABASE_URL;
process.env.MEYER_APP_KEY = 'key'; process.env.MEYER_APP_SECRET = 'secret'; process.env.DATABASE_URL = 'postgresql://test:test@127.0.0.1:5532/pac_meyer_ci_mock';
const stdout = jest.spyOn(console, 'log').mockImplementation(() => {});
try {
handler = url => ({ body: url.endsWith('/tokens/pub') ? auth() : { code: 200,
data: url.endsWith('/query-by-clinic') ? { total: 1, page: 1, size: 100, images: [meyerImage()] } : meyerSymptom() } });
const { writeFile } = await import('node:fs/promises');
await writeFile(join(dir, 'config.json'), JSON.stringify((({ scopes, ...rest }) => ({ ...rest, clinicIds: scopes.map(s => s.clinicId) }))(config)));
expect(await runMeyerCli([`--config=${join(dir, 'config.json')}`, `--state-dir=${join(dir, 'state')}`])).toBe(0);
const report = JSON.parse(await readFile(join(dir, 'state/last-run.json'), 'utf8'));
expect(report).toMatchObject({ apply: false, collected: 1, applied: 0, failed: 0 });
expect(stdout.mock.calls.flat().join('')).not.toContain('patient-1');
handler = url => ({ body: url.endsWith('/tokens/pub') ? auth() : { code: 200, data: { urls: [
{ ossId: 'oss-1', found: true, url: 'https://example.com/original.png?signature=private', expiresAt: '2026-10-17 13:40:36' },
] } } });
expect(await runMeyerCli([`--config=${join(dir, 'config.json')}`, `--state-dir=${join(dir, 'state')}`, '--image-files=image-1'])).toBe(0);
const access = JSON.parse(await readFile(join(dir, 'state', `access-${imageKey(config.scopes[0]!, 'image-1')}.json`), 'utf8'));
expect(access.urls[0].found).toBe(true);
expect(stdout.mock.calls.flat().join('')).not.toContain('signature=private');
} finally {
stdout.mockRestore(); mapping.mockRestore();
for (const [key, value] of Object.entries({ MEYER_APP_KEY: oldKey, MEYER_APP_SECRET: oldSecret, DATABASE_URL: oldDb })) {
if (value === undefined) delete process.env[key]; else process.env[key] = value;
}
await rm(dir, { recursive: true, force: true });
}
});
});
import { mkdtemp, rm, mkdir, writeFile } from 'node:fs/promises';
import { hostname, tmpdir } from 'node:os';
import { spawn } from 'node:child_process';
import { join } from 'node:path';
import { MeyerStore } from '../src/integrations/meyer/store';
import { runMeyerTick } from '../src/integrations/meyer/scheduler';
import { watchMeyer, withRoundDeadline } from '../src/integrations/meyer/watch';
import { meyerConfig, meyerImage, meyerSymptom, codes } from './meyer-imaging-fixtures';
import type { MeyerSource, MeyerBundle } from '../src/integrations/meyer/contracts';
describe('Meyer conservative limits and recovery', () => {
let dir: string;
beforeEach(async () => { dir = await mkdtemp(join(tmpdir(), 'meyer-resume-')); });
afterEach(async () => { await rm(dir, { recursive: true, force: true }); });
const now = Date.parse('2026-10-10T03:00:00Z');
const config = () => ({ ...meyerConfig(), timeStart: '2026-10-10 09:00:00', maxNewImagesPerTick: 2, pageSize: 4 });
const images = () => Array.from({ length: 5 }, (_, i) => ({ ...meyerImage(), imageId: `image-${i}` }));
const source = (rows: ReturnType<typeof images>): MeyerSource => ({
list: async (_scope, page) => ({ total: rows.length, page, size: 4, images: rows.slice((page - 1) * 4, page * 4) }),
symptom: async () => meyerSymptom(),
});
test('row checkpoint survives restart; cursor waits for all pages; renewed URLs do not reset progress', async () => {
const rows = images(), c = config(); const applied: string[] = [];
const sink = { apply: async (b: MeyerBundle) => { applied.push(b.raw.image.imageId); } };
const tick = () => runMeyerTick(c, () => source(rows), new MeyerStore(dir), codes, sink, now);
const first = await tick(); expect(first.scan?.examined).toBe(2); expect(first.cursor).toBeNull();
expect(await new MeyerStore(dir).read('scan-progress')).toMatchObject({ page: 1, row: 2 });
rows.forEach(r => { Object.assign(r, { imageUrl: 'https://example.com/renewed-signature' }); });
const second = await tick(); expect(second.scan?.examined).toBe(2); expect(second.cursor).toBeNull();
const third = await tick(); expect(third.scan?.examined).toBe(1); expect(third.scan?.complete).toBe(true);
expect(third.cursor).toEqual({ through: '2026-10-10 10:00:00' });
expect(applied).toEqual(rows.map(r => r.imageId));
});
test('offset shift restarts fixed window and does not skip inserted image', async () => {
const rows = images(), c = config(), written: string[] = [];
const sink = { apply: async (b: MeyerBundle) => { written.push(b.raw.image.imageId); } };
await runMeyerTick(c, () => source(rows), new MeyerStore(dir), codes, sink, now);
rows.unshift({ ...meyerImage(), imageId: 'inserted' });
for (let n = 0; n < 4; n++) {
const r = await runMeyerTick(c, () => source(rows), new MeyerStore(dir), codes, sink, now);
if (r.scan?.complete) break;
}
expect(new Set(written)).toEqual(new Set(rows.map(r => r.imageId)));
expect(written).toHaveLength(6);
});
test('source failure after first page preserves checkpoint and resumes after recovery', async () => {
const c = { ...config(), maxNewImagesPerTick: 10 }, rows = images(); let down = true;
const sink = { apply: jest.fn(async () => {}) };
const factory = (): MeyerSource => ({ ...source(rows), list: async (scope, page) => {
if (page === 2 && down) throw Error('unavailable'); return source(rows).list(scope, page);
} });
const first = await runMeyerTick(c, factory, new MeyerStore(dir), codes, sink, now);
expect(first.scan?.failed).toBe(1); expect(first.cursor).toBeNull();
down = false;
const next = await runMeyerTick(c, factory, new MeyerStore(dir), codes, sink, now);
expect(next.scan?.complete).toBe(true); expect(sink.apply).toHaveBeenCalledTimes(5);
});
test('duplicate caused by offset drift blocks completion and can recover by re-scan', async () => {
const c = { ...config(), maxNewImagesPerTick: 10 }, rows = images(); let drift = true;
const sink = { apply: jest.fn(async () => {}) };
const factory = (): MeyerSource => ({ ...source(rows), list: async (scope, page) => {
const batch = await source(rows).list(scope, page);
return page === 2 && drift ? { ...batch, images: [rows[0]!] } : batch;
} });
const first = await runMeyerTick(c, factory, new MeyerStore(dir), codes, sink, now);
expect(first.scan?.failed).toBe(1); expect(first.cursor).toBeNull();
drift = false;
const next = await runMeyerTick(c, factory, new MeyerStore(dir), codes, sink, now);
expect(next.scan?.complete).toBe(true); expect(sink.apply).toHaveBeenCalledTimes(5);
});
test('watch backs off for thrown outages and error exits, recovers, then stops cleanly', async () => {
const c = { ...config(), pollIntervalMs: 1000, errorBackoffMaxMs: 4000 };
const stop = new AbortController(), waits: number[] = []; let calls = 0;
await watchMeyer(c, async () => { calls++; if (calls === 1) throw Error('db down'); return calls < 4 ? 1 : 0; },
stop.signal, async ms => { waits.push(ms); if (waits.length === 4) stop.abort(); }, () => {});
expect(waits).toEqual([1000, 2000, 4000, 1000]); expect(calls).toBe(4);
});
test('stalled round invokes watchdog, completed round clears it', async () => {
jest.useFakeTimers();
try {
const stop = jest.fn(); let finish!: () => void;
const work = withRoundDeadline(() => new Promise<void>(resolve => { finish = resolve; }), 1000, stop);
jest.advanceTimersByTime(1000); expect(stop).toHaveBeenCalledTimes(1);
finish(); await work;
stop.mockClear(); await withRoundDeadline(async () => 0, 1000, stop);
jest.advanceTimersByTime(2000); expect(stop).not.toHaveBeenCalled();
} finally { jest.useRealTimers(); }
});
test('dead local lock is reclaimed; live process is never evicted', async () => {
const child = spawn(process.execPath, ['-e', 'process.exit(0)']);
const pid = child.pid!; await new Promise<void>(r => child.on('exit', () => r()));
await mkdir(join(dir, '.lock'));
await writeFile(join(dir, '.lock/owner.json'), JSON.stringify({ host: hostname(), pid }));
const store = new MeyerStore(dir); const release = await store.acquire('scope');
await expect(new MeyerStore(dir).acquire('scope')).rejects.toThrow('live process');
await release();
const releaseAgain = await store.acquire('scope'); await releaseAgain();
});
});
import { mkdtemp, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { runMeyerTick, chinaTime } from '../src/integrations/meyer/scheduler';
import { MeyerStore } from '../src/integrations/meyer/store';
import { imageKey, type MeyerSource } from '../src/integrations/meyer/contracts';
import { parseMeyerArgs } from '../src/cli/meyer-imaging.cli';
import { meyerConfig, meyerImage, meyerSymptom, codes } from './meyer-imaging-fixtures';
describe('independent Meyer scheduling', () => {
let dir: string;
beforeEach(async () => { dir = await mkdtemp(join(tmpdir(), 'meyer-schedule-')); });
afterEach(async () => { await rm(dir, { recursive: true, force: true }); });
const config = () => ({ ...meyerConfig(), timeStart: '2026-10-10 09:00:00' });
const now = Date.parse('2026-10-10T03:00:00Z');
test('empty analysis persists, cursor advances; retry outside next window writes late analysis', async () => {
const c = config(); const store = new MeyerStore(dir); const pending = new MeyerStore(join(dir, 'pending'));
const im = meyerImage(); let ready = false;
const sink = { apply: jest.fn(async () => {}) };
const factory = (window: ReturnType<typeof config>): MeyerSource => ({
list: async (_scope, page) => ({ total: window.timeStart > im.imageCreateTime! ? 0 : 1,
page, size: 100, images: window.timeStart > im.imageCreateTime! ? [] : [im] }),
symptom: async () => ready ? meyerSymptom() : { teeth: [], diagnosis: '', description: '' },
});
const first = await runMeyerTick(c, factory, store, codes, sink, now);
expect(first.pending).toBe(1); expect(first.scan?.failed).toBe(0); expect(sink.apply).not.toHaveBeenCalled();
expect(await store.read('cursor')).toEqual({ through: '2026-10-10 10:00:00' });
const key = imageKey(c.scopes[0]!, im.imageId);
const item = await pending.read<any>(key); await pending.write(key, { ...item, nextRetryAt: '2000-01-01T00:00:00Z' });
ready = true;
const next = await runMeyerTick(c, factory, store, codes, sink, now);
expect(next.retryAttempted).toBe(1); expect(next.pending).toBe(0); expect(sink.apply).toHaveBeenCalledTimes(1);
});
test('failed page never advances cursor; minute overlap is explicit', async () => {
const store = new MeyerStore(dir); const windows: string[] = [];
const factory = (c: ReturnType<typeof config>): MeyerSource => ({
list: async () => { windows.push(c.timeStart); throw Error('network'); }, symptom: async () => meyerSymptom(),
});
await store.write('cursor', { through: '2026-10-10 10:00:00' });
const status = await runMeyerTick(config(), factory, store, codes, { apply: async () => {} }, now);
expect(status.scan?.failed).toBe(1); expect(windows).toEqual(['2026-10-10 09:59:00']);
expect(await store.read('cursor')).toEqual({ through: '2026-10-10 10:00:00' });
});
test('pending rebind is not written and keeps retry evidence', async () => {
const c = config(), store = new MeyerStore(dir), pending = new MeyerStore(join(dir, 'pending'));
await store.write('cursor', { through: chinaTime(now) });
await pending.write('sample', { scope: c.scopes[0], image: meyerImage(), attempts: 1, nextRetryAt: '2000-01-01T00:00:00Z' });
const sink = { apply: jest.fn(async () => {}) };
const status = await runMeyerTick(c, () => ({ list: async () => ({ total: 1, page: 1, size: 100,
images: [{ ...meyerImage(), patientId: 'different' }] }), symptom: async () => meyerSymptom() }), store, codes, sink, now);
expect(status.retryFailed).toBe(1); expect(status.pending).toBe(1); expect(sink.apply).not.toHaveBeenCalled();
});
test('retry work is bounded and unavailable images remain queued with backoff', async () => {
const c = config(), store = new MeyerStore(dir), pending = new MeyerStore(join(dir, 'pending'));
await store.write('cursor', { through: chinaTime(now) });
for (let i = 0; i < 25; i++) await pending.write(`item-${i}`, { scope: c.scopes[0], image: meyerImage(),
attempts: 1, nextRetryAt: '2000-01-01T00:00:00Z' });
let requests = 0;
const status = await runMeyerTick(c, () => ({ list: async () => { requests++;
return { total: 0, page: 1, size: 100, images: [] }; }, symptom: async () => meyerSymptom() }),
store, codes, { apply: async () => { throw Error('must not write'); } }, now);
expect(requests).toBe(10); expect(status.retryAttempted).toBe(10); expect(status.pending).toBe(25);
const values = await Promise.all((await pending.keys()).map(k => pending.read<any>(k)));
expect(values.filter(v => Date.parse(v.nextRetryAt) > now)).toHaveLength(10);
});
test('scheduler cannot advance preview state', () => {
expect(() => parseMeyerArgs(['--config=x', '--state-dir=y', '--tick'])).toThrow('requires --apply');
expect(parseMeyerArgs(['--config=x', '--state-dir=y', '--apply', '--watch']).tick).toBe(true);
});
});
import { meyerBundle } from './meyer-imaging-fixtures';
import { ImageParser } from '../src/modules/sync/pipeline/parsers/image.parser';
import { DiagnosisParser } from '../src/modules/sync/pipeline/parsers/diagnosis.parser';
import { Action, ImageEmbeddedAnalysisSchema } from '@pac/types';
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 { toOpenFact } from '../src/modules/open/open-fact';
import { lookupContentNames } from '../src/modules/open/lookup-content';
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/integration/patient-fact-content.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]!));
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',
];
describe('public fact content reference', () => {
it('covers every registered fact type exactly once', () => {
expect(examples.map((e) => e.type).sort()).toEqual(Object.values(FactType).sort());
it('covers each documented type exactly once and only registered types', () => {
expect(examples.map((e) => e.type).sort()).toEqual([...documentedTypes].sort());
for (const type of documentedTypes) expect(Object.values(FactType)).toContain(type);
});
it.each(examples)(
'$type example passes canonical validation and open serialization',
({ type, content }) => {
({ type, content, occurredAt, plannedFor }) => {
const schema = FactContentSchemas[type as keyof typeof FactContentSchemas];
const stored = schema.parse(content);
const out = toOpenFact({
const sources = JSON.parse(readFileSync(resolve(__dirname, 'fixtures/lookup-content-source.json'), 'utf8'));
const stored = schema.parse(sources.find((r: any) => r.type === type).content);
const out = toLookupFact({
fact_id: '33333333-3333-4333-8333-333333333333', host_name: 'friday',
appointment_created_values: type === 'appointment_record' ? ['2026-10-19 09:00:00'] : null,
subject_id: `${type}:fixture`,
type,
kind: 'actual',
......@@ -33,24 +49,26 @@ describe('public fact content reference', () => {
title: null,
summary: null,
content: stored,
});
}, null);
const factSchema = PatientFactsLookupResponseSchema.shape.items.element.shape.facts.element;
expect(factSchema.safeParse({ ...out, clinicName: null }).success).toBe(true);
// 文档不展示会在出站被移除的键,也不依赖未说明的存储默认值。
expect(redactJson(content)).toEqual(content);
expect(out.content).toMatchObject(content);
if (type !== 'treatment_record') expect(out.steps).toEqual([]);
expect(out).toMatchObject({ occurredAt, plannedFor });
expect(Object.keys(out).sort()).toEqual(['id','type','kind','clinicId','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)) {
const section = text.split(`## \`${type}\``)[1]!.split('\n## ')[0]!;
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)) continue;
expect({ type, field, documented: fields.includes(field) }).toEqual({
if (['notes', 'staff_notes'].includes(field) || (type === 'appointment_record' && field === 'arrived_at')) continue;
expect({ type, field, documented: fields.includes(lookupContentNames[type]?.[field] ?? field) }).toEqual({
type,
field,
documented: true,
......@@ -58,8 +76,42 @@ describe('public fact content reference', () => {
}
}
});
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);
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 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;
expect(example).toHaveLength(2);
const bundle = meyerBundle();
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',
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) };
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 }, '示例诊所');
expect(out).toMatchObject(item);
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 = toOpenFact({
const planned = toLookupFact({
fact_id: '33333333-3333-4333-8333-333333333333', host_name: 'friday',
subject_id: 'treatment:1',
type: 'treatment_record',
kind: 'planned',
......@@ -71,12 +123,12 @@ describe('public fact content reference', () => {
title: null,
summary: null,
content: { category: 'endodontic', status: 'planned', treat_stages: ['canal_filling'] },
});
}, null);
expect(planned).toMatchObject({
kind: 'planned',
status: 'active',
content: { status: 'planned' },
steps: ['canal_filling'],
occurredAt: '2026-11-01T00:00:00.000Z',
plannedFor: null,
content: { status: 'planned', treatStages: ['canal_filling'] },
});
});
});
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { z } from 'zod';
import * as yaml from 'js-yaml';
import {
FactKind,
PACAcquisitionChannels,
PACTreatmentStep,
} from '@pac/types';
import { FactContentSchemas } from '../src/modules/sync/pipeline/fact-content-schemas';
import { PatientFactsLookupResponseSchema } from '../src/modules/open/patient-facts-lookup.schema';
const guide = readFileSync(resolve(__dirname, '../../pac-docs/content/docs/api/open/PatientFactsLookupController_lookup.mdx'), 'utf8');
const contentDoc = guide;
const documentedTypes = [...guide.matchAll(/<details id="([^"]+)">/g)].map((m) => m[1]!);
function enumStrings(node: unknown): string[] {
if (!node || typeof node !== 'object') return [];
if (Array.isArray(node)) return node.flatMap(enumStrings);
const obj = node as Record<string, unknown>;
const own = [
...(Array.isArray(obj.enum) ? obj.enum : []),
...(typeof obj.const === 'string' ? [obj.const] : []),
];
return [
...own.filter((v): v is string => typeof v === 'string'),
...Object.values(obj).flatMap(enumStrings),
];
}
describe('open patient enum documentation', () => {
it('documents actual source relationship mappings without pretending strings are a closed enum', () => {
for (const host of ['friday', 'jvs-dw']) {
const data = yaml.load(
readFileSync(
resolve(__dirname, `../data/${host}/assemblers/patient_relation.yaml`),
'utf8',
),
) as any;
for (const value of new Set(Object.values(data.enum_mapping.relationship))) {
expect(guide).toContain('`' + value + '`');
}
}
expect(guide).toContain('对方是当前患者的什么人');
const relation =
PatientFactsLookupResponseSchema.shape.items.element.shape.relations.element.shape
.relationship;
expect(relation.safeParse('future_relationship').success).toBe(true);
});
it('documents channels, fact kind/type and all current treatment steps', () => {
for (const value of [
...Object.keys(PACAcquisitionChannels),
...Object.values(FactKind),
]) {
expect(guide).toContain('`' + value + '`');
}
for (const value of Object.keys(PACTreatmentStep)) {
expect(contentDoc).toContain('`' + value + '`');
}
});
it('documents every current content enum and literal, with FDI tooth ranges instead of 52 individual codes', () => {
for (const [type, schema] of Object.entries(FactContentSchemas)) {
if (!documentedTypes.includes(type)) continue;
const json = z.toJSONSchema(schema, { unrepresentable: 'any' });
for (const value of new Set(enumStrings(json))) {
if (/^[1-8][1-8]$/.test(value)) {
expect(contentDoc).toContain('FDI');
continue;
}
expect({ type, value, documented: contentDoc.includes('`' + value + '`') }).toEqual({
type,
value,
documented: true,
});
}
}
});
it('keeps the full response example executable against the current schema', () => {
const response = [...guide.matchAll(/```json\n([\s\S]*?)\n```/g)]
.map((m) => JSON.parse(m[1]!))
.find((v) => v.data?.items);
expect(PatientFactsLookupResponseSchema.safeParse(response.data).success).toBe(true);
const persona = PatientFactsLookupResponseSchema.shape.items.element.shape.persona;
expect(persona.safeParse([]).success).toBe(true);
expect(persona.safeParse(['充填治疗']).success).toBe(true);
expect(persona.safeParse(null).success).toBe(false);
expect(persona.safeParse({ features: [] }).success).toBe(false);
});
});
......@@ -92,6 +92,7 @@ suite('local HTTP + PostgreSQL patient facts acceptance', () => {
otherHost = await prisma.host.create({
data: {
name: `other-${randomUUID()}`,
openFactsReadEnabled: true,
appId: `app-${randomUUID()}`,
appSecretHash: hashSecret('local-other'),
pushSecretHashes: [],
......@@ -122,6 +123,8 @@ suite('local HTTP + PostgreSQL patient facts acceptance', () => {
birthDate: new Date('2010-01-02T00:00:00Z'),
preferences: {
language: '中文',
dedicatedCs: { id: 'source-staff-123', name: '客服13900139000', internal: '不外发' },
contactPhone: { relationship: 'mother', isSelf: false },
contactWindow: '18:00-20:00',
mobile: '13900139000',
text: '联系13900139000',
......@@ -134,6 +137,7 @@ suite('local HTTP + PostgreSQL patient facts acceptance', () => {
lastVisitAt: new Date('2026-10-01T00:00:00Z'),
doNotContact: true,
doNotContactReason: 'opt_out',
deceased: true,
hostFollowUpActive: null,
tags: ['VIP', '联系13900139000'],
notes: '内部备注不可外发',
......@@ -223,7 +227,7 @@ suite('local HTTP + PostgreSQL patient facts acceptance', () => {
clinicId: 'clinic',
occurredAt: at,
title: subjectId,
content: { illness_desc: '联系13900139000', staff_notes: '不应外发' },
content: { emr_external_id: subjectId, illness_desc: '联系13900139000', staff_notes: '不应外发' },
transactionIds: [],
},
});
......@@ -319,6 +323,7 @@ suite('local HTTP + PostgreSQL patient facts acceptance', () => {
await prisma.patientRelation.deleteMany({ where: { hostId: { in: ids } } });
await prisma.persona.deleteMany({ where: { hostId: { in: ids } } });
await prisma.patientFact.deleteMany({ where: { hostId: { in: ids } } });
await prisma.patientTransaction.deleteMany({ where: { hostId: { in: ids } } });
await prisma.patient.deleteMany({ where: { hostId: { in: ids } } });
await prisma.host.deleteMany({ where: { id: { in: ids } } });
await prisma.$disconnect();
......@@ -358,18 +363,20 @@ suite('local HTTP + PostgreSQL patient facts acceptance', () => {
r.data.items.map((i: any) => i.facts.length).sort((a: number, b: number) => a - b),
).toEqual([0, 1, 253]);
const facts = r.data.items.flatMap((i: any) => i.facts);
expect(facts.filter((f: any) => f.subjectId === 'same-subject')).toHaveLength(2);
expect(facts.filter((f: any) => f.content.externalId === 'same-subject')).toHaveLength(2);
expect(
facts.some((f: any) =>
['cancelled-head', 'outside', 'archived', 'moved-patient'].includes(f.subjectId),
['cancelled-head', 'outside', 'archived', 'moved-patient'].includes(f.content.externalId),
),
).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());
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).subjectId).toBe('no-time');
expect(long.at(-1).content.externalId).toBe('no-time');
expect(JSON.stringify(r.data)).not.toContain('13900139000');
expect(JSON.stringify(r.data)).not.toContain('staff_notes');
expect(facts.find((f: any) => f.subjectId === 'same-subject').clinicName).toBe('验收诊所');
expect(facts.find((f: any) => f.content.externalId === 'same-subject').clinicName).toBe('验收诊所');
});
it('returns saved basic/profile/persona and directed relations with scope and redaction', async () => {
const r = await request(
......@@ -382,28 +389,28 @@ suite('local HTTP + PostgreSQL patient facts acceptance', () => {
expect(a.patient).toMatchObject({
gender: '女',
birthDate: '2010-01-02',
preferences: { language: '中文', text: '联系139****9000' },
dedicatedCs: { id: 'source-staff-123', name: '客服139****9000' },
});
expect(a.patient).toMatchObject({
lastVisitAt: '2026-10-01',
doNotContact: true,
hostFollowUpActive: null,
acquisitionChannel: 'word_of_mouth',
});
expect(a.patient).not.toHaveProperty('notes');
expect(a.persona).toMatchObject({
version: 3,
computedAt: '2026-10-18T01:00:00.000Z',
refreshedAt: '2026-10-19T02:00:00.000Z',
features: [
{
key: 'fixture',
score: 0.75,
description: '版本3 联系139****9000',
data: { text: '联系139****9000' },
},
],
});
for (const item of r.data.items) {
for (const field of [
'doNotContact',
'doNotContactReason',
'deceased',
'preferences',
'preferredDoctor',
'hostFollowUpActive',
'tags',
'lastVisitAt',
'lastVisitDoctor',
]) {
expect(item.patient).not.toHaveProperty(field);
}
}
expect(a.persona).toEqual(['版本3 联系139****9000']);
expect(a.relations).toHaveLength(5);
expect(a.relations.find((rel: any) => rel.relationship === 'mother').patient).toEqual({
id: expect.any(String),
......@@ -417,19 +424,22 @@ suite('local HTTP + PostgreSQL patient facts acceptance', () => {
for (const key of ['father', 'friend', 'sibling', 'child'])
expect(a.relations.find((rel: any) => rel.relationship === key).patient).toBeNull();
const known = r.data.items.find((i: any) => i.facts.length === 1);
expect(known.patient).toMatchObject({ doNotContact: false, deceased: false, tags: [] });
expect(known.patient).toMatchObject({
dedicatedCs: null,
acquisitionChannel: null,
acquisitionSub: null,
});
const empty = r.data.items.find((i: any) => i.facts.length === 0);
expect(empty.patient.externalId).toBe('0000123');
expect(empty).not.toHaveProperty('profile');
expect(empty.patient).toMatchObject({
doNotContact: null,
deceased: null,
tags: null,
lastVisitAt: null,
dedicatedCs: null,
acquisitionChannel: null,
acquisitionSub: null,
});
expect(a).not.toHaveProperty('profile');
expect(Object.keys(a).sort()).toEqual(['facts', 'patient', 'persona', 'relations']);
expect(empty.persona).toBeNull();
expect(empty.persona).toEqual([]);
expect(empty.relations).toEqual([]);
const mother = await request(
'/pac/v1/open/patient-facts/lookup',
......@@ -514,6 +524,82 @@ suite('local HTTP + PostgreSQL patient facts acceptance', () => {
expect(r.code).toBe(0);
expect((await request('/pac/v1/open/patients/search', body, token)).code).not.toBe(0);
});
test.each(['friday', 'jvs-dw'])('%s appointment creation comes only from scoped linked evidence', async (hostName) => {
const owner = hostName === 'friday' ? host : otherHost;
const foreign = hostName === 'friday' ? otherHost : host;
await prisma.host.update({ where: { id: owner.id }, data: { name: hostName } });
try {
const sourceField = hostName === 'friday' ? 'created_gmt_at' : 'created_date';
const patient = await prisma.patient.create({ data: {
hostId: owner.id, tenantId: 'market', sourceUnit: 'brand-time', externalId: 'time-patient',
name: '时间验收', medicalRecordNumber: 'TIME-001',
} });
const another = await prisma.patient.create({ data: {
hostId: owner.id, tenantId: 'market', sourceUnit: 'other-brand', externalId: 'time-patient', name: '另一品牌患者',
} });
const created = '2026-10-01T02:00:00.000Z';
const scheduled = new Date('2026-10-20T01:00:00Z');
const arrived = new Date('2026-10-20T01:10:00Z');
const addEvidence = (subjectId: string, value: any, extra: any = {}) => prisma.patientTransaction.create({ data: {
hostId: owner.id, tenantId: 'market', patientId: patient.id, clinicId: 'clinic',
action: 'appointment_created', subjectType: 'appointment', subjectId,
rawPayload: { [sourceField]: value, internal_extra: 'must-not-leak' }, payloadHash: 'fixture',
occurredAt: scheduled, ...extra,
} });
for (const kind of ['planned', 'actual']) {
const ids = await Promise.all(['2026-10-01 10:00:00', created].map(v => addEvidence(kind, v)));
await prisma.patientFact.create({ data: {
hostId: owner.id, tenantId: 'market', patientId: patient.id, sourceUnit: patient.sourceUnit,
subjectId: `appointment_record:${kind}`, type: 'appointment_record', kind,
status: kind === 'actual' ? 'fulfilled' : 'active', version: 1, clinicId: 'clinic',
occurredAt: arrived, plannedFor: scheduled,
content: { appointment_external_id: kind, scheduled_at: scheduled.toISOString(), arrived_at: arrived.toISOString() },
transactionIds: ids.map(t => t.id),
} });
}
for (const [label, extra] of Object.entries({
wrongPatient: { patientId: another.id }, wrongHost: { hostId: foreign.id },
wrongTenant: { tenantId: 'other-tenant' }, wrongSubject: { subjectId: 'other-appointment' },
wrongType: { subjectType: 'encounter' }, wrongClinic: { clinicId: 'other-clinic' },
missing: {}, conflict: {}, unlinked: {},
})) {
const evidence = await addEvidence(label, label === 'missing' ? null : created, extra);
const conflict = label === 'conflict' ? await addEvidence(label, '2026-10-02T02:00:00Z') : null;
await prisma.patientFact.create({ data: {
hostId: owner.id, tenantId: 'market', patientId: patient.id, sourceUnit: patient.sourceUnit,
subjectId: `appointment_record:${label}`, type: 'appointment_record', kind: 'actual',
status: 'fulfilled', version: 1, clinicId: 'clinic', occurredAt: arrived, plannedFor: scheduled,
content: { appointment_external_id: label, arrived_at: arrived.toISOString() },
transactionIds: label === 'unlinked' ? [] : [evidence.id, ...(conflict ? [conflict.id] : [])],
} });
}
await prisma.patientFact.create({ data: {
hostId: owner.id, tenantId: 'market', patientId: patient.id, sourceUnit: patient.sourceUnit,
subjectId: 'encounter_record:actual', type: 'encounter_record', kind: 'actual', status: 'active',
version: 1, clinicId: 'clinic', occurredAt: arrived,
content: { encounter_external_id: 'actual' }, transactionIds: [],
} });
const before = await prisma.patientFact.findMany({ where: { patientId: patient.id }, orderBy: { id: 'asc' } });
const auth = await request('/pac/v1/auth/token', { appId: owner.appId, appSecret: hostName === 'friday' ? 'local-secret' : 'local-other' });
expect(auth.code).toBe(0);
const response = await request('/pac/v1/open/patient-facts/lookup', { medicalRecordNumber: 'TIME-001' }, auth.data.accessToken);
expect(response.code).toBe(0);
expect(response.data.items).toHaveLength(1);
const facts = response.data.items[0].facts;
expect(facts).toHaveLength(12);
expect(facts[0]).toMatchObject({ type: 'encounter_record', occurredAt: arrived.toISOString() });
for (const fact of facts.filter((f: any) => f.type === 'appointment_record')) {
expect(fact.occurredAt).toBe(['planned', 'actual'].includes(fact.content.externalId) ? created : null);
expect(fact.plannedFor).toBe(scheduled.toISOString());
expect(fact.content).not.toHaveProperty('arrivedAt');
expect(fact.content).not.toHaveProperty('arrived_at');
}
expect(JSON.stringify(response)).not.toContain('must-not-leak');
expect(await prisma.patientFact.findMany({ where: { patientId: patient.id }, orderBy: { id: 'asc' } })).toEqual(before);
} finally {
await prisma.host.update({ where: { id: owner.id }, data: { name: owner.name } });
}
});
it('permission revocation takes effect on issued tokens, without changing HMAC grants', async () => {
await prisma.host.update({ where: { id: host.id }, data: { openFactsReadEnabled: false } });
expect(
......
import { compactPersonaValue, personaValueLabels, shortPersonaValueLabel } from '@pac/types';
import { toOpenPersona } from '../src/modules/open/open-persona';
const f = (key: string, description: string | null, data: unknown = null) => ({
key,
description,
data,
});
describe('open persona display tags', () => {
it('returns arrays for absent and empty personas', () => {
expect(toOpenPersona(null)).toEqual([]);
expect(toOpenPersona(undefined)).toEqual([]);
expect(toOpenPersona([])).toEqual([]);
expect(toOpenPersona([f('empty', ' '), f('missing', null), f('dash', '—')])).toEqual([]);
});
it('uses current potential-treatment labels and expands all values', () => {
const data = { types: ['filling', 'endo'], labels: ['潜在补牙', '旧文案'] };
expect(toOpenPersona([f('potential_treatment', '旧描述', data)])).toEqual([
'充填治疗',
'根管治疗',
]);
expect(personaValueLabels(data, 'potential_treatment')).toEqual(['充填治疗', '根管治疗']);
expect(compactPersonaValue('旧描述', data, 'potential_treatment')).toEqual({
text: '充填治疗',
more: 1,
});
});
it('retains non-key features, sorts by display order and handles new features', () => {
const features = [
f('age_bracket', '成年 · 35岁'),
f('new_b', '新增B'),
f('rfm', '重要价值 · 距上次 140 天 · 累计¥1000'),
f('potential_treatment', '旧', { types: ['filling'] }),
f('new_a', '新增A'),
];
const expected = ['充填治疗', '重要价值', '成年', '新增A', '新增B'];
expect(toOpenPersona(features)).toEqual(expected);
expect(toOpenPersona([...features].reverse())).toEqual(expected);
expect(features[0]!.key).toBe('age_bracket'); // 不改传入数组
});
it('expands structured labels without truncating meaningful slashes, deduplicates and redacts', () => {
expect(
toOpenPersona([
f('a', '不要外发的说明', {
labels: [' 种植/手术禁忌 ', '', '种植/手术禁忌', '联系13900139000'],
}),
f('b', '联系13900139000'),
f('c', '不应回退', { labels: [] }),
]),
).toEqual(['种植/手术禁忌', '联系139****9000']);
});
it('uses concise descriptions for single values and legacy data without labels', () => {
expect(
toOpenPersona([f('rfm', '新客/未消费(说明)'), f('age_bracket', '成年(35岁)')]),
).toEqual(['新客', '成年']);
expect(shortPersonaValueLabel('重要价值 · 距上次 140 天')).toBe('重要价值');
expect(shortPersonaValueLabel('成年(35岁)')).toBe('成年');
expect(toOpenPersona([f('x', '有效标签 · 说明', { labels: [null] })])).toEqual(['有效标签']);
});
});
......@@ -59,8 +59,19 @@ describe('patient facts lookup contract', () => {
phone: '13800138000',
gender: null,
birthDate: null,
preferences: null,
profile: null,
preferences: {
dedicatedCs: { id: 'staff-id', name: '客服' },
contactPhone: { relationship: 'mother', isSelf: false },
},
profile:
id === 'p1'
? {
doNotContact: true,
doNotContactReason: 'opt_out',
deceased: true,
tags: [],
}
: null,
personas: [],
relationsOut: [],
})),
......@@ -79,10 +90,26 @@ 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: '客服' });
for (const item of result.items) {
for (const field of [
'doNotContact',
'doNotContactReason',
'deceased',
'preferences',
'preferredDoctor',
'hostFollowUpActive',
'tags',
'lastVisitAt',
'lastVisitDoctor',
]) {
expect(item.patient).not.toHaveProperty(field);
}
}
expect(result.items[0]!.facts[0]).toMatchObject({
clinicName: '测试诊所',
summary: '联系139****9000',
content: { illness_desc: '电话139****9000' },
id: expect.any(String),
content: { illnessDesc: '电话139****9000' },
});
expect(JSON.stringify(result)).not.toContain('staff_notes');
const sql = prisma.$queryRaw.mock.calls[0][0];
......@@ -91,6 +118,49 @@ describe('patient facts lookup contract', () => {
expect(sql.sql).toContain('AND p.phone =');
expect(sql.values).toContain('h');
});
test.each([
[null, null],
[{ contactPhone: { relationship: 'mother' } }, null],
[{ dedicatedCs: { id: ' ', name: '' } }, null],
[{ dedicatedCs: { id: '0', name: null } }, null],
[{ dedicatedCs: 'invalid' }, null],
[{ dedicatedCs: [] }, null],
[{ dedicatedCs: { id: {}, name: [] } }, null],
[{ dedicatedCs: { id: '13900139000' } }, { id: '13900139000', name: null }],
[
{ dedicatedCs: { name: ' 客服13900139000 ', internal: 'hidden' } },
{ id: null, name: '客服139****9000' },
],
])('projects only dedicated customer service from %j', async (preferences, expected) => {
const prisma: any = {
$queryRaw: jest.fn().mockResolvedValue([{ patient_id: 'p', fact_id: null }]),
patient: {
findMany: jest.fn().mockResolvedValue([
{
id: 'p',
externalId: 'source-p',
name: null,
phone: null,
medicalRecordNumber: null,
gender: null,
birthDate: null,
preferences,
profile: null,
personas: [],
relationsOut: [],
},
]),
},
};
prisma.$transaction = jest.fn((run) => run(prisma));
const result = await new PatientFactsLookupService(prisma).lookup(
{ hostId: 'h', appId: 'a', clinicNames: {} },
{ medicalRecordNumber: '001' },
);
expect(result.items[0]!.patient.dedicatedCs).toEqual(expected);
expect(result.items[0]!.patient).not.toHaveProperty('preferences');
expect(result.items[0]!.patient).not.toHaveProperty('contactPhone');
});
it('returns empty items for no match', async () => {
const prisma: any = { $queryRaw: jest.fn().mockResolvedValue([]) };
prisma.$transaction = jest.fn((run) => run(prisma));
......
import { potentialTreatmentCardLabel } from '@pac/types';
/**
* persona feature.description 的展示辅助 —— 把后端那句自包含描述裁成不同密度的展示形态。
*
* 后端每个特征产一句 description(如 `重要价值 · 距上次 140 天 · 就诊20次 · 累计¥154,350`),
* 详情抽屉整句显示,紧凑位(右侧 hint 列 / 标签云 chip)放不下,要短词。
*
* 注:这里曾有一套 `[enum] 文本` 前缀的解析(`[gap] 治疗链有 3 处缺口` → chip「缺口」+ 正文),
* 服务的是 treatment_chain_status / recall_risk / value / do_not_contact_status 四个特征 ——
* W7 已把它们摘除(前三个并入 rfm,免打扰并入 special_attention)。现存 16 个 extractor
* 没有一个产 `[enum]` 前缀,那套解析与它的中文字典已随之删除。
*/
/**
* 短标签 —— 用于"关键事实"等紧凑位置。
* `新客/未消费(累计净消费 ¥58.00,含 ...)` → `新客`
* `重要价值 · 距上次 140 天 · 累计¥154,350` → `重要价值`
* 规则:截到第一个分隔符(`·` / `/` / 半角或全角括号)之前。
*/
export function shortPersonaValueLabel(raw: string | null | undefined): string {
if (!raw) return '—';
const stop = raw.search(/[·\/(()]/);
return (stop > 0 ? raw.slice(0, stop) : raw).trim();
}
/**
* 潜在治疗的展示中文 —— **从 code 查表,不读 data.labels**。
*
* ⚠️ 2026-07-30 线上口径不一致就是这里踩的:同一个患者,召回池卡片显示「充填治疗」、
* 详情页 chip 显示「潜在补牙」。因为 `data.labels` 是**算画像那一刻烤进 JSON 的**
* (`{"types":["filling"],"labels":["潜在补牙"]}`),而卡片早就改成"只拿 code、中文查表"了。
* 措辞这几天改过三轮,烤死的那份自然全是旧词 —— 要改回来得全量重算画像(百万级、几小时)。
*
* 纪律:**凡是中文措辞可能改的标签,展示一律 code → 查表**,别读 labels。
* 目前只有 potential_treatment 有 code 表(POTENTIAL_TREATMENT_CARD_LABEL);
* 其余多值特征(治疗史/权益/禁忌/时间偏好…)暂时只能读 labels,它们的措辞也没改过。
*/
function potentialTreatmentLabels(data: unknown): string[] | null {
const types = (data as { types?: unknown } | null | undefined)?.types;
if (!Array.isArray(types)) return null;
const codes = types.filter((t): t is string => typeof t === 'string');
return codes.length > 0 ? codes.map(potentialTreatmentCardLabel) : null;
}
/**
* 多值标签的紧凑显示(首值 + `+N` 溢出)。
*
* 治疗史 / 权益 / 禁忌 / 潜在治疗 / 时间偏好 / 治疗敏感 / 特别关注 这 7 个特征是「`/` 并列多值」,
* 直接交给 shortPersonaValueLabel 会在第一个 `/` 处截断、只剩首值且不提示还有更多。
* 这里改读结构化 `data.labels`(这 7 个后端都带),显示首值 + `+N`,完整列表走 hover 卡片。
* 单值特征 → `{ text, more: 0 }`。
*
* ⭐ 传 featureKey:potential_treatment 走 code 查表(见上方注释),不读烤死的 labels。
*/
export function compactPersonaValue(
raw: string | null | undefined,
data?: unknown,
featureKey?: string,
): { text: string; more: number } {
const fromCode = featureKey === 'potential_treatment' ? potentialTreatmentLabels(data) : null;
const labels =
fromCode ?? (data as { labels?: unknown } | null | undefined)?.labels;
if (Array.isArray(labels) && labels.length > 0 && typeof labels[0] === 'string') {
return { text: labels[0] as string, more: labels.length - 1 };
}
return { text: shortPersonaValueLabel(raw), more: 0 };
}
/** 抽屉里"全部展开"用的多值中文 —— 同样对 potential_treatment 走 code 查表 */
export function personaValueLabels(data: unknown, featureKey?: string): string[] | null {
const fromCode = featureKey === 'potential_treatment' ? potentialTreatmentLabels(data) : null;
if (fromCode) return fromCode;
const labels = (data as { labels?: unknown } | null | undefined)?.labels;
return Array.isArray(labels) && labels.every((x) => typeof x === 'string')
? (labels as string[])
: null;
}
// 标签文本转换由前后端共享,保持工作台与开放接口口径一致。
export {
shortPersonaValueLabel,
compactPersonaValue,
personaValueLabels,
} from '@pac/types';
......@@ -13,3 +13,5 @@ export * from './potential-label-rules';
export * from './host-action-message';
export * from './kin-relationship';
export * from './emr-narrative';
export * from './persona-display';
import { potentialTreatmentCardLabel } from './labels';
/**
* persona feature.description 的展示辅助 —— 把后端那句自包含描述裁成不同密度的展示形态。
*
* 后端每个特征产一句 description(如 `重要价值 · 距上次 140 天 · 就诊20次 · 累计¥154,350`),
* 详情抽屉整句显示,紧凑位(右侧 hint 列 / 标签云 chip)放不下,要短词。
*
* 注:这里曾有一套 `[enum] 文本` 前缀的解析(`[gap] 治疗链有 3 处缺口` → chip「缺口」+ 正文),
* 服务的是 treatment_chain_status / recall_risk / value / do_not_contact_status 四个特征 ——
* W7 已把它们摘除(前三个并入 rfm,免打扰并入 special_attention)。现存 16 个 extractor
* 没有一个产 `[enum]` 前缀,那套解析与它的中文字典已随之删除。
*/
/**
* 短标签 —— 用于"关键事实"等紧凑位置。
* `新客/未消费(累计净消费 ¥58.00,含 ...)` → `新客`
* `重要价值 · 距上次 140 天 · 累计¥154,350` → `重要价值`
* 规则:截到第一个分隔符(`·` / `/` / 半角或全角括号)之前。
*/
export function shortPersonaValueLabel(raw: string | null | undefined): string {
if (!raw) return '—';
const stop = raw.search(/[·/((]/);
return (stop > 0 ? raw.slice(0, stop) : raw).trim();
}
/**
* 潜在治疗的展示中文 —— **从 code 查表,不读 data.labels**。
*
* ⚠️ 2026-07-30 线上口径不一致就是这里踩的:同一个患者,召回池卡片显示「充填治疗」、
* 详情页 chip 显示「潜在补牙」。因为 `data.labels` 是**算画像那一刻烤进 JSON 的**
* (`{"types":["filling"],"labels":["潜在补牙"]}`),而卡片早就改成"只拿 code、中文查表"了。
* 措辞这几天改过三轮,烤死的那份自然全是旧词 —— 要改回来得全量重算画像(百万级、几小时)。
*
* 纪律:**凡是中文措辞可能改的标签,展示一律 code → 查表**,别读 labels。
* 目前只有 potential_treatment 有 code 表(POTENTIAL_TREATMENT_CARD_LABEL);
* 其余多值特征(治疗史/权益/禁忌/时间偏好…)暂时只能读 labels,它们的措辞也没改过。
*/
function potentialTreatmentLabels(data: unknown): string[] | null {
const types = (data as { types?: unknown } | null | undefined)?.types;
if (!Array.isArray(types)) return null;
const codes = types.filter((t): t is string => typeof t === 'string');
return codes.length > 0 ? codes.map(potentialTreatmentCardLabel) : null;
}
/**
* 多值标签的紧凑显示(首值 + `+N` 溢出)。
*
* 治疗史 / 权益 / 禁忌 / 潜在治疗 / 时间偏好 / 治疗敏感 / 特别关注 这 7 个特征是「`/` 并列多值」,
* 直接交给 shortPersonaValueLabel 会在第一个 `/` 处截断、只剩首值且不提示还有更多。
* 这里改读结构化 `data.labels`(这 7 个后端都带),显示首值 + `+N`,完整列表走 hover 卡片。
* 单值特征 → `{ text, more: 0 }`。
*
* ⭐ 传 featureKey:potential_treatment 走 code 查表(见上方注释),不读烤死的 labels。
*/
export function compactPersonaValue(
raw: string | null | undefined,
data?: unknown,
featureKey?: string,
): { text: string; more: number } {
const fromCode = featureKey === 'potential_treatment' ? potentialTreatmentLabels(data) : null;
const labels =
fromCode ?? (data as { labels?: unknown } | null | undefined)?.labels;
if (Array.isArray(labels) && labels.length > 0 && typeof labels[0] === 'string') {
return { text: labels[0] as string, more: labels.length - 1 };
}
return { text: shortPersonaValueLabel(raw), more: 0 };
}
/** 抽屉里"全部展开"用的多值中文 —— 同样对 potential_treatment 走 code 查表 */
export function personaValueLabels(data: unknown, featureKey?: string): string[] | null {
const fromCode = featureKey === 'potential_treatment' ? potentialTreatmentLabels(data) : null;
if (fromCode) return fromCode;
const labels = (data as { labels?: unknown } | null | undefined)?.labels;
return Array.isArray(labels) && labels.every((x) => typeof x === 'string')
? (labels as string[])
: null;
}
import { z } from 'zod';
import { ImageEmbeddedAnalysisSchema } from './image-embedded-analysis';
/**
* Canonical resource schemas — PAC AssemblerEngine 经 yaml 配置投影 + 类型归一化后期望看到的形态。
......@@ -356,6 +357,8 @@ export const ImageStudyCanonicalSchema = z
fileUrl: z.string().optional().nullable(),
fileName: z.string().optional().nullable(),
fileType: z.string().optional().nullable(),
/// 可选且无默认值,旧影像缺失时不改变内容或哈希。
analysis: ImageEmbeddedAnalysisSchema.nullable().optional(),
})
.passthrough();
export type ImageStudyCanonical = z.infer<typeof ImageStudyCanonicalSchema>;
......
import { z } from 'zod';
import { PACDiagnosisCodeSchema } from '../canonical-codes';
/** 单张影像的分析快照。来源必须能可靠关联该影像;不推断完成状态或医生确诊。 */
export const ImageEmbeddedAnalysisSchema = z.object({
source: z.string().min(1),
summary: z.string().nullable(),
description: z.string().nullable(),
analyzedAt: z.iso.datetime({ offset: true }).transform(value => new Date(value).toISOString()).nullable(),
findings: z.array(z.object({
name: z.string().min(1),
toothPosition: z.string().nullable(),
code: PACDiagnosisCodeSchema.nullable(),
})),
});
export type ImageEmbeddedAnalysis = z.infer<typeof ImageEmbeddedAnalysisSchema>;
......@@ -12,4 +12,5 @@ export * from './agent';
export * from './sync';
export * from './admin';
export * from './canonical';
export * from './image-embedded-analysis';
export * from './reason-signals';
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