approve-delivery exit-gate fix + Import Loading Confirmation frontend panel — done this session, not yet committed

This commit is contained in:
Hagernesh
2026-07-03 14:06:34 +00:00
147 changed files with 7024 additions and 2098 deletions

View File

@@ -3,9 +3,9 @@ import {
listBatchWindowsForDate,
listBatchWindowsForBookings,
BATCH_WINDOW_START_HOURS,
boardWindowForTimestamp,
listBoardWindowsForRange,
listConfigBookingWindows,
groupBookingsIntoBoardWindows,
type BoardWindowConfig,
} from './batch-window.util';
describe('batch-window.util', () => {
@@ -54,83 +54,87 @@ describe('batch-window.util', () => {
});
});
describe('batch-window board windows (midnight-based 3h slots)', () => {
it('maps 04:00 EAT to the 03:0006:00 slot', () => {
// 01:00 UTC = 04:00 EAT on 11 Jun
const w = boardWindowForTimestamp(new Date('2026-06-11T01:00:00.000Z'));
expect(w.label).toContain('03:00');
expect(w.label).toContain('06:00');
expect(w.date).toBe('2026-06-11');
expect(w.dateLabel).toContain('11 Jun');
});
describe('batch-window board windows (config-driven booking cycles)', () => {
// Default rules: open 08:00 EAT, 3 days before departure, 3h long, reopen 90m later.
const cfg: BoardWindowConfig = {
importWindowLeadDays: 3,
windowOpenHour: 8,
windowDurationHours: 3,
reopenDelayMinutes: 90,
exportBookingLeadHours: 24,
};
it('maps 00:30 EAT to the 00:0003:00 slot of that EAT day', () => {
// 21:30 UTC on 10 Jun = 00:30 EAT on 11 Jun
const w = boardWindowForTimestamp(new Date('2026-06-10T21:30:00.000Z'));
expect(w.label).toContain('00:00');
expect(w.label).toContain('03:00');
expect(w.date).toBe('2026-06-11');
});
it('maps 23:00 EAT to the final 21:0024:00 slot', () => {
// 20:00 UTC = 23:00 EAT on 11 Jun
const w = boardWindowForTimestamp(new Date('2026-06-11T20:00:00.000Z'));
expect(w.label).toContain('21:00');
expect(w.label).toContain('24:00');
expect(w.date).toBe('2026-06-11');
});
it('lists a continuous range open→departure clamped at both ends', () => {
// open 05 Jun 08:00 EAT (05:00 UTC) → departs 08 Jun 14:00 EAT (11:00 UTC)
const open = new Date('2026-06-05T05:00:00.000Z');
it('import: first window opens at windowOpenHour EAT, importWindowLeadDays before departure', () => {
// departs 08 Jun 14:00 EAT (11:00 UTC) → window day = 05 Jun, opens 08:00 EAT (05:00 UTC)
const departure = new Date('2026-06-08T11:00:00.000Z');
const windows = listBoardWindowsForRange(open, departure);
const windows = listConfigBookingWindows('IMPORT', departure, cfg);
// Day 5: 06,09,12,15,18,21 = 6 ; Days 6,7: 8 each ; Day 8: 00,03,06,09,12 = 5
expect(windows).toHaveLength(6 + 8 + 8 + 5);
expect(windows[0].date).toBe('2026-06-05');
expect(windows[0].label).toContain('06:00');
expect(windows[0].label).toContain('09:00');
const last = windows[windows.length - 1];
expect(last.date).toBe('2026-06-08');
expect(last.label).toContain('12:00');
expect(last.label).toContain('15:00');
// chronological + unique keys
const keys = windows.map((w) => w.key);
expect(new Set(keys).size).toBe(keys.length);
expect(windows[0].label).toContain('08:00');
expect(windows[0].start.toISOString()).toBe('2026-06-05T05:00:00.000Z');
// end = open + windowDurationHours (3h) = 08:00 → 11:00 EAT (08:00 UTC)
expect(windows[0].end.toISOString()).toBe('2026-06-05T08:00:00.000Z');
});
it('handles a same-day open→departure range', () => {
const open = new Date('2026-06-05T05:00:00.000Z'); // 08:00 EAT (0609 slot)
const departure = new Date('2026-06-05T11:00:00.000Z'); // 14:00 EAT (1215 slot)
const windows = listBoardWindowsForRange(open, departure);
// 06,09,12 = 3 slots
expect(windows).toHaveLength(3);
it('import: reopens reopenDelayMinutes after close, same booking day', () => {
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
expect(windows.length).toBeGreaterThanOrEqual(2);
expect(windows[1].start.toISOString()).toBe('2026-06-05T09:30:00.000Z'); // 12:30 EAT
// all cycles stay on the same EAT booking day
expect(windows.every((w) => w.date === '2026-06-05')).toBe(true);
});
it('buckets bookings by fullyExecutedAt and keeps empty + pending windows', () => {
const open = new Date('2026-06-05T05:00:00.000Z');
const departure = new Date('2026-06-06T11: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);
expect(windows).toHaveLength(1);
// 24h before 11:00 UTC on 08 Jun = 11:00 UTC on 07 Jun
expect(windows[0].start.toISOString()).toBe('2026-06-07T11:00:00.000Z');
expect(windows[0].end.toISOString()).toBe(departure.toISOString());
});
it('buckets bookings into config cycles and keeps empty + pending windows', () => {
const departure = new Date('2026-06-08T11:00:00.000Z');
const items = [
{ id: 'a', ts: new Date('2026-06-05T05:30:00.000Z') }, // 08:30 EAT → 0609 on 5th
{ id: 'a', ts: new Date('2026-06-05T05:30:00.000Z') }, // 08:30 EAT → inside cycle 1
{ id: 'b', ts: null }, // pending
];
const map = groupBookingsIntoBoardWindows(
items,
(i) => i.ts,
open,
'IMPORT',
departure,
cfg,
'pending-contract',
);
const pending = map.get('pending-contract');
expect(pending?.items.map((i) => i.id)).toEqual(['b']);
const withA = [...map.values()].find((b) => b.items.some((i) => i.id === 'a'));
expect(withA?.window?.date).toBe('2026-06-05');
// empty slots are retained for the UI
// empty cycles are retained for the UI
const emptyCount = [...map.values()].filter(
(b) => b.window && b.items.length === 0,
).length;
expect(emptyCount).toBeGreaterThan(0);
});
it('attaches a booking made before the window opened to the first cycle', () => {
const departure = new Date('2026-06-08T11:00:00.000Z');
const items = [{ id: 'early', ts: new Date('2026-06-01T00:00:00.000Z') }];
const map = groupBookingsIntoBoardWindows(
items,
(i) => i.ts,
'IMPORT',
departure,
cfg,
'pending-contract',
);
const withEarly = [...map.values()].find((b) =>
b.items.some((i) => i.id === 'early'),
);
expect(withEarly?.window?.date).toBe('2026-06-05');
expect(withEarly?.window?.label).toContain('08:00');
});
});

View File

@@ -230,14 +230,13 @@ export function listBatchWindowsForBookings(
}
// ---------------------------------------------------------------------------
// Board-display windows: full-day, midnight-based 3h slots over a date range.
// These are used ONLY for the batch-board UI grouping (not persisted, and
// independent of the cron intake hours above).
// 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.
// ---------------------------------------------------------------------------
/** Midnight-based 3-hour slot starts (0003, 0306, … 2124). */
export const BOARD_WINDOW_HOURS = [0, 3, 6, 9, 12, 15, 18, 21] as const;
/** A board window carries an EAT calendar date in addition to the slot times. */
export interface BoardWindow extends BatchWindow {
/** EAT calendar day as ISO `YYYY-MM-DD`. */
@@ -246,6 +245,15 @@ export interface BoardWindow extends BatchWindow {
dateLabel: string;
}
/** Config fields the board needs to reconstruct booking-window cycles. */
export interface BoardWindowConfig {
importWindowLeadDays: number;
windowOpenHour: number;
windowDurationHours: number;
reopenDelayMinutes: number;
exportBookingLeadHours: number;
}
const dayLabelFmt = new Intl.DateTimeFormat('en-GB', {
weekday: 'short',
day: '2-digit',
@@ -257,119 +265,124 @@ function pad2(n: number): string {
return String(n).padStart(2, '0');
}
/** Build a midnight-based 3h board window for an EAT calendar day + slot start hour. */
function boardWindowFromEatStart(
year: number,
month: number,
day: number,
startHour: number,
): BoardWindow {
const start = eatToUtc(year, month, day, startHour);
const endHour = startHour + 3; // 21 -> 24 (handled by Date.UTC roll-over)
const end = eatToUtc(year, month, day, endHour);
const endLabel = endHour >= 24 ? '24:00' : `${pad2(endHour)}:00`;
/** Wrap a [start, end] interval as a labelled BoardWindow keyed on its EAT day. */
function boardWindowFromInterval(start: Date, end: Date): BoardWindow {
const { year, month, day } = eatParts(start);
return {
key: start.toISOString(),
start,
end,
label: formatWindowLabel(start, end, endLabel),
label: formatWindowLabel(start, end),
date: `${year}-${pad2(month)}-${pad2(day)}`,
dateLabel: dayLabelFmt.format(start),
};
}
/** Which midnight-based 3h EAT slot a timestamp falls in. */
export function boardWindowForTimestamp(date: Date): BoardWindow {
const { year, month, day, hour } = eatParts(date);
let startHour: (typeof BOARD_WINDOW_HOURS)[number] = 0;
for (const h of BOARD_WINDOW_HOURS) {
if (hour >= h) startHour = h;
}
return boardWindowFromEatStart(year, month, day, startHour);
}
/**
* Continuous list of board windows from `openDate` to `departureDate` (inclusive),
* clamped to the slot containing `openDate` on the first day and the slot
* containing `departureDate` on the last day. Returned in chronological order.
* 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.
* EXPORT: a single FCFS window from `departure exportBookingLeadHours` to departure.
*/
export function listBoardWindowsForRange(
openDate: Date,
departureDate: Date,
export function listConfigBookingWindows(
direction: string | null | undefined,
departure: Date,
cfg: BoardWindowConfig,
): BoardWindow[] {
const startWin = boardWindowForTimestamp(openDate);
const endWin = boardWindowForTimestamp(departureDate);
// Guard against an inverted range (departure before open).
if (endWin.start.getTime() < startWin.start.getTime()) {
return [startWin];
if (direction === 'EXPORT') {
const start = new Date(departure.getTime() - cfg.exportBookingLeadHours * 3_600_000);
return [boardWindowFromInterval(start, departure)];
}
const windows: BoardWindow[] = [];
const seen = new Set<string>();
// Walk day-by-day in EAT, emitting each day's slots, stepping via UTC noon to
// avoid any boundary ambiguity, then filter to [startWin.start, endWin.start].
let cursor = new Date(eatToUtc(
Number(startWin.date.slice(0, 4)),
Number(startWin.date.slice(5, 7)),
Number(startWin.date.slice(8, 10)),
12,
));
const lastDayMs = eatToUtc(
Number(endWin.date.slice(0, 4)),
Number(endWin.date.slice(5, 7)),
Number(endWin.date.slice(8, 10)),
12,
).getTime();
const durationMs = cfg.windowDurationHours * 3_600_000;
const reopenMs = cfg.reopenDelayMinutes * 60_000;
const windowDay = shiftEatDay(eatDay(departure), -cfg.importWindowLeadDays);
while (cursor.getTime() <= lastDayMs) {
const { year, month, day } = eatParts(cursor);
for (const h of BOARD_WINDOW_HOURS) {
const w = boardWindowFromEatStart(year, month, day, h);
if (
w.start.getTime() >= startWin.start.getTime() &&
w.start.getTime() <= endWin.start.getTime() &&
!seen.has(w.key)
) {
seen.add(w.key);
windows.push(w);
}
let opensAt = eatDayToUtc(windowDay, cfg.windowOpenHour);
// Reopen stays on the same EAT booking day and before departure; cap at 12 cycles.
for (let cycle = 0; cycle < 12; cycle += 1) {
if (opensAt.getTime() >= departure.getTime()) break;
let closesAt = new Date(opensAt.getTime() + durationMs);
if (closesAt.getTime() > departure.getTime()) closesAt = departure;
windows.push(boardWindowFromInterval(opensAt, closesAt));
const nextOpensAt = new Date(closesAt.getTime() + reopenMs);
if (
nextOpensAt.getTime() >= departure.getTime() ||
eatDay(nextOpensAt) !== eatDay(opensAt)
) {
break;
}
cursor = new Date(cursor.getTime() + 24 * 60 * 60 * 1000);
opensAt = nextOpensAt;
}
windows.sort(compareBatchWindows);
// Degenerate config (no window before departure) — surface a single window
// clamped to departure so the board still renders something meaningful.
if (windows.length === 0) {
windows.push(boardWindowFromInterval(new Date(departure.getTime() - durationMs), departure));
}
return windows;
}
/** Which config booking-window a timestamp falls in; null if before/after all of them. */
function configWindowForTimestamp(
windows: BoardWindow[],
date: Date,
): BoardWindow | null {
const ms = date.getTime();
for (const w of windows) {
if (ms >= w.start.getTime() && ms < w.end.getTime()) return w;
}
return null;
}
/**
* Group items into board windows spanning [openDate, departureDate]. Empty
* windows are kept so the UI shows every slot. Items whose timestamp falls
* outside the range still get their own window (nothing hidden). Items without
* a timestamp go to `pendingKey`.
* Group items into the real config booking-window cycles for a schedule. Empty
* windows are kept so the UI shows every cycle. Items whose timestamp falls
* outside every window (e.g. a booking created before the window opened) are
* attached to the nearest window by start time so nothing is hidden. Items
* without a timestamp go to `pendingKey`.
*/
export function groupBookingsIntoBoardWindows<T>(
items: T[],
getTimestamp: (item: T) => Date | null | undefined,
openDate: Date,
departureDate: Date,
direction: string | null | undefined,
departure: Date,
cfg: BoardWindowConfig,
pendingKey = 'pending-contract',
): Map<string, { window: BoardWindow | null; items: T[] }> {
const windows = listConfigBookingWindows(direction, departure, cfg);
const map = new Map<string, { window: BoardWindow | null; items: T[] }>();
for (const w of listBoardWindowsForRange(openDate, departureDate)) {
for (const w of windows) {
map.set(w.key, { window: w, items: [] });
}
map.set(pendingKey, { window: null, items: [] });
const firstWindow = windows[0] ?? null;
const lastWindow = windows[windows.length - 1] ?? null;
for (const item of items) {
const ts = getTimestamp(item);
if (!ts) {
map.get(pendingKey)!.items.push(item);
continue;
}
const w = boardWindowForTimestamp(ts);
if (!map.has(w.key)) {
map.set(w.key, { window: w, items: [] });
let w = configWindowForTimestamp(windows, ts);
if (!w) {
// Booked before the window opened → first cycle; after it closed → last cycle.
w =
firstWindow && ts.getTime() < firstWindow.start.getTime()
? firstWindow
: lastWindow;
}
if (!w) {
map.get(pendingKey)!.items.push(item);
continue;
}
map.get(w.key)!.items.push(item);
}

View File

@@ -269,5 +269,56 @@ describe('BookingBatchService — PAID reconcile', () => {
}),
);
});
it('reserves both partners of a consolidated pair together on one train', async () => {
// Two 20ft bookings, 1 container each — a shared wagon. Both in the pool.
const consol = (id: string, partnerId: string, priority: number): Booking =>
({
id,
reference: id,
isGovernment: false,
priorityScore: priority,
status: 'FULLY_EXECUTED',
wagonsRequired: 1,
cargoTotalWeightVgm: 10,
freightType: 'CONTAINER',
consolidationPartnerId: partnerId,
bookingContainers: [{ quantity: 1 }],
}) as unknown as Booking;
bookingsRepository.findBatchPoolByRouteDay.mockResolvedValue([
consol('a', 'b', 30),
consol('b', 'a', 20),
]);
await service.fillRouteDay(originYardId, destinationYardId, day);
// Both reserved on the same (first) train; neither reported unplaced.
const reservedIds = notifier.payNow.mock.calls.map((c) => (c[0] as Booking).id);
expect(reservedIds.sort()).toEqual(['a', 'b']);
expect(notifier.unplaced).not.toHaveBeenCalled();
});
it('skips a consolidated booking whose partner is not in the pool (both-or-neither)', async () => {
const lonely = {
id: 'a',
reference: 'a',
isGovernment: false,
priorityScore: 30,
status: 'FULLY_EXECUTED',
wagonsRequired: 1,
cargoTotalWeightVgm: 10,
freightType: 'CONTAINER',
consolidationPartnerId: 'missing-partner',
bookingContainers: [{ quantity: 1 }],
} as unknown as Booking;
bookingsRepository.findBatchPoolByRouteDay.mockResolvedValue([lonely]);
await service.fillRouteDay(originYardId, destinationYardId, day);
// Never reserved — waits for its partner in a later cycle.
expect(notifier.payNow).not.toHaveBeenCalled();
});
});
});

View File

@@ -9,7 +9,7 @@ import {
} from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { SchedulerRegistry } from '@nestjs/schedule';
import { DataSource } from 'typeorm';
import { DataSource, In } from 'typeorm';
import { Booking } from '../bookings/entities/booking.entity';
import { BookingsRepository } from '../bookings/bookings.repository';
@@ -40,6 +40,7 @@ import {
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
import { BookingSplitService } from './booking-split.service';
import { MAX_TEU_SLOTS_PER_WAGON } from './wagon-plan.util';
/** A train's remaining capacity along the three physical limits the batch enforces. */
interface Capacity {
@@ -89,6 +90,9 @@ export interface BatchBoardBookingDetail extends BatchBoardBooking {
selectedForBatchAt: string | null;
allocationStatus: BookingAllocationStatus;
allocationIssue: string | null;
/** Set when this booking shares a wagon with a consolidation partner. */
consolidationPartnerId: string | null;
consolidationPartnerRef: string | null;
}
export interface BatchWindowGroup {
@@ -433,7 +437,7 @@ export class BookingBatchService implements OnModuleInit {
* fits the booking. Throws ConflictException when every train is full — the
* staff accept fails and no more export bookings are taken.
*/
async pickExportSchedule(booking: Booking): Promise<string> {
async pickExportSchedule(booking: Booking, need?: Capacity): Promise<string> {
if (!booking.scheduledDate) {
throw new BadRequestException('Booking has no scheduled date');
}
@@ -471,7 +475,7 @@ export class BookingBatchService implements OnModuleInit {
const rules = await this.loadGlobalRules();
const wagonLengths = await this.loadWagonLengths();
const need = this.needFor(booking, wagonLengths);
const required = need ?? this.needFor(booking, wagonLengths);
for (const candidate of candidates) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
candidate.id,
@@ -480,18 +484,47 @@ export class BookingBatchService implements OnModuleInit {
if (!schedule || !locomotive) continue;
const limits = await this.capacityLimits(locomotive, rules);
const budget = await this.remainingCapacity(schedule, limits, wagonLengths);
if (this.fits(need, budget)) return schedule.id;
if (this.fits(required, budget)) return schedule.id;
}
throw new ConflictException('Train is full — no export capacity left for this day');
}
/**
* Reserve an accepted export booking on its picked train and open the pay
* window immediately (payment notification goes out on reserve). Marks the
* train FULL when this reservation exhausts the wagon budget.
* Accept an export booking into the FCFS flow. Solo bookings reserve immediately.
* A consolidated booking reserves as a pair only once BOTH partners are ready
* (FULLY_EXECUTED): the second partner's accept triggers the pair reservation
* against the combined shared-wagon need; the first partner's accept just waits.
* Throws ConflictException (before this booking is persisted-ready) when there is
* no export capacity for the day, so staff accept fails.
*/
async reserveExportBooking(booking: Booking, scheduleId: string): Promise<void> {
await this.reserve(booking, scheduleId);
async acceptExportBooking(booking: Booking): Promise<void> {
const partnerId = booking.consolidationPartnerId ?? null;
if (!partnerId) {
const scheduleId = await this.pickExportSchedule(booking);
await this.reserveOnExport([booking], scheduleId);
return;
}
const partner = await this.dataSource
.getRepository(Booking)
.findOne({ where: { id: partnerId }, relations: { company: true, bookingContainers: true } });
// Partner not yet accepted → this booking is now FULLY_EXECUTED and simply
// waits; the partner's later accept will reserve the pair.
if (!partner || partner.status !== 'FULLY_EXECUTED') {
return;
}
const wagonLengths = await this.loadWagonLengths();
const need = this.combinedNeed(booking, partner, wagonLengths);
const scheduleId = await this.pickExportSchedule(booking, need);
await this.reserveOnExport([booking, partner], scheduleId);
}
/** Reserve one or two (consolidated) export bookings on a train and open pay windows. */
private async reserveOnExport(
bookings: Booking[],
scheduleId: string,
): Promise<void> {
for (const b of bookings) await this.reserve(b, scheduleId);
this.armSettle(scheduleId);
const schedule =
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
@@ -557,6 +590,9 @@ export class BookingBatchService implements OnModuleInit {
const board: BatchBoardSchedule[] = [];
for (const s of schedules) {
if (s.status === "ARRIVED" || s.status === "CANCELLED") continue;
// Batch board is IMPORT-only: export is FCFS with no batch/priority calc,
// and domestic/legacy schedules run the legacy fill, not the window batch.
if (s.direction !== "IMPORT") continue;
const links = await linkRepo.find({ where: { trainScheduleId: s.id } });
const linkedIds = new Set(links.map((l) => l.bookingId));
@@ -597,6 +633,12 @@ export class BookingBatchService implements OnModuleInit {
if (s.status === "ARRIVED" || s.status === "CANCELLED") {
throw new BadRequestException("Schedule is no longer active");
}
// Batch board is IMPORT-only (export is FCFS, no batch/priority calc).
if (s.direction !== "IMPORT") {
throw new BadRequestException(
"The batch board only covers import schedules",
);
}
const wagonLengths = await this.loadWagonLengths();
const linkRepo = this.dataSource.getRepository(TrainScheduleBooking);
@@ -622,6 +664,27 @@ export class BookingBatchService implements OnModuleInit {
allocationPreview.issues.map((i) => [i.bookingId, i]),
);
// Resolve consolidation-partner references for the shared-wagon badge. Most
// partners are on this same schedule; look up any that aren't in one query.
const refById = new Map(
bookings.map((b) => [b.id, b.reference ?? b.id.slice(0, 8)]),
);
const missingPartnerIds = [
...new Set(
bookings
.map((b) => b.consolidationPartnerId)
.filter((id): id is string => Boolean(id) && !refById.has(id!)),
),
];
if (missingPartnerIds.length) {
const partners = await this.dataSource
.getRepository(Booking)
.find({ where: { id: In(missingPartnerIds) } });
for (const p of partners) {
refById.set(p.id, p.reference ?? p.id.slice(0, 8));
}
}
const items: BatchBoardBookingDetail[] = bookings.map((b) => {
const need = this.needFor(b, wagonLengths);
const alloc = allocationByBooking.get(b.id);
@@ -647,20 +710,27 @@ export class BookingBatchService implements OnModuleInit {
: null,
allocationStatus: alloc?.status ?? "NOT_ATTEMPTED",
allocationIssue: alloc?.issue ?? null,
consolidationPartnerId: b.consolidationPartnerId ?? null,
consolidationPartnerRef: b.consolidationPartnerId
? (refById.get(b.consolidationPartnerId) ?? null)
: null,
};
});
const loco = s.trainSet?.locomotive ?? null;
// Display windows span the whole booking window: from when it opened
// (schedule creation) through the scheduled departure, in 3-hour EAT slots.
const openDate = s.createdAt ?? s.scheduledDepartureDate ?? new Date();
// Display windows are the REAL booking-window cycles from the global-rules
// config (import: opens at windowOpenHour EAT importWindowLeadDays before
// departure, lasts windowDurationHours, reopens per reopenDelayMinutes;
// export: single FCFS lead window) — not a fixed clock grid.
const windowCfg = await this.trainSchedulingService.getWindowConfig();
const departureDate = s.scheduledDepartureDate ?? new Date();
const windowBuckets = groupBookingsIntoBoardWindows(
items,
(item) => (item.fullyExecutedAt ? new Date(item.fullyExecutedAt) : null),
openDate,
s.direction ?? null,
departureDate,
windowCfg,
);
const emptyCounts = () => ({
@@ -903,13 +973,19 @@ export class BookingBatchService implements OnModuleInit {
}
const pool = await this.bookingsRepository.findBatchPool(scheduleId);
const units = this.groupConsolidatedPool(pool);
let armed = false;
for (const booking of pool) {
const need = this.needFor(booking, wagonLengths);
for (const unit of units) {
const { primary: booking, partner } = unit;
const isPair = partner != null;
const need = isPair
? this.combinedNeed(booking, partner, wagonLengths)
: this.needFor(booking, wagonLengths);
const isGov = booking.isGovernment || (partner?.isGovernment ?? false);
if (!this.fits(need, budget)) {
if (booking.isGovernment) {
if (isGov) {
budget = await this.preemptForGovernment(
scheduleId,
need,
@@ -918,14 +994,16 @@ export class BookingBatchService implements OnModuleInit {
);
if (!this.fits(need, budget)) continue; // still doesn't fit even after preempt
} else {
continue; // skip a booking that exceeds weight/length/wagons, try the next
continue; // skip a unit that exceeds weight/length/wagons, try the next
}
}
if (booking.isGovernment) {
if (isGov) {
await this.allocate(scheduleId, booking, "gov");
if (partner) await this.allocate(scheduleId, partner, "gov");
} else {
await this.reserve(booking, scheduleId);
if (partner) await this.reserve(partner, scheduleId);
armed = true;
}
budget = this.subtract(budget, need);
@@ -1013,15 +1091,23 @@ export class BookingBatchService implements OnModuleInit {
destinationYardId,
day,
);
// Consolidated partners collapse into one atomic unit (both-or-neither); a
// consolidated booking whose partner isn't ready this cycle is skipped.
const units = this.groupConsolidatedPool(pool);
for (const booking of pool) {
const need = this.needFor(booking, wagonLengths);
for (const unit of units) {
const { primary: booking, partner } = unit;
const isPair = partner != null;
const need = isPair
? this.combinedNeed(booking, partner, wagonLengths)
: this.needFor(booking, wagonLengths);
const isGov = booking.isGovernment || (partner?.isGovernment ?? false);
// First train (earliest departure) that fits this booking as-is.
// First train (earliest departure) that fits this unit as-is.
let target = trains.find((t) => this.fits(need, t.budget));
if (!target && booking.isGovernment) {
// Government booking fits nowhere on its own — try to preempt commercial
if (!target && isGov) {
// Government fits nowhere on its own — try to preempt commercial
// on each train (earliest first) until one frees enough room.
for (const t of trains) {
t.budget = await this.preemptForGovernment(
@@ -1038,41 +1124,45 @@ export class BookingBatchService implements OnModuleInit {
}
if (!target) {
// Fits no train whole. Import GENERAL-contract commercial bookings get a
// partial-capacity offer on the train with the most free wagons: pay =
// accept the split (remainder returns to the contract cap), no pay =
// booking stays whole and expires for this train.
const partialTarget = [...trains]
.filter((t) => t.budget.wagons >= 1)
.sort((a, b) => b.budget.wagons - a.budget.wagons)[0];
if (
partialTarget &&
!booking.isGovernment &&
booking.tradeDirection === "IMPORT" &&
booking.contractKind === "GENERAL" &&
this.splitService
) {
const offered = await this.tryPartialOffer(
booking,
partialTarget.id,
partialTarget.budget,
need,
);
if (offered) {
partialTarget.budget = this.subtract(partialTarget.budget, offered);
partialTarget.armed = true;
continue;
// A consolidated pair is placed whole or not at all — never split.
if (!isPair) {
// Fits no train whole. Import GENERAL-contract commercial bookings get a
// partial-capacity offer on the train with the most free wagons.
const partialTarget = [...trains]
.filter((t) => t.budget.wagons >= 1)
.sort((a, b) => b.budget.wagons - a.budget.wagons)[0];
if (
partialTarget &&
!booking.isGovernment &&
booking.tradeDirection === "IMPORT" &&
booking.contractKind === "GENERAL" &&
this.splitService
) {
const offered = await this.tryPartialOffer(
booking,
partialTarget.id,
partialTarget.budget,
need,
);
if (offered) {
partialTarget.budget = this.subtract(partialTarget.budget, offered);
partialTarget.armed = true;
continue;
}
}
}
// Stays in the pool, retried next batch/window cycle.
this.notifier.unplaced(booking, day);
if (partner) this.notifier.unplaced(partner, day);
continue;
}
if (booking.isGovernment) {
if (isGov) {
await this.allocate(target.id, booking, "gov");
if (partner) await this.allocate(target.id, partner, "gov");
} else {
await this.reserve(booking, target.id);
if (partner) await this.reserve(partner, target.id);
target.armed = true;
}
target.budget = this.subtract(target.budget, need);
@@ -1099,6 +1189,8 @@ export class BookingBatchService implements OnModuleInit {
need: Capacity,
): Promise<Capacity | null> {
if (!this.splitService) return null;
// A consolidated booking is already half of a shared wagon — never split it.
if (booking.consolidationPartnerId) return null;
if (await this.splitService.findOpenOffer(booking.id)) return null;
const wagonLengths = await this.loadWagonLengths();
@@ -1143,29 +1235,69 @@ export class BookingBatchService implements OnModuleInit {
return capacity > 0 ? capacity : 60;
}
/** Durable settle: allocate paid / expire overdue reservations, then top up. */
async settleDueReservations(scheduleId: string): Promise<void> {
/**
* Settle a schedule's reserved bookings. `expireUnpaidUnknownDeadline` decides
* how to treat a reservation with no deadline (durable path: leave it; timeout
* path: expire it). Consolidated pairs settle atomically: both allocate only
* when both paid; if either partner expires, both expire (a half-paid shared
* wagon must not ship). Returns whether anything changed.
*/
private async settleReserved(
scheduleId: string,
expireUnpaidUnknownDeadline: boolean,
): Promise<boolean> {
const reserved =
await this.bookingsRepository.findReservedForSchedule(scheduleId);
const now = Date.now();
const byId = new Map(reserved.map((b) => [b.id, b]));
const done = new Set<string>();
let anySettled = false;
for (const booking of reserved) {
const paid =
booking.paymentStatus === "PAID" || booking.status === "PAID";
const expired = booking.paymentDeadline
? booking.paymentDeadline.getTime() <= now
: false;
const isPaid = (b: Booking) =>
b.paymentStatus === "PAID" || b.status === "PAID";
const isExpired = (b: Booking) =>
b.paymentDeadline
? b.paymentDeadline.getTime() <= now
: expireUnpaidUnknownDeadline;
if (paid) {
for (const booking of reserved) {
if (done.has(booking.id)) continue;
const partner = booking.consolidationPartnerId
? (byId.get(booking.consolidationPartnerId) ?? null)
: null;
if (partner) {
done.add(booking.id);
done.add(partner.id);
// Both-or-neither: allocate the shared wagon only when both partners paid;
// if either lapsed, expire both so no half-paid wagon rides.
if (isPaid(booking) && isPaid(partner)) {
await this.allocate(scheduleId, booking, "paid");
await this.allocate(scheduleId, partner, "paid");
anySettled = true;
} else if (isExpired(booking) || isExpired(partner)) {
await this.expire(booking);
await this.expire(partner);
anySettled = true;
}
continue;
}
done.add(booking.id);
if (isPaid(booking)) {
await this.allocate(scheduleId, booking, "paid");
anySettled = true;
} else if (expired) {
} else if (isExpired(booking)) {
await this.expire(booking);
anySettled = true;
}
}
return anySettled;
}
/** Durable settle: allocate paid / expire overdue reservations, then top up. */
async settleDueReservations(scheduleId: string): Promise<void> {
const anySettled = await this.settleReserved(scheduleId, false);
if (anySettled) await this.fillSchedule(scheduleId);
}
@@ -1174,25 +1306,7 @@ export class BookingBatchService implements OnModuleInit {
/** Allocate paid reservations, expire the rest, then top up. */
async settleBatch(scheduleId: string): Promise<void> {
this.removeTimeout(scheduleId);
const reserved =
await this.bookingsRepository.findReservedForSchedule(scheduleId);
const now = Date.now();
for (const booking of reserved) {
const paid =
booking.paymentStatus === "PAID" || booking.status === "PAID";
const expired = booking.paymentDeadline
? booking.paymentDeadline.getTime() <= now
: true;
if (paid) {
await this.allocate(scheduleId, booking, "paid");
} else if (expired) {
await this.expire(booking);
}
// else: still within window (rare at settle) → leave for the re-armed timeout
}
await this.settleReserved(scheduleId, true);
await this.fillSchedule(scheduleId);
void this.triggerWagonAllocation(scheduleId);
}
@@ -1452,6 +1566,74 @@ export class BookingBatchService implements OnModuleInit {
// ---- capacity helpers -----------------------------------------------------
/**
* Collapse consolidated partners into single pool entries so the fill treats a
* shared-wagon pair as one atomic unit (both-or-neither). For each pool entry:
* - no `consolidationPartnerId` → passes through as a lone booking.
* - consolidated + partner also in this pool → emitted ONCE (at the position of
* whichever partner ranks first) as a pair; the partner is not emitted again.
* - consolidated + partner NOT in this pool → dropped (can't ship half a wagon;
* it waits for the partner to become ready in a later cycle).
* The pool is already priority-ordered, so emitting the pair at the first-seen
* partner's slot ranks it by the stronger (max-priority) partner automatically.
*/
private groupConsolidatedPool(
pool: Booking[],
): Array<{ primary: Booking; partner: Booking | null }> {
const byId = new Map(pool.map((b) => [b.id, b]));
const emitted = new Set<string>();
const units: Array<{ primary: Booking; partner: Booking | null }> = [];
for (const booking of pool) {
if (emitted.has(booking.id)) continue;
const partnerId = booking.consolidationPartnerId ?? null;
if (!partnerId) {
emitted.add(booking.id);
units.push({ primary: booking, partner: null });
continue;
}
const partner = byId.get(partnerId) ?? null;
if (!partner) {
// Both-or-neither: partner not ready in this pool → skip the pair entirely.
emitted.add(booking.id);
continue;
}
emitted.add(booking.id);
emitted.add(partner.id);
units.push({ primary: booking, partner });
}
return units;
}
/**
* Combined capacity need of a consolidated pair sharing wagons. The whole point of
* consolidation is that the two partial 20ft counts pack onto the SAME wagons, so
* the shared wagon count is ceil((c1+c2)/2) — strictly fewer than summing the two
* independently-rounded-up needs (that is the capacity consolidation saves).
*/
private combinedNeed(
primary: Booking,
partner: Booking,
wagonLengths: WagonLengths,
): Capacity {
const containers = (b: Booking): number =>
(b.bookingContainers ?? []).reduce((sum, c) => sum + Number(c.quantity ?? 0), 0);
const totalContainers = containers(primary) + containers(partner);
const sharedWagons =
totalContainers > 0
? Math.ceil(totalContainers / MAX_TEU_SLOTS_PER_WAGON)
: this.wagonsFor(primary) + this.wagonsFor(partner);
const weightTons =
Number(primary.cargoTotalWeightVgm ?? 0) + Number(partner.cargoTotalWeightVgm ?? 0);
return {
wagons: sharedWagons,
weightTons,
lengthMeters: bookingTrainLengthMeters(primary.freightType, sharedWagons, {
container: wagonLengths.container,
bulk: wagonLengths.bulk,
}),
};
}
private wagonsFor(booking: Booking): number {
if (booking.wagonsRequired && booking.wagonsRequired > 0) {
return Math.ceil(booking.wagonsRequired);

View File

@@ -3257,6 +3257,53 @@ export class TrainSchedulingService {
return days.includes(day);
}
/**
* Enforce the config-driven booking window at booking-create time.
*
* A booking is only allowed when the route has an OPEN departure the customer
* can join for the requested day — which, because the window engine keeps
* `bookingWindowStatus === 'OPEN'` in lockstep with the live window, means:
* - IMPORT: the day's window is currently open (opens at `windowOpenHour` EAT,
* `importWindowLeadDays` before departure, for `windowDurationHours`).
* - EXPORT: now is within `exportBookingLeadHours` before that departure (FCFS).
*
* `getBookableScheduleEntities` filters on `bookingWindowStatus === 'OPEN'`, so
* both gates are satisfied by checking that route for open departures. When a
* specific day is requested, require an open departure on that EAT day; when no
* day is given, require at least one open departure on the route at all.
* Throws `BadRequestException` when the window is closed. No-ops when the route
* yards are unknown (nothing to gate against).
*/
async assertBookingWindowOpen(input: {
originYardId?: string | null;
destinationYardId?: string | null;
scheduledDate?: Date | string | null;
direction?: string | null;
}): Promise<void> {
const { originYardId, destinationYardId } = input;
if (!originYardId || !destinationYardId) return;
const { days } = await this.getAvailableDays(originYardId, destinationYardId);
if (days.length === 0) {
throw new BadRequestException(
input.direction === 'EXPORT'
? 'The export booking window for this route is not open yet'
: 'The import booking window for this route is closed right now',
);
}
if (input.scheduledDate) {
const day = eatDay(new Date(input.scheduledDate));
if (!days.includes(day)) {
throw new BadRequestException(
input.direction === 'EXPORT'
? 'No departure is within the export booking window on the selected day'
: 'The import booking window is not open for the selected day',
);
}
}
}
private async mapScheduleDetail(
schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule,
) {