mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
1540 lines
56 KiB
TypeScript
1540 lines
56 KiB
TypeScript
import { BookingBatchService } from './booking-batch.service';
|
||
import { paymentDrainMs } from './booking-batch.constants';
|
||
import { Booking } from '../bookings/entities/booking.entity';
|
||
import { WagonStockLedger } from './wagon-stock-ledger.util';
|
||
|
||
/**
|
||
* A pay window closed long enough ago to be past its drain tail as well — i.e.
|
||
* genuinely expirable. Inside the tail nothing expires (see payWindowLapsed).
|
||
*/
|
||
const fullyLapsedDeadline = () => new Date(Date.now() - 60_000 - paymentDrainMs());
|
||
|
||
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;
|
||
findBatchPoolByRouteDay: jest.Mock;
|
||
findBatchPoolByCorridorDay: jest.Mock;
|
||
findUnacceptedForRouteDay: jest.Mock;
|
||
findReservedForSchedule: jest.Mock;
|
||
update: jest.Mock;
|
||
};
|
||
let trainScheduleBookingsRepository: {
|
||
existsForBooking: jest.Mock;
|
||
createMany: jest.Mock;
|
||
};
|
||
let trainSchedulesRepository: {
|
||
findById: jest.Mock;
|
||
findByIdWithFullGraph: jest.Mock;
|
||
findAll: jest.Mock;
|
||
};
|
||
let trainSchedulingService: {
|
||
tryAutoWagonAllocation: jest.Mock;
|
||
previewPaidBookingWagonShortage: jest.Mock;
|
||
getBookableSchedules: jest.Mock;
|
||
getWindowConfig: jest.Mock;
|
||
wagonStockForSchedule: jest.Mock;
|
||
};
|
||
let dataSource: {
|
||
getRepository: jest.Mock;
|
||
transaction: jest.Mock;
|
||
query: jest.Mock;
|
||
};
|
||
let notifier: {
|
||
payNow: jest.Mock;
|
||
secured: jest.Mock;
|
||
expired: jest.Mock;
|
||
unplaced: jest.Mock;
|
||
};
|
||
|
||
beforeEach(() => {
|
||
bookingsRepository = {
|
||
findPaidUnlinkedForSchedule: jest.fn().mockResolvedValue([]),
|
||
findBatchPool: jest.fn().mockResolvedValue([]),
|
||
findBatchPoolByRouteDay: jest.fn().mockResolvedValue([]),
|
||
findBatchPoolByCorridorDay: jest.fn().mockResolvedValue([]),
|
||
findUnacceptedForRouteDay: jest.fn().mockResolvedValue([]),
|
||
findReservedForSchedule: jest.fn().mockResolvedValue([]),
|
||
update: jest.fn().mockResolvedValue(undefined),
|
||
};
|
||
trainScheduleBookingsRepository = {
|
||
existsForBooking: jest.fn().mockResolvedValue(false),
|
||
createMany: jest.fn().mockResolvedValue(undefined),
|
||
};
|
||
trainSchedulesRepository = {
|
||
findById: jest.fn().mockResolvedValue({
|
||
id: scheduleId,
|
||
bookingWindowStatus: 'OPEN',
|
||
windowPhase: null,
|
||
}),
|
||
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: [],
|
||
}),
|
||
// No shortage by default — paid bookings link as before.
|
||
previewPaidBookingWagonShortage: jest.fn().mockResolvedValue(null),
|
||
// No physical stock configured → the wagon-type gate stands down and these
|
||
// specs keep testing the abstract capacity budget on its own.
|
||
wagonStockForSchedule: jest.fn().mockResolvedValue({
|
||
mode: 'YARD',
|
||
remainingByTypeId: new Map<string, number>(),
|
||
codesByTypeId: new Map<string, string>(),
|
||
}),
|
||
getBookableSchedules: jest.fn().mockResolvedValue([]),
|
||
getWindowConfig: jest.fn().mockResolvedValue({
|
||
importWindowLeadDays: 3,
|
||
exportBookingLeadHours: 24,
|
||
windowOpenHour: 8,
|
||
windowCloseHour: 17,
|
||
windowDurationHours: 3,
|
||
docReviewMinutes: 30,
|
||
paymentWindowMinutes: 60,
|
||
exportPaymentWindowMinutes: 60,
|
||
}),
|
||
};
|
||
|
||
const bookingRepo = {
|
||
findOne: jest.fn().mockResolvedValue(paidBooking),
|
||
update: jest.fn().mockResolvedValue(undefined),
|
||
// WagonType.find() / global-rules find() fall back to defaults when empty.
|
||
find: jest.fn().mockResolvedValue([]),
|
||
};
|
||
dataSource = {
|
||
getRepository: jest.fn().mockReturnValue(bookingRepo),
|
||
transaction: jest.fn(async (fn: (m: unknown) => Promise<void>) => {
|
||
const manager = {
|
||
getRepository: () => bookingRepo,
|
||
};
|
||
await fn(manager);
|
||
}),
|
||
// cargo/container type -> allowed wagon type lookups (loadAllowedWagonTypeIds).
|
||
// Empty = unresolvable, so the physical-stock gate stands down and these
|
||
// specs keep exercising the abstract capacity budget alone.
|
||
query: jest.fn().mockResolvedValue([]),
|
||
};
|
||
|
||
notifier = {
|
||
payNow: jest.fn(),
|
||
secured: jest.fn(),
|
||
expired: jest.fn(),
|
||
unplaced: jest.fn(),
|
||
};
|
||
|
||
service = new BookingBatchService(
|
||
dataSource as never,
|
||
bookingsRepository as never,
|
||
trainSchedulesRepository as never,
|
||
trainScheduleBookingsRepository as never,
|
||
notifier as never,
|
||
{ addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never,
|
||
trainSchedulingService as never,
|
||
{
|
||
issuePayable: jest.fn().mockResolvedValue(null),
|
||
expirePayable: jest.fn().mockResolvedValue(undefined),
|
||
// Gateway reconcile-before-expire: default = verifiably unpaid.
|
||
reconcilePayable: jest.fn().mockResolvedValue({ paid: false, unverifiable: false }),
|
||
} as never,
|
||
{ emitPhase: jest.fn() } as never,
|
||
{ computeSubmitPriorityScore: jest.fn().mockResolvedValue(0) } 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('ensurePaidBookingAllocated holds a wagon-short booking out of the train', async () => {
|
||
trainSchedulingService.previewPaidBookingWagonShortage.mockResolvedValue({
|
||
wagonTypeCodes: 'NW6',
|
||
wagonsNeeded: 1,
|
||
wagonsAvailable: 0,
|
||
wagonsShort: 1,
|
||
});
|
||
|
||
await service.ensurePaidBookingAllocated(bookingId);
|
||
|
||
// Not linked, no wagon run — held PAID + unlinked, flagged for manual placement.
|
||
expect(trainScheduleBookingsRepository.createMany).not.toHaveBeenCalled();
|
||
expect(trainSchedulingService.tryAutoWagonAllocation).not.toHaveBeenCalled();
|
||
expect(dataSource.getRepository().update).toHaveBeenCalledWith(
|
||
bookingId,
|
||
expect.objectContaining({ schedulingStatus: 'WAITING_FOR_WAGON' }),
|
||
);
|
||
});
|
||
|
||
it('reconcilePaidUnlinked leaves WAITING_FOR_WAGON bookings held', async () => {
|
||
bookingsRepository.findPaidUnlinkedForSchedule.mockResolvedValue([
|
||
{ ...paidBooking, schedulingStatus: 'WAITING_FOR_WAGON' },
|
||
]);
|
||
|
||
await service.reconcilePaidUnlinked(scheduleId);
|
||
|
||
expect(trainScheduleBookingsRepository.createMany).not.toHaveBeenCalled();
|
||
expect(
|
||
trainSchedulingService.previewPaidBookingWagonShortage,
|
||
).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('processSchedule reconciles PAID-unlinked before wagon allocation', async () => {
|
||
const fillSpy = jest.spyOn(service, 'fillSchedule').mockResolvedValue(0);
|
||
const settleSpy = jest.spyOn(service, 'settleDueReservations').mockResolvedValue(undefined);
|
||
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);
|
||
});
|
||
|
||
describe('extendPaymentPhaseForTopUp', () => {
|
||
const schedRepo = () => dataSource.getRepository();
|
||
|
||
it('pushes paymentPhaseEndsAt out when a fresh window exceeds it', async () => {
|
||
const soon = new Date(Date.now() + 5_000); // phase almost over
|
||
const departure = new Date(Date.now() + 24 * 3_600_000);
|
||
schedRepo().findOne.mockResolvedValueOnce({
|
||
id: scheduleId,
|
||
windowPhase: 'PAYMENT',
|
||
paymentPhaseEndsAt: soon,
|
||
scheduledDepartureDate: departure,
|
||
});
|
||
|
||
await service.extendPaymentPhaseForTopUp(scheduleId);
|
||
|
||
// paymentWindowMinutes = 60 (mock) → new end ≈ now + 1h, which is > soon.
|
||
expect(schedRepo().update).toHaveBeenCalledWith(
|
||
scheduleId,
|
||
expect.objectContaining({ paymentPhaseEndsAt: expect.any(Date) }),
|
||
);
|
||
const [, patch] = schedRepo().update.mock.calls.at(-1)!;
|
||
expect((patch.paymentPhaseEndsAt as Date).getTime()).toBeGreaterThan(
|
||
soon.getTime(),
|
||
);
|
||
});
|
||
|
||
it('does not pull the deadline in when the current end is already later', async () => {
|
||
const far = new Date(Date.now() + 10 * 3_600_000); // 10h out, beyond a 1h window
|
||
schedRepo().findOne.mockResolvedValueOnce({
|
||
id: scheduleId,
|
||
windowPhase: 'PAYMENT',
|
||
paymentPhaseEndsAt: far,
|
||
scheduledDepartureDate: new Date(Date.now() + 24 * 3_600_000),
|
||
});
|
||
|
||
await service.extendPaymentPhaseForTopUp(scheduleId);
|
||
|
||
expect(schedRepo().update).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('is a no-op outside the PAYMENT phase', async () => {
|
||
schedRepo().findOne.mockResolvedValueOnce({
|
||
id: scheduleId,
|
||
windowPhase: 'OPEN',
|
||
paymentPhaseEndsAt: null,
|
||
scheduledDepartureDate: new Date(Date.now() + 24 * 3_600_000),
|
||
});
|
||
|
||
await service.extendPaymentPhaseForTopUp(scheduleId);
|
||
|
||
expect(schedRepo().update).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('never extends past departure', async () => {
|
||
const departure = new Date(Date.now() + 60_000); // 1 min away
|
||
schedRepo().findOne.mockResolvedValueOnce({
|
||
id: scheduleId,
|
||
windowPhase: 'PAYMENT',
|
||
paymentPhaseEndsAt: new Date(Date.now() + 1_000),
|
||
scheduledDepartureDate: departure,
|
||
});
|
||
|
||
await service.extendPaymentPhaseForTopUp(scheduleId);
|
||
|
||
const [, patch] = schedRepo().update.mock.calls.at(-1)!;
|
||
expect((patch.paymentPhaseEndsAt as Date).getTime()).toBeLessThanOrEqual(
|
||
departure.getTime(),
|
||
);
|
||
});
|
||
});
|
||
|
||
describe('fillRouteDay — day-level distribution', () => {
|
||
const originYardId = 'yard-origin';
|
||
const destinationYardId = 'yard-dest';
|
||
const day = '2026-06-20';
|
||
// 06:00Z and 09:00Z on 2026-06-20 both land on the same EAT day.
|
||
const trainA = 'train-a';
|
||
const trainB = 'train-b';
|
||
|
||
// A tiny locomotive: default wagon = 14m / 70t → exactly 1 wagon slot fits.
|
||
const smallLoco = { maxPullWeightTons: 70, maxTrainLengthMeters: 14 };
|
||
|
||
const commercial = (id: string, priority: number): Booking =>
|
||
({
|
||
id,
|
||
reference: id,
|
||
isGovernment: false,
|
||
priorityScore: priority,
|
||
status: 'FULLY_EXECUTED',
|
||
wagonsRequired: 1,
|
||
cargoTotalWeightVgm: 10,
|
||
freightType: 'CONTAINER',
|
||
bookingContainers: [],
|
||
originYardId,
|
||
destinationYardId,
|
||
}) as unknown as Booking;
|
||
|
||
beforeEach(() => {
|
||
// Two OPEN legacy trains on the same route + day, train A earlier than train B.
|
||
// fillRouteDay now selects fillable schedules straight from the repository.
|
||
trainSchedulesRepository.findAll.mockResolvedValue([
|
||
{
|
||
id: trainA,
|
||
originStationId: originYardId,
|
||
destinationStationId: destinationYardId,
|
||
scheduledDepartureDate: new Date('2026-06-20T06:00:00.000Z'),
|
||
bookingWindowStatus: 'OPEN',
|
||
windowPhase: null,
|
||
},
|
||
{
|
||
id: trainB,
|
||
originStationId: originYardId,
|
||
destinationStationId: destinationYardId,
|
||
scheduledDepartureDate: new Date('2026-06-20T09:00:00.000Z'),
|
||
bookingWindowStatus: 'OPEN',
|
||
windowPhase: null,
|
||
},
|
||
]);
|
||
trainSchedulesRepository.findByIdWithFullGraph.mockImplementation((id: string) =>
|
||
Promise.resolve({
|
||
id,
|
||
maxWagons: 1,
|
||
bookingWindowStatus: 'OPEN',
|
||
trainSetId: `set-${id}`,
|
||
trainSet: { locomotive: smallLoco },
|
||
scheduleBookings: [],
|
||
originStationId: originYardId,
|
||
destinationStationId: destinationYardId,
|
||
}),
|
||
);
|
||
});
|
||
|
||
it('spills overflow to the next train by priority, then reports unplaced', async () => {
|
||
// 3 commercial bookings, descending priority; only 1 fits per train (2 total).
|
||
bookingsRepository.findBatchPoolByCorridorDay.mockResolvedValue([
|
||
commercial('hi', 30),
|
||
commercial('mid', 20),
|
||
commercial('lo', 10),
|
||
]);
|
||
|
||
const touched = await service.fillRouteDay(originYardId, destinationYardId, day);
|
||
|
||
expect(bookingsRepository.findBatchPoolByCorridorDay).toHaveBeenCalledWith(
|
||
[originYardId, destinationYardId],
|
||
day,
|
||
);
|
||
// Both trains were processed.
|
||
expect(touched).toEqual([trainA, trainB]);
|
||
// Highest priority reserved on train A, next on train B (commercial → reserve).
|
||
const reservedOn = notifier.payNow.mock.calls.map((c) => (c[0] as Booking).id);
|
||
expect(reservedOn).toEqual(['hi', 'mid']);
|
||
// The third booking fits no train and is reported unplaced (and only it).
|
||
expect(notifier.unplaced).toHaveBeenCalledTimes(1);
|
||
expect((notifier.unplaced.mock.calls[0][0] as Booking).id).toBe('lo');
|
||
expect(notifier.unplaced.mock.calls[0][1]).toBe(day);
|
||
});
|
||
|
||
it('reserves the chosen train id on each commercial booking', async () => {
|
||
bookingsRepository.findBatchPoolByCorridorDay.mockResolvedValue([commercial('hi', 30)]);
|
||
|
||
await service.fillRouteDay(originYardId, destinationYardId, day);
|
||
|
||
// reserve() persists trainScheduleId so the settle lifecycle can find the train.
|
||
expect(bookingsRepository.update).toHaveBeenCalledWith(
|
||
'hi',
|
||
expect.objectContaining({
|
||
trainScheduleId: trainA,
|
||
status: 'SELECTED_FOR_BATCH',
|
||
}),
|
||
);
|
||
});
|
||
|
||
it('reserves both partners of a consolidated pair together on one train', async () => {
|
||
// Two 20ft bookings, 1 container each — a shared wagon. Both in the pool.
|
||
const consol = (id: string, partnerId: string, priority: number): Booking =>
|
||
({
|
||
id,
|
||
reference: id,
|
||
isGovernment: false,
|
||
priorityScore: priority,
|
||
status: 'FULLY_EXECUTED',
|
||
wagonsRequired: 1,
|
||
cargoTotalWeightVgm: 10,
|
||
freightType: 'CONTAINER',
|
||
consolidationPartnerId: partnerId,
|
||
bookingContainers: [{ quantity: 1 }],
|
||
originYardId,
|
||
destinationYardId,
|
||
}) as unknown as Booking;
|
||
|
||
bookingsRepository.findBatchPoolByCorridorDay.mockResolvedValue([
|
||
consol('a', 'b', 30),
|
||
consol('b', 'a', 20),
|
||
]);
|
||
|
||
await service.fillRouteDay(originYardId, destinationYardId, day);
|
||
|
||
// Both reserved on the same (first) train; neither reported unplaced.
|
||
const reservedIds = notifier.payNow.mock.calls.map((c) => (c[0] as Booking).id);
|
||
expect(reservedIds.sort()).toEqual(['a', 'b']);
|
||
expect(notifier.unplaced).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('skips a consolidated booking whose partner is not in the pool (both-or-neither)', async () => {
|
||
const lonely = {
|
||
id: 'a',
|
||
reference: 'a',
|
||
isGovernment: false,
|
||
priorityScore: 30,
|
||
status: 'FULLY_EXECUTED',
|
||
wagonsRequired: 1,
|
||
cargoTotalWeightVgm: 10,
|
||
freightType: 'CONTAINER',
|
||
consolidationPartnerId: 'missing-partner',
|
||
bookingContainers: [{ quantity: 1 }],
|
||
originYardId,
|
||
destinationYardId,
|
||
} as unknown as Booking;
|
||
|
||
bookingsRepository.findBatchPoolByCorridorDay.mockResolvedValue([lonely]);
|
||
|
||
await service.fillRouteDay(originYardId, destinationYardId, day);
|
||
|
||
// Never reserved — waits for its partner in a later cycle.
|
||
expect(notifier.payNow).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('clears a stale FULL flag and fills a train whose bookings all expired', async () => {
|
||
// The deadlock: train A filled once, every booking then expired, but
|
||
// bookingWindowStatus stayed FULL. isFillable() rejects FULL before it ever
|
||
// reads the budget, so the batch skipped the train forever — it just cycled
|
||
// PRE_WINDOW→DOC_REVIEW→PAYMENT with an empty consist, and only the odd
|
||
// already-pinned booking got settled, one per cycle.
|
||
const staleFull = {
|
||
id: trainA,
|
||
maxWagons: 1,
|
||
bookingWindowStatus: 'FULL',
|
||
// The batch runs while the customer window is closed.
|
||
windowPhase: 'PAYMENT',
|
||
direction: 'IMPORT',
|
||
trainSetId: `set-${trainA}`,
|
||
trainSet: { locomotive: smallLoco },
|
||
scheduleBookings: [],
|
||
scheduledDepartureDate: new Date('2026-06-20T06:00:00.000Z'),
|
||
originStationId: originYardId,
|
||
destinationStationId: destinationYardId,
|
||
};
|
||
trainSchedulesRepository.findAll.mockResolvedValue([{ ...staleFull }]);
|
||
// Live capacity says the train is empty: 1 free wagon, nothing allocated.
|
||
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(staleFull);
|
||
// refreshWindowStatus writes CLOSED (mid-PAYMENT, not a customer-open phase);
|
||
// the re-read reports it, and isFillable() admits CLOSED during PAYMENT.
|
||
trainSchedulesRepository.findById.mockResolvedValue({
|
||
id: trainA,
|
||
bookingWindowStatus: 'CLOSED',
|
||
windowPhase: 'PAYMENT',
|
||
});
|
||
bookingsRepository.findBatchPoolByCorridorDay.mockResolvedValue([
|
||
commercial('waiting', 30),
|
||
]);
|
||
|
||
const touched = await service.fillRouteDay(originYardId, destinationYardId, day);
|
||
|
||
// The train was reopened to the batch and actually filled, not skipped.
|
||
expect(touched).toEqual([trainA]);
|
||
expect(notifier.payNow).toHaveBeenCalledTimes(1);
|
||
expect((notifier.payNow.mock.calls[0][0] as Booking).id).toBe('waiting');
|
||
expect(notifier.unplaced).not.toHaveBeenCalled();
|
||
});
|
||
});
|
||
|
||
describe('expireUnacceptedForRouteDay — doc-review sweep', () => {
|
||
const originYardId = 'yard-origin';
|
||
const destinationYardId = 'yard-dest';
|
||
const day = '2026-06-20';
|
||
|
||
const pendingBooking = {
|
||
id: 'pending-1',
|
||
reference: 'BK-PENDING-1',
|
||
status: 'OPERATION_REQUEST_PENDING',
|
||
isGovernment: false,
|
||
originYardId,
|
||
destinationYardId,
|
||
} as unknown as Booking;
|
||
|
||
beforeEach(() => {
|
||
// One fillable schedule on this corridor/day so corridorYardsForRouteDay
|
||
// resolves a non-empty yard set (legacy two-stop route → [origin, dest]).
|
||
trainSchedulesRepository.findAll.mockResolvedValue([
|
||
{
|
||
id: 'sched-1',
|
||
originStationId: originYardId,
|
||
destinationStationId: destinationYardId,
|
||
scheduledDepartureDate: new Date('2026-06-20T06:00:00.000Z'),
|
||
},
|
||
]);
|
||
});
|
||
|
||
it('expires each un-accepted booking and clears its scheduled day', async () => {
|
||
bookingsRepository.findUnacceptedForRouteDay.mockResolvedValue([pendingBooking]);
|
||
|
||
await service.expireUnacceptedForRouteDay({
|
||
originYardId,
|
||
destinationYardId,
|
||
day,
|
||
});
|
||
|
||
expect(bookingsRepository.findUnacceptedForRouteDay).toHaveBeenCalledWith(
|
||
expect.arrayContaining([originYardId, destinationYardId]),
|
||
day,
|
||
);
|
||
expect(bookingsRepository.update).toHaveBeenCalledWith(
|
||
'pending-1',
|
||
expect.objectContaining({
|
||
status: 'EXPIRED',
|
||
schedulingStatus: 'ELIGIBLE',
|
||
scheduledDate: null,
|
||
}),
|
||
);
|
||
expect(notifier.expired).toHaveBeenCalledWith(pendingBooking);
|
||
});
|
||
|
||
it('is a no-op when nothing is un-accepted', async () => {
|
||
bookingsRepository.findUnacceptedForRouteDay.mockResolvedValue([]);
|
||
|
||
await service.expireUnacceptedForRouteDay({
|
||
originYardId,
|
||
destinationYardId,
|
||
day,
|
||
});
|
||
|
||
expect(bookingsRepository.update).not.toHaveBeenCalled();
|
||
expect(notifier.expired).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('does nothing when the route-day has no fillable schedule', async () => {
|
||
trainSchedulesRepository.findAll.mockResolvedValue([]);
|
||
|
||
await service.expireUnacceptedForRouteDay({
|
||
originYardId,
|
||
destinationYardId,
|
||
day,
|
||
});
|
||
|
||
expect(bookingsRepository.findUnacceptedForRouteDay).not.toHaveBeenCalled();
|
||
});
|
||
});
|
||
|
||
describe('expireLeftoverExportDay — export day sweep', () => {
|
||
const exportSchedule = {
|
||
id: scheduleId,
|
||
direction: 'EXPORT',
|
||
originStationId: 'yard-origin',
|
||
destinationStationId: 'yard-dest',
|
||
scheduledDepartureDate: new Date('2026-06-20T06:00:00.000Z'),
|
||
windowPhase: 'DONE',
|
||
bookingWindowStatus: 'CLOSED',
|
||
};
|
||
let unacceptedSpy: jest.SpyInstance;
|
||
let poolSpy: jest.SpyInstance;
|
||
|
||
beforeEach(() => {
|
||
unacceptedSpy = jest
|
||
.spyOn(service, 'expireUnacceptedForRouteDay')
|
||
.mockResolvedValue(undefined);
|
||
poolSpy = jest.spyOn(service, 'expireLeftoverDayPool').mockResolvedValue(0);
|
||
});
|
||
|
||
it('ignores non-export schedules', async () => {
|
||
trainSchedulesRepository.findById.mockResolvedValue({
|
||
...exportSchedule,
|
||
direction: 'IMPORT',
|
||
});
|
||
|
||
await service.expireLeftoverExportDay(scheduleId);
|
||
|
||
expect(unacceptedSpy).not.toHaveBeenCalled();
|
||
expect(poolSpy).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('defers while another export train on the day can still take bookings', async () => {
|
||
trainSchedulesRepository.findById.mockResolvedValue(exportSchedule);
|
||
trainSchedulesRepository.findAll.mockResolvedValue([
|
||
exportSchedule,
|
||
{
|
||
...exportSchedule,
|
||
id: 'sched-2',
|
||
windowPhase: 'OPEN',
|
||
bookingWindowStatus: 'OPEN',
|
||
},
|
||
]);
|
||
|
||
await service.expireLeftoverExportDay(scheduleId);
|
||
|
||
expect(unacceptedSpy).not.toHaveBeenCalled();
|
||
expect(poolSpy).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('defers while a FULL train still has live pay windows', async () => {
|
||
trainSchedulesRepository.findById.mockResolvedValue(exportSchedule);
|
||
trainSchedulesRepository.findAll.mockResolvedValue([
|
||
exportSchedule,
|
||
{
|
||
...exportSchedule,
|
||
id: 'sched-2',
|
||
windowPhase: 'OPEN',
|
||
bookingWindowStatus: 'FULL',
|
||
},
|
||
]);
|
||
bookingsRepository.findReservedForSchedule.mockResolvedValue([
|
||
{
|
||
paymentStatus: 'PENDING',
|
||
status: 'AWAITING_PAYMENT',
|
||
paymentDeadline: new Date(Date.now() + 60_000),
|
||
},
|
||
]);
|
||
|
||
await service.expireLeftoverExportDay(scheduleId);
|
||
|
||
expect(unacceptedSpy).not.toHaveBeenCalled();
|
||
expect(poolSpy).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('sweeps un-accepted + waiting bookings once every train on the day is shut', async () => {
|
||
trainSchedulesRepository.findById.mockResolvedValue(exportSchedule);
|
||
trainSchedulesRepository.findAll.mockResolvedValue([
|
||
exportSchedule,
|
||
{
|
||
...exportSchedule,
|
||
id: 'sched-2',
|
||
windowPhase: 'OPEN',
|
||
bookingWindowStatus: 'FULL',
|
||
},
|
||
]);
|
||
|
||
await service.expireLeftoverExportDay(scheduleId);
|
||
|
||
expect(unacceptedSpy).toHaveBeenCalledWith({
|
||
originYardId: 'yard-origin',
|
||
destinationYardId: 'yard-dest',
|
||
day: '2026-06-20',
|
||
});
|
||
expect(poolSpy).toHaveBeenCalledWith(scheduleId);
|
||
});
|
||
});
|
||
|
||
describe('maybeOfferPartial — split-eligibility gate', () => {
|
||
const importGeneral = {
|
||
id: 'b1',
|
||
reference: 'b1',
|
||
isGovernment: false,
|
||
tradeDirection: 'IMPORT',
|
||
contractKind: 'GENERAL',
|
||
consolidationPartnerId: null,
|
||
} as unknown as Booking;
|
||
|
||
const call = (booking: Booking, isPair: boolean): boolean =>
|
||
(
|
||
service as unknown as {
|
||
isSplitEligible: (b: Booking, p: boolean) => boolean;
|
||
}
|
||
).isSplitEligible(booking, isPair);
|
||
|
||
it('allows IMPORT + GENERAL when splitService is present', () => {
|
||
const withSplit = new BookingBatchService(
|
||
dataSource as never,
|
||
bookingsRepository as never,
|
||
trainSchedulesRepository as never,
|
||
trainScheduleBookingsRepository as never,
|
||
notifier as never,
|
||
{ addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never,
|
||
trainSchedulingService as never,
|
||
{
|
||
issuePayable: jest.fn(),
|
||
expirePayable: jest.fn(),
|
||
reconcilePayable: jest
|
||
.fn()
|
||
.mockResolvedValue({ paid: false, unverifiable: false }),
|
||
} as never,
|
||
{ emitPhase: jest.fn() } as never,
|
||
{ computeSubmitPriorityScore: jest.fn().mockResolvedValue(0) } as never,
|
||
undefined,
|
||
{ findOpenOffer: jest.fn() } as never,
|
||
);
|
||
const eligible = (
|
||
withSplit as unknown as {
|
||
isSplitEligible: (b: Booking, p: boolean) => boolean;
|
||
}
|
||
).isSplitEligible(importGeneral, false);
|
||
expect(eligible).toBe(true);
|
||
});
|
||
|
||
it('allows IMPORT + ONE_TIME (promoted to GENERAL on split)', () => {
|
||
const withSplit = new BookingBatchService(
|
||
dataSource as never,
|
||
bookingsRepository as never,
|
||
trainSchedulesRepository as never,
|
||
trainScheduleBookingsRepository as never,
|
||
notifier as never,
|
||
{ addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never,
|
||
trainSchedulingService as never,
|
||
{
|
||
issuePayable: jest.fn(),
|
||
expirePayable: jest.fn(),
|
||
reconcilePayable: jest
|
||
.fn()
|
||
.mockResolvedValue({ paid: false, unverifiable: false }),
|
||
} as never,
|
||
{ emitPhase: jest.fn() } as never,
|
||
{ computeSubmitPriorityScore: jest.fn().mockResolvedValue(0) } as never,
|
||
undefined,
|
||
{ findOpenOffer: jest.fn() } as never,
|
||
);
|
||
const eligible = (
|
||
withSplit as unknown as {
|
||
isSplitEligible: (b: Booking, p: boolean) => boolean;
|
||
}
|
||
).isSplitEligible(
|
||
{ ...importGeneral, contractKind: 'ONE_TIME' } as Booking,
|
||
false,
|
||
);
|
||
expect(eligible).toBe(true);
|
||
});
|
||
|
||
it('rejects when splitService is absent (default test service)', () => {
|
||
// `service` from the outer beforeEach was built without a splitService.
|
||
expect(call(importGeneral, false)).toBe(false);
|
||
});
|
||
|
||
it('rejects EXPORT, government, consolidated pairs, and other directions', () => {
|
||
const withSplit = new BookingBatchService(
|
||
dataSource as never,
|
||
bookingsRepository as never,
|
||
trainSchedulesRepository as never,
|
||
trainScheduleBookingsRepository as never,
|
||
notifier as never,
|
||
{ addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never,
|
||
trainSchedulingService as never,
|
||
{
|
||
issuePayable: jest.fn(),
|
||
expirePayable: jest.fn(),
|
||
reconcilePayable: jest
|
||
.fn()
|
||
.mockResolvedValue({ paid: false, unverifiable: false }),
|
||
} as never,
|
||
{ emitPhase: jest.fn() } as never,
|
||
{ computeSubmitPriorityScore: jest.fn().mockResolvedValue(0) } as never,
|
||
undefined,
|
||
{ findOpenOffer: jest.fn() } as never,
|
||
);
|
||
const check = (
|
||
withSplit as unknown as {
|
||
isSplitEligible: (b: Booking, p: boolean) => boolean;
|
||
}
|
||
).isSplitEligible.bind(withSplit);
|
||
|
||
expect(check({ ...importGeneral, tradeDirection: 'EXPORT' } as Booking, false)).toBe(false);
|
||
expect(check({ ...importGeneral, isGovernment: true } as Booking, false)).toBe(false);
|
||
expect(check(importGeneral, true)).toBe(false); // consolidated pair
|
||
expect(check({ ...importGeneral, contractKind: null } as Booking, false)).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('settleDueReservations — expire then promote the waiting list', () => {
|
||
const originYardId = 'yard-origin';
|
||
const destinationYardId = 'yard-dest';
|
||
const trainId = 'train-a';
|
||
// 14m / 70t default wagon → two wagon slots on this locomotive.
|
||
const smallLoco = { maxPullWeightTons: 200, maxTrainLengthMeters: 28 };
|
||
|
||
const booking = (id: string, priority: number, overrides = {}): Booking =>
|
||
({
|
||
id,
|
||
reference: id,
|
||
isGovernment: false,
|
||
priorityScore: priority,
|
||
status: 'FULLY_EXECUTED',
|
||
wagonsRequired: 1,
|
||
cargoTotalWeightVgm: 10,
|
||
freightType: 'CONTAINER',
|
||
bookingContainers: [],
|
||
originYardId,
|
||
destinationYardId,
|
||
trainScheduleId: trainId,
|
||
...overrides,
|
||
}) as unknown as Booking;
|
||
|
||
beforeEach(() => {
|
||
const scheduleRow = {
|
||
id: trainId,
|
||
maxWagons: 2,
|
||
bookingWindowStatus: 'CLOSED',
|
||
windowPhase: 'PAYMENT',
|
||
direction: 'IMPORT',
|
||
trainSetId: `set-${trainId}`,
|
||
trainSet: { locomotive: smallLoco },
|
||
scheduleBookings: [],
|
||
scheduledDepartureDate: new Date('2026-06-20T06:00:00.000Z'),
|
||
originStationId: originYardId,
|
||
destinationStationId: destinationYardId,
|
||
};
|
||
trainSchedulesRepository.findAll.mockResolvedValue([{ ...scheduleRow }]);
|
||
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(scheduleRow);
|
||
trainSchedulesRepository.findById.mockResolvedValue({
|
||
id: trainId,
|
||
bookingWindowStatus: 'CLOSED',
|
||
windowPhase: 'PAYMENT',
|
||
scheduledDepartureDate: scheduleRow.scheduledDepartureDate,
|
||
originStationId: originYardId,
|
||
destinationStationId: destinationYardId,
|
||
});
|
||
});
|
||
|
||
it('promotes a waiting booking into the wagons an expired reservation frees', async () => {
|
||
// One reservation whose pay window lapsed, and one booking on the waiting list.
|
||
const lapsed = booking('lapsed', 50, {
|
||
status: 'SELECTED_FOR_BATCH',
|
||
paymentDeadline: fullyLapsedDeadline(),
|
||
});
|
||
const waiting = booking('waiting', 10, { trainScheduleId: null });
|
||
|
||
bookingsRepository.findReservedForSchedule
|
||
.mockResolvedValueOnce([lapsed]) // settleReserved sees the lapsed one
|
||
.mockResolvedValue([]); // afterwards nothing is reserved
|
||
// The day pool the top-up draws from: only the waiting booking is eligible.
|
||
bookingsRepository.findBatchPoolByCorridorDay
|
||
.mockResolvedValueOnce([waiting])
|
||
.mockResolvedValue([]);
|
||
// expire()'s paid-guard and reserve()'s idempotency guard both re-read the
|
||
// booking fresh — answer with the matching row, not the paidBooking default
|
||
// (which would make the guard rescue-allocate the lapsed reservation).
|
||
const byId: Record<string, Booking> = { lapsed, waiting };
|
||
dataSource
|
||
.getRepository()
|
||
.findOne.mockImplementation(
|
||
async (opts: { where?: { id?: string } }) =>
|
||
byId[opts?.where?.id ?? ''] ?? null,
|
||
);
|
||
|
||
await service.settleDueReservations(trainId);
|
||
|
||
// The lapsed reservation expired...
|
||
expect(notifier.expired).toHaveBeenCalledTimes(1);
|
||
expect((notifier.expired.mock.calls[0][0] as Booking).id).toBe('lapsed');
|
||
// ...and the waiting booking was promoted in the SAME settle, not next cycle.
|
||
expect(notifier.payNow).toHaveBeenCalledTimes(1);
|
||
expect((notifier.payNow.mock.calls[0][0] as Booking).id).toBe('waiting');
|
||
});
|
||
|
||
it('holds a reservation whose deadline passed but whose drain tail has not', async () => {
|
||
// Settlement is asynchronous, so a payment made in the window's last
|
||
// seconds lands after the deadline. Expiring here would free the wagons
|
||
// out from under it — the swallowed-payment finding.
|
||
const draining = booking('draining', 50, {
|
||
status: 'SELECTED_FOR_BATCH',
|
||
paymentDeadline: new Date(Date.now() - 60_000),
|
||
});
|
||
const waiting = booking('waiting', 10, { trainScheduleId: null });
|
||
|
||
bookingsRepository.findReservedForSchedule
|
||
.mockResolvedValueOnce([draining])
|
||
.mockResolvedValue([]);
|
||
bookingsRepository.findBatchPoolByCorridorDay
|
||
.mockResolvedValueOnce([waiting])
|
||
.mockResolvedValue([]);
|
||
const byId: Record<string, Booking> = { draining, waiting };
|
||
dataSource
|
||
.getRepository()
|
||
.findOne.mockImplementation(
|
||
async (opts: { where?: { id?: string } }) =>
|
||
byId[opts?.where?.id ?? ''] ?? null,
|
||
);
|
||
|
||
await service.settleDueReservations(trainId);
|
||
|
||
expect(notifier.expired).not.toHaveBeenCalled();
|
||
// ...and its wagons were NOT handed to the waiting list either.
|
||
expect(notifier.payNow).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('serialises concurrent settles so the same reservation is not settled twice', async () => {
|
||
const lapsed = booking('lapsed', 50, {
|
||
status: 'SELECTED_FOR_BATCH',
|
||
paymentDeadline: fullyLapsedDeadline(),
|
||
});
|
||
// Both callers read the reservation; the lock must stop the second from
|
||
// acting on rows the first already expired. (The PAYMENT transition and the
|
||
// tick's overdue backstop do exactly this, in the same second.)
|
||
let reads = 0;
|
||
bookingsRepository.findReservedForSchedule.mockImplementation(() => {
|
||
reads += 1;
|
||
return Promise.resolve(reads === 1 ? [lapsed] : []);
|
||
});
|
||
bookingsRepository.findBatchPoolByCorridorDay.mockResolvedValue([]);
|
||
// expire()'s paid-guard re-reads the booking fresh — answer with the
|
||
// (unpaid) lapsed row, not the paidBooking default.
|
||
dataSource
|
||
.getRepository()
|
||
.findOne.mockImplementation(
|
||
async (opts: { where?: { id?: string } }) =>
|
||
opts?.where?.id === 'lapsed' ? lapsed : null,
|
||
);
|
||
|
||
await Promise.all([
|
||
service.settleDueReservations(trainId),
|
||
service.settleDueReservations(trainId),
|
||
]);
|
||
|
||
expect(notifier.expired).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
it('never expires a reservation whose payment landed — allocates it instead', async () => {
|
||
const latePaid = booking('late-paid', 50, {
|
||
status: 'SELECTED_FOR_BATCH',
|
||
paymentDeadline: fullyLapsedDeadline(),
|
||
});
|
||
bookingsRepository.findReservedForSchedule
|
||
.mockResolvedValueOnce([latePaid])
|
||
.mockResolvedValue([]);
|
||
bookingsRepository.findBatchPoolByCorridorDay.mockResolvedValue([]);
|
||
// The payment webhook flipped paymentStatus between the settle's list
|
||
// read and expire()'s fresh re-read — the deadline had already passed.
|
||
dataSource
|
||
.getRepository()
|
||
.findOne.mockImplementation(
|
||
async (opts: { where?: { id?: string } }) =>
|
||
opts?.where?.id === 'late-paid'
|
||
? { ...latePaid, paymentStatus: 'PAID' }
|
||
: null,
|
||
);
|
||
|
||
await service.settleDueReservations(trainId);
|
||
|
||
// Money was taken → the booking boards. Never expired.
|
||
expect(notifier.expired).not.toHaveBeenCalled();
|
||
expect(notifier.secured).toHaveBeenCalledTimes(1);
|
||
expect(trainScheduleBookingsRepository.createMany).toHaveBeenCalledWith(
|
||
[{ trainScheduleId: trainId, bookingId: 'late-paid' }],
|
||
expect.anything(),
|
||
);
|
||
});
|
||
});
|
||
|
||
describe('acceptIntercity — export pay window expires at window close', () => {
|
||
const exportScheduleId = 'export-train';
|
||
// Window closes in 30 minutes; the configured pay window is 60 minutes.
|
||
const closesAt = new Date(Date.now() + 30 * 60_000);
|
||
|
||
const waiting = {
|
||
id: 'ic-1',
|
||
reference: 'IC-1',
|
||
isGovernment: false,
|
||
status: 'FULLY_EXECUTED',
|
||
trainScheduleId: null,
|
||
freightType: 'CONTAINER',
|
||
cargoTotalWeightVgm: 10,
|
||
bookingContainers: [],
|
||
} as unknown as Booking;
|
||
|
||
let scheduleRepo: { findOne: jest.Mock };
|
||
let bookingRepo: { findOne: jest.Mock; update: jest.Mock; find: jest.Mock };
|
||
|
||
beforeEach(() => {
|
||
bookingRepo = dataSource.getRepository();
|
||
bookingRepo.findOne.mockResolvedValue(waiting);
|
||
scheduleRepo = { findOne: jest.fn() };
|
||
// reserve() reads the target schedule to clamp export deadlines — route
|
||
// TrainSchedule reads to their own repo, everything else stays as before.
|
||
dataSource.getRepository.mockImplementation((entity?: { name?: string }) =>
|
||
entity?.name === 'TrainSchedule' ? scheduleRepo : bookingRepo,
|
||
);
|
||
});
|
||
|
||
it('clamps the intercity pay deadline to the export window close', async () => {
|
||
scheduleRepo.findOne.mockResolvedValue({
|
||
id: exportScheduleId,
|
||
direction: 'EXPORT',
|
||
windowClosesAt: closesAt,
|
||
scheduledDepartureDate: new Date(closesAt.getTime() + 2 * 3_600_000),
|
||
});
|
||
|
||
await service.acceptIntercity(waiting, exportScheduleId);
|
||
|
||
expect(bookingsRepository.update).toHaveBeenCalledWith(
|
||
'ic-1',
|
||
expect.objectContaining({
|
||
status: 'SELECTED_FOR_BATCH',
|
||
paymentDeadline: closesAt,
|
||
}),
|
||
);
|
||
expect(notifier.payNow).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
it('keeps the plain payment window on import trains', async () => {
|
||
scheduleRepo.findOne.mockResolvedValue({
|
||
id: 'import-train',
|
||
direction: 'IMPORT',
|
||
windowClosesAt: closesAt,
|
||
});
|
||
|
||
await service.acceptIntercity(waiting, 'import-train');
|
||
|
||
const deadline = (
|
||
bookingsRepository.update.mock.calls[0][1] as { paymentDeadline: Date }
|
||
).paymentDeadline;
|
||
// 60-minute pay window runs past the 30-minutes-out close: no clamp.
|
||
expect(deadline.getTime()).toBeGreaterThan(closesAt.getTime());
|
||
});
|
||
|
||
it('rejects an accept after the export window closed — no pay window opens', async () => {
|
||
scheduleRepo.findOne.mockResolvedValue({
|
||
id: exportScheduleId,
|
||
direction: 'EXPORT',
|
||
windowClosesAt: new Date(Date.now() - 60_000),
|
||
});
|
||
|
||
await expect(
|
||
service.acceptIntercity(waiting, exportScheduleId),
|
||
).rejects.toThrow(/window has closed/);
|
||
expect(bookingsRepository.update).not.toHaveBeenCalled();
|
||
expect(notifier.payNow).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('expires an unpaid export ride-along at close and frees the train', async () => {
|
||
const lapsed = {
|
||
...(waiting as unknown as Record<string, unknown>),
|
||
status: 'SELECTED_FOR_BATCH',
|
||
trainScheduleId: exportScheduleId,
|
||
paymentDeadline: fullyLapsedDeadline(),
|
||
originYardId: 'yard-a',
|
||
destinationYardId: 'yard-b',
|
||
priorityScore: 0,
|
||
wagonsRequired: 1,
|
||
} as unknown as Booking;
|
||
bookingsRepository.findReservedForSchedule
|
||
.mockResolvedValueOnce([lapsed])
|
||
.mockResolvedValue([]);
|
||
bookingsRepository.findBatchPoolByCorridorDay.mockResolvedValue([]);
|
||
// expire()'s paid-guard re-reads the booking fresh — still unpaid.
|
||
bookingRepo.findOne.mockResolvedValue(lapsed);
|
||
trainSchedulesRepository.findById.mockResolvedValue({
|
||
id: exportScheduleId,
|
||
bookingWindowStatus: 'CLOSED',
|
||
windowPhase: 'DONE',
|
||
scheduledDepartureDate: new Date(Date.now() + 3_600_000),
|
||
originStationId: 'yard-a',
|
||
destinationStationId: 'yard-b',
|
||
});
|
||
|
||
await service.settleDueReservations(exportScheduleId);
|
||
|
||
expect(notifier.expired).toHaveBeenCalledTimes(1);
|
||
expect(bookingsRepository.update).toHaveBeenCalledWith(
|
||
'ic-1',
|
||
expect.objectContaining({ status: 'EXPIRED', trainScheduleId: null }),
|
||
);
|
||
});
|
||
});
|
||
});
|
||
|
||
describe('BookingBatchService — wagonsFor', () => {
|
||
// wagonsFor is pure arithmetic over its two arguments and touches no injected
|
||
// dependency, so the service can be built with none.
|
||
const service = new BookingBatchService(
|
||
null as never,
|
||
null as never,
|
||
null as never,
|
||
null as never,
|
||
null as never,
|
||
null as never,
|
||
null as never,
|
||
null as never,
|
||
null as never,
|
||
null as never,
|
||
) as unknown as {
|
||
wagonsFor(booking: unknown, dims: unknown): number;
|
||
needFor(booking: unknown, dims: unknown): {
|
||
wagons: number;
|
||
weightTons: number;
|
||
lengthMeters: number;
|
||
};
|
||
};
|
||
|
||
// PW2 box wagon: 70T rated payload, 25.2T tare, 17.066m.
|
||
const dims = {
|
||
container: { lengthMeters: 13.966, tareWeightTons: 22.4, capacityTons: 70 },
|
||
bulk: { lengthMeters: 17.066, tareWeightTons: 25.2, capacityTons: 70 },
|
||
byWagonTypeId: new Map(),
|
||
};
|
||
|
||
const bulk = (cargoTons: number, over: Record<string, unknown> = {}) => ({
|
||
freightType: 'BULK',
|
||
cargoTotalWeightVgm: cargoTons,
|
||
bookingContainers: [],
|
||
...over,
|
||
});
|
||
|
||
it('sizes a bulk booking by cargo ÷ rated payload, not a flat 1 wagon', () => {
|
||
// 37 × 1400 fertilizer packages × 50kg = 2590T of cargo.
|
||
expect(service.wagonsFor(bulk(2590), dims)).toBe(37);
|
||
});
|
||
|
||
it('rounds a partial wagon up', () => {
|
||
expect(service.wagonsFor(bulk(70.1), dims)).toBe(2);
|
||
expect(service.wagonsFor(bulk(70), dims)).toBe(1);
|
||
});
|
||
|
||
it('still floors at one wagon when a bulk booking has no recorded cargo', () => {
|
||
expect(service.wagonsFor(bulk(0), dims)).toBe(1);
|
||
});
|
||
|
||
it('honours an explicit wagonsRequired override', () => {
|
||
expect(service.wagonsFor(bulk(2590, { wagonsRequired: 40 }), dims)).toBe(40);
|
||
});
|
||
|
||
it('ignores a stale undersized wagonsRequired: 700T of sugar rides 10 wagons, not 1', () => {
|
||
// Rows written while sumWagonsRequired hardcoded BULK to 1 are still in the
|
||
// DB; trusting them charged one tare for the whole consist (700 + 25.2
|
||
// instead of 700 + 10 × 25.2 gross).
|
||
expect(service.wagonsFor(bulk(700, { wagonsRequired: 1 }), dims)).toBe(10);
|
||
});
|
||
|
||
it('takes the binding axis for containers: weight can exceed TEU geometry', () => {
|
||
// Two 40ft units => 2 wagons by TEU geometry, but 210T needs 3 at 70T each.
|
||
const booking = {
|
||
freightType: 'CONTAINER',
|
||
cargoTotalWeightVgm: 210,
|
||
bookingContainers: [
|
||
{ quantity: 2, wagonsRequired: 2, containerType: { sizeFt: 40 } },
|
||
],
|
||
};
|
||
expect(service.wagonsFor(booking, dims)).toBe(3);
|
||
});
|
||
|
||
it('keeps TEU geometry when it binds before weight', () => {
|
||
// Four 20ft units => 2 wagons by geometry; 40T of cargo needs only 1 by weight.
|
||
const booking = {
|
||
freightType: 'CONTAINER',
|
||
cargoTotalWeightVgm: 40,
|
||
bookingContainers: [
|
||
{ quantity: 4, wagonsRequired: 2, containerType: { sizeFt: 20 } },
|
||
],
|
||
};
|
||
expect(service.wagonsFor(booking, dims)).toBe(2);
|
||
});
|
||
|
||
describe('per-booking wagon type (cargo/container type FK)', () => {
|
||
// The booking's cargo type rides PW2 (25.2T tare / 70T), but the
|
||
// representative bulk fallback is a CW3-ish 23.4T tare. Measuring the
|
||
// booking on the fallback under-charged its gross (2100 + 30 × 23.4 =
|
||
// 2802 instead of 2856), so the fill loop admitted sets that allocation's
|
||
// real-consist check later rejected — after the customer had paid.
|
||
const dimsWithTypes = {
|
||
container: { lengthMeters: 13.966, tareWeightTons: 22.4, capacityTons: 70 },
|
||
bulk: { lengthMeters: 17.066, tareWeightTons: 23.4, capacityTons: 70 },
|
||
byWagonTypeId: new Map([
|
||
['pw2-id', { lengthMeters: 17.066, tareWeightTons: 25.2, capacityTons: 70 }],
|
||
]),
|
||
};
|
||
|
||
it('charges a bulk booking the tare of ITS wagon type, not the representative', () => {
|
||
const booking = bulk(2100, { cargoType: { wagonTypes: [{ id: 'pw2-id' }] } });
|
||
const need = service.needFor(booking, dimsWithTypes);
|
||
expect(need.wagons).toBe(30);
|
||
expect(need.weightTons).toBe(2856); // 2100 + 30 × 25.2 — matches allocation
|
||
});
|
||
|
||
it('falls back to the representative dims when no wagon type is configured', () => {
|
||
const need = service.needFor(bulk(2100), dimsWithTypes);
|
||
expect(need.weightTons).toBe(2802); // 2100 + 30 × 23.4 (legacy behavior)
|
||
});
|
||
|
||
it('resolves a container booking through its container type', () => {
|
||
const booking = {
|
||
freightType: 'CONTAINER',
|
||
cargoTotalWeightVgm: 140,
|
||
bookingContainers: [
|
||
{
|
||
quantity: 2,
|
||
wagonsRequired: 2,
|
||
containerType: { sizeFt: 40, wagonTypes: [{ id: 'pw2-id' }] },
|
||
},
|
||
],
|
||
};
|
||
const need = service.needFor(booking, dimsWithTypes);
|
||
expect(need.wagons).toBe(2);
|
||
expect(need.weightTons).toBe(190.4); // 140 + 2 × 25.2
|
||
expect(need.lengthMeters).toBeCloseTo(34.132, 3); // 2 × 17.066, not NW5's 13.966
|
||
});
|
||
});
|
||
});
|
||
|
||
describe('BookingBatchService — built-train wagon capacity', () => {
|
||
// A schedule created from a built train is capped by its PHYSICAL consist:
|
||
// wagon count only. The locomotive here is deliberately tiny (1T / 1m) — the
|
||
// old weight/length math would call every one of these trains FULL, so any
|
||
// assertion below that says "not full" proves those axes are ignored.
|
||
const scheduleId = 'schedule-built';
|
||
|
||
const reservedBooking = (id: string, leg?: { origin: string; dest: string }) =>
|
||
({
|
||
id,
|
||
freightType: 'BULK',
|
||
cargoTotalWeightVgm: 50, // 1 wagon at the 60T default bulk payload
|
||
bookingContainers: [],
|
||
originYardId: leg?.origin ?? 'yard-a',
|
||
destinationYardId: leg?.dest ?? 'yard-b',
|
||
}) as unknown as Booking;
|
||
|
||
const buildService = (opts: {
|
||
physicalWagons: number;
|
||
reserved: Booking[];
|
||
maxWagons?: number;
|
||
routeStops?: string[];
|
||
yardCountries?: Record<string, string>;
|
||
}) => {
|
||
const schedule = {
|
||
id: scheduleId,
|
||
maxWagons: opts.maxWagons ?? 44, // stale locomotive-derived cap on purpose
|
||
bookingWindowStatus: 'OPEN',
|
||
originStationId: 'yard-a',
|
||
destinationStationId: 'yard-b',
|
||
routeId: opts.routeStops ? 'route-1' : null,
|
||
scheduleBookings: [],
|
||
trainSet: {
|
||
locomotive: {
|
||
maxPullWeightTons: 1,
|
||
maxTrainLengthMeters: 1,
|
||
overageToleranceTons: 0,
|
||
overageToleranceMeters: 0,
|
||
},
|
||
train: { id: 'train-built-1' },
|
||
},
|
||
};
|
||
const wagonRepo = { count: jest.fn().mockResolvedValue(opts.physicalWagons) };
|
||
const milestoneRepo = {
|
||
find: jest
|
||
.fn()
|
||
.mockResolvedValue(
|
||
(opts.routeStops ?? []).map((yardId, i) => ({ yardId, sequenceNo: i + 1 })),
|
||
),
|
||
};
|
||
const genericRepo = {
|
||
find: jest.fn().mockResolvedValue([]),
|
||
update: jest.fn().mockResolvedValue(undefined),
|
||
};
|
||
const yardRepo = {
|
||
find: jest
|
||
.fn()
|
||
.mockResolvedValue(
|
||
Object.entries(opts.yardCountries ?? {}).map(([id, country]) => ({
|
||
id,
|
||
country,
|
||
})),
|
||
),
|
||
};
|
||
const dataSource = {
|
||
getRepository: jest.fn((entity: { name?: string }) => {
|
||
if (entity?.name === 'Wagon') return wagonRepo;
|
||
if (entity?.name === 'RouteMilestone') return milestoneRepo;
|
||
if (entity?.name === 'Yard') return yardRepo;
|
||
return genericRepo;
|
||
}),
|
||
transaction: jest.fn(),
|
||
query: jest.fn().mockResolvedValue([]),
|
||
};
|
||
const service = new BookingBatchService(
|
||
dataSource as never,
|
||
{
|
||
findReservedForSchedule: jest.fn().mockResolvedValue(opts.reserved),
|
||
} as never,
|
||
{
|
||
findByIdWithFullGraph: jest.fn().mockResolvedValue(schedule),
|
||
findById: jest.fn().mockResolvedValue(schedule),
|
||
} as never,
|
||
null as never,
|
||
null as never,
|
||
null as never,
|
||
null as never,
|
||
null as never,
|
||
{ emitPhase: jest.fn() } as never,
|
||
null as never,
|
||
);
|
||
return { service, wagonRepo };
|
||
};
|
||
|
||
it('is FULL when bookings hold every physical wagon, even with loco-derived slots free', async () => {
|
||
const { service } = buildService({
|
||
physicalWagons: 2,
|
||
reserved: [reservedBooking('b1'), reservedBooking('b2')],
|
||
maxWagons: 44, // stale: the old slot cap would say 42 slots remain
|
||
});
|
||
await expect(service.isScheduleFull(scheduleId)).resolves.toBe(true);
|
||
});
|
||
|
||
it('is NOT full while physical wagons remain, ignoring weight/length limits', async () => {
|
||
const { service } = buildService({
|
||
physicalWagons: 3,
|
||
reserved: [reservedBooking('b1'), reservedBooking('b2')],
|
||
});
|
||
// 1T pull cap would have been exhausted long ago under the old math.
|
||
await expect(service.isScheduleFull(scheduleId)).resolves.toBe(false);
|
||
});
|
||
|
||
it('is NOT full when only a middle leg is sold and other edges run free (domestic route)', async () => {
|
||
// Leg-aware allocation (planWagonsWithStock legs) made mid-leg wagons real
|
||
// capacity on the edges they don't ride: a domestic corridor with cargo
|
||
// only on m1→m2 still boards bookings on the free first/last edges, so the
|
||
// window must stay open for them.
|
||
const { service } = buildService({
|
||
physicalWagons: 2,
|
||
routeStops: ['yard-a', 'yard-m1', 'yard-m2', 'yard-b'],
|
||
reserved: [
|
||
reservedBooking('b1', { origin: 'yard-m1', dest: 'yard-m2' }),
|
||
reservedBooking('b2', { origin: 'yard-m1', dest: 'yard-m2' }),
|
||
],
|
||
});
|
||
await expect(service.isScheduleFull(scheduleId)).resolves.toBe(false);
|
||
});
|
||
|
||
it('is NOT full when the border edge is sold out but a home leg still has room', async () => {
|
||
// FULL is corridor-wide now: b→dj holds every wagon, but a→b is empty, so
|
||
// sub-corridor bookings can still sell that leg — the window stays open.
|
||
const { service } = buildService({
|
||
physicalWagons: 2,
|
||
routeStops: ['yard-a', 'yard-b', 'yard-dj'],
|
||
yardCountries: {
|
||
'yard-a': 'ETHIOPIA',
|
||
'yard-b': 'ETHIOPIA',
|
||
'yard-dj': 'DJIBOUTI',
|
||
},
|
||
reserved: [
|
||
reservedBooking('b1', { origin: 'yard-b', dest: 'yard-dj' }),
|
||
reservedBooking('b2', { origin: 'yard-b', dest: 'yard-dj' }),
|
||
],
|
||
});
|
||
await expect(service.isScheduleFull(scheduleId)).resolves.toBe(false);
|
||
});
|
||
|
||
it('is FULL once every leg of the corridor is sold out', async () => {
|
||
const { service } = buildService({
|
||
physicalWagons: 2,
|
||
routeStops: ['yard-a', 'yard-b', 'yard-dj'],
|
||
yardCountries: {
|
||
'yard-a': 'ETHIOPIA',
|
||
'yard-b': 'ETHIOPIA',
|
||
'yard-dj': 'DJIBOUTI',
|
||
},
|
||
reserved: [
|
||
reservedBooking('b1', { origin: 'yard-a', dest: 'yard-dj' }),
|
||
reservedBooking('b2', { origin: 'yard-a', dest: 'yard-dj' }),
|
||
],
|
||
});
|
||
await expect(service.isScheduleFull(scheduleId)).resolves.toBe(true);
|
||
});
|
||
|
||
it('is NOT full while the border edge still has room, even with a home leg sold out', async () => {
|
||
// Intercity rode a→b on both wagons; the border edge b→dj is still free,
|
||
// so exports can still board — the window stays open.
|
||
const { service } = buildService({
|
||
physicalWagons: 2,
|
||
routeStops: ['yard-a', 'yard-b', 'yard-dj'],
|
||
yardCountries: {
|
||
'yard-a': 'ETHIOPIA',
|
||
'yard-b': 'ETHIOPIA',
|
||
'yard-dj': 'DJIBOUTI',
|
||
},
|
||
reserved: [
|
||
reservedBooking('b1', { origin: 'yard-a', dest: 'yard-b' }),
|
||
reservedBooking('b2', { origin: 'yard-a', dest: 'yard-b' }),
|
||
],
|
||
});
|
||
await expect(service.isScheduleFull(scheduleId)).resolves.toBe(false);
|
||
});
|
||
|
||
it('reports over-allocation when the consist is trimmed below committed bookings', async () => {
|
||
const { service } = buildService({
|
||
physicalWagons: 1,
|
||
reserved: [reservedBooking('b1'), reservedBooking('b2')],
|
||
});
|
||
await expect(service.scheduleWagonUsage(scheduleId)).resolves.toEqual({
|
||
maxWagons: 1,
|
||
allocatedWagons: 2,
|
||
remainingSlots: 0,
|
||
overAllocatedBy: 1,
|
||
});
|
||
});
|
||
});
|
||
|
||
/**
|
||
* The reported failure: a train advertising 20 free wagons where only 16 are of
|
||
* the type the booking can ride. Selecting all 20 took the customer's money for
|
||
* space that never existed and then stalled at allocation on wagon 17.
|
||
*/
|
||
describe('BookingBatchService — physical wagon-type gate', () => {
|
||
const NW5 = 'wagon-type-nw5';
|
||
const PW2 = 'wagon-type-pw2';
|
||
const WHOLE_LEG = { fromEdge: 0, toEdge: 1 };
|
||
|
||
/** 16 NW5 + 4 PW2 = 20 wagons on the train, but only 16 usable by an NW5 booking. */
|
||
const mixedStock = () => new WagonStockLedger(new Map([[NW5, 16], [PW2, 4]]), 1);
|
||
|
||
const internals = (svc: BookingBatchService) =>
|
||
svc as unknown as {
|
||
hasWagonStock: (
|
||
stock: WagonStockLedger,
|
||
ids: string[],
|
||
needed: number,
|
||
leg: { fromEdge: number; toEdge: number },
|
||
) => boolean;
|
||
maybeOfferPartial: (
|
||
booking: Booking,
|
||
isPair: boolean,
|
||
candidates: unknown[],
|
||
need: { wagons: number; weightTons: number; lengthMeters: number },
|
||
ids: string[],
|
||
) => Promise<boolean>;
|
||
tryPartialOffer: unknown;
|
||
isSplitEligible: unknown;
|
||
};
|
||
|
||
const service = () =>
|
||
new BookingBatchService(
|
||
{ getRepository: jest.fn(), transaction: jest.fn(), query: jest.fn() } as never,
|
||
{} as never,
|
||
{} as never,
|
||
{} as never,
|
||
{} as never,
|
||
{} as never,
|
||
{} as never,
|
||
{} as never,
|
||
{} as never,
|
||
{} as never,
|
||
undefined,
|
||
{ findOpenOffer: jest.fn() } as never,
|
||
);
|
||
|
||
it('refuses a 20-wagon NW5 booking on a train holding only 16 NW5', () => {
|
||
const svc = internals(service());
|
||
const stock = mixedStock();
|
||
expect(svc.hasWagonStock(stock, [NW5], 20, WHOLE_LEG)).toBe(false);
|
||
expect(svc.hasWagonStock(stock, [NW5], 16, WHOLE_LEG)).toBe(true);
|
||
// A booking that may ride either type sees all 20.
|
||
expect(svc.hasWagonStock(stock, [NW5, PW2], 20, WHOLE_LEG)).toBe(true);
|
||
});
|
||
|
||
it('stands down when the booking has no allowed wagon type configured', () => {
|
||
// Unresolvable configuration must not strand every booking that uses it —
|
||
// the abstract capacity budget still governs.
|
||
expect(internals(service()).hasWagonStock(mixedStock(), [], 999, WHOLE_LEG)).toBe(true);
|
||
});
|
||
|
||
it('sizes the split offer to the wagons that physically exist, not the free slots', async () => {
|
||
const svc = service();
|
||
const inner = internals(svc);
|
||
// Isolate the sizing decision: eligibility and offer creation are covered
|
||
// elsewhere, what matters here is the room handed to tryPartialOffer.
|
||
(inner as { isSplitEligible: unknown }).isSplitEligible = () => true;
|
||
const tryPartial = jest
|
||
.fn()
|
||
.mockResolvedValue({ wagons: 16, weightTons: 1600, lengthMeters: 224 });
|
||
(inner as { tryPartialOffer: unknown }).tryPartialOffer = tryPartial;
|
||
|
||
const stock = mixedStock();
|
||
const candidate = {
|
||
id: 'schedule-1',
|
||
// 20 abstract slots free, weight and length wide open.
|
||
budget: {
|
||
legOf: () => WHOLE_LEG,
|
||
remainingFor: () => ({ wagons: 20, weightTons: 99_999, lengthMeters: 99_999 }),
|
||
subtract: jest.fn(),
|
||
},
|
||
armed: false,
|
||
stock,
|
||
};
|
||
|
||
const offered = await inner.maybeOfferPartial(
|
||
{ id: 'b1', reference: 'BK-1', originYardId: 'a', destinationYardId: 'b' } as Booking,
|
||
false,
|
||
[candidate],
|
||
{ wagons: 20, weightTons: 2000, lengthMeters: 280 },
|
||
[NW5],
|
||
);
|
||
|
||
expect(offered).toBe(true);
|
||
// 16, not the 20 free slots — the customer is billed for what can be loaded.
|
||
expect(tryPartial.mock.calls[0][2]).toMatchObject({ wagons: 16 });
|
||
// Those 16 are now held, so the next booking in the pass cannot re-take them.
|
||
expect(stock.availableFor([NW5], WHOLE_LEG)).toBe(0);
|
||
});
|
||
});
|