From f98868a1a14fd4c79cf946821d784790a1f3df25 Mon Sep 17 00:00:00 2001 From: Pela2silveira Date: Fri, 14 Aug 2026 11:48:01 -0300 Subject: [PATCH 1/6] PACS-7: reconciliar estudios al abrir el visor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Valida el UID actual y corrige la metadata solo cuando un Ășnico estudio coincide con el criterio configurado. Co-authored-by: Cursor --- modules/pacs/pacs-config.schema.ts | 7 ++ modules/pacs/pacs-network.ts | 48 +++++++ modules/pacs/pacs-reconciliation.test.ts | 151 +++++++++++++++++++++++ modules/pacs/pacs-reconciliation.ts | 104 ++++++++++++++++ modules/pacs/pacs.ts | 106 +++++++++++++++- 5 files changed, 410 insertions(+), 6 deletions(-) create mode 100644 modules/pacs/pacs-reconciliation.test.ts create mode 100644 modules/pacs/pacs-reconciliation.ts 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..140b7d9cb8 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,53 @@ export async function createWorkList(pacsConfig: IPacsConfig, data: any, token: return response; } +export async function studyExists(pacsConfig: IPacsConfig, studyUID: string, token: string): Promise { + const url = `${pacsConfig.host}/dcm4chee-arc/aets/${pacsConfig.aet}/rs/studies/${encodeURIComponent(studyUID)}/series`; + const [status, body] = await handleHttpRequest({ + method: 'GET', + url, + qs: { limit: 1 }, + headers: { + Authorization: 'Bearer ' + token, + Accept: 'application/dicom+json' + } + }); + + if (status === 404) { + return false; + } + if (status >= 200 && status < 300) { + return hasDicomResults(body); + } + throw new Error(`PACS study lookup failed with status ${status}`); +} + +export async function searchStudies( + pacsConfig: IPacsConfig, + patientID: string, + studyDate: string, + token: string +): Promise { + const url = `${pacsConfig.host}/dcm4chee-arc/aets/${pacsConfig.aet}/rs/studies`; + const [status, body] = await handleHttpRequest({ + method: 'GET', + url, + qs: { + '00100020': patientID, + '00080020': studyDate + }, + headers: { + Authorization: 'Bearer ' + token, + Accept: 'application/dicom+json' + } + }); + + if (status >= 200 && status < 300) { + return body; + } + throw new Error(`PACS study search failed with status ${status}`); +} + 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..00c779053b --- /dev/null +++ b/modules/pacs/pacs-reconciliation.test.ts @@ -0,0 +1,151 @@ +import { handleHttpRequest } from '../../utils/requestHandler'; +import { IPacsConfig } from './pacs-config.schema'; +import { searchStudies, studyExists } from './pacs-network'; +import { + configuredMatchingModalities, + hasDicomResults, + matchingStudies, + reconciledMetadata, + reconciliationFailureMetadata +} from './pacs-reconciliation'; + +jest.mock('../../utils/requestHandler', () => ({ + handleHttpRequest: jest.fn() +})); + +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 requestMock = handleHttpRequest as jest.Mock; + + beforeEach(() => requestMock.mockReset()); + + test('enables reconciliation by flag and defaults to the configured modality', () => { + expect(configuredMatchingModalities(false, ['CR', 'DX'], 'CR')).toEqual([]); + expect(configuredMatchingModalities(true, undefined, 'CT')).toEqual(['CT']); + expect(configuredMatchingModalities(true, [], 'MR')).toEqual(['MR']); + expect(configuredMatchingModalities(true, ['CR', 'DX'], 'CR')).toEqual(['CR', 'DX']); + }); + + test('checks study existence with a bounded series lookup', async () => { + requestMock.mockResolvedValueOnce([200, '[]']); + await expect(studyExists(config, '1.2.3', 'token')).resolves.toBe(false); + + requestMock.mockResolvedValueOnce([200, JSON.stringify([{ '0020000E': { Value: ['4.5.6'] } }])]); + await expect(studyExists(config, '1.2.3', 'token')).resolves.toBe(true); + + expect(requestMock).toHaveBeenLastCalledWith(expect.objectContaining({ + method: 'GET', + url: 'https://pacs.example.org/dcm4chee-arc/aets/PACS/rs/studies/1.2.3/series', + qs: { limit: 1 } + })); + }); + + test('searches potential matches only by patient and date', async () => { + requestMock.mockResolvedValueOnce([200, '[]']); + + await expect(searchStudies(config, 'patient-1', '20260814', 'token')).resolves.toBe('[]'); + expect(requestMock).toHaveBeenCalledWith(expect.objectContaining({ + qs: { + '00100020': 'patient-1', + '00080020': '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('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 === 'old-pacs-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: 'old-pacs-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 === 'old-pacs-uid')?.valor).toBe('mwl'); + }); + + test('cleans the old UID after the real PACS merge', () => { + const metadata = [ + { key: 'pacs-uid', valor: 'acquired' }, + { key: 'old-pacs-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 === 'old-pacs-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'] + }); + }); +}); diff --git a/modules/pacs/pacs-reconciliation.ts b/modules/pacs/pacs-reconciliation.ts new file mode 100644 index 0000000000..95b5fbe58f --- /dev/null +++ b/modules/pacs/pacs-reconciliation.ts @@ -0,0 +1,104 @@ +export interface IPacsStudyCandidate { + uid: string; + modalities: string[]; +} + +export interface IPacsReconciliationStatus { + status: 'zero-match' | 'multiple-match'; + checkedAt: Date; + candidateUIDs?: string[]; +} + +type Metadata = { key: string; valor: any }[]; + +export function configuredMatchingModalities( + enabled: boolean, + configuredModalities: string[] | undefined, + defaultModality: string +): string[] { + if (!enabled) { + return []; + } + + 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()); + + if (uid && modalities.some(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 originalUID = metadata.find(item => item.key === 'old-pacs-uid')?.valor; + let next = removeMetadata(metadata, 'pacs-reconciliation'); + + next = setMetadata(next, 'pacs-uid', matchedUID); + if (originalUID && originalUID === matchedUID) { + return removeMetadata(next, 'old-pacs-uid'); + } + if (!originalUID && currentUID !== matchedUID) { + next = setMetadata(next, 'old-pacs-uid', currentUID); + } + return next; +} + +export function reconciliationFailureMetadata( + metadata: Metadata, + status: IPacsReconciliationStatus +): Metadata { + return setMetadata(metadata, 'pacs-reconciliation', status); +} + +export function clearReconciliationStatus(metadata: Metadata): Metadata { + return removeMetadata(metadata, 'pacs-reconciliation'); +} + +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..e0399fb75e 100644 --- a/modules/pacs/pacs.ts +++ b/modules/pacs/pacs.ts @@ -5,14 +5,29 @@ 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 { + clearReconciliationStatus, + configuredMatchingModalities, + matchingStudies, + reconciledMetadata, + reconciliationFailureMetadata +} from './pacs-reconciliation'; export async function syncWorkList(prestacion: IPrestacion) { try { @@ -82,20 +97,99 @@ 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 uid = metadata.find(item => item.key === 'pacs-uid')?.valor; + const configId = metadata.find(item => item.key === 'pacs-config')?.valor; + if (!uid || !configId) { + return null; + } + const config = await PacsConfigController.findById(configId); if (config) { const token = await loginPacs(config); - const url = `${config.visualizador_host}/viewer/${uid}/?token=${token}`; - return url; + const matchingModalities = configuredMatchingModalities( + config.featureFlags?.reconciliarEstudios, + config.reconcileMatchingModalities, + config.modalidad + ); + + if (!matchingModalities.length) { + return getViewerURL(config, uid, token); + } + + if (await studyExists(config, uid, token)) { + pacsLogs.info('getVisualizadorURL.uid-exists', { prestacion: prestacion.id, uid }, userScheduler); + if (metadata.some(item => item.key === 'pacs-reconciliation')) { + try { + await updatePacsMetadata(prestacion, uid, clearReconciliationStatus(metadata)); + } catch (err) { + pacsLogs.error('getVisualizadorURL.clear-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 response = await searchStudies(config, String(patientID), studyDate, token); + const candidates = matchingStudies(response, matchingModalities); + + if (candidates.length === 1) { + const matchedUID = candidates[0].uid; + const nextMetadata = reconciledMetadata(metadata, uid, matchedUID); + 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'); From 07251280d30cf4d6493e5745b56471f80fec9890 Mon Sep 17 00:00:00 2001 From: Pela2silveira Date: Fri, 14 Aug 2026 11:53:57 -0300 Subject: [PATCH 2/6] PACS-7: distinguir modalidades suplementarias Co-authored-by: Cursor --- modules/pacs/pacs-reconciliation.test.ts | 1 + modules/pacs/pacs-reconciliation.ts | 9 ++++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/modules/pacs/pacs-reconciliation.test.ts b/modules/pacs/pacs-reconciliation.test.ts index 00c779053b..0a0f8161bd 100644 --- a/modules/pacs/pacs-reconciliation.test.ts +++ b/modules/pacs/pacs-reconciliation.test.ts @@ -75,6 +75,7 @@ describe('PACS reconciliation', () => { dicomStudy('cr', ['CR']), dicomStudy('dx', ['DX']), dicomStudy('ct-sr', ['CT', 'SR']), + dicomStudy('ct-us', ['CT', 'US']), dicomStudy('sr', ['SR']) ]; diff --git a/modules/pacs/pacs-reconciliation.ts b/modules/pacs/pacs-reconciliation.ts index 95b5fbe58f..eb72caf941 100644 --- a/modules/pacs/pacs-reconciliation.ts +++ b/modules/pacs/pacs-reconciliation.ts @@ -11,6 +11,8 @@ export interface IPacsReconciliationStatus { type Metadata = { key: string; valor: any }[]; +const SUPPLEMENTAL_MODALITIES = ['SR']; + export function configuredMatchingModalities( enabled: boolean, configuredModalities: string[] | undefined, @@ -48,8 +50,13 @@ export function matchingStudies(body: any, allowedModalities: string[]): IPacsSt 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 && modalities.some(modality => allowed.includes(modality))) { + if ( + uid && + primaryModalities.length > 0 && + primaryModalities.every(modality => allowed.includes(modality)) + ) { candidates.set(uid, { uid, modalities }); } }); From 218da8d0487414e1c5ff91c41953bb03a7c2be12 Mon Sep 17 00:00:00 2001 From: Pela2silveira Date: Fri, 14 Aug 2026 13:15:04 -0300 Subject: [PATCH 3/6] =?UTF-8?q?PACS-7:=20identificar=20el=20UID=20can?= =?UTF-8?q?=C3=B3nico?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- modules/pacs/pacs-reconciliation.test.ts | 12 ++++++------ modules/pacs/pacs-reconciliation.ts | 10 +++++----- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/modules/pacs/pacs-reconciliation.test.ts b/modules/pacs/pacs-reconciliation.test.ts index 0a0f8161bd..aa64550d38 100644 --- a/modules/pacs/pacs-reconciliation.test.ts +++ b/modules/pacs/pacs-reconciliation.test.ts @@ -101,30 +101,30 @@ describe('PACS reconciliation', () => { const next = reconciledMetadata(metadata, 'mwl', 'acquired'); expect(next.find(item => item.key === 'pacs-uid')?.valor).toBe('acquired'); - expect(next.find(item => item.key === 'old-pacs-uid')?.valor).toBe('mwl'); + 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: 'old-pacs-uid', valor: 'mwl' } + { 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 === 'old-pacs-uid')?.valor).toBe('mwl'); + expect(next.find(item => item.key === 'pacs-canonical-uid')?.valor).toBe('mwl'); }); - test('cleans the old UID after the real PACS merge', () => { + test('cleans the canonical UID marker after the real PACS merge', () => { const metadata = [ { key: 'pacs-uid', valor: 'acquired' }, - { key: 'old-pacs-uid', valor: 'mwl' } + { 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 === 'old-pacs-uid')).toBe(false); + expect(next.some(item => item.key === 'pacs-canonical-uid')).toBe(false); }); test('replaces the reconciliation failure marker', () => { diff --git a/modules/pacs/pacs-reconciliation.ts b/modules/pacs/pacs-reconciliation.ts index eb72caf941..352c93ea9c 100644 --- a/modules/pacs/pacs-reconciliation.ts +++ b/modules/pacs/pacs-reconciliation.ts @@ -65,15 +65,15 @@ export function matchingStudies(body: any, allowedModalities: string[]): IPacsSt } export function reconciledMetadata(metadata: Metadata, currentUID: string, matchedUID: string): Metadata { - const originalUID = metadata.find(item => item.key === 'old-pacs-uid')?.valor; + const canonicalUID = metadata.find(item => item.key === 'pacs-canonical-uid')?.valor; let next = removeMetadata(metadata, 'pacs-reconciliation'); next = setMetadata(next, 'pacs-uid', matchedUID); - if (originalUID && originalUID === matchedUID) { - return removeMetadata(next, 'old-pacs-uid'); + if (canonicalUID && canonicalUID === matchedUID) { + return removeMetadata(next, 'pacs-canonical-uid'); } - if (!originalUID && currentUID !== matchedUID) { - next = setMetadata(next, 'old-pacs-uid', currentUID); + if (!canonicalUID && currentUID !== matchedUID) { + next = setMetadata(next, 'pacs-canonical-uid', currentUID); } return next; } From f6f2398923d47c08d1164571bdcd1052479c9536 Mon Sep 17 00:00:00 2001 From: Pela2silveira Date: Fri, 14 Aug 2026 13:28:55 -0300 Subject: [PATCH 4/6] PACS-7: cachear reconciliaciones resueltas Co-authored-by: Cursor --- modules/pacs/pacs-reconciliation.test.ts | 18 +++++++++++++++- modules/pacs/pacs-reconciliation.ts | 17 +++++++++++++--- modules/pacs/pacs.ts | 26 ++++++++++++++++-------- 3 files changed, 48 insertions(+), 13 deletions(-) diff --git a/modules/pacs/pacs-reconciliation.test.ts b/modules/pacs/pacs-reconciliation.test.ts index aa64550d38..48d5a6a930 100644 --- a/modules/pacs/pacs-reconciliation.test.ts +++ b/modules/pacs/pacs-reconciliation.test.ts @@ -4,9 +4,11 @@ import { searchStudies, studyExists } from './pacs-network'; import { configuredMatchingModalities, hasDicomResults, + isReconciliationResolved, matchingStudies, reconciledMetadata, - reconciliationFailureMetadata + reconciliationFailureMetadata, + resolvedReconciliationMetadata } from './pacs-reconciliation'; jest.mock('../../utils/requestHandler', () => ({ @@ -149,4 +151,18 @@ describe('PACS reconciliation', () => { 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 index 352c93ea9c..adc7a2fafb 100644 --- a/modules/pacs/pacs-reconciliation.ts +++ b/modules/pacs/pacs-reconciliation.ts @@ -4,7 +4,7 @@ export interface IPacsStudyCandidate { } export interface IPacsReconciliationStatus { - status: 'zero-match' | 'multiple-match'; + status: 'uid-exists' | 'reconciled' | 'zero-match' | 'multiple-match'; checkedAt: Date; candidateUIDs?: string[]; } @@ -85,8 +85,19 @@ export function reconciliationFailureMetadata( return setMetadata(metadata, 'pacs-reconciliation', status); } -export function clearReconciliationStatus(metadata: Metadata): Metadata { - return removeMetadata(metadata, 'pacs-reconciliation'); +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[] { diff --git a/modules/pacs/pacs.ts b/modules/pacs/pacs.ts index e0399fb75e..5acf7e925c 100644 --- a/modules/pacs/pacs.ts +++ b/modules/pacs/pacs.ts @@ -22,11 +22,12 @@ import { userScheduler } from '../../config.private'; import { IPacsConfig } from './pacs-config.schema'; import { pacsLogs } from './pacs.logs'; import { - clearReconciliationStatus, configuredMatchingModalities, + isReconciliationResolved, matchingStudies, reconciledMetadata, - reconciliationFailureMetadata + reconciliationFailureMetadata, + resolvedReconciliationMetadata } from './pacs-reconciliation'; export async function syncWorkList(prestacion: IPrestacion) { @@ -117,14 +118,20 @@ export async function getVisualizadorURL(prestacion: IPrestacion) { return getViewerURL(config, uid, token); } + if (isReconciliationResolved(metadata)) { + return getViewerURL(config, uid, token); + } + if (await studyExists(config, uid, token)) { pacsLogs.info('getVisualizadorURL.uid-exists', { prestacion: prestacion.id, uid }, userScheduler); - if (metadata.some(item => item.key === 'pacs-reconciliation')) { - try { - await updatePacsMetadata(prestacion, uid, clearReconciliationStatus(metadata)); - } catch (err) { - pacsLogs.error('getVisualizadorURL.clear-status', { prestacion: prestacion.id, uid }, err, 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); } @@ -140,7 +147,8 @@ export async function getVisualizadorURL(prestacion: IPrestacion) { if (candidates.length === 1) { const matchedUID = candidates[0].uid; - const nextMetadata = reconciledMetadata(metadata, uid, matchedUID); + const reconciled = reconciledMetadata(metadata, uid, matchedUID); + const nextMetadata = resolvedReconciliationMetadata(reconciled, 'reconciled'); await updatePacsMetadata(prestacion, uid, nextMetadata); pacsLogs.info( 'getVisualizadorURL.single-match', From 560a67b0f6c0b2bfde357ace90119a379f5d083b Mon Sep 17 00:00:00 2001 From: Pela2silveira Date: Fri, 14 Aug 2026 13:41:32 -0300 Subject: [PATCH 5/6] =?UTF-8?q?PACS-7:=20simplificar=20flujo=20de=20reconc?= =?UTF-8?q?iliaci=C3=B3n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- modules/pacs/pacs-reconciliation.test.ts | 9 +- modules/pacs/pacs-reconciliation.ts | 5 - modules/pacs/pacs.ts | 116 +++++++++++------------ 3 files changed, 58 insertions(+), 72 deletions(-) diff --git a/modules/pacs/pacs-reconciliation.test.ts b/modules/pacs/pacs-reconciliation.test.ts index 48d5a6a930..34833a0083 100644 --- a/modules/pacs/pacs-reconciliation.test.ts +++ b/modules/pacs/pacs-reconciliation.test.ts @@ -34,11 +34,10 @@ describe('PACS reconciliation', () => { beforeEach(() => requestMock.mockReset()); - test('enables reconciliation by flag and defaults to the configured modality', () => { - expect(configuredMatchingModalities(false, ['CR', 'DX'], 'CR')).toEqual([]); - expect(configuredMatchingModalities(true, undefined, 'CT')).toEqual(['CT']); - expect(configuredMatchingModalities(true, [], 'MR')).toEqual(['MR']); - expect(configuredMatchingModalities(true, ['CR', 'DX'], 'CR')).toEqual(['CR', 'DX']); + 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 () => { diff --git a/modules/pacs/pacs-reconciliation.ts b/modules/pacs/pacs-reconciliation.ts index adc7a2fafb..0ea53edc09 100644 --- a/modules/pacs/pacs-reconciliation.ts +++ b/modules/pacs/pacs-reconciliation.ts @@ -14,14 +14,9 @@ type Metadata = { key: string; valor: any }[]; const SUPPLEMENTAL_MODALITIES = ['SR']; export function configuredMatchingModalities( - enabled: boolean, configuredModalities: string[] | undefined, defaultModality: string ): string[] { - if (!enabled) { - return []; - } - const modalities = (configuredModalities || []) .map(modality => modality.trim()) .filter(Boolean); diff --git a/modules/pacs/pacs.ts b/modules/pacs/pacs.ts index 5acf7e925c..7237a19230 100644 --- a/modules/pacs/pacs.ts +++ b/modules/pacs/pacs.ts @@ -98,79 +98,71 @@ export async function syncWorkList(prestacion: IPrestacion) { export async function getVisualizadorURL(prestacion: IPrestacion) { try { - const metadata = prestacion.metadata || []; - const uid = metadata.find(item => item.key === 'pacs-uid')?.valor; - const configId = metadata.find(item => item.key === 'pacs-config')?.valor; - if (!uid || !configId) { + 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) { return null; } - const config = await PacsConfigController.findById(configId); - if (config) { - const token = await loginPacs(config); - const matchingModalities = configuredMatchingModalities( - config.featureFlags?.reconciliarEstudios, - config.reconcileMatchingModalities, - config.modalidad - ); - - if (!matchingModalities.length) { - return getViewerURL(config, uid, token); - } - - if (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 response = await searchStudies(config, String(patientID), studyDate, token); - const candidates = matchingStudies(response, matchingModalities); + const token = await loginPacs(config); + if (!config.featureFlags?.reconciliarEstudios || isReconciliationResolved(metadata)) { + return getViewerURL(config, uid, token); + } - 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 + 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') ); - return getViewerURL(config, matchedUID, token); + } catch (err) { + pacsLogs.error('getVisualizadorURL.save-status', { prestacion: prestacion.id, uid }, err, userScheduler); } + return getViewerURL(config, uid, 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); + 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.${status}`, - { prestacion: prestacion.id, uid, candidateUIDs: candidates.map(candidate => candidate.uid) }, + '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); From ab554250afe8075b84329a402c2e9a25ab42386c Mon Sep 17 00:00:00 2001 From: Pela2silveira Date: Fri, 14 Aug 2026 14:22:30 -0300 Subject: [PATCH 6/6] PACS-7: consultar PACS mediante servicios Co-authored-by: Cursor --- modules/pacs/pacs-network.ts | 47 ++++++-------------- modules/pacs/pacs-reconciliation.test.ts | 56 ++++++++++++++---------- 2 files changed, 45 insertions(+), 58 deletions(-) diff --git a/modules/pacs/pacs-network.ts b/modules/pacs/pacs-network.ts index 140b7d9cb8..daeffc1fb9 100644 --- a/modules/pacs/pacs-network.ts +++ b/modules/pacs/pacs-network.ts @@ -40,24 +40,14 @@ export async function createWorkList(pacsConfig: IPacsConfig, data: any, token: } export async function studyExists(pacsConfig: IPacsConfig, studyUID: string, token: string): Promise { - const url = `${pacsConfig.host}/dcm4chee-arc/aets/${pacsConfig.aet}/rs/studies/${encodeURIComponent(studyUID)}/series`; - const [status, body] = await handleHttpRequest({ - method: 'GET', - url, - qs: { limit: 1 }, - headers: { - Authorization: 'Bearer ' + token, - Accept: 'application/dicom+json' - } + const response = await services.get('dcm4chee-buscar-series-estudio').exec({ + host: pacsConfig.host, + aet: pacsConfig.aet, + token, + studyUID, + limit: 1 }); - - if (status === 404) { - return false; - } - if (status >= 200 && status < 300) { - return hasDicomResults(body); - } - throw new Error(`PACS study lookup failed with status ${status}`); + return hasDicomResults(response); } export async function searchStudies( @@ -66,24 +56,13 @@ export async function searchStudies( studyDate: string, token: string ): Promise { - const url = `${pacsConfig.host}/dcm4chee-arc/aets/${pacsConfig.aet}/rs/studies`; - const [status, body] = await handleHttpRequest({ - method: 'GET', - url, - qs: { - '00100020': patientID, - '00080020': studyDate - }, - headers: { - Authorization: 'Bearer ' + token, - Accept: 'application/dicom+json' - } + return services.get('dcm4chee-buscar-estudios').exec({ + host: pacsConfig.host, + aet: pacsConfig.aet, + token, + patientID, + studyDate }); - - if (status >= 200 && status < 300) { - return body; - } - throw new Error(`PACS study search failed with status ${status}`); } diff --git a/modules/pacs/pacs-reconciliation.test.ts b/modules/pacs/pacs-reconciliation.test.ts index 34833a0083..efca42db75 100644 --- a/modules/pacs/pacs-reconciliation.test.ts +++ b/modules/pacs/pacs-reconciliation.test.ts @@ -1,4 +1,4 @@ -import { handleHttpRequest } from '../../utils/requestHandler'; +import { services } from '../../services'; import { IPacsConfig } from './pacs-config.schema'; import { searchStudies, studyExists } from './pacs-network'; import { @@ -11,12 +11,10 @@ import { resolvedReconciliationMetadata } from './pacs-reconciliation'; -jest.mock('../../utils/requestHandler', () => ({ - handleHttpRequest: jest.fn() -})); - jest.mock('../../services', () => ({ - services: { get: jest.fn() } + services: { + get: jest.fn() + } })); const config = { @@ -30,9 +28,14 @@ const dicomStudy = (uid: string, modalities: string[]) => ({ }); describe('PACS reconciliation', () => { - const requestMock = handleHttpRequest as jest.Mock; + const getServiceMock = services.get as jest.Mock; + const execMock = jest.fn(); - beforeEach(() => requestMock.mockReset()); + beforeEach(() => { + execMock.mockReset(); + getServiceMock.mockReset(); + getServiceMock.mockReturnValue({ exec: execMock }); + }); test('defaults matching to the configured PACS modality', () => { expect(configuredMatchingModalities(undefined, 'CT')).toEqual(['CT']); @@ -41,29 +44,34 @@ describe('PACS reconciliation', () => { }); test('checks study existence with a bounded series lookup', async () => { - requestMock.mockResolvedValueOnce([200, '[]']); + execMock.mockResolvedValueOnce([]); await expect(studyExists(config, '1.2.3', 'token')).resolves.toBe(false); - requestMock.mockResolvedValueOnce([200, JSON.stringify([{ '0020000E': { Value: ['4.5.6'] } }])]); + execMock.mockResolvedValueOnce([{ '0020000E': { Value: ['4.5.6'] } }]); await expect(studyExists(config, '1.2.3', 'token')).resolves.toBe(true); - expect(requestMock).toHaveBeenLastCalledWith(expect.objectContaining({ - method: 'GET', - url: 'https://pacs.example.org/dcm4chee-arc/aets/PACS/rs/studies/1.2.3/series', - qs: { limit: 1 } - })); + 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 () => { - requestMock.mockResolvedValueOnce([200, '[]']); - - await expect(searchStudies(config, 'patient-1', '20260814', 'token')).resolves.toBe('[]'); - expect(requestMock).toHaveBeenCalledWith(expect.objectContaining({ - qs: { - '00100020': 'patient-1', - '00080020': '20260814' - } - })); + 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', () => {