diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 387674dbc..dc7283c37 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -6925,6 +6925,42 @@ export class TrainSchedulingService { reverseWagonOrder: schedule.reverseWagonOrder, }); + // Heaviest-edge consist usage. Cross-leg slot sharing means plain sums + // over-report a multi-stop train — a wagon reused Gelan→Adama and + // Adama→Doraleh is two slots but ONE physical wagon, and the train is never + // heavier/longer than its heaviest single leg. Same math as the pull-limit + // enforcement; coupled-but-empty consist wagons ride every edge. + const heaviestLeg = schedule.trainSet + ? (() => { + const usage = maxEdgeConsistUsage( + [ + ...(schedule.trainSet.wagons ?? []).map((w) => ({ + lengthMeters: Number(w.lengthMeters), + tareWeightTons: w.wagonType + ? Number(w.wagonType.tareWeightTons) + : 0, + assignedWeightTons: Number(w.assignedWeightTons), + boardYardId: w.boardYardId ?? null, + alightYardId: w.alightYardId ?? null, + allocations: w.allocations ?? [], + })), + ...emptyConsistWagons.map((w) => ({ + lengthMeters: w.lengthMeters, + tareWeightTons: Number(w.tareWeightTons ?? 0), + assignedWeightTons: 0, + allocations: [], + })), + ], + this.mapScheduleStops(schedule).map((s) => s.yardId), + ); + return { + grossWeightTons: roundTons(usage.grossWeightTons), + lengthMeters: roundTons(usage.lengthMeters), + loadedWagonCount: usage.loadedWagonCount, + }; + })() + : null; + return { id: schedule.id, reference: schedule.reference ?? null, @@ -6997,6 +7033,9 @@ export class TrainSchedulingService { wagonCount: schedule.trainSet.wagonCount, totalWeightTons: roundTons(Number(schedule.trainSet.totalWeightTons)), totalLengthMeters: roundTons(Number(schedule.trainSet.totalLengthMeters)), + // What the locomotives actually haul: usage on the corridor's + // heaviest edge, not the sum of every leg's slots. + heaviestLeg, locomotive: schedule.trainSet.locomotive ? { id: schedule.trainSet.locomotive.id, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.spec.ts index a7d430b91..43c9d0f31 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.spec.ts @@ -308,6 +308,7 @@ describe('maxEdgeConsistUsage — the binding edge, not the whole-route sum', () expect(maxEdgeConsistUsage(plan, stops)).toEqual({ grossWeightTons: 89, lengthMeters: 14, + loadedWagonCount: 1, }); }); @@ -328,6 +329,7 @@ describe('maxEdgeConsistUsage — the binding edge, not the whole-route sum', () expect(maxEdgeConsistUsage(plan, ['a', 'b'])).toEqual({ grossWeightTons: 178, lengthMeters: 28, + loadedWagonCount: 2, }); }); }); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts index 2b88bb903..a59e87950 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts @@ -565,9 +565,22 @@ export function validateMixedTrainLimitsPerEdge( return [...violations]; } +/** + * The slot fields per-edge usage math actually reads — lets callers feed + * persisted TrainSetWagon rows (or any structural subset), not only plan slots. + */ +export type EdgeUsageSlot = Pick< + WagonPlanSlot, + 'lengthMeters' | 'tareWeightTons' | 'assignedWeightTons' +> & { + boardYardId?: string | null; + alightYardId?: string | null; + allocations?: unknown[]; +}; + /** Per-slot stop-index spans; a yard missing from the stop list keeps the slot on the whole route. */ function slotSpans( - wagonPlan: WagonPlanSlot[], + wagonPlan: EdgeUsageSlot[], stops: string[], ): Array<{ from: number; to: number }> { const lastIdx = stops.length - 1; @@ -587,26 +600,28 @@ function slotSpans( * Two stops or fewer degrade to the whole-train totals. */ export function maxEdgeConsistUsage( - wagonPlan: WagonPlanSlot[], + wagonPlan: EdgeUsageSlot[], stops: string[], -): { grossWeightTons: number; lengthMeters: number } { - const totals = (slots: WagonPlanSlot[]) => ({ +): { grossWeightTons: number; lengthMeters: number; loadedWagonCount: number } { + const totals = (slots: EdgeUsageSlot[]) => ({ grossWeightTons: slots.reduce( (sum, w) => sum + Number(w.tareWeightTons ?? 0) + Number(w.assignedWeightTons ?? 0), 0, ), lengthMeters: slots.reduce((sum, w) => sum + Number(w.lengthMeters ?? 0), 0), + loadedWagonCount: slots.filter((w) => (w.allocations?.length ?? 1) > 0).length, }); if (stops.length <= 2) return totals(wagonPlan); const spans = slotSpans(wagonPlan, stops); - const usage = { grossWeightTons: 0, lengthMeters: 0 }; + const usage = { grossWeightTons: 0, lengthMeters: 0, loadedWagonCount: 0 }; for (let edge = 0; edge < stops.length - 1; edge += 1) { const active = totals( wagonPlan.filter((_, i) => spans[i].from <= edge && edge < spans[i].to), ); usage.grossWeightTons = Math.max(usage.grossWeightTons, active.grossWeightTons); usage.lengthMeters = Math.max(usage.lengthMeters, active.lengthMeters); + usage.loadedWagonCount = Math.max(usage.loadedWagonCount, active.loadedWagonCount); } return usage; } diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/TrainCompositionDiagram.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/TrainCompositionDiagram.tsx index ee09b0a6e..8f98b337a 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/TrainCompositionDiagram.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/TrainCompositionDiagram.tsx @@ -559,11 +559,14 @@ export function TrainCompositionDiagram({ // ceiling the allocation engine spends from. const totalTare = normalized.reduce((s, w) => s + w.tareWeightTons, 0); const grossWeight = totalWeight + totalTare; - // Weakest locomotive caps the set — same rule the allocation engine applies. + // Engine rule (combinedLocomotiveLimits): coupled locomotives pull + // TOGETHER, so their pull limits SUM. const pullLimits = locos .map((l) => Number(l.maxPullWeightTons)) .filter((v) => Number.isFinite(v) && v > 0); - const pullLimit = pullLimits.length ? Math.min(...pullLimits) : null; + const pullLimit = pullLimits.length + ? pullLimits.reduce((sum, v) => sum + v, 0) + : null; return { total: normalized.length, assigned, diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/TrainConsistView.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/TrainConsistView.tsx index 8ce8bba12..299edec03 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/TrainConsistView.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/TrainConsistView.tsx @@ -132,17 +132,24 @@ export const TrainConsistView = ({ 0, ); const tareUsed = wagons.reduce((sum, w) => sum + (w.tareWeightTons ?? 0), 0); - const weightUsed = cargoUsed + tareUsed; - const lengthUsed = wagons.reduce((sum, w) => sum + (w.lengthMeters ?? 0), 0); + // Prefer the server's heaviest-edge figures: on a multi-stop corridor a + // wagon reused across legs is several slots but one physical wagon, so the + // plain sums over-report the train against the pull/length/slot caps. + const heaviest = trainSet?.heaviestLeg; + const weightUsed = heaviest?.grossWeightTons ?? cargoUsed + tareUsed; + const lengthUsed = + heaviest?.lengthMeters ?? wagons.reduce((sum, w) => sum + (w.lengthMeters ?? 0), 0); + const wagonsUsed = heaviest?.loadedWagonCount ?? loadedCount; - // Weakest locomotive caps the set — same rule the allocation engine applies. + // Engine rule (combinedLocomotiveLimits): coupled locomotives pull TOGETHER, + // so pull caps SUM; the track doesn't lengthen, so the length cap is the MIN. const locos = trainSet?.locomotives?.length ? trainSet.locomotives : trainSet?.locomotive ? [trainSet.locomotive] : []; const weightMax = locos.length - ? Math.min(...locos.map((l) => l.maxPullWeightTons)) + ? locos.reduce((sum, l) => sum + l.maxPullWeightTons, 0) : null; const lengthCaps = locos .map((l) => l.maxTrainLengthMeters) @@ -156,7 +163,7 @@ export const TrainConsistView = ({ weightMax={weightMax} lengthUsed={lengthUsed} lengthMax={lengthMax} - wagonCount={loadedCount} + wagonCount={wagonsUsed} wagonMax={maxWagons} /> diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx index f0761293a..d87fa50b8 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx @@ -1103,19 +1103,31 @@ export default function TrainScheduleV2DetailPage() { }, { label: "Wagons / load", - // Gross: cargo load + the tare of every wagon in the consist — the - // weight the locomotive actually hauls. - value: `${schedule.trainSet?.wagonCount ?? displayWagonPlan.length} · ${ - Math.round( - ((schedule.trainSet?.totalWeightTons ?? 0) + - (schedule.trainSet?.wagons ?? []).reduce( - (sum, w) => sum + (Number(w.tareWeightTons) || 0), - 0, - )) * - 100, - ) / 100 - }T`, - hint: "gross · wagon tare + cargo", + // Gross: cargo load + wagon tare — the weight the locomotives + // actually haul. Heaviest corridor edge when the server sends it; + // plain consist sums over-report a multi-stop train (cross-leg + // wagon sharing counts one physical wagon as several slots). + value: (() => { + const heaviest = schedule.trainSet?.heaviestLeg; + const wagonCount = + heaviest?.loadedWagonCount ?? + schedule.trainSet?.wagonCount ?? + displayWagonPlan.length; + const grossTons = + heaviest?.grossWeightTons ?? + Math.round( + ((schedule.trainSet?.totalWeightTons ?? 0) + + (schedule.trainSet?.wagons ?? []).reduce( + (sum, w) => sum + (Number(w.tareWeightTons) || 0), + 0, + )) * + 100, + ) / 100; + return `${wagonCount} · ${grossTons}T`; + })(), + hint: schedule.trainSet?.heaviestLeg + ? "heaviest leg · wagon tare + cargo" + : "gross · wagon tare + cargo", icon: Weight, }, { diff --git a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts index 9cc210cc9..2dc7662b4 100644 --- a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts +++ b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts @@ -608,6 +608,16 @@ export interface TrainScheduleDetail { wagonCount: number; totalWeightTons: number; totalLengthMeters: number; + /** + * Usage on the corridor's heaviest edge — what the locomotives actually + * haul. Cross-leg wagon sharing makes plain slot sums over-report a + * multi-stop train. Null without a train set. + */ + heaviestLeg?: { + grossWeightTons: number; + lengthMeters: number; + loadedWagonCount: number; + } | null; locomotive?: { id: string; code: string;