import type { BatchBoardBookingDetail, BatchBoardScheduleDetail, } from "@/types/trainScheduling"; /** * Client-side forecast of what the batch engine WOULD select if it ran right now. * * The real selection only happens once the document-review window closes and staff * hit "run batch". Before that, operations can only see the *current* per-booking * state (READY / SELECTED / …). This module simulates the engine's greedy fill so * the board can show the likely winners + waiting list live, during OPEN and * DOC_REVIEW, before anything is committed. * * It mirrors the engine (booking-batch.service): rank government-first, then * priority score desc, then oldest booked; greedily board each booking while it * fits ALL THREE capacity axes at once — wagon slots, max pull weight (tons), and * train length (metres). The first booking that busts any axis, and everyone after * it, drops to the waiting list. Purely a projection; the server stays the source * of truth for the real run. */ export interface ForecastLimits { /** Wagon-slot cap (schedule.maxWagons), or null if unknown. */ maxWagons: number | null; /** Locomotive max pull weight in tons, or null. */ maxWeightTons: number | null; /** Max train length in metres, or null. */ maxLengthMeters: number | null; } /** Which capacity axis stopped a booking from boarding (for the "why not" hint). */ export type BlockingAxis = "wagons" | "weight" | "length"; export interface ForecastRow { booking: BatchBoardBookingDetail; /** 1-based rank across the whole eligible pool. */ rank: number; /** True → boards in the simulated batch; false → simulated waiting list. */ selected: boolean; /** Cumulative wagons/weight/length AFTER this booking (only when selected). */ cumulativeWagons: number; cumulativeWeightTons: number; cumulativeLengthMeters: number; /** If not selected, the first axis that would have overflowed. */ blockedBy: BlockingAxis | null; } export interface ForecastResult { rows: ForecastRow[]; selected: ForecastRow[]; waiting: ForecastRow[]; /** Bookings excluded from the sim entirely (expired / no signed contract). */ ineligible: BatchBoardBookingDetail[]; limits: ForecastLimits; /** Totals of the simulated batch. */ usedWagons: number; usedWeightTons: number; usedLengthMeters: number; /** True once any axis is at/over its cap — train is "full" in the sim. */ full: boolean; } /** Engine rank order: government first, then priority desc, then oldest booked. */ export function rankBookings( bookings: BatchBoardBookingDetail[], ): BatchBoardBookingDetail[] { const time = (b: BatchBoardBookingDetail) => b.fullyExecutedAt ? new Date(b.fullyExecutedAt).getTime() : Number.MAX_SAFE_INTEGER; return [...bookings].sort((a, b) => { if (a.isGovernment !== b.isGovernment) return a.isGovernment ? -1 : 1; if (b.priorityScore !== a.priorityScore) return b.priorityScore - a.priorityScore; return time(a) - time(b); }); } /** * A booking can compete in the batch only once its contract is signed. Expired * bookings and pending-contract bookings never board, so they're pulled out of the * sim (surfaced separately so they don't vanish from the board). */ function isEligible(b: BatchBoardBookingDetail): boolean { return b.state !== "EXPIRED" && b.state !== "PENDING_CONTRACT"; } const round2 = (n: number) => Math.round(n * 100) / 100; /** Would adding `add` to `used` exceed `cap`? (cap null ⇒ axis unconstrained.) */ function overflows(used: number, add: number, cap: number | null): boolean { return cap != null && used + add > cap; } export function simulateBatch( bookings: BatchBoardBookingDetail[], limits: ForecastLimits, ): ForecastResult { const ranked = rankBookings(bookings); const eligible = ranked.filter(isEligible); const ineligible = ranked.filter((b) => !isEligible(b)); const rows: ForecastRow[] = []; let wagons = 0; let weight = 0; let length = 0; // Once the train is full we stop boarding, but keep ranking the rest as waiting. let full = false; eligible.forEach((booking, i) => { let blockedBy: BlockingAxis | null = null; if (!full) { if (overflows(wagons, booking.wagons, limits.maxWagons)) blockedBy = "wagons"; else if (overflows(weight, booking.weightTons, limits.maxWeightTons)) blockedBy = "weight"; else if (overflows(length, booking.lengthMeters, limits.maxLengthMeters)) blockedBy = "length"; } // Strict fill: the first booking that doesn't fit closes the train, so lower- // priority bookings can't leapfrog it even if they'd individually fit. Matches // the engine's greedy pass. const selected = !full && blockedBy === null; if (selected) { wagons += booking.wagons; weight = round2(weight + booking.weightTons); length = round2(length + booking.lengthMeters); } else { full = true; } rows.push({ booking, rank: i + 1, selected, cumulativeWagons: selected ? wagons : 0, cumulativeWeightTons: selected ? weight : 0, cumulativeLengthMeters: selected ? length : 0, blockedBy: selected ? null : (blockedBy ?? firstBindingAxis(limits)), }); }); return { rows, selected: rows.filter((r) => r.selected), waiting: rows.filter((r) => !r.selected), ineligible, limits, usedWagons: wagons, usedWeightTons: weight, usedLengthMeters: length, full, }; } /** When the train closed on an earlier booking, name the tightest axis for the hint. */ function firstBindingAxis(limits: ForecastLimits): BlockingAxis { if (limits.maxWagons != null) return "wagons"; if (limits.maxWeightTons != null) return "weight"; return "length"; } /** Pull the three capacity caps off the board detail response. */ export function limitsFromDetail( data: BatchBoardScheduleDetail, ): ForecastLimits { return { maxWagons: data.capacity.maxWagons ?? null, maxWeightTons: data.capacity.maxWeightTons ?? data.locomotive?.maxPullWeightTons ?? null, maxLengthMeters: data.capacity.maxLengthMeters ?? data.locomotive?.maxTrainLengthMeters ?? null, }; } /** * The forecast is meaningful before the batch is committed — i.e. while bookings * are still being taken or reviewed. Once the engine has run (PAYMENT onward) the * real per-booking state is the truth, so we stop showing the projection. */ export function forecastIsLive( phase: BatchBoardScheduleDetail["windowPhase"], ): boolean { return phase === "PRE_WINDOW" || phase === "OPEN" || phase === "DOC_REVIEW"; }