mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 11:21:18 +00:00
Warehouse Enhancemendt
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
import {
|
||||
getBatchWindowForTimestamp,
|
||||
listBatchWindowsForDate,
|
||||
listBatchWindowsForBookings,
|
||||
BATCH_WINDOW_START_HOURS,
|
||||
boardWindowForTimestamp,
|
||||
listBoardWindowsForRange,
|
||||
groupBookingsIntoBoardWindows,
|
||||
} from './batch-window.util';
|
||||
|
||||
describe('batch-window.util', () => {
|
||||
it('maps 20:15 EAT to the 19:00–22:00 window', () => {
|
||||
// 20:15 EAT = 17:15 UTC on 11 Jun 2026
|
||||
const ts = new Date('2026-06-11T17:15:00.000Z');
|
||||
const window = getBatchWindowForTimestamp(ts);
|
||||
|
||||
expect(window.label).toContain('19:00');
|
||||
expect(window.label).toContain('22:00');
|
||||
expect(window.label).toContain('11 Jun 2026');
|
||||
});
|
||||
|
||||
it('maps 08:30 EAT to the 07:00–10:00 window', () => {
|
||||
const ts = new Date('2026-06-11T05:30:00.000Z'); // 08:30 EAT
|
||||
const window = getBatchWindowForTimestamp(ts);
|
||||
expect(window.label).toContain('07:00');
|
||||
expect(window.label).toContain('10:00');
|
||||
});
|
||||
|
||||
it('maps 02:00 EAT to the previous day 22:00–07:00 window', () => {
|
||||
const ts = new Date('2026-06-11T23:00:00.000Z'); // 02:00 EAT on 12 Jun
|
||||
const window = getBatchWindowForTimestamp(ts);
|
||||
expect(window.label).toContain('22:00');
|
||||
expect(window.label).toContain('07:00');
|
||||
expect(window.label).toContain('11 Jun 2026');
|
||||
});
|
||||
|
||||
it('lists six windows for a calendar day', () => {
|
||||
const ref = new Date('2026-06-11T12:00:00.000Z');
|
||||
const windows = listBatchWindowsForDate(ref);
|
||||
expect(windows).toHaveLength(BATCH_WINDOW_START_HOURS.length);
|
||||
expect(windows[0].label).toContain('07:00');
|
||||
expect(windows[windows.length - 1].label).toContain('22:00');
|
||||
});
|
||||
|
||||
it('includes cross-day overnight window when booking signed at 00:02 EAT', () => {
|
||||
// 21:02 UTC = 00:02 EAT on 12 Jun → belongs to 11 Jun 22:00–07:00 window
|
||||
const fullyExecutedAt = new Date('2026-06-11T21:02:05.153Z');
|
||||
const scheduleDate = new Date('2026-06-12T06:00:00.000Z');
|
||||
const windows = listBatchWindowsForBookings([fullyExecutedAt], scheduleDate);
|
||||
const overnight = windows.find((w) => w.label.includes('22:00') && w.label.includes('07:00'));
|
||||
expect(overnight).toBeDefined();
|
||||
expect(overnight!.label).toContain('11 Jun 2026');
|
||||
expect(getBatchWindowForTimestamp(fullyExecutedAt).key).toBe(overnight!.key);
|
||||
});
|
||||
});
|
||||
|
||||
describe('batch-window board windows (midnight-based 3h slots)', () => {
|
||||
it('maps 04:00 EAT to the 03:00–06: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');
|
||||
});
|
||||
|
||||
it('maps 00:30 EAT to the 00:00–03: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:00–24: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');
|
||||
const departure = new Date('2026-06-08T11:00:00.000Z');
|
||||
const windows = listBoardWindowsForRange(open, departure);
|
||||
|
||||
// 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);
|
||||
});
|
||||
|
||||
it('handles a same-day open→departure range', () => {
|
||||
const open = new Date('2026-06-05T05:00:00.000Z'); // 08:00 EAT (06–09 slot)
|
||||
const departure = new Date('2026-06-05T11:00:00.000Z'); // 14:00 EAT (12–15 slot)
|
||||
const windows = listBoardWindowsForRange(open, departure);
|
||||
// 06,09,12 = 3 slots
|
||||
expect(windows).toHaveLength(3);
|
||||
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');
|
||||
const items = [
|
||||
{ id: 'a', ts: new Date('2026-06-05T05:30:00.000Z') }, // 08:30 EAT → 06–09 on 5th
|
||||
{ id: 'b', ts: null }, // pending
|
||||
];
|
||||
const map = groupBookingsIntoBoardWindows(
|
||||
items,
|
||||
(i) => i.ts,
|
||||
open,
|
||||
departure,
|
||||
'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
|
||||
const emptyCount = [...map.values()].filter(
|
||||
(b) => b.window && b.items.length === 0,
|
||||
).length;
|
||||
expect(emptyCount).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,340 @@
|
||||
import { BATCH_TIMEZONE } from './booking-batch.constants';
|
||||
|
||||
/** EAT intake boundaries — cron runs at these hours; each window spans to the next. */
|
||||
export const BATCH_WINDOW_START_HOURS = [7, 10, 13, 16, 19, 22] as const;
|
||||
|
||||
export interface BatchWindow {
|
||||
key: string;
|
||||
label: string;
|
||||
start: Date;
|
||||
end: Date;
|
||||
}
|
||||
|
||||
type EatDateParts = {
|
||||
year: number;
|
||||
month: number;
|
||||
day: number;
|
||||
hour: number;
|
||||
minute: number;
|
||||
};
|
||||
|
||||
const dateFmt = new Intl.DateTimeFormat('en-GB', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
timeZone: BATCH_TIMEZONE,
|
||||
});
|
||||
|
||||
const timeFmt = new Intl.DateTimeFormat('en-GB', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
timeZone: BATCH_TIMEZONE,
|
||||
});
|
||||
|
||||
function eatParts(date: Date): EatDateParts {
|
||||
const parts = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: BATCH_TIMEZONE,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
}).formatToParts(date);
|
||||
|
||||
const get = (type: Intl.DateTimeFormatPartTypes) =>
|
||||
Number(parts.find((p) => p.type === type)?.value ?? 0);
|
||||
|
||||
return {
|
||||
year: get('year'),
|
||||
month: get('month'),
|
||||
day: get('day'),
|
||||
hour: get('hour'),
|
||||
minute: get('minute'),
|
||||
};
|
||||
}
|
||||
|
||||
/** Build a UTC Date for a given EAT local wall-clock time on a calendar day. */
|
||||
function eatToUtc(
|
||||
year: number,
|
||||
month: number,
|
||||
day: number,
|
||||
hour: number,
|
||||
minute = 0,
|
||||
): Date {
|
||||
// EAT is UTC+3 year-round (no DST). Binary search would be safer across DST zones;
|
||||
// for Africa/Addis_Ababa the offset is fixed.
|
||||
const utcMs = Date.UTC(year, month - 1, day, hour - 3, minute, 0, 0);
|
||||
return new Date(utcMs);
|
||||
}
|
||||
|
||||
function formatWindowLabel(start: Date, end: Date, endHourLabel?: string): string {
|
||||
const endTime = endHourLabel ?? timeFmt.format(new Date(end.getTime() - 60_000));
|
||||
return `${dateFmt.format(start)} · ${timeFmt.format(start)} – ${endTime} EAT`;
|
||||
}
|
||||
|
||||
function windowFromEatStart(
|
||||
year: number,
|
||||
month: number,
|
||||
day: number,
|
||||
startHour: number,
|
||||
): BatchWindow {
|
||||
const start = eatToUtc(year, month, day, startHour);
|
||||
let endYear = year;
|
||||
let endMonth = month;
|
||||
let endDay = day;
|
||||
let endHour: number;
|
||||
let endHourLabel: string;
|
||||
|
||||
const idx = BATCH_WINDOW_START_HOURS.indexOf(startHour as (typeof BATCH_WINDOW_START_HOURS)[number]);
|
||||
if (idx === BATCH_WINDOW_START_HOURS.length - 1) {
|
||||
endHour = 7;
|
||||
endHourLabel = '07:00';
|
||||
const next = new Date(eatToUtc(year, month, day, 0));
|
||||
next.setUTCDate(next.getUTCDate() + 1);
|
||||
const nextParts = eatParts(next);
|
||||
endYear = nextParts.year;
|
||||
endMonth = nextParts.month;
|
||||
endDay = nextParts.day;
|
||||
} else {
|
||||
endHour = BATCH_WINDOW_START_HOURS[idx + 1];
|
||||
endHourLabel = `${String(endHour).padStart(2, '0')}:00`;
|
||||
}
|
||||
|
||||
const end = eatToUtc(endYear, endMonth, endDay, endHour);
|
||||
return {
|
||||
key: start.toISOString(),
|
||||
start,
|
||||
end,
|
||||
label: formatWindowLabel(start, end, endHourLabel),
|
||||
};
|
||||
}
|
||||
|
||||
/** Which 3h EAT intake window a timestamp (e.g. fullyExecutedAt) belongs to. */
|
||||
export function getBatchWindowForTimestamp(date: Date): BatchWindow {
|
||||
const { year, month, day, hour } = eatParts(date);
|
||||
|
||||
if (hour < 7) {
|
||||
const prev = new Date(eatToUtc(year, month, day, 0));
|
||||
prev.setUTCDate(prev.getUTCDate() - 1);
|
||||
const prevParts = eatParts(prev);
|
||||
return windowFromEatStart(prevParts.year, prevParts.month, prevParts.day, 22);
|
||||
}
|
||||
|
||||
let startHour: (typeof BATCH_WINDOW_START_HOURS)[number] = 7;
|
||||
for (const h of BATCH_WINDOW_START_HOURS) {
|
||||
if (hour >= h) startHour = h;
|
||||
}
|
||||
|
||||
return windowFromEatStart(year, month, day, startHour);
|
||||
}
|
||||
|
||||
/** All six intake windows for an EAT calendar day (includes overnight 22:00–07:00). */
|
||||
export function listBatchWindowsForDate(reference: Date): BatchWindow[] {
|
||||
const { year, month, day } = eatParts(reference);
|
||||
return BATCH_WINDOW_START_HOURS.map((startHour) =>
|
||||
windowFromEatStart(year, month, day, startHour),
|
||||
);
|
||||
}
|
||||
|
||||
export function compareBatchWindows(a: BatchWindow, b: BatchWindow): number {
|
||||
return a.start.getTime() - b.start.getTime();
|
||||
}
|
||||
|
||||
/** Schedule-day windows plus any extra windows that contain booking timestamps (cross-day). */
|
||||
export function listBatchWindowsForBookings(
|
||||
timestamps: Array<Date | null | undefined>,
|
||||
referenceDate: Date,
|
||||
): BatchWindow[] {
|
||||
const byKey = new Map<string, BatchWindow>();
|
||||
for (const w of listBatchWindowsForDate(referenceDate)) {
|
||||
byKey.set(w.key, w);
|
||||
}
|
||||
for (const ts of timestamps) {
|
||||
if (!ts) continue;
|
||||
const w = getBatchWindowForTimestamp(ts);
|
||||
byKey.set(w.key, w);
|
||||
}
|
||||
return [...byKey.values()].sort(compareBatchWindows);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Midnight-based 3-hour slot starts (00–03, 03–06, … 21–24). */
|
||||
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`. */
|
||||
date: string;
|
||||
/** Human label for the day, e.g. `Thu, 05 Jun`. */
|
||||
dateLabel: string;
|
||||
}
|
||||
|
||||
const dayLabelFmt = new Intl.DateTimeFormat('en-GB', {
|
||||
weekday: 'short',
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
timeZone: BATCH_TIMEZONE,
|
||||
});
|
||||
|
||||
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`;
|
||||
return {
|
||||
key: start.toISOString(),
|
||||
start,
|
||||
end,
|
||||
label: formatWindowLabel(start, end, endLabel),
|
||||
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.
|
||||
*/
|
||||
export function listBoardWindowsForRange(
|
||||
openDate: Date,
|
||||
departureDate: Date,
|
||||
): 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];
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
cursor = new Date(cursor.getTime() + 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
windows.sort(compareBatchWindows);
|
||||
return windows;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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`.
|
||||
*/
|
||||
export function groupBookingsIntoBoardWindows<T>(
|
||||
items: T[],
|
||||
getTimestamp: (item: T) => Date | null | undefined,
|
||||
openDate: Date,
|
||||
departureDate: Date,
|
||||
pendingKey = 'pending-contract',
|
||||
): Map<string, { window: BoardWindow | null; items: T[] }> {
|
||||
const map = new Map<string, { window: BoardWindow | null; items: T[] }>();
|
||||
|
||||
for (const w of listBoardWindowsForRange(openDate, departureDate)) {
|
||||
map.set(w.key, { window: w, items: [] });
|
||||
}
|
||||
map.set(pendingKey, { window: null, items: [] });
|
||||
|
||||
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: [] });
|
||||
}
|
||||
map.get(w.key)!.items.push(item);
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
/** Group items by batch window key; items without a timestamp go to `pendingKey`. */
|
||||
export function groupByBatchWindow<T>(
|
||||
items: T[],
|
||||
getTimestamp: (item: T) => Date | null | undefined,
|
||||
referenceDate: Date,
|
||||
pendingKey = 'pending-contract',
|
||||
): Map<string, { window: BatchWindow | null; items: T[] }> {
|
||||
const timestamps = items.map(getTimestamp);
|
||||
const windows = listBatchWindowsForBookings(timestamps, referenceDate);
|
||||
const map = new Map<string, { window: BatchWindow | null; items: T[] }>();
|
||||
|
||||
for (const w of windows) {
|
||||
map.set(w.key, { window: w, items: [] });
|
||||
}
|
||||
map.set(pendingKey, { window: null, items: [] });
|
||||
|
||||
for (const item of items) {
|
||||
const ts = getTimestamp(item);
|
||||
if (!ts) {
|
||||
map.get(pendingKey)!.items.push(item);
|
||||
continue;
|
||||
}
|
||||
const w = getBatchWindowForTimestamp(ts);
|
||||
if (!map.has(w.key)) {
|
||||
map.set(w.key, { window: w, items: [] });
|
||||
}
|
||||
map.get(w.key)!.items.push(item);
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Tunables for the demand-batching booking → allocation flow.
|
||||
* Times run in EAT so the 07:00/10:00/… boundaries match the local operating clock.
|
||||
*/
|
||||
|
||||
/** Batch boundaries — every 3h from 07:00 (the 07:00–10:00 intake settles at 10:00, etc.). */
|
||||
// export const BATCH_CRON = '0 7,10,13,16,19,22 * * *';
|
||||
// export const BATCH_CRON = '*/3 * * * *';
|
||||
export const BATCH_CRON = '*/5 * * * *';
|
||||
|
||||
export const BATCH_TIMEZONE = 'Africa/Addis_Ababa';
|
||||
|
||||
/** How long a selected commercial customer has to pay before their slot expires. */
|
||||
// export const PAYMENT_WINDOW_MS = 60 * 60 * 1000; // 1 hour
|
||||
export const PAYMENT_WINDOW_MS = 5 * 60 * 1000; // 5 minutes (test mode)
|
||||
|
||||
/** Fallback wagons-per-booking when a booking has no computed `wagonsRequired`. */
|
||||
export const DEFAULT_WAGONS_PER_BOOKING = 1;
|
||||
|
||||
/**
|
||||
* Fallback per-wagon length (m) for the batch length budget when global rules don't yet
|
||||
* define maxTrainLength / maxWagons to derive it from. Used only to estimate train length
|
||||
* against the locomotive's max train length.
|
||||
*/
|
||||
export const DEFAULT_WAGON_LENGTH_METERS = 14;
|
||||
|
||||
/** Default NW5 flat wagon length for container bookings (m). */
|
||||
export const DEFAULT_CONTAINER_WAGON_LENGTH_METERS = 14;
|
||||
|
||||
/** Default CW3 covered wagon length for bulk bookings (m). */
|
||||
export const DEFAULT_BULK_WAGON_LENGTH_METERS = 14;
|
||||
@@ -0,0 +1,144 @@
|
||||
import { BookingBatchService } from './booking-batch.service';
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
|
||||
describe('BookingBatchService — PAID reconcile', () => {
|
||||
const scheduleId = 'schedule-1';
|
||||
const bookingId = 'booking-1';
|
||||
|
||||
const paidBooking = {
|
||||
id: bookingId,
|
||||
reference: 'BK-2026-000034',
|
||||
trainScheduleId: scheduleId,
|
||||
status: 'PAID',
|
||||
paymentStatus: 'PAID',
|
||||
isGovernment: false,
|
||||
cargoTotalWeightVgm: 20,
|
||||
bookingContainers: [],
|
||||
} as unknown as Booking;
|
||||
|
||||
let service: BookingBatchService;
|
||||
let bookingsRepository: {
|
||||
findPaidUnlinkedForSchedule: jest.Mock;
|
||||
findBatchPool: jest.Mock;
|
||||
findReservedForSchedule: jest.Mock;
|
||||
update: jest.Mock;
|
||||
};
|
||||
let trainScheduleBookingsRepository: {
|
||||
existsForBooking: jest.Mock;
|
||||
createMany: jest.Mock;
|
||||
};
|
||||
let trainSchedulesRepository: {
|
||||
findByIdWithFullGraph: jest.Mock;
|
||||
findAll: jest.Mock;
|
||||
};
|
||||
let trainSchedulingService: {
|
||||
tryAutoWagonAllocation: jest.Mock;
|
||||
};
|
||||
let dataSource: {
|
||||
getRepository: jest.Mock;
|
||||
transaction: jest.Mock;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
bookingsRepository = {
|
||||
findPaidUnlinkedForSchedule: jest.fn().mockResolvedValue([]),
|
||||
findBatchPool: jest.fn().mockResolvedValue([]),
|
||||
findReservedForSchedule: jest.fn().mockResolvedValue([]),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
trainScheduleBookingsRepository = {
|
||||
existsForBooking: jest.fn().mockResolvedValue(false),
|
||||
createMany: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
trainSchedulesRepository = {
|
||||
findByIdWithFullGraph: jest.fn().mockResolvedValue({
|
||||
id: scheduleId,
|
||||
maxWagons: 10,
|
||||
bookingWindowStatus: 'OPEN',
|
||||
trainSet: { locomotive: { maxPullWeightTons: 3500, maxTrainLengthMeters: 760 } },
|
||||
scheduleBookings: [],
|
||||
}),
|
||||
findAll: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
trainSchedulingService = {
|
||||
tryAutoWagonAllocation: jest.fn().mockResolvedValue({
|
||||
assignedBookingIds: [],
|
||||
deferred: [],
|
||||
issues: [],
|
||||
violations: [],
|
||||
}),
|
||||
};
|
||||
|
||||
const bookingRepo = {
|
||||
findOne: jest.fn().mockResolvedValue(paidBooking),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
dataSource = {
|
||||
getRepository: jest.fn().mockReturnValue(bookingRepo),
|
||||
transaction: jest.fn(async (fn: (m: unknown) => Promise<void>) => {
|
||||
const manager = {
|
||||
getRepository: () => bookingRepo,
|
||||
};
|
||||
await fn(manager);
|
||||
}),
|
||||
};
|
||||
|
||||
service = new BookingBatchService(
|
||||
dataSource as never,
|
||||
bookingsRepository as never,
|
||||
trainSchedulesRepository as never,
|
||||
trainScheduleBookingsRepository as never,
|
||||
{ payNow: jest.fn(), secured: jest.fn(), expired: jest.fn() } as never,
|
||||
{ addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never,
|
||||
trainSchedulingService as never,
|
||||
);
|
||||
});
|
||||
|
||||
it('reconcilePaidUnlinked links PAID bookings without a schedule row', async () => {
|
||||
bookingsRepository.findPaidUnlinkedForSchedule.mockResolvedValue([paidBooking]);
|
||||
|
||||
await service.reconcilePaidUnlinked(scheduleId);
|
||||
|
||||
expect(bookingsRepository.findPaidUnlinkedForSchedule).toHaveBeenCalledWith(scheduleId);
|
||||
expect(trainScheduleBookingsRepository.createMany).toHaveBeenCalledWith(
|
||||
[{ trainScheduleId: scheduleId, bookingId }],
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('ensurePaidBookingAllocated links PAID booking when not yet linked', async () => {
|
||||
await service.ensurePaidBookingAllocated(bookingId);
|
||||
|
||||
expect(trainScheduleBookingsRepository.createMany).toHaveBeenCalledTimes(1);
|
||||
expect(trainSchedulingService.tryAutoWagonAllocation).toHaveBeenCalledWith(scheduleId);
|
||||
});
|
||||
|
||||
it('ensurePaidBookingAllocated is idempotent when already linked', async () => {
|
||||
trainScheduleBookingsRepository.existsForBooking.mockResolvedValue(true);
|
||||
|
||||
await service.ensurePaidBookingAllocated(bookingId);
|
||||
await service.ensurePaidBookingAllocated(bookingId);
|
||||
|
||||
expect(trainScheduleBookingsRepository.createMany).not.toHaveBeenCalled();
|
||||
expect(trainSchedulingService.tryAutoWagonAllocation).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('processSchedule reconciles PAID-unlinked before wagon allocation', async () => {
|
||||
const fillSpy = jest.spyOn(service, 'fillSchedule').mockResolvedValue(undefined);
|
||||
const settleSpy = jest.spyOn(service, 'settleDueReservations').mockResolvedValue(undefined);
|
||||
const reconcileSpy = jest.spyOn(service, 'reconcilePaidUnlinked').mockResolvedValue(undefined);
|
||||
|
||||
await service.processSchedule(scheduleId);
|
||||
|
||||
expect(fillSpy).toHaveBeenCalledWith(scheduleId);
|
||||
expect(settleSpy).toHaveBeenCalledWith(scheduleId);
|
||||
expect(reconcileSpy).toHaveBeenCalledWith(scheduleId);
|
||||
expect(trainSchedulingService.tryAutoWagonAllocation).toHaveBeenCalledWith(scheduleId);
|
||||
|
||||
const fillOrder = fillSpy.mock.invocationCallOrder[0];
|
||||
const reconcileOrder = reconcileSpy.mock.invocationCallOrder[0];
|
||||
const wagonOrder = trainSchedulingService.tryAutoWagonAllocation.mock.invocationCallOrder[0];
|
||||
expect(fillOrder).toBeLessThan(reconcileOrder);
|
||||
expect(reconcileOrder).toBeLessThan(wagonOrder);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,74 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { PAYMENT_WINDOW_MS } from './booking-batch.constants';
|
||||
|
||||
@Injectable()
|
||||
export class BookingNotifierService {
|
||||
private readonly logger = new Logger(BookingNotifierService.name);
|
||||
|
||||
constructor(private readonly notifications: NotificationsService) {}
|
||||
|
||||
private ref(b: Booking): string {
|
||||
return `${b.reference}${b.isGovernment ? ' (gov)' : ''}`;
|
||||
}
|
||||
|
||||
private async notifyContact(
|
||||
b: Booking,
|
||||
message: string,
|
||||
logLabel: string,
|
||||
): Promise<void> {
|
||||
this.logger.log(`${logLabel} — ${this.ref(b)}`);
|
||||
const phone = b.company?.contactPersonPhone ?? b.company?.phone ?? null;
|
||||
const email = b.company?.email ?? b.company?.generalManagerEmail ?? null;
|
||||
|
||||
if (phone) {
|
||||
try {
|
||||
await this.notifications.directSend('sms', phone, message);
|
||||
} catch (err) {
|
||||
this.logger.warn(`SMS failed for ${this.ref(b)}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
if (email) {
|
||||
try {
|
||||
await this.notifications.directSend('email', email, message);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Email failed for ${this.ref(b)}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
if (!phone && !email) {
|
||||
this.logger.warn(`No contact on file for ${this.ref(b)} — notification not sent`);
|
||||
}
|
||||
}
|
||||
|
||||
async payNow(b: Booking, deadline: Date): Promise<void> {
|
||||
const payMinutes = Math.round(PAYMENT_WINDOW_MS / 60_000);
|
||||
const eat = deadline.toLocaleString('en-GB', { timeZone: 'Africa/Addis_Ababa' });
|
||||
const msg = `Pay within ${payMinutes} minute${payMinutes === 1 ? '' : 's'} to secure train slot ${b.reference ?? b.id}. Deadline: ${eat} EAT.`;
|
||||
await this.notifyContact(b, msg, 'PAY NOW');
|
||||
}
|
||||
|
||||
secured(b: Booking, reason: 'paid' | 'gov'): void {
|
||||
const msg = `Booking ${b.reference ?? b.id} allocated on train schedule ${b.trainScheduleId ?? ''}${
|
||||
reason === 'gov' ? ' (government)' : ''
|
||||
}.`;
|
||||
void this.notifyContact(b, msg, 'ALLOCATED');
|
||||
}
|
||||
|
||||
expired(b: Booking): void {
|
||||
const msg = `Payment window expired for booking ${b.reference ?? b.id}. Reschedule or cancel — no re-approval needed.`;
|
||||
void this.notifyContact(b, msg, 'EXPIRED');
|
||||
}
|
||||
|
||||
scheduleFull(b: Booking): void {
|
||||
this.logger.warn(
|
||||
`SCHEDULE FULL — ${this.ref(b)} could not be placed; change schedule, pick another day, or cancel.`,
|
||||
);
|
||||
}
|
||||
|
||||
displaced(b: Booking): void {
|
||||
const msg = `Booking ${b.reference ?? b.id} was displaced by a government booking. Move to another schedule or cancel.`;
|
||||
void this.notifyContact(b, msg, 'DISPLACED');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import {
|
||||
autoFillPlacements,
|
||||
findMissingContainerNumberIssues,
|
||||
type ContainerUnitForPlacement,
|
||||
} from './container-placement.util';
|
||||
|
||||
describe('container-placement.util', () => {
|
||||
const units: ContainerUnitForPlacement[] = [
|
||||
{
|
||||
bookingId: 'b1',
|
||||
bookingContainerId: 'c1',
|
||||
unitIndex: 0,
|
||||
label: 'REF · 1/1 · 20GP',
|
||||
teuSlots: 1,
|
||||
sizeFt: 20,
|
||||
containerNumber: 'ABCD1234567',
|
||||
},
|
||||
{
|
||||
bookingId: 'b2',
|
||||
bookingContainerId: 'c2',
|
||||
unitIndex: 0,
|
||||
label: 'REF2 · 1/1 · 40GP',
|
||||
teuSlots: 2,
|
||||
sizeFt: 40,
|
||||
containerNumber: null,
|
||||
},
|
||||
];
|
||||
|
||||
it('auto-fills placements across slots', () => {
|
||||
const placements = autoFillPlacements(units, [1, 2]);
|
||||
expect(placements).toHaveLength(2);
|
||||
expect(placements[0].sequenceNo).toBe(1);
|
||||
expect(placements[1].sequenceNo).toBe(2);
|
||||
});
|
||||
|
||||
it('reports missing container numbers only when placement is empty', () => {
|
||||
const placements = autoFillPlacements(units, [1, 2]);
|
||||
const issues = findMissingContainerNumberIssues(units, placements);
|
||||
expect(issues).toHaveLength(0);
|
||||
expect(placements[1].containerNumber).toMatch(/^TBD-/);
|
||||
});
|
||||
|
||||
it('generates TBD placeholder for missing container numbers', () => {
|
||||
const single: ContainerUnitForPlacement[] = [
|
||||
{
|
||||
bookingId: 'b2',
|
||||
bookingReference: 'BK-2026-000033',
|
||||
bookingContainerId: 'c2',
|
||||
unitIndex: 0,
|
||||
label: 'REF2 · 1/1 · 40GP',
|
||||
teuSlots: 2,
|
||||
sizeFt: 40,
|
||||
containerNumber: null,
|
||||
},
|
||||
];
|
||||
const placements = autoFillPlacements(single, [1]);
|
||||
expect(placements[0].containerNumber).toBe('TBD-BK-2026-000033-1');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { ContainerPlacementInput } from './wagon-plan.util';
|
||||
|
||||
export type ContainerUnitForPlacement = {
|
||||
bookingId: string;
|
||||
bookingReference?: string | null;
|
||||
bookingContainerId: string;
|
||||
unitIndex: number;
|
||||
label: string;
|
||||
teuSlots?: number;
|
||||
sizeFt?: number;
|
||||
containerNumber?: string | null;
|
||||
};
|
||||
|
||||
export function placeholderContainerNumber(unit: ContainerUnitForPlacement): string {
|
||||
const ref = unit.bookingReference ?? unit.bookingId.slice(0, 8);
|
||||
return `TBD-${ref}-${unit.unitIndex + 1}`;
|
||||
}
|
||||
|
||||
export function isPlaceholderContainerNumber(value: string | null | undefined): boolean {
|
||||
return Boolean(value?.trim().startsWith('TBD-'));
|
||||
}
|
||||
|
||||
export function resolveContainerNumber(unit: ContainerUnitForPlacement): string {
|
||||
const trimmed = unit.containerNumber?.trim();
|
||||
return trimmed || placeholderContainerNumber(unit);
|
||||
}
|
||||
|
||||
export function autoFillPlacements(
|
||||
units: ContainerUnitForPlacement[],
|
||||
containerSlots: number[],
|
||||
): ContainerPlacementInput[] {
|
||||
if (!units.length || !containerSlots.length) return [];
|
||||
|
||||
const placements: ContainerPlacementInput[] = [];
|
||||
const MAX_TEU_PER_WAGON = 2;
|
||||
let currentSlotIndex = 0;
|
||||
let teuInCurrentSlot = 0;
|
||||
|
||||
for (const unit of units) {
|
||||
const teu = unit.teuSlots ?? (unit.sizeFt && unit.sizeFt >= 40 ? 2 : 1);
|
||||
|
||||
if (teuInCurrentSlot > 0 && teuInCurrentSlot + teu > MAX_TEU_PER_WAGON) {
|
||||
currentSlotIndex += 1;
|
||||
teuInCurrentSlot = 0;
|
||||
}
|
||||
|
||||
const sequenceNo =
|
||||
containerSlots[Math.min(currentSlotIndex, containerSlots.length - 1)] ??
|
||||
containerSlots[containerSlots.length - 1] ??
|
||||
containerSlots[0];
|
||||
|
||||
placements.push({
|
||||
bookingContainerId: unit.bookingContainerId,
|
||||
unitIndex: unit.unitIndex,
|
||||
sequenceNo,
|
||||
containerNumber: resolveContainerNumber(unit),
|
||||
});
|
||||
|
||||
teuInCurrentSlot += teu;
|
||||
}
|
||||
|
||||
return placements;
|
||||
}
|
||||
|
||||
export function findMissingContainerNumberIssues(
|
||||
units: ContainerUnitForPlacement[],
|
||||
placements: ContainerPlacementInput[],
|
||||
): Array<{ bookingId: string; issue: string }> {
|
||||
const issues: Array<{ bookingId: string; issue: string }> = [];
|
||||
const byUnit = new Map(
|
||||
placements.map((p) => [`${p.bookingContainerId}:${p.unitIndex}`, p]),
|
||||
);
|
||||
|
||||
for (const unit of units) {
|
||||
const placement = byUnit.get(`${unit.bookingContainerId}:${unit.unitIndex}`);
|
||||
if (!placement?.containerNumber?.trim()) {
|
||||
issues.push({
|
||||
bookingId: unit.bookingId,
|
||||
issue: `Missing container number for ${unit.label}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
export function placementsForBookings(
|
||||
placements: ContainerPlacementInput[],
|
||||
bookingIds: Set<string>,
|
||||
units: ContainerUnitForPlacement[],
|
||||
): ContainerPlacementInput[] {
|
||||
const unitBookingIds = new Map(
|
||||
units.map((u) => [`${u.bookingContainerId}:${u.unitIndex}`, u.bookingId]),
|
||||
);
|
||||
return placements.filter((p) => {
|
||||
const bookingId = unitBookingIds.get(`${p.bookingContainerId}:${p.unitIndex}`);
|
||||
return bookingId ? bookingIds.has(bookingId) : false;
|
||||
});
|
||||
}
|
||||
@@ -1,19 +1,4 @@
|
||||
import type { ScheduleTradeDirection } from '@edr/types';
|
||||
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
||||
|
||||
type YardLike = { country?: string | null };
|
||||
|
||||
export function deriveScheduleDirection(
|
||||
originYard: YardLike,
|
||||
destinationYard: YardLike,
|
||||
): ScheduleTradeDirection {
|
||||
const originCountry = originYard.country?.trim();
|
||||
const destinationCountry = destinationYard.country?.trim();
|
||||
|
||||
if (originCountry === 'Djibouti') {
|
||||
return 'IMPORT';
|
||||
}
|
||||
if (destinationCountry === 'Djibouti' && originCountry !== 'Djibouti') {
|
||||
return 'EXPORT';
|
||||
}
|
||||
return 'DOMESTIC';
|
||||
}
|
||||
/** @deprecated Use deriveTradeDirection from common — kept as alias for train scheduling. */
|
||||
export const deriveScheduleDirection = deriveTradeDirection;
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsUUID } from 'class-validator';
|
||||
|
||||
export class AssignUnassignedBookingDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID()
|
||||
bookingId!: string;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsUUID } from 'class-validator';
|
||||
|
||||
export class AvailableLocomotivesQueryDto {
|
||||
@ApiProperty({ format: 'uuid', description: 'Route used to filter locomotives at the origin yard' })
|
||||
@IsUUID()
|
||||
routeId!: string;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsOptional, IsUUID } from 'class-validator';
|
||||
|
||||
export class BookableSchedulesQueryDto {
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
originYardId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
destinationYardId?: string;
|
||||
}
|
||||
@@ -11,11 +11,6 @@ export class CreateContainerTrainScheduleDto {
|
||||
@IsDateString()
|
||||
scheduleDate!: string;
|
||||
|
||||
@ApiProperty({ example: '2026-06-22T08:00:00.000Z', required: false })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
arrivalDate?: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID()
|
||||
locomotiveId!: string;
|
||||
|
||||
@@ -17,6 +17,14 @@ export class GetEligibleBookingsDto {
|
||||
@IsUUID()
|
||||
destinationStationId?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
format: 'uuid',
|
||||
description: 'Scope to bookings that targeted this specific schedule (batch parity).',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
trainScheduleId?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
schedulingStatus?: string;
|
||||
|
||||
@@ -12,6 +12,11 @@ export class GetEligibleBulkBookingsDto {
|
||||
@IsUUID()
|
||||
destinationStationId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
trainScheduleId?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'HOLDING' })
|
||||
@IsOptional()
|
||||
schedulingStatus?: string;
|
||||
|
||||
@@ -12,6 +12,11 @@ export class GetEligibleContainerBookingsDto {
|
||||
@IsUUID()
|
||||
destinationStationId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
trainScheduleId?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'HOLDING' })
|
||||
@IsOptional()
|
||||
schedulingStatus?: string;
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { TrainCheckpointKind } from '@edr/types';
|
||||
import {
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsISO8601,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class RecordCheckpointDto {
|
||||
@ApiProperty({ description: 'Station position along the route (0 = origin).' })
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
sequenceNo!: number;
|
||||
|
||||
@ApiProperty({ enum: TrainCheckpointKind, required: false })
|
||||
@IsOptional()
|
||||
@IsEnum(TrainCheckpointKind)
|
||||
kind?: TrainCheckpointKind;
|
||||
|
||||
@ApiProperty({ required: false, description: 'ISO timestamp; defaults to now.' })
|
||||
@IsOptional()
|
||||
@IsISO8601()
|
||||
occurredAt?: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
note?: string;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class UpdateContainerItemDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
containerNumber?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { TrainCheckpointKind } from '@edr/types';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
|
||||
import { Yard } from '../../rule-engine/entities/yard.entity';
|
||||
import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity';
|
||||
|
||||
/**
|
||||
* One staff-logged tracking checkpoint for a dispatched train as it passes a
|
||||
* station along its route (origin → milestones → destination).
|
||||
*/
|
||||
@Entity({ schema: 'freight', name: 'train_checkpoint_events' })
|
||||
@Index(['trainScheduleId'])
|
||||
@Index(['trainScheduleId', 'sequenceNo'])
|
||||
export class TrainCheckpointEvent extends BaseEntity {
|
||||
@Column({ name: 'train_schedule_id', type: 'uuid' })
|
||||
trainScheduleId!: string;
|
||||
|
||||
@ManyToOne(() => TrainSchedule, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'train_schedule_id' })
|
||||
trainSchedule?: TrainSchedule;
|
||||
|
||||
@Column({ name: 'yard_id', type: 'uuid' })
|
||||
yardId!: string;
|
||||
|
||||
@ManyToOne(() => Yard)
|
||||
@JoinColumn({ name: 'yard_id' })
|
||||
yard?: Yard;
|
||||
|
||||
/** Position along the corridor: 0 = origin, N+1 = destination. */
|
||||
@Column({ name: 'sequence_no', type: 'int' })
|
||||
sequenceNo!: number;
|
||||
|
||||
@Column({ name: 'kind', type: 'varchar', length: 20 })
|
||||
kind!: TrainCheckpointKind;
|
||||
|
||||
@Column({ name: 'occurred_at', type: 'timestamptz' })
|
||||
occurredAt!: Date;
|
||||
|
||||
@Column({ name: 'note', type: 'text', nullable: true })
|
||||
note?: string | null;
|
||||
|
||||
@Column({ name: 'recorded_by_user_id', type: 'uuid', nullable: true })
|
||||
recordedByUserId?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import {
|
||||
bookingTrainLengthMeters,
|
||||
deriveTrainCapacityFromLocomotive,
|
||||
} from './train-capacity.util';
|
||||
|
||||
describe('train-capacity.util', () => {
|
||||
const nw5 = { lengthMeters: 14, capacityTons: 70 };
|
||||
|
||||
it('derives wagon slots from locomotive length and weight, not a fixed 53', () => {
|
||||
const shortLoco = deriveTrainCapacityFromLocomotive(
|
||||
{ maxPullWeightTons: 2000, maxTrainLengthMeters: 280 },
|
||||
[nw5],
|
||||
);
|
||||
expect(shortLoco.maxWagonSlots).toBe(20); // 280 / 14
|
||||
expect(shortLoco.maxWagonSlots).not.toBe(53);
|
||||
|
||||
const heavyLoco = deriveTrainCapacityFromLocomotive(
|
||||
{ maxPullWeightTons: 2100, maxTrainLengthMeters: 760 },
|
||||
[nw5],
|
||||
);
|
||||
expect(heavyLoco.maxWagonSlots).toBe(30); // min(54, 30) from weight 2100/70
|
||||
});
|
||||
|
||||
it('uses shortest wagon type when mixed types are present', () => {
|
||||
const longBulk = { lengthMeters: 18, capacityTons: 80 };
|
||||
const mixed = deriveTrainCapacityFromLocomotive(
|
||||
{ maxPullWeightTons: 3500, maxTrainLengthMeters: 760 },
|
||||
[nw5, longBulk],
|
||||
);
|
||||
expect(mixed.maxWagonSlots).toBe(
|
||||
Math.min(Math.floor(760 / 14), Math.floor(3500 / 70)),
|
||||
);
|
||||
});
|
||||
|
||||
it('computes booking length by freight type', () => {
|
||||
expect(
|
||||
bookingTrainLengthMeters('CONTAINER', 2, { container: 14, bulk: 14 }),
|
||||
).toBe(28);
|
||||
expect(bookingTrainLengthMeters('BULK', 3, { container: 14, bulk: 18 })).toBe(54);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
/** Physical dimensions used when deriving how many wagons a locomotive can pull. */
|
||||
export type WagonTypeDimensions = {
|
||||
lengthMeters: number;
|
||||
capacityTons: number;
|
||||
};
|
||||
|
||||
export type LocomotiveLimits = {
|
||||
maxPullWeightTons: number;
|
||||
maxTrainLengthMeters: number;
|
||||
};
|
||||
|
||||
export type DerivedTrainCapacity = {
|
||||
maxWeightTons: number;
|
||||
maxLengthMeters: number;
|
||||
maxWagonSlots: number;
|
||||
};
|
||||
|
||||
const DEFAULT_WAGON_LENGTH_M = 14;
|
||||
const DEFAULT_WAGON_CAPACITY_T = 70;
|
||||
|
||||
/**
|
||||
* Derive train capacity from locomotive pull weight and train length.
|
||||
* Wagon count is NOT a fixed 53 — it is the minimum of:
|
||||
* - floor(maxLength / shortest wagon type length)
|
||||
* - floor(maxWeight / lightest wagon type capacity)
|
||||
*/
|
||||
export function deriveTrainCapacityFromLocomotive(
|
||||
locomotive: LocomotiveLimits,
|
||||
wagonTypes: WagonTypeDimensions[],
|
||||
ruleCaps?: { maxTrainWeightTons?: number; maxTrainLengthMeters?: number },
|
||||
): DerivedTrainCapacity {
|
||||
const maxWeightTons = Math.min(
|
||||
Number(locomotive.maxPullWeightTons) || Infinity,
|
||||
ruleCaps?.maxTrainWeightTons ?? Infinity,
|
||||
);
|
||||
const maxLengthMeters = Math.min(
|
||||
Number(locomotive.maxTrainLengthMeters) || Infinity,
|
||||
ruleCaps?.maxTrainLengthMeters ?? Infinity,
|
||||
);
|
||||
|
||||
const types =
|
||||
wagonTypes.length > 0
|
||||
? wagonTypes
|
||||
: [{ lengthMeters: DEFAULT_WAGON_LENGTH_M, capacityTons: DEFAULT_WAGON_CAPACITY_T }];
|
||||
|
||||
const minLength = Math.min(...types.map((w) => Number(w.lengthMeters) || DEFAULT_WAGON_LENGTH_M));
|
||||
const minCapacity = Math.min(
|
||||
...types.map((w) => Number(w.capacityTons) || DEFAULT_WAGON_CAPACITY_T),
|
||||
);
|
||||
|
||||
const byLength =
|
||||
minLength > 0 && Number.isFinite(maxLengthMeters)
|
||||
? Math.floor(maxLengthMeters / minLength)
|
||||
: 0;
|
||||
const byWeight =
|
||||
minCapacity > 0 && Number.isFinite(maxWeightTons)
|
||||
? Math.floor(maxWeightTons / minCapacity)
|
||||
: byLength;
|
||||
|
||||
const maxWagonSlots = Math.max(0, Math.min(byLength, byWeight));
|
||||
|
||||
return {
|
||||
maxWeightTons: Number.isFinite(maxWeightTons) ? maxWeightTons : MAX_FALLBACK_WEIGHT,
|
||||
maxLengthMeters: Number.isFinite(maxLengthMeters) ? maxLengthMeters : MAX_FALLBACK_LENGTH,
|
||||
maxWagonSlots,
|
||||
};
|
||||
}
|
||||
|
||||
export const MAX_FALLBACK_WEIGHT = 3500;
|
||||
export const MAX_FALLBACK_LENGTH = 760;
|
||||
|
||||
/** Per-booking train length from wagon count and freight-specific wagon type length. */
|
||||
export function bookingTrainLengthMeters(
|
||||
freightType: string | null | undefined,
|
||||
wagonCount: number,
|
||||
lengths: { container: number; bulk: number },
|
||||
): number {
|
||||
const perWagon = freightType === 'BULK' ? lengths.bulk : lengths.container;
|
||||
return wagonCount * perWagon;
|
||||
}
|
||||
|
||||
export function wagonTypeDimensionsFromEntity(wt: {
|
||||
lengthMeters?: number | string | null;
|
||||
capacityTons?: number | string | null;
|
||||
}): WagonTypeDimensions {
|
||||
return {
|
||||
lengthMeters: Number(wt.lengthMeters) || DEFAULT_WAGON_LENGTH_M,
|
||||
capacityTons: Number(wt.capacityTons) || DEFAULT_WAGON_CAPACITY_T,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
|
||||
|
||||
@Injectable()
|
||||
export class TrainCheckpointEventsRepository extends BaseRepository<TrainCheckpointEvent> {
|
||||
constructor(
|
||||
@InjectRepository(TrainCheckpointEvent)
|
||||
repository: Repository<TrainCheckpointEvent>,
|
||||
) {
|
||||
super(repository);
|
||||
}
|
||||
|
||||
findBySchedule(trainScheduleId: string): Promise<TrainCheckpointEvent[]> {
|
||||
return this.findAll({
|
||||
where: { trainScheduleId },
|
||||
relations: { yard: true },
|
||||
order: { sequenceNo: 'ASC', occurredAt: 'ASC' },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -8,198 +8,423 @@ import {
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
} from "@nestjs/common";
|
||||
import { CurrentUser } from "@edr/api-common";
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import type { AuthUserPayload } from "../../common/resolve-auth-user-id";
|
||||
import { resolveAuthUserId } from "../../common/resolve-auth-user-id";
|
||||
|
||||
import { TrainSchedulingManage, TrainSchedulingView } from '../../common/booking-guards';
|
||||
import { AssignBookingsDto } from './dto/assign-bookings.dto';
|
||||
import { CreateContainerTrainScheduleDto } from './dto/create-container-train-schedule.dto';
|
||||
import { GetEligibleBookingsDto } from './dto/get-eligible-bookings.dto';
|
||||
import { GetEligibleBulkBookingsDto } from './dto/get-eligible-bulk-bookings.dto';
|
||||
import { GetEligibleContainerBookingsDto } from './dto/get-eligible-container-bookings.dto';
|
||||
import { PinWagonsDto } from './dto/pin-wagons.dto';
|
||||
import { PreviewBulkTrainScheduleDto } from './dto/preview-bulk-train-schedule.dto';
|
||||
import { PreviewContainerTrainScheduleDto } from './dto/preview-container-train-schedule.dto';
|
||||
import { PreviewTrainScheduleDto } from './dto/preview-train-schedule.dto';
|
||||
import { UpdateTrainSchedulingGlobalRulesDto } from './dto/update-train-scheduling-global-rules.dto';
|
||||
import { TrainSchedulingService } from './train-scheduling.service';
|
||||
import {
|
||||
TrainSchedulingManage,
|
||||
TrainSchedulingView,
|
||||
} from "../../common/booking-guards";
|
||||
import { AssignBookingsDto } from "./dto/assign-bookings.dto";
|
||||
import { AssignUnassignedBookingDto } from "./dto/assign-unassigned-booking.dto";
|
||||
import { CreateContainerTrainScheduleDto } from "./dto/create-container-train-schedule.dto";
|
||||
import { GetEligibleBookingsDto } from "./dto/get-eligible-bookings.dto";
|
||||
import { GetEligibleBulkBookingsDto } from "./dto/get-eligible-bulk-bookings.dto";
|
||||
import { GetEligibleContainerBookingsDto } from "./dto/get-eligible-container-bookings.dto";
|
||||
import { PinWagonsDto } from "./dto/pin-wagons.dto";
|
||||
import { UpdateContainerItemDto } from "./dto/update-container-item.dto";
|
||||
import { PreviewBulkTrainScheduleDto } from "./dto/preview-bulk-train-schedule.dto";
|
||||
import { PreviewContainerTrainScheduleDto } from "./dto/preview-container-train-schedule.dto";
|
||||
import { PreviewTrainScheduleDto } from "./dto/preview-train-schedule.dto";
|
||||
import { RecordCheckpointDto } from "./dto/record-checkpoint.dto";
|
||||
import { AvailableLocomotivesQueryDto } from "./dto/available-locomotives-query.dto";
|
||||
import { BookableSchedulesQueryDto } from "./dto/bookable-schedules-query.dto";
|
||||
import { UpdateTrainSchedulingGlobalRulesDto } from "./dto/update-train-scheduling-global-rules.dto";
|
||||
import { TrainSchedulingService } from "./train-scheduling.service";
|
||||
import { BookingBatchService } from "./booking-batch.service";
|
||||
|
||||
@ApiTags('train-scheduling')
|
||||
@ApiTags("train-scheduling")
|
||||
@ApiBearerAuth()
|
||||
@Controller('train-scheduling')
|
||||
@Controller("train-scheduling")
|
||||
export class TrainSchedulingController {
|
||||
constructor(private readonly trainSchedulingService: TrainSchedulingService) {}
|
||||
constructor(
|
||||
private readonly trainSchedulingService: TrainSchedulingService,
|
||||
private readonly bookingBatchService: BookingBatchService,
|
||||
) { }
|
||||
|
||||
@Get('global-rules')
|
||||
@Get("global-rules")
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({ summary: 'Get global train scheduling rules (singleton)' })
|
||||
@ApiOperation({ summary: "Get global train scheduling rules (singleton)" })
|
||||
getGlobalRules() {
|
||||
return this.trainSchedulingService.getTrainSchedulingGlobalRules();
|
||||
}
|
||||
|
||||
@Patch('global-rules')
|
||||
@Patch("global-rules")
|
||||
@TrainSchedulingManage()
|
||||
@ApiOperation({ summary: 'Update global train scheduling rules (singleton)' })
|
||||
@ApiOperation({ summary: "Update global train scheduling rules (singleton)" })
|
||||
updateGlobalRules(@Body() dto: UpdateTrainSchedulingGlobalRulesDto) {
|
||||
return this.trainSchedulingService.updateTrainSchedulingGlobalRules(dto);
|
||||
}
|
||||
|
||||
@Get('eligible-bookings')
|
||||
@Get("eligible-bookings")
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({ summary: 'List eligible bookings (container and/or bulk)' })
|
||||
@ApiOperation({ summary: "List eligible bookings (container and/or bulk)" })
|
||||
getEligibleBookings(@Query() query: GetEligibleBookingsDto) {
|
||||
return this.trainSchedulingService.getEligibleBookings(query);
|
||||
}
|
||||
|
||||
@Get('container/eligible-bookings')
|
||||
@Get("batch-board")
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({ summary: 'List eligible container bookings' })
|
||||
getEligibleContainerBookings(@Query() query: GetEligibleContainerBookingsDto) {
|
||||
@ApiOperation({
|
||||
summary: "Batch monitoring board: schedules with bookings grouped by state",
|
||||
})
|
||||
getBatchBoard() {
|
||||
return this.bookingBatchService.getBatchBoard();
|
||||
}
|
||||
|
||||
@Get("batch-board/:scheduleId")
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({
|
||||
summary: "Batch board detail for one schedule with EAT 3h windows",
|
||||
})
|
||||
getBatchBoardDetail(@Param("scheduleId", ParseUUIDPipe) scheduleId: string) {
|
||||
return this.bookingBatchService.getBatchBoardDetail(scheduleId);
|
||||
}
|
||||
|
||||
@Get("available-locomotives")
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({
|
||||
summary: "List AVAILABLE locomotives at the route origin yard",
|
||||
})
|
||||
getAvailableLocomotives(@Query() query: AvailableLocomotivesQueryDto) {
|
||||
return this.trainSchedulingService.getAvailableLocomotivesForRoute(
|
||||
query.routeId,
|
||||
);
|
||||
}
|
||||
|
||||
@Get("bookable-schedules")
|
||||
// No staff guard: customers hit this while creating a booking to find OPEN
|
||||
// same-route schedules. Do not attach train_scheduling permissions here.
|
||||
@ApiOperation({
|
||||
summary: "OPEN same-route schedules a new booking can target",
|
||||
})
|
||||
getBookableSchedules(@Query() query: BookableSchedulesQueryDto) {
|
||||
return this.trainSchedulingService.getBookableSchedules(
|
||||
query.originYardId,
|
||||
query.destinationYardId,
|
||||
);
|
||||
}
|
||||
|
||||
@Get("container/eligible-bookings")
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({ summary: "List eligible container bookings" })
|
||||
getEligibleContainerBookings(
|
||||
@Query() query: GetEligibleContainerBookingsDto,
|
||||
) {
|
||||
return this.trainSchedulingService.getEligibleContainerBookings(query);
|
||||
}
|
||||
|
||||
@Get('bulk/eligible-bookings')
|
||||
@Get("bulk/eligible-bookings")
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({ summary: 'List eligible bulk bookings' })
|
||||
@ApiOperation({ summary: "List eligible bulk bookings" })
|
||||
getEligibleBulkBookings(@Query() query: GetEligibleBulkBookingsDto) {
|
||||
return this.trainSchedulingService.getEligibleBulkBookings(query);
|
||||
}
|
||||
|
||||
@Post('preview')
|
||||
@Post("preview")
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({ summary: 'Preview a mixed-capable train schedule' })
|
||||
@ApiOperation({ summary: "Preview a mixed-capable train schedule" })
|
||||
previewTrainSchedule(@Body() dto: PreviewTrainScheduleDto) {
|
||||
return this.trainSchedulingService.previewTrainSchedule(dto);
|
||||
}
|
||||
|
||||
@Post('container/preview')
|
||||
@Post("container/preview")
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({ summary: 'Preview a container train schedule' })
|
||||
@ApiOperation({ summary: "Preview a container train schedule" })
|
||||
previewContainerTrainSchedule(@Body() dto: PreviewContainerTrainScheduleDto) {
|
||||
return this.trainSchedulingService.previewContainerTrainSchedule(dto);
|
||||
}
|
||||
|
||||
@Post('bulk/preview')
|
||||
@Post("bulk/preview")
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({ summary: 'Preview a bulk train schedule' })
|
||||
@ApiOperation({ summary: "Preview a bulk train schedule" })
|
||||
previewBulkTrainSchedule(@Body() dto: PreviewBulkTrainScheduleDto) {
|
||||
return this.trainSchedulingService.previewBulkTrainSchedule(dto);
|
||||
}
|
||||
|
||||
@Post('container/schedules')
|
||||
@Post("container/schedules")
|
||||
@TrainSchedulingManage()
|
||||
@ApiOperation({ summary: 'Create a container train schedule' })
|
||||
@ApiOperation({ summary: "Create a container train schedule" })
|
||||
createContainerTrainSchedule(@Body() dto: CreateContainerTrainScheduleDto) {
|
||||
return this.trainSchedulingService.createContainerTrainSchedule(dto);
|
||||
}
|
||||
|
||||
@Post('bulk/schedules')
|
||||
@Post("bulk/schedules")
|
||||
@TrainSchedulingManage()
|
||||
@ApiOperation({ summary: 'Create a bulk train schedule' })
|
||||
@ApiOperation({ summary: "Create a bulk train schedule" })
|
||||
createBulkTrainSchedule(@Body() dto: CreateContainerTrainScheduleDto) {
|
||||
return this.trainSchedulingService.createContainerTrainSchedule(dto);
|
||||
}
|
||||
|
||||
@Post('schedules/:id/assign-bookings')
|
||||
@Post("schedules/:id/assign-bookings")
|
||||
@TrainSchedulingManage()
|
||||
@ApiOperation({ summary: 'Assign bookings to a train schedule (mixed-capable)' })
|
||||
@ApiOperation({
|
||||
summary: "Assign bookings to a train schedule (mixed-capable)",
|
||||
})
|
||||
assignBookings(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: AssignBookingsDto,
|
||||
) {
|
||||
return this.trainSchedulingService.assignBookingsToSchedule(id, dto);
|
||||
}
|
||||
|
||||
@Post('container/schedules/:id/assign-bookings')
|
||||
@Post("container/schedules/:id/assign-bookings")
|
||||
@TrainSchedulingManage()
|
||||
@ApiOperation({ summary: 'Assign container bookings to a train schedule' })
|
||||
@ApiOperation({ summary: "Assign container bookings to a train schedule" })
|
||||
assignContainerBookings(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: AssignBookingsDto,
|
||||
) {
|
||||
return this.trainSchedulingService.assignBookingsToSchedule(id, dto, 'CONTAINER');
|
||||
return this.trainSchedulingService.assignBookingsToSchedule(
|
||||
id,
|
||||
dto,
|
||||
"CONTAINER",
|
||||
);
|
||||
}
|
||||
|
||||
@Post('bulk/schedules/:id/assign-bookings')
|
||||
@Post("bulk/schedules/:id/assign-bookings")
|
||||
@TrainSchedulingManage()
|
||||
@ApiOperation({ summary: 'Assign bulk bookings to a train schedule' })
|
||||
@ApiOperation({ summary: "Assign bulk bookings to a train schedule" })
|
||||
assignBulkBookings(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: AssignBookingsDto,
|
||||
) {
|
||||
return this.trainSchedulingService.assignBookingsToSchedule(id, dto, 'BULK');
|
||||
return this.trainSchedulingService.assignBookingsToSchedule(
|
||||
id,
|
||||
dto,
|
||||
"BULK",
|
||||
);
|
||||
}
|
||||
|
||||
@Delete('schedules/:id/bookings/:bookingId')
|
||||
@Delete("schedules/:id/bookings/:bookingId")
|
||||
@TrainSchedulingManage()
|
||||
@ApiOperation({ summary: 'Unassign a booking from a train schedule' })
|
||||
@ApiOperation({ summary: "Unassign a booking from a train schedule" })
|
||||
unassignBooking(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Param("bookingId", ParseUUIDPipe) bookingId: string,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.trainSchedulingService.unassignBooking(id, bookingId);
|
||||
return this.trainSchedulingService.unassignBooking(
|
||||
id,
|
||||
bookingId,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
}
|
||||
|
||||
@Post('schedules/:id/pin-wagons')
|
||||
@Delete("schedules/:id/wagons/:trainSetWagonId")
|
||||
@TrainSchedulingManage()
|
||||
@ApiOperation({ summary: 'Pin physical wagons to train set slots' })
|
||||
pinWagons(@Param('id', ParseUUIDPipe) id: string, @Body() dto: PinWagonsDto) {
|
||||
@ApiOperation({ summary: "Remove an empty wagon slot from a train" })
|
||||
removeWagonSlot(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Param("trainSetWagonId", ParseUUIDPipe) trainSetWagonId: string,
|
||||
) {
|
||||
return this.trainSchedulingService.removeTrainSetWagonSlot(
|
||||
id,
|
||||
trainSetWagonId,
|
||||
);
|
||||
}
|
||||
|
||||
@Patch("schedules/:id/container-items/:itemId")
|
||||
@TrainSchedulingManage()
|
||||
@ApiOperation({ summary: "Update a container number on a wagon slot" })
|
||||
updateContainerItem(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Param("itemId", ParseUUIDPipe) itemId: string,
|
||||
@Body() dto: UpdateContainerItemDto,
|
||||
) {
|
||||
return this.trainSchedulingService.updateContainerItem(id, itemId, dto);
|
||||
}
|
||||
|
||||
@Get("schedules/:id/unassigned-bookings")
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({ summary: "Get unassigned bookings for a schedule" })
|
||||
getUnassignedBookings(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.trainSchedulingService.getUnassignedBookings(id);
|
||||
}
|
||||
|
||||
@Post("schedules/:id/assign-unassigned-booking")
|
||||
@TrainSchedulingManage()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Assign one linked unallocated booking to wagons (preserves existing assignments)",
|
||||
})
|
||||
assignUnassignedBooking(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: AssignUnassignedBookingDto,
|
||||
) {
|
||||
return this.trainSchedulingService.assignUnassignedBookingToWagons(
|
||||
id,
|
||||
dto.bookingId,
|
||||
);
|
||||
}
|
||||
|
||||
@Get("schedules/:id/composition-removals")
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({ summary: "Get removal log for a schedule" })
|
||||
getCompositionRemovals(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.trainSchedulingService.getCompositionRemovals(id);
|
||||
}
|
||||
|
||||
@Post("schedules/:id/pin-wagons")
|
||||
@TrainSchedulingManage()
|
||||
@ApiOperation({ summary: "Pin physical wagons to train set slots" })
|
||||
pinWagons(@Param("id", ParseUUIDPipe) id: string, @Body() dto: PinWagonsDto) {
|
||||
return this.trainSchedulingService.pinWagons(id, dto);
|
||||
}
|
||||
|
||||
@Post('schedules/:id/finalize')
|
||||
@Post("schedules/:id/finalize")
|
||||
@TrainSchedulingManage()
|
||||
@ApiOperation({ summary: 'Finalize a draft train schedule' })
|
||||
finalizeSchedule(@Param('id', ParseUUIDPipe) id: string) {
|
||||
@ApiOperation({ summary: "Finalize a draft train schedule" })
|
||||
finalizeSchedule(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.trainSchedulingService.finalizeSchedule(id);
|
||||
}
|
||||
|
||||
@Post('schedules/:id/dispatch')
|
||||
@Post("schedules/:id/dispatch")
|
||||
@TrainSchedulingManage()
|
||||
@ApiOperation({ summary: 'Dispatch a scheduled train' })
|
||||
dispatchSchedule(@Param('id', ParseUUIDPipe) id: string) {
|
||||
@ApiOperation({ summary: "Dispatch a scheduled train" })
|
||||
dispatchSchedule(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.trainSchedulingService.dispatchSchedule(id);
|
||||
}
|
||||
|
||||
@Get('container/schedules')
|
||||
// ---- batch / booking-window staff actions ----
|
||||
|
||||
@Post("schedules/:id/run-batch")
|
||||
@TrainSchedulingManage()
|
||||
@ApiOperation({ summary: "Manually run the batch fill for a schedule" })
|
||||
async runBatch(@Param("id", ParseUUIDPipe) id: string) {
|
||||
await this.bookingBatchService.fillSchedule(id);
|
||||
return this.bookingBatchService.getBatchBoardDetail(id);
|
||||
}
|
||||
|
||||
@Post("schedules/:id/run-allocation")
|
||||
@TrainSchedulingManage()
|
||||
@ApiOperation({
|
||||
summary: "Run wagon-level allocation for all eligible linked bookings",
|
||||
})
|
||||
async runAllocation(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.bookingBatchService.runWagonAllocation(id);
|
||||
}
|
||||
|
||||
@Patch("schedules/:id/booking-window")
|
||||
@TrainSchedulingManage()
|
||||
@ApiOperation({ summary: "Open or close a schedule booking window" })
|
||||
async setBookingWindow(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body("status") status: "OPEN" | "CLOSED",
|
||||
) {
|
||||
await this.trainSchedulingService.setBookingWindow(
|
||||
id,
|
||||
status === "CLOSED" ? "CLOSED" : "OPEN",
|
||||
);
|
||||
return this.trainSchedulingService.getContainerTrainScheduleById(id);
|
||||
}
|
||||
|
||||
@Post("bookings/:bookingId/mark-paid")
|
||||
@TrainSchedulingManage()
|
||||
@ApiOperation({
|
||||
summary: "Staff: mark a reserved booking paid and allocate it now",
|
||||
})
|
||||
async markBookingPaid(@Param("bookingId", ParseUUIDPipe) bookingId: string) {
|
||||
await this.bookingBatchService.markPaid(bookingId);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@Post("bookings/:bookingId/expire")
|
||||
@TrainSchedulingManage()
|
||||
@ApiOperation({
|
||||
summary: "Staff: expire a reservation and free its capacity",
|
||||
})
|
||||
async expireBooking(@Param("bookingId", ParseUUIDPipe) bookingId: string) {
|
||||
await this.bookingBatchService.expireReservation(bookingId);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@Post("bookings/:bookingId/move-schedule")
|
||||
@TrainSchedulingManage()
|
||||
@ApiOperation({
|
||||
summary: "Re-point a booking to another OPEN same-route schedule",
|
||||
})
|
||||
async moveBookingSchedule(
|
||||
@Param("bookingId", ParseUUIDPipe) bookingId: string,
|
||||
@Body("trainScheduleId", ParseUUIDPipe) trainScheduleId: string,
|
||||
) {
|
||||
await this.bookingBatchService.moveToSchedule(bookingId, trainScheduleId);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@Get("schedules/:id/checkpoints")
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({ summary: 'List container train schedules' })
|
||||
@ApiOperation({
|
||||
summary: "Get the tracking corridor + logged checkpoints for a train",
|
||||
})
|
||||
getScheduleCheckpoints(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.trainSchedulingService.getScheduleCheckpoints(id);
|
||||
}
|
||||
|
||||
@Post("schedules/:id/checkpoints")
|
||||
@TrainSchedulingManage()
|
||||
@ApiOperation({
|
||||
summary: "Log the train passing a station (final station triggers arrival)",
|
||||
})
|
||||
recordCheckpoint(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: RecordCheckpointDto,
|
||||
) {
|
||||
return this.trainSchedulingService.recordCheckpoint(id, dto);
|
||||
}
|
||||
|
||||
@Post("schedules/:id/arrive")
|
||||
@TrainSchedulingManage()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Mark a dispatched train arrived (move assets to destination yard, free assets)",
|
||||
})
|
||||
arriveSchedule(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.trainSchedulingService.arriveSchedule(id);
|
||||
}
|
||||
|
||||
@Get("container/schedules")
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({ summary: "List container train schedules" })
|
||||
getContainerTrainSchedules() {
|
||||
return this.trainSchedulingService.getContainerTrainSchedules();
|
||||
}
|
||||
|
||||
@Get('bulk/schedules')
|
||||
@Get("bulk/schedules")
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({ summary: 'List bulk train schedules' })
|
||||
@ApiOperation({ summary: "List bulk train schedules" })
|
||||
getBulkTrainSchedules() {
|
||||
return this.trainSchedulingService.getContainerTrainSchedules();
|
||||
}
|
||||
|
||||
@Get('container/schedules/:id')
|
||||
@Get("container/schedules/:id")
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({ summary: 'Get container train schedule detail' })
|
||||
getContainerTrainScheduleById(@Param('id', ParseUUIDPipe) id: string) {
|
||||
@ApiOperation({ summary: "Get container train schedule detail" })
|
||||
getContainerTrainScheduleById(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.trainSchedulingService.getContainerTrainScheduleById(id);
|
||||
}
|
||||
|
||||
@Get('bulk/schedules/:id')
|
||||
@Get("bulk/schedules/:id")
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({ summary: 'Get bulk train schedule detail' })
|
||||
getBulkTrainScheduleById(@Param('id', ParseUUIDPipe) id: string) {
|
||||
@ApiOperation({ summary: "Get bulk train schedule detail" })
|
||||
getBulkTrainScheduleById(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.trainSchedulingService.getContainerTrainScheduleById(id);
|
||||
}
|
||||
|
||||
@Post('container/schedules/:id/cancel')
|
||||
@Post("container/schedules/:id/cancel")
|
||||
@TrainSchedulingManage()
|
||||
@ApiOperation({ summary: 'Cancel container train schedule' })
|
||||
cancelTrainSchedule(@Param('id', ParseUUIDPipe) id: string) {
|
||||
@ApiOperation({ summary: "Cancel container train schedule" })
|
||||
cancelTrainSchedule(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.trainSchedulingService.cancelTrainSchedule(id);
|
||||
}
|
||||
|
||||
@Post('bulk/schedules/:id/cancel')
|
||||
@TrainSchedulingManage()
|
||||
@ApiOperation({ summary: 'Cancel bulk train schedule' })
|
||||
cancelBulkTrainSchedule(@Param('id', ParseUUIDPipe) id: string) {
|
||||
@ApiOperation({ summary: "Cancel bulk train schedule" })
|
||||
cancelBulkTrainSchedule(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.trainSchedulingService.cancelTrainSchedule(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { BookingsModule } from '../bookings/bookings.module';
|
||||
@@ -14,24 +14,30 @@ import { TrainSchedulesModule } from '../train-schedules/train-schedules.module'
|
||||
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||
import { WagonTypesModule } from '../wagon-types/wagon-types.module';
|
||||
import { Wagon } from '../wagons/entities/wagon.entity';
|
||||
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
|
||||
import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity';
|
||||
import { TrainCheckpointEventsRepository } from './train-checkpoint-events.repository';
|
||||
import { TrainSchedulingController } from './train-scheduling.controller';
|
||||
import { TrainSchedulingService } from './train-scheduling.service';
|
||||
import { BookingBatchService } from './booking-batch.service';
|
||||
import { BookingNotifierService } from './booking-notifier.service';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
Locomotive,
|
||||
WagonType,
|
||||
Wagon,
|
||||
TrainSet,
|
||||
TrainSetWagon,
|
||||
Route,
|
||||
Wagon,
|
||||
Container,
|
||||
TrainSchedulingGlobalRules,
|
||||
TrainCheckpointEvent,
|
||||
]),
|
||||
BookingsModule,
|
||||
forwardRef(() => BookingsModule),
|
||||
NotificationsModule,
|
||||
LocomotivesModule,
|
||||
WagonTypesModule,
|
||||
TrainSetsModule,
|
||||
@@ -39,7 +45,12 @@ import { TrainSchedulingService } from './train-scheduling.service';
|
||||
RuleEngineModule,
|
||||
],
|
||||
controllers: [TrainSchedulingController],
|
||||
providers: [TrainSchedulingService],
|
||||
exports: [TrainSchedulingService],
|
||||
providers: [
|
||||
TrainSchedulingService,
|
||||
TrainCheckpointEventsRepository,
|
||||
BookingBatchService,
|
||||
BookingNotifierService,
|
||||
],
|
||||
exports: [TrainSchedulingService, BookingBatchService],
|
||||
})
|
||||
export class TrainSchedulingModule {}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { ConflictException } from '@nestjs/common';
|
||||
import { WagonReadiness, WagonStatus } from '@edr/types';
|
||||
import { BadRequestException, ConflictException } from '@nestjs/common';
|
||||
import { WagonStatus } from '@edr/types';
|
||||
|
||||
import { Wagon } from '../wagons/entities/wagon.entity';
|
||||
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
|
||||
import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity';
|
||||
import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity';
|
||||
import { TrainSchedulingService } from './train-scheduling.service';
|
||||
|
||||
const nw5 = {
|
||||
@@ -25,6 +26,7 @@ const locomotive = {
|
||||
maxPullWeightTons: 3500,
|
||||
maxTrainLengthMeters: 760,
|
||||
status: 'AVAILABLE',
|
||||
currentYardId: 'yard-origin',
|
||||
};
|
||||
|
||||
const cw3 = {
|
||||
@@ -95,6 +97,7 @@ describe('TrainSchedulingService', () => {
|
||||
bookingsRepository = {
|
||||
findEligibleForScheduling: jest.fn(),
|
||||
findByIdsForScheduling: jest.fn(),
|
||||
findAll: jest.fn(),
|
||||
updateSchedulingFields: jest.fn(),
|
||||
};
|
||||
locomotivesRepository = { findById: jest.fn(), findAll: jest.fn() };
|
||||
@@ -125,6 +128,13 @@ describe('TrainSchedulingService', () => {
|
||||
findAll: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
|
||||
const trainCheckpointEventsRepository = {
|
||||
findBySchedule: jest.fn().mockResolvedValue([]),
|
||||
findAll: jest.fn().mockResolvedValue([]),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
};
|
||||
|
||||
service = new TrainSchedulingService(
|
||||
dataSource as never,
|
||||
bookingsRepository as never,
|
||||
@@ -135,6 +145,8 @@ describe('TrainSchedulingService', () => {
|
||||
wagonBookingAllocationsRepository as never,
|
||||
wagonAllocationContainerItemsRepository as never,
|
||||
wagonAllocationBulkLoadsRepository as never,
|
||||
trainCheckpointEventsRepository as never,
|
||||
{} as never, // trainCompositionRemovalLogRepository
|
||||
);
|
||||
|
||||
const defaultFleetWagons = [
|
||||
@@ -142,14 +154,14 @@ describe('TrainSchedulingService', () => {
|
||||
id: `wagon-nw5-${index}`,
|
||||
wagonTypeId: nw5.id,
|
||||
status: WagonStatus.Available,
|
||||
readiness: WagonReadiness.ImportReady,
|
||||
currentYardId: 'yard-origin',
|
||||
currentTrainScheduleId: null,
|
||||
})),
|
||||
...Array.from({ length: 50 }, (_, index) => ({
|
||||
id: `wagon-cw3-${index}`,
|
||||
wagonTypeId: cw3.id,
|
||||
status: WagonStatus.Available,
|
||||
readiness: WagonReadiness.ImportReady,
|
||||
currentYardId: 'yard-origin',
|
||||
currentTrainScheduleId: null,
|
||||
})),
|
||||
];
|
||||
@@ -183,7 +195,7 @@ describe('TrainSchedulingService', () => {
|
||||
id: `wagon-${index}`,
|
||||
wagonTypeId: nw5.id,
|
||||
status: WagonStatus.Available,
|
||||
readiness: WagonReadiness.ImportReady,
|
||||
currentYardId: 'yard-origin',
|
||||
currentTrainScheduleId: null,
|
||||
}));
|
||||
|
||||
@@ -345,7 +357,7 @@ describe('TrainSchedulingService', () => {
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects bookings that are not in assignable status', async () => {
|
||||
it('rejects bookings that are not in schedulable status', async () => {
|
||||
const bookings = [
|
||||
{ ...makeBooking('b7', 'BKG-CONT-007', 120, 2, '40FT', 2), status: 'APPROVED' },
|
||||
];
|
||||
@@ -364,7 +376,7 @@ describe('TrainSchedulingService', () => {
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.violations).toContain(
|
||||
'Only APPROVED, READY_FOR_ASSIGNMENT bookings can be assigned; received: PAID',
|
||||
'Only PAID bookings can be scheduled; received: APPROVED',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -412,6 +424,9 @@ describe('TrainSchedulingService', () => {
|
||||
if (entity === TrainSchedulingGlobalRules) {
|
||||
return { find: jest.fn().mockResolvedValue([]) };
|
||||
}
|
||||
if (entity === WagonType) {
|
||||
return { find: jest.fn().mockResolvedValue([nw5, cw3]) };
|
||||
}
|
||||
throw new Error(`Unexpected repository ${(entity as { name?: string })?.name}`);
|
||||
});
|
||||
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue({ id: 'schedule-1' });
|
||||
@@ -521,14 +536,14 @@ describe('TrainSchedulingService', () => {
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
|
||||
it('rejects pin when wagon readiness does not match schedule direction', async () => {
|
||||
it('rejects pin when wagon is not at the schedule origin yard', async () => {
|
||||
const scheduleId = 'sched-1';
|
||||
const slotId = 'slot-1';
|
||||
|
||||
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue({
|
||||
id: scheduleId,
|
||||
status: 'DRAFT',
|
||||
direction: 'IMPORT',
|
||||
originStationId: 'yard-origin',
|
||||
trainSet: {
|
||||
wagons: [{ id: slotId, physicalWagonId: null }],
|
||||
},
|
||||
@@ -542,7 +557,7 @@ describe('TrainSchedulingService', () => {
|
||||
id: 'wagon-1',
|
||||
wagonNumber: 'WGN-001',
|
||||
status: WagonStatus.Available,
|
||||
readiness: WagonReadiness.ExportReady,
|
||||
currentYardId: 'yard-other',
|
||||
currentTrainScheduleId: null,
|
||||
}),
|
||||
update: jest.fn(),
|
||||
@@ -564,4 +579,355 @@ describe('TrainSchedulingService', () => {
|
||||
}),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
|
||||
it('flags physical fleet shortfall when wagons are not at the origin yard', async () => {
|
||||
const exportBooking = makeBooking(
|
||||
'exp-1',
|
||||
'BKG-EXP',
|
||||
50,
|
||||
1,
|
||||
'40FT',
|
||||
1,
|
||||
'2026-06-20T08:00:00.000Z',
|
||||
'yard-addis',
|
||||
'yard-djibouti',
|
||||
{
|
||||
originYard: { label: 'Addis Ababa', code: 'ADDIS', country: 'Ethiopia' },
|
||||
destinationYard: { label: 'Djibouti', code: 'DJIBOUTI', country: 'Djibouti' },
|
||||
},
|
||||
);
|
||||
|
||||
wagonTypesRepository.findAll.mockResolvedValue([nw5]);
|
||||
bookingsRepository.findByIdsForScheduling.mockResolvedValue([exportBooking]);
|
||||
trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]);
|
||||
locomotivesRepository.findAll.mockResolvedValue([
|
||||
{ ...locomotive, currentYardId: 'yard-addis' },
|
||||
]);
|
||||
|
||||
const wrongYardFleet = Array.from({ length: 5 }, (_, index) => ({
|
||||
id: `wagon-nw5-${index}`,
|
||||
wagonTypeId: nw5.id,
|
||||
wagonNumber: `WGN-${index}`,
|
||||
status: WagonStatus.Available,
|
||||
currentYardId: 'yard-djibouti',
|
||||
currentTrainScheduleId: null,
|
||||
}));
|
||||
|
||||
dataSource.getRepository.mockImplementation((entity: unknown) => {
|
||||
if (entity === TrainSchedulingGlobalRules) {
|
||||
return { find: jest.fn().mockResolvedValue([]) };
|
||||
}
|
||||
if (entity === Wagon) {
|
||||
return { find: jest.fn().mockResolvedValue(wrongYardFleet) };
|
||||
}
|
||||
if (entity === WagonType) {
|
||||
return { find: jest.fn().mockResolvedValue([nw5]) };
|
||||
}
|
||||
return { find: jest.fn().mockResolvedValue([]), findOne: jest.fn().mockResolvedValue(null) };
|
||||
});
|
||||
|
||||
const result = await service.previewContainerTrainSchedule({
|
||||
bookingIds: ['exp-1'],
|
||||
scheduleDate: '2026-06-20T08:00:00.000Z',
|
||||
originStationId: 'yard-addis',
|
||||
destinationStationId: 'yard-djibouti',
|
||||
});
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(
|
||||
result.violations.some((v) => v.includes('available at yard') && v.includes('NW5')),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('assignBookingsToSchedule rejects when physical wagons cannot be pinned', async () => {
|
||||
const scheduleId = 'sched-assign-1';
|
||||
const trainSetId = 'train-set-1';
|
||||
const booking = makeBooking('b-pin', 'BKG-PIN', 50, 1, '40FT', 1);
|
||||
|
||||
wagonTypesRepository.findAll.mockResolvedValue([nw5]);
|
||||
bookingsRepository.findByIdsForScheduling.mockResolvedValue([{ ...booking, trainScheduleId: scheduleId }]);
|
||||
trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]);
|
||||
locomotivesRepository.findAll.mockResolvedValue([locomotive]);
|
||||
trainSchedulesRepository.findById.mockResolvedValue({
|
||||
id: scheduleId,
|
||||
direction: 'IMPORT',
|
||||
});
|
||||
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue({
|
||||
id: scheduleId,
|
||||
status: 'DRAFT',
|
||||
direction: 'IMPORT',
|
||||
trainSetId,
|
||||
originStationId: 'yard-origin',
|
||||
destinationStationId: 'yard-destination',
|
||||
scheduledDepartureDate: new Date('2026-06-20T08:00:00.000Z'),
|
||||
trainSet: {
|
||||
id: trainSetId,
|
||||
locomotive,
|
||||
wagons: [],
|
||||
},
|
||||
scheduleBookings: [],
|
||||
});
|
||||
|
||||
dataSource.getRepository.mockImplementation((entity: unknown) => {
|
||||
if (entity === TrainSchedulingGlobalRules) {
|
||||
return { find: jest.fn().mockResolvedValue([]) };
|
||||
}
|
||||
if (entity === Wagon) {
|
||||
return { find: jest.fn().mockResolvedValue([]) };
|
||||
}
|
||||
if (entity === WagonType) {
|
||||
return { find: jest.fn().mockResolvedValue([nw5]) };
|
||||
}
|
||||
return { find: jest.fn().mockResolvedValue([]), findOne: jest.fn().mockResolvedValue(null) };
|
||||
});
|
||||
|
||||
const wagonRepo = {
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
update: jest.fn(),
|
||||
};
|
||||
const trainSetWagonRepo = {
|
||||
delete: jest.fn(),
|
||||
create: jest.fn((v) => v),
|
||||
save: jest.fn(async (rows) =>
|
||||
rows.map((r: { sequenceNo: number; wagonTypeId: string }, i: number) => ({
|
||||
...r,
|
||||
id: `slot-${i + 1}`,
|
||||
})),
|
||||
),
|
||||
update: jest.fn(),
|
||||
};
|
||||
|
||||
const manager = {
|
||||
getRepository: jest.fn((entity: unknown) => {
|
||||
if (entity === Wagon) return wagonRepo;
|
||||
if (entity === WagonType) return { find: jest.fn().mockResolvedValue([nw5]) };
|
||||
if (entity === TrainSetWagon) return trainSetWagonRepo;
|
||||
if ((entity as { name?: string })?.name === 'TrainSet') return { update: jest.fn() };
|
||||
if ((entity as { name?: string })?.name === 'TrainScheduleBooking') return { delete: jest.fn() };
|
||||
if ((entity as { name?: string })?.name === 'WagonBookingAllocation') {
|
||||
return {
|
||||
create: jest.fn((v) => v),
|
||||
save: jest.fn(async (v) => ({ ...v, id: 'alloc-1' })),
|
||||
delete: jest.fn(),
|
||||
};
|
||||
}
|
||||
return { delete: jest.fn(), update: jest.fn(), find: jest.fn().mockResolvedValue([]) };
|
||||
}),
|
||||
};
|
||||
dataSource.transaction.mockImplementation(async (cb: (m: typeof manager) => Promise<void>) =>
|
||||
cb(manager),
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.assignBookingsToSchedule(
|
||||
scheduleId,
|
||||
{ bookingIds: ['b-pin'], containerPlacements: [] },
|
||||
'CONTAINER',
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
describe('getUnassignedBookings', () => {
|
||||
const scheduleId = 'sched-unassigned-1';
|
||||
const trainSetId = 'train-set-unassigned';
|
||||
const assignedBooking = makeBooking('b-assigned', 'BKG-ASSIGNED', 50, 1, '40FT', 1);
|
||||
const unassignedBooking = makeBooking('b-unassigned', 'BKG-UNASSIGNED', 60, 1, '40FT', 1);
|
||||
|
||||
const buildScheduleGraph = () => ({
|
||||
id: scheduleId,
|
||||
status: 'DRAFT',
|
||||
originStationId: 'yard-origin',
|
||||
destinationStationId: 'yard-destination',
|
||||
scheduledDepartureDate: new Date('2026-06-20T08:00:00.000Z'),
|
||||
trainSet: {
|
||||
id: trainSetId,
|
||||
locomotive: { ...locomotive, status: 'ASSIGNED', currentYardId: 'yard-origin' },
|
||||
wagons: [{ id: 'slot-1', sequenceNo: 1, wagonTypeId: nw5.id, allocations: [] }],
|
||||
},
|
||||
scheduleBookings: [],
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
wagonTypesRepository.findAll.mockResolvedValue([nw5]);
|
||||
trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]);
|
||||
locomotivesRepository.findAll.mockResolvedValue([locomotive]);
|
||||
bookingsRepository.findAll.mockResolvedValue([
|
||||
{
|
||||
...assignedBooking,
|
||||
trainScheduleId: scheduleId,
|
||||
paymentStatus: 'PAID',
|
||||
isGovernment: false,
|
||||
},
|
||||
{
|
||||
...unassignedBooking,
|
||||
trainScheduleId: scheduleId,
|
||||
paymentStatus: 'PAID',
|
||||
isGovernment: false,
|
||||
},
|
||||
]);
|
||||
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(buildScheduleGraph());
|
||||
});
|
||||
|
||||
it('allows assign when train slots are full but origin yard has matching wagons', async () => {
|
||||
const yardFleet = [
|
||||
{
|
||||
id: 'wagon-pinned',
|
||||
wagonTypeId: nw5.id,
|
||||
status: WagonStatus.Assigned,
|
||||
currentYardId: 'yard-origin',
|
||||
currentTrainScheduleId: scheduleId,
|
||||
},
|
||||
...Array.from({ length: 2 }, (_, index) => ({
|
||||
id: `wagon-yard-${index}`,
|
||||
wagonTypeId: nw5.id,
|
||||
status: WagonStatus.Available,
|
||||
currentYardId: 'yard-origin',
|
||||
currentTrainScheduleId: null,
|
||||
})),
|
||||
];
|
||||
|
||||
bookingsRepository.findByIdsForScheduling.mockImplementation(async (ids: string[]) => {
|
||||
const map = new Map([
|
||||
[assignedBooking.id, { ...assignedBooking, trainScheduleId: scheduleId }],
|
||||
[unassignedBooking.id, { ...unassignedBooking, trainScheduleId: scheduleId }],
|
||||
]);
|
||||
return ids.map((id) => map.get(id)).filter(Boolean);
|
||||
});
|
||||
|
||||
dataSource.getRepository.mockImplementation((entity: unknown) => {
|
||||
if (entity === TrainSchedulingGlobalRules) {
|
||||
return { find: jest.fn().mockResolvedValue([]) };
|
||||
}
|
||||
if (entity === Wagon) {
|
||||
return { find: jest.fn().mockResolvedValue(yardFleet) };
|
||||
}
|
||||
if (entity === WagonType) {
|
||||
return { find: jest.fn().mockResolvedValue([nw5]) };
|
||||
}
|
||||
if (entity === WagonBookingAllocation) {
|
||||
return {
|
||||
find: jest.fn().mockResolvedValue([{ bookingId: assignedBooking.id }]),
|
||||
};
|
||||
}
|
||||
return { find: jest.fn().mockResolvedValue([]), findOne: jest.fn().mockResolvedValue(null) };
|
||||
});
|
||||
|
||||
const result = await service.getUnassignedBookings(scheduleId);
|
||||
|
||||
expect(result.bookings).toHaveLength(1);
|
||||
expect(result.bookings[0].id).toBe(unassignedBooking.id);
|
||||
expect(result.bookings[0].canAssign).toBe(true);
|
||||
expect(result.bookings[0].blockReason).toBeNull();
|
||||
expect(
|
||||
result.fleetAtOrigin.some(
|
||||
(row: { wagonTypeCode: string; available: number }) =>
|
||||
row.wagonTypeCode === 'NW5' && row.available >= 2,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('blocks assign when origin yard lacks wagons of the required type', async () => {
|
||||
const yardFleet = [
|
||||
{
|
||||
id: 'wagon-pinned',
|
||||
wagonTypeId: nw5.id,
|
||||
status: WagonStatus.Assigned,
|
||||
currentYardId: 'yard-origin',
|
||||
currentTrainScheduleId: scheduleId,
|
||||
},
|
||||
];
|
||||
|
||||
bookingsRepository.findByIdsForScheduling.mockImplementation(async (ids: string[]) => {
|
||||
const map = new Map([
|
||||
[assignedBooking.id, { ...assignedBooking, trainScheduleId: scheduleId }],
|
||||
[unassignedBooking.id, { ...unassignedBooking, trainScheduleId: scheduleId }],
|
||||
]);
|
||||
return ids.map((id) => map.get(id)).filter(Boolean);
|
||||
});
|
||||
|
||||
dataSource.getRepository.mockImplementation((entity: unknown) => {
|
||||
if (entity === TrainSchedulingGlobalRules) {
|
||||
return { find: jest.fn().mockResolvedValue([]) };
|
||||
}
|
||||
if (entity === Wagon) {
|
||||
return { find: jest.fn().mockResolvedValue(yardFleet) };
|
||||
}
|
||||
if (entity === WagonType) {
|
||||
return { find: jest.fn().mockResolvedValue([nw5]) };
|
||||
}
|
||||
if (entity === WagonBookingAllocation) {
|
||||
return {
|
||||
find: jest.fn().mockResolvedValue([{ bookingId: assignedBooking.id }]),
|
||||
};
|
||||
}
|
||||
return { find: jest.fn().mockResolvedValue([]), findOne: jest.fn().mockResolvedValue(null) };
|
||||
});
|
||||
|
||||
const result = await service.getUnassignedBookings(scheduleId);
|
||||
|
||||
expect(result.bookings).toHaveLength(1);
|
||||
expect(result.bookings[0].canAssign).toBe(false);
|
||||
expect(result.bookings[0].blockReason).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAvailableLocomotivesForRoute', () => {
|
||||
it('returns locomotives at the route origin yard', async () => {
|
||||
const routeId = 'route-export';
|
||||
const originYardId = 'yard-addis';
|
||||
const routeRepo = {
|
||||
findOne: jest.fn().mockResolvedValue({
|
||||
id: routeId,
|
||||
name: 'Addis → Djibouti',
|
||||
isActive: true,
|
||||
originYardId,
|
||||
originYard: { country: 'Ethiopia' },
|
||||
destinationYard: { country: 'Djibouti' },
|
||||
}),
|
||||
};
|
||||
dataSource.getRepository.mockImplementation((entity: unknown) => {
|
||||
if ((entity as { name?: string })?.name === 'Route') return routeRepo;
|
||||
return { findOne: jest.fn(), update: jest.fn() };
|
||||
});
|
||||
locomotivesRepository.findAll.mockResolvedValue([
|
||||
{ id: 'l2', code: 'EXP', status: 'AVAILABLE', currentYardId: originYardId },
|
||||
]);
|
||||
|
||||
const result = await service.getAvailableLocomotivesForRoute(routeId);
|
||||
|
||||
expect(locomotivesRepository.findAll).toHaveBeenCalledWith({
|
||||
where: { status: 'AVAILABLE', currentYardId: originYardId },
|
||||
order: { code: 'ASC' },
|
||||
});
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].code).toBe('EXP');
|
||||
});
|
||||
|
||||
it('returns all locomotives returned by the repository for domestic routes', async () => {
|
||||
const routeId = 'route-domestic';
|
||||
const originYardId = 'yard-addis';
|
||||
const routeRepo = {
|
||||
findOne: jest.fn().mockResolvedValue({
|
||||
id: routeId,
|
||||
name: 'Addis → Dire Dawa',
|
||||
isActive: true,
|
||||
originYardId,
|
||||
originYard: { country: 'Ethiopia' },
|
||||
destinationYard: { country: 'Ethiopia' },
|
||||
}),
|
||||
};
|
||||
dataSource.getRepository.mockImplementation((entity: unknown) => {
|
||||
if ((entity as { name?: string })?.name === 'Route') return routeRepo;
|
||||
return { findOne: jest.fn(), update: jest.fn() };
|
||||
});
|
||||
locomotivesRepository.findAll.mockResolvedValue([
|
||||
{ id: 'l1', code: 'IMP', status: 'AVAILABLE', currentYardId: originYardId },
|
||||
{ id: 'l2', code: 'EXP', status: 'AVAILABLE', currentYardId: originYardId },
|
||||
]);
|
||||
|
||||
const result = await service.getAvailableLocomotivesForRoute(routeId);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -415,8 +415,10 @@ export function validateTrainLimits(
|
||||
const violations: string[] = [];
|
||||
const maxWeightTons = limits?.maxWeightTons ?? MAX_TRAIN_WEIGHT_TONS;
|
||||
const maxLengthMeters = limits?.maxLengthMeters ?? MAX_TRAIN_LENGTH_METERS;
|
||||
const wagonLength = Number(wagonType.lengthMeters) || 14;
|
||||
const maxWagonsPerTrain =
|
||||
limits?.maxWagonsPerTrain ?? Number(wagonType.maxWagonsPerTrain ?? 53);
|
||||
limits?.maxWagonsPerTrain ??
|
||||
Math.floor(maxLengthMeters / wagonLength);
|
||||
|
||||
const totalWeightTons = roundTons(
|
||||
wagonPlan.reduce((sum, w) => sum + w.assignedWeightTons, 0),
|
||||
@@ -451,9 +453,13 @@ export function validateMixedTrainLimits(
|
||||
wagonTypes: WagonType[],
|
||||
limits?: TrainLimitConfig,
|
||||
): string[] {
|
||||
const maxLengthMeters = limits?.maxLengthMeters ?? MAX_TRAIN_LENGTH_METERS;
|
||||
const minWagonLength = Math.min(
|
||||
...wagonTypes.map((wt) => Number(wt.lengthMeters) || 14),
|
||||
14,
|
||||
);
|
||||
const maxWagonsPerTrain =
|
||||
limits?.maxWagonsPerTrain ??
|
||||
Math.max(...wagonTypes.map((wt) => Number(wt.maxWagonsPerTrain ?? 53)), 53);
|
||||
limits?.maxWagonsPerTrain ?? Math.floor(maxLengthMeters / minWagonLength);
|
||||
|
||||
return validateTrainLimits(
|
||||
wagonPlan,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { WagonReadiness, type ScheduleTradeDirection } from '@edr/types';
|
||||
|
||||
/** @deprecated Replaced by yard-based fleet filtering via `currentYardId`. */
|
||||
export function requiredWagonReadiness(
|
||||
direction: ScheduleTradeDirection | string | null | undefined,
|
||||
): WagonReadiness | null {
|
||||
@@ -8,6 +9,7 @@ export function requiredWagonReadiness(
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @deprecated Replaced by `wagon.currentYardId === originYardId` checks. */
|
||||
export function wagonReadinessMatchesSchedule(
|
||||
wagonReadiness: WagonReadiness | string,
|
||||
direction: ScheduleTradeDirection | string | null | undefined,
|
||||
@@ -16,3 +18,14 @@ export function wagonReadinessMatchesSchedule(
|
||||
if (!required) return true;
|
||||
return wagonReadiness === required;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Replaced by setting `currentYardId = schedule.destinationStationId` on arrival.
|
||||
*/
|
||||
export function flipReadiness(
|
||||
readiness: WagonReadiness | string,
|
||||
): WagonReadiness {
|
||||
return readiness === WagonReadiness.ImportReady
|
||||
? WagonReadiness.ExportReady
|
||||
: WagonReadiness.ImportReady;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user