Files
edr-platform/apps/edr-freight-api/src/modules/train-scheduling/remainder-placement.service.spec.ts
2026-07-18 09:12:52 +00:00

212 lines
7.6 KiB
TypeScript

import { RemainderPlacementService } from './remainder-placement.service';
/**
* The remainder placer reconstructs the outstanding split remainder as a new
* booking. The delicate parts under test: bulk sizes from the outstanding tons;
* container recovers real numbers from the SOFT-DELETED units (never fabricates)
* and throws on a shortfall; and nothing is placed when there's no outstanding
* or no fitting train.
*/
describe('RemainderPlacementService', () => {
const DAY = '2026-07-20';
function make(opts: {
freightType: 'CONTAINER' | 'BULK';
contractKind?: 'ONE_TIME' | 'GENERAL';
outstanding: unknown;
createThrows?: Error;
deferredUnits?: Array<{
containerNumber: string;
vgmTons: number;
isHazardous?: boolean;
isReefer?: boolean;
}>;
fittingTrains?: Array<{ scheduleId: string }>;
}) {
const contract = {
id: 'c-1',
freightType: opts.freightType,
contractKind: opts.contractKind ?? 'ONE_TIME',
};
const contractsRepository = {
findByIdWithRelations: jest.fn().mockResolvedValue(contract),
};
const createUnderContract = opts.createThrows
? jest.fn().mockRejectedValue(opts.createThrows)
: jest
.fn()
.mockResolvedValue({ booking: { id: 'rem-1', reference: 'BKG-R' }, warnings: [] });
const contractBookingService = {
splitOutstanding: jest.fn().mockResolvedValue(opts.outstanding),
createUnderContract,
};
const bookingBatchService = {
fittingTrainsForDay: jest
.fn()
.mockResolvedValue(opts.fittingTrains ?? [{ scheduleId: 's-2' }]),
};
// getRepository is only hit on the container path (recoverDeferredUnits).
const lineRepo = {
find: jest.fn().mockResolvedValue([{ id: 'line-1' }]),
};
const unitRepo = {
find: jest.fn().mockResolvedValue(opts.deferredUnits ?? []),
};
const dataSource = {
getRepository: jest.fn((entity: { name?: string }) => {
const n = entity?.name ?? '';
if (n.includes('Unit')) return unitRepo;
return lineRepo;
}),
};
const notifier = { remainderPlaced: jest.fn() };
const service = new RemainderPlacementService(
dataSource as never,
contractsRepository as never,
contractBookingService as never,
bookingBatchService as never,
notifier as never,
);
return {
service,
createUnderContract,
contractBookingService,
bookingBatchService,
notifier,
};
}
const splitBooking = {
id: 'bk-1',
reference: 'BKG-1',
contractId: 'c-1',
scheduledDate: new Date('2026-07-20T06:00:00Z'),
createdByUserId: 'u-1',
} as never;
it('sizes a BULK remainder from the outstanding tons', async () => {
const { service, createUnderContract } = make({
freightType: 'BULK',
outstanding: { bySize: new Map(), bulk: { total: 100, outstanding: 40 } },
});
const id = await service.placeRemainder(splitBooking);
expect(id).toBe('rem-1');
const dto = createUnderContract.mock.calls[0][1];
expect(dto.bulkLines).toEqual([{ cargoWeightTons: 40 }]);
expect(dto.scheduledDate).toBe(DAY);
});
it('rebuilds a CONTAINER remainder from the soft-deleted units', async () => {
const deferredUnits = [
{ containerNumber: 'ABCD1234567', vgmTons: 12, isReefer: true },
{ containerNumber: 'ABCD7654321', vgmTons: 10, isHazardous: true },
];
const { service, createUnderContract } = make({
freightType: 'CONTAINER',
outstanding: {
bySize: new Map([['40ft', { total: 5, outstanding: 2 }]]),
bulk: null,
},
deferredUnits,
});
const id = await service.placeRemainder(splitBooking);
expect(id).toBe('rem-1');
const dto = createUnderContract.mock.calls[0][1];
expect(dto.containers).toHaveLength(1);
const line = dto.containers[0];
expect(line.containerSize).toBe('40ft');
expect(line.quantity).toBe(2);
expect(line.units.map((u: { containerNumber: string }) => u.containerNumber)).toEqual([
'ABCD1234567',
'ABCD7654321',
]);
expect(line.reeferQuantity).toBe(1);
expect(line.hazardousQuantity).toBe(1);
});
it('throws (→ no placement) when fewer units are recoverable than outstanding — never fabricates', async () => {
const { service, createUnderContract } = make({
freightType: 'CONTAINER',
outstanding: {
bySize: new Map([['40ft', { total: 5, outstanding: 3 }]]),
bulk: null,
},
deferredUnits: [{ containerNumber: 'ABCD1234567', vgmTons: 12 }], // only 1, need 3
});
const id = await service.placeRemainder(splitBooking);
expect(id).toBeNull();
expect(createUnderContract).not.toHaveBeenCalled();
});
it('is a no-op when there is no outstanding remainder', async () => {
const { service, createUnderContract } = make({
freightType: 'BULK',
outstanding: { bySize: new Map(), bulk: { total: 100, outstanding: 0 } },
});
const id = await service.placeRemainder(splitBooking);
expect(id).toBeNull();
expect(createUnderContract).not.toHaveBeenCalled();
});
it('tells the customer the leftover wagons were booked on another train', async () => {
const { service, notifier } = make({
freightType: 'BULK',
outstanding: { bySize: new Map(), bulk: { total: 100, outstanding: 40 } },
});
await service.placeRemainder(splitBooking);
expect(notifier.remainderPlaced).toHaveBeenCalledWith(
expect.objectContaining({ id: 'rem-1' }),
'BKG-1',
);
});
it('never double-books the leftover when two payments land together', async () => {
const { service, createUnderContract } = make({
freightType: 'BULK',
outstanding: { bySize: new Map(), bulk: { total: 100, outstanding: 40 } },
});
// Both callers enter before either create commits.
await Promise.all([
service.placeRemainder(splitBooking),
service.placeRemainder(splitBooking),
]);
expect(createUnderContract).toHaveBeenCalledTimes(1);
});
// splitOutstanding subtracts a CONTRACT-WIDE booked total from ONE booking's
// snapshot — coherent only for ONE_TIME. On GENERAL that mixes scopes and
// either drops a real remainder or double-draws the cap, so we must not place.
it('never auto-places on a GENERAL contract (cap ledger mismatch)', async () => {
const { service, createUnderContract, contractBookingService } = make({
freightType: 'BULK',
contractKind: 'GENERAL',
outstanding: { bySize: new Map(), bulk: { total: 100, outstanding: 40 } },
});
const id = await service.placeRemainder(splitBooking);
expect(id).toBeNull();
expect(createUnderContract).not.toHaveBeenCalled();
expect(contractBookingService.splitOutstanding).not.toHaveBeenCalled();
});
// The paid booking has already boarded — a create-gate rejection (e.g. the
// export whole-train gate) must leave the remainder rebookable, not escape.
it('swallows a create rejection and leaves the remainder for manual rebook', async () => {
const { service } = make({
freightType: 'BULK',
outstanding: { bySize: new Map(), bulk: { total: 100, outstanding: 40 } },
createThrows: new Error('Not enough train space for this day.'),
});
await expect(service.placeRemainder(splitBooking)).resolves.toBeNull();
});
it('is a no-op when the contract has no split chain', async () => {
const { service, createUnderContract } = make({
freightType: 'BULK',
outstanding: null,
});
const id = await service.placeRemainder(splitBooking);
expect(id).toBeNull();
expect(createUnderContract).not.toHaveBeenCalled();
});
});