mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 23:40:56 +00:00
919 lines
34 KiB
TypeScript
919 lines
34 KiB
TypeScript
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;
|
||
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;
|
||
getBookableSchedules: jest.Mock;
|
||
getWindowConfig: jest.Mock;
|
||
};
|
||
let dataSource: {
|
||
getRepository: jest.Mock;
|
||
transaction: 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: [],
|
||
}),
|
||
getBookableSchedules: jest.fn().mockResolvedValue([]),
|
||
getWindowConfig: jest.fn().mockResolvedValue({
|
||
importWindowLeadDays: 3,
|
||
exportBookingLeadHours: 24,
|
||
windowOpenHour: 8,
|
||
windowCloseHour: 17,
|
||
windowDurationHours: 3,
|
||
docReviewMinutes: 30,
|
||
paymentWindowMinutes: 60,
|
||
reopenDelayMinutes: 90,
|
||
}),
|
||
};
|
||
|
||
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);
|
||
}),
|
||
};
|
||
|
||
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,
|
||
{
|
||
syncPayableDueDate: jest.fn().mockResolvedValue(undefined),
|
||
expirePayable: jest.fn().mockResolvedValue(undefined),
|
||
} 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('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('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,
|
||
{ syncPayableDueDate: jest.fn(), expirePayable: jest.fn() } 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,
|
||
{ syncPayableDueDate: jest.fn(), expirePayable: jest.fn() } 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,
|
||
{ syncPayableDueDate: jest.fn(), expirePayable: jest.fn() } 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: new Date(Date.now() - 60_000),
|
||
});
|
||
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('serialises concurrent settles so the same reservation is not settled twice', async () => {
|
||
const lapsed = booking('lapsed', 50, {
|
||
status: 'SELECTED_FOR_BATCH',
|
||
paymentDeadline: new Date(Date.now() - 60_000),
|
||
});
|
||
// 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: new Date(Date.now() - 60_000),
|
||
});
|
||
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('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: { wagonsPerUnit: 1, 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: { wagonsPerUnit: 0.5, 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: { wagonsPerUnit: 1, 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
|
||
});
|
||
});
|
||
});
|