mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 11:08:12 +00:00
Merge pull request #701 from Tria-plc/freight_feature/usermanagement
Freight feature/usermanagement
This commit is contained in:
@@ -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;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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];
|
||||||
|
|||||||
@@ -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:00–11:00; reopen +90m → cycle 2 opens 12:30 EAT, same day
|
// cycle 1: 08:00–11:00; reopen +90m → cycle 2 opens 12:30 EAT, same day
|
||||||
@@ -250,6 +259,17 @@ describe('batch-window board windows (config-driven booking cycles)', () => {
|
|||||||
expect(new Set(windows.map((w) => w.date)).size).toBeGreaterThanOrEqual(3);
|
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);
|
||||||
|
|||||||
@@ -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 8–17 → closes 17:00, not 19:00). A
|
||||||
|
* round-the-clock desk never caps; a desk-close at/before the open (degenerate
|
||||||
|
* config) is ignored so the window is never clamped to zero length here.
|
||||||
|
*/
|
||||||
|
export function clampCloseToOfficeHours(
|
||||||
|
opensAt: Date,
|
||||||
|
closesAt: Date,
|
||||||
|
hours: OfficeHours,
|
||||||
|
): Date {
|
||||||
|
const deskClose = officeCloseAfter(opensAt, hours);
|
||||||
|
if (
|
||||||
|
deskClose != null &&
|
||||||
|
deskClose.getTime() > opensAt.getTime() &&
|
||||||
|
closesAt.getTime() > deskClose.getTime()
|
||||||
|
) {
|
||||||
|
return deskClose;
|
||||||
|
}
|
||||||
|
return closesAt;
|
||||||
|
}
|
||||||
|
|
||||||
export interface InitialWindowTimes {
|
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));
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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 8–17 → closes 17:00).
|
||||||
|
nextClosesAt = clampCloseToOfficeHours(nextOpensAt, nextClosesAt, officeHours);
|
||||||
if (nextClosesAt > schedule.scheduledDepartureDate) {
|
if (nextClosesAt > schedule.scheduledDepartureDate) {
|
||||||
nextClosesAt = schedule.scheduledDepartureDate;
|
nextClosesAt = schedule.scheduledDepartureDate;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: [
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -435,7 +436,12 @@ export class TrainSchedulingService {
|
|||||||
.where('s.originStationId = :originStationId', { originStationId })
|
.where('s.originStationId = :originStationId', { originStationId })
|
||||||
.andWhere('s.destinationStationId = :destinationStationId', { destinationStationId })
|
.andWhere('s.destinationStationId = :destinationStationId', { destinationStationId })
|
||||||
.andWhere('s.scheduledDepartureDate >= :dayStart', { dayStart })
|
.andWhere('s.scheduledDepartureDate >= :dayStart', { dayStart })
|
||||||
.andWhere('s.scheduledDepartureDate < :nextDayStart', { nextDayStart });
|
.andWhere('s.scheduledDepartureDate < :nextDayStart', { nextDayStart })
|
||||||
|
// A cancelled train is not a sibling: cancel retires its window as DONE,
|
||||||
|
// and a newborn anchoring to it would inherit that dead window verbatim.
|
||||||
|
.andWhere('s.status != :cancelledStatus', {
|
||||||
|
cancelledStatus: TrainScheduleStatusEnum.Cancelled,
|
||||||
|
});
|
||||||
if (excludeScheduleId) {
|
if (excludeScheduleId) {
|
||||||
qb.andWhere('s.id != :excludeScheduleId', { excludeScheduleId });
|
qb.andWhere('s.id != :excludeScheduleId', { excludeScheduleId });
|
||||||
}
|
}
|
||||||
@@ -471,7 +477,12 @@ export class TrainSchedulingService {
|
|||||||
departure,
|
departure,
|
||||||
);
|
);
|
||||||
if (siblings.length === 0) return null;
|
if (siblings.length === 0) return null;
|
||||||
const withWindow = siblings.filter((s) => s.windowOpensAt != null);
|
// A DONE window is retired (the day's last cycle already ran) — anchoring
|
||||||
|
// to it would hand the newborn a dead window no tick ever advances. With no
|
||||||
|
// live or pending sibling left, fall back to fresh times (return null).
|
||||||
|
const withWindow = siblings.filter(
|
||||||
|
(s) => s.windowOpensAt != null && s.windowPhase !== 'DONE',
|
||||||
|
);
|
||||||
if (withWindow.length === 0) return null;
|
if (withWindow.length === 0) return null;
|
||||||
|
|
||||||
// A group whose window is live (some sibling has moved past PRE_WINDOW but is
|
// A group whose window is live (some sibling has moved past PRE_WINDOW but is
|
||||||
@@ -603,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
|
||||||
@@ -692,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
|
||||||
@@ -919,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),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1069,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,
|
||||||
@@ -1164,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,
|
||||||
}),
|
}),
|
||||||
@@ -2674,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;
|
||||||
@@ -2695,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) {
|
||||||
@@ -4614,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
|
||||||
? {
|
? {
|
||||||
@@ -5512,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,
|
||||||
@@ -5520,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,
|
||||||
@@ -6148,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 {
|
||||||
@@ -6156,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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6176,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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6220,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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6230,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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6249,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),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6267,6 +6343,7 @@ export class TrainSchedulingService {
|
|||||||
yardWagonsAvailable,
|
yardWagonsAvailable,
|
||||||
canAssign: false,
|
canAssign: false,
|
||||||
blockReason: missing.issue,
|
blockReason: missing.issue,
|
||||||
|
shortage: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6277,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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -5,14 +5,26 @@ import {
|
|||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
IsUUID,
|
IsUUID,
|
||||||
|
Matches,
|
||||||
MaxLength,
|
MaxLength,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
|
||||||
export class BuildTrainDto {
|
export class BuildTrainDto {
|
||||||
@ApiProperty({ example: '81001', description: 'Operator-assigned train code (unique)' })
|
@ApiProperty({ example: '8001', description: 'EXPORT run number (odd, unique across trains)' })
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(32)
|
@MaxLength(20)
|
||||||
code!: string;
|
@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()
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -85,6 +85,16 @@ export class TrainBuilderController {
|
|||||||
return this.trainBuilderService.removeWagon(id, wagonId);
|
return this.trainBuilderService.removeWagon(id, wagonId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post(':id/wagons/:wagonId/maintenance')
|
||||||
|
@FleetManage()
|
||||||
|
@ApiOperation({ summary: 'Detach one wagon and move it to MAINTENANCE status' })
|
||||||
|
sendWagonToMaintenance(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Param('wagonId', ParseUUIDPipe) wagonId: string,
|
||||||
|
) {
|
||||||
|
return this.trainBuilderService.sendWagonToMaintenance(id, wagonId);
|
||||||
|
}
|
||||||
|
|
||||||
@Post(':id/reorder-wagons')
|
@Post(':id/reorder-wagons')
|
||||||
@FleetManage()
|
@FleetManage()
|
||||||
@ApiOperation({ summary: 'Persist a drag-reorder of the full consist' })
|
@ApiOperation({ summary: 'Persist a drag-reorder of the full consist' })
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -50,10 +59,25 @@ export class TrainBuilderService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const trainId = await this.dataSource.transaction(async (manager) => {
|
const trainId = await this.dataSource.transaction(async (manager) => {
|
||||||
const code = dto.code.trim();
|
const code = await this.generateTrainCode(manager);
|
||||||
const existing = await manager.getRepository(Train).findOne({ where: { code } });
|
|
||||||
if (existing) {
|
// Friendly 409 before the partial unique indexes (the race-proof backstop):
|
||||||
throw new ConflictException(`Train code ${code} is already in use`);
|
// 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 } });
|
||||||
@@ -76,6 +100,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 +143,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 +195,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 +268,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
|
||||||
@@ -356,6 +414,33 @@ export class TrainBuilderService {
|
|||||||
return this.getComposition(id);
|
return this.getComposition(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detach one wagon AND flag it for maintenance: it leaves the consist and
|
||||||
|
* moves to MAINTENANCE status (not AVAILABLE), so it is not re-coupled until
|
||||||
|
* it clears maintenance. The freed sequence gap is closed.
|
||||||
|
*/
|
||||||
|
async sendWagonToMaintenance(id: string, wagonId: string) {
|
||||||
|
await this.dataSource.transaction(async (manager) => {
|
||||||
|
const train = await this.getEditableTrain(manager, id);
|
||||||
|
const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } });
|
||||||
|
if (!wagon || wagon.trainId !== train.id) {
|
||||||
|
throw new NotFoundException(`Wagon ${wagonId} is not part of this train`);
|
||||||
|
}
|
||||||
|
if (wagon.currentTrainScheduleId) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Wagon ${wagon.wagonNumber} is pinned to an active schedule and cannot be removed`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await manager.getRepository(Wagon).update(wagon.id, {
|
||||||
|
trainId: null,
|
||||||
|
sequenceNumber: null,
|
||||||
|
status: WagonStatus.Maintenance,
|
||||||
|
});
|
||||||
|
await this.resequenceWagons(manager, train.id);
|
||||||
|
});
|
||||||
|
return this.getComposition(id);
|
||||||
|
}
|
||||||
|
|
||||||
/** Persist a drag-reorder: `wagonIds` is the full consist in its new order. */
|
/** Persist a drag-reorder: `wagonIds` is the full consist in its new order. */
|
||||||
async reorderWagons(id: string, dto: ReorderTrainWagonsDto) {
|
async reorderWagons(id: string, dto: ReorderTrainWagonsDto) {
|
||||||
await this.dataSource.transaction(async (manager) => {
|
await this.dataSource.transaction(async (manager) => {
|
||||||
@@ -407,7 +492,30 @@ export class TrainBuilderService {
|
|||||||
|
|
||||||
// ---------------------------------------------------------------- internals
|
// ---------------------------------------------------------------- internals
|
||||||
|
|
||||||
private mapSummary(train: Train) {
|
/**
|
||||||
|
* System-assigned train code `TR-NNNNN`. Draws the next number from the
|
||||||
|
* highest existing `TR-` code and probes past any manual collision so the
|
||||||
|
* unique constraint never rejects the build.
|
||||||
|
*/
|
||||||
|
private async generateTrainCode(manager: EntityManager): Promise<string> {
|
||||||
|
const [row]: { max_seq: string | null }[] = await manager.query(
|
||||||
|
`SELECT MAX(CAST(SUBSTRING(code FROM '^TR-([0-9]+)$') AS INTEGER)) AS max_seq
|
||||||
|
FROM freight.trains
|
||||||
|
WHERE code ~ '^TR-[0-9]+$'`,
|
||||||
|
);
|
||||||
|
let seq = Number(row?.max_seq ?? 0) + 1;
|
||||||
|
for (let attempt = 0; attempt < 50; attempt += 1) {
|
||||||
|
const code = `TR-${String(seq).padStart(5, '0')}`;
|
||||||
|
const exists = await manager
|
||||||
|
.getRepository(Train)
|
||||||
|
.findOne({ where: { code }, withDeleted: true });
|
||||||
|
if (!exists) return code;
|
||||||
|
seq += 1;
|
||||||
|
}
|
||||||
|
throw new ConflictException('Could not allocate a unique train code');
|
||||||
|
}
|
||||||
|
|
||||||
|
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 +529,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 }
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,15 @@ export default function AvailableWagonsPanel({
|
|||||||
const typeOptions = useMemo(() => {
|
const typeOptions = useMemo(() => {
|
||||||
const byId = new Map<string, string>();
|
const byId = new Map<string, string>();
|
||||||
for (const wagon of wagonsQuery.data ?? []) {
|
for (const wagon of wagonsQuery.data ?? []) {
|
||||||
if (wagon.wagonType) byId.set(wagon.wagonType.id, wagon.wagonType.name);
|
if (wagon.wagonType) {
|
||||||
|
// e.g. "Flat wagon (NW5)" — name with its type code.
|
||||||
|
byId.set(
|
||||||
|
wagon.wagonType.id,
|
||||||
|
wagon.wagonType.code
|
||||||
|
? `${wagon.wagonType.name} (${wagon.wagonType.code})`
|
||||||
|
: wagon.wagonType.name,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return [
|
return [
|
||||||
{ value: "ALL", label: "All types" },
|
{ value: "ALL", label: "All types" },
|
||||||
@@ -64,6 +72,22 @@ export default function AvailableWagonsPanel({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const allSelected =
|
||||||
|
wagons.length > 0 && wagons.every((w) => selected.includes(w.id));
|
||||||
|
const someSelected = wagons.some((w) => selected.includes(w.id));
|
||||||
|
|
||||||
|
const toggleAll = (checked: boolean) => {
|
||||||
|
setSelected((prev) => {
|
||||||
|
if (checked) {
|
||||||
|
const ids = new Set(prev);
|
||||||
|
wagons.forEach((w) => ids.add(w.id));
|
||||||
|
return [...ids];
|
||||||
|
}
|
||||||
|
const visible = new Set(wagons.map((w) => w.id));
|
||||||
|
return prev.filter((id) => !visible.has(id));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const handleAssign = () => {
|
const handleAssign = () => {
|
||||||
if (!selected.length) return;
|
if (!selected.length) return;
|
||||||
onAssign(selected);
|
onAssign(selected);
|
||||||
@@ -88,6 +112,16 @@ export default function AvailableWagonsPanel({
|
|||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
|
{wagons.length ? (
|
||||||
|
<Checkbox
|
||||||
|
size="sm"
|
||||||
|
label={`Select all (${wagons.length})`}
|
||||||
|
checked={allSelected}
|
||||||
|
indeterminate={!allSelected && someSelected}
|
||||||
|
onChange={(e) => toggleAll(e.currentTarget.checked)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<ScrollArea.Autosize mah={380} type="auto">
|
<ScrollArea.Autosize mah={380} type="auto">
|
||||||
<Stack gap={6}>
|
<Stack gap={6}>
|
||||||
{wagonsQuery.isLoading ? (
|
{wagonsQuery.isLoading ? (
|
||||||
|
|||||||
@@ -26,14 +26,19 @@ 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: pick the yard it is being assembled in and
|
||||||
* yard it is being assembled in, and couple at least two locomotives from that
|
* couple at least two locomotives from that yard. The train code is assigned by
|
||||||
* yard. Wagons are attached afterwards on the composition page.
|
* the system. Wagons are attached afterwards on the composition page.
|
||||||
*/
|
*/
|
||||||
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 [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[]>([]);
|
||||||
@@ -56,7 +61,8 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!opened) {
|
if (!opened) {
|
||||||
setCode("");
|
setExportTrainNumber("");
|
||||||
|
setImportTrainNumber("");
|
||||||
setTrainName("");
|
setTrainName("");
|
||||||
setYardId("");
|
setYardId("");
|
||||||
setLocomotiveIds([]);
|
setLocomotiveIds([]);
|
||||||
@@ -65,16 +71,24 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain
|
|||||||
}, [opened]);
|
}, [opened]);
|
||||||
|
|
||||||
const handleBuild = async () => {
|
const handleBuild = async () => {
|
||||||
if (!code.trim() || !yardId || locomotiveIds.length < 2) {
|
if (!yardId || locomotiveIds.length < 2) {
|
||||||
toast({
|
toast({
|
||||||
title: "Enter a train code, pick a yard, and couple at least two locomotives",
|
title: "Pick a yard and couple at least two locomotives",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
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",
|
variant: "destructive",
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const composition = await build.mutateAsync({
|
const composition = await build.mutateAsync({
|
||||||
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() } : {}),
|
||||||
@@ -108,22 +122,42 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain
|
|||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
A train is assembled in one yard: two or more locomotives plus wagons
|
A train is assembled in one yard: two or more locomotives plus wagons
|
||||||
standing in that same yard. Wagons are attached on the next screen.
|
standing in that same yard. The train code is assigned automatically;
|
||||||
|
wagons are attached on the next screen.
|
||||||
</Text>
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
label="Name (optional)"
|
||||||
|
placeholder="e.g. Fertilizer block"
|
||||||
|
value={trainName}
|
||||||
|
onChange={(e) => setTrainName(e.currentTarget.value)}
|
||||||
|
maxLength={100}
|
||||||
|
/>
|
||||||
<Group grow>
|
<Group grow>
|
||||||
<TextInput
|
<TextInput
|
||||||
label="Train code"
|
label="Export train number"
|
||||||
placeholder="e.g. 81001"
|
description="Odd — Ethiopia → Djibouti runs"
|
||||||
value={code}
|
placeholder="e.g. 8001"
|
||||||
onChange={(e) => setCode(e.currentTarget.value)}
|
value={exportTrainNumber}
|
||||||
maxLength={32}
|
onChange={(e) => setExportTrainNumber(e.currentTarget.value)}
|
||||||
|
maxLength={20}
|
||||||
|
error={
|
||||||
|
exportTrainNumber && !isOddNumber(exportTrainNumber)
|
||||||
|
? "Must be numeric and odd"
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<TextInput
|
<TextInput
|
||||||
label="Name (optional)"
|
label="Import train number"
|
||||||
placeholder="e.g. Fertilizer block"
|
description="Even — Djibouti → Ethiopia runs"
|
||||||
value={trainName}
|
placeholder="e.g. 8002"
|
||||||
onChange={(e) => setTrainName(e.currentTarget.value)}
|
value={importTrainNumber}
|
||||||
maxLength={100}
|
onChange={(e) => setImportTrainNumber(e.currentTarget.value)}
|
||||||
|
maxLength={20}
|
||||||
|
error={
|
||||||
|
importTrainNumber && !isEvenNumber(importTrainNumber)
|
||||||
|
? "Must be numeric and even"
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
<Select
|
<Select
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
type DropResult,
|
type DropResult,
|
||||||
} from "@hello-pangea/dnd";
|
} from "@hello-pangea/dnd";
|
||||||
import { ActionIcon, Badge, Box, Group, Stack, Text, Tooltip } from "@mantine/core";
|
import { ActionIcon, Badge, Box, Group, Stack, Text, Tooltip } from "@mantine/core";
|
||||||
import { GripVertical, Trash2 } from "lucide-react";
|
import { GripVertical, Trash2, Wrench } from "lucide-react";
|
||||||
import { type ReactNode } from "react";
|
import { type ReactNode } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
|
|
||||||
@@ -36,6 +36,7 @@ export default function ConsistWagonList({
|
|||||||
editable,
|
editable,
|
||||||
onReorder,
|
onReorder,
|
||||||
onRemove,
|
onRemove,
|
||||||
|
onMaintenance,
|
||||||
busy = false,
|
busy = false,
|
||||||
}: ConsistWagonListProps) {
|
}: ConsistWagonListProps) {
|
||||||
const onDragEnd = (result: DropResult) => {
|
const onDragEnd = (result: DropResult) => {
|
||||||
@@ -78,6 +79,7 @@ export default function ConsistWagonList({
|
|||||||
editable={editable}
|
editable={editable}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
onRemove={onRemove}
|
onRemove={onRemove}
|
||||||
|
onMaintenance={onMaintenance}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Draggable>
|
</Draggable>
|
||||||
@@ -95,6 +97,8 @@ export interface ConsistWagonListProps {
|
|||||||
editable: boolean;
|
editable: boolean;
|
||||||
onReorder: (wagonIds: string[]) => void;
|
onReorder: (wagonIds: string[]) => void;
|
||||||
onRemove: (wagonId: string) => void;
|
onRemove: (wagonId: string) => void;
|
||||||
|
/** Detach the wagon and move it to MAINTENANCE status. */
|
||||||
|
onMaintenance: (wagonId: string) => void;
|
||||||
busy?: boolean;
|
busy?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,6 +110,7 @@ function WagonRow({
|
|||||||
editable,
|
editable,
|
||||||
busy,
|
busy,
|
||||||
onRemove,
|
onRemove,
|
||||||
|
onMaintenance,
|
||||||
}: {
|
}: {
|
||||||
wagon: TrainCompositionWagon;
|
wagon: TrainCompositionWagon;
|
||||||
index: number;
|
index: number;
|
||||||
@@ -114,6 +119,7 @@ function WagonRow({
|
|||||||
editable: boolean;
|
editable: boolean;
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
onRemove: (wagonId: string) => void;
|
onRemove: (wagonId: string) => void;
|
||||||
|
onMaintenance: (wagonId: string) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<PortalAwareRow snapshot={snapshot}>
|
<PortalAwareRow snapshot={snapshot}>
|
||||||
@@ -153,17 +159,30 @@ function WagonRow({
|
|||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
{editable ? (
|
{editable ? (
|
||||||
<Tooltip label="Detach wagon" withArrow>
|
<Group gap={4} wrap="nowrap">
|
||||||
<ActionIcon
|
<Tooltip label="Send to maintenance (detaches)" withArrow>
|
||||||
variant="subtle"
|
<ActionIcon
|
||||||
color="red"
|
variant="subtle"
|
||||||
disabled={busy}
|
color="orange"
|
||||||
onClick={() => onRemove(wagon.id)}
|
disabled={busy}
|
||||||
aria-label={`Detach wagon ${wagon.wagonNumber}`}
|
onClick={() => onMaintenance(wagon.id)}
|
||||||
>
|
aria-label={`Send wagon ${wagon.wagonNumber} to maintenance`}
|
||||||
<Trash2 size={16} />
|
>
|
||||||
</ActionIcon>
|
<Wrench size={16} />
|
||||||
</Tooltip>
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip label="Detach wagon" withArrow>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="red"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => onRemove(wagon.id)}
|
||||||
|
aria-label={`Detach wagon ${wagon.wagonNumber}`}
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</Group>
|
||||||
) : null}
|
) : null}
|
||||||
</Group>
|
</Group>
|
||||||
</PortalAwareRow>
|
</PortalAwareRow>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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'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"
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -72,12 +76,18 @@ export default function TrainBuilderDetailPage() {
|
|||||||
);
|
);
|
||||||
const assignWagons = useMutation(api.trainBuilder.assignWagons.mutationOptions());
|
const assignWagons = useMutation(api.trainBuilder.assignWagons.mutationOptions());
|
||||||
const removeWagon = useMutation(api.trainBuilder.removeWagon.mutationOptions());
|
const removeWagon = useMutation(api.trainBuilder.removeWagon.mutationOptions());
|
||||||
|
const maintenanceWagon = useMutation(
|
||||||
|
api.trainBuilder.sendWagonToMaintenance.mutationOptions(),
|
||||||
|
);
|
||||||
const reorderWagons = useMutation(api.trainBuilder.reorderWagons.mutationOptions());
|
const reorderWagons = useMutation(api.trainBuilder.reorderWagons.mutationOptions());
|
||||||
const disband = useMutation(api.trainBuilder.disband.mutationOptions());
|
const disband = useMutation(api.trainBuilder.disband.mutationOptions());
|
||||||
|
|
||||||
const composition = compositionQuery.data;
|
const composition = compositionQuery.data;
|
||||||
const busy =
|
const busy =
|
||||||
assignWagons.isPending || removeWagon.isPending || reorderWagons.isPending;
|
assignWagons.isPending ||
|
||||||
|
removeWagon.isPending ||
|
||||||
|
maintenanceWagon.isPending ||
|
||||||
|
reorderWagons.isPending;
|
||||||
|
|
||||||
const withToast = async (action: () => Promise<unknown>, failTitle: string) => {
|
const withToast = async (action: () => Promise<unknown>, failTitle: string) => {
|
||||||
try {
|
try {
|
||||||
@@ -128,9 +138,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}>
|
||||||
@@ -272,6 +290,12 @@ export default function TrainBuilderDetailPage() {
|
|||||||
"Could not detach wagon",
|
"Could not detach wagon",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
onMaintenance={(wagonId) =>
|
||||||
|
void withToast(
|
||||||
|
() => maintenanceWagon.mutateAsync({ id: composition.id, wagonId }),
|
||||||
|
"Could not send wagon to maintenance",
|
||||||
|
)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -289,6 +313,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>
|
||||||
|
|||||||
@@ -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
|
||||||
? {
|
? {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1850,6 +1850,15 @@ export const api = {
|
|||||||
() => TRAIN_BUILDER_INVALIDATIONS,
|
() => TRAIN_BUILDER_INVALIDATIONS,
|
||||||
),
|
),
|
||||||
|
|
||||||
|
sendWagonToMaintenance: endpoint<{ id: string; wagonId: string }, TrainComposition>(
|
||||||
|
"train-builder",
|
||||||
|
"sendWagonToMaintenance",
|
||||||
|
({ id, wagonId }) =>
|
||||||
|
trainBuilderService.sendWagonToMaintenance(id, wagonId).then((r) => r.data),
|
||||||
|
undefined,
|
||||||
|
() => TRAIN_BUILDER_INVALIDATIONS,
|
||||||
|
),
|
||||||
|
|
||||||
reorderWagons: endpoint<{ id: string; wagonIds: string[] }, TrainComposition>(
|
reorderWagons: endpoint<{ id: string; wagonIds: string[] }, TrainComposition>(
|
||||||
"train-builder",
|
"train-builder",
|
||||||
"reorderWagons",
|
"reorderWagons",
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,7 +129,10 @@ export interface BuiltTrainListResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface BuildTrainPayload {
|
export interface BuildTrainPayload {
|
||||||
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 +146,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 }>;
|
||||||
@@ -225,6 +248,9 @@ export const trainBuilderService = {
|
|||||||
apiClient.post<TrainComposition>(`${BASE}/${id}/wagons`, { wagonIds }),
|
apiClient.post<TrainComposition>(`${BASE}/${id}/wagons`, { wagonIds }),
|
||||||
removeWagon: (id: string, wagonId: string) =>
|
removeWagon: (id: string, wagonId: string) =>
|
||||||
apiClient.delete<TrainComposition>(`${BASE}/${id}/wagons/${wagonId}`),
|
apiClient.delete<TrainComposition>(`${BASE}/${id}/wagons/${wagonId}`),
|
||||||
|
/** Detach a wagon and move it to MAINTENANCE status. */
|
||||||
|
sendWagonToMaintenance: (id: string, wagonId: string) =>
|
||||||
|
apiClient.post<TrainComposition>(`${BASE}/${id}/wagons/${wagonId}/maintenance`),
|
||||||
reorderWagons: (id: string, wagonIds: string[]) =>
|
reorderWagons: (id: string, wagonIds: string[]) =>
|
||||||
apiClient.post<TrainComposition>(`${BASE}/${id}/reorder-wagons`, { wagonIds }),
|
apiClient.post<TrainComposition>(`${BASE}/${id}/reorder-wagons`, { wagonIds }),
|
||||||
disband: (id: string) => apiClient.delete<void>(`${BASE}/${id}`),
|
disband: (id: string) => apiClient.delete<void>(`${BASE}/${id}`),
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
108
packages/ui-common/src/lib/booking-window-display.ts
Normal file
108
packages/ui-common/src/lib/booking-window-display.ts
Normal 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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user