Commit bcdd0f4c by luoqi

fix(emr): display clinic dates in Shanghai timezone

parent 1211c4ab
import { join } from 'node:path'; import { join } from 'node:path';
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
import { readEmrNarrative } from '@pac/types';
import { EmrNormalizationReplay, type EmrEvidence } from '../src/cli/backfill-emr-normalization.lib'; import { EmrNormalizationReplay, type EmrEvidence } from '../src/cli/backfill-emr-normalization.lib';
beforeAll(() => Logger.overrideLogger(false)); beforeAll(() => Logger.overrideLogger(false));
...@@ -19,6 +20,36 @@ const evidence: EmrEvidence = { ...@@ -19,6 +20,36 @@ const evidence: EmrEvidence = {
function setup() { return new EmrNormalizationReplay(join(__dirname, '..', 'data', 'jvs-dw')); } function setup() { return new EmrNormalizationReplay(join(__dirname, '..', 'data', 'jvs-dw')); }
test.each([
['2023-11-14 06:01:00', '2023-11-13T22:01:00.000Z', '无不适', '复诊', 'OHI,更换结扎圈'],
['2023-12-17 09:36:00', '2023-12-17T01:36:00.000Z', '复查', '更换结扎圈', '前牙更换结扎圈,结扎丝加力'],
])('Friday 旧病历恢复原文且不改变正确的源时间 %s', (sourceTime, utc, examination, treatment, disposal) => {
const replay = new EmrNormalizationReplay(join(__dirname, '..', 'data', 'friday'));
const raw = {
tenant_id: 'brand-fixture', emr_sub_id: 'E1', patient_id: 'P1', organization_id: 'C1', status: 4,
clinic_time: sourceTime, created_gmt_at: sourceTime, updated_gmt_at: null,
examine: [{ toothPosition: '', value: examination }],
treat: [{ toothPosition: '', value: treatment, linkCode: '' }],
dispose: [{ toothPosition: '', value: disposal }], auxi_exam: '辅助检查原文',
};
const result = replay.replay({ ...evidence, tenantId: 'friday-market', rawPayload: raw }, {
sourceUnit: 'brand-fixture', patientExternalId: 'P1',
});
expect(result.occurredAt.toISOString()).toBe(utc);
const old = {
...result.content, exam_findings: JSON.stringify(raw.examine), disposal: JSON.stringify(raw.dispose),
treatment_notes: null, aux_exam_findings: null, custom_note: '保留',
};
const fixed = replay.patchContent(old, result.content, result.occurredAt, false);
for (const [key, message] of Object.entries({
exam_findings: examination, disposal, treatment_notes: treatment, aux_exam_findings: '辅助检查原文',
})) expect(readEmrNarrative(fixed[key]).map((row) => row.message)).toEqual([message]);
// treat 是本次治疗;不能凭列表页标签制造未来计划或复制处置。
expect(fixed.treatment_plan ?? null).toBeNull();
expect(fixed.custom_note).toBe('保留');
expect(replay.patchContent(fixed, result.content, result.occurredAt, false)).toEqual(fixed);
});
test('存量重放从源时间纠正 UTC,身份和其他内容保持,重复修复内容稳定', () => { test('存量重放从源时间纠正 UTC,身份和其他内容保持,重复修复内容稳定', () => {
const replay = setup(); const replay = setup();
const result = replay.replay(evidence, { sourceUnit: '瑞尔', patientExternalId: 'P1' }); const result = replay.replay(evidence, { sourceUnit: '瑞尔', patientExternalId: 'P1' });
......
import { describe, expect, it } from 'vitest';
import { formatEmrDate } from './emr-date';
describe('病历就诊日期', () => {
it.each([
['2023-11-13T22:01:00.000Z', '2023-11-14'],
['2023-12-17T01:36:00.000Z', '2023-12-17'],
['2023-12-31T16:00:00.000Z', '2024-01-01'],
['2023-11-14T06:01:00+08:00', '2023-11-14'],
['2025-03-03T02:34:29.000Z', '2025-03-03'],
['2023-11-14', '2023-11-14'],
[null, '—'], [undefined, '—'], ['', '—'], ['invalid', '—'],
])('%s → %s', (value, expected) => {
expect(formatEmrDate(value)).toBe(expected);
});
});
const clinicDate = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit',
});
/** 当前宿主均使用中国诊所日历日;不能截取入库 UTC 日期,也不能依赖浏览器时区。 */
export function formatEmrDate(value: string | null | undefined): string {
if (!value) return '—';
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) return value;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '—';
const parts = clinicDate.formatToParts(date);
return ['year', 'month', 'day'].map((key) => parts.find((part) => part.type === key)!.value).join('-');
}
...@@ -5,6 +5,7 @@ import { diagnosisCodeNameZh, treatmentCategoryNameZh, readEmrNarrative } from ' ...@@ -5,6 +5,7 @@ import { diagnosisCodeNameZh, treatmentCategoryNameZh, readEmrNarrative } from '
import type { EmrNarrativeRow } from '@pac/types'; import type { EmrNarrativeRow } from '@pac/types';
import { cn, formatToothPosition } from '@/lib/utils'; import { cn, formatToothPosition } from '@/lib/utils';
import type { AdaptedFact } from './adapt-data'; import type { AdaptedFact } from './adapt-data';
import { formatEmrDate } from './emr-date';
/** /**
* EmrSoapView — 病历快读(SOAP 结构化 + 多 EMR carousel 翻页) * EmrSoapView — 病历快读(SOAP 结构化 + 多 EMR carousel 翻页)
...@@ -153,7 +154,7 @@ function EmrSection({ ...@@ -153,7 +154,7 @@ function EmrSection({
// 所以匹配同次接诊用 emr_external_id,不能用 encounter_external_id(那个是挂号号) // 所以匹配同次接诊用 emr_external_id,不能用 encounter_external_id(那个是挂号号)
const emrId = String(c.emr_external_id ?? ''); const emrId = String(c.emr_external_id ?? '');
const doctor = (c.doctor_name as string | undefined) ?? '—'; const doctor = (c.doctor_name as string | undefined) ?? '—';
const date = emr.occurredAt?.slice(0, 10) ?? '—'; const date = formatEmrDate(emr.occurredAt);
// 关联同 encounter 的 diagnosis / treatment(planned) / image // 关联同 encounter 的 diagnosis / treatment(planned) / image
const sameEncounter = (f: AdaptedFact) => const sameEncounter = (f: AdaptedFact) =>
......
# Friday 病历快读字段及日期核对
分支:`codex/friday-emr-display`,基于 main `1211c4abdf16aca3df245f711a1ae5b246aee403`。
## 已核实
- 测试库截图对应的两份病历,源就诊时间分别为 `2023-12-17 09:36:00`、`2023-11-14 06:01:00`;入库 UTC 分别为 `2023-12-17T01:36:00Z`、`2023-11-13T22:01:00Z`,转换正确。
- 病历快读使用 `occurredAt.slice(0,10)` 展示 UTC 日期,导致凌晨就诊日期少一天。本次改为明确使用 Asia/Shanghai 日历日,保持原始时间戳不变。
- 存量事实的检查和处置为含 `value` 的旧 JSON 数组,标准叙述读取器使用 `message`,因此未展示。原始交易证据仍含完整 examine/dispose/treat。
- main 已有 Friday 元素级规范化及 treatment_notes 保留逻辑;这些旧事实尚未执行存量修复。现有 CLI 针对该患者只读预演结果:scanned=12、changed=12、written=0、conflicted=0。
- FridaySaaS AllMedicalRecords 列表将 treat 显示为“治疗计划”,但打印页 PrintContent、Java MedEmrInfoMvo/MedEmrInfoSaveParams 等均标为“本次治疗”。不把 dispose 重复写成本次治疗,也不将 treat 擅自改成未来治疗计划。
- auxi_exam 是单独的辅助检查原文。现有 PAC 已在客观检查区域保留独立“辅助检查”标签;截图两条源值均为空,不应人为填充。
## 本次变更与验证
- 修复病历快读日期显示,覆盖 UTC 跨日、跨年、显式时区、纯日期及缺失/无效值。
- 增加脱敏 Friday 存量回放回归:检查、处置、本次治疗、辅助检查完整恢复;正确 UTC 时间不变;保留无关字段;重复修复内容不变。
- Service 三组相关测试通过(32 项),包含 5i5ya 与源隔离回归。
- Web 日期测试通过(10 项),Web 类型检查通过。
- Service 类型检查通过;首次检查遇到本地 Prisma 生成客户端落后于当前 schema,重新生成客户端后通过,未执行数据库迁移。
## 边界
本次未提交、未部署,未修改生产;测试存量数据目前仅 dry-run。代码部署不会自动修复既有事实,后续应使用既有 backfill-emr-normalization CLI 按患者定向执行,保存私密审计文件,再次 dry-run 应 changed=0。不得使用全库重放替代这次定向修复。
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