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);
});
});

View File

@@ -654,9 +654,16 @@ export function validateMixedTrainLimitsPerEdge(
const label = (i: number) => stopLabels?.[i] ?? stops[i];
const violations = new Set<string>();
for (let edge = 0; edge < stops.length - 1; edge += 1) {
const active = wagonPlan.filter(
(_, i) => spans[i].from <= edge && edge < spans[i].to,
);
// A shared slot rides the UNION of its cargo legs, but only carries each
// booking's cargo on that booking's own edges — weigh the edge with the
// cargo actually aboard there, not the slot's whole-route scalar, or a
// container boarding at Dire Dawa reads as hauled from Djibouti.
const active = wagonPlan
.filter((_, i) => spans[i].from <= edge && edge < spans[i].to)
.map((slot) => ({
...slot,
assignedWeightTons: slotCargoOnEdge(slot, edge, edges, legs),
}));
if (!active.length) continue;
for (const violation of validateMixedTrainLimits(
active,
@@ -684,6 +691,40 @@ export type EdgeUsageSlot = Pick<
allocations?: unknown[];
};
/**
* Cargo tons a slot actually carries on one edge. With a legs map and readable
* allocation records, each booking's cargo counts only on the edges that
* booking rides (an unmapped booking stays on the slot's whole span). Without
* either — or when any allocation lacks a numeric weight, e.g. persisted rows
* fed through {@link EdgeUsageSlot} — falls back to the slot's whole-span
* `assignedWeightTons`, the pre-existing reading.
*/
function slotCargoOnEdge(
slot: EdgeUsageSlot,
edge: number,
edgeCount: number,
legs?: Map<string, { from: number; to: number }>,
): number {
const wholeSpanCargo = Number(slot.assignedWeightTons ?? 0);
const allocations = (slot.allocations ?? []) as Array<{
bookingId?: string;
allocatedWeightTons?: number | string;
}>;
if (!legs?.size || !allocations.length) return wholeSpanCargo;
let cargo = 0;
for (const allocation of allocations) {
const weight = Number(allocation?.allocatedWeightTons);
if (!Number.isFinite(weight)) return wholeSpanCargo;
const leg = allocation.bookingId ? legs.get(allocation.bookingId) : undefined;
const rides =
!leg || leg.from < 0 || leg.to > edgeCount || leg.from >= leg.to
? true
: leg.from <= edge && edge < leg.to;
if (rides) cargo += weight;
}
return cargo;
}
/** Per-slot stop-index spans; a yard missing from the stop list keeps the slot on the whole route. */
function slotSpans(
wagonPlan: EdgeUsageSlot[],
@@ -708,8 +749,10 @@ function slotSpans(
export function maxEdgeConsistUsage(
wagonPlan: EdgeUsageSlot[],
stops: string[],
/** Booking id → stop-index span; cargo then weighs only its own edges. */
legs?: Map<string, { from: number; to: number }>,
): { grossWeightTons: number; lengthMeters: number; loadedWagonCount: number } {
return perEdgeConsistUsage(wagonPlan, stops).reduce(
return perEdgeConsistUsage(wagonPlan, stops, legs).reduce(
(max, e) => ({
grossWeightTons: Math.max(max.grossWeightTons, e.grossWeightTons),
lengthMeters: Math.max(max.lengthMeters, e.lengthMeters),
@@ -737,12 +780,19 @@ export type EdgeConsistUsage = {
export function perEdgeConsistUsage(
wagonPlan: EdgeUsageSlot[],
stops: string[],
/**
* Booking id → stop-index span. When given, a shared slot's cargo weighs
* only the edges its booking rides (tare still rides the slot's whole
* span) — without it a slot's full cargo counts on every edge it spans.
*/
legs?: Map<string, { from: number; to: number }>,
): EdgeConsistUsage[] {
const edgeCount = Math.max(1, stops.length - 1);
const totals = (edge: number, slots: EdgeUsageSlot[]): EdgeConsistUsage => ({
edge,
grossWeightTons: slots.reduce(
(sum, w) =>
sum + Number(w.tareWeightTons ?? 0) + Number(w.assignedWeightTons ?? 0),
sum + Number(w.tareWeightTons ?? 0) + slotCargoOnEdge(w, edge, edgeCount, legs),
0,
),
lengthMeters: slots.reduce((sum, w) => sum + Number(w.lengthMeters ?? 0), 0),