mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 06:40:57 +00:00
Merge pull request #701 from Tria-plc/freight_feature/usermanagement
Freight feature/usermanagement
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Drop the unused reopen-delay knob from the global rules.
|
||||
*
|
||||
* The window engine never honoured `reopen_delay_minutes`: a not-yet-full train
|
||||
* reopens as soon as its payment phase settles, so the real gap between a cycle
|
||||
* closing and reopening is doc review + payment — nothing else. The per-schedule
|
||||
* `rule_reopen_delay_minutes` snapshot stays: it freezes that derived gap at
|
||||
* creation so the batch board keeps projecting the cycles the customer was shown.
|
||||
*/
|
||||
export class DropReopenDelayMinutes2190000000000 implements MigrationInterface {
|
||||
name = "DropReopenDelayMinutes2190000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_scheduling_global_rules
|
||||
DROP COLUMN IF EXISTS reopen_delay_minutes;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_scheduling_global_rules
|
||||
ADD COLUMN IF NOT EXISTS reopen_delay_minutes integer NOT NULL DEFAULT 90;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Every built train owns a fixed pair of run numbers, typed at build time:
|
||||
* an EXPORT number (odd, e.g. 8001) and an IMPORT number (even, e.g. 8002).
|
||||
* Scheduling copies the route-direction-matched number onto the schedule at
|
||||
* creation; legacy trains with a null pair keep dispatch-time pool assignment.
|
||||
*
|
||||
* NOTE: the shared dev DB has no applied migration history, so this is also
|
||||
* hand-applied there. IF NOT EXISTS keeps that idempotent.
|
||||
*/
|
||||
export class TrainNumberPair2200000000000 implements MigrationInterface {
|
||||
name = 'TrainNumberPair2200000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.trains
|
||||
ADD COLUMN IF NOT EXISTS import_train_number varchar(20),
|
||||
ADD COLUMN IF NOT EXISTS export_train_number varchar(20);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_trains_import_train_number"
|
||||
ON freight.trains (import_train_number)
|
||||
WHERE import_train_number IS NOT NULL;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_trains_export_train_number"
|
||||
ON freight.trains (export_train_number)
|
||||
WHERE export_train_number IS NOT NULL;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_trains_export_train_number";`);
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_trains_import_train_number";`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.trains
|
||||
DROP COLUMN IF EXISTS export_train_number,
|
||||
DROP COLUMN IF EXISTS import_train_number;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -86,6 +86,7 @@ export const SCHEDULING_STATUSES = [
|
||||
SchedulingStatus.Eligible,
|
||||
SchedulingStatus.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)) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
@@ -435,7 +436,12 @@ export class TrainSchedulingService {
|
||||
.where('s.originStationId = :originStationId', { originStationId })
|
||||
.andWhere('s.destinationStationId = :destinationStationId', { destinationStationId })
|
||||
.andWhere('s.scheduledDepartureDate >= :dayStart', { dayStart })
|
||||
.andWhere('s.scheduledDepartureDate < :nextDayStart', { nextDayStart });
|
||||
.andWhere('s.scheduledDepartureDate < :nextDayStart', { nextDayStart })
|
||||
// A cancelled train is not a sibling: cancel retires its window as DONE,
|
||||
// and a newborn anchoring to it would inherit that dead window verbatim.
|
||||
.andWhere('s.status != :cancelledStatus', {
|
||||
cancelledStatus: TrainScheduleStatusEnum.Cancelled,
|
||||
});
|
||||
if (excludeScheduleId) {
|
||||
qb.andWhere('s.id != :excludeScheduleId', { excludeScheduleId });
|
||||
}
|
||||
@@ -471,7 +477,12 @@ export class TrainSchedulingService {
|
||||
departure,
|
||||
);
|
||||
if (siblings.length === 0) return null;
|
||||
const withWindow = siblings.filter((s) => s.windowOpensAt != null);
|
||||
// A DONE window is retired (the day's last cycle already ran) — anchoring
|
||||
// to it would hand the newborn a dead window no tick ever advances. With no
|
||||
// live or pending sibling left, fall back to fresh times (return null).
|
||||
const withWindow = siblings.filter(
|
||||
(s) => s.windowOpensAt != null && s.windowPhase !== 'DONE',
|
||||
);
|
||||
if (withWindow.length === 0) return null;
|
||||
|
||||
// A group whose window is live (some sibling has moved past PRE_WINDOW but is
|
||||
@@ -603,7 +614,6 @@ export class TrainSchedulingService {
|
||||
if (dto.windowDurationHours != null) row.windowDurationHours = dto.windowDurationHours;
|
||||
if (dto.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
|
||||
@@ -692,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
|
||||
@@ -919,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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1069,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,
|
||||
@@ -1164,6 +1186,7 @@ export class TrainSchedulingService {
|
||||
scheduledDepartureDate: departure,
|
||||
status: TrainScheduleStatusEnum.Draft,
|
||||
direction,
|
||||
trainNumber: pairTrainNumber ?? undefined,
|
||||
maxWagons,
|
||||
...windowFields,
|
||||
}),
|
||||
@@ -2674,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;
|
||||
@@ -2695,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) {
|
||||
@@ -4614,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
|
||||
? {
|
||||
@@ -5512,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,
|
||||
@@ -5520,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,
|
||||
@@ -6148,6 +6209,7 @@ export class TrainSchedulingService {
|
||||
yardWagonsAvailable: number;
|
||||
canAssign: boolean;
|
||||
blockReason: string | null;
|
||||
shortage: BookingWagonShortage | null;
|
||||
}> {
|
||||
if (!schedule.trainSet?.locomotive) {
|
||||
return {
|
||||
@@ -6156,6 +6218,7 @@ export class TrainSchedulingService {
|
||||
yardWagonsAvailable: 0,
|
||||
canAssign: false,
|
||||
blockReason: 'Schedule has no locomotive',
|
||||
shortage: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6176,6 +6239,7 @@ export class TrainSchedulingService {
|
||||
yardWagonsAvailable: 0,
|
||||
canAssign: false,
|
||||
blockReason: 'No suitable wagon type found',
|
||||
shortage: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6220,6 +6284,7 @@ export class TrainSchedulingService {
|
||||
yardWagonsAvailable,
|
||||
canAssign: false,
|
||||
blockReason: err instanceof Error ? err.message : 'Validation failed',
|
||||
shortage: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6230,6 +6295,7 @@ export class TrainSchedulingService {
|
||||
yardWagonsAvailable,
|
||||
canAssign: false,
|
||||
blockReason: validation.violations[0] ?? 'Booking validation failed',
|
||||
shortage: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6249,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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6267,6 +6343,7 @@ export class TrainSchedulingService {
|
||||
yardWagonsAvailable,
|
||||
canAssign: false,
|
||||
blockReason: missing.issue,
|
||||
shortage: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -6277,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,14 +5,26 @@ import {
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
Matches,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
|
||||
export class BuildTrainDto {
|
||||
@ApiProperty({ example: '81001', description: 'Operator-assigned train code (unique)' })
|
||||
@ApiProperty({ example: '8001', description: 'EXPORT run number (odd, unique across trains)' })
|
||||
@IsString()
|
||||
@MaxLength(32)
|
||||
code!: string;
|
||||
@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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -85,6 +85,16 @@ export class TrainBuilderController {
|
||||
return this.trainBuilderService.removeWagon(id, wagonId);
|
||||
}
|
||||
|
||||
@Post(':id/wagons/:wagonId/maintenance')
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: 'Detach one wagon and move it to MAINTENANCE status' })
|
||||
sendWagonToMaintenance(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param('wagonId', ParseUUIDPipe) wagonId: string,
|
||||
) {
|
||||
return this.trainBuilderService.sendWagonToMaintenance(id, wagonId);
|
||||
}
|
||||
|
||||
@Post(':id/reorder-wagons')
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: 'Persist a drag-reorder of the full consist' })
|
||||
|
||||
@@ -27,6 +27,15 @@ import {
|
||||
|
||||
const round = (value: unknown) => Math.round((Number(value) || 0) * 100) / 100;
|
||||
|
||||
/** 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
|
||||
@@ -50,10 +59,25 @@ export class TrainBuilderService {
|
||||
}
|
||||
|
||||
const trainId = await this.dataSource.transaction(async (manager) => {
|
||||
const code = dto.code.trim();
|
||||
const existing = await manager.getRepository(Train).findOne({ where: { code } });
|
||||
if (existing) {
|
||||
throw new ConflictException(`Train code ${code} is already in use`);
|
||||
const code = await this.generateTrainCode(manager);
|
||||
|
||||
// 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 } });
|
||||
@@ -76,6 +100,8 @@ export class TrainBuilderService {
|
||||
status: Freight.TrainStatus.Available,
|
||||
trainName: dto.trainName?.trim() || undefined,
|
||||
notes: dto.notes?.trim() || undefined,
|
||||
importTrainNumber,
|
||||
exportTrainNumber,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -117,12 +143,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 +195,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 +268,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
|
||||
@@ -356,6 +414,33 @@ export class TrainBuilderService {
|
||||
return this.getComposition(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detach one wagon AND flag it for maintenance: it leaves the consist and
|
||||
* moves to MAINTENANCE status (not AVAILABLE), so it is not re-coupled until
|
||||
* it clears maintenance. The freed sequence gap is closed.
|
||||
*/
|
||||
async sendWagonToMaintenance(id: string, wagonId: string) {
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const train = await this.getEditableTrain(manager, id);
|
||||
const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } });
|
||||
if (!wagon || wagon.trainId !== train.id) {
|
||||
throw new NotFoundException(`Wagon ${wagonId} is not part of this train`);
|
||||
}
|
||||
if (wagon.currentTrainScheduleId) {
|
||||
throw new ConflictException(
|
||||
`Wagon ${wagon.wagonNumber} is pinned to an active schedule and cannot be removed`,
|
||||
);
|
||||
}
|
||||
await manager.getRepository(Wagon).update(wagon.id, {
|
||||
trainId: null,
|
||||
sequenceNumber: null,
|
||||
status: WagonStatus.Maintenance,
|
||||
});
|
||||
await this.resequenceWagons(manager, train.id);
|
||||
});
|
||||
return this.getComposition(id);
|
||||
}
|
||||
|
||||
/** Persist a drag-reorder: `wagonIds` is the full consist in its new order. */
|
||||
async reorderWagons(id: string, dto: ReorderTrainWagonsDto) {
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
@@ -407,7 +492,30 @@ export class TrainBuilderService {
|
||||
|
||||
// ---------------------------------------------------------------- internals
|
||||
|
||||
private mapSummary(train: Train) {
|
||||
/**
|
||||
* System-assigned train code `TR-NNNNN`. Draws the next number from the
|
||||
* highest existing `TR-` code and probes past any manual collision so the
|
||||
* unique constraint never rejects the build.
|
||||
*/
|
||||
private async generateTrainCode(manager: EntityManager): Promise<string> {
|
||||
const [row]: { max_seq: string | null }[] = await manager.query(
|
||||
`SELECT MAX(CAST(SUBSTRING(code FROM '^TR-([0-9]+)$') AS INTEGER)) AS max_seq
|
||||
FROM freight.trains
|
||||
WHERE code ~ '^TR-[0-9]+$'`,
|
||||
);
|
||||
let seq = Number(row?.max_seq ?? 0) + 1;
|
||||
for (let attempt = 0; attempt < 50; attempt += 1) {
|
||||
const code = `TR-${String(seq).padStart(5, '0')}`;
|
||||
const exists = await manager
|
||||
.getRepository(Train)
|
||||
.findOne({ where: { code }, withDeleted: true });
|
||||
if (!exists) return code;
|
||||
seq += 1;
|
||||
}
|
||||
throw new ConflictException('Could not allocate a unique train code');
|
||||
}
|
||||
|
||||
private mapSummary(train: Train, activeSchedule: ActiveScheduleRef | null) {
|
||||
const locomotives = [...(train.locomotives ?? [])]
|
||||
.sort((a, b) => a.sequenceNo - b.sequenceNo)
|
||||
.map((link) => link.locomotive)
|
||||
@@ -421,6 +529,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 }
|
||||
|
||||
Reference in New Issue
Block a user