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