Warehouse Enhancemendt

This commit is contained in:
hagiye
2026-06-20 11:49:22 +03:00
1393 changed files with 334619 additions and 20282 deletions

View File

@@ -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:0022: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:0010: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:0007: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:0007: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: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');
});
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');
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 (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);
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 → 0609 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);
});
});

View File

@@ -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:0007: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 (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`. */
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;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,8 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsUUID } from 'class-validator';
export class AssignUnassignedBookingDto {
@ApiProperty({ format: 'uuid' })
@IsUUID()
bookingId!: string;
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,6 +12,11 @@ export class GetEligibleBulkBookingsDto {
@IsUUID()
destinationStationId?: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
trainScheduleId?: string;
@ApiPropertyOptional({ example: 'HOLDING' })
@IsOptional()
schedulingStatus?: string;

View File

@@ -12,6 +12,11 @@ export class GetEligibleContainerBookingsDto {
@IsUUID()
destinationStationId?: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
trainScheduleId?: string;
@ApiPropertyOptional({ example: 'HOLDING' })
@IsOptional()
schedulingStatus?: string;

View File

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

View File

@@ -0,0 +1,7 @@
import { IsOptional, IsString } from 'class-validator';
export class UpdateContainerItemDto {
@IsString()
@IsOptional()
containerNumber?: string | null;
}

View File

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

View File

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

View File

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

View File

@@ -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' },
});
}
}

View File

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

View File

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

View File

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

View File

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

View File

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