diff --git a/bun.lock b/bun.lock index a3b3a32..a430955 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": { "name": "interbox-workspace", "dependencies": { - "@health-samurai/interbox": "^1.0.0", + "@health-samurai/interbox": "^1.12.1", }, "devDependencies": { "@types/bun": "^1.3.14", diff --git a/package.json b/package.json index 1bd1097..a350620 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "hl7v2:simulator": "bun install --cwd utils/hl7v2-simulator && bun run --cwd utils/hl7v2-simulator ui" }, "dependencies": { - "@health-samurai/interbox": "^1.0.0" + "@health-samurai/interbox": "^1.12.1" }, "devDependencies": { "@types/bun": "^1.3.14", diff --git a/src/mappers/v2-to-fhir/index.ts b/src/mappers/v2-to-fhir/index.ts index ab4127f..752af43 100644 --- a/src/mappers/v2-to-fhir/index.ts +++ b/src/mappers/v2-to-fhir/index.ts @@ -22,6 +22,7 @@ import { convertADT_A03 } from "./messages/adt-a03.ts"; import { convertADT_A08 } from "./messages/adt-a08.ts"; import { convertORM_O01 } from "./messages/orm-o01.ts"; import { convertORU_R01 } from "./messages/oru-r01.ts"; +import { convertSIU_S12 } from "./messages/siu-s12.ts"; import { convertVXU_V04 } from "./messages/vxu-v04.ts"; import { findSegment } from "./support/segments.ts"; @@ -52,6 +53,13 @@ export async function convertToFhir( ); } + // Routed on the code alone: every SIU trigger event (S12 booking through S26 + // no-show) uses the SIU_S12 structure, and the converter reads the event to + // infer a status when SCH-25 does not carry one. + if (code === "SIU") { + return convertSIU_S12(segments, event); + } + switch (`${code}_${event}`) { case "ADT_A01": // A04 (register) shares the A01 structure per the v2-to-FHIR IG. diff --git a/src/mappers/v2-to-fhir/messages/siu-s12.ts b/src/mappers/v2-to-fhir/messages/siu-s12.ts new file mode 100644 index 0000000..3007c5e --- /dev/null +++ b/src/mappers/v2-to-fhir/messages/siu-s12.ts @@ -0,0 +1,278 @@ +/** + * HL7v2 SIU_S12 Message to FHIR Converter + * + * SIU_S12 is the message structure every SIU trigger event shares (S12 booking + * through S26 no-show), so this one converter serves all of them and reads the + * trigger event only to infer a status when SCH-25 does not carry one. + * + * Creates: + * - Appointment from SCH (+ TQ1, AIS, NTE) + * - Patient[] from PID[] + * - Location[] from AIL[] across every resource group + * - Practitioner[] from AIP[] across every resource group + * + * Everything the appointment needs to reference is emitted alongside it, and each + * one appears once even when several resource groups name the same room or + * clinician. + * + * Not mapped: AIG (equipment and other general resources). FHIR would model those + * as Device or HealthcareService participants, which this mapper does not produce + * — the same line the reference parser draws. + * + * Encounters are left to ADT, as in ORM_O01: a scheduling message says what is + * planned, not what happened, and R4 links the two from Encounter.appointment. + */ + +import type { AIS, HL7v2Message, HL7v2Segment } from "@health-samurai/interbox/hl7v2"; +import { + fromAIL, + fromAIP, + fromAIS, + fromMSH, + fromNTE, + fromPID, + fromSCH, + fromTQ1, +} from "@health-samurai/interbox/hl7v2"; +import type { + Appointment, + AppointmentParticipant, + CodeableConcept, + DomainResource, + Location, + Patient, + Practitioner, + Reference, +} from "@health-samurai/interbox/fhir/4.0.1"; +import { domainError } from "@health-samurai/interbox"; +import { findSegment, findAllSegments } from "../support/segments.ts"; +import { senderFromMsh } from "../support/msh.ts"; +import { appointmentIdFromSch, patientIdFromPid } from "../support/identity.ts"; +import { convertPIDToPatient } from "../segments/pid-patient.ts"; +import { convertNTEsToAnnotation } from "../segments/nte-annotation.ts"; +import { convertSCHToAppointment } from "../segments/sch-appointment.ts"; +import { convertAILToLocation } from "../segments/ail-location.ts"; +import { convertCEToCodeableConcept } from "../datatypes/ce-codeableconcept.ts"; +import { + buildPractitionerIdFromXCN, + convertXCNToPractitioner, +} from "../datatypes/xcn-practitioner.ts"; + +const PARTICIPATION_TYPE_SYSTEM = "http://terminology.hl7.org/CodeSystem/v3-ParticipationType"; + +/** Participation type used for an AIP that does not say what the person's role is. */ +const DEFAULT_PERSONNEL_TYPE: CodeableConcept = { + coding: [{ system: PARTICIPATION_TYPE_SYSTEM, code: "ATND", display: "attender" }], +}; + +/** + * NTE segments that belong to the appointment as a whole: the ones before the + * first RGS. NTEs inside a resource group annotate that service or resource, not + * the appointment, so they are left out of Appointment.comment. + */ +function appointmentNotes(message: HL7v2Message): HL7v2Segment[] { + const firstRgs = message.findIndex((s) => s.segment === "RGS"); + const head = firstRgs === -1 ? message : message.slice(0, firstRgs); + return head.filter((s) => s.segment === "NTE"); +} + +/** Patients, plus a participant for each, from the message's PID segments. */ +function buildPatients(message: HL7v2Message): { + patients: Patient[]; + participants: AppointmentParticipant[]; +} { + const patients: Patient[] = []; + const participants: AppointmentParticipant[] = []; + const seen = new Set(); + + for (const segment of findAllSegments(message, "PID")) { + const pid = fromPID(segment); + const patient = convertPIDToPatient(pid); + patient.id = patientIdFromPid(pid); + if (seen.has(patient.id)) { + continue; + } + seen.add(patient.id); + + patients.push(patient); + participants.push({ + actor: { reference: `Patient/${patient.id}` } as Reference<"Patient">, + status: "accepted", + }); + } + + return { patients, participants }; +} + +/** Locations, plus a participant for each, from the message's AIL segments. */ +function buildLocations( + message: HL7v2Message, + sendingFacility: string, +): { locations: Location[]; participants: AppointmentParticipant[] } { + const locations: Location[] = []; + const participants: AppointmentParticipant[] = []; + const seen = new Set(); + + for (const segment of findAllSegments(message, "AIL")) { + const ail = fromAIL(segment); + // AIL-3 repeats: a resource group can name several rooms at once. + for (const pl of ail.$3_locationResourceId ?? []) { + const location = convertAILToLocation({ ...ail, $3_locationResourceId: [pl] }, sendingFacility); + if (!location?.id || seen.has(location.id)) { + continue; + } + seen.add(location.id); + + locations.push(location); + participants.push({ + actor: { reference: `Location/${location.id}` } as Reference<"Location">, + status: "accepted", + }); + } + } + + return { locations, participants }; +} + +/** Practitioners, plus a participant for each, from the message's AIP segments. */ +function buildPractitioners(message: HL7v2Message): { + practitioners: Practitioner[]; + participants: AppointmentParticipant[]; +} { + const practitioners: Practitioner[] = []; + const participants: AppointmentParticipant[] = []; + const seen = new Set(); + // Keyed by person *and* role: the same clinician can appear in two resource + // groups as, say, the performer of one service and the attender of another, and + // both belong on the appointment — but the same pair repeated does not. + const seenRoles = new Set(); + + for (const segment of findAllSegments(message, "AIP")) { + const aip = fromAIP(segment); + // AIP-4 is the role the person plays in this appointment (HL7 table 0182). + const type = convertCEToCodeableConcept(aip.$4_resourceType) ?? DEFAULT_PERSONNEL_TYPE; + const roleKey = type.coding?.[0]?.code ?? type.text ?? ""; + + // AIP-3 repeats: one segment can name several people in the same role. + for (const xcn of aip.$3_personnelResourceId ?? []) { + const id = buildPractitionerIdFromXCN(xcn); + const practitioner = convertXCNToPractitioner(xcn); + // No XCN.1 means nothing stable to key the Practitioner on, and a + // reference to it would dangle — skip rather than mint a random id. + if (!id || !practitioner) { + continue; + } + + if (!seen.has(id)) { + seen.add(id); + practitioner.id = id; + practitioners.push(practitioner); + } + + if (seenRoles.has(`${id}|${roleKey}`)) { + continue; + } + seenRoles.add(`${id}|${roleKey}`); + + participants.push({ + type: [type] as AppointmentParticipant["type"], + actor: { reference: `Practitioner/${id}` } as Reference<"Practitioner">, + status: "accepted", + }); + } + } + + return { practitioners, participants }; +} + +/** Every AIS in the message, in order — the appointment's scheduled services. */ +function scheduledServices(message: HL7v2Message): AIS[] { + return findAllSegments(message, "AIS").map((segment) => fromAIS(segment)); +} + +/** + * Convert an HL7v2 SIU message to a flat array of FHIR resources. + * + * Message Structure (v2.5): + * MSH [1..1] + * SCH [1..1] + * TQ1 [0..*] (timing; replaces the deprecated SCH-11) + * NTE [0..*] (appointment-level notes) + * PATIENT [0..*] + * PID [1..1], PV1 [0..1], … + * RESOURCES [1..*] + * RGS [1..1] + * SERVICE [0..*] AIS [1..1], NTE [0..*] + * GENERAL_RESOURCE [0..*] AIG [1..1], NTE [0..*] + * LOCATION_RESOURCE [0..*] AIL [1..1], NTE [0..*] + * PERSONNEL_RESOURCE [0..*] AIP [1..1], NTE [0..*] + * + * @param triggerEvent MSH-9.2 (S12, S14, S15, …) — the fallback for a status + * SCH-25 does not give. + */ +export function convertSIU_S12( + parsed: HL7v2Message, + triggerEvent: string, +): DomainResource[] { + const mshSegment = findSegment(parsed, "MSH"); + if (!mshSegment) { + throw domainError("parse", "missing_msh", "MSH segment not found in SIU message"); + } + const { sendingFacility } = senderFromMsh(fromMSH(mshSegment)); + + const schSegment = findSegment(parsed, "SCH"); + if (!schSegment) { + throw domainError( + "structure", + "missing_sch", + "SCH segment is required for SIU but missing", + ); + } + const sch = fromSCH(schSegment); + + const appointmentId = appointmentIdFromSch(sch, sendingFacility); + if (!appointmentId) { + throw domainError( + "field", + "missing_appointment_id", + "SCH-1 (Placer Appointment ID) and SCH-2 (Filler Appointment ID) are both empty", + ); + } + + const tq1Segment = findSegment(parsed, "TQ1"); + const appointment: Appointment = convertSCHToAppointment( + sch, + triggerEvent, + tq1Segment ? fromTQ1(tq1Segment) : undefined, + scheduledServices(parsed), + ); + appointment.id = appointmentId; + + const notes = appointmentNotes(parsed).map((segment) => fromNTE(segment)); + const comment = convertNTEsToAnnotation(notes)?.text; + if (comment) { + appointment.comment = comment; + } + + // A blocked-slot notification (S22/S23) carries no PID, so the patient + // participant is genuinely optional here. + const { patients, participants: patientParticipants } = buildPatients(parsed); + const { locations, participants: locationParticipants } = buildLocations(parsed, sendingFacility); + const { practitioners, participants: practitionerParticipants } = buildPractitioners(parsed); + + appointment.participant = [ + ...patientParticipants, + ...practitionerParticipants, + ...locationParticipants, + ]; + if (appointment.participant.length === 0) { + throw domainError( + "structure", + "missing_appointment_participant", + "SIU message names no patient (PID), personnel (AIP) or location (AIL); " + + "FHIR requires at least one Appointment.participant", + ); + } + + return [appointment, ...patients, ...practitioners, ...locations]; +} diff --git a/src/mappers/v2-to-fhir/segments/ail-location.ts b/src/mappers/v2-to-fhir/segments/ail-location.ts new file mode 100644 index 0000000..5759ab8 --- /dev/null +++ b/src/mappers/v2-to-fhir/segments/ail-location.ts @@ -0,0 +1,98 @@ +/** + * HL7v2 AIL Segment to FHIR Location Mapping + * + * AIL names the location a scheduled appointment needs. AIL-3 is a PL, so it can + * carry a whole hierarchy (point of care, room, bed, building, floor, facility) in + * one field; this emits one Location for it, identified by every part the sender + * populated, and lets Appointment.participant reference it. + * + * Mapping: + * - AIL.3 -> id, identifier, name, description, physicalType + * - AIL.4 -> type (location type, e.g. "Outpatient") + */ + +import type { AIL, PL } from "@health-samurai/interbox/hl7v2"; +import type { Location } from "@health-samurai/interbox/fhir/4.0.1"; +import { convertCEToCodeableConcept } from "../datatypes/ce-codeableconcept.ts"; +import { convertPLToLocation } from "../datatypes/pl-converters.ts"; +import { toKebabCase } from "../support/string.ts"; + +const LOCATION_RESOURCE_ID_SYSTEM = "urn:hl7v2:ail-3:location-resource-id"; + +/** PL parts, coarse to fine, paired with the label used in the description. */ +const PL_PARTS: { label: string; get: (pl: PL) => string | undefined }[] = [ + { label: "Point of care", get: (pl) => pl.$1_careSite }, + { label: "Building", get: (pl) => pl.$7_building }, + { label: "Floor", get: (pl) => pl.$8_floor }, + { label: "Room", get: (pl) => pl.$2_room }, + { label: "Bed", get: (pl) => pl.$3_bed }, +]; + +function presentParts(pl: PL): { label: string; value: string }[] { + return PL_PARTS.flatMap(({ label, get }) => { + const value = get(pl)?.trim(); + return value ? [{ label, value }] : []; + }); +} + +/** + * Deterministic Location.id: kebab("-"). Every part is included because senders + * reuse room and bed names across buildings — "260" alone is not an identity. + * + * Returns undefined when AIL-3 is empty, which makes the segment unmappable. + */ +export function locationIdFromPL( + pl: PL, + sendingFacility: string, +): string | undefined { + const parts = presentParts(pl); + if (parts.length === 0) { + return undefined; + } + + const scope = pl.$4_facility?.$1_namespace || sendingFacility; + return toKebabCase(`${scope}-${parts.map((p) => p.value).join("-")}`); +} + +/** + * Convert an AIL segment to a FHIR Location. Returns undefined when AIL-3 + * carries nothing to identify a location by; the caller skips such segments. + */ +export function convertAILToLocation( + ail: AIL, + sendingFacility: string, +): Location | undefined { + const pl = ail.$3_locationResourceId?.[0]; + if (!pl) { + return undefined; + } + + const id = locationIdFromPL(pl, sendingFacility); + if (!id) { + return undefined; + } + + const parts = presentParts(pl); + // PL.9 is the sender's own label for the location; fall back to the coarsest + // part (usually the point of care) when it is absent. + const name = pl.$9_description?.trim() || parts[0]?.value; + const description = parts.map((p) => `${p.label} ${p.value}`).join(", "); + const type = convertCEToCodeableConcept(ail.$4_locationTypeAil); + const physicalType = convertPLToLocation(pl)?.physicalType; + + return { + resourceType: "Location", + id, + identifier: [ + { + system: LOCATION_RESOURCE_ID_SYSTEM, + value: parts.map((p) => p.value).join("^"), + }, + ], + ...(name && { name }), + ...(description && { description }), + ...(type && { type: [type] }), + ...(physicalType && { physicalType }), + }; +} diff --git a/src/mappers/v2-to-fhir/segments/sch-appointment.ts b/src/mappers/v2-to-fhir/segments/sch-appointment.ts new file mode 100644 index 0000000..e327565 --- /dev/null +++ b/src/mappers/v2-to-fhir/segments/sch-appointment.ts @@ -0,0 +1,307 @@ +/** + * HL7v2 SCH Segment to FHIR Appointment Mapping + * + * SCH carries the appointment itself; the timing comes from TQ1 (v2.5+) or the + * deprecated SCH-11 TQ field (v2.4 senders), and the scheduled services from the + * AIS segments of every resource group. + * + * Mapping: + * - SCH.1 -> identifier (placer appointment id) + * - SCH.2 -> identifier (filler appointment id) + * - SCH.6 -> description (event reason text) + * - SCH.7 -> reasonCode + * - SCH.8 -> appointmentType + * - SCH.9/10 -> minutesDuration + * - SCH.11 -> start/end (v2.4 senders; superseded by TQ1) + * - SCH.25 -> status (HL7 table 0278), else inferred from the trigger event + * - AIS.3 -> serviceType + * - AIS.4/7/8 -> start/duration fallback when SCH and TQ1 carry no timing + */ + +import type { AIS, SCH, TQ1 } from "@health-samurai/interbox/hl7v2"; +import type { + Appointment, + CodeableConcept, + Identifier, +} from "@health-samurai/interbox/fhir/4.0.1"; +import { domainError } from "@health-samurai/interbox"; +import { convertCEToCodeableConcept } from "../datatypes/ce-codeableconcept.ts"; +import { convertDTMToDateTime } from "../support/datetime.ts"; + +const PLACER_APPOINTMENT_ID_SYSTEM = "urn:hl7v2:sch-1:placer-appointment-id"; +const FILLER_APPOINTMENT_ID_SYSTEM = "urn:hl7v2:sch-2:filler-appointment-id"; + +/** + * HL7 table 0278 (Filler Status Code) -> FHIR AppointmentStatus. + * Keys are lowercased before lookup because senders differ on case. + */ +const FILLER_STATUS_TO_APPOINTMENT_STATUS: Record = { + pending: "pending", + waitlist: "waitlist", + booked: "booked", + started: "checked-in", + complete: "fulfilled", + cancelled: "cancelled", + canceled: "cancelled", + dc: "cancelled", // Discontinued + deleted: "entered-in-error", + blocked: "entered-in-error", + overbook: "booked", + noshow: "noshow", +}; + +/** + * SIU trigger event -> FHIR AppointmentStatus, used when SCH-25 is absent or + * carries a code outside table 0278. + * + * S18–S22 add, cancel, discontinue or delete a *service or resource* on an + * existing appointment, and S22 blocks a slot: the appointment (or the block) + * still occupies its time, so they stay booked. S23 reopens a blocked slot, + * which retires the block. FHIR has no "discontinued", so S16 lands on + * cancelled; only a deletion (S17) is entered-in-error. + */ +const TRIGGER_EVENT_TO_APPOINTMENT_STATUS: Record = { + S12: "booked", // New appointment booking + S13: "booked", // Appointment rescheduling + S14: "booked", // Appointment modification + S15: "cancelled", // Appointment cancellation + S16: "cancelled", // Appointment discontinuation + S17: "entered-in-error", // Appointment deletion + S18: "booked", // Addition of service/resource + S19: "booked", // Cancellation of service/resource + S20: "booked", // Discontinuation of service/resource + S21: "booked", // Deletion of service/resource + S22: "booked", // Blocked schedule time slot(s) + S23: "cancelled", // Opened ("unblocked") schedule time slot(s) + S24: "noshow", // Patient did not show up + S26: "noshow", // Notification that patient did not show up +}; + +/** Duration units (HL7 table 0335 and the abbreviations senders actually use) -> minutes. */ +const DURATION_UNIT_MINUTES: Record = { + s: 1 / 60, + sec: 1 / 60, + min: 1, + mins: 1, + minute: 1, + minutes: 1, + h: 60, + hr: 60, + hrs: 60, + hour: 60, + hours: 60, + d: 1440, + day: 1440, + days: 1440, +}; + +/** + * Resolve AppointmentStatus. SCH-25 wins when it maps: the filler states the + * appointment's status directly, while the trigger event only says what kind of + * notification this is (an S14 modification, for instance, can carry a + * "Cancelled" filler status). + */ +export function resolveAppointmentStatus( + sch: SCH, + triggerEvent: string, +): Appointment["status"] { + const fillerStatus = sch.$25_fillerStatus?.$1_code?.trim().toLowerCase(); + const fromFiller = fillerStatus + ? FILLER_STATUS_TO_APPOINTMENT_STATUS[fillerStatus] + : undefined; + if (fromFiller) { + return fromFiller; + } + + const fromEvent = TRIGGER_EVENT_TO_APPOINTMENT_STATUS[triggerEvent.toUpperCase()]; + if (fromEvent) { + return fromEvent; + } + + throw domainError( + "field", + "unknown_appointment_status", + `cannot resolve Appointment.status: SCH-25 is "${sch.$25_fillerStatus?.$1_code ?? ""}" ` + + `and trigger event ${triggerEvent} has no status mapping`, + ); +} + +/** + * HL7 DTM -> FHIR instant. + * + * A date-only value (senders do use YYYYMMDD for all-day slots) is widened to + * midnight rather than dropped, since Appointment.start/end are instants and + * convertDTMToDateTime would leave "2026-04-23" — not a valid instant. + * + * Anything coarser than a day is rejected instead of widened: a year or a month + * says nothing about when the appointment is, and one of the fields read here + * (SCH-11.1) legitimately holds a *count* for senders that follow the TQ + * datatype, which must not be mistaken for a date. + */ +function toInstant(dtm: string | undefined): string | undefined { + const trimmed = dtm?.trim(); + if (!trimmed || !/^\d{8}/.test(trimmed)) { + return undefined; + } + + const value = convertDTMToDateTime(trimmed); + if (!value) { + return undefined; + } + return value.includes("T") ? value : `${value}T00:00:00Z`; +} + +/** Add minutes to an instant produced by toInstant(). */ +function addMinutes(instant: string, minutes: number): string | undefined { + const parsed = Date.parse(instant); + if (Number.isNaN(parsed)) { + return undefined; + } + return `${new Date(parsed + minutes * 60_000).toISOString().slice(0, 19)}Z`; +} + +/** Duration + units -> whole minutes, or undefined when either is unusable. */ +function toMinutes( + duration: string | undefined, + units: string | undefined, +): number | undefined { + const value = Number(duration?.trim()); + if (!duration?.trim() || Number.isNaN(value) || value <= 0) { + return undefined; + } + + // HL7 defaults SCH-10/AIS-8 to minutes when the sender omits them. + const factor = units?.trim() + ? DURATION_UNIT_MINUTES[units.trim().toLowerCase()] + : 1; + if (factor === undefined) { + return undefined; + } + + const minutes = Math.round(value * factor); + return minutes > 0 ? minutes : undefined; +} + +function buildIdentifiers(sch: SCH): Identifier[] { + const identifiers: Identifier[] = []; + + const placer = sch.$1_placerAppointmentId; + if (placer?.$1_value) { + identifiers.push({ + system: PLACER_APPOINTMENT_ID_SYSTEM, + value: placer.$1_value, + ...(placer.$2_namespace && { assigner: { display: placer.$2_namespace } }), + }); + } + + const filler = sch.$2_fillerAppointmentId; + if (filler?.$1_value) { + identifiers.push({ + system: FILLER_APPOINTMENT_ID_SYSTEM, + value: filler.$1_value, + ...(filler.$2_namespace && { assigner: { display: filler.$2_namespace } }), + }); + } + + return identifiers; +} + +/** + * Resolve start/end/minutesDuration. + * + * Precedence for the start: TQ1-7, then SCH-11.4 (TQ.4 "start date/time"), then + * SCH-11.1.1, then AIS-4. That third step looks wrong but is not: MEDITECH (and + * other v2.4 senders) put the appointment datetime in the TQ *quantity* + * component and never populate TQ.4, so reading only TQ.4 loses the appointment + * time for every one of those messages. + * + * The end is taken from TQ1-8 or SCH-11.5 when present, and otherwise computed + * from the duration (SCH-9/10, falling back to AIS-7/8). + */ +function resolveTiming( + sch: SCH, + tq1: TQ1 | undefined, + services: AIS[], +): { start?: string; end?: string; minutesDuration?: number } { + // Recurring appointments repeat SCH-11/TQ1 per occurrence; only the first is + // mapped, since one SIU message becomes one Appointment here. + const schTiming = sch.$11_appointmentTimingQuantity?.[0]; + const firstService = services[0]; + + const start = + toInstant(tq1?.$7_start) ?? + toInstant(schTiming?.$4_start) ?? + toInstant(schTiming?.$1_value?.$1_value) ?? + toInstant(firstService?.$4_start); + + const minutesDuration = + toMinutes(sch.$9_appointmentDuration, sch.$10_appointmentDurationUnit?.$1_code) ?? + toMinutes(firstService?.$7_duration, firstService?.$8_durationUnit?.$1_code); + + const explicitEnd = toInstant(tq1?.$8_end) ?? toInstant(schTiming?.$5_end); + const end = + explicitEnd ?? + (start && minutesDuration !== undefined + ? addMinutes(start, minutesDuration) + : undefined); + + return { + ...(start && { start }), + ...(end && { end }), + ...(minutesDuration !== undefined && { minutesDuration }), + }; +} + +/** Scheduled services (AIS-3) across every resource group, de-duplicated by code. */ +function buildServiceTypes(services: AIS[]): CodeableConcept[] { + const byCode = new Map(); + + for (const ais of services) { + const concept = convertCEToCodeableConcept(ais.$3_service); + if (!concept) { + continue; + } + const key = + ais.$3_service?.$1_code ?? + ais.$3_service?.$2_text ?? + ""; + if (!byCode.has(key)) { + byCode.set(key, concept); + } + } + + return [...byCode.values()]; +} + +/** + * Convert SCH (plus TQ1 and the message's AIS segments) to a FHIR Appointment. + * + * The caller owns `id` and `participant`: ids are scoped by the sending facility + * (see appointmentIdFromSch) and participants are assembled from PID, AIL and + * AIP across the resource groups. + */ +export function convertSCHToAppointment( + sch: SCH, + triggerEvent: string, + tq1: TQ1 | undefined, + services: AIS[], +): Appointment { + const identifier = buildIdentifiers(sch); + const serviceType = buildServiceTypes(services); + const reasonCode = convertCEToCodeableConcept(sch.$7_appointmentReason); + const appointmentType = convertCEToCodeableConcept(sch.$8_appointmentType); + const description = sch.$6_eventReason?.$2_text ?? sch.$6_eventReason?.$1_code; + + return { + resourceType: "Appointment", + status: resolveAppointmentStatus(sch, triggerEvent), + ...(identifier.length > 0 && { identifier }), + ...(serviceType.length > 0 && { serviceType }), + ...(appointmentType && { appointmentType }), + ...(reasonCode && { reasonCode: [reasonCode] }), + ...(description && { description }), + ...resolveTiming(sch, tq1, services), + // Required by FHIR (1..*); the caller fills it from PID/AIL/AIP. + participant: [], + }; +} diff --git a/src/mappers/v2-to-fhir/support/identity.ts b/src/mappers/v2-to-fhir/support/identity.ts index f0a8377..ff7708a 100644 --- a/src/mappers/v2-to-fhir/support/identity.ts +++ b/src/mappers/v2-to-fhir/support/identity.ts @@ -1,4 +1,4 @@ -import type { PID, PV1 } from "@health-samurai/interbox/hl7v2"; +import type { PID, PV1, SCH } from "@health-samurai/interbox/hl7v2"; import { domainError } from "@health-samurai/interbox"; import { toKebabCase } from "./string.ts"; @@ -39,3 +39,35 @@ export function encounterIdFromPv1( const scope = visitNumber.$4_system?.$1_namespace || sendingFacility; return toKebabCase(`${scope}-${visitNumber.$1_value}`); } + +/** + * Deterministic Appointment.id from SCH-2 (Filler Appointment ID), falling back + * to SCH-1 (Placer Appointment ID): + * kebab("-[-]"). + * + * The filler ID is preferred because the filler (the scheduling system) is the + * SIU sender: it keeps that ID stable across the booking (S12), every + * modification (S13/S14) and the cancellation (S15), so those messages land on + * one Appointment instead of creating a new one each time. SCH-3 is part of the + * id because a recurring appointment repeats one ID pair per occurrence. + * + * Returns undefined when neither field carries a value — the caller decides + * whether that is field/missing_appointment_id. + */ +export function appointmentIdFromSch( + sch: SCH, + sendingFacility: string, +): string | undefined { + const ei = sch.$2_fillerAppointmentId?.$1_value + ? sch.$2_fillerAppointmentId + : sch.$1_placerAppointmentId; + if (!ei?.$1_value) { + return undefined; + } + + const scope = ei.$2_namespace || sendingFacility; + const occurrence = sch.$3_occurrenceNumber?.trim(); + return toKebabCase( + `${scope}-${ei.$1_value}${occurrence ? `-${occurrence}` : ""}`, + ); +} diff --git a/test/siu.test.ts b/test/siu.test.ts new file mode 100644 index 0000000..77e5474 --- /dev/null +++ b/test/siu.test.ts @@ -0,0 +1,379 @@ +import { expect, test } from "bun:test"; +import { parseHl7v2, type HL7v2Segment } from "@health-samurai/interbox/hl7v2"; +import type { + Appointment, + Location, + Patient, + Practitioner, +} from "@health-samurai/interbox/fhir/4.0.1"; +import { convertToFhir } from "../src/mappers/v2-to-fhir/index.ts"; + +// The SIU corpus these expectations were built against is MEDITECH v2.4: no TQ1, +// the appointment datetime in SCH-11.1 rather than SCH-11.4, notes between PV1 and +// the first RGS, one resource group per appointment. +const MEDITECH_S12 = [ + "MSH|^~\\&|CWS|BMH|||202603201318||SIU^S12|260884663|P|2.4|||AL|NE", + "EVN|S12|202603201317||BOOK|JJM675^Morehead^James^J^^^^^^^^^XX", + "SCH|BH2-B20260303145038026|T0-B20260303145037916|||MRTHORACICG^MRI Thoracic GAD|BOOK|Disease of spinal cord, unspecified|Normal^Scheduled|30|min|202604231500|||||||||JJM675^Morehead^James^J^^^^^^^^^XX", + "PID|1|6579143^^^OCCAM^PE|M000008833^^^MEDITECH^MR||Difvnx^Rjnckfdu^O^^^^L|Flkkags|19580101|F", + "PV1|1|P|BXBC260MRI|EL|||APHO^Appleberry^Holly^Carolyn^^^DO^^^^^^XX", + "ROL|1|AD|PP|DICWC.MD^DiCuccio^William^C^^^MD^^^^^^XX", + "NTE|1||*Complete screening assessment", + "NTE|2||*Arrive 30 minutes prior to exam, 260 Butler Commons", + "RGS|1||MRRMBC^MRI^MRI", + "AIS|1||MRRMBC^MRI Room (BC)|202604231500|||30|min||Booked", + "AIL|1||BXBC260MRI^Butler Commons 260 MRI|Outpatient", + "AIP|1|A|APHO^Appleberry^Holly^Carolyn^^^DO^^^^^^XX|NS^Non-Staff", +].join("\r"); + +async function convert(raw: string): Promise[]> { + const segments = parseHl7v2(raw) as unknown as HL7v2Segment[]; + return (await convertToFhir(segments)) as Record[]; +} + +function pick(resources: Record[], resourceType: string): T[] { + return resources.filter((r) => r["resourceType"] === resourceType) as T[]; +} + +async function appointmentOf(raw: string): Promise { + const appointments = pick(await convert(raw), "Appointment"); + expect(appointments).toHaveLength(1); + return appointments[0]!; +} + +test("a MEDITECH SIU^S12 becomes one Appointment plus everything it references", async () => { + const resources = await convert(MEDITECH_S12); + + const appointment = pick(resources, "Appointment")[0]!; + const patients = pick(resources, "Patient"); + const practitioners = pick(resources, "Practitioner"); + const locations = pick(resources, "Location"); + + expect(appointment.id).toBe("bmh-t0-b20260303145037916"); + expect(appointment.status).toBe("booked"); + expect(appointment.identifier).toEqual([ + { + system: "urn:hl7v2:sch-1:placer-appointment-id", + value: "BH2-B20260303145038026", + }, + { + system: "urn:hl7v2:sch-2:filler-appointment-id", + value: "T0-B20260303145037916", + }, + ]); + + // SCH-11.1 carries the datetime for this sender; SCH-9/10 give the duration, + // and the end is derived from the two. + expect(appointment.start).toBe("2026-04-23T15:00:00Z"); + expect(appointment.end).toBe("2026-04-23T15:30:00Z"); + expect(appointment.minutesDuration).toBe(30); + + expect(appointment.description).toBe("BOOK"); + expect(appointment.appointmentType?.coding?.[0]).toMatchObject({ code: "Normal", display: "Scheduled" }); + expect(appointment.reasonCode?.[0]?.coding?.[0]?.code).toBe("Disease of spinal cord, unspecified"); + expect(appointment.serviceType?.[0]?.coding?.[0]).toMatchObject({ + code: "MRRMBC", + display: "MRI Room (BC)", + }); + expect(appointment.comment).toBe( + "*Complete screening assessment\n*Arrive 30 minutes prior to exam, 260 Butler Commons", + ); + + // One participant per referenced resource: patient, then personnel, then location. + // PID-2 holds MEDITECH's external id and PID-3 the MRN, so the MRN is what + // patientIdFromPid keys on. + expect(appointment.participant.map((p) => p.actor?.reference)).toEqual([ + "Patient/meditech-m000008833", + "Practitioner/apho", + "Location/bmh-bxbc260mri-butler-commons-260-mri", + ]); + expect(appointment.participant.every((p) => p.status === "accepted")).toBe(true); + expect(appointment.participant[1]?.type?.[0]?.coding?.[0]).toMatchObject({ + code: "NS", + display: "Non-Staff", + }); + + expect(patients.map((p) => p.id)).toEqual(["meditech-m000008833"]); + expect(practitioners[0]).toMatchObject({ + id: "apho", + name: [{ family: "Appleberry", given: ["Holly", "Carolyn"] }], + }); + expect(locations[0]).toMatchObject({ + id: "bmh-bxbc260mri-butler-commons-260-mri", + name: "BXBC260MRI", + description: "Point of care BXBC260MRI, Room Butler Commons 260 MRI", + physicalType: { coding: [{ code: "ro" }] }, + type: [{ coding: [{ code: "Outpatient" }] }], + }); + + // Every reference in the Appointment resolves inside the same bundle. + const ids = new Set(resources.map((r) => `${r["resourceType"]}/${r["id"]}`)); + for (const participant of appointment.participant) { + expect(ids.has(participant.actor?.reference ?? "")).toBe(true); + } +}); + +test("later events land on the appointment the booking created", async () => { + const booked = await appointmentOf(MEDITECH_S12); + + // Same SCH-2, new trigger event and a filler status: the S15 has to update the + // S12's Appointment rather than create a second one. + const cancelled = await appointmentOf( + MEDITECH_S12.replace("SIU^S12", "SIU^S15").replace("|EVN|S12", "|EVN|S15"), + ); + + expect(cancelled.id).toBe(booked.id); + expect(cancelled.status).toBe("cancelled"); +}); + +test("SCH-25 decides the status, and the trigger event fills in when it is empty", async () => { + const withFillerStatus = [ + "MSH|^~\\&|SCHED|HC|||20260601120000||SIU^S14^SIU_S12|MSG2|P|2.5", + // 24 pipes carry the segment out to SCH-25. + `SCH|PLC-1|FIL-1${"|".repeat(23)}Cancelled`, + "TQ1|1||||||202606011400|202606011430", + "PID|1||PT-1^^^HC^MR||Doe^Jane||19850315|F", + "RGS|1|A", + "AIL|1|A|CLINIC-A", + ].join("\r"); + + // An S14 (modification) carrying a cancelled filler status is cancelled: the + // filler states the appointment's status, the event only names the notification. + const modified = await appointmentOf(withFillerStatus); + expect(modified.status).toBe("cancelled"); + expect(modified.start).toBe("2026-06-01T14:00:00Z"); + expect(modified.end).toBe("2026-06-01T14:30:00Z"); + expect(modified.minutesDuration).toBeUndefined(); + + // Same message without SCH-25 falls back to the event. + const byEvent = async (event: string) => + (await appointmentOf( + withFillerStatus.replace("SIU^S14^SIU_S12", `SIU^${event}^SIU_S12`).replace("Cancelled", ""), + )).status; + + expect(await byEvent("S12")).toBe("booked"); + expect(await byEvent("S15")).toBe("cancelled"); + expect(await byEvent("S16")).toBe("cancelled"); + expect(await byEvent("S17")).toBe("entered-in-error"); + expect(await byEvent("S23")).toBe("cancelled"); + expect(await byEvent("S26")).toBe("noshow"); +}); + +test("resources repeated across groups are emitted once and referenced once", async () => { + // Two services in two resource groups, sharing a clinician and naming three + // rooms — one of them twice, and one group repeats AIL-3 within a single field. + const resources = await convert( + [ + "MSH|^~\\&|SCHED|HC|||20260601120000||SIU^S12^SIU_S12|MSG3|P|2.5", + // SCH-3 occurrence 1, SCH-9/10 duration 45 min, SCH-25 filler status. + `SCH|PLC-2|FIL-2|1|||FOLLOWUP|||45|min${"|".repeat(15)}Booked`, + "TQ1|1||||||202606020900", + "PID|1||PT-2^^^HC^MR||Roe^Sam||19700202|M", + "RGS|1|A|GRP-1", + "AIS|1|A|XR-CHEST^Chest X-Ray", + "AIL|1|A|IMAGING^ROOM-1^^SITE-A~IMAGING^ROOM-2^^SITE-A|C", + "AIP|1|A|DOC-9^Vance^Ada^^^^MD|D^Physician", + "RGS|2|A|GRP-2", + "AIS|1|A|XR-SPINE^Spine X-Ray", + "AIL|1|A|IMAGING^ROOM-1^^SITE-A|C", + "AIP|1|A|DOC-9^Vance^Ada^^^^MD|D^Physician", + "AIP|2|A|TECH-4^Bell^Ray|D^Technician", + ].join("\r"), + ); + + const appointment = pick(resources, "Appointment")[0]!; + const locations = pick(resources, "Location"); + const practitioners = pick(resources, "Practitioner"); + + // SCH-3 (occurrence number) is part of the id: a recurring appointment repeats + // the same placer/filler pair once per occurrence. + expect(appointment.id).toBe("hc-fil-2-1"); + + expect(locations.map((l) => l.id)).toEqual([ + "site-a-imaging-room-1", + "site-a-imaging-room-2", + ]); + expect(practitioners.map((p) => p.id)).toEqual(["doc-9", "tech-4"]); + + expect(appointment.participant.map((p) => p.actor?.reference)).toEqual([ + "Patient/hc-pt-2", + "Practitioner/doc-9", + "Practitioner/tech-4", + "Location/site-a-imaging-room-1", + "Location/site-a-imaging-room-2", + ]); + + // Both AIS segments survive as service types. + expect(appointment.serviceType?.map((s) => s.coding?.[0]?.code)).toEqual([ + "XR-CHEST", + "XR-SPINE", + ]); + + // No end datetime and a duration in SCH-9: the end is computed. + expect(appointment.start).toBe("2026-06-02T09:00:00Z"); + expect(appointment.end).toBe("2026-06-02T09:45:00Z"); +}); + +test("a blocked-slot notification maps without a patient", async () => { + const resources = await convert( + [ + "MSH|^~\\&|SCHED|HC|||20260601120000||SIU^S23^SIU_S12|MSG4|P|2.5", + `SCH|PLC-3|FIL-3${"|".repeat(23)}Deleted`, + "TQ1|1||||||202606030800|202606031200", + "RGS|1|A", + "AIL|1|A|CLINIC-B^^^SITE-B", + ].join("\r"), + ); + + expect(pick(resources, "Patient")).toHaveLength(0); + const appointment = pick(resources, "Appointment")[0]!; + expect(appointment.status).toBe("entered-in-error"); // SCH-25 "Deleted" + expect(appointment.participant.map((p) => p.actor?.reference)).toEqual([ + "Location/site-b-clinic-b", + ]); +}); + +test("duration units other than minutes are converted", async () => { + const appointment = await appointmentOf( + [ + "MSH|^~\\&|SCHED|HC|||20260601120000||SIU^S12^SIU_S12|MSG5|P|2.5", + "SCH|PLC-4|FIL-4|||||||2|hr|202606040800", + "RGS|1|A", + "AIL|1|A|CLINIC-C", + ].join("\r"), + ); + + expect(appointment.minutesDuration).toBe(120); + expect(appointment.start).toBe("2026-06-04T08:00:00Z"); + expect(appointment.end).toBe("2026-06-04T10:00:00Z"); +}); + +test("a TQ quantity that is a count, not a datetime, is not read as timing", async () => { + // A sender following the TQ datatype puts the number of occurrences in + // SCH-11.1 and the datetime in SCH-11.4. Reading .1 for the MEDITECH dialect + // must not turn that count into a start instant. + const appointment = await appointmentOf( + [ + "MSH|^~\\&|SCHED|HC|||20260601120000||SIU^S12^SIU_S12|MSG8|P|2.4", + "SCH|PLC-6|FIL-6|||||||30|min|1^^^202606061100^202606061130", + "PID|1||PT-6^^^HC^MR", + "RGS|1|A", + "AIL|1|A|CLINIC-E", + ].join("\r"), + ); + + expect(appointment.start).toBe("2026-06-06T11:00:00Z"); + expect(appointment.end).toBe("2026-06-06T11:30:00Z"); +}); + +test("timing coarser than a day is left off rather than widened", async () => { + const appointment = await appointmentOf( + [ + "MSH|^~\\&|SCHED|HC|||20260601120000||SIU^S12^SIU_S12|MSG9|P|2.4", + "SCH|PLC-8|FIL-8|||||||30|min|202606", + "PID|1||PT-8^^^HC^MR", + "RGS|1|A", + "AIL|1|A|CLINIC-F", + ].join("\r"), + ); + + expect(appointment.start).toBeUndefined(); + expect(appointment.end).toBeUndefined(); + expect(appointment.minutesDuration).toBe(30); +}); + +test("a date-only appointment time becomes midnight", async () => { + const appointment = await appointmentOf( + [ + "MSH|^~\\&|SCHED|HC|||20260601120000||SIU^S12^SIU_S12|MSG10|P|2.5", + "SCH|PLC-9|FIL-9", + "TQ1|1||||||20260607", + "PID|1||PT-9^^^HC^MR", + "RGS|1|A", + "AIL|1|A|CLINIC-G", + ].join("\r"), + ); + + expect(appointment.start).toBe("2026-06-07T00:00:00Z"); +}); + +test("AIS timing is used when neither TQ1 nor SCH carries any", async () => { + const appointment = await appointmentOf( + [ + "MSH|^~\\&|SCHED|HC|||20260601120000||SIU^S12^SIU_S12|MSG6|P|2.5", + "SCH|PLC-5|FIL-5", + "RGS|1|A", + "AIS|1|A|PT-EVAL^PT Evaluation|202606050930|||60|min", + "AIL|1|A|CLINIC-D", + ].join("\r"), + ); + + expect(appointment.start).toBe("2026-06-05T09:30:00Z"); + expect(appointment.minutesDuration).toBe(60); + expect(appointment.end).toBe("2026-06-05T10:30:00Z"); +}); + +test("the SIU shape utils/hl7v2-simulator emits maps end to end", async () => { + // Mirrors buildSiu in utils/hl7v2-simulator: SCH-11 as ^^^^, no + // AIL/AIP, one AIS. Kept in step with the generator so simulated traffic that + // reaches this mapper is known to convert. + const resources = await convert( + [ + "MSH|^~\\&|SIM|SIMFAC|INTERBOX|HOSP|20260601120000||SIU^S12|SIM-1|P|2.5.1", + "SCH|PLC-SIM-1|FIL-SIM-1|||||Routine||30|MIN|^^^202606081300^202606081330||||||DOC^Who^Bob", + "PID|1||PT-SIM-1^^^SIMFAC^MR||Sim^Pat||19800101|M", + "RGS|1|A", + "AIS|1|A|PLC-SIM-1^Consult", + ].join("\r"), + ); + + const appointment = pick(resources, "Appointment")[0]!; + expect(appointment.id).toBe("simfac-fil-sim-1"); + expect(appointment.status).toBe("booked"); + expect(appointment.start).toBe("2026-06-08T13:00:00Z"); + expect(appointment.end).toBe("2026-06-08T13:30:00Z"); + expect(appointment.minutesDuration).toBe(30); + expect(appointment.reasonCode?.[0]?.coding?.[0]?.code).toBe("Routine"); + expect(appointment.serviceType?.[0]?.coding?.[0]?.display).toBe("Consult"); + expect(appointment.participant.map((p) => p.actor?.reference)).toEqual([ + "Patient/simfac-pt-sim-1", + ]); +}); + +test("unmappable SIU messages fail with a domain error naming what is missing", async () => { + const header = "MSH|^~\\&|SCHED|HC|||20260601120000||SIU^S12^SIU_S12|MSG7|P|2.5"; + + // domainError puts "/" on the error rather than in the message. + const kindOf = async (raw: string): Promise => { + try { + await convert(raw); + } catch (error) { + return (error as { kind?: string }).kind ?? ""; + } + throw new Error("expected the conversion to fail"); + }; + + // No SCH at all. + expect(await kindOf([header, "PID|1||PT-7^^^HC^MR"].join("\r"))).toBe("structure/missing_sch"); + + // SCH with neither a placer nor a filler appointment id. + expect(await kindOf([header, "SCH||||||BOOK", "PID|1||PT-7^^^HC^MR"].join("\r"))).toBe( + "field/missing_appointment_id", + ); + + // Nothing to participate: no PID, no AIL, no AIP. + expect( + await kindOf([header, "SCH|PLC-7|FIL-7", "RGS|1|A", "AIS|1|A|SVC^Service"].join("\r")), + ).toBe("structure/missing_appointment_participant"); + + // A trigger event outside the mapped set, with no SCH-25 to fall back on. + expect( + await kindOf( + [ + header.replace("SIU^S12^SIU_S12", "SIU^S99^SIU_S12"), + "SCH|PLC-7|FIL-7", + "PID|1||PT-7^^^HC^MR", + ].join("\r"), + ), + ).toBe("field/unknown_appointment_status"); +});