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.
This commit is contained in:
Marshal
2026-07-21 23:02:06 +00:00
parent 00a81fda15
commit 835c9e111c
35 changed files with 1896 additions and 154 deletions

View File

@@ -187,3 +187,115 @@ describe('applyWagonOrderReversal', () => {
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);
});
});