Skip to content
Projects
Groups
Snippets
Help
This project
Loading...
Sign in / Register
Toggle navigation
P
pac
Overview
Overview
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
ai-tools
pac
Commits
94dfcc3b
Commit
94dfcc3b
authored
Oct 23, 2026
by
luoqi
Browse files
Options
Browse Files
Download
Plain Diff
Merge persona dimensions and inline image access into test
parents
33fd6e80
6e099cc1
Pipeline
#3866
failed in 0 seconds
Changes
14
Pipelines
1
Hide whitespace changes
Inline
Side-by-side
Showing
14 changed files
with
319 additions
and
38 deletions
+319
-38
apps/pac-docs/content/docs/api/open/PatientFactsLookupController_lookup.mdx
+37
-9
apps/pac-docs/openapi/pac.json
+37
-3
apps/pac-service/src/modules/open/lookup-content.schema.ts
+11
-2
apps/pac-service/src/modules/open/lookup-image-access.service.ts
+94
-0
apps/pac-service/src/modules/open/open-persona.ts
+12
-5
apps/pac-service/src/modules/open/open.module.ts
+2
-1
apps/pac-service/src/modules/open/patient-facts-lookup.controller.ts
+3
-2
apps/pac-service/src/modules/open/patient-facts-lookup.schema.ts
+6
-2
apps/pac-service/src/modules/open/patient-facts-lookup.service.ts
+14
-2
apps/pac-service/tests/lookup-contract-postgres.spec.ts
+1
-1
apps/pac-service/tests/lookup-image-access.spec.ts
+67
-0
apps/pac-service/tests/open-patient-enum-doc.spec.ts
+2
-1
apps/pac-service/tests/open-persona.spec.ts
+8
-7
apps/pac-service/tests/patient-facts-lookup.spec.ts
+25
-3
No files found.
apps/pac-docs/content/docs/api/open/PatientFactsLookupController_lookup.mdx
View file @
94dfcc3b
...
@@ -65,10 +65,12 @@ curl "$PAC_BASE_URL/pac/v1/open/patient-facts/lookup" \
...
@@ -65,10 +65,12 @@ curl "$PAC_BASE_URL/pac/v1/open/patient-facts/lookup" \
-d '{"medicalRecordNumber":"000123"}'
-d '{"medicalRecordNumber":"000123"}'
```
```
###
获取影像文件访问
地址
###
刷新文件地址或获取 CT 切片
地址
`POST /pac/v1/open/image-files/resolve`,使用相同的应用 Bearer 令牌。
`POST /pac/v1/open/image-files/resolve`,使用相同的应用 Bearer 令牌。
**患者查询已在 `content.fileRefs` 的原图、缩略图项内附带 `url` 和 `expiresAt`,可直接访问,无需再请求本接口。** 地址为空、过期或需要 CT 切片时,再调用本接口。
从查询结果中取得患者 `patient.id`、影像事实 `facts[].id` 和该影像 `content.fileRefs` 的数组下标。
从查询结果中取得患者 `patient.id`、影像事实 `facts[].id` 和该影像 `content.fileRefs` 的数组下标。
```json
```json
...
@@ -83,7 +85,7 @@ curl "$PAC_BASE_URL/pac/v1/open/patient-facts/lookup" \
...
@@ -83,7 +85,7 @@ curl "$PAC_BASE_URL/pac/v1/open/patient-facts/lookup" \
| --- | --- | --- |
| --- | --- | --- |
| `patientId` | string | PAC 患者 UUID |
| `patientId` | string | PAC 患者 UUID |
| `imageFactId` | string | 当前影像事实版本 UUID,不是源影像 ID |
| `imageFactId` | string | 当前影像事实版本 UUID,不是源影像 ID |
| `fileIndexes` | integer[] | `fileRefs` 中从 0 开始的下标;每批 1–100 个,不重复 |
| `fileIndexes` | integer[] | `fileRefs` 中从 0 开始的下标;每批 1–100 个,不重复
;每个下标 0–9999,须对应实际文件
|
成功返回 `data.imageFactId` 与 `data.files`,文件顺序与请求下标一致:
成功返回 `data.imageFactId` 与 `data.files`,文件顺序与请求下标一致:
...
@@ -126,7 +128,7 @@ curl "$PAC_BASE_URL/pac/v1/open/patient-facts/lookup" \
...
@@ -126,7 +128,7 @@ curl "$PAC_BASE_URL/pac/v1/open/patient-facts/lookup" \
| --- | --- | --- |
| --- | --- | --- |
| `data.items` | object[] | 匹配患者列表;无匹配为 `[]`,病历号或姓名重号时返回多项 |
| `data.items` | object[] | 匹配患者列表;无匹配为 `[]`,病历号或姓名重号时返回多项 |
| `items[].patient` | object | 患者基本信息及业务属性 |
| `items[].patient` | object | 患者基本信息及业务属性 |
| `items[].persona` |
string[] | 可直接展示的画像标签;无标签
为 `[]` |
| `items[].persona` |
object[] | 按维度分组的画像;无可展示值
为 `[]` |
| `items[].relations` | object[] | 患者关系;无记录为 `[]` |
| `items[].relations` | object[] | 患者关系;无记录为 `[]` |
| `items[].facts` | object[] | 全部当前有效事实;无记录为 `[]`,不截断 |
| `items[].facts` | object[] | 全部当前有效事实;无记录为 `[]`,不截断 |
...
@@ -158,8 +160,8 @@ curl "$PAC_BASE_URL/pac/v1/open/patient-facts/lookup" \
...
@@ -158,8 +160,8 @@ curl "$PAC_BASE_URL/pac/v1/open/patient-facts/lookup" \
"acquisitionSub": null
"acquisitionSub": null
},
},
"persona": [
"persona": [
"充填治疗"
,
{"key": "potential_treatment", "name": "潜在治疗", "values": ["充填治疗"]}
,
"重要价值"
{"key": "rfm", "name": "价值分群(RFM)", "values": ["重要价值"]}
],
],
"relations": [],
"relations": [],
"facts": [
"facts": [
...
@@ -194,7 +196,7 @@ curl "$PAC_BASE_URL/pac/v1/open/patient-facts/lookup" \
...
@@ -194,7 +196,7 @@ curl "$PAC_BASE_URL/pac/v1/open/patient-facts/lookup" \
| 字段 | 类型 | 可空 | 说明 |
| 字段 | 类型 | 可空 | 说明 |
| --- | --- | --- | --- |
| --- | --- | --- | --- |
| `id` | string | 否 | PAC 患者 UUID,用于区分患者、缓存结果 |
| `id` | string | 否 | PAC 患者 UUID,用于区分患者、缓存结果 |
| `externalId` | string
/null
| 否 | 宿主患者 ID;不是病历号,不保证跨品牌唯一,回链宿主需结合自身上下文 |
| `externalId` | string | 否 | 宿主患者 ID;不是病历号,不保证跨品牌唯一,回链宿主需结合自身上下文 |
| `name` | string | 是 | 姓名 |
| `name` | string | 是 | 姓名 |
| `medicalRecordNumber` | string | 是 | 病历号 |
| `medicalRecordNumber` | string | 是 | 病历号 |
| `phone` | string | 是 | 脱敏手机号 |
| `phone` | string | 是 | 脱敏手机号 |
...
@@ -227,7 +229,22 @@ curl "$PAC_BASE_URL/pac/v1/open/patient-facts/lookup" \
...
@@ -227,7 +229,22 @@ curl "$PAC_BASE_URL/pac/v1/open/patient-facts/lookup" \
### 画像 persona
### 画像 persona
返回标签数组,例如 `["充填治疗", "重要价值"]`。标签可能滞后于事实;标签是展示文案,不作为业务代码,也不要依赖数组位置判断业务。
每个对象代表一个画像维度,单值、多值统一使用 `values` 数组。
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `key` | string | 稳定维度标识,开放集合,如 potential_treatment、rfm、urgency_level |
| `name` | string | 维度展示名称;未登记的维度回退为 key |
| `values` | string[] | 该维度的展示值;维度内去空去重,不跨维度合并 |
```json
{"persona": [
{"key": "potential_treatment", "name": "潜在治疗", "values": ["根管治疗", "充填治疗"]},
{"key": "urgency_level", "name": "急迫等级", "values": ["中"]}
]}
```
按展示顺序返回;无可展示值的维度不返回。画像可能滞后于事实,查询不触发重算。用 `key` 识别维度,兼容新增 key;`name` 和 `values` 是展示文案,不作为业务枚举,也不依赖数组位置判断业务。
### 患者关系 relations
### 患者关系 relations
...
@@ -409,7 +426,7 @@ curl "$PAC_BASE_URL/pac/v1/open/patient-facts/lookup" \
...
@@ -409,7 +426,7 @@ curl "$PAC_BASE_URL/pac/v1/open/patient-facts/lookup" \
- **读取**:展示一张影像的分析时读取 `analysis`;它与关联的影像来源诊断表达同一批病症,不应重复计数。
- **读取**:展示一张影像的分析时读取 `analysis`;它与关联的影像来源诊断表达同一批病症,不应重复计数。
- **病症**:`name`、`toothPosition` 保留病症和牙位;`code` 未能可靠映射时为 null,仍返回该发现,不代表无异常,也不等同医生确诊。
- **病症**:`name`、`toothPosition` 保留病症和牙位;`code` 未能可靠映射时为 null,仍返回该发现,不代表无异常,也不等同医生确诊。
- **空结果**:影像可以先返回,分析取得后再补充到对应影像及牙位诊断;分析缺失不能推断检查正常。
- **空结果**:影像可以先返回,分析取得后再补充到对应影像及牙位诊断;分析缺失不能推断检查正常。
- **访问文件**:通过影像的 `fileRefs` 获取原图、缩略图或 CT 切片的临时地址,调用方法见上文。`analysis` 只包含综合结论、描述和病症列表。
- **访问文件**:通过影像的 `fileRefs` 获取原图、缩略图或 CT 切片的临时地址,
原图、缩略图直接使用查询附带的 `url`;CT 切片或地址刷新
调用方法见上文。`analysis` 只包含综合结论、描述和病症列表。
以下截取 `facts` 中的一张影像与一条分析信号;同张影像可对应多条诊断,字段省略不表示其他字段不存在。
以下截取 `facts` 中的一张影像与一条分析信号;同张影像可对应多条诊断,字段省略不表示其他字段不存在。
...
@@ -614,7 +631,7 @@ kind=planned 表示计划,actual 表示实际记录。实际记录也可能仍
...
@@ -614,7 +631,7 @@ kind=planned 表示计划,actual 表示实际记录。实际记录也可能仍
<details id="image_record">
<details id="image_record">
<summary>影像 · image_record</summary>
<summary>影像 · image_record</summary>
`fileRefs`
是稳定文件引用,访问地址通过文件访问接口获取
。fileLocation 不是 PAC 保证可访问的原片下载授权。查看原片走宿主授权入口;没有文件位置不等于没有影像。
`fileRefs`
保留稳定文件引用;原图、缩略图项同时附带临时 `url`、`expiresAt`。CT 切片保留原引用,按需调用文件访问接口
。fileLocation 不是 PAC 保证可访问的原片下载授权。查看原片走宿主授权入口;没有文件位置不等于没有影像。
| 字段 | 类型 | 含义 |
| 字段 | 类型 | 含义 |
| --- | --- | --- |
| --- | --- | --- |
...
@@ -639,6 +656,17 @@ kind=planned 表示计划,actual 表示实际记录。实际记录也可能仍
...
@@ -639,6 +656,17 @@ kind=planned 表示计划,actual 表示实际记录。实际记录也可能仍
| `fileId` | string | 来源内的稳定文件标识;按不透明字符串使用,不是 URL,也不是影像 ID |
| `fileId` | string | 来源内的稳定文件标识;按不透明字符串使用,不是 URL,也不是影像 ID |
| `role` | string | `original` 原文件;`thumbnail` 缩略图;`slice` CT 切片 |
| `role` | string | `original` 原文件;`thumbnail` 缩略图;`slice` CT 切片 |
| `index` | integer | 仅切片提供,从 0 开始的源切片序号;不同于 `fileRefs` 数组下标 |
| `index` | integer | 仅切片提供,从 0 开始的源切片序号;不同于 `fileRefs` 数组下标 |
| `url` | string/null | 原图、缩略图必返,可直接访问的临时 HTTPS 地址;CT 切片不返回此字段 |
| `expiresAt` | string/null | 地址过期时间,UTC ISO 8601;原图、缩略图必返,CT 切片不返回此字段 |
地址未找到、来源未配置或暂不可用、获取超时时,`url` 和 `expiresAt` 为 null,患者事实及分析仍正常返回;需要时调用文件访问接口重试。查询为地址获取最多额外等待约 8 秒,按诊所及来源批量解析,不保证每次查询所有地址均可获得。临时地址不作为长期数据保存。
```json
{"fileRefs": [
{"provider": "meyer", "connectionId": "example", "fileId": "original-id", "role": "original", "url": "https://files.example.com/image.png?signature=temporary", "expiresAt": "2026-10-23T08:00:00.000Z"},
{"provider": "meyer", "connectionId": "example", "fileId": "slice-id", "role": "slice", "index": 0}
]}
```
文件和影像的标识各自独立;分析及诊断仍关联影像 `externalId`。
文件和影像的标识各自独立;分析及诊断仍关联影像 `externalId`。
...
...
apps/pac-docs/openapi/pac.json
View file @
94dfcc3b
...
@@ -3331,7 +3331,7 @@
...
@@ -3331,7 +3331,7 @@
},
},
"/pac/v1/open/patient-facts/lookup"
:
{
"/pac/v1/open/patient-facts/lookup"
:
{
"post"
:
{
"post"
:
{
"description"
:
"使用应用 Bearer 令牌,需开通宿主查询权限。病历号、姓名、手机号至少一项,多项精确匹配。
\n\n
返回患者数组:`patient`(资料)、`persona`(
画像)、`relations`(关系)、`facts`(全部当前有效事实)。无匹配返回空数组,不分页
。
\n\n
[完整接口说明、字段与示例](/docs/api/open/PatientFactsLookupController_lookup)"
,
"description"
:
"使用应用 Bearer 令牌,需开通宿主查询权限。病历号、姓名、手机号至少一项,多项精确匹配。
\n\n
返回患者数组:`patient`(资料)、`persona`(
key/name/values 画像数组)、`relations`(关系)、`facts`(全部当前有效事实)。无匹配返回空数组,不分页。影像原图、缩略图的 fileRefs 附带临时 url/expiresAt;CT 切片按需获取
。
\n\n
[完整接口说明、字段与示例](/docs/api/open/PatientFactsLookupController_lookup)"
,
"operationId"
:
"PatientFactsLookupController_lookup"
,
"operationId"
:
"PatientFactsLookupController_lookup"
,
"parameters"
:
[],
"parameters"
:
[],
"requestBody"
:
{
"requestBody"
:
{
...
@@ -9502,9 +9502,32 @@
...
@@ -9502,9 +9502,32 @@
"persona"
:
{
"persona"
:
{
"type"
:
"array"
,
"type"
:
"array"
,
"items"
:
{
"items"
:
{
"type"
:
"string"
"type"
:
"object"
,
"properties"
:
{
"key"
:
{
"type"
:
"string"
,
"description"
:
"稳定画像维度标识,开放集合"
},
"name"
:
{
"type"
:
"string"
,
"description"
:
"维度展示名称;未知维度回退为 key"
},
"values"
:
{
"type"
:
"array"
,
"items"
:
{
"type"
:
"string"
},
"description"
:
"该维度的展示值,单值也用数组"
}
},
"required"
:
[
"key"
,
"name"
,
"values"
],
"additionalProperties"
:
false
},
},
"description"
:
"当前已保存画像
的展示标签;多值展开、去空去重,按展示顺序返回;无画像或无可展示标签为 [],查询不重算。文案不是枚举,不用于业务判断
"
"description"
:
"当前已保存画像
,按维度分组及展示顺序返回;维度内去空去重,不跨维度合并;无可展示值为 [],查询不重算。name 和 values 是展示文案,不是业务枚举
"
},
},
"relations"
:
{
"relations"
:
{
"type"
:
"array"
,
"type"
:
"array"
,
...
@@ -10491,6 +10514,17 @@
...
@@ -10491,6 +10514,17 @@
"type"
:
"integer"
,
"type"
:
"integer"
,
"minimum"
:
0
,
"minimum"
:
0
,
"maximum"
:
9007199254740991
"maximum"
:
9007199254740991
},
"url"
:
{
"type"
:
"string"
,
"format"
:
"uri"
,
"nullable"
:
true
},
"expiresAt"
:
{
"type"
:
"string"
,
"format"
:
"date-time"
,
"pattern"
:
"^(?:(?:
\\
d
\\
d[2468][048]|
\\
d
\\
d[13579][26]|
\\
d
\\
d0[48]|[02468][048]00|[13579][26]00)-02-29|
\\
d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]
\\
d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]
\\
d|30)|(?:02)-(?:0[1-9]|1
\\
d|2[0-8])))T(?:(?:[01]
\\
d|2[0-3]):[0-5]
\\
d(?::[0-5]
\\
d(?:
\\
.
\\
d+)?)?(?:Z))$"
,
"nullable"
:
true
}
}
},
},
"required"
:
[
"required"
:
[
...
...
apps/pac-service/src/modules/open/lookup-content.schema.ts
View file @
94dfcc3b
import
{
z
}
from
'zod'
;
import
{
z
}
from
'zod'
;
import
{
ImageFileRefsSchema
}
from
'@pac/types'
;
import
{
ImageFileRefSchema
}
from
'@pac/types'
;
// 仅开放查询增加临时地址;不写入事实,不改变源文件引用。
export
const
LookupFileRefSchema
=
ImageFileRefSchema
.
safeExtend
({
url
:
z
.
string
().
url
().
nullable
().
optional
(),
expiresAt
:
z
.
iso
.
datetime
().
nullable
().
optional
(),
}).
superRefine
((
ref
,
ctx
)
=>
{
if
(
ref
.
role
===
'slice'
&&
(
ref
.
url
!==
undefined
||
ref
.
expiresAt
!==
undefined
))
ctx
.
addIssue
({
code
:
'custom'
,
message
:
'slice URLs are resolved on demand'
});
});
// 新接口专属白名单;不复用入库 passthrough,内部增加字段不会自动扩大公开契约。
// 新接口专属白名单;不复用入库 passthrough,内部增加字段不会自动扩大公开契约。
const
s
=
z
.
string
().
nullable
().
optional
();
const
s
=
z
.
string
().
nullable
().
optional
();
...
@@ -149,7 +158,7 @@ export const LookupContentSchemas = {
...
@@ -149,7 +158,7 @@ export const LookupContentSchemas = {
finding
:
s
,
finding
:
s
,
capturedAt
:
s
,
capturedAt
:
s
,
fileLocation
:
s
,
fileLocation
:
s
,
fileRefs
:
ImageFileRefsSchema
.
nullable
().
optional
(),
fileRefs
:
z
.
array
(
LookupFileRefSchema
).
max
(
10000
)
.
nullable
().
optional
(),
fileName
:
s
,
fileName
:
s
,
fileType
:
s
,
fileType
:
s
,
analysis
:
LookupAnalysisSchema
.
nullable
(),
analysis
:
LookupAnalysisSchema
.
nullable
(),
...
...
apps/pac-service/src/modules/open/lookup-image-access.service.ts
0 → 100644
View file @
94dfcc3b
import
{
Inject
,
Injectable
,
Logger
}
from
'@nestjs/common'
;
import
type
{
ImageFileRef
}
from
'@pac/types'
;
import
type
{
PatientFactsLookupItem
}
from
'./patient-facts-lookup.schema'
;
import
{
IMAGE_FILE_PROVIDERS
,
type
ImageFileContext
,
type
ImageFileProvider
}
from
'./image-file-provider'
;
export
const
LOOKUP_IMAGE_ACCESS_BUDGET_MS
=
8000
;
type
File
=
ImageFileRef
&
{
url
?:
string
|
null
;
expiresAt
?:
string
|
null
};
type
Group
=
{
context
:
ImageFileContext
;
provider
:
ImageFileProvider
;
files
:
Map
<
string
,
File
[]
>
};
/** 查询附带原图/缩略图授权;只使用已授权事实快照中的引用,不改变事实与 CT 切片。 */
@
Injectable
()
export
class
LookupImageAccessService
{
private
readonly
logger
=
new
Logger
(
LookupImageAccessService
.
name
);
constructor
(@
Inject
(
IMAGE_FILE_PROVIDERS
)
private
readonly
providers
:
ImageFileProvider
[])
{}
async
enrich
(
items
:
PatientFactsLookupItem
[],
contexts
:
ReadonlyMap
<
string
,
ImageFileContext
>
):
Promise
<
void
>
{
const
groups
=
new
Map
<
string
,
Group
>
();
const
outputs
:
File
[]
=
[];
for
(
const
item
of
items
)
for
(
const
fact
of
item
.
facts
)
{
if
(
fact
.
type
!==
'image_record'
)
continue
;
const
context
=
contexts
.
get
(
fact
.
id
);
for
(
const
ref
of
fact
.
content
.
fileRefs
??
[])
{
if
(
ref
.
role
===
'slice'
)
continue
;
ref
.
url
=
null
;
ref
.
expiresAt
=
null
;
outputs
.
push
(
ref
);
const
provider
=
this
.
providers
.
find
(
p
=>
p
.
provider
===
ref
.
provider
);
if
(
!
context
||
!
provider
)
continue
;
// 患者已经过宿主与身份域校验;跨诊所、品牌、连接的文件绝不能混批。
const
key
=
JSON
.
stringify
([
context
.
hostId
,
context
.
tenantId
,
context
.
sourceUnit
,
context
.
clinicId
,
ref
.
provider
,
ref
.
connectionId
??
''
]);
let
group
=
groups
.
get
(
key
);
if
(
!
group
)
{
group
=
{
context
,
provider
,
files
:
new
Map
()
};
groups
.
set
(
key
,
group
);
}
const
refs
=
group
.
files
.
get
(
ref
.
fileId
)
??
[];
refs
.
push
(
ref
);
group
.
files
.
set
(
ref
.
fileId
,
refs
);
}
}
const
batches
:
Array
<
{
group
:
Group
;
ids
:
string
[]
}
>
=
[];
for
(
const
group
of
groups
.
values
())
{
const
ids
=
[...
group
.
files
.
keys
()];
for
(
let
i
=
0
;
i
<
ids
.
length
;
i
+=
100
)
batches
.
push
({
group
,
ids
:
ids
.
slice
(
i
,
i
+
100
)
});
}
if
(
!
batches
.
length
)
return
;
let
next
=
0
,
closed
=
false
,
failures
=
0
;
const
deadline
=
Date
.
now
()
+
LOOKUP_IMAGE_ACCESS_BUDGET_MS
;
const
worker
=
async
()
=>
{
while
(
!
closed
&&
Date
.
now
()
<
deadline
&&
next
<
batches
.
length
)
{
const
{
group
,
ids
}
=
batches
[
next
++
]
!
;
// 临时地址不能传回稳定引用接口。
const
refs
=
ids
.
map
(
id
=>
{
const
{
url
:
_url
,
expiresAt
:
_expiry
,
...
ref
}
=
group
.
files
.
get
(
id
)
!
[
0
]
!
;
return
ref
;
});
try
{
const
result
=
await
group
.
provider
.
resolve
(
group
.
context
,
refs
);
if
(
closed
||
Date
.
now
()
>=
deadline
)
return
;
if
(
result
.
length
!==
ids
.
length
||
result
.
some
((
r
,
i
)
=>
r
.
fileId
!==
ids
[
i
]))
throw
new
Error
(
'File response mismatch'
);
for
(
const
access
of
result
)
{
if
(
!
access
.
found
||
!
access
.
url
||
!
access
.
expiresAt
)
continue
;
const
expiry
=
Date
.
parse
(
access
.
expiresAt
);
if
(
!
Number
.
isFinite
(
expiry
)
||
expiry
<=
Date
.
now
()
+
5000
||
new
URL
(
access
.
url
).
protocol
!==
'https:'
)
continue
;
for
(
const
ref
of
group
.
files
.
get
(
access
.
fileId
)
!
)
{
ref
.
url
=
access
.
url
;
ref
.
expiresAt
=
new
Date
(
expiry
).
toISOString
();
}
}
}
catch
{
failures
++
;
}
}
};
let
timer
:
ReturnType
<
typeof
setTimeout
>
|
undefined
;
try
{
await
Promise
.
race
([
Promise
.
all
(
Array
.
from
({
length
:
Math
.
min
(
2
,
batches
.
length
)
},
worker
)),
new
Promise
<
void
>
(
resolve
=>
{
timer
=
setTimeout
(
resolve
,
LOOKUP_IMAGE_ACCESS_BUDGET_MS
);
}),
]);
}
finally
{
// 已发出的供应商请求由其自身超时收尾;至多 2 个,⛔ 超时后继续排队或修改返回对象。
closed
=
true
;
if
(
timer
)
clearTimeout
(
timer
);
}
for
(
const
ref
of
outputs
)
{
if
(
ref
.
expiresAt
&&
Date
.
parse
(
ref
.
expiresAt
)
<=
Date
.
now
()
+
5000
)
{
ref
.
url
=
null
;
ref
.
expiresAt
=
null
;
}
}
if
(
failures
||
outputs
.
some
(
ref
=>
!
ref
.
url
))
this
.
logger
.
warn
(
JSON
.
stringify
({
event
:
'open_lookup_image_access_partial'
,
requestedFiles
:
outputs
.
length
,
resolvedFiles
:
outputs
.
filter
(
ref
=>
ref
.
url
).
length
,
failedBatches
:
failures
,
}));
}
}
apps/pac-service/src/modules/open/open-persona.ts
View file @
94dfcc3b
import
{
personaFeatureSortKey
,
personaValueLabels
,
shortPersonaValueLabel
}
from
'@pac/types'
;
import
{
PERSONA_FEATURE_SPECS
,
personaFeatureSortKey
,
personaValueLabels
,
shortPersonaValueLabel
}
from
'@pac/types'
;
import
{
redactText
}
from
'./open-redact'
;
import
{
redactText
}
from
'./open-redact'
;
interface
PersonaDisplayFeature
{
interface
PersonaDisplayFeature
{
...
@@ -10,21 +10,28 @@ interface PersonaDisplayFeature {
...
@@ -10,21 +10,28 @@ interface PersonaDisplayFeature {
/** 读取已存画像,展开全部展示标签;不触发计算,也不按工作台首屏白名单截断。 */
/** 读取已存画像,展开全部展示标签;不触发计算,也不按工作台首屏白名单截断。 */
export
function
toOpenPersona
(
export
function
toOpenPersona
(
features
:
readonly
PersonaDisplayFeature
[]
|
null
|
undefined
,
features
:
readonly
PersonaDisplayFeature
[]
|
null
|
undefined
,
):
string
[]
{
):
Array
<
{
key
:
string
;
name
:
string
;
values
:
string
[]
}
>
{
const
ordered
=
[...(
features
??
[])].
sort
((
a
,
b
)
=>
{
const
ordered
=
[...(
features
??
[])].
sort
((
a
,
b
)
=>
{
const
[
ga
,
oa
]
=
personaFeatureSortKey
(
a
.
key
);
const
[
ga
,
oa
]
=
personaFeatureSortKey
(
a
.
key
);
const
[
gb
,
ob
]
=
personaFeatureSortKey
(
b
.
key
);
const
[
gb
,
ob
]
=
personaFeatureSortKey
(
b
.
key
);
return
ga
-
gb
||
oa
-
ob
||
a
.
key
.
localeCompare
(
b
.
key
);
return
ga
-
gb
||
oa
-
ob
||
a
.
key
.
localeCompare
(
b
.
key
);
});
});
const
tags
=
new
Set
<
string
>
();
const
tags
=
new
Map
<
string
,
{
key
:
string
;
name
:
string
;
values
:
string
[]
}
>
();
for
(
const
feature
of
ordered
)
{
for
(
const
feature
of
ordered
)
{
const
labels
=
personaValueLabels
(
feature
.
data
,
feature
.
key
)
??
[
const
labels
=
personaValueLabels
(
feature
.
data
,
feature
.
key
)
??
[
shortPersonaValueLabel
(
feature
.
description
),
shortPersonaValueLabel
(
feature
.
description
),
];
];
for
(
const
label
of
labels
)
{
for
(
const
label
of
labels
)
{
const
text
=
redactText
(
label
)?.
trim
();
const
text
=
redactText
(
label
)?.
trim
();
if
(
text
&&
text
!==
'—'
)
tags
.
add
(
text
);
if
(
text
&&
text
!==
'—'
)
{
let
group
=
tags
.
get
(
feature
.
key
);
if
(
!
group
)
{
group
=
{
key
:
feature
.
key
,
name
:
PERSONA_FEATURE_SPECS
[
feature
.
key
]?.
nameZh
??
feature
.
key
,
values
:
[]
};
tags
.
set
(
feature
.
key
,
group
);
}
if
(
!
group
.
values
.
includes
(
text
))
group
.
values
.
push
(
text
);
}
}
}
}
}
return
[...
tags
];
return
[...
tags
.
values
()
];
}
}
apps/pac-service/src/modules/open/open.module.ts
View file @
94dfcc3b
...
@@ -2,6 +2,7 @@ import { ApplicationTokenGuard } from './application-token.guard';
...
@@ -2,6 +2,7 @@ import { ApplicationTokenGuard } from './application-token.guard';
import
{
PatientFactsLookupController
}
from
'./patient-facts-lookup.controller'
;
import
{
PatientFactsLookupController
}
from
'./patient-facts-lookup.controller'
;
import
{
PatientFactsLookupService
}
from
'./patient-facts-lookup.service'
;
import
{
PatientFactsLookupService
}
from
'./patient-facts-lookup.service'
;
import
{
Module
}
from
'@nestjs/common'
;
import
{
Module
}
from
'@nestjs/common'
;
import
{
LookupImageAccessService
}
from
'./lookup-image-access.service'
;
import
{
AuthModule
}
from
'../auth/auth.module'
;
import
{
AuthModule
}
from
'../auth/auth.module'
;
import
{
PatientModule
}
from
'../patient/patient.module'
;
import
{
PatientModule
}
from
'../patient/patient.module'
;
import
{
HmacVerifier
}
from
'../sync/push/hmac-verifier.service'
;
import
{
HmacVerifier
}
from
'../sync/push/hmac-verifier.service'
;
...
@@ -27,7 +28,7 @@ import { MeyerImageFileProvider } from '../../integrations/meyer/image-file-prov
...
@@ -27,7 +28,7 @@ import { MeyerImageFileProvider } from '../../integrations/meyer/image-file-prov
@
Module
({
@
Module
({
imports
:
[
AuthModule
,
PatientModule
],
imports
:
[
AuthModule
,
PatientModule
],
controllers
:
[
PatientFactsLookupController
,
ImageFilesController
,
OpenPatientsController
,
OpenOrgController
,
OpenIdentityController
],
controllers
:
[
PatientFactsLookupController
,
ImageFilesController
,
OpenPatientsController
,
OpenOrgController
,
OpenIdentityController
],
providers
:
[
ApplicationTokenGuard
,
PatientFactsLookupService
,
ImageFilesService
,
MeyerImageFileProvider
,
providers
:
[
ApplicationTokenGuard
,
PatientFactsLookupService
,
LookupImageAccessService
,
ImageFilesService
,
MeyerImageFileProvider
,
{
provide
:
IMAGE_FILE_PROVIDERS
,
useFactory
:
(
meyer
:
MeyerImageFileProvider
)
=>
[
meyer
],
inject
:
[
MeyerImageFileProvider
]
},
{
provide
:
IMAGE_FILE_PROVIDERS
,
useFactory
:
(
meyer
:
MeyerImageFileProvider
)
=>
[
meyer
],
inject
:
[
MeyerImageFileProvider
]
},
HmacVerifier
,
OpenProfileService
,
OpenAppointmentService
,
OpenFactGate
,
OpenFactWindowService
,],
HmacVerifier
,
OpenProfileService
,
OpenAppointmentService
,
OpenFactGate
,
OpenFactWindowService
,],
})
})
...
...
apps/pac-service/src/modules/open/patient-facts-lookup.controller.ts
View file @
94dfcc3b
import
{
Body
,
Controller
,
HttpCode
,
Post
,
Req
,
UseGuards
}
from
'@nestjs/common'
;
import
{
Body
,
Controller
,
H
eader
,
H
ttpCode
,
Post
,
Req
,
UseGuards
}
from
'@nestjs/common'
;
import
{
ApiBearerAuth
,
ApiOperation
,
ApiTags
}
from
'@nestjs/swagger'
;
import
{
ApiBearerAuth
,
ApiOperation
,
ApiTags
}
from
'@nestjs/swagger'
;
import
{
ZodResponse
}
from
'nestjs-zod'
;
import
{
ZodResponse
}
from
'nestjs-zod'
;
import
{
Public
}
from
'../../common/decorators/public.decorator'
;
import
{
Public
}
from
'../../common/decorators/public.decorator'
;
...
@@ -17,6 +17,7 @@ export class PatientFactsLookupController {
...
@@ -17,6 +17,7 @@ export class PatientFactsLookupController {
@
Post
(
'lookup'
)
@
Post
(
'lookup'
)
@
HttpCode
(
200
)
@
HttpCode
(
200
)
@
Header
(
'Cache-Control'
,
'private, no-store'
)
@
ZodResponse
({
status
:
200
,
type
:
PatientFactsLookupResponseDto
})
@
ZodResponse
({
status
:
200
,
type
:
PatientFactsLookupResponseDto
})
@
Public
()
@
Public
()
@
UseGuards
(
ApplicationTokenGuard
)
@
UseGuards
(
ApplicationTokenGuard
)
...
@@ -24,7 +25,7 @@ export class PatientFactsLookupController {
...
@@ -24,7 +25,7 @@ export class PatientFactsLookupController {
summary
:
'查询患者资料与事实'
,
summary
:
'查询患者资料与事实'
,
description
:
[
description
:
[
'使用应用 Bearer 令牌,需开通宿主查询权限。病历号、姓名、手机号至少一项,多项精确匹配。'
,
'使用应用 Bearer 令牌,需开通宿主查询权限。病历号、姓名、手机号至少一项,多项精确匹配。'
,
'返回患者数组:`patient`(资料)、`persona`(
画像)、`relations`(关系)、`facts`(全部当前有效事实)。无匹配返回空数组,不分页
。'
,
'返回患者数组:`patient`(资料)、`persona`(
key/name/values 画像数组)、`relations`(关系)、`facts`(全部当前有效事实)。无匹配返回空数组,不分页。影像原图、缩略图的 fileRefs 附带临时 url/expiresAt;CT 切片按需获取
。'
,
'[完整接口说明、字段与示例](/docs/api/open/PatientFactsLookupController_lookup)'
,
'[完整接口说明、字段与示例](/docs/api/open/PatientFactsLookupController_lookup)'
,
].
join
(
'
\
n
\
n'
),
].
join
(
'
\
n
\
n'
),
})
})
...
...
apps/pac-service/src/modules/open/patient-facts-lookup.schema.ts
View file @
94dfcc3b
...
@@ -188,9 +188,13 @@ export const PatientFactsLookupResponseSchema = z.object({
...
@@ -188,9 +188,13 @@ export const PatientFactsLookupResponseSchema = z.object({
.
describe
(
'获客二级渠道,保留宿主原值;未提供时为 null'
),
.
describe
(
'获客二级渠道,保留宿主原值;未提供时为 null'
),
}),
}),
persona
:
z
persona
:
z
.
array
(
z
.
string
())
.
array
(
z
.
object
({
key
:
z
.
string
().
describe
(
'稳定画像维度标识,开放集合'
),
name
:
z
.
string
().
describe
(
'维度展示名称;未知维度回退为 key'
),
values
:
z
.
array
(
z
.
string
()).
describe
(
'该维度的展示值,单值也用数组'
),
}).
strict
())
.
describe
(
.
describe
(
'当前已保存画像
的展示标签;多值展开、去空去重,按展示顺序返回;无画像或无可展示标签为 [],查询不重算。文案不是枚举,不用于业务判断
'
,
'当前已保存画像
,按维度分组及展示顺序返回;维度内去空去重,不跨维度合并;无可展示值为 [],查询不重算。name 和 values 是展示文案,不是业务枚举
'
,
),
),
relations
:
z
.
array
(
relations
:
z
.
array
(
z
.
object
({
z
.
object
({
...
...
apps/pac-service/src/modules/open/patient-facts-lookup.service.ts
View file @
94dfcc3b
...
@@ -7,6 +7,8 @@ import { LookupContentError } from './lookup-narrative';
...
@@ -7,6 +7,8 @@ import { LookupContentError } from './lookup-narrative';
import
{
toLookupFact
}
from
'./lookup-fact'
;
import
{
toLookupFact
}
from
'./lookup-fact'
;
import
{
redactText
}
from
'./open-redact'
;
import
{
redactText
}
from
'./open-redact'
;
import
{
toOpenPersona
}
from
'./open-persona'
;
import
{
toOpenPersona
}
from
'./open-persona'
;
import
{
LookupImageAccessService
}
from
'./lookup-image-access.service'
;
import
type
{
ImageFileContext
}
from
'./image-file-provider'
;
import
{
import
{
PatientFactsLookupSchema
,
PatientFactsLookupSchema
,
type
PatientFactsLookupRequest
,
type
PatientFactsLookupRequest
,
...
@@ -17,6 +19,8 @@ import type { ApplicationPrincipal } from './application-token.guard';
...
@@ -17,6 +19,8 @@ import type { ApplicationPrincipal } from './application-token.guard';
type
LookupRow
=
OpenFactRow
&
{
type
LookupRow
=
OpenFactRow
&
{
patient_id
:
string
;
patient_id
:
string
;
host_name
:
string
;
host_name
:
string
;
tenant_id
:
string
;
source_unit
:
string
;
fact_id
:
string
|
null
;
fact_id
:
string
|
null
;
appointment_created_values
?:
unknown
;
appointment_created_values
?:
unknown
;
source_evidence
?:
unknown
;
source_evidence
?:
unknown
;
...
@@ -73,7 +77,7 @@ function dedicatedCsOf(
...
@@ -73,7 +77,7 @@ function dedicatedCsOf(
@
Injectable
()
@
Injectable
()
export
class
PatientFactsLookupService
{
export
class
PatientFactsLookupService
{
private
readonly
logger
=
new
Logger
(
PatientFactsLookupService
.
name
);
private
readonly
logger
=
new
Logger
(
PatientFactsLookupService
.
name
);
constructor
(
private
readonly
prisma
:
PrismaService
)
{}
constructor
(
private
readonly
prisma
:
PrismaService
,
private
readonly
imageAccess
:
LookupImageAccessService
)
{}
async
lookup
(
async
lookup
(
application
:
ApplicationPrincipal
,
application
:
ApplicationPrincipal
,
...
@@ -81,13 +85,14 @@ export class PatientFactsLookupService {
...
@@ -81,13 +85,14 @@ export class PatientFactsLookupService {
):
Promise
<
{
items
:
LookupItem
[]
}
>
{
):
Promise
<
{
items
:
LookupItem
[]
}
>
{
const
startedAt
=
Date
.
now
();
const
startedAt
=
Date
.
now
();
const
q
=
PatientFactsLookupSchema
.
parse
(
input
);
const
q
=
PatientFactsLookupSchema
.
parse
(
input
);
const
imageContexts
=
new
Map
<
string
,
ImageFileContext
>
();
// 多表读取使用同一可重复读快照;⛔ 不在查询接口触发画像重算。
// 多表读取使用同一可重复读快照;⛔ 不在查询接口触发画像重算。
const
items
=
await
this
.
prisma
.
$transaction
(
const
items
=
await
this
.
prisma
.
$transaction
(
async
(
tx
)
=>
{
async
(
tx
)
=>
{
// 不设 LIMIT,不静默截断;以 PAC 患者主键分组,⛔ 不按姓名/电话/病历号合并不同人。
// 不设 LIMIT,不静默截断;以 PAC 患者主键分组,⛔ 不按姓名/电话/病历号合并不同人。
// 预约创建时间只查本版本绑定的证据主键,⛔ 不按姓名或预约号跨患者补配、不回扫账本。
// 预约创建时间只查本版本绑定的证据主键,⛔ 不按姓名或预约号跨患者补配、不回扫账本。
const
rows
=
await
tx
.
$queryRaw
<
LookupRow
[]
>
(
Prisma
.
sql
`
const
rows
=
await
tx
.
$queryRaw
<
LookupRow
[]
>
(
Prisma
.
sql
`
SELECT p.id AS patient_id, h.name AS host_name,
SELECT p.id AS patient_id, h.name AS host_name,
p.tenant_id, p.source_unit,
f.id AS fact_id, f.subject_id, f.type, f.kind, f.status, f.clinic_id,
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 (
CASE WHEN f.type = 'appointment_record' AND h.name IN ('friday', 'jvs-dw') THEN (
...
@@ -231,6 +236,11 @@ export class PatientFactsLookupService {
...
@@ -231,6 +236,11 @@ export class PatientFactsLookupService {
throw
error
;
throw
error
;
}
}
}
}
for
(
const
row
of
rows
)
{
if
(
row
.
fact_id
&&
row
.
type
===
'image_record'
&&
row
.
clinic_id
)
imageContexts
.
set
(
row
.
fact_id
,
{
hostId
:
application
.
hostId
,
hostName
:
row
.
host_name
,
tenantId
:
row
.
tenant_id
,
sourceUnit
:
row
.
source_unit
,
clinicId
:
row
.
clinic_id
});
}
// 按实际返回的时间排序,避免预约旧兜底值改变对外时间轴。
// 按实际返回的时间排序,避免预约旧兜底值改变对外时间轴。
for
(
const
item
of
items
.
values
())
for
(
const
item
of
items
.
values
())
item
.
facts
.
sort
((
a
,
b
)
=>
{
item
.
facts
.
sort
((
a
,
b
)
=>
{
...
@@ -245,6 +255,8 @@ export class PatientFactsLookupService {
...
@@ -245,6 +255,8 @@ export class PatientFactsLookupService {
},
},
{
isolationLevel
:
Prisma
.
TransactionIsolationLevel
.
RepeatableRead
,
timeout
:
15000
},
{
isolationLevel
:
Prisma
.
TransactionIsolationLevel
.
RepeatableRead
,
timeout
:
15000
},
);
);
// 外部文件授权在数据库快照结束后执行,⛔ 不占着事务等待供应商。
await
this
.
imageAccess
.
enrich
([...
items
.
values
()],
imageContexts
);
// 审计仅记录调用应用、条件字段和数量,⛔ 不记录查询值、令牌或患者正文。
// 审计仅记录调用应用、条件字段和数量,⛔ 不记录查询值、令牌或患者正文。
this
.
logger
.
log
(
this
.
logger
.
log
(
JSON
.
stringify
({
JSON
.
stringify
({
...
...
apps/pac-service/tests/lookup-contract-postgres.spec.ts
View file @
94dfcc3b
...
@@ -57,7 +57,7 @@ describePg('lookup contract | isolated PostgreSQL and real HTTP', () => {
...
@@ -57,7 +57,7 @@ describePg('lookup contract | isolated PostgreSQL and real HTTP', () => {
},
},
})
})
).
id
;
).
id
;
service
=
new
PatientFactsLookupService
(
db
as
any
);
service
=
new
PatientFactsLookupService
(
db
as
any
,
{
enrich
:
jest
.
fn
().
mockResolvedValue
(
undefined
)
}
as
any
);
});
});
beforeEach
(
async
()
=>
{
beforeEach
(
async
()
=>
{
await
db
.
patientFact
.
deleteMany
({
where
:
{
hostId
:
{
in
:
[
friday
,
jvs
]
}
}
});
await
db
.
patientFact
.
deleteMany
({
where
:
{
hostId
:
{
in
:
[
friday
,
jvs
]
}
}
});
...
...
apps/pac-service/tests/lookup-image-access.spec.ts
0 → 100644
View file @
94dfcc3b
import
{
LookupImageAccessService
,
LOOKUP_IMAGE_ACCESS_BUDGET_MS
}
from
'../src/modules/open/lookup-image-access.service'
;
import
{
LookupFileRefSchema
}
from
'../src/modules/open/lookup-content.schema'
;
const
context
=
{
hostId
:
'h'
,
hostName
:
'friday'
,
tenantId
:
't'
,
sourceUnit
:
'b'
,
clinicId
:
'c'
};
const
ref
=
(
fileId
:
string
,
role
=
'original'
,
extra
=
{})
=>
({
provider
:
'meyer'
,
connectionId
:
'test'
,
fileId
,
role
,
...
extra
});
const
item
=
(
refs
:
any
[],
id
=
'f'
)
=>
({
facts
:
[{
id
,
type
:
'image_record'
,
content
:
{
fileRefs
:
refs
,
analysis
:
{
source
:
'meyer'
}
}
}]
})
as
any
;
const
access
=
(
refs
:
any
[])
=>
refs
.
map
(
r
=>
({
fileId
:
r
.
fileId
,
found
:
true
,
url
:
`https://example.com/
${
r
.
fileId
}
`
,
expiresAt
:
new
Date
(
Date
.
now
()
+
60000
).
toISOString
()
}));
describe
(
'lookup inline image access'
,
()
=>
{
it
(
'batches originals and thumbnails, deduplicates files, leaves CT slices untouched'
,
async
()
=>
{
const
resolve
=
jest
.
fn
(
async
(
_c
,
refs
)
=>
access
(
refs
));
const
items
=
[
item
([
ref
(
'a'
),
ref
(
'b'
,
'thumbnail'
),
ref
(
's'
,
'slice'
,
{
index
:
0
})]),
item
([
ref
(
'a'
)],
'f2'
)];
await
new
LookupImageAccessService
([{
provider
:
'meyer'
,
resolve
}]).
enrich
(
items
,
new
Map
([[
'f'
,
context
],
[
'f2'
,
context
]]));
expect
(
resolve
).
toHaveBeenCalledTimes
(
1
);
expect
(
resolve
.
mock
.
calls
[
0
]
!
[
1
].
map
((
r
:
any
)
=>
r
.
fileId
)).
toEqual
([
'a'
,
'b'
]);
expect
(
items
[
0
].
facts
[
0
].
content
.
fileRefs
[
0
].
url
).
toBe
(
'https://example.com/a'
);
expect
(
items
[
1
].
facts
[
0
].
content
.
fileRefs
[
0
].
url
).
toBe
(
'https://example.com/a'
);
expect
(
items
[
0
].
facts
[
0
].
content
.
fileRefs
[
2
]).
toEqual
(
ref
(
's'
,
'slice'
,
{
index
:
0
}));
for
(
const
f
of
items
.
flatMap
(
i
=>
i
.
facts
))
for
(
const
r
of
f
.
content
.
fileRefs
)
expect
(
LookupFileRefSchema
.
safeParse
(
r
).
success
).
toBe
(
true
);
});
it
(
'never combines different clinics, brands or connections, and sends at most 100 IDs'
,
async
()
=>
{
const
resolve
=
jest
.
fn
(
async
(
_c
,
refs
)
=>
access
(
refs
));
const
items
=
[
item
(
Array
.
from
({
length
:
205
},
(
_
,
i
)
=>
ref
(
String
(
i
)))),
item
([
ref
(
'same'
)],
'other'
),
item
([
ref
(
'same'
,
'original'
,
{
connectionId
:
'other'
})],
'conn'
),
item
([
ref
(
'same'
)],
'brand'
)];
await
new
LookupImageAccessService
([{
provider
:
'meyer'
,
resolve
}]).
enrich
(
items
,
new
Map
([[
'f'
,
context
],
[
'other'
,
{
...
context
,
clinicId
:
'other'
}],
[
'conn'
,
context
],
[
'brand'
,
{
...
context
,
sourceUnit
:
'other'
}]]));
expect
(
resolve
).
toHaveBeenCalledTimes
(
6
);
expect
(
resolve
.
mock
.
calls
.
every
(([,
refs
])
=>
refs
.
length
<=
100
)).
toBe
(
true
);
expect
(
resolve
.
mock
.
calls
.
some
(([
ctx
])
=>
ctx
.
clinicId
===
'other'
)).
toBe
(
true
);
expect
(
resolve
.
mock
.
calls
.
some
(([
ctx
])
=>
ctx
.
sourceUnit
===
'other'
)).
toBe
(
true
);
});
it
(
'preserves facts with null URLs for missing, unsupported, failed or mismatched results'
,
async
()
=>
{
const
cases
=
[
jest
.
fn
().
mockRejectedValue
(
new
Error
(
'offline'
)),
jest
.
fn
(
async
(
_c
,
refs
)
=>
refs
.
map
((
r
:
any
)
=>
({
fileId
:
r
.
fileId
,
found
:
false
,
url
:
null
,
expiresAt
:
null
}))),
jest
.
fn
(
async
()
=>
access
([{
fileId
:
'wrong'
}]))];
for
(
const
resolve
of
cases
)
{
const
items
=
[
item
([
ref
(
'a'
)])];
await
new
LookupImageAccessService
([{
provider
:
'meyer'
,
resolve
}]).
enrich
(
items
,
new
Map
([[
'f'
,
context
]]));
expect
(
items
[
0
].
facts
[
0
].
content
).
toMatchObject
({
analysis
:
{
source
:
'meyer'
},
fileRefs
:
[{
url
:
null
,
expiresAt
:
null
}]
});
}
const
resolve
=
jest
.
fn
();
const
items
=
[
item
([
ref
(
'a'
)])];
await
new
LookupImageAccessService
([{
provider
:
'meyer'
,
resolve
}]).
enrich
(
items
,
new
Map
());
expect
(
resolve
).
not
.
toHaveBeenCalled
();
expect
(
items
[
0
].
facts
[
0
].
content
.
fileRefs
[
0
].
url
).
toBeNull
();
});
it
(
'rejects expired or insecure addresses without failing the query'
,
async
()
=>
{
const
resolve
=
jest
.
fn
(
async
(
_c
,
refs
)
=>
access
(
refs
).
map
((
r
,
i
)
=>
i
===
0
?
{
...
r
,
expiresAt
:
new
Date
(
Date
.
now
()
-
1
).
toISOString
()
}
:
{
...
r
,
url
:
'http://example.com/a'
}));
const
items
=
[
item
([
ref
(
'a'
),
ref
(
'b'
)])];
await
new
LookupImageAccessService
([{
provider
:
'meyer'
,
resolve
}]).
enrich
(
items
,
new
Map
([[
'f'
,
context
]]));
expect
(
items
[
0
].
facts
[
0
].
content
.
fileRefs
.
every
((
r
:
any
)
=>
r
.
url
===
null
)).
toBe
(
true
);
});
it
(
'bounds lookup latency and concurrency, and never mutates a response after timeout'
,
async
()
=>
{
jest
.
useFakeTimers
();
try
{
const
pending
:
Array
<
()
=>
void
>
=
[];
const
resolve
=
jest
.
fn
((
_c
,
refs
)
=>
new
Promise
<
any
[]
>
(
done
=>
pending
.
push
(()
=>
done
(
access
(
refs
)))));
const
items
=
[
item
(
Array
.
from
({
length
:
301
},
(
_
,
i
)
=>
ref
(
String
(
i
))))];
const
run
=
new
LookupImageAccessService
([{
provider
:
'meyer'
,
resolve
}]).
enrich
(
items
,
new
Map
([[
'f'
,
context
]]));
expect
(
resolve
).
toHaveBeenCalledTimes
(
2
);
await
jest
.
advanceTimersByTimeAsync
(
LOOKUP_IMAGE_ACCESS_BUDGET_MS
);
await
run
;
const
snapshot
=
JSON
.
stringify
(
items
);
pending
.
forEach
(
done
=>
done
());
await
Promise
.
resolve
();
await
Promise
.
resolve
();
expect
(
resolve
).
toHaveBeenCalledTimes
(
2
);
expect
(
JSON
.
stringify
(
items
)).
toBe
(
snapshot
);
}
finally
{
jest
.
useRealTimers
();
}
});
it
(
'disallows inline CT URLs in the response schema'
,
()
=>
{
expect
(
LookupFileRefSchema
.
safeParse
({
...
ref
(
's'
,
'slice'
,
{
index
:
0
}),
url
:
null
}).
success
).
toBe
(
false
);
});
});
apps/pac-service/tests/open-patient-enum-doc.spec.ts
View file @
94dfcc3b
...
@@ -82,7 +82,8 @@ describe('open patient enum documentation', () => {
...
@@ -82,7 +82,8 @@ describe('open patient enum documentation', () => {
expect
(
PatientFactsLookupResponseSchema
.
safeParse
(
response
.
data
).
success
).
toBe
(
true
);
expect
(
PatientFactsLookupResponseSchema
.
safeParse
(
response
.
data
).
success
).
toBe
(
true
);
const
persona
=
PatientFactsLookupResponseSchema
.
shape
.
items
.
element
.
shape
.
persona
;
const
persona
=
PatientFactsLookupResponseSchema
.
shape
.
items
.
element
.
shape
.
persona
;
expect
(
persona
.
safeParse
([]).
success
).
toBe
(
true
);
expect
(
persona
.
safeParse
([]).
success
).
toBe
(
true
);
expect
(
persona
.
safeParse
([
'充填治疗'
]).
success
).
toBe
(
true
);
expect
(
persona
.
safeParse
([{
key
:
'potential_treatment'
,
name
:
'潜在治疗'
,
values
:
[
'充填治疗'
]
}]).
success
).
toBe
(
true
);
expect
(
persona
.
safeParse
([
'充填治疗'
]).
success
).
toBe
(
false
);
expect
(
persona
.
safeParse
(
null
).
success
).
toBe
(
false
);
expect
(
persona
.
safeParse
(
null
).
success
).
toBe
(
false
);
expect
(
persona
.
safeParse
({
features
:
[]
}).
success
).
toBe
(
false
);
expect
(
persona
.
safeParse
({
features
:
[]
}).
success
).
toBe
(
false
);
});
});
...
...
apps/pac-service/tests/open-persona.spec.ts
View file @
94dfcc3b
import
{
compactPersonaValue
,
personaValueLabels
,
shortPersonaValueLabel
}
from
'@pac/types'
;
import
{
PERSONA_FEATURE_SPECS
,
compactPersonaValue
,
personaValueLabels
,
shortPersonaValueLabel
}
from
'@pac/types'
;
import
{
toOpenPersona
}
from
'../src/modules/open/open-persona'
;
import
{
toOpenPersona
}
from
'../src/modules/open/open-persona'
;
const
f
=
(
key
:
string
,
description
:
string
|
null
,
data
:
unknown
=
null
)
=>
({
const
f
=
(
key
:
string
,
description
:
string
|
null
,
data
:
unknown
=
null
)
=>
({
...
@@ -7,6 +7,8 @@ const f = (key: string, description: string | null, data: unknown = null) => ({
...
@@ -7,6 +7,8 @@ const f = (key: string, description: string | null, data: unknown = null) => ({
data
,
data
,
});
});
const
group
=
(
key
:
string
,
values
:
string
[])
=>
({
key
,
name
:
PERSONA_FEATURE_SPECS
[
key
]?.
nameZh
??
key
,
values
});
describe
(
'open persona display tags'
,
()
=>
{
describe
(
'open persona display tags'
,
()
=>
{
it
(
'returns arrays for absent and empty personas'
,
()
=>
{
it
(
'returns arrays for absent and empty personas'
,
()
=>
{
expect
(
toOpenPersona
(
null
)).
toEqual
([]);
expect
(
toOpenPersona
(
null
)).
toEqual
([]);
...
@@ -17,8 +19,7 @@ describe('open persona display tags', () => {
...
@@ -17,8 +19,7 @@ describe('open persona display tags', () => {
it
(
'uses current potential-treatment labels and expands all values'
,
()
=>
{
it
(
'uses current potential-treatment labels and expands all values'
,
()
=>
{
const
data
=
{
types
:
[
'filling'
,
'endo'
],
labels
:
[
'潜在补牙'
,
'旧文案'
]
};
const
data
=
{
types
:
[
'filling'
,
'endo'
],
labels
:
[
'潜在补牙'
,
'旧文案'
]
};
expect
(
toOpenPersona
([
f
(
'potential_treatment'
,
'旧描述'
,
data
)])).
toEqual
([
expect
(
toOpenPersona
([
f
(
'potential_treatment'
,
'旧描述'
,
data
)])).
toEqual
([
'充填治疗'
,
group
(
'potential_treatment'
,
[
'充填治疗'
,
'根管治疗'
]),
'根管治疗'
,
]);
]);
expect
(
personaValueLabels
(
data
,
'potential_treatment'
)).
toEqual
([
'充填治疗'
,
'根管治疗'
]);
expect
(
personaValueLabels
(
data
,
'potential_treatment'
)).
toEqual
([
'充填治疗'
,
'根管治疗'
]);
expect
(
compactPersonaValue
(
'旧描述'
,
data
,
'potential_treatment'
)).
toEqual
({
expect
(
compactPersonaValue
(
'旧描述'
,
data
,
'potential_treatment'
)).
toEqual
({
...
@@ -34,7 +35,7 @@ describe('open persona display tags', () => {
...
@@ -34,7 +35,7 @@ describe('open persona display tags', () => {
f
(
'potential_treatment'
,
'旧'
,
{
types
:
[
'filling'
]
}),
f
(
'potential_treatment'
,
'旧'
,
{
types
:
[
'filling'
]
}),
f
(
'new_a'
,
'新增A'
),
f
(
'new_a'
,
'新增A'
),
];
];
const
expected
=
[
'充填治疗'
,
'重要价值'
,
'成年'
,
'新增A'
,
'新增B'
];
const
expected
=
[
group
(
'potential_treatment'
,
[
'充填治疗'
]),
group
(
'rfm'
,
[
'重要价值'
]),
group
(
'age_bracket'
,
[
'成年'
]),
group
(
'new_a'
,
[
'新增A'
]),
group
(
'new_b'
,
[
'新增B'
])
];
expect
(
toOpenPersona
(
features
)).
toEqual
(
expected
);
expect
(
toOpenPersona
(
features
)).
toEqual
(
expected
);
expect
(
toOpenPersona
([...
features
].
reverse
())).
toEqual
(
expected
);
expect
(
toOpenPersona
([...
features
].
reverse
())).
toEqual
(
expected
);
expect
(
features
[
0
]
!
.
key
).
toBe
(
'age_bracket'
);
// 不改传入数组
expect
(
features
[
0
]
!
.
key
).
toBe
(
'age_bracket'
);
// 不改传入数组
...
@@ -48,14 +49,14 @@ describe('open persona display tags', () => {
...
@@ -48,14 +49,14 @@ describe('open persona display tags', () => {
f
(
'b'
,
'联系13900139000'
),
f
(
'b'
,
'联系13900139000'
),
f
(
'c'
,
'不应回退'
,
{
labels
:
[]
}),
f
(
'c'
,
'不应回退'
,
{
labels
:
[]
}),
]),
]),
).
toEqual
([
'种植/手术禁忌'
,
'联系139****9000'
]);
).
toEqual
([
group
(
'a'
,
[
'种植/手术禁忌'
,
'联系139****9000'
]),
group
(
'b'
,
[
'联系139****9000'
])
]);
});
});
it
(
'uses concise descriptions for single values and legacy data without labels'
,
()
=>
{
it
(
'uses concise descriptions for single values and legacy data without labels'
,
()
=>
{
expect
(
expect
(
toOpenPersona
([
f
(
'rfm'
,
'新客/未消费(说明)'
),
f
(
'age_bracket'
,
'成年(35岁)'
)]),
toOpenPersona
([
f
(
'rfm'
,
'新客/未消费(说明)'
),
f
(
'age_bracket'
,
'成年(35岁)'
)]),
).
toEqual
([
'新客'
,
'成年'
]);
).
toEqual
([
group
(
'rfm'
,
[
'新客'
]),
group
(
'age_bracket'
,
[
'成年'
])
]);
expect
(
shortPersonaValueLabel
(
'重要价值 · 距上次 140 天'
)).
toBe
(
'重要价值'
);
expect
(
shortPersonaValueLabel
(
'重要价值 · 距上次 140 天'
)).
toBe
(
'重要价值'
);
expect
(
shortPersonaValueLabel
(
'成年(35岁)'
)).
toBe
(
'成年'
);
expect
(
shortPersonaValueLabel
(
'成年(35岁)'
)).
toBe
(
'成年'
);
expect
(
toOpenPersona
([
f
(
'x'
,
'有效标签 · 说明'
,
{
labels
:
[
null
]
})])).
toEqual
([
'有效标签'
]);
expect
(
toOpenPersona
([
f
(
'x'
,
'有效标签 · 说明'
,
{
labels
:
[
null
]
})])).
toEqual
([
group
(
'x'
,
[
'有效标签'
])
]);
});
});
});
});
apps/pac-service/tests/patient-facts-lookup.spec.ts
View file @
94dfcc3b
...
@@ -77,7 +77,7 @@ describe('patient facts lookup contract', () => {
...
@@ -77,7 +77,7 @@ describe('patient facts lookup contract', () => {
})),
})),
),
),
};
};
const
result
=
await
new
PatientFactsLookupService
(
prisma
).
lookup
(
const
result
=
await
new
PatientFactsLookupService
(
prisma
,
{
enrich
:
jest
.
fn
().
mockResolvedValue
(
undefined
)
}
as
any
).
lookup
(
{
hostId
:
'h'
,
appId
:
'a'
,
clinicNames
:
{
c
:
'测试诊所'
}
},
{
hostId
:
'h'
,
appId
:
'a'
,
clinicNames
:
{
c
:
'测试诊所'
}
},
{
name
:
'同名'
,
phone
:
'13800138000'
},
{
name
:
'同名'
,
phone
:
'13800138000'
},
);
);
...
@@ -158,7 +158,7 @@ describe('patient facts lookup contract', () => {
...
@@ -158,7 +158,7 @@ describe('patient facts lookup contract', () => {
},
},
};
};
prisma
.
$transaction
=
jest
.
fn
((
run
)
=>
run
(
prisma
));
prisma
.
$transaction
=
jest
.
fn
((
run
)
=>
run
(
prisma
));
const
result
=
await
new
PatientFactsLookupService
(
prisma
).
lookup
(
const
result
=
await
new
PatientFactsLookupService
(
prisma
,
{
enrich
:
jest
.
fn
().
mockResolvedValue
(
undefined
)
}
as
any
).
lookup
(
{
hostId
:
'h'
,
appId
:
'a'
,
clinicNames
:
{}
},
{
hostId
:
'h'
,
appId
:
'a'
,
clinicNames
:
{}
},
{
medicalRecordNumber
:
'001'
},
{
medicalRecordNumber
:
'001'
},
);
);
...
@@ -169,9 +169,31 @@ describe('patient facts lookup contract', () => {
...
@@ -169,9 +169,31 @@ describe('patient facts lookup contract', () => {
it
(
'returns empty items for no match'
,
async
()
=>
{
it
(
'returns empty items for no match'
,
async
()
=>
{
const
prisma
:
any
=
{
$queryRaw
:
jest
.
fn
().
mockResolvedValue
([])
};
const
prisma
:
any
=
{
$queryRaw
:
jest
.
fn
().
mockResolvedValue
([])
};
prisma
.
$transaction
=
jest
.
fn
((
run
)
=>
run
(
prisma
));
prisma
.
$transaction
=
jest
.
fn
((
run
)
=>
run
(
prisma
));
const
service
=
new
PatientFactsLookupService
(
prisma
);
const
service
=
new
PatientFactsLookupService
(
prisma
,
{
enrich
:
jest
.
fn
().
mockResolvedValue
(
undefined
)
}
as
any
);
await
expect
(
await
expect
(
service
.
lookup
({
hostId
:
'h'
,
appId
:
'a'
,
clinicNames
:
{}
},
{
name
:
'nobody'
}),
service
.
lookup
({
hostId
:
'h'
,
appId
:
'a'
,
clinicNames
:
{}
},
{
name
:
'nobody'
}),
).
resolves
.
toEqual
({
items
:
[]
});
).
resolves
.
toEqual
({
items
:
[]
});
});
});
});
});
it
(
'enriches only authorized image snapshots after the database transaction closes'
,
async
()
=>
{
const
factId
=
'11111111-1111-4111-8111-111111111111'
;
const
prisma
:
any
=
{
$queryRaw
:
jest
.
fn
().
mockResolvedValue
([{
patient_id
:
'p'
,
host_name
:
'friday'
,
tenant_id
:
't'
,
source_unit
:
'b'
,
fact_id
:
factId
,
subject_id
:
'image:i'
,
type
:
'image_record'
,
kind
:
'actual'
,
status
:
'active'
,
clinic_id
:
'c'
,
occurred_at
:
null
,
planned_for
:
null
,
content
:
{
modality
:
'pano'
,
file_refs
:
[{
provider
:
'meyer'
,
connectionId
:
'test'
,
fileId
:
'a'
,
role
:
'original'
}]
}
}]),
patient
:
{
findMany
:
jest
.
fn
().
mockResolvedValue
([{
id
:
'p'
,
hostId
:
'h'
,
tenantId
:
't'
,
sourceUnit
:
'b'
,
externalId
:
'p'
,
name
:
null
,
medicalRecordNumber
:
'001'
,
phone
:
null
,
gender
:
null
,
birthDate
:
null
,
personas
:
[{
tenantId
:
't'
,
features
:
[{
key
:
'urgency_level'
,
description
:
'中'
,
data
:
null
}]
}],
relationsOut
:
[]
}])
},
};
let
transactionOpen
=
false
;
prisma
.
$transaction
=
async
(
run
:
any
)
=>
{
transactionOpen
=
true
;
const
result
=
await
run
(
prisma
);
transactionOpen
=
false
;
return
result
;
};
const
enrich
=
jest
.
fn
(
async
(
items
,
contexts
)
=>
{
expect
(
transactionOpen
).
toBe
(
false
);
expect
(
contexts
.
get
(
factId
)).
toEqual
({
hostId
:
'h'
,
hostName
:
'friday'
,
tenantId
:
't'
,
sourceUnit
:
'b'
,
clinicId
:
'c'
});
items
[
0
].
facts
[
0
].
content
.
fileRefs
[
0
].
url
=
'https://example.com/a'
;
});
const
result
=
await
new
PatientFactsLookupService
(
prisma
,
{
enrich
}
as
any
).
lookup
({
hostId
:
'h'
,
appId
:
'a'
,
clinicNames
:
{}
},
{
medicalRecordNumber
:
'001'
});
expect
(
result
.
items
[
0
]
!
.
persona
).
toEqual
([{
key
:
'urgency_level'
,
name
:
'急迫等级'
,
values
:
[
'中'
]
}]);
expect
((
result
.
items
[
0
]
!
.
facts
[
0
]
!
.
content
as
any
).
fileRefs
[
0
].
url
).
toBe
(
'https://example.com/a'
);
expect
(
enrich
).
toHaveBeenCalledTimes
(
1
);
});
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment