import type { Appointment, CodeableConcept, Schedule } from 'fhir/r4'; import { evaluateScheduleMatch, type MatchContext, type MatchCriteria } from './matchSchedules'; import { availabilityWindowsFor, type AvailabilityEventLike } from './scheduleYieldsTimes'; /** *, slice 2 - CAN THIS APPOINTMENT MOVE TO THAT PRACTITIONER? * * THE CONSTRAINT THIS EXISTS FOR, in Spencer's words (2026-08-14): *"it must respect that * practitioner's schedule and services - a reassignment into a slot they do not offer is a booking the * gate would have refused."* A reassign that skips the booking rules is a back door around every one of * them: the exam gate, the location rules, telehealth opt-in, self-bookable services. * * SO IT DELEGATES RATHER THAN RE-DERIVES. `evaluateScheduleMatch` is imported and called here, not * reimplemented - that module's own header says it is *"the ONE place a schedule is judged against the * patient's criteria"* and records that two paths kept in step by convention is exactly how * `practitionerBookingReadiness.ts` came to contradict it. A second definition of "can they take this" * would be the same defect, and the appointment organisation check spent a whole card being rescued from * precisely that shape. * * ONE THING THE DATA MODEL CANNOT ANSWER, STATED RATHER THAN GUESSED * **An Appointment does not record WHICH HealthcareService it was booked for.** The booking flow writes * `serviceType: selectedService?.type` onto the Slot, and the Appointment copies that - so what survives is * the service's TYPE CODING, never its id, while `evaluateScheduleMatch` judges services by **id**. * The service check here therefore matches on the coding: *does the target offer a self-bookable service * whose type coding matches this appointment's?* * **That is a WIDENING, and the direction is deliberate but not free.** Two distinct services can share * a type coding, so this can say yes where a strict id match would say no. It is the closest question the * stored data supports, and narrowing it would mean refusing legitimate moves on data we never captured. * Worth its own card: an Appointment that cannot name its own service is a gap wider than reassign. */ export type ReassignBlocker = /** No schedule of the target's passes the booking gate for this appointment at all. */ | 'TARGET_HAS_NO_MATCHING_BLOCK' /** They have a matching block, but none of its open hours contain this appointment's time. */ | 'TARGET_NOT_WORKING_THEN' /** They are already committed at that time. */ | 'TARGET_ALREADY_BOOKED_THEN' /** The target offers nothing of this appointment's service type. */ | 'TARGET_DOES_NOT_OFFER_SERVICE' /** A patient's time-window REQUEST has no time yet, so there is nothing to check a schedule against. */ | 'APPOINTMENT_HAS_NO_TIME'; export interface ReassignAccessors { availabilityEvents: (schedule: Schedule) => AvailabilityEventLike[]; /** Same accessor the booking screen injects - HealthcareService ids the block can be booked for. */ selfBookableServices: (schedule: Schedule) => { id: string; display: string }[]; /** A service's own `type` codings, by HealthcareService id. Empty for an unknown id. */ serviceTypeCodings: (serviceId: string) => CodeableConcept[]; } export interface ReassignAssessment { readonly appointment: Appointment; readonly feasible: boolean; readonly blockers: ReassignBlocker[]; /** The block it would move into. Set only when feasible. */ readonly scheduleId?: string; } /** Every coding on a CodeableConcept list, as `system|code`, lowercased so two spellings compare equal. */ const codingKeys = (concepts: ReadonlyArray | undefined): Set => { const keys = new Set(); for (const concept of concepts ?? []) { for (const coding of concept.coding ?? []) { if (coding.code) keys.add(`${(coding.system ?? '').toLowerCase()}|${coding.code.toLowerCase()}`); } } return keys; }; /** The appointment's span. `end` falls back to `start` so a zero-length entry still has to fit somewhere. */ const spanOf = (appointment: Appointment): { start: number; end: number } | undefined => { const start = Date.parse(appointment.start ?? ''); if (Number.isNaN(start)) return undefined; const parsedEnd = Date.parse(appointment.end ?? ''); const end = Number.isNaN(parsedEnd) ? start + (appointment.minutesDuration ?? 0) * 60_000 : parsedEnd; return { start, end: Math.max(end, start) }; }; /** * Does one open window hold the whole appointment? * * ONE window, not the union of several. Two back-to-back blocks with a gap between them are two * separate commitments, and an appointment spanning the gap is one the practitioner never agreed to - * the same rule the date picker applies (`dateHasBookableWindow`), kept identical on purpose. */ const windowHolds = (window: { start: string | Date; end: string | Date }, span: { start: number; end: number }): boolean => { const from = new Date(window.start).getTime(); const to = new Date(window.end).getTime(); if (Number.isNaN(from) || Number.isNaN(to)) return false; return from <= span.start && to >= span.end; }; /** Statuses that no longer occupy the practitioner's time, so they cannot conflict with a move. */ const SETTLED: ReadonlySet = new Set(['cancelled', 'noshow', 'entered-in-error', 'fulfilled']); const overlaps = (a: { start: number; end: number }, b: { start: number; end: number }): boolean => a.start < b.end && b.start < a.end; export interface AssessReassignInput { appointment: Appointment; /** Every Schedule belonging to the practitioner being moved TO. */ targetSchedules: Schedule[]; /** That practitioner's own existing appointments, for the conflict check. */ targetAppointments: ReadonlyArray; accessors: ReassignAccessors; /** Criteria and context for the booking gate. `practitionerId` is set by the caller to the target. */ criteria: MatchCriteria; matchContext: MatchContext; } /** * Assess ONE appointment against ONE target practitioner. * * It reports EVERY reason it failed, not the first. *"They do not work Tuesdays"* and *"they do not do * this kind of visit"* lead to different next actions, and a receptionist deciding between reassigning and * cancelling fourteen visits needs the distinction rather than a bare "no". */ export function assessReassign(input: AssessReassignInput): ReassignAssessment { const { appointment, targetSchedules, targetAppointments, accessors, matchContext } = input; const blockers: ReassignBlocker[] = []; /* THE LOCATION COMES FROM THE APPOINTMENT, NOT FROM THE CALLER - closed by construction rather than left to be remembered. A reassignment keeps the visit's Location participant (the patient was told where to go), so if the target does not serve that site the move is a booking the gate would have refused - the exact thing this module exists to prevent. A caller who simply forgot to pass `criteria.location` would otherwise get every site silently accepted. An explicit caller value still wins, for the case where the location is deliberately changing too. */ const appointmentLocation = (appointment.participant ?? []) .map((participant) => participant.actor?.reference) .find((reference) => reference?.startsWith('Location/')) ?.slice('Location/'.length); const criteria: MatchCriteria = { ...input.criteria, location: input.criteria.location ?? appointmentLocation, }; const span = spanOf(appointment); if (!span) { // A time-window request has no time to check. It is not INFEASIBLE so much as unanswerable here - // it needs placing, which is a booking decision, not a reassignment one. return { appointment, feasible: false, blockers: ['APPOINTMENT_HAS_NO_TIME'] }; } // The booking gate, called rather than copied. Anything it excludes is excluded here, by // construction - including the exam gate, the location rules and virtual-location opt-in. const gatedSchedules = targetSchedules.filter((schedule) => evaluateScheduleMatch(schedule, criteria, matchContext).ok); if (gatedSchedules.length === 0) { return { appointment, feasible: false, blockers: ['TARGET_HAS_NO_MATCHING_BLOCK'] }; } // The service the appointment was booked for, matched by CODING - see this module's header for why the // id is unavailable. An appointment with no serviceType at all imposes no service constraint. const wanted = codingKeys(appointment.serviceType); const serviceMatches = (schedule: Schedule): boolean => { if (wanted.size === 0) return true; return accessors.selfBookableServices(schedule).some((service) => [...codingKeys(accessors.serviceTypeCodings(service.id))].some((key) => wanted.has(key))); }; const offering = gatedSchedules.filter(serviceMatches); if (offering.length === 0) blockers.push('TARGET_DOES_NOT_OFFER_SERVICE'); const working = offering.filter((schedule) => availabilityWindowsFor(accessors.availabilityEvents(schedule)) .some((event) => windowHolds(event as unknown as { start: string | Date; end: string | Date }, span))); if (offering.length > 0 && working.length === 0) blockers.push('TARGET_NOT_WORKING_THEN'); // The conflict check is LAST and is not optional. A schedule saying the practitioner works at 10:00 // says nothing about whether someone else already has 10:00 - moving a visit onto an occupied hour // double-books a real patient, which is worse than the stranded appointment this whole card is about. const conflict = targetAppointments.some((existing) => { if (existing.id === appointment.id) return false; if (SETTLED.has(existing.status ?? '')) return false; const other = spanOf(existing); return other !== undefined && overlaps(span, other); }); if (conflict) blockers.push('TARGET_ALREADY_BOOKED_THEN'); if (blockers.length > 0) return { appointment, feasible: false, blockers }; return { appointment, feasible: true, blockers: [], scheduleId: working[0]?.id }; } export interface ReassignPlan { readonly movable: ReassignAssessment[]; readonly blocked: ReassignAssessment[]; } /** * Assess a whole calendar in one pass. * * The SPLIT is the deliverable, not a yes/no. *"12 of 14 can move to Dr. Chen; 2 cannot"* is the sentence * a practice can act on - and the 2 keep the choice slice 1 already built (cancel them, or leave them). * An all-or-nothing reassign would be refused by any practice with one awkward appointment in the set. */ export function planReassign( appointments: ReadonlyArray, shared: Omit, ): ReassignPlan { const assessments = appointments.map((appointment) => assessReassign({ ...shared, appointment })); return { movable: assessments.filter((a) => a.feasible), blocked: assessments.filter((a) => !a.feasible), }; } /** Plain-language reason, for a receptionist rather than a log. */ export const REASSIGN_BLOCKER_TEXT: Record = { TARGET_HAS_NO_MATCHING_BLOCK: 'they have no schedule that can take this visit', TARGET_NOT_WORKING_THEN: 'they are not working at that time', TARGET_ALREADY_BOOKED_THEN: 'they already have an appointment then', TARGET_DOES_NOT_OFFER_SERVICE: 'they do not offer this kind of visit', APPOINTMENT_HAS_NO_TIME: 'it is a pending request with no time set yet', };