feat(train-scheduling): mid-route consist changes, audit history, safer workspace

- planned couples: loose wagons join the train at a route stop, added
  from the schedule yards tab; capacity credits them per corridor edge
  and coupling validates locomotive weight/length caps per leg
- real-cut toggle: a cut wagon permanently leaves the train build at
  its cut yard (soft cut still sits out one trip only)
- fix heaviest-leg display counting a shared slot's full cargo on
  every spanned edge (phantom pull-weight overload on S-2026-00045)
- confirmation dialogs for workspace add/load/unload/remove actions
- train-builder History and Detached-wagons tabs, backed by paginated
  endpoints; builder detaches now always write adjustment-log rows

Migrations 3660 (planned_wagon_couples, planned_wagon_real_cuts) and
3670 (adjustment log train_schedule_id nullable) — both applied to the
dev DB by hand; watch mode does not run migrations.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Marshal
2026-08-23 03:55:54 +00:00
parent ba89b670c8
commit 8e6fc09aac
34 changed files with 2532 additions and 202 deletions

View File

@@ -14,6 +14,7 @@ import {
sumWagonsRequired,
validate20ftContainerRules,
validateContainerPlacements,
validateMixedTrainLimitsPerEdge,
validateWagonCargoExclusivity,
} from './wagon-plan.util';
@@ -412,4 +413,91 @@ describe('maxEdgeConsistUsage — the binding edge, not the whole-route sum', ()
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);
});
});