mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
remove reopen delay minutes from global rules and update related types
- Removed the field from and related components. - Updated to reflect the removal of the reopen delay input field. - Modified to include new train number fields: and . - Added interface to manage active schedules with trade direction. - Introduced interface to track wagon shortages in bookings. - Updated logic to ensure consistent UI state representation. - Created migrations to drop the column and add and columns to the table. - Added tests for the new booking window display logic and wagon planning functionality.
This commit is contained in:
@@ -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.Scheduled,
|
||||
SchedulingStatus.Dispatched,
|
||||
SchedulingStatus.WaitingForWagon,
|
||||
] as const;
|
||||
|
||||
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', () => {
|
||||
// Opens now (05 Jul 12:00 EAT); a 24h duration would close 06 Jul 12:00 EAT,
|
||||
// past the 06 Jul 08:00 departure → clamped to departure.
|
||||
// Round-the-clock desk (no desk-close cap in play). Opens now (05 Jul 12:00
|
||||
// EAT); a 24h duration would close 06 Jul 12:00 EAT, past the 06 Jul 08:00
|
||||
// departure → clamped to departure.
|
||||
const now = new Date('2026-07-05T09:00:00.000Z');
|
||||
const { windowClosesAt } = computeImportWindowTimes(
|
||||
departure,
|
||||
{ ...bounded, windowDurationHours: 24 },
|
||||
{ ...bounded, windowOpenHour: 8, windowCloseHour: 8, windowDurationHours: 24 },
|
||||
now,
|
||||
);
|
||||
expect(windowClosesAt.toISOString()).toBe(departure.toISOString());
|
||||
});
|
||||
|
||||
it('desk close hour cuts the window short (duration never outlives the desk)', () => {
|
||||
// Opens now (05 Jul 12:00 EAT); the 15h duration would run to 03:00 next
|
||||
// day, but the desk shuts 17:00 EAT (14:00 UTC) → the window closes with it.
|
||||
const now = new Date('2026-07-05T09:00:00.000Z');
|
||||
const { windowClosesAt } = computeImportWindowTimes(departure, bounded, now);
|
||||
expect(windowClosesAt.toISOString()).toBe('2026-07-05T14:00:00.000Z');
|
||||
});
|
||||
|
||||
describe('overnight desk (open > close, wraps past midnight)', () => {
|
||||
// Desk open 08:00, closes 05:00 next morning — open across midnight.
|
||||
const overnight = { ...bounded, windowOpenHour: 8, windowCloseHour: 5 };
|
||||
@@ -189,13 +198,13 @@ describe('computeImportWindowTimes — overnight desk (open > close, wraps midni
|
||||
|
||||
describe('batch-window board windows (config-driven booking cycles)', () => {
|
||||
// Default rules: open 08:00 EAT, desk shuts 17:00, 3 days before departure,
|
||||
// 3h long, reopen 90m later.
|
||||
// 3h long, reopen gap (doc review + payment) 90m.
|
||||
const cfg: BoardWindowConfig = {
|
||||
importWindowLeadDays: 3,
|
||||
windowOpenHour: 8,
|
||||
windowCloseHour: 17,
|
||||
windowDurationHours: 3,
|
||||
reopenDelayMinutes: 90,
|
||||
reopenGapMinutes: 90,
|
||||
exportBookingLeadHours: 24,
|
||||
};
|
||||
|
||||
@@ -211,7 +220,7 @@ describe('batch-window board windows (config-driven booking cycles)', () => {
|
||||
expect(windows[0].end.toISOString()).toBe('2026-06-05T08:00:00.000Z');
|
||||
});
|
||||
|
||||
it('import: reopens reopenDelayMinutes after close while inside office hours', () => {
|
||||
it('import: reopens after the doc-review + payment gap while inside office hours', () => {
|
||||
const departure = new Date('2026-06-08T11:00:00.000Z');
|
||||
const windows = listConfigBookingWindows('IMPORT', departure, cfg);
|
||||
// cycle 1: 08:00–11:00; reopen +90m → cycle 2 opens 12:30 EAT, same day
|
||||
@@ -250,6 +259,17 @@ describe('batch-window board windows (config-driven booking cycles)', () => {
|
||||
expect(new Set(windows.map((w) => w.date)).size).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('import: desk close hour cuts a cycle short (duration past 17:00 clamps)', () => {
|
||||
const longCfg: BoardWindowConfig = { ...cfg, windowDurationHours: 10 };
|
||||
const departure = new Date('2026-06-08T11:00:00.000Z');
|
||||
const windows = listConfigBookingWindows('IMPORT', departure, longCfg);
|
||||
// Cycle 1 opens 08:00 EAT; 10h would close 18:00 — desk shuts 17:00 (14:00 UTC).
|
||||
expect(windows[0].start.toISOString()).toBe('2026-06-05T05:00:00.000Z');
|
||||
expect(windows[0].end.toISOString()).toBe('2026-06-05T14:00:00.000Z');
|
||||
// Reopen 90m after the clamped close lands past 17:00 → next morning 08:00 EAT.
|
||||
expect(windows[1].start.toISOString()).toBe('2026-06-06T05:00:00.000Z');
|
||||
});
|
||||
|
||||
it('export: single FCFS window exportBookingLeadHours before departure', () => {
|
||||
const departure = new Date('2026-06-08T11:00:00.000Z');
|
||||
const windows = listConfigBookingWindows('EXPORT', departure, cfg);
|
||||
|
||||
@@ -223,6 +223,49 @@ export function nextCycleOpensAt(
|
||||
return opensAt.getTime() < departure.getTime() ? opensAt : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The desk-close instant of the office window containing `opensAt`; null for a
|
||||
* round-the-clock desk. Same-day desk (open < close): closeHour on `opensAt`'s
|
||||
* EAT day. Overnight desk (open > close): closeHour on the NEXT EAT day when
|
||||
* `opensAt` sits in the evening half, closeHour the same day when it sits in the
|
||||
* after-midnight half.
|
||||
*/
|
||||
export function officeCloseAfter(opensAt: Date, hours: OfficeHours): Date | null {
|
||||
if (isRoundTheClock(hours)) return null;
|
||||
const { hour, minute } = eatParts(opensAt);
|
||||
const openMinutes = hour * 60 + minute;
|
||||
if (
|
||||
hours.windowOpenHour > hours.windowCloseHour &&
|
||||
openMinutes >= hours.windowOpenHour * 60
|
||||
) {
|
||||
return eatDayToUtc(shiftEatDay(eatDay(opensAt), 1), hours.windowCloseHour);
|
||||
}
|
||||
return eatDayToUtc(eatDay(opensAt), hours.windowCloseHour);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cap a window close at the desk-close hour that follows its open: the office
|
||||
* hours end a running window early rather than letting the duration outlive the
|
||||
* desk (open 16:00, 3h duration, desk 8–17 → closes 17:00, not 19:00). A
|
||||
* round-the-clock desk never caps; a desk-close at/before the open (degenerate
|
||||
* config) is ignored so the window is never clamped to zero length here.
|
||||
*/
|
||||
export function clampCloseToOfficeHours(
|
||||
opensAt: Date,
|
||||
closesAt: Date,
|
||||
hours: OfficeHours,
|
||||
): Date {
|
||||
const deskClose = officeCloseAfter(opensAt, hours);
|
||||
if (
|
||||
deskClose != null &&
|
||||
deskClose.getTime() > opensAt.getTime() &&
|
||||
closesAt.getTime() > deskClose.getTime()
|
||||
) {
|
||||
return deskClose;
|
||||
}
|
||||
return closesAt;
|
||||
}
|
||||
|
||||
export interface InitialWindowTimes {
|
||||
windowOpensAt: Date;
|
||||
windowClosesAt: Date;
|
||||
@@ -243,7 +286,8 @@ export interface InitialWindowTimes {
|
||||
* • `now` before openHour that EAT day → opens at openHour that morning
|
||||
* • `now` at/after closeHour → desk shut; opens openHour next morning
|
||||
*
|
||||
* `windowDurationHours` extends from that open, capped at departure.
|
||||
* `windowDurationHours` extends from that open, capped at the desk close hour
|
||||
* and at departure.
|
||||
*/
|
||||
export function computeImportWindowTimes(
|
||||
departure: Date,
|
||||
@@ -276,6 +320,10 @@ export function computeImportWindowTimes(
|
||||
}
|
||||
|
||||
let closesAt = new Date(opensAt.getTime() + cfg.windowDurationHours * 3_600_000);
|
||||
closesAt = clampCloseToOfficeHours(opensAt, closesAt, {
|
||||
windowOpenHour: cfg.windowOpenHour,
|
||||
windowCloseHour: cfg.windowCloseHour,
|
||||
});
|
||||
if (closesAt.getTime() > departure.getTime()) {
|
||||
closesAt = departure;
|
||||
}
|
||||
@@ -381,10 +429,11 @@ export function listBatchWindowsForBookings(
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Board-display windows: the REAL booking-window cycles derived from the
|
||||
// train_scheduling_global_rules config (window open hour, lead days, duration,
|
||||
// reopen delay) — NOT a fixed clock grid. Import shows each booking-window cycle
|
||||
// (opens at windowOpenHour EAT, lasts windowDurationHours, reopens after
|
||||
// reopenDelayMinutes until departure). Export shows the single FCFS lead window.
|
||||
// schedule's frozen window rule (open/close hour, lead days, duration, reopen
|
||||
// gap = doc review + payment) — NOT a fixed clock grid. Import shows each
|
||||
// booking-window cycle (opens at windowOpenHour EAT, lasts windowDurationHours
|
||||
// capped at the desk close, reopens after the gap until departure). Export shows
|
||||
// the single FCFS lead window.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** A board window carries an EAT calendar date in addition to the slot times. */
|
||||
@@ -402,8 +451,11 @@ export interface BoardWindowConfig {
|
||||
/** EAT hour the daily booking desk shuts; equals windowOpenHour for a 24h desk. */
|
||||
windowCloseHour: number;
|
||||
windowDurationHours: number;
|
||||
/** Gap between a cycle's close and its reopen (doc review + payment minutes). */
|
||||
reopenDelayMinutes: number;
|
||||
/**
|
||||
* Gap between a cycle's close and its reopen — always doc review + payment
|
||||
* minutes (the schedule's frozen snapshot, or the live sum for legacy rows).
|
||||
*/
|
||||
reopenGapMinutes: number;
|
||||
exportBookingLeadHours: number;
|
||||
}
|
||||
|
||||
@@ -435,10 +487,11 @@ function boardWindowFromInterval(start: Date, end: Date): BoardWindow {
|
||||
* The real booking-window cycles for a schedule, straight from config.
|
||||
*
|
||||
* IMPORT: first window opens at `windowOpenHour` EAT on `departure − importWindowLeadDays`
|
||||
* for `windowDurationHours`; if the train isn't full it reopens `reopenDelayMinutes`
|
||||
* after each close, on the same booking day, until departure. This mirrors
|
||||
* `computeImportWindowTimes` + `concludeCycle`'s reopen math so the board shows the
|
||||
* exact windows the engine runs.
|
||||
* for `windowDurationHours` (cut short by the desk close hour); if the train isn't
|
||||
* full it reopens `reopenGapMinutes` (doc review + payment) after each close,
|
||||
* honouring office hours, until departure. This mirrors `computeImportWindowTimes`
|
||||
* + `concludeCycle`'s reopen math so the board shows the exact windows the engine
|
||||
* runs.
|
||||
* EXPORT: a single FCFS window from `departure − exportBookingLeadHours` to departure,
|
||||
* with the open shifted to the next desk opening when it lands outside office hours
|
||||
* (same math as `computeExportWindowTimes`).
|
||||
@@ -464,7 +517,7 @@ export function listConfigBookingWindows(
|
||||
const durationMs = cfg.windowDurationHours * 3_600_000;
|
||||
// Post-close gap before the next cycle opens (doc review + payment), subject
|
||||
// to office hours below.
|
||||
const reopenMs = cfg.reopenDelayMinutes * 60_000;
|
||||
const reopenMs = cfg.reopenGapMinutes * 60_000;
|
||||
const officeHours: OfficeHours = {
|
||||
windowOpenHour: cfg.windowOpenHour,
|
||||
windowCloseHour: cfg.windowCloseHour,
|
||||
@@ -484,6 +537,7 @@ export function listConfigBookingWindows(
|
||||
for (let cycle = 0; cycle < maxCycles; cycle += 1) {
|
||||
if (opensAt.getTime() >= departure.getTime()) break;
|
||||
let closesAt = new Date(opensAt.getTime() + durationMs);
|
||||
closesAt = clampCloseToOfficeHours(opensAt, closesAt, officeHours);
|
||||
if (closesAt.getTime() > departure.getTime()) closesAt = departure;
|
||||
windows.push(boardWindowFromInterval(opensAt, closesAt));
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ describe('BookingBatchService — PAID reconcile', () => {
|
||||
};
|
||||
let trainSchedulingService: {
|
||||
tryAutoWagonAllocation: jest.Mock;
|
||||
previewPaidBookingWagonShortage: jest.Mock;
|
||||
getBookableSchedules: jest.Mock;
|
||||
getWindowConfig: jest.Mock;
|
||||
};
|
||||
@@ -87,6 +88,8 @@ describe('BookingBatchService — PAID reconcile', () => {
|
||||
issues: [],
|
||||
violations: [],
|
||||
}),
|
||||
// No shortage by default — paid bookings link as before.
|
||||
previewPaidBookingWagonShortage: jest.fn().mockResolvedValue(null),
|
||||
getBookableSchedules: jest.fn().mockResolvedValue([]),
|
||||
getWindowConfig: jest.fn().mockResolvedValue({
|
||||
importWindowLeadDays: 3,
|
||||
@@ -96,7 +99,6 @@ describe('BookingBatchService — PAID reconcile', () => {
|
||||
windowDurationHours: 3,
|
||||
docReviewMinutes: 30,
|
||||
paymentWindowMinutes: 60,
|
||||
reopenDelayMinutes: 90,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -169,6 +171,38 @@ describe('BookingBatchService — PAID reconcile', () => {
|
||||
expect(trainSchedulingService.tryAutoWagonAllocation).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('ensurePaidBookingAllocated holds a wagon-short booking out of the train', async () => {
|
||||
trainSchedulingService.previewPaidBookingWagonShortage.mockResolvedValue({
|
||||
wagonTypeCodes: 'NW6',
|
||||
wagonsNeeded: 1,
|
||||
wagonsAvailable: 0,
|
||||
wagonsShort: 1,
|
||||
});
|
||||
|
||||
await service.ensurePaidBookingAllocated(bookingId);
|
||||
|
||||
// Not linked, no wagon run — held PAID + unlinked, flagged for manual placement.
|
||||
expect(trainScheduleBookingsRepository.createMany).not.toHaveBeenCalled();
|
||||
expect(trainSchedulingService.tryAutoWagonAllocation).not.toHaveBeenCalled();
|
||||
expect(dataSource.getRepository().update).toHaveBeenCalledWith(
|
||||
bookingId,
|
||||
expect.objectContaining({ schedulingStatus: 'WAITING_FOR_WAGON' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('reconcilePaidUnlinked leaves WAITING_FOR_WAGON bookings held', async () => {
|
||||
bookingsRepository.findPaidUnlinkedForSchedule.mockResolvedValue([
|
||||
{ ...paidBooking, schedulingStatus: 'WAITING_FOR_WAGON' },
|
||||
]);
|
||||
|
||||
await service.reconcilePaidUnlinked(scheduleId);
|
||||
|
||||
expect(trainScheduleBookingsRepository.createMany).not.toHaveBeenCalled();
|
||||
expect(
|
||||
trainSchedulingService.previewPaidBookingWagonShortage,
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('processSchedule reconciles PAID-unlinked before wagon allocation', async () => {
|
||||
const fillSpy = jest.spyOn(service, 'fillSchedule').mockResolvedValue(0);
|
||||
const settleSpy = jest.spyOn(service, 'settleDueReservations').mockResolvedValue(undefined);
|
||||
|
||||
@@ -497,6 +497,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const linked =
|
||||
await this.trainScheduleBookingsRepository.existsForBooking(bookingId);
|
||||
if (!linked) {
|
||||
if (await this.holdIfWagonShort(booking.trainScheduleId, booking)) return;
|
||||
await this.allocate(booking.trainScheduleId, booking, "paid");
|
||||
this.logger.log(
|
||||
`Linked PAID booking ${booking.reference ?? bookingId} to schedule ${booking.trainScheduleId}`,
|
||||
@@ -783,6 +784,9 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const unlinked =
|
||||
await this.bookingsRepository.findPaidUnlinkedForSchedule(scheduleId);
|
||||
for (const booking of unlinked) {
|
||||
// Held on purpose (paid, no wagon free) — the cron must not undo it.
|
||||
if (booking.schedulingStatus === "WAITING_FOR_WAGON") continue;
|
||||
if (await this.holdIfWagonShort(scheduleId, booking)) continue;
|
||||
await this.allocate(scheduleId, booking, "paid");
|
||||
this.logger.log(
|
||||
`Reconciled PAID booking ${booking.reference ?? booking.id} → schedule ${scheduleId}`,
|
||||
@@ -1050,7 +1054,11 @@ export class BookingBatchService implements OnModuleInit {
|
||||
s.ruleWindowDurationHours,
|
||||
liveCfg.windowDurationHours,
|
||||
),
|
||||
reopenDelayMinutes: num(s.ruleReopenDelayMinutes, liveCfg.reopenDelayMinutes),
|
||||
// Frozen doc-review + payment sum; legacy rows fall back to the live sum.
|
||||
reopenGapMinutes: num(
|
||||
s.ruleReopenDelayMinutes,
|
||||
liveCfg.docReviewMinutes + liveCfg.paymentWindowMinutes,
|
||||
),
|
||||
importWindowLeadDays: num(
|
||||
s.ruleImportWindowLeadDays,
|
||||
liveCfg.importWindowLeadDays,
|
||||
@@ -1894,7 +1902,9 @@ export class BookingBatchService implements OnModuleInit {
|
||||
|
||||
done.add(booking.id);
|
||||
if (isPaid(booking)) {
|
||||
if (!(await this.holdIfWagonShort(scheduleId, booking))) {
|
||||
await this.allocate(scheduleId, booking, "paid");
|
||||
}
|
||||
anySettled = true;
|
||||
} else if (isExpired(booking)) {
|
||||
await this.expire(booking);
|
||||
@@ -1920,6 +1930,29 @@ export class BookingBatchService implements OnModuleInit {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Conclude-time retry: promote whatever still fits from the route-day waiting
|
||||
* list, opening fresh pay windows. Returns how many commercial units got
|
||||
* reserved — corridor-wide, since the fill is day-level and may reserve onto a
|
||||
* sibling train; the caller must check `hasLiveReservations` for its OWN
|
||||
* schedule before deciding to stay in PAYMENT.
|
||||
*/
|
||||
async fillFromWaitingList(scheduleId: string): Promise<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.
|
||||
* Returns whether anything settled.
|
||||
@@ -2050,7 +2083,9 @@ export class BookingBatchService implements OnModuleInit {
|
||||
await this.dataSource
|
||||
.getRepository(Booking)
|
||||
.update(bookingId, { paymentStatus: "PAID" });
|
||||
if (!(await this.holdIfWagonShort(booking.trainScheduleId, booking))) {
|
||||
await this.allocate(booking.trainScheduleId, booking, "paid");
|
||||
}
|
||||
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
|
||||
booking.trainScheduleId,
|
||||
@@ -2112,7 +2147,12 @@ export class BookingBatchService implements OnModuleInit {
|
||||
await manager.getRepository(Booking).update(bookingId, {
|
||||
trainScheduleId: newScheduleId,
|
||||
status: restoredStatus,
|
||||
schedulingStatus: "ELIGIBLE",
|
||||
// A paid booking still hunting for a wagon keeps its flag through the
|
||||
// move — it only clears when wagons are actually assigned.
|
||||
schedulingStatus:
|
||||
booking.schedulingStatus === "WAITING_FOR_WAGON"
|
||||
? "WAITING_FOR_WAGON"
|
||||
: "ELIGIBLE",
|
||||
paymentDeadline: null,
|
||||
selectedForBatchAt: null,
|
||||
} as never);
|
||||
@@ -2254,6 +2294,50 @@ export class BookingBatchService implements OnModuleInit {
|
||||
}
|
||||
|
||||
/** Allocate a booking to the schedule's train (creates the TrainScheduleBooking link). */
|
||||
/**
|
||||
* Fleet preflight shared by every single-booking paid-allocation path: when
|
||||
* no wagon of the booking's required type is free, hold it OUT of the train
|
||||
* instead of linking — it stays PAID + unlinked in the (route, day) pool,
|
||||
* flagged WAITING_FOR_WAGON, and staff place it on any same-day schedule from
|
||||
* the workspace "Paid · unassigned" panel once a wagon frees up. Returns true
|
||||
* when the booking was held. Consolidated pairs are exempt (the shared wagon
|
||||
* is both-or-neither and settles atomically in settleReserved).
|
||||
*/
|
||||
private async holdIfWagonShort(
|
||||
scheduleId: string,
|
||||
booking: Booking,
|
||||
): Promise<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(
|
||||
scheduleId: string,
|
||||
booking: Booking,
|
||||
@@ -2358,7 +2442,9 @@ export class BookingBatchService implements OnModuleInit {
|
||||
`[BATCH] expire skipped for ${booking.reference} — payment already ` +
|
||||
`landed; allocating on schedule ${paidScheduleId} instead`,
|
||||
);
|
||||
if (!(await this.holdIfWagonShort(paidScheduleId, fresh))) {
|
||||
await this.allocate(paidScheduleId, fresh, "paid");
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,8 +19,6 @@ export interface BookingWindowConfig {
|
||||
/** Max staff document-review time after the window closes. */
|
||||
docReviewMinutes: number;
|
||||
paymentWindowMinutes: number;
|
||||
/** Delay after window close before reopening when the train is not full. */
|
||||
reopenDelayMinutes: number;
|
||||
}
|
||||
|
||||
/** Window phase lifecycle for the one-booking-day import cycle. NULL on legacy/DOMESTIC schedules. */
|
||||
|
||||
@@ -20,6 +20,7 @@ describe('BookingWindowService — window state machine', () => {
|
||||
hasLiveReservations: jest.Mock;
|
||||
refreshWindowStatus: jest.Mock;
|
||||
expireLeftoverDayPool: jest.Mock;
|
||||
fillFromWaitingList: jest.Mock;
|
||||
};
|
||||
let trainSchedulesRepository: { findById: jest.Mock; findAll: jest.Mock };
|
||||
let trainSchedulingService: { finalizeSchedule: jest.Mock; getWindowConfig: jest.Mock };
|
||||
@@ -33,7 +34,6 @@ describe('BookingWindowService — window state machine', () => {
|
||||
windowDurationHours: 1,
|
||||
docReviewMinutes: 30,
|
||||
paymentWindowMinutes: 60,
|
||||
reopenDelayMinutes: 0,
|
||||
};
|
||||
|
||||
const baseSchedule = (over: Partial<TrainSchedule>): TrainSchedule =>
|
||||
@@ -75,6 +75,8 @@ describe('BookingWindowService — window state machine', () => {
|
||||
hasLiveReservations: jest.fn().mockResolvedValue(false),
|
||||
refreshWindowStatus: jest.fn().mockResolvedValue(undefined),
|
||||
expireLeftoverDayPool: jest.fn().mockResolvedValue(0),
|
||||
// No waiting booking fits by default, so conclude proceeds to reopen/DONE.
|
||||
fillFromWaitingList: jest.fn().mockResolvedValue(0),
|
||||
};
|
||||
trainSchedulesRepository = {
|
||||
findById: jest.fn().mockResolvedValue(null),
|
||||
@@ -123,6 +125,8 @@ describe('BookingWindowService — window state machine', () => {
|
||||
});
|
||||
|
||||
it('DOC_REVIEW → PAYMENT expires un-accepted, then runs the batch', async () => {
|
||||
// The batch reserved someone (live reservations exist) → real PAYMENT phase.
|
||||
batch.hasLiveReservations.mockResolvedValue(true);
|
||||
const s = baseSchedule({
|
||||
windowPhase: 'DOC_REVIEW',
|
||||
docReviewEndsAt: new Date('2026-07-01T01:30:00.000Z'),
|
||||
@@ -140,6 +144,7 @@ describe('BookingWindowService — window state machine', () => {
|
||||
});
|
||||
|
||||
it('DOC_REVIEW → PAYMENT also fires when staff finished review early (docReviewCompletedAt)', async () => {
|
||||
batch.hasLiveReservations.mockResolvedValue(true);
|
||||
const s = baseSchedule({
|
||||
windowPhase: 'DOC_REVIEW',
|
||||
docReviewEndsAt: new Date('2026-07-01T05:00:00.000Z'), // far future
|
||||
@@ -150,6 +155,21 @@ describe('BookingWindowService — window state machine', () => {
|
||||
expect(s.windowPhase).toBe('PAYMENT');
|
||||
});
|
||||
|
||||
it('DOC_REVIEW → batch reserves nothing → skips the empty PAYMENT phase and reopens', async () => {
|
||||
// Default hasLiveReservations=false: the batch reserved nobody. Waiting a
|
||||
// full payment window with the desk shut would serve no one — the cycle
|
||||
// concludes immediately (24h desk + far departure → straight to PRE_WINDOW).
|
||||
const s = baseSchedule({
|
||||
windowPhase: 'DOC_REVIEW',
|
||||
docReviewEndsAt: new Date('2026-07-01T01:30:00.000Z'),
|
||||
});
|
||||
const advanced = await advanceImport(s, new Date('2026-07-01T01:30:01.000Z'));
|
||||
expect(advanced).toBe(true);
|
||||
expect(batch.processRouteDay).toHaveBeenCalledTimes(1);
|
||||
expect(s.windowPhase).toBe('PRE_WINDOW');
|
||||
expect(s.windowOpensAt).not.toBeNull();
|
||||
});
|
||||
|
||||
it('PAYMENT → conclude at paymentPhaseEndsAt settles due reservations', async () => {
|
||||
const s = baseSchedule({
|
||||
windowPhase: 'PAYMENT',
|
||||
@@ -205,6 +225,22 @@ describe('BookingWindowService — window state machine', () => {
|
||||
expect(trainSchedulingService.finalizeSchedule).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('conclude: waiting booking still fits → fresh pay window, back to PAYMENT, no reopen', async () => {
|
||||
batch.isScheduleFull.mockResolvedValue(false);
|
||||
batch.fillFromWaitingList.mockResolvedValue(2);
|
||||
batch.hasLiveReservations.mockResolvedValue(true);
|
||||
const s = baseSchedule({
|
||||
windowPhase: 'PAYMENT',
|
||||
scheduledDepartureDate: new Date('2026-08-01T06:00:00.000Z'),
|
||||
});
|
||||
const now = new Date('2026-07-01T02:30:05.000Z');
|
||||
await concludeCycle(s, now);
|
||||
expect(batch.fillFromWaitingList).toHaveBeenCalledWith(scheduleId);
|
||||
expect(s.windowPhase).toBe('PAYMENT');
|
||||
// Fresh pay window from `now`, not a reopen.
|
||||
expect(s.paymentPhaseEndsAt).toEqual(new Date(now.getTime() + 60 * 60_000));
|
||||
});
|
||||
|
||||
it('conclude: NOT full but NO cycle fits before departure → DONE', async () => {
|
||||
batch.isScheduleFull.mockResolvedValue(false);
|
||||
const s = baseSchedule({
|
||||
|
||||
@@ -17,7 +17,12 @@ import { BookingBatchService } from './booking-batch.service';
|
||||
import { BookingWindowGateway } from './booking-window.gateway';
|
||||
import { TrainSchedulingService, effectiveWindowConfig } from './train-scheduling.service';
|
||||
import { BATCH_TIMEZONE } from './booking-batch.constants';
|
||||
import { eatDay, nextCycleOpensAt, type OfficeHours } from './batch-window.util';
|
||||
import {
|
||||
clampCloseToOfficeHours,
|
||||
eatDay,
|
||||
nextCycleOpensAt,
|
||||
type OfficeHours,
|
||||
} from './batch-window.util';
|
||||
import { type BookingWindowConfig } from './booking-window.config';
|
||||
|
||||
/**
|
||||
@@ -297,6 +302,17 @@ export class BookingWindowService implements OnModuleInit {
|
||||
// (or allocating government) — skipped automatically for everyone who fits
|
||||
// is handled inside the fill (all fit → all reserved → all notified).
|
||||
await this.bookingBatchService.processRouteDay(routeDay);
|
||||
// Batch reserved nobody (empty pool, or it allocated without pay windows):
|
||||
// a PAYMENT phase with nobody to pay is a dead hour with the window shut.
|
||||
// Conclude straight away — full → DONE, otherwise reopen per office hours.
|
||||
if (!(await this.bookingBatchService.hasLiveReservations(schedule.id))) {
|
||||
this.logger.log(
|
||||
`[WINDOW] ${schedule.id} DOC_REVIEW→PAYMENT — batch reserved nothing; ` +
|
||||
`skipping the empty payment phase and concluding the cycle`,
|
||||
);
|
||||
await this.concludeCycle(schedule, cfg, now);
|
||||
return true;
|
||||
}
|
||||
this.logger.log(
|
||||
`[WINDOW] ${schedule.id} DOC_REVIEW→PAYMENT — batch ran; payment phase ` +
|
||||
`until ${paymentPhaseEndsAt.toISOString()}`,
|
||||
@@ -353,7 +369,10 @@ export class BookingWindowService implements OnModuleInit {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** After settle: full → finalize + DONE; space left → reopen same day or close for the day. */
|
||||
/**
|
||||
* After settle: full → finalize + DONE; waiting bookings still fit → fresh pay
|
||||
* window, back to PAYMENT; otherwise reopen (office hours decide when) or DONE.
|
||||
*/
|
||||
private async concludeCycle(
|
||||
schedule: TrainSchedule,
|
||||
cfg: BookingWindowConfig,
|
||||
@@ -386,6 +405,31 @@ export class BookingWindowService implements OnModuleInit {
|
||||
if (fresh) schedule.bookingWindowStatus = fresh.bookingWindowStatus;
|
||||
}
|
||||
|
||||
// The window reopens only once the waiting list is exhausted: a booking can
|
||||
// still reach the pool mid-payment (late doc accept, consolidation partner),
|
||||
// so retry the batch before reopening. Anything that fits gets a fresh pay
|
||||
// window and the cycle stays in PAYMENT; check live reservations on THIS
|
||||
// schedule because the day-level fill may have reserved onto a sibling.
|
||||
// Waiting bookings that fit no train stay pooled and the window reopens.
|
||||
const promoted = await this.bookingBatchService.fillFromWaitingList(schedule.id);
|
||||
if (
|
||||
promoted > 0 &&
|
||||
(await this.bookingBatchService.hasLiveReservations(schedule.id))
|
||||
) {
|
||||
let paymentPhaseEndsAt = new Date(
|
||||
now.getTime() + cfg.paymentWindowMinutes * 60_000,
|
||||
);
|
||||
if (paymentPhaseEndsAt > schedule.scheduledDepartureDate) {
|
||||
paymentPhaseEndsAt = schedule.scheduledDepartureDate;
|
||||
}
|
||||
await this.setPhase(schedule, { windowPhase: 'PAYMENT', paymentPhaseEndsAt });
|
||||
this.logger.log(
|
||||
`[WINDOW] ${schedule.id} conclude → waiting list still had bookings that ` +
|
||||
`fit — back in PAYMENT until ${paymentPhaseEndsAt.toISOString()}, no reopen yet`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Doc review + payment have already run, so the desk is ready to reopen NOW —
|
||||
// office hours decide whether that is this afternoon or tomorrow morning. Past
|
||||
// the last cycle before departure, nextCycleOpensAt returns null and we finish.
|
||||
@@ -413,6 +457,9 @@ export class BookingWindowService implements OnModuleInit {
|
||||
let nextClosesAt = new Date(
|
||||
nextOpensAt.getTime() + cfg.windowDurationHours * 3_600_000,
|
||||
);
|
||||
// Office hours end a running window early: never let the duration outlive
|
||||
// the desk close (open 16:00, 3h, desk 8–17 → closes 17:00).
|
||||
nextClosesAt = clampCloseToOfficeHours(nextOpensAt, nextClosesAt, officeHours);
|
||||
if (nextClosesAt > schedule.scheduledDepartureDate) {
|
||||
nextClosesAt = schedule.scheduledDepartureDate;
|
||||
}
|
||||
|
||||
@@ -95,11 +95,4 @@ export class UpdateTrainSchedulingGlobalRulesDto {
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
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 })
|
||||
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);
|
||||
});
|
||||
|
||||
it('names the booking and its per-type shortfall when the deferral carries a shortage', () => {
|
||||
const warnings = summarizeFleetWarnings(
|
||||
[],
|
||||
[
|
||||
{
|
||||
id: 'b1',
|
||||
reference: 'BKG-1',
|
||||
reason: 'No available NW6 wagon at the yard',
|
||||
shortage: {
|
||||
wagonTypeCodes: 'NW6',
|
||||
wagonsNeeded: 2,
|
||||
wagonsAvailable: 1,
|
||||
wagonsShort: 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
expect(
|
||||
warnings.some(
|
||||
(w) =>
|
||||
w.includes('BKG-1') &&
|
||||
w.includes('2 × NW6') &&
|
||||
w.includes('only 1 available') &&
|
||||
w.includes('short 1'),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('counts wagons required per booking from container lines', () => {
|
||||
const booking = makeBooking('b1', {
|
||||
bookingContainers: [
|
||||
|
||||
@@ -17,10 +17,21 @@ export type FleetAvailabilityRow = {
|
||||
shortfall: number;
|
||||
};
|
||||
|
||||
/** Per-booking wagon shortage: how many wagons of which type this booking still lacks. */
|
||||
export type BookingWagonShortage = {
|
||||
/** Candidate wagon-type codes usable by the booking, joined ("NW6" or "NW6/CW3"). */
|
||||
wagonTypeCodes: string;
|
||||
wagonsNeeded: number;
|
||||
wagonsAvailable: number;
|
||||
wagonsShort: number;
|
||||
};
|
||||
|
||||
export type DeferredBookingRow = {
|
||||
id: string;
|
||||
reference: string;
|
||||
reason: string;
|
||||
/** Set when the deferral is a fleet-stock shortage (absent for config issues). */
|
||||
shortage?: BookingWagonShortage | null;
|
||||
};
|
||||
|
||||
export function sortBookingsForScheduling(bookings: Booking[]): Booking[] {
|
||||
@@ -156,6 +167,16 @@ export function summarizeFleetWarnings(
|
||||
);
|
||||
}
|
||||
|
||||
// Name the bookings the shortage actually hits, with their own per-type counts,
|
||||
// so staff know WHAT is held out — not just that the pool is short overall.
|
||||
for (const row of deferred) {
|
||||
if (!row.shortage) continue;
|
||||
warnings.push(
|
||||
`Booking ${row.reference} held out: needs ${row.shortage.wagonsNeeded} × ${row.shortage.wagonTypeCodes}, ` +
|
||||
`only ${row.shortage.wagonsAvailable} available (short ${row.shortage.wagonsShort})`,
|
||||
);
|
||||
}
|
||||
|
||||
if (deferred.length) {
|
||||
warnings.push(
|
||||
`${deferred.length} booking(s) deferred to next train due to insufficient fleet wagons`,
|
||||
|
||||
@@ -99,6 +99,7 @@ import {
|
||||
summarizeFleetWarnings,
|
||||
totalAssignedWeight,
|
||||
wagonsRequiredForBooking,
|
||||
type BookingWagonShortage,
|
||||
type DeferredBookingRow,
|
||||
type FleetAvailabilityRow,
|
||||
} from './fleet-plan.util';
|
||||
@@ -214,8 +215,6 @@ export function effectiveWindowConfig(
|
||||
: liveCfg.windowDurationHours,
|
||||
docReviewMinutes: liveCfg.docReviewMinutes,
|
||||
paymentWindowMinutes: liveCfg.paymentWindowMinutes,
|
||||
reopenDelayMinutes:
|
||||
schedule.ruleReopenDelayMinutes ?? liveCfg.reopenDelayMinutes,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -251,6 +250,8 @@ export interface CompositionUnassignedBookingRow {
|
||||
yardWagonsAvailable: number;
|
||||
canAssign: boolean;
|
||||
blockReason: string | null;
|
||||
/** Structured fleet shortage when the block is missing wagons (null otherwise). */
|
||||
shortage: BookingWagonShortage | null;
|
||||
}
|
||||
|
||||
export interface UnassignedBookingsResponse {
|
||||
@@ -613,7 +614,6 @@ export class TrainSchedulingService {
|
||||
if (dto.windowDurationHours != null) row.windowDurationHours = dto.windowDurationHours;
|
||||
if (dto.docReviewMinutes != null) row.docReviewMinutes = dto.docReviewMinutes;
|
||||
if (dto.paymentWindowMinutes != null) row.paymentWindowMinutes = dto.paymentWindowMinutes;
|
||||
if (dto.reopenDelayMinutes != null) row.reopenDelayMinutes = dto.reopenDelayMinutes;
|
||||
|
||||
// The booking desk supports three shapes: a same-day range
|
||||
// (closeHour > openHour), a 24-hour desk (openHour === closeHour), and an
|
||||
@@ -702,7 +702,6 @@ export class TrainSchedulingService {
|
||||
// override changes them, so the derived snapshot delay stays consistent.
|
||||
docReviewMinutes: dto.docReviewMinutes ?? liveCfg.docReviewMinutes,
|
||||
paymentWindowMinutes: dto.paymentWindowMinutes ?? liveCfg.paymentWindowMinutes,
|
||||
reopenDelayMinutes: liveCfg.reopenDelayMinutes,
|
||||
};
|
||||
|
||||
// Same-day, 24-hour, and overnight (openHour > closeHour) desks are all valid
|
||||
@@ -929,7 +928,6 @@ export class TrainSchedulingService {
|
||||
windowDurationHours: num(row?.windowDurationHours, 3),
|
||||
docReviewMinutes: num(row?.docReviewMinutes, 30),
|
||||
paymentWindowMinutes: num(row?.paymentWindowMinutes, 60),
|
||||
reopenDelayMinutes: num(row?.reopenDelayMinutes, 90),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1079,6 +1077,20 @@ export class TrainSchedulingService {
|
||||
// getSchedulableRoute already rejected DOMESTIC (intercity).
|
||||
const direction = this.resolveRouteDirection(route);
|
||||
|
||||
// Direction-matched fixed number from the built train's typed pair.
|
||||
// Legacy locomotive-picked schedules keep dispatch-time pool assignment
|
||||
// (assignTrainNumber is idempotent, so both paths compose).
|
||||
const pairTrainNumber = builtTrain
|
||||
? (direction === 'IMPORT'
|
||||
? builtTrain.importTrainNumber
|
||||
: builtTrain.exportTrainNumber) ?? null
|
||||
: null;
|
||||
if (builtTrain && !pairTrainNumber) {
|
||||
scheduleWarnings.push(
|
||||
`Train ${builtTrain.code} has no ${direction === 'IMPORT' ? 'import' : 'export'} train number; a pool number will be assigned at dispatch`,
|
||||
);
|
||||
}
|
||||
|
||||
const trainSet = await this.buildEmptyTrainSet(
|
||||
manager,
|
||||
lockedLocomotives,
|
||||
@@ -1174,6 +1186,7 @@ export class TrainSchedulingService {
|
||||
scheduledDepartureDate: departure,
|
||||
status: TrainScheduleStatusEnum.Draft,
|
||||
direction,
|
||||
trainNumber: pairTrainNumber ?? undefined,
|
||||
maxWagons,
|
||||
...windowFields,
|
||||
}),
|
||||
@@ -2684,7 +2697,23 @@ export class TrainSchedulingService {
|
||||
manager: EntityManager,
|
||||
schedule: TrainSchedule,
|
||||
): Promise<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.
|
||||
let containerWagons = 0;
|
||||
@@ -2705,17 +2734,38 @@ export class TrainSchedulingService {
|
||||
|
||||
// Lock the set of currently-active numbered schedules so two concurrent
|
||||
// dispatches serialize and can't both claim the same lowest-free number.
|
||||
// DRAFT/SCHEDULED are included because pair numbers are now assigned at
|
||||
// creation and must be invisible to pool picks.
|
||||
const activeNumbered = await manager
|
||||
.getRepository(TrainSchedule)
|
||||
.createQueryBuilder('schedule')
|
||||
.setLock('pessimistic_write')
|
||||
.where('schedule.status = :status', { status: TrainScheduleStatusEnum.Dispatched })
|
||||
.where('schedule.status IN (:...statuses)', {
|
||||
statuses: [
|
||||
TrainScheduleStatusEnum.Draft,
|
||||
TrainScheduleStatusEnum.Scheduled,
|
||||
TrainScheduleStatusEnum.Dispatched,
|
||||
],
|
||||
})
|
||||
.andWhere('schedule.train_number IS NOT NULL')
|
||||
.getMany();
|
||||
|
||||
const usedNumbers = activeNumbered
|
||||
// Every typed train pair is reserved for its train — the pool may never
|
||||
// hand one out, even when that train has no active schedule right now.
|
||||
const pairRows: { n: string }[] = await manager.query(
|
||||
`SELECT import_train_number AS n FROM freight.trains
|
||||
WHERE deleted_at IS NULL AND import_train_number IS NOT NULL
|
||||
UNION
|
||||
SELECT export_train_number FROM freight.trains
|
||||
WHERE deleted_at IS NULL AND export_train_number IS NOT NULL`,
|
||||
);
|
||||
|
||||
const usedNumbers = [
|
||||
...activeNumbered
|
||||
.map((s) => s.trainNumber)
|
||||
.filter((n): n is string => Boolean(n));
|
||||
.filter((n): n is string => Boolean(n)),
|
||||
...pairRows.map((row) => row.n),
|
||||
];
|
||||
|
||||
const number = pickLowestFreeNumber(pool.numbers, usedNumbers);
|
||||
if (!number) {
|
||||
@@ -4624,6 +4674,8 @@ export class TrainSchedulingService {
|
||||
code: train.code,
|
||||
trainName: train.trainName ?? null,
|
||||
status: train.status,
|
||||
importTrainNumber: train.importTrainNumber ?? null,
|
||||
exportTrainNumber: train.exportTrainNumber ?? null,
|
||||
currentYardId: train.currentYardId ?? null,
|
||||
currentYard: train.currentYard
|
||||
? {
|
||||
@@ -5522,7 +5574,7 @@ export class TrainSchedulingService {
|
||||
// Per-schedule booking-window rule snapshot — powers the "Booking window
|
||||
// settings" editor on the ops board (prefill + save one schedule's
|
||||
// override). docReview/payment are not snapshotted per schedule (only their
|
||||
// sum, as reopenDelayMinutes), so the editor prefills them from live config.
|
||||
// sum, as the frozen reopen gap), so the editor prefills them from live config.
|
||||
windowRule: {
|
||||
windowOpenHour: schedule.ruleWindowOpenHour ?? null,
|
||||
windowCloseHour: schedule.ruleWindowCloseHour ?? null,
|
||||
@@ -5530,7 +5582,6 @@ export class TrainSchedulingService {
|
||||
schedule.ruleWindowDurationHours != null
|
||||
? Number(schedule.ruleWindowDurationHours)
|
||||
: null,
|
||||
reopenDelayMinutes: schedule.ruleReopenDelayMinutes ?? null,
|
||||
importWindowLeadDays: schedule.ruleImportWindowLeadDays ?? null,
|
||||
exportBookingLeadHours: schedule.ruleExportBookingLeadHours ?? null,
|
||||
docReviewMinutes: windowCfg.docReviewMinutes,
|
||||
@@ -6158,6 +6209,7 @@ export class TrainSchedulingService {
|
||||
yardWagonsAvailable: number;
|
||||
canAssign: boolean;
|
||||
blockReason: string | null;
|
||||
shortage: BookingWagonShortage | null;
|
||||
}> {
|
||||
if (!schedule.trainSet?.locomotive) {
|
||||
return {
|
||||
@@ -6166,6 +6218,7 @@ export class TrainSchedulingService {
|
||||
yardWagonsAvailable: 0,
|
||||
canAssign: false,
|
||||
blockReason: 'Schedule has no locomotive',
|
||||
shortage: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6186,6 +6239,7 @@ export class TrainSchedulingService {
|
||||
yardWagonsAvailable: 0,
|
||||
canAssign: false,
|
||||
blockReason: 'No suitable wagon type found',
|
||||
shortage: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6230,6 +6284,7 @@ export class TrainSchedulingService {
|
||||
yardWagonsAvailable,
|
||||
canAssign: false,
|
||||
blockReason: err instanceof Error ? err.message : 'Validation failed',
|
||||
shortage: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6240,6 +6295,7 @@ export class TrainSchedulingService {
|
||||
yardWagonsAvailable,
|
||||
canAssign: false,
|
||||
blockReason: validation.violations[0] ?? 'Booking validation failed',
|
||||
shortage: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6259,6 +6315,16 @@ export class TrainSchedulingService {
|
||||
deferred?.reason ??
|
||||
yardShortfall ??
|
||||
`Need ${wagonsRequired} ${requiredWagonTypeCode} wagon(s) at origin yard`,
|
||||
shortage:
|
||||
deferred?.shortage ??
|
||||
(yardShortfall
|
||||
? {
|
||||
wagonTypeCodes: requiredWagonTypeCode,
|
||||
wagonsNeeded: wagonsRequired,
|
||||
wagonsAvailable: yardWagonsAvailable,
|
||||
wagonsShort: Math.max(1, wagonsRequired - yardWagonsAvailable),
|
||||
}
|
||||
: null),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6277,6 +6343,7 @@ export class TrainSchedulingService {
|
||||
yardWagonsAvailable,
|
||||
canAssign: false,
|
||||
blockReason: missing.issue,
|
||||
shortage: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -6287,9 +6354,49 @@ export class TrainSchedulingService {
|
||||
yardWagonsAvailable,
|
||||
canAssign: true,
|
||||
blockReason: null,
|
||||
shortage: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fleet-shortage preflight for a PAID booking targeting a schedule: the
|
||||
* structured per-type shortage this booking would hit if placed on top of the
|
||||
* schedule's current wagon assignments, or null when it fits (or is blocked
|
||||
* by something other than missing wagons — those keep the legacy link-then-
|
||||
* fix-manually path).
|
||||
*/
|
||||
async previewPaidBookingWagonShortage(
|
||||
scheduleId: string,
|
||||
bookingId: string,
|
||||
): Promise<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. */
|
||||
private isReadyToLoadBooking(booking: {
|
||||
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 { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||
import { sortBookingsForScheduling, type DeferredBookingRow } from './fleet-plan.util';
|
||||
import {
|
||||
sortBookingsForScheduling,
|
||||
type BookingWagonShortage,
|
||||
type DeferredBookingRow,
|
||||
} from './fleet-plan.util';
|
||||
import {
|
||||
MAX_TEU_SLOTS_PER_WAGON,
|
||||
containerWagonsForLines,
|
||||
expandBookingContainerUnits,
|
||||
roundTons,
|
||||
tareTonsOf,
|
||||
@@ -55,7 +60,12 @@ type OpenSlot = {
|
||||
freeCapacityTons: number;
|
||||
};
|
||||
|
||||
type PlacementProblem = { kind: 'config' | 'stock'; message: string };
|
||||
type PlacementProblem = {
|
||||
kind: 'config' | 'stock';
|
||||
message: string;
|
||||
/** Wagon types the failing placement could have used (stock problems only). */
|
||||
candidates?: WagonType[];
|
||||
};
|
||||
|
||||
const slotFromWagonType = (wagonType: WagonType, kind: SlotLoadType): WagonPlanSlot => ({
|
||||
sequenceNo: 0, // stamped at the end
|
||||
@@ -69,6 +79,38 @@ const slotFromWagonType = (wagonType: WagonType, kind: SlotLoadType): WagonPlanS
|
||||
slotLoadType: kind,
|
||||
});
|
||||
|
||||
/**
|
||||
* Booking-level shortage against the wagon types the failing placement could
|
||||
* use: wagons the whole booking needs vs stock left for those types. Container
|
||||
* counts are TEU-packed per booking; bulk divides by the largest candidate.
|
||||
*/
|
||||
const shortageFor = (
|
||||
booking: Booking,
|
||||
candidates: WagonType[],
|
||||
remaining: Map<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 = (
|
||||
slot: WagonPlanSlot,
|
||||
bookingId: string,
|
||||
@@ -120,7 +162,9 @@ export function planWagonsWithStock(params: {
|
||||
cargoTypeId: string | null,
|
||||
): OpenSlot | PlacementProblem => {
|
||||
const inStock = candidates.filter((wt) => (remaining.get(wt.id) ?? 0) > 0);
|
||||
if (!inStock.length) return { kind: 'stock', message: noStockMessage(candidates) };
|
||||
if (!inStock.length) {
|
||||
return { kind: 'stock', message: noStockMessage(candidates), candidates };
|
||||
}
|
||||
// Bulk favors the largest wagon (fewest wagons for the tonnage); containers
|
||||
// favor the deepest stock so the consist drains evenly. Ties keep config order.
|
||||
const chosen = [...inStock].sort((a, b) =>
|
||||
@@ -271,7 +315,21 @@ export function planWagonsWithStock(params: {
|
||||
});
|
||||
|
||||
if (problem.kind === 'config') configIssues.add(problem.message);
|
||||
deferred.push({ id: booking.id, reference: booking.reference, reason: problem.message });
|
||||
// remaining is rolled back here, so the shortage counts the stock this
|
||||
// booking actually saw — not what its own partial placement consumed.
|
||||
const shortage =
|
||||
problem.kind === 'stock' && problem.candidates?.length
|
||||
? shortageFor(booking, problem.candidates, remaining)
|
||||
: null;
|
||||
deferred.push({
|
||||
id: booking.id,
|
||||
reference: booking.reference,
|
||||
reason: shortage
|
||||
? `${problem.message} — needs ${shortage.wagonsNeeded} × ${shortage.wagonTypeCodes}, ` +
|
||||
`${shortage.wagonsAvailable} available (short ${shortage.wagonsShort})`
|
||||
: problem.message,
|
||||
shortage,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
Matches,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
|
||||
@@ -14,6 +15,22 @@ export class BuildTrainDto {
|
||||
@MaxLength(32)
|
||||
code!: string;
|
||||
|
||||
@ApiProperty({ example: '8001', description: 'EXPORT run number (odd, unique across trains)' })
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
@Matches(/^\d*[13579]$/, {
|
||||
message: 'Export train number must be numeric and odd (e.g. 8001)',
|
||||
})
|
||||
exportTrainNumber!: string;
|
||||
|
||||
@ApiProperty({ example: '8002', description: 'IMPORT run number (even, unique across trains)' })
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
@Matches(/^\d*[02468]$/, {
|
||||
message: 'Import train number must be numeric and even (e.g. 8002)',
|
||||
})
|
||||
importTrainNumber!: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid', description: 'Yard the train is built in' })
|
||||
@IsUUID()
|
||||
currentYardId!: string;
|
||||
|
||||
@@ -32,6 +32,14 @@ export class Train extends BaseEntity {
|
||||
@Column({ name: 'notes', type: 'text', nullable: true })
|
||||
notes?: string | null;
|
||||
|
||||
/** Fixed IMPORT (even) run number typed at build time; unique via partial index. */
|
||||
@Column({ name: 'import_train_number', type: 'varchar', length: 20, nullable: true })
|
||||
importTrainNumber!: string | null;
|
||||
|
||||
/** Fixed EXPORT (odd) run number typed at build time; unique via partial index. */
|
||||
@Column({ name: 'export_train_number', type: 'varchar', length: 20, nullable: true })
|
||||
exportTrainNumber!: string | null;
|
||||
|
||||
// --- new required fields ---
|
||||
@Column({ name: 'train_number', type: 'varchar', length: 20, unique: true, nullable: true })
|
||||
trainNumber?: string;
|
||||
|
||||
@@ -27,6 +27,15 @@ import {
|
||||
|
||||
const round = (value: unknown) => Math.round((Number(value) || 0) * 100) / 100;
|
||||
|
||||
/** The one active (DRAFT/SCHEDULED/DISPATCHED) schedule surfaced per built train. */
|
||||
export interface ActiveScheduleRef {
|
||||
id: string;
|
||||
status: string;
|
||||
reference: string | null;
|
||||
direction: string | null;
|
||||
trainNumber: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Train Builder — assembles persistent fleet trains (code + 2+ locomotives +
|
||||
* ordered wagons, all in one yard) that scheduling can later reference as a
|
||||
@@ -56,6 +65,25 @@ export class TrainBuilderService {
|
||||
throw new ConflictException(`Train code ${code} is already in use`);
|
||||
}
|
||||
|
||||
// Friendly 409 before the partial unique indexes (the race-proof backstop):
|
||||
// the typed pair may not collide with any train's pair or legacy number.
|
||||
const importTrainNumber = dto.importTrainNumber.trim();
|
||||
const exportTrainNumber = dto.exportTrainNumber.trim();
|
||||
const numberClash: { code: string }[] = await manager.query(
|
||||
`SELECT code FROM freight.trains
|
||||
WHERE deleted_at IS NULL
|
||||
AND (import_train_number IN ($1, $2)
|
||||
OR export_train_number IN ($1, $2)
|
||||
OR train_number IN ($1, $2))
|
||||
LIMIT 1`,
|
||||
[importTrainNumber, exportTrainNumber],
|
||||
);
|
||||
if (numberClash.length) {
|
||||
throw new ConflictException(
|
||||
`Train number ${importTrainNumber}/${exportTrainNumber} is already used by train ${numberClash[0].code}`,
|
||||
);
|
||||
}
|
||||
|
||||
const yard = await manager.getRepository(Yard).findOne({ where: { id: dto.currentYardId } });
|
||||
if (!yard) throw new NotFoundException(`Yard ${dto.currentYardId} not found`);
|
||||
|
||||
@@ -76,6 +104,8 @@ export class TrainBuilderService {
|
||||
status: Freight.TrainStatus.Available,
|
||||
trainName: dto.trainName?.trim() || undefined,
|
||||
notes: dto.notes?.trim() || undefined,
|
||||
importTrainNumber,
|
||||
exportTrainNumber,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -117,12 +147,42 @@ export class TrainBuilderService {
|
||||
take,
|
||||
});
|
||||
|
||||
const activeByTrain = await this.loadActiveScheduleByTrain(trains.map((t) => t.id));
|
||||
|
||||
return {
|
||||
items: trains.map((train) => this.mapSummary(train)),
|
||||
items: trains.map((train) => this.mapSummary(train, activeByTrain.get(train.id) ?? null)),
|
||||
meta: buildPaginationMeta(total, page, pageSize),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* One ACTIVE schedule per train for the page (prefer the DISPATCHED run,
|
||||
* else the earliest upcoming departure) — feeds the list's direction tint
|
||||
* and in-use train number.
|
||||
*/
|
||||
private async loadActiveScheduleByTrain(
|
||||
trainIds: string[],
|
||||
): Promise<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. */
|
||||
async getComposition(id: string) {
|
||||
const train = await this.dataSource.getRepository(Train).findOne({
|
||||
@@ -139,9 +199,9 @@ export class TrainBuilderService {
|
||||
});
|
||||
if (!train) throw new NotFoundException(`Train ${id} not found`);
|
||||
|
||||
const schedules: { id: string; status: string; reference: string | null }[] =
|
||||
await this.dataSource.query(
|
||||
`SELECT ts.id, ts.status, ts.reference
|
||||
const schedules: ActiveScheduleRef[] = await this.dataSource.query(
|
||||
`SELECT ts.id, ts.status, ts.reference, ts.direction,
|
||||
ts.train_number AS "trainNumber"
|
||||
FROM freight.train_schedules ts
|
||||
JOIN freight.train_sets tset ON tset.id = ts.train_set_id
|
||||
WHERE tset.train_id = $1
|
||||
@@ -212,6 +272,8 @@ export class TrainBuilderService {
|
||||
code: train.code,
|
||||
trainName: train.trainName ?? null,
|
||||
status: train.status,
|
||||
importTrainNumber: train.importTrainNumber ?? null,
|
||||
exportTrainNumber: train.exportTrainNumber ?? null,
|
||||
notes: train.notes ?? null,
|
||||
createdAt: train.createdAt,
|
||||
currentYard: train.currentYard
|
||||
@@ -407,7 +469,7 @@ export class TrainBuilderService {
|
||||
|
||||
// ---------------------------------------------------------------- internals
|
||||
|
||||
private mapSummary(train: Train) {
|
||||
private mapSummary(train: Train, activeSchedule: ActiveScheduleRef | null) {
|
||||
const locomotives = [...(train.locomotives ?? [])]
|
||||
.sort((a, b) => a.sequenceNo - b.sequenceNo)
|
||||
.map((link) => link.locomotive)
|
||||
@@ -421,6 +483,9 @@ export class TrainBuilderService {
|
||||
code: train.code,
|
||||
trainName: train.trainName ?? null,
|
||||
status: train.status,
|
||||
importTrainNumber: train.importTrainNumber ?? null,
|
||||
exportTrainNumber: train.exportTrainNumber ?? null,
|
||||
activeSchedule,
|
||||
createdAt: train.createdAt,
|
||||
currentYard: train.currentYard
|
||||
? { id: train.currentYard.id, code: train.currentYard.code, label: train.currentYard.label }
|
||||
|
||||
@@ -17,7 +17,8 @@ import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
import { CountdownTimer } from "@edr/ui-common";
|
||||
import { CountdownTimer, bookingWindowUiState } from "@edr/ui-common";
|
||||
import type { BookingWindowUiKind } from "@edr/ui-common";
|
||||
|
||||
import { useBookingWindowSocket } from "@/features/bookingWindows/useBookingWindowSocket";
|
||||
import { api } from "@/services/api";
|
||||
@@ -81,51 +82,40 @@ function windowLabel(w: WindowRow): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* The countdown for whichever phase the window is currently in, mirroring the
|
||||
* customer portal. `expiredText` names the NEXT step so a deadline that lapses
|
||||
* between refetches announces what comes next rather than the bare "Expired".
|
||||
* The countdown for the window's UI state, mirroring the customer portal.
|
||||
* Derived from the SAME state as the badge (`bookingWindowUiState`) so they
|
||||
* can never contradict — a full train shows no ticking countdown.
|
||||
* `expiredText` names the NEXT step so a deadline that lapses between
|
||||
* refetches announces what comes next rather than the bare "Expired".
|
||||
*/
|
||||
const COUNTDOWN_TEXT: Partial<
|
||||
Record<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(
|
||||
w: WindowRow,
|
||||
): { label: string; deadline: string; expiredText: string } | null {
|
||||
switch (w.windowPhase) {
|
||||
case "PRE_WINDOW":
|
||||
return w.windowOpensAt
|
||||
? {
|
||||
label: "Opens in",
|
||||
deadline: w.windowOpensAt,
|
||||
expiredText: "Opening now…",
|
||||
}
|
||||
: null;
|
||||
case "OPEN":
|
||||
return w.windowClosesAt
|
||||
? {
|
||||
label: "Closes in",
|
||||
deadline: w.windowClosesAt,
|
||||
expiredText: "Review starting…",
|
||||
}
|
||||
: null;
|
||||
case "DOC_REVIEW":
|
||||
return w.docReviewEndsAt
|
||||
? {
|
||||
label: "Doc review ends in",
|
||||
deadline: w.docReviewEndsAt,
|
||||
expiredText: "Payment starting…",
|
||||
}
|
||||
: null;
|
||||
case "PAYMENT":
|
||||
return w.paymentPhaseEndsAt
|
||||
? {
|
||||
label: "Payment ends in",
|
||||
deadline: w.paymentPhaseEndsAt,
|
||||
expiredText: "Closing…",
|
||||
}
|
||||
: null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
const state = bookingWindowUiState(w);
|
||||
const text = COUNTDOWN_TEXT[state.kind];
|
||||
if (!state.countdownTo || !text) return null;
|
||||
return { ...text, deadline: state.countdownTo };
|
||||
}
|
||||
|
||||
/** Badge label + Mantine color per UI state — same state the countdown uses. */
|
||||
const KIND_BADGE: Record<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
|
||||
* client clock. The server query already excludes terminal / departed rows;
|
||||
@@ -139,7 +129,9 @@ function isPast(w: WindowRow): boolean {
|
||||
|
||||
function WindowCard({ w }: { w: WindowRow }) {
|
||||
const cd = phaseCountdown(w);
|
||||
const open = w.isOpenNow;
|
||||
const state = bookingWindowUiState(w);
|
||||
const badge = KIND_BADGE[state.kind];
|
||||
const open = state.isBookable;
|
||||
const isImport = w.direction === "IMPORT";
|
||||
|
||||
return (
|
||||
@@ -177,13 +169,11 @@ function WindowCard({ w }: { w: WindowRow }) {
|
||||
)}
|
||||
<Badge
|
||||
variant={open ? "filled" : "light"}
|
||||
color={open ? "edr-green" : "gray"}
|
||||
color={badge.color}
|
||||
radius="sm"
|
||||
size="sm"
|
||||
>
|
||||
{open
|
||||
? "Open now"
|
||||
: (w.windowPhase ?? w.bookingWindowStatus).replace(/_/g, " ")}
|
||||
{badge.label}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
|
||||
@@ -26,6 +26,10 @@ const parseError = (error: unknown, fallback: string) => {
|
||||
return fallback;
|
||||
};
|
||||
|
||||
// Run-number parity carries the trade direction: odd = export, even = import.
|
||||
const isOddNumber = (value: string) => /^\d*[13579]$/.test(value.trim());
|
||||
const isEvenNumber = (value: string) => /^\d*[02468]$/.test(value.trim());
|
||||
|
||||
/**
|
||||
* Step one of the Train Builder: give the train its operator code, pick the
|
||||
* yard it is being assembled in, and couple at least two locomotives from that
|
||||
@@ -34,6 +38,8 @@ const parseError = (error: unknown, fallback: string) => {
|
||||
export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrainModalProps) {
|
||||
const { toast } = useToast();
|
||||
const [code, setCode] = useState("");
|
||||
const [exportTrainNumber, setExportTrainNumber] = useState("");
|
||||
const [importTrainNumber, setImportTrainNumber] = useState("");
|
||||
const [trainName, setTrainName] = useState("");
|
||||
const [yardId, setYardId] = useState("");
|
||||
const [locomotiveIds, setLocomotiveIds] = useState<string[]>([]);
|
||||
@@ -57,6 +63,8 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain
|
||||
useEffect(() => {
|
||||
if (!opened) {
|
||||
setCode("");
|
||||
setExportTrainNumber("");
|
||||
setImportTrainNumber("");
|
||||
setTrainName("");
|
||||
setYardId("");
|
||||
setLocomotiveIds([]);
|
||||
@@ -72,9 +80,18 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!isOddNumber(exportTrainNumber) || !isEvenNumber(importTrainNumber)) {
|
||||
toast({
|
||||
title: "Enter both run numbers — export must be odd (e.g. 8001), import even (e.g. 8002)",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const composition = await build.mutateAsync({
|
||||
code: code.trim(),
|
||||
exportTrainNumber: exportTrainNumber.trim(),
|
||||
importTrainNumber: importTrainNumber.trim(),
|
||||
currentYardId: yardId,
|
||||
locomotiveIds,
|
||||
...(trainName.trim() ? { trainName: trainName.trim() } : {}),
|
||||
@@ -126,6 +143,34 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain
|
||||
maxLength={100}
|
||||
/>
|
||||
</Group>
|
||||
<Group grow>
|
||||
<TextInput
|
||||
label="Export train number"
|
||||
description="Odd — Ethiopia → Djibouti runs"
|
||||
placeholder="e.g. 8001"
|
||||
value={exportTrainNumber}
|
||||
onChange={(e) => setExportTrainNumber(e.currentTarget.value)}
|
||||
maxLength={20}
|
||||
error={
|
||||
exportTrainNumber && !isOddNumber(exportTrainNumber)
|
||||
? "Must be numeric and odd"
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<TextInput
|
||||
label="Import train number"
|
||||
description="Even — Djibouti → Ethiopia runs"
|
||||
placeholder="e.g. 8002"
|
||||
value={importTrainNumber}
|
||||
onChange={(e) => setImportTrainNumber(e.currentTarget.value)}
|
||||
maxLength={20}
|
||||
error={
|
||||
importTrainNumber && !isEvenNumber(importTrainNumber)
|
||||
? "Must be numeric and even"
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</Group>
|
||||
<Select
|
||||
label="Build yard"
|
||||
placeholder="Select the yard the train is assembled in"
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
import type { BuiltTrainStatus } from "@/services/trainBuilder.service";
|
||||
|
||||
/** Badge color per built-train lifecycle status (Mantine palette keys). */
|
||||
@@ -23,3 +25,17 @@ export const trainStatusLabel = (status: BuiltTrainStatus | string): string =>
|
||||
.toLowerCase()
|
||||
.replace(/_/g, " ")
|
||||
.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) ───────────────────────────────
|
||||
const assign = useMutation(api.trainScheduling.assignBookings.mutationOptions());
|
||||
const assignUnassigned = useMutation(
|
||||
api.trainScheduling.assignUnassignedBooking.mutationOptions(),
|
||||
);
|
||||
const unassign = useMutation(api.trainScheduling.unassignBooking.mutationOptions());
|
||||
const setLoading = useMutation(
|
||||
api.trainScheduling.setLoadingStatus.mutationOptions(),
|
||||
@@ -166,6 +169,12 @@ export function ScheduleWorkspacePanel({
|
||||
const [moveBookingId, setMoveBookingId] = 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(
|
||||
api.trainScheduling.bookableSchedules.queryOptions({
|
||||
input: {
|
||||
@@ -190,6 +199,22 @@ export function ScheduleWorkspacePanel({
|
||||
[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) ────────────────────
|
||||
const used = usedWeight(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 = () => {
|
||||
if (!moveBookingId || !moveTarget) return;
|
||||
moveSchedule
|
||||
@@ -465,8 +520,10 @@ export function ScheduleWorkspacePanel({
|
||||
customer={b.customer}
|
||||
weightTons={b.weightTons}
|
||||
status={b.status}
|
||||
waitingForWagon={b.schedulingStatus === "WAITING_FOR_WAGON"}
|
||||
right={
|
||||
canManage ? (
|
||||
<Group gap={6} wrap="nowrap" justify="flex-end">
|
||||
<Tooltip label="Force-add to this train" withArrow>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
@@ -479,6 +536,25 @@ export function ScheduleWorkspacePanel({
|
||||
Add
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
label="Pick any train departing this day that has wagons free"
|
||||
withArrow
|
||||
>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
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
|
||||
}
|
||||
/>
|
||||
@@ -588,6 +664,52 @@ export function ScheduleWorkspacePanel({
|
||||
</Group>
|
||||
</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 */}
|
||||
<Modal
|
||||
opened={Boolean(moveBookingId)}
|
||||
@@ -710,6 +832,7 @@ function BookingCard({
|
||||
weightTons,
|
||||
status,
|
||||
loadingStatus,
|
||||
waitingForWagon,
|
||||
right,
|
||||
}: {
|
||||
reference: string;
|
||||
@@ -717,6 +840,8 @@ function BookingCard({
|
||||
weightTons?: number | null;
|
||||
status?: string | null;
|
||||
loadingStatus?: "LOADED" | "UNLOADED";
|
||||
/** Paid, but no wagon of the required type was free — waiting for one. */
|
||||
waitingForWagon?: boolean;
|
||||
right?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
@@ -742,6 +867,16 @@ function BookingCard({
|
||||
{reference}
|
||||
</Text>
|
||||
{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 ? (
|
||||
<Badge
|
||||
size="sm"
|
||||
|
||||
@@ -32,7 +32,11 @@ import AvailableWagonsPanel from "@/components/trainBuilder/AvailableWagonsPanel
|
||||
import ChangeLocomotivesModal from "@/components/trainBuilder/ChangeLocomotivesModal";
|
||||
import ChangeYardModal from "@/components/trainBuilder/ChangeYardModal";
|
||||
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 { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
||||
import { api } from "@/services/api";
|
||||
@@ -128,9 +132,17 @@ export default function TrainBuilderDetailPage() {
|
||||
}
|
||||
backTo="/dashboard/train-builder"
|
||||
meta={
|
||||
<Group gap="xs">
|
||||
<Badge color={trainStatusColor(composition.status)} variant="light">
|
||||
{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={
|
||||
<Menu position="bottom-end" withinPortal shadow="md" width={220}>
|
||||
@@ -289,6 +301,16 @@ export default function TrainBuilderDetailPage() {
|
||||
<Text size="sm" ff="monospace" fw={600}>
|
||||
{schedule.reference ?? schedule.id.slice(0, 8)}
|
||||
</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">
|
||||
{schedule.status}
|
||||
</Badge>
|
||||
|
||||
@@ -27,7 +27,12 @@ import { useNavigate } from "react-router-dom";
|
||||
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
||||
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||||
import BuildTrainModal from "@/components/trainBuilder/BuildTrainModal";
|
||||
import { trainStatusColor, trainStatusLabel } from "@/components/trainBuilder/trainStatus";
|
||||
import {
|
||||
directionColor,
|
||||
directionRowStyle,
|
||||
trainStatusColor,
|
||||
trainStatusLabel,
|
||||
} from "@/components/trainBuilder/trainStatus";
|
||||
import { api } from "@/services/api";
|
||||
import type {
|
||||
BuiltTrainListFilters,
|
||||
@@ -146,6 +151,32 @@ export default function TrainBuilderListPage() {
|
||||
</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",
|
||||
header: "Yard",
|
||||
@@ -293,6 +324,7 @@ export default function TrainBuilderListPage() {
|
||||
data={trains}
|
||||
status={tableStatus}
|
||||
onRowClick={(train) => navigate(`/dashboard/train-builder/${train.id}`)}
|
||||
rowStyle={(train) => directionRowStyle(train.activeSchedule?.direction)}
|
||||
error={
|
||||
trainsQuery.isError
|
||||
? {
|
||||
|
||||
@@ -244,6 +244,21 @@ export default function TrainScheduleV2DetailPage() {
|
||||
return [];
|
||||
}, [previewResult?.wagonPlan, schedule?.trainSet?.wagons]);
|
||||
|
||||
// EXPORT schedules render the consist back-to-front (the train turns around
|
||||
// for the return run) — DISPLAY ONLY: stored sequenceNos, allocations,
|
||||
// documents, and the adjust-consist / placement flows keep the as-built order.
|
||||
const isExportDisplay = schedule?.direction === "EXPORT";
|
||||
const displayWagonPlanOriented = useMemo(
|
||||
() => (isExportDisplay ? [...displayWagonPlan].reverse() : displayWagonPlan),
|
||||
[displayWagonPlan, isExportDisplay],
|
||||
);
|
||||
const diagramWagons = useMemo(() => {
|
||||
const source = schedule?.trainSet?.wagons?.length
|
||||
? schedule.trainSet.wagons
|
||||
: displayWagonPlan;
|
||||
return isExportDisplay ? [...source].reverse() : source;
|
||||
}, [schedule?.trainSet?.wagons, displayWagonPlan, isExportDisplay]);
|
||||
|
||||
const runPreview = useCallback(
|
||||
async (options?: { silent?: boolean; advanceStep?: boolean }) => {
|
||||
if (!schedule || !scheduleId) return null;
|
||||
@@ -683,7 +698,12 @@ export default function TrainScheduleV2DetailPage() {
|
||||
fleetAvailability={previewResult?.fleetAvailability}
|
||||
deferredBookings={previewResult?.deferredBookings}
|
||||
/>
|
||||
<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) ? (
|
||||
<Group>
|
||||
{!hasContainerStep ? (
|
||||
@@ -760,15 +780,16 @@ export default function TrainScheduleV2DetailPage() {
|
||||
<TrainCompositionDiagram
|
||||
locomotive={schedule.trainSet?.locomotive}
|
||||
locomotives={locomotives}
|
||||
wagons={
|
||||
schedule.trainSet?.wagons?.length
|
||||
? schedule.trainSet.wagons
|
||||
: displayWagonPlan
|
||||
}
|
||||
wagons={diagramWagons}
|
||||
freightType={freightType}
|
||||
trainNumber={schedule.train ? schedule.train.code : schedule.trainNumber}
|
||||
trainNumber={schedule.trainNumber ?? schedule.train?.code ?? null}
|
||||
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
|
||||
p="lg"
|
||||
radius="lg"
|
||||
@@ -885,6 +906,11 @@ export default function TrainScheduleV2DetailPage() {
|
||||
{schedule.trainNumber}
|
||||
</Badge>
|
||||
) : null}
|
||||
{schedule.train ? (
|
||||
<Text size="xs" c="dimmed" ff="monospace">
|
||||
Train {schedule.train.code}
|
||||
</Text>
|
||||
) : null}
|
||||
</Group>
|
||||
<Box maw={340}>
|
||||
<RouteCorridor
|
||||
|
||||
@@ -327,19 +327,24 @@ export default function TrainScheduleV2ListPage() {
|
||||
header: "Train",
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => {
|
||||
// Schedules created from the Train Builder carry the train code;
|
||||
// legacy rows fall back to their locomotive set.
|
||||
// Schedules created from the Train Builder show the direction-matched
|
||||
// run number first (falling back to the train code); legacy rows fall
|
||||
// back to their locomotive set.
|
||||
if (row.original.train) {
|
||||
const subtitle = [row.original.trainNumber ? row.original.train.code : null,
|
||||
row.original.train.trainName]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
return (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Train size={14} color="var(--mantine-color-gray-5)" />
|
||||
<Stack gap={0}>
|
||||
<Text size="sm" fw={600} ff="monospace" lh={1.2}>
|
||||
{row.original.train.code}
|
||||
{row.original.trainNumber ?? row.original.train.code}
|
||||
</Text>
|
||||
{row.original.train.trainName ? (
|
||||
{subtitle ? (
|
||||
<Text size="xs" c="dimmed" lh={1.2}>
|
||||
{row.original.train.trainName}
|
||||
{subtitle}
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
@@ -758,14 +763,21 @@ export default function TrainScheduleV2ListPage() {
|
||||
label="Train"
|
||||
description="A built train (Train Builder) runs this departure with its locomotives and wagons"
|
||||
placeholder={routeId ? "Select a train" : "Select a route first"}
|
||||
data={(trainsQuery.data ?? []).map((train) => ({
|
||||
data={(trainsQuery.data ?? []).map((train) => {
|
||||
// Route direction picks which of the train's typed pair this run uses.
|
||||
const runNumber =
|
||||
selectedRoute?.direction === "IMPORT"
|
||||
? train.importTrainNumber
|
||||
: train.exportTrainNumber;
|
||||
return {
|
||||
value: train.id,
|
||||
label: `${train.code}${train.trainName ? ` — ${train.trainName}` : ""} · ${
|
||||
train.locomotives.length
|
||||
} locos · ${train.wagonCount} wagons${train.atOriginYard ? "" : " · not at origin yard"}${
|
||||
train.futureScheduleCount ? ` · ${train.futureScheduleCount} future run(s)` : ""
|
||||
}`,
|
||||
}))}
|
||||
label: `${train.code}${train.trainName ? ` — ${train.trainName}` : ""}${
|
||||
runNumber ? ` · runs as ${runNumber}` : ""
|
||||
} · ${train.locomotives.length} locos · ${train.wagonCount} wagons${
|
||||
train.atOriginYard ? "" : " · not at origin yard"
|
||||
}${train.futureScheduleCount ? ` · ${train.futureScheduleCount} future run(s)` : ""}`,
|
||||
};
|
||||
})}
|
||||
value={trainId || null}
|
||||
onChange={(v) => setTrainId(v ?? "")}
|
||||
searchable
|
||||
|
||||
@@ -62,7 +62,6 @@ export default function TrainSchedulingGlobalRulesPage() {
|
||||
"windowDurationHours",
|
||||
"docReviewMinutes",
|
||||
"paymentWindowMinutes",
|
||||
"reopenDelayMinutes",
|
||||
];
|
||||
const payload: Partial<Record<keyof TrainSchedulingGlobalRules, number>> = {};
|
||||
for (const key of fields) {
|
||||
@@ -261,17 +260,6 @@ export default function TrainSchedulingGlobalRulesPage() {
|
||||
min={1}
|
||||
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">
|
||||
<Button loading={saving} disabled={loading} onClick={() => void handleSave()}>
|
||||
Save rules
|
||||
|
||||
@@ -17,11 +17,27 @@ export interface YardRefLite {
|
||||
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 {
|
||||
id: string;
|
||||
code: string;
|
||||
trainName: string | null;
|
||||
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;
|
||||
currentYard: YardRefLite | null;
|
||||
locomotives: Array<{ id: string; code: string; name: string | null }>;
|
||||
@@ -80,13 +96,15 @@ export interface TrainComposition {
|
||||
code: string;
|
||||
trainName: string | null;
|
||||
status: BuiltTrainStatus;
|
||||
importTrainNumber: string | null;
|
||||
exportTrainNumber: string | null;
|
||||
notes: string | null;
|
||||
createdAt: string;
|
||||
currentYard: YardRefLite | null;
|
||||
locomotives: TrainCompositionLocomotive[];
|
||||
wagons: TrainCompositionWagon[];
|
||||
totals: TrainCompositionTotals;
|
||||
activeSchedules: Array<{ id: string; status: string; reference: string | null }>;
|
||||
activeSchedules: ActiveScheduleRef[];
|
||||
editable: boolean;
|
||||
}
|
||||
|
||||
@@ -112,6 +130,10 @@ export interface BuiltTrainListResponse {
|
||||
|
||||
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;
|
||||
locomotiveIds: string[];
|
||||
wagonIds?: string[];
|
||||
@@ -125,6 +147,8 @@ export interface AvailableTrain {
|
||||
code: string;
|
||||
trainName: string | null;
|
||||
status: BuiltTrainStatus;
|
||||
importTrainNumber: string | null;
|
||||
exportTrainNumber: string | null;
|
||||
currentYardId: string | null;
|
||||
currentYard: YardRefLite | null;
|
||||
locomotives: Array<{ id: string; code: string; name: string | null }>;
|
||||
|
||||
@@ -7,7 +7,8 @@ export type SchedulingStatus =
|
||||
| "HOLDING"
|
||||
| "ELIGIBLE"
|
||||
| "SCHEDULED"
|
||||
| "DISPATCHED";
|
||||
| "DISPATCHED"
|
||||
| "WAITING_FOR_WAGON";
|
||||
|
||||
export type TrainScheduleStatus =
|
||||
| "DRAFT"
|
||||
@@ -94,10 +95,20 @@ export interface FleetAvailabilityRow {
|
||||
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 {
|
||||
id: string;
|
||||
reference: string;
|
||||
reason: string;
|
||||
/** Set when the deferral is a fleet-stock shortage (absent for config issues). */
|
||||
shortage?: BookingWagonShortage | null;
|
||||
}
|
||||
|
||||
export interface TrainSchedulingGlobalRules {
|
||||
@@ -114,7 +125,6 @@ export interface TrainSchedulingGlobalRules {
|
||||
windowDurationHours: number;
|
||||
docReviewMinutes: number;
|
||||
paymentWindowMinutes: number;
|
||||
reopenDelayMinutes: number;
|
||||
}
|
||||
|
||||
export interface TrainSchedulePreviewResponse {
|
||||
@@ -493,7 +503,6 @@ export interface ScheduleWindowRule {
|
||||
windowOpenHour: number | null;
|
||||
windowCloseHour: number | null;
|
||||
windowDurationHours: number | null;
|
||||
reopenDelayMinutes: number | null;
|
||||
importWindowLeadDays: number | null;
|
||||
exportBookingLeadHours: number | null;
|
||||
/** Live global values (not snapshotted per schedule) — editor prefill baseline. */
|
||||
@@ -831,6 +840,7 @@ export interface CompositionUnassignedBooking {
|
||||
yardWagonsAvailable: number;
|
||||
canAssign: boolean;
|
||||
blockReason: string | null;
|
||||
shortage?: BookingWagonShortage | null;
|
||||
}
|
||||
|
||||
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,
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
import { CountdownTimer } from "@edr/ui-common";
|
||||
import { CountdownTimer, bookingWindowUiState } from "@edr/ui-common";
|
||||
import type { MyBookingWindow } from "@/services/bookings.service";
|
||||
import { windowRouteStops } from "@/pages/contracts/booking-window";
|
||||
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:
|
||||
* pre-window (opens at windowOpensAt) → open (closes at windowClosesAt) →
|
||||
* document review (docReviewEndsAt) → payment (paymentPhaseEndsAt).
|
||||
* The countdown for the window's UI state (shared with the status badge via
|
||||
* `bookingWindowUiState`, so the two can never contradict — a FULL train shows
|
||||
* no ticking "closes in" under a non-open badge).
|
||||
*
|
||||
* `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
|
||||
* announces what comes next ("Booking opening now…", "Review starting…") rather
|
||||
* 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(
|
||||
w: MyBookingWindow,
|
||||
): { label: string; deadline: string; expiredText: string } | null {
|
||||
switch (w.windowPhase) {
|
||||
case "PRE_WINDOW":
|
||||
if (w.windowOpensAt)
|
||||
return {
|
||||
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;
|
||||
}
|
||||
const state = bookingWindowUiState(w);
|
||||
const text = COUNTDOWN_TEXT[state.kind];
|
||||
if (!state.countdownTo || !text) return null;
|
||||
return { ...text, deadline: state.countdownTo };
|
||||
}
|
||||
|
||||
function Pill({
|
||||
@@ -147,20 +127,48 @@ function DirectionBadge({ direction }: { direction: MyBookingWindow["direction"]
|
||||
}
|
||||
|
||||
function StatusBadge({ window: w }: { window: MyBookingWindow }) {
|
||||
if (w.isOpenNow) {
|
||||
const state = bookingWindowUiState(w);
|
||||
switch (state.kind) {
|
||||
case "OPEN":
|
||||
return (
|
||||
<Pill bg="#ECF6F1" color="#0A6F4D" border="#CDEBDD">
|
||||
Open now
|
||||
</Pill>
|
||||
);
|
||||
}
|
||||
if (w.windowPhase === "PRE_WINDOW" && w.windowOpensAt) {
|
||||
case "FULL":
|
||||
return (
|
||||
<Pill bg="#FDECEA" color="#B3261E" border="#F6C9C4">
|
||||
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 (
|
||||
<Pill bg="#F1F5F9" color={MUTED}>
|
||||
Upcoming
|
||||
|
||||
@@ -18,7 +18,8 @@ import {
|
||||
ChevronRight,
|
||||
Clock,
|
||||
} 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 {
|
||||
@@ -90,50 +91,29 @@ function windowLabel(w: MyBookingWindow): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* The countdown for whichever phase the window is currently in, mirroring the
|
||||
* home dashboard's Booking Windows card. `expiredText` names the NEXT step so a
|
||||
* deadline that lapses between refetches announces what comes next rather than
|
||||
* the bare "Expired".
|
||||
* The countdown for the window's UI state, mirroring the home dashboard's
|
||||
* Booking Windows card. Derived from the SAME state as the badge
|
||||
* (`bookingWindowUiState`) so they can never contradict — a full train shows
|
||||
* no ticking countdown. `expiredText` names the NEXT step so a deadline that
|
||||
* lapses between refetches announces what comes next rather than the bare
|
||||
* "Expired".
|
||||
*/
|
||||
const COUNTDOWN_TEXT: Partial<
|
||||
Record<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(
|
||||
w: MyBookingWindow,
|
||||
): { label: string; deadline: string; expiredText: string } | null {
|
||||
switch (w.windowPhase) {
|
||||
case "PRE_WINDOW":
|
||||
return w.windowOpensAt
|
||||
? {
|
||||
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;
|
||||
}
|
||||
const state = bookingWindowUiState(w);
|
||||
const text = COUNTDOWN_TEXT[state.kind];
|
||||
if (!state.countdownTo || !text) return null;
|
||||
return { ...text, deadline: state.countdownTo };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -148,9 +128,21 @@ function isPast(w: MyBookingWindow): boolean {
|
||||
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 }) {
|
||||
const cd = phaseCountdown(w);
|
||||
const open = w.isOpenNow;
|
||||
const state = bookingWindowUiState(w);
|
||||
const badge = KIND_BADGE[state.kind];
|
||||
const open = state.isBookable;
|
||||
const isImport = w.direction === "IMPORT";
|
||||
|
||||
return (
|
||||
@@ -183,13 +175,11 @@ function WindowCard({ w }: { w: MyBookingWindow }) {
|
||||
)}
|
||||
<Badge
|
||||
variant={open ? "filled" : "light"}
|
||||
color={open ? "edr-green" : "gray"}
|
||||
color={badge.color}
|
||||
radius="sm"
|
||||
size="sm"
|
||||
>
|
||||
{open
|
||||
? "Open now"
|
||||
: windowPhaseLabel(w.windowPhase ?? w.bookingWindowStatus)}
|
||||
{badge.label}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
|
||||
@@ -182,6 +182,8 @@ export enum SchedulingStatus {
|
||||
Eligible = "ELIGIBLE",
|
||||
Scheduled = "SCHEDULED",
|
||||
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 {
|
||||
|
||||
@@ -15,6 +15,8 @@ export function DataTable<TData, TValue>({
|
||||
data,
|
||||
status,
|
||||
onRowClick,
|
||||
rowStyle,
|
||||
rowClassName,
|
||||
tableOptions,
|
||||
pagination,
|
||||
footer,
|
||||
@@ -115,11 +117,15 @@ export function DataTable<TData, TValue>({
|
||||
onRowClick(row.original);
|
||||
}}
|
||||
role={onRowClick ? "button" : ""}
|
||||
className={
|
||||
style={rowStyle?.(row.original)}
|
||||
className={[
|
||||
onRowClick
|
||||
? "cursor-pointer hover:bg-accent hover:text-foreground"
|
||||
: ""
|
||||
}
|
||||
: "",
|
||||
rowClassName?.(row.original) ?? "",
|
||||
]
|
||||
.join(" ")
|
||||
.trim()}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<Table.Td
|
||||
|
||||
@@ -21,6 +21,10 @@ export interface DataTableProps<TData, TValue> {
|
||||
data: TData[];
|
||||
status?: "loading" | "error" | "success";
|
||||
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<TData>,
|
||||
"data" | "columns" | "getCoreRowModel"
|
||||
|
||||
@@ -69,3 +69,10 @@ export * from "./components/select";
|
||||
export * from "./components/switch";
|
||||
export * from "./components/separator";
|
||||
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