mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 20:40:55 +00:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user