mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 14:15:44 +00:00
- Implemented method in to allow customers to request an extension for expired contracts. - Added component in for users to initiate extension requests. - Updated to include logic for handling extension requests for expired contracts. - Enhanced to display extension request options and status. - Created migration to add and columns to the contracts table. - Added unit tests for contract extension request and handling in . - Defined DTOs for request and extension in . - Updated types in to include new fields related to contract extensions.
521 lines
18 KiB
TypeScript
521 lines
18 KiB
TypeScript
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,
|
||
maxEdgeConsistUsage,
|
||
roundTons,
|
||
sumWagonsRequired,
|
||
validate20ftContainerRules,
|
||
validateContainerPlacements,
|
||
validateMixedTrainLimitsPerEdge,
|
||
validateWagonCargoExclusivity,
|
||
} 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 a container over its line weight-limit-rule capacity', () => {
|
||
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, {
|
||
maxContainerWeightTonsByLineId: { [units[0]!.bookingContainerId]: 30 },
|
||
max20ftPairWeightDiffTons: 10,
|
||
});
|
||
|
||
expect(violations.filter((v) => v.includes('weight limit rule capacity of 30T'))).toHaveLength(2);
|
||
});
|
||
|
||
it('applies no per-box ceiling to a line without a weight-limit-rule capacity', () => {
|
||
const booking = makeContainerBooking('c20b', [{ 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, {
|
||
maxContainerWeightTonsByLineId: {},
|
||
max20ftPairWeightDiffTons: 10,
|
||
});
|
||
|
||
expect(violations).toEqual([]);
|
||
});
|
||
|
||
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, {
|
||
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);
|
||
});
|
||
|
||
it('never pools two bulk bookings on one wagon', () => {
|
||
// 5T + 40T both fit a single 60T CW3 by tonnage — but a wagon with bulk
|
||
// takes that one load only, so each booking gets its own wagon.
|
||
const small = {
|
||
id: 'bulk-5',
|
||
reference: 'bulk-5',
|
||
freightType: 'BULK',
|
||
cargoTotalWeightVgm: 5,
|
||
bookingContainers: [],
|
||
} as unknown as Booking;
|
||
const other = {
|
||
id: 'bulk-40',
|
||
reference: 'bulk-40',
|
||
freightType: 'BULK',
|
||
cargoTotalWeightVgm: 40,
|
||
bookingContainers: [],
|
||
} as unknown as Booking;
|
||
const plan = buildBulkWagonPlan([small, other], cw3);
|
||
expect(plan).toHaveLength(2);
|
||
for (const slot of plan) {
|
||
expect(slot.allocations).toHaveLength(1);
|
||
}
|
||
expect(plan[0]?.allocations[0]?.bookingId).toBe('bulk-5');
|
||
expect(plan[1]?.allocations[0]?.bookingId).toBe('bulk-40');
|
||
expect(validateWagonCargoExclusivity(plan)).toEqual([]);
|
||
});
|
||
|
||
it('a multi-wagon bulk booking still spreads over its own wagons', () => {
|
||
const big = {
|
||
id: 'bulk-130',
|
||
reference: 'bulk-130',
|
||
freightType: 'BULK',
|
||
cargoTotalWeightVgm: 130,
|
||
bookingContainers: [],
|
||
} as unknown as Booking;
|
||
const plan = buildBulkWagonPlan([big], cw3);
|
||
expect(plan).toHaveLength(3);
|
||
expect(plan.map((s) => s.allocations[0]?.allocatedWeightTons)).toEqual([60, 60, 10]);
|
||
});
|
||
|
||
it('flags a wagon mixing bulk with anything else', () => {
|
||
const bulkAlloc = {
|
||
bookingId: 'b',
|
||
bookingReference: 'b',
|
||
allocatedWeightTons: 5,
|
||
loadType: AllocationLoadType.Bulk,
|
||
};
|
||
const containerAlloc = {
|
||
bookingId: 'c',
|
||
bookingReference: 'c',
|
||
allocatedWeightTons: 25,
|
||
loadType: AllocationLoadType.Container,
|
||
};
|
||
const slot = (allocations: (typeof bulkAlloc)[]) => ({
|
||
sequenceNo: 1,
|
||
wagonTypeId: cw3.id,
|
||
wagonTypeCode: cw3.code,
|
||
capacityTons: 60,
|
||
lengthMeters: 14,
|
||
tareWeightTons: 24,
|
||
assignedWeightTons: 0,
|
||
allocations,
|
||
});
|
||
// bulk + container on one wagon
|
||
expect(validateWagonCargoExclusivity([slot([bulkAlloc, containerAlloc])]))
|
||
.toHaveLength(1);
|
||
// bulk + bulk on one wagon
|
||
expect(
|
||
validateWagonCargoExclusivity([slot([bulkAlloc, { ...bulkAlloc, bookingId: 'b2' }])]),
|
||
).toHaveLength(1);
|
||
// bulk alone, and containers sharing, are fine
|
||
expect(validateWagonCargoExclusivity([slot([bulkAlloc])])).toEqual([]);
|
||
expect(
|
||
validateWagonCargoExclusivity([
|
||
slot([containerAlloc, { ...containerAlloc, bookingId: 'c2' }]),
|
||
]),
|
||
).toEqual([]);
|
||
});
|
||
});
|
||
|
||
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);
|
||
});
|
||
});
|
||
|
||
describe('maxEdgeConsistUsage — the binding edge, not the whole-route sum', () => {
|
||
const slot = (
|
||
tare: number,
|
||
cargo: number,
|
||
length: number,
|
||
board?: string | null,
|
||
alight?: string | null,
|
||
) =>
|
||
({
|
||
tareWeightTons: tare,
|
||
assignedWeightTons: cargo,
|
||
lengthMeters: length,
|
||
boardYardId: board ?? null,
|
||
alightYardId: alight ?? null,
|
||
}) as never;
|
||
|
||
const stops = ['a', 'b', 'c'];
|
||
|
||
it('does not sum disjoint legs: intercity a→b + export b→c', () => {
|
||
const plan = [
|
||
slot(24, 65, 14, null, 'b'), // intercity, rides a→b only
|
||
slot(24, 65, 14, 'b', null), // export, rides b→c only
|
||
];
|
||
// Each edge carries one slot: 89T gross / 14m — never 178T.
|
||
expect(maxEdgeConsistUsage(plan, stops)).toEqual({
|
||
grossWeightTons: 89,
|
||
lengthMeters: 14,
|
||
loadedWagonCount: 1,
|
||
});
|
||
});
|
||
|
||
it('sums overlapping legs on their shared edge (the S-2026-00024 shape)', () => {
|
||
// 20 intercity a→b wagons + 20 export a→c wagons, 23.94T tare, 64.75T cargo:
|
||
// shared edge a→b carries all 40 slots = 3547.6T gross.
|
||
const plan = [
|
||
...Array.from({ length: 20 }, () => slot(23.94, 64.75, 14, null, 'b')),
|
||
...Array.from({ length: 20 }, () => slot(23.94, 64.75, 14, null, null)),
|
||
];
|
||
const usage = maxEdgeConsistUsage(plan, stops);
|
||
expect(usage.grossWeightTons).toBeCloseTo(3547.6, 1);
|
||
expect(usage.lengthMeters).toBe(560);
|
||
});
|
||
|
||
it('degrades to whole-train totals on a two-stop route', () => {
|
||
const plan = [slot(24, 65, 14), slot(24, 65, 14)];
|
||
expect(maxEdgeConsistUsage(plan, ['a', 'b'])).toEqual({
|
||
grossWeightTons: 178,
|
||
lengthMeters: 28,
|
||
loadedWagonCount: 2,
|
||
});
|
||
});
|
||
|
||
it('with a legs map, shared-slot cargo weighs only its own edges (the S-2026-00045 shape)', () => {
|
||
// One wagon reused across legs: booking X rides a→b (40T), booking Y
|
||
// boards at b with 30T. The slot spans the whole route, but edge a→b
|
||
// must weigh 24 + 40 = 64T — not 24 + 70. Tare rides both edges.
|
||
const shared = {
|
||
tareWeightTons: 24,
|
||
assignedWeightTons: 70,
|
||
lengthMeters: 14,
|
||
boardYardId: null,
|
||
alightYardId: null,
|
||
allocations: [
|
||
{ bookingId: 'X', allocatedWeightTons: 40 },
|
||
{ bookingId: 'Y', allocatedWeightTons: 30 },
|
||
],
|
||
} as never;
|
||
const legs = new Map([
|
||
['X', { from: 0, to: 1 }],
|
||
['Y', { from: 1, to: 2 }],
|
||
]);
|
||
// Without legs: whole-span scalar on both edges (94T binding edge).
|
||
expect(maxEdgeConsistUsage([shared], stops).grossWeightTons).toBe(94);
|
||
// With legs: heaviest edge is a→b at 64T (b→c is 54T).
|
||
expect(maxEdgeConsistUsage([shared], stops, legs).grossWeightTons).toBe(64);
|
||
});
|
||
|
||
it('falls back to the whole-span scalar when an allocation has no readable weight', () => {
|
||
const shared = {
|
||
tareWeightTons: 24,
|
||
assignedWeightTons: 70,
|
||
lengthMeters: 14,
|
||
boardYardId: null,
|
||
alightYardId: null,
|
||
allocations: [{ bookingId: 'X' }],
|
||
} as never;
|
||
const legs = new Map([['X', { from: 0, to: 1 }]]);
|
||
expect(maxEdgeConsistUsage([shared], stops, legs).grossWeightTons).toBe(94);
|
||
});
|
||
});
|
||
|
||
describe('validateMixedTrainLimitsPerEdge — leg-aware cargo weighing', () => {
|
||
it('does not flag a leg whose overweight is only later-boarding cargo (S-2026-00045)', () => {
|
||
// 2 shared wagons, 100T cap. Booking X rides a→b with 30T/wagon, booking Y
|
||
// boards at b with 25T/wagon. Whole-span scalars read every edge as
|
||
// 2×(20 + 55) = 150T > 100T; the cargo actually aboard is 100T (a→b) and
|
||
// 90T (b→c) — both fit.
|
||
const slot = (seq: number) => ({
|
||
sequenceNo: seq,
|
||
wagonTypeId: 'wt-nw5',
|
||
wagonTypeCode: 'NW5',
|
||
capacityTons: 70,
|
||
lengthMeters: 14,
|
||
tareWeightTons: 20,
|
||
assignedWeightTons: 55,
|
||
boardYardId: null,
|
||
alightYardId: null,
|
||
allocations: [
|
||
{
|
||
bookingId: 'X',
|
||
bookingReference: 'X',
|
||
allocatedWeightTons: 30,
|
||
loadType: AllocationLoadType.Container,
|
||
},
|
||
{
|
||
bookingId: 'Y',
|
||
bookingReference: 'Y',
|
||
allocatedWeightTons: 25,
|
||
loadType: AllocationLoadType.Container,
|
||
},
|
||
],
|
||
});
|
||
const legs = new Map([
|
||
['X', { from: 0, to: 1 }],
|
||
['Y', { from: 1, to: 2 }],
|
||
]);
|
||
const run = (withLegs?: typeof legs) =>
|
||
validateMixedTrainLimitsPerEdge(
|
||
[slot(1), slot(2)] as never,
|
||
[{ lengthMeters: 14 }],
|
||
{ maxWeightTons: 100 },
|
||
['a', 'b', 'c'],
|
||
undefined,
|
||
withLegs,
|
||
);
|
||
expect(run()).toHaveLength(2); // both edges falsely overweight without legs
|
||
expect(run(legs)).toHaveLength(0);
|
||
});
|
||
});
|