mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
144 lines
5.0 KiB
TypeScript
144 lines
5.0 KiB
TypeScript
import { Booking } from '../bookings/entities/booking.entity';
|
||
import { BookingBatchService } from './booking-batch.service';
|
||
import { WagonStockLedger } from './wagon-stock-ledger.util';
|
||
|
||
/**
|
||
* smartBulkNeed math in isolation: the private helpers it touches
|
||
* (allowedDimsWithTypes) read only their arguments, so a bare prototype
|
||
* instance is enough — no Nest wiring.
|
||
*///
|
||
describe('BookingBatchService.smartBulkNeed', () => {
|
||
const service = Object.create(BookingBatchService.prototype) as BookingBatchService;
|
||
const call = (
|
||
booking: Booking,
|
||
stock: WagonStockLedger,
|
||
rank: Map<string, number>,
|
||
) =>
|
||
(
|
||
service as unknown as {
|
||
smartBulkNeed: (
|
||
b: Booking,
|
||
d: unknown,
|
||
s: WagonStockLedger,
|
||
l: { fromEdge: number; toEdge: number },
|
||
r: Map<string, number>,
|
||
ids: readonly string[],
|
||
) => { need: { wagons: number }; perType: Array<{ wagonTypeId: string; wagons: number }> } | null;
|
||
}
|
||
).smartBulkNeed(booking, wagonDims, stock, { fromEdge: 0, toEdge: 1 }, rank, allowedIds);
|
||
|
||
const nw5 = { id: 'wt-nw5', capacityTons: 70 };
|
||
const pw2 = { id: 'wt-pw2', capacityTons: 70 };
|
||
// Shaped like a BATCH POOL entity: cargoType WITHOUT the wagonTypes
|
||
// relation (the pool query never joins it) — allowed types must come from
|
||
// the ids parameter, or every pool bulk booking reads as unseatable.
|
||
const perishable = {
|
||
id: 'cargo-perishable',
|
||
tonsPerWagonMap: { [nw5.id]: 30, [pw2.id]: 20 },
|
||
};
|
||
const allowedIds = [nw5.id, pw2.id];
|
||
const wagonDims = {
|
||
container: { lengthMeters: 14, tareWeightTons: 24, capacityTons: 70 },
|
||
bulk: { lengthMeters: 14, tareWeightTons: 24, capacityTons: 70 },
|
||
byWagonTypeId: new Map([
|
||
[nw5.id, { lengthMeters: 14, tareWeightTons: 24, capacityTons: 70 }],
|
||
[pw2.id, { lengthMeters: 14, tareWeightTons: 24, capacityTons: 70 }],
|
||
]),
|
||
};
|
||
const booking = (tons: number): Booking =>
|
||
({
|
||
id: 'b1',
|
||
reference: 'b1',
|
||
freightType: 'BULK',
|
||
cargoTotalWeightVgm: tons,
|
||
cargoTypeId: perishable.id,
|
||
cargoType: perishable,
|
||
bookingContainers: [],
|
||
}) as unknown as Booking;
|
||
// Containers compete for NW5 → NW5 rank 2, PW2 rank 1.
|
||
const contested = new Map([
|
||
[nw5.id, 2],
|
||
[pw2.id, 1],
|
||
]);
|
||
|
||
it('seats 695T as 10 PW2 (20T) + 17 NW5 (30T) = 27 wagons, PW2 first', () => {
|
||
const stock = new WagonStockLedger(
|
||
new Map([
|
||
[nw5.id, 18],
|
||
[pw2.id, 10],
|
||
]),
|
||
1,
|
||
);
|
||
const smart = call(booking(695), stock, contested);
|
||
expect(smart).not.toBeNull();
|
||
expect(smart!.need.wagons).toBe(27);
|
||
expect(smart!.perType).toEqual([
|
||
{ wagonTypeId: pw2.id, wagons: 10 },
|
||
{ wagonTypeId: nw5.id, wagons: 17 },
|
||
]);
|
||
});
|
||
|
||
it('returns null when the free stock cannot seat the whole booking', () => {
|
||
const stock = new WagonStockLedger(
|
||
new Map([
|
||
[nw5.id, 5],
|
||
[pw2.id, 10],
|
||
]),
|
||
1,
|
||
);
|
||
// 10×20 + 5×30 = 350T < 695T.
|
||
expect(call(booking(695), stock, contested)).toBeNull();
|
||
});
|
||
|
||
it('S-2026-00020 shape: 42x40ft eat the NW5, 200T bulk still seats on the 10 coupled PW2', () => {
|
||
// The staging complaint: a built train of 42 NW5 + 10 PW2, containers
|
||
// hold every NW5, and a bulk booking sits in "Ready for batch" while the
|
||
// PW2 ride empty. The chain: committed containers drain NW5 from the
|
||
// ledger (their types cannot touch PW2), then the smart gate must seat
|
||
// 200T of Perishable on the 10 PW2 at the 20T cap.
|
||
const stock = new WagonStockLedger(
|
||
new Map([
|
||
[nw5.id, 42],
|
||
[pw2.id, 10],
|
||
]),
|
||
2, // DCT -> Dire -> GMP: two edges
|
||
);
|
||
// Committed container booking rides Dire->GMP (edge 1) on 42 NW5 —
|
||
// container-capable types only, exactly how stockLedgerFor debits it.
|
||
stock.consume([nw5.id], 42, { fromEdge: 1, toEdge: 2 });
|
||
expect(stock.availableFor([nw5.id], { fromEdge: 0, toEdge: 2 })).toBe(0);
|
||
expect(stock.availableFor([pw2.id], { fromEdge: 0, toEdge: 2 })).toBe(10);
|
||
|
||
const smart = (
|
||
service as unknown as {
|
||
smartBulkNeed: (
|
||
b: Booking,
|
||
d: unknown,
|
||
s: WagonStockLedger,
|
||
l: { fromEdge: number; toEdge: number },
|
||
r: Map<string, number>,
|
||
ids: readonly string[],
|
||
) => { need: { wagons: number }; perType: Array<{ wagonTypeId: string; wagons: number }> } | null;
|
||
}
|
||
).smartBulkNeed(booking(200), wagonDims, stock, { fromEdge: 0, toEdge: 2 }, contested, allowedIds);
|
||
expect(smart).not.toBeNull();
|
||
expect(smart!.perType).toEqual([{ wagonTypeId: pw2.id, wagons: 10 }]);
|
||
});
|
||
|
||
it('uncontested types fall back to biggest per-cargo take (fewest wagons)', () => {
|
||
const stock = new WagonStockLedger(
|
||
new Map([
|
||
[nw5.id, 10],
|
||
[pw2.id, 10],
|
||
]),
|
||
1,
|
||
);
|
||
const even = new Map([
|
||
[nw5.id, 1],
|
||
[pw2.id, 1],
|
||
]);
|
||
const smart = call(booking(60), stock, even);
|
||
expect(smart!.perType).toEqual([{ wagonTypeId: nw5.id, wagons: 2 }]);
|
||
});
|
||
});
|