import { BadRequestException, ConflictException, forwardRef, Inject, Injectable, Logger, NotFoundException, OnModuleInit, Optional, } from '@nestjs/common'; import { InjectDataSource } from '@nestjs/typeorm'; import { SchedulerRegistry } from '@nestjs/schedule'; import { Between, DataSource, FindOptionsWhere, ILike, In, 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 { RouteMilestone } from '../routes/entities/route-milestone.entity'; import { Yard } from '../rule-engine/entities/yard.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 } from './train-scheduling.service'; import { eatDay } 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, } from "./booking-batch.constants"; import { LocomotiveLimits, WagonTypeDimensions, bookingCargoTons, 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, } from './wagon-plan.util'; import { Capacity, CorridorBudget, CorridorLeg, OverageTolerance, stopYardsFor, } from './corridor-capacity.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; } /** 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 { 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). */ usedWeightTons: number; maxWeightTons: number | null; /** Wagon-slot cap for the train (locomotive/wagon-type derived). */ maxWagons: 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() @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')`) .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, ); 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; if (!booking.trainScheduleId) { // A paid booking with no train is money taken and nothing boarding — // scream so staff pin it to a schedule manually (batch board / assign). if (booking.paymentStatus === "PAID" || booking.status === "PAID") { this.logger.error( `PAID booking ${booking.reference ?? bookingId} has no train_schedule_id — ` + `its reservation was likely expired before the payment landed. ` + `Assign it to a schedule manually from the batch board.`, ); } return; } 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); 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); } /** 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: [ { status: TrainScheduleStatusEnum.Draft }, { status: TrainScheduleStatusEnum.Scheduled }, ], }); const candidates = corridor .filter( (s) => s.scheduledDepartureDate != null && eatDay(s.scheduledDepartureDate) === day && this.isFillable(s), ) .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); 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 { if (!report.trainsForDay || !report.corridorMatched) { return 'No export train is accepting bookings for this day'; } const best = report.bestAvailable; const base = '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: [ { status: TrainScheduleStatusEnum.Draft }, { status: TrainScheduleStatusEnum.Scheduled }, ], }); 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); 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; } /** * 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: [ { status: TrainScheduleStatusEnum.Draft }, { status: TrainScheduleStatusEnum.Scheduled }, ], }); 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); 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); 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", ); } 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 = {}, ): 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, }); // 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: { trainSet: { 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)); } 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); 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 items: BatchBoardBookingDetail[] = bookings.map((b) => { const need = this.needFor(b, wagonDims); const alloc = allocationByBooking.get(b.id); 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, 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); 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: this.computeBoardCapacity(items, loco, s.maxWagons ?? null), 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<{ state: BatchBoardBookingState; wagons: number; weightTons: number; lengthMeters: number; }>, loco: LocomotiveLimits | null, maxWagons: 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; return { allocatedWagons: allocated.reduce((sum, i) => sum + i.wagons, 0), allocatedLengthMeters: round2( allocated.reduce((sum, i) => sum + i.lengthMeters, 0), ), maxLengthMeters: caps ? caps.maxLengthMeters : null, usedWeightTons: round2(committed.reduce((sum, i) => sum + i.weightTons, 0)), maxWeightTons: caps ? caps.maxWeightTons : null, maxWagons: maxWagons ?? null, }; } private buildScheduleSummary( s: TrainSchedule, items: BatchBoardBooking[], ): BatchBoardSchedule { const loco = trainSetLocomotiveLimits(s.trainSet); 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), 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" ) { 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 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); const units = this.groupConsolidatedPool(pool); 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); // Per-unit fit trace: which axis (wagons/weight/length) admits or rejects. this.logger.debug( `[fillSchedule ${scheduleId}] unit ${booking.reference}: need=${JSON.stringify(need)} ` + `roomOnLeg=${JSON.stringify(budget.remainingFor(leg))} fits=${budget.fits(need, leg)}`, ); if (!budget.fits(need, leg)) { 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 } = { id: scheduleId, budget, armed, }; if (await this.maybeOfferPartial(booking, isPair, [cand], need)) { armed = cand.armed; continue; } continue; // skip a unit that exceeds weight/length/wagons, 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(need, 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, }, { originStationId: originYardId, destinationStationId: destinationYardId, status: TrainScheduleStatusEnum.Scheduled, }, ], }); 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(); // Live per-schedule corridor budget + arm/changed flags, in departure order. const trains: Array<{ id: string; budget: CorridorBudget; armed: boolean; changed: boolean; }> = []; 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; } const limits = await this.capacityLimits(locomotive); await this.syncScheduleMaxWagons(schedule, locomotive); const budget = await this.remainingBudget(schedule, limits, wagonDims); trains.push({ id, budget, 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); // 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); // 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); // First train (earliest departure) whose corridor carries this booking's // leg and still fits it as-is. let target = trains.find((t) => { const leg = legOn(t); return leg != null && t.budget.fits(need, leg); }); // 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); 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(need, 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 is 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 === "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 }>, need: Capacity, ): Promise { if (!this.isSplitEligible(booking, isPair)) return false; const target = candidates .map((c) => { const leg = c.budget.legOf(booking.originYardId, booking.destinationYardId); return leg ? { c, leg, room: c.budget.remainingFor(leg) } : null; }) .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, ); if (!offered) return false; target.c.budget.subtract(offered, 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, ): 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 = this.dimsFor(booking, wagonDims); const partial = sizePartialOfferWagons(budget, need.wagons, perWagon, { fullWagonsOnly: booking.freightType === "BULK", }); if (!partial) return null; const sized = await this.splitService.sizeOffer( booking, partial.wagons, need.wagons, 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.paymentWindowMs())); 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, } 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; if either partner expires, both expire (a half-paid shared * wagon must not ship). 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"; const isExpired = (b: Booking) => b.paymentDeadline ? b.paymentDeadline.getTime() <= 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)) { await this.expire(booking); await this.expire(partner); 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, 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"); } /** 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"); } } // ---- 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; } | 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); return { budget, needFor: (booking) => this.needFor(booking, wagonDims) }; } /** * 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; } await this.reserve(booking, scheduleId); this.armSettle(scheduleId); this.notifyBoardChanged(scheduleId, 'intercity_accepted'); } // ---- 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(); let deadline = new Date(now.getTime() + (await this.paymentWindowMs())); // 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. const targetSchedule = await this.dataSource .getRepository(TrainSchedule) .findOne({ where: { id: scheduleId } }); 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, } 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", ): Promise { 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(), 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); 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. Consolidated bookings are exempt from the * rescue: the shared wagon is both-or-neither, and settleReserved owns that * pair decision. */ private async expire( booking: Booking, reason: "payment" | "no-capacity" = "payment", ): Promise { 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; } } const freedScheduleId = booking.trainScheduleId; await this.bookingsRepository.update(booking.id, { trainScheduleId: null, status: "EXPIRED", schedulingStatus: "ELIGIBLE", paymentDeadline: null, selectedForBatchAt: 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(); for (const booking of unaccepted) { 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"); } } /** * 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; await this.dataSource.transaction(async (manager) => { await this.trainScheduleBookingsRepository.deleteByScheduleAndBooking( scheduleId, victim.id, manager, ); 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}`, ); } } } /** Restore the batch pool ordering (mirrors findBatchPool's ORDER BY) after scores changed. */ private resortPoolByPriority(pool: Booking[]): void { pool.sort( (a, b) => Number(b.isGovernment) - Number(a.isGovernment) || 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); const byWeight = cargoTons > 0 && capacityTons > 0 ? Math.ceil(cargoTons / capacityTons) : 0; return Math.max(DEFAULT_WAGONS_PER_BOOKING, stored, byLength, byWeight); } /** * 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, }; } 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[] { 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 dims: PerWagonDims[] = []; for (const id of ids) { if (!id || seen.has(id)) continue; seen.add(id); const d = wagonDims.byWagonTypeId.get(id); if (d) { dims.push({ ...d, capacityTons: d.capacityTons > 0 ? d.capacityTons : fallback.capacityTons, }); } } return dims.length ? dims : [fallback]; } /** * 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, ): 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); const allocated = (schedule.scheduleBookings ?? []) .map((sb) => sb.booking) .filter((b): b is Booking => Boolean(b)); const reserved = await this.bookingsRepository.findReservedForSchedule( schedule.id, ); for (const b of [...allocated, ...reserved]) { budget.subtract( this.needFor(b, wagonDims), budget.legForYards(b.originYardId, b.destinationYardId), ); } return budget; } /** * 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. * * 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 && b.paymentDeadline.getTime() > now, ); } /** * FULL is DIRECTIONAL: the schedule's trade direction is full when the * border-crossing edge (which every export/import must ride) can't 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). * Home-side legs may still run empty; intercity ride-alongs keep filling * them via the per-leg budget and never consult this flag. Domestic routes * (no border) are full only when every edge is closed. */ 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" means full FOR THE TRAIN'S TRADE DIRECTION. Every export and // every import must cross the ET↔DJ border edge, so once that edge can't // take one more minimal wagon the booking window may close — even while // home-side legs still run empty. Intercity ride-alongs never consult this // flag; they keep booking the free legs through the per-leg budget. // A single-country (domestic) corridor has no mandatory edge, so it is // full only when EVERY edge is closed on some axis. 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); const border = await this.borderLeg(budget.stops); if (border) { return !budget.fits( { wagons: 1, weightTons: minNeed.grossWeightTons, lengthMeters: minNeed.lengthMeters, }, border, ); } return budget.isExhausted(minNeed); } /** * The corridor's single border-crossing edge (last home-country stop → first * far-country stop), or null when every stop is in one country. This is the * edge every EXPORT and IMPORT booking must ride, whichever sub-corridor it * books — which makes it the train's directional fullness gauge. */ private async borderLeg(stops: string[]): Promise { if (stops.length < 2) return null; const yards = await this.dataSource .getRepository(Yard) .find({ where: { id: In(stops) } }); const countryOf = new Map(yards.map((y) => [y.id, y.country])); const first = countryOf.get(stops[0]); if (!first) return null; const crossIdx = stops.findIndex((id) => { const country = countryOf.get(id); return country != null && country !== first; }); if (crossIdx <= 0) return null; return { fromEdge: crossIdx - 1, toEdge: crossIdx }; } /** * 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; 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 ------------------------------------------------------- /** Configured customer pay window in ms (global rules, with defaults). */ private async paymentWindowMs(): Promise { const cfg = await this.trainSchedulingService.getWindowConfig(); return cfg.paymentWindowMinutes * 60_000; } 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.paymentWindowMs() .then((delayMs) => { 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.paymentWindowMs(); 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 } } }