mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 21:08:12 +00:00
booking operations and trains scheduling also allocations
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user