diff --git a/modules/pacs/pacs-config.schema.ts b/modules/pacs/pacs-config.schema.ts index 4a2d5a33f2..2812fc2f58 100644 --- a/modules/pacs/pacs-config.schema.ts +++ b/modules/pacs/pacs-config.schema.ts @@ -16,6 +16,7 @@ export interface IPacsConfig { aet: string; host: string; visualizador_host: string; + reconcileMatchingModalities?: string[]; auth: { host: string; clientId: string; @@ -23,6 +24,7 @@ export interface IPacsConfig { }; featureFlags: { usoIdDNI: boolean; + reconciliarEstudios?: boolean; }; } @@ -36,6 +38,7 @@ export const PacsConfigSchema = new Schema({ aet: String, host: String, visualizador_host: String, + reconcileMatchingModalities: [String], auth: { host: String, clientId: String, @@ -45,6 +48,10 @@ export const PacsConfigSchema = new Schema({ usoIdDNI: { type: Boolean, default: false + }, + reconciliarEstudios: { + type: Boolean, + default: false } } }); diff --git a/modules/pacs/pacs-network.ts b/modules/pacs/pacs-network.ts index e63b65e582..daeffc1fb9 100644 --- a/modules/pacs/pacs-network.ts +++ b/modules/pacs/pacs-network.ts @@ -1,6 +1,7 @@ import { services } from '../../services'; import { handleHttpRequest } from '../../utils/requestHandler'; import { IPacsConfig } from './pacs-config.schema'; +import { hasDicomResults } from './pacs-reconciliation'; export async function loginPacs(pacsConfig: IPacsConfig) { @@ -38,6 +39,32 @@ export async function createWorkList(pacsConfig: IPacsConfig, data: any, token: return response; } +export async function studyExists(pacsConfig: IPacsConfig, studyUID: string, token: string): Promise { + const response = await services.get('dcm4chee-buscar-series-estudio').exec({ + host: pacsConfig.host, + aet: pacsConfig.aet, + token, + studyUID, + limit: 1 + }); + return hasDicomResults(response); +} + +export async function searchStudies( + pacsConfig: IPacsConfig, + patientID: string, + studyDate: string, + token: string +): Promise { + return services.get('dcm4chee-buscar-estudios').exec({ + host: pacsConfig.host, + aet: pacsConfig.aet, + token, + patientID, + studyDate + }); +} + export async function enviarInforme(pacsConfig: IPacsConfig, uid: string, data: any, pdf: any, token: string) { const fs = require('fs'); diff --git a/modules/pacs/pacs-reconciliation.test.ts b/modules/pacs/pacs-reconciliation.test.ts new file mode 100644 index 0000000000..efca42db75 --- /dev/null +++ b/modules/pacs/pacs-reconciliation.test.ts @@ -0,0 +1,175 @@ +import { services } from '../../services'; +import { IPacsConfig } from './pacs-config.schema'; +import { searchStudies, studyExists } from './pacs-network'; +import { + configuredMatchingModalities, + hasDicomResults, + isReconciliationResolved, + matchingStudies, + reconciledMetadata, + reconciliationFailureMetadata, + resolvedReconciliationMetadata +} from './pacs-reconciliation'; + +jest.mock('../../services', () => ({ + services: { + get: jest.fn() + } +})); + +const config = { + host: 'https://pacs.example.org', + aet: 'PACS', +} as IPacsConfig; + +const dicomStudy = (uid: string, modalities: string[]) => ({ + '0020000D': { vr: 'UI', Value: [uid] }, + '00080061': { vr: 'CS', Value: modalities } +}); + +describe('PACS reconciliation', () => { + const getServiceMock = services.get as jest.Mock; + const execMock = jest.fn(); + + beforeEach(() => { + execMock.mockReset(); + getServiceMock.mockReset(); + getServiceMock.mockReturnValue({ exec: execMock }); + }); + + test('defaults matching to the configured PACS modality', () => { + expect(configuredMatchingModalities(undefined, 'CT')).toEqual(['CT']); + expect(configuredMatchingModalities([], 'MR')).toEqual(['MR']); + expect(configuredMatchingModalities(['CR', 'DX'], 'CR')).toEqual(['CR', 'DX']); + }); + + test('checks study existence with a bounded series lookup', async () => { + execMock.mockResolvedValueOnce([]); + await expect(studyExists(config, '1.2.3', 'token')).resolves.toBe(false); + + execMock.mockResolvedValueOnce([{ '0020000E': { Value: ['4.5.6'] } }]); + await expect(studyExists(config, '1.2.3', 'token')).resolves.toBe(true); + + expect(getServiceMock).toHaveBeenLastCalledWith('dcm4chee-buscar-series-estudio'); + expect(execMock).toHaveBeenLastCalledWith({ + host: 'https://pacs.example.org', + aet: 'PACS', + token: 'token', + studyUID: '1.2.3', + limit: 1 + }); + }); + + test('searches potential matches only by patient and date', async () => { + execMock.mockResolvedValueOnce([]); + + await expect(searchStudies(config, 'patient-1', '20260814', 'token')).resolves.toEqual([]); + expect(getServiceMock).toHaveBeenCalledWith('dcm4chee-buscar-estudios'); + expect(execMock).toHaveBeenCalledWith({ + host: 'https://pacs.example.org', + aet: 'PACS', + token: 'token', + patientID: 'patient-1', + studyDate: '20260814' + }); + }); + + test('requires actual DICOM results even on a successful response', () => { + expect(hasDicomResults('[]')).toBe(false); + expect(hasDicomResults(JSON.stringify([dicomStudy('1', ['CR'])]))).toBe(true); + }); + + test('matches equivalent modalities and treats SR as supplemental', () => { + const response = [ + dicomStudy('cr', ['CR']), + dicomStudy('dx', ['DX']), + dicomStudy('ct-sr', ['CT', 'SR']), + dicomStudy('ct-us', ['CT', 'US']), + dicomStudy('sr', ['SR']) + ]; + + expect(matchingStudies(response, ['CR', 'DX']).map(study => study.uid)).toEqual(['cr', 'dx']); + expect(matchingStudies(response, ['CT']).map(study => study.uid)).toEqual(['ct-sr']); + expect(matchingStudies(response, ['MR'])).toEqual([]); + }); + + test('classifies zero, single and multiple matches by candidate count', () => { + expect(matchingStudies([], ['CT'])).toHaveLength(0); + expect(matchingStudies([dicomStudy('one', ['CT'])], ['CT'])).toHaveLength(1); + expect(matchingStudies([ + dicomStudy('one', ['CT']), + dicomStudy('two', ['CT']) + ], ['CT'])).toHaveLength(2); + }); + + test('stores the original MWL UID on first reconciliation', () => { + const metadata = [ + { key: 'pacs-uid', valor: 'mwl' }, + { key: 'pacs-reconciliation', valor: { status: 'zero-match' } } + ]; + const next = reconciledMetadata(metadata, 'mwl', 'acquired'); + + expect(next.find(item => item.key === 'pacs-uid')?.valor).toBe('acquired'); + expect(next.find(item => item.key === 'pacs-canonical-uid')?.valor).toBe('mwl'); + expect(next.some(item => item.key === 'pacs-reconciliation')).toBe(false); + }); + + test('preserves the MWL UID through later reconciliations', () => { + const metadata = [ + { key: 'pacs-uid', valor: 'acquired-1' }, + { key: 'pacs-canonical-uid', valor: 'mwl' } + ]; + const next = reconciledMetadata(metadata, 'acquired-1', 'acquired-2'); + + expect(next.find(item => item.key === 'pacs-uid')?.valor).toBe('acquired-2'); + expect(next.find(item => item.key === 'pacs-canonical-uid')?.valor).toBe('mwl'); + }); + + test('cleans the canonical UID marker after the real PACS merge', () => { + const metadata = [ + { key: 'pacs-uid', valor: 'acquired' }, + { key: 'pacs-canonical-uid', valor: 'mwl' } + ]; + const next = reconciledMetadata(metadata, 'acquired', 'mwl'); + + expect(next.find(item => item.key === 'pacs-uid')?.valor).toBe('mwl'); + expect(next.some(item => item.key === 'pacs-canonical-uid')).toBe(false); + }); + + test('replaces the reconciliation failure marker', () => { + const checkedAt = new Date(); + const next = reconciliationFailureMetadata( + [ + { key: 'pacs-uid', valor: 'mwl' }, + { key: 'pacs-reconciliation', valor: { status: 'zero-match' } } + ], + { + status: 'multiple-match', + checkedAt, + candidateUIDs: ['one', 'two'] + } + ); + + const statuses = next.filter(item => item.key === 'pacs-reconciliation'); + expect(statuses).toHaveLength(1); + expect(statuses[0].valor).toEqual({ + status: 'multiple-match', + checkedAt, + candidateUIDs: ['one', 'two'] + }); + }); + + test('marks resolved studies so future accesses can skip PACS lookups', () => { + const metadata = [{ key: 'pacs-uid', valor: 'study' }]; + const existing = resolvedReconciliationMetadata(metadata, 'uid-exists'); + const reconciled = resolvedReconciliationMetadata(metadata, 'reconciled'); + const unresolved = reconciliationFailureMetadata(metadata, { + status: 'zero-match', + checkedAt: new Date() + }); + + expect(isReconciliationResolved(existing)).toBe(true); + expect(isReconciliationResolved(reconciled)).toBe(true); + expect(isReconciliationResolved(unresolved)).toBe(false); + }); +}); diff --git a/modules/pacs/pacs-reconciliation.ts b/modules/pacs/pacs-reconciliation.ts new file mode 100644 index 0000000000..0ea53edc09 --- /dev/null +++ b/modules/pacs/pacs-reconciliation.ts @@ -0,0 +1,117 @@ +export interface IPacsStudyCandidate { + uid: string; + modalities: string[]; +} + +export interface IPacsReconciliationStatus { + status: 'uid-exists' | 'reconciled' | 'zero-match' | 'multiple-match'; + checkedAt: Date; + candidateUIDs?: string[]; +} + +type Metadata = { key: string; valor: any }[]; + +const SUPPLEMENTAL_MODALITIES = ['SR']; + +export function configuredMatchingModalities( + configuredModalities: string[] | undefined, + defaultModality: string +): string[] { + const modalities = (configuredModalities || []) + .map(modality => modality.trim()) + .filter(Boolean); + return modalities.length ? modalities : [defaultModality]; +} + +export function parseDicomJson(body: any): any[] { + const parsed = typeof body === 'string' ? JSON.parse(body) : body; + if (!Array.isArray(parsed)) { + throw new Error('Invalid DICOM JSON response'); + } + return parsed; +} + +export function hasDicomResults(body: any): boolean { + return parseDicomJson(body).length > 0; +} + +export function matchingStudies(body: any, allowedModalities: string[]): IPacsStudyCandidate[] { + const allowed = allowedModalities.map(modality => modality.toUpperCase()); + const studies = parseDicomJson(body); + const candidates = new Map(); + + studies.forEach((study) => { + const uid = dicomValues(study, '0020000D')[0]; + const modalities = dicomValues(study, '00080061') + .reduce((all, value) => all.concat(value.split('\\')), []) + .map(modality => modality.toUpperCase()); + const primaryModalities = modalities.filter(modality => !SUPPLEMENTAL_MODALITIES.includes(modality)); + + if ( + uid && + primaryModalities.length > 0 && + primaryModalities.every(modality => allowed.includes(modality)) + ) { + candidates.set(uid, { uid, modalities }); + } + }); + + return Array.from(candidates.values()); +} + +export function reconciledMetadata(metadata: Metadata, currentUID: string, matchedUID: string): Metadata { + const canonicalUID = metadata.find(item => item.key === 'pacs-canonical-uid')?.valor; + let next = removeMetadata(metadata, 'pacs-reconciliation'); + + next = setMetadata(next, 'pacs-uid', matchedUID); + if (canonicalUID && canonicalUID === matchedUID) { + return removeMetadata(next, 'pacs-canonical-uid'); + } + if (!canonicalUID && currentUID !== matchedUID) { + next = setMetadata(next, 'pacs-canonical-uid', currentUID); + } + return next; +} + +export function reconciliationFailureMetadata( + metadata: Metadata, + status: IPacsReconciliationStatus +): Metadata { + return setMetadata(metadata, 'pacs-reconciliation', status); +} + +export function resolvedReconciliationMetadata( + metadata: Metadata, + status: 'uid-exists' | 'reconciled' +): Metadata { + return setMetadata(metadata, 'pacs-reconciliation', { + status, + checkedAt: new Date() + }); +} + +export function isReconciliationResolved(metadata: Metadata): boolean { + const status = metadata.find(item => item.key === 'pacs-reconciliation')?.valor?.status; + return status === 'uid-exists' || status === 'reconciled'; +} + +function dicomValues(study: any, tag: string): string[] { + const values = study?.[tag]?.Value; + if (!Array.isArray(values)) { + return []; + } + return values.filter(value => typeof value === 'string'); +} + +function removeMetadata(metadata: Metadata, key: string): Metadata { + return metadata + .filter(item => item.key !== key) + .map(item => ({ key: item.key, valor: item.valor })); +} + +function setMetadata(metadata: Metadata, key: string, valor: any): Metadata { + return [ + ...removeMetadata(metadata, key), + { key, valor } + ]; +} diff --git a/modules/pacs/pacs.ts b/modules/pacs/pacs.ts index 11b9afbde4..7237a19230 100644 --- a/modules/pacs/pacs.ts +++ b/modules/pacs/pacs.ts @@ -5,14 +5,30 @@ import { Prestacion } from '../rup/schemas/prestacion'; import { DICOMInformePDFObject } from './dicom/informe-encode'; import { DICOMPacienteObject } from './dicom/paciente-encode'; import { DICOMPrestacionObject } from './dicom/prestacion-encode'; -import { DICOMPaciente, DICOMPrestacion, DICOMInforme } from './dicom/dicom.helpers'; +import { DICOMPaciente, DICOMPrestacion, DICOMInforme, formatDicomDate } from './dicom/dicom.helpers'; export { DICOMPaciente, DICOMPrestacion, DICOMInforme } from './dicom/dicom.helpers'; import { PacsConfigController } from './pacs-config.controller'; -import { createPaciente, createWorkList, enviarInforme, loginPacs, anularPacs } from './pacs-network'; +import { + createPaciente, + createWorkList, + enviarInforme, + loginPacs, + anularPacs, + searchStudies, + studyExists +} from './pacs-network'; import { userScheduler } from '../../config.private'; import { IPacsConfig } from './pacs-config.schema'; import { pacsLogs } from './pacs.logs'; +import { + configuredMatchingModalities, + isReconciliationResolved, + matchingStudies, + reconciledMetadata, + reconciliationFailureMetadata, + resolvedReconciliationMetadata +} from './pacs-reconciliation'; export async function syncWorkList(prestacion: IPrestacion) { try { @@ -82,20 +98,98 @@ export async function syncWorkList(prestacion: IPrestacion) { export async function getVisualizadorURL(prestacion: IPrestacion) { try { - const { valor: uid } = prestacion.metadata.find(item => item.key === 'pacs-uid'); - const { valor: configId } = prestacion.metadata.find(item => item.key === 'pacs-config'); + const metadata = prestacion.metadata; + const { valor: uid } = metadata.find(item => item.key === 'pacs-uid'); + const { valor: configId } = metadata.find(item => item.key === 'pacs-config'); const config = await PacsConfigController.findById(configId); - if (config) { - const token = await loginPacs(config); - const url = `${config.visualizador_host}/viewer/${uid}/?token=${token}`; - return url; + if (!config) { + return null; } + + const token = await loginPacs(config); + if (!config.featureFlags?.reconciliarEstudios || isReconciliationResolved(metadata)) { + return getViewerURL(config, uid, token); + } + + if (await studyExists(config, uid, token)) { + pacsLogs.info('getVisualizadorURL.uid-exists', { prestacion: prestacion.id, uid }, userScheduler); + try { + await updatePacsMetadata( + prestacion, + uid, + resolvedReconciliationMetadata(metadata, 'uid-exists') + ); + } catch (err) { + pacsLogs.error('getVisualizadorURL.save-status', { prestacion: prestacion.id, uid }, err, userScheduler); + } + return getViewerURL(config, uid, token); + } + + const patientID = metadata.find(item => item.key === 'pacs-pacienteIdDicom')?.valor; + const studyDate = formatDicomDate(prestacion.ejecucion.fecha); + if (!patientID || !studyDate) { + throw new Error('Missing patient ID or study date for PACS reconciliation'); + } + + const matchingModalities = configuredMatchingModalities( + config.reconcileMatchingModalities, + config.modalidad + ); + const response = await searchStudies(config, String(patientID), studyDate, token); + const candidates = matchingStudies(response, matchingModalities); + + if (candidates.length === 1) { + const matchedUID = candidates[0].uid; + const reconciled = reconciledMetadata(metadata, uid, matchedUID); + const nextMetadata = resolvedReconciliationMetadata(reconciled, 'reconciled'); + await updatePacsMetadata(prestacion, uid, nextMetadata); + pacsLogs.info( + 'getVisualizadorURL.single-match', + { prestacion: prestacion.id, uid, matchedUID }, + userScheduler + ); + return getViewerURL(config, matchedUID, token); + } + + const status = candidates.length === 0 ? 'zero-match' : 'multiple-match'; + const failureMetadata = reconciliationFailureMetadata(metadata, { + status, + checkedAt: new Date(), + candidateUIDs: candidates.length ? candidates.map(candidate => candidate.uid) : undefined + }); + await updatePacsMetadata(prestacion, uid, failureMetadata); + pacsLogs.info( + `getVisualizadorURL.${status}`, + { prestacion: prestacion.id, uid, candidateUIDs: candidates.map(candidate => candidate.uid) }, + userScheduler + ); return null; } catch (err) { + pacsLogs.error('getVisualizadorURL', { prestacion: prestacion.id }, err, userScheduler); return null; } } +function getViewerURL(config: IPacsConfig, uid: string, token: string): string { + return `${config.visualizador_host}/viewer/${uid}/?token=${token}`; +} + +async function updatePacsMetadata(prestacion: IPrestacion, currentUID: string, metadata: any[]): Promise { + const id = (prestacion as any)._id || prestacion.id; + await Prestacion.updateOne( + { + _id: id, + metadata: { + $elemMatch: { + key: 'pacs-uid', + valor: currentUID + } + } + }, + { $set: { metadata } } + ); +} + export async function updateWork(metadata: any, estado: string) { try { const { valor: uid } = metadata.find(item => item.key === 'pacs-uid');