diff --git a/apps/edr-freight-api/src/migrations/2190000000000-DropReopenDelayMinutes.ts b/apps/edr-freight-api/src/migrations/2190000000000-DropReopenDelayMinutes.ts new file mode 100644 index 000000000..5a667e2e4 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2190000000000-DropReopenDelayMinutes.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Drop the unused reopen-delay knob from the global rules. + * + * The window engine never honoured `reopen_delay_minutes`: a not-yet-full train + * reopens as soon as its payment phase settles, so the real gap between a cycle + * closing and reopening is doc review + payment — nothing else. The per-schedule + * `rule_reopen_delay_minutes` snapshot stays: it freezes that derived gap at + * creation so the batch board keeps projecting the cycles the customer was shown. + */ +export class DropReopenDelayMinutes2190000000000 implements MigrationInterface { + name = "DropReopenDelayMinutes2190000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_scheduling_global_rules + DROP COLUMN IF EXISTS reopen_delay_minutes; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_scheduling_global_rules + ADD COLUMN IF NOT EXISTS reopen_delay_minutes integer NOT NULL DEFAULT 90; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2200000000000-TrainNumberPair.ts b/apps/edr-freight-api/src/migrations/2200000000000-TrainNumberPair.ts new file mode 100644 index 000000000..65a3d3cda --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2200000000000-TrainNumberPair.ts @@ -0,0 +1,42 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Every built train owns a fixed pair of run numbers, typed at build time: + * an EXPORT number (odd, e.g. 8001) and an IMPORT number (even, e.g. 8002). + * Scheduling copies the route-direction-matched number onto the schedule at + * creation; legacy trains with a null pair keep dispatch-time pool assignment. + * + * NOTE: the shared dev DB has no applied migration history, so this is also + * hand-applied there. IF NOT EXISTS keeps that idempotent. + */ +export class TrainNumberPair2200000000000 implements MigrationInterface { + name = 'TrainNumberPair2200000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.trains + ADD COLUMN IF NOT EXISTS import_train_number varchar(20), + ADD COLUMN IF NOT EXISTS export_train_number varchar(20); + `); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_trains_import_train_number" + ON freight.trains (import_train_number) + WHERE import_train_number IS NOT NULL; + `); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_trains_export_train_number" + ON freight.trains (export_train_number) + WHERE export_train_number IS NOT NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_trains_export_train_number";`); + await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_trains_import_train_number";`); + await queryRunner.query(` + ALTER TABLE freight.trains + DROP COLUMN IF EXISTS export_train_number, + DROP COLUMN IF EXISTS import_train_number; + `); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index 4cc15854e..79d15069b 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts @@ -86,6 +86,7 @@ export const SCHEDULING_STATUSES = [ SchedulingStatus.Eligible, SchedulingStatus.Scheduled, SchedulingStatus.Dispatched, + SchedulingStatus.WaitingForWagon, ] as const; export type BookingSchedulingStatus = (typeof SCHEDULING_STATUSES)[number]; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.spec.ts index ba0995679..e913af6b7 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.spec.ts @@ -109,17 +109,26 @@ describe('computeImportWindowTimes — first-window open respects office hours', }); it('caps the close at departure', () => { - // Opens now (05 Jul 12:00 EAT); a 24h duration would close 06 Jul 12:00 EAT, - // past the 06 Jul 08:00 departure → clamped to departure. + // Round-the-clock desk (no desk-close cap in play). Opens now (05 Jul 12:00 + // EAT); a 24h duration would close 06 Jul 12:00 EAT, past the 06 Jul 08:00 + // departure → clamped to departure. const now = new Date('2026-07-05T09:00:00.000Z'); const { windowClosesAt } = computeImportWindowTimes( departure, - { ...bounded, windowDurationHours: 24 }, + { ...bounded, windowOpenHour: 8, windowCloseHour: 8, windowDurationHours: 24 }, now, ); expect(windowClosesAt.toISOString()).toBe(departure.toISOString()); }); + it('desk close hour cuts the window short (duration never outlives the desk)', () => { + // Opens now (05 Jul 12:00 EAT); the 15h duration would run to 03:00 next + // day, but the desk shuts 17:00 EAT (14:00 UTC) → the window closes with it. + const now = new Date('2026-07-05T09:00:00.000Z'); + const { windowClosesAt } = computeImportWindowTimes(departure, bounded, now); + expect(windowClosesAt.toISOString()).toBe('2026-07-05T14:00:00.000Z'); + }); + describe('overnight desk (open > close, wraps past midnight)', () => { // Desk open 08:00, closes 05:00 next morning — open across midnight. const overnight = { ...bounded, windowOpenHour: 8, windowCloseHour: 5 }; @@ -189,13 +198,13 @@ describe('computeImportWindowTimes — overnight desk (open > close, wraps midni describe('batch-window board windows (config-driven booking cycles)', () => { // Default rules: open 08:00 EAT, desk shuts 17:00, 3 days before departure, - // 3h long, reopen 90m later. + // 3h long, reopen gap (doc review + payment) 90m. const cfg: BoardWindowConfig = { importWindowLeadDays: 3, windowOpenHour: 8, windowCloseHour: 17, windowDurationHours: 3, - reopenDelayMinutes: 90, + reopenGapMinutes: 90, exportBookingLeadHours: 24, }; @@ -211,7 +220,7 @@ describe('batch-window board windows (config-driven booking cycles)', () => { expect(windows[0].end.toISOString()).toBe('2026-06-05T08:00:00.000Z'); }); - it('import: reopens reopenDelayMinutes after close while inside office hours', () => { + it('import: reopens after the doc-review + payment gap while inside office hours', () => { const departure = new Date('2026-06-08T11:00:00.000Z'); const windows = listConfigBookingWindows('IMPORT', departure, cfg); // cycle 1: 08:00–11:00; reopen +90m → cycle 2 opens 12:30 EAT, same day @@ -250,6 +259,17 @@ describe('batch-window board windows (config-driven booking cycles)', () => { expect(new Set(windows.map((w) => w.date)).size).toBeGreaterThanOrEqual(3); }); + it('import: desk close hour cuts a cycle short (duration past 17:00 clamps)', () => { + const longCfg: BoardWindowConfig = { ...cfg, windowDurationHours: 10 }; + const departure = new Date('2026-06-08T11:00:00.000Z'); + const windows = listConfigBookingWindows('IMPORT', departure, longCfg); + // Cycle 1 opens 08:00 EAT; 10h would close 18:00 — desk shuts 17:00 (14:00 UTC). + expect(windows[0].start.toISOString()).toBe('2026-06-05T05:00:00.000Z'); + expect(windows[0].end.toISOString()).toBe('2026-06-05T14:00:00.000Z'); + // Reopen 90m after the clamped close lands past 17:00 → next morning 08:00 EAT. + expect(windows[1].start.toISOString()).toBe('2026-06-06T05:00:00.000Z'); + }); + it('export: single FCFS window exportBookingLeadHours before departure', () => { const departure = new Date('2026-06-08T11:00:00.000Z'); const windows = listConfigBookingWindows('EXPORT', departure, cfg); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts index 0fdacc572..a1dfe61f4 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts @@ -223,6 +223,49 @@ export function nextCycleOpensAt( return opensAt.getTime() < departure.getTime() ? opensAt : null; } +/** + * The desk-close instant of the office window containing `opensAt`; null for a + * round-the-clock desk. Same-day desk (open < close): closeHour on `opensAt`'s + * EAT day. Overnight desk (open > close): closeHour on the NEXT EAT day when + * `opensAt` sits in the evening half, closeHour the same day when it sits in the + * after-midnight half. + */ +export function officeCloseAfter(opensAt: Date, hours: OfficeHours): Date | null { + if (isRoundTheClock(hours)) return null; + const { hour, minute } = eatParts(opensAt); + const openMinutes = hour * 60 + minute; + if ( + hours.windowOpenHour > hours.windowCloseHour && + openMinutes >= hours.windowOpenHour * 60 + ) { + return eatDayToUtc(shiftEatDay(eatDay(opensAt), 1), hours.windowCloseHour); + } + return eatDayToUtc(eatDay(opensAt), hours.windowCloseHour); +} + +/** + * Cap a window close at the desk-close hour that follows its open: the office + * hours end a running window early rather than letting the duration outlive the + * desk (open 16:00, 3h duration, desk 8–17 → closes 17:00, not 19:00). A + * round-the-clock desk never caps; a desk-close at/before the open (degenerate + * config) is ignored so the window is never clamped to zero length here. + */ +export function clampCloseToOfficeHours( + opensAt: Date, + closesAt: Date, + hours: OfficeHours, +): Date { + const deskClose = officeCloseAfter(opensAt, hours); + if ( + deskClose != null && + deskClose.getTime() > opensAt.getTime() && + closesAt.getTime() > deskClose.getTime() + ) { + return deskClose; + } + return closesAt; +} + export interface InitialWindowTimes { windowOpensAt: Date; windowClosesAt: Date; @@ -243,7 +286,8 @@ export interface InitialWindowTimes { * • `now` before openHour that EAT day → opens at openHour that morning * • `now` at/after closeHour → desk shut; opens openHour next morning * - * `windowDurationHours` extends from that open, capped at departure. + * `windowDurationHours` extends from that open, capped at the desk close hour + * and at departure. */ export function computeImportWindowTimes( departure: Date, @@ -276,6 +320,10 @@ export function computeImportWindowTimes( } let closesAt = new Date(opensAt.getTime() + cfg.windowDurationHours * 3_600_000); + closesAt = clampCloseToOfficeHours(opensAt, closesAt, { + windowOpenHour: cfg.windowOpenHour, + windowCloseHour: cfg.windowCloseHour, + }); if (closesAt.getTime() > departure.getTime()) { closesAt = departure; } @@ -381,10 +429,11 @@ export function listBatchWindowsForBookings( // --------------------------------------------------------------------------- // Board-display windows: the REAL booking-window cycles derived from the -// train_scheduling_global_rules config (window open hour, lead days, duration, -// reopen delay) — NOT a fixed clock grid. Import shows each booking-window cycle -// (opens at windowOpenHour EAT, lasts windowDurationHours, reopens after -// reopenDelayMinutes until departure). Export shows the single FCFS lead window. +// schedule's frozen window rule (open/close hour, lead days, duration, reopen +// gap = doc review + payment) — NOT a fixed clock grid. Import shows each +// booking-window cycle (opens at windowOpenHour EAT, lasts windowDurationHours +// capped at the desk close, reopens after the gap until departure). Export shows +// the single FCFS lead window. // --------------------------------------------------------------------------- /** A board window carries an EAT calendar date in addition to the slot times. */ @@ -402,8 +451,11 @@ export interface BoardWindowConfig { /** EAT hour the daily booking desk shuts; equals windowOpenHour for a 24h desk. */ windowCloseHour: number; windowDurationHours: number; - /** Gap between a cycle's close and its reopen (doc review + payment minutes). */ - reopenDelayMinutes: number; + /** + * Gap between a cycle's close and its reopen — always doc review + payment + * minutes (the schedule's frozen snapshot, or the live sum for legacy rows). + */ + reopenGapMinutes: number; exportBookingLeadHours: number; } @@ -435,10 +487,11 @@ function boardWindowFromInterval(start: Date, end: Date): BoardWindow { * The real booking-window cycles for a schedule, straight from config. * * IMPORT: first window opens at `windowOpenHour` EAT on `departure − importWindowLeadDays` - * for `windowDurationHours`; if the train isn't full it reopens `reopenDelayMinutes` - * after each close, on the same booking day, until departure. This mirrors - * `computeImportWindowTimes` + `concludeCycle`'s reopen math so the board shows the - * exact windows the engine runs. + * for `windowDurationHours` (cut short by the desk close hour); if the train isn't + * full it reopens `reopenGapMinutes` (doc review + payment) after each close, + * honouring office hours, until departure. This mirrors `computeImportWindowTimes` + * + `concludeCycle`'s reopen math so the board shows the exact windows the engine + * runs. * EXPORT: a single FCFS window from `departure − exportBookingLeadHours` to departure, * with the open shifted to the next desk opening when it lands outside office hours * (same math as `computeExportWindowTimes`). @@ -464,7 +517,7 @@ export function listConfigBookingWindows( const durationMs = cfg.windowDurationHours * 3_600_000; // Post-close gap before the next cycle opens (doc review + payment), subject // to office hours below. - const reopenMs = cfg.reopenDelayMinutes * 60_000; + const reopenMs = cfg.reopenGapMinutes * 60_000; const officeHours: OfficeHours = { windowOpenHour: cfg.windowOpenHour, windowCloseHour: cfg.windowCloseHour, @@ -484,6 +537,7 @@ export function listConfigBookingWindows( for (let cycle = 0; cycle < maxCycles; cycle += 1) { if (opensAt.getTime() >= departure.getTime()) break; let closesAt = new Date(opensAt.getTime() + durationMs); + closesAt = clampCloseToOfficeHours(opensAt, closesAt, officeHours); if (closesAt.getTime() > departure.getTime()) closesAt = departure; windows.push(boardWindowFromInterval(opensAt, closesAt)); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts index c8e254141..c37430121 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts @@ -37,6 +37,7 @@ describe('BookingBatchService — PAID reconcile', () => { }; let trainSchedulingService: { tryAutoWagonAllocation: jest.Mock; + previewPaidBookingWagonShortage: jest.Mock; getBookableSchedules: jest.Mock; getWindowConfig: jest.Mock; }; @@ -87,6 +88,8 @@ describe('BookingBatchService — PAID reconcile', () => { issues: [], violations: [], }), + // No shortage by default — paid bookings link as before. + previewPaidBookingWagonShortage: jest.fn().mockResolvedValue(null), getBookableSchedules: jest.fn().mockResolvedValue([]), getWindowConfig: jest.fn().mockResolvedValue({ importWindowLeadDays: 3, @@ -96,7 +99,6 @@ describe('BookingBatchService — PAID reconcile', () => { windowDurationHours: 3, docReviewMinutes: 30, paymentWindowMinutes: 60, - reopenDelayMinutes: 90, }), }; @@ -169,6 +171,38 @@ describe('BookingBatchService — PAID reconcile', () => { expect(trainSchedulingService.tryAutoWagonAllocation).toHaveBeenCalledTimes(2); }); + it('ensurePaidBookingAllocated holds a wagon-short booking out of the train', async () => { + trainSchedulingService.previewPaidBookingWagonShortage.mockResolvedValue({ + wagonTypeCodes: 'NW6', + wagonsNeeded: 1, + wagonsAvailable: 0, + wagonsShort: 1, + }); + + await service.ensurePaidBookingAllocated(bookingId); + + // Not linked, no wagon run — held PAID + unlinked, flagged for manual placement. + expect(trainScheduleBookingsRepository.createMany).not.toHaveBeenCalled(); + expect(trainSchedulingService.tryAutoWagonAllocation).not.toHaveBeenCalled(); + expect(dataSource.getRepository().update).toHaveBeenCalledWith( + bookingId, + expect.objectContaining({ schedulingStatus: 'WAITING_FOR_WAGON' }), + ); + }); + + it('reconcilePaidUnlinked leaves WAITING_FOR_WAGON bookings held', async () => { + bookingsRepository.findPaidUnlinkedForSchedule.mockResolvedValue([ + { ...paidBooking, schedulingStatus: 'WAITING_FOR_WAGON' }, + ]); + + await service.reconcilePaidUnlinked(scheduleId); + + expect(trainScheduleBookingsRepository.createMany).not.toHaveBeenCalled(); + expect( + trainSchedulingService.previewPaidBookingWagonShortage, + ).not.toHaveBeenCalled(); + }); + it('processSchedule reconciles PAID-unlinked before wagon allocation', async () => { const fillSpy = jest.spyOn(service, 'fillSchedule').mockResolvedValue(0); const settleSpy = jest.spyOn(service, 'settleDueReservations').mockResolvedValue(undefined); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index e51d4ad23..82782446a 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -497,6 +497,7 @@ export class BookingBatchService implements OnModuleInit { const linked = await this.trainScheduleBookingsRepository.existsForBooking(bookingId); if (!linked) { + if (await this.holdIfWagonShort(booking.trainScheduleId, booking)) return; await this.allocate(booking.trainScheduleId, booking, "paid"); this.logger.log( `Linked PAID booking ${booking.reference ?? bookingId} to schedule ${booking.trainScheduleId}`, @@ -783,6 +784,9 @@ export class BookingBatchService implements OnModuleInit { const unlinked = await this.bookingsRepository.findPaidUnlinkedForSchedule(scheduleId); for (const booking of unlinked) { + // Held on purpose (paid, no wagon free) — the cron must not undo it. + if (booking.schedulingStatus === "WAITING_FOR_WAGON") continue; + if (await this.holdIfWagonShort(scheduleId, booking)) continue; await this.allocate(scheduleId, booking, "paid"); this.logger.log( `Reconciled PAID booking ${booking.reference ?? booking.id} → schedule ${scheduleId}`, @@ -1050,7 +1054,11 @@ export class BookingBatchService implements OnModuleInit { s.ruleWindowDurationHours, liveCfg.windowDurationHours, ), - reopenDelayMinutes: num(s.ruleReopenDelayMinutes, liveCfg.reopenDelayMinutes), + // Frozen doc-review + payment sum; legacy rows fall back to the live sum. + reopenGapMinutes: num( + s.ruleReopenDelayMinutes, + liveCfg.docReviewMinutes + liveCfg.paymentWindowMinutes, + ), importWindowLeadDays: num( s.ruleImportWindowLeadDays, liveCfg.importWindowLeadDays, @@ -1894,7 +1902,9 @@ export class BookingBatchService implements OnModuleInit { done.add(booking.id); if (isPaid(booking)) { - await this.allocate(scheduleId, booking, "paid"); + if (!(await this.holdIfWagonShort(scheduleId, booking))) { + await this.allocate(scheduleId, booking, "paid"); + } anySettled = true; } else if (isExpired(booking)) { await this.expire(booking); @@ -1920,6 +1930,29 @@ export class BookingBatchService implements OnModuleInit { ); } + /** + * Conclude-time retry: promote whatever still fits from the route-day waiting + * list, opening fresh pay windows. Returns how many commercial units got + * reserved — corridor-wide, since the fill is day-level and may reserve onto a + * sibling train; the caller must check `hasLiveReservations` for its OWN + * schedule before deciding to stay in PAYMENT. + */ + async fillFromWaitingList(scheduleId: string): Promise { + return this.withScheduleLock(scheduleId, async () => { + let promoted = 0; + for (let round = 0; round < 10; round += 1) { + const reservedThisRound = await this.topUpFill(scheduleId); + if (reservedThisRound <= 0) break; + promoted += reservedThisRound; + await this.extendPaymentPhaseForTopUp(scheduleId); + } + if (promoted > 0) { + this.notifyBoardChanged(scheduleId, "conclude_waiting_list_fill"); + } + return promoted; + }); + } + /** * Settle, then keep promoting the waiting list until the train can take no more. * Returns whether anything settled. @@ -2050,7 +2083,9 @@ export class BookingBatchService implements OnModuleInit { await this.dataSource .getRepository(Booking) .update(bookingId, { paymentStatus: "PAID" }); - await this.allocate(booking.trainScheduleId, booking, "paid"); + if (!(await this.holdIfWagonShort(booking.trainScheduleId, booking))) { + await this.allocate(booking.trainScheduleId, booking, "paid"); + } const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph( booking.trainScheduleId, @@ -2112,7 +2147,12 @@ export class BookingBatchService implements OnModuleInit { await manager.getRepository(Booking).update(bookingId, { trainScheduleId: newScheduleId, status: restoredStatus, - schedulingStatus: "ELIGIBLE", + // A paid booking still hunting for a wagon keeps its flag through the + // move — it only clears when wagons are actually assigned. + schedulingStatus: + booking.schedulingStatus === "WAITING_FOR_WAGON" + ? "WAITING_FOR_WAGON" + : "ELIGIBLE", paymentDeadline: null, selectedForBatchAt: null, } as never); @@ -2254,6 +2294,50 @@ export class BookingBatchService implements OnModuleInit { } /** Allocate a booking to the schedule's train (creates the TrainScheduleBooking link). */ + /** + * Fleet preflight shared by every single-booking paid-allocation path: when + * no wagon of the booking's required type is free, hold it OUT of the train + * instead of linking — it stays PAID + unlinked in the (route, day) pool, + * flagged WAITING_FOR_WAGON, and staff place it on any same-day schedule from + * the workspace "Paid · unassigned" panel once a wagon frees up. Returns true + * when the booking was held. Consolidated pairs are exempt (the shared wagon + * is both-or-neither and settles atomically in settleReserved). + */ + private async holdIfWagonShort( + scheduleId: string, + booking: Booking, + ): Promise { + if (booking.consolidationPartnerId) return false; + const shortage = + await this.trainSchedulingService.previewPaidBookingWagonShortage( + scheduleId, + booking.id, + ); + if (!shortage) return false; + + await this.dataSource.getRepository(Booking).update(booking.id, { + status: "PAID", + paymentStatus: "PAID", + schedulingStatus: "WAITING_FOR_WAGON", + paymentDeadline: null, + selectedForBatchAt: null, + } as never); + // Payment landed — record it even though nothing boards yet. The wagon + // milestone stays pending until staff assign one. + void this.completeTrackingMilestones(booking.id, [ + "FREIGHT_PAYMENT_PENDING", + "FREIGHT_PAYMENT_SETTLED", + ]); + this.logger.warn( + `PAID booking ${booking.reference ?? booking.id} is WAITING FOR WAGON: ` + + `needs ${shortage.wagonsNeeded} × ${shortage.wagonTypeCodes}, ` + + `${shortage.wagonsAvailable} available (short ${shortage.wagonsShort}). ` + + `Held in the day pool for manual placement.`, + ); + this.notifyBoardChanged(scheduleId, "booking_waiting_wagon"); + return true; + } + private async allocate( scheduleId: string, booking: Booking, @@ -2358,7 +2442,9 @@ export class BookingBatchService implements OnModuleInit { `[BATCH] expire skipped for ${booking.reference} — payment already ` + `landed; allocating on schedule ${paidScheduleId} instead`, ); - await this.allocate(paidScheduleId, fresh, "paid"); + if (!(await this.holdIfWagonShort(paidScheduleId, fresh))) { + await this.allocate(paidScheduleId, fresh, "paid"); + } return; } } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.config.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.config.ts index 25d569171..7128bd705 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.config.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.config.ts @@ -19,8 +19,6 @@ export interface BookingWindowConfig { /** Max staff document-review time after the window closes. */ docReviewMinutes: number; paymentWindowMinutes: number; - /** Delay after window close before reopening when the train is not full. */ - reopenDelayMinutes: number; } /** Window phase lifecycle for the one-booking-day import cycle. NULL on legacy/DOMESTIC schedules. */ diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.spec.ts index 72229cfd6..9393c520f 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.spec.ts @@ -20,6 +20,7 @@ describe('BookingWindowService — window state machine', () => { hasLiveReservations: jest.Mock; refreshWindowStatus: jest.Mock; expireLeftoverDayPool: jest.Mock; + fillFromWaitingList: jest.Mock; }; let trainSchedulesRepository: { findById: jest.Mock; findAll: jest.Mock }; let trainSchedulingService: { finalizeSchedule: jest.Mock; getWindowConfig: jest.Mock }; @@ -33,7 +34,6 @@ describe('BookingWindowService — window state machine', () => { windowDurationHours: 1, docReviewMinutes: 30, paymentWindowMinutes: 60, - reopenDelayMinutes: 0, }; const baseSchedule = (over: Partial): TrainSchedule => @@ -75,6 +75,8 @@ describe('BookingWindowService — window state machine', () => { hasLiveReservations: jest.fn().mockResolvedValue(false), refreshWindowStatus: jest.fn().mockResolvedValue(undefined), expireLeftoverDayPool: jest.fn().mockResolvedValue(0), + // No waiting booking fits by default, so conclude proceeds to reopen/DONE. + fillFromWaitingList: jest.fn().mockResolvedValue(0), }; trainSchedulesRepository = { findById: jest.fn().mockResolvedValue(null), @@ -123,6 +125,8 @@ describe('BookingWindowService — window state machine', () => { }); it('DOC_REVIEW → PAYMENT expires un-accepted, then runs the batch', async () => { + // The batch reserved someone (live reservations exist) → real PAYMENT phase. + batch.hasLiveReservations.mockResolvedValue(true); const s = baseSchedule({ windowPhase: 'DOC_REVIEW', docReviewEndsAt: new Date('2026-07-01T01:30:00.000Z'), @@ -140,6 +144,7 @@ describe('BookingWindowService — window state machine', () => { }); it('DOC_REVIEW → PAYMENT also fires when staff finished review early (docReviewCompletedAt)', async () => { + batch.hasLiveReservations.mockResolvedValue(true); const s = baseSchedule({ windowPhase: 'DOC_REVIEW', docReviewEndsAt: new Date('2026-07-01T05:00:00.000Z'), // far future @@ -150,6 +155,21 @@ describe('BookingWindowService — window state machine', () => { expect(s.windowPhase).toBe('PAYMENT'); }); + it('DOC_REVIEW → batch reserves nothing → skips the empty PAYMENT phase and reopens', async () => { + // Default hasLiveReservations=false: the batch reserved nobody. Waiting a + // full payment window with the desk shut would serve no one — the cycle + // concludes immediately (24h desk + far departure → straight to PRE_WINDOW). + const s = baseSchedule({ + windowPhase: 'DOC_REVIEW', + docReviewEndsAt: new Date('2026-07-01T01:30:00.000Z'), + }); + const advanced = await advanceImport(s, new Date('2026-07-01T01:30:01.000Z')); + expect(advanced).toBe(true); + expect(batch.processRouteDay).toHaveBeenCalledTimes(1); + expect(s.windowPhase).toBe('PRE_WINDOW'); + expect(s.windowOpensAt).not.toBeNull(); + }); + it('PAYMENT → conclude at paymentPhaseEndsAt settles due reservations', async () => { const s = baseSchedule({ windowPhase: 'PAYMENT', @@ -205,6 +225,22 @@ describe('BookingWindowService — window state machine', () => { expect(trainSchedulingService.finalizeSchedule).not.toHaveBeenCalled(); }); + it('conclude: waiting booking still fits → fresh pay window, back to PAYMENT, no reopen', async () => { + batch.isScheduleFull.mockResolvedValue(false); + batch.fillFromWaitingList.mockResolvedValue(2); + batch.hasLiveReservations.mockResolvedValue(true); + const s = baseSchedule({ + windowPhase: 'PAYMENT', + scheduledDepartureDate: new Date('2026-08-01T06:00:00.000Z'), + }); + const now = new Date('2026-07-01T02:30:05.000Z'); + await concludeCycle(s, now); + expect(batch.fillFromWaitingList).toHaveBeenCalledWith(scheduleId); + expect(s.windowPhase).toBe('PAYMENT'); + // Fresh pay window from `now`, not a reopen. + expect(s.paymentPhaseEndsAt).toEqual(new Date(now.getTime() + 60 * 60_000)); + }); + it('conclude: NOT full but NO cycle fits before departure → DONE', async () => { batch.isScheduleFull.mockResolvedValue(false); const s = baseSchedule({ diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts index d3405c951..f728afe6a 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts @@ -17,7 +17,12 @@ import { BookingBatchService } from './booking-batch.service'; import { BookingWindowGateway } from './booking-window.gateway'; import { TrainSchedulingService, effectiveWindowConfig } from './train-scheduling.service'; import { BATCH_TIMEZONE } from './booking-batch.constants'; -import { eatDay, nextCycleOpensAt, type OfficeHours } from './batch-window.util'; +import { + clampCloseToOfficeHours, + eatDay, + nextCycleOpensAt, + type OfficeHours, +} from './batch-window.util'; import { type BookingWindowConfig } from './booking-window.config'; /** @@ -297,6 +302,17 @@ export class BookingWindowService implements OnModuleInit { // (or allocating government) — skipped automatically for everyone who fits // is handled inside the fill (all fit → all reserved → all notified). await this.bookingBatchService.processRouteDay(routeDay); + // Batch reserved nobody (empty pool, or it allocated without pay windows): + // a PAYMENT phase with nobody to pay is a dead hour with the window shut. + // Conclude straight away — full → DONE, otherwise reopen per office hours. + if (!(await this.bookingBatchService.hasLiveReservations(schedule.id))) { + this.logger.log( + `[WINDOW] ${schedule.id} DOC_REVIEW→PAYMENT — batch reserved nothing; ` + + `skipping the empty payment phase and concluding the cycle`, + ); + await this.concludeCycle(schedule, cfg, now); + return true; + } this.logger.log( `[WINDOW] ${schedule.id} DOC_REVIEW→PAYMENT — batch ran; payment phase ` + `until ${paymentPhaseEndsAt.toISOString()}`, @@ -353,7 +369,10 @@ export class BookingWindowService implements OnModuleInit { return false; } - /** After settle: full → finalize + DONE; space left → reopen same day or close for the day. */ + /** + * After settle: full → finalize + DONE; waiting bookings still fit → fresh pay + * window, back to PAYMENT; otherwise reopen (office hours decide when) or DONE. + */ private async concludeCycle( schedule: TrainSchedule, cfg: BookingWindowConfig, @@ -386,6 +405,31 @@ export class BookingWindowService implements OnModuleInit { if (fresh) schedule.bookingWindowStatus = fresh.bookingWindowStatus; } + // The window reopens only once the waiting list is exhausted: a booking can + // still reach the pool mid-payment (late doc accept, consolidation partner), + // so retry the batch before reopening. Anything that fits gets a fresh pay + // window and the cycle stays in PAYMENT; check live reservations on THIS + // schedule because the day-level fill may have reserved onto a sibling. + // Waiting bookings that fit no train stay pooled and the window reopens. + const promoted = await this.bookingBatchService.fillFromWaitingList(schedule.id); + if ( + promoted > 0 && + (await this.bookingBatchService.hasLiveReservations(schedule.id)) + ) { + let paymentPhaseEndsAt = new Date( + now.getTime() + cfg.paymentWindowMinutes * 60_000, + ); + if (paymentPhaseEndsAt > schedule.scheduledDepartureDate) { + paymentPhaseEndsAt = schedule.scheduledDepartureDate; + } + await this.setPhase(schedule, { windowPhase: 'PAYMENT', paymentPhaseEndsAt }); + this.logger.log( + `[WINDOW] ${schedule.id} conclude → waiting list still had bookings that ` + + `fit — back in PAYMENT until ${paymentPhaseEndsAt.toISOString()}, no reopen yet`, + ); + return; + } + // Doc review + payment have already run, so the desk is ready to reopen NOW — // office hours decide whether that is this afternoon or tomorrow morning. Past // the last cycle before departure, nextCycleOpensAt returns null and we finish. @@ -413,6 +457,9 @@ export class BookingWindowService implements OnModuleInit { let nextClosesAt = new Date( nextOpensAt.getTime() + cfg.windowDurationHours * 3_600_000, ); + // Office hours end a running window early: never let the duration outlive + // the desk close (open 16:00, 3h, desk 8–17 → closes 17:00). + nextClosesAt = clampCloseToOfficeHours(nextOpensAt, nextClosesAt, officeHours); if (nextClosesAt > schedule.scheduledDepartureDate) { nextClosesAt = schedule.scheduledDepartureDate; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts index 0e252240b..2948b874d 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts @@ -95,11 +95,4 @@ export class UpdateTrainSchedulingGlobalRulesDto { @IsInt() @Min(1) paymentWindowMinutes?: number; - - @ApiPropertyOptional({ example: 90 }) - @IsOptional() - @Type(() => Number) - @IsInt() - @Min(1) - reopenDelayMinutes?: number; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts b/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts index ffa42fd7e..729063599 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts @@ -79,8 +79,4 @@ export class TrainSchedulingGlobalRules extends BaseEntity { @Column({ name: 'payment_window_minutes', type: 'int', default: 60 }) paymentWindowMinutes!: number; - - /** Delay after window close before the window reopens when the train is not yet full. */ - @Column({ name: 'reopen_delay_minutes', type: 'int', default: 90 }) - reopenDelayMinutes!: number; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.spec.ts index 54bf7a181..ff685d30c 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.spec.ts @@ -114,6 +114,35 @@ describe('fleet-plan.util', () => { expect(warnings.some((w) => w.includes('deferred'))).toBe(true); }); + it('names the booking and its per-type shortfall when the deferral carries a shortage', () => { + const warnings = summarizeFleetWarnings( + [], + [ + { + id: 'b1', + reference: 'BKG-1', + reason: 'No available NW6 wagon at the yard', + shortage: { + wagonTypeCodes: 'NW6', + wagonsNeeded: 2, + wagonsAvailable: 1, + wagonsShort: 1, + }, + }, + ], + ); + + expect( + warnings.some( + (w) => + w.includes('BKG-1') && + w.includes('2 × NW6') && + w.includes('only 1 available') && + w.includes('short 1'), + ), + ).toBe(true); + }); + it('counts wagons required per booking from container lines', () => { const booking = makeBooking('b1', { bookingContainers: [ diff --git a/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts index 6173da6b1..9cca4d213 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts @@ -17,10 +17,21 @@ export type FleetAvailabilityRow = { shortfall: number; }; +/** Per-booking wagon shortage: how many wagons of which type this booking still lacks. */ +export type BookingWagonShortage = { + /** Candidate wagon-type codes usable by the booking, joined ("NW6" or "NW6/CW3"). */ + wagonTypeCodes: string; + wagonsNeeded: number; + wagonsAvailable: number; + wagonsShort: number; +}; + export type DeferredBookingRow = { id: string; reference: string; reason: string; + /** Set when the deferral is a fleet-stock shortage (absent for config issues). */ + shortage?: BookingWagonShortage | null; }; export function sortBookingsForScheduling(bookings: Booking[]): Booking[] { @@ -156,6 +167,16 @@ export function summarizeFleetWarnings( ); } + // Name the bookings the shortage actually hits, with their own per-type counts, + // so staff know WHAT is held out — not just that the pool is short overall. + for (const row of deferred) { + if (!row.shortage) continue; + warnings.push( + `Booking ${row.reference} held out: needs ${row.shortage.wagonsNeeded} × ${row.shortage.wagonTypeCodes}, ` + + `only ${row.shortage.wagonsAvailable} available (short ${row.shortage.wagonsShort})`, + ); + } + if (deferred.length) { warnings.push( `${deferred.length} booking(s) deferred to next train due to insufficient fleet wagons`, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index a0b1591f3..26de90298 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -99,6 +99,7 @@ import { summarizeFleetWarnings, totalAssignedWeight, wagonsRequiredForBooking, + type BookingWagonShortage, type DeferredBookingRow, type FleetAvailabilityRow, } from './fleet-plan.util'; @@ -214,8 +215,6 @@ export function effectiveWindowConfig( : liveCfg.windowDurationHours, docReviewMinutes: liveCfg.docReviewMinutes, paymentWindowMinutes: liveCfg.paymentWindowMinutes, - reopenDelayMinutes: - schedule.ruleReopenDelayMinutes ?? liveCfg.reopenDelayMinutes, }; } @@ -251,6 +250,8 @@ export interface CompositionUnassignedBookingRow { yardWagonsAvailable: number; canAssign: boolean; blockReason: string | null; + /** Structured fleet shortage when the block is missing wagons (null otherwise). */ + shortage: BookingWagonShortage | null; } export interface UnassignedBookingsResponse { @@ -613,7 +614,6 @@ export class TrainSchedulingService { if (dto.windowDurationHours != null) row.windowDurationHours = dto.windowDurationHours; if (dto.docReviewMinutes != null) row.docReviewMinutes = dto.docReviewMinutes; if (dto.paymentWindowMinutes != null) row.paymentWindowMinutes = dto.paymentWindowMinutes; - if (dto.reopenDelayMinutes != null) row.reopenDelayMinutes = dto.reopenDelayMinutes; // The booking desk supports three shapes: a same-day range // (closeHour > openHour), a 24-hour desk (openHour === closeHour), and an @@ -702,7 +702,6 @@ export class TrainSchedulingService { // override changes them, so the derived snapshot delay stays consistent. docReviewMinutes: dto.docReviewMinutes ?? liveCfg.docReviewMinutes, paymentWindowMinutes: dto.paymentWindowMinutes ?? liveCfg.paymentWindowMinutes, - reopenDelayMinutes: liveCfg.reopenDelayMinutes, }; // Same-day, 24-hour, and overnight (openHour > closeHour) desks are all valid @@ -929,7 +928,6 @@ export class TrainSchedulingService { windowDurationHours: num(row?.windowDurationHours, 3), docReviewMinutes: num(row?.docReviewMinutes, 30), paymentWindowMinutes: num(row?.paymentWindowMinutes, 60), - reopenDelayMinutes: num(row?.reopenDelayMinutes, 90), }; } @@ -1079,6 +1077,20 @@ export class TrainSchedulingService { // getSchedulableRoute already rejected DOMESTIC (intercity). const direction = this.resolveRouteDirection(route); + // Direction-matched fixed number from the built train's typed pair. + // Legacy locomotive-picked schedules keep dispatch-time pool assignment + // (assignTrainNumber is idempotent, so both paths compose). + const pairTrainNumber = builtTrain + ? (direction === 'IMPORT' + ? builtTrain.importTrainNumber + : builtTrain.exportTrainNumber) ?? null + : null; + if (builtTrain && !pairTrainNumber) { + scheduleWarnings.push( + `Train ${builtTrain.code} has no ${direction === 'IMPORT' ? 'import' : 'export'} train number; a pool number will be assigned at dispatch`, + ); + } + const trainSet = await this.buildEmptyTrainSet( manager, lockedLocomotives, @@ -1174,6 +1186,7 @@ export class TrainSchedulingService { scheduledDepartureDate: departure, status: TrainScheduleStatusEnum.Draft, direction, + trainNumber: pairTrainNumber ?? undefined, maxWagons, ...windowFields, }), @@ -2684,7 +2697,23 @@ export class TrainSchedulingService { manager: EntityManager, schedule: TrainSchedule, ): Promise { - if (schedule.trainNumber) return schedule.trainNumber; + if (schedule.trainNumber) { + // Creation-assigned pair number: two live runs may never share a number, + // so block dispatch while another DISPATCHED schedule still carries it. + const clash = await manager + .getRepository(TrainSchedule) + .createQueryBuilder('s') + .where('s.status = :status', { status: TrainScheduleStatusEnum.Dispatched }) + .andWhere('s.train_number = :trainNumber', { trainNumber: schedule.trainNumber }) + .andWhere('s.id != :id', { id: schedule.id }) + .getOne(); + if (clash) { + throw new ConflictException( + `Train number ${schedule.trainNumber} is already out on ${clash.reference ?? clash.id}; it must arrive before this train dispatches`, + ); + } + return schedule.trainNumber; + } // Count container vs bulk wagons from the planned allocations. let containerWagons = 0; @@ -2705,17 +2734,38 @@ export class TrainSchedulingService { // Lock the set of currently-active numbered schedules so two concurrent // dispatches serialize and can't both claim the same lowest-free number. + // DRAFT/SCHEDULED are included because pair numbers are now assigned at + // creation and must be invisible to pool picks. const activeNumbered = await manager .getRepository(TrainSchedule) .createQueryBuilder('schedule') .setLock('pessimistic_write') - .where('schedule.status = :status', { status: TrainScheduleStatusEnum.Dispatched }) + .where('schedule.status IN (:...statuses)', { + statuses: [ + TrainScheduleStatusEnum.Draft, + TrainScheduleStatusEnum.Scheduled, + TrainScheduleStatusEnum.Dispatched, + ], + }) .andWhere('schedule.train_number IS NOT NULL') .getMany(); - const usedNumbers = activeNumbered - .map((s) => s.trainNumber) - .filter((n): n is string => Boolean(n)); + // Every typed train pair is reserved for its train — the pool may never + // hand one out, even when that train has no active schedule right now. + const pairRows: { n: string }[] = await manager.query( + `SELECT import_train_number AS n FROM freight.trains + WHERE deleted_at IS NULL AND import_train_number IS NOT NULL + UNION + SELECT export_train_number FROM freight.trains + WHERE deleted_at IS NULL AND export_train_number IS NOT NULL`, + ); + + const usedNumbers = [ + ...activeNumbered + .map((s) => s.trainNumber) + .filter((n): n is string => Boolean(n)), + ...pairRows.map((row) => row.n), + ]; const number = pickLowestFreeNumber(pool.numbers, usedNumbers); if (!number) { @@ -4624,6 +4674,8 @@ export class TrainSchedulingService { code: train.code, trainName: train.trainName ?? null, status: train.status, + importTrainNumber: train.importTrainNumber ?? null, + exportTrainNumber: train.exportTrainNumber ?? null, currentYardId: train.currentYardId ?? null, currentYard: train.currentYard ? { @@ -5522,7 +5574,7 @@ export class TrainSchedulingService { // Per-schedule booking-window rule snapshot — powers the "Booking window // settings" editor on the ops board (prefill + save one schedule's // override). docReview/payment are not snapshotted per schedule (only their - // sum, as reopenDelayMinutes), so the editor prefills them from live config. + // sum, as the frozen reopen gap), so the editor prefills them from live config. windowRule: { windowOpenHour: schedule.ruleWindowOpenHour ?? null, windowCloseHour: schedule.ruleWindowCloseHour ?? null, @@ -5530,7 +5582,6 @@ export class TrainSchedulingService { schedule.ruleWindowDurationHours != null ? Number(schedule.ruleWindowDurationHours) : null, - reopenDelayMinutes: schedule.ruleReopenDelayMinutes ?? null, importWindowLeadDays: schedule.ruleImportWindowLeadDays ?? null, exportBookingLeadHours: schedule.ruleExportBookingLeadHours ?? null, docReviewMinutes: windowCfg.docReviewMinutes, @@ -6158,6 +6209,7 @@ export class TrainSchedulingService { yardWagonsAvailable: number; canAssign: boolean; blockReason: string | null; + shortage: BookingWagonShortage | null; }> { if (!schedule.trainSet?.locomotive) { return { @@ -6166,6 +6218,7 @@ export class TrainSchedulingService { yardWagonsAvailable: 0, canAssign: false, blockReason: 'Schedule has no locomotive', + shortage: null, }; } @@ -6186,6 +6239,7 @@ export class TrainSchedulingService { yardWagonsAvailable: 0, canAssign: false, blockReason: 'No suitable wagon type found', + shortage: null, }; } @@ -6230,6 +6284,7 @@ export class TrainSchedulingService { yardWagonsAvailable, canAssign: false, blockReason: err instanceof Error ? err.message : 'Validation failed', + shortage: null, }; } @@ -6240,6 +6295,7 @@ export class TrainSchedulingService { yardWagonsAvailable, canAssign: false, blockReason: validation.violations[0] ?? 'Booking validation failed', + shortage: null, }; } @@ -6259,6 +6315,16 @@ export class TrainSchedulingService { deferred?.reason ?? yardShortfall ?? `Need ${wagonsRequired} ${requiredWagonTypeCode} wagon(s) at origin yard`, + shortage: + deferred?.shortage ?? + (yardShortfall + ? { + wagonTypeCodes: requiredWagonTypeCode, + wagonsNeeded: wagonsRequired, + wagonsAvailable: yardWagonsAvailable, + wagonsShort: Math.max(1, wagonsRequired - yardWagonsAvailable), + } + : null), }; } @@ -6277,6 +6343,7 @@ export class TrainSchedulingService { yardWagonsAvailable, canAssign: false, blockReason: missing.issue, + shortage: null, }; } } @@ -6287,9 +6354,49 @@ export class TrainSchedulingService { yardWagonsAvailable, canAssign: true, blockReason: null, + shortage: null, }; } + /** + * Fleet-shortage preflight for a PAID booking targeting a schedule: the + * structured per-type shortage this booking would hit if placed on top of the + * schedule's current wagon assignments, or null when it fits (or is blocked + * by something other than missing wagons — those keep the legacy link-then- + * fix-manually path). + */ + async previewPaidBookingWagonShortage( + scheduleId: string, + bookingId: string, + ): Promise { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule?.trainSet?.locomotive) return null; + if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) return null; + + const [booking] = await this.bookingsRepository.findByIdsForScheduling([bookingId]); + if (!booking) return null; + + const wagonAssignedIds = await this.getWagonAssignedBookingIds(scheduleId); + const fleetCounts = await this.countFleetAvailability( + schedule.originStationId, + scheduleId, + ); + const fleetByTypeId = new Map( + fleetCounts.map((row) => [ + row.wagonTypeId, + { code: row.wagonTypeCode, available: row.available }, + ]), + ); + + const assignability = await this.previewUnassignedBookingAssignability( + schedule, + wagonAssignedIds, + booking, + fleetByTypeId, + ); + return assignability.shortage; + } + /** Paid (or government) bookings that may be loaded onto wagons — excludes expired / awaiting payment. */ private isReadyToLoadBooking(booking: { status: string; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts new file mode 100644 index 000000000..157666f55 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts @@ -0,0 +1,133 @@ +import { Booking } from '../bookings/entities/booking.entity'; +import { WagonType } from '../wagon-types/entities/wagon-type.entity'; +import { planWagonsWithStock } from './wagon-plan-flex.util'; + +const nw6: WagonType = { + id: 'wt-nw6', + code: 'NW6', + name: 'Flat Wagon', + capacityTons: 70, + lengthMeters: 14, + supportedLoadTypes: ['CONTAINER'], + isActive: true, + supportsContainer: true, +} as WagonType; + +const cw3: WagonType = { + id: 'wt-cw3', + code: 'CW3', + name: 'Covered Wagon', + capacityTons: 60, + lengthMeters: 14, + supportedLoadTypes: ['BULK'], + isActive: true, + supportsContainer: false, +} as WagonType; + +const containerBooking = (id: string, quantity: number, wagonsRequired: number): Booking => + ({ + id, + reference: id, + freightType: 'CONTAINER', + cargoTotalWeightVgm: quantity * 25, + bookingContainers: [ + { + id: `${id}-line-0`, + containerTypeId: 'ct-1', + quantity, + wagonsRequired, + vgmPerUnitTons: 25, + }, + ], + }) as Booking; + +describe('planWagonsWithStock — shortage detail', () => { + it('defers with a structured per-type shortage when container stock runs out', () => { + const result = planWagonsWithStock({ + bookings: [containerBooking('BKG-1', 2, 1)], + allowed: { + byContainerTypeId: new Map([['ct-1', [nw6]]]), + byCargoTypeId: new Map(), + }, + stock: { + mode: 'YARD', + remainingByTypeId: new Map([[nw6.id, 0]]), + codesByTypeId: new Map([[nw6.id, nw6.code]]), + }, + }); + + expect(result.fitting).toHaveLength(0); + expect(result.deferred).toHaveLength(1); + const row = result.deferred[0]!; + expect(row.reference).toBe('BKG-1'); + expect(row.reason).toContain('No available NW6 wagon at the yard'); + expect(row.reason).toContain('short 1'); + expect(row.shortage).toEqual({ + wagonTypeCodes: 'NW6', + wagonsNeeded: 1, + wagonsAvailable: 0, + wagonsShort: 1, + }); + }); + + it('counts the stock the deferred booking actually saw, not its rolled-back usage', () => { + // Two wagons needed (2 × 40ft), one in stock: booking rolls back entirely, + // the shortage reports 1 available / 1 short. + const fortyFooter = containerBooking('BKG-2', 2, 2); + fortyFooter.bookingContainers![0]!.containerType = { + code: '40GP', + sizeFt: 40, + wagonsPerUnit: 1, + } as never; + const result = planWagonsWithStock({ + bookings: [fortyFooter], + allowed: { + byContainerTypeId: new Map([['ct-1', [nw6]]]), + byCargoTypeId: new Map(), + }, + stock: { + mode: 'YARD', + remainingByTypeId: new Map([[nw6.id, 1]]), + codesByTypeId: new Map([[nw6.id, nw6.code]]), + }, + }); + + expect(result.deferred).toHaveLength(1); + expect(result.deferred[0]?.shortage).toEqual({ + wagonTypeCodes: 'NW6', + wagonsNeeded: 2, + wagonsAvailable: 1, + wagonsShort: 1, + }); + // The rolled-back wagon is plannable again for later bookings. + expect(result.plan).toHaveLength(0); + }); + + it('leaves shortage unset for configuration problems', () => { + const bulkBooking = { + id: 'BKG-3', + reference: 'BKG-3', + freightType: 'BULK', + cargoTotalWeightVgm: 40, + cargoTypeId: 'cargo-1', + cargoType: { id: 'cargo-1', cargoTypeName: 'Fertilizer' }, + bookingContainers: [], + } as unknown as Booking; + + const result = planWagonsWithStock({ + bookings: [bulkBooking], + allowed: { + byContainerTypeId: new Map(), + byCargoTypeId: new Map(), // no wagon types configured → config issue + }, + stock: { + mode: 'YARD', + remainingByTypeId: new Map([[cw3.id, 5]]), + codesByTypeId: new Map([[cw3.id, cw3.code]]), + }, + }); + + expect(result.configIssues).toHaveLength(1); + expect(result.deferred[0]?.shortage).toBeNull(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts index b4b8657ba..ad4c29aa1 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts @@ -2,9 +2,14 @@ import { AllocationLoadType } from '@edr/types'; import { Booking } from '../bookings/entities/booking.entity'; import { WagonType } from '../wagon-types/entities/wagon-type.entity'; -import { sortBookingsForScheduling, type DeferredBookingRow } from './fleet-plan.util'; +import { + sortBookingsForScheduling, + type BookingWagonShortage, + type DeferredBookingRow, +} from './fleet-plan.util'; import { MAX_TEU_SLOTS_PER_WAGON, + containerWagonsForLines, expandBookingContainerUnits, roundTons, tareTonsOf, @@ -55,7 +60,12 @@ type OpenSlot = { freeCapacityTons: number; }; -type PlacementProblem = { kind: 'config' | 'stock'; message: string }; +type PlacementProblem = { + kind: 'config' | 'stock'; + message: string; + /** Wagon types the failing placement could have used (stock problems only). */ + candidates?: WagonType[]; +}; const slotFromWagonType = (wagonType: WagonType, kind: SlotLoadType): WagonPlanSlot => ({ sequenceNo: 0, // stamped at the end @@ -69,6 +79,38 @@ const slotFromWagonType = (wagonType: WagonType, kind: SlotLoadType): WagonPlanS slotLoadType: kind, }); +/** + * Booking-level shortage against the wagon types the failing placement could + * use: wagons the whole booking needs vs stock left for those types. Container + * counts are TEU-packed per booking; bulk divides by the largest candidate. + */ +const shortageFor = ( + booking: Booking, + candidates: WagonType[], + remaining: Map, +): BookingWagonShortage => { + const wagonsNeeded = + booking.freightType === 'BULK' + ? Math.max( + 1, + Math.ceil( + Number(booking.cargoTotalWeightVgm ?? 0) / + Math.max(1, ...candidates.map((wt) => Number(wt.capacityTons))), + ), + ) + : Math.max(1, containerWagonsForLines(booking.bookingContainers ?? [])); + const wagonsAvailable = candidates.reduce( + (sum, wt) => sum + (remaining.get(wt.id) ?? 0), + 0, + ); + return { + wagonTypeCodes: [...new Set(candidates.map((wt) => wt.code))].join('/'), + wagonsNeeded, + wagonsAvailable, + wagonsShort: Math.max(1, wagonsNeeded - wagonsAvailable), + }; +}; + const addAllocation = ( slot: WagonPlanSlot, bookingId: string, @@ -120,7 +162,9 @@ export function planWagonsWithStock(params: { cargoTypeId: string | null, ): OpenSlot | PlacementProblem => { const inStock = candidates.filter((wt) => (remaining.get(wt.id) ?? 0) > 0); - if (!inStock.length) return { kind: 'stock', message: noStockMessage(candidates) }; + if (!inStock.length) { + return { kind: 'stock', message: noStockMessage(candidates), candidates }; + } // Bulk favors the largest wagon (fewest wagons for the tonnage); containers // favor the deepest stock so the consist drains evenly. Ties keep config order. const chosen = [...inStock].sort((a, b) => @@ -271,7 +315,21 @@ export function planWagonsWithStock(params: { }); if (problem.kind === 'config') configIssues.add(problem.message); - deferred.push({ id: booking.id, reference: booking.reference, reason: problem.message }); + // remaining is rolled back here, so the shortage counts the stock this + // booking actually saw — not what its own partial placement consumed. + const shortage = + problem.kind === 'stock' && problem.candidates?.length + ? shortageFor(booking, problem.candidates, remaining) + : null; + deferred.push({ + id: booking.id, + reference: booking.reference, + reason: shortage + ? `${problem.message} — needs ${shortage.wagonsNeeded} × ${shortage.wagonTypeCodes}, ` + + `${shortage.wagonsAvailable} available (short ${shortage.wagonsShort})` + : problem.message, + shortage, + }); } return { diff --git a/apps/edr-freight-api/src/modules/trains/dto/build-train.dto.ts b/apps/edr-freight-api/src/modules/trains/dto/build-train.dto.ts index c44be8786..b4548edbb 100644 --- a/apps/edr-freight-api/src/modules/trains/dto/build-train.dto.ts +++ b/apps/edr-freight-api/src/modules/trains/dto/build-train.dto.ts @@ -5,6 +5,7 @@ import { IsOptional, IsString, IsUUID, + Matches, MaxLength, } from 'class-validator'; @@ -14,6 +15,22 @@ export class BuildTrainDto { @MaxLength(32) code!: string; + @ApiProperty({ example: '8001', description: 'EXPORT run number (odd, unique across trains)' }) + @IsString() + @MaxLength(20) + @Matches(/^\d*[13579]$/, { + message: 'Export train number must be numeric and odd (e.g. 8001)', + }) + exportTrainNumber!: string; + + @ApiProperty({ example: '8002', description: 'IMPORT run number (even, unique across trains)' }) + @IsString() + @MaxLength(20) + @Matches(/^\d*[02468]$/, { + message: 'Import train number must be numeric and even (e.g. 8002)', + }) + importTrainNumber!: string; + @ApiProperty({ format: 'uuid', description: 'Yard the train is built in' }) @IsUUID() currentYardId!: string; diff --git a/apps/edr-freight-api/src/modules/trains/entities/train.entity.ts b/apps/edr-freight-api/src/modules/trains/entities/train.entity.ts index 078b07a20..5b26c696a 100644 --- a/apps/edr-freight-api/src/modules/trains/entities/train.entity.ts +++ b/apps/edr-freight-api/src/modules/trains/entities/train.entity.ts @@ -32,6 +32,14 @@ export class Train extends BaseEntity { @Column({ name: 'notes', type: 'text', nullable: true }) notes?: string | null; + /** Fixed IMPORT (even) run number typed at build time; unique via partial index. */ + @Column({ name: 'import_train_number', type: 'varchar', length: 20, nullable: true }) + importTrainNumber!: string | null; + + /** Fixed EXPORT (odd) run number typed at build time; unique via partial index. */ + @Column({ name: 'export_train_number', type: 'varchar', length: 20, nullable: true }) + exportTrainNumber!: string | null; + // --- new required fields --- @Column({ name: 'train_number', type: 'varchar', length: 20, unique: true, nullable: true }) trainNumber?: string; diff --git a/apps/edr-freight-api/src/modules/trains/train-builder.service.ts b/apps/edr-freight-api/src/modules/trains/train-builder.service.ts index 16ff29d38..d385a424c 100644 --- a/apps/edr-freight-api/src/modules/trains/train-builder.service.ts +++ b/apps/edr-freight-api/src/modules/trains/train-builder.service.ts @@ -27,6 +27,15 @@ import { const round = (value: unknown) => Math.round((Number(value) || 0) * 100) / 100; +/** The one active (DRAFT/SCHEDULED/DISPATCHED) schedule surfaced per built train. */ +export interface ActiveScheduleRef { + id: string; + status: string; + reference: string | null; + direction: string | null; + trainNumber: string | null; +} + /** * Train Builder — assembles persistent fleet trains (code + 2+ locomotives + * ordered wagons, all in one yard) that scheduling can later reference as a @@ -56,6 +65,25 @@ export class TrainBuilderService { throw new ConflictException(`Train code ${code} is already in use`); } + // Friendly 409 before the partial unique indexes (the race-proof backstop): + // the typed pair may not collide with any train's pair or legacy number. + const importTrainNumber = dto.importTrainNumber.trim(); + const exportTrainNumber = dto.exportTrainNumber.trim(); + const numberClash: { code: string }[] = await manager.query( + `SELECT code FROM freight.trains + WHERE deleted_at IS NULL + AND (import_train_number IN ($1, $2) + OR export_train_number IN ($1, $2) + OR train_number IN ($1, $2)) + LIMIT 1`, + [importTrainNumber, exportTrainNumber], + ); + if (numberClash.length) { + throw new ConflictException( + `Train number ${importTrainNumber}/${exportTrainNumber} is already used by train ${numberClash[0].code}`, + ); + } + const yard = await manager.getRepository(Yard).findOne({ where: { id: dto.currentYardId } }); if (!yard) throw new NotFoundException(`Yard ${dto.currentYardId} not found`); @@ -76,6 +104,8 @@ export class TrainBuilderService { status: Freight.TrainStatus.Available, trainName: dto.trainName?.trim() || undefined, notes: dto.notes?.trim() || undefined, + importTrainNumber, + exportTrainNumber, }), ); @@ -117,12 +147,42 @@ export class TrainBuilderService { take, }); + const activeByTrain = await this.loadActiveScheduleByTrain(trains.map((t) => t.id)); + return { - items: trains.map((train) => this.mapSummary(train)), + items: trains.map((train) => this.mapSummary(train, activeByTrain.get(train.id) ?? null)), meta: buildPaginationMeta(total, page, pageSize), }; } + /** + * One ACTIVE schedule per train for the page (prefer the DISPATCHED run, + * else the earliest upcoming departure) — feeds the list's direction tint + * and in-use train number. + */ + private async loadActiveScheduleByTrain( + trainIds: string[], + ): Promise> { + if (!trainIds.length) return new Map(); + const rows: (ActiveScheduleRef & { trainId: string })[] = await this.dataSource.query( + `SELECT DISTINCT ON (tset.train_id) + tset.train_id AS "trainId", + ts.id, + ts.status, + ts.reference, + ts.direction, + ts.train_number AS "trainNumber" + FROM freight.train_schedules ts + JOIN freight.train_sets tset ON tset.id = ts.train_set_id + WHERE tset.train_id = ANY($1) + AND ts.deleted_at IS NULL + AND ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED') + ORDER BY tset.train_id, (ts.status = 'DISPATCHED') DESC, ts.scheduled_departure_date ASC`, + [trainIds], + ); + return new Map(rows.map(({ trainId, ...schedule }) => [trainId, schedule])); + } + /** Full consist: yard, ordered locomotives + wagons, totals vs. haul limits. */ async getComposition(id: string) { const train = await this.dataSource.getRepository(Train).findOne({ @@ -139,17 +199,17 @@ export class TrainBuilderService { }); if (!train) throw new NotFoundException(`Train ${id} not found`); - const schedules: { id: string; status: string; reference: string | null }[] = - await this.dataSource.query( - `SELECT ts.id, ts.status, ts.reference - FROM freight.train_schedules ts - JOIN freight.train_sets tset ON tset.id = ts.train_set_id - WHERE tset.train_id = $1 - AND ts.deleted_at IS NULL - AND ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED') - ORDER BY ts.scheduled_departure_date ASC`, - [id], - ); + const schedules: ActiveScheduleRef[] = await this.dataSource.query( + `SELECT ts.id, ts.status, ts.reference, ts.direction, + ts.train_number AS "trainNumber" + FROM freight.train_schedules ts + JOIN freight.train_sets tset ON tset.id = ts.train_set_id + WHERE tset.train_id = $1 + AND ts.deleted_at IS NULL + AND ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED') + ORDER BY ts.scheduled_departure_date ASC`, + [id], + ); const locomotives = (train.locomotives ?? []) .filter((link) => link.locomotive) @@ -212,6 +272,8 @@ export class TrainBuilderService { code: train.code, trainName: train.trainName ?? null, status: train.status, + importTrainNumber: train.importTrainNumber ?? null, + exportTrainNumber: train.exportTrainNumber ?? null, notes: train.notes ?? null, createdAt: train.createdAt, currentYard: train.currentYard @@ -407,7 +469,7 @@ export class TrainBuilderService { // ---------------------------------------------------------------- internals - private mapSummary(train: Train) { + private mapSummary(train: Train, activeSchedule: ActiveScheduleRef | null) { const locomotives = [...(train.locomotives ?? [])] .sort((a, b) => a.sequenceNo - b.sequenceNo) .map((link) => link.locomotive) @@ -421,6 +483,9 @@ export class TrainBuilderService { code: train.code, trainName: train.trainName ?? null, status: train.status, + importTrainNumber: train.importTrainNumber ?? null, + exportTrainNumber: train.exportTrainNumber ?? null, + activeSchedule, createdAt: train.createdAt, currentYard: train.currentYard ? { id: train.currentYard.id, code: train.currentYard.code, label: train.currentYard.label } diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlUpcomingWindowsSection.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlUpcomingWindowsSection.tsx index c35537d9e..ba9e01402 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlUpcomingWindowsSection.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlUpcomingWindowsSection.tsx @@ -17,7 +17,8 @@ import { ChevronLeft, ChevronRight, } from "lucide-react"; -import { CountdownTimer } from "@edr/ui-common"; +import { CountdownTimer, bookingWindowUiState } from "@edr/ui-common"; +import type { BookingWindowUiKind } from "@edr/ui-common"; import { useBookingWindowSocket } from "@/features/bookingWindows/useBookingWindowSocket"; import { api } from "@/services/api"; @@ -81,51 +82,40 @@ function windowLabel(w: WindowRow): string { } /** - * The countdown for whichever phase the window is currently in, mirroring the - * customer portal. `expiredText` names the NEXT step so a deadline that lapses - * between refetches announces what comes next rather than the bare "Expired". + * The countdown for the window's UI state, mirroring the customer portal. + * Derived from the SAME state as the badge (`bookingWindowUiState`) so they + * can never contradict — a full train shows no ticking countdown. + * `expiredText` names the NEXT step so a deadline that lapses between + * refetches announces what comes next rather than the bare "Expired". */ +const COUNTDOWN_TEXT: Partial< + Record +> = { + PRE_WINDOW: { label: "Opens in", expiredText: "Opening now…" }, + OPEN: { label: "Closes in", expiredText: "Review starting…" }, + DOC_REVIEW: { label: "Doc review ends in", expiredText: "Payment starting…" }, + PAYMENT: { label: "Payment ends in", expiredText: "Closing…" }, +}; + function phaseCountdown( w: WindowRow, ): { label: string; deadline: string; expiredText: string } | null { - switch (w.windowPhase) { - case "PRE_WINDOW": - return w.windowOpensAt - ? { - label: "Opens in", - deadline: w.windowOpensAt, - expiredText: "Opening now…", - } - : null; - case "OPEN": - return w.windowClosesAt - ? { - label: "Closes in", - deadline: w.windowClosesAt, - expiredText: "Review starting…", - } - : null; - case "DOC_REVIEW": - return w.docReviewEndsAt - ? { - label: "Doc review ends in", - deadline: w.docReviewEndsAt, - expiredText: "Payment starting…", - } - : null; - case "PAYMENT": - return w.paymentPhaseEndsAt - ? { - label: "Payment ends in", - deadline: w.paymentPhaseEndsAt, - expiredText: "Closing…", - } - : null; - default: - return null; - } + const state = bookingWindowUiState(w); + const text = COUNTDOWN_TEXT[state.kind]; + if (!state.countdownTo || !text) return null; + return { ...text, deadline: state.countdownTo }; } +/** Badge label + Mantine color per UI state — same state the countdown uses. */ +const KIND_BADGE: Record = { + OPEN: { label: "Open now", color: "edr-green" }, + FULL: { label: "Train full", color: "red" }, + PRE_WINDOW: { label: "Opens soon", color: "yellow" }, + DOC_REVIEW: { label: "Doc review", color: "gray" }, + PAYMENT: { label: "Payment", color: "gray" }, + CLOSED: { label: "Closed", color: "gray" }, +}; + /** * Drop windows the SERVER considers finished — keyed off windowPhase, never the * client clock. The server query already excludes terminal / departed rows; @@ -139,7 +129,9 @@ function isPast(w: WindowRow): boolean { function WindowCard({ w }: { w: WindowRow }) { const cd = phaseCountdown(w); - const open = w.isOpenNow; + const state = bookingWindowUiState(w); + const badge = KIND_BADGE[state.kind]; + const open = state.isBookable; const isImport = w.direction === "IMPORT"; return ( @@ -177,13 +169,11 @@ function WindowCard({ w }: { w: WindowRow }) { )} - {open - ? "Open now" - : (w.windowPhase ?? w.bookingWindowStatus).replace(/_/g, " ")} + {badge.label} diff --git a/apps/edr-freight-web/backoffice/src/components/trainBuilder/BuildTrainModal.tsx b/apps/edr-freight-web/backoffice/src/components/trainBuilder/BuildTrainModal.tsx index 74f897728..6eb2ab9a8 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainBuilder/BuildTrainModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainBuilder/BuildTrainModal.tsx @@ -26,6 +26,10 @@ const parseError = (error: unknown, fallback: string) => { return fallback; }; +// Run-number parity carries the trade direction: odd = export, even = import. +const isOddNumber = (value: string) => /^\d*[13579]$/.test(value.trim()); +const isEvenNumber = (value: string) => /^\d*[02468]$/.test(value.trim()); + /** * Step one of the Train Builder: give the train its operator code, pick the * yard it is being assembled in, and couple at least two locomotives from that @@ -34,6 +38,8 @@ const parseError = (error: unknown, fallback: string) => { export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrainModalProps) { const { toast } = useToast(); const [code, setCode] = useState(""); + const [exportTrainNumber, setExportTrainNumber] = useState(""); + const [importTrainNumber, setImportTrainNumber] = useState(""); const [trainName, setTrainName] = useState(""); const [yardId, setYardId] = useState(""); const [locomotiveIds, setLocomotiveIds] = useState([]); @@ -57,6 +63,8 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain useEffect(() => { if (!opened) { setCode(""); + setExportTrainNumber(""); + setImportTrainNumber(""); setTrainName(""); setYardId(""); setLocomotiveIds([]); @@ -72,9 +80,18 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain }); return; } + if (!isOddNumber(exportTrainNumber) || !isEvenNumber(importTrainNumber)) { + toast({ + title: "Enter both run numbers — export must be odd (e.g. 8001), import even (e.g. 8002)", + variant: "destructive", + }); + return; + } try { const composition = await build.mutateAsync({ code: code.trim(), + exportTrainNumber: exportTrainNumber.trim(), + importTrainNumber: importTrainNumber.trim(), currentYardId: yardId, locomotiveIds, ...(trainName.trim() ? { trainName: trainName.trim() } : {}), @@ -126,6 +143,34 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain maxLength={100} /> + + setExportTrainNumber(e.currentTarget.value)} + maxLength={20} + error={ + exportTrainNumber && !isOddNumber(exportTrainNumber) + ? "Must be numeric and odd" + : undefined + } + /> + setImportTrainNumber(e.currentTarget.value)} + maxLength={20} + error={ + importTrainNumber && !isEvenNumber(importTrainNumber) + ? "Must be numeric and even" + : undefined + } + /> + + + + + + + + {/* Reassign modal */} {status ? : null} + {waitingForWagon ? ( + + + Waiting for wagon + + + ) : null} {loadingStatus ? ( - {trainStatusLabel(composition.status)} - + + + {trainStatusLabel(composition.status)} + + + IMP {composition.importTrainNumber ?? "—"} + + + EXP {composition.exportTrainNumber ?? "—"} + + } action={ @@ -289,6 +301,16 @@ export default function TrainBuilderDetailPage() { {schedule.reference ?? schedule.id.slice(0, 8)} + {schedule.trainNumber ? ( + + {schedule.trainNumber} + + ) : null} + {schedule.direction ? ( + + {schedule.direction} + + ) : null} {schedule.status} diff --git a/apps/edr-freight-web/backoffice/src/pages/trainBuilder/TrainBuilderListPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainBuilder/TrainBuilderListPage.tsx index 53bd1a818..de6b96aa5 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainBuilder/TrainBuilderListPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainBuilder/TrainBuilderListPage.tsx @@ -27,7 +27,12 @@ import { useNavigate } from "react-router-dom"; import { KpiStrip, PageContainer, PageHeader } from "@/components/page"; import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles"; import BuildTrainModal from "@/components/trainBuilder/BuildTrainModal"; -import { trainStatusColor, trainStatusLabel } from "@/components/trainBuilder/trainStatus"; +import { + directionColor, + directionRowStyle, + trainStatusColor, + trainStatusLabel, +} from "@/components/trainBuilder/trainStatus"; import { api } from "@/services/api"; import type { BuiltTrainListFilters, @@ -146,6 +151,32 @@ export default function TrainBuilderListPage() { ), }, + { + id: "numbers", + header: "Train No.", + meta: { headerClassName, cellClassName }, + cell: ({ row }) => { + const active = row.original.activeSchedule; + return ( + + {active?.trainNumber ? ( + + + {active.trainNumber} + + + {active.direction ?? "—"} + + + ) : null} + + IMP {row.original.importTrainNumber ?? "—"} · EXP{" "} + {row.original.exportTrainNumber ?? "—"} + + + ); + }, + }, { id: "yard", header: "Yard", @@ -293,6 +324,7 @@ export default function TrainBuilderListPage() { data={trains} status={tableStatus} onRowClick={(train) => navigate(`/dashboard/train-builder/${train.id}`)} + rowStyle={(train) => directionRowStyle(train.activeSchedule?.direction)} error={ trainsQuery.isError ? { diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx index 2fa5b2ee0..671b351b7 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx @@ -244,6 +244,21 @@ export default function TrainScheduleV2DetailPage() { return []; }, [previewResult?.wagonPlan, schedule?.trainSet?.wagons]); + // EXPORT schedules render the consist back-to-front (the train turns around + // for the return run) — DISPLAY ONLY: stored sequenceNos, allocations, + // documents, and the adjust-consist / placement flows keep the as-built order. + const isExportDisplay = schedule?.direction === "EXPORT"; + const displayWagonPlanOriented = useMemo( + () => (isExportDisplay ? [...displayWagonPlan].reverse() : displayWagonPlan), + [displayWagonPlan, isExportDisplay], + ); + const diagramWagons = useMemo(() => { + const source = schedule?.trainSet?.wagons?.length + ? schedule.trainSet.wagons + : displayWagonPlan; + return isExportDisplay ? [...source].reverse() : source; + }, [schedule?.trainSet?.wagons, displayWagonPlan, isExportDisplay]); + const runPreview = useCallback( async (options?: { silent?: boolean; advanceStep?: boolean }) => { if (!schedule || !scheduleId) return null; @@ -683,7 +698,12 @@ export default function TrainScheduleV2DetailPage() { fleetAvailability={previewResult?.fleetAvailability} deferredBookings={previewResult?.deferredBookings} /> - + {isExportDisplay && displayWagonPlanOriented.length ? ( + + Shown rear-first (export direction) — positions keep their original numbers. + + ) : null} + {canEditBookings && (previewResult || displayWagonPlan.length) ? ( {!hasContainerStep ? ( @@ -760,15 +780,16 @@ export default function TrainScheduleV2DetailPage() { + {isExportDisplay && diagramWagons.length ? ( + + Shown rear-first (export direction) — positions keep their original numbers. + + ) : null} ) : null} + {schedule.train ? ( + + Train {schedule.train.code} + + ) : null} { - // Schedules created from the Train Builder carry the train code; - // legacy rows fall back to their locomotive set. + // Schedules created from the Train Builder show the direction-matched + // run number first (falling back to the train code); legacy rows fall + // back to their locomotive set. if (row.original.train) { + const subtitle = [row.original.trainNumber ? row.original.train.code : null, + row.original.train.trainName] + .filter(Boolean) + .join(" · "); return ( - {row.original.train.code} + {row.original.trainNumber ?? row.original.train.code} - {row.original.train.trainName ? ( + {subtitle ? ( - {row.original.train.trainName} + {subtitle} ) : null} @@ -758,14 +763,21 @@ export default function TrainScheduleV2ListPage() { label="Train" description="A built train (Train Builder) runs this departure with its locomotives and wagons" placeholder={routeId ? "Select a train" : "Select a route first"} - data={(trainsQuery.data ?? []).map((train) => ({ - value: train.id, - label: `${train.code}${train.trainName ? ` — ${train.trainName}` : ""} · ${ - train.locomotives.length - } locos · ${train.wagonCount} wagons${train.atOriginYard ? "" : " · not at origin yard"}${ - train.futureScheduleCount ? ` · ${train.futureScheduleCount} future run(s)` : "" - }`, - }))} + data={(trainsQuery.data ?? []).map((train) => { + // Route direction picks which of the train's typed pair this run uses. + const runNumber = + selectedRoute?.direction === "IMPORT" + ? train.importTrainNumber + : train.exportTrainNumber; + return { + value: train.id, + label: `${train.code}${train.trainName ? ` — ${train.trainName}` : ""}${ + runNumber ? ` · runs as ${runNumber}` : "" + } · ${train.locomotives.length} locos · ${train.wagonCount} wagons${ + train.atOriginYard ? "" : " · not at origin yard" + }${train.futureScheduleCount ? ` · ${train.futureScheduleCount} future run(s)` : ""}`, + }; + })} value={trainId || null} onChange={(v) => setTrainId(v ?? "")} searchable diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainSchedulingGlobalRulesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainSchedulingGlobalRulesPage.tsx index 0186d5acb..fb02133f2 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainSchedulingGlobalRulesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainSchedulingGlobalRulesPage.tsx @@ -62,7 +62,6 @@ export default function TrainSchedulingGlobalRulesPage() { "windowDurationHours", "docReviewMinutes", "paymentWindowMinutes", - "reopenDelayMinutes", ]; const payload: Partial> = {}; for (const key of fields) { @@ -261,17 +260,6 @@ export default function TrainSchedulingGlobalRulesPage() { min={1} disabled={loading} /> - - setForm((current) => ({ ...current, reopenDelayMinutes: value })) - } - min={1} - disabled={loading} - />