Files
edr-platform/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.spec.ts
Hagernesh 6a62ece814 feat(train-scheduling): auto-place intercity cargo on wagons freed mid-corridor
Intercity bookings ride the wagons freed by earlier unloads (e.g. import
containers uncoupled at Dire Dawa). loadBooking now auto-allocates a
DOMESTIC booking onto on-train slots whose cargo has all departed —
greedy in consist order by capacity, container numbers copied for the
marshalling tally. Falls back to unallocated load when nothing is free.
2026-08-02 23:07:59 +00:00

146 lines
5.2 KiB
TypeScript

import { BookingJourneyService } from './booking-journey.service';
/**
* autoPlaceOnFreedWagons: intercity cargo boards the wagons freed by earlier
* unloads. Exercised directly with a stubbed EntityManager — the surrounding
* loadBooking flow is integration-tested through the running app.
*/
describe('BookingJourneyService.autoPlaceOnFreedWagons', () => {
const service = new BookingJourneyService(
{} as never, // dataSource
{} as never, // yardFacilities
{} as never, // facilityHandling
{ emit: jest.fn() } as never, // events
);
const schedule = { id: 'sched-1', trainSetId: 'ts-1' };
const booking = {
id: 'booking-1',
reference: 'BK-1',
cargoTotalWeightVgm: 50,
freightType: 'CONTAINER',
};
const makeManager = (slots: unknown[], existingAllocs: unknown[] = []) => {
const savedAllocs: Array<Record<string, unknown>> = [];
const savedItems: Array<Record<string, unknown>> = [];
const allocQb = {
innerJoinAndSelect: jest.fn().mockReturnThis(),
innerJoin: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
getMany: jest.fn().mockResolvedValue(existingAllocs),
};
const slotQb = {
leftJoinAndSelect: jest.fn().mockReturnThis(),
innerJoin: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
getMany: jest.fn().mockResolvedValue(slots),
};
let allocId = 0;
const manager = {
getRepository: jest.fn((entity: { name?: string }) => {
const name = entity?.name;
if (name === 'WagonBookingAllocation') {
return {
createQueryBuilder: jest.fn(() => allocQb),
create: jest.fn((v: Record<string, unknown>) => v),
save: jest.fn(async (v: Record<string, unknown>) => {
const row = { ...v, id: `alloc-${++allocId}` };
savedAllocs.push(row);
return row;
}),
update: jest.fn(),
};
}
if (name === 'TrainSetWagon') {
return { createQueryBuilder: jest.fn(() => slotQb) };
}
if (name === 'BookingContainer') {
return {
find: jest.fn().mockResolvedValue([
{
id: 'line-1',
containerNumber: 'LINE-001',
containerTypeId: 'ct-20',
units: [{ containerNumber: 'UNIT-001' }, { containerNumber: 'UNIT-002' }],
},
]),
};
}
if (name === 'WagonAllocationContainerItem') {
return {
create: jest.fn((v: Record<string, unknown>) => v),
save: jest.fn(async (v: Record<string, unknown>) => {
savedItems.push(v);
return v;
}),
};
}
throw new Error(`Unexpected repository ${name}`);
}),
};
return { manager, savedAllocs, savedItems };
};
const call = (manager: unknown) =>
(service as never as {
autoPlaceOnFreedWagons: (m: unknown, s: unknown, b: unknown) => Promise<void>;
}).autoPlaceOnFreedWagons(manager, schedule, booking);
it('places the booking on freed slots in consist order, with container items', async () => {
const slots = [
// Active cargo still riding — NOT freed.
{ id: 'slot-1', sequenceNo: 1, capacityTons: 60, allocations: [{ status: 'LOADED' }] },
// Freed by an earlier unload.
{ id: 'slot-2', sequenceNo: 2, capacityTons: 60, allocations: [{ status: 'DEPARTED' }] },
{ id: 'slot-3', sequenceNo: 3, capacityTons: 60, allocations: [] },
];
const { manager, savedAllocs, savedItems } = makeManager(slots);
await call(manager);
// 50 t fits on the first freed slot alone.
expect(savedAllocs).toHaveLength(1);
expect(savedAllocs[0]).toMatchObject({
trainSetWagonId: 'slot-2',
bookingId: 'booking-1',
allocatedWeightTons: 50,
status: 'LOADED',
});
// One item per physical unit, on the first allocation.
expect(savedItems.map((i) => i.containerNumber)).toEqual(['UNIT-001', 'UNIT-002']);
expect(savedItems.every((i) => i.wagonBookingAllocationId === 'alloc-1')).toBe(true);
});
it('spills over onto the next freed slot when one is not enough', async () => {
const slots = [
{ id: 'slot-2', sequenceNo: 2, capacityTons: 30, allocations: [{ status: 'DEPARTED' }] },
{ id: 'slot-3', sequenceNo: 3, capacityTons: 30, allocations: [] },
];
const { manager, savedAllocs } = makeManager(slots);
await call(manager);
expect(savedAllocs.map((a) => [a.trainSetWagonId, a.allocatedWeightTons])).toEqual([
['slot-2', 30],
['slot-3', 20],
]);
});
it('does nothing when the booking already has allocations', async () => {
const { manager, savedAllocs } = makeManager([], [{ id: 'existing' }]);
await call(manager);
expect(savedAllocs).toHaveLength(0);
});
it('loads without allocation when no wagon is free', async () => {
const slots = [
{ id: 'slot-1', sequenceNo: 1, capacityTons: 60, allocations: [{ status: 'LOADED' }] },
];
const { manager, savedAllocs } = makeManager(slots);
await expect(call(manager)).resolves.toBeUndefined();
expect(savedAllocs).toHaveLength(0);
});
});