Files
edr-platform/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.spec.ts

203 lines
6.9 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,
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,
maxWagonsPerTrain: 53,
supportedLoadTypes: ['CONTAINER'],
isActive: true,
supportsContainer: true,
} as WagonType;
const cw3: WagonType = {
id: 'wt-cw3',
code: 'CW3',
name: 'Covered Wagon',
capacityTons: 60,
lengthMeters: 14,
maxWagonsPerTrain: 53,
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('6×20ft containers = 3 wagon slots (2 per wagon)', () => {
// 20ft containers have wagonsPerUnit = 0.5, 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);
});
});