remove reopen delay minutes from global rules and update related types

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

View File

@@ -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];

View File

@@ -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:0011:00; reopen +90m → cycle 2 opens 12:30 EAT, same day
@@ -250,6 +259,17 @@ describe('batch-window board windows (config-driven booking cycles)', () => {
expect(new Set(windows.map((w) => w.date)).size).toBeGreaterThanOrEqual(3);
});
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);

View File

@@ -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 817 → closes 17:00, not 19:00). A
* round-the-clock desk never caps; a desk-close at/before the open (degenerate
* config) is ignored so the window is never clamped to zero length here.
*/
export function clampCloseToOfficeHours(
opensAt: Date,
closesAt: Date,
hours: OfficeHours,
): Date {
const deskClose = officeCloseAfter(opensAt, hours);
if (
deskClose != null &&
deskClose.getTime() > opensAt.getTime() &&
closesAt.getTime() > deskClose.getTime()
) {
return deskClose;
}
return closesAt;
}
export interface InitialWindowTimes {
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));

View File

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

View File

@@ -497,6 +497,7 @@ export class BookingBatchService implements OnModuleInit {
const linked =
await this.trainScheduleBookingsRepository.existsForBooking(bookingId);
if (!linked) {
if (await this.holdIfWagonShort(booking.trainScheduleId, booking)) return;
await this.allocate(booking.trainScheduleId, booking, "paid");
this.logger.log(
`Linked PAID booking ${booking.reference ?? bookingId} to schedule ${booking.trainScheduleId}`,
@@ -783,6 +784,9 @@ export class BookingBatchService implements OnModuleInit {
const unlinked =
await this.bookingsRepository.findPaidUnlinkedForSchedule(scheduleId);
for (const booking of unlinked) {
// Held on purpose (paid, no wagon free) — the cron must not undo it.
if (booking.schedulingStatus === "WAITING_FOR_WAGON") continue;
if (await this.holdIfWagonShort(scheduleId, booking)) continue;
await this.allocate(scheduleId, booking, "paid");
this.logger.log(
`Reconciled PAID booking ${booking.reference ?? booking.id} → schedule ${scheduleId}`,
@@ -1050,7 +1054,11 @@ export class BookingBatchService implements OnModuleInit {
s.ruleWindowDurationHours,
liveCfg.windowDurationHours,
),
reopenDelayMinutes: num(s.ruleReopenDelayMinutes, liveCfg.reopenDelayMinutes),
// Frozen doc-review + payment sum; legacy rows fall back to the live sum.
reopenGapMinutes: num(
s.ruleReopenDelayMinutes,
liveCfg.docReviewMinutes + liveCfg.paymentWindowMinutes,
),
importWindowLeadDays: num(
s.ruleImportWindowLeadDays,
liveCfg.importWindowLeadDays,
@@ -1894,7 +1902,9 @@ export class BookingBatchService implements OnModuleInit {
done.add(booking.id);
if (isPaid(booking)) {
await this.allocate(scheduleId, booking, "paid");
if (!(await this.holdIfWagonShort(scheduleId, booking))) {
await this.allocate(scheduleId, booking, "paid");
}
anySettled = true;
} else if (isExpired(booking)) {
await this.expire(booking);
@@ -1920,6 +1930,29 @@ export class BookingBatchService implements OnModuleInit {
);
}
/**
* Conclude-time retry: promote whatever still fits from the route-day waiting
* list, opening fresh pay windows. Returns how many commercial units got
* reserved — corridor-wide, since the fill is day-level and may reserve onto a
* sibling train; the caller must check `hasLiveReservations` for its OWN
* schedule before deciding to stay in PAYMENT.
*/
async fillFromWaitingList(scheduleId: string): Promise<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" });
await this.allocate(booking.trainScheduleId, booking, "paid");
if (!(await this.holdIfWagonShort(booking.trainScheduleId, booking))) {
await this.allocate(booking.trainScheduleId, booking, "paid");
}
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
booking.trainScheduleId,
@@ -2112,7 +2147,12 @@ export class BookingBatchService implements OnModuleInit {
await manager.getRepository(Booking).update(bookingId, {
trainScheduleId: newScheduleId,
status: restoredStatus,
schedulingStatus: "ELIGIBLE",
// A paid booking still hunting for a wagon keeps its flag through the
// move — it only clears when wagons are actually assigned.
schedulingStatus:
booking.schedulingStatus === "WAITING_FOR_WAGON"
? "WAITING_FOR_WAGON"
: "ELIGIBLE",
paymentDeadline: null,
selectedForBatchAt: null,
} as never);
@@ -2254,6 +2294,50 @@ export class BookingBatchService implements OnModuleInit {
}
/** Allocate a booking to the schedule's train (creates the TrainScheduleBooking link). */
/**
* Fleet preflight shared by every single-booking paid-allocation path: when
* no wagon of the booking's required type is free, hold it OUT of the train
* instead of linking — it stays PAID + unlinked in the (route, day) pool,
* flagged WAITING_FOR_WAGON, and staff place it on any same-day schedule from
* the workspace "Paid · unassigned" panel once a wagon frees up. Returns true
* when the booking was held. Consolidated pairs are exempt (the shared wagon
* is both-or-neither and settles atomically in settleReserved).
*/
private async holdIfWagonShort(
scheduleId: string,
booking: Booking,
): Promise<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`,
);
await this.allocate(paidScheduleId, fresh, "paid");
if (!(await this.holdIfWagonShort(paidScheduleId, fresh))) {
await this.allocate(paidScheduleId, fresh, "paid");
}
return;
}
}

View File

@@ -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. */

View File

@@ -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({

View File

@@ -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 817 → closes 17:00).
nextClosesAt = clampCloseToOfficeHours(nextOpensAt, nextClosesAt, officeHours);
if (nextClosesAt > schedule.scheduledDepartureDate) {
nextClosesAt = schedule.scheduledDepartureDate;
}

View File

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

View File

@@ -79,8 +79,4 @@ export class TrainSchedulingGlobalRules extends BaseEntity {
@Column({ name: 'payment_window_minutes', type: 'int', default: 60 })
paymentWindowMinutes!: number;
/** Delay after window close before the window reopens when the train is not yet full. */
@Column({ name: 'reopen_delay_minutes', type: 'int', default: 90 })
reopenDelayMinutes!: number;
}

View File

@@ -114,6 +114,35 @@ describe('fleet-plan.util', () => {
expect(warnings.some((w) => w.includes('deferred'))).toBe(true);
});
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: [

View File

@@ -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`,

View File

@@ -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
.map((s) => s.trainNumber)
.filter((n): n is string => Boolean(n));
// Every typed train pair is reserved for its train — the pool may never
// hand one out, even when that train has no active schedule right now.
const pairRows: { n: string }[] = await manager.query(
`SELECT import_train_number AS n FROM freight.trains
WHERE deleted_at IS NULL AND import_train_number IS NOT NULL
UNION
SELECT export_train_number FROM freight.trains
WHERE deleted_at IS NULL AND export_train_number IS NOT NULL`,
);
const usedNumbers = [
...activeNumbered
.map((s) => s.trainNumber)
.filter((n): n is string => Boolean(n)),
...pairRows.map((row) => row.n),
];
const number = pickLowestFreeNumber(pool.numbers, usedNumbers);
if (!number) {
@@ -4624,6 +4674,8 @@ export class TrainSchedulingService {
code: train.code,
trainName: train.trainName ?? null,
status: train.status,
importTrainNumber: train.importTrainNumber ?? null,
exportTrainNumber: train.exportTrainNumber ?? null,
currentYardId: train.currentYardId ?? null,
currentYard: train.currentYard
? {
@@ -5522,7 +5574,7 @@ export class TrainSchedulingService {
// Per-schedule booking-window rule snapshot — powers the "Booking window
// settings" editor on the ops board (prefill + save one schedule's
// override). docReview/payment are not snapshotted per schedule (only their
// sum, as reopenDelayMinutes), so the editor prefills them from live config.
// sum, as the frozen reopen gap), so the editor prefills them from live config.
windowRule: {
windowOpenHour: schedule.ruleWindowOpenHour ?? null,
windowCloseHour: schedule.ruleWindowCloseHour ?? null,
@@ -5530,7 +5582,6 @@ export class TrainSchedulingService {
schedule.ruleWindowDurationHours != null
? Number(schedule.ruleWindowDurationHours)
: null,
reopenDelayMinutes: schedule.ruleReopenDelayMinutes ?? null,
importWindowLeadDays: schedule.ruleImportWindowLeadDays ?? null,
exportBookingLeadHours: schedule.ruleExportBookingLeadHours ?? null,
docReviewMinutes: windowCfg.docReviewMinutes,
@@ -6158,6 +6209,7 @@ export class TrainSchedulingService {
yardWagonsAvailable: number;
canAssign: boolean;
blockReason: string | null;
shortage: BookingWagonShortage | null;
}> {
if (!schedule.trainSet?.locomotive) {
return {
@@ -6166,6 +6218,7 @@ export class TrainSchedulingService {
yardWagonsAvailable: 0,
canAssign: false,
blockReason: 'Schedule has no locomotive',
shortage: null,
};
}
@@ -6186,6 +6239,7 @@ export class TrainSchedulingService {
yardWagonsAvailable: 0,
canAssign: false,
blockReason: 'No suitable wagon type found',
shortage: null,
};
}
@@ -6230,6 +6284,7 @@ export class TrainSchedulingService {
yardWagonsAvailable,
canAssign: false,
blockReason: err instanceof Error ? err.message : 'Validation failed',
shortage: null,
};
}
@@ -6240,6 +6295,7 @@ export class TrainSchedulingService {
yardWagonsAvailable,
canAssign: false,
blockReason: validation.violations[0] ?? 'Booking validation failed',
shortage: null,
};
}
@@ -6259,6 +6315,16 @@ export class TrainSchedulingService {
deferred?.reason ??
yardShortfall ??
`Need ${wagonsRequired} ${requiredWagonTypeCode} wagon(s) at origin yard`,
shortage:
deferred?.shortage ??
(yardShortfall
? {
wagonTypeCodes: requiredWagonTypeCode,
wagonsNeeded: wagonsRequired,
wagonsAvailable: yardWagonsAvailable,
wagonsShort: Math.max(1, wagonsRequired - yardWagonsAvailable),
}
: null),
};
}
@@ -6277,6 +6343,7 @@ export class TrainSchedulingService {
yardWagonsAvailable,
canAssign: false,
blockReason: missing.issue,
shortage: null,
};
}
}
@@ -6287,9 +6354,49 @@ export class TrainSchedulingService {
yardWagonsAvailable,
canAssign: true,
blockReason: null,
shortage: null,
};
}
/**
* Fleet-shortage preflight for a PAID booking targeting a schedule: the
* structured per-type shortage this booking would hit if placed on top of the
* schedule's current wagon assignments, or null when it fits (or is blocked
* by something other than missing wagons — those keep the legacy link-then-
* fix-manually path).
*/
async previewPaidBookingWagonShortage(
scheduleId: string,
bookingId: string,
): Promise<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;

View File

@@ -0,0 +1,133 @@
import { Booking } from '../bookings/entities/booking.entity';
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
import { planWagonsWithStock } from './wagon-plan-flex.util';
const nw6: WagonType = {
id: 'wt-nw6',
code: 'NW6',
name: 'Flat Wagon',
capacityTons: 70,
lengthMeters: 14,
supportedLoadTypes: ['CONTAINER'],
isActive: true,
supportsContainer: true,
} as WagonType;
const cw3: WagonType = {
id: 'wt-cw3',
code: 'CW3',
name: 'Covered Wagon',
capacityTons: 60,
lengthMeters: 14,
supportedLoadTypes: ['BULK'],
isActive: true,
supportsContainer: false,
} as WagonType;
const containerBooking = (id: string, quantity: number, wagonsRequired: number): Booking =>
({
id,
reference: id,
freightType: 'CONTAINER',
cargoTotalWeightVgm: quantity * 25,
bookingContainers: [
{
id: `${id}-line-0`,
containerTypeId: 'ct-1',
quantity,
wagonsRequired,
vgmPerUnitTons: 25,
},
],
}) as Booking;
describe('planWagonsWithStock — shortage detail', () => {
it('defers with a structured per-type shortage when container stock runs out', () => {
const result = planWagonsWithStock({
bookings: [containerBooking('BKG-1', 2, 1)],
allowed: {
byContainerTypeId: new Map([['ct-1', [nw6]]]),
byCargoTypeId: new Map(),
},
stock: {
mode: 'YARD',
remainingByTypeId: new Map([[nw6.id, 0]]),
codesByTypeId: new Map([[nw6.id, nw6.code]]),
},
});
expect(result.fitting).toHaveLength(0);
expect(result.deferred).toHaveLength(1);
const row = result.deferred[0]!;
expect(row.reference).toBe('BKG-1');
expect(row.reason).toContain('No available NW6 wagon at the yard');
expect(row.reason).toContain('short 1');
expect(row.shortage).toEqual({
wagonTypeCodes: 'NW6',
wagonsNeeded: 1,
wagonsAvailable: 0,
wagonsShort: 1,
});
});
it('counts the stock the deferred booking actually saw, not its rolled-back usage', () => {
// Two wagons needed (2 × 40ft), one in stock: booking rolls back entirely,
// the shortage reports 1 available / 1 short.
const fortyFooter = containerBooking('BKG-2', 2, 2);
fortyFooter.bookingContainers![0]!.containerType = {
code: '40GP',
sizeFt: 40,
wagonsPerUnit: 1,
} as never;
const result = planWagonsWithStock({
bookings: [fortyFooter],
allowed: {
byContainerTypeId: new Map([['ct-1', [nw6]]]),
byCargoTypeId: new Map(),
},
stock: {
mode: 'YARD',
remainingByTypeId: new Map([[nw6.id, 1]]),
codesByTypeId: new Map([[nw6.id, nw6.code]]),
},
});
expect(result.deferred).toHaveLength(1);
expect(result.deferred[0]?.shortage).toEqual({
wagonTypeCodes: 'NW6',
wagonsNeeded: 2,
wagonsAvailable: 1,
wagonsShort: 1,
});
// The rolled-back wagon is plannable again for later bookings.
expect(result.plan).toHaveLength(0);
});
it('leaves shortage unset for configuration problems', () => {
const bulkBooking = {
id: 'BKG-3',
reference: 'BKG-3',
freightType: 'BULK',
cargoTotalWeightVgm: 40,
cargoTypeId: 'cargo-1',
cargoType: { id: 'cargo-1', cargoTypeName: 'Fertilizer' },
bookingContainers: [],
} as unknown as Booking;
const result = planWagonsWithStock({
bookings: [bulkBooking],
allowed: {
byContainerTypeId: new Map(),
byCargoTypeId: new Map(), // no wagon types configured → config issue
},
stock: {
mode: 'YARD',
remainingByTypeId: new Map([[cw3.id, 5]]),
codesByTypeId: new Map([[cw3.id, cw3.code]]),
},
});
expect(result.configIssues).toHaveLength(1);
expect(result.deferred[0]?.shortage).toBeNull();
});
});

View File

@@ -2,9 +2,14 @@ import { AllocationLoadType } from '@edr/types';
import { Booking } from '../bookings/entities/booking.entity';
import { 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 {

View File

@@ -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;

View File

@@ -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;

View File

@@ -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,17 +199,17 @@ export class TrainBuilderService {
});
if (!train) throw new NotFoundException(`Train ${id} not found`);
const schedules: { id: string; status: string; reference: string | null }[] =
await this.dataSource.query(
`SELECT ts.id, ts.status, ts.reference
FROM freight.train_schedules ts
JOIN freight.train_sets tset ON tset.id = ts.train_set_id
WHERE tset.train_id = $1
AND ts.deleted_at IS NULL
AND ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED')
ORDER BY ts.scheduled_departure_date ASC`,
[id],
);
const schedules: ActiveScheduleRef[] = await this.dataSource.query(
`SELECT ts.id, ts.status, ts.reference, ts.direction,
ts.train_number AS "trainNumber"
FROM freight.train_schedules ts
JOIN freight.train_sets tset ON tset.id = ts.train_set_id
WHERE tset.train_id = $1
AND ts.deleted_at IS NULL
AND ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED')
ORDER BY ts.scheduled_departure_date ASC`,
[id],
);
const locomotives = (train.locomotives ?? [])
.filter((link) => link.locomotive)
@@ -212,6 +272,8 @@ export class TrainBuilderService {
code: train.code,
trainName: train.trainName ?? null,
status: train.status,
importTrainNumber: train.importTrainNumber ?? null,
exportTrainNumber: train.exportTrainNumber ?? null,
notes: train.notes ?? null,
createdAt: train.createdAt,
currentYard: train.currentYard
@@ -407,7 +469,7 @@ export class TrainBuilderService {
// ---------------------------------------------------------------- internals
private mapSummary(train: Train) {
private mapSummary(train: Train, activeSchedule: ActiveScheduleRef | null) {
const locomotives = [...(train.locomotives ?? [])]
.sort((a, b) => a.sequenceNo - b.sequenceNo)
.map((link) => link.locomotive)
@@ -421,6 +483,9 @@ export class TrainBuilderService {
code: train.code,
trainName: train.trainName ?? null,
status: train.status,
importTrainNumber: train.importTrainNumber ?? null,
exportTrainNumber: train.exportTrainNumber ?? null,
activeSchedule,
createdAt: train.createdAt,
currentYard: train.currentYard
? { id: train.currentYard.id, code: train.currentYard.code, label: train.currentYard.label }