mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
212 lines
7.6 KiB
TypeScript
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();
|
|
});
|
|
});
|