import { BadRequestException, ConflictException, forwardRef, Inject, Injectable, Logger, NotFoundException, OnModuleInit, Optional, } from '@nestjs/common'; import { InjectDataSource } from '@nestjs/typeorm'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { SchedulerRegistry } from '@nestjs/schedule'; import { Between, DataSource, FindOptionsWhere, ILike, In, IsNull, LessThanOrEqual, MoreThanOrEqual, } from 'typeorm'; import { Booking } from '../bookings/entities/booking.entity'; import { BookingsRepository } from '../bookings/bookings.repository'; import { BookingPricingService } from '../bookings/booking-pricing.service'; import { formatRouteLabel } from '../routes/entities/route.entity'; import { isRoadService } from '../bookings/road.util'; import { RouteMilestone } from '../routes/entities/route-milestone.entity'; import { Yard } from '../rule-engine/entities/yard.entity'; import { CargoType } from '../rule-engine/entities/cargo-type.entity'; import { ContainerType } from '../rule-engine/entities/container-type.entity'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity'; import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository'; import { TrainScheduleBookingsRepository } from '../train-schedules/train-schedule-bookings.repository'; import { BookingNotifierService } from './booking-notifier.service'; import { TrainSchedulingService, effectiveWindowConfig, } from './services/train-scheduling.service'; import { eatDay, listConfigBookingWindows } from './batch-window.util'; import { BATCH_BOARD_STATUSES, BatchBoardQueryDto, } from './dto/batch-board-query.dto'; import { Freight, PaginatedResponse, TrainScheduleStatus as TrainScheduleStatusEnum, } from "@edr/types"; import { BillingService } from "../billing/billing.service"; import { buildPaginationMeta, normalizePagination, } from '../../common/utils/pagination.util'; import { DEFAULT_BULK_WAGON_CAPACITY_TONS, DEFAULT_BULK_WAGON_LENGTH_METERS, DEFAULT_BULK_WAGON_TARE_TONS, DEFAULT_CONTAINER_WAGON_CAPACITY_TONS, DEFAULT_CONTAINER_WAGON_LENGTH_METERS, DEFAULT_CONTAINER_WAGON_TARE_TONS, DEFAULT_WAGONS_PER_BOOKING, PAYMENT_REMINDER_LEAD_MS, payWindowLapsed, paymentDrainMs, } from "./booking-batch.constants"; import { LocomotiveLimits, WagonTypeDimensions, bookingCargoTons, bulkItemsFitFor, bulkItemWagonsRequired, bulkTonsPerWagon, bookingGrossWeightTons, deriveTrainCapacityFromLocomotive, sizePartialOfferWagons, trainHardCaps, trainSetLocomotiveLimits, wagonTypeDimensionsFromEntity, } from './train-capacity.util'; import { WagonType } from '../wagon-types/entities/wagon-type.entity'; import { Wagon } from '../wagons/entities/wagon.entity'; import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service'; import { BookingSplitService } from './booking-split.service'; import { RemainderPlacementService } from './remainder-placement.service'; import { BookingWindowGateway } from './booking-window.gateway'; import { MAX_TEU_SLOTS_PER_WAGON, containerWagonsForLines, roundTons, } from './utils/wagon-plan.util'; import { Capacity, CorridorBudget, CorridorLeg, OverageTolerance, addCoupledWagons, stopYardsFor, subtractCutWagons, } from './corridor-capacity.util'; import { WagonStockLedger } from './wagon-stock-ledger.util'; export type { Capacity } from './corridor-capacity.util'; /** * A train's fill limits: the base caps the corridor budget spends from, plus * the locomotive overage tolerance spendable only on whole-booking admission. */ type TrainLimits = { base: Capacity; tolerance: OverageTolerance }; /** * Result of the export whole-booking single-train space check. `scheduleId` * is the earliest fillable train that carries the whole booking, or null when * none can — then `bestAvailable` reports the largest single-train leftover * in the booking's own units and `fullMessage` is the customer-facing copy. */ export interface ExportSpaceReport { scheduleId: string | null; trainsForDay: boolean; corridorMatched: boolean; need: Capacity; bestAvailable: { wagons: number; cargoTons: number } | null; fullMessage: string | null; } /** * One export train the customer can pick for a shipment day: live free-wagon * space measured against THE BOOKING'S allowed wagon types (so the per-type * list doubles as "what cargo this train can take for you"). Unpaid holds * count as taken; lapsed holds free up via the lazy-expiry capacity filter. */ export interface ExportTrainOption { scheduleId: string; /** Schedule's train number (falls back to the built train's number). */ trainNumber: string | null; /** Built train's name/code, when the schedule runs a Train Builder train. */ trainName: string | null; departure: Date; /** Booking cutoff for this train (windowClosesAt), null on legacy rows. */ bookingClosesAt: Date | null; /** Whether the export FCFS window is open for booking right now. */ isOpen: boolean; /** Best bookable wagons across the booking's allowed types. */ freeWagons: number; /** Wagons this booking needs — `fits` = freeWagons >= neededWagons. */ neededWagons: number; fits: boolean; byWagonType: Array<{ wagonTypeId: string | null; code: string | null; name: string | null; freeWagons: number; }>; } /** Form-entered cargo for a train-options probe (nothing persisted yet). */ export interface TrainOptionCargoOverrides { /** Container types drive the per-type space. */ containerTypeIds?: string[]; /** Size labels ("20ft"/"40ft") when the form has no type ids. */ containerSizes?: string[]; /** Bulk counterparts of the container inputs. */ cargoTypeId?: string; cargoTypeCode?: string; /** Needed wagons estimate from the form (drives the `fits` flag). */ wagons?: number; } /** A train a paid-unallocated booking can board (route + capacity verified). */ export interface AllocationCandidate { id: string; reference: string | null; direction: string | null; scheduledDepartureDate: Date; } /** A day-level pool key: all trains on this route departing on this EAT day. */ interface RouteDayGroup { originYardId: string; destinationYardId: string; /** EAT calendar day, `yyyy-MM-dd`. */ day: string; } /** One wagon type's footprint: its length on the train, the tare it adds to the * locomotive's gross load, and the payload it carries. */ type PerWagonDims = { lengthMeters: number; tareWeightTons: number; capacityTons: number }; /** * Wagon dimensions used to size a booking's capacity draw. `byWagonTypeId` holds * every wagon type so a booking is measured on the type its cargo/container type * actually rides (the same FK resolution allocation uses); `container`/`bulk` are * representative fallbacks for bookings whose type has no wagon type configured. */ type WagonDims = { container: PerWagonDims; bulk: PerWagonDims; byWagonTypeId: Map; }; export type BatchBoardBookingState = | "ALLOCATED" | "SELECTED_FOR_BATCH" | "READY" | "WAITING" | "PENDING_CONTRACT" | "EXPIRED"; export interface BatchBoardBooking { id: string; reference: string; company: string; isGovernment: boolean; wagons: number; weightTons: number; lengthMeters: number; paymentDeadline: string | null; state: BatchBoardBookingState; /** Rule-engine priority score used to rank the batch (higher = boards first). */ priorityScore: number; /** CONTAINER | BULK — for the priority-tracking visuals. */ freightType: string | null; } export type BookingAllocationStatus = | "NOT_ATTEMPTED" | "ASSIGNED" | "DEFERRED" | "FAILED"; export interface BatchBoardBookingDetail extends BatchBoardBooking { /** * 0-based booking-window cycle this booking entered the pool in (derived from * `fullyExecutedAt` against the schedule's window cycles). Ranking compares * bookings within a cycle only — an earlier cycle always boards before a later * one regardless of score. Null while the contract is still pending. */ windowCycleNo: number | null; fullyExecutedAt: string | null; selectedForBatchAt: string | null; allocationStatus: BookingAllocationStatus; allocationIssue: string | null; /** Set when this booking shares a wagon with a consolidation partner. */ consolidationPartnerId: string | null; consolidationPartnerRef: string | null; } export interface BatchBoardCounts { allocated: number; selectedForBatch: number; ready: number; waiting: number; expired: number; pendingContract: number; } /** A booking bucket on the detail board (in-window vs pending-contract). */ export interface BatchBoardBucket { counts: BatchBoardCounts; bookings: BatchBoardBookingDetail[]; } export interface BatchBoardScheduleDetail { scheduleId: string; /** Human-facing schedule reference (S-YYYY-NNNNN). */ scheduleReference: string | null; trainNumber: string | null; routeName: string | null; origin: string | null; destination: string | null; scheduleDate: string | null; status: string; bookingWindowStatus: string; direction: string | null; windowPhase: string | null; windowOpensAt: string | null; windowClosesAt: string | null; docReviewEndsAt: string | null; paymentPhaseEndsAt: string | null; bookingCycleNo: number; /** Built train (Train Builder) behind this departure, when scheduled by train. */ train: BatchBoardSchedule["train"]; locomotive: BatchBoardSchedule["locomotive"]; capacity: BatchBoardSchedule["capacity"]; counts: BatchBoardSchedule["counts"]; /** Bookings inside the schedule's booking window (fully-executed contracts). */ bookings: BatchBoardBookingDetail[]; pendingContract: BatchBoardBucket; allocationViolations: string[]; } export interface BatchBoardSchedule { scheduleId: string; /** Human-facing schedule reference (S-YYYY-NNNNN). */ scheduleReference: string | null; trainNumber: string | null; routeName: string | null; origin: string | null; destination: string | null; scheduleDate: string | null; createdAt: string | null; status: string; bookingWindowStatus: string; direction: string | null; windowPhase: string | null; windowOpensAt: string | null; windowClosesAt: string | null; docReviewEndsAt: string | null; paymentPhaseEndsAt: string | null; bookingCycleNo: number; /** Built train (Train Builder) behind this departure, when scheduled by train. */ train: { id: string; code: string; trainName: string | null; } | null; locomotive: { code: string; name: string | null; maxPullWeightTons: number; maxTrainLengthMeters: number; } | null; capacity: { /** Wagons on bookings already linked to the train (ALLOCATED only). */ allocatedWagons: number; /** Train length used by allocated bookings (from wagon-type dimensions). */ allocatedLengthMeters: number; maxLengthMeters: number | null; /** * Weight committed on the train (allocated + selected-for-batch). On a * multi-stop corridor this is the HEAVIEST single edge, not the sum — * disjoint legs (intercity + export) never ride together, so summing * them over-reports the train against the pull limit. */ usedWeightTons: number; maxWeightTons: number | null; /** Wagon-slot cap for the train (locomotive/wagon-type derived). */ maxWagons: number | null; /** Physical consist length of the built train (Train Builder), null without one. */ trainLengthMeters: number | null; /** Committed gross weight per corridor edge, in stop order; null on 2-stop routes. */ legUsage: Array<{ from: string; to: string; usedWeightTons: number }> | null; }; counts: { allocated: number; selectedForBatch: number; ready: number; waiting: number; pendingContract: number; expired: number; }; bookings: BatchBoardBooking[]; } /** Paginated batch-board list in the shared `{items, meta}` envelope — the API * response wrapper already uses `data`, and the frontend's unwrap() strips one * `data` level. */ export type BatchBoardListResponse = PaginatedResponse; /** * Demand-batching engine: every 3h (EAT) it ranks each OPEN schedule's ready pool * by priority, greedily fills the train to capacity (skipping bookings that don't fit), * reserves a 1h pay window for commercial customers (government allocated unpaid, * preempting lower-priority commercial if needed), then settles each batch 1h later — * allocating those who paid and expiring those who didn't, topping up from the waiting list. * Capacity is bounded on three axes at once: wagon count (`schedule.maxWagons`), the * locomotive's max pull weight, and its max train length (also capped by global rules). */ @Injectable() export class BookingBatchService implements OnModuleInit { private readonly logger = new Logger(BookingBatchService.name); /** * Serialises settle/top-up per schedule. The PAYMENT phase transition and the * tick's overdue backstop both call settleDueReservations for the same schedule * in the same second; without this they interleave and the top-up runs against a * schedule whose phase has already been concluded. */ private readonly scheduleLocks = new Map>(); constructor( @InjectDataSource() private readonly dataSource: DataSource, private readonly bookingsRepository: BookingsRepository, private readonly trainSchedulesRepository: TrainSchedulesRepository, private readonly trainScheduleBookingsRepository: TrainScheduleBookingsRepository, private readonly notifier: BookingNotifierService, private readonly scheduler: SchedulerRegistry, // forwardRef: TrainSchedulingService injects this service back (window // refresh after adjust-consist), so the classes load in a cycle. @Inject(forwardRef(() => TrainSchedulingService)) private readonly trainSchedulingService: TrainSchedulingService, private readonly billing: BillingService, private readonly bookingWindowGateway: BookingWindowGateway, @Inject(forwardRef(() => BookingPricingService)) private readonly pricingService: BookingPricingService, @Optional() private readonly milestoneService?: ClearanceMilestoneService, @Optional() private readonly splitService?: BookingSplitService, // Optional so hand-constructed spec instances keep compiling. @Optional() private readonly eventEmitter?: EventEmitter2, @Optional() @Inject(forwardRef(() => RemainderPlacementService)) private readonly remainderPlacement?: RemainderPlacementService, ) {} /** * Auto-place a paid booking's split remainder onto the next fitting train. * Gated so it can ship dark: off unless FREIGHT_AUTO_REMAINDER=true. */ private get autoRemainderEnabled(): boolean { return process.env.FREIGHT_AUTO_REMAINDER === "true"; } /** * Let EXPORT bookings split (offer the largest fitting part, leftover rebooks * on the next train). Separate flag from auto-remainder: export touches the * FCFS money path, so partial-offer can be enabled independently. */ private get exportSplitEnabled(): boolean { return process.env.FREIGHT_EXPORT_SPLIT === "true"; } /** On boot, reconcile OPEN route-days and re-arm settle timers. */ async onModuleInit(): Promise { const groups = await this.openRouteDayGroups(); for (const group of groups) { try { await this.processRouteDay(group); } catch (err) { this.logger.warn( `Boot reconcile failed for ${this.groupLabel(group)}: ${(err as Error).message}`, ); } } const reserved = await this.dataSource .getRepository(Booking) .createQueryBuilder("b") .select("DISTINCT b.train_schedule_id", "scheduleId") .where( `b.status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT', 'PAYMENT_VERIFICATION_IN_PROGRESS')`, ) .andWhere("b.train_schedule_id IS NOT NULL") .getRawMany<{ scheduleId: string }>(); for (const { scheduleId } of reserved) this.armSettle(scheduleId); } /** * Fire-and-forget batch pipeline for the (route, day) a schedule belongs to * (contract sign, payment). Day-level pooling distributes across all of that * day's trains, so a single schedule id maps to its whole route-day group. */ enqueueScheduleProcessing(scheduleId: string): void { void this.processRouteDayForSchedule(scheduleId).catch((err) => this.logger.error( `processRouteDay for schedule ${scheduleId} failed: ${(err as Error).message}`, ), ); } /** * Fire-and-forget batch pipeline for a (route, day) directly — used when a * booking enters the pool without a target train yet (e.g. after the * operations team accepts an operation request). The booking is already * FULLY_EXECUTED with its scheduled_date set, so the day-level fill will pick * it up; this just runs that fill immediately instead of waiting for the cron. */ enqueueRouteDayProcessing( originYardId: string, destinationYardId: string, day: string, ): void { void this.processRouteDay({ originYardId, destinationYardId, day }).catch( (err) => this.logger.error( `processRouteDay for ${originYardId}→${destinationYardId} on ${day} failed: ${(err as Error).message}`, ), ); } /** Resolve a schedule's (route, day) group and run the day-level pipeline. */ private async processRouteDayForSchedule(scheduleId: string): Promise { const schedule = await this.trainSchedulesRepository.findById(scheduleId); if (!schedule?.scheduledDepartureDate) return; await this.processRouteDay({ originYardId: schedule.originStationId, destinationYardId: schedule.destinationStationId, day: eatDay(schedule.scheduledDepartureDate), }); } /** * Day-level pipeline: distribute the (route, day) pool across all its trains, * then settle / reconcile / assign wagons per schedule (those steps stay * schedule-scoped — only the fill is day-level). */ async processRouteDay(group: RouteDayGroup): Promise { this.logger.log( `[BATCH] processRouteDay START ${group.originYardId}->${group.destinationYardId} ${group.day}`, ); const scheduleIds = await this.fillRouteDay( group.originYardId, group.destinationYardId, group.day, ); // Backstop: PAID bookings stranded without a schedule (hold expired before // the payment landed) get re-placed onto whatever fits today. await this.rescueStrandedPaidForDay(group.day); for (const scheduleId of scheduleIds) { await this.settleDueReservations(scheduleId); await this.reconcilePaidUnlinked(scheduleId); await this.trainSchedulingService.tryAutoWagonAllocation(scheduleId); } } /** Fill pool, settle due reservations, link orphaned PAID, then assign wagons. */ async processSchedule(scheduleId: string): Promise { await this.fillSchedule(scheduleId); await this.settleDueReservations(scheduleId); await this.reconcilePaidUnlinked(scheduleId); await this.trainSchedulingService.tryAutoWagonAllocation(scheduleId); } /** * Distinct (origin, destination, EAT day) groups across LEGACY OPEN schedules — * schedules with a `windowPhase` are driven exclusively by the window engine * (BookingWindowService), never by the periodic legacy fill. */ private async openRouteDayGroups(): Promise { const open = ( await this.trainSchedulesRepository.findAll({ where: [ { bookingWindowStatus: "OPEN", status: TrainScheduleStatusEnum.Draft }, { bookingWindowStatus: "OPEN", status: TrainScheduleStatusEnum.Scheduled }, ], }) ).filter((s) => s.windowPhase == null); const groups = new Map(); for (const s of open) { if (!s.scheduledDepartureDate) continue; const day = eatDay(s.scheduledDepartureDate); const key = `${s.originStationId}|${s.destinationStationId}|${day}`; if (!groups.has(key)) { groups.set(key, { originYardId: s.originStationId, destinationYardId: s.destinationStationId, day, }); } } return [...groups.values()]; } private groupLabel(group: RouteDayGroup): string { return `${group.originYardId}→${group.destinationYardId} on ${group.day}`; } /** * Idempotent: link a paid batch booking to its schedule and assign wagons. * Handles SELECTED_FOR_BATCH, PAID-without-link, and PAID-already-linked cases. */ async ensurePaidBookingAllocated(bookingId: string): Promise { const booking = await this.dataSource.getRepository(Booking).findOne({ where: { id: bookingId }, relations: { company: true }, }); if (!booking) return; // A dead booking keeps payment_status = 'PAID' (it was paid before it died), // so every rescue path below would happily re-place and re-allocate it — // that is how a cancelled consolidation-lapse booking came back onto its // train 30s after being cancelled. Never resurrect a dead booking. if (["CANCELLED", "EXPIRED", "REJECTED", "COMPLETED"].includes(booking.status)) return; if (!booking.trainScheduleId) { // A paid booking with no train is money taken and nothing boarding. The // hold was expired before the payment landed (webhook lag beat the // reconcile, or the stranding predates it) — try to re-place it on a // fitting same-day train before falling back to a manual-assign scream. if (booking.paymentStatus === "PAID" || booking.status === "PAID") { const rescuedScheduleId = await this.replaceStrandedPaidBooking(booking); if (!rescuedScheduleId) { this.logger.error( `PAID booking ${booking.reference ?? bookingId} has no train_schedule_id — ` + `its reservation was likely expired before the payment landed and no ` + `same-day train fits it. Assign it to a schedule manually from the batch board.`, ); return; } booking.trainScheduleId = rescuedScheduleId; } else { return; } } if (!booking.trainScheduleId) return; // unreachable — narrows the rescue path for TS const isBatchPaid = booking.status === "SELECTED_FOR_BATCH" || booking.status === "AWAITING_PAYMENT" || booking.status === "PAID" || booking.paymentStatus === "PAID"; if (!isBatchPaid) return; if ( booking.status === "SELECTED_FOR_BATCH" || booking.status === "AWAITING_PAYMENT" ) { await this.dataSource .getRepository(Booking) .update(bookingId, { paymentStatus: "PAID", status: "PAID" }); } else if (booking.paymentStatus !== "PAID") { await this.dataSource .getRepository(Booking) .update(bookingId, { paymentStatus: "PAID" }); } // Paying inside the window accepts an open partial offer — reduce the booking // to the offered part before it boards (remainder returns to the contract cap). if (this.splitService) { await this.splitService.applySplit(bookingId); // The split only happens on payment (here) — so auto-placing the remainder // also only happens once the customer has accepted+paid. Re-read to see if // applySplit actually reduced this booking (an open offer existed); if so, // auto-create + place the remainder booking on the next fitting train. // applySplit committed its own transaction before returning, so this reads // the reduced lines. Best-effort: a placement failure never blocks the // paid booking from boarding — the remainder falls back to manual rebook. if (this.autoRemainderEnabled && this.remainderPlacement) { const split = await this.dataSource .getRepository(Booking) .findOne({ where: { id: bookingId } }); // Export remainders only auto-place when export split is on — otherwise // an export booking never splits in the first place. const directionOn = split?.tradeDirection !== "EXPORT" || this.exportSplitEnabled; if (split?.isSplit && directionOn) { await this.remainderPlacement .placeRemainder(split) .catch((err) => this.logger.error( `Auto-place remainder failed for ${split.reference}: ${ err instanceof Error ? err.message : String(err) }`, ), ); } } } const linked = await this.trainScheduleBookingsRepository.existsForBooking(bookingId); // Intercity is allocated MANUALLY: payment secures the ride, staff then // place it on whichever same-route train suits (intercity panel). Unpin // from the train it reserved against — that train may be the wrong one by // the time it departs — and return it to the waiting pool as PAID. if (!linked && booking.tradeDirection === "DOMESTIC" && !booking.isGovernment) { await this.dataSource.getRepository(Booking).update(bookingId, { trainScheduleId: null, schedulingStatus: "ELIGIBLE", paymentDeadline: null, } as never); this.logger.log( `[BATCH] intercity ${booking.reference ?? bookingId} PAID — awaiting manual placement by staff`, ); void this.completeTrackingMilestones(bookingId, [ "FREIGHT_PAYMENT_PENDING", "FREIGHT_PAYMENT_SETTLED", ]); this.notifyBoardChanged(booking.trainScheduleId, "intercity_paid_unplaced"); return; } if (!linked) { if (await this.holdIfWagonShort(booking.trainScheduleId, booking)) return; await this.allocate(booking.trainScheduleId, booking, "paid"); this.logger.log( `Linked PAID booking ${booking.reference ?? bookingId} to schedule ${booking.trainScheduleId}`, ); } else { // Already linked at booking time (export FCFS: the customer books a // specific train, so allocate() ran up front). allocate() is where the // payment-settled tracking milestones are written, so on this branch we // record them here — otherwise a paid, already-linked booking leaves // FREIGHT_PAYMENT_SETTLED stuck PENDING and the clearance step never ticks. void this.completeTrackingMilestones(bookingId, [ "WAGON_REQUESTED", "FREIGHT_PAYMENT_PENDING", "FREIGHT_PAYMENT_SETTLED", ]); void this.markWagonAllocatedMilestone(bookingId); } const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph( booking.trainScheduleId, ); if (schedule && (await this.isTrainFull(schedule))) { await this.setWindow(booking.trainScheduleId, "FULL"); // This payment may have been the last live pay window on a now-full // export day — the settle that normally re-runs the sweep finds nothing // left to settle, so trigger it here. void this.expireLeftoverExportDay(booking.trainScheduleId); } const result = await this.trainSchedulingService.tryAutoWagonAllocation( booking.trainScheduleId, ); if (result.assignedBookingIds.length) { this.logger.log( `Wagon allocation for ${booking.reference ?? bookingId}: ${result.assignedBookingIds.length} assigned`, ); } if ( result.issues.some( (i) => i.bookingId === bookingId && i.status !== "ASSIGNED", ) ) { const issue = result.issues.find((i) => i.bookingId === bookingId); this.logger.warn( `Wagon allocation issue for ${booking.reference ?? bookingId}: ${issue?.issue ?? issue?.status}`, ); } this.notifyBoardChanged(booking.trainScheduleId, "booking_paid_allocated"); } /** Customer paid — delegate to ensurePaidBookingAllocated. */ async confirmPaidAndAllocate(bookingId: string): Promise { await this.ensurePaidBookingAllocated(bookingId); } /** * A late settlement paid a partial offer whose window had lapsed — bring the * offer back so `ensurePaidBookingAllocated`'s applySplit still reduces the * booking to what was actually bought. No-op without the split feature. */ async reviveOfferForInvoice(invoiceId: string): Promise { await this.splitService?.reviveOfferForInvoice(invoiceId); } /** * Day-level backstop for stranded PAID bookings: reconcilePaidUnlinked is * keyed on train_schedule_id, so a booking whose hold was expired (schedule * cleared) before its payment landed never re-enters it. Sweep the day's * PAID-but-unscheduled bookings through ensurePaidBookingAllocated, which * re-places them on a fitting train. */ private async rescueStrandedPaidForDay(day: string): Promise { const stranded: Array<{ id: string }> = await this.dataSource.query( `SELECT id FROM freight.bookings WHERE deleted_at IS NULL AND train_schedule_id IS NULL AND (payment_status = 'PAID' OR status = 'PAID') AND scheduled_date IS NOT NULL AND DATE(scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = $1`, [day], ); for (const { id } of stranded) { await this.ensurePaidBookingAllocated(id).catch((err) => this.logger.error( `Stranded-PAID rescue failed for booking ${id}: ${(err as Error).message}`, ), ); } } /** * Re-place a PAID booking whose hold was expired before the payment landed * (trainScheduleId already cleared). Picks the earliest same-day train that * still fits the booking's whole need on ITS OWN leg and pins the booking to * it. Returns the schedule id, or null when no train fits (manual assign). */ private async replaceStrandedPaidBooking( booking: Booking, ): Promise { if (!booking.scheduledDate) return null; // The booking loaded by ensurePaidBookingAllocated carries no cargo // relations; needFor/fittingTrainsForDay derive the wagon need from them. const full = await this.dataSource.getRepository(Booking).findOne({ where: { id: booking.id }, relations: { bookingContainers: { containerType: true }, cargoType: true, }, }); if (!full) return null; const day = eatDay(new Date(booking.scheduledDate)); const direction = booking.tradeDirection === "EXPORT" ? "EXPORT" : "IMPORT"; const wagonDims = await this.loadWagonDims(); const need = this.needFor(full, wagonDims); const fitting = await this.fittingTrainsForDay(full, day, direction); const target = fitting.find((t) => t.freeWagons >= need.wagons); if (!target) return null; await this.dataSource .getRepository(Booking) .update(booking.id, { trainScheduleId: target.scheduleId }); this.logger.warn( `[BATCH] re-placed stranded PAID booking ${booking.reference ?? booking.id} ` + `onto schedule ${target.scheduleId} — its hold expired before the payment landed`, ); return target.scheduleId; } /** Open partial-capacity offer summary for booking detail payloads (null when none). */ async getOpenOfferSummary(bookingId: string): Promise<{ offeredWagons: number; totalWagons: number; offeredAmount: number; paymentDeadline: Date; } | null> { if (!this.splitService) return null; const offer = await this.splitService.findOpenOffer(bookingId); if (!offer) return null; return { offeredWagons: offer.offeredWagons, totalWagons: offer.totalWagons, offeredAmount: Number(offer.offeredAmount), paymentDeadline: offer.paymentDeadline, }; } // ---- export FCFS ----------------------------------------------------------- /** * Whole-booking single-train space report for an EXPORT booking. Export * bookings never split — the entire booking must ride ONE train, so the * report scans every fillable export train on the booking's corridor/day * (earliest first) for one whose remaining budget fits the whole need. When * none fits, `bestAvailable` carries the largest single-train leftover * converted into the booking's own units (base caps, no overage tolerance) * so the customer can be told exactly how much he COULD book on that day. */ async exportSpaceReport( booking: Booking, need?: Capacity, ): Promise { if (!booking.scheduledDate) { throw new BadRequestException('Booking has no scheduled date'); } const day = eatDay(new Date(booking.scheduledDate)); // Corridor-aware: any train whose route carries the booking's origin // strictly before its destination qualifies — a Dire→Djibouti booking may // ride an Addis→…→Djibouti train. The leg check below (legOf) enforces the // stop order, so we fetch the day's open trains without endpoint filters. const corridor = await this.trainSchedulesRepository.findAll({ where: [ // Dedicated shipping-line trains are never customer-booking targets. { status: TrainScheduleStatusEnum.Draft, shippingLineCompanyId: IsNull() }, { status: TrainScheduleStatusEnum.Scheduled, shippingLineCompanyId: IsNull() }, ], }); // A customer-picked train narrows the scan to that ONE schedule: export // FCFS honors the pick or fails loudly (exportFullMessage names it). const requestedId = booking.requestedTrainScheduleId ?? null; const candidates = corridor .filter( (s) => s.scheduledDepartureDate != null && eatDay(s.scheduledDepartureDate) === day && this.isFillable(s) && (!requestedId || s.id === requestedId), ) .sort( (a, b) => a.scheduledDepartureDate.getTime() - b.scheduledDepartureDate.getTime(), ); const wagonDims = await this.loadWagonDims(); const required = need ?? this.needFor(booking, wagonDims); const dims = this.dimsFor(booking, wagonDims); const report: ExportSpaceReport = { scheduleId: null, trainsForDay: candidates.length > 0, corridorMatched: false, need: required, bestAvailable: null, fullMessage: null, }; for (const candidate of candidates) { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph( candidate.id, ); const locomotive = trainSetLocomotiveLimits(schedule?.trainSet); if (!schedule || !locomotive) continue; const limits = await this.capacityLimits(locomotive); const budget = await this.remainingBudget(schedule, limits, wagonDims, [ booking.id, ]); const leg = budget.legOf(booking.originYardId, booking.destinationYardId); if (!leg) continue; // this train's route doesn't carry the booking's leg report.corridorMatched = true; if (budget.fits(required, leg)) { // Earliest fitting train wins — no need to keep sizing leftovers. report.scheduleId = schedule.id; return report; } const available = this.bookableWithin(budget.remainingFor(leg), dims); if ( !report.bestAvailable || available.cargoTons > report.bestAvailable.cargoTons || (available.cargoTons === report.bestAvailable.cargoTons && available.wagons > report.bestAvailable.wagons) ) { report.bestAvailable = available; } } report.fullMessage = this.exportFullMessage(booking, report); return report; } /** * Largest booking (in the requester's own wagon-type units) that a single * train's leftover base capacity could still admit: bounded by free wagon * slots, free train length, and the locomotive's remaining pull weight * (gross — each wagon's tare eats into it before any cargo does). */ private bookableWithin( remaining: Capacity, dims: PerWagonDims, ): { wagons: number; cargoTons: number } { const byLength = dims.lengthMeters > 0 ? Math.floor(Math.max(0, remaining.lengthMeters) / dims.lengthMeters) : Math.floor(Math.max(0, remaining.wagons)); const maxWagons = Math.max( 0, Math.min(Math.floor(Math.max(0, remaining.wagons)), byLength), ); let bestTons = 0; let usableWagons = 0; for (let w = 1; w <= maxWagons; w++) { if (w * dims.tareWeightTons > remaining.weightTons) break; usableWagons = w; const tons = Math.min( w * dims.capacityTons, remaining.weightTons - w * dims.tareWeightTons, ); if (tons > bestTons) bestTons = tons; } return { wagons: usableWagons, cargoTons: Math.max(0, Math.floor(bestTons * 1000) / 1000), }; } /** Customer-facing "train is full" copy carrying the bookable leftover. */ private exportFullMessage(booking: Booking, report: ExportSpaceReport): string { const picked = Boolean(booking.requestedTrainScheduleId); if (!report.trainsForDay || !report.corridorMatched) { return picked ? 'The selected train is no longer accepting bookings — pick another train or day.' : 'No export train is accepting bookings for this day'; } const best = report.bestAvailable; const base = picked ? 'Not enough space left on the selected train — an export booking must ' + 'ride one train whole. ' : 'Not enough train space — an export booking must ride a single train whole, ' + 'and no open train on this day can carry it. '; if (!best || best.wagons <= 0) { return base + 'No capacity is left on this day — pick another shipment day.'; } if (booking.freightType === 'BULK') { return ( base + `The largest remaining space is about ${best.cargoTons} tons ` + `(${best.wagons} wagon${best.wagons === 1 ? '' : 's'}) — book up to that amount or pick another day.` ); } return ( base + `The largest remaining space is ${best.wagons} wagon${best.wagons === 1 ? '' : 's'} ` + `(up to ${best.wagons * 2} × 20ft or ${best.wagons} × 40ft, weight permitting) — ` + 'reduce the booking or pick another day.' ); } /** * Export is first-come-first-serve: no window cycle, no priority, no batch. * Pick the earliest open export train on the booking's corridor/day that still * fits the booking. Throws ConflictException when every train is full — the * staff accept fails and no more export bookings are taken. */ async pickExportSchedule(booking: Booking, need?: Capacity): Promise { const report = await this.exportSpaceReport(booking, need); if (report.scheduleId) return report.scheduleId; throw new ConflictException( report.fullMessage ?? 'Train is full — no export capacity left for this day', ); } /** * Trains that can carry a booking's leg on a given day, earliest departure * first, each with the largest number of wagons it could still admit for the * booking's wagon type. Direction-filtered: EXPORT bookings see export trains, * IMPORT/DOMESTIC see non-export trains. Measures against the booking's FULL * allowed wagon-type set ({@link dimsForAllowed}) so a train stocking a * non-primary allowed type still counts. The remainder placer uses this to * pick the next fitting train; the `free` wagon count is the best across the * allowed types (a train fits under whichever allowed type gives most room). */ async fittingTrainsForDay( booking: Booking, day: string, direction: "IMPORT" | "EXPORT", ): Promise> { const corridor = await this.trainSchedulesRepository.findAll({ where: [ // Dedicated shipping-line trains are never customer-booking targets. { status: TrainScheduleStatusEnum.Draft, shippingLineCompanyId: IsNull() }, { status: TrainScheduleStatusEnum.Scheduled, shippingLineCompanyId: IsNull() }, ], }); const candidates = corridor .filter( (s) => s.scheduledDepartureDate != null && eatDay(s.scheduledDepartureDate) === day && s.bookingWindowStatus !== "FULL" && (direction === "EXPORT" ? s.direction === "EXPORT" : s.direction !== "EXPORT"), ) .sort( (a, b) => a.scheduledDepartureDate!.getTime() - b.scheduledDepartureDate!.getTime(), ); const wagonDims = await this.loadWagonDims(); const dimsOptions = this.dimsForAllowed(booking, wagonDims); const out: Array<{ scheduleId: string; departure: Date; freeWagons: number }> = []; for (const candidate of candidates) { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph( candidate.id, ); const locomotive = trainSetLocomotiveLimits(schedule?.trainSet); if (!schedule || !locomotive) continue; const limits = await this.capacityLimits(locomotive); const budget = await this.remainingBudget(schedule, limits, wagonDims, [ booking.id, ]); const leg = budget.legOf(booking.originYardId, booking.destinationYardId); if (!leg) continue; // this train's route doesn't carry the booking's leg const room = budget.remainingFor(leg); // Best usable wagons across the allowed types — a train fits under // whichever configured wagon type gives it the most room. let freeWagons = 0; for (const dims of dimsOptions) { const w = this.bookableWithin(room, dims).wagons; if (w > freeWagons) freeWagons = w; } if (freeWagons > 0) { out.push({ scheduleId: schedule.id, departure: schedule.scheduledDepartureDate!, freeWagons, }); } } return out; } /** * The export train picker: every export train on the booking's corridor/day * with its live space, measured per allowed wagon type so the customer sees * what each train can still take for THEIR cargo. Includes full/not-yet-open * trains (freeWagons 0 / isOpen false) so the UI can show them disabled — * the request-time gate (exportSpaceReport) stays the enforcement point. */ async exportTrainOptionsForDay( booking: Booking, day: string, overrides?: TrainOptionCargoOverrides, ): Promise { booking = await this.withCargoOverrides(booking, overrides); const corridor = await this.trainSchedulesRepository.findAll({ where: [ // Dedicated shipping-line trains are never customer-booking targets. { status: TrainScheduleStatusEnum.Draft, shippingLineCompanyId: IsNull() }, { status: TrainScheduleStatusEnum.Scheduled, shippingLineCompanyId: IsNull() }, ], }); const candidates = corridor .filter( (s) => s.scheduledDepartureDate != null && eatDay(s.scheduledDepartureDate) === day && s.direction === 'EXPORT', ) .sort( (a, b) => a.scheduledDepartureDate!.getTime() - b.scheduledDepartureDate!.getTime(), ); return this.buildTrainOptions(booking, candidates); } /** * The same per-train wagon-availability cards, but for the trains DEDICATED * to a shipping line on the booking's lane + day. Same option shape as the * export picker so the portal reuses the same component; `isOpen` * additionally respects the dedicated close offset (windowClosesAt), since * these trains run no window cycle. */ async dedicatedTrainOptionsForDay( booking: Booking, day: string | null, shippingLineCompanyId: string, overrides?: TrainOptionCargoOverrides, ): Promise { booking = await this.withCargoOverrides(booking, overrides); const dedicated = await this.trainSchedulesRepository.findAll({ where: [ { status: TrainScheduleStatusEnum.Draft, shippingLineCompanyId }, { status: TrainScheduleStatusEnum.Scheduled, shippingLineCompanyId }, ], }); const candidates = dedicated .filter( (s) => s.scheduledDepartureDate != null && // A day narrows to that departure day; without one, every upcoming // departure on the lane is listed (the picker's full card list). (day ? eatDay(s.scheduledDepartureDate) === day : s.scheduledDepartureDate.getTime() > Date.now() - 3_600_000) && (!booking.originYardId || s.originStationId === booking.originYardId) && (!booking.destinationYardId || s.destinationStationId === booking.destinationYardId), ) .sort( (a, b) => a.scheduledDepartureDate!.getTime() - b.scheduledDepartureDate!.getTime(), ); const options = await this.buildTrainOptions(booking, candidates); const now = Date.now(); return options.map((o) => ({ ...o, isOpen: o.isOpen && (o.bookingClosesAt == null || o.bookingClosesAt.getTime() > now), })); } /** Resolve form-entered cargo onto an (unpersisted) booking probe. */ private async withCargoOverrides( booking: Booking, overrides?: TrainOptionCargoOverrides, ): Promise { const sizeFts = (overrides?.containerSizes ?? []) .map((s) => parseInt(s, 10)) .filter((n) => Number.isFinite(n) && n > 0); if (overrides?.containerTypeIds?.length || sizeFts.length) { const types = await this.dataSource.getRepository(ContainerType).find({ where: overrides?.containerTypeIds?.length ? { id: In(overrides.containerTypeIds) } : { sizeFt: In(sizeFts) }, relations: { wagonTypes: true }, }); booking = { ...booking, freightType: "CONTAINER", bookingContainers: types.map((ct) => ({ containerType: ct })), } as Booking; } else if (overrides?.cargoTypeId || overrides?.cargoTypeCode) { const cargoType = await this.dataSource.getRepository(CargoType).findOne({ where: overrides.cargoTypeId ? { id: overrides.cargoTypeId } : { code: overrides.cargoTypeCode }, relations: { wagonTypes: true }, }); booking = { ...booking, freightType: "BULK", cargoType: cargoType ?? undefined, } as Booking; } if (overrides?.wagons && overrides.wagons > 0) { booking = { ...booking, wagonsRequired: overrides.wagons } as Booking; } return booking; } /** One availability card per candidate schedule — the export picker's math. */ private async buildTrainOptions( booking: Booking, candidates: TrainSchedule[], ): Promise { const wagonDims = await this.loadWagonDims(); const allowed = this.allowedDimsWithTypes(booking, wagonDims); const neededWagons = this.wagonsFor(booking, wagonDims); const typeIds = allowed .map((a) => a.wagonTypeId) .filter((id): id is string => Boolean(id)); const types = typeIds.length ? await this.dataSource .getRepository(WagonType) .find({ where: { id: In(typeIds) } }) : []; const typeById = new Map(types.map((t) => [t.id, t])); const out: ExportTrainOption[] = []; for (const candidate of candidates) { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph( candidate.id, ); const locomotive = trainSetLocomotiveLimits(schedule?.trainSet); if (!schedule || !locomotive) continue; const limits = await this.capacityLimits(locomotive); const budget = await this.remainingBudget(schedule, limits, wagonDims, [ booking.id, ]); const leg = budget.legOf(booking.originYardId, booking.destinationYardId); if (!leg) continue; // this train's route doesn't carry the booking's leg const room = budget.remainingFor(leg); // The abstract budget can't tell wagon types apart — cap each type's free // count with the PHYSICAL wagons of that type the train (or yard pool) // actually holds on this leg, and on a built train hide types the consist // doesn't carry at all. Otherwise a 47×NW5 train advertised "PW2: 47 free". const stock = await this.trainSchedulingService.wagonStockForSchedule( schedule.id, schedule.originStationId, budget.stops, ); const ledger = await this.stockLedgerFor(schedule, budget, [booking.id]); // On a multi-yard consist the pool that matters is the one standing at // the booking's own boarding yard — a type carried only in Mojo must not // be advertised to a customer boarding at Dire. const carriedAtBoardYard = (wagonTypeId: string): number => { const boardYardId = stock.byYardId ? budget.stops[leg.fromEdge] : null; if (boardYardId) return stock.byYardId?.get(boardYardId)?.get(wagonTypeId) ?? 0; return stock.remainingByTypeId.get(wagonTypeId) ?? 0; }; const byWagonType = allowed .filter( ({ wagonTypeId }) => stock.mode !== 'TRAIN' || !wagonTypeId || carriedAtBoardYard(wagonTypeId) > 0, ) .map(({ wagonTypeId, dims }) => { const type = wagonTypeId ? typeById.get(wagonTypeId) : undefined; const roomWagons = this.bookableWithin(room, dims).wagons; const physical = wagonTypeId ? ledger.availableFor([wagonTypeId], leg) : roomWagons; return { wagonTypeId, code: type?.code ?? null, name: type?.name ?? null, freeWagons: Math.min(roomWagons, physical), }; }); const freeWagons = byWagonType.reduce( (best, t) => Math.max(best, t.freeWagons), 0, ); const builtTrain = schedule.trainSet?.train; out.push({ scheduleId: schedule.id, trainNumber: schedule.trainNumber ?? builtTrain?.exportTrainNumber ?? builtTrain?.trainNumber ?? null, trainName: builtTrain?.trainName ?? builtTrain?.code ?? null, departure: schedule.scheduledDepartureDate!, bookingClosesAt: schedule.windowClosesAt ?? null, isOpen: this.isFillable(schedule), freeWagons, neededWagons, fits: freeWagons >= neededWagons, byWagonType, }); } return out; } /** * Advisory free-wagon count for an IMPORT/DOMESTIC booking on a given day, * summed across every train on the booking's corridor that day. Unlike the * export gate this does NOT block and does NOT first-fit a single train: * import is batched and splittable, so the honest number a customer can plan * against is the TOTAL room across the day's trains for the booking's wagon * type, in that type's own wagon units. * * It deliberately skips the `isFillable` window-phase gate. A customer picks a * shipment day while its window is still OPEN (or pre-window) — the batch fill * only makes those trains fillable after the window closes — so gating on the * fill phase here would report 0 for exactly the days customers are choosing. * We therefore count any non-FULL train that carries the leg, netting out the * capacity already consumed by allocated + live-reserved bookings * (`remainingBudget`). The count is an upper bound: the batch engine may still * split the booking across trains or defer a remainder to a later window. */ async dayImportAvailability( booking: Booking, day: string, ): Promise<{ freeWagons: number; need: number; trainsForDay: boolean }> { const corridor = await this.trainSchedulesRepository.findAll({ where: [ // Dedicated shipping-line trains are never customer-booking targets. { status: TrainScheduleStatusEnum.Draft, shippingLineCompanyId: IsNull() }, { status: TrainScheduleStatusEnum.Scheduled, shippingLineCompanyId: IsNull() }, ], }); const candidates = corridor.filter( (s) => s.scheduledDepartureDate != null && eatDay(s.scheduledDepartureDate) === day && s.bookingWindowStatus !== 'FULL' && s.direction !== 'EXPORT', ); const wagonDims = await this.loadWagonDims(); const dims = this.dimsFor(booking, wagonDims); const need = this.wagonsFor(booking, wagonDims); let freeWagons = 0; let trainsForDay = false; for (const candidate of candidates) { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph( candidate.id, ); const locomotive = trainSetLocomotiveLimits(schedule?.trainSet); if (!schedule || !locomotive) continue; const limits = await this.capacityLimits(locomotive); const budget = await this.remainingBudget(schedule, limits, wagonDims); const leg = budget.legOf(booking.originYardId, booking.destinationYardId); if (!leg) continue; // this train's route doesn't carry the booking's leg trainsForDay = true; freeWagons += this.bookableWithin(budget.remainingFor(leg), dims).wagons; } return { freeWagons, need, trainsForDay }; } /** * Export split: no single train carries the whole booking, so offer the * largest fitting part on the export train with the most room for its leg. * Returns true when an offer was opened (the caller must NOT then reserve — * the offer already opened its own pay window), false when the booking fits * whole somewhere (normal FCFS path) or no meaningful partial exists. * * Only the offer is written here: the booking is reduced to the offered part * on payment (applySplit), and the leftover is auto-placed afterwards. So an * unpaid export booking stays whole and the customer may still cancel it. */ private async tryExportPartialOffer(booking: Booking): Promise { if (!this.splitService) return false; const report = await this.exportSpaceReport(booking); // A train fits it whole — nothing to split, take the normal path. if (report.scheduleId) return false; if (!report.bestAvailable || report.bestAvailable.wagons < 1) return false; if (!booking.scheduledDate) return false; const day = eatDay(new Date(booking.scheduledDate)); const fitting = await this.fittingTrainsForDay(booking, day, "EXPORT"); if (!fitting.length) return false; // Most room first — the largest single part ships now, the smallest leftover // is what has to find another train. const target = [...fitting].sort((a, b) => b.freeWagons - a.freeWagons)[0]; const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph( target.scheduleId, ); const locomotive = trainSetLocomotiveLimits(schedule?.trainSet); if (!schedule || !locomotive) return false; const wagonDims = await this.loadWagonDims(); const limits = await this.capacityLimits(locomotive); const budget = await this.remainingBudget(schedule, limits, wagonDims, [ booking.id, ]); const leg = budget.legOf(booking.originYardId, booking.destinationYardId); if (!leg) return false; const offered = await this.tryPartialOffer( booking, schedule.id, budget.remainingFor(leg), report.need, ); if (!offered) return false; this.logger.log( `[EXPORT SPLIT] offered partial to ${booking.reference} on schedule ` + `${schedule.id} — leftover rebooks on the next train once paid.`, ); this.notifyBoardChanged(schedule.id, "batch_fill"); return true; } /** * Accept an export booking into the FCFS flow. Solo bookings reserve immediately. * A consolidated booking reserves as a pair only once BOTH partners are ready * (FULLY_EXECUTED): the second partner's accept triggers the pair reservation * against the combined shared-wagon need; the first partner's accept just waits. * Throws ConflictException (before this booking is persisted-ready) when there is * no export capacity for the day, so staff accept fails. */ async acceptExportBooking(booking: Booking): Promise { const partnerId = booking.consolidationPartnerId ?? null; if (!partnerId) { // Export split: when no single train carries the whole booking, offer the // largest fitting part instead of failing the accept. The customer pays // that part; on payment applySplit reduces this booking to it and the // leftover is auto-placed as its own booking on the next train. Pairs are // excluded (handled below) — a shared wagon is never split. if (this.exportSplitEnabled && this.isSplitEligible(booking, false)) { const offered = await this.tryExportPartialOffer(booking); if (offered) return; } const scheduleId = await this.pickExportSchedule(booking); await this.reserveOnExport([booking], scheduleId); return; } const partner = await this.dataSource .getRepository(Booking) .findOne({ where: { id: partnerId }, relations: { company: true, bookingContainers: { containerType: true }, cargoType: true, }, }); // Partner not yet accepted → this booking is now FULLY_EXECUTED and simply // waits; the partner's later accept will reserve the pair. if (!partner || partner.status !== 'FULLY_EXECUTED') { return; } const wagonDims = await this.loadWagonDims(); const need = this.combinedNeed(booking, partner, wagonDims); const scheduleId = await this.pickExportSchedule(booking, need); await this.reserveOnExport([booking, partner], scheduleId); } /** Reserve one or two (consolidated) export bookings on a train and open pay windows. */ private async reserveOnExport( bookings: Booking[], scheduleId: string, ): Promise { // H8: the capacity check (pickExportSchedule → budget.fits) and the // reservation writes below are not atomic on their own — two concurrent // export accepts can each see the same train as fitting and both reserve, // overshooting the train's capacity. Serialize reservations against this // schedule: take a pessimistic_write lock on the TrainSchedule row // (SELECT … FOR UPDATE), then RE-VERIFY budget.fits for these bookings' // combined need from freshly-committed state INSIDE the lock before the // reserve writes run. A loser (another accept took the space first) gets a // ConflictException — the staff accept fails and reverts, exactly as an // up-front full train does. Covered: the fits-vs-reserve overshoot on the // export FCFS path; the lock is held for the duration of the reserve writes. await this.dataSource.transaction(async (manager) => { const locked = await manager.findOne(TrainSchedule, { where: { id: scheduleId }, lock: { mode: "pessimistic_write" }, }); if (!locked) { throw new ConflictException( "Export train is no longer available for reservation", ); } const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); const locomotive = trainSetLocomotiveLimits(schedule?.trainSet); if (!schedule || !locomotive) { throw new ConflictException( "Export train is no longer available for reservation", ); } const wagonDims = await this.loadWagonDims(); const limits = await this.capacityLimits(locomotive); const budget = await this.remainingBudget( schedule, limits, wagonDims, bookings.map((b) => b.id), ); const leg = budget.legOf( bookings[0].originYardId, bookings[0].destinationYardId, ); const need = bookings.length >= 2 ? this.combinedNeed(bookings[0], bookings[1], wagonDims) : this.needFor(bookings[0], wagonDims); if (!leg || !budget.fits(need, leg)) { throw new ConflictException( "Train is full — no export capacity left for this day", ); } // Physical wagon gate — a pay window must never open for wagons that do // not exist in a type this cargo can ride. PER_TON bulk is seated // type-by-type at its per-wagon caps (the count allocation will really // need); everything else checks the summed free stock of its types. const stock = await this.stockLedgerFor( schedule, budget, bookings.map((b) => b.id), ); const allowedWagonTypes = await this.loadAllowedWagonTypeIds(); const primary = bookings[0]; const wagonTypeIds = this.allowedWagonTypeIdsFor(primary, allowedWagonTypes); const perItemBulk = Number(primary.bulkTotalWeightTons ?? 0) > 0 && Number(primary.cargoTotalWeightVgm ?? 0) > 0; const useSmart = bookings.length === 1 && primary.freightType === "BULK" && !perItemBulk && wagonTypeIds.length > 0; const smart = useSmart ? this.smartBulkNeed( primary, wagonDims, stock, leg, this.scarcityRankForPool([primary], allowedWagonTypes), wagonTypeIds, ) : null; const seated = useSmart ? smart != null && budget.fits(smart.need, leg) : this.hasWagonStock(stock, wagonTypeIds, need.wagons, leg); if (!seated) { throw new ConflictException( "Train has no free wagons of a type this cargo can ride — payment was not opened", ); } for (const b of bookings) await this.reserve(b, scheduleId); }); this.armSettle(scheduleId); const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); if (schedule && (await this.isTrainFull(schedule))) { await this.setWindow(scheduleId, 'FULL'); } this.notifyBoardChanged(scheduleId, 'export_booking_accepted'); } /** Link PAID bookings that have no train_schedule_bookings row (cron backstop). */ async reconcilePaidUnlinked(scheduleId: string): Promise { const unlinked = await this.bookingsRepository.findPaidUnlinkedForSchedule(scheduleId); for (const booking of unlinked) { // Held on purpose (paid, no wagon free) — the cron must not undo it. if (booking.schedulingStatus === "WAITING_FOR_WAGON") continue; if (await this.holdIfWagonShort(scheduleId, booking)) continue; await this.allocate(scheduleId, booking, "paid"); this.logger.log( `Reconciled PAID booking ${booking.reference ?? booking.id} → schedule ${scheduleId}`, ); } if (unlinked.length > 0) { this.notifyBoardChanged(scheduleId, "paid_reconciled"); } } // ---- legacy fill entry point ---------------------------------------------- /** * Legacy periodic fill for schedules without a window phase (DOMESTIC and * pre-migration trains). Invoked by BookingWindowService's tick — the old * standalone cron was replaced by the window engine. */ async runBatchFill(): Promise { const groups = await this.openRouteDayGroups(); this.logger.log(`Batch fill: ${groups.length} OPEN route-day group(s).`); for (const group of groups) { try { await this.processRouteDay(group); } catch (err) { this.logger.error( `Batch fill failed for ${this.groupLabel(group)}: ${(err as Error).message}`, ); } } } // ---- monitoring board ----------------------------------------------------- /** * Read model for the batch monitoring page: every import schedule — including * dispatched, arrived and cancelled history — with its locomotive, capacity * usage and its bookings grouped by lifecycle state (allocated / awaiting * payment / paid-waiting / pending contract / expired). Paginated and * filterable; per-schedule booking summaries are only computed for the * requested page. */ async getBatchBoard( query: BatchBoardQueryDto = {}, allowedDirections?: string[], ): Promise { // Board cards are heavy (per-schedule booking summaries), so the default // page is smaller than the toolkit-wide 20. const { page, pageSize, skip, take } = normalizePagination(query, { defaultPageSize: 12, }); // The board is IMPORT-only — a user scoped away from IMPORT sees nothing. if (allowedDirections && !allowedDirections.includes("IMPORT")) { return { items: [], meta: buildPaginationMeta(0, page, pageSize) }; } // Status filter: any subset of the lifecycle. Omitted = all statuses, so // arrived / cancelled / dispatched schedules stay visible as history. const allowedStatuses = new Set(BATCH_BOARD_STATUSES); const statuses = (query.statuses ?? "") .split(",") .map((v) => v.trim().toUpperCase()) .filter((v) => allowedStatuses.has(v)); const dateRange = (from?: string, to?: string) => { const f = from ? new Date(from) : null; const t = to ? new Date(to) : null; if (f && t) return Between(f, t); if (f) return MoreThanOrEqual(f); if (t) return LessThanOrEqual(t); return undefined; }; // Batch board is IMPORT-only: export is FCFS with no batch/priority calc, // and domestic/legacy schedules run the legacy fill, not the window batch. const base: FindOptionsWhere = { direction: "IMPORT" }; if (statuses.length) base.status = In(statuses) as never; if (query.bookingWindowStatus) { base.bookingWindowStatus = query.bookingWindowStatus; } const departure = dateRange(query.departureFrom, query.departureTo); if (departure) base.scheduledDepartureDate = departure as never; const created = dateRange(query.createdFrom, query.createdTo); if (created) base.createdAt = created as never; // Search fans out across every human-recognizable label. Each OR variant // repeats the base filters so the search never widens them. const term = query.search?.trim(); let where: FindOptionsWhere | FindOptionsWhere[] = base; if (term) { const like = ILike(`%${term}%`); where = [ { ...base, trainNumber: like as never }, { ...base, originStation: { label: like } }, { ...base, destinationStation: { label: like } }, { ...base, route: { originYard: { label: like } } }, { ...base, route: { destinationYard: { label: like } } }, { ...base, trainSet: { locomotive: { code: like } } }, ] as FindOptionsWhere[]; } const sortBy = query.sortBy ?? "createdAt"; const sortOrder = query.sortOrder ?? "DESC"; const [schedules, total] = await this.trainSchedulesRepository.findAndCount({ where, relations: { // locomotives (plural) too — the caps SUM the whole set's pull; the // single legacy column alone under-reports a two-loco train by half. trainSet: { locomotive: true, locomotives: { locomotive: true }, train: true }, originStation: true, destinationStation: true, // Yards supply the route's display name for `routeName` below; // milestones (with yards) give it the full corridor path. route: { originYard: true, destinationYard: true, milestones: { yard: true } }, }, order: { [sortBy]: sortOrder } as never, skip, take, }); const wagonDims = await this.loadWagonDims(); const linkRepo = this.dataSource.getRepository(TrainScheduleBooking); // One links query + one bookings query for the whole page (was 2 per card). const scheduleIds = schedules.map((s) => s.id); const [allLinks, allBookings] = await Promise.all([ scheduleIds.length ? linkRepo.find({ where: { trainScheduleId: In(scheduleIds) } }) : Promise.resolve([]), this.bookingsRepository.findAllBySchedules(scheduleIds), ]); const linkedIdsBySchedule = new Map>(); for (const l of allLinks) { let set = linkedIdsBySchedule.get(l.trainScheduleId); if (!set) linkedIdsBySchedule.set(l.trainScheduleId, (set = new Set())); set.add(l.bookingId); } const bookingsBySchedule = new Map(); for (const b of allBookings) { if (!b.trainScheduleId) continue; let list = bookingsBySchedule.get(b.trainScheduleId); if (!list) bookingsBySchedule.set(b.trainScheduleId, (list = [])); list.push(b); } const board: BatchBoardSchedule[] = []; for (const s of schedules) { const linkedIds = linkedIdsBySchedule.get(s.id) ?? new Set(); const bookings = bookingsBySchedule.get(s.id) ?? []; const items: BatchBoardBooking[] = bookings.map((b) => { const need = this.needFor(b, wagonDims); return { id: b.id, reference: b.reference ?? b.id.slice(0, 8), company: b.isGovernment ? (b.governmentInstitution ?? "Government") : (b.company?.name ?? "—"), isGovernment: Boolean(b.isGovernment), wagons: need.wagons, weightTons: need.weightTons, lengthMeters: need.lengthMeters, paymentDeadline: b.paymentDeadline ? b.paymentDeadline.toISOString() : null, state: this.boardState(b, linkedIds.has(b.id)), priorityScore: Number(b.priorityScore ?? 0), freightType: b.freightType ?? null, }; }); board.push( this.buildScheduleSummary( s, items, new Map( bookings.map((b) => [ b.id, { originYardId: b.originYardId ?? null, destinationYardId: b.destinationYardId ?? null, }, ]), ), ), ); } return { items: board, meta: buildPaginationMeta(total, page, pageSize) }; } /** Schedule-level batch board: the schedule's own booking window plus its * bookings split into in-window (contract executed) vs pending-contract. */ async getBatchBoardDetail( scheduleId: string, ): Promise { const s = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); if (!s) throw new NotFoundException(`Train schedule ${scheduleId} not found`); // Arrived / cancelled schedules stay viewable — the board is also the // historical record of what each train carried. // Batch board is IMPORT-only (export is FCFS, no batch/priority calc). if (s.direction !== "IMPORT") { throw new BadRequestException( "The batch board only covers import schedules", ); } const wagonDims = await this.loadWagonDims(); // The full graph already carries the schedule↔booking links — no separate // link query needed. const linkedIds = new Set( (s.scheduleBookings ?? []).map((l) => l.bookingId), ); const bookings = await this.bookingsRepository.findAllBySchedule(s.id); // Under day-level pooling a booking is only pinned to a schedule by // reserve() — until then its train_schedule_id is NULL and the query above // misses it. Merge in the corridor-day candidates so staff see the whole // waiting pool (the 7 that lost the batch), not just the winners. These are // display-only candidates: they are excluded from the capacity meters below. const pinnedIds = new Set(bookings.map((b) => b.id)); // Corridor stops drive both the day-pool candidate merge and the per-leg // capacity meters below; a failed lookup degrades to whole-route math. let stops: string[] = []; try { stops = await this.stopsForSchedule(s); } catch (err) { this.logger.warn( `Stop lookup failed for schedule ${s.id}: ${(err as Error).message}`, ); } if (s.scheduledDepartureDate && stops.length) { try { const candidates = await this.bookingsRepository.findBatchPoolByCorridorDay( stops, eatDay(s.scheduledDepartureDate), ); for (const b of candidates) { if (!pinnedIds.has(b.id)) bookings.push(b); } // Expiry frees the schedule pin (expire() nulls train_schedule_id), so // expired bookings match neither query above — merge them back so the // board keeps its expired lane. Display-only: boardState maps them to // EXPIRED, which every capacity meter already excludes. const expiredPool = await this.bookingsRepository.findExpiredByCorridorDay( stops, eatDay(s.scheduledDepartureDate), ); for (const b of expiredPool) { if (!pinnedIds.has(b.id)) bookings.push(b); } } catch (err) { // The board must still render the pinned bookings. this.logger.warn( `Corridor-day candidate merge failed for schedule ${s.id}: ` + `${(err as Error).message}`, ); } } let allocationPreview: Awaited< ReturnType >; try { // Reuse the graph loaded above — the preview otherwise re-loads the same // heavy schedule graph a second time per request. allocationPreview = await this.trainSchedulingService.previewAllocationForSchedule( s.id, s, ); } catch { allocationPreview = { assignedBookingIds: [], deferred: [], issues: [], violations: [], }; } const allocationByBooking = new Map( allocationPreview.issues.map((i) => [i.bookingId, i]), ); // Resolve consolidation-partner references for the shared-wagon badge. Most // partners are on this same schedule; look up any that aren't in one query. const refById = new Map( bookings.map((b) => [b.id, b.reference ?? b.id.slice(0, 8)]), ); const missingPartnerIds = [ ...new Set( bookings .map((b) => b.consolidationPartnerId) .filter((id): id is string => Boolean(id) && !refById.has(id!)), ), ]; if (missingPartnerIds.length) { const partners = await this.dataSource .getRepository(Booking) .find({ where: { id: In(missingPartnerIds) } }); for (const p of partners) { refById.set(p.id, p.reference ?? p.id.slice(0, 8)); } } const cycleOf = await this.windowCycleIndexer(s); const items: BatchBoardBookingDetail[] = bookings.map((b) => { const need = this.needFor(b, wagonDims); const alloc = allocationByBooking.get(b.id); return { windowCycleNo: b.fullyExecutedAt ? cycleOf(b.fullyExecutedAt) : null, id: b.id, reference: b.reference ?? b.id.slice(0, 8), company: b.isGovernment ? (b.governmentInstitution ?? "Government") : (b.company?.name ?? "—"), isGovernment: Boolean(b.isGovernment), wagons: need.wagons, weightTons: need.weightTons, lengthMeters: need.lengthMeters, paymentDeadline: b.paymentDeadline ? b.paymentDeadline.toISOString() : null, state: this.boardState(b, linkedIds.has(b.id)), priorityScore: Number(b.priorityScore ?? 0), freightType: b.freightType ?? null, fullyExecutedAt: b.fullyExecutedAt ? b.fullyExecutedAt.toISOString() : null, selectedForBatchAt: b.selectedForBatchAt ? b.selectedForBatchAt.toISOString() : null, allocationStatus: alloc?.status ?? "NOT_ATTEMPTED", allocationIssue: alloc?.issue ?? null, consolidationPartnerId: b.consolidationPartnerId ?? null, consolidationPartnerRef: b.consolidationPartnerId ? (refById.get(b.consolidationPartnerId) ?? null) : null, }; }); const loco = trainSetLocomotiveLimits(s.trainSet); // The board renders ONE booking window — the schedule's own frozen window // (windowOpensAt/windowClosesAt + phase deadlines returned below). Bookings // split into two buckets: contract executed (in the window) vs pending // contract. The old per-cycle window projection was dropped — the UI never // showed it, and reconstructing every cycle cost a config load + grouping // pass per request. const countFor = (bucket: BatchBoardBookingDetail[]): BatchBoardCounts => { const counts: BatchBoardCounts = { allocated: 0, selectedForBatch: 0, ready: 0, waiting: 0, expired: 0, pendingContract: 0, }; for (const b of bucket) { if (b.state === "ALLOCATED") counts.allocated += 1; else if (b.state === "SELECTED_FOR_BATCH") counts.selectedForBatch += 1; else if (b.state === "READY") counts.ready += 1; else if (b.state === "WAITING") counts.waiting += 1; else if (b.state === "EXPIRED") counts.expired += 1; else counts.pendingContract += 1; } return counts; }; const windowBookings = items.filter((i) => i.fullyExecutedAt); const pendingBookings = items.filter((i) => !i.fullyExecutedAt); const stopLabels = stops.length > 2 ? await this.yardLabels(stops) : new Map(); const yardsByBookingId = new Map( bookings.map((b) => [ b.id, { originYardId: b.originYardId ?? null, destinationYardId: b.destinationYardId ?? null, }, ]), ); return { scheduleId: s.id, scheduleReference: s.reference ?? null, trainNumber: s.trainNumber ?? null, routeName: s.route ? formatRouteLabel(s.route) : null, origin: s.originStation?.label ?? s.originStation?.code ?? null, destination: s.destinationStation?.label ?? s.destinationStation?.code ?? null, scheduleDate: s.scheduledDepartureDate ? s.scheduledDepartureDate.toISOString() : null, status: s.status, bookingWindowStatus: s.bookingWindowStatus, direction: s.direction ?? null, windowPhase: s.windowPhase ?? null, windowOpensAt: s.windowOpensAt ? s.windowOpensAt.toISOString() : null, windowClosesAt: s.windowClosesAt ? s.windowClosesAt.toISOString() : null, docReviewEndsAt: s.docReviewEndsAt ? s.docReviewEndsAt.toISOString() : null, paymentPhaseEndsAt: s.paymentPhaseEndsAt ? s.paymentPhaseEndsAt.toISOString() : null, bookingCycleNo: s.bookingCycleNo ?? 0, train: s.trainSet?.train ? { id: s.trainSet.train.id, code: s.trainSet.train.code, trainName: s.trainSet.train.trainName ?? null, } : null, // Identity from the primary (legacy) locomotive; limit figures from the // whole set's effective minimum — what the fill engine actually spends. locomotive: loco ? { code: s.trainSet?.locomotive?.code ?? '', name: s.trainSet?.locomotive?.name ?? null, maxPullWeightTons: Number(loco.maxPullWeightTons), maxTrainLengthMeters: Number(loco.maxTrainLengthMeters), } : null, // Capacity holds come from bookings actually pinned to this train — // unpinned day-pool candidates are shown in the lists but hold nothing. capacity: this.computeBoardCapacity( items.filter((i) => pinnedIds.has(i.id)), loco, s.maxWagons ?? null, { stops, labelByYardId: stopLabels, yardsByBookingId, trainLengthMeters: this.builtTrainLengthOf(s), }, ), counts: { allocated: items.filter((i) => i.state === "ALLOCATED").length, selectedForBatch: items.filter((i) => i.state === "SELECTED_FOR_BATCH") .length, ready: items.filter((i) => i.state === "READY").length, waiting: items.filter((i) => i.state === "WAITING").length, pendingContract: items.filter((i) => i.state === "PENDING_CONTRACT") .length, expired: items.filter((i) => i.state === "EXPIRED").length, }, bookings: windowBookings, pendingContract: { counts: countFor(pendingBookings), bookings: pendingBookings, }, allocationViolations: allocationPreview.violations, }; } /** Run wagon-level allocation for all eligible linked bookings on a schedule. */ async runWagonAllocation(scheduleId: string) { const result = await this.trainSchedulingService.tryAutoWagonAllocation(scheduleId); if (result.assignedBookingIds.length > 0) { this.notifyBoardChanged(scheduleId, "wagon_allocation_run"); } return result; } /** * Board capacity figures. `usedWeightTons` is GROSS (each item's weight already * includes the tare of the wagons it occupies), so the ceiling it is measured * against must be the same one the fill loop spends from: the locomotive's own * limits widened by its overage tolerance (global rule caps do not apply, same * as {@link capacityLimits}). Reading the raw `loco.maxPullWeightTons` here * showed staff a ceiling the batch engine did not use. */ private computeBoardCapacity( items: Array<{ id: string; state: BatchBoardBookingState; wagons: number; weightTons: number; lengthMeters: number; }>, loco: LocomotiveLimits | null, maxWagons: number | null, legCtx?: { /** Ordered corridor stop yard ids; per-leg math needs 3+ stops. */ stops: string[]; labelByYardId: Map; yardsByBookingId: Map< string, { originYardId: string | null; destinationYardId: string | null } >; trainLengthMeters: number | null; }, ): BatchBoardSchedule["capacity"] { const allocated = items.filter((i) => i.state === "ALLOCATED"); // Every booking still targeting this train holds gross weight — including // PAID ones waiting for wagon allocation (WAITING) and post-dispatch // catch-all states. Counting only ALLOCATED + SELECTED_FOR_BATCH zeroed the // board's weight the moment customers paid. Only EXPIRED released its hold. const committed = items.filter((i) => i.state !== "EXPIRED"); const caps = loco ? trainHardCaps({ maxPullWeightTons: Number(loco.maxPullWeightTons), maxTrainLengthMeters: Number(loco.maxTrainLengthMeters), overageToleranceTons: Number(loco.overageToleranceTons) || 0, overageToleranceMeters: Number(loco.overageToleranceMeters) || 0, }) : null; const round2 = (value: number) => Math.round(value * 100) / 100; // Per-leg committed usage: a booking holds capacity only on the edges it // rides, so every meter compares the HEAVIEST single edge against its cap // — weight, wagons and length alike. Whole-route bookings (or yards // missing from the stop list) load every edge — never under-reported. const stops = legCtx?.stops ?? []; let usedWeightTons = round2( committed.reduce((sum, i) => sum + i.weightTons, 0), ); let allocatedWagons = allocated.reduce((sum, i) => sum + i.wagons, 0); let allocatedLengthMeters = round2( allocated.reduce((sum, i) => sum + i.lengthMeters, 0), ); let legUsage: BatchBoardSchedule["capacity"]["legUsage"] = null; if (legCtx && stops.length > 2) { const stopIndex = new Map(stops.map((yardId, i) => [yardId, i])); const edgeCount = stops.length - 1; const legOf = (bookingId: string): { from: number; to: number } => { const yards = legCtx.yardsByBookingId.get(bookingId); const from = yards?.originYardId ? stopIndex.get(yards.originYardId) : undefined; const to = yards?.destinationYardId ? stopIndex.get(yards.destinationYardId) : undefined; return from != null && to != null && from < to ? { from, to } : { from: 0, to: edgeCount }; }; const weightEdges = new Array(edgeCount).fill(0); for (const item of committed) { const leg = legOf(item.id); for (let e = leg.from; e < leg.to; e += 1) weightEdges[e] += item.weightTons; } const wagonEdges = new Array(edgeCount).fill(0); const lengthEdges = new Array(edgeCount).fill(0); for (const item of allocated) { const leg = legOf(item.id); for (let e = leg.from; e < leg.to; e += 1) { wagonEdges[e] += item.wagons; lengthEdges[e] += item.lengthMeters; } } const label = (yardId: string) => legCtx.labelByYardId.get(yardId) ?? yardId; legUsage = weightEdges.map((weight, i) => ({ from: label(stops[i]), to: label(stops[i + 1]), usedWeightTons: round2(weight), })); usedWeightTons = round2(Math.max(0, ...weightEdges)); allocatedWagons = Math.max(0, ...wagonEdges); allocatedLengthMeters = round2(Math.max(0, ...lengthEdges)); } return { allocatedWagons, allocatedLengthMeters, maxLengthMeters: caps ? caps.maxLengthMeters : null, usedWeightTons, maxWeightTons: caps ? caps.maxWeightTons : null, maxWagons: maxWagons ?? null, trainLengthMeters: legCtx?.trainLengthMeters ?? null, legUsage, }; } /** Built consist's physical length (what Train Builder shows), null without a built train. */ private builtTrainLengthOf(s: TrainSchedule): number | null { const raw = s.trainSet?.totalLengthMeters; const value = raw != null ? Number(raw) : NaN; return Number.isFinite(value) && value > 0 ? value : null; } /** Yard display labels for corridor stops (falls back to the yard id). */ private async yardLabels(yardIds: string[]): Promise> { if (!yardIds.length) return new Map(); const yards = await this.dataSource .getRepository(Yard) .find({ where: { id: In(yardIds) } }); return new Map(yards.map((y) => [y.id, y.label ?? y.code])); } /** * Corridor stops + labels from the already-loaded route graph (milestones * with yards) — the list flow must not fire a query per schedule row. */ private stopsFromGraph(s: TrainSchedule): { stops: string[]; labelByYardId: Map; } { const milestones = [...(s.route?.milestones ?? [])].sort( (a, b) => a.sequenceNo - b.sequenceNo, ); const stops: string[] = []; const labelByYardId = new Map(); const push = (yardId?: string | null, label?: string | null) => { if (!yardId || labelByYardId.has(yardId)) return; stops.push(yardId); labelByYardId.set(yardId, label ?? yardId); }; if (milestones.length >= 2) { for (const m of milestones) push(m.yardId, m.yard?.label ?? m.yard?.code); } else { push(s.originStationId, s.originStation?.label ?? s.originStation?.code); push( s.destinationStationId, s.destinationStation?.label ?? s.destinationStation?.code, ); } return { stops, labelByYardId }; } private buildScheduleSummary( s: TrainSchedule, items: BatchBoardBooking[], yardsByBookingId: Map< string, { originYardId: string | null; destinationYardId: string | null } >, ): BatchBoardSchedule { const loco = trainSetLocomotiveLimits(s.trainSet); const { stops, labelByYardId } = this.stopsFromGraph(s); return { scheduleId: s.id, scheduleReference: s.reference ?? null, trainNumber: s.trainNumber ?? null, routeName: s.route ? formatRouteLabel(s.route) : null, origin: s.originStation?.label ?? s.originStation?.code ?? null, destination: s.destinationStation?.label ?? s.destinationStation?.code ?? null, scheduleDate: s.scheduledDepartureDate ? s.scheduledDepartureDate.toISOString() : null, createdAt: s.createdAt ? s.createdAt.toISOString() : null, status: s.status, bookingWindowStatus: s.bookingWindowStatus, direction: s.direction ?? null, windowPhase: s.windowPhase ?? null, windowOpensAt: s.windowOpensAt ? s.windowOpensAt.toISOString() : null, windowClosesAt: s.windowClosesAt ? s.windowClosesAt.toISOString() : null, docReviewEndsAt: s.docReviewEndsAt ? s.docReviewEndsAt.toISOString() : null, paymentPhaseEndsAt: s.paymentPhaseEndsAt ? s.paymentPhaseEndsAt.toISOString() : null, bookingCycleNo: s.bookingCycleNo ?? 0, train: s.trainSet?.train ? { id: s.trainSet.train.id, code: s.trainSet.train.code, trainName: s.trainSet.train.trainName ?? null, } : null, // Identity from the primary (legacy) locomotive; limit figures from the // whole set's effective minimum — what the fill engine actually spends. locomotive: loco ? { code: s.trainSet?.locomotive?.code ?? '', name: s.trainSet?.locomotive?.name ?? null, maxPullWeightTons: Number(loco.maxPullWeightTons), maxTrainLengthMeters: Number(loco.maxTrainLengthMeters), } : null, capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null, { stops, labelByYardId, yardsByBookingId, trainLengthMeters: this.builtTrainLengthOf(s), }), counts: { allocated: items.filter((i) => i.state === "ALLOCATED").length, selectedForBatch: items.filter((i) => i.state === "SELECTED_FOR_BATCH") .length, ready: items.filter((i) => i.state === "READY").length, waiting: items.filter((i) => i.state === "WAITING").length, pendingContract: items.filter((i) => i.state === "PENDING_CONTRACT") .length, expired: items.filter((i) => i.state === "EXPIRED").length, }, bookings: items.slice(0, 3), }; } private boardState( booking: Booking, linked: boolean, ): BatchBoardBookingState { if (linked) return "ALLOCATED"; if ( booking.status === "SELECTED_FOR_BATCH" || booking.status === "AWAITING_PAYMENT" || // Redirect-acked, webhook pending — still a reserved (unpaid) hold. booking.status === "PAYMENT_VERIFICATION_IN_PROGRESS" ) { return "SELECTED_FOR_BATCH"; } if (booking.status === "EXPIRED") return "EXPIRED"; if (booking.status === "FULLY_EXECUTED" && booking.fullyExecutedAt) return "READY"; if (booking.status === "PAID") return "WAITING"; return "PENDING_CONTRACT"; } // ---- core fill ------------------------------------------------------------ /** * Whether the batch engine may reserve/allocate onto this schedule right now. * Legacy (no window phase): the customer-facing OPEN gate doubles as the fill gate. * Import window cycle: the engine fills while the customer window is CLOSED — * during DOC_REVIEW (early staff trigger) and PAYMENT (batch run + top-ups). * Export: FCFS while the booking window is open. */ isFillable(schedule: TrainSchedule): boolean { if (schedule.bookingWindowStatus === "FULL") return false; if (!schedule.windowPhase) return schedule.bookingWindowStatus === "OPEN"; if (schedule.direction === "EXPORT") { return schedule.windowPhase === "OPEN" && schedule.bookingWindowStatus === "OPEN"; } return schedule.windowPhase === "DOC_REVIEW" || schedule.windowPhase === "PAYMENT"; } /** * Fill one schedule from its priority-ordered pool until full. Returns the * number of commercial units it RESERVED this pass (0 for government-only or * no-fit passes) so a top-up caller can extend the payment phase only when a * fresh pay window actually opened. */ async fillSchedule(scheduleId: string): Promise { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); if (!schedule || !this.isFillable(schedule)) return 0; const locomotive = trainSetLocomotiveLimits(schedule.trainSet); if (!schedule.trainSetId || !locomotive) { this.logger.warn( `Schedule ${scheduleId} has no locomotive/train set — skipped.`, ); return 0; } const wagonDims = await this.loadWagonDims(); const limits = await this.capacityLimits(locomotive); await this.syncScheduleMaxWagons(schedule, locomotive); const budget = await this.remainingBudget(schedule, limits, wagonDims); const stock = await this.stockLedgerFor(schedule, budget); const allowedWagonTypes = await this.loadAllowedWagonTypeIds(); const minPerWagon = this.minPerWagonNeed(wagonDims); if (budget.isExhausted(minPerWagon)) { await this.setWindow(scheduleId, "FULL"); return 0; } const pool = await this.bookingsRepository.findBatchPool(scheduleId); // Same bulk re-score as fillRouteDayInternal — the legacy per-schedule fill // must rank bulk bookings by their wagon-derived priority too. await this.recomputeBulkPriorities(pool, wagonDims); this.resortPoolByPriority(pool, await this.windowCycleIndexer(schedule)); const units = this.groupConsolidatedPool(pool); const scarcityRank = this.scarcityRankForPool(pool, allowedWagonTypes); let armed = false; let preempted = false; let reservedThisPass = 0; let commercialReserved = 0; // Batch fill trace: caps + pool at entry. Kept on debug level — invaluable when // reservations trickle instead of landing in one pass (a reserve() throwing // mid-loop, e.g. schema drift, or a mis-synced capacity cap). this.logger.debug( `[fillSchedule ${scheduleId}] limits=${JSON.stringify(limits)} ` + `maxWagons=${schedule.maxWagons} remaining=${JSON.stringify(budget.maxRemaining())} ` + `poolSize=${pool.length} units=${units.length}`, ); for (const unit of units) { const { primary: booking, partner } = unit; const isPair = partner != null; const need = isPair ? this.combinedNeed(booking, partner, wagonDims) : this.needFor(booking, wagonDims); const isGov = booking.isGovernment || (partner?.isGovernment ?? false); // Consolidated partners always share one corridor, so the primary's leg // stands for the pair. const leg = budget.legForYards(booking.originYardId, booking.destinationYardId); const wagonTypeIds = this.allowedWagonTypeIdsFor(booking, allowedWagonTypes); // Abstract room AND real wagons of a type this booking can ride — see // fillRouteDayInternal for why both gates are needed. PER_TON bulk // singles get the smart gate (exact per-type seating at the cargo's // caps); a booking is only reserved — and only ever invoiced — when // that seating is proven against the train's actual free wagons. const perItemBulk = Number(booking.bulkTotalWeightTons ?? 0) > 0 && Number(booking.cargoTotalWeightVgm ?? 0) > 0; const useSmart = !isPair && booking.freightType === "BULK" && !perItemBulk && wagonTypeIds.length > 0; const smart = useSmart ? this.smartBulkNeed(booking, wagonDims, stock, leg, scarcityRank, wagonTypeIds) : null; const admitted = useSmart ? smart != null && budget.fits(smart.need, leg) : budget.fits(need, leg) && this.hasWagonStock(stock, wagonTypeIds, need.wagons, leg); // Per-unit fit trace: which axis (wagons/weight/length/stock) admits or rejects. this.logger.debug( `[fillSchedule ${scheduleId}] unit ${booking.reference}: need=${JSON.stringify( smart?.need ?? need, )} roomOnLeg=${JSON.stringify(budget.remainingFor(leg))} admitted=${admitted}`, ); if (!admitted) { if (isGov) { const freed = await this.preemptForGovernment( scheduleId, need, leg, budget, wagonDims, ); preempted = true; if (!freed) continue; // still doesn't fit even after preempt } else { // Doesn't fit whole. A split-eligible import booking is offered the part // that fits in the remaining room (top-up path splits the boundary // booking, mirroring fillRouteDay); otherwise skip and try the next. const cand: { id: string; budget: CorridorBudget; armed: boolean; stock: WagonStockLedger; } = { id: scheduleId, budget, armed, stock }; if ( await this.maybeOfferPartial(booking, isPair, [cand], need, wagonTypeIds) ) { armed = cand.armed; continue; } continue; // skip a unit that exceeds weight/length/wagons/stock, try the next } } // Isolate each unit so a throw in reserve/allocate (e.g. billing hiccup) // can't abort the whole top-up pass and leave the rest to trickle in one // per tick. Log + skip the failing unit, keep going. try { if (isGov) { await this.allocate(scheduleId, booking, "gov"); if (partner) await this.allocate(scheduleId, partner, "gov"); } else { await this.reserve(booking, scheduleId); if (partner) await this.reserve(partner, scheduleId); armed = true; commercialReserved += 1; } budget.subtract(smart?.need ?? need, leg); // Hold the physical wagons too — the next unit must not re-count them. // The smart gate holds the exact per-type counts it seated. if (smart) { for (const part of smart.perType) { stock.consume([part.wagonTypeId], part.wagons, leg); } } else { stock.consume(wagonTypeIds, need.wagons, leg); } reservedThisPass += 1; } catch (err) { this.logger.error( `[fillSchedule ${scheduleId}] reserve/allocate FAILED for ${booking.reference} ` + `— skipping this unit, continuing: ${(err as Error).message}`, ); continue; } if (budget.maxRemaining().wagons <= 0) break; // every leg exhausted — nothing more can board } this.logger.log( `[fillSchedule ${scheduleId}] reserved ${reservedThisPass}/${units.length} unit(s) this pass`, ); if (budget.isExhausted(minPerWagon)) await this.setWindow(scheduleId, "FULL"); if (armed) this.armSettle(scheduleId); // One push per fill pass (never per booking) — only when rows changed. if (reservedThisPass > 0 || armed || preempted) { this.notifyBoardChanged(scheduleId, "batch_fill"); } void this.triggerWagonAllocation(scheduleId); return commercialReserved; } /** * Distribute one (route, day) pool across ALL of that day's OPEN trains, by * priority, filling each train (earliest departure first) until it's full and * spilling overflow to the next. Government bookings that fit no train preempt * lower-priority commercial; bookings that fit no train at all stay pending and * trigger a staff `unplaced` warning. Returns the schedule ids that were touched * (or that had remaining pool work) so the caller can settle them per-schedule. */ async fillRouteDay( originYardId: string, destinationYardId: string, day: string, ): Promise { const { scheduleIds } = await this.fillRouteDayInternal( originYardId, destinationYardId, day, ); return scheduleIds; } /** * Route-day top-up for a single schedule: re-run the DAY pool over the whole * corridor the schedule belongs to, and report how many commercial units got a * fresh pay window. * * `fillSchedule` cannot do this job. Its pool (`findBatchPool`) is keyed on * `booking.train_schedule_id = :scheduleId`, but under day-level pooling a * booking that has not been reserved yet has a NULL `train_schedule_id` — it is * only pinned by `reserve()`. So the schedule-scoped top-up returned zero rows * and the waiting list never boarded after an expiry freed capacity; bookings * trickled in one per window cycle instead. */ private async topUpFill(scheduleId: string): Promise { const schedule = await this.trainSchedulesRepository.findById(scheduleId); if (!schedule?.scheduledDepartureDate) return 0; const { commercialReserved } = await this.fillRouteDayInternal( schedule.originStationId, schedule.destinationStationId, eatDay(schedule.scheduledDepartureDate), ); return commercialReserved; } private async fillRouteDayInternal( originYardId: string, destinationYardId: string, day: string, ): Promise<{ scheduleIds: string[]; commercialReserved: number }> { // The day's fillable schedules on this exact corridor, earliest first. Fillable // covers legacy OPEN trains and window-cycle trains in DOC_REVIEW/PAYMENT — // the batch must run while the customer window is closed. const corridor = await this.trainSchedulesRepository.findAll({ where: [ { originStationId: originYardId, destinationStationId: destinationYardId, status: TrainScheduleStatusEnum.Draft, // Dedicated shipping-line trains never join the customer day pool. shippingLineCompanyId: IsNull(), }, { originStationId: originYardId, destinationStationId: destinationYardId, status: TrainScheduleStatusEnum.Scheduled, shippingLineCompanyId: IsNull(), }, ], }); const onDay = corridor .filter( (s) => s.scheduledDepartureDate != null && eatDay(s.scheduledDepartureDate) === day, ) .sort( (a, b) => a.scheduledDepartureDate.getTime() - b.scheduledDepartureDate.getTime(), ); // A schedule flagged FULL is rejected by isFillable() before its budget is // ever consulted. Re-derive that flag from live capacity first, so a train // whose bookings all expired is not skipped forever with an empty consist. for (const s of onDay) { if (s.bookingWindowStatus === "FULL") { await this.refreshWindowStatus(s.id); const fresh = await this.trainSchedulesRepository.findById(s.id); if (fresh) s.bookingWindowStatus = fresh.bookingWindowStatus; } } const scheduleIds = onDay.filter((s) => this.isFillable(s)).map((s) => s.id); if (scheduleIds.length === 0) { return { scheduleIds: [], commercialReserved: 0 }; } const wagonDims = await this.loadWagonDims(); const allowedWagonTypes = await this.loadAllowedWagonTypeIds(); // Live per-schedule corridor budget + physical wagon-type stock + arm/changed // flags, in departure order. const trains: Array<{ id: string; budget: CorridorBudget; stock: WagonStockLedger; armed: boolean; changed: boolean; }> = []; // The day group shares one booking window (route+day grouping), so any // member's window grid stands for the pool's cycle derivation. let cycleSchedule: TrainSchedule | null = null; for (const id of scheduleIds) { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(id); const locomotive = trainSetLocomotiveLimits(schedule?.trainSet); if (!schedule || !schedule.trainSetId || !locomotive) { this.logger.warn( `Schedule ${id} has no locomotive/train set — skipped.`, ); continue; } cycleSchedule ??= schedule; const limits = await this.capacityLimits(locomotive); await this.syncScheduleMaxWagons(schedule, locomotive); const budget = await this.remainingBudget(schedule, limits, wagonDims); const stock = await this.stockLedgerFor(schedule, budget); trains.push({ id, budget, stock, armed: false, changed: false }); } if (trains.length === 0) return { scheduleIds, commercialReserved: 0 }; // The day pool covers every booking whose leg lies somewhere on one of the // day's corridors — full-route AND sub-corridor (e.g. Dire→Djibouti on an // Addis→Djibouti train). Which train actually takes a booking is decided // by the per-train legOf check below. const corridorYards = [...new Set(trains.flatMap((t) => t.budget.stops))]; const pool = await this.bookingsRepository.findBatchPoolByCorridorDay( corridorYards, day, ); // BULK bookings only get their real (wagon-derived) priority score now, at // batch time — stamp it and re-rank before the fill consumes the pool. await this.recomputeBulkPriorities(pool, wagonDims); this.resortPoolByPriority( pool, cycleSchedule ? await this.windowCycleIndexer(cycleSchedule) : undefined, ); // Consolidated partners collapse into one atomic unit (both-or-neither); a // consolidated booking whose partner isn't ready this cycle is skipped. const units = this.groupConsolidatedPool(pool); // Least-shareable-type-first seating for bulk (see smartBulkNeed): ranked // once against the whole pool, so what containers will need is known // before any bulk booking picks its wagons. const scarcityRank = this.scarcityRankForPool(pool, allowedWagonTypes); // Batch fill trace: each train's caps + the day pool size at entry. this.logger.debug( `[fillRouteDay ${originYardId}->${destinationYardId} ${day}] ` + `trains=${trains.map((t) => `${t.id}:${JSON.stringify(t.budget.maxRemaining())}`).join(",")} ` + `poolSize=${pool.length} units=${units.length}`, ); let reservedThisPass = 0; let commercialReserved = 0; for (const unit of units) { const { primary: booking, partner } = unit; const isPair = partner != null; const need = isPair ? this.combinedNeed(booking, partner, wagonDims) : this.needFor(booking, wagonDims); const isGov = booking.isGovernment || (partner?.isGovernment ?? false); const legOn = (t: { budget: CorridorBudget }): CorridorLeg | null => t.budget.legOf(booking.originYardId, booking.destinationYardId); // Consolidated pairs share one wagon set; the primary's types stand for both. const wagonTypeIds = this.allowedWagonTypeIdsFor(booking, allowedWagonTypes); // PER_TON bulk singles get the smart gate: seated type-by-type at the // cargo's per-wagon caps, scarcest type first — the count the allocator // will actually need, not a one-type estimate. Pairs, PER_ITEM and // unconfigured cargo keep the generic gate (gov preemption and partial // offers below also still size on the generic `need`). const perItemBulk = Number(booking.bulkTotalWeightTons ?? 0) > 0 && Number(booking.cargoTotalWeightVgm ?? 0) > 0; const useSmart = !isPair && booking.freightType === "BULK" && !perItemBulk && wagonTypeIds.length > 0; let smart: { need: Capacity; perType: Array<{ wagonTypeId: string; wagons: number }>; } | null = null; // First train (earliest departure) whose corridor carries this booking's // leg, still fits it as-is AND physically holds enough wagons of a type the // booking can ride. Both gates matter: abstract room without the right // wagon type is space the allocator can never turn into a loaded consist. let target: (typeof trains)[number] | undefined; for (const t of trains) { const leg = legOn(t); if (leg == null) continue; if (useSmart) { const probe = this.smartBulkNeed( booking, wagonDims, t.stock, leg, scarcityRank, wagonTypeIds, ); if (probe != null && t.budget.fits(probe.need, leg)) { smart = probe; target = t; break; } } else if ( t.budget.fits(need, leg) && this.hasWagonStock(t.stock, wagonTypeIds, need.wagons, leg) ) { target = t; break; } } // Per-unit trace: chosen train + each train's remaining room on this leg. this.logger.debug( `[fillRouteDay] unit ${booking.reference}: need=${JSON.stringify(need)} ` + `targetTrain=${target?.id ?? "none"} ` + `rooms=${trains .map((t) => { const leg = legOn(t); return leg ? `${t.id}:${JSON.stringify(t.budget.remainingFor(leg))}` : `${t.id}:offleg`; }) .join(",")}`, ); if (!target && isGov) { // Government fits nowhere on its own — try to preempt commercial // on each corridor-matching train (earliest first) until one frees room. for (const t of trains) { const leg = legOn(t); if (!leg) continue; const freed = await this.preemptForGovernment( t.id, need, leg, t.budget, wagonDims, ); // Preempt may have displaced (expired) victims even when the need // still doesn't fit — the board must refresh either way. t.changed = true; if (freed) { target = t; break; } } } if (!target) { // Fits no train whole. A split-eligible booking is offered the largest // part that fits on the train with the most free wagons on its leg (this // covers both "fits nowhere" and the boundary case where earlier bookings // already consumed most of the room). Consolidated pairs / government / // non-import never split — isSplitEligible guards that. Passing the live // `trains` entries lets maybeOfferPartial mutate the chosen budget/armed. const offered = await this.maybeOfferPartial( booking, isPair, trains, need, wagonTypeIds, ); if (offered) { // A partial offer opens a real commercial pay window, same as reserve(). commercialReserved += 1; reservedThisPass += 1; continue; } // Stays in the pool, retried next batch/window cycle. this.notifier.unplaced(booking, day); if (partner) this.notifier.unplaced(partner, day); continue; } // A throw here (e.g. a billing/invoice hiccup inside reserve) must NOT abort // the whole pass — otherwise only the bookings before the failure get a pay // window and the rest trickle in one-per-tick on later retries (the // "selected one at a time / staggered" symptom). Isolate each unit: log + // skip a failing one, keep reserving the others. The skipped unit stays in // the pool and is retried next cycle. try { if (isGov) { await this.allocate(target.id, booking, "gov"); if (partner) await this.allocate(target.id, partner, "gov"); } else { await this.reserve(booking, target.id); if (partner) await this.reserve(partner, target.id); target.armed = true; commercialReserved += 1; } target.budget.subtract(smart?.need ?? need, legOn(target)!); // Hold the physical wagons too, so the next unit in this pass sees them // gone — otherwise two bookings both "fit" the same 16 NW5. The smart // gate holds the EXACT per-type counts it seated (10 PW2 + 17 NW5), // not a type-blind total drained deepest-first. if (smart) { for (const part of smart.perType) { target.stock.consume([part.wagonTypeId], part.wagons, legOn(target)!); } } else { target.stock.consume(wagonTypeIds, need.wagons, legOn(target)!); } target.changed = true; reservedThisPass += 1; } catch (err) { this.logger.error( `[fillRouteDay] reserve/allocate FAILED for ${booking.reference} on ${target.id} ` + `— skipping this unit, continuing the batch: ${(err as Error).message}`, ); } } this.logger.log( `[fillRouteDay ${originYardId}->${destinationYardId} ${day}] reserved ${reservedThisPass}/${units.length} unit(s) this pass`, ); const minPerWagon = this.minPerWagonNeed(wagonDims); for (const t of trains) { if (t.budget.isExhausted(minPerWagon)) await this.setWindow(t.id, "FULL"); if (t.armed) this.armSettle(t.id); // One push per touched train per pass (never per booking). `armed` covers // commercial reserves + partial offers; `changed` covers gov allocations // and preemption. if (t.armed || t.changed) this.notifyBoardChanged(t.id, "batch_fill"); void this.triggerWagonAllocation(t.id); } return { scheduleIds: trains.map((t) => t.id), commercialReserved }; } /** * A lone commercial booking on a GENERAL or ONE_TIME contract may be offered a * partial (split-on-payment). Consolidated pairs never split (both-or-neither * shared wagon) and government bookings never split (they preempt). * * IMPORT and DOMESTIC (intercity ride-along) are always eligible. EXPORT is * eligible only when export split is enabled: export historically rides one * train whole, so splitting it changes the FCFS money path — each split part * still rides ONE train whole, and the leftover becomes its own booking on * the next train. */ private isSplitEligible(booking: Booking, isPair: boolean): boolean { const directionOk = booking.tradeDirection === "IMPORT" || booking.tradeDirection === "DOMESTIC" || (booking.tradeDirection === "EXPORT" && this.exportSplitEnabled); return ( !isPair && !booking.isGovernment && directionOk && (booking.contractKind === "GENERAL" || booking.contractKind === "ONE_TIME") && this.splitService != null ); } /** * Offer the largest fitting part of a booking that does not fit any candidate * train whole, on the train with the most free wagons on the booking's leg. * Mutates the chosen candidate's budget + armed flag in place. Returns true when * an offer was opened (caller should `continue` past this unit), false otherwise. * Shared by fillRouteDay (multi-train) and fillSchedule (single train). The leg * is computed per candidate from the booking's yards, so callers pass their live * train entries and only leg-carrying trains are considered. */ private async maybeOfferPartial( booking: Booking, isPair: boolean, candidates: Array<{ id: string; budget: CorridorBudget; armed: boolean; stock?: WagonStockLedger; }>, need: Capacity, wagonTypeIds: string[] = [], ): Promise { if (!this.isSplitEligible(booking, isPair)) return false; // PER_TON bulk partials are sized on ONE concrete wagon type at the // cargo's per-wagon cap — sizing on the first type's raw 70T rating // offered tonnage the wagons could never carry (Perishable caps at // 20/30T), taking payment for cargo that stalls at allocation. // ponytail: single-type bulk partials; a multi-type partial (PW2+NW5 // mixed) is the upgrade path if offers come out too small. const perItemBulk = Number(booking.bulkTotalWeightTons ?? 0) > 0 && Number(booking.cargoTotalWeightVgm ?? 0) > 0; const cappedBulk = !isPair && booking.freightType === "BULK" && !perItemBulk && wagonTypeIds.length > 0; const wagonDims = cappedBulk ? await this.loadWagonDims() : null; const target = candidates .map((c) => { const leg = c.budget.legOf(booking.originYardId, booking.destinationYardId); if (!leg) return null; const room = c.budget.remainingFor(leg); // The offer may never exceed the wagons that physically exist in a type // this booking can ride. This is what turns "20 free wagons, only 16 of // them NW5" into an offer for 16 — the customer pays for 16 and the // other 4 leave as the usual remainder booking, instead of paying for // 20 and stalling at allocation on wagon 17. if (cappedBulk && wagonDims) { // Types resolved from the id list (join tables), never the pool // entity's unloaded cargoType.wagonTypes relation — see smartBulkNeed. const best = [...new Set(wagonTypeIds)] .map((wagonTypeId) => ({ wagonTypeId, dims: wagonDims.byWagonTypeId.get(wagonTypeId), })) .filter((o): o is { wagonTypeId: string; dims: PerWagonDims } => o.dims != null) .map((o) => ({ ...o, free: c.stock?.availableFor([o.wagonTypeId], leg) ?? 0, takePerWagon: bulkTonsPerWagon( booking.cargoType, o.wagonTypeId, o.dims.capacityTons, ), })) .filter((o) => o.free > 0 && o.takePerWagon > 0) .sort((a, b) => b.takePerWagon - a.takePerWagon)[0]; if (!best) return null; return { c, leg, room: { ...room, wagons: Math.min(room.wagons, best.free) }, seat: { wagonTypeId: best.wagonTypeId, perWagon: { ...best.dims, capacityTons: best.takePerWagon }, }, }; } const physical = wagonTypeIds.length ? c.stock?.availableFor(wagonTypeIds, leg) : undefined; const wagons = physical == null ? room.wagons : Math.min(room.wagons, physical); return { c, leg, room: { ...room, wagons }, seat: undefined }; }) .filter((x): x is NonNullable => x != null && x.room.wagons >= 1) .sort((a, b) => b.room.wagons - a.room.wagons)[0]; if (!target) return false; const offered = await this.tryPartialOffer( booking, target.c.id, target.room, need, target.seat, ); if (!offered) return false; target.c.budget.subtract(offered, target.leg); target.c.stock?.consume( target.seat ? [target.seat.wagonTypeId] : wagonTypeIds, offered.wagons, target.leg, ); target.c.armed = true; return true; } /** * Offer the largest fitting part of an over-capacity booking as a partial * (split-on-payment). Returns the capacity the offer consumes, or null when no * meaningful partial fits / an offer is already open. */ private async tryPartialOffer( booking: Booking, scheduleId: string, budget: Capacity, need: Capacity, /** * Capped-bulk seating (see maybeOfferPartial): the ONE wagon type this * offer rides, with capacityTons already reduced to the cargo's per-wagon * cap — so the offered tonnage is what those wagons can really carry. */ seat?: { wagonTypeId: string; perWagon: PerWagonDims }, ): Promise { if (!this.splitService) return null; // A consolidated booking is already half of a shared wagon — never split it. if (booking.consolidationPartnerId) return null; if (await this.splitService.findOpenOffer(booking.id)) return null; const wagonDims = await this.loadWagonDims(); // The wagon-slot axis alone under-constrains the offer. On a weight- or // length-limited train (slots to spare, but e.g. only 798T of pull weight // left) sizing by slots either produced an offer the fits() check below // rejected, or — when the free slots exceeded the booking's own wagon // count — sizeOffer refused outright, so a bulk booking on a weight-bound // train was never offered a split at all. Size across all three axes, // measured on the booking's REAL wagon type — the same one allocation // validates against. Bulk splits ride FULL wagons only: the offer never // part-loads its last wagon. const perWagon = seat?.perWagon ?? this.dimsFor(booking, wagonDims); // With a capped seat, the whole booking's wagon count follows the cap too // (695T at 30T/wagon = 24, not 10 at the raw rating) — the offer must be a // strict subset of THAT count. const wholeWagons = seat ? Math.max(1, Math.ceil(bookingCargoTons(booking) / perWagon.capacityTons)) : need.wagons; const partial = sizePartialOfferWagons(budget, wholeWagons, perWagon, { fullWagonsOnly: booking.freightType === "BULK", }); if (!partial) return null; const sized = await this.splitService.sizeOffer( booking, partial.wagons, wholeWagons, perWagon.capacityTons, partial.maxCargoTons, ); if (!sized) return null; const offeredNeed: Capacity = { wagons: sized.offeredWagons, weightTons: bookingGrossWeightTons( sized.offeredWeightTons, sized.offeredWagons, perWagon.tareWeightTons, ), lengthMeters: sized.offeredWagons * perWagon.lengthMeters, }; if (!this.fits(offeredNeed, budget)) return null; const deadline = new Date( Date.now() + (await this.paymentWindowMsFor(await this.scheduleById(scheduleId))), ); await this.splitService.createOffer(booking, scheduleId, sized, deadline); // Reserve like a normal batch selection, but the partial invoice + partial // pay-now notification were already produced by createOffer. await this.bookingsRepository.update(booking.id, { trainScheduleId: scheduleId, status: "SELECTED_FOR_BATCH", selectedForBatchAt: new Date(), paymentDeadline: deadline, paymentReminderSentAt: null, } as never); booking.trainScheduleId = scheduleId; return offeredNeed; } /** * Settle a schedule's reserved bookings. `expireUnpaidUnknownDeadline` decides * how to treat a reservation with no deadline (durable path: leave it; timeout * path: expire it). Consolidated pairs settle atomically: both allocate only * when both paid; when neither paid, both expire. A half-paid pair splits: * the paid half keeps the whole wagon, the lapsed half expires and owes the * cancellation fee (expire()'s pair cascade). Returns whether anything changed. */ private async settleReserved( scheduleId: string, expireUnpaidUnknownDeadline: boolean, ): Promise { const reserved = await this.bookingsRepository.findReservedForSchedule(scheduleId); const now = Date.now(); const byId = new Map(reserved.map((b) => [b.id, b])); const done = new Set(); let anySettled = false; this.logger.debug( `[settleReserved ${scheduleId}] ${reserved.length} reserved booking(s) to settle`, ); const isPaid = (b: Booking) => b.paymentStatus === "PAID" || b.status === "PAID"; // The deadline carries a drain tail (payWindowLapsed): settlement is async, // so a payment made in the window's last seconds lands after it. Nothing is // expired until the tail passes. expire()'s gateway reconcile is the second // line of defence, not the first. const isExpired = (b: Booking) => b.paymentDeadline ? payWindowLapsed(b.paymentDeadline, now) : expireUnpaidUnknownDeadline; for (const booking of reserved) { if (done.has(booking.id)) continue; const partner = booking.consolidationPartnerId ? (byId.get(booking.consolidationPartnerId) ?? null) : null; if (partner) { done.add(booking.id); done.add(partner.id); // Both-or-neither: allocate the shared wagon only when both partners paid; // if either lapsed, expire both so no half-paid wagon rides. if (isPaid(booking) && isPaid(partner)) { await this.allocate(scheduleId, booking, "paid"); await this.allocate(scheduleId, partner, "paid"); anySettled = true; } else if (isExpired(booking) || isExpired(partner)) { // One call is enough: expire()'s pair cascade settles both sides — // both expire when neither paid; a paid half is rescued (keeps the // whole wagon) while the lapsed half expires with its fee. await this.expire(booking); anySettled = true; } continue; } done.add(booking.id); if (isPaid(booking)) { if (!(await this.holdIfWagonShort(scheduleId, booking))) { await this.allocate(scheduleId, booking, "paid"); } anySettled = true; } else if (isExpired(booking)) { await this.expire(booking); anySettled = true; } } return anySettled; } /** * Durable settle: allocate paid / expire overdue reservations, then top up the * freed capacity from the waiting list. * * Serialised per schedule. Two callers race here every time a payment phase * ends: `advanceImport`'s PAYMENT branch and the tick's `settleOverdueReservations` * backstop. Both read the same reserved rows in the same second, so without the * lock the second caller re-settles rows the first is mid-way through expiring, * and `concludeCycle` observes capacity that is neither pre- nor post-expiry. */ async settleDueReservations(scheduleId: string): Promise { await this.withScheduleLock(scheduleId, () => this.settleAndTopUp(scheduleId, false), ); } /** * Conclude-time retry: promote whatever still fits from the route-day waiting * list, opening fresh pay windows. Returns how many commercial units got * reserved — corridor-wide, since the fill is day-level and may reserve onto a * sibling train; the caller must check `hasLiveReservations` for its OWN * schedule before deciding to stay in PAYMENT. */ async fillFromWaitingList(scheduleId: string): Promise { return this.withScheduleLock(scheduleId, async () => { let promoted = 0; for (let round = 0; round < 10; round += 1) { const reservedThisRound = await this.topUpFill(scheduleId); if (reservedThisRound <= 0) break; promoted += reservedThisRound; await this.extendPaymentPhaseForTopUp(scheduleId); } if (promoted > 0) { this.notifyBoardChanged(scheduleId, "conclude_waiting_list_fill"); } return promoted; }); } /** * Settle, then keep promoting the waiting list until the train can take no more. * Returns whether anything settled. * * One top-up pass is not enough: expiring an N-wagon booking can free room for * several smaller ones, and reserving those can in turn leave room for the next * size down. Loop until a pass reserves nothing, so the batch ends with the train * as full as the pool allows — rather than leaving a booking stranded until the * next window cycle. * * Each round that opens a fresh pay window pushes `paymentPhaseEndsAt` out, so * `concludeCycle` cannot fire before the promoted customers' deadlines. */ private async settleAndTopUp( scheduleId: string, expireUnpaidUnknownDeadline: boolean, ): Promise { const anySettled = await this.settleReserved( scheduleId, expireUnpaidUnknownDeadline, ); if (!anySettled) return false; this.logger.log( `[BATCH] settle changed state on ${scheduleId} — running top-up fill for the waiting list`, ); // Bounded: every round either reserves at least one unit (shrinking the pool) // or breaks. The cap is a backstop against a pathological reserve/expire cycle. let promoted = 0; for (let round = 0; round < 10; round += 1) { const reservedThisRound = await this.topUpFill(scheduleId); if (reservedThisRound <= 0) break; promoted += reservedThisRound; await this.extendPaymentPhaseForTopUp(scheduleId); } if (promoted > 0) { this.logger.log( `[BATCH] top-up promoted ${promoted} waiting booking(s) onto ${scheduleId} ` + `— payment phase extended for them`, ); } // The settle may have resolved the last pay window on a full export day // (paid → allocated, and the top-up found nothing else that fits) — sweep // the date's leftover bookings. Self-guarded: no-op for import/domestic // and while any train on the day can still take bookings. await this.expireLeftoverExportDay(scheduleId); // Emitted here (not in settleDueReservations/settleBatch, which both wrap // this) so one settle produces one push, after every allocation/expiry/ // top-up extension for this schedule has been persisted. this.notifyBoardChanged(scheduleId, "reservations_settled"); return true; } /** * Run `fn` with exclusive access to `scheduleId`. Concurrent callers await the * in-flight run rather than interleaving with it. Single-process only — a second * API replica would need a row lock on the schedule instead. */ private async withScheduleLock( scheduleId: string, fn: () => Promise, ): Promise { const inFlight = this.scheduleLocks.get(scheduleId) ?? Promise.resolve(); // Chain onto the previous holder; swallow its rejection so one failure does // not poison every later caller's lock. const run = inFlight.catch(() => undefined).then(fn); const gate = run.then( () => undefined, () => undefined, ); this.scheduleLocks.set(scheduleId, gate); try { return await run; } finally { // Last one out clears the slot so the map does not grow without bound. if (this.scheduleLocks.get(scheduleId) === gate) { this.scheduleLocks.delete(scheduleId); } } } // ---- settle (1h after a batch) ------------------------------------------- /** Allocate paid reservations, expire the rest, then top up. */ async settleBatch(scheduleId: string): Promise { this.removeTimeout(scheduleId); await this.withScheduleLock(scheduleId, () => this.settleAndTopUp(scheduleId, true), ); void this.triggerWagonAllocation(scheduleId); } private triggerWagonAllocation(scheduleId: string): void { void this.trainSchedulingService .tryAutoWagonAllocation(scheduleId) .catch((err) => this.logger.warn( `Auto wagon allocation failed for ${scheduleId}: ${(err as Error).message}`, ), ); } /** * Announce that a schedule's batch-board data changed so open boards refetch. * Called AFTER the state change is persisted; a push failure only logs — it * must never break the business transaction that triggered it. */ private notifyBoardChanged(scheduleId: string, reason: string): void { try { this.bookingWindowGateway.emitBatchChanged(scheduleId, reason); } catch (err) { this.logger.warn( `Batch-board push (${reason}) failed for ${scheduleId}: ${(err as Error).message}`, ); } } // ---- staff override actions ---------------------------------------------- /** Staff "mark paid" override → set PAID and allocate immediately (don't wait for settle). */ async markPaid(bookingId: string): Promise { const booking = await this.dataSource .getRepository(Booking) .findOne({ where: { id: bookingId } }); if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`); if (!booking.trainScheduleId) { throw new BadRequestException( "Booking has no target schedule to allocate to", ); } await this.dataSource .getRepository(Booking) .update(bookingId, { paymentStatus: "PAID" }); if (!(await this.holdIfWagonShort(booking.trainScheduleId, booking))) { await this.allocate(booking.trainScheduleId, booking, "paid"); } const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph( booking.trainScheduleId, ); if (schedule && (await this.isTrainFull(schedule))) { await this.setWindow(booking.trainScheduleId, "FULL"); // Same as the webhook path: a staff mark-paid can settle the last live // pay window on a now-full export day — sweep the date's leftovers. void this.expireLeftoverExportDay(booking.trainScheduleId); } void this.triggerWagonAllocation(booking.trainScheduleId!); this.notifyBoardChanged(booking.trainScheduleId, "booking_marked_paid"); } /** * Re-point a booking to another OPEN same-route schedule (keeps approval/contract + priority). * Used for EXPIRED or full-schedule bookings — no re-approval. */ async moveToSchedule( bookingId: string, newScheduleId: string, ): Promise { const booking = await this.dataSource .getRepository(Booking) .findOne({ where: { id: bookingId } }); if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`); const schedule = await this.dataSource .getRepository(TrainSchedule) .findOne({ where: { id: newScheduleId } }); if (!schedule) throw new NotFoundException(`Train schedule ${newScheduleId} not found`); if (schedule.bookingWindowStatus !== "OPEN") { throw new BadRequestException( "Target schedule is not accepting bookings", ); } const stops = await this.stopsForSchedule(schedule); const fromIdx = stops.indexOf(booking.originYardId); const toIdx = stops.indexOf(booking.destinationYardId); if (fromIdx < 0 || toIdx < 0 || fromIdx >= toIdx) { throw new BadRequestException( "Target schedule is not on the booking route", ); } const sourceScheduleId = booking.trainScheduleId ?? null; await this.dataSource.transaction(async (manager) => { if (booking.trainScheduleId) { await this.trainScheduleBookingsRepository.deleteByScheduleAndBooking( booking.trainScheduleId, bookingId, manager, ); } const restoredStatus = booking.status === "EXPIRED" ? booking.isGovernment ? "APPROVED" : "FULLY_EXECUTED" : booking.status; await manager.getRepository(Booking).update(bookingId, { trainScheduleId: newScheduleId, scheduledDate: schedule.scheduledDepartureDate, status: restoredStatus, // A paid booking still hunting for a wagon keeps its flag through the // move — it only clears when wagons are actually assigned. schedulingStatus: booking.schedulingStatus === "WAITING_FOR_WAGON" ? "WAITING_FOR_WAGON" : "ELIGIBLE", paymentDeadline: null, selectedForBatchAt: null, } as never); }); // Both boards changed: the booking left the source train and joined the target. if (sourceScheduleId && sourceScheduleId !== newScheduleId) { this.notifyBoardChanged(sourceScheduleId, "booking_moved"); } this.notifyBoardChanged(newScheduleId, "booking_moved"); } /** * Trains a paid-but-unallocated booking can board right now: OPEN window, * future departure, route covers the booking's leg, and remaining corridor * capacity fits it. Split by the booking's own scheduled day so the UI can * offer one-click same-day allocation vs an explicit "another date" choice. */ async allocationCandidates(bookingId: string): Promise<{ sameDay: AllocationCandidate[]; otherDays: AllocationCandidate[]; }> { const booking = await this.dataSource.getRepository(Booking).findOne({ where: { id: bookingId }, relations: { bookingContainers: { containerType: true }, // wagonTypes drives the break-bulk items-per-wagon fit — size the // booking exactly as the intercity accept check does. cargoType: { wagonTypes: true }, }, }); if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`); const schedules = await this.trainSchedulesRepository.findAll({ where: [ // Dedicated shipping-line trains are never customer-booking targets. { status: TrainScheduleStatusEnum.Draft, shippingLineCompanyId: IsNull() }, { status: TrainScheduleStatusEnum.Scheduled, shippingLineCompanyId: IsNull() }, ], }); const today = eatDay(new Date()); const bookingDay = booking.scheduledDate ? eatDay(booking.scheduledDate) : null; const sameDay: AllocationCandidate[] = []; const otherDays: AllocationCandidate[] = []; for (const s of schedules) { if (!s.scheduledDepartureDate || eatDay(s.scheduledDepartureDate) < today) continue; if (s.bookingWindowStatus !== "OPEN") continue; if (s.id === booking.trainScheduleId) continue; const stops = await this.stopsForSchedule(s); const fromIdx = stops.indexOf(booking.originYardId); const toIdx = stops.indexOf(booking.destinationYardId); if (fromIdx < 0 || toIdx < 0 || fromIdx >= toIdx) continue; // ponytail: full capacity build per candidate is heavy; the set is small // (future OPEN trains on the booking's route) — precompute if it grows. const cap = await this.intercityCapacity(s.id); if (!cap) continue; const leg = cap.budget.legForYards(booking.originYardId, booking.destinationYardId); if (!cap.budget.fits(cap.needFor(booking), leg)) continue; const candidate: AllocationCandidate = { id: s.id, reference: s.reference ?? s.trainNumber ?? null, direction: s.direction ?? null, scheduledDepartureDate: s.scheduledDepartureDate, }; (eatDay(s.scheduledDepartureDate) === bookingDay ? sameDay : otherDays).push(candidate); } const byDate = (a: AllocationCandidate, b: AllocationCandidate) => new Date(a.scheduledDepartureDate).getTime() - new Date(b.scheduledDepartureDate).getTime(); sameDay.sort(byDate); otherDays.sort(byDate); return { sameDay, otherDays }; } /** * Place a PAID booking that lost (or never got) its train: re-point via * moveToSchedule (window/route validation + day sync), then allocate it * immediately — payment already landed, so no new pay window opens. The * customer gets an in-app notice when the new train departs on a different * day than their original choice. */ async allocatePaid(bookingId: string, scheduleId: string): Promise { const before = await this.dataSource .getRepository(Booking) .findOne({ where: { id: bookingId } }); if (!before) throw new NotFoundException(`Booking ${bookingId} not found`); if (before.paymentStatus !== "PAID" && before.status !== "PAID") { throw new BadRequestException( "Booking is not paid — use the regular scheduling flow", ); } const previousDay = before.scheduledDate ? eatDay(before.scheduledDate) : null; await this.moveToSchedule(bookingId, scheduleId); const fresh = await this.dataSource.getRepository(Booking).findOne({ where: { id: bookingId }, relations: { bookingContainers: { containerType: true }, cargoType: true }, }); if (!fresh) return; if (!(await this.holdIfWagonShort(scheduleId, fresh))) { await this.allocate(scheduleId, fresh, "paid"); } const schedule = await this.dataSource .getRepository(TrainSchedule) .findOne({ where: { id: scheduleId } }); if ( previousDay && schedule?.scheduledDepartureDate && eatDay(schedule.scheduledDepartureDate) !== previousDay ) { this.notifier.allocatedOtherDay(fresh, schedule.scheduledDepartureDate); } } /** * Auto-allocate an accepted SHIPPING-LINE booking onto its company's * dedicated train for the booking's lane and shipment day. * * Runs at operation-accept: shipping lines pay later on the credit ledger, * so there is no pay window between accept and wagon placement — the * booking boards its train immediately. Customer bookings never come here; * they keep the batch pool → reserve → pay → allocate pipeline. * * Wagon shortage parks the booking WAITING_FOR_WAGON on the schedule * (without the PAID stamps the customer hold writes — nothing was paid). * No dedicated train on the day is not an error: the booking simply stays * in the ordinary day pool for the batch engine. */ async allocateShippingLineAccepted(bookingId: string): Promise { const booking = await this.dataSource.getRepository(Booking).findOne({ where: { id: bookingId }, relations: { bookingContainers: { containerType: true }, cargoType: true }, }); if (!booking?.shippingLineCompanyId || !booking.scheduledDate) return; if (isRoadService(booking.serviceType)) return; const day = eatDay(booking.scheduledDate); const dedicated = await this.dataSource.getRepository(TrainSchedule).find({ where: [ { shippingLineCompanyId: booking.shippingLineCompanyId, originStationId: booking.originYardId, destinationStationId: booking.destinationYardId, status: TrainScheduleStatusEnum.Draft, }, { shippingLineCompanyId: booking.shippingLineCompanyId, originStationId: booking.originYardId, destinationStationId: booking.destinationYardId, status: TrainScheduleStatusEnum.Scheduled, }, ], }); const target = dedicated.find( (s) => s.scheduledDepartureDate && eatDay(s.scheduledDepartureDate) === day, ); if (!target) { this.logger.log( `[BATCH] shipping-line booking ${booking.reference} has no dedicated ` + `train on ${day} — left in the day pool for the batch engine`, ); return; } // Point the booking at its train BEFORE the shortage probe — the probe // reads the link to size the need against that schedule's wagons. await this.dataSource.getRepository(Booking).update(booking.id, { trainScheduleId: target.id, } as never); booking.trainScheduleId = target.id; // One dedicated train carries ONE booking: the accept claims the train by // closing its booking window on the spot. Both gates a later booking // passes — the day picker (isStillOpen on windowClosesAt) and the // completion's dedicated-day check — read these fields, so a second // booking can never pick this train. await this.dataSource.getRepository(TrainSchedule).update(target.id, { bookingWindowStatus: "CLOSED", windowClosesAt: new Date(), } as never); this.notifyBoardChanged(target.id, "shipping_line_train_claimed"); const shortage = await this.trainSchedulingService.previewPaidBookingWagonShortage( target.id, booking.id, ); if (shortage) { // Parked for staff to attach wagons — WITHOUT the customer hold's PAID // stamps: a shipping line has paid nothing, its debt sits on the ledger. await this.dataSource.getRepository(Booking).update(booking.id, { schedulingStatus: "WAITING_FOR_WAGON", } as never); this.logger.warn( `Shipping-line booking ${booking.reference} WAITING FOR WAGON on its ` + `dedicated train ${target.reference ?? target.id}: needs ` + `${shortage.wagonsNeeded} × ${shortage.wagonTypeCodes}, ` + `${shortage.wagonsAvailable} available (short ${shortage.wagonsShort}).`, ); this.notifyBoardChanged(target.id, "booking_waiting_wagon"); return; } await this.allocate(target.id, booking, "shipping_line"); } /** * One reminder per hold, shortly before its pay deadline (the window tick * calls this every pass; `payment_reminder_sent_at` dedups). Skips paid * bookings — a landed payment the settle hasn't processed yet needs no nag. */ async sendPaymentReminders(): Promise { const now = new Date(); const due = await this.dataSource .getRepository(Booking) .createQueryBuilder("b") .leftJoinAndSelect("b.company", "company") .where(`b.status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT')`) .andWhere(`b.payment_status != 'PAID'`) .andWhere("b.payment_reminder_sent_at IS NULL") .andWhere("b.payment_deadline > :now", { now }) .andWhere("b.payment_deadline <= :soon", { soon: new Date(now.getTime() + PAYMENT_REMINDER_LEAD_MS), }) .getMany(); for (const booking of due) { // Stamp BEFORE sending so a slow notifier can't double-send next tick. await this.bookingsRepository.update(booking.id, { paymentReminderSentAt: new Date(), } as never); if (booking.paymentDeadline) { await this.notifier.payDeadlineApproaching( booking, booking.paymentDeadline, ); } } } /** Staff "expire" override → free a reservation now (booking becomes EXPIRED). */ async expireReservation(bookingId: string): Promise { const booking = await this.dataSource .getRepository(Booking) .findOne({ where: { id: bookingId } }); if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`); // Capture the train before expire() detaches the booking from it — the // top-up has to run against the schedule whose wagons were just freed. const freedScheduleId = booking.trainScheduleId; await this.expire(booking); if (freedScheduleId) { const topUpReserved = await this.topUpFill(freedScheduleId); if (topUpReserved > 0) { await this.extendPaymentPhaseForTopUp(freedScheduleId); } // After the top-up + phase extension so one push carries the final state. this.notifyBoardChanged(freedScheduleId, "reservation_expired"); } } /** * Customer cancel of an unpaid hold: the same immediate release as * expireReservation, but the booking ends CANCELLED (the customer chose to * walk away — "payment window missed" copy would be wrong). Consolidated * pairs are rejected by the caller: the shared wagon is both-or-neither. */ async cancelReservation(bookingId: string): Promise { const booking = await this.dataSource .getRepository(Booking) .findOne({ where: { id: bookingId } }); if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`); const freedScheduleId = booking.trainScheduleId; await this.bookingsRepository.update(booking.id, { trainScheduleId: null, requestedTrainScheduleId: null, status: "CANCELLED", schedulingStatus: "ELIGIBLE", paymentDeadline: null, selectedForBatchAt: null, paymentReminderSentAt: null, } as never); // An unpaid partial offer dies with the hold — same as expire(). if (this.splitService) { await this.splitService.expireOpenOffer(booking.id); } await this.billing.expirePayable( Freight.InvoiceSource.Booking, booking.id, "PREPAID", ); if (freedScheduleId) { // Same release choreography as expireReservation: reopen a FULL window, // top up from the waiting list, push one board update with final state. await this.refreshWindowStatus(freedScheduleId); const topUpReserved = await this.topUpFill(freedScheduleId); if (topUpReserved > 0) { await this.extendPaymentPhaseForTopUp(freedScheduleId); } this.notifyBoardChanged(freedScheduleId, "reservation_expired"); } this.logger.log( `[BATCH] CANCELLED hold ${booking.reference} — customer released the ` + `reservation before paying; wagons freed`, ); } // ---- intercity ride-along API --------------------------------------------- /** * Remaining corridor capacity budget (per-edge wagons / weight / length) for * a schedule, and the per-booking need calculator — exposed for the intercity * accept flow, which reserves ride-along bookings onto import/export trains * outside the batch engine. Segment-based: an intercity booking fits whenever * ITS leg has room, even if the train is full on other legs. */ async intercityCapacity(scheduleId: string): Promise<{ budget: CorridorBudget; needFor: (booking: Booking) => Capacity; /** * Per-wagon-type split of `needFor(booking).wagons`, against THIS * schedule's own wagon stock — so the same booking reads differently on a * different train. Empty when the stock can't be resolved. */ breakdownFor: ( booking: Booking, ) => Array<{ wagonTypeId: string; code: string; wagons: number }>; } | null> { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); const locomotive = trainSetLocomotiveLimits(schedule?.trainSet); if (!schedule || !locomotive) return null; const wagonDims = await this.loadWagonDims(); const limits = await this.capacityLimits(locomotive); // Built trains use the leg-aware corridor budget too: the wagon planner // consumes stock PER EDGE (planWagonsWithStock legs), so a consist wagon // that runs empty Gelan→Adama genuinely can carry an intercity booking // there before its export cargo boards at Adama. A train full on one leg // still accepts ride-alongs on its empty legs — that is the whole point // of the ride-along flow. const budget = await this.remainingBudget(schedule, limits, wagonDims); // Physical stock of THIS schedule's train (built consist, or the yard fleet // it will draw from) — what makes the breakdown train-specific. const stock = await this.trainSchedulingService.wagonStockForSchedule( schedule.id, schedule.originStationId, budget.stops, ); return { budget, needFor: (booking) => this.needFor(booking, wagonDims), breakdownFor: (booking) => this.wagonBreakdownFor( booking, wagonDims, stock.remainingByTypeId, stock.codesByTypeId, ), }; } /** * Accept an intercity booking onto the given train. Commercial bookings get * the same pay-window lifecycle as a batch reservation (deadline, invoice * due-date sync, pay-now notify, settle on the window tick), so payment → * allocation needs no special path. Government bookings allocate directly. */ async acceptIntercity(booking: Booking, scheduleId: string): Promise { if (booking.isGovernment) { await this.dataSource .getRepository(Booking) .update(booking.id, { trainScheduleId: scheduleId }); booking.trainScheduleId = scheduleId; await this.allocate(scheduleId, booking, 'gov'); this.notifyBoardChanged(scheduleId, 'intercity_accepted'); return; } // Manual placement of an ALREADY-PAID intercity booking: payment landed // earlier (and unpinned it back to the pool) — staff are now choosing its // train, so link directly. No new pay window; wagon assignment stays with // staff in the workspace. if (booking.paymentStatus === 'PAID' || booking.status === 'PAID') { await this.dataSource .getRepository(Booking) .update(booking.id, { trainScheduleId: scheduleId }); booking.trainScheduleId = scheduleId; await this.allocate(scheduleId, booking, 'paid'); this.notifyBoardChanged(scheduleId, 'intercity_accepted'); return; } await this.reserve(booking, scheduleId); this.armSettle(scheduleId); this.notifyBoardChanged(scheduleId, 'intercity_accepted'); } /** * Intercity booking that does not fit its leg whole: offer the largest part * that does (split-on-payment, customer notified with a pay window), sized * against the leg's remaining room AND the train's physical wagon stock. * Returns true when an offer was opened. The caller's budget is mutated so * later bookings in the same accept pass see the offer's consumption. */ async offerIntercityPartial( booking: Booking, scheduleId: string, budget: CorridorBudget, ): Promise { const wagonDims = await this.loadWagonDims(); const need = this.needFor(booking, wagonDims); const allowed = await this.loadAllowedWagonTypeIds(); const wagonTypeIds = this.allowedWagonTypeIdsFor(booking, allowed); const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); if (!schedule) return false; const stock = await this.stockLedgerFor(schedule, budget); const cand = { id: scheduleId, budget, armed: false, stock }; const offered = await this.maybeOfferPartial( booking, false, [cand], need, wagonTypeIds, ); if (offered && cand.armed) { this.armSettle(scheduleId); this.notifyBoardChanged(scheduleId, 'intercity_partial_offered'); } return offered; } // ---- mutations ------------------------------------------------------------ /** * Reserve capacity for a commercial booking on a specific train and open its * pay window. `scheduleId` is persisted so the settle/allocate lifecycle * (settleDueReservations, settleBatch, ensurePaidBookingAllocated, markPaid), * which is all keyed off `booking.trainScheduleId`, can find the train — with * day-level pooling the booking arrives here with `trainScheduleId` still null, * so the engine sets it as it picks the train. */ private async reserve(booking: Booking, scheduleId: string): Promise { // Idempotency guard: a booking already reserved (pay window open) or already // paid on THIS schedule must never be re-reserved — that would fire a second // `payNow` and reset its deadline, the "asked to pay again after paying" // symptom. Read fresh state (the in-memory `booking` may be stale from the // pooled query). Only bookings not yet committed to this train pass through. const fresh = await this.dataSource .getRepository(Booking) .findOne({ where: { id: booking.id } }); if ( fresh && fresh.trainScheduleId === scheduleId && (fresh.status === "SELECTED_FOR_BATCH" || fresh.status === "AWAITING_PAYMENT" || fresh.status === "PAID" || fresh.paymentStatus === "PAID") ) { this.logger.debug( `[BATCH] reserve skipped for ${booking.reference} — already ` + `${fresh.status}/${fresh.paymentStatus} on schedule ${scheduleId}`, ); return; } const now = new Date(); const targetSchedule = await this.scheduleById(scheduleId); let deadline = new Date( now.getTime() + (await this.paymentWindowMsFor(targetSchedule)), ); // EXPORT parity: pay windows on an export train never outlive its booking // window — export bookings expire at close, so anything reserved onto the // same train (FCFS export or an intercity ride-along) must too. Import // keeps the plain payment window; its cycles re-fill after settle. if (targetSchedule?.direction === "EXPORT") { const cutoff = targetSchedule.windowClosesAt ?? targetSchedule.scheduledDepartureDate; if (cutoff && cutoff.getTime() <= now.getTime()) { throw new BadRequestException( "Export booking window has closed — cannot open a pay window on this train", ); } if (cutoff && cutoff.getTime() < deadline.getTime()) { deadline = new Date(cutoff); } } await this.bookingsRepository.update(booking.id, { trainScheduleId: scheduleId, status: "SELECTED_FOR_BATCH", selectedForBatchAt: now, paymentDeadline: deadline, paymentReminderSentAt: null, } as never); booking.trainScheduleId = scheduleId; // The invoice was generated DRAFT at booking creation / operation-accept, // before this pay window existed. Reserving is the moment the booking becomes // payable (SELECTED_FOR_BATCH + a real deadline), so issue the draft here and // print the deadline as its due date — never earlier, or the customer could // settle an invoice for a slot they have not been offered yet. Idempotent: a // re-reserve only refreshes `dueAt`. await this.billing.issuePayable( Freight.InvoiceSource.Booking, booking.id, deadline, "PREPAID", ); await this.notifier.payNow(booking, deadline); const reservedWagons = this.wagonsFor(booking, await this.loadWagonDims()); this.logger.log( `[BATCH] RESERVED ${booking.reference} (${reservedWagons}w, ` + `priority ${booking.priorityScore ?? 0}) on schedule ${scheduleId} — ` + `pay by ${deadline.toISOString()}`, ); // Customer tracking: a wagon slot is reserved and the freight pay window is // open. Doc-trigger path — silent no-op for bookings without milestone rows. void this.completeTrackingMilestones(booking.id, [ "WAGON_REQUESTED", "FREIGHT_PAYMENT_PENDING", ]); } /** Allocate a booking to the schedule's train (creates the TrainScheduleBooking link). */ /** * Fleet preflight shared by every single-booking paid-allocation path: when * no wagon of the booking's required type is free, hold it OUT of the train * instead of linking — it stays PAID + unlinked in the (route, day) pool, * flagged WAITING_FOR_WAGON, and staff place it on any same-day schedule from * the workspace "Paid · unassigned" panel once a wagon frees up. Returns true * when the booking was held. Consolidated pairs are exempt (the shared wagon * is both-or-neither and settles atomically in settleReserved). */ private async holdIfWagonShort( scheduleId: string, booking: Booking, ): Promise { if (booking.consolidationPartnerId) return false; const shortage = await this.trainSchedulingService.previewPaidBookingWagonShortage( scheduleId, booking.id, ); if (!shortage) return false; await this.dataSource.getRepository(Booking).update(booking.id, { status: "PAID", paymentStatus: "PAID", schedulingStatus: "WAITING_FOR_WAGON", paymentDeadline: null, selectedForBatchAt: null, } as never); // Payment landed — record it even though nothing boards yet. The wagon // milestone stays pending until staff assign one. void this.completeTrackingMilestones(booking.id, [ "FREIGHT_PAYMENT_PENDING", "FREIGHT_PAYMENT_SETTLED", ]); this.logger.warn( `PAID booking ${booking.reference ?? booking.id} is WAITING FOR WAGON: ` + `needs ${shortage.wagonsNeeded} × ${shortage.wagonTypeCodes}, ` + `${shortage.wagonsAvailable} available (short ${shortage.wagonsShort}). ` + `Held in the day pool for manual placement.`, ); this.notifyBoardChanged(scheduleId, "booking_waiting_wagon"); return true; } private async allocate( scheduleId: string, booking: Booking, reason: "paid" | "gov" | "shipping_line", ): Promise { // Stamp the computed wagon need on the link. Several callers pass a booking // loaded without cargo relations (ensurePaidBookingAllocated), and a NULL // wagonsRequired makes every capacity/occupancy reader miscount this // booking as 1 wagon — reload with the relations wagonsFor sizes from. const wagonDims = await this.loadWagonDims(); const full = booking.bookingContainers || booking.cargoType ? booking : await this.dataSource.getRepository(Booking).findOne({ where: { id: booking.id }, relations: { bookingContainers: { containerType: true }, cargoType: true, }, }); const wagonsRequired = this.wagonsFor(full ?? booking, wagonDims); await this.dataSource.transaction(async (manager) => { const exists = await this.trainScheduleBookingsRepository.existsForBooking( booking.id, manager, ); if (!exists) { await this.trainScheduleBookingsRepository.createMany( [{ trainScheduleId: scheduleId, bookingId: booking.id }], manager, ); } await manager.getRepository(Booking).update(booking.id, { status: reason === "paid" ? "PAID" : booking.status, schedulingStatus: "SCHEDULED", scheduledAt: new Date(), wagonsRequired, paymentDeadline: null, selectedForBatchAt: null, } as never); }); this.logger.log( `[BATCH] ALLOCATED ${booking.reference} (${reason}) to train on schedule ${scheduleId}`, ); this.notifier.secured(booking, reason, scheduleId); // Intercity rides are placed on wagons BY STAFF (workspace wizard) — auto // wagon assignment is for the import/export batch flow only. if (booking.tradeDirection !== 'DOMESTIC') { void this.triggerWagonAllocation(scheduleId); } void this.markWagonAllocatedMilestone(booking.id); // Customer tracking: freight payment settled (commercial pay-window path). // Government allocations don't pay upfront — theirs stay pending. if (reason === 'paid') { void this.completeTrackingMilestones(booking.id, [ 'WAGON_REQUESTED', 'FREIGHT_PAYMENT_PENDING', 'FREIGHT_PAYMENT_SETTLED', ]); } } private async markWagonAllocatedMilestone(bookingId: string): Promise { if (!this.milestoneService) return; try { await this.milestoneService.completeForBooking(bookingId, 'WAGON_ALLOCATED'); } catch { // Booking may have no milestone rows (non-contract path). } } /** * Complete customer-tracking milestones on lifecycle events via the * doc-trigger path — a silent no-op for bookings without milestone rows * (non-customs bookings). Never blocks the batch action. */ private async completeTrackingMilestones( bookingId: string, codes: string[], ): Promise { if (!this.milestoneService) return; for (const code of codes) { try { await this.milestoneService.completeByDocTrigger({ bookingId }, code); } catch (err) { this.logger.warn( `Milestone ${code} completion failed for booking ${bookingId}: ${(err as Error).message}`, ); } } } /** * Expire an unpaid reservation and free its capacity. With day-level pooling we * also clear `trainScheduleId` so the booking is no longer pinned to the train * it failed to pay for — it's back in the day pool for staff to act on. * `reason` picks the customer message: 'payment' (pay window lapsed) or * 'no-capacity' (no train on the chosen day could take the booking). * * PAID GUARD: a booking whose payment has landed is never expired — money was * taken, so it boards, even when the webhook arrived after the deadline or the * settle read a stale row. It allocates onto the train it was selected for; if * the wagon planner then finds no physical wagon, the booking stays linked and * staff assign wagons manually. EXCEPTION — a consolidated booking whose * partner lapsed unpaid is NOT rescued: its odd 20ft cannot board without the * partner, so the paid side is cancelled with the cancellation fee (the * partnerLapsed listener in BookingWagonCancellationService). */ private async expire( booking: Booking, reason: "payment" | "no-capacity" = "payment", ): Promise { // Consolidated pair: break the link FIRST, then settle each side singly. // - neither paid → both expire, no fee. // - one side paid → BOTH die: the unpaid half expires fee-free (fees only // apply to paid bookings); the paid half cannot board alone, so the // 'partnerLapsed' event cancels it with the cancellation fee on ceil of // its wagons (BookingWagonCancellationService) — paid freight kept as // rebooking credit for GL staff. // - both paid → nothing to expire; the paid guard rescues. if (booking.consolidationPartnerId) { const partnerId = booking.consolidationPartnerId; const bookingRepo = this.dataSource.getRepository(Booking); const partnerRow = await bookingRepo.findOne({ where: { id: partnerId }, relations: { company: true }, }); const freshSelf = await bookingRepo.findOne({ where: { id: booking.id }, }); const paidOf = (b: Booking | null) => b != null && (b.paymentStatus === "PAID" || b.status === "PAID"); const selfPaid = paidOf(freshSelf); const partnerPaid = paidOf(partnerRow); await this.bookingsRepository.clearConsolidationPair( booking.id, partnerId, ); booking.consolidationPartnerId = null; if (partnerRow) partnerRow.consolidationPartnerId = null; if (selfPaid && !partnerPaid) { // Wrong side called first: the unpaid partner expires fee-free; this // PAID booking cannot board without it, so the listener cancels it // with the cancellation fee — never rescued. if (partnerRow && !["EXPIRED", "CANCELLED"].includes(partnerRow.status)) { await this.expire(partnerRow, reason); } this.eventEmitter?.emit("booking.consolidation.partnerLapsed", { paidBookingId: booking.id, }); return; } else if (!selfPaid && partnerPaid) { // This unpaid side expires below, fee-free; the PAID partner cannot // board alone, so the listener cancels it with the cancellation fee. this.eventEmitter?.emit("booking.consolidation.partnerLapsed", { paidBookingId: partnerId, }); } else if (!selfPaid && !partnerPaid) { if (partnerRow && !["EXPIRED", "CANCELLED"].includes(partnerRow.status)) { await this.expire(partnerRow, reason); } } } if (!booking.consolidationPartnerId) { const fresh = await this.dataSource .getRepository(Booking) .findOne({ where: { id: booking.id }, relations: { company: true } }); const paid = fresh != null && (fresh.paymentStatus === "PAID" || fresh.status === "PAID"); const paidScheduleId = fresh?.trainScheduleId ?? booking.trainScheduleId; if (paid && paidScheduleId) { this.logger.log( `[BATCH] expire skipped for ${booking.reference} — payment already ` + `landed; allocating on schedule ${paidScheduleId} instead`, ); if (!(await this.holdIfWagonShort(paidScheduleId, fresh))) { await this.allocate(paidScheduleId, fresh, "paid"); } return; } // Paid but detached from any train (staff removed it from an allocation, // or a sweep caught it unpinned): money was taken, so it must board — it // stays paid-unallocated for staff to place via the allocate action. if (paid) { this.logger.log( `[BATCH] expire skipped for ${booking.reference} — payment landed ` + `but no train attached; left paid-unallocated for manual placement`, ); return; } // Reconcile-before-expire (only when a pay window was actually open): // no webhook arrived, so ask the gateway DIRECTLY whether the money // landed. A late capture found there is registered as SUCCEEDED and // emits payment.succeeded — that event marks the booking PAID and // allocates it, so we just leave the hold alone here. `unverifiable` // (provider query errored / payment still in flight) means we could not // confirm "not paid" — never expire on unknown; the next settle tick // asks again. // TODO: CBE has no reconcile endpoint yet — re-enable once available. // if (reason === "payment" && (fresh?.paymentDeadline ?? booking.paymentDeadline)) { // const reconcile = await this.billing.reconcilePayable(booking.id); // if (reconcile.paid) { // this.logger.log( // `[BATCH] expire skipped for ${booking.reference} — gateway ` + // `reconcile found a settled payment; payment.succeeded will allocate it`, // ); // return; // } // if (reconcile.unverifiable) { // this.logger.warn( // `[BATCH] expire deferred for ${booking.reference} — settlement ` + // `unverifiable at the gateway; retrying next settle tick`, // ); // return; // } // } } const freedScheduleId = booking.trainScheduleId; await this.bookingsRepository.update(booking.id, { trainScheduleId: null, // The customer's train pick died with the hold — a rebook re-picks. requestedTrainScheduleId: null, status: "EXPIRED", schedulingStatus: "ELIGIBLE", paymentDeadline: null, selectedForBatchAt: null, paymentReminderSentAt: null, } as never); booking.trainScheduleId = null; // The wagons this reservation held are back — a schedule parked at FULL // because of it must reopen, or it can never be filled again. if (freedScheduleId) await this.refreshWindowStatus(freedScheduleId); // An unpaid partial offer dies with the reservation — the booking stays whole. if (this.splitService) { await this.splitService.expireOpenOffer(booking.id); } // Pay window closed before settlement → expire the booking's open invoice too // (emits `booking.invoice.expired`). Domain owns the reaction; billing stays // source-agnostic. await this.billing.expirePayable(Freight.InvoiceSource.Booking, booking.id, "PREPAID"); if (reason === "no-capacity") { this.notifier.expiredNoCapacity(booking); } else { this.notifier.expired(booking); } this.logger.log( `[BATCH] EXPIRED ${booking.reference} — ` + (reason === "no-capacity" ? "no train on its day had capacity left" : "payment window passed; freed its wagons back to the pool for top-up"), ); } /** * End-of-day sweep: once a schedule's window cycle concludes and NO other * train on the same route-day can still run a cycle, the waiting pool for * that day is dead — a FULLY_EXECUTED booking left in it would wait forever. * Expire every leftover commercial booking and tell the customers to rebook * another day. Government bookings are never auto-expired (they preempt). * Returns how many bookings were expired. */ async expireLeftoverDayPool(scheduleId: string): Promise { const schedule = await this.trainSchedulesRepository.findById(scheduleId); if (!schedule?.scheduledDepartureDate) return 0; const day = eatDay(schedule.scheduledDepartureDate); const group: RouteDayGroup = { originYardId: schedule.originStationId, destinationYardId: schedule.destinationStationId, day, }; // Another train on this route-day that can still take bookings keeps the // pool alive — when IT concludes, its own sweep runs this check again. const siblings = await this.trainSchedulesRepository.findAll({ where: [ { originStationId: group.originYardId, destinationStationId: group.destinationYardId, status: TrainScheduleStatusEnum.Draft, }, { originStationId: group.originYardId, destinationStationId: group.destinationYardId, status: TrainScheduleStatusEnum.Scheduled, }, ], }); const anotherTrainStillOpen = siblings.some( (s) => s.id !== schedule.id && s.scheduledDepartureDate != null && eatDay(s.scheduledDepartureDate) === day && s.windowPhase !== "DONE" && s.bookingWindowStatus !== "FULL", ); if (anotherTrainStillOpen) return 0; const corridorYards = await this.corridorYardsForRouteDay(group); const pool = corridorYards.length ? await this.bookingsRepository.findBatchPoolByCorridorDay(corridorYards, day) : await this.bookingsRepository.findBatchPoolByRouteDay( group.originYardId, group.destinationYardId, day, ); const leftovers = pool.filter((b) => !b.isGovernment); // Capture pinned schedules BEFORE expire() clears trainScheduleId, so each // touched board gets exactly one push at the end of the sweep. const touchedScheduleIds = new Set(); if (leftovers.length) touchedScheduleIds.add(scheduleId); for (const booking of leftovers) { if (booking.trainScheduleId) touchedScheduleIds.add(booking.trainScheduleId); await this.expire(booking, "no-capacity"); } if (leftovers.length) { this.logger.log( `[BATCH] ${this.groupLabel(group)}: no train left with capacity — ` + `expired ${leftovers.length} waiting booking(s)`, ); } for (const id of touchedScheduleIds) { this.notifyBoardChanged(id, "day_pool_expired"); } return leftovers.length; } /** * EXPORT counterpart of the conclude-time sweep. Export has no batch cycle, * so nothing ever concluded its day: bookings still waiting when the trains * filled up or the window closed stayed pending forever. Once every export * train on this route-day is shut — window DONE, or FULL with no pay window * still live that could lapse and free space — the date is dead: expire the * un-accepted bookings staff can no longer accept AND the ready * (FULLY_EXECUTED) bookings that never got a reservation (consolidation * waiters). Runs at export window close and whenever an export train's * fullness settles. */ async expireLeftoverExportDay(scheduleId: string): Promise { const schedule = await this.trainSchedulesRepository.findById(scheduleId); if (schedule?.direction !== "EXPORT" || !schedule.scheduledDepartureDate) { return; } const day = eatDay(schedule.scheduledDepartureDate); const trains = ( await this.trainSchedulesRepository.findAll({ where: [ { originStationId: schedule.originStationId, destinationStationId: schedule.destinationStationId, status: TrainScheduleStatusEnum.Draft, }, { originStationId: schedule.originStationId, destinationStationId: schedule.destinationStationId, status: TrainScheduleStatusEnum.Scheduled, }, ], }) ).filter( (s) => s.scheduledDepartureDate != null && eatDay(s.scheduledDepartureDate) === day, ); for (const s of trains) { // Any train still taking bookings keeps the date alive. if (s.windowPhase !== "DONE" && s.bookingWindowStatus !== "FULL") return; // A FULL train whose reservations are still inside their pay windows can // reopen when one lapses unpaid — defer; the settle re-runs this sweep. if (s.windowPhase !== "DONE" && (await this.hasLiveReservations(s.id))) { return; } } await this.expireUnacceptedForRouteDay({ originYardId: schedule.originStationId, destinationYardId: schedule.destinationStationId, day, }); await this.expireLeftoverDayPool(scheduleId); } /** * Union of stop yards across the day's fillable schedules on this corridor — * the same pool scope fillRouteDay uses, so full-route AND sub-corridor bookings * are covered. Empty when no fillable schedule exists for the group. */ private async corridorYardsForRouteDay( group: RouteDayGroup, ): Promise { const corridor = await this.trainSchedulesRepository.findAll({ where: [ { originStationId: group.originYardId, destinationStationId: group.destinationYardId, status: TrainScheduleStatusEnum.Draft, }, { originStationId: group.originYardId, destinationStationId: group.destinationYardId, status: TrainScheduleStatusEnum.Scheduled, }, ], }); const yards = new Set(); for (const schedule of corridor) { if ( schedule.scheduledDepartureDate == null || eatDay(schedule.scheduledDepartureDate) !== group.day ) { continue; } for (const yardId of await this.stopsForSchedule(schedule)) { yards.add(yardId); } } return [...yards]; } /** * Sweep bookings on a route-day whose operation request staff did NOT accept by * the time the window's document-review phase ends. They never reached * FULLY_EXECUTED, so they never enter the batch — expire them (customer must * rebook a new window). No reservation and no invoice exists yet at this stage, * so this is a lighter expiry than `expire()`: just flip status + notify, and * best-effort close any payable if one was issued early. Government/export are * excluded by the query. */ async expireUnacceptedForRouteDay(group: RouteDayGroup): Promise { const corridorYards = await this.corridorYardsForRouteDay(group); if (corridorYards.length === 0) return; const unaccepted = await this.bookingsRepository.findUnacceptedForRouteDay( corridorYards, group.day, ); if (unaccepted.length > 0) { this.logger.log( `[BATCH] doc-review end: expiring ${unaccepted.length} un-accepted booking(s) ` + `on ${group.originYardId}->${group.destinationYardId} ${group.day}`, ); } // Only bookings pinned to a train show on a board — collect their schedules // and push once per schedule after the sweep (most unaccepted rows are // unpinned under day-level pooling, so this usually emits nothing). const touchedScheduleIds = new Set(); const swept = new Set(); for (const booking of unaccepted) { if (swept.has(booking.id)) continue; swept.add(booking.id); // Consolidated pair: the partner may sit outside this route-day's result // set (different yards/day/status), so cascade explicitly — an unpaid // partner expires with this booking, fee-free; a PAID partner cannot // board alone, so partnerLapsed cancels it with the cancellation fee. if (booking.consolidationPartnerId) { const partner = await this.dataSource.getRepository(Booking).findOne({ where: { id: booking.consolidationPartnerId }, relations: { company: true }, }); await this.bookingsRepository.clearConsolidationPair( booking.id, booking.consolidationPartnerId, ); booking.consolidationPartnerId = null; if (partner) { const partnerPaid = partner.paymentStatus === "PAID" || partner.status === "PAID"; if (partnerPaid) { this.eventEmitter?.emit("booking.consolidation.partnerLapsed", { paidBookingId: partner.id, }); } else if (!["EXPIRED", "CANCELLED"].includes(partner.status)) { swept.add(partner.id); partner.consolidationPartnerId = null; if (partner.trainScheduleId) touchedScheduleIds.add(partner.trainScheduleId); await this.bookingsRepository.update(partner.id, { status: "EXPIRED", schedulingStatus: "ELIGIBLE", scheduledDate: null, } as never); await this.billing .expirePayable(Freight.InvoiceSource.Booking, partner.id, "PREPAID") .catch(() => undefined); this.notifier.expired(partner); this.logger.log( `[BATCH] EXPIRED (unaccepted, with consolidation partner) ${partner.reference}:${partner.id} at doc-review end`, ); } } } if (booking.trainScheduleId) touchedScheduleIds.add(booking.trainScheduleId); await this.bookingsRepository.update(booking.id, { status: "EXPIRED", schedulingStatus: "ELIGIBLE", // Free the shipment day so the customer can rebook a fresh window. scheduledDate: null, } as never); // Close any payable issued before doc-review end (normally none — the invoice // is created at ops-accept, which by definition has not happened here). await this.billing .expirePayable(Freight.InvoiceSource.Booking, booking.id, "PREPAID") .catch(() => undefined); this.notifier.expired(booking); this.logger.log( `[BATCH] EXPIRED (unaccepted) ${booking.reference}:${booking.id} at doc-review end`, ); } for (const id of touchedScheduleIds) { this.notifyBoardChanged(id, "unaccepted_expired"); } } /** * How many bookings on this route-day would be expired if document review * ended right now — i.e. requests staff have neither accepted nor rejected. * Same query the doc-review-end sweep runs, so the number staff see is * exactly what is at risk. */ async countUnacceptedForRouteDay(group: RouteDayGroup): Promise { const corridorYards = await this.corridorYardsForRouteDay(group); if (corridorYards.length === 0) return 0; const unaccepted = await this.bookingsRepository.findUnacceptedForRouteDay( corridorYards, group.day, ); return unaccepted.length; } /** * Free capacity for a government booking by displacing the lowest-priority commercial * bookings (reserved first, then allocated — including PAID). Displaced → EXPIRED + notified. * Only victims whose legs overlap the government booking's leg actually free useful * room, so others are skipped. Mutates `budget`; returns whether the need now fits. */ private async preemptForGovernment( scheduleId: string, need: Capacity, leg: CorridorLeg, budget: CorridorBudget, wagonDims: WagonDims, ): Promise { if (budget.fits(need, leg)) return true; const reservedCommercial = ( await this.bookingsRepository.findReservedForSchedule(scheduleId) ).filter((b) => !b.isGovernment); const allocatedCommercial = await this.bookingsRepository.findAllocatedCommercialForSchedule( scheduleId, ); // lowest priority first; reserved are cheaper to free than allocated const candidates = [...reservedCommercial, ...allocatedCommercial].sort( (a, b) => (a.priorityScore ?? 0) - (b.priorityScore ?? 0), ); for (const victim of candidates) { if (budget.fits(need, leg)) break; const victimLeg = budget.legForYards( victim.originYardId, victim.destinationYardId, ); // Displacing a booking on a disjoint leg frees nothing the government // booking can use — don't kill it for nothing. const overlaps = victimLeg.fromEdge < leg.toEdge && leg.fromEdge < victimLeg.toEdge; if (!overlaps) continue; const victimPaid = victim.paymentStatus === "PAID" || victim.status === "PAID"; await this.dataSource.transaction(async (manager) => { await this.trainScheduleBookingsRepository.deleteByScheduleAndBooking( scheduleId, victim.id, manager, ); if (victimPaid) { // Paid bookings are never expired — money was taken, so it boards. // Detach it so it surfaces in the paid-unallocated queue for staff // to re-place; the settled invoice stays untouched. await manager.getRepository(Booking).update(victim.id, { trainScheduleId: null, schedulingStatus: "ELIGIBLE", paymentDeadline: null, selectedForBatchAt: null, } as never); return; } await manager.getRepository(Booking).update(victim.id, { status: "EXPIRED", schedulingStatus: "ELIGIBLE", paymentDeadline: null, selectedForBatchAt: null, } as never); // Displaced → EXPIRED: close its open invoice too, so a dead booking // can't still be paid (mirrors `expire()`; enlisted in this txn). await this.billing.expirePayable( Freight.InvoiceSource.Booking, victim.id, "PREPAID", manager, ); }); this.notifier.displaced(victim); budget.add(this.needFor(victim, wagonDims), victimLeg); // Displacing frees wagons the same way an expiry does — don't leave the // schedule stuck at FULL. await this.refreshWindowStatus(scheduleId); } return budget.fits(need, leg); } // ---- capacity helpers ----------------------------------------------------- /** * Collapse consolidated partners into single pool entries so the fill treats a * shared-wagon pair as one atomic unit (both-or-neither). For each pool entry: * - no `consolidationPartnerId` → passes through as a lone booking. * - consolidated + partner also in this pool → emitted ONCE (at the position of * whichever partner ranks first) as a pair; the partner is not emitted again. * - consolidated + partner NOT in this pool → dropped (can't ship half a wagon; * it waits for the partner to become ready in a later cycle). * The pool is already priority-ordered, so emitting the pair at the first-seen * partner's slot ranks it by the stronger (max-priority) partner automatically. */ private groupConsolidatedPool( pool: Booking[], ): Array<{ primary: Booking; partner: Booking | null }> { const byId = new Map(pool.map((b) => [b.id, b])); const emitted = new Set(); const units: Array<{ primary: Booking; partner: Booking | null }> = []; for (const booking of pool) { if (emitted.has(booking.id)) continue; const partnerId = booking.consolidationPartnerId ?? null; if (!partnerId) { emitted.add(booking.id); units.push({ primary: booking, partner: null }); continue; } const partner = byId.get(partnerId) ?? null; if (!partner) { // Both-or-neither: partner not ready in this pool → skip the pair entirely. emitted.add(booking.id); continue; } emitted.add(booking.id); emitted.add(partner.id); units.push({ primary: booking, partner }); } return units; } /** * Combined capacity need of a consolidated pair sharing wagons. The whole point of * consolidation is that the two partial 20ft counts pack onto the SAME wagons, so * the shared wagon count is ceil((c1+c2)/2) — strictly fewer than summing the two * independently-rounded-up needs (that is the capacity consolidation saves). */ private combinedNeed( primary: Booking, partner: Booking, wagonDims: WagonDims, ): Capacity { const containers = (b: Booking): number => (b.bookingContainers ?? []).reduce((sum, c) => sum + Number(c.quantity ?? 0), 0); const totalContainers = containers(primary) + containers(partner); const cargoTons = bookingCargoTons(primary) + bookingCargoTons(partner); // Consolidation shares TEU slots, never rated payload: the pair still needs // enough wagons to carry its combined cargo, so the weight axis bounds the // shared count exactly as it bounds an individual booking's. A pair shares // wagons, so the primary's wagon type stands for both partners. const dims = this.dimsFor(primary, wagonDims); const capacityTons = dims.capacityTons; const byWeight = cargoTons > 0 && capacityTons > 0 ? Math.ceil(cargoTons / capacityTons) : 0; const byLength = totalContainers > 0 ? Math.ceil(totalContainers / MAX_TEU_SLOTS_PER_WAGON) : this.wagonsFor(primary, wagonDims) + this.wagonsFor(partner, wagonDims); const sharedWagons = Math.max(byLength, byWeight); return { wagons: sharedWagons, // Consolidation saves tare as well as slots: the pair rides `sharedWagons` // wagons, so it is charged `sharedWagons` tares, not one per booking. weightTons: bookingGrossWeightTons( cargoTons, sharedWagons, dims.tareWeightTons, ), lengthMeters: sharedWagons * dims.lengthMeters, }; } /** * Stamp real priority scores on the pool's BULK bookings before the batch * ranks it. Submit-time scoring runs with totalWagons = 0 for bulk (a bulk * booking has no container lines to carry a wagon count), so every * wagon-range priority config missed and bulk import bookings entered the * batch at score 0 — they were never prioritized. Their wagon footprint is * derivable from tonnage vs. live wagon capacity (wagonsFor), so the score * is computed here — when doc review closes and the batch runs — and * persisted so the priority board shows the same ranking. The pool arrives * SQL-ordered by the old scores; the caller must re-sort after this. */ private async recomputeBulkPriorities( pool: Booking[], wagonDims: WagonDims, ): Promise { for (const booking of pool) { if (booking.freightType !== 'BULK') continue; try { const wagons = this.wagonsFor(booking, wagonDims); const score = await this.pricingService.computeSubmitPriorityScore( booking, wagons, ); if (Number(booking.priorityScore ?? 0) === score) continue; await this.dataSource .getRepository(Booking) .update(booking.id, { priorityScore: score }); booking.priorityScore = score; } catch (err) { // A failed recompute keeps the stored score — never blocks the batch. this.logger.warn( `Bulk priority recompute failed for ${booking.reference ?? booking.id}: ` + `${(err as Error).message}`, ); } } } /** * Maps a booking's pool-entry time (`fullyExecutedAt`) to the 0-based * booking-window cycle it arrived in: the last window whose open is at/before * the timestamp (a timestamp in the doc-review/payment gap belongs to the * cycle that just closed). The cycle grid comes from the schedule's frozen * window-rule snapshot — the exact windows the cycle engine runs. */ private async windowCycleIndexer( schedule: TrainSchedule, ): Promise<(ts: Date | null | undefined) => number> { if (!schedule.scheduledDepartureDate) return () => 0; let starts: number[]; try { const liveCfg = await this.trainSchedulingService.getWindowConfig(); const cfg = effectiveWindowConfig(schedule, liveCfg); const windows = listConfigBookingWindows( schedule.direction, schedule.scheduledDepartureDate, { ...cfg, reopenGapMinutes: schedule.ruleReopenDelayMinutes ?? cfg.docReviewMinutes + cfg.paymentWindowMinutes, }, ); starts = windows.map((w) => w.start.getTime()); } catch (err) { // A failed cycle derivation must never block the batch — fall back to one // flat cycle (pure priority order, the old behaviour). this.logger.warn( `Window-cycle derivation failed for schedule ${schedule.id}: ` + `${(err as Error).message}`, ); return () => 0; } return (ts) => { if (!ts) return 0; const ms = ts.getTime(); let idx = 0; for (let i = 0; i < starts.length; i += 1) { if (ms >= starts[i]) idx = i; } return idx; }; } /** * Rank the batch pool: government first, then WINDOW CYCLE (bookings compete * only within the cycle they arrived in — an earlier cycle's booking always * outranks a later cycle's, whatever the scores), then priority score, then * oldest. `cycleOf` comes from {@link windowCycleIndexer}. */ private resortPoolByPriority( pool: Booking[], cycleOf: (ts: Date | null | undefined) => number = () => 0, ): void { pool.sort( (a, b) => Number(b.isGovernment) - Number(a.isGovernment) || cycleOf(a.fullyExecutedAt) - cycleOf(b.fullyExecutedAt) || Number(b.priorityScore ?? 0) - Number(a.priorityScore ?? 0) || (a.fullyExecutedAt?.getTime() ?? Infinity) - (b.fullyExecutedAt?.getTime() ?? Infinity) || a.createdAt.getTime() - b.createdAt.getTime(), ); } /** * Wagons a booking occupies. Two axes bind independently and the booking needs * enough wagons to satisfy BOTH, so the count is the larger of: * * weight — ceil(cargoTons / wagonType.capacityTons), the rated payload * length — TEU geometry, two 20ft to a wagon (container bookings only) * * The weight axis was missing entirely. A BULK booking carries no container * lines, so `containerWagonsForLines` returned 0 and every bulk booking * collapsed to a single wagon no matter its tonnage — a 2590T fertilizer * booking counted as 1 wagon, and `needFor` then charged 1 tare instead of 37. * That under-reported the board and let the fill loop overbook the train. */ private wagonsFor(booking: Booking, wagonDims: WagonDims): number { // Stored wagonsRequired is a candidate, never an early return: rows written // while sumWagonsRequired hardcoded BULK to 1 wagon are still in the DB, and // trusting them charged one tare for a whole bulk consist (a 700T booking on // 70T wagons read 700 + 1 tare instead of 700 + 10 tares). const stored = booking.wagonsRequired && booking.wagonsRequired > 0 ? Math.ceil(booking.wagonsRequired) : 0; // TEU-aware: two 20ft share one wagon (half a wagon each). The old fallback // summed raw container QUANTITY, so 20×20ft counted as 20 wagons, not 10. const byLength = containerWagonsForLines(booking.bookingContainers ?? []); const capacityTons = this.dimsFor(booking, wagonDims).capacityTons; const cargoTons = bookingCargoTons(booking); // PER_TON cargo may cap tons per wagon below the rating (sugar 50T on a 70T // wagon), so divide by the cap where one is configured for this type. const tonsPerWagon = bulkTonsPerWagon( booking.cargoType, booking.cargoType?.wagonTypes?.[0]?.id, capacityTons, ); const byWeight = cargoTons > 0 && tonsPerWagon > 0 ? Math.ceil(cargoTons / tonsPerWagon) : 0; // Break-bulk (PER_ITEM): indivisible items can need more wagons than raw // tonnage suggests (floor items-per-wagon loses the fractional capacity). // `dimsFor` resolved dims from the first allowed wagon type, so charge that // same type's configured items-fit alongside its capacity. const byItems = bulkItemWagonsRequired( booking, capacityTons, bulkItemsFitFor(booking.cargoType, booking.cargoType?.wagonTypes?.[0]?.id), ); return Math.max(DEFAULT_WAGONS_PER_BOOKING, stored, byLength, byWeight, byItems); } /** * What one booking consumes along all three capacity axes. * * The weight axis is GROSS — cargo plus the tare of every wagon the booking * occupies — because it is spent against the locomotive's pull limit, which * governs the whole train and not just its payload. Charging cargo alone let a * 37-wagon box-wagon train read 2590T when it really weighed 3522T. */ private needFor(booking: Booking, wagonDims: WagonDims): Capacity { const wagons = this.wagonsFor(booking, wagonDims); const dims = this.dimsFor(booking, wagonDims); return { wagons, weightTons: bookingGrossWeightTons( bookingCargoTons(booking), wagons, dims.tareWeightTons, ), lengthMeters: wagons * dims.lengthMeters, }; } /** * The wagon count of {@link wagonsFor}, split across the wagon TYPES this * particular train stocks — "3 × N35 + 1 × PW2" rather than a bare 4. * * `wagonsFor` sizes the booking on ONE representative type (the first the * cargo type allows), which is all the abstract budget needs. Staff placing a * ride-along need the physical picture: how many of each type this schedule * must actually give up. So each allowed type is sized on its OWN capacity and * items-fit, then filled greedily from the type with the largest per-wagon * take, bounded by what the schedule has left of it. * * Because the stock is per-schedule, the same booking breaks down differently * on a train stocking 60T N35s than on one stocking 40T PW2s. Returns [] when * the booking's types are unconfigured or the train stocks none of them — the * caller then shows the plain total. */ private wagonBreakdownFor( booking: Booking, wagonDims: WagonDims, stockByTypeId: Map, codesByTypeId: Map, ): Array<{ wagonTypeId: string; code: string; wagons: number }> { const total = this.wagonsFor(booking, wagonDims); if (total <= 0) return []; // Per-wagon take of each allowed type ON THIS TRAIN, largest first: a type // that swallows more of the booking per wagon needs fewer wagons. const options = this.allowedDimsWithTypes(booking, wagonDims) .filter((o) => o.wagonTypeId && (stockByTypeId.get(o.wagonTypeId) ?? 0) > 0) .map((o) => { const wagonTypeId = o.wagonTypeId as string; // Each type sized on its OWN per-wagon tonnage cap, not just its rating // — a type capped lower swallows less per wagon. const tonsPerWagon = bulkTonsPerWagon( booking.cargoType, wagonTypeId, o.dims.capacityTons, ); const wagonsIfAlone = Math.max( 1, bulkItemWagonsRequired( booking, o.dims.capacityTons, bulkItemsFitFor(booking.cargoType, wagonTypeId), ) || (tonsPerWagon > 0 ? Math.ceil(bookingCargoTons(booking) / tonsPerWagon) : total), ); return { wagonTypeId, code: codesByTypeId.get(wagonTypeId) ?? '—', available: stockByTypeId.get(wagonTypeId) ?? 0, // Share of the whole booking one wagon of this type carries. takePerWagon: 1 / wagonsIfAlone, }; }) .sort((a, b) => b.takePerWagon - a.takePerWagon); if (!options.length) return []; // Fill greedily by take, capped by stock; `remaining` is the fraction of the // booking still unplaced, so a wagon of any type covers `takePerWagon` of it. const out: Array<{ wagonTypeId: string; code: string; wagons: number }> = []; let remaining = 1; for (const option of options) { if (remaining <= 1e-9) break; const wagons = Math.min( option.available, Math.ceil(remaining / option.takePerWagon), ); if (wagons <= 0) continue; out.push({ wagonTypeId: option.wagonTypeId, code: option.code, wagons }); remaining -= wagons * option.takePerWagon; } // The train cannot hold the whole booking in the types it stocks — the // `fits` check already fails it; report only what it CAN take. return out; } private fits(need: Capacity, budget: Capacity): boolean { return ( need.wagons <= budget.wagons && need.weightTons <= budget.weightTons && need.lengthMeters <= budget.lengthMeters ); } /** * Caps for a schedule's train: gross pull weight, train length, and the * length-derived wagon slot count (never a fixed 53). Bookings spend against * `base` via {@link needFor}, whose weight axis is gross. The locomotive's * overage tolerance is returned separately — the corridor budget spends it * only to admit a booking whole, never to size a split. * * Limits come from the LOCOMOTIVE ALONE — the global-rules weight/length * caps deliberately do not apply here (a mis-set global row once capped * every train at 14m and no export booking could board). */ private async capacityLimits(locomotive: LocomotiveLimits): Promise { const wagonTypes = await this.loadWagonTypeDimensions(); const derived = deriveTrainCapacityFromLocomotive( { maxPullWeightTons: Number(locomotive.maxPullWeightTons), maxTrainLengthMeters: Number(locomotive.maxTrainLengthMeters), overageToleranceTons: Number(locomotive.overageToleranceTons) || 0, overageToleranceMeters: Number(locomotive.overageToleranceMeters) || 0, }, wagonTypes, ); return { base: { wagons: derived.maxWagonSlots, weightTons: derived.baseWeightTons, lengthMeters: derived.baseLengthMeters, }, tolerance: { weightTons: derived.toleranceTons, lengthMeters: derived.toleranceMeters, }, }; } /** * Keep schedule.max_wagons aligned with the train's boarding limit. A built * train's limit is its physical consist — the wagon count staff marshalled * (and may change via adjust-consist). Only schedules WITHOUT a built train * fall back to the locomotive's length-derived slot count, where bookings * are admitted on length/weight alone and yard staff attach the wagons * manually before departure. */ private async syncScheduleMaxWagons( schedule: TrainSchedule, locomotive: LocomotiveLimits, ): Promise { const physicalWagons = await this.builtTrainWagonCount(schedule); const maxWagons = physicalWagons ?? (await this.capacityLimits(locomotive)).base.wagons; if ((schedule.maxWagons ?? 0) !== maxWagons) { await this.dataSource .getRepository(TrainSchedule) .update(schedule.id, { maxWagons }); schedule.maxWagons = maxWagons; } } /** * Every active wagon type, so the slot count is derived from the shortest wagon * the fleet can actually marshal rather than from an arbitrary two-code sample. */ private async loadWagonTypeDimensions(): Promise { const types = await this.dataSource .getRepository(WagonType) .find({ where: { isActive: true } }); if (types.length) return types.map(wagonTypeDimensionsFromEntity); return [ { lengthMeters: DEFAULT_CONTAINER_WAGON_LENGTH_METERS, capacityTons: 70, tareWeightTons: DEFAULT_CONTAINER_WAGON_TARE_TONS, }, { lengthMeters: DEFAULT_BULK_WAGON_LENGTH_METERS, capacityTons: 60, tareWeightTons: DEFAULT_BULK_WAGON_TARE_TONS, }, ]; } /** * Every wagon type keyed by id (drives per-booking dims via the cargo/container * type's wagon_type_id FK), plus representative fallbacks per freight type * (NW5 flat for containers, CW3 gondola for bulk) for bookings whose type has * no wagon type configured yet. */ /** Wagon types are near-static reference data — a short TTL cache spares one * table scan per board/detail request without letting edits go stale long. */ private wagonDimsCache: { value: WagonDims; expiresAt: number } | null = null; private async loadWagonDims(): Promise { if (this.wagonDimsCache && this.wagonDimsCache.expiresAt > Date.now()) { return this.wagonDimsCache.value; } const types = await this.dataSource.getRepository(WagonType).find(); const byCode = new Map( types.map((t) => [t.code, wagonTypeDimensionsFromEntity(t)]), ); const byWagonTypeId = new Map( types.map((t) => [t.id, wagonTypeDimensionsFromEntity(t)]), ); const nw5 = byCode.get("NW5"); const cw3 = byCode.get("CW3"); // capacityTons divides a bulk booking's cargo, so a 0 or missing rated payload // must fall back rather than yield an infinite wagon count. const payload = (value: number | undefined, fallback: number): number => value && value > 0 ? value : fallback; const value: WagonDims = { container: { lengthMeters: nw5?.lengthMeters ?? DEFAULT_CONTAINER_WAGON_LENGTH_METERS, tareWeightTons: nw5?.tareWeightTons ?? DEFAULT_CONTAINER_WAGON_TARE_TONS, capacityTons: payload(nw5?.capacityTons, DEFAULT_CONTAINER_WAGON_CAPACITY_TONS), }, bulk: { lengthMeters: cw3?.lengthMeters ?? DEFAULT_BULK_WAGON_LENGTH_METERS, tareWeightTons: cw3?.tareWeightTons ?? DEFAULT_BULK_WAGON_TARE_TONS, capacityTons: payload(cw3?.capacityTons, DEFAULT_BULK_WAGON_CAPACITY_TONS), }, byWagonTypeId, }; this.wagonDimsCache = { value, expiresAt: Date.now() + 60_000 }; return value; } /** * Dimensions of the wagon type THIS booking rides: bulk resolves through its * cargo type's allowed wagon-type list, container through the first container * line's — the same list resolution the scheduling planner applies when the * paid booking is allocated. Board/fill math measured on a representative * wagon while allocation validated the real one let a selected batch flunk * the post-payment gross-weight check; sharing the resolution closes that * gap. Uses the first configured type (the fill engine has no train context); * falls back to the representative dims when the list or relation is absent. */ private dimsFor(booking: Booking, wagonDims: WagonDims): PerWagonDims { const fallback = booking.freightType === "BULK" ? wagonDims.bulk : wagonDims.container; const wagonTypeId = booking.freightType === "BULK" ? booking.cargoType?.wagonTypes?.[0]?.id : (booking.bookingContainers ?? []) .flatMap((line) => line.containerType?.wagonTypes ?? []) .map((wagonType) => wagonType.id) .find((id): id is string => Boolean(id)); const dims = wagonTypeId ? wagonDims.byWagonTypeId.get(wagonTypeId) : undefined; if (!dims) return fallback; return { ...dims, capacityTons: dims.capacityTons > 0 ? dims.capacityTons : fallback.capacityTons, }; } /** * EVERY wagon-type dimension a booking may ride — its cargo/container type's * full allowed (many-to-many) wagon-type list, not just the first like * {@link dimsFor}. The remainder placer needs the whole set so a train that * stocks a non-primary allowed type still counts as fitting: a container type * mapped to both NW5 and (say) NW7 must be measured against whichever a given * train actually has free. Deduped by wagon-type id; falls back to the single * representative dims when no allowed type is configured. */ private dimsForAllowed(booking: Booking, wagonDims: WagonDims): PerWagonDims[] { return this.allowedDimsWithTypes(booking, wagonDims).map((p) => p.dims); } /** * Same allowed set as {@link dimsForAllowed} but keeping each wagon-type id, * so callers (the export train picker) can label per-type availability. * `wagonTypeId` is null only on the unconfigured fallback entry. */ private allowedDimsWithTypes( booking: Booking, wagonDims: WagonDims, ): Array<{ wagonTypeId: string | null; dims: PerWagonDims }> { const fallback = booking.freightType === "BULK" ? wagonDims.bulk : wagonDims.container; const ids = booking.freightType === "BULK" ? (booking.cargoType?.wagonTypes ?? []).map((wt) => wt.id) : (booking.bookingContainers ?? []) .flatMap((line) => line.containerType?.wagonTypes ?? []) .map((wt) => wt.id); const seen = new Set(); const out: Array<{ wagonTypeId: string | null; dims: PerWagonDims }> = []; for (const id of ids) { if (!id || seen.has(id)) continue; seen.add(id); const d = wagonDims.byWagonTypeId.get(id); if (d) { out.push({ wagonTypeId: id, dims: { ...d, capacityTons: d.capacityTons > 0 ? d.capacityTons : fallback.capacityTons, }, }); } } return out.length ? out : [{ wagonTypeId: null, dims: fallback }]; } /** * Physical wagon-type stock for one schedule, on the same corridor edges its * {@link CorridorBudget} uses. Sourced from the scheduling service so the * batch counts exactly the wagons the allocator will later plan against. */ private async stockLedgerFor( schedule: TrainSchedule, budget: CorridorBudget, excludeBookingIds?: string[], ): Promise { const stock = await this.trainSchedulingService.wagonStockForSchedule( schedule.id, schedule.originStationId, budget.stops, ); const ledger = new WagonStockLedger( stock.remainingByTypeId, Math.max(1, budget.stops.length - 1), stock.byYardId, budget.stops, ); // Wagons staff cut mid-route are not stock past their cut stop. ledger.debitCutWagons(stock.cutWagons ?? []); // Debit what is already committed, per boarding yard and wagon type — the // same bookings the corridor budget subtracted. A booking with no resolvable // wagon type still occupies steel, so it drains any type at its yard. const [wagonDims, allowed] = await Promise.all([ this.loadWagonDims(), this.loadAllowedWagonTypeIds(), ]); const anyType = [...stock.remainingByTypeId.keys()]; const committed = await this.committedBookings(schedule, excludeBookingIds); // Debit committed PER_TON bulk the way it was SEATED — per type at the // cargo's caps, scarcest type first — not a one-type wagon count drained // deepest-first (which mis-charged 695T Perishable as 24 NW5 when it holds // 10 PW2 + 17 NW5, so later passes over-counted free PW2 and sold NW5 that // were already spoken for). const rank = this.scarcityRankForPool(committed, allowed); for (const b of committed) { const typeIds = this.allowedWagonTypeIdsFor(b, allowed); const leg = budget.legForYards(b.originYardId, b.destinationYardId); const perItemBulk = Number(b.bulkTotalWeightTons ?? 0) > 0 && Number(b.cargoTotalWeightVgm ?? 0) > 0; if (b.freightType === "BULK" && !perItemBulk && typeIds.length) { const smart = this.smartBulkNeed(b, wagonDims, ledger, leg, rank, typeIds); if (smart) { for (const part of smart.perType) { ledger.consume([part.wagonTypeId], part.wagons, leg); } continue; } // Over-committed (stock cannot seat it any more) — drain what exists, // same as before, so the shortage stays visible to the gates. } ledger.consume( typeIds.length ? typeIds : anyType, this.wagonsFor(b, wagonDims), leg, ); } return ledger; } /** * Whether the train holds enough PHYSICAL wagons of the types this booking may * ride. Unresolvable configuration (no allowed wagon type) returns true: the * abstract budget still governs, and a mis-configured cargo type must not * silently strand every booking that uses it. */ private hasWagonStock( stock: WagonStockLedger, wagonTypeIds: string[], wagonsNeeded: number, leg: CorridorLeg, ): boolean { if (!wagonTypeIds.length) return true; return stock.availableFor(wagonTypeIds, leg) >= wagonsNeeded; } /** * Scarcity rank over the day pool: how many distinct demand groups (bulk * cargo types / container types among these bookings) may ride each wagon * type. The batch seats least-shareable types first, so bulk with a * bulk-only alternative (PW2) never eats the container-capable stock (NW5) * that containers cannot substitute. */ private scarcityRankForPool( pool: Booking[], allowed: { byCargoTypeId: Map; byContainerTypeId: Map; }, ): Map { const groups = new Map(); for (const b of pool) { if (b.freightType === "BULK") { const cargoTypeId = b.cargoTypeId ?? b.cargoType?.id; if (cargoTypeId) { groups.set(`B:${cargoTypeId}`, allowed.byCargoTypeId.get(cargoTypeId) ?? []); } } else { for (const line of b.bookingContainers ?? []) { const containerTypeId = line.containerTypeId ?? line.containerType?.id; if (containerTypeId) { groups.set( `C:${containerTypeId}`, allowed.byContainerTypeId.get(containerTypeId) ?? [], ); } } } } const rank = new Map(); for (const ids of groups.values()) { for (const id of ids) rank.set(id, (rank.get(id) ?? 0) + 1); } return rank; } /** * Cap-aware, scarcity-ordered seating of a PER_TON bulk booking across the * wagon types this train actually has free on its leg — the same policy the * wagon planner applies at allocation time (least-shareable type first, each * wagon filled to the cargo type's per-wagon cap, one booking per wagon). * * This is the payment gate's real fit check for bulk: the generic * `hasWagonStock` sums free wagons across allowed types against a count * sized on ONE type, so 695T Perishable read "24 wagons needed, 28 free" * when seating it across 10 PW2 (20T) + NW5 (30T) really takes 27 wagons. * Returns the exact per-type counts and the three-axis capacity they * consume, or null when the free stock cannot seat the whole booking. */ private smartBulkNeed( booking: Booking, wagonDims: WagonDims, stock: WagonStockLedger, leg: CorridorLeg, scarcityRank: Map, /** * Wagon-type ids this booking may ride, from {@link loadAllowedWagonTypeIds} * — NEVER from `booking.cargoType.wagonTypes`. The batch pool finders * deliberately do not join that relation (hot path), so on a pool entity * it is always empty; resolving through it made every PER_TON bulk booking * unseatable — no whole fit and no partial offer, silently READY forever * (the S-2026-00020 / BK-2026-000036 incident). */ wagonTypeIds: readonly string[], ): { need: Capacity; perType: Array<{ wagonTypeId: string; wagons: number }> } | null { const options = [...new Set(wagonTypeIds)] .map((wagonTypeId) => ({ wagonTypeId, dims: wagonDims.byWagonTypeId.get(wagonTypeId) })) .filter((o): o is { wagonTypeId: string; dims: PerWagonDims } => o.dims != null) .map((o) => ({ ...o, free: stock.availableFor([o.wagonTypeId], leg), takePerWagon: bulkTonsPerWagon( booking.cargoType, o.wagonTypeId, o.dims.capacityTons, ), })) .filter((o) => o.free > 0 && o.takePerWagon > 0) .sort( (a, b) => (scarcityRank.get(a.wagonTypeId) ?? 1) - (scarcityRank.get(b.wagonTypeId) ?? 1) || b.takePerWagon - a.takePerWagon, ); let remaining = bookingCargoTons(booking); if (remaining <= 0) return null; const perType: Array<{ wagonTypeId: string; wagons: number }> = []; let weightTons = remaining; // gross: cargo plus each seated wagon's tare let lengthMeters = 0; let wagons = 0; for (const option of options) { if (remaining <= 1e-9) break; const take = Math.min(option.free, Math.ceil(remaining / option.takePerWagon)); if (take <= 0) continue; remaining = roundTons(Math.max(0, remaining - take * option.takePerWagon)); wagons += take; weightTons += take * option.dims.tareWeightTons; lengthMeters += take * option.dims.lengthMeters; perType.push({ wagonTypeId: option.wagonTypeId, wagons: take }); } if (remaining > 1e-9) return null; return { need: { wagons, weightTons: roundTons(weightTons), lengthMeters: roundTons(lengthMeters), }, perType, }; } private allowedWagonTypeCache: { byCargoTypeId: Map; byContainerTypeId: Map; expiresAt: number; } | null = null; /** * Wagon-type ids each cargo / container type may ride, read straight from the * join tables. * * The batch pool finders deliberately do NOT join `cargoType.wagonTypes` / * `containerType.wagonTypes` — those many-to-many joins multiply rows badly on * a hot path. So the pool's booking entities carry the type FK but not the * allowed list, and resolving it per booking through the relation would come * back empty. Two small lookups, cached for a minute like {@link loadWagonDims}, * give the same answer without touching the pool query. */ private async loadAllowedWagonTypeIds(): Promise<{ byCargoTypeId: Map; byContainerTypeId: Map; }> { if (this.allowedWagonTypeCache && this.allowedWagonTypeCache.expiresAt > Date.now()) { return this.allowedWagonTypeCache; } // Inactive wagon types are excluded, matching loadAllowedWagonTypes() in the // scheduling service — the allocator will not plan against them either. const [cargoRows, containerRows]: [ Array<{ typeId: string; wagonTypeId: string }>, Array<{ typeId: string; wagonTypeId: string }>, ] = await Promise.all([ this.dataSource.query( `SELECT ct.cargo_type_id AS "typeId", ct.wagon_type_id AS "wagonTypeId" FROM freight.cargo_type_wagon_types ct JOIN freight.wagon_types wt ON wt.id = ct.wagon_type_id WHERE wt.is_active IS NOT FALSE`, ), this.dataSource.query( `SELECT ct.container_type_id AS "typeId", ct.wagon_type_id AS "wagonTypeId" FROM freight.container_type_wagon_types ct JOIN freight.wagon_types wt ON wt.id = ct.wagon_type_id WHERE wt.is_active IS NOT FALSE`, ), ]); const collect = (rows: Array<{ typeId: string; wagonTypeId: string }>) => { const map = new Map(); for (const row of rows) { const list = map.get(row.typeId) ?? []; list.push(row.wagonTypeId); map.set(row.typeId, list); } return map; }; const value = { byCargoTypeId: collect(cargoRows), byContainerTypeId: collect(containerRows), }; this.allowedWagonTypeCache = { ...value, expiresAt: Date.now() + 60_000 }; return value; } /** * Every wagon-type id this booking may ride. Empty means "unresolvable" — the * caller must then skip the physical-stock gate rather than block the booking * on missing configuration. */ private allowedWagonTypeIdsFor( booking: Booking, allowed: { byCargoTypeId: Map; byContainerTypeId: Map; }, ): string[] { if (booking.freightType === "BULK") { const cargoTypeId = booking.cargoTypeId ?? booking.cargoType?.id; return cargoTypeId ? (allowed.byCargoTypeId.get(cargoTypeId) ?? []) : []; } const ids = new Set(); for (const line of booking.bookingContainers ?? []) { const containerTypeId = line.containerTypeId ?? line.containerType?.id; if (!containerTypeId) continue; for (const id of allowed.byContainerTypeId.get(containerTypeId) ?? []) { ids.add(id); } } return [...ids]; } /** * Ordered stop yards of the schedule's route (origin → milestones → * destination); the legacy two-stop pseudo-route when milestones are absent. */ private async stopsForSchedule(schedule: TrainSchedule): Promise { let milestoneYards: string[] | null = null; if (schedule.routeId) { const milestones = await this.dataSource .getRepository(RouteMilestone) .find({ where: { routeId: schedule.routeId }, order: { sequenceNo: 'ASC' } }); if (milestones.length >= 2) milestoneYards = milestones.map((m) => m.yardId); } return stopYardsFor( milestoneYards, schedule.originStationId, schedule.destinationStationId, ); } /** * Remaining capacity per corridor edge = hard caps minus what allocated + * reserved bookings already use ON THEIR OWN LEGS. A booking riding only * Dire→Djibouti leaves the Addis→Dire edges untouched. * * Two capacity regimes, decided by the schedule's train: * - Built train (Train Builder consist with physical wagons): the consist IS * the capacity. Wagon slots = physical wagon count; weight and length are * NOT re-checked here — the builder and adjust-consist already enforced the * locomotive's pull/length limits when the consist was assembled. * - No built train (legacy schedules): the locomotive's length-derived slot * count plus its weight/length budgets, as before — yard staff attach the * missing wagons manually before wagon assignment. */ private async remainingBudget( schedule: TrainSchedule, limits: TrainLimits, wagonDims: WagonDims, excludeBookingIds?: string[], ): Promise { const physicalWagons = await this.builtTrainWagonCount(schedule); if (physicalWagons != null) { limits = { base: { wagons: physicalWagons, weightTons: Number.POSITIVE_INFINITY, lengthMeters: Number.POSITIVE_INFINITY, }, tolerance: { weightTons: 0, lengthMeters: 0 }, }; } // Built trains keep the leg-aware multi-edge corridor too: the wagon // planner consumes stock per edge (planWagonsWithStock legs), so a consist // wagon serves disjoint legs — capacity freed past an alight yard is real. const stops = await this.stopsForSchedule(schedule); const budget = new CorridorBudget(stops, limits.base, limits.tolerance); // Wagons staff plan to cut mid-route are gone from every edge past the cut. // ponytail: the wagon-type stock ledger stays cut-blind; bucket // builtTrainStock by (yard, reach) if mixed-type cut trains appear. subtractCutWagons(budget, schedule.plannedWagonCutYards); // Planned couples add a slot from their couple stop onward. addCoupledWagons(budget, schedule.plannedWagonCouples); for (const b of await this.committedBookings(schedule, excludeBookingIds)) { budget.subtract( this.needFor(b, wagonDims), budget.legForYards(b.originYardId, b.destinationYardId), ); } return budget; } /** * Every booking already holding capacity on the schedule: allocated (linked), * live-reserved (unexpired pay window or paid), and pending export requests * that named this train. The ONE list both the abstract corridor budget and * the per-yard wagon-type ledger must debit — when only the budget saw them, * a train with 15 wagons planned at Mojo and 15 already booked from Mojo * still advertised "15 free" there, because the whole-train budget had room * left on that edge (from the other yard's wagons) and the ledger was born * full. */ private async committedBookings( schedule: TrainSchedule, excludeBookingIds?: string[], ): Promise { const allocated = (schedule.scheduleBookings ?? []) .map((sb) => sb.booking) .filter((b): b is Booking => Boolean(b)); // Lazy-expiry guard: a hold whose deadline AND drain tail lapsed no longer // blocks capacity, even before the 10s sweep flips it to EXPIRED — // availability shown to the next customer is honest between ticks. The drain // has to be honoured here too: releasing the wagons at the raw deadline // would resell them to someone else while the paying customer's settlement // is still in flight, stranding it into WAITING_FOR_WAGON. const deadlineCutoff = Date.now(); const reserved = ( await this.bookingsRepository.findReservedForSchedule(schedule.id) ).filter( (b) => b.paymentStatus === "PAID" || b.status === "PAID" || !payWindowLapsed(b.paymentDeadline, deadlineCutoff), ); // Export FCFS: a customer's pending operation request HOLDS its wagons from // the moment it is submitted — the request named this exact train // (requestedTrainScheduleId), so its capacity must not be shown to or // booked by anyone else while staff review it. The booking(s) being // evaluated are excluded so a request never blocks its own accept. const pendingHolds = ( await this.dataSource.getRepository(Booking).find({ where: { requestedTrainScheduleId: schedule.id, status: 'OPERATION_REQUEST_PENDING', } as never, relations: ['bookingContainers'], }) ).filter((b) => !excludeBookingIds?.includes(b.id)); // A booking can sit in more than one set (allocated AND still reserved); // it holds its wagons once. const seen = new Set(); return [...allocated, ...reserved, ...pendingHolds].filter((b) => { if (seen.has(b.id) || excludeBookingIds?.includes(b.id)) return false; seen.add(b.id); return true; }); } /** * Physical wagons marshalled in the schedule's built train, or null when the * schedule has NO built train and the legacy locomotive-derived capacity must * apply. This count is what caps a built train's bookings: 50 wagons coupled * → 50 wagon slots, no more. * * A built train with an EMPTY consist returns 0, NOT null: zero coupled * wagons means zero capacity. Folding that case into null used to hand an * un-consisted train the abstract locomotive budget, so an empty train * advertised its full maxWagons as free space and accepted bookings the * allocator could never place. */ private async builtTrainWagonCount( schedule: TrainSchedule, ): Promise { const trainId = schedule.trainSet?.train?.id; if (!trainId) return null; return this.dataSource.getRepository(Wagon).count({ where: { trainId } }); } /** * Wagon slots still boardable somewhere on the corridor (most-open edge). * ≤ 0 means no leg can take another booking. Slot axis ONLY — the train-wide * FULL signal is {@link isTrainFull}, which also closes weight/length-bound * trains that still show free slots. */ private async remainingWagons(schedule: TrainSchedule): Promise { const wagonDims = await this.loadWagonDims(); const budget = await this.remainingBudget( schedule, { base: { wagons: schedule.maxWagons ?? 0, weightTons: Number.POSITIVE_INFINITY, lengthMeters: Number.POSITIVE_INFINITY, }, tolerance: { weightTons: 0, lengthMeters: 0 }, }, wagonDims, ); return budget.maxRemaining().wagons; } async setWindow( scheduleId: string, status: "OPEN" | "FULL" | "CLOSED", ): Promise { await this.dataSource .getRepository(TrainSchedule) .update(scheduleId, { bookingWindowStatus: status }); // Push the change (open / train full / closed) so portal home and GL cards // flip in real time — FULL in particular happens outside the window tick // (batch fill, staff mark-paid) and had no live signal before. try { const fresh = await this.trainSchedulesRepository.findById(scheduleId); if (fresh) this.bookingWindowGateway.emitPhase(fresh); } catch (err) { this.logger.warn( `Booking-window push failed for ${scheduleId}: ${(err as Error).message}`, ); } } /** * A reservation on this schedule still has time left to pay — including its * drain tail, so the cycle cannot conclude out from under a settlement that is * still in flight. * * The PAYMENT phase ends a hair BEFORE its own reservations do: `paymentPhaseEndsAt` * is stamped when the phase starts, then `reserve()` gives each booking * `now + paymentWindow` a few hundred milliseconds later, one booking at a time. So * the first settle after the phase deadline finds every reservation still in date, * expires nothing, reports `anySettled = false`, runs no top-up — and the caller * concludes the cycle out from under customers who still had time to pay. The next * tick then expires them with no cycle left to promote the waiting list into. * * Callers must not conclude the cycle while this returns true. */ async hasLiveReservations(scheduleId: string): Promise { const reserved = await this.bookingsRepository.findReservedForSchedule(scheduleId); const now = Date.now(); return reserved.some( (b) => b.paymentStatus !== "PAID" && b.status !== "PAID" && b.paymentDeadline != null && !payWindowLapsed(b.paymentDeadline, now), ); } /** * FULL is CORRIDOR-WIDE: the train is full only when NO leg can take one * more minimal wagon on any axis — slots for built trains (the consist is * the capacity, weight/length settled at build), all three axes otherwise * (PW2: weight binds at 37 wagons = 3522.4T of 3500+90T, slots bind at 44). * A full DCT→Dire leg alone does NOT close the window while Dire→GMP still * has room — sub-corridor bookings keep selling the open legs. */ async isScheduleFull(scheduleId: string): Promise { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); if (!schedule) return false; return this.isTrainFull(schedule); } /** * Wagon-slot usage snapshot for staff UIs (adjust-consist dialog): the * schedule's slot capacity, how many slots allocated + reserved bookings * already hold on the busiest edge, how many are still free on the most-open * edge, and by how many slots the consist has been trimmed BELOW what is * already committed (0 when nothing is over-allocated). */ async scheduleWagonUsage(scheduleId: string): Promise<{ maxWagons: number; allocatedWagons: number; remainingSlots: number; overAllocatedBy: number; } | null> { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); if (!schedule) return null; const capacity = (await this.builtTrainWagonCount(schedule)) ?? schedule.maxWagons ?? 0; const wagonDims = await this.loadWagonDims(); const budget = await this.remainingBudget( schedule, { base: { wagons: capacity, weightTons: Number.POSITIVE_INFINITY, lengthMeters: Number.POSITIVE_INFINITY, }, tolerance: { weightTons: 0, lengthMeters: 0 }, }, wagonDims, ); const tightest = budget.remainingFor(budget.fullLeg()).wagons; return { maxWagons: capacity, allocatedWagons: capacity - tightest, remainingSlots: Math.max(0, budget.maxRemaining().wagons), overAllocatedBy: Math.max(0, -tightest), }; } /** See {@link isScheduleFull} — same check for callers that already hold the full graph. */ private async isTrainFull(schedule: TrainSchedule): Promise { // Full only when EVERY edge is closed on some axis: a full border edge // still leaves the home-side legs bookable by sub-corridor cargo, so the // window must stay open until not even the smallest wagon fits anywhere. const wagonDims = await this.loadWagonDims(); const physicalWagons = await this.builtTrainWagonCount(schedule); let limits: TrainLimits; if (physicalWagons != null) { // The consist is the capacity; weight/length were settled at build time. // remainingBudget swaps in the physical wagon count per edge itself. limits = { base: { wagons: physicalWagons, weightTons: Number.POSITIVE_INFINITY, lengthMeters: Number.POSITIVE_INFINITY, }, tolerance: { weightTons: 0, lengthMeters: 0 }, }; } else { const locomotive = trainSetLocomotiveLimits(schedule.trainSet); // No loco, no built train: only the slot axis exists to bind against. if (!locomotive) return (await this.remainingWagons(schedule)) <= 0; limits = await this.capacityLimits(locomotive); } const budget = await this.remainingBudget(schedule, limits, wagonDims); const minNeed = this.minPerWagonNeed(wagonDims); return budget.isExhausted(minNeed); } /** * Smallest gross weight / shortest length one more wagon could add: the * lightest wagon type at its rated payload. Feeds CorridorBudget.isExhausted, * so FULL is only declared when not even this wagon fits anywhere. */ private minPerWagonNeed(wagonDims: WagonDims): { grossWeightTons: number; lengthMeters: number; } { const all = [ wagonDims.container, wagonDims.bulk, ...wagonDims.byWagonTypeId.values(), ]; return { grossWeightTons: Math.min( ...all.map((d) => d.tareWeightTons + d.capacityTons), ), lengthMeters: Math.min(...all.map((d) => d.lengthMeters)), }; } /** * Re-derive `bookingWindowStatus` from live capacity after wagons were freed * (a reservation expired, a booking was displaced, a link was removed). * * FULL used to be a one-way door: `isFillable()` rejects a FULL schedule before * it ever looks at the budget, and the only writers of OPEN skip a FULL row. So * a train that filled once and then lost every booking to expiry stayed FULL * with all its wagons free — permanently unfillable, cycling PRE_WINDOW→PAYMENT * forever while `concludeCycle` (which reads real capacity, not the flag) kept * reopening it. Clearing FULL here is what lets the next batch actually run. * * Only the customer-facing OPEN phases may go back to OPEN; a schedule mid * DOC_REVIEW/PAYMENT drops to CLOSED, which `isFillable()` still admits. */ async refreshWindowStatus(scheduleId: string): Promise { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); if (!schedule || schedule.bookingWindowStatus !== "FULL") return; // Symmetric with isScheduleFull: a weight/length-bound FULL is not stale // just because slots remain — clearing it here would reopen a train // nothing can board. if (await this.isTrainFull(schedule)) return; // FULL concluded the cycle (phase DONE) and DONE rows are skipped by the // window tick forever — so when wagons free up before departure, restart // the cycle or nobody (customer or batch) can ever book the freed space. // ponytail: reopens now and closes at departure; the office-hours clamp // reapplies on the next conclude cycle. const departure = schedule.scheduledDepartureDate; if ( schedule.windowPhase === "DONE" && ["DRAFT", "SCHEDULED"].includes(schedule.status) && departure && departure.getTime() > Date.now() ) { await this.dataSource.getRepository(TrainSchedule).update(scheduleId, { windowPhase: "PRE_WINDOW", windowOpensAt: new Date(), windowClosesAt: departure, }); await this.setWindow(scheduleId, "OPEN"); this.logger.log( `[BATCH] ${scheduleId} FULL cleared after wagons freed — window revived ` + `(PRE_WINDOW, reopens immediately, closes at departure)`, ); return; } const customerWindowOpen = schedule.windowPhase == null || schedule.windowPhase === "OPEN"; await this.setWindow(scheduleId, customerWindowOpen ? "OPEN" : "CLOSED"); this.logger.log( `[BATCH] ${scheduleId} cleared stale FULL — wagons freed, window is now ` + `${customerWindowOpen ? "OPEN" : "CLOSED"} and the batch can fill it again`, ); } // ---- timer plumbing ------------------------------------------------------- /** * Effective customer pay window in ms for a target schedule: the staff * per-schedule override wins, else the global value for the schedule's * direction (export and import pay windows are tuned independently). * No schedule (unknown target) falls back to the import global. */ private async paymentWindowMsFor( schedule?: Pick< TrainSchedule, "direction" | "rulePaymentWindowMinutes" > | null, ): Promise { if (schedule?.rulePaymentWindowMinutes != null) { return schedule.rulePaymentWindowMinutes * 60_000; } const cfg = await this.trainSchedulingService.getWindowConfig(); const minutes = schedule?.direction === "EXPORT" ? cfg.exportPaymentWindowMinutes : cfg.paymentWindowMinutes; return minutes * 60_000; } private scheduleById(id: string): Promise { return this.dataSource .getRepository(TrainSchedule) .findOne({ where: { id } }); } private timeoutName(scheduleId: string): string { return `settle:${scheduleId}`; } /** * In-process accelerator only — the durable settle enforcement is the window * engine's minute tick calling settleDueReservations off `paymentDeadline`. */ private armSettle(scheduleId: string): void { void this.scheduleById(scheduleId) // + drain tail: firing at the raw deadline is a guaranteed no-op pass now // that nothing expires until the tail passes. .then((schedule) => this.paymentWindowMsFor(schedule)) .then((windowMs: number) => windowMs + paymentDrainMs()) .then((delayMs: number) => { this.removeTimeout(scheduleId); const handle = setTimeout(() => { void this.settleBatch(scheduleId).catch((err) => this.logger.error( `settleBatch ${scheduleId} failed: ${(err as Error).message}`, ), ); }, delayMs); this.scheduler.addTimeout(this.timeoutName(scheduleId), handle); }) .catch((err) => this.logger.warn( `armSettle ${scheduleId} skipped: ${(err as Error).message}`, ), ); } /** * A top-up reservation (settle freed capacity mid-cycle, so the next waiting * booking got a fresh pay window) sets a NEW paymentDeadline. But the schedule's * `paymentPhaseEndsAt` — which the window tick watches to end PAYMENT and run * concludeCycle — was frozen when the phase started. Without this, concludeCycle * fires before the top-up customer's deadline and expires a booking that still * had time to pay. Push `paymentPhaseEndsAt` to at least cover a full payment * window from now, but never past departure. Only while the schedule is still * in the PAYMENT phase (a reopened cycle manages its own phase). */ async extendPaymentPhaseForTopUp(scheduleId: string): Promise { const schedule = await this.dataSource .getRepository(TrainSchedule) .findOne({ where: { id: scheduleId } }); if (!schedule || schedule.windowPhase !== "PAYMENT") return; const windowMs = await this.paymentWindowMsFor(schedule); let target = new Date(Date.now() + windowMs); if ( schedule.scheduledDepartureDate && target > schedule.scheduledDepartureDate ) { target = schedule.scheduledDepartureDate; } // Only ever push the deadline OUT, never pull it in. if ( schedule.paymentPhaseEndsAt && schedule.paymentPhaseEndsAt.getTime() >= target.getTime() ) { return; } await this.dataSource .getRepository(TrainSchedule) .update(scheduleId, { paymentPhaseEndsAt: target }); this.logger.log( `[BATCH] extended PAYMENT phase for ${scheduleId} to ${target.toISOString()} ` + `(top-up reservation opened a fresh pay window)`, ); } private removeTimeout(scheduleId: string): void { const name = this.timeoutName(scheduleId); try { if (this.scheduler.doesExist("timeout", name)) { this.scheduler.deleteTimeout(name); } } catch { // ignore — not armed } } }