Files
edr-platform/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts
Marshal 835c9e111c feat(train-scheduling): implement container movement between wagons
- Added functionality to move containers between wagons in the train scheduling system.
- Introduced  API endpoint and service method to handle container movement.
- Updated  component to support drag-and-drop for rearranging containers.
- Enhanced  to allow moving containers to other wagons via a context menu.
- Implemented UI feedback for container movement actions, including loading states and success/error notifications.
- Updated relevant types and constants to accommodate new container movement logic.
- Added tests for the rule engine to ensure proper handling of hazardous bookings.
2026-07-21 23:02:06 +00:00

302 lines
9.3 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 { Booking } from '../bookings/entities/booking.entity';
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
import {
applyWagonOrderReversal,
planWagonsWithStock,
} from './wagon-plan-flex.util';
import type { WagonPlanSlot } from './wagon-plan.util';
const nw6: WagonType = {
id: 'wt-nw6',
code: 'NW6',
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 containerBooking = (id: string, quantity: number, wagonsRequired: number): Booking =>
({
id,
reference: id,
freightType: 'CONTAINER',
cargoTotalWeightVgm: quantity * 25,
bookingContainers: [
{
id: `${id}-line-0`,
containerTypeId: 'ct-1',
quantity,
wagonsRequired,
vgmPerUnitTons: 25,
},
],
}) as Booking;
describe('planWagonsWithStock — shortage detail', () => {
it('defers with a structured per-type shortage when container stock runs out', () => {
const result = planWagonsWithStock({
bookings: [containerBooking('BKG-1', 2, 1)],
allowed: {
byContainerTypeId: new Map([['ct-1', [nw6]]]),
byCargoTypeId: new Map(),
},
stock: {
mode: 'YARD',
remainingByTypeId: new Map([[nw6.id, 0]]),
codesByTypeId: new Map([[nw6.id, nw6.code]]),
},
});
expect(result.fitting).toHaveLength(0);
expect(result.deferred).toHaveLength(1);
const row = result.deferred[0]!;
expect(row.reference).toBe('BKG-1');
expect(row.reason).toContain('No available NW6 wagon at the yard');
expect(row.reason).toContain('short 1');
expect(row.shortage).toEqual({
wagonTypeCodes: 'NW6',
wagonsNeeded: 1,
wagonsAvailable: 0,
wagonsShort: 1,
});
});
it('counts the stock the deferred booking actually saw, not its rolled-back usage', () => {
// Two wagons needed (2 × 40ft), one in stock: booking rolls back entirely,
// the shortage reports 1 available / 1 short.
const fortyFooter = containerBooking('BKG-2', 2, 2);
fortyFooter.bookingContainers![0]!.containerType = {
code: '40GP',
sizeFt: 40,
} as never;
const result = planWagonsWithStock({
bookings: [fortyFooter],
allowed: {
byContainerTypeId: new Map([['ct-1', [nw6]]]),
byCargoTypeId: new Map(),
},
stock: {
mode: 'YARD',
remainingByTypeId: new Map([[nw6.id, 1]]),
codesByTypeId: new Map([[nw6.id, nw6.code]]),
},
});
expect(result.deferred).toHaveLength(1);
expect(result.deferred[0]?.shortage).toEqual({
wagonTypeCodes: 'NW6',
wagonsNeeded: 2,
wagonsAvailable: 1,
wagonsShort: 1,
});
// The rolled-back wagon is plannable again for later bookings.
expect(result.plan).toHaveLength(0);
});
it('leaves shortage unset for configuration problems', () => {
const bulkBooking = {
id: 'BKG-3',
reference: 'BKG-3',
freightType: 'BULK',
cargoTotalWeightVgm: 40,
cargoTypeId: 'cargo-1',
cargoType: { id: 'cargo-1', cargoTypeName: 'Fertilizer' },
bookingContainers: [],
} as unknown as Booking;
const result = planWagonsWithStock({
bookings: [bulkBooking],
allowed: {
byContainerTypeId: new Map(),
byCargoTypeId: new Map(), // no wagon types configured → config issue
},
stock: {
mode: 'YARD',
remainingByTypeId: new Map([[cw3.id, 5]]),
codesByTypeId: new Map([[cw3.id, cw3.code]]),
},
});
expect(result.configIssues).toHaveLength(1);
expect(result.deferred[0]?.shortage).toBeNull();
});
});
describe('applyWagonOrderReversal', () => {
const slot = (
seq: number,
wagonTypeId: string,
bookingId: string,
): WagonPlanSlot =>
({
sequenceNo: seq,
wagonTypeId,
capacityTons: 70,
lengthMeters: 14,
assignedWeightTons: 25,
allocations: [{ bookingId }],
}) as unknown as WagonPlanSlot;
const plan: WagonPlanSlot[] = [
slot(1, 'wt-a', 'BKG-A'),
slot(2, 'wt-b', 'BKG-B'),
slot(3, 'wt-c', 'BKG-C'),
];
it('returns the plan unchanged when the flag is false/absent', () => {
expect(applyWagonOrderReversal(plan, false)).toBe(plan);
expect(applyWagonOrderReversal(plan, undefined)).toBe(plan);
expect(applyWagonOrderReversal(plan, null)).toBe(plan);
});
it('flips the order and renumbers sequenceNo 1..N when the flag is true', () => {
const reversed = applyWagonOrderReversal(plan, true);
// Physically-last wagon (was seq 3, wt-c) is now position 1.
expect(reversed.map((s) => s.wagonTypeId)).toEqual(['wt-c', 'wt-b', 'wt-a']);
expect(reversed.map((s) => s.sequenceNo)).toEqual([1, 2, 3]);
});
it('keeps each booking with its own wagon — only the position changes', () => {
const reversed = applyWagonOrderReversal(plan, true);
// The booking that was in the last wagon now sits at sequenceNo 1.
expect(reversed[0].sequenceNo).toBe(1);
expect(
(reversed[0].allocations as { bookingId: string }[])[0].bookingId,
).toBe('BKG-C');
expect(
(reversed[2].allocations as { bookingId: string }[])[0].bookingId,
).toBe('BKG-A');
});
it('does not mutate the input plan', () => {
applyWagonOrderReversal(plan, true);
expect(plan.map((s) => s.sequenceNo)).toEqual([1, 2, 3]);
expect(plan.map((s) => s.wagonTypeId)).toEqual(['wt-a', 'wt-b', 'wt-c']);
});
});
describe('planWagonsWithStock — leg-aware stock (intercity ride-along)', () => {
const allowed = {
byContainerTypeId: new Map([['ct-1', [nw6]]]),
byCargoTypeId: new Map(),
};
// Corridor Gelan(0) → Adama(1) → Doraleh(2): edges 0 and 1.
const legs = (entries: Array<[string, { from: number; to: number }]>) =>
new Map(entries);
it('lets an intercity booking ride the empty leg of a train that is full on the other leg', () => {
// 1 wagon in stock. Export rides edge 1 only; intercity rides edge 0 only.
const result = planWagonsWithStock({
bookings: [
containerBooking('EXPORT-1', 1, 1),
containerBooking('INTERCITY-1', 1, 1),
],
allowed,
stock: {
mode: 'TRAIN',
remainingByTypeId: new Map([[nw6.id, 1]]),
codesByTypeId: new Map([[nw6.id, nw6.code]]),
},
legs: legs([
['EXPORT-1', { from: 1, to: 2 }],
['INTERCITY-1', { from: 0, to: 1 }],
]),
edgeCount: 2,
});
expect(result.deferred).toHaveLength(0);
expect(result.fitting.map((b) => b.id).sort()).toEqual([
'EXPORT-1',
'INTERCITY-1',
]);
// Two slots planned, but both drawn from the single physical wagon.
expect(result.plan).toHaveLength(2);
});
it('still defers when the legs overlap and stock is exhausted', () => {
const result = planWagonsWithStock({
bookings: [
containerBooking('EXPORT-1', 1, 1),
containerBooking('INTERCITY-1', 1, 1),
],
allowed,
stock: {
mode: 'TRAIN',
remainingByTypeId: new Map([[nw6.id, 1]]),
codesByTypeId: new Map([[nw6.id, nw6.code]]),
},
legs: legs([
// Both ride edge 0 — they compete for the one wagon.
['EXPORT-1', { from: 0, to: 2 }],
['INTERCITY-1', { from: 0, to: 1 }],
]),
edgeCount: 2,
});
expect(result.fitting.map((b) => b.id)).toEqual(['EXPORT-1']);
expect(result.deferred).toHaveLength(1);
expect(result.deferred[0]!.reference).toBe('INTERCITY-1');
expect(result.deferred[0]!.reason).toContain('Train has no free NW6 wagon left');
});
it('never packs bookings with different legs into the same wagon slot', () => {
// Two 20ft units with room to share one wagon by TEU — but disjoint legs
// must open separate slots (each with its own leg), not one mixed slot.
const result = planWagonsWithStock({
bookings: [
containerBooking('EXPORT-1', 1, 1),
containerBooking('INTERCITY-1', 1, 1),
],
allowed,
stock: {
mode: 'TRAIN',
remainingByTypeId: new Map([[nw6.id, 2]]),
codesByTypeId: new Map([[nw6.id, nw6.code]]),
},
legs: legs([
['EXPORT-1', { from: 1, to: 2 }],
['INTERCITY-1', { from: 0, to: 1 }],
]),
edgeCount: 2,
});
expect(result.plan).toHaveLength(2);
const bookingsPerSlot = result.plan.map((s) =>
[...new Set(s.allocations.map((a) => a.bookingId))].sort(),
);
expect(bookingsPerSlot).toEqual([['EXPORT-1'], ['INTERCITY-1']]);
});
it('behaves exactly like the whole-route planner when no legs are given', () => {
const result = planWagonsWithStock({
bookings: [
containerBooking('EXPORT-1', 1, 1),
containerBooking('INTERCITY-1', 1, 1),
],
allowed,
stock: {
mode: 'TRAIN',
remainingByTypeId: new Map([[nw6.id, 1]]),
codesByTypeId: new Map([[nw6.id, nw6.code]]),
},
});
// One wagon, two 20ft bookings: they TEU-share the single slot (legacy).
expect(result.deferred).toHaveLength(0);
expect(result.plan).toHaveLength(1);
});
});