fix(train-scheduling): drop wagon slots with no physical wagon pinned from marshalling docs

A slot can hold a LOADED allocation with no physical wagon backing it
— a fleet shortfall can leave a booking's allocation unpinned, and a
REAL cut nulls physical_wagon_id without ever touching the slot's own
status. Neither buildImportLoadListHtml, buildExportLoadListHtml, nor
intercityOnBoardView checked for this: they rendered a ghost row (dash
wagon number, but cargo/container info still listed) and counted it
toward the Wagons tile.

Reproduced live on S-2026-00073: 5 slots (seq 60-64) with no physical
wagon, from a booking currently held out for lack of fleet, rendered
as phantom rows on the origin doc and every numbered marshalling doc
— visible as the Seq column running up to 64 despite only 54 real
wagons.

All three now additionally require physicalWagonId (or a resolved
wagonNumber, for the import doc's already-flattened shape) before a
slot gets a row. Added coverage for all three call sites.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Hagernesh
2026-08-29 10:43:34 +00:00
parent 7df96d3609
commit f23500c273
2 changed files with 108 additions and 2 deletions

View File

@@ -3602,6 +3602,13 @@ export class TrainSchedulingService {
const wagons = (schedule.trainSet?.wagons ?? [])
.filter((wagon) => {
if (wagon.status === 'DEPARTED') return false;
// No physical wagon pinned to the slot — a booking can hold an
// allocation before a real wagon backs it (e.g. a fleet shortfall
// left it unpinned). There is nothing physical here to marshal, and
// a REAL cut also lands here: it nulls physicalWagonId without ever
// touching this slot's own status, so a cut wagon would otherwise
// linger as a phantom row with its cargo still listed.
if (!wagon.physicalWagonId) return false;
const hasLoaded = (wagon.allocations ?? []).some((a) => a.status === 'LOADED');
return wagon.boardYardId == null || hasLoaded;
})
@@ -3930,6 +3937,11 @@ export class TrainSchedulingService {
// Their own coupling shows up on THAT stop's own marshalling document.
const wagons = [...(opts?.wagons ?? schedule.trainSet?.wagons ?? [])]
.filter((wagon) => !opts?.pendingBoardYardLabelBySlot?.get(wagon.id))
// No physical wagon pinned to the slot (fleet shortfall left a booking's
// allocation unpinned, or a REAL cut nulled it out): nothing physical
// to marshal, so no row. Harmless no-op for the numbered docs, whose
// wagons list already went through intercityOnBoardView's own check.
.filter((wagon) => Boolean(wagon.physicalWagonId))
.sort((a, b) => Number(a.sequenceNo ?? 0) - Number(b.sequenceNo ?? 0));
// Empties sit on wagons that carry no booking allocation, keyed by the wagon
// slot recorded when they were loaded.
@@ -4323,8 +4335,11 @@ export class TrainSchedulingService {
// A leg slot (boardYard set) couples mid-corridor — it is not part of the
// consist this Djibouti-side document is checked against yet, so it gets
// no row and no count here at all. Its own coupling shows up on THAT
// stop's own marshalling document once it actually happens.
const wagons = loadList.wagons.filter((wagon) => !wagon.boardYard);
// stop's own marshalling document once it actually happens. Same for a
// slot with no physical wagon pinned at all — a booking can hold an
// allocation before a real wagon backs it (fleet shortfall), or a REAL
// cut nulled it out; either way there is nothing physical to marshal.
const wagons = loadList.wagons.filter((wagon) => !wagon.boardYard && wagon.wagonNumber != null);
const totalAllocations = wagons.reduce((sum, wagon) => sum + wagon.allocations.length, 0);
const totalWeight = wagons.reduce(
(sum, wagon) => sum + wagon.allocations.reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0),