Commit 5b80c042 by luoqi

Merge open patient contract and source time fixes into test

parents d3834189 b502e1b3
Pipeline #3855 failed in 0 seconds
......@@ -11,6 +11,9 @@ primary:
table: appointment_all_rows
key: id
# 来源创建时间对应 appointment 记录;派生事实不把父记录时间解释为自身创建。
source_created_at: { field: createdAt }
field_mapping:
externalId: id
patientExternalId: patient_appointment_id # 预约患者 = customer_basic_info.id
......
......@@ -14,6 +14,9 @@ primary:
key: id
dedup_by: id
# 来源创建时间对应 consultation 记录;派生事实不把父记录时间解释为自身创建。
source_created_at: { field: createdAt }
field_mapping:
externalId: id
updatedAt: updated_gmt_at
......
......@@ -18,6 +18,9 @@ primary:
key: diag_external_id
dedup_by: diag_external_id
# 来源创建时间对应 emr 记录;派生事实不把父记录时间解释为自身创建。
source_created_at: { field: createdAt }
field_mapping:
externalId: diag_external_id
updatedAt: updated_gmt_at
......
......@@ -13,6 +13,9 @@ primary:
key: emr_sub_id # 小病历号 uuid(两侧连接键 + 临床事实锚点)
dedup_by: [tenant_id, emr_sub_id] # 源组织 + 病历号;同一身份仍按原规则取首行
# 来源创建时间对应 emr 记录;派生事实不把父记录时间解释为自身创建。
source_created_at: { field: createdAt }
field_mapping:
externalId: emr_sub_id
# updatedAt 进 source_event_id 幂等键,host UPDATE → 新 transaction(同 jvs-dw 纪律)
......
......@@ -13,6 +13,9 @@ primary:
table: appointment_encounter_rows
key: id
# 来源创建时间对应 appointment 记录;派生事实不把父记录时间解释为自身创建。
source_created_at: { field: createdAt }
field_mapping:
externalId: id # = 预约 id;EMR 侧 appointmentId 将据此回挂
patientExternalId: patient_appointment_id
......
......@@ -14,6 +14,9 @@ primary:
key: id
dedup_by: id
# 来源创建时间对应 image 记录;派生事实不把父记录时间解释为自身创建。
source_created_at: { field: createdAt }
field_mapping:
externalId: id
updatedAt: updated_gmt_at
......
......@@ -8,6 +8,9 @@ emits:
primary:
table: med_check_analysis_req_record
key: id
# 来源创建时间对应 image_analysis 记录;派生事实不把父记录时间解释为自身创建。
source_created_at: { field: createdAt }
field_mapping:
externalId: id
patientExternalId: patient_id
......
......@@ -14,6 +14,9 @@ primary:
key: uuid
dedup_by: uuid
# 来源创建时间对应 settlement 记录;派生事实不把父记录时间解释为自身创建。
source_created_at: { field: createdAt }
field_mapping:
externalId: uuid
updatedAt: updated_gmt_at
......
......@@ -13,6 +13,9 @@ primary:
key: rec_external_id
dedup_by: rec_external_id
# 来源创建时间对应 emr 记录;派生事实不把父记录时间解释为自身创建。
source_created_at: { field: createdAt }
field_mapping:
externalId: rec_external_id
updatedAt: updated_gmt_at
......
......@@ -17,6 +17,9 @@ primary:
key: uuid
dedup_by: uuid
# 来源创建时间对应 settlement 记录;派生事实不把父记录时间解释为自身创建。
source_created_at: { field: createdAt }
field_mapping:
externalId: uuid
updatedAt: updated_gmt_at
......
......@@ -15,6 +15,9 @@ primary:
key: id
dedup_by: id
# 来源创建时间对应 settlement_item 记录;派生事实不把父记录时间解释为自身创建。
source_created_at: { field: createdAt }
field_mapping:
externalId: id
updatedAt: updated_gmt_at
......
......@@ -16,6 +16,9 @@ primary:
key: treat_external_id
dedup_by: treat_external_id
# 来源创建时间对应 emr 记录;派生事实不把父记录时间解释为自身创建。
source_created_at: { field: createdAt }
field_mapping:
externalId: treat_external_id
updatedAt: updated_gmt_at
......
......@@ -14,6 +14,9 @@ primary:
key: id
dedup_by: id
# 来源创建时间对应 treatment_plan_item 记录;派生事实不把父记录时间解释为自身创建。
source_created_at: { field: createdAt }
field_mapping:
externalId: id
updatedAt: updated_gmt_at
......
......@@ -16,6 +16,9 @@ primary:
key: treat_external_id
dedup_by: treat_external_id
# 来源创建时间对应 emr 记录;派生事实不把父记录时间解释为自身创建。
source_created_at: { field: createdAt }
field_mapping:
externalId: treat_external_id
updatedAt: updated_gmt_at
......
......@@ -11,6 +11,9 @@ primary:
table: appointment_all_rows
key: id
# 来源创建时间对应 appointment 记录;派生事实不把父记录时间解释为自身创建。
source_created_at: { field: createdAt }
field_mapping:
externalId: id
# W4 末 DW upsert:updatedAt 进 source_event_id 幂等键,host UPDATE → 新 transaction(否则永久 dedup 漏 update)
......
......@@ -9,6 +9,9 @@ primary:
table: complaint_rows
key: id
# group_rows 已按 [brand,id] 聚合,不再取首行丢弃跟进。
# 来源创建时间对应 complaint 记录;派生事实不把父记录时间解释为自身创建。
source_created_at: { field: createdAt }
field_mapping:
externalId: id
patientExternalId: patient_id
......
......@@ -14,6 +14,9 @@ primary:
table: diagnosis_rows
key: diag_external_id
# 来源创建时间对应 emr 记录;派生事实不把父记录时间解释为自身创建。
source_created_at: { field: createdAt }
field_mapping:
externalId: diag_external_id
# W4 末 DW upsert:updatedAt 进 source_event_id 幂等键,host UPDATE → 新 transaction(否则永久 dedup 漏 update)
......
......@@ -15,6 +15,9 @@ primary:
key: id
dedup_by: [brand, id] # 源组织 + 病历号;同一身份仍按原规则取首行
# 来源创建时间对应 emr 记录;派生事实不把父记录时间解释为自身创建。
source_created_at: { field: createdAt }
field_mapping:
externalId: id
# W4 末 DW upsert:updatedAt 进 source_event_id 幂等键,host UPDATE → 新 transaction(否则永久 dedup 漏 update)
......
......@@ -11,6 +11,9 @@ primary:
table: appointment_encounter_rows
key: id
# 来源创建时间对应 appointment 记录;派生事实不把父记录时间解释为自身创建。
source_created_at: { field: createdAt }
field_mapping:
externalId: id
# W4 末 DW upsert:updatedAt 进 source_event_id 幂等键,host UPDATE → 新 transaction(否则永久 dedup 漏 update)
......
......@@ -12,6 +12,9 @@ primary:
key: image_external_id
dedup_by: image_external_id # 同一影像跨 emr 行重复兜底
# 来源创建时间对应 emr 记录;派生事实不把父记录时间解释为自身创建。
source_created_at: { field: createdAt }
field_mapping:
externalId: image_external_id
# W4 末 DW upsert:updatedAt 进 source_event_id 幂等键,host UPDATE → 新 transaction(否则永久 dedup 漏 update)
......
......@@ -11,6 +11,9 @@ primary:
table: payment_rows
key: id
# 来源创建时间对应 settlement 记录;派生事实不把父记录时间解释为自身创建。
source_created_at: { field: createdAt }
field_mapping:
externalId: id
# W4 末 DW upsert:updatedAt 进 source_event_id 幂等键,host UPDATE → 新 transaction(否则永久 dedup 漏 update)
......
......@@ -12,6 +12,9 @@ primary:
key: rec_external_id
dedup_by: rec_external_id
# 来源创建时间对应 emr 记录;派生事实不把父记录时间解释为自身创建。
source_created_at: { field: createdAt }
field_mapping:
externalId: rec_external_id
# W4 末 DW upsert:updatedAt 进 source_event_id 幂等键,host UPDATE → 新 transaction(否则永久 dedup 漏 update)
......
......@@ -23,6 +23,9 @@ primary:
key: id
dedup_by: id # 同 settlement_id 不应重复(SQL 已去重)
# 来源创建时间对应 settlement 记录;派生事实不把父记录时间解释为自身创建。
source_created_at: { field: createdAt }
field_mapping:
externalId: id
# W4 末 DW upsert:updatedAt 进 source_event_id 幂等键,host UPDATE → 新 transaction(否则永久 dedup 漏 update)
......
......@@ -23,6 +23,9 @@ primary:
key: treat_external_id
dedup_by: treat_external_id # 炸行兜底:同 emr × treatName × tooth_position 因影像 JOIN 重复
# 来源创建时间对应 emr 记录;派生事实不把父记录时间解释为自身创建。
source_created_at: { field: createdAt }
field_mapping:
externalId: treat_external_id
# W4 末 DW upsert:updatedAt 进 source_event_id 幂等键,host UPDATE → 新 transaction(否则永久 dedup 漏 update)
......
......@@ -22,6 +22,9 @@ primary:
key: treat_external_id
dedup_by: treat_external_id # 炸行兜底:同 emr × treatName × tooth_position 因影像 JOIN 重复
# 来源创建时间对应 emr 记录;派生事实不把父记录时间解释为自身创建。
source_created_at: { field: createdAt }
field_mapping:
externalId: treat_external_id
# W4 末 DW upsert:updatedAt 进 source_event_id 幂等键,host UPDATE → 新 transaction(否则永久 dedup 漏 update)
......
......@@ -20,6 +20,9 @@ primary:
key: treat_external_id
dedup_by: treat_external_id
# 来源创建时间对应 emr 记录;派生事实不把父记录时间解释为自身创建。
source_created_at: { field: createdAt }
field_mapping:
externalId: treat_external_id
# W4 末 DW upsert:updatedAt 进 source_event_id 幂等键,host UPDATE → 新 transaction(否则永久 dedup 漏 update)
......
......@@ -942,7 +942,7 @@ transforms:
- kind: project
input: fact_settlement_out_refund
output: refund_rows
keep: [id, organization_id, brand, patient_id, rq, billing_date, created_date,
keep: [id, organization_id, brand, patient_id, rq, billing_date, created_date, updated_date,
settlement_type, doctor_id, settlement_project_name, settlement_money,
registration_id, is_refund, settlement_status]
......
-- 仅新增可空元数据,不更新任何历史行,不改变业务时间或已有乱序水位。
ALTER TABLE "patient_facts"
ADD COLUMN "source_created_at" TIMESTAMPTZ(3);
......@@ -576,7 +576,7 @@ model PatientTransaction {
/// 宿主侧实际发生时间(adapter 按 platform.pullConfig.timezone 解析无时区字符串后转 UTC)。
/// 跟 patient_facts.occurredAt 字段名一致(同一语义在事件账本和事实视图复用)
occurredAt DateTime @map("occurred_at") @db.Timestamptz(3)
/// PAC 接收时间(同步监控用)
/// PAC 事件落库时间;当前与本表 createdAt 均由数据库默认 now() 生成,未独立采样请求到达时刻。
receivedAt DateTime @default(now()) @map("received_at") @db.Timestamptz(3)
/// 关联 sync_logs.id;一次 run 把若干 transaction 一并落库,可反查"哪批拉进来的"
syncLogId String? @map("sync_log_id") @db.Uuid
......@@ -724,10 +724,14 @@ model PatientFact {
/// actual 应填 — 事实在宿主侧实际发生时间
occurredAt DateTime? @map("occurred_at") @db.Timestamptz(3)
/// 源记录末次修改时间(= canonicalRow.updatedAt,宿主侧口径)。乱序防护用:
/// 来源记录更新时间(= canonicalRow.updatedAt,宿主侧口径),不是 PAC 入库时间。乱序防护用:
/// 现有少数映射使用聚合快照或业务日期代理值,须按来源解释,不据此推断诊断等动作时间。
/// 携带更旧 updatedAt 的版本晚到(补推快照 vs 实时推送并发)→ fact-writer 按此裁决跳过,不旧覆新。
/// null = 源没给 updatedAt → 退回"到达顺序"语义(向后兼容,老数据/无该列的 host 不受影响)。
sourceUpdatedAt DateTime? @map("source_updated_at") @db.Timestamptz(3)
/// 承载事实的来源记录创建时刻。仅新事实版本写入,不回填历史;取不到可靠时间则为空。
/// 例如诊断继承 emr、接诊继承 appointment;不表示衍生条目首次出现或业务实际发生时间。
sourceCreatedAt DateTime? @map("source_created_at") @db.Timestamptz(3)
/// planned 推荐填 — 未来安排的目标时间(预约时间 / 复查时间 / 计划治疗时间)
plannedFor DateTime? @map("planned_for") @db.Timestamptz(3)
/// planned 时间窗起;部分场景(医嘱"1-2 周内复查")需要区间
......
......@@ -77,7 +77,11 @@ export class MeyerPacSink implements MeyerSink {
}
const metrics = await this.pipeline.runForTransaction({
transaction: { ...transaction, action: item.action },
canonicalRow: item.resource === 'image_study' ? projectedImage : item.row,
// 来源元数据仅交给事实写入,不改变既有 bundle/hash/原始事件幂等身份。
// 美亚只给影像创建时间,诊断的分析创建时间未知,⛔ 不借用影像创建时间。
canonicalRow: item.resource === 'image_study'
? { ...projectedImage, sourceCreatedAt: image.uploadedAt }
: item.row,
});
if (!metrics.parserMatched || metrics.factsFailed) throw new Error('PAC fact write failed; retry retained bundle');
}
......
import { z } from 'zod';
// 新接口专属白名单;不复用入库 passthrough,内部增加字段不会自动扩大公开契约。
const s = z.string().nullable().optional();
const n = z.number().nullable().optional();
const cents = z.number().int().nonnegative().nullable().optional();
const b = z.boolean().nullable().optional();
const a = z.array(z.string()).nullable().optional();
const identity = { externalId: z.string().nullable() };
const doctor = { doctorExternalId: s, doctorName: s };
const emrRef = { emrExternalId: z.string().nullable(), encounterExternalId: z.string().nullable() };
export const LookupClinicalSignalSchema = z
.object({
code: s,
label: s,
evidence: s,
sourceField: s,
negated: b,
severity: s,
treatmentDomains: z.array(z.string()).optional(),
validUntil: s,
reviewAfter: s,
})
.strict();
export const LookupAnalysisSchema = z
.object({
source: z.string().min(1),
summary: z.string().nullable().default(null),
description: z.string().nullable().default(null),
analyzedAt: z.string().nullable().default(null),
findings: z.array(
z
.object({
name: z.string().min(1),
toothPosition: z.string().nullable().default(null),
code: z.string().nullable().default(null),
})
.strict(),
),
})
.strict();
export const LookupQualitySchema = z.object({ sourceStatus: s, rejectionReason: s }).strict();
export const LookupFollowUpSchema = z
.object({
occurredAt: s,
action: s,
sourceAction: s,
description: s,
actorExternalId: s,
actorName: s,
})
.strict();
export const narrativeNames: Record<string, string> = {
illness_desc: 'illnessDescription',
pre_illness: 'presentIllness',
past_history: 'pastHistory',
general_condition: 'generalCondition',
exam_findings: 'examFindings',
aux_exam_findings: 'auxiliaryExamFindings',
diagnosis_text: 'diagnosis',
treatment_plan: 'treatmentPlan',
treatment_notes: 'treatment',
disposal: 'procedure',
doctor_advice: 'doctorAdvice',
};
const narrativeShape = Object.fromEntries(
Object.values(narrativeNames).map((key) => [key, z.string().nullable()]),
);
export const LookupContentSchemas = {
consultation_record: z
.object({
...identity,
doctorExternalId: s,
appointmentExternalId: z.string().nullable(),
consultDate: s,
intents: z.array(z.string()).optional(),
intentCategories: z.array(z.string()).optional(),
isFirstVisit: b,
})
.strict(),
encounter_record: z
.object({ ...identity, ...doctor, chiefComplaint: s, encounterType: s })
.strict(),
diagnosis_record: z
.object({
...identity,
...doctor,
...emrRef,
imageExternalId: z.string().nullable(),
analysisExternalId: z.string().nullable(),
code: s,
codeSource: s,
name: s,
toothPosition: s,
severity: s,
onsetDate: s,
clinicalSignals: z.array(LookupClinicalSignalSchema).optional(),
})
.strict(),
treatment_record: z
.object({
...identity,
...doctor,
...emrRef,
category: s,
subtype: s,
toothPosition: s,
status: s,
startedAt: s,
completedAt: s,
quantity: n,
unitName: s,
stages: a,
reviewWindowDays: n,
})
.strict(),
recommendation_record: z
.object({
...identity,
...doctor,
...emrRef,
code: s,
recommendation: s,
toothPosition: s,
extractionMethod: s,
confidence: z.number().min(0).max(1).nullable().optional(),
suggestedAt: s,
windowDays: n,
})
.strict(),
emr_record: z
.object({
...identity,
...doctor,
encounterExternalId: z.string().nullable(),
...narrativeShape,
qualityCheck: LookupQualitySchema.nullable().optional(),
clinicalSignals: z.array(LookupClinicalSignalSchema).optional(),
})
.strict(),
image_record: z
.object({
...identity,
...doctor,
...emrRef,
modality: s,
toothPositions: a,
finding: s,
capturedAt: s,
fileLocation: s,
fileName: s,
fileType: s,
fileRefs: z
.array(
z
.object({
provider: z.string().min(1),
connectionId: s,
fileId: z.string().min(1),
role: z.enum(['original', 'thumbnail', 'slice']),
index: z.number().int().nonnegative().optional(),
})
.strict(),
)
.nullable()
.optional(),
analysis: LookupAnalysisSchema.nullable(),
})
.strict(),
appointment_record: z
.object({
...identity,
...doctor,
scheduledAt: s,
appointmentType: s,
resourceName: s,
visitReasonCategory: s,
chiefComplaint: s,
durationMinutes: n,
cancellationReason: s,
status: s,
})
.strict(),
payment_record: z
.object({
...identity,
doctorExternalId: s,
amountCents: cents,
receivableCents: n,
discountCents: n,
channel: s,
insuranceName: s,
cardTypeName: s,
cardName: s,
settlementProject: s,
encounterExternalId: z.string().nullable(),
orderExternalId: z.string().nullable(),
})
.strict(),
refund_record: z
.object({
...identity,
amountCents: cents,
reason: s,
paymentExternalId: z.string().nullable(),
})
.strict(),
complaint_record: z
.object({
...identity,
number: s,
severity: s,
status: s,
feedbackKind: s,
category: s,
channel: s,
description: s,
settlementNumber: z.string().nullable(),
resolvedAt: s,
sourceUpdatedAt: s,
sourceStatus: z.string().nullable(),
sourceCategory: s,
sourceConcern: s,
sourceChannel: s,
followUps: z.array(LookupFollowUpSchema).optional(),
})
.strict(),
// 保留已注册但尚无对外样例的类型;同样封闭投影,禁止自由 JSON 出口。
visit_registration_record: z
.object({ ...identity, doctorExternalId: s, registeredAt: s })
.strict(),
visit_reception_record: z.object({ ...identity, receptionType: s, receivedAt: s }).strict(),
order_record: z
.object({
...identity,
encounterExternalId: z.string().nullable(),
amountCents: cents,
totalAmount: n,
hostStatus: s,
items: z
.array(
z
.object({
itemType: s,
itemCode: s,
itemName: s,
quantity: n,
unitPriceCents: n,
amountCents: cents,
amount: n,
name: s,
treatmentCategory: s,
})
.strict(),
)
.nullable()
.optional(),
})
.strict(),
recharge_record: z
.object({ ...identity, amountCents: cents, amount: n, cardType: s, rechargeType: s })
.strict(),
referral_record: z
.object({
...identity,
referrerExternalId: s,
referrerName: s,
referrerChartNumber: s,
referrerType: s,
channel: s,
})
.strict(),
image_analysis_record: z
.object({
...identity,
imageExternalId: s,
codeSource: s,
status: s,
analyzedAt: s,
sourceUpdatedAt: s,
analysisResult: s,
findings: z
.array(
z
.object({
externalId: s,
name: s,
code: s,
toothPosition: s,
excluded: b,
})
.strict(),
)
.optional(),
})
.strict(),
} as const;
export type LookupContentType = keyof typeof LookupContentSchemas;
import { z } from 'zod';
import type { OpenFactRow } from './open-fact';
import { redactJson } from './open-redact';
import { lookupContent } from './lookup-content';
import { LookupContentError } from './lookup-narrative';
import { LookupFactSchema } from './patient-facts-lookup.schema';
export type LookupFactRow = OpenFactRow & { fact_id: string; host_name: string; appointment_created_values?: unknown };
export type LookupFactRow = OpenFactRow & {
fact_id: string;
host_name: string;
appointment_created_values?: unknown;
source_evidence?: unknown;
};
/** 仅用于新增患者查询;不改变存储事实或原有开放接口的时间口径。 */
export function toLookupFact(row: LookupFactRow, clinicName: string | null) {
const c = row.content && typeof row.content === 'object' && !Array.isArray(row.content)
? row.content as Record<string, unknown> : {};
const c =
row.content && typeof row.content === 'object' && !Array.isArray(row.content)
? (row.content as Record<string, unknown>)
: {};
let occurredAt = row.occurred_at?.toISOString() ?? null;
let plannedFor: string | null = null;
const knownSource = row.host_name === 'friday' || row.host_name === 'jvs-dw';
......@@ -17,24 +25,42 @@ export function toLookupFact(row: LookupFactRow, clinicName: string | null) {
occurredAt = appointmentCreatedAt(row.host_name, row.appointment_created_values);
plannedFor = row.planned_for?.toISOString() ?? explicitInstant(c.scheduled_at);
} else if (row.type === 'recommendation_record') {
occurredAt = explicitInstant(c.doctor_suggested_at) ?? occurredAt
?? (knownSource ? row.planned_for?.toISOString() ?? null : null);
occurredAt =
explicitInstant(c.doctor_suggested_at) ??
occurredAt ??
(knownSource ? (row.planned_for?.toISOString() ?? null) : null);
} else if (row.type === 'treatment_record' && row.kind === 'planned') {
// 两宿主现行映射均将制定时间写在 planned_for;没有明确执行日期。
occurredAt = occurredAt ?? (knownSource ? row.planned_for?.toISOString() ?? null : null);
occurredAt = occurredAt ?? (knownSource ? (row.planned_for?.toISOString() ?? null) : null);
}
// 内层明确时间可以补齐空外层,已有不同时间不覆盖,内层保留供辨识。
const occurrenceKey: Record<string, string> = {
image_analysis_record: 'analyzed_at', visit_registration_record: 'registered_at',
visit_reception_record: 'received_at', image_record: 'captured_at',
image_analysis_record: 'analyzed_at',
visit_registration_record: 'registered_at',
visit_reception_record: 'received_at',
image_record: 'captured_at',
};
const key = occurrenceKey[row.type];
if (!occurredAt && key) occurredAt = explicitInstant(c[key]);
return {
id: row.fact_id, type: row.type, kind: row.kind,
clinicId: row.clinic_id, clinicName, occurredAt, plannedFor,
content: lookupContent(row.type, redactJson(row.content), occurredAt, plannedFor),
};
const parsed = LookupFactSchema.safeParse({
id: row.fact_id,
type: row.type,
kind: row.kind,
clinicExternalId: row.clinic_id,
clinicName,
occurredAt,
plannedFor,
content: lookupContent(
row.type,
row.content,
occurredAt,
plannedFor,
row.host_name,
row.source_evidence,
),
});
if (!parsed.success) throw new LookupContentError('fact');
return parsed.data;
}
/** 无时区历史文本不能按服务运行时区猜测;只接受明确时区的有效时刻。 */
......
import { InternalServerErrorException } from '@nestjs/common';
/** 仅用于新增查询返回;错误信息不带病历正文或来源值。 */
export class LookupContentError extends InternalServerErrorException {
constructor(readonly field: string) {
super({
code: 'FACT_CONTENT_NORMALIZATION_FAILED',
message: '事实内容无法可靠转换,请联系服务方核查',
details: { reason: 'FACT_CONTENT_NORMALIZATION_FAILED' },
});
}
}
export function isRecord(v: unknown): v is Record<string, unknown> {
return v !== null && typeof v === 'object' && !Array.isArray(v);
}
const empty = (v: unknown): boolean =>
v == null ||
(typeof v === 'string' && !v.trim()) ||
(Array.isArray(v) && v.length === 0) ||
(isRecord(v) && Object.keys(v).length === 0);
const editorKeys = new Set([
'englishVal',
'englishValTopRight',
'englishValBotRight',
'englishValBotLeft',
]);
/** 有限、可核实的正文形态;⛔ 不递归搜寻未知对象里的“像正文”的值。 */
export function lookupNarrative(value: unknown, host: string, field: string): string | null {
const fail = (): never => {
throw new LookupContentError(field);
};
let v = value;
if (typeof v === 'string') {
const s = v.trim();
if (!s || s === 'null') return null;
if (/^[{[]/.test(s)) {
try {
v = JSON.parse(s);
} catch {
// [36] 叩痛是普通牙位文本;以对象/条目数组开头的破损结构不能冒充正文。
if (!/^\[[^\[\]{}"]+\]\s*\S/.test(s)) return fail();
return value as string;
}
} else return v;
}
if (empty(v)) return null;
const legacy =
host === 'friday' && ['examFindings', 'procedure', 'treatment'].includes(field)
? 'value'
: host === 'jvs-dw' && ['treatment', 'treatmentPlan'].includes(field)
? 'treatName'
: null;
const render = (item: unknown): string | null => {
if (!isRecord(item)) return fail();
// 5i5ya 旧编辑器同时保存可读牙位、四象限选择和序列化备份。
// 四象限选择与正文牙位必须一致;备份包含未选中的旧牙面状态,不能据此重建牙位。
const legacyEditor = host === 'jvs-dw' && ['examFindings', 'procedure'].includes(field);
if (legacyEditor) {
const quadrants = [
'englishVal',
'englishValTopRight',
'englishValBotRight',
'englishValBotLeft',
];
const selected: string[] = [];
quadrants.forEach((key, i) => {
const values = item[key];
if (empty(values)) return;
if (!Array.isArray(values) || values.some((v) => typeof v !== 'string' || !v.trim()))
fail();
for (const v of values as string[]) selected.push(`${i + 1}${v.trim()}`);
});
if (selected.length) {
if (typeof item.toothPosition !== 'string') return fail();
const teeth = item.toothPosition
.split(';')
.map((s) => s.trim())
.filter(Boolean);
if (
JSON.stringify([...new Set(teeth)].sort()) !==
JSON.stringify([...new Set(selected)].sort())
)
return fail();
}
const backup = item.toothPositionBak;
if (!empty(backup) && backup !== 'AAAAAA==') {
if (typeof backup !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(backup)) return fail();
}
}
for (const [key, val] of Object.entries(item)) {
if (key === 'message' || key === 'toothPosition' || key === legacy) continue;
if (legacyEditor && (editorKeys.has(key) || key === 'toothPositionBak')) continue;
return fail();
}
const text = (x: unknown): string | null => {
if (x == null) return null;
if (typeof x !== 'string') return fail();
if (/^\s*[{[]/.test(x) && !/^\s*\[\d+\]\s*[^\s\],}]/.test(x)) return fail();
return x.trim() ? x : null;
};
const message = text(item.message),
alias = legacy ? text(item[legacy]) : null;
if (message !== null && alias !== null && message !== alias) return fail();
const body = message ?? alias,
tooth = text(item.toothPosition);
if (!body) return tooth ? `牙位 ${tooth}(未提供正文)` : null;
return tooth ? `${tooth}:${body}` : body;
};
const lines = (Array.isArray(v) ? v : [v]).map(render).filter((s): s is string => s !== null);
return lines.length ? lines.join('\n') : null;
}
import { isRecord } from './lookup-narrative';
/** 仅包含本事实绑定、同宿主/租户/患者/诊所的流水证据摘要。 */
export interface LookupEvidence {
sourceEventId: string | null;
subjectType: string;
subjectId: string;
raw: Record<string, unknown>;
}
const id = (v: unknown): string | null => (typeof v === 'string' && v.trim() ? v : null);
function route(host: string, e: LookupEvidence): string | null {
const event = e.sourceEventId ?? '';
if (host === 'friday' && event.startsWith('supplement:meyer:') && e.raw.meyer === true)
return 'meyer';
if (!['friday', 'jvs-dw'].includes(host)) return null;
for (const prefix of ['cold-import:', `pull:${host}:`, `push:${host}:`, `reconcile:${host}:`]) {
if (event.startsWith(prefix)) return event.slice(prefix.length).split(':')[0] || null;
}
return null;
}
export function lookupRelations(
type: string,
c: Record<string, unknown>,
host: string,
evidence: unknown,
) {
const values = Array.isArray(evidence) ? evidence : [];
const all = (resolve: (e: LookupEvidence, resource: string) => string | null): string | null => {
if (!values.length) return null;
const result = new Set<string>();
for (const e of values) {
if (
!isRecord(e) ||
!isRecord(e.raw) ||
typeof e.subjectType !== 'string' ||
typeof e.subjectId !== 'string'
)
return null;
const r = route(host, e as unknown as LookupEvidence);
const value = r && resolve(e as unknown as LookupEvidence, r);
if (!value) return null;
result.add(value);
}
return result.size === 1 ? [...result][0]! : null;
};
const equal = (stored: unknown, ...sources: unknown[]): string | null => {
const value = id(stored),
present = sources.filter((v) => v != null && v !== '');
return value && present.length && present.every((v) => id(v) === value) ? value : null;
};
const clinical = ['diagnosis_record', 'treatment_record', 'recommendation_record'].includes(type);
const result: Record<string, string | null> = {};
if (clinical || type === 'image_record') {
result.emrExternalId = all((e, r) => {
if (r === 'meyer' || r === 'image_analysis') return null;
if (type === 'image_record') {
if (host !== 'jvs-dw' || r !== 'image' || e.subjectType !== 'image') return null;
// 原始父病历行,或保留独立 emr_id 的拆行;绝不切割 image_external_id。
if (e.raw.emr_id != null) return id(e.raw.emr_id);
return e.raw.hasFileUrl === true ? id(e.raw.id) : null;
}
const expected = type.replace('_record', '');
if (r !== expected || e.subjectType !== expected) return null;
if (host === 'friday') return equal(c.source_encounter_external_id, e.raw.emr_sub_id);
// 5i5ya 拆行保留父 EMR 原文;旧 image_finding 源行直接带 emr_id。
return equal(c.source_encounter_external_id, e.raw.emr_id ?? e.raw.id);
});
}
if (clinical || ['emr_record', 'image_record', 'payment_record', 'order_record'].includes(type)) {
result.encounterExternalId =
type === 'emr_record' && host === 'friday'
? all((e, r) =>
r === 'emr' && e.subjectType === 'emr' && equal(c.emr_external_id, e.raw.emr_sub_id)
? equal(c.encounter_external_id, e.raw.appointment_id)
: null,
)
: null;
}
if (type === 'diagnosis_record') {
result.imageExternalId = all((e, r) => {
if (r === 'meyer' && e.subjectType === 'diagnosis')
return equal(c.source_image_external_id, e.raw.sourceImageExternalId);
if (host === 'friday' && r === 'image_analysis' && e.subjectType === 'image_analysis')
return equal(c.source_image_external_id, e.raw.check_id);
return null;
});
result.analysisExternalId = all((e, r) =>
host === 'friday' && r === 'image_analysis' && e.subjectType === 'image_analysis'
? equal(c.source_analysis_external_id, e.raw.id, e.subjectId)
: null,
);
}
if (type === 'consultation_record')
result.appointmentExternalId = all((e, r) =>
host === 'jvs-dw' && r === 'consult' && e.subjectType === 'consultation'
? equal(c.appointment_external_id, e.raw.appo_id)
: null,
);
if (type === 'refund_record')
result.paymentExternalId = all((e, r) =>
host === 'friday' && r === 'refund' && e.subjectType === 'refund'
? equal(c.related_payment_external_id, e.raw.ref_settlement_id ?? e.raw.settlement_id)
: null,
);
if (type === 'payment_record') result.orderExternalId = null;
if (type === 'complaint_record') {
result.settlementNumber = all((e, r) =>
host === 'jvs-dw' && r === 'complaint' && e.subjectType === 'complaint'
? equal(c.related_order_external_id, e.raw.settlement_number)
: null,
);
result.sourceStatus = all((e, r) =>
host === 'jvs-dw' && r === 'complaint' && e.subjectType === 'complaint'
? equal(c.host_status, e.raw.complain_type_name)
: null,
);
}
return result;
}
......@@ -25,7 +25,7 @@ export class PatientFactsLookupController {
description: [
'使用应用 Bearer 令牌,需开通宿主查询权限。病历号、姓名、手机号至少一项,多项精确匹配。',
'返回患者数组:`patient`(资料)、`persona`(画像)、`relations`(关系)、`facts`(全部当前有效事实)。无匹配返回空数组,不分页。',
'[调用说明与示例](/docs/integration/patient-facts-lookup) · [事实 content 字段](/docs/integration/patient-fact-content)',
'[完整接口说明、字段与示例](/docs/api/open/PatientFactsLookupController_lookup)',
].join('\n\n'),
})
lookup(@Req() req: ApplicationRequest, @Body() body: PatientFactsLookupRequestDto) {
......
import { z } from 'zod';
import { createZodDto } from 'nestjs-zod';
import { LookupContentSchemas } from './lookup-content.schema';
export const PatientFactsLookupSchema = z
.strictObject({
......@@ -28,13 +29,149 @@ const PatientBasicSchema = z.object({
birthDate: z.string().nullable().describe('YYYY-MM-DD 业务日期'),
});
const factBase = {
id: z.string().uuid().describe('PAC 事实版本 ID;新版本会变化'),
kind: z.enum(['actual', 'planned']).describe('actual 实际记录;planned 计划或建议'),
clinicExternalId: z.string().nullable().describe('宿主诊所标识'),
clinicName: z.string().nullable(),
occurredAt: z
.string()
.nullable()
.describe('业务发生时间;预约为宿主创建时间,未知 null;UTC ISO 8601'),
plannedFor: z
.string()
.nullable()
.describe('明确约定的执行时间;目前为预约约定到诊时间,其他为 null'),
};
export const LookupFactSchema = z.discriminatedUnion('type', [
z
.object({
...factBase,
type: z.literal('consultation_record'),
content: LookupContentSchemas.consultation_record,
})
.strict(),
z
.object({
...factBase,
type: z.literal('encounter_record'),
content: LookupContentSchemas.encounter_record,
})
.strict(),
z
.object({
...factBase,
type: z.literal('diagnosis_record'),
content: LookupContentSchemas.diagnosis_record,
})
.strict(),
z
.object({
...factBase,
type: z.literal('treatment_record'),
content: LookupContentSchemas.treatment_record,
})
.strict(),
z
.object({
...factBase,
type: z.literal('recommendation_record'),
content: LookupContentSchemas.recommendation_record,
})
.strict(),
z
.object({
...factBase,
type: z.literal('emr_record'),
content: LookupContentSchemas.emr_record,
})
.strict(),
z
.object({
...factBase,
type: z.literal('image_record'),
content: LookupContentSchemas.image_record,
})
.strict(),
z
.object({
...factBase,
type: z.literal('appointment_record'),
content: LookupContentSchemas.appointment_record,
})
.strict(),
z
.object({
...factBase,
type: z.literal('payment_record'),
content: LookupContentSchemas.payment_record,
})
.strict(),
z
.object({
...factBase,
type: z.literal('refund_record'),
content: LookupContentSchemas.refund_record,
})
.strict(),
z
.object({
...factBase,
type: z.literal('complaint_record'),
content: LookupContentSchemas.complaint_record,
})
.strict(),
z
.object({
...factBase,
type: z.literal('visit_registration_record'),
content: LookupContentSchemas.visit_registration_record,
})
.strict(),
z
.object({
...factBase,
type: z.literal('visit_reception_record'),
content: LookupContentSchemas.visit_reception_record,
})
.strict(),
z
.object({
...factBase,
type: z.literal('order_record'),
content: LookupContentSchemas.order_record,
})
.strict(),
z
.object({
...factBase,
type: z.literal('recharge_record'),
content: LookupContentSchemas.recharge_record,
})
.strict(),
z
.object({
...factBase,
type: z.literal('referral_record'),
content: LookupContentSchemas.referral_record,
})
.strict(),
z
.object({
...factBase,
type: z.literal('image_analysis_record'),
content: LookupContentSchemas.image_analysis_record,
})
.strict(),
]);
export const PatientFactsLookupResponseSchema = z.object({
items: z.array(
z.object({
patient: PatientBasicSchema.extend({
dedicatedCs: z
.object({
id: z.string().nullable().describe('宿主侧专属客服标识,不是 PAC 用户 ID'),
externalId: z.string().nullable().describe('宿主侧专属客服标识'),
name: z.string().nullable().describe('专属客服姓名'),
})
.nullable()
......@@ -67,20 +204,7 @@ export const PatientFactsLookupResponseSchema = z.object({
),
}),
),
facts: z.array(
z.object({
id: z.string().uuid().describe('PAC 事实版本 ID;同一业务事实产生新版本时 ID 会变化'),
type: z
.string()
.describe('决定 content 结构的 PAC 事实类型;详见事实内容参考,允许未来新增类型'),
kind: z.string().describe('actual=实际记录;planned=计划或建议'),
clinicId: z.string().nullable(),
clinicName: z.string().nullable(),
occurredAt: z.string().nullable().describe('业务发生时间;建议/治疗计划为提出或制定时间,预约为宿主创建时间,取不到留 null;实际到诊由 encounter_record 表达。UTC ISO 8601,未知 null;其他类型沿用已存业务时间,不保证等于源记录创建时间'),
plannedFor: z.string().nullable().describe('明确约定的执行时间;当前仅预约返回约定时间,建议和治疗计划无明确执行日期时为 null。UTC ISO 8601'),
content: z.unknown().describe('按 type 定义的脱敏内容;已知字段统一 camelCase、本记录源标识为 externalId、完整承接的重复时间省略。歧义关联/金额不合并,未知扩展及冲突原值保留;详见 content 字段参考'),
}),
),
facts: z.array(LookupFactSchema),
}),
),
});
......
......@@ -3,6 +3,7 @@ import { Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { currentFactHeadSql } from '../facts/current-fact';
import type { OpenFactRow } from './open-fact';
import { LookupContentError } from './lookup-narrative';
import { toLookupFact } from './lookup-fact';
import { redactText } from './open-redact';
import { toOpenPersona } from './open-persona';
......@@ -18,6 +19,7 @@ type LookupRow = OpenFactRow & {
host_name: string;
fact_id: string | null;
appointment_created_values?: unknown;
source_evidence?: unknown;
};
type LookupItem = PatientFactsLookupItem;
......@@ -55,7 +57,9 @@ function basicPatient(p: {
}
/** 只投影专属客服的稳定字段,避免把 preferences 中的其他内部属性透出。 */
function dedicatedCsOf(preferences: unknown): { id: string | null; name: string | null } | null {
function dedicatedCsOf(
preferences: unknown,
): { externalId: string | null; name: string | null } | null {
if (!preferences || typeof preferences !== 'object' || Array.isArray(preferences)) return null;
const value = (preferences as Record<string, unknown>).dedicatedCs;
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
......@@ -63,7 +67,7 @@ function dedicatedCsOf(preferences: unknown): { id: string | null; name: string
const rawId = typeof cs.id === 'string' ? cs.id.trim() : '';
const id = rawId && rawId !== '0' ? rawId : null;
const name = typeof cs.name === 'string' ? redactText(cs.name.trim()) || null : null;
return id || name ? { id, name } : null;
return id || name ? { externalId: id, name } : null;
}
@Injectable()
......@@ -96,7 +100,25 @@ export class PatientFactsLookupService {
AND t.subject_type = 'appointment'
AND t.subject_id = f.content ->> 'appointment_external_id'
AND (f.clinic_id IS NULL OR t.clinic_id = f.clinic_id)
) ELSE NULL END AS appointment_created_values
) ELSE NULL END AS appointment_created_values,
(SELECT CASE WHEN count(*) = cardinality(f.transaction_ids) THEN jsonb_agg(jsonb_build_object(
'sourceEventId', t.source_event_id, 'subjectType', t.subject_type, 'subjectId', t.subject_id,
'raw', jsonb_build_object(
'id', t.raw_payload->'id', 'emr_sub_id', t.raw_payload->'emr_sub_id',
'emr_id', t.raw_payload->'emr_id', 'appointment_id', t.raw_payload->'appointment_id',
'appo_id', t.raw_payload->'appo_id', 'check_id', t.raw_payload->'check_id',
'ref_settlement_id', t.raw_payload->'ref_settlement_id', 'settlement_id', t.raw_payload->'settlement_id',
'settlement_number', t.raw_payload->'settlement_number',
'complain_type_name', t.raw_payload->'complain_type_name',
'sourceImageExternalId', t.raw_payload->'sourceImageExternalId',
'meyer', jsonb_typeof(t.raw_payload->'_meyer') = 'object',
'hasFileUrl', t.raw_payload ? 'file_url'))) ELSE NULL END
FROM patient_transactions t
WHERE t.id = ANY(f.transaction_ids)
AND t.host_id = f.host_id AND t.tenant_id = f.tenant_id
AND t.patient_id = f.patient_id
AND t.clinic_id IS NOT DISTINCT FROM f.clinic_id
) AS source_evidence
FROM patients p
JOIN hosts h ON h.id = p.host_id
LEFT JOIN patient_facts f ON f.patient_id = p.id
......@@ -190,18 +212,35 @@ export class PatientFactsLookupService {
const item = items.get(row.patient_id);
if (!item) continue;
if (row.fact_id !== null)
item.facts.push(toLookupFact(
{ ...row, fact_id: row.fact_id },
row.clinic_id ? (application.clinicNames[row.clinic_id] ?? null) : null,
));
try {
item.facts.push(
toLookupFact(
{ ...row, fact_id: row.fact_id },
row.clinic_id ? (application.clinicNames[row.clinic_id] ?? null) : null,
),
);
} catch (error) {
if (error instanceof LookupContentError)
this.logger.error(
JSON.stringify({
event: 'open_fact_content_normalization_failed',
factId: row.fact_id,
field: error.field,
}),
);
throw error;
}
}
// 按实际返回的时间排序,避免预约旧兜底值改变对外时间轴。
for (const item of items.values()) item.facts.sort((a, b) => {
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);
});
for (const item of items.values())
item.facts.sort((a, b) => {
const at = a.occurredAt ?? a.plannedFor;
const bt = b.occurredAt ?? b.plannedFor;
return (
(bt ? Date.parse(bt) : -Infinity) - (at ? Date.parse(at) : -Infinity) ||
b.id.localeCompare(a.id)
);
});
return items;
},
{ isolationLevel: Prisma.TransactionIsolationLevel.RepeatableRead, timeout: 15000 },
......
......@@ -111,6 +111,11 @@ export const AssemblerConfigSchema = z.object({
/// 主表字段 → canonical 字段映射(canonical key ← host field)
field_mapping: fieldMapping,
/// 经核实的来源记录创建字段(canonical 名);不配置时不猜测 createdAt 的语义。
source_created_at: z.object({
field: z.string().min(1),
}).optional(),
/// 显式兼容无正文的病历数组元素;仅白名单元数据为空时补 message:null。
empty_narrative_entries: z.record(z.string(), z.object({
empty_metadata_keys: z.array(z.string().min(1)),
......
......@@ -3,6 +3,7 @@ import {
CanonicalResourceKey,
CanonicalResourceMeta,
CanonicalResourceSchemas,
CanonicalDateTimeSchema,
} from '@pac/types';
/**
......@@ -184,7 +185,9 @@ export function normalizeCanonical(
// birthDate/taskDate 等纯日期仍只按各自契约处理,不按字段后缀猜测时间类型。
for (const k of new Set(['createdAt', 'updatedAt', ...meta.datetimeFields])) {
if (k in out && typeof out[k] === 'string') {
out[k] = normalizeDatetime(out[k] as string, ctx.timezone);
// CSV 的 null 占位不是时间;必填业务字段仍由后续 schema 拒收。
out[k] = (out[k] as string).trim().toLowerCase() === 'null'
? null : normalizeDatetime(out[k] as string, ctx.timezone);
}
}
......@@ -198,6 +201,15 @@ export function normalizeCanonical(
? { ...row, [field]: normalizeDatetime(row[field] as string, ctx.timezone) } : item;
});
}
for (const { object, field } of meta.datetimeObjectFields ?? []) {
const value = out[object];
if (value && typeof value === 'object' && !Array.isArray(value)) {
const row = value as Record<string, unknown>;
if (typeof row[field] === 'string') {
out[object] = { ...row, [field]: normalizeDatetime(row[field] as string, ctx.timezone) };
}
}
}
return out;
}
......@@ -296,7 +308,7 @@ export function normalizeDatetime(s: string, timezone: string): string {
const trimmed = s.trim();
// 已有 tz
if (/[Zz]$/.test(trimmed) || /[+-]\d{2}:?\d{2}$/.test(trimmed)) {
return trimmed.replace(' ', 'T');
return trimmed.replace(' ', 'T').replace(/z$/, 'Z').replace(/([+-]\d{2})(\d{2})$/, '$1:$2');
}
// 仅日期 → 当地零点(见上方注释:直通会被 JS 当成 UTC 零点)
if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) {
......@@ -311,9 +323,8 @@ export function normalizeDatetime(s: string, timezone: string): string {
}
/**
* IANA 时区 → 当前时刻的 UTC offset suffix(如 '+08:00')。
* 简化版:对常用东亚时区直接映射;真要严谨可用 Intl.DateTimeFormat 推导,但
* v1 不处理 DST(中国大陆不实行;若接欧美 host 再迭代)。
* 当前宿主约定使用固定偏移。未支持的时区须显式报错,不能静默当作 UTC。
* 接入需要夏令时的宿主时,应按源日期解析偏移,再扩展此处。
*/
export function timezoneToOffsetSuffix(tz: string): string {
const fixed: Record<string, string> = {
......@@ -326,9 +337,18 @@ export function timezoneToOffsetSuffix(tz: string): string {
UTC: 'Z',
'Etc/UTC': 'Z',
};
return fixed[tz] ?? '+00:00';
const offset = fixed[tz];
if (!offset) throw new Error(`Unsupported host timezone: ${tz}`);
return offset;
}
// passthrough 字段也参与事件幂等/乱序裁决,不能绕过时间契约。
const optionalCanonicalTime = z.union([CanonicalDateTimeSchema, z.date(), z.literal('')]).nullish();
const commonTimesSchema = z.object({
createdAt: optionalCanonicalTime,
updatedAt: optionalCanonicalTime,
});
// ─────────────────────────────────────────────────────────────
// Stage ③:zod 校验 + 漂移抛错
// ─────────────────────────────────────────────────────────────
......@@ -340,6 +360,7 @@ export function validateCanonical<T>(opts: {
resource: string;
}): T {
try {
commonTimesSchema.parse(opts.raw);
return opts.schema.parse(opts.raw);
} catch (err) {
if (err instanceof ZodError) {
......
import { CanonicalDateTimeSchema } from '@pac/types';
/** canonical 已完成宿主时区归一;⛔ 不在下游按进程时区猜测裸时间。 */
export function readCanonicalTime(value: unknown, field: string): Date | null {
if (value == null || value === '') return null;
if (value instanceof Date && Number.isFinite(value.getTime())) return value;
const parsed = CanonicalDateTimeSchema.safeParse(value);
if (parsed.success) return new Date(parsed.data);
// 不带源值,避免诊疗原文进入错误日志;错误须由现有逐行拒收机制处理。
throw new Error(`Invalid canonical time at ${field}: expected a valid datetime with timezone`);
}
import { readSourceCreatedTime, type SourceCreatedTime } from '../source-created-time';
import { Injectable, Logger } from '@nestjs/common';
import { createHash } from 'node:crypto';
import { FactStatus, type FactType, type FactKind } from '@pac/types';
......@@ -182,7 +183,7 @@ export class FactWriter {
}, { maxWait: 30_000, timeout: 120_000 });
}
async writeDraft(input: {
async writeDraft(input: Partial<SourceCreatedTime> & {
draft: FactDraft;
hostId: string;
tenantId: string;
......@@ -322,6 +323,7 @@ export class FactWriter {
clinicId: validatedDraft.clinicId ?? null,
occurredAt: validatedDraft.occurredAt ?? null,
sourceUpdatedAt: sourceUpdatedAt ?? null,
...readSourceCreatedTime({ ...input }),
plannedFor: validatedDraft.plannedFor ?? null,
validFrom: validatedDraft.validFrom ?? null,
validUntil: validatedDraft.validUntil ?? null,
......@@ -558,6 +560,7 @@ export class FactWriter {
clinicId: entry.draft.clinicId ?? null,
occurredAt: entry.draft.occurredAt ?? null,
sourceUpdatedAt: entry.sourceUpdatedAt ?? null,
...readSourceCreatedTime({ ...entry }),
plannedFor: entry.draft.plannedFor ?? null,
validFrom: entry.draft.validFrom ?? null,
validUntil: entry.draft.validUntil ?? null,
......@@ -752,7 +755,7 @@ export interface BulkWriteOutcome {
}
/// FactWriter.bulkWrite 入参条目 — 一个 draft + 它所属的 patient / host / tenant / tx 上下文
export interface BulkEntry {
export interface BulkEntry extends Partial<SourceCreatedTime> {
draft: FactDraft;
hostId: string;
tenantId: string;
......
import { readSourceCreatedTime } from '../source-created-time';
import { readCanonicalTime } from '../canonical-time';
import { Injectable, Logger } from '@nestjs/common';
import type { Action } from '@pac/types';
import { PrismaService } from '../../../prisma/prisma.service';
......@@ -112,7 +114,7 @@ export class ParserPipeline {
try {
const entries = drafts.map(draft => ({ draft, hostId: transaction.hostId,
tenantId: transaction.tenantId, sourceUnit: patientSourceUnit,
patientId: transaction.patientId!, transactionId: transaction.id, sourceUpdatedAt }));
patientId: transaction.patientId!, transactionId: transaction.id, sourceUpdatedAt, ...readSourceCreatedTime(canonicalRow) }));
applyOutcome(metrics, await this.writer.writeSnapshots([{ ...entries[0]!, scope, entries }]));
} catch (err) {
metrics.factsFailed++;
......@@ -131,6 +133,7 @@ export class ParserPipeline {
patientId: transaction.patientId,
transactionId: transaction.id,
sourceUpdatedAt,
...readSourceCreatedTime(canonicalRow),
});
metrics.writes.push(result);
switch (result.action) {
......@@ -224,6 +227,7 @@ export class ParserPipeline {
patientId: item.transaction.patientId,
transactionId: item.transaction.id,
sourceUpdatedAt,
...readSourceCreatedTime(item.canonicalRow),
});
}
const scope = parser.snapshotScope?.({ transaction: item.transaction, canonicalRow: item.canonicalRow });
......@@ -296,6 +300,7 @@ export class ParserPipeline {
patientId: e.patientId,
transactionId: e.transactionId,
sourceUpdatedAt: e.sourceUpdatedAt,
sourceCreatedAt: e.sourceCreatedAt,
});
metrics.writes.push(r);
switch (r.action) {
......@@ -383,13 +388,7 @@ export interface PipelineRunMetrics {
writes: FactWriteResult[];
}
/// canonicalRow.updatedAt(宿主末次修改时间,string / Date)→ Date;缺失或不可解析 → null(退回到达顺序语义)。
/// 只接受已归一的来源时间;仅缺失时为 null,有值但非法不能偷偷降为到达顺序。
function parseSourceUpdatedAt(canonicalRow: Record<string, unknown>): Date | null {
const v = canonicalRow.updatedAt;
if (v instanceof Date) return isNaN(v.getTime()) ? null : v;
if (typeof v === 'string' && v.trim()) {
const d = new Date(v);
return isNaN(d.getTime()) ? null : d;
}
return null;
return readCanonicalTime(canonicalRow.updatedAt, 'updatedAt');
}
......@@ -3,6 +3,7 @@ import { createHash } from 'node:crypto';
import { Action, ActionSchema, SubjectType, SubjectTypeSchema } from '@pac/types';
import type { Prisma } from '@prisma/client';
import type { EmitsConfig } from '../assembler/assembler.schema';
import { readCanonicalTime } from '../canonical-time';
/**
* Transaction Synthesizer(v2.1 重塑)
......@@ -77,6 +78,18 @@ export class TransactionSynthesizer {
// canonical 字段提取
const c = input.canonicalRow;
// 即使业务时间有效,幂等键使用的来源时间也必须已经过统一归一。
// 与身份拒收一样返回 null,由调用方计失败;不让单条异常终止整批。
let occurredAt: Date | null;
try {
const sourceCreatedAt = readCanonicalTime(c.createdAt, 'createdAt');
const sourceUpdatedAt = readCanonicalTime(c.updatedAt, 'updatedAt');
occurredAt = readCanonicalTime(c[emits.occurredAtField], emits.occurredAtField)
?? sourceUpdatedAt ?? sourceCreatedAt;
} catch (err) {
this.logger.warn(`${input.resource}: ${err instanceof Error ? err.message : String(err)}; skipping`);
return null;
}
const subjectId = String(c.externalId ?? '').trim();
if (!subjectId) {
this.logger.warn(
......@@ -99,14 +112,9 @@ export class TransactionSynthesizer {
: undefined;
// occurredAt — 从 yaml emits.occurredAtField 指定的字段拿;
// 缺失/不可解析时确定性回退 updatedAt → createdAt(与 sourceEventId 的 updatedAtMark 同序)。
// 缺失时确定性回退 updatedAt → createdAt;有值但不合法则拒收,不能隐式猜测时区。
// 墙钟只在三者皆缺时兜底:否则同一源行每次摄入 occurredAt 抖动,
// 下游 fact 时间锚变更检测会产生伪 supersede 版本。
let occurredAt = this.pickOccurredAt(c, emits.occurredAtField);
if (!occurredAt) {
occurredAt =
this.pickOccurredAt(c, 'updatedAt') ?? this.pickOccurredAt(c, 'createdAt');
}
if (!occurredAt) {
this.logger.warn(
`Cannot determine occurredAt from field="${emits.occurredAtField}" ` +
......@@ -143,20 +151,6 @@ export class TransactionSynthesizer {
};
}
/** 按 yaml 指定字段名取 occurredAt;字段值需是 ISO 字符串 / Date */
private pickOccurredAt(
c: Record<string, unknown>,
fieldName: string,
): Date | null {
const v = c[fieldName];
if (typeof v === 'string' && v) {
const d = new Date(v);
return Number.isNaN(d.getTime()) ? null : d;
}
if (v instanceof Date) return v;
return null;
}
private sha256(s: string): string {
return createHash('sha256').update(s).digest('hex');
}
......
import { z } from 'zod';
/** 承载事实的来源记录创建时间;不表示诊断等业务动作发生时间。 */
export interface SourceCreatedTime {
sourceCreatedAt: Date | null;
}
/** 纯日期没有创建时刻精度;⛔ 不把归一化补出的零点当作源创建时刻。 */
export function hasSourceTimePrecision(value: unknown): boolean {
return value instanceof Date ? Number.isFinite(value.getTime())
: typeof value === 'string' && /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/.test(value.trim());
}
/** 只读取显式声明的来源元数据;⛔ 不回退到 occurredAt、updatedAt 或 PAC 当前时间。 */
export function readSourceCreatedTime(row: Record<string, unknown>): SourceCreatedTime {
const empty: SourceCreatedTime = { sourceCreatedAt: null };
const value = row.sourceCreatedAt;
if (value instanceof Date) {
return Number.isFinite(value.getTime())
? { sourceCreatedAt: new Date(value) } : empty;
}
if (typeof value !== 'string') return empty;
const parsed = z.iso.datetime({ offset: true }).safeParse(value.trim());
if (!parsed.success) return empty;
const date = new Date(parsed.data);
return Number.isFinite(date.getTime())
? { sourceCreatedAt: date } : empty;
}
import { readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
import * as yaml from 'js-yaml';
import { CanonicalResourceMeta, type CanonicalResourceKey } from '@pac/types';
import { z } from 'zod';
import { CanonicalResourceMeta, CanonicalResourceSchemas, type CanonicalResourceKey } from '@pac/types';
import { AssemblerEngine } from '../src/modules/sync/assembler/assembler-engine';
import { AssemblerConfigSchema } from '../src/modules/sync/assembler/assembler.schema';
import { applyMapping, normalizeCanonical } from '../src/modules/sync/assembler/field-mapper';
import { applyMapping, normalizeCanonical, validateCanonical, ContractDriftError, mapNormalizeValidate,
normalizeDatetime } from '../src/modules/sync/assembler/field-mapper';
import { resolveDictIncludes } from '../src/modules/sync/assembler/dict-include';
import { TransactionSynthesizer } from '../src/modules/sync/pipeline/transaction-synthesizer';
import { ParserPipeline } from '../src/modules/sync/pipeline/parser-pipeline.service';
......@@ -65,6 +67,65 @@ test.each(configs)('$host/$file 源创建/修改时间统一归一', ({ config }
}
});
test.each(configs)('$host/$file 事件时间和来源创建字段不可漏配归一', ({ config }) => {
const fields = new Set(['createdAt', 'updatedAt',
...CanonicalResourceMeta[config.canonical as CanonicalResourceKey].datetimeFields]);
if (config.emits) expect(fields.has(config.emits.occurredAtField)).toBe(true);
if (config.source_created_at) expect(fields.has(config.source_created_at.field)).toBe(true);
});
// 从实际契约反查归一清单,新增 datetime 字段却忘了配置时立即失败。
function datetimePaths(schema: unknown, path = ''): string[] {
if (!schema || typeof schema !== 'object') return [];
const node = schema as Record<string, unknown>;
const result = node.format === 'date-time' ? [path] : [];
for (const [key, child] of Object.entries((node.properties ?? {}) as Record<string, unknown>)) {
result.push(...datetimePaths(child, path ? `${path}.${key}` : key));
}
if (node.items) result.push(...datetimePaths(node.items, `${path}[]`));
for (const key of ['anyOf', 'allOf', 'oneOf']) {
if (Array.isArray(node[key])) for (const child of node[key]) result.push(...datetimePaths(child, path));
}
return result;
}
test.each(resources)('%s 契约中的时刻字段全部纳入统一归一', resource => {
const meta = CanonicalResourceMeta[resource];
const covered = new Set(['createdAt', 'updatedAt', ...meta.datetimeFields,
...(meta.datetimeArrayFields ?? []).map(item => `${item.array}[].${item.field}`),
...(meta.datetimeObjectFields ?? []).map(item => `${item.object}.${item.field}`)]);
const jsonSchema = z.toJSONSchema(CanonicalResourceSchemas[resource], { io: 'input' });
expect(datetimePaths(jsonSchema).filter(path => !covered.has(path))).toEqual([]);
});
test('内嵌影像分析时间走同一归一入口,保留原始快照', () => {
const raw = Object.freeze({ analysis: Object.freeze({ analyzedAt: wall }) });
const result = normalizeCanonical(raw, 'image', context);
expect(new Date((result.analysis as { analyzedAt: string }).analyzedAt).toISOString()).toBe(utc);
expect(raw.analysis.analyzedAt).toBe(wall);
expect(normalizeCanonical(result, 'image', context)).toEqual(result);
});
test.each(['createdAt', 'updatedAt'])('passthrough 的 %s 也不能绕过时区校验', field => {
expect(() => validateCanonical({ raw: { [field]: wall }, schema: z.object({}).passthrough(),
hostId: 'host', resource: 'encounter' })).toThrow(ContractDriftError);
});
test.each(['10/21/2026 12:30:00', '2026-02-30 12:30:00', '2026-10-21T12:00:00+25:00'])(
'含糊或非法时间 %s 不能靠 JS 宽松解析蒙混入库', value => {
expect(() => mapNormalizeValidate({ raw: { externalId: 'visit', patientExternalId: 'patient',
clinicId: 'clinic', startedAt: value }, resource: 'encounter', mapping: {},
normalize: context, hostId: 'host' })).toThrow(ContractDriftError);
});
test('源空值占位留空、显式偏移格式归一、未知时区不默认 UTC', () => {
expect(normalizeCanonical({ updatedAt: 'null', createdAt: ' NULL ' }, 'encounter', context))
.toEqual({ updatedAt: null, createdAt: null });
expect(normalizeDatetime('2026-10-16T10:47:34+0800', context.timezone)).toBe('2026-10-16T10:47:34+08:00');
expect(normalizeDatetime('2026-10-16T02:47:34z', context.timezone)).toBe('2026-10-16T02:47:34Z');
expect(() => normalizeDatetime(wall, 'Asia/ShangHai')).toThrow('Unsupported host timezone');
});
function encounter() {
const config = configs.find(c => c.host === 'friday' && c.config.canonical === 'encounter')!.config;
const raw = { id: 'visit-1', patient_appointment_id: 'patient-1', organization_id: 'clinic-1',
......@@ -109,3 +170,20 @@ test.each(['single', 'batch'] as const)('归一后进入 %s parser,乱序裁
expect(metrics.factsFailed).toBe(0);
expect(metrics.factsCreated).toBe(1);
});
test.each(['single', 'batch'] as const)('%s parser 拒绝未归一的版本时间,不降级成无水位写入', async mode => {
const { canonicalRow } = encounter();
const writer = { writeDraft: jest.fn(), bulkWrite: jest.fn() };
const prisma = { patient: { findUnique: async () => ({ sourceUnit: 'brand-1' }),
findMany: async () => [{ id: 'patient-uuid', sourceUnit: 'brand-1' }] } };
const pipeline = new ParserPipeline({ get: () => new EncounterParser() } as never, writer as never,
prisma as never, { enrichAll: (drafts: unknown) => drafts } as never);
const item = { canonicalRow: { ...canonicalRow, updatedAt: wall },
transaction: { id: 'tx-1', hostId: 'host-1', tenantId: 'tenant-1', patientId: 'patient-uuid',
action: 'doctor_encounter_started' as const, subjectType: 'encounter', subjectId: 'visit-1',
clinicId: 'clinic-1', occurredAt: new Date(utc) } };
if (mode === 'single') await expect(pipeline.runForTransaction(item)).rejects.toThrow('Invalid canonical time');
else expect((await pipeline.runForBatch([item])).factsFailed).toBe(1);
expect(writer.writeDraft).not.toHaveBeenCalled();
expect(writer.bulkWrite).not.toHaveBeenCalled();
});
......@@ -38,6 +38,7 @@ interface Row {
clinicId: string | null;
occurredAt: Date | null;
sourceUpdatedAt: Date | null;
sourceCreatedAt: Date | null;
plannedFor: Date | null;
validFrom: Date | null;
validUntil: Date | null;
......@@ -70,6 +71,7 @@ function makeStore(seed: Partial<Row>[] = []) {
clinicId: (data.clinicId as string | null) ?? null,
occurredAt: (data.occurredAt as Date | null) ?? null,
sourceUpdatedAt: (data.sourceUpdatedAt as Date | null) ?? null,
sourceCreatedAt: (data.sourceCreatedAt as Date | null) ?? null,
plannedFor: (data.plannedFor as Date | null) ?? null,
validFrom: (data.validFrom as Date | null) ?? null,
validUntil: (data.validUntil as Date | null) ?? null,
......@@ -605,3 +607,31 @@ describe('患者归属更正版本与旧患者水位', () => {
expect(rows[0]!.updatedAt).toEqual(T0);
});
});
describe.each(['single', 'bulk'] as const)('来源创建时间 %s', mode => {
const submit = async (w: FactWriter, input: Parameters<FactWriter['writeDraft']>[0]) => mode === 'single'
? w.writeDraft(input) : (await w.bulkWrite([input])).results[0]!;
test('新事实版本保存已校验的来源记录时间,业务时间不变', async () => {
const { prisma, rows } = makeStore();
await submit(writer(prisma), { ...baseInput, draft: encounterDraft(), transactionId: 'tx-created',
sourceCreatedAt: new Date('2026-01-01T00:00:00Z') });
expect(rows[0]).toMatchObject({ sourceCreatedAt: new Date('2026-01-01T00:00:00Z'), occurredAt: T0 });
});
test('同事实元数据不同不制造新版本、不补写已有版本;正常新版本携带本次来源值', async () => {
const { prisma, rows } = makeStore([seedActive()]); const w = writer(prisma);
const input = { ...baseInput, draft: encounterDraft(), transactionId: 'tx-created',
sourceCreatedAt: new Date('2026-01-01T00:00:00Z') };
await submit(w, input);
expect(rows).toHaveLength(1); expect(rows[0]!.sourceCreatedAt).toBeNull();
await submit(w, { ...input, transactionId: 'tx-next', draft: { ...encounterDraft(), occurredAt: new Date(T0.getTime() + 60000) } });
expect(rows).toHaveLength(2); expect(rows[0]!.sourceCreatedAt).toBeNull();
expect(rows[1]!.sourceCreatedAt).toEqual(input.sourceCreatedAt);
});
test('缺字段、非法日期不能写入创建元数据', async () => {
const { prisma, rows } = makeStore();
await submit(writer(prisma), { ...baseInput, draft: encounterDraft(), transactionId: 'tx-unknown',
sourceCreatedAt: new Date('bad') });
expect(rows[0]).toMatchObject({ sourceCreatedAt: null });
});
});
......@@ -41,7 +41,7 @@
"code_source": "std_code",
"name_zh": "龋病",
"tooth_position": "36",
"source_encounter_external_id": "enc-1"
"source_encounter_external_id": "emr-1"
}
},
{
......
import { PrismaClient, Prisma } from '@prisma/client';
import { Logger } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { hashSecret } from '@pac/utils';
import { PatientFactsLookupService } from '../src/modules/open/patient-facts-lookup.service';
import { createMeyerOpenHttp } from './helpers/meyer-open-http';
const url = process.env.PAC_MEYER_TEST_DATABASE_URL;
const describePg = url ? describe : describe.skip;
describePg('lookup contract | isolated PostgreSQL and real HTTP', () => {
let db: PrismaClient, service: PatientFactsLookupService;
let friday: string, jvs: string, patient: string, other: string;
const tenant = 'lookup-contract',
unit = 'brand-a',
clinic = 'clinic-a';
beforeAll(async () => {
const u = new URL(url!);
if (
!['localhost', '127.0.0.1'].includes(u.hostname) ||
!u.pathname.startsWith('/pac_meyer_ci_')
)
throw new Error('isolated local database required');
Logger.overrideLogger(false);
db = new PrismaClient({ datasources: { db: { url } } });
friday = (
await db.host.create({
data: {
name: 'friday',
appId: 'lookup-ci',
appSecretHash: hashSecret('lookup-ci-secret'),
openFactsReadEnabled: true,
},
})
).id;
jvs = (
await db.host.create({ data: { name: 'jvs-dw', appId: 'lookup-dw', appSecretHash: 'test' } })
).id;
patient = (
await db.patient.create({
data: {
hostId: friday,
tenantId: tenant,
sourceUnit: unit,
externalId: 'P',
medicalRecordNumber: 'LOOKUP-CI',
},
})
).id;
other = (
await db.patient.create({
data: {
hostId: friday,
tenantId: tenant,
sourceUnit: 'brand-b',
externalId: 'P',
medicalRecordNumber: 'OTHER-CI',
},
})
).id;
service = new PatientFactsLookupService(db as any);
});
beforeEach(async () => {
await db.patientFact.deleteMany({ where: { hostId: { in: [friday, jvs] } } });
await db.patientTransaction.deleteMany({ where: { hostId: { in: [friday, jvs] } } });
});
afterAll(async () => {
if (!db) return;
await db.patientFact.deleteMany({ where: { hostId: { in: [friday, jvs] } } });
await db.patientTransaction.deleteMany({ where: { hostId: { in: [friday, jvs] } } });
await db.patient.deleteMany({ where: { hostId: { in: [friday, jvs] } } });
await db.host.deleteMany({ where: { id: { in: [friday, jvs] } } });
await db.$disconnect();
});
const lookup = () =>
service.lookup(
{ hostId: friday, appId: 'lookup-ci', clinicNames: { [clinic]: '合成诊所' } },
{ medicalRecordNumber: 'LOOKUP-CI' },
);
async function tx(extra: Partial<Prisma.PatientTransactionUncheckedCreateInput> = {}) {
return db.patientTransaction.create({
data: {
hostId: friday,
tenantId: tenant,
patientId: patient,
clinicId: clinic,
subjectId: 'DX',
subjectType: 'diagnosis',
action: 'diagnosis_recorded',
occurredAt: new Date('2026-10-21T01:00:00Z'),
sourceEventId: `cold-import:diagnosis:${unit}:DX:${randomUUID()}`,
rawPayload: { emr_sub_id: 'EMR-A' },
payloadHash: 'synthetic',
...extra,
},
});
}
async function fact(ids: string[], extra: Partial<Prisma.PatientFactUncheckedCreateInput> = {}) {
return db.patientFact.create({
data: {
hostId: friday,
tenantId: tenant,
sourceUnit: unit,
patientId: patient,
clinicId: clinic,
subjectId: `diagnosis_record:${randomUUID()}`,
kind: 'actual',
type: 'diagnosis_record',
status: 'active',
version: 1,
content: { name_zh: '合成诊断', source_encounter_external_id: 'EMR-A' },
transactionIds: ids,
...extra,
},
});
}
it('queries only bound evidence and leaves stored facts/transactions unchanged', async () => {
const good = await tx();
await tx({ rawPayload: { emr_sub_id: 'UNBOUND' } }); // 同患者未绑定证据不可污染当前版本。
await fact([good.id]);
const before = await db.patientFact.findMany({ where: { patientId: patient } });
const txBefore = await db.patientTransaction.findMany({
where: { patientId: patient },
orderBy: { id: 'asc' },
});
const result = await lookup();
expect(result.items[0]!.facts[0]!.content).toMatchObject({
emrExternalId: 'EMR-A',
encounterExternalId: null,
});
expect(await db.patientFact.findMany({ where: { patientId: patient } })).toEqual(before);
expect(
await db.patientTransaction.findMany({
where: { patientId: patient },
orderBy: { id: 'asc' },
}),
).toEqual(txBefore);
});
test.each(['patient', 'tenant', 'host', 'clinic', 'missing', 'conflict'])(
'rejects partially valid evidence when another bound item has %s mismatch',
async (mismatch) => {
const good = await tx();
let badId: string = randomUUID();
if (mismatch !== 'missing') {
const extra: Partial<Prisma.PatientTransactionUncheckedCreateInput> =
mismatch === 'patient'
? { patientId: other }
: mismatch === 'tenant'
? { tenantId: 'foreign' }
: mismatch === 'host'
? { hostId: jvs }
: mismatch === 'clinic'
? { clinicId: 'foreign' }
: { rawPayload: { emr_sub_id: 'OTHER-EMR' } };
badId = (await tx(extra)).id;
}
await fact([good.id, badId]);
expect((await lookup()).items[0]!.facts[0]!.content).toHaveProperty('emrExternalId', null);
},
);
it('HTTP returns readable paragraphs and explicit whole-request failure without leaking source text', async () => {
const evidence = await tx({
subjectType: 'emr',
subjectId: 'E',
sourceEventId: 'cold-import:emr:brand-a:E:v',
rawPayload: { emr_sub_id: 'E', appointment_id: 'A' },
});
const emr = await fact([evidence.id], {
type: 'emr_record',
content: {
emr_external_id: 'E',
encounter_external_id: 'A',
exam_findings: '[{"toothPosition":"36","value":"未见异常13900139000"}]',
staff_notes: 'private-note',
},
});
const http = await createMeyerOpenHttp(db as any);
try {
const auth = await http.request('/auth/token', {
appId: 'lookup-ci',
appSecret: 'lookup-ci-secret',
});
expect(auth.code).toBe(0);
const result = await http.request(
'/open/patient-facts/lookup',
{ medicalRecordNumber: 'LOOKUP-CI' },
auth.data.accessToken,
);
expect(result.code).toBe(0);
expect(result.data.items[0].facts[0].content).toMatchObject({
encounterExternalId: 'A',
examFindings: '36:未见异常139****9000',
treatment: null,
procedure: null,
});
expect(JSON.stringify(result)).not.toContain('private-note');
await db.patientFact.update({
where: { id: emr.id },
data: { content: { exam_findings: '{"unknown":"private-patient"}' } },
});
const bad = await http.request(
'/open/patient-facts/lookup',
{ medicalRecordNumber: 'LOOKUP-CI' },
auth.data.accessToken,
);
expect(bad).toMatchObject({
code: 90000,
data: null,
details: { reason: 'FACT_CONTENT_NORMALIZATION_FAILED' },
});
expect(JSON.stringify(bad)).not.toContain('private-patient');
} finally {
await http.close();
}
});
});
......@@ -4,46 +4,138 @@ import { PatientFactsLookupResponseSchema } from '../src/modules/open/patient-fa
const early = '2026-10-01T02:00:00.000Z';
const late = '2026-10-20T02:00:00.000Z';
const base: LookupFactRow = {
fact_id: '11111111-1111-4111-8111-111111111111', host_name: 'friday',
subject_id: 'treatment_record:source-1', type: 'treatment_record', kind: 'planned',
status: 'active', clinic_id: 'c', occurred_at: null, planned_for: new Date(early),
valid_until: null, title: '治疗', summary: '摘要',
fact_id: '11111111-1111-4111-8111-111111111111',
host_name: 'friday',
subject_id: 'treatment_record:source-1',
type: 'treatment_record',
kind: 'planned',
status: 'active',
clinic_id: 'c',
occurred_at: null,
planned_for: new Date(early),
valid_until: null,
title: '治疗',
summary: '摘要',
content: { category: 'restorative', subtype: '充填', status: 'planned' },
};
const convert = (changes: Partial<LookupFactRow> = {}) => toLookupFact({ ...base, ...changes }, '诊所');
const convert = (changes: Partial<LookupFactRow> = {}) =>
toLookupFact({ ...base, ...changes }, '诊所');
describe('new lookup fact projection only', () => {
test.each(['friday', 'jvs-dw'])('%s plan creation is not its execution date', host_name => {
test.each(['friday', 'jvs-dw'])('%s plan creation is not its execution date', (host_name) => {
const result = convert({ host_name });
expect(result).toEqual({ id: base.fact_id, type: base.type, kind: 'planned', clinicId: 'c', clinicName: '诊所', occurredAt: early, plannedFor: null, content: base.content });
expect(PatientFactsLookupResponseSchema.shape.items.element.shape.facts.element.safeParse(result).success).toBe(true);
expect(result).toEqual({
id: base.fact_id,
type: base.type,
kind: 'planned',
clinicExternalId: 'c',
clinicName: '诊所',
occurredAt: early,
plannedFor: null,
content: {
...(base.content as Record<string, unknown>),
externalId: null,
emrExternalId: null,
encounterExternalId: null,
},
});
expect(
PatientFactsLookupResponseSchema.shape.items.element.shape.facts.element.safeParse(result)
.success,
).toBe(true);
});
it('prefers stored occurrence over old planned fallback', () => {
expect(convert({ occurred_at: new Date(late) }).occurredAt).toBe(late);
});
test.each(['friday', 'jvs-dw'])('%s recommendation uses its proposal time', host_name => {
test.each(['friday', 'jvs-dw'])('%s recommendation uses its proposal time', (host_name) => {
expect(convert({ type: 'recommendation_record', host_name }).occurredAt).toBe(early);
expect(convert({ type: 'recommendation_record', host_name, content: { doctor_suggested_at: '2026-10-20T10:00:00+08:00' } })).toMatchObject({ occurredAt: late, plannedFor: null });
});
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 });
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: {} });
}
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();
expect(
convert({ type: 'appointment_record', appointment_created_values: ['2026-10-01'] })
.occurredAt,
).toBe('2026-09-30T16:00:00.000Z');
expect(
convert({
type: 'appointment_record',
host_name: 'unknown',
appointment_created_values: [early],
}).occurredAt,
).toBeNull();
});
it('keeps actual arrival on encounter without changing source data or old appointment APIs', () => {
const row = { ...base, type: 'appointment_record', kind: 'actual', occurred_at: new Date(late), content: { arrived_at: late }, appointment_created_values: [early] };
const row = {
...base,
type: 'appointment_record',
kind: 'actual',
occurred_at: new Date(late),
content: { arrived_at: late },
appointment_created_values: [early],
};
const snapshot = JSON.stringify(row);
expect(toLookupFact(row, null)).toMatchObject({ occurredAt: early, content: {} });
expect(toLookupFact({ ...row, type: 'encounter_record' }, null).occurredAt).toBe(late);
......@@ -51,9 +143,24 @@ describe('new lookup fact projection only', () => {
expect(JSON.stringify(row)).toBe(snapshot);
});
it('retains other stored business dates and redaction without mutating the row', () => {
const row = { ...base, type: 'emr_record', kind: 'actual', occurred_at: new Date(early), planned_for: null, content: { illness_desc: '13900139000', staff_notes: '内部', source_fact_subject_id: 'source:1' } };
const row = {
...base,
type: 'emr_record',
kind: 'actual',
occurred_at: new Date(early),
planned_for: null,
content: {
illness_desc: '13900139000',
staff_notes: '内部',
source_fact_subject_id: 'source:1',
},
};
const snapshot = JSON.stringify(row);
expect(toLookupFact(row, null)).toMatchObject({ occurredAt: early, plannedFor: null, content: { illnessDesc: '139****9000', source_fact_subject_id: 'source:1' } });
expect(toLookupFact(row, null)).toMatchObject({
occurredAt: early,
plannedFor: null,
content: { illnessDescription: '139****9000' },
});
expect(toLookupFact(row, null).content).not.toHaveProperty('staff_notes');
expect(JSON.stringify(row)).toBe(snapshot);
});
......@@ -61,7 +168,15 @@ describe('new lookup fact projection only', () => {
const before = toOpenFact(base);
convert();
expect(toOpenFact(base)).toEqual(before);
expect(before).toMatchObject({ subjectId: base.subject_id, status: 'active', occurredAt: null, plannedFor: early, title: '治疗', summary: '摘要', steps: ['composite_filling'] });
expect(before).toMatchObject({
subjectId: base.subject_id,
status: 'active',
occurredAt: null,
plannedFor: early,
title: '治疗',
summary: '摘要',
steps: ['composite_filling'],
});
expect(before).not.toHaveProperty('id');
});
});
import { lookupNarrative, LookupContentError } from '../src/modules/open/lookup-narrative';
import { lookupContent } from '../src/modules/open/lookup-content';
describe('lookup-only readable clinical paragraphs', () => {
test.each(['原文\n否认疼痛,1.5mm', '[36] 叩痛', '36 MOD 无明显异常'])(
'preserves ordinary text: %s',
(text) => {
expect(lookupNarrative(text, 'friday', 'examFindings')).toBe(text);
},
);
test.each(['friday', 'jvs-dw'])(
'%s standard entries preserve order, duplicate entries, teeth and negation',
(host) => {
const entries = [
{ toothPosition: '36 MOD', message: '未见根尖影' },
{ toothPosition: '37', message: null },
{ message: '复查' },
{ message: '复查' },
];
for (const input of [entries, JSON.stringify(entries)])
expect(lookupNarrative(input, host, 'examFindings')).toBe(
'36 MOD:未见根尖影\n牙位 37(未提供正文)\n复查\n复查',
);
expect(lookupNarrative({ message: '正文' }, host, 'diagnosis')).toBe('正文');
},
);
test.each([null, '', ' ', 'null', [], {}, '[]', '{}'])('empty %j remains unknown', (input) =>
expect(lookupNarrative(input, 'friday', 'examFindings')).toBeNull(),
);
test.each([
['friday', 'examFindings', 'value'],
['friday', 'procedure', 'value'],
['friday', 'treatment', 'value'],
['jvs-dw', 'treatment', 'treatName'],
['jvs-dw', 'treatmentPlan', 'treatName'],
])('known %s %s legacy %s only', (host, field, alias) => {
expect(lookupNarrative([{ toothPosition: '36', [alias]: '未治疗' }], host, field)).toBe(
'36:未治疗',
);
expect(lookupNarrative([{ message: '相同', [alias]: '相同' }], host, field)).toBe('相同');
expect(() => lookupNarrative([{ message: '不同', [alias]: '原值' }], host, field)).toThrow(
LookupContentError,
);
});
it('recognizes only confirmed empty editor metadata', () => {
const empty = {
englishVal: [],
englishValTopRight: [],
englishValBotRight: [],
englishValBotLeft: [],
};
expect(
lookupNarrative(
[empty, { toothPosition: '16' }, {}, { message: '原文' }],
'jvs-dw',
'examFindings',
),
).toBe('牙位 16(未提供正文)\n原文');
expect(() => lookupNarrative({ englishVal: ['16'] }, 'jvs-dw', 'examFindings')).toThrow(
LookupContentError,
);
});
it('renders legacy quadrant selections only when they agree with the explicit tooth text', () => {
const old = {
toothPosition: '1A BLM;2B;3C;4D',
message: '未见异常',
toothPositionBak: 'bGVnYWN5',
englishVal: ['A BLM'],
englishValTopRight: ['B'],
englishValBotRight: ['C'],
englishValBotLeft: ['D'],
};
expect(lookupNarrative([old], 'jvs-dw', 'examFindings')).toBe('1A BLM;2B;3C;4D:未见异常');
expect(
lookupNarrative({ message: '复查', toothPositionBak: 'AAAAAA==' }, 'jvs-dw', 'procedure'),
).toBe('复查');
expect(() =>
lookupNarrative({ ...old, toothPosition: '1A' }, 'jvs-dw', 'examFindings'),
).toThrow(LookupContentError);
expect(lookupNarrative({ toothPositionBak: 'bGVnYWN5' }, 'jvs-dw', 'examFindings')).toBeNull();
});
test.each([
{ value: '不猜' },
{ unknown: '秘密' },
{ message: '正文', notes: '内部备注' },
{ message: { nested: '正文' } },
[{ message: '正常' }, 12],
'[{"message":"断裂"',
'{broken}',
'["text"]',
{ toothPosition: 36 },
])('rejects unsupported nonempty structure without partial success: %j', (value) => {
expect(() => lookupNarrative(value, 'unknown', 'examFindings')).toThrow(LookupContentError);
});
it('applies final redaction to decoded JSON and returns all eleven paragraphs', () => {
const output = lookupContent(
'emr_record',
{ exam_findings: '[{"toothPosition":"36","message":"联系13900139000"}]' },
null,
null,
'friday',
);
expect(output.examFindings).toBe('36:联系139****9000');
expect(output.procedure).toBeNull();
expect(output.treatment).toBeNull();
});
it('does not include clinical text in the error', () => {
try {
lookupNarrative({ secret: 'patient-private' }, '', 'diagnosis');
fail('expected error');
} catch (e) {
expect(JSON.stringify(e)).not.toContain('patient-private');
expect((e as LookupContentError).getResponse()).toMatchObject({
code: 'FACT_CONTENT_NORMALIZATION_FAILED',
});
}
});
});
import { lookupContent } from '../src/modules/open/lookup-content';
import { LookupEvidence } from '../src/modules/open/lookup-relations';
const evidence = (
resource: string,
raw: Record<string, unknown>,
subjectType = resource,
host = 'friday',
): LookupEvidence => ({
sourceEventId: `push:${host}:${resource}:brand:opaque:with:colon:v1`,
subjectType,
subjectId: 'own',
raw,
});
const run = (type: string, content: any, proofs: unknown, host = 'friday') =>
lookupContent(type, content, null, null, host, proofs);
describe('source-proven lookup relations', () => {
test.each(['diagnosis', 'treatment', 'recommendation'])(
'%s derived from EMR is an EMR reference, not an encounter',
(resource) => {
for (const host of ['friday', 'jvs-dw']) {
const parent = host === 'friday' ? { emr_sub_id: 'EMR' } : { id: 'EMR' };
expect(
run(
`${resource}_record`,
{ source_encounter_external_id: 'EMR' },
[evidence(resource, parent, resource, host)],
host,
),
).toMatchObject({ emrExternalId: 'EMR', encounterExternalId: null });
}
},
);
it('does not turn Friday independent treatment plans into EMR references', () => {
expect(
run('treatment_record', { source_encounter_external_id: 'same-string' }, [
evidence('treatment', { id: 'same-string' }),
]),
).toMatchObject({ emrExternalId: null, encounterExternalId: null });
});
it('Friday EMR appointment is in encounter domain; 5i5ya registration is not', () => {
const c = { emr_external_id: 'EMR', encounter_external_id: 'APPT' };
expect(
run('emr_record', c, [evidence('emr', { emr_sub_id: 'EMR', appointment_id: 'APPT' })]),
).toHaveProperty('encounterExternalId', 'APPT');
expect(
run(
'emr_record',
c,
[evidence('emr', { id: 'EMR', registration_id: 'APPT' }, 'emr', 'jvs-dw')],
'jvs-dw',
),
).toHaveProperty('encounterExternalId', null);
});
it('uses only bound, consistent source evidence and rejects wrong routes and conflicting aliases', () => {
const good = evidence('diagnosis', { emr_sub_id: 'E' });
const c = { source_encounter_external_id: 'E' };
for (const proofs of [
null,
[],
[good, evidence('diagnosis', { emr_sub_id: 'other' })],
[good, { ...good, sourceEventId: null }],
[evidence('image', { emr_sub_id: 'E' })],
]) {
expect(run('diagnosis_record', c, proofs)).toHaveProperty('emrExternalId', null);
}
expect(run('diagnosis_record', { ...c, emrExternalId: 'other' }, [good])).toHaveProperty(
'emrExternalId',
null,
);
expect(run('diagnosis_record', c, [good, good])).toHaveProperty('emrExternalId', 'E');
expect(run('diagnosis_record', c, [good], 'new-host')).toHaveProperty('emrExternalId', null);
});
it('distinguishes Meyer from Friday despite shared host and image_ai code source', () => {
const proof = {
...evidence('diagnosis', {
meyer: true,
sourceImageExternalId: 'meyer:n:img',
emr_sub_id: 'fake',
}),
sourceEventId: 'supplement:meyer:n:diagnosis:b:d:v',
};
expect(
run(
'diagnosis_record',
{
source_encounter_external_id: 'fake',
source_image_external_id: 'meyer:n:img',
source_analysis_external_id: 'fake',
code_source: 'image_ai',
},
[proof],
),
).toMatchObject({
emrExternalId: null,
encounterExternalId: null,
imageExternalId: 'meyer:n:img',
analysisExternalId: null,
});
expect(run('diagnosis_record', { source_image_external_id: 'wrong' }, [proof])).toHaveProperty(
'imageExternalId',
null,
);
});
it('JVS image parent uses bound raw parent rather than splitting an ID, Friday old image header stays unknown', () => {
expect(
run(
'image_record',
{ image_external_id: 'arbitrary', encounter_external_id: 'REG' },
[evidence('image', { id: 'EMR', hasFileUrl: true }, 'image', 'jvs-dw')],
'jvs-dw',
),
).toMatchObject({ emrExternalId: 'EMR', encounterExternalId: null });
expect(run('image_record', { image_external_id: 'EMR|img|x' }, [], 'jvs-dw')).toHaveProperty(
'emrExternalId',
null,
);
expect(
run('image_record', { encounter_external_id: 'HEADER' }, [
evidence('image', { emr_id: 'HEADER' }),
]),
).toMatchObject({ emrExternalId: null, encounterExternalId: null });
});
it('JVS old image diagnosis keeps EMR only, generic Friday analysis can retain explicit analysis/image references', () => {
expect(
run(
'diagnosis_record',
{ source_encounter_external_id: 'E', code_source: 'image_ai' },
[evidence('diagnosis', { emr_id: 'E' }, 'diagnosis', 'jvs-dw')],
'jvs-dw',
),
).toMatchObject({ emrExternalId: 'E', imageExternalId: null, analysisExternalId: null });
const e = { ...evidence('image_analysis', { id: 'A', check_id: 'I' }), subjectId: 'A' };
expect(
run('diagnosis_record', { source_analysis_external_id: 'A', source_image_external_id: 'I' }, [
e,
]),
).toMatchObject({ analysisExternalId: 'A', imageExternalId: 'I', emrExternalId: null });
});
it('keeps only verified consultation/refund references and treats settlement number as a number, not an ID', () => {
expect(
run(
'consultation_record',
{ appointment_external_id: 'A' },
[evidence('consult', { appo_id: 'A' }, 'consultation', 'jvs-dw')],
'jvs-dw',
),
).toHaveProperty('appointmentExternalId', 'A');
for (const key of ['settlement_id', 'ref_settlement_id'])
expect(
run('refund_record', { related_payment_external_id: 'P' }, [
evidence('refund', { [key]: 'P' }),
]),
).toHaveProperty('paymentExternalId', 'P');
expect(run('refund_record', { related_payment_external_id: 'P' }, [], 'jvs-dw')).toHaveProperty(
'paymentExternalId',
null,
);
const c = run(
'complaint_record',
{ related_order_external_id: '0001', host_status: '原文' },
[
evidence(
'complaint',
{ settlement_number: '0001', complain_type_name: '原文' },
'complaint',
'jvs-dw',
),
],
'jvs-dw',
);
expect(c).toMatchObject({ settlementNumber: '0001', sourceStatus: '原文' });
expect(c).not.toHaveProperty('orderExternalId');
expect(run('complaint_record', { host_status: 'resolved' }, [], 'jvs-dw')).toHaveProperty(
'sourceStatus',
null,
);
});
});
......@@ -425,18 +425,21 @@ describePg('Meyer | isolated PostgreSQL + actual minimal CLI module', () => {
expect(patient.facts).toHaveLength(3);
expect(patient.facts.some((f: any) => f.type === 'image_analysis_record')).toBe(false);
const image = patient.facts.find((f: any) => f.type === 'image_record');
expect(image).toMatchObject({ clinicId: 'clinic-a', clinicName: '本地影像验收诊所',
expect(image).toMatchObject({ clinicExternalId: 'clinic-a', clinicName: '本地影像验收诊所',
occurredAt: '2026-10-10T02:00:00.000Z', plannedFor: null,
content: { externalId: 'meyer:test:image-1', modality: 'pano' } });
expect(image.content).not.toHaveProperty('fileUrl');
expect(image.content).not.toHaveProperty('fileLocation');
expect(image.content.analysis).toEqual(meyerBundle().image.analysis);
const storedImage = await prisma.patientFact.findUniqueOrThrow({ where: { id: image.id } });
expect((storedImage.content as any).analysis).toEqual(image.content.analysis);
expect(storedImage.sourceCreatedAt?.toISOString()).toBe('2026-10-10T02:00:00.000Z');
const storedDiagnoses = await prisma.patientFact.findMany({ where: { hostId, patientId, type: 'diagnosis_record' } });
expect(storedDiagnoses.every(d => d.sourceCreatedAt === null)).toBe(true);
const diagnoses = patient.facts.filter((f: any) => f.type === 'diagnosis_record');
expect(diagnoses).toHaveLength(2);
for (const diagnosis of diagnoses) {
expect(diagnosis).toMatchObject({ occurredAt: '2026-10-09T01:00:00.000Z', plannedFor: null,
content: { codeSource: 'image_ai', sourceImageExternalId: image.content.externalId } });
content: { codeSource: 'image_ai', imageExternalId: image.content.externalId } });
expect(diagnosis.content).not.toHaveProperty('sourceAnalysisExternalId');
expect(diagnosis.content).not.toHaveProperty('code_source');
}
......
......@@ -370,7 +370,7 @@ suite('local HTTP + PostgreSQL patient facts acceptance', () => {
),
).toBe(false);
expect(new Set(facts.map((f: any) => f.id)).size).toBe(facts.length);
expect(Object.keys(facts[0]).sort()).toEqual(['id','type','kind','clinicId','clinicName','occurredAt','plannedFor','content'].sort());
expect(Object.keys(facts[0]).sort()).toEqual(['id','type','kind','clinicExternalId','clinicName','occurredAt','plannedFor','content'].sort());
const long = r.data.items.find((i: any) => i.facts.length > 200).facts;
expect(long[0].occurredAt).toBe('2026-10-02T00:00:00.000Z');
expect(long.at(-1).content.externalId).toBe('no-time');
......@@ -389,7 +389,7 @@ suite('local HTTP + PostgreSQL patient facts acceptance', () => {
expect(a.patient).toMatchObject({
gender: '女',
birthDate: '2010-01-02',
dedicatedCs: { id: 'source-staff-123', name: '客服139****9000' },
dedicatedCs: { externalId: 'source-staff-123', name: '客服139****9000' },
});
expect(a.patient).toMatchObject({
acquisitionChannel: 'word_of_mouth',
......
......@@ -30,7 +30,7 @@ describe('patient facts lookup contract', () => {
patient_name: '同名',
medical_record_number: '001',
phone: '13800138000',
fact_id: `${i}`,
fact_id: `11111111-1111-4111-8111-${String(i).padStart(12, '0')}`,
subject_id: `s${i}`,
type: 'emr_record',
kind: 'actual',
......@@ -90,7 +90,7 @@ describe('patient facts lookup contract', () => {
expect(result.items[0]!.facts).toHaveLength(251);
expect(result.items[1]!.facts).toEqual([]);
expect(result.items[0]!.patient.phone).toBe('138****8000');
expect(result.items[0]!.patient.dedicatedCs).toEqual({ id: 'staff-id', name: '客服' });
expect(result.items[0]!.patient.dedicatedCs).toEqual({ externalId: 'staff-id', name: '客服' });
for (const item of result.items) {
for (const field of [
'doNotContact',
......@@ -109,13 +109,18 @@ describe('patient facts lookup contract', () => {
expect(result.items[0]!.facts[0]).toMatchObject({
clinicName: '测试诊所',
id: expect.any(String),
content: { illnessDesc: '电话139****9000' },
content: { illnessDescription: '电话139****9000' },
});
expect(JSON.stringify(result)).not.toContain('staff_notes');
const sql = prisma.$queryRaw.mock.calls[0][0];
expect(sql.sql).not.toMatch(/LIMIT|ILIKE/);
expect(sql.sql).toContain('AND p.name =');
expect(sql.sql).toContain('AND p.phone =');
expect(sql.sql).toContain('t.id = ANY(f.transaction_ids)');
expect(sql.sql).toContain('t.host_id = f.host_id AND t.tenant_id = f.tenant_id');
expect(sql.sql).toContain('t.patient_id = f.patient_id');
expect(sql.sql).toContain('t.clinic_id IS NOT DISTINCT FROM f.clinic_id');
expect(sql.sql).toContain('count(*) = cardinality(f.transaction_ids)');
expect(sql.values).toContain('h');
});
test.each([
......@@ -126,10 +131,10 @@ describe('patient facts lookup contract', () => {
[{ dedicatedCs: 'invalid' }, null],
[{ dedicatedCs: [] }, null],
[{ dedicatedCs: { id: {}, name: [] } }, null],
[{ dedicatedCs: { id: '13900139000' } }, { id: '13900139000', name: null }],
[{ dedicatedCs: { id: '13900139000' } }, { externalId: '13900139000', name: null }],
[
{ dedicatedCs: { name: ' 客服13900139000 ', internal: 'hidden' } },
{ id: null, name: '客服139****9000' },
{ externalId: null, name: '客服139****9000' },
],
])('projects only dedicated customer service from %j', async (preferences, expected) => {
const prisma: any = {
......
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import * as yaml from 'js-yaml';
import { PrismaClient } from '@prisma/client';
import { Logger } from '@nestjs/common';
import { ColdImportManifestSchema } from '../src/modules/sync/cold-import/manifest.schema';
import { AssemblerConfigSchema } from '../src/modules/sync/assembler/assembler.schema';
import { AssemblerEngine } from '../src/modules/sync/assembler/assembler-engine';
import { TransformEngine } from '../src/modules/sync/transforms/transform-engine';
import { TransactionSynthesizer } from '../src/modules/sync/pipeline/transaction-synthesizer';
import { ParserPipeline } from '../src/modules/sync/pipeline/parser-pipeline.service';
import { FactWriter } from '../src/modules/sync/pipeline/fact-writer.service';
import { RefundParser } from '../src/modules/sync/pipeline/parsers/refund.parser';
const url = process.env.PAC_MEYER_TEST_DATABASE_URL;
const describePg = url ? describe : describe.skip;
describePg('来源创建时间 | 独立本地 PostgreSQL', () => {
let db: PrismaClient, hostId: string, patientId: string, pipeline: ParserPipeline;
const tenantId = 'source-time-ci', sourceUnit = 'brand-ci';
const manifest = ColdImportManifestSchema.parse(yaml.load(readFileSync(join(__dirname, '../data/jvs-dw/manifest.yaml'), 'utf8')));
const config = AssemblerConfigSchema.parse(yaml.load(readFileSync(join(__dirname, '../data/jvs-dw/assemblers/refund.yaml'), 'utf8')));
beforeAll(async () => {
const u = new URL(url!);
if (!['127.0.0.1', 'localhost'].includes(u.hostname) || !u.pathname.startsWith('/pac_meyer_ci_')) throw new Error('isolated local database required');
Logger.overrideLogger(false);
db = new PrismaClient({ datasources: { db: { url } } });
hostId = (await db.host.create({ data: { name: 'source-time-ci', appId: 'source-time-ci', appSecretHash: 'test' } })).id;
patientId = (await db.patient.create({ data: { hostId, tenantId, sourceUnit, externalId: 'patient-ci' } })).id;
pipeline = new ParserPipeline({ get: () => new RefundParser() } as never, new FactWriter(db as never),
db as never, { enrichAll: (drafts: unknown) => drafts } as never);
});
beforeEach(async () => { await db.patientFact.deleteMany({ where: { hostId } }); await db.patientTransaction.deleteMany({ where: { hostId } }); });
afterAll(async () => {
if (!db) return;
await db.patientFact.deleteMany({ where: { hostId } }); await db.patientTransaction.deleteMany({ where: { hostId } });
await db.patient.deleteMany({ where: { hostId } }); await db.host.delete({ where: { id: hostId } }); await db.$disconnect();
});
async function prepare(extra: Record<string, unknown> = {}) {
const raw = { id: 'refund-ci', patient_id: 'patient-ci', organization_id: 'clinic-ci', brand: sourceUnit,
created_date: '2026-10-21 08:15:00', updated_date: '2026-10-21 09:00:00', settlement_money: 100, ...extra };
const tables = new TransformEngine().run({ tables: { fact_settlement_out_refund: [raw] },
transforms: manifest.transforms!.filter(t => 'output' in t && t.output === 'refund_rows') });
const result = new AssemblerEngine().assemble({ tables, config, hostId,
normalize: { timezone: manifest.timezone, amountUnit: 'fen' } });
expect(result.errors).toEqual([]);
const row = result.rows[0]!, canonicalRow = row.canonical as Record<string, unknown>;
const data = new TransactionSynthesizer().synthesize({ rawRow: row.rawSource, canonicalRow,
emits: config.emits, resource: 'refund', hostId, tenantId, sourceUnit,
sourceContext: 'source-time-ci', patientIdResolver: () => patientId })!;
const tx = await db.patientTransaction.create({ data });
return { canonicalRow, transaction: { ...tx, action: 'refund_created' as const } };
}
test('事实表仅保留来源创建、更新时间与原有来源组织字段', async () => {
const columns = await db.$queryRaw<Array<{ column_name: string }>>`
SELECT column_name FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'patient_facts'
AND column_name LIKE 'source\\_%' ESCAPE '\\'
ORDER BY column_name`;
expect(columns.map(c => c.column_name)).toEqual(['source_created_at', 'source_unit', 'source_updated_at']);
});
test.each(['single', 'batch'] as const)('%s 从原始退费到事实写入 UTC 来源时间,业务内容不变', async mode => {
const item = await prepare();
const metrics = mode === 'single' ? await pipeline.runForTransaction(item) : await pipeline.runForBatch([item]);
expect(metrics.factsFailed).toBe(0);
const fact = await db.patientFact.findFirstOrThrow({ where: { hostId } });
expect(fact.sourceCreatedAt?.toISOString()).toBe('2026-10-21T00:15:00.000Z');
expect(fact.sourceUpdatedAt?.toISOString()).toBe('2026-10-21T01:00:00.000Z');
expect(fact.occurredAt?.toISOString()).toBe('2026-10-21T00:15:00.000Z');
expect(fact.content).toMatchObject({ amount_cents: 100 });
});
test('旧版本不补写;重复不造版本,正常业务更新的新版本才带来源创建时间', async () => {
const first = await prepare();
delete first.canonicalRow.sourceCreatedAt;
await pipeline.runForTransaction(first);
const before = await db.patientFact.findFirstOrThrow({ where: { hostId } });
expect(before.sourceCreatedAt).toBeNull();
first.canonicalRow.sourceCreatedAt = '2026-10-21T00:15:00.000Z';
await pipeline.runForBatch([first]);
expect(await db.patientFact.count({ where: { hostId } })).toBe(1);
expect((await db.patientFact.findUniqueOrThrow({ where: { id: before.id } })).sourceCreatedAt).toBeNull();
await pipeline.runForBatch([await prepare({ updated_date: '2026-10-21 10:00:00', settlement_money: 200 })]);
const facts = await db.patientFact.findMany({ where: { hostId }, orderBy: { version: 'asc' } });
expect(facts).toHaveLength(2); expect(facts[0]!.sourceCreatedAt).toBeNull();
expect(facts[1]!.sourceCreatedAt?.toISOString()).toBe('2026-10-21T00:15:00.000Z');
expect(facts[1]!.sourceUpdatedAt?.toISOString()).toBe('2026-10-21T02:00:00.000Z');
});
test('没有精确创建时间时不借用 PAC/更新时间', async () => {
const item = await prepare({ created_date: '2026-10-21' });
await pipeline.runForTransaction(item);
const fact = await db.patientFact.findFirstOrThrow({ where: { hostId } });
expect(fact.sourceCreatedAt).toBeNull();
});
});
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import * as yaml from 'js-yaml';
import { Logger } from '@nestjs/common';
import { AssemblerConfigSchema } from '../src/modules/sync/assembler/assembler.schema';
import { AssemblerEngine } from '../src/modules/sync/assembler/assembler-engine';
import { TransformEngine } from '../src/modules/sync/transforms/transform-engine';
import { ColdImportManifestSchema } from '../src/modules/sync/cold-import/manifest.schema';
import { TransactionSynthesizer } from '../src/modules/sync/pipeline/transaction-synthesizer';
import { RefundParser } from '../src/modules/sync/pipeline/parsers/refund.parser';
import { ParserPipeline } from '../src/modules/sync/pipeline/parser-pipeline.service';
import { readSourceCreatedTime } from '../src/modules/sync/source-created-time';
import { ImageAnalysisParser } from '../src/modules/sync/pipeline/parsers/image-analysis.parser';
import { DiagnosisParser } from '../src/modules/sync/pipeline/parsers/diagnosis.parser';
import { canonicalAnalysis, parserContext } from './image-analysis-fixtures';
const file = (p: string) => yaml.load(readFileSync(join(__dirname, '../data', p), 'utf8'));
const manifest = ColdImportManifestSchema.parse(file('jvs-dw/manifest.yaml'));
const config = AssemblerConfigSchema.parse(file('jvs-dw/assemblers/refund.yaml'));
const created = '2026-10-21T00:15:00.000Z';
const updated = '2026-10-21T01:00:00.000Z';
const raw = (overrides = {}) => ({ id: 'refund-1', patient_id: 'patient-1', organization_id: 'clinic-1',
brand: 'brand-1', created_date: '2026-10-21 08:15:00', updated_date: '2026-10-21 09:00:00',
settlement_money: 100, settlement_status: 4, is_refund: 1, ...overrides });
function assemble(input = raw()) {
const tables = new TransformEngine().run({ tables: { fact_settlement_out_refund: [input] },
transforms: manifest.transforms!.filter(t => 'output' in t && t.output === 'refund_rows') });
return new AssemblerEngine().assemble({ tables, config,
normalize: { timezone: manifest.timezone, amountUnit: 'fen' }, hostId: 'host' });
}
beforeAll(() => Logger.overrideLogger(false));
test('真实退费选列、映射、事件、解析链保留源更新时间和原文;更新能形成新事件', () => {
const input = raw(); const r = assemble(input); expect(r.errors).toEqual([]);
const c = r.rows[0]!.canonical as Record<string, unknown>;
expect(readSourceCreatedTime(c)).toEqual({ sourceCreatedAt: new Date(created) });
expect(new Date(c.updatedAt as string).toISOString()).toBe(updated);
expect(r.rows[0]!.rawSource).toEqual(input);
const synthesize = (row: typeof r.rows[number]) => new TransactionSynthesizer().synthesize({
rawRow: row.rawSource, canonicalRow: row.canonical as Record<string, unknown>, emits: config.emits,
resource: 'refund', hostId: 'host', tenantId: 'tenant', sourceUnit: 'brand-1',
sourceContext: 'test', patientIdResolver: () => 'patient',
})!;
const tx = synthesize(r.rows[0]!);
expect(tx.rawPayload).toEqual(input);
const later = assemble(raw({ updated_date: '2026-10-21 10:00:00' }));
expect(synthesize(later.rows[0]!).sourceEventId).not.toBe(tx.sourceEventId);
expect(synthesize(assemble(input).rows[0]!).sourceEventId).toBe(tx.sourceEventId);
});
test.each([null, '', 'null', '2026-10-21'])('未提供精确源创建时刻 %p 时留空', value => {
// refund 的业务时间必填;这里用与其相同字段映射的独立元数据字段验证可空语义。
const custom = { ...config, field_mapping: { ...config.field_mapping, metadataCreatedAt: 'metadata_created' },
source_created_at: { field: 'metadataCreatedAt' } };
const result = new AssemblerEngine().assemble({ tables: { refund_rows: [{ ...raw(), metadata_created: value }] },
config: custom, normalize: { timezone: manifest.timezone, amountUnit: 'fen' }, hostId: 'host' });
expect(result.errors).toEqual([]);
expect(readSourceCreatedTime(result.rows[0]!.canonical as Record<string, unknown>))
.toEqual({ sourceCreatedAt: null });
});
test.each([
{ sourceCreatedAt: '2026-02-30T00:00:00Z' },
{ sourceCreatedAt: '2026-10-21 08:15:00' },
{ sourceCreatedAt: '2026-10-21' },
{ sourceCreatedAt: new Date('bad') },
{ createdAt: created, occurredAt: created, updatedAt: updated },
])('未知/非法/没有显式来源声明不能被猜成创建时间 %#', row => {
expect(readSourceCreatedTime(row)).toEqual({ sourceCreatedAt: null });
});
test.each([
['friday', 'diagnosis'], ['jvs-dw', 'image'],
['friday', 'encounter'], ['jvs-dw', 'encounter'],
['friday', 'treatment_planned'], ['jvs-dw', 'treatment_planned'],
['friday', 'refund_item'],
])('%s/%s 保留经核实的来源创建字段映射', (host, name) => {
// 这里只核对配置声明;端到端入库行为另由 pipeline/writer 与 PostgreSQL 测试覆盖。
const rawConfig = file(`${host}/assemblers/${name}.yaml`) as Record<string, unknown>;
expect(rawConfig.source_created_at).toEqual({ field: 'createdAt' });
});
test.each([created, new Date(created)])('来源创建时刻可独立保存,无需附加类别 %p', value => {
expect(readSourceCreatedTime({ sourceCreatedAt: value })).toEqual({ sourceCreatedAt: new Date(created) });
});
test.each(['consult', 'image_finding', 'referral'])('5i5ya %s 的业务日期代理不声明为源创建时间', name => {
const c = file(`jvs-dw/assemblers/${name}.yaml`) as Record<string, unknown>;
expect(c.source_created_at).toBeUndefined();
});
test.each(['single', 'batch', 'fallback'] as const)('%s 管道把创建元数据和更新水位一起交给 writer', async mode => {
const canonicalRow = assemble().rows[0]!.canonical as Record<string, unknown>;
const writer = { writeDraft: jest.fn().mockResolvedValue({ action: 'created' }),
bulkWrite: jest.fn().mockResolvedValue({ results: [], rejected: [] }), writeSnapshots: jest.fn() };
if (mode === 'fallback') writer.bulkWrite.mockRejectedValueOnce(new Error('simulated batch failure'));
const pipeline = new ParserPipeline({ get: () => new RefundParser() } as never, writer as never,
{ patient: { findUnique: async () => ({ sourceUnit: 'brand-1' }),
findMany: async () => [{ id: 'patient', sourceUnit: 'brand-1' }] } } as never,
{ enrichAll: (drafts: unknown) => drafts } as never);
const input = { canonicalRow, transaction: { id: 'tx', hostId: 'host', tenantId: 'tenant', patientId: 'patient',
action: 'refund_created' as const, subjectType: 'refund', subjectId: 'refund-1',
occurredAt: new Date(created), clinicId: 'clinic-1' } };
if (mode === 'single') await pipeline.runForTransaction(input); else await pipeline.runForBatch([input]);
const entry = mode === 'batch' ? writer.bulkWrite.mock.calls[0]![0][0] : writer.writeDraft.mock.calls[0]![0];
expect(entry).toMatchObject({ sourceCreatedAt: new Date(created), sourceUpdatedAt: new Date(updated) });
});
test.each(['single', 'batch'] as const)('%s 分析快照及派生诊断均声明为父分析记录时间', async mode => {
const writer = { writeDraft: jest.fn(), bulkWrite: jest.fn(),
writeSnapshots: jest.fn().mockResolvedValue({ results: [], rejected: [] }) };
const pipeline = new ParserPipeline({ get: () => new ImageAnalysisParser(new DiagnosisParser()) } as never,
writer as never, { patient: { findUnique: async () => ({ sourceUnit: 'brand-a' }),
findMany: async () => [{ id: 'patient-demo', sourceUnit: 'brand-a' }] } } as never,
{ enrichAll: (drafts: unknown) => drafts } as never);
const input = parserContext(canonicalAnalysis());
const result = mode === 'single' ? await pipeline.runForTransaction(input) : await pipeline.runForBatch([input]);
expect(result.factsFailed).toBe(0);
expect(writer.writeSnapshots).toHaveBeenCalledTimes(1);
const entries = writer.writeSnapshots.mock.calls[0]![0][0].entries;
expect(entries).toHaveLength(2);
for (const entry of entries) expect(entry).toMatchObject({
sourceCreatedAt: new Date('2026-10-12T02:01:00Z'),
});
expect(writer.writeDraft).not.toHaveBeenCalled();
});
/**
* TransactionSynthesizer occurredAt 三级确定性回退单元测试。
*
* 坐实:emits.occurredAtField 缺失/不可解析时,回退 updatedAt → createdAt,
* 坐实:emits.occurredAtField 缺失时,回退 updatedAt → createdAt;非法非空时间拒收。
* 仅三者皆缺才落墙钟。否则同一源行(新 sourceEventId、时间字段为空)每次摄入
* 得到不同 occurredAt,下游 fact 时间锚变更检测会产生伪 supersede 版本。
*/
......@@ -48,14 +48,19 @@ describe('synthesizer | occurredAt 确定性回退', () => {
expect(tx!.occurredAt).toEqual(new Date('2026-02-01T00:00:00+08:00'));
});
test('occurredAtField 不可解析 → 回退 updatedAt', () => {
test('occurredAtField 非空但非法 → 拒收,不以其他时间掩盖错误', () => {
const tx = synth.synthesize(
make({
occurredAt: 'not-a-date',
updatedAt: '2026-02-01T00:00:00+08:00',
}),
);
expect(tx!.occurredAt).toEqual(new Date('2026-02-01T00:00:00+08:00'));
expect(tx).toBeNull();
});
test.each(['occurredAt', 'createdAt', 'updatedAt'])('%s 绕过入口传入裸时间 → 拒收', field => {
expect(synth.synthesize(make({ occurredAt: '2026-01-01T00:00:00Z',
[field]: '2026-10-21 12:30:00' }))).toBeNull();
});
test('occurredAtField/updatedAt 皆缺 → 回退 createdAt', () => {
......
......@@ -25,7 +25,9 @@ import { ImageEmbeddedAnalysisSchema } from './image-embedded-analysis';
// Datetime 工具 schema — ISO 8601 字符串(经 normalize 后必带 offset)
// ─────────────────────────────────────────────────────────
const isoDateTime = z.string().min(1).describe('ISO 8601 datetime with timezone offset');
export const CanonicalDateTimeSchema = z.iso.datetime({ offset: true })
.describe('ISO 8601 datetime with timezone offset');
const isoDateTime = CanonicalDateTimeSchema;
/// CSV 入口空字段是 `""`,统一接受空串等同于"未提供"
const optionalIsoDateTime = z.union([isoDateTime, z.literal(''), z.null()]).optional()
.transform((v) => (v === '' || v === null ? null : v));
......@@ -623,6 +625,7 @@ export const CanonicalResourceMeta: Record<
moneyArrayFields: readonly { array: string; field: string }[]; // 数组里嵌套 money 字段
datetimeFields: readonly string[];
datetimeArrayFields?: readonly { array: string; field: string }[];
datetimeObjectFields?: readonly { object: string; field: string }[];
booleanFields: readonly string[];
}
> = {
......@@ -726,6 +729,7 @@ export const CanonicalResourceMeta: Record<
moneyFields: [],
moneyArrayFields: [],
datetimeFields: ['uploadedAt'],
datetimeObjectFields: [{ object: 'analysis', field: 'analyzedAt' }],
booleanFields: [],
},
image_analysis: {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment