Files
edr-platform/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.spec.ts
2026-07-17 09:16:35 +00:00

282 lines
9.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { AllocationLoadType } from '@edr/types';
import { Booking } from '../bookings/entities/booking.entity';
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
import {
buildBulkWagonPlan,
buildContainerWagonPlan,
buildMixedWagonPlan,
containerWagonsForLines,
expandBookingContainerUnits,
expandContainerItems,
roundTons,
sumWagonsRequired,
validate20ftContainerRules,
validateContainerPlacements,
} from './wagon-plan.util';
const nw5: WagonType = {
id: 'wt-nw5',
code: 'NW5',
name: 'Flat Wagon',
capacityTons: 70,
lengthMeters: 14,
supportedLoadTypes: ['CONTAINER'],
isActive: true,
supportsContainer: true,
} as WagonType;
const cw3: WagonType = {
id: 'wt-cw3',
code: 'CW3',
name: 'Covered Wagon',
capacityTons: 60,
lengthMeters: 14,
supportedLoadTypes: ['BULK'],
isActive: true,
supportsContainer: false,
} as WagonType;
const makeContainerBooking = (
id: string,
lines: Array<{ quantity: number; wagonsRequired: number; vgmPerUnitTons?: number }>,
): Booking =>
({
id,
reference: id,
freightType: 'CONTAINER',
cargoTotalWeightVgm: lines.reduce(
(sum, line) => sum + line.quantity * (line.vgmPerUnitTons ?? 25),
0,
),
bookingContainers: lines.map((line, index) => ({
id: `${id}-line-${index}`,
containerTypeId: `ct-${index}`,
quantity: line.quantity,
wagonsRequired: line.wagonsRequired,
vgmPerUnitTons: line.vgmPerUnitTons ?? 25,
})),
}) as Booking;
describe('wagon-plan.util', () => {
it('uses slot-based planning: 2×20ft = 1 wagon slot', () => {
const booking = makeContainerBooking('b1', [{ quantity: 2, wagonsRequired: 1 }]);
const plan = buildContainerWagonPlan([booking], nw5);
expect(plan).toHaveLength(1);
expect(plan[0]?.allocations[0]?.loadType).toBe(AllocationLoadType.Container);
});
it('uses slot-based planning: 1×40ft = 1 wagon slot', () => {
const booking = makeContainerBooking('b2', [{ quantity: 1, wagonsRequired: 1 }]);
const plan = buildContainerWagonPlan([booking], nw5);
expect(plan).toHaveLength(1);
});
it('sums wagons across multiple container lines', () => {
const booking = makeContainerBooking('b3', [
{ quantity: 2, wagonsRequired: 1 },
{ quantity: 1, wagonsRequired: 1 },
]);
expect(sumWagonsRequired(booking)).toBe(2);
const plan = buildContainerWagonPlan([booking], nw5);
expect(plan).toHaveLength(2);
});
it('counts a bulk booking\'s wagons from the plan, not a flat 1', () => {
// 700T of sugar on 60T CW3 gondolas = 12 wagons; the stored wagonsRequired
// must carry all of them so gross weight charges 12 tares downstream.
const booking = {
id: 'bulk-700',
reference: 'bulk-700',
freightType: 'BULK',
cargoTotalWeightVgm: 700,
bookingContainers: [],
} as unknown as Booking;
const plan = buildBulkWagonPlan([booking], cw3);
expect(plan).toHaveLength(12);
expect(sumWagonsRequired(booking, plan)).toBe(12);
// Without a plan the pre-plan fallback still applies.
expect(sumWagonsRequired(booking)).toBe(1);
});
it('counts container wagons from the plan TEU packing', () => {
const booking = makeContainerBooking('c-plan', [{ quantity: 6, wagonsRequired: 3 }]);
const plan = buildContainerWagonPlan([booking], nw5);
expect(sumWagonsRequired(booking, plan)).toBe(3);
});
it('6×20ft containers = 3 wagon slots (2 per wagon)', () => {
// 20ft containers take half a wagon each, so 6 * 0.5 = 3 wagons
const booking = makeContainerBooking('b6x20', [{ quantity: 6, wagonsRequired: 3 }]);
expect(sumWagonsRequired(booking)).toBe(3);
const plan = buildContainerWagonPlan([booking], nw5);
expect(plan).toHaveLength(3);
// Verify sequence numbers are 1, 2, 3
expect(plan.map((s) => s.sequenceNo)).toEqual([1, 2, 3]);
});
it('expands container items per quantity', () => {
const booking = makeContainerBooking('b4', [{ quantity: 3, wagonsRequired: 3 }]);
const items = expandContainerItems(booking, 'alloc-1');
expect(items).toHaveLength(3);
expect(items[0]?.wagonBookingAllocationId).toBe('alloc-1');
});
it('rounds tons to three decimal places', () => {
expect(roundTons(1.23456)).toBe(1.235);
expect(roundTons('bad')).toBe(0);
});
it('builds mixed plan with container block before bulk', () => {
const containerBooking = makeContainerBooking('c1', [{ quantity: 2, wagonsRequired: 2 }]);
const bulkBooking = {
id: 'b1',
reference: 'BKG-BULK',
freightType: 'BULK',
cargoTotalWeightVgm: 120,
bookingContainers: [],
} as unknown as Booking;
const plan = buildMixedWagonPlan([containerBooking], [bulkBooking], nw5, cw3);
expect(plan).toHaveLength(4);
expect(plan[0]?.slotLoadType).toBe('CONTAINER');
expect(plan[2]?.slotLoadType).toBe('BULK');
expect(plan.map((s) => s.sequenceNo)).toEqual([1, 2, 3, 4]);
});
it('expands booking container units for UI rows', () => {
const booking = makeContainerBooking('c2', [{ quantity: 3, wagonsRequired: 3 }]);
const units = expandBookingContainerUnits([booking]);
expect(units).toHaveLength(3);
expect(units[1]?.unitIndex).toBe(1);
expect(units[1]?.bookingContainerId).toBe('c2-line-0');
});
it('validates required placements per container unit', () => {
const booking = makeContainerBooking('c3', [{ quantity: 2, wagonsRequired: 2 }]);
const plan = buildContainerWagonPlan([booking], nw5);
const violations = validateContainerPlacements([booking], plan, []);
expect(violations.some((v) => v.includes('required'))).toBe(true);
const units = expandBookingContainerUnits([booking]);
const placements = units.map((unit, index) => ({
bookingContainerId: unit.bookingContainerId,
unitIndex: unit.unitIndex,
sequenceNo: plan[index]?.sequenceNo ?? 1,
containerNumber: `CNTR-${index + 1}`,
}));
expect(validateContainerPlacements([booking], plan, placements)).toEqual([]);
});
it('rejects 20ft container over max individual weight', () => {
const booking = makeContainerBooking('c20', [{ quantity: 2, wagonsRequired: 1, vgmPerUnitTons: 35 }]);
const units = expandBookingContainerUnits([booking]);
const placements = units.map((unit, index) => ({
bookingContainerId: unit.bookingContainerId,
unitIndex: unit.unitIndex,
sequenceNo: 1,
containerNumber: `CNTR-${index + 1}`,
}));
const violations = validate20ftContainerRules(units, placements, {
max20ftContainerWeightTons: 30,
max20ftPairWeightDiffTons: 10,
});
expect(violations.some((v) => v.includes('exceeds max 30T'))).toBe(true);
});
it('rejects 20ft pair when weight difference exceeds limit', () => {
const booking = makeContainerBooking('c21', [
{ quantity: 2, wagonsRequired: 1, vgmPerUnitTons: 25 },
]);
booking.bookingContainers![0]!.vgmPerUnitTons = 25;
const units = expandBookingContainerUnits([booking]);
units[1]!.grossWeightTons = 10;
const placements = units.map((unit) => ({
bookingContainerId: unit.bookingContainerId,
unitIndex: unit.unitIndex,
sequenceNo: 1,
containerNumber: `CNTR-${unit.unitIndex}`,
}));
const violations = validate20ftContainerRules(units, placements, {
max20ftContainerWeightTons: 30,
max20ftPairWeightDiffTons: 10,
});
expect(violations.some((v) => v.includes('weight difference'))).toBe(true);
});
it('builds bulk-only plan as degenerate mixed case', () => {
const bulkBooking = {
id: 'b2',
reference: 'BKG-BULK-2',
freightType: 'BULK',
cargoTotalWeightVgm: 60,
bookingContainers: [],
} as unknown as Booking;
const plan = buildMixedWagonPlan([], [bulkBooking], nw5, cw3);
expect(plan).toHaveLength(1);
expect(plan[0]?.slotLoadType).toBe('BULK');
expect(buildBulkWagonPlan([bulkBooking], cw3)).toHaveLength(1);
});
});
describe('containerWagonsForLines — TEU-aware, ceil booking total once', () => {
const line = (quantity: number, wagonsPerUnit: number, wagonsRequired?: number) => ({
quantity,
wagonsRequired: wagonsRequired ?? quantity * wagonsPerUnit,
containerType: { sizeFt: wagonsPerUnit >= 1 ? 40 : 20 },
});
it('20×20ft = 10 wagons (not 20)', () => {
expect(containerWagonsForLines([line(20, 0.5)])).toBe(10);
});
it('38×20ft = 19 wagons', () => {
expect(containerWagonsForLines([line(38, 0.5)])).toBe(19);
});
it('2×20ft = 1 wagon', () => {
expect(containerWagonsForLines([line(2, 0.5)])).toBe(1);
});
it('odd 3×20ft = 2 wagons (single line ceils)', () => {
expect(containerWagonsForLines([line(3, 0.5)])).toBe(2);
});
it('3×20ft + 3×20ft = 3 wagons (ceil TOTAL, not per line)', () => {
// per-line ceil would give 2 + 2 = 4; the booking total is ceil(1.5+1.5)=3.
expect(containerWagonsForLines([line(3, 0.5), line(3, 0.5)])).toBe(3);
});
it('three 1×20ft lines = 2 wagons (ceil TOTAL)', () => {
// per-line ceil would give 1+1+1 = 3; total is ceil(0.5*3)=ceil(1.5)=2.
expect(
containerWagonsForLines([line(1, 0.5), line(1, 0.5), line(1, 0.5)]),
).toBe(2);
});
it('5×20ft + 2×40ft = 5 wagons', () => {
expect(containerWagonsForLines([line(5, 0.5), line(2, 1)])).toBe(5);
});
it('21×40ft = 21 wagons', () => {
expect(containerWagonsForLines([line(21, 1)])).toBe(21);
});
it('falls back to line wagonsRequired when containerType/sizeFt missing', () => {
// No containerType relation loaded → use the stored (0.5-aware) fraction.
expect(
containerWagonsForLines([
{ quantity: 20, wagonsRequired: 10 } as never,
]),
).toBe(10);
});
it('empty line set = 0 wagons', () => {
expect(containerWagonsForLines([])).toBe(0);
});
});