mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-03 07:23:40 +00:00
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:
@@ -1062,6 +1062,7 @@ describe('TrainSchedulingService', () => {
|
||||
const makeWagon = (sequenceNo: number, wagonNumber: string, allocations: unknown[]) => ({
|
||||
sequenceNo,
|
||||
wagonNumber,
|
||||
physicalWagonId: `wagon-id-${wagonNumber}`,
|
||||
physicalWagon: { wagonNumber },
|
||||
wagonType: { code: 'NW5', name: 'Flat Wagon', tareWeightTons: 22 },
|
||||
lengthMeters: 14,
|
||||
@@ -1185,6 +1186,57 @@ describe('TrainSchedulingService', () => {
|
||||
expect(html).toContain('<span>Total containers</span><strong>1</strong>');
|
||||
});
|
||||
|
||||
it('drops a slot with no physical wagon pinned from the import document too', () => {
|
||||
const loadList = {
|
||||
generatedAt: '2026-07-17T08:00:00.000Z',
|
||||
trainScheduleId: 'schedule-1',
|
||||
trainNumber: '7002',
|
||||
route: 'DCT/SGTD → GMP',
|
||||
origin: 'DCT/SGTD',
|
||||
destination: 'GMP',
|
||||
totalBookings: 2,
|
||||
wagons: [
|
||||
{
|
||||
sequenceNo: 1,
|
||||
// No physical wagon pinned (fleet shortfall, or a REAL cut nulled
|
||||
// it out) — nothing physical to marshal, even though the slot
|
||||
// still carries a LOADED allocation.
|
||||
wagonNumber: null,
|
||||
boardYard: null,
|
||||
alightYard: null,
|
||||
allocations: [
|
||||
{
|
||||
...loadedAllocation,
|
||||
containerItems: [{ containerNumber: 'GHOST-001' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
sequenceNo: 2,
|
||||
wagonNumber: 'W-IMP',
|
||||
boardYard: null,
|
||||
alightYard: null,
|
||||
allocations: [
|
||||
{
|
||||
...loadedAllocation,
|
||||
containerItems: [{ containerNumber: 'CONT-001', containerType: { sizeFt: 20 } }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
operation: { status: {} },
|
||||
};
|
||||
|
||||
const html = (service as never as {
|
||||
buildImportLoadListHtml: (l: unknown) => string;
|
||||
}).buildImportLoadListHtml(loadList);
|
||||
|
||||
expect(html).not.toContain('GHOST-001');
|
||||
expect(html).toContain('W-IMP');
|
||||
expect(html).toContain('<span>Wagons</span><strong>1</strong>');
|
||||
expect(html).toContain('<span>Total containers</span><strong>1</strong>');
|
||||
});
|
||||
|
||||
it('drops a leg slot entirely from the export document — not part of the departing consist', () => {
|
||||
const sizedAllocation = {
|
||||
...loadedAllocation,
|
||||
@@ -1211,6 +1263,29 @@ describe('TrainSchedulingService', () => {
|
||||
expect(html).toContain('<span>Total containers</span><strong>1</strong>');
|
||||
});
|
||||
|
||||
it('drops a whole-route slot with no physical wagon pinned from the export document too', () => {
|
||||
const sizedAllocation = {
|
||||
...loadedAllocation,
|
||||
containerItems: [{ containerNumber: 'CONT-001', containerType: { sizeFt: 20 } }],
|
||||
};
|
||||
const ghost = { ...makeWagon(2, 'W-GHOST', [sizedAllocation]), id: 'slot-ghost', physicalWagonId: null };
|
||||
const schedule = {
|
||||
id: 'schedule-1',
|
||||
trainNumber: '8302',
|
||||
direction: 'EXPORT',
|
||||
trainSet: { wagons: [{ ...makeWagon(1, 'W-001', [sizedAllocation]), id: 'slot-1' }, ghost] },
|
||||
scheduleBookings: [],
|
||||
};
|
||||
|
||||
const html = (service as never as {
|
||||
buildExportLoadListHtml: (s: unknown, o?: unknown) => string;
|
||||
}).buildExportLoadListHtml(schedule, {});
|
||||
|
||||
expect(html).not.toContain('W-GHOST');
|
||||
expect(html).toContain('<span>Wagons</span><strong>1</strong>');
|
||||
expect(html).toContain('<span>Total containers</span><strong>1</strong>');
|
||||
});
|
||||
|
||||
it('prints the consist-changes table for this stop, and omits it when there are none', () => {
|
||||
const schedule = {
|
||||
id: 'schedule-1',
|
||||
@@ -1444,6 +1519,22 @@ describe('TrainSchedulingService', () => {
|
||||
expect(numbers).toEqual(['W-LEG2']);
|
||||
});
|
||||
|
||||
it('drops a whole-route slot with no physical wagon pinned, even though its allocation is LOADED', () => {
|
||||
// A booking can hold a LOADED allocation before a real wagon backs it
|
||||
// (fleet shortfall left the slot unpinned), or a REAL cut nulls
|
||||
// physicalWagonId without ever touching the slot's own status. Either
|
||||
// way there is no physical wagon standing there to marshal.
|
||||
const pinned = makeWagon(1, 'W-001', [allocWith({ status: 'LOADED' })]);
|
||||
const ghost = { ...makeWagon(2, 'W-002', [allocWith({ status: 'LOADED' })]), physicalWagonId: null };
|
||||
const schedule = { trainSet: { wagons: [pinned, ghost] }, scheduleBookings: [] };
|
||||
|
||||
const { wagons } = onBoardView(schedule);
|
||||
const numbers = (wagons as Array<{ physicalWagon: { wagonNumber: string } }>).map(
|
||||
(w) => w.physicalWagon.wagonNumber,
|
||||
);
|
||||
expect(numbers).toEqual(['W-001']);
|
||||
});
|
||||
|
||||
it('drops a leg slot LOADED by generation time but not yet coupled as of this stop', () => {
|
||||
// Both W-DIRE (coupled+loaded at Dire Dawa) and W-ADAMA (coupled+loaded
|
||||
// at Adama, a LATER stop) read identically to intercityOnBoardView by
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user