Commit 6c3f5f8e by luoqi

Merge refined patient facts and Meyer imaging into test

parents 1e0c92f4 355a7550
Pipeline #3833 failed in 0 seconds
...@@ -4,7 +4,7 @@ description: 宿主把用户登录到内嵌 PAC 的换票接口、请求参数 ...@@ -4,7 +4,7 @@ description: 宿主把用户登录到内嵌 PAC 的换票接口、请求参数
icon: KeyRound 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 页面。 > PAC 工作台以 iframe 嵌入宿主系统。用户在宿主侧登录后,宿主**换票**给 PAC —— 传入「身份 + 角色 + 可见范围」,换取一次性 code 打开 PAC 页面。
......
...@@ -10,8 +10,6 @@ ...@@ -10,8 +10,6 @@
"channel-push", "channel-push",
"friday-push-payload", "friday-push-payload",
"auth-login", "auth-login",
"patient-facts-lookup",
"patient-fact-content",
"friday-ai-host", "friday-ai-host",
"postmessage-actions", "postmessage-actions",
"execution-callback", "execution-callback",
......
---
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'; ...@@ -7,10 +7,15 @@ import { docsContentRoute, docsImageRoute, docsRoute } from './shared';
// 多源 loader:手写 MDX(docs)+ 由 OpenAPI spec 派生的虚拟 API 参考页(openapi)。 // 多源 loader:手写 MDX(docs)+ 由 OpenAPI spec 派生的虚拟 API 参考页(openapi)。
// openapi 页挂在 /docs/api 下,按 tag(≈ controller)分组。loaderPlugin 给侧栏加 // openapi 页挂在 /docs/api 下,按 tag(≈ controller)分组。loaderPlugin 给侧栏加
// GET/POST 方法标记。See https://fumadocs.dev/docs/headless/source-api // 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( export const source = loader(
{ {
docs: docs.toFumadocsSource(), docs: docs.toFumadocsSource(),
openapi: await openapi.staticSource({ groupBy: 'tag', baseDir: 'api' }), openapi: apiSource,
}, },
{ {
baseUrl: docsRoute, baseUrl: docsRoute,
......
...@@ -16,6 +16,8 @@ const config = { ...@@ -16,6 +16,8 @@ const config = {
// 没有营销首页 / 文档首页 —— 根路径直接进文档,/docs 再进第一篇内容 // 没有营销首页 / 文档首页 —— 根路径直接进文档,/docs 再进第一篇内容
async redirects() { async redirects() {
return [ 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: '/', destination: '/docs', permanent: false },
{ source: '/docs', destination: '/docs/start/what-is-pac', permanent: false }, { source: '/docs', destination: '/docs/start/what-is-pac', permanent: false },
]; ];
......
...@@ -16,6 +16,8 @@ ...@@ -16,6 +16,8 @@
"prisma:deploy": "prisma migrate deploy", "prisma:deploy": "prisma migrate deploy",
"prisma:studio": "prisma studio", "prisma:studio": "prisma studio",
"prisma:seed": "ts-node --transpile-only prisma/seed.ts", "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": "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: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", "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;
});
}
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({ ...@@ -21,7 +21,10 @@ const PatientBasicSchema = z.object({
name: z.string().nullable(), name: z.string().nullable(),
medicalRecordNumber: z.string().nullable(), medicalRecordNumber: z.string().nullable(),
phone: z.string().nullable().describe('脱敏手机号'), 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 业务日期'), birthDate: z.string().nullable().describe('YYYY-MM-DD 业务日期'),
}); });
...@@ -29,37 +32,36 @@ export const PatientFactsLookupResponseSchema = z.object({ ...@@ -29,37 +32,36 @@ export const PatientFactsLookupResponseSchema = z.object({
items: z.array( items: z.array(
z.object({ z.object({
patient: PatientBasicSchema.extend({ patient: PatientBasicSchema.extend({
preferences: z.unknown(), dedicatedCs: z
lastVisitAt: z.string().nullable().describe('最近到诊业务日期 YYYY-MM-DD'), .object({
lastVisitDoctor: z.string().nullable(), id: z.string().nullable().describe('宿主侧专属客服标识,不是 PAC 用户 ID'),
preferredDoctor: z.string().nullable(), name: z.string().nullable().describe('专属客服姓名'),
doNotContact: z.boolean().nullable().describe('无业务属性记录时为 null,不能当成 false'), })
doNotContactReason: z.string().nullable(), .nullable()
deceased: z.boolean().nullable(), .describe('当前专属客服;未提供有效标识或姓名时为 null'),
hostFollowUpActive: z.boolean().nullable(), acquisitionChannel: z
tags: z.array(z.string()).nullable().describe('null=无业务属性记录;[]=已存标签为空'), .string()
acquisitionChannel: z.string().nullable(), .nullable()
acquisitionSub: 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 persona: z
.object({ .array(z.string())
version: z.number().int(), .describe(
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'),
relations: z.array( relations: z.array(
z.object({ z.object({
relationship: z.string().describe('对方是本人的什么关系,按 PAC 已存方向返回'), relationship: z
.string()
.describe(
'对方是本人的关系:spouse=配偶、father=父亲、mother=母亲、child=子女、grandparent=祖父母/外祖父母、grandchild=孙辈/外孙辈、sibling=兄弟姐妹、friend=朋友、other=其他;开放字符串,兼容未知值,不推断反向关系',
),
patient: PatientBasicSchema.nullable().describe( patient: PatientBasicSchema.nullable().describe(
'关系人未解析、归档或越界时为 null;不展开其事实和画像', '关系人未解析、归档或越界时为 null;不展开其事实和画像',
), ),
...@@ -67,35 +69,16 @@ export const PatientFactsLookupResponseSchema = z.object({ ...@@ -67,35 +69,16 @@ export const PatientFactsLookupResponseSchema = z.object({
), ),
facts: z.array( facts: z.array(
z.object({ z.object({
subjectId: z id: z.string().uuid().describe('PAC 事实版本 ID;同一业务事实产生新版本时 ID 会变化'),
.string()
.describe('来源事实主体标识,跨版本稳定;仅作溯源,不能单独当全局唯一键'),
type: z type: z
.string() .string()
.describe('决定 content 结构的 PAC 事实类型;详见事实内容参考,允许未来新增类型'), .describe('决定 content 结构的 PAC 事实类型;详见事实内容参考,允许未来新增类型'),
kind: z.string().describe('actual=实际记录;planned=计划或建议'), kind: z.string().describe('actual=实际记录;planned=计划或建议'),
status: z
.string()
.describe(
'事实生命周期:本接口仅 active/fulfilled;业务进度看对应 content 字段,不代表疾病是否治愈',
),
clinicId: z.string().nullable(), clinicId: z.string().nullable(),
clinicName: z.string().nullable(), clinicName: z.string().nullable(),
occurredAt: z.string().nullable().describe('实际发生时刻,UTC ISO 8601;未知为 null'), occurredAt: z.string().nullable().describe('业务发生时间;建议/治疗计划为提出或制定时间,预约为宿主创建时间,取不到留 null;实际到诊由 encounter_record 表达。UTC ISO 8601,未知 null;其他类型沿用已存业务时间,不保证等于源记录创建时间'),
plannedFor: z plannedFor: z.string().nullable().describe('明确约定的执行时间;当前仅预约返回约定时间,建议和治疗计划无明确执行日期时为 null。UTC ISO 8601'),
.string() content: z.unknown().describe('按 type 定义的脱敏内容;已知字段统一 camelCase、本记录源标识为 externalId、完整承接的重复时间省略。歧义关联/金额不合并,未知扩展及冲突原值保留;详见 content 字段参考'),
.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 有值;其他类型 [],允许新增步骤'),
}), }),
), ),
}), }),
......
...@@ -2,8 +2,10 @@ import { Injectable, Logger } from '@nestjs/common'; ...@@ -2,8 +2,10 @@ import { Injectable, Logger } from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import { currentFactHeadSql } from '../facts/current-fact'; import { currentFactHeadSql } from '../facts/current-fact';
import { toOpenFact, type OpenFactRow } from './open-fact'; import type { OpenFactRow } from './open-fact';
import { redactText, redactJson } from './open-redact'; import { toLookupFact } from './lookup-fact';
import { redactText } from './open-redact';
import { toOpenPersona } from './open-persona';
import { import {
PatientFactsLookupSchema, PatientFactsLookupSchema,
type PatientFactsLookupRequest, type PatientFactsLookupRequest,
...@@ -13,7 +15,9 @@ import type { ApplicationPrincipal } from './application-token.guard'; ...@@ -13,7 +15,9 @@ import type { ApplicationPrincipal } from './application-token.guard';
type LookupRow = OpenFactRow & { type LookupRow = OpenFactRow & {
patient_id: string; patient_id: string;
host_name: string;
fact_id: string | null; fact_id: string | null;
appointment_created_values?: unknown;
}; };
type LookupItem = PatientFactsLookupItem; type LookupItem = PatientFactsLookupItem;
...@@ -50,6 +54,18 @@ function basicPatient(p: { ...@@ -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() @Injectable()
export class PatientFactsLookupService { export class PatientFactsLookupService {
private readonly logger = new Logger(PatientFactsLookupService.name); private readonly logger = new Logger(PatientFactsLookupService.name);
...@@ -65,11 +81,24 @@ export class PatientFactsLookupService { ...@@ -65,11 +81,24 @@ export class PatientFactsLookupService {
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, 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.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 FROM patients p
JOIN hosts h ON h.id = p.host_id
LEFT JOIN patient_facts f ON f.patient_id = p.id LEFT JOIN patient_facts f ON f.patient_id = p.id
AND f.host_id = p.host_id AND f.tenant_id = p.tenant_id AND f.source_unit = p.source_unit AND f.host_id = p.host_id AND f.tenant_id = p.tenant_id AND f.source_unit = p.source_unit
AND ${currentFactHeadSql('f')} AND ${currentFactHeadSql('f')}
...@@ -94,14 +123,6 @@ export class PatientFactsLookupService { ...@@ -94,14 +123,6 @@ export class PatientFactsLookupService {
preferences: true, preferences: true,
profile: { profile: {
select: { select: {
lastVisitAt: true,
lastVisitDoctor: true,
preferredDoctor: true,
doNotContact: true,
doNotContactReason: true,
deceased: true,
hostFollowUpActive: true,
tags: true,
acquisitionChannel: true, acquisitionChannel: true,
acquisitionSub: true, acquisitionSub: true,
}, },
...@@ -111,12 +132,9 @@ export class PatientFactsLookupService { ...@@ -111,12 +132,9 @@ export class PatientFactsLookupService {
orderBy: [{ version: 'desc' }, { id: 'desc' }], orderBy: [{ version: 'desc' }, { id: 'desc' }],
select: { select: {
tenantId: true, tenantId: true,
version: true,
computedAt: true,
refreshedAt: true,
features: { features: {
orderBy: { key: 'asc' }, 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 { ...@@ -145,31 +163,11 @@ export class PatientFactsLookupService {
items.set(p.id, { items.set(p.id, {
patient: { patient: {
...basicPatient(p), ...basicPatient(p),
preferences: redactJson(p.preferences), dedicatedCs: dedicatedCsOf(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,
acquisitionChannel: redactText(p.profile?.acquisitionChannel), acquisitionChannel: redactText(p.profile?.acquisitionChannel),
acquisitionSub: redactText(p.profile?.acquisitionSub), acquisitionSub: redactText(p.profile?.acquisitionSub),
}, },
persona: persona persona: toOpenPersona(persona?.features),
? {
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,
relations: p.relationsOut relations: p.relationsOut
.filter((rel) => rel.tenantId === p.tenantId) .filter((rel) => rel.tenantId === p.tenantId)
.map((rel) => { .map((rel) => {
...@@ -192,11 +190,18 @@ export class PatientFactsLookupService { ...@@ -192,11 +190,18 @@ export class PatientFactsLookupService {
const item = items.get(row.patient_id); const item = items.get(row.patient_id);
if (!item) continue; if (!item) continue;
if (row.fact_id !== null) if (row.fact_id !== null)
item.facts.push({ item.facts.push(toLookupFact(
...toOpenFact(row), { ...row, fact_id: row.fact_id },
clinicName: row.clinic_id ? (application.clinicNames[row.clinic_id] ?? null) : null, 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; return items;
}, },
{ isolationLevel: Prisma.TransactionIsolationLevel.RepeatableRead, timeout: 15000 }, { isolationLevel: Prisma.TransactionIsolationLevel.RepeatableRead, timeout: 15000 },
......
...@@ -19,6 +19,7 @@ import { z } from 'zod'; ...@@ -19,6 +19,7 @@ import { z } from 'zod';
import { import {
ComplaintStatusSchema, ComplaintFeedbackKindSchema, ComplaintCategorySchema, ComplaintChannelSchema, ComplaintStatusSchema, ComplaintFeedbackKindSchema, ComplaintCategorySchema, ComplaintChannelSchema,
ClinicalSignalsSchema, ClinicalSignalsSchema,
ImageEmbeddedAnalysisSchema,
FactType, FactType,
PACDiagnosisCodeSchema, PACDiagnosisCodeSchema,
PACTreatmentCategorySchema, PACTreatmentCategorySchema,
...@@ -230,11 +231,12 @@ const EmrRecordContent = z ...@@ -230,11 +231,12 @@ const EmrRecordContent = z
.passthrough(); // 自由文本 fact 允许 host 多带字段(算法不消费,Layer C 抽取消费) .passthrough(); // 自由文本 fact 允许 host 多带字段(算法不消费,Layer C 抽取消费)
/** /**
* image_record — 影像 metadata + finding(自由文本) * image_record — 影像 metadata + finding(自由文本) + 可选的单张影像分析快照
*/ */
const ImageRecordContent = z const ImageRecordContent = z
.object({ .object({
image_external_id: z.string().min(1), image_external_id: z.string().min(1),
analysis: ImageEmbeddedAnalysisSchema.nullable().optional(),
/// 原样留存宿主文件位置,不在摄入层要求完整链接。 /// 原样留存宿主文件位置,不在摄入层要求完整链接。
file_url: z.string().optional(), file_url: z.string().optional(),
file_name: z.string().optional(), file_name: z.string().optional(),
......
import { Injectable, Logger } from '@nestjs/common'; 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 type { FactDraft, Parser, ParserContext } from './parser.interface';
import { normalizeToothPosition } from './tooth-position.util'; import { normalizeToothPosition } from './tooth-position.util';
/** /**
* ImageParser — `image_uploaded` 解析器 * ImageParser — `image_uploaded` 解析器
* *
* 产 1 个 `image_record`(actual),只装 metadata + 医生 finding,**不持文件本体**。 * 产 1 个 `image_record`(actual),保留 metadata、finding 与可选 analysis,不持文件本体。
* 宿主分析另走通用 image_analysis;PAC 自身模型产物归 agent_invocations.output。 * analysis 仅接收已明确关联本张影像的快照;诊断信号由原有诊断摄入链路独立处理。
*/ */
@Injectable() @Injectable()
export class ImageParser implements Parser { export class ImageParser implements Parser {
...@@ -45,6 +45,7 @@ export class ImageParser implements Parser { ...@@ -45,6 +45,7 @@ export class ImageParser implements Parser {
modality, modality,
tooth_positions: toothPositions, tooth_positions: toothPositions,
finding, finding,
...(c.analysis !== undefined ? { analysis: ImageEmbeddedAnalysisSchema.nullable().parse(c.analysis) } : {}),
encounter_external_id: encounterExternalId, encounter_external_id: encounterExternalId,
doctor_id: c.doctorId ? String(c.doctorId) : null, doctor_id: c.doctorId ? String(c.doctorId) : null,
doctor_name: (c.doctorName as string | undefined) ?? 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 { 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 { readFileSync } from 'node:fs';
import { resolve } from 'node:path'; import { resolve } from 'node:path';
import { FactType } from '@pac/types'; import { FactType } from '@pac/types';
import { FactContentSchemas } from '../src/modules/sync/pipeline/fact-content-schemas'; import { FactContentSchemas } from '../src/modules/sync/pipeline/fact-content-schemas';
import { 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 { redactJson } from '../src/modules/open/open-redact';
import { PatientFactsLookupResponseSchema } from '../src/modules/open/patient-facts-lookup.schema'; import { PatientFactsLookupResponseSchema } from '../src/modules/open/patient-facts-lookup.schema';
const text = readFileSync( const text = readFileSync(
resolve(__dirname, '../../pac-docs/content/docs/integration/patient-fact-content.mdx'), resolve(__dirname, '../../pac-docs/content/docs/api/open/PatientFactsLookupController_lookup.mdx'),
'utf8', 'utf8',
); );
const examples = [...text.matchAll(/```json\n([\s\S]*?)\n```/g)].map((m) => JSON.parse(m[1]!)); 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', () => { describe('public fact content reference', () => {
it('covers every registered fact type exactly once', () => { it('covers each documented type exactly once and only registered types', () => {
expect(examples.map((e) => e.type).sort()).toEqual(Object.values(FactType).sort()); expect(examples.map((e) => e.type).sort()).toEqual([...documentedTypes].sort());
for (const type of documentedTypes) expect(Object.values(FactType)).toContain(type);
}); });
it.each(examples)( it.each(examples)(
'$type example passes canonical validation and open serialization', '$type example passes canonical validation and open serialization',
({ type, content }) => { ({ type, content, occurredAt, plannedFor }) => {
const schema = FactContentSchemas[type as keyof typeof FactContentSchemas]; const schema = FactContentSchemas[type as keyof typeof FactContentSchemas];
const stored = schema.parse(content); const sources = JSON.parse(readFileSync(resolve(__dirname, 'fixtures/lookup-content-source.json'), 'utf8'));
const out = toOpenFact({ 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`, subject_id: `${type}:fixture`,
type, type,
kind: 'actual', kind: 'actual',
...@@ -33,24 +49,26 @@ describe('public fact content reference', () => { ...@@ -33,24 +49,26 @@ describe('public fact content reference', () => {
title: null, title: null,
summary: null, summary: null,
content: stored, content: stored,
}); }, null);
const factSchema = PatientFactsLookupResponseSchema.shape.items.element.shape.facts.element; const factSchema = PatientFactsLookupResponseSchema.shape.items.element.shape.facts.element;
expect(factSchema.safeParse({ ...out, clinicName: null }).success).toBe(true); expect(factSchema.safeParse({ ...out, clinicName: null }).success).toBe(true);
// 文档不展示会在出站被移除的键,也不依赖未说明的存储默认值。 // 文档不展示会在出站被移除的键,也不依赖未说明的存储默认值。
expect(redactJson(content)).toEqual(content); expect(redactJson(content)).toEqual(content);
expect(out.content).toMatchObject(content); expect(out.content).toMatchObject(content);
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', () => { it('documents every standard top-level content field except keys intentionally redacted', () => {
for (const [type, schema] of Object.entries(FactContentSchemas)) { 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) => const fields = [...section.matchAll(/\| `([^`]+)` \|/g)].flatMap((m) =>
m[1]!.split(' / ').map((f) => f.split(/[.[]/)[0]!), m[1]!.split(' / ').map((f) => f.split(/[.[]/)[0]!),
); );
for (const field of Object.keys(schema.shape)) { for (const field of Object.keys(schema.shape)) {
if (['notes', 'staff_notes'].includes(field)) continue; if (['notes', 'staff_notes'].includes(field) || (type === 'appointment_record' && field === 'arrived_at')) continue;
expect({ type, field, documented: fields.includes(field) }).toEqual({ expect({ type, field, documented: fields.includes(lookupContentNames[type]?.[field] ?? field) }).toEqual({
type, type,
field, field,
documented: true, documented: true,
...@@ -58,8 +76,42 @@ describe('public fact content reference', () => { ...@@ -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', () => { 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', subject_id: 'treatment:1',
type: 'treatment_record', type: 'treatment_record',
kind: 'planned', kind: 'planned',
...@@ -71,12 +123,12 @@ describe('public fact content reference', () => { ...@@ -71,12 +123,12 @@ describe('public fact content reference', () => {
title: null, title: null,
summary: null, summary: null,
content: { category: 'endodontic', status: 'planned', treat_stages: ['canal_filling'] }, content: { category: 'endodontic', status: 'planned', treat_stages: ['canal_filling'] },
}); }, null);
expect(planned).toMatchObject({ expect(planned).toMatchObject({
kind: 'planned', kind: 'planned',
status: 'active', occurredAt: '2026-11-01T00:00:00.000Z',
content: { status: 'planned' }, plannedFor: null,
steps: ['canal_filling'], 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);
});
});
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', () => { ...@@ -59,8 +59,19 @@ describe('patient facts lookup contract', () => {
phone: '13800138000', phone: '13800138000',
gender: null, gender: null,
birthDate: null, birthDate: null,
preferences: null, preferences: {
profile: null, dedicatedCs: { id: 'staff-id', name: '客服' },
contactPhone: { relationship: 'mother', isSelf: false },
},
profile:
id === 'p1'
? {
doNotContact: true,
doNotContactReason: 'opt_out',
deceased: true,
tags: [],
}
: null,
personas: [], personas: [],
relationsOut: [], relationsOut: [],
})), })),
...@@ -79,10 +90,26 @@ describe('patient facts lookup contract', () => { ...@@ -79,10 +90,26 @@ describe('patient facts lookup contract', () => {
expect(result.items[0]!.facts).toHaveLength(251); expect(result.items[0]!.facts).toHaveLength(251);
expect(result.items[1]!.facts).toEqual([]); expect(result.items[1]!.facts).toEqual([]);
expect(result.items[0]!.patient.phone).toBe('138****8000'); expect(result.items[0]!.patient.phone).toBe('138****8000');
expect(result.items[0]!.patient.dedicatedCs).toEqual({ 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({ expect(result.items[0]!.facts[0]).toMatchObject({
clinicName: '测试诊所', clinicName: '测试诊所',
summary: '联系139****9000', id: expect.any(String),
content: { illness_desc: '电话139****9000' }, content: { illnessDesc: '电话139****9000' },
}); });
expect(JSON.stringify(result)).not.toContain('staff_notes'); expect(JSON.stringify(result)).not.toContain('staff_notes');
const sql = prisma.$queryRaw.mock.calls[0][0]; const sql = prisma.$queryRaw.mock.calls[0][0];
...@@ -91,6 +118,49 @@ describe('patient facts lookup contract', () => { ...@@ -91,6 +118,49 @@ describe('patient facts lookup contract', () => {
expect(sql.sql).toContain('AND p.phone ='); expect(sql.sql).toContain('AND p.phone =');
expect(sql.values).toContain('h'); 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 () => { 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));
......
import { potentialTreatmentCardLabel } from '@pac/types'; // 标签文本转换由前后端共享,保持工作台与开放接口口径一致。
export {
/** shortPersonaValueLabel,
* persona feature.description 的展示辅助 —— 把后端那句自包含描述裁成不同密度的展示形态。 compactPersonaValue,
* personaValueLabels,
* 后端每个特征产一句 description(如 `重要价值 · 距上次 140 天 · 就诊20次 · 累计¥154,350`), } from '@pac/types';
* 详情抽屉整句显示,紧凑位(右侧 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;
}
...@@ -13,3 +13,5 @@ export * from './potential-label-rules'; ...@@ -13,3 +13,5 @@ export * from './potential-label-rules';
export * from './host-action-message'; export * from './host-action-message';
export * from './kin-relationship'; export * from './kin-relationship';
export * from './emr-narrative'; 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 { z } from 'zod';
import { ImageEmbeddedAnalysisSchema } from './image-embedded-analysis';
/** /**
* Canonical resource schemas — PAC AssemblerEngine 经 yaml 配置投影 + 类型归一化后期望看到的形态。 * Canonical resource schemas — PAC AssemblerEngine 经 yaml 配置投影 + 类型归一化后期望看到的形态。
...@@ -356,6 +357,8 @@ export const ImageStudyCanonicalSchema = z ...@@ -356,6 +357,8 @@ export const ImageStudyCanonicalSchema = z
fileUrl: z.string().optional().nullable(), fileUrl: z.string().optional().nullable(),
fileName: z.string().optional().nullable(), fileName: z.string().optional().nullable(),
fileType: z.string().optional().nullable(), fileType: z.string().optional().nullable(),
/// 可选且无默认值,旧影像缺失时不改变内容或哈希。
analysis: ImageEmbeddedAnalysisSchema.nullable().optional(),
}) })
.passthrough(); .passthrough();
export type ImageStudyCanonical = z.infer<typeof ImageStudyCanonicalSchema>; 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'; ...@@ -12,4 +12,5 @@ export * from './agent';
export * from './sync'; export * from './sync';
export * from './admin'; export * from './admin';
export * from './canonical'; export * from './canonical';
export * from './image-embedded-analysis';
export * from './reason-signals'; 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