mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 20:05:41 +00:00
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.
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user