auto allocation and batch managemnt, tracking the train

This commit is contained in:
marshal
2026-06-12 11:42:46 +03:00
parent 8618ea2aa8
commit ef0abf1c41
61 changed files with 3541 additions and 378 deletions

View File

@@ -0,0 +1,52 @@
import {
getBatchWindowForTimestamp,
listBatchWindowsForDate,
listBatchWindowsForBookings,
BATCH_WINDOW_START_HOURS,
} 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);
});
});

View File

@@ -0,0 +1,192 @@
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);
}
/** 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

@@ -4,14 +4,15 @@
*/
/** 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 = '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 = 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;
@@ -22,3 +23,9 @@ export const DEFAULT_WAGONS_PER_BOOKING = 1;
* 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);
});
});

View File

@@ -18,13 +18,24 @@ import { TrainSchedulesRepository } from '../train-schedules/train-schedules.rep
import { TrainScheduleBookingsRepository } from '../train-schedules/train-schedule-bookings.repository';
import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity';
import { BookingNotifierService } from './booking-notifier.service';
import { TrainSchedulingService } from './train-scheduling.service';
import {
groupByBatchWindow,
} from './batch-window.util';
import {
BATCH_CRON,
BATCH_TIMEZONE,
DEFAULT_WAGON_LENGTH_METERS,
DEFAULT_BULK_WAGON_LENGTH_METERS,
DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
DEFAULT_WAGONS_PER_BOOKING,
PAYMENT_WINDOW_MS,
} from './booking-batch.constants';
import {
bookingTrainLengthMeters,
deriveTrainCapacityFromLocomotive,
wagonTypeDimensionsFromEntity,
} from './train-capacity.util';
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
/** A train's remaining capacity along the three physical limits the batch enforces. */
interface Capacity {
@@ -33,9 +44,12 @@ interface Capacity {
lengthMeters: number;
}
type WagonLengths = { container: number; bulk: number };
export type BatchBoardBookingState =
| 'ALLOCATED'
| 'AWAITING_PAYMENT'
| 'SELECTED_FOR_BATCH'
| 'READY'
| 'WAITING'
| 'PENDING_CONTRACT'
| 'EXPIRED';
@@ -47,10 +61,57 @@ export interface BatchBoardBooking {
isGovernment: boolean;
wagons: number;
weightTons: number;
lengthMeters: number;
paymentDeadline: string | null;
state: BatchBoardBookingState;
}
export type BookingAllocationStatus =
| 'NOT_ATTEMPTED'
| 'ASSIGNED'
| 'DEFERRED'
| 'FAILED';
export interface BatchBoardBookingDetail extends BatchBoardBooking {
fullyExecutedAt: string | null;
selectedForBatchAt: string | null;
allocationStatus: BookingAllocationStatus;
allocationIssue: string | null;
}
export interface BatchWindowGroup {
key: string;
label: string;
start: string;
end: string;
counts: {
allocated: number;
selectedForBatch: number;
ready: number;
waiting: number;
expired: number;
pendingContract: number;
};
bookings: BatchBoardBookingDetail[];
}
export interface BatchBoardScheduleDetail {
scheduleId: string;
trainNumber: string | null;
routeName: string | null;
origin: string | null;
destination: string | null;
scheduleDate: string | null;
status: string;
bookingWindowStatus: string;
locomotive: BatchBoardSchedule['locomotive'];
capacity: BatchBoardSchedule['capacity'];
counts: BatchBoardSchedule['counts'];
windows: BatchWindowGroup[];
pendingContract: BatchWindowGroup;
allocationViolations: string[];
}
export interface BatchBoardSchedule {
scheduleId: string;
trainNumber: string | null;
@@ -67,15 +128,19 @@ export interface BatchBoardSchedule {
maxTrainLengthMeters: number;
} | null;
capacity: {
maxWagons: number;
usedWagons: number;
remainingWagons: number;
/** Wagons on bookings already linked to the train (ALLOCATED only). */
allocatedWagons: number;
/** Train length used by allocated bookings (from wagon-type dimensions). */
allocatedLengthMeters: number;
maxLengthMeters: number | null;
/** Weight committed on the train (allocated + selected-for-batch). */
usedWeightTons: number;
maxWeightTons: number | null;
};
counts: {
allocated: number;
awaitingPayment: number;
selectedForBatch: number;
ready: number;
waiting: number;
pendingContract: number;
expired: number;
@@ -103,20 +168,121 @@ export class BookingBatchService implements OnModuleInit {
private readonly trainScheduleBookingsRepository: TrainScheduleBookingsRepository,
private readonly notifier: BookingNotifierService,
private readonly scheduler: SchedulerRegistry,
private readonly trainSchedulingService: TrainSchedulingService,
) {}
/** On boot, re-arm a settle timeout for any schedule that still has live reservations. */
/** On boot, reconcile OPEN schedules and re-arm settle timers. */
async onModuleInit(): Promise<void> {
const open = await this.trainSchedulesRepository.findAll({
where: { bookingWindowStatus: 'OPEN' },
});
for (const s of open) {
try {
await this.processSchedule(s.id);
} catch (err) {
this.logger.warn(`Boot reconcile failed for ${s.id}: ${(err as Error).message}`);
}
}
const reserved = await this.dataSource
.getRepository(Booking)
.createQueryBuilder('b')
.select('DISTINCT b.train_schedule_id', 'scheduleId')
.where(`b.status = 'AWAITING_PAYMENT'`)
.where(`b.status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT')`)
.andWhere('b.train_schedule_id IS NOT NULL')
.getRawMany<{ scheduleId: string }>();
for (const { scheduleId } of reserved) this.armSettle(scheduleId);
}
/** Fire-and-forget batch pipeline for a schedule (contract sign, cron, payment). */
enqueueScheduleProcessing(scheduleId: string): void {
void this.processSchedule(scheduleId).catch((err) =>
this.logger.error(`processSchedule ${scheduleId} failed: ${(err as Error).message}`),
);
}
/** Fill pool, settle due reservations, link orphaned PAID, then assign wagons. */
async processSchedule(scheduleId: string): Promise<void> {
await this.fillSchedule(scheduleId);
await this.settleDueReservations(scheduleId);
await this.reconcilePaidUnlinked(scheduleId);
await this.trainSchedulingService.tryAutoWagonAllocation(scheduleId);
}
/**
* Idempotent: link a paid batch booking to its schedule and assign wagons.
* Handles SELECTED_FOR_BATCH, PAID-without-link, and PAID-already-linked cases.
*/
async ensurePaidBookingAllocated(bookingId: string): Promise<void> {
const booking = await this.dataSource.getRepository(Booking).findOne({
where: { id: bookingId },
relations: { company: true },
});
if (!booking?.trainScheduleId) return;
const isBatchPaid =
booking.status === 'SELECTED_FOR_BATCH' ||
booking.status === 'AWAITING_PAYMENT' ||
booking.status === 'PAID' ||
booking.paymentStatus === 'PAID';
if (!isBatchPaid) return;
if (booking.status === 'SELECTED_FOR_BATCH' || booking.status === 'AWAITING_PAYMENT') {
await this.dataSource
.getRepository(Booking)
.update(bookingId, { paymentStatus: 'PAID', status: 'PAID' });
} else if (booking.paymentStatus !== 'PAID') {
await this.dataSource
.getRepository(Booking)
.update(bookingId, { paymentStatus: 'PAID' });
}
const linked = await this.trainScheduleBookingsRepository.existsForBooking(bookingId);
if (!linked) {
await this.allocate(booking.trainScheduleId, booking, 'paid');
this.logger.log(
`Linked PAID booking ${booking.reference ?? bookingId} to schedule ${booking.trainScheduleId}`,
);
}
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
booking.trainScheduleId,
);
if (schedule && (await this.remainingWagons(schedule)) <= 0) {
await this.setWindow(booking.trainScheduleId, 'FULL');
}
const result = await this.trainSchedulingService.tryAutoWagonAllocation(
booking.trainScheduleId,
);
if (result.assignedBookingIds.length) {
this.logger.log(
`Wagon allocation for ${booking.reference ?? bookingId}: ${result.assignedBookingIds.length} assigned`,
);
}
if (result.issues.some((i) => i.bookingId === bookingId && i.status !== 'ASSIGNED')) {
const issue = result.issues.find((i) => i.bookingId === bookingId);
this.logger.warn(
`Wagon allocation issue for ${booking.reference ?? bookingId}: ${issue?.issue ?? issue?.status}`,
);
}
}
/** Customer paid — delegate to ensurePaidBookingAllocated. */
async confirmPaidAndAllocate(bookingId: string): Promise<void> {
await this.ensurePaidBookingAllocated(bookingId);
}
/** Link PAID bookings that have no train_schedule_bookings row (cron backstop). */
async reconcilePaidUnlinked(scheduleId: string): Promise<void> {
const unlinked = await this.bookingsRepository.findPaidUnlinkedForSchedule(scheduleId);
for (const booking of unlinked) {
await this.allocate(scheduleId, booking, 'paid');
this.logger.log(
`Reconciled PAID booking ${booking.reference ?? booking.id} → schedule ${scheduleId}`,
);
}
}
// ---- cron entry point -----------------------------------------------------
@Cron(BATCH_CRON, { name: 'booking-batch-fill', timeZone: BATCH_TIMEZONE })
@@ -127,7 +293,7 @@ export class BookingBatchService implements OnModuleInit {
this.logger.log(`Batch fill: ${open.length} OPEN schedule(s).`);
for (const s of open) {
try {
await this.fillSchedule(s.id);
await this.processSchedule(s.id);
} catch (err) {
this.logger.error(`Batch fill failed for ${s.id}: ${(err as Error).message}`);
}
@@ -152,8 +318,7 @@ export class BookingBatchService implements OnModuleInit {
order: { scheduledDepartureDate: 'ASC' },
});
const rules = await this.loadGlobalRules();
const perWagonLength = this.perWagonLength(rules);
const wagonLengths = await this.loadWagonLengths();
const linkRepo = this.dataSource.getRepository(TrainScheduleBooking);
const board: BatchBoardSchedule[] = [];
@@ -165,7 +330,7 @@ export class BookingBatchService implements OnModuleInit {
const bookings = await this.bookingsRepository.findAllBySchedule(s.id);
const items: BatchBoardBooking[] = bookings.map((b) => {
const need = this.needFor(b, perWagonLength);
const need = this.needFor(b, wagonLengths);
return {
id: b.id,
reference: b.reference ?? b.id.slice(0, 8),
@@ -175,60 +340,223 @@ export class BookingBatchService implements OnModuleInit {
isGovernment: Boolean(b.isGovernment),
wagons: need.wagons,
weightTons: need.weightTons,
lengthMeters: need.lengthMeters,
paymentDeadline: b.paymentDeadline ? b.paymentDeadline.toISOString() : null,
state: this.boardState(b, linkedIds.has(b.id)),
};
});
const usedWagons = items
.filter((i) => i.state === 'ALLOCATED' || i.state === 'AWAITING_PAYMENT')
.reduce((sum, i) => sum + i.wagons, 0);
const usedWeight = items
.filter((i) => i.state === 'ALLOCATED' || i.state === 'AWAITING_PAYMENT')
.reduce((sum, i) => sum + i.weightTons, 0);
const loco = s.trainSet?.locomotive ?? null;
board.push({
scheduleId: s.id,
trainNumber: s.trainNumber ?? null,
routeName: s.route?.name ?? null,
origin: s.originStation?.label ?? s.originStation?.code ?? null,
destination: s.destinationStation?.label ?? s.destinationStation?.code ?? null,
scheduleDate: s.scheduledDepartureDate ? s.scheduledDepartureDate.toISOString() : null,
status: s.status,
bookingWindowStatus: s.bookingWindowStatus,
locomotive: loco
? {
code: loco.code,
name: loco.name ?? null,
maxPullWeightTons: Number(loco.maxPullWeightTons),
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
}
: null,
capacity: {
maxWagons: s.maxWagons ?? 0,
usedWagons,
remainingWagons: Math.max(0, (s.maxWagons ?? 0) - usedWagons),
usedWeightTons: Math.round(usedWeight * 100) / 100,
maxWeightTons: loco ? Number(loco.maxPullWeightTons) : null,
},
counts: {
allocated: items.filter((i) => i.state === 'ALLOCATED').length,
awaitingPayment: items.filter((i) => i.state === 'AWAITING_PAYMENT').length,
waiting: items.filter((i) => i.state === 'WAITING').length,
pendingContract: items.filter((i) => i.state === 'PENDING_CONTRACT').length,
expired: items.filter((i) => i.state === 'EXPIRED').length,
},
bookings: items,
});
board.push(this.buildScheduleSummary(s, items));
}
return board;
}
/** Schedule-level batch board with EAT 3h windows grouped by fullyExecutedAt. */
async getBatchBoardDetail(scheduleId: string): Promise<BatchBoardScheduleDetail> {
const s = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!s) throw new NotFoundException(`Train schedule ${scheduleId} not found`);
if (s.status === 'ARRIVED' || s.status === 'CANCELLED') {
throw new BadRequestException('Schedule is no longer active');
}
const wagonLengths = await this.loadWagonLengths();
const linkRepo = this.dataSource.getRepository(TrainScheduleBooking);
const links = await linkRepo.find({ where: { trainScheduleId: s.id } });
const linkedIds = new Set(links.map((l) => l.bookingId));
const bookings = await this.bookingsRepository.findAllBySchedule(s.id);
let allocationPreview: Awaited<
ReturnType<TrainSchedulingService['previewAllocationForSchedule']>
>;
try {
allocationPreview = await this.trainSchedulingService.previewAllocationForSchedule(s.id);
} catch {
allocationPreview = { assignedBookingIds: [], deferred: [], issues: [], violations: [] };
}
const allocationByBooking = new Map(
allocationPreview.issues.map((i) => [i.bookingId, i]),
);
const items: BatchBoardBookingDetail[] = bookings.map((b) => {
const need = this.needFor(b, wagonLengths);
const alloc = allocationByBooking.get(b.id);
return {
id: b.id,
reference: b.reference ?? b.id.slice(0, 8),
company: b.isGovernment
? (b.governmentInstitution ?? 'Government')
: (b.company?.name ?? '—'),
isGovernment: Boolean(b.isGovernment),
wagons: need.wagons,
weightTons: need.weightTons,
lengthMeters: need.lengthMeters,
paymentDeadline: b.paymentDeadline ? b.paymentDeadline.toISOString() : null,
state: this.boardState(b, linkedIds.has(b.id)),
fullyExecutedAt: b.fullyExecutedAt ? b.fullyExecutedAt.toISOString() : null,
selectedForBatchAt: b.selectedForBatchAt ? b.selectedForBatchAt.toISOString() : null,
allocationStatus: alloc?.status ?? 'NOT_ATTEMPTED',
allocationIssue: alloc?.issue ?? null,
};
});
const loco = s.trainSet?.locomotive ?? null;
const referenceDate = s.scheduledDepartureDate ?? new Date();
const windowBuckets = groupByBatchWindow(
items,
(item) => (item.fullyExecutedAt ? new Date(item.fullyExecutedAt) : null),
referenceDate,
);
const emptyCounts = () => ({
allocated: 0,
selectedForBatch: 0,
ready: 0,
waiting: 0,
expired: 0,
pendingContract: 0,
});
const countFor = (bookingsInWindow: BatchBoardBookingDetail[]) => {
const counts = emptyCounts();
for (const b of bookingsInWindow) {
if (b.state === 'ALLOCATED') counts.allocated += 1;
else if (b.state === 'SELECTED_FOR_BATCH') counts.selectedForBatch += 1;
else if (b.state === 'READY') counts.ready += 1;
else if (b.state === 'WAITING') counts.waiting += 1;
else if (b.state === 'EXPIRED') counts.expired += 1;
else counts.pendingContract += 1;
}
return counts;
};
const windows: BatchWindowGroup[] = [];
for (const [key, bucket] of windowBuckets) {
if (key === 'pending-contract' || !bucket.window) continue;
const w = bucket.window;
windows.push({
key: w.key,
label: w.label,
start: w.start.toISOString(),
end: w.end.toISOString(),
counts: countFor(bucket.items),
bookings: bucket.items,
});
}
windows.sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime());
const pendingBookings = windowBuckets.get('pending-contract')?.items ?? [];
return {
scheduleId: s.id,
trainNumber: s.trainNumber ?? null,
routeName: s.route?.name ?? null,
origin: s.originStation?.label ?? s.originStation?.code ?? null,
destination: s.destinationStation?.label ?? s.destinationStation?.code ?? null,
scheduleDate: s.scheduledDepartureDate ? s.scheduledDepartureDate.toISOString() : null,
status: s.status,
bookingWindowStatus: s.bookingWindowStatus,
locomotive: loco
? {
code: loco.code,
name: loco.name ?? null,
maxPullWeightTons: Number(loco.maxPullWeightTons),
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
}
: null,
capacity: this.computeBoardCapacity(items, loco),
counts: {
allocated: items.filter((i) => i.state === 'ALLOCATED').length,
selectedForBatch: items.filter((i) => i.state === 'SELECTED_FOR_BATCH').length,
ready: items.filter((i) => i.state === 'READY').length,
waiting: items.filter((i) => i.state === 'WAITING').length,
pendingContract: items.filter((i) => i.state === 'PENDING_CONTRACT').length,
expired: items.filter((i) => i.state === 'EXPIRED').length,
},
windows,
pendingContract: {
key: 'pending-contract',
label: 'Pending contract',
start: '',
end: '',
counts: countFor(pendingBookings),
bookings: pendingBookings,
},
allocationViolations: allocationPreview.violations,
};
}
/** Run wagon-level allocation for all eligible linked bookings on a schedule. */
async runWagonAllocation(scheduleId: string) {
return this.trainSchedulingService.tryAutoWagonAllocation(scheduleId);
}
private computeBoardCapacity(
items: Array<{
state: BatchBoardBookingState;
wagons: number;
weightTons: number;
lengthMeters: number;
}>,
loco: Locomotive | null,
): BatchBoardSchedule['capacity'] {
const allocated = items.filter((i) => i.state === 'ALLOCATED');
const committed = items.filter(
(i) => i.state === 'ALLOCATED' || i.state === 'SELECTED_FOR_BATCH',
);
return {
allocatedWagons: allocated.reduce((sum, i) => sum + i.wagons, 0),
allocatedLengthMeters:
Math.round(allocated.reduce((sum, i) => sum + i.lengthMeters, 0) * 100) / 100,
maxLengthMeters: loco ? Number(loco.maxTrainLengthMeters) : null,
usedWeightTons: Math.round(committed.reduce((sum, i) => sum + i.weightTons, 0) * 100) / 100,
maxWeightTons: loco ? Number(loco.maxPullWeightTons) : null,
};
}
private buildScheduleSummary(
s: TrainSchedule,
items: BatchBoardBooking[],
): BatchBoardSchedule {
const loco = s.trainSet?.locomotive ?? null;
return {
scheduleId: s.id,
trainNumber: s.trainNumber ?? null,
routeName: s.route?.name ?? null,
origin: s.originStation?.label ?? s.originStation?.code ?? null,
destination: s.destinationStation?.label ?? s.destinationStation?.code ?? null,
scheduleDate: s.scheduledDepartureDate ? s.scheduledDepartureDate.toISOString() : null,
status: s.status,
bookingWindowStatus: s.bookingWindowStatus,
locomotive: loco
? {
code: loco.code,
name: loco.name ?? null,
maxPullWeightTons: Number(loco.maxPullWeightTons),
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
}
: null,
capacity: this.computeBoardCapacity(items, loco),
counts: {
allocated: items.filter((i) => i.state === 'ALLOCATED').length,
selectedForBatch: items.filter((i) => i.state === 'SELECTED_FOR_BATCH').length,
ready: items.filter((i) => i.state === 'READY').length,
waiting: items.filter((i) => i.state === 'WAITING').length,
pendingContract: items.filter((i) => i.state === 'PENDING_CONTRACT').length,
expired: items.filter((i) => i.state === 'EXPIRED').length,
},
bookings: items.slice(0, 3),
};
}
private boardState(booking: Booking, linked: boolean): BatchBoardBookingState {
if (linked) return 'ALLOCATED';
if (booking.status === 'AWAITING_PAYMENT') return 'AWAITING_PAYMENT';
if (booking.status === 'SELECTED_FOR_BATCH' || booking.status === 'AWAITING_PAYMENT') {
return 'SELECTED_FOR_BATCH';
}
if (booking.status === 'EXPIRED') return 'EXPIRED';
if (booking.status === 'FULLY_EXECUTED' && booking.fullyExecutedAt) return 'READY';
if (booking.status === 'PAID') return 'WAITING';
return 'PENDING_CONTRACT';
}
@@ -246,9 +574,10 @@ export class BookingBatchService implements OnModuleInit {
}
const rules = await this.loadGlobalRules();
const perWagonLength = this.perWagonLength(rules);
const limits = this.capacityLimits(schedule, locomotive, rules);
let budget = await this.remainingCapacity(schedule, limits, perWagonLength);
const wagonLengths = await this.loadWagonLengths();
const limits = await this.capacityLimits(locomotive, rules);
await this.syncScheduleMaxWagons(schedule, locomotive, rules);
let budget = await this.remainingCapacity(schedule, limits, wagonLengths);
if (budget.wagons <= 0) {
await this.setWindow(scheduleId, 'FULL');
return;
@@ -258,11 +587,11 @@ export class BookingBatchService implements OnModuleInit {
let armed = false;
for (const booking of pool) {
const need = this.needFor(booking, perWagonLength);
const need = this.needFor(booking, wagonLengths);
if (!this.fits(need, budget)) {
if (booking.isGovernment) {
budget = await this.preemptForGovernment(scheduleId, need, budget, perWagonLength);
budget = await this.preemptForGovernment(scheduleId, need, budget, wagonLengths);
if (!this.fits(need, budget)) continue; // still doesn't fit even after preempt
} else {
continue; // skip a booking that exceeds weight/length/wagons, try the next
@@ -281,6 +610,31 @@ export class BookingBatchService implements OnModuleInit {
if (budget.wagons <= 0) await this.setWindow(scheduleId, 'FULL');
if (armed) this.armSettle(scheduleId);
void this.triggerWagonAllocation(scheduleId);
}
/** Durable settle: allocate paid / expire overdue reservations, then top up. */
async settleDueReservations(scheduleId: string): Promise<void> {
const reserved = await this.bookingsRepository.findReservedForSchedule(scheduleId);
const now = Date.now();
let anySettled = false;
for (const booking of reserved) {
const paid = booking.paymentStatus === 'PAID' || booking.status === 'PAID';
const expired = booking.paymentDeadline
? booking.paymentDeadline.getTime() <= now
: false;
if (paid) {
await this.allocate(scheduleId, booking, 'paid');
anySettled = true;
} else if (expired) {
await this.expire(booking);
anySettled = true;
}
}
if (anySettled) await this.fillSchedule(scheduleId);
}
// ---- settle (1h after a batch) -------------------------------------------
@@ -306,6 +660,15 @@ export class BookingBatchService implements OnModuleInit {
}
await this.fillSchedule(scheduleId);
void this.triggerWagonAllocation(scheduleId);
}
private triggerWagonAllocation(scheduleId: string): void {
void this.trainSchedulingService.tryAutoWagonAllocation(scheduleId).catch((err) =>
this.logger.warn(
`Auto wagon allocation failed for ${scheduleId}: ${(err as Error).message}`,
),
);
}
// ---- staff override actions ----------------------------------------------
@@ -330,6 +693,7 @@ export class BookingBatchService implements OnModuleInit {
if (schedule && (await this.remainingWagons(schedule)) <= 0) {
await this.setWindow(booking.trainScheduleId, 'FULL');
}
void this.triggerWagonAllocation(booking.trainScheduleId!);
}
/**
@@ -375,6 +739,7 @@ export class BookingBatchService implements OnModuleInit {
status: restoredStatus,
schedulingStatus: 'ELIGIBLE',
paymentDeadline: null,
selectedForBatchAt: null,
} as never);
});
}
@@ -391,14 +756,16 @@ export class BookingBatchService implements OnModuleInit {
// ---- mutations ------------------------------------------------------------
/** Reserve capacity for a commercial booking and open its 1h pay window. */
/** Reserve capacity for a commercial booking and open its pay window. */
private async reserve(booking: Booking): Promise<void> {
const deadline = new Date(Date.now() + PAYMENT_WINDOW_MS);
const now = new Date();
const deadline = new Date(now.getTime() + PAYMENT_WINDOW_MS);
await this.bookingsRepository.update(booking.id, {
status: 'AWAITING_PAYMENT',
status: 'SELECTED_FOR_BATCH',
selectedForBatchAt: now,
paymentDeadline: deadline,
} as never);
this.notifier.payNow(booking, deadline);
await this.notifier.payNow(booking, deadline);
}
/** Allocate a booking to the schedule's train (creates the TrainScheduleBooking link). */
@@ -423,9 +790,11 @@ export class BookingBatchService implements OnModuleInit {
schedulingStatus: 'SCHEDULED',
scheduledAt: new Date(),
paymentDeadline: null,
selectedForBatchAt: null,
} as never);
});
this.notifier.secured(booking, reason);
void this.triggerWagonAllocation(scheduleId);
}
/** Expire an unpaid reservation and free its capacity. */
@@ -434,6 +803,7 @@ export class BookingBatchService implements OnModuleInit {
status: 'EXPIRED',
schedulingStatus: 'ELIGIBLE',
paymentDeadline: null,
selectedForBatchAt: null,
} as never);
this.notifier.expired(booking);
}
@@ -446,7 +816,7 @@ export class BookingBatchService implements OnModuleInit {
scheduleId: string,
need: Capacity,
budget: Capacity,
perWagonLength: number,
wagonLengths: WagonLengths,
): Promise<Capacity> {
const reservedCommercial = (
await this.bookingsRepository.findReservedForSchedule(scheduleId)
@@ -472,10 +842,11 @@ export class BookingBatchService implements OnModuleInit {
status: 'EXPIRED',
schedulingStatus: 'ELIGIBLE',
paymentDeadline: null,
selectedForBatchAt: null,
} as never);
});
this.notifier.displaced(victim);
freed = this.add(freed, this.needFor(victim, perWagonLength));
freed = this.add(freed, this.needFor(victim, wagonLengths));
}
return freed;
}
@@ -494,12 +865,15 @@ export class BookingBatchService implements OnModuleInit {
}
/** What one booking consumes along all three capacity axes. */
private needFor(booking: Booking, perWagonLength: number): Capacity {
private needFor(booking: Booking, wagonLengths: WagonLengths): Capacity {
const wagons = this.wagonsFor(booking);
return {
wagons,
weightTons: Number(booking.cargoTotalWeightVgm ?? 0),
lengthMeters: wagons * perWagonLength,
lengthMeters: bookingTrainLengthMeters(booking.freightType, wagons, {
container: wagonLengths.container,
bulk: wagonLengths.bulk,
}),
};
}
@@ -527,29 +901,71 @@ export class BookingBatchService implements OnModuleInit {
};
}
/** The train's hard caps: wagon count, locomotive pull weight, locomotive/global length. */
private capacityLimits(
schedule: TrainSchedule,
/** Locomotive + wagon-type-derived caps (weight, length, wagon slots — not a fixed 53). */
private async capacityLimits(
locomotive: Locomotive,
rules: TrainSchedulingGlobalRules | null,
): Capacity {
const locoWeight = Number(locomotive.maxPullWeightTons) || Infinity;
const locoLength = Number(locomotive.maxTrainLengthMeters) || Infinity;
const ruleWeight = rules?.maxTrainWeightTons ? Number(rules.maxTrainWeightTons) : Infinity;
const ruleLength = rules?.maxTrainLengthMeters ? Number(rules.maxTrainLengthMeters) : Infinity;
): Promise<Capacity> {
const wagonTypes = await this.loadWagonTypeDimensions();
const derived = deriveTrainCapacityFromLocomotive(
{
maxPullWeightTons: Number(locomotive.maxPullWeightTons),
maxTrainLengthMeters: Number(locomotive.maxTrainLengthMeters),
},
wagonTypes,
{
maxTrainWeightTons: rules?.maxTrainWeightTons
? Number(rules.maxTrainWeightTons)
: undefined,
maxTrainLengthMeters: rules?.maxTrainLengthMeters
? Number(rules.maxTrainLengthMeters)
: undefined,
},
);
return {
wagons: schedule.maxWagons ?? 0,
weightTons: Math.min(locoWeight, ruleWeight),
lengthMeters: Math.min(locoLength, ruleLength),
wagons: derived.maxWagonSlots,
weightTons: derived.maxWeightTons,
lengthMeters: derived.maxLengthMeters,
};
}
/** Per-wagon length, derived from global rules (maxLength / maxWagons) or a fallback. */
private perWagonLength(rules: TrainSchedulingGlobalRules | null): number {
const len = rules ? Number(rules.maxTrainLengthMeters) : 0;
const wagons = rules ? Number(rules.maxWagonsPerTrain) : 0;
if (len > 0 && wagons > 0) return len / wagons;
return DEFAULT_WAGON_LENGTH_METERS;
/** Keep schedule.max_wagons aligned with locomotive physical limits. */
private async syncScheduleMaxWagons(
schedule: TrainSchedule,
locomotive: Locomotive,
rules: TrainSchedulingGlobalRules | null,
): Promise<void> {
const limits = await this.capacityLimits(locomotive, rules);
if ((schedule.maxWagons ?? 0) !== limits.wagons) {
await this.dataSource
.getRepository(TrainSchedule)
.update(schedule.id, { maxWagons: limits.wagons });
schedule.maxWagons = limits.wagons;
}
}
private async loadWagonTypeDimensions(): Promise<
Array<{ lengthMeters: number; capacityTons: number }>
> {
const types = await this.dataSource.getRepository(WagonType).find({
where: [{ code: 'NW5' }, { code: 'CW3' }],
});
if (types.length) return types.map(wagonTypeDimensionsFromEntity);
return [
{ lengthMeters: DEFAULT_CONTAINER_WAGON_LENGTH_METERS, capacityTons: 70 },
{ lengthMeters: DEFAULT_BULK_WAGON_LENGTH_METERS, capacityTons: 60 },
];
}
private async loadWagonLengths(): Promise<WagonLengths> {
const types = await this.dataSource.getRepository(WagonType).find({
where: [{ code: 'NW5' }, { code: 'CW3' }],
});
const byCode = new Map(types.map((t) => [t.code, wagonTypeDimensionsFromEntity(t)]));
return {
container: byCode.get('NW5')?.lengthMeters ?? DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
bulk: byCode.get('CW3')?.lengthMeters ?? DEFAULT_BULK_WAGON_LENGTH_METERS,
};
}
private async loadGlobalRules(): Promise<TrainSchedulingGlobalRules | null> {
@@ -560,14 +976,14 @@ export class BookingBatchService implements OnModuleInit {
private async remainingCapacity(
schedule: TrainSchedule,
limits: Capacity,
perWagonLength: number,
wagonLengths: WagonLengths,
): Promise<Capacity> {
const allocated = (schedule.scheduleBookings ?? [])
.map((sb) => sb.booking)
.filter((b): b is Booking => Boolean(b));
const reserved = await this.bookingsRepository.findReservedForSchedule(schedule.id);
const used = [...allocated, ...reserved].reduce<Capacity>(
(acc, b) => this.add(acc, this.needFor(b, perWagonLength)),
(acc, b) => this.add(acc, this.needFor(b, wagonLengths)),
{ wagons: 0, weightTons: 0, lengthMeters: 0 },
);
return this.subtract(limits, used);

View File

@@ -1,38 +1,64 @@
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';
/**
* Stub notifier for the batch flow — **console.log only** for now.
* Injectable so it can later be swapped for the real NotificationsService without
* touching the batch engine.
*/
@Injectable()
export class BookingNotifierService {
private readonly logger = new Logger('BookingNotifier');
private readonly logger = new Logger(BookingNotifierService.name);
constructor(private readonly notifications: NotificationsService) {}
private ref(b: Booking): string {
return `${b.reference}${b.isGovernment ? ' (gov)' : ''}`;
}
payNow(b: Booking, deadline: Date): void {
this.logger.log(
`PAY NOW — ${this.ref(b)} selected for schedule ${b.trainScheduleId}; pay before ${deadline.toISOString()} (1h).`,
);
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 {
this.logger.log(
`ALLOCATED — ${this.ref(b)} secured on schedule ${b.trainScheduleId}${
reason === 'gov' ? ' (government, unpaid)' : ''
}.`,
);
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 {
this.logger.warn(
`EXPIRED — ${this.ref(b)} did not pay in time; can move to another schedule or cancel (no re-approval).`,
);
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 {
@@ -42,8 +68,7 @@ export class BookingNotifierService {
}
displaced(b: Booking): void {
this.logger.warn(
`DISPLACED — ${this.ref(b)} bumped by a government booking; move to another schedule or cancel.`,
);
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 AvailableLocomotivesQueryDto {
@ApiProperty({ format: 'uuid', description: 'Route used to derive import/export/domestic readiness' })
@IsUUID()
routeId!: string;
}

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

@@ -22,6 +22,7 @@ import { PreviewBulkTrainScheduleDto } from './dto/preview-bulk-train-schedule.d
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';
@@ -64,6 +65,22 @@ export class TrainSchedulingController {
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 filtered by route corridor readiness',
})
getAvailableLocomotives(@Query() query: AvailableLocomotivesQueryDto) {
return this.trainSchedulingService.getAvailableLocomotivesForRoute(query.routeId);
}
@Get('bookable-schedules')
@TrainSchedulingView()
@ApiOperation({ summary: 'OPEN same-route schedules a new booking can target' })
@@ -191,7 +208,14 @@ export class TrainSchedulingController {
@ApiOperation({ summary: 'Manually run the batch fill for a schedule' })
async runBatch(@Param('id', ParseUUIDPipe) id: string) {
await this.bookingBatchService.fillSchedule(id);
return this.trainSchedulingService.getContainerTrainScheduleById(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')

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';
@@ -21,6 +21,7 @@ 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: [
@@ -35,7 +36,8 @@ import { BookingNotifierService } from './booking-notifier.service';
TrainSchedulingGlobalRules,
TrainCheckpointEvent,
]),
BookingsModule,
forwardRef(() => BookingsModule),
NotificationsModule,
LocomotivesModule,
WagonTypesModule,
TrainSetsModule,

View File

@@ -1,4 +1,4 @@
import { ConflictException } from '@nestjs/common';
import { BadRequestException, ConflictException } from '@nestjs/common';
import { WagonReadiness, WagonStatus } from '@edr/types';
import { Wagon } from '../wagons/entities/wagon.entity';
@@ -25,6 +25,7 @@ const locomotive = {
maxPullWeightTons: 3500,
maxTrainLengthMeters: 760,
status: 'AVAILABLE',
readiness: WagonReadiness.ImportReady,
};
const cw3 = {
@@ -420,6 +421,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' });
@@ -572,4 +576,202 @@ describe('TrainSchedulingService', () => {
}),
).rejects.toBeInstanceOf(ConflictException);
});
it('flags physical fleet shortfall when export schedule lacks EXPORT_READY wagons', 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]);
const importOnlyFleet = Array.from({ length: 5 }, (_, index) => ({
id: `wagon-nw5-${index}`,
wagonTypeId: nw5.id,
wagonNumber: `WGN-${index}`,
status: WagonStatus.Available,
readiness: WagonReadiness.ImportReady,
currentTrainScheduleId: null,
}));
dataSource.getRepository.mockImplementation((entity: unknown) => {
if (entity === TrainSchedulingGlobalRules) {
return { find: jest.fn().mockResolvedValue([]) };
}
if (entity === Wagon) {
return { find: jest.fn().mockResolvedValue(importOnlyFleet) };
}
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('EXPORT_READY') && 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('getAvailableLocomotivesForRoute', () => {
it('filters to export-ready locomotives on Ethiopia → Djibouti routes', async () => {
const routeId = 'route-export';
const routeRepo = {
findOne: jest.fn().mockResolvedValue({
id: routeId,
name: 'Addis → Djibouti',
isActive: true,
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: 'l1', code: 'IMP', status: 'AVAILABLE', readiness: WagonReadiness.ImportReady },
{ id: 'l2', code: 'EXP', status: 'AVAILABLE', readiness: WagonReadiness.ExportReady },
]);
const result = await service.getAvailableLocomotivesForRoute(routeId);
expect(result).toHaveLength(1);
expect(result[0].code).toBe('EXP');
});
it('returns all available locomotives on domestic routes', async () => {
const routeId = 'route-domestic';
const routeRepo = {
findOne: jest.fn().mockResolvedValue({
id: routeId,
name: 'Addis → Dire Dawa',
isActive: true,
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', readiness: WagonReadiness.ImportReady },
{ id: 'l2', code: 'EXP', status: 'AVAILABLE', readiness: WagonReadiness.ExportReady },
]);
const result = await service.getAvailableLocomotivesForRoute(routeId);
expect(result).toHaveLength(2);
});
});
});

View File

@@ -75,17 +75,56 @@ import {
pickBulkWagonType,
} from './wagon-type-resolver.util';
import { deriveScheduleDirection } from './derive-schedule-direction.util';
import { flipReadiness, wagonReadinessMatchesSchedule } from './wagon-readiness.util';
import {
flipReadiness,
requiredWagonReadiness,
wagonReadinessMatchesSchedule,
} from './wagon-readiness.util';
import {
deriveTrainCapacityFromLocomotive,
wagonTypeDimensionsFromEntity,
} from './train-capacity.util';
import {
DEFAULT_BULK_WAGON_LENGTH_METERS,
DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
} from './booking-batch.constants';
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
import { TrainCheckpointEventsRepository } from './train-checkpoint-events.repository';
import { RecordCheckpointDto } from './dto/record-checkpoint.dto';
import { RouteMilestone } from '../routes/entities/route-milestone.entity';
import {
autoFillPlacements,
findMissingContainerNumberIssues,
isPlaceholderContainerNumber,
placementsForBookings,
type ContainerUnitForPlacement,
} from './container-placement.util';
const SCHEDULABLE_BOOKING_STATUSES = ['PAID'] as const;
export type BookingWagonAllocationStatus =
| 'NOT_ATTEMPTED'
| 'ASSIGNED'
| 'DEFERRED'
| 'FAILED';
export interface BookingWagonAllocationIssue {
bookingId: string;
status: BookingWagonAllocationStatus;
issue: string | null;
}
export interface WagonAllocationAttemptResult {
assignedBookingIds: string[];
deferred: DeferredBookingRow[];
issues: BookingWagonAllocationIssue[];
violations: string[];
}
const DEFAULT_TRAIN_LIMITS: Required<TrainLimitConfig> = {
maxWeightTons: 3500,
maxLengthMeters: 760,
maxWagonsPerTrain: 53,
maxWagonsPerTrain: Math.floor(760 / 14),
max20ftContainerWeightTons: 30,
max20ftPairWeightDiffTons: 10,
};
@@ -245,7 +284,9 @@ export class TrainSchedulingService {
scheduledDepartureDate: new Date(dto.scheduleDate),
status: TrainScheduleStatusEnum.Draft,
direction,
maxWagons: (await this.resolveTrainLimitConfig(dto)).maxWagonsPerTrain,
maxWagons: (
await this.resolveTrainLimitConfig(dto, lockedLocomotive)
).maxWagonsPerTrain,
});
const saved = await manager.getRepository(TrainSchedule).save(schedule);
await manager.getRepository(Locomotive).update(lockedLocomotive.id, { status: 'ASSIGNED' });
@@ -294,10 +335,11 @@ export class TrainSchedulingService {
destinationStationId: schedule.destinationStationId,
maxTrainWeightTons: dto.maxTrainWeightTons,
maxTrainLengthMeters: dto.maxTrainLengthMeters,
maxWagonsPerTrain: dto.maxWagonsPerTrain ?? schedule.maxWagons,
maxWagonsPerTrain: dto.maxWagonsPerTrain,
};
const limits = await this.resolveTrainLimitConfig(previewDto);
const locomotive = schedule.trainSet.locomotive;
const limits = await this.resolveTrainLimitConfig(previewDto, locomotive ?? undefined);
const validation = await this.validateBookingsForScheduling(
previewDto,
freightType ?? null,
@@ -329,7 +371,6 @@ export class TrainSchedulingService {
const totalWeightTons = validation.summary.totalWeightTons;
const totalLengthMeters = validation.summary.totalLengthMeters;
const locomotive = schedule.trainSet.locomotive;
if (!locomotive) {
throw new BadRequestException('Schedule train set has no locomotive');
}
@@ -473,6 +514,7 @@ export class TrainSchedulingService {
(sb) => sb.bookingId !== bookingId,
);
if (remainingBookings.length === 0) {
await this.releasePinnedWagonsForTrainSet(manager, schedule.trainSetId);
await this.wagonBookingAllocationsRepository.deleteByTrainSetId(
schedule.trainSetId,
manager,
@@ -617,7 +659,7 @@ export class TrainSchedulingService {
paymentDeadline: null,
})
.where('train_schedule_id = :scheduleId', { scheduleId })
.andWhere(`status = 'AWAITING_PAYMENT'`)
.andWhere(`status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT')`)
.execute();
});
@@ -1068,6 +1110,14 @@ export class TrainSchedulingService {
bulkWagonType,
});
violations.push(
...(await this.validatePhysicalFleetForPlan(
wagonPlan,
scheduleDirection,
targetScheduleId,
)),
);
const placementRules = {
max20ftContainerWeightTons: trainLimits.max20ftContainerWeightTons,
max20ftPairWeightDiffTons: trainLimits.max20ftPairWeightDiffTons,
@@ -1120,11 +1170,18 @@ export class TrainSchedulingService {
}
}
const availableLocomotives = await this.locomotivesRepository.findAll({
where: { status: 'AVAILABLE' },
});
const availableLocomotives = (
await this.locomotivesRepository.findAll({
where: { status: 'AVAILABLE' },
})
).filter((l) => wagonReadinessMatchesSchedule(l.readiness, scheduleDirection));
if (!availableLocomotives.length) {
violations.push('No available locomotive exists for scheduling');
const readinessHint = requiredWagonReadiness(scheduleDirection);
violations.push(
readinessHint
? `No available ${readinessHint} locomotive exists for this ${scheduleDirection} schedule`
: 'No available locomotive exists for scheduling',
);
} else if (
!availableLocomotives.some(
(l) =>
@@ -1168,11 +1225,14 @@ export class TrainSchedulingService {
}
}
private async resolveTrainLimitConfig(dto?: {
maxTrainWeightTons?: number;
maxTrainLengthMeters?: number;
maxWagonsPerTrain?: number;
}): Promise<Required<TrainLimitConfig>> {
private async resolveTrainLimitConfig(
dto?: {
maxTrainWeightTons?: number;
maxTrainLengthMeters?: number;
maxWagonsPerTrain?: number;
},
locomotive?: Pick<Locomotive, 'maxPullWeightTons' | 'maxTrainLengthMeters'>,
): Promise<Required<TrainLimitConfig>> {
const row = await this.loadGlobalRulesRow();
const configured = this.configService?.get<{
maxTrainWeightTons?: number;
@@ -1180,25 +1240,73 @@ export class TrainSchedulingService {
maxWagonsPerTrain?: number;
}>('app.trainScheduling');
const ruleWeightCap =
dto?.maxTrainWeightTons ??
(row?.maxTrainWeightTons != null
? Number(row.maxTrainWeightTons)
: configured?.maxTrainWeightTons);
const ruleLengthCap =
dto?.maxTrainLengthMeters ??
(row?.maxTrainLengthMeters != null
? Number(row.maxTrainLengthMeters)
: configured?.maxTrainLengthMeters);
const wagonTypes = await this.loadSchedulingWagonTypeDimensions();
if (locomotive) {
const derived = deriveTrainCapacityFromLocomotive(
{
maxPullWeightTons: Number(locomotive.maxPullWeightTons),
maxTrainLengthMeters: Number(locomotive.maxTrainLengthMeters),
},
wagonTypes,
{
maxTrainWeightTons: ruleWeightCap,
maxTrainLengthMeters: ruleLengthCap,
},
);
return {
maxWeightTons: derived.maxWeightTons,
maxLengthMeters: derived.maxLengthMeters,
maxWagonsPerTrain:
dto?.maxWagonsPerTrain != null
? Math.floor(this.positiveNumber(dto.maxWagonsPerTrain, derived.maxWagonSlots))
: derived.maxWagonSlots,
max20ftContainerWeightTons: this.positiveNumber(
undefined,
Number(row?.max20ftContainerWeightTons) ||
DEFAULT_TRAIN_LIMITS.max20ftContainerWeightTons,
),
max20ftPairWeightDiffTons: this.positiveNumber(
undefined,
Number(row?.max20ftPairWeightDiffTons) ||
DEFAULT_TRAIN_LIMITS.max20ftPairWeightDiffTons,
),
};
}
const maxWeightTons = this.positiveNumber(
dto?.maxTrainWeightTons,
ruleWeightCap ?? DEFAULT_TRAIN_LIMITS.maxWeightTons,
);
const maxLengthMeters = this.positiveNumber(
dto?.maxTrainLengthMeters,
ruleLengthCap ?? DEFAULT_TRAIN_LIMITS.maxLengthMeters,
);
const derivedWithoutLoco = deriveTrainCapacityFromLocomotive(
{ maxPullWeightTons: maxWeightTons, maxTrainLengthMeters: maxLengthMeters },
wagonTypes,
);
return {
maxWeightTons: this.positiveNumber(
dto?.maxTrainWeightTons,
Number(row?.maxTrainWeightTons) ||
configured?.maxTrainWeightTons ||
DEFAULT_TRAIN_LIMITS.maxWeightTons,
),
maxLengthMeters: this.positiveNumber(
dto?.maxTrainLengthMeters,
Number(row?.maxTrainLengthMeters) ||
configured?.maxTrainLengthMeters ||
DEFAULT_TRAIN_LIMITS.maxLengthMeters,
),
maxWeightTons,
maxLengthMeters,
maxWagonsPerTrain: Math.floor(
this.positiveNumber(
dto?.maxWagonsPerTrain,
Number(row?.maxWagonsPerTrain) ||
configured?.maxWagonsPerTrain ||
DEFAULT_TRAIN_LIMITS.maxWagonsPerTrain,
row?.maxWagonsPerTrain != null
? Number(row.maxWagonsPerTrain)
: configured?.maxWagonsPerTrain ?? derivedWithoutLoco.maxWagonSlots,
),
),
max20ftContainerWeightTons: this.positiveNumber(
@@ -1213,6 +1321,19 @@ export class TrainSchedulingService {
};
}
private async loadSchedulingWagonTypeDimensions(): Promise<
Array<{ lengthMeters: number; capacityTons: number }>
> {
const types = await this.dataSource.getRepository(WagonType).find({
where: [{ code: 'NW5' }, { code: 'CW3' }],
});
if (types.length) return types.map(wagonTypeDimensionsFromEntity);
return [
{ lengthMeters: DEFAULT_CONTAINER_WAGON_LENGTH_METERS, capacityTons: 70 },
{ lengthMeters: DEFAULT_BULK_WAGON_LENGTH_METERS, capacityTons: 60 },
];
}
private async resolveScheduleDirection(
targetScheduleId: string | undefined,
bookings: Booking[],
@@ -1282,26 +1403,48 @@ export class TrainSchedulingService {
slots: TrainSetWagon[],
) {
const wagons = await manager.getRepository(Wagon).find();
const assignedPhysicalIds = new Set<string>();
const wagonTypes = await manager.getRepository(WagonType).find();
const typeCodeById = new Map(wagonTypes.map((wt) => [wt.id, wt.code]));
for (const slot of [...slots].sort((a, b) => a.sequenceNo - b.sequenceNo)) {
const candidates = wagons.filter((wagon) => {
if (wagon.wagonTypeId !== slot.wagonTypeId) return false;
if (assignedPhysicalIds.has(wagon.id)) return false;
const pinnedOnSchedule = wagon.currentTrainScheduleId === scheduleId;
if (wagon.status !== WagonStatus.Available && !pinnedOnSchedule) return false;
return wagonReadinessMatchesSchedule(wagon.readiness, scheduleDirection);
const planSlots = [...slots]
.sort((a, b) => a.sequenceNo - b.sequenceNo)
.map((slot) => ({
sequenceNo: slot.sequenceNo,
wagonTypeId: slot.wagonTypeId,
wagonTypeCode: typeCodeById.get(slot.wagonTypeId) ?? slot.wagonTypeId,
trainSetWagonId: slot.id,
}));
const unpinnable = this.findUnpinnableWagonSlots(
planSlots,
wagons,
scheduleId,
scheduleDirection,
);
if (unpinnable.length) {
throw new BadRequestException({
message: 'Insufficient physical wagons to pin all train slots',
violations: unpinnable,
});
}
const physical = candidates[0];
const assignedPhysicalIds = new Set<string>();
for (const slot of planSlots) {
const physical = this.pickPhysicalWagonForSlot(
slot,
wagons,
scheduleId,
scheduleDirection,
assignedPhysicalIds,
);
if (!physical) continue;
await manager.getRepository(TrainSetWagon).update(slot.id, {
await manager.getRepository(TrainSetWagon).update(slot.trainSetWagonId!, {
physicalWagonId: physical.id,
status: 'RESERVED',
});
await manager.getRepository(Wagon).update(physical.id, {
trainSetWagonId: slot.id,
trainSetWagonId: slot.trainSetWagonId,
currentTrainScheduleId: scheduleId,
status: WagonStatus.Assigned,
});
@@ -1309,6 +1452,76 @@ export class TrainSchedulingService {
}
}
/** Pre-assign check: every planned slot must have a matching physical wagon. */
private async validatePhysicalFleetForPlan(
wagonPlan: WagonPlanSlot[],
scheduleDirection: string | null,
targetScheduleId?: string,
): Promise<string[]> {
if (!wagonPlan.length) return [];
const wagons = await this.dataSource.getRepository(Wagon).find();
return this.findUnpinnableWagonSlots(
wagonPlan.map((slot) => ({
sequenceNo: slot.sequenceNo,
wagonTypeId: slot.wagonTypeId,
wagonTypeCode: slot.wagonTypeCode,
})),
wagons,
targetScheduleId,
scheduleDirection,
);
}
private findUnpinnableWagonSlots(
slots: Array<{ sequenceNo: number; wagonTypeId: string; wagonTypeCode: string }>,
wagons: Wagon[],
scheduleId: string | undefined,
scheduleDirection: string | null,
): string[] {
const violations: string[] = [];
const assignedPhysicalIds = new Set<string>();
const required = requiredWagonReadiness(scheduleDirection);
const readinessLabel = required ?? 'any readiness';
for (const slot of [...slots].sort((a, b) => a.sequenceNo - b.sequenceNo)) {
const physical = this.pickPhysicalWagonForSlot(
slot,
wagons,
scheduleId,
scheduleDirection,
assignedPhysicalIds,
);
if (!physical) {
violations.push(
`No ${readinessLabel} ${slot.wagonTypeCode} wagon available for slot #${slot.sequenceNo}`,
);
continue;
}
assignedPhysicalIds.add(physical.id);
}
return violations;
}
private pickPhysicalWagonForSlot(
slot: { wagonTypeId: string },
wagons: Wagon[],
scheduleId: string | undefined,
scheduleDirection: string | null,
assignedPhysicalIds: Set<string>,
): Wagon | undefined {
return wagons.find((wagon) => {
if (wagon.wagonTypeId !== slot.wagonTypeId) return false;
if (assignedPhysicalIds.has(wagon.id)) return false;
const pinnedOnSchedule = scheduleId
? wagon.currentTrainScheduleId === scheduleId
: false;
if (wagon.status !== WagonStatus.Available && !pinnedOnSchedule) return false;
return wagonReadinessMatchesSchedule(wagon.readiness, scheduleDirection);
});
}
private positiveNumber(value: number | undefined, fallback: number): number {
const numeric = Number(value);
return Number.isFinite(numeric) && numeric > 0 ? numeric : fallback;
@@ -1639,6 +1852,27 @@ export class TrainSchedulingService {
};
}
/** AVAILABLE locomotives whose readiness matches the corridor implied by the route. */
async getAvailableLocomotivesForRoute(routeId: string): Promise<Locomotive[]> {
const route = await this.getActiveRoute(routeId);
const direction = deriveScheduleDirection(
route.originYard ?? { country: null },
route.destinationYard ?? { country: null },
);
const requiredReadiness = requiredWagonReadiness(direction);
const locomotives = await this.locomotivesRepository.findAll({
where: { status: 'AVAILABLE' },
order: { code: 'ASC' },
});
if (!requiredReadiness) {
return locomotives;
}
return locomotives.filter((l) => wagonReadinessMatchesSchedule(l.readiness, direction));
}
/** OPEN, same-route schedules a new booking may target (with rough remaining capacity). */
async getBookableSchedules(originYardId?: string, destinationYardId?: string) {
const schedules = await this.trainSchedulesRepository.findAll({
@@ -1796,4 +2030,221 @@ export class TrainSchedulingService {
}
return SchedulingStatus.Eligible;
}
/** Preview wagon allocation issues per linked booking without mutating the schedule. */
async previewAllocationForSchedule(
scheduleId: string,
): Promise<WagonAllocationAttemptResult> {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
return this.buildAllocationAttempt(schedule, false);
}
/** Assign all eligible linked bookings to wagons; returns per-booking issues. */
async tryAutoWagonAllocation(
scheduleId: string,
): Promise<WagonAllocationAttemptResult> {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
return this.buildAllocationAttempt(schedule, true);
}
private async buildAllocationAttempt(
schedule: TrainSchedule,
performAssign: boolean,
): Promise<WagonAllocationAttemptResult> {
const empty: WagonAllocationAttemptResult = {
assignedBookingIds: [],
deferred: [],
issues: [],
violations: [],
};
if (!schedule.trainSet?.locomotive) {
return { ...empty, violations: ['Schedule has no locomotive — cannot allocate wagons'] };
}
if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) {
return {
...empty,
violations: [`Cannot allocate wagons for schedule in status ${schedule.status}`],
};
}
const linkedBookings = (schedule.scheduleBookings ?? [])
.map((sb) => sb.booking)
.filter((b): b is Booking => Boolean(b));
const eligible = linkedBookings.filter(
(b) => SCHEDULABLE_BOOKING_STATUSES.includes(b.status as 'PAID') || b.isGovernment,
);
if (!eligible.length) return empty;
const wagonAssignedIds = await this.getWagonAssignedBookingIds(schedule.id);
const previewDto = {
bookingIds: eligible.map((b) => b.id),
scheduleDate: schedule.scheduledDepartureDate.toISOString(),
originStationId: schedule.originStationId,
destinationStationId: schedule.destinationStationId,
};
const limits = await this.resolveTrainLimitConfig(
undefined,
schedule.trainSet.locomotive,
);
let validation: Awaited<ReturnType<TrainSchedulingService['validateBookingsForScheduling']>>;
try {
validation = await this.validateBookingsForScheduling(
previewDto,
null,
false,
[],
false,
limits,
schedule.id,
);
} catch (err) {
const message = err instanceof Error ? err.message : 'Validation failed';
return {
...empty,
violations: [message],
issues: eligible.map((b) => ({
bookingId: b.id,
status: 'FAILED' as const,
issue: message,
})),
};
}
const fittingIds = new Set(validation.bookings.map((b) => b.id));
const deferredMap = new Map(
validation.deferredBookings.map((d) => [d.id, d.reason]),
);
const containerBookings = validation.bookings.filter((b) => b.freightType === 'CONTAINER');
const units: ContainerUnitForPlacement[] = expandBookingContainerUnits(containerBookings);
const slots = getContainerSlotSequenceNos(validation.wagonPlan);
const placements = autoFillPlacements(units, slots);
const missingNumbers = findMissingContainerNumberIssues(units, placements);
const missingByBooking = new Map<string, string>();
for (const m of missingNumbers) {
if (!missingByBooking.has(m.bookingId)) missingByBooking.set(m.bookingId, m.issue);
}
const placeholderWarnings = new Map<string, string>();
for (const p of placements) {
if (!isPlaceholderContainerNumber(p.containerNumber)) continue;
const unit = units.find(
(u) => u.bookingContainerId === p.bookingContainerId && u.unitIndex === p.unitIndex,
);
if (unit && !placeholderWarnings.has(unit.bookingId)) {
placeholderWarnings.set(
unit.bookingId,
'Container number auto-assigned — verify before dispatch.',
);
}
}
const assignableIds = validation.bookings
.filter((b) => !missingByBooking.has(b.id))
.map((b) => b.id);
const assignableSet = new Set(assignableIds);
const assignPlacements = placementsForBookings(
placements,
assignableSet,
units,
);
const issues: BookingWagonAllocationIssue[] = eligible.map((b) => {
const placeholderIssue = placeholderWarnings.get(b.id) ?? null;
if (wagonAssignedIds.has(b.id) && assignableSet.has(b.id)) {
return { bookingId: b.id, status: 'ASSIGNED', issue: placeholderIssue };
}
if (missingByBooking.has(b.id)) {
return { bookingId: b.id, status: 'FAILED', issue: missingByBooking.get(b.id)! };
}
if (deferredMap.has(b.id)) {
return { bookingId: b.id, status: 'DEFERRED', issue: deferredMap.get(b.id)! };
}
if (!fittingIds.has(b.id)) {
const refIssue = validation.violations.find((v) => v.includes(b.reference ?? b.id));
return {
bookingId: b.id,
status: 'FAILED',
issue: refIssue ?? 'Does not fit train capacity or fleet constraints',
};
}
if (wagonAssignedIds.has(b.id)) {
return { bookingId: b.id, status: 'ASSIGNED', issue: null };
}
return { bookingId: b.id, status: 'NOT_ATTEMPTED', issue: null };
});
const result: WagonAllocationAttemptResult = {
assignedBookingIds: [],
deferred: validation.deferredBookings,
issues,
violations: validation.violations,
};
if (!performAssign || !assignableIds.length) return result;
const needsPlacements = containerBookings.some((b) => assignableSet.has(b.id));
if (needsPlacements && !assignPlacements.length) {
return {
...result,
violations: [...result.violations, 'Container placements could not be generated'],
};
}
try {
await this.assignBookingsToSchedule(
schedule.id,
{
bookingIds: assignableIds,
containerPlacements: needsPlacements ? assignPlacements : undefined,
},
undefined,
);
result.assignedBookingIds = assignableIds;
for (const issue of result.issues) {
if (assignableSet.has(issue.bookingId)) {
issue.status = 'ASSIGNED';
issue.issue = placeholderWarnings.get(issue.bookingId) ?? null;
}
}
} catch (err) {
const message =
err instanceof BadRequestException
? ((err.getResponse() as { message?: string; violations?: string[] }).violations?.join(
'; ',
) ??
(err.getResponse() as { message?: string }).message ??
err.message)
: err instanceof Error
? err.message
: 'Allocation failed';
result.violations = [...result.violations, message];
for (const issue of result.issues) {
if (assignableSet.has(issue.bookingId) && issue.status !== 'ASSIGNED') {
issue.status = 'FAILED';
issue.issue = message;
}
}
}
return result;
}
private async getWagonAssignedBookingIds(scheduleId: string): Promise<Set<string>> {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
const wagonIds = (schedule?.trainSet?.wagons ?? []).map((w) => w.id);
if (!wagonIds.length) return new Set();
const allocations = await this.dataSource.getRepository(WagonBookingAllocation).find({
where: { trainSetWagonId: In(wagonIds) },
select: ['bookingId'],
});
return new Set(allocations.map((a) => a.bookingId));
}
}

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,