Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions modules/pacs/pacs-config.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,15 @@ export interface IPacsConfig {
aet: string;
host: string;
visualizador_host: string;
reconcileMatchingModalities?: string[];
auth: {
host: string;
clientId: string;
clientSecret: string;
};
featureFlags: {
usoIdDNI: boolean;
reconciliarEstudios?: boolean;
};
}

Expand All @@ -36,6 +38,7 @@ export const PacsConfigSchema = new Schema({
aet: String,
host: String,
visualizador_host: String,
reconcileMatchingModalities: [String],
auth: {
host: String,
clientId: String,
Expand All @@ -45,6 +48,10 @@ export const PacsConfigSchema = new Schema({
usoIdDNI: {
type: Boolean,
default: false
},
reconciliarEstudios: {
type: Boolean,
default: false
}
}
});
Expand Down
27 changes: 27 additions & 0 deletions modules/pacs/pacs-network.ts
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down Expand Up @@ -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<boolean> {
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<any> {
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');
Expand Down
175 changes: 175 additions & 0 deletions modules/pacs/pacs-reconciliation.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
117 changes: 117 additions & 0 deletions modules/pacs/pacs-reconciliation.ts
Original file line number Diff line number Diff line change
@@ -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<string, IPacsStudyCandidate>();

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 }
];
}
Loading
Loading