remove reopen delay minutes from global rules and update related types

- Removed the  field from  and related components.
- Updated  to reflect the removal of the reopen delay input field.
- Modified  to include new train number fields:  and .
- Added  interface to manage active schedules with trade direction.
- Introduced  interface to track wagon shortages in bookings.
- Updated  logic to ensure consistent UI state representation.
- Created migrations to drop the  column and add  and  columns to the  table.
- Added tests for the new booking window display logic and wagon planning functionality.
This commit is contained in:
Marshal
2026-07-15 09:13:02 +00:00
parent 9be7f356f0
commit 11771e5f92
39 changed files with 1731 additions and 269 deletions

View File

@@ -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<void> {
await queryRunner.query(`
ALTER TABLE freight.train_scheduling_global_rules
DROP COLUMN IF EXISTS reopen_delay_minutes;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_scheduling_global_rules
ADD COLUMN IF NOT EXISTS reopen_delay_minutes integer NOT NULL DEFAULT 90;
`);
}
}

View File

@@ -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<void> {
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<void> {
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;
`);
}
}

View File

@@ -86,6 +86,7 @@ export const SCHEDULING_STATUSES = [
SchedulingStatus.Eligible, SchedulingStatus.Eligible,
SchedulingStatus.Scheduled, SchedulingStatus.Scheduled,
SchedulingStatus.Dispatched, SchedulingStatus.Dispatched,
SchedulingStatus.WaitingForWagon,
] as const; ] as const;
export type BookingSchedulingStatus = (typeof SCHEDULING_STATUSES)[number]; export type BookingSchedulingStatus = (typeof SCHEDULING_STATUSES)[number];

View File

@@ -109,17 +109,26 @@ describe('computeImportWindowTimes — first-window open respects office hours',
}); });
it('caps the close at departure', () => { it('caps the close at departure', () => {
// Opens now (05 Jul 12:00 EAT); a 24h duration would close 06 Jul 12:00 EAT, // Round-the-clock desk (no desk-close cap in play). Opens now (05 Jul 12:00
// past the 06 Jul 08:00 departure → clamped to departure. // 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 now = new Date('2026-07-05T09:00:00.000Z');
const { windowClosesAt } = computeImportWindowTimes( const { windowClosesAt } = computeImportWindowTimes(
departure, departure,
{ ...bounded, windowDurationHours: 24 }, { ...bounded, windowOpenHour: 8, windowCloseHour: 8, windowDurationHours: 24 },
now, now,
); );
expect(windowClosesAt.toISOString()).toBe(departure.toISOString()); 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)', () => { describe('overnight desk (open > close, wraps past midnight)', () => {
// Desk open 08:00, closes 05:00 next morning — open across midnight. // Desk open 08:00, closes 05:00 next morning — open across midnight.
const overnight = { ...bounded, windowOpenHour: 8, windowCloseHour: 5 }; 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)', () => { describe('batch-window board windows (config-driven booking cycles)', () => {
// Default rules: open 08:00 EAT, desk shuts 17:00, 3 days before departure, // 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 = { const cfg: BoardWindowConfig = {
importWindowLeadDays: 3, importWindowLeadDays: 3,
windowOpenHour: 8, windowOpenHour: 8,
windowCloseHour: 17, windowCloseHour: 17,
windowDurationHours: 3, windowDurationHours: 3,
reopenDelayMinutes: 90, reopenGapMinutes: 90,
exportBookingLeadHours: 24, 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'); 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 departure = new Date('2026-06-08T11:00:00.000Z');
const windows = listConfigBookingWindows('IMPORT', departure, cfg); const windows = listConfigBookingWindows('IMPORT', departure, cfg);
// cycle 1: 08:0011:00; reopen +90m → cycle 2 opens 12:30 EAT, same day // cycle 1: 08:0011: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); 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', () => { it('export: single FCFS window exportBookingLeadHours before departure', () => {
const departure = new Date('2026-06-08T11:00:00.000Z'); const departure = new Date('2026-06-08T11:00:00.000Z');
const windows = listConfigBookingWindows('EXPORT', departure, cfg); const windows = listConfigBookingWindows('EXPORT', departure, cfg);

View File

@@ -223,6 +223,49 @@ export function nextCycleOpensAt(
return opensAt.getTime() < departure.getTime() ? opensAt : null; 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 817 → 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 { export interface InitialWindowTimes {
windowOpensAt: Date; windowOpensAt: Date;
windowClosesAt: Date; windowClosesAt: Date;
@@ -243,7 +286,8 @@ export interface InitialWindowTimes {
* • `now` before openHour that EAT day → opens at openHour that morning * • `now` before openHour that EAT day → opens at openHour that morning
* • `now` at/after closeHour → desk shut; opens openHour next 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( export function computeImportWindowTimes(
departure: Date, departure: Date,
@@ -276,6 +320,10 @@ export function computeImportWindowTimes(
} }
let closesAt = new Date(opensAt.getTime() + cfg.windowDurationHours * 3_600_000); 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()) { if (closesAt.getTime() > departure.getTime()) {
closesAt = departure; closesAt = departure;
} }
@@ -381,10 +429,11 @@ export function listBatchWindowsForBookings(
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Board-display windows: the REAL booking-window cycles derived from the // Board-display windows: the REAL booking-window cycles derived from the
// train_scheduling_global_rules config (window open hour, lead days, duration, // schedule's frozen window rule (open/close hour, lead days, duration, reopen
// reopen delay) — NOT a fixed clock grid. Import shows each booking-window cycle // gap = doc review + payment) — NOT a fixed clock grid. Import shows each
// (opens at windowOpenHour EAT, lasts windowDurationHours, reopens after // booking-window cycle (opens at windowOpenHour EAT, lasts windowDurationHours
// reopenDelayMinutes until departure). Export shows the single FCFS lead window. // 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. */ /** 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. */ /** EAT hour the daily booking desk shuts; equals windowOpenHour for a 24h desk. */
windowCloseHour: number; windowCloseHour: number;
windowDurationHours: 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; exportBookingLeadHours: number;
} }
@@ -435,10 +487,11 @@ function boardWindowFromInterval(start: Date, end: Date): BoardWindow {
* The real booking-window cycles for a schedule, straight from config. * The real booking-window cycles for a schedule, straight from config.
* *
* IMPORT: first window opens at `windowOpenHour` EAT on `departure importWindowLeadDays` * IMPORT: first window opens at `windowOpenHour` EAT on `departure importWindowLeadDays`
* for `windowDurationHours`; if the train isn't full it reopens `reopenDelayMinutes` * for `windowDurationHours` (cut short by the desk close hour); if the train isn't
* after each close, on the same booking day, until departure. This mirrors * full it reopens `reopenGapMinutes` (doc review + payment) after each close,
* `computeImportWindowTimes` + `concludeCycle`'s reopen math so the board shows the * honouring office hours, until departure. This mirrors `computeImportWindowTimes`
* exact windows the engine runs. * + `concludeCycle`'s reopen math so the board shows the exact windows the engine
* runs.
* EXPORT: a single FCFS window from `departure exportBookingLeadHours` to departure, * 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 * with the open shifted to the next desk opening when it lands outside office hours
* (same math as `computeExportWindowTimes`). * (same math as `computeExportWindowTimes`).
@@ -464,7 +517,7 @@ export function listConfigBookingWindows(
const durationMs = cfg.windowDurationHours * 3_600_000; const durationMs = cfg.windowDurationHours * 3_600_000;
// Post-close gap before the next cycle opens (doc review + payment), subject // Post-close gap before the next cycle opens (doc review + payment), subject
// to office hours below. // to office hours below.
const reopenMs = cfg.reopenDelayMinutes * 60_000; const reopenMs = cfg.reopenGapMinutes * 60_000;
const officeHours: OfficeHours = { const officeHours: OfficeHours = {
windowOpenHour: cfg.windowOpenHour, windowOpenHour: cfg.windowOpenHour,
windowCloseHour: cfg.windowCloseHour, windowCloseHour: cfg.windowCloseHour,
@@ -484,6 +537,7 @@ export function listConfigBookingWindows(
for (let cycle = 0; cycle < maxCycles; cycle += 1) { for (let cycle = 0; cycle < maxCycles; cycle += 1) {
if (opensAt.getTime() >= departure.getTime()) break; if (opensAt.getTime() >= departure.getTime()) break;
let closesAt = new Date(opensAt.getTime() + durationMs); let closesAt = new Date(opensAt.getTime() + durationMs);
closesAt = clampCloseToOfficeHours(opensAt, closesAt, officeHours);
if (closesAt.getTime() > departure.getTime()) closesAt = departure; if (closesAt.getTime() > departure.getTime()) closesAt = departure;
windows.push(boardWindowFromInterval(opensAt, closesAt)); windows.push(boardWindowFromInterval(opensAt, closesAt));

View File

@@ -37,6 +37,7 @@ describe('BookingBatchService — PAID reconcile', () => {
}; };
let trainSchedulingService: { let trainSchedulingService: {
tryAutoWagonAllocation: jest.Mock; tryAutoWagonAllocation: jest.Mock;
previewPaidBookingWagonShortage: jest.Mock;
getBookableSchedules: jest.Mock; getBookableSchedules: jest.Mock;
getWindowConfig: jest.Mock; getWindowConfig: jest.Mock;
}; };
@@ -87,6 +88,8 @@ describe('BookingBatchService — PAID reconcile', () => {
issues: [], issues: [],
violations: [], violations: [],
}), }),
// No shortage by default — paid bookings link as before.
previewPaidBookingWagonShortage: jest.fn().mockResolvedValue(null),
getBookableSchedules: jest.fn().mockResolvedValue([]), getBookableSchedules: jest.fn().mockResolvedValue([]),
getWindowConfig: jest.fn().mockResolvedValue({ getWindowConfig: jest.fn().mockResolvedValue({
importWindowLeadDays: 3, importWindowLeadDays: 3,
@@ -96,7 +99,6 @@ describe('BookingBatchService — PAID reconcile', () => {
windowDurationHours: 3, windowDurationHours: 3,
docReviewMinutes: 30, docReviewMinutes: 30,
paymentWindowMinutes: 60, paymentWindowMinutes: 60,
reopenDelayMinutes: 90,
}), }),
}; };
@@ -169,6 +171,38 @@ describe('BookingBatchService — PAID reconcile', () => {
expect(trainSchedulingService.tryAutoWagonAllocation).toHaveBeenCalledTimes(2); 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 () => { it('processSchedule reconciles PAID-unlinked before wagon allocation', async () => {
const fillSpy = jest.spyOn(service, 'fillSchedule').mockResolvedValue(0); const fillSpy = jest.spyOn(service, 'fillSchedule').mockResolvedValue(0);
const settleSpy = jest.spyOn(service, 'settleDueReservations').mockResolvedValue(undefined); const settleSpy = jest.spyOn(service, 'settleDueReservations').mockResolvedValue(undefined);

View File

@@ -497,6 +497,7 @@ export class BookingBatchService implements OnModuleInit {
const linked = const linked =
await this.trainScheduleBookingsRepository.existsForBooking(bookingId); await this.trainScheduleBookingsRepository.existsForBooking(bookingId);
if (!linked) { if (!linked) {
if (await this.holdIfWagonShort(booking.trainScheduleId, booking)) return;
await this.allocate(booking.trainScheduleId, booking, "paid"); await this.allocate(booking.trainScheduleId, booking, "paid");
this.logger.log( this.logger.log(
`Linked PAID booking ${booking.reference ?? bookingId} to schedule ${booking.trainScheduleId}`, `Linked PAID booking ${booking.reference ?? bookingId} to schedule ${booking.trainScheduleId}`,
@@ -783,6 +784,9 @@ export class BookingBatchService implements OnModuleInit {
const unlinked = const unlinked =
await this.bookingsRepository.findPaidUnlinkedForSchedule(scheduleId); await this.bookingsRepository.findPaidUnlinkedForSchedule(scheduleId);
for (const booking of unlinked) { 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"); await this.allocate(scheduleId, booking, "paid");
this.logger.log( this.logger.log(
`Reconciled PAID booking ${booking.reference ?? booking.id} → schedule ${scheduleId}`, `Reconciled PAID booking ${booking.reference ?? booking.id} → schedule ${scheduleId}`,
@@ -1050,7 +1054,11 @@ export class BookingBatchService implements OnModuleInit {
s.ruleWindowDurationHours, s.ruleWindowDurationHours,
liveCfg.windowDurationHours, 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( importWindowLeadDays: num(
s.ruleImportWindowLeadDays, s.ruleImportWindowLeadDays,
liveCfg.importWindowLeadDays, liveCfg.importWindowLeadDays,
@@ -1894,7 +1902,9 @@ export class BookingBatchService implements OnModuleInit {
done.add(booking.id); done.add(booking.id);
if (isPaid(booking)) { if (isPaid(booking)) {
await this.allocate(scheduleId, booking, "paid"); if (!(await this.holdIfWagonShort(scheduleId, booking))) {
await this.allocate(scheduleId, booking, "paid");
}
anySettled = true; anySettled = true;
} else if (isExpired(booking)) { } else if (isExpired(booking)) {
await this.expire(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<number> {
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. * Settle, then keep promoting the waiting list until the train can take no more.
* Returns whether anything settled. * Returns whether anything settled.
@@ -2050,7 +2083,9 @@ export class BookingBatchService implements OnModuleInit {
await this.dataSource await this.dataSource
.getRepository(Booking) .getRepository(Booking)
.update(bookingId, { paymentStatus: "PAID" }); .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( const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
booking.trainScheduleId, booking.trainScheduleId,
@@ -2112,7 +2147,12 @@ export class BookingBatchService implements OnModuleInit {
await manager.getRepository(Booking).update(bookingId, { await manager.getRepository(Booking).update(bookingId, {
trainScheduleId: newScheduleId, trainScheduleId: newScheduleId,
status: restoredStatus, 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, paymentDeadline: null,
selectedForBatchAt: null, selectedForBatchAt: null,
} as never); } as never);
@@ -2254,6 +2294,50 @@ export class BookingBatchService implements OnModuleInit {
} }
/** Allocate a booking to the schedule's train (creates the TrainScheduleBooking link). */ /** 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<boolean> {
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( private async allocate(
scheduleId: string, scheduleId: string,
booking: Booking, booking: Booking,
@@ -2358,7 +2442,9 @@ export class BookingBatchService implements OnModuleInit {
`[BATCH] expire skipped for ${booking.reference} — payment already ` + `[BATCH] expire skipped for ${booking.reference} — payment already ` +
`landed; allocating on schedule ${paidScheduleId} instead`, `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; return;
} }
} }

View File

@@ -19,8 +19,6 @@ export interface BookingWindowConfig {
/** Max staff document-review time after the window closes. */ /** Max staff document-review time after the window closes. */
docReviewMinutes: number; docReviewMinutes: number;
paymentWindowMinutes: 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. */ /** Window phase lifecycle for the one-booking-day import cycle. NULL on legacy/DOMESTIC schedules. */

View File

@@ -20,6 +20,7 @@ describe('BookingWindowService — window state machine', () => {
hasLiveReservations: jest.Mock; hasLiveReservations: jest.Mock;
refreshWindowStatus: jest.Mock; refreshWindowStatus: jest.Mock;
expireLeftoverDayPool: jest.Mock; expireLeftoverDayPool: jest.Mock;
fillFromWaitingList: jest.Mock;
}; };
let trainSchedulesRepository: { findById: jest.Mock; findAll: jest.Mock }; let trainSchedulesRepository: { findById: jest.Mock; findAll: jest.Mock };
let trainSchedulingService: { finalizeSchedule: jest.Mock; getWindowConfig: jest.Mock }; let trainSchedulingService: { finalizeSchedule: jest.Mock; getWindowConfig: jest.Mock };
@@ -33,7 +34,6 @@ describe('BookingWindowService — window state machine', () => {
windowDurationHours: 1, windowDurationHours: 1,
docReviewMinutes: 30, docReviewMinutes: 30,
paymentWindowMinutes: 60, paymentWindowMinutes: 60,
reopenDelayMinutes: 0,
}; };
const baseSchedule = (over: Partial<TrainSchedule>): TrainSchedule => const baseSchedule = (over: Partial<TrainSchedule>): TrainSchedule =>
@@ -75,6 +75,8 @@ describe('BookingWindowService — window state machine', () => {
hasLiveReservations: jest.fn().mockResolvedValue(false), hasLiveReservations: jest.fn().mockResolvedValue(false),
refreshWindowStatus: jest.fn().mockResolvedValue(undefined), refreshWindowStatus: jest.fn().mockResolvedValue(undefined),
expireLeftoverDayPool: jest.fn().mockResolvedValue(0), expireLeftoverDayPool: jest.fn().mockResolvedValue(0),
// No waiting booking fits by default, so conclude proceeds to reopen/DONE.
fillFromWaitingList: jest.fn().mockResolvedValue(0),
}; };
trainSchedulesRepository = { trainSchedulesRepository = {
findById: jest.fn().mockResolvedValue(null), 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 () => { 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({ const s = baseSchedule({
windowPhase: 'DOC_REVIEW', windowPhase: 'DOC_REVIEW',
docReviewEndsAt: new Date('2026-07-01T01:30:00.000Z'), 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 () => { it('DOC_REVIEW → PAYMENT also fires when staff finished review early (docReviewCompletedAt)', async () => {
batch.hasLiveReservations.mockResolvedValue(true);
const s = baseSchedule({ const s = baseSchedule({
windowPhase: 'DOC_REVIEW', windowPhase: 'DOC_REVIEW',
docReviewEndsAt: new Date('2026-07-01T05:00:00.000Z'), // far future 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'); 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 () => { it('PAYMENT → conclude at paymentPhaseEndsAt settles due reservations', async () => {
const s = baseSchedule({ const s = baseSchedule({
windowPhase: 'PAYMENT', windowPhase: 'PAYMENT',
@@ -205,6 +225,22 @@ describe('BookingWindowService — window state machine', () => {
expect(trainSchedulingService.finalizeSchedule).not.toHaveBeenCalled(); 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 () => { it('conclude: NOT full but NO cycle fits before departure → DONE', async () => {
batch.isScheduleFull.mockResolvedValue(false); batch.isScheduleFull.mockResolvedValue(false);
const s = baseSchedule({ const s = baseSchedule({

View File

@@ -17,7 +17,12 @@ import { BookingBatchService } from './booking-batch.service';
import { BookingWindowGateway } from './booking-window.gateway'; import { BookingWindowGateway } from './booking-window.gateway';
import { TrainSchedulingService, effectiveWindowConfig } from './train-scheduling.service'; import { TrainSchedulingService, effectiveWindowConfig } from './train-scheduling.service';
import { BATCH_TIMEZONE } from './booking-batch.constants'; 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'; 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 // (or allocating government) — skipped automatically for everyone who fits
// is handled inside the fill (all fit → all reserved → all notified). // is handled inside the fill (all fit → all reserved → all notified).
await this.bookingBatchService.processRouteDay(routeDay); 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( this.logger.log(
`[WINDOW] ${schedule.id} DOC_REVIEW→PAYMENT — batch ran; payment phase ` + `[WINDOW] ${schedule.id} DOC_REVIEW→PAYMENT — batch ran; payment phase ` +
`until ${paymentPhaseEndsAt.toISOString()}`, `until ${paymentPhaseEndsAt.toISOString()}`,
@@ -353,7 +369,10 @@ export class BookingWindowService implements OnModuleInit {
return false; 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( private async concludeCycle(
schedule: TrainSchedule, schedule: TrainSchedule,
cfg: BookingWindowConfig, cfg: BookingWindowConfig,
@@ -386,6 +405,31 @@ export class BookingWindowService implements OnModuleInit {
if (fresh) schedule.bookingWindowStatus = fresh.bookingWindowStatus; 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 — // 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 // office hours decide whether that is this afternoon or tomorrow morning. Past
// the last cycle before departure, nextCycleOpensAt returns null and we finish. // the last cycle before departure, nextCycleOpensAt returns null and we finish.
@@ -413,6 +457,9 @@ export class BookingWindowService implements OnModuleInit {
let nextClosesAt = new Date( let nextClosesAt = new Date(
nextOpensAt.getTime() + cfg.windowDurationHours * 3_600_000, 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 817 → closes 17:00).
nextClosesAt = clampCloseToOfficeHours(nextOpensAt, nextClosesAt, officeHours);
if (nextClosesAt > schedule.scheduledDepartureDate) { if (nextClosesAt > schedule.scheduledDepartureDate) {
nextClosesAt = schedule.scheduledDepartureDate; nextClosesAt = schedule.scheduledDepartureDate;
} }

View File

@@ -95,11 +95,4 @@ export class UpdateTrainSchedulingGlobalRulesDto {
@IsInt() @IsInt()
@Min(1) @Min(1)
paymentWindowMinutes?: number; paymentWindowMinutes?: number;
@ApiPropertyOptional({ example: 90 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
reopenDelayMinutes?: number;
} }

View File

@@ -79,8 +79,4 @@ export class TrainSchedulingGlobalRules extends BaseEntity {
@Column({ name: 'payment_window_minutes', type: 'int', default: 60 }) @Column({ name: 'payment_window_minutes', type: 'int', default: 60 })
paymentWindowMinutes!: number; 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;
} }

View File

@@ -114,6 +114,35 @@ describe('fleet-plan.util', () => {
expect(warnings.some((w) => w.includes('deferred'))).toBe(true); 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', () => { it('counts wagons required per booking from container lines', () => {
const booking = makeBooking('b1', { const booking = makeBooking('b1', {
bookingContainers: [ bookingContainers: [

View File

@@ -17,10 +17,21 @@ export type FleetAvailabilityRow = {
shortfall: number; 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 = { export type DeferredBookingRow = {
id: string; id: string;
reference: string; reference: string;
reason: 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[] { 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) { if (deferred.length) {
warnings.push( warnings.push(
`${deferred.length} booking(s) deferred to next train due to insufficient fleet wagons`, `${deferred.length} booking(s) deferred to next train due to insufficient fleet wagons`,

View File

@@ -99,6 +99,7 @@ import {
summarizeFleetWarnings, summarizeFleetWarnings,
totalAssignedWeight, totalAssignedWeight,
wagonsRequiredForBooking, wagonsRequiredForBooking,
type BookingWagonShortage,
type DeferredBookingRow, type DeferredBookingRow,
type FleetAvailabilityRow, type FleetAvailabilityRow,
} from './fleet-plan.util'; } from './fleet-plan.util';
@@ -214,8 +215,6 @@ export function effectiveWindowConfig(
: liveCfg.windowDurationHours, : liveCfg.windowDurationHours,
docReviewMinutes: liveCfg.docReviewMinutes, docReviewMinutes: liveCfg.docReviewMinutes,
paymentWindowMinutes: liveCfg.paymentWindowMinutes, paymentWindowMinutes: liveCfg.paymentWindowMinutes,
reopenDelayMinutes:
schedule.ruleReopenDelayMinutes ?? liveCfg.reopenDelayMinutes,
}; };
} }
@@ -251,6 +250,8 @@ export interface CompositionUnassignedBookingRow {
yardWagonsAvailable: number; yardWagonsAvailable: number;
canAssign: boolean; canAssign: boolean;
blockReason: string | null; blockReason: string | null;
/** Structured fleet shortage when the block is missing wagons (null otherwise). */
shortage: BookingWagonShortage | null;
} }
export interface UnassignedBookingsResponse { export interface UnassignedBookingsResponse {
@@ -613,7 +614,6 @@ export class TrainSchedulingService {
if (dto.windowDurationHours != null) row.windowDurationHours = dto.windowDurationHours; if (dto.windowDurationHours != null) row.windowDurationHours = dto.windowDurationHours;
if (dto.docReviewMinutes != null) row.docReviewMinutes = dto.docReviewMinutes; if (dto.docReviewMinutes != null) row.docReviewMinutes = dto.docReviewMinutes;
if (dto.paymentWindowMinutes != null) row.paymentWindowMinutes = dto.paymentWindowMinutes; 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 // The booking desk supports three shapes: a same-day range
// (closeHour > openHour), a 24-hour desk (openHour === closeHour), and an // (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. // override changes them, so the derived snapshot delay stays consistent.
docReviewMinutes: dto.docReviewMinutes ?? liveCfg.docReviewMinutes, docReviewMinutes: dto.docReviewMinutes ?? liveCfg.docReviewMinutes,
paymentWindowMinutes: dto.paymentWindowMinutes ?? liveCfg.paymentWindowMinutes, paymentWindowMinutes: dto.paymentWindowMinutes ?? liveCfg.paymentWindowMinutes,
reopenDelayMinutes: liveCfg.reopenDelayMinutes,
}; };
// Same-day, 24-hour, and overnight (openHour > closeHour) desks are all valid // Same-day, 24-hour, and overnight (openHour > closeHour) desks are all valid
@@ -929,7 +928,6 @@ export class TrainSchedulingService {
windowDurationHours: num(row?.windowDurationHours, 3), windowDurationHours: num(row?.windowDurationHours, 3),
docReviewMinutes: num(row?.docReviewMinutes, 30), docReviewMinutes: num(row?.docReviewMinutes, 30),
paymentWindowMinutes: num(row?.paymentWindowMinutes, 60), paymentWindowMinutes: num(row?.paymentWindowMinutes, 60),
reopenDelayMinutes: num(row?.reopenDelayMinutes, 90),
}; };
} }
@@ -1079,6 +1077,20 @@ export class TrainSchedulingService {
// getSchedulableRoute already rejected DOMESTIC (intercity). // getSchedulableRoute already rejected DOMESTIC (intercity).
const direction = this.resolveRouteDirection(route); 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( const trainSet = await this.buildEmptyTrainSet(
manager, manager,
lockedLocomotives, lockedLocomotives,
@@ -1174,6 +1186,7 @@ export class TrainSchedulingService {
scheduledDepartureDate: departure, scheduledDepartureDate: departure,
status: TrainScheduleStatusEnum.Draft, status: TrainScheduleStatusEnum.Draft,
direction, direction,
trainNumber: pairTrainNumber ?? undefined,
maxWagons, maxWagons,
...windowFields, ...windowFields,
}), }),
@@ -2684,7 +2697,23 @@ export class TrainSchedulingService {
manager: EntityManager, manager: EntityManager,
schedule: TrainSchedule, schedule: TrainSchedule,
): Promise<string> { ): Promise<string> {
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. // Count container vs bulk wagons from the planned allocations.
let containerWagons = 0; let containerWagons = 0;
@@ -2705,17 +2734,38 @@ export class TrainSchedulingService {
// Lock the set of currently-active numbered schedules so two concurrent // Lock the set of currently-active numbered schedules so two concurrent
// dispatches serialize and can't both claim the same lowest-free number. // 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 const activeNumbered = await manager
.getRepository(TrainSchedule) .getRepository(TrainSchedule)
.createQueryBuilder('schedule') .createQueryBuilder('schedule')
.setLock('pessimistic_write') .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') .andWhere('schedule.train_number IS NOT NULL')
.getMany(); .getMany();
const usedNumbers = activeNumbered // Every typed train pair is reserved for its train — the pool may never
.map((s) => s.trainNumber) // hand one out, even when that train has no active schedule right now.
.filter((n): n is string => Boolean(n)); 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); const number = pickLowestFreeNumber(pool.numbers, usedNumbers);
if (!number) { if (!number) {
@@ -4624,6 +4674,8 @@ export class TrainSchedulingService {
code: train.code, code: train.code,
trainName: train.trainName ?? null, trainName: train.trainName ?? null,
status: train.status, status: train.status,
importTrainNumber: train.importTrainNumber ?? null,
exportTrainNumber: train.exportTrainNumber ?? null,
currentYardId: train.currentYardId ?? null, currentYardId: train.currentYardId ?? null,
currentYard: train.currentYard currentYard: train.currentYard
? { ? {
@@ -5522,7 +5574,7 @@ export class TrainSchedulingService {
// Per-schedule booking-window rule snapshot — powers the "Booking window // Per-schedule booking-window rule snapshot — powers the "Booking window
// settings" editor on the ops board (prefill + save one schedule's // settings" editor on the ops board (prefill + save one schedule's
// override). docReview/payment are not snapshotted per schedule (only their // 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: { windowRule: {
windowOpenHour: schedule.ruleWindowOpenHour ?? null, windowOpenHour: schedule.ruleWindowOpenHour ?? null,
windowCloseHour: schedule.ruleWindowCloseHour ?? null, windowCloseHour: schedule.ruleWindowCloseHour ?? null,
@@ -5530,7 +5582,6 @@ export class TrainSchedulingService {
schedule.ruleWindowDurationHours != null schedule.ruleWindowDurationHours != null
? Number(schedule.ruleWindowDurationHours) ? Number(schedule.ruleWindowDurationHours)
: null, : null,
reopenDelayMinutes: schedule.ruleReopenDelayMinutes ?? null,
importWindowLeadDays: schedule.ruleImportWindowLeadDays ?? null, importWindowLeadDays: schedule.ruleImportWindowLeadDays ?? null,
exportBookingLeadHours: schedule.ruleExportBookingLeadHours ?? null, exportBookingLeadHours: schedule.ruleExportBookingLeadHours ?? null,
docReviewMinutes: windowCfg.docReviewMinutes, docReviewMinutes: windowCfg.docReviewMinutes,
@@ -6158,6 +6209,7 @@ export class TrainSchedulingService {
yardWagonsAvailable: number; yardWagonsAvailable: number;
canAssign: boolean; canAssign: boolean;
blockReason: string | null; blockReason: string | null;
shortage: BookingWagonShortage | null;
}> { }> {
if (!schedule.trainSet?.locomotive) { if (!schedule.trainSet?.locomotive) {
return { return {
@@ -6166,6 +6218,7 @@ export class TrainSchedulingService {
yardWagonsAvailable: 0, yardWagonsAvailable: 0,
canAssign: false, canAssign: false,
blockReason: 'Schedule has no locomotive', blockReason: 'Schedule has no locomotive',
shortage: null,
}; };
} }
@@ -6186,6 +6239,7 @@ export class TrainSchedulingService {
yardWagonsAvailable: 0, yardWagonsAvailable: 0,
canAssign: false, canAssign: false,
blockReason: 'No suitable wagon type found', blockReason: 'No suitable wagon type found',
shortage: null,
}; };
} }
@@ -6230,6 +6284,7 @@ export class TrainSchedulingService {
yardWagonsAvailable, yardWagonsAvailable,
canAssign: false, canAssign: false,
blockReason: err instanceof Error ? err.message : 'Validation failed', blockReason: err instanceof Error ? err.message : 'Validation failed',
shortage: null,
}; };
} }
@@ -6240,6 +6295,7 @@ export class TrainSchedulingService {
yardWagonsAvailable, yardWagonsAvailable,
canAssign: false, canAssign: false,
blockReason: validation.violations[0] ?? 'Booking validation failed', blockReason: validation.violations[0] ?? 'Booking validation failed',
shortage: null,
}; };
} }
@@ -6259,6 +6315,16 @@ export class TrainSchedulingService {
deferred?.reason ?? deferred?.reason ??
yardShortfall ?? yardShortfall ??
`Need ${wagonsRequired} ${requiredWagonTypeCode} wagon(s) at origin yard`, `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, yardWagonsAvailable,
canAssign: false, canAssign: false,
blockReason: missing.issue, blockReason: missing.issue,
shortage: null,
}; };
} }
} }
@@ -6287,9 +6354,49 @@ export class TrainSchedulingService {
yardWagonsAvailable, yardWagonsAvailable,
canAssign: true, canAssign: true,
blockReason: null, 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<BookingWagonShortage | null> {
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. */ /** Paid (or government) bookings that may be loaded onto wagons — excludes expired / awaiting payment. */
private isReadyToLoadBooking(booking: { private isReadyToLoadBooking(booking: {
status: string; status: string;

View File

@@ -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();
});
});

View File

@@ -2,9 +2,14 @@ import { AllocationLoadType } from '@edr/types';
import { Booking } from '../bookings/entities/booking.entity'; import { Booking } from '../bookings/entities/booking.entity';
import { WagonType } from '../wagon-types/entities/wagon-type.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 { import {
MAX_TEU_SLOTS_PER_WAGON, MAX_TEU_SLOTS_PER_WAGON,
containerWagonsForLines,
expandBookingContainerUnits, expandBookingContainerUnits,
roundTons, roundTons,
tareTonsOf, tareTonsOf,
@@ -55,7 +60,12 @@ type OpenSlot = {
freeCapacityTons: number; 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 => ({ const slotFromWagonType = (wagonType: WagonType, kind: SlotLoadType): WagonPlanSlot => ({
sequenceNo: 0, // stamped at the end sequenceNo: 0, // stamped at the end
@@ -69,6 +79,38 @@ const slotFromWagonType = (wagonType: WagonType, kind: SlotLoadType): WagonPlanS
slotLoadType: kind, 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<string, number>,
): 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 = ( const addAllocation = (
slot: WagonPlanSlot, slot: WagonPlanSlot,
bookingId: string, bookingId: string,
@@ -120,7 +162,9 @@ export function planWagonsWithStock(params: {
cargoTypeId: string | null, cargoTypeId: string | null,
): OpenSlot | PlacementProblem => { ): OpenSlot | PlacementProblem => {
const inStock = candidates.filter((wt) => (remaining.get(wt.id) ?? 0) > 0); 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 // Bulk favors the largest wagon (fewest wagons for the tonnage); containers
// favor the deepest stock so the consist drains evenly. Ties keep config order. // favor the deepest stock so the consist drains evenly. Ties keep config order.
const chosen = [...inStock].sort((a, b) => const chosen = [...inStock].sort((a, b) =>
@@ -271,7 +315,21 @@ export function planWagonsWithStock(params: {
}); });
if (problem.kind === 'config') configIssues.add(problem.message); 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 { return {

View File

@@ -5,6 +5,7 @@ import {
IsOptional, IsOptional,
IsString, IsString,
IsUUID, IsUUID,
Matches,
MaxLength, MaxLength,
} from 'class-validator'; } from 'class-validator';
@@ -14,6 +15,22 @@ export class BuildTrainDto {
@MaxLength(32) @MaxLength(32)
code!: string; 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' }) @ApiProperty({ format: 'uuid', description: 'Yard the train is built in' })
@IsUUID() @IsUUID()
currentYardId!: string; currentYardId!: string;

View File

@@ -32,6 +32,14 @@ export class Train extends BaseEntity {
@Column({ name: 'notes', type: 'text', nullable: true }) @Column({ name: 'notes', type: 'text', nullable: true })
notes?: string | null; 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 --- // --- new required fields ---
@Column({ name: 'train_number', type: 'varchar', length: 20, unique: true, nullable: true }) @Column({ name: 'train_number', type: 'varchar', length: 20, unique: true, nullable: true })
trainNumber?: string; trainNumber?: string;

View File

@@ -27,6 +27,15 @@ import {
const round = (value: unknown) => Math.round((Number(value) || 0) * 100) / 100; 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 + * Train Builder — assembles persistent fleet trains (code + 2+ locomotives +
* ordered wagons, all in one yard) that scheduling can later reference as a * 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`); 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 } }); const yard = await manager.getRepository(Yard).findOne({ where: { id: dto.currentYardId } });
if (!yard) throw new NotFoundException(`Yard ${dto.currentYardId} not found`); if (!yard) throw new NotFoundException(`Yard ${dto.currentYardId} not found`);
@@ -76,6 +104,8 @@ export class TrainBuilderService {
status: Freight.TrainStatus.Available, status: Freight.TrainStatus.Available,
trainName: dto.trainName?.trim() || undefined, trainName: dto.trainName?.trim() || undefined,
notes: dto.notes?.trim() || undefined, notes: dto.notes?.trim() || undefined,
importTrainNumber,
exportTrainNumber,
}), }),
); );
@@ -117,12 +147,42 @@ export class TrainBuilderService {
take, take,
}); });
const activeByTrain = await this.loadActiveScheduleByTrain(trains.map((t) => t.id));
return { 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), 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<Map<string, ActiveScheduleRef>> {
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. */ /** Full consist: yard, ordered locomotives + wagons, totals vs. haul limits. */
async getComposition(id: string) { async getComposition(id: string) {
const train = await this.dataSource.getRepository(Train).findOne({ const train = await this.dataSource.getRepository(Train).findOne({
@@ -139,17 +199,17 @@ export class TrainBuilderService {
}); });
if (!train) throw new NotFoundException(`Train ${id} not found`); if (!train) throw new NotFoundException(`Train ${id} not found`);
const schedules: { id: string; status: string; reference: string | null }[] = const schedules: ActiveScheduleRef[] = await this.dataSource.query(
await this.dataSource.query( `SELECT ts.id, ts.status, ts.reference, ts.direction,
`SELECT ts.id, ts.status, ts.reference ts.train_number AS "trainNumber"
FROM freight.train_schedules ts FROM freight.train_schedules ts
JOIN freight.train_sets tset ON tset.id = ts.train_set_id JOIN freight.train_sets tset ON tset.id = ts.train_set_id
WHERE tset.train_id = $1 WHERE tset.train_id = $1
AND ts.deleted_at IS NULL AND ts.deleted_at IS NULL
AND ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED') AND ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED')
ORDER BY ts.scheduled_departure_date ASC`, ORDER BY ts.scheduled_departure_date ASC`,
[id], [id],
); );
const locomotives = (train.locomotives ?? []) const locomotives = (train.locomotives ?? [])
.filter((link) => link.locomotive) .filter((link) => link.locomotive)
@@ -212,6 +272,8 @@ export class TrainBuilderService {
code: train.code, code: train.code,
trainName: train.trainName ?? null, trainName: train.trainName ?? null,
status: train.status, status: train.status,
importTrainNumber: train.importTrainNumber ?? null,
exportTrainNumber: train.exportTrainNumber ?? null,
notes: train.notes ?? null, notes: train.notes ?? null,
createdAt: train.createdAt, createdAt: train.createdAt,
currentYard: train.currentYard currentYard: train.currentYard
@@ -407,7 +469,7 @@ export class TrainBuilderService {
// ---------------------------------------------------------------- internals // ---------------------------------------------------------------- internals
private mapSummary(train: Train) { private mapSummary(train: Train, activeSchedule: ActiveScheduleRef | null) {
const locomotives = [...(train.locomotives ?? [])] const locomotives = [...(train.locomotives ?? [])]
.sort((a, b) => a.sequenceNo - b.sequenceNo) .sort((a, b) => a.sequenceNo - b.sequenceNo)
.map((link) => link.locomotive) .map((link) => link.locomotive)
@@ -421,6 +483,9 @@ export class TrainBuilderService {
code: train.code, code: train.code,
trainName: train.trainName ?? null, trainName: train.trainName ?? null,
status: train.status, status: train.status,
importTrainNumber: train.importTrainNumber ?? null,
exportTrainNumber: train.exportTrainNumber ?? null,
activeSchedule,
createdAt: train.createdAt, createdAt: train.createdAt,
currentYard: train.currentYard currentYard: train.currentYard
? { id: train.currentYard.id, code: train.currentYard.code, label: train.currentYard.label } ? { id: train.currentYard.id, code: train.currentYard.code, label: train.currentYard.label }

View File

@@ -17,7 +17,8 @@ import {
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
} from "lucide-react"; } 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 { useBookingWindowSocket } from "@/features/bookingWindows/useBookingWindowSocket";
import { api } from "@/services/api"; 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 * The countdown for the window's UI state, mirroring the customer portal.
* customer portal. `expiredText` names the NEXT step so a deadline that lapses * Derived from the SAME state as the badge (`bookingWindowUiState`) so they
* between refetches announces what comes next rather than the bare "Expired". * 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<BookingWindowUiKind, { label: string; expiredText: string }>
> = {
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( function phaseCountdown(
w: WindowRow, w: WindowRow,
): { label: string; deadline: string; expiredText: string } | null { ): { label: string; deadline: string; expiredText: string } | null {
switch (w.windowPhase) { const state = bookingWindowUiState(w);
case "PRE_WINDOW": const text = COUNTDOWN_TEXT[state.kind];
return w.windowOpensAt if (!state.countdownTo || !text) return null;
? { return { ...text, deadline: state.countdownTo };
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;
}
} }
/** Badge label + Mantine color per UI state — same state the countdown uses. */
const KIND_BADGE: Record<BookingWindowUiKind, { label: string; color: string }> = {
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 * Drop windows the SERVER considers finished — keyed off windowPhase, never the
* client clock. The server query already excludes terminal / departed rows; * client clock. The server query already excludes terminal / departed rows;
@@ -139,7 +129,9 @@ function isPast(w: WindowRow): boolean {
function WindowCard({ w }: { w: WindowRow }) { function WindowCard({ w }: { w: WindowRow }) {
const cd = phaseCountdown(w); 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"; const isImport = w.direction === "IMPORT";
return ( return (
@@ -177,13 +169,11 @@ function WindowCard({ w }: { w: WindowRow }) {
)} )}
<Badge <Badge
variant={open ? "filled" : "light"} variant={open ? "filled" : "light"}
color={open ? "edr-green" : "gray"} color={badge.color}
radius="sm" radius="sm"
size="sm" size="sm"
> >
{open {badge.label}
? "Open now"
: (w.windowPhase ?? w.bookingWindowStatus).replace(/_/g, " ")}
</Badge> </Badge>
</Group> </Group>

View File

@@ -26,6 +26,10 @@ const parseError = (error: unknown, fallback: string) => {
return fallback; 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 * 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 * 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) { export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrainModalProps) {
const { toast } = useToast(); const { toast } = useToast();
const [code, setCode] = useState(""); const [code, setCode] = useState("");
const [exportTrainNumber, setExportTrainNumber] = useState("");
const [importTrainNumber, setImportTrainNumber] = useState("");
const [trainName, setTrainName] = useState(""); const [trainName, setTrainName] = useState("");
const [yardId, setYardId] = useState(""); const [yardId, setYardId] = useState("");
const [locomotiveIds, setLocomotiveIds] = useState<string[]>([]); const [locomotiveIds, setLocomotiveIds] = useState<string[]>([]);
@@ -57,6 +63,8 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain
useEffect(() => { useEffect(() => {
if (!opened) { if (!opened) {
setCode(""); setCode("");
setExportTrainNumber("");
setImportTrainNumber("");
setTrainName(""); setTrainName("");
setYardId(""); setYardId("");
setLocomotiveIds([]); setLocomotiveIds([]);
@@ -72,9 +80,18 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain
}); });
return; 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 { try {
const composition = await build.mutateAsync({ const composition = await build.mutateAsync({
code: code.trim(), code: code.trim(),
exportTrainNumber: exportTrainNumber.trim(),
importTrainNumber: importTrainNumber.trim(),
currentYardId: yardId, currentYardId: yardId,
locomotiveIds, locomotiveIds,
...(trainName.trim() ? { trainName: trainName.trim() } : {}), ...(trainName.trim() ? { trainName: trainName.trim() } : {}),
@@ -126,6 +143,34 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain
maxLength={100} maxLength={100}
/> />
</Group> </Group>
<Group grow>
<TextInput
label="Export train number"
description="Odd — Ethiopia → Djibouti runs"
placeholder="e.g. 8001"
value={exportTrainNumber}
onChange={(e) => setExportTrainNumber(e.currentTarget.value)}
maxLength={20}
error={
exportTrainNumber && !isOddNumber(exportTrainNumber)
? "Must be numeric and odd"
: undefined
}
/>
<TextInput
label="Import train number"
description="Even — Djibouti → Ethiopia runs"
placeholder="e.g. 8002"
value={importTrainNumber}
onChange={(e) => setImportTrainNumber(e.currentTarget.value)}
maxLength={20}
error={
importTrainNumber && !isEvenNumber(importTrainNumber)
? "Must be numeric and even"
: undefined
}
/>
</Group>
<Select <Select
label="Build yard" label="Build yard"
placeholder="Select the yard the train is assembled in" placeholder="Select the yard the train is assembled in"

View File

@@ -1,3 +1,5 @@
import type { CSSProperties } from "react";
import type { BuiltTrainStatus } from "@/services/trainBuilder.service"; import type { BuiltTrainStatus } from "@/services/trainBuilder.service";
/** Badge color per built-train lifecycle status (Mantine palette keys). */ /** Badge color per built-train lifecycle status (Mantine palette keys). */
@@ -23,3 +25,17 @@ export const trainStatusLabel = (status: BuiltTrainStatus | string): string =>
.toLowerCase() .toLowerCase()
.replace(/_/g, " ") .replace(/_/g, " ")
.replace(/^\w/, (c) => c.toUpperCase()); .replace(/^\w/, (c) => c.toUpperCase());
/** Badge color per trade direction (Mantine palette keys). */
export const directionColor = (direction?: string | null): string =>
direction === "IMPORT" ? "blue" : direction === "EXPORT" ? "orange" : "gray";
/** Row background tint for a train whose active schedule runs in `direction`. */
export const directionRowStyle = (
direction?: string | null,
): CSSProperties | undefined =>
direction === "IMPORT"
? { backgroundColor: "var(--mantine-color-blue-0)" }
: direction === "EXPORT"
? { backgroundColor: "var(--mantine-color-orange-0)" }
: undefined;

View File

@@ -152,6 +152,9 @@ export function ScheduleWorkspacePanel({
// ── Mutations (reuse the existing endpoints) ─────────────────────────────── // ── Mutations (reuse the existing endpoints) ───────────────────────────────
const assign = useMutation(api.trainScheduling.assignBookings.mutationOptions()); const assign = useMutation(api.trainScheduling.assignBookings.mutationOptions());
const assignUnassigned = useMutation(
api.trainScheduling.assignUnassignedBooking.mutationOptions(),
);
const unassign = useMutation(api.trainScheduling.unassignBooking.mutationOptions()); const unassign = useMutation(api.trainScheduling.unassignBooking.mutationOptions());
const setLoading = useMutation( const setLoading = useMutation(
api.trainScheduling.setLoadingStatus.mutationOptions(), api.trainScheduling.setLoadingStatus.mutationOptions(),
@@ -166,6 +169,12 @@ export function ScheduleWorkspacePanel({
const [moveBookingId, setMoveBookingId] = useState<string | null>(null); const [moveBookingId, setMoveBookingId] = useState<string | null>(null);
const [moveTarget, setMoveTarget] = useState<string | null>(null); const [moveTarget, setMoveTarget] = useState<string | null>(null);
// Pool → pick a same-day schedule with free wagons and place the booking there.
const [poolAssign, setPoolAssign] = useState<{ id: string; reference: string } | null>(
null,
);
const [poolTarget, setPoolTarget] = useState<string | null>(null);
const { data: targets } = useQuery( const { data: targets } = useQuery(
api.trainScheduling.bookableSchedules.queryOptions({ api.trainScheduling.bookableSchedules.queryOptions({
input: { input: {
@@ -190,6 +199,22 @@ export function ScheduleWorkspacePanel({
[targets, schedule.id], [targets, schedule.id],
); );
// Every schedule departing on THIS train's day (EAT) — a paid booking waiting
// for a wagon may board any of them, so staff pick whichever has wagons free.
const eatDayOf = (iso: string) =>
new Date(iso).toLocaleDateString("en-CA", { timeZone: "Africa/Addis_Ababa" });
const sameDayOptions = useMemo(() => {
const day = eatDayOf(schedule.scheduledDepartureDate);
return (targets ?? [])
.filter((s) => eatDayOf(s.scheduleDate) === day)
.map((s) => ({
value: s.id,
label: `${s.id === schedule.id ? "This train · " : ""}${
s.routeName ?? `${s.origin}${s.destination}`
} · ${s.remainingWagons}/${s.maxWagons} wagons free`,
}));
}, [targets, schedule.id, schedule.scheduledDepartureDate]);
// ── Capacity meter (by cargo weight vs locomotive pull) ──────────────────── // ── Capacity meter (by cargo weight vs locomotive pull) ────────────────────
const used = usedWeight(schedule); const used = usedWeight(schedule);
const capacity = pullCapacity(schedule); const capacity = pullCapacity(schedule);
@@ -288,6 +313,36 @@ export function ScheduleWorkspacePanel({
); );
}; };
// Point the pool booking at the chosen same-day train, then put it on wagons.
// If the wagon step fails (that train is short too) the booking stays paid &
// unassigned in the pool — nothing is lost, staff just pick another train.
const doPoolAssign = () => {
if (!poolAssign || !poolTarget) return;
const { id: bookingId, reference } = poolAssign;
moveSchedule
.mutateAsync({ bookingId, trainScheduleId: poolTarget })
.then(() => assignUnassigned.mutateAsync({ id: poolTarget, bookingId }))
.then(() => {
toast({
title: `${reference} assigned`,
description: "Booking placed on the selected train with wagons pinned.",
});
setPoolAssign(null);
onChanged();
void poolQuery.refetch();
})
.catch((error) =>
toast({
title: `Could not assign ${reference}`,
description: apiErrorMessage(
error,
"The selected train has no free wagon of the required type.",
),
variant: "destructive",
}),
);
};
const doMove = () => { const doMove = () => {
if (!moveBookingId || !moveTarget) return; if (!moveBookingId || !moveTarget) return;
moveSchedule moveSchedule
@@ -465,20 +520,41 @@ export function ScheduleWorkspacePanel({
customer={b.customer} customer={b.customer}
weightTons={b.weightTons} weightTons={b.weightTons}
status={b.status} status={b.status}
waitingForWagon={b.schedulingStatus === "WAITING_FOR_WAGON"}
right={ right={
canManage ? ( canManage ? (
<Tooltip label="Force-add to this train" withArrow> <Group gap={6} wrap="nowrap" justify="flex-end">
<Button <Tooltip label="Force-add to this train" withArrow>
size="compact-sm" <Button
color="edr-green" size="compact-sm"
radius="md" color="edr-green"
rightSection={<ArrowRight size={14} />} radius="md"
loading={assign.isPending} rightSection={<ArrowRight size={14} />}
onClick={() => forceAdd(b.id, b.reference, b.weightTons)} loading={assign.isPending}
onClick={() => forceAdd(b.id, b.reference, b.weightTons)}
>
Add
</Button>
</Tooltip>
<Tooltip
label="Pick any train departing this day that has wagons free"
withArrow
> >
Add <Button
</Button> size="compact-sm"
</Tooltip> variant="light"
color="edr-green"
radius="md"
leftSection={<ArrowLeftRight size={13} />}
onClick={() => {
setPoolAssign({ id: b.id, reference: b.reference });
setPoolTarget(null);
}}
>
Add to
</Button>
</Tooltip>
</Group>
) : null ) : null
} }
/> />
@@ -588,6 +664,52 @@ export function ScheduleWorkspacePanel({
</Group> </Group>
</Stack> </Stack>
{/* Pool → same-day train assignment modal */}
<Modal
opened={Boolean(poolAssign)}
onClose={() => setPoolAssign(null)}
title={
<Group gap={8}>
<Train size={18} />
<Text fw={700}>
Assign {poolAssign?.reference ?? "booking"} to a train on this day
</Text>
</Group>
}
centered
radius="lg"
>
<Stack gap="md">
<Text size="xs" c="dimmed">
All open trains departing on this schedule&apos;s day. Pick one with
free wagons the booking is placed and its wagons pinned in one step.
</Text>
<Select
label="Target train (same day)"
placeholder="Select a departure"
data={sameDayOptions}
value={poolTarget}
onChange={setPoolTarget}
searchable
nothingFoundMessage="No open schedules depart on this day"
/>
<Group justify="flex-end">
<Button variant="default" onClick={() => setPoolAssign(null)}>
Cancel
</Button>
<Button
color="edr-green"
disabled={!poolTarget}
loading={moveSchedule.isPending || assignUnassigned.isPending}
leftSection={<CheckCircle2 size={16} />}
onClick={doPoolAssign}
>
Assign to train
</Button>
</Group>
</Stack>
</Modal>
{/* Reassign modal */} {/* Reassign modal */}
<Modal <Modal
opened={Boolean(moveBookingId)} opened={Boolean(moveBookingId)}
@@ -710,6 +832,7 @@ function BookingCard({
weightTons, weightTons,
status, status,
loadingStatus, loadingStatus,
waitingForWagon,
right, right,
}: { }: {
reference: string; reference: string;
@@ -717,6 +840,8 @@ function BookingCard({
weightTons?: number | null; weightTons?: number | null;
status?: string | null; status?: string | null;
loadingStatus?: "LOADED" | "UNLOADED"; loadingStatus?: "LOADED" | "UNLOADED";
/** Paid, but no wagon of the required type was free — waiting for one. */
waitingForWagon?: boolean;
right?: React.ReactNode; right?: React.ReactNode;
}) { }) {
return ( return (
@@ -742,6 +867,16 @@ function BookingCard({
{reference} {reference}
</Text> </Text>
{status ? <BookingStatusBadge status={status} /> : null} {status ? <BookingStatusBadge status={status} /> : null}
{waitingForWagon ? (
<Tooltip
label="Paid, but no wagon of the required type was free. Free a wagon or assign it to a same-day train that has one."
withArrow
>
<Badge size="sm" radius="sm" variant="light" color="orange">
Waiting for wagon
</Badge>
</Tooltip>
) : null}
{loadingStatus ? ( {loadingStatus ? (
<Badge <Badge
size="sm" size="sm"

View File

@@ -32,7 +32,11 @@ import AvailableWagonsPanel from "@/components/trainBuilder/AvailableWagonsPanel
import ChangeLocomotivesModal from "@/components/trainBuilder/ChangeLocomotivesModal"; import ChangeLocomotivesModal from "@/components/trainBuilder/ChangeLocomotivesModal";
import ChangeYardModal from "@/components/trainBuilder/ChangeYardModal"; import ChangeYardModal from "@/components/trainBuilder/ChangeYardModal";
import ConsistWagonList from "@/components/trainBuilder/ConsistWagonList"; import ConsistWagonList from "@/components/trainBuilder/ConsistWagonList";
import { trainStatusColor, trainStatusLabel } from "@/components/trainBuilder/trainStatus"; import {
directionColor,
trainStatusColor,
trainStatusLabel,
} from "@/components/trainBuilder/trainStatus";
import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram"; import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page"; import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import { api } from "@/services/api"; import { api } from "@/services/api";
@@ -128,9 +132,17 @@ export default function TrainBuilderDetailPage() {
} }
backTo="/dashboard/train-builder" backTo="/dashboard/train-builder"
meta={ meta={
<Badge color={trainStatusColor(composition.status)} variant="light"> <Group gap="xs">
{trainStatusLabel(composition.status)} <Badge color={trainStatusColor(composition.status)} variant="light">
</Badge> {trainStatusLabel(composition.status)}
</Badge>
<Badge color="blue" variant="light" ff="monospace">
IMP {composition.importTrainNumber ?? "—"}
</Badge>
<Badge color="orange" variant="light" ff="monospace">
EXP {composition.exportTrainNumber ?? "—"}
</Badge>
</Group>
} }
action={ action={
<Menu position="bottom-end" withinPortal shadow="md" width={220}> <Menu position="bottom-end" withinPortal shadow="md" width={220}>
@@ -289,6 +301,16 @@ export default function TrainBuilderDetailPage() {
<Text size="sm" ff="monospace" fw={600}> <Text size="sm" ff="monospace" fw={600}>
{schedule.reference ?? schedule.id.slice(0, 8)} {schedule.reference ?? schedule.id.slice(0, 8)}
</Text> </Text>
{schedule.trainNumber ? (
<Text size="sm" ff="monospace" fw={700}>
{schedule.trainNumber}
</Text>
) : null}
{schedule.direction ? (
<Badge size="sm" variant="light" color={directionColor(schedule.direction)}>
{schedule.direction}
</Badge>
) : null}
<Badge size="sm" variant="light"> <Badge size="sm" variant="light">
{schedule.status} {schedule.status}
</Badge> </Badge>

View File

@@ -27,7 +27,12 @@ import { useNavigate } from "react-router-dom";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page"; import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles"; import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import BuildTrainModal from "@/components/trainBuilder/BuildTrainModal"; 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 { api } from "@/services/api";
import type { import type {
BuiltTrainListFilters, BuiltTrainListFilters,
@@ -146,6 +151,32 @@ export default function TrainBuilderListPage() {
</Group> </Group>
), ),
}, },
{
id: "numbers",
header: "Train No.",
meta: { headerClassName, cellClassName },
cell: ({ row }) => {
const active = row.original.activeSchedule;
return (
<Stack gap={2}>
{active?.trainNumber ? (
<Group gap={6} wrap="nowrap">
<Text size="sm" fw={700} ff="monospace" lh={1.2}>
{active.trainNumber}
</Text>
<Badge size="xs" variant="light" color={directionColor(active.direction)}>
{active.direction ?? "—"}
</Badge>
</Group>
) : null}
<Text size="xs" c="dimmed" ff="monospace" lh={1.2}>
IMP {row.original.importTrainNumber ?? "—"} · EXP{" "}
{row.original.exportTrainNumber ?? "—"}
</Text>
</Stack>
);
},
},
{ {
id: "yard", id: "yard",
header: "Yard", header: "Yard",
@@ -293,6 +324,7 @@ export default function TrainBuilderListPage() {
data={trains} data={trains}
status={tableStatus} status={tableStatus}
onRowClick={(train) => navigate(`/dashboard/train-builder/${train.id}`)} onRowClick={(train) => navigate(`/dashboard/train-builder/${train.id}`)}
rowStyle={(train) => directionRowStyle(train.activeSchedule?.direction)}
error={ error={
trainsQuery.isError trainsQuery.isError
? { ? {

View File

@@ -244,6 +244,21 @@ export default function TrainScheduleV2DetailPage() {
return []; return [];
}, [previewResult?.wagonPlan, schedule?.trainSet?.wagons]); }, [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( const runPreview = useCallback(
async (options?: { silent?: boolean; advanceStep?: boolean }) => { async (options?: { silent?: boolean; advanceStep?: boolean }) => {
if (!schedule || !scheduleId) return null; if (!schedule || !scheduleId) return null;
@@ -683,7 +698,12 @@ export default function TrainScheduleV2DetailPage() {
fleetAvailability={previewResult?.fleetAvailability} fleetAvailability={previewResult?.fleetAvailability}
deferredBookings={previewResult?.deferredBookings} deferredBookings={previewResult?.deferredBookings}
/> />
<WagonPlanGrid wagonPlan={displayWagonPlan} freightType={freightType} /> {isExportDisplay && displayWagonPlanOriented.length ? (
<Text size="xs" c="dimmed">
Shown rear-first (export direction) positions keep their original numbers.
</Text>
) : null}
<WagonPlanGrid wagonPlan={displayWagonPlanOriented} freightType={freightType} />
{canEditBookings && (previewResult || displayWagonPlan.length) ? ( {canEditBookings && (previewResult || displayWagonPlan.length) ? (
<Group> <Group>
{!hasContainerStep ? ( {!hasContainerStep ? (
@@ -760,15 +780,16 @@ export default function TrainScheduleV2DetailPage() {
<TrainCompositionDiagram <TrainCompositionDiagram
locomotive={schedule.trainSet?.locomotive} locomotive={schedule.trainSet?.locomotive}
locomotives={locomotives} locomotives={locomotives}
wagons={ wagons={diagramWagons}
schedule.trainSet?.wagons?.length
? schedule.trainSet.wagons
: displayWagonPlan
}
freightType={freightType} freightType={freightType}
trainNumber={schedule.train ? schedule.train.code : schedule.trainNumber} trainNumber={schedule.trainNumber ?? schedule.train?.code ?? null}
totalLengthMeters={schedule.trainSet?.totalLengthMeters} totalLengthMeters={schedule.trainSet?.totalLengthMeters}
/> />
{isExportDisplay && diagramWagons.length ? (
<Text size="xs" c="dimmed">
Shown rear-first (export direction) positions keep their original numbers.
</Text>
) : null}
<Paper <Paper
p="lg" p="lg"
radius="lg" radius="lg"
@@ -885,6 +906,11 @@ export default function TrainScheduleV2DetailPage() {
{schedule.trainNumber} {schedule.trainNumber}
</Badge> </Badge>
) : null} ) : null}
{schedule.train ? (
<Text size="xs" c="dimmed" ff="monospace">
Train {schedule.train.code}
</Text>
) : null}
</Group> </Group>
<Box maw={340}> <Box maw={340}>
<RouteCorridor <RouteCorridor

View File

@@ -327,19 +327,24 @@ export default function TrainScheduleV2ListPage() {
header: "Train", header: "Train",
meta: { headerClassName, cellClassName }, meta: { headerClassName, cellClassName },
cell: ({ row }) => { cell: ({ row }) => {
// Schedules created from the Train Builder carry the train code; // Schedules created from the Train Builder show the direction-matched
// legacy rows fall back to their locomotive set. // run number first (falling back to the train code); legacy rows fall
// back to their locomotive set.
if (row.original.train) { if (row.original.train) {
const subtitle = [row.original.trainNumber ? row.original.train.code : null,
row.original.train.trainName]
.filter(Boolean)
.join(" · ");
return ( return (
<Group gap={6} wrap="nowrap"> <Group gap={6} wrap="nowrap">
<Train size={14} color="var(--mantine-color-gray-5)" /> <Train size={14} color="var(--mantine-color-gray-5)" />
<Stack gap={0}> <Stack gap={0}>
<Text size="sm" fw={600} ff="monospace" lh={1.2}> <Text size="sm" fw={600} ff="monospace" lh={1.2}>
{row.original.train.code} {row.original.trainNumber ?? row.original.train.code}
</Text> </Text>
{row.original.train.trainName ? ( {subtitle ? (
<Text size="xs" c="dimmed" lh={1.2}> <Text size="xs" c="dimmed" lh={1.2}>
{row.original.train.trainName} {subtitle}
</Text> </Text>
) : null} ) : null}
</Stack> </Stack>
@@ -758,14 +763,21 @@ export default function TrainScheduleV2ListPage() {
label="Train" label="Train"
description="A built train (Train Builder) runs this departure with its locomotives and wagons" description="A built train (Train Builder) runs this departure with its locomotives and wagons"
placeholder={routeId ? "Select a train" : "Select a route first"} placeholder={routeId ? "Select a train" : "Select a route first"}
data={(trainsQuery.data ?? []).map((train) => ({ data={(trainsQuery.data ?? []).map((train) => {
value: train.id, // Route direction picks which of the train's typed pair this run uses.
label: `${train.code}${train.trainName ? `${train.trainName}` : ""} · ${ const runNumber =
train.locomotives.length selectedRoute?.direction === "IMPORT"
} locos · ${train.wagonCount} wagons${train.atOriginYard ? "" : " · not at origin yard"}${ ? train.importTrainNumber
train.futureScheduleCount ? ` · ${train.futureScheduleCount} future run(s)` : "" : 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} value={trainId || null}
onChange={(v) => setTrainId(v ?? "")} onChange={(v) => setTrainId(v ?? "")}
searchable searchable

View File

@@ -62,7 +62,6 @@ export default function TrainSchedulingGlobalRulesPage() {
"windowDurationHours", "windowDurationHours",
"docReviewMinutes", "docReviewMinutes",
"paymentWindowMinutes", "paymentWindowMinutes",
"reopenDelayMinutes",
]; ];
const payload: Partial<Record<keyof TrainSchedulingGlobalRules, number>> = {}; const payload: Partial<Record<keyof TrainSchedulingGlobalRules, number>> = {};
for (const key of fields) { for (const key of fields) {
@@ -261,17 +260,6 @@ export default function TrainSchedulingGlobalRulesPage() {
min={1} min={1}
disabled={loading} disabled={loading}
/> />
<DurationField
label="Reopen delay"
description="Delay after window close before reopening when the train is not full (90 min = 11:00 close → 12:30 reopen)"
value={form.reopenDelayMinutes ?? ""}
nativeUnit="minutes"
onChange={(value) =>
setForm((current) => ({ ...current, reopenDelayMinutes: value }))
}
min={1}
disabled={loading}
/>
<Group justify="flex-end"> <Group justify="flex-end">
<Button loading={saving} disabled={loading} onClick={() => void handleSave()}> <Button loading={saving} disabled={loading} onClick={() => void handleSave()}>
Save rules Save rules

View File

@@ -17,11 +17,27 @@ export interface YardRefLite {
label: string; label: string;
} }
export type TradeDirection = "IMPORT" | "EXPORT" | "DOMESTIC";
/** The one active (DRAFT/SCHEDULED/DISPATCHED) schedule surfaced per built train. */
export interface ActiveScheduleRef {
id: string;
status: string;
reference: string | null;
direction: TradeDirection | null;
trainNumber: string | null;
}
export interface BuiltTrainSummary { export interface BuiltTrainSummary {
id: string; id: string;
code: string; code: string;
trainName: string | null; trainName: string | null;
status: BuiltTrainStatus; status: BuiltTrainStatus;
/** Fixed IMPORT (even) run number typed at build time. */
importTrainNumber: string | null;
/** Fixed EXPORT (odd) run number typed at build time. */
exportTrainNumber: string | null;
activeSchedule: ActiveScheduleRef | null;
createdAt: string; createdAt: string;
currentYard: YardRefLite | null; currentYard: YardRefLite | null;
locomotives: Array<{ id: string; code: string; name: string | null }>; locomotives: Array<{ id: string; code: string; name: string | null }>;
@@ -80,13 +96,15 @@ export interface TrainComposition {
code: string; code: string;
trainName: string | null; trainName: string | null;
status: BuiltTrainStatus; status: BuiltTrainStatus;
importTrainNumber: string | null;
exportTrainNumber: string | null;
notes: string | null; notes: string | null;
createdAt: string; createdAt: string;
currentYard: YardRefLite | null; currentYard: YardRefLite | null;
locomotives: TrainCompositionLocomotive[]; locomotives: TrainCompositionLocomotive[];
wagons: TrainCompositionWagon[]; wagons: TrainCompositionWagon[];
totals: TrainCompositionTotals; totals: TrainCompositionTotals;
activeSchedules: Array<{ id: string; status: string; reference: string | null }>; activeSchedules: ActiveScheduleRef[];
editable: boolean; editable: boolean;
} }
@@ -112,6 +130,10 @@ export interface BuiltTrainListResponse {
export interface BuildTrainPayload { export interface BuildTrainPayload {
code: string; code: string;
/** EXPORT run number — odd, unique across trains (e.g. 8001). */
exportTrainNumber: string;
/** IMPORT run number — even, unique across trains (e.g. 8002). */
importTrainNumber: string;
currentYardId: string; currentYardId: string;
locomotiveIds: string[]; locomotiveIds: string[];
wagonIds?: string[]; wagonIds?: string[];
@@ -125,6 +147,8 @@ export interface AvailableTrain {
code: string; code: string;
trainName: string | null; trainName: string | null;
status: BuiltTrainStatus; status: BuiltTrainStatus;
importTrainNumber: string | null;
exportTrainNumber: string | null;
currentYardId: string | null; currentYardId: string | null;
currentYard: YardRefLite | null; currentYard: YardRefLite | null;
locomotives: Array<{ id: string; code: string; name: string | null }>; locomotives: Array<{ id: string; code: string; name: string | null }>;

View File

@@ -7,7 +7,8 @@ export type SchedulingStatus =
| "HOLDING" | "HOLDING"
| "ELIGIBLE" | "ELIGIBLE"
| "SCHEDULED" | "SCHEDULED"
| "DISPATCHED"; | "DISPATCHED"
| "WAITING_FOR_WAGON";
export type TrainScheduleStatus = export type TrainScheduleStatus =
| "DRAFT" | "DRAFT"
@@ -94,10 +95,20 @@ export interface FleetAvailabilityRow {
shortfall: number; shortfall: number;
} }
/** Per-booking wagon shortage: how many wagons of which type the booking still lacks. */
export interface BookingWagonShortage {
wagonTypeCodes: string;
wagonsNeeded: number;
wagonsAvailable: number;
wagonsShort: number;
}
export interface DeferredBookingRow { export interface DeferredBookingRow {
id: string; id: string;
reference: string; reference: string;
reason: string; reason: string;
/** Set when the deferral is a fleet-stock shortage (absent for config issues). */
shortage?: BookingWagonShortage | null;
} }
export interface TrainSchedulingGlobalRules { export interface TrainSchedulingGlobalRules {
@@ -114,7 +125,6 @@ export interface TrainSchedulingGlobalRules {
windowDurationHours: number; windowDurationHours: number;
docReviewMinutes: number; docReviewMinutes: number;
paymentWindowMinutes: number; paymentWindowMinutes: number;
reopenDelayMinutes: number;
} }
export interface TrainSchedulePreviewResponse { export interface TrainSchedulePreviewResponse {
@@ -493,7 +503,6 @@ export interface ScheduleWindowRule {
windowOpenHour: number | null; windowOpenHour: number | null;
windowCloseHour: number | null; windowCloseHour: number | null;
windowDurationHours: number | null; windowDurationHours: number | null;
reopenDelayMinutes: number | null;
importWindowLeadDays: number | null; importWindowLeadDays: number | null;
exportBookingLeadHours: number | null; exportBookingLeadHours: number | null;
/** Live global values (not snapshotted per schedule) — editor prefill baseline. */ /** Live global values (not snapshotted per schedule) — editor prefill baseline. */
@@ -831,6 +840,7 @@ export interface CompositionUnassignedBooking {
yardWagonsAvailable: number; yardWagonsAvailable: number;
canAssign: boolean; canAssign: boolean;
blockReason: string | null; blockReason: string | null;
shortage?: BookingWagonShortage | null;
} }
export interface UnassignedBookingsResponse { export interface UnassignedBookingsResponse {

View File

@@ -0,0 +1,264 @@
import { describe, expect, it } from "vitest";
import { bookingWindowUiState } from "@edr/ui-common";
import type {
BookingWindowStateInput,
BookingWindowUiState,
} from "@edr/ui-common";
/**
* Scenario table for the shared badge/countdown state. This is the logic that
* previously let a full export train show an "Upcoming" badge above a live
* "Window closes in …" countdown — every row asserts badge kind, countdown
* target, and bookability TOGETHER, so they can never disagree again.
*/
const OPENS = "2026-07-26T05:00:00.000Z";
const CLOSES = "2026-07-27T05:00:00.000Z";
const DOC_ENDS = "2026-07-24T08:30:00.000Z";
const PAY_ENDS = "2026-07-24T09:30:00.000Z";
/** A full row with every timestamp present; scenarios override what they test. */
function row(over: Partial<BookingWindowStateInput>): BookingWindowStateInput {
return {
windowPhase: "OPEN",
bookingWindowStatus: "OPEN",
windowOpensAt: OPENS,
windowClosesAt: CLOSES,
docReviewEndsAt: DOC_ENDS,
paymentPhaseEndsAt: PAY_ENDS,
...over,
};
}
interface Scenario {
name: string;
input: BookingWindowStateInput;
expected: BookingWindowUiState;
}
const scenarios: Scenario[] = [
// ---- export FCFS lifecycle -------------------------------------------------
{
name: "export announced, before lead window (PRE_WINDOW/CLOSED)",
input: row({ windowPhase: "PRE_WINDOW", bookingWindowStatus: "CLOSED" }),
expected: { kind: "PRE_WINDOW", countdownTo: OPENS, isBookable: false },
},
{
name: "export window open, space left (OPEN/OPEN)",
input: row({}),
expected: { kind: "OPEN", countdownTo: CLOSES, isBookable: true },
},
{
name: "export filled mid-window (OPEN/FULL) — the reported bug",
input: row({ bookingWindowStatus: "FULL" }),
expected: { kind: "FULL", countdownTo: null, isBookable: false },
},
{
name: "export space freed after an expiry cleared FULL (OPEN/OPEN again)",
input: row({}),
expected: { kind: "OPEN", countdownTo: CLOSES, isBookable: true },
},
{
name: "export window over (DONE/CLOSED)",
input: row({ windowPhase: "DONE", bookingWindowStatus: "CLOSED" }),
expected: { kind: "CLOSED", countdownTo: null, isBookable: false },
},
{
name: "export departed while full (DONE/FULL)",
input: row({ windowPhase: "DONE", bookingWindowStatus: "FULL" }),
expected: { kind: "CLOSED", countdownTo: null, isBookable: false },
},
// ---- import daily cycle ----------------------------------------------------
{
name: "import before booking day (PRE_WINDOW/CLOSED)",
input: row({ windowPhase: "PRE_WINDOW", bookingWindowStatus: "CLOSED" }),
expected: { kind: "PRE_WINDOW", countdownTo: OPENS, isBookable: false },
},
{
name: "import window open (OPEN/OPEN)",
input: row({}),
expected: { kind: "OPEN", countdownTo: CLOSES, isBookable: true },
},
{
name: "import window closed, staff reviewing docs (DOC_REVIEW/CLOSED)",
input: row({ windowPhase: "DOC_REVIEW", bookingWindowStatus: "CLOSED" }),
expected: { kind: "DOC_REVIEW", countdownTo: DOC_ENDS, isBookable: false },
},
{
name: "import payment phase, selected customers paying (PAYMENT/CLOSED)",
input: row({ windowPhase: "PAYMENT", bookingWindowStatus: "CLOSED" }),
expected: { kind: "PAYMENT", countdownTo: PAY_ENDS, isBookable: false },
},
{
name: "import batch tentatively filled the train (PAYMENT/FULL) — phase wins, unpaid may still free space",
input: row({ windowPhase: "PAYMENT", bookingWindowStatus: "FULL" }),
expected: { kind: "PAYMENT", countdownTo: PAY_ENDS, isBookable: false },
},
{
name: "import doc review while flag already FULL (DOC_REVIEW/FULL) — phase wins",
input: row({ windowPhase: "DOC_REVIEW", bookingWindowStatus: "FULL" }),
expected: { kind: "DOC_REVIEW", countdownTo: DOC_ENDS, isBookable: false },
},
{
name: "import reopen cycle scheduled (PRE_WINDOW/CLOSED, cycle 2)",
input: row({ windowPhase: "PRE_WINDOW", bookingWindowStatus: "CLOSED" }),
expected: { kind: "PRE_WINDOW", countdownTo: OPENS, isBookable: false },
},
{
name: "import reopen refused while train still FULL (PRE_WINDOW/FULL)",
input: row({ windowPhase: "PRE_WINDOW", bookingWindowStatus: "FULL" }),
expected: { kind: "FULL", countdownTo: null, isBookable: false },
},
{
name: "import train full and finalized (DONE/FULL)",
input: row({ windowPhase: "DONE", bookingWindowStatus: "FULL" }),
expected: { kind: "CLOSED", countdownTo: null, isBookable: false },
},
{
name: "import no cycle fits before departure (DONE/CLOSED)",
input: row({ windowPhase: "DONE", bookingWindowStatus: "CLOSED" }),
expected: { kind: "CLOSED", countdownTo: null, isBookable: false },
},
{
name: "legacy closed-for-the-day row (CLOSED_FOR_DAY/CLOSED)",
input: row({ windowPhase: "CLOSED_FOR_DAY", bookingWindowStatus: "CLOSED" }),
expected: { kind: "CLOSED", countdownTo: null, isBookable: false },
},
{
name: "legacy closed-for-the-day row while full (CLOSED_FOR_DAY/FULL)",
input: row({ windowPhase: "CLOSED_FOR_DAY", bookingWindowStatus: "FULL" }),
expected: { kind: "CLOSED", countdownTo: null, isBookable: false },
},
// ---- desync / stale rows ---------------------------------------------------
{
name: "phase OPEN but desk flag CLOSED (desync) — closed, no countdown",
input: row({ bookingWindowStatus: "CLOSED" }),
expected: { kind: "CLOSED", countdownTo: null, isBookable: false },
},
{
name: "dispatched train stuck at OPEN/CLOSED (tick skips non-scheduled rows)",
input: row({ bookingWindowStatus: "CLOSED" }),
expected: { kind: "CLOSED", countdownTo: null, isBookable: false },
},
{
name: "FULL flag with no phase at all (legacy pre-window-engine row)",
input: row({ windowPhase: null, bookingWindowStatus: "FULL" }),
expected: { kind: "FULL", countdownTo: null, isBookable: false },
},
{
name: "legacy row, no phase, desk open (null/OPEN) — not phase-driven, shows closed",
input: row({ windowPhase: null }),
expected: { kind: "CLOSED", countdownTo: null, isBookable: false },
},
{
name: "unknown future phase value — safe fallback to closed",
input: row({ windowPhase: "SOMETHING_NEW" }),
expected: { kind: "CLOSED", countdownTo: null, isBookable: false },
},
// ---- missing timestamps (no countdown, badge still right) -------------------
{
name: "PRE_WINDOW without an opens-at timestamp",
input: row({
windowPhase: "PRE_WINDOW",
bookingWindowStatus: "CLOSED",
windowOpensAt: null,
}),
expected: { kind: "PRE_WINDOW", countdownTo: null, isBookable: false },
},
{
name: "OPEN without a closes-at timestamp",
input: row({ windowClosesAt: null }),
expected: { kind: "OPEN", countdownTo: null, isBookable: true },
},
{
name: "DOC_REVIEW without an ends-at timestamp",
input: row({
windowPhase: "DOC_REVIEW",
bookingWindowStatus: "CLOSED",
docReviewEndsAt: null,
}),
expected: { kind: "DOC_REVIEW", countdownTo: null, isBookable: false },
},
{
name: "PAYMENT without an ends-at timestamp",
input: row({
windowPhase: "PAYMENT",
bookingWindowStatus: "CLOSED",
paymentPhaseEndsAt: null,
}),
expected: { kind: "PAYMENT", countdownTo: null, isBookable: false },
},
{
name: "row with every field null",
input: {
windowPhase: null,
bookingWindowStatus: null,
windowOpensAt: null,
windowClosesAt: null,
docReviewEndsAt: null,
paymentPhaseEndsAt: null,
},
expected: { kind: "CLOSED", countdownTo: null, isBookable: false },
},
{
name: "row with every field undefined (structural minimum)",
input: {},
expected: { kind: "CLOSED", countdownTo: null, isBookable: false },
},
// ---- countdown targets track the right deadline per phase -------------------
{
name: "PRE_WINDOW counts to opens-at, not closes-at",
input: row({ windowPhase: "PRE_WINDOW", bookingWindowStatus: "CLOSED" }),
expected: { kind: "PRE_WINDOW", countdownTo: OPENS, isBookable: false },
},
{
name: "OPEN counts to closes-at, not doc review",
input: row({}),
expected: { kind: "OPEN", countdownTo: CLOSES, isBookable: true },
},
{
name: "DOC_REVIEW counts to review end, not payment end",
input: row({ windowPhase: "DOC_REVIEW", bookingWindowStatus: "CLOSED" }),
expected: { kind: "DOC_REVIEW", countdownTo: DOC_ENDS, isBookable: false },
},
{
name: "PAYMENT counts to payment end, not window close",
input: row({ windowPhase: "PAYMENT", bookingWindowStatus: "CLOSED" }),
expected: { kind: "PAYMENT", countdownTo: PAY_ENDS, isBookable: false },
},
];
describe("bookingWindowUiState", () => {
it.each(scenarios)("$name", ({ input, expected }) => {
expect(bookingWindowUiState(input)).toEqual(expected);
});
it("never yields a countdown on a non-bookable FULL state, whatever else is set", () => {
for (const phase of ["OPEN", "PRE_WINDOW", null, "ANYTHING"]) {
const state = bookingWindowUiState(row({ windowPhase: phase, bookingWindowStatus: "FULL" }));
expect(state.kind).toBe("FULL");
expect(state.countdownTo).toBeNull();
expect(state.isBookable).toBe(false);
}
});
it("is bookable ONLY when phase and desk flag are both OPEN", () => {
const combos: Array<[string | null, string | null]> = [];
for (const phase of ["PRE_WINDOW", "OPEN", "DOC_REVIEW", "PAYMENT", "DONE", "CLOSED_FOR_DAY", null]) {
for (const status of ["OPEN", "CLOSED", "FULL", null]) {
combos.push([phase, status]);
}
}
for (const [phase, status] of combos) {
const state = bookingWindowUiState(
row({ windowPhase: phase, bookingWindowStatus: status }),
);
expect(state.isBookable).toBe(phase === "OPEN" && status === "OPEN");
}
});
});

View File

@@ -6,7 +6,7 @@ import {
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
} from "lucide-react"; } from "lucide-react";
import { CountdownTimer } from "@edr/ui-common"; import { CountdownTimer, bookingWindowUiState } from "@edr/ui-common";
import type { MyBookingWindow } from "@/services/bookings.service"; import type { MyBookingWindow } from "@/services/bookings.service";
import { windowRouteStops } from "@/pages/contracts/booking-window"; import { windowRouteStops } from "@/pages/contracts/booking-window";
import { Card } from "./Card"; import { Card } from "./Card";
@@ -50,54 +50,34 @@ function windowLabel(w: MyBookingWindow): string {
} }
/** /**
* The countdown for whichever phase the window is currently in. Phases run: * The countdown for the window's UI state (shared with the status badge via
* pre-window (opens at windowOpensAt) → open (closes at windowClosesAt) → * `bookingWindowUiState`, so the two can never contradict — a FULL train shows
* document review (docReviewEndsAt) → payment (paymentPhaseEndsAt). * no ticking "closes in" under a non-open badge).
* *
* `label` describes the deadline being counted down to; `expiredText` names the * `label` describes the deadline being counted down to; `expiredText` names the
* NEXT step so that when a deadline lapses between the 60s refetches the row * NEXT step so that when a deadline lapses between the 60s refetches the row
* announces what comes next ("Booking opening now…", "Review starting…") rather * announces what comes next ("Booking opening now…", "Review starting…") rather
* than the bare word "Expired". Returns null when no phase is timing down. * than the bare word "Expired". Returns null when no phase is timing down.
*/ */
const COUNTDOWN_TEXT: Partial<
Record<
ReturnType<typeof bookingWindowUiState>["kind"],
{ label: string; expiredText: string }
>
> = {
PRE_WINDOW: { label: "Booking opens in", expiredText: "Booking opening now…" },
OPEN: { label: "Window closes in", expiredText: "Document review starting…" },
DOC_REVIEW: { label: "Document review ends in", expiredText: "Payment starting…" },
PAYMENT: { label: "Payment due in", expiredText: "Payment window closing…" },
};
function phaseCountdown( function phaseCountdown(
w: MyBookingWindow, w: MyBookingWindow,
): { label: string; deadline: string; expiredText: string } | null { ): { label: string; deadline: string; expiredText: string } | null {
switch (w.windowPhase) { const state = bookingWindowUiState(w);
case "PRE_WINDOW": const text = COUNTDOWN_TEXT[state.kind];
if (w.windowOpensAt) if (!state.countdownTo || !text) return null;
return { return { ...text, deadline: state.countdownTo };
label: "Booking opens in",
deadline: w.windowOpensAt,
expiredText: "Booking opening now…",
};
return null;
case "OPEN":
if (w.windowClosesAt)
return {
label: "Window closes in",
deadline: w.windowClosesAt,
expiredText: "Document review starting…",
};
return null;
case "DOC_REVIEW":
if (w.docReviewEndsAt)
return {
label: "Document review ends in",
deadline: w.docReviewEndsAt,
expiredText: "Payment starting…",
};
return null;
case "PAYMENT":
if (w.paymentPhaseEndsAt)
return {
label: "Payment due in",
deadline: w.paymentPhaseEndsAt,
expiredText: "Payment window closing…",
};
return null;
default:
return null;
}
} }
function Pill({ function Pill({
@@ -147,19 +127,47 @@ function DirectionBadge({ direction }: { direction: MyBookingWindow["direction"]
} }
function StatusBadge({ window: w }: { window: MyBookingWindow }) { function StatusBadge({ window: w }: { window: MyBookingWindow }) {
if (w.isOpenNow) { const state = bookingWindowUiState(w);
return ( switch (state.kind) {
<Pill bg="#ECF6F1" color="#0A6F4D" border="#CDEBDD"> case "OPEN":
Open now return (
</Pill> <Pill bg="#ECF6F1" color="#0A6F4D" border="#CDEBDD">
); Open now
} </Pill>
if (w.windowPhase === "PRE_WINDOW" && w.windowOpensAt) { );
return ( case "FULL":
<Pill bg="#FEF6E6" color="#B07D14"> return (
Opens at {fmtTime(w.windowOpensAt)} EAT <Pill bg="#FDECEA" color="#B3261E" border="#F6C9C4">
</Pill> Train full
); </Pill>
);
case "PRE_WINDOW":
if (w.windowOpensAt) {
return (
<Pill bg="#FEF6E6" color="#B07D14">
Opens at {fmtTime(w.windowOpensAt)} EAT
</Pill>
);
}
break;
case "DOC_REVIEW":
return (
<Pill bg="#EAF1FB" color="#2E5B96">
Document review
</Pill>
);
case "PAYMENT":
return (
<Pill bg="#EAF1FB" color="#2E5B96">
Payment window
</Pill>
);
case "CLOSED":
return (
<Pill bg="#F1F5F9" color={MUTED}>
Closed
</Pill>
);
} }
return ( return (
<Pill bg="#F1F5F9" color={MUTED}> <Pill bg="#F1F5F9" color={MUTED}>

View File

@@ -18,7 +18,8 @@ import {
ChevronRight, ChevronRight,
Clock, Clock,
} from "lucide-react"; } from "lucide-react";
import { CountdownTimer } from "@edr/ui-common"; import { CountdownTimer, bookingWindowUiState } from "@edr/ui-common";
import type { BookingWindowUiKind } from "@edr/ui-common";
import type { MyBookingWindow } from "@/services/bookings.service"; import type { MyBookingWindow } from "@/services/bookings.service";
import { import {
@@ -90,50 +91,29 @@ function windowLabel(w: MyBookingWindow): string {
} }
/** /**
* The countdown for whichever phase the window is currently in, mirroring the * The countdown for the window's UI state, mirroring the home dashboard's
* home dashboard's Booking Windows card. `expiredText` names the NEXT step so a * Booking Windows card. Derived from the SAME state as the badge
* deadline that lapses between refetches announces what comes next rather than * (`bookingWindowUiState`) so they can never contradict — a full train shows
* the bare "Expired". * 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<BookingWindowUiKind, { label: string; expiredText: string }>
> = {
PRE_WINDOW: { label: "Booking opens in", expiredText: "Booking opening now…" },
OPEN: { label: "Window closes in", expiredText: "Document review starting…" },
DOC_REVIEW: { label: "Document review ends in", expiredText: "Payment starting…" },
PAYMENT: { label: "Payment due in", expiredText: "Payment window closing…" },
};
function phaseCountdown( function phaseCountdown(
w: MyBookingWindow, w: MyBookingWindow,
): { label: string; deadline: string; expiredText: string } | null { ): { label: string; deadline: string; expiredText: string } | null {
switch (w.windowPhase) { const state = bookingWindowUiState(w);
case "PRE_WINDOW": const text = COUNTDOWN_TEXT[state.kind];
return w.windowOpensAt if (!state.countdownTo || !text) return null;
? { return { ...text, deadline: state.countdownTo };
label: "Booking opens in",
deadline: w.windowOpensAt,
expiredText: "Booking opening now…",
}
: null;
case "OPEN":
return w.windowClosesAt
? {
label: "Window closes in",
deadline: w.windowClosesAt,
expiredText: "Document review starting…",
}
: null;
case "DOC_REVIEW":
return w.docReviewEndsAt
? {
label: "Document review ends in",
deadline: w.docReviewEndsAt,
expiredText: "Payment starting…",
}
: null;
case "PAYMENT":
return w.paymentPhaseEndsAt
? {
label: "Payment due in",
deadline: w.paymentPhaseEndsAt,
expiredText: "Payment window closing…",
}
: null;
default:
return null;
}
} }
/** /**
@@ -148,9 +128,21 @@ function isPast(w: MyBookingWindow): boolean {
return w.windowPhase === "DONE" || w.windowPhase === "CLOSED_FOR_DAY"; return w.windowPhase === "DONE" || w.windowPhase === "CLOSED_FOR_DAY";
} }
/** Badge label + Mantine color per UI state — same state the countdown uses. */
const KIND_BADGE: Record<BookingWindowUiKind, { label: string; color: string }> = {
OPEN: { label: "Open now", color: "edr-green" },
FULL: { label: "Train full", color: "red" },
PRE_WINDOW: { label: "Opens soon", color: "yellow" },
DOC_REVIEW: { label: "Document review", color: "gray" },
PAYMENT: { label: "Payment due", color: "gray" },
CLOSED: { label: "Closed", color: "gray" },
};
function WindowCard({ w }: { w: MyBookingWindow }) { function WindowCard({ w }: { w: MyBookingWindow }) {
const cd = phaseCountdown(w); 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"; const isImport = w.direction === "IMPORT";
return ( return (
@@ -183,13 +175,11 @@ function WindowCard({ w }: { w: MyBookingWindow }) {
)} )}
<Badge <Badge
variant={open ? "filled" : "light"} variant={open ? "filled" : "light"}
color={open ? "edr-green" : "gray"} color={badge.color}
radius="sm" radius="sm"
size="sm" size="sm"
> >
{open {badge.label}
? "Open now"
: windowPhaseLabel(w.windowPhase ?? w.bookingWindowStatus)}
</Badge> </Badge>
</Group> </Group>

View File

@@ -182,6 +182,8 @@ export enum SchedulingStatus {
Eligible = "ELIGIBLE", Eligible = "ELIGIBLE",
Scheduled = "SCHEDULED", Scheduled = "SCHEDULED",
Dispatched = "DISPATCHED", Dispatched = "DISPATCHED",
/** Paid, but no wagon of the required type was free — held in the day pool for manual placement. */
WaitingForWagon = "WAITING_FOR_WAGON",
} }
export enum TrainScheduleStatus { export enum TrainScheduleStatus {

View File

@@ -15,6 +15,8 @@ export function DataTable<TData, TValue>({
data, data,
status, status,
onRowClick, onRowClick,
rowStyle,
rowClassName,
tableOptions, tableOptions,
pagination, pagination,
footer, footer,
@@ -115,11 +117,15 @@ export function DataTable<TData, TValue>({
onRowClick(row.original); onRowClick(row.original);
}} }}
role={onRowClick ? "button" : ""} role={onRowClick ? "button" : ""}
className={ style={rowStyle?.(row.original)}
className={[
onRowClick onRowClick
? "cursor-pointer hover:bg-accent hover:text-foreground " ? "cursor-pointer hover:bg-accent hover:text-foreground"
: "" : "",
} rowClassName?.(row.original) ?? "",
]
.join(" ")
.trim()}
> >
{row.getVisibleCells().map((cell) => ( {row.getVisibleCells().map((cell) => (
<Table.Td <Table.Td

View File

@@ -21,6 +21,10 @@ export interface DataTableProps<TData, TValue> {
data: TData[]; data: TData[];
status?: "loading" | "error" | "success"; status?: "loading" | "error" | "success";
onRowClick?: (row: TData) => void; onRowClick?: (row: TData) => void;
/** Per-row inline style (e.g. data-driven background tints via CSS variables). */
rowStyle?: (row: TData) => React.CSSProperties | undefined;
/** Per-row extra class, appended after the built-in clickable-row classes. */
rowClassName?: (row: TData) => string | undefined;
tableOptions?: Omit< tableOptions?: Omit<
TableOptions<TData>, TableOptions<TData>,
"data" | "columns" | "getCoreRowModel" "data" | "columns" | "getCoreRowModel"

View File

@@ -69,3 +69,10 @@ export * from "./components/select";
export * from "./components/switch"; export * from "./components/switch";
export * from "./components/separator"; export * from "./components/separator";
export * from "./components/field"; export * from "./components/field";
export { bookingWindowUiState } from "./lib/booking-window-display";
export type {
BookingWindowUiKind,
BookingWindowStateInput,
BookingWindowUiState,
} from "./lib/booking-window-display";

View File

@@ -0,0 +1,108 @@
/**
* Single source of truth for how a booking window row is presented to a
* customer or staff list: which status badge to show and which deadline (if
* any) to count down to.
*
* The badge and the countdown MUST be derived together. They used to be
* computed independently (badge from `isOpenNow`, countdown from
* `windowPhase`), which let them contradict each other — an export train that
* filled mid-window kept `windowPhase='OPEN'` (space can free again if a pay
* window lapses) while `bookingWindowStatus='FULL'`, so the card showed an
* "Upcoming" badge above a live "Window closes in …" countdown.
*
* Phase/status matrix this resolves (server fields on the schedule row):
* - windowPhase: PRE_WINDOW → OPEN → DOC_REVIEW → PAYMENT → DONE
* (export skips the review/payment phases; CLOSED_FOR_DAY is legacy)
* - bookingWindowStatus: OPEN | CLOSED | FULL — whether the booking desk
* actually accepts bookings right now.
*/
export type BookingWindowUiKind =
/** Bookable right now (phase OPEN and the desk flag agrees). */
| "OPEN"
/** Train has no capacity left — not bookable; may reopen if a reservation expires. */
| "FULL"
/** Announced, opens at `countdownTo`. */
| "PRE_WINDOW"
/** Window closed, staff reviewing documents (import cycle). */
| "DOC_REVIEW"
/** Batch ran, selected customers are paying (import cycle). */
| "PAYMENT"
/** Terminal or not bookable for any other reason. */
| "CLOSED";
export interface BookingWindowStateInput {
windowPhase?: string | null;
bookingWindowStatus?: string | null;
windowOpensAt?: string | null;
windowClosesAt?: string | null;
docReviewEndsAt?: string | null;
paymentPhaseEndsAt?: string | null;
}
export interface BookingWindowUiState {
kind: BookingWindowUiKind;
/** ISO deadline a countdown may tick toward; null = show no countdown. */
countdownTo: string | null;
/** True only when the customer can book right now. */
isBookable: boolean;
}
export function bookingWindowUiState(
w: BookingWindowStateInput,
): BookingWindowUiState {
const phase = w.windowPhase ?? null;
const status = w.bookingWindowStatus ?? null;
if (phase === "DONE" || phase === "CLOSED_FOR_DAY") {
return { kind: "CLOSED", countdownTo: null, isBookable: false };
}
// Mid-cycle phases win over the FULL flag: the batch may have tentatively
// filled the train, but an unpaid reservation can still expire and free
// space, so "document review" / "payment" is the truthful state here.
if (phase === "DOC_REVIEW") {
return {
kind: "DOC_REVIEW",
countdownTo: w.docReviewEndsAt ?? null,
isBookable: false,
};
}
if (phase === "PAYMENT") {
return {
kind: "PAYMENT",
countdownTo: w.paymentPhaseEndsAt ?? null,
isBookable: false,
};
}
// Outside the resolving phases a FULL train is simply not bookable — no
// countdown either: ticking toward "closes in" would promise a window the
// customer cannot use.
if (status === "FULL") {
return { kind: "FULL", countdownTo: null, isBookable: false };
}
if (phase === "PRE_WINDOW") {
return {
kind: "PRE_WINDOW",
countdownTo: w.windowOpensAt ?? null,
isBookable: false,
};
}
if (phase === "OPEN") {
if (status === "OPEN") {
return {
kind: "OPEN",
countdownTo: w.windowClosesAt ?? null,
isBookable: true,
};
}
// Phase says OPEN but the desk flag disagrees (CLOSED): not bookable, and
// no countdown that pretends otherwise.
return { kind: "CLOSED", countdownTo: null, isBookable: false };
}
return { kind: "CLOSED", countdownTo: null, isBookable: false };
}