import { BadRequestException, ConflictException, forwardRef, Inject, Injectable, Logger, NotFoundException, OnModuleInit, Optional, } from '@nestjs/common'; import { InjectDataSource } from '@nestjs/typeorm'; import { SchedulerRegistry } from '@nestjs/schedule'; import { DataSource, In } from 'typeorm'; import { Booking } from '../bookings/entities/booking.entity'; import { BookingsRepository } from '../bookings/bookings.repository'; import { BookingPricingService } from '../bookings/booking-pricing.service'; import { Locomotive } from '../locomotives/entities/locomotive.entity'; import { formatRouteLabel } from '../routes/entities/route.entity'; import { RouteMilestone } from '../routes/entities/route-milestone.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 { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity'; import { BookingNotifierService } from './booking-notifier.service'; import { TrainSchedulingService } from './train-scheduling.service'; import { eatDay, groupBookingsIntoBoardWindows } from './batch-window.util'; import { Freight, TrainScheduleStatus as TrainScheduleStatusEnum } from "@edr/types"; import { BillingService } from "../billing/billing.service"; 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 { WagonTypeDimensions, bookingGrossWeightTons, bookingTrainLengthMeters, deriveTrainCapacityFromLocomotive, sizePartialOfferWagons, trainHardCaps, wagonTypeDimensionsFromEntity, } from './train-capacity.util'; import { WagonType } from '../wagon-types/entities/wagon-type.entity'; import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service'; import { BookingSplitService } from './booking-split.service'; import { BookingWindowGateway } from './booking-window.gateway'; import { MAX_TEU_SLOTS_PER_WAGON, containerWagonsForLines, } from './wagon-plan.util'; import { Capacity, CorridorBudget, CorridorLeg, stopYardsFor, } from './corridor-capacity.util'; export type { Capacity } from './corridor-capacity.util'; /** 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; } /** * Per-freight-type wagon dimensions used to size a booking's capacity draw: * its length on the train and the tare it adds to the locomotive's gross load. */ type WagonDims = { container: { lengthMeters: number; tareWeightTons: number; capacityTons: number }; bulk: { lengthMeters: number; tareWeightTons: number; capacityTons: number }; }; 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 BatchWindowGroup { key: string; label: string; /** EAT calendar day as ISO `YYYY-MM-DD` (empty for the pending-contract bucket). */ date: string; /** Human label for the day, e.g. `Thu, 05 Jun` (empty for pending-contract). */ dateLabel: string; start: string; end: string; counts: { allocated: number; selectedForBatch: number; ready: number; waiting: number; expired: number; pendingContract: number; }; bookings: BatchBoardBookingDetail[]; } export interface BatchBoardScheduleDetail { scheduleId: string; 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; locomotive: BatchBoardSchedule["locomotive"]; capacity: BatchBoardSchedule["capacity"]; counts: BatchBoardSchedule["counts"]; windows: BatchWindowGroup[]; pendingContract: BatchWindowGroup; allocationViolations: string[]; } export interface BatchBoardSchedule { scheduleId: string; 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; 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[]; } /** * 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, 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, ) {} /** 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?.trainScheduleId) 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); } const linked = await this.trainScheduleBookingsRepository.existsForBooking(bookingId); if (!linked) { 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.remainingWagons(schedule)) <= 0) { await this.setWindow(booking.trainScheduleId, "FULL"); } 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}`, ); } } /** 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 ----------------------------------------------------------- /** * 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 { 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(), ); if (!candidates.length) { throw new ConflictException( 'No export train is accepting bookings for this day', ); } const rules = await this.loadGlobalRules(); const wagonDims = await this.loadWagonDims(); const required = need ?? this.needFor(booking, wagonDims); let corridorMatched = false; for (const candidate of candidates) { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph( candidate.id, ); const locomotive = schedule?.trainSet?.locomotive; if (!schedule || !locomotive) continue; const limits = await this.capacityLimits(locomotive, rules); 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 corridorMatched = true; if (budget.fits(required, leg)) return schedule.id; } if (!corridorMatched) { throw new ConflictException( 'No export train is accepting bookings for this day', ); } throw new ConflictException('Train is full — no export capacity left for this day'); } /** * 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) { 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: 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 { for (const b of bookings) await this.reserve(b, scheduleId); this.armSettle(scheduleId); const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); if (schedule && (await this.remainingWagons(schedule)) <= 0) { await this.setWindow(scheduleId, 'FULL'); } } /** 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) { await this.allocate(scheduleId, booking, "paid"); this.logger.log( `Reconciled PAID booking ${booking.reference ?? booking.id} → schedule ${scheduleId}`, ); } } // ---- 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 still-relevant schedule (not arrived/ * cancelled) with its locomotive, capacity usage and its bookings grouped by lifecycle * state (allocated / awaiting payment / paid-waiting / pending contract / expired). */ async getBatchBoard(): Promise { const schedules = await this.trainSchedulesRepository.findAll({ relations: { trainSet: { locomotive: true }, originStation: true, destinationStation: true, // Yards supply the route's display name for `routeName` below. route: { originYard: true, destinationYard: true }, }, order: { scheduledDepartureDate: "ASC" }, }); const wagonDims = await this.loadWagonDims(); const rules = await this.loadGlobalRules(); const linkRepo = this.dataSource.getRepository(TrainScheduleBooking); const board: BatchBoardSchedule[] = []; for (const s of schedules) { if (s.status === "ARRIVED" || s.status === "CANCELLED") continue; // 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. if (s.direction !== "IMPORT") continue; const links = await linkRepo.find({ where: { trainScheduleId: s.id } }); const linkedIds = new Set(links.map((l) => l.bookingId)); const bookings = await this.bookingsRepository.findAllBySchedule(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, rules)); } return board; } /** Schedule-level batch board with EAT 3h windows grouped by fullyExecutedAt. */ async getBatchBoardDetail( scheduleId: string, ): Promise { const s = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); if (!s) throw new NotFoundException(`Train schedule ${scheduleId} not found`); if (s.status === "ARRIVED" || s.status === "CANCELLED") { throw new BadRequestException("Schedule is no longer active"); } // 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(); const rules = await this.loadGlobalRules(); const linkRepo = this.dataSource.getRepository(TrainScheduleBooking); const links = await linkRepo.find({ where: { trainScheduleId: s.id } }); const linkedIds = new Set(links.map((l) => l.bookingId)); const bookings = await this.bookingsRepository.findAllBySchedule(s.id); let allocationPreview: Awaited< ReturnType >; try { allocationPreview = await this.trainSchedulingService.previewAllocationForSchedule(s.id); } 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 = s.trainSet?.locomotive ?? null; // Display windows are the REAL booking-window cycles this schedule was FROZEN // with at creation (import: opens at its stored window time, lasts its rule's // duration, reopens per its rule's delay; export: single FCFS lead window) — // NOT the live global config. A later global-rules edit only re-derives // not-yet-open schedules (restampPendingWindows), so an already-open schedule // must keep drawing from its own snapshot, anchored on its stored open time. // Legacy rows with no snapshot fall back to the live config. const liveCfg = await this.trainSchedulingService.getWindowConfig(); const num = (v: unknown, fallback: number) => { const n = v == null ? NaN : Number(v); return Number.isFinite(n) ? n : fallback; }; const windowCfg = { windowOpenHour: num(s.ruleWindowOpenHour, liveCfg.windowOpenHour), windowCloseHour: num(s.ruleWindowCloseHour, liveCfg.windowCloseHour), windowDurationHours: num( s.ruleWindowDurationHours, liveCfg.windowDurationHours, ), reopenDelayMinutes: num(s.ruleReopenDelayMinutes, liveCfg.reopenDelayMinutes), importWindowLeadDays: num( s.ruleImportWindowLeadDays, liveCfg.importWindowLeadDays, ), exportBookingLeadHours: num( s.ruleExportBookingLeadHours, liveCfg.exportBookingLeadHours, ), }; const departureDate = s.scheduledDepartureDate ?? new Date(); const windowBuckets = groupBookingsIntoBoardWindows( items, (item) => (item.fullyExecutedAt ? new Date(item.fullyExecutedAt) : null), s.direction ?? null, departureDate, windowCfg, undefined, s.windowOpensAt ?? null, ); const emptyCounts = () => ({ allocated: 0, selectedForBatch: 0, ready: 0, waiting: 0, expired: 0, pendingContract: 0, }); const countFor = (bookingsInWindow: BatchBoardBookingDetail[]) => { const counts = emptyCounts(); for (const b of bookingsInWindow) { 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 windows: BatchWindowGroup[] = []; for (const [key, bucket] of windowBuckets) { if (key === "pending-contract" || !bucket.window) continue; const w = bucket.window; windows.push({ key: w.key, label: w.label, date: w.date, dateLabel: w.dateLabel, start: w.start.toISOString(), end: w.end.toISOString(), counts: countFor(bucket.items), bookings: bucket.items, }); } windows.sort( (a, b) => new Date(a.start).getTime() - new Date(b.start).getTime(), ); const pendingBookings = windowBuckets.get("pending-contract")?.items ?? []; return { scheduleId: s.id, 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, locomotive: loco ? { code: loco.code, name: loco.name ?? null, maxPullWeightTons: Number(loco.maxPullWeightTons), maxTrainLengthMeters: Number(loco.maxTrainLengthMeters), } : null, capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null, rules), 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, }, windows, pendingContract: { key: "pending-contract", label: "Pending contract", date: "", dateLabel: "", start: "", end: "", counts: countFor(pendingBookings), bookings: pendingBookings, }, allocationViolations: allocationPreview.violations, }; } /** Run wagon-level allocation for all eligible linked bookings on a schedule. */ async runWagonAllocation(scheduleId: string) { return this.trainSchedulingService.tryAutoWagonAllocation(scheduleId); } /** * 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 floored * by the global rule caps and widened by its overage tolerance. 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: Locomotive | null, maxWagons: number | null, rules: TrainSchedulingGlobalRules | null, ): BatchBoardSchedule["capacity"] { const allocated = items.filter((i) => i.state === "ALLOCATED"); const committed = items.filter( (i) => i.state === "ALLOCATED" || i.state === "SELECTED_FOR_BATCH", ); const caps = loco ? trainHardCaps( { maxPullWeightTons: Number(loco.maxPullWeightTons), maxTrainLengthMeters: Number(loco.maxTrainLengthMeters), overageToleranceTons: Number(loco.overageToleranceTons) || 0, overageToleranceMeters: Number(loco.overageToleranceMeters) || 0, }, { maxTrainWeightTons: rules?.maxTrainWeightTons ? Number(rules.maxTrainWeightTons) : undefined, maxTrainLengthMeters: rules?.maxTrainLengthMeters ? Number(rules.maxTrainLengthMeters) : undefined, }, ) : 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[], rules: TrainSchedulingGlobalRules | null, ): BatchBoardSchedule { const loco = s.trainSet?.locomotive ?? null; return { scheduleId: s.id, 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, locomotive: loco ? { code: loco.code, name: loco.name ?? null, maxPullWeightTons: Number(loco.maxPullWeightTons), maxTrainLengthMeters: Number(loco.maxTrainLengthMeters), } : null, capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null, rules), 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 = schedule.trainSet?.locomotive; if (!schedule.trainSetId || !locomotive) { this.logger.warn( `Schedule ${scheduleId} has no locomotive/train set — skipped.`, ); return 0; } const rules = await this.loadGlobalRules(); const wagonDims = await this.loadWagonDims(); const limits = await this.capacityLimits(locomotive, rules); await this.syncScheduleMaxWagons(schedule, locomotive, rules); const budget = await this.remainingBudget(schedule, limits, wagonDims); if (budget.maxRemaining().wagons <= 0) { 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 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, ); 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.maxRemaining().wagons <= 0) await this.setWindow(scheduleId, "FULL"); if (armed) this.armSettle(scheduleId); 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 rules = await this.loadGlobalRules(); const wagonDims = await this.loadWagonDims(); // Live per-schedule corridor budget + arm flag, in departure order. const trains: Array<{ id: string; budget: CorridorBudget; armed: boolean }> = []; for (const id of scheduleIds) { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(id); const locomotive = schedule?.trainSet?.locomotive; if (!schedule || !schedule.trainSetId || !locomotive) { this.logger.warn( `Schedule ${id} has no locomotive/train set — skipped.`, ); continue; } const limits = await this.capacityLimits(locomotive, rules); await this.syncScheduleMaxWagons(schedule, locomotive, rules); const budget = await this.remainingBudget(schedule, limits, wagonDims); trains.push({ id, budget, armed: 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, ); 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)!); 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`, ); for (const t of trains) { if (t.budget.maxRemaining().wagons <= 0) await this.setWindow(t.id, "FULL"); if (t.armed) this.armSettle(t.id); void this.triggerWagonAllocation(t.id); } return { scheduleIds: trains.map((t) => t.id), commercialReserved }; } /** * A lone commercial IMPORT 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). */ private isSplitEligible(booking: Booking, isPair: boolean): boolean { return ( !isPair && !booking.isGovernment && booking.tradeDirection === "IMPORT" && (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(); const bulkCapacityTons = await this.loadBulkWagonCapacityTons(); // 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. const perWagon = booking.freightType === "BULK" ? wagonDims.bulk : wagonDims.container; const partial = sizePartialOfferWagons(budget, need.wagons, perWagon); if (!partial) return null; const sized = await this.splitService.sizeOffer( booking, partial.wagons, need.wagons, bulkCapacityTons, partial.maxCargoTons, ); if (!sized) return null; const offeredNeed: Capacity = { wagons: sized.offeredWagons, weightTons: bookingGrossWeightTons( sized.offeredWeightTons, sized.offeredWagons, this.tareFor(booking.freightType, wagonDims), ), lengthMeters: bookingTrainLengthMeters( booking.freightType, sized.offeredWagons, this.lengthsOf(wagonDims), ), }; 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; } private async loadBulkWagonCapacityTons(): Promise { const cw3 = await this.dataSource .getRepository(WagonType) .findOne({ where: { code: "CW3" } }); const capacity = cw3 ? wagonTypeDimensionsFromEntity(cw3).capacityTons : 60; return capacity > 0 ? capacity : 60; } /** * 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)) { 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), ); } /** * 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`, ); } 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}`, ), ); } // ---- 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" }); await this.allocate(booking.trainScheduleId, booking, "paid"); const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph( booking.trainScheduleId, ); if (schedule && (await this.remainingWagons(schedule)) <= 0) { await this.setWindow(booking.trainScheduleId, "FULL"); } void this.triggerWagonAllocation(booking.trainScheduleId!); } /** * 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", ); } 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, schedulingStatus: "ELIGIBLE", paymentDeadline: null, selectedForBatchAt: null, } as never); }); } /** 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); } } } // ---- 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 = schedule?.trainSet?.locomotive; if (!schedule || !locomotive) return null; const rules = await this.loadGlobalRules(); const wagonDims = await this.loadWagonDims(); const limits = await this.capacityLimits(locomotive, rules); 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'); return; } await this.reserve(booking, scheduleId); this.armSettle(scheduleId); } // ---- mutations ------------------------------------------------------------ /** * Reserve capacity for a commercial booking on a specific train and open its * pay window. `scheduleId` is persisted so the settle/allocate lifecycle * (settleDueReservations, settleBatch, ensurePaidBookingAllocated, markPaid), * which is all keyed off `booking.trainScheduleId`, can find the train — with * day-level pooling the booking arrives here with `trainScheduleId` still null, * so the engine sets it as it picks the train. */ private async reserve(booking: Booking, scheduleId: string): Promise { // Idempotency guard: a booking already reserved (pay window open) or already // paid on THIS schedule must never be re-reserved — that would fire a second // `payNow` and reset its deadline, the "asked to pay again after paying" // symptom. Read fresh state (the in-memory `booking` may be stale from the // pooled query). Only bookings not yet committed to this train pass through. const fresh = await this.dataSource .getRepository(Booking) .findOne({ where: { id: booking.id } }); if ( fresh && fresh.trainScheduleId === scheduleId && (fresh.status === "SELECTED_FOR_BATCH" || fresh.status === "AWAITING_PAYMENT" || fresh.status === "PAID" || fresh.paymentStatus === "PAID") ) { this.logger.debug( `[BATCH] reserve skipped for ${booking.reference} — already ` + `${fresh.status}/${fresh.paymentStatus} on schedule ${scheduleId}`, ); return; } const now = new Date(); const deadline = new Date(now.getTime() + (await this.paymentWindowMs())); 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 at booking creation/approval, before this pay // window opened — refresh its printed due date to the real deadline. await this.billing.syncPayableDueDate( 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). */ 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); 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. */ private async expire(booking: Booking): Promise { 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"); this.notifier.expired(booking); this.logger.log( `[BATCH] EXPIRED ${booking.reference} — payment window passed; freed its ` + `wagons back to the pool for top-up`, ); } /** * 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}`, ); } for (const booking of unaccepted) { 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`, ); } } /** * 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 = Number(primary.cargoTotalWeightVgm ?? 0) + Number(partner.cargoTotalWeightVgm ?? 0); // 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. const capacityTons = this.capacityFor(primary.freightType, wagonDims); 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, this.tareFor(primary.freightType, wagonDims), ), lengthMeters: bookingTrainLengthMeters( primary.freightType, sharedWagons, this.lengthsOf(wagonDims), ), }; } /** Per-wagon tare for the wagon type this freight rides on. */ private tareFor(freightType: string | null | undefined, wagonDims: WagonDims): number { return freightType === 'BULK' ? wagonDims.bulk.tareWeightTons : wagonDims.container.tareWeightTons; } private lengthsOf(wagonDims: WagonDims): { container: number; bulk: number } { return { container: wagonDims.container.lengthMeters, bulk: wagonDims.bulk.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 (wagonsPerUnit = 0.5). The old fallback // summed raw container QUANTITY, so 20×20ft counted as 20 wagons, not 10. const byLength = containerWagonsForLines(booking.bookingContainers ?? []); const capacityTons = this.capacityFor(booking.freightType, wagonDims); const cargoTons = Number(booking.cargoTotalWeightVgm ?? 0); const byWeight = cargoTons > 0 && capacityTons > 0 ? Math.ceil(cargoTons / capacityTons) : 0; return Math.max(DEFAULT_WAGONS_PER_BOOKING, stored, byLength, byWeight); } private capacityFor( freightType: string | null | undefined, wagonDims: WagonDims, ): number { return freightType === "BULK" ? wagonDims.bulk.capacityTons : wagonDims.container.capacityTons; } /** * 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); return { wagons, weightTons: bookingGrossWeightTons( Number(booking.cargoTotalWeightVgm ?? 0), wagons, this.tareFor(booking.freightType, wagonDims), ), lengthMeters: bookingTrainLengthMeters( booking.freightType, wagons, this.lengthsOf(wagonDims), ), }; } private fits(need: Capacity, budget: Capacity): boolean { return ( need.wagons <= budget.wagons && need.weightTons <= budget.weightTons && need.lengthMeters <= budget.lengthMeters ); } /** * Hard 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 * these via {@link needFor}, whose weight axis is gross. */ private async capacityLimits( locomotive: Locomotive, rules: TrainSchedulingGlobalRules | null, ): 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, { maxTrainWeightTons: rules?.maxTrainWeightTons ? Number(rules.maxTrainWeightTons) : undefined, maxTrainLengthMeters: rules?.maxTrainLengthMeters ? Number(rules.maxTrainLengthMeters) : undefined, }, ); return { wagons: derived.maxWagonSlots, weightTons: derived.maxWeightTons, lengthMeters: derived.maxLengthMeters, }; } /** Keep schedule.max_wagons aligned with locomotive physical limits. */ private async syncScheduleMaxWagons( schedule: TrainSchedule, locomotive: Locomotive, rules: TrainSchedulingGlobalRules | null, ): Promise { const limits = await this.capacityLimits(locomotive, rules); if ((schedule.maxWagons ?? 0) !== limits.wagons) { await this.dataSource .getRepository(TrainSchedule) .update(schedule.id, { maxWagons: limits.wagons }); schedule.maxWagons = limits.wagons; } } /** * 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, }, ]; } /** Representative wagon per freight type: NW5 flat for containers, CW3 gondola for bulk. */ private async loadWagonDims(): Promise { const types = await this.dataSource.getRepository(WagonType).find({ where: [{ code: "NW5" }, { code: "CW3" }], }); const byCode = new Map( types.map((t) => [t.code, 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; return { 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), }, }; } private async loadGlobalRules(): Promise { return this.dataSource .getRepository(TrainSchedulingGlobalRules) .findOne({ where: {} }); } /** * 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. */ private async remainingBudget( schedule: TrainSchedule, limits: Capacity, wagonDims: WagonDims, ): Promise { const stops = await this.stopsForSchedule(schedule); const budget = new CorridorBudget(stops, limits); 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; } /** * Wagon slots still boardable somewhere on the corridor (most-open edge). * ≤ 0 means no leg can take another booking — the train-wide FULL signal. */ private async remainingWagons(schedule: TrainSchedule): Promise { const wagonDims = await this.loadWagonDims(); const budget = await this.remainingBudget( schedule, { wagons: schedule.maxWagons ?? 0, weightTons: Number.POSITIVE_INFINITY, lengthMeters: Number.POSITIVE_INFINITY, }, 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, ); } /** No wagon slots left for allocated + reserved bookings. */ async isScheduleFull(scheduleId: string): Promise { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); if (!schedule) return false; return (await this.remainingWagons(schedule)) <= 0; } /** * 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; if ((await this.remainingWagons(schedule)) <= 0) 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 } } }