fix(train-scheduling): drop not-yet-boarded wagons from every marshalling doc

Leg-slot wagons planned to couple at a LATER stop were being counted
into the Wagons/Allocations/Total containers tiles on the ORIGIN
marshalling document (Import Load List, export load list) — inflating
the departing count from 54 to 64 on a real train, plus a 'TO BE
LOADED AT X' / 'TO LOAD AT X' row and a separate 'To load en route'
tally to work around it.

Filters those slots out of buildImportLoadListHtml / buildExportLoadListHtml
entirely instead: a wagon not part of the departing consist gets no
row and no count on this document, full stop. Its own coupling shows
up on THAT stop's own numbered marshalling document once it actually
happens (intercityOnBoardView already filtered correctly there,
unaffected). Deletes the now-dead pendingBoardYardLabelBySlot special-
casing, loadsHere guards, and the 'to load en route' tile.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Hagernesh
2026-08-29 09:21:17 +00:00
parent fdcc69c43e
commit 80a057dde8
2 changed files with 41 additions and 51 deletions

View File

@@ -1134,7 +1134,7 @@ describe('TrainSchedulingService', () => {
expect(html).toContain('2 (1 empty)'); expect(html).toContain('2 (1 empty)');
}); });
it('marks a leg slot on the import document as TO BE LOADED and keeps it out of the loaded tallies', () => { it('drops a leg slot entirely from the import document — not part of the departing consist', () => {
const loadList = { const loadList = {
generatedAt: '2026-07-17T08:00:00.000Z', generatedAt: '2026-07-17T08:00:00.000Z',
trainScheduleId: 'schedule-1', trainScheduleId: 'schedule-1',
@@ -1176,15 +1176,16 @@ describe('TrainSchedulingService', () => {
buildImportLoadListHtml: (l: unknown) => string; buildImportLoadListHtml: (l: unknown) => string;
}).buildImportLoadListHtml(loadList); }).buildImportLoadListHtml(loadList);
expect(html).toContain('TO BE LOADED AT DIRE DAWA PORT'); // The leg slot (W-ICY, boards later at Dire Dawa) gets no row at all —
// Departure station of the leg slot is its board yard, not the origin. // it isn't on the departing consist. Only W-IMP appears.
expect(html).toContain('<td>Dire Dawa Port</td>'); expect(html).not.toContain('W-ICY');
// Only the origin-loaded container counts; the leg slot's tallies separately. expect(html).not.toContain('ICY-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>'); expect(html).toContain('<span>Total containers</span><strong>1</strong>');
expect(html).toContain('<span>To load en route</span><strong>1 containers</strong>');
}); });
it('marks a leg slot on the export document as TO LOAD AT its board yard and keeps it out of the tallies', () => { it('drops a leg slot entirely from the export document — not part of the departing consist', () => {
const sizedAllocation = { const sizedAllocation = {
...loadedAllocation, ...loadedAllocation,
containerItems: [{ containerNumber: 'CONT-001', containerType: { sizeFt: 20 } }], containerItems: [{ containerNumber: 'CONT-001', containerType: { sizeFt: 20 } }],
@@ -1204,9 +1205,10 @@ describe('TrainSchedulingService', () => {
pendingBoardYardLabelBySlot: new Map([['slot-leg', 'Dire Dawa Port']]), pendingBoardYardLabelBySlot: new Map([['slot-leg', 'Dire Dawa Port']]),
}); });
expect(html).toContain('TO LOAD AT DIRE DAWA PORT'); // The leg slot (W-LEG, boards later at Dire Dawa) gets no row at all.
expect(html).not.toContain('W-LEG');
expect(html).toContain('<span>Wagons</span><strong>1</strong>');
expect(html).toContain('<span>Total containers</span><strong>1</strong>'); expect(html).toContain('<span>Total containers</span><strong>1</strong>');
expect(html).toContain('<span>To load en route</span><strong>1 containers</strong>');
}); });
it('prints the consist-changes table for this stop, and omits it when there are none', () => { it('prints the consist-changes table for this stop, and omits it when there are none', () => {

View File

@@ -3866,10 +3866,15 @@ export class TrainSchedulingService {
const time = (value: unknown) => (value ? new Date(value as string | Date).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }) : '-'); const time = (value: unknown) => (value ? new Date(value as string | Date).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }) : '-');
const bookingById = new Map((schedule.scheduleBookings ?? []).map((link) => [link.bookingId, link.booking])); const bookingById = new Map((schedule.scheduleBookings ?? []).map((link) => [link.bookingId, link.booking]));
// The document is checked against the physical train, so it has to run in // The document is checked against the physical train, so it has to run in
// consist order — the relation comes back unordered. // consist order — the relation comes back unordered. Slots planned to
const wagons = [...(opts?.wagons ?? schedule.trainSet?.wagons ?? [])].sort( // couple at a LATER stop (pendingBoardYardLabelBySlot, origin docs only —
(a, b) => Number(a.sequenceNo ?? 0) - Number(b.sequenceNo ?? 0), // intercity calls never pass it, their wagons list is already on-board
); // only) are dropped here, not just tallied around: they are not part of
// the departing consist, so they get no row and no count on this document.
// 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))
.sort((a, b) => Number(a.sequenceNo ?? 0) - Number(b.sequenceNo ?? 0));
// Empties sit on wagons that carry no booking allocation, keyed by the wagon // Empties sit on wagons that carry no booking allocation, keyed by the wagon
// slot recorded when they were loaded. // slot recorded when they were loaded.
const emptiesByWagon = new Map<number, EmptyContainerReturn[]>(); const emptiesByWagon = new Map<number, EmptyContainerReturn[]>();
@@ -3916,7 +3921,6 @@ export class TrainSchedulingService {
</tr>`, </tr>`,
]; ];
} }
const pendingAt = opts?.pendingBoardYardLabelBySlot?.get(wagon.id);
return allocations.map((allocation) => { return allocations.map((allocation) => {
const booking = allocation.booking ?? bookingById.get(allocation.bookingId); const booking = allocation.booking ?? bookingById.get(allocation.bookingId);
const cargoType = (booking as unknown as { cargoType?: { name?: string; code?: string } } | undefined)?.cargoType; const cargoType = (booking as unknown as { cargoType?: { name?: string; code?: string } } | undefined)?.cargoType;
@@ -3928,7 +3932,7 @@ export class TrainSchedulingService {
const chassisNumbers = containerItems.map((item) => item.chassisNumber).filter(Boolean).join(', '); const chassisNumbers = containerItems.map((item) => item.chassisNumber).filter(Boolean).join(', ');
return `<tr> return `<tr>
${wagonCells} ${wagonCells}
<td>${pendingAt ? `TO LOAD AT ${esc(pendingAt).toUpperCase()}` : ''}${esc(cargoType?.name ?? cargoType?.code ?? allocation.loadType)}</td> <td>${esc(cargoType?.name ?? cargoType?.code ?? allocation.loadType)}</td>
<td>${esc(companyName)}</td> <td>${esc(companyName)}</td>
<td>${esc(containerNumbers || firstContainer?.containerNumber)}</td> <td>${esc(containerNumbers || firstContainer?.containerNumber)}</td>
<td>${esc(chassisNumbers)}</td> <td>${esc(chassisNumbers)}</td>
@@ -3965,27 +3969,20 @@ export class TrainSchedulingService {
(wagon.allocations ?? []).length === 0 && (wagon.allocations ?? []).length === 0 &&
!emptiesByWagon.get(Number(wagon.sequenceNo))?.length, !emptiesByWagon.get(Number(wagon.sequenceNo))?.length,
).length; ).length;
const loadsHere = (wagon: TrainSetWagon) => !opts?.pendingBoardYardLabelBySlot?.get(wagon.id);
const totalWeight = wagons.reduce( const totalWeight = wagons.reduce(
(sum, wagon) => (sum, wagon) =>
sum + sum + (wagon.allocations ?? []).reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0),
(loadsHere(wagon)
? (wagon.allocations ?? []).reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0)
: 0),
0, 0,
); );
// Container count summary (40ft, 20ft) — empties returning to Djibouti are // Container count summary (40ft, 20ft) — empties returning to Djibouti are
// physically on the train, so they count, and are called out on their own tile. // physically on the train, so they count, and are called out on their own
// Cargo boarding downstream is not on this train yet — it tallies separately. // tile. Cargo boarding downstream never enters this loop — `wagons` above
let count40ft = 0, count20ft = 0, pendingContainers = 0; // already excludes those slots.
let count40ft = 0, count20ft = 0;
wagons.forEach((wagon) => { wagons.forEach((wagon) => {
(wagon.allocations ?? []).forEach((allocation) => { (wagon.allocations ?? []).forEach((allocation) => {
(allocation.containerItems ?? []).forEach((item) => { (allocation.containerItems ?? []).forEach((item) => {
if (!loadsHere(wagon)) {
pendingContainers++;
return;
}
const size = this.resolveContainerItemSize(item); const size = this.resolveContainerItemSize(item);
if (size === 40) count40ft++; if (size === 40) count40ft++;
else if (size === 20) count20ft++; else if (size === 20) count20ft++;
@@ -4053,7 +4050,6 @@ export class TrainSchedulingService {
<div class="tile"><span>Containers 40ft</span><strong>${esc(count40ft)}</strong></div> <div class="tile"><span>Containers 40ft</span><strong>${esc(count40ft)}</strong></div>
<div class="tile"><span>Containers 20ft</span><strong>${esc(count20ft)}</strong></div> <div class="tile"><span>Containers 20ft</span><strong>${esc(count20ft)}</strong></div>
<div class="tile"><span>Total containers</span><strong>${esc(count40ft + count20ft)}</strong></div> <div class="tile"><span>Total containers</span><strong>${esc(count40ft + count20ft)}</strong></div>
${pendingContainers ? `<div class="tile"><span>To load en route</span><strong>${esc(pendingContainers)} containers</strong></div>` : ''}
${emptyContainers.length ? `<div class="tile"><span>Empty containers</span><strong>${esc(emptyContainers.length)}</strong></div>` : ''} ${emptyContainers.length ? `<div class="tile"><span>Empty containers</span><strong>${esc(emptyContainers.length)}</strong></div>` : ''}
<div class="tile"><span>Prepared person</span><strong>${esc(schedule.preparedByUserId)}</strong></div> <div class="tile"><span>Prepared person</span><strong>${esc(schedule.preparedByUserId)}</strong></div>
<div class="tile"><span>Check person</span><strong>${esc(schedule.checkedByUserId)}</strong></div> <div class="tile"><span>Check person</span><strong>${esc(schedule.checkedByUserId)}</strong></div>
@@ -4253,30 +4249,23 @@ export class TrainSchedulingService {
.replace(/'/g, '&#39;'); .replace(/'/g, '&#39;');
const date = (value: unknown) => (value ? new Date(value as string | Date).toLocaleString('en-GB') : '-'); const date = (value: unknown) => (value ? new Date(value as string | Date).toLocaleString('en-GB') : '-');
const status = loadList.operation.status; const status = loadList.operation.status;
// A leg slot (boardYard set) couples mid-corridor — its cargo is NOT on the // A leg slot (boardYard set) couples mid-corridor — it is not part of the
// physical train this Djibouti-side document is checked against, so it must // consist this Djibouti-side document is checked against yet, so it gets
// stay out of the loaded tallies or the gate count stops matching. // no row and no count here at all. Its own coupling shows up on THAT
const loadsHere = (wagon: (typeof loadList.wagons)[number]) => !wagon.boardYard; // stop's own marshalling document once it actually happens.
const totalAllocations = loadList.wagons.reduce((sum, wagon) => sum + wagon.allocations.length, 0); const wagons = loadList.wagons.filter((wagon) => !wagon.boardYard);
const totalWeight = loadList.wagons.reduce( const totalAllocations = wagons.reduce((sum, wagon) => sum + wagon.allocations.length, 0);
(sum, wagon) => const totalWeight = wagons.reduce(
sum + (sum, wagon) => sum + wagon.allocations.reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0),
(loadsHere(wagon)
? wagon.allocations.reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0)
: 0),
0, 0,
); );
const emptyWagons = loadList.wagons.filter((wagon) => wagon.allocations.length === 0).length; const emptyWagons = wagons.filter((wagon) => wagon.allocations.length === 0).length;
// Container count summary (40ft, 20ft) — loaded at origin vs. en route // Container count summary (40ft, 20ft)
let count40ft = 0, count20ft = 0, pendingContainers = 0; let count40ft = 0, count20ft = 0;
loadList.wagons.forEach((wagon) => { wagons.forEach((wagon) => {
wagon.allocations.forEach((allocation) => { wagon.allocations.forEach((allocation) => {
(allocation.containerItems ?? []).forEach((item) => { (allocation.containerItems ?? []).forEach((item) => {
if (!loadsHere(wagon)) {
pendingContainers++;
return;
}
const size = this.resolveContainerItemSize(item); const size = this.resolveContainerItemSize(item);
if (size === 40) count40ft++; if (size === 40) count40ft++;
else if (size === 20) count20ft++; else if (size === 20) count20ft++;
@@ -4284,7 +4273,7 @@ export class TrainSchedulingService {
}); });
}); });
const allocationRows = loadList.wagons const allocationRows = wagons
.flatMap((wagon) => { .flatMap((wagon) => {
const wagonCells = `<td>${esc(wagon.sequenceNo)}</td> const wagonCells = `<td>${esc(wagon.sequenceNo)}</td>
<td>${esc(wagon.wagonNumber)}</td> <td>${esc(wagon.wagonNumber)}</td>
@@ -4317,7 +4306,7 @@ export class TrainSchedulingService {
<td>${esc(allocation.loadType)}</td> <td>${esc(allocation.loadType)}</td>
<td>${esc(allocation.containerNumbers.length ? allocation.containerNumbers.join(', ') : '-')}</td> <td>${esc(allocation.containerNumbers.length ? allocation.containerNumbers.join(', ') : '-')}</td>
<td>${esc(sealNumbers || '-')}</td> <td>${esc(sealNumbers || '-')}</td>
<td>${wagon.boardYard ? `TO BE LOADED AT ${esc(wagon.boardYard).toUpperCase()}` : ''}</td> <td></td>
<td class="num">${esc(Number(allocation.allocatedWeightTons || 0).toFixed(3))}</td> <td class="num">${esc(Number(allocation.allocatedWeightTons || 0).toFixed(3))}</td>
</tr>`; </tr>`;
}, },
@@ -4384,13 +4373,12 @@ export class TrainSchedulingService {
<div class="tile"><span>Origin</span><strong>${esc(loadList.origin)}</strong></div> <div class="tile"><span>Origin</span><strong>${esc(loadList.origin)}</strong></div>
<div class="tile"><span>Destination</span><strong>${esc(loadList.destination)}</strong></div> <div class="tile"><span>Destination</span><strong>${esc(loadList.destination)}</strong></div>
<div class="tile"><span>Total bookings</span><strong>${esc(loadList.totalBookings)}</strong></div> <div class="tile"><span>Total bookings</span><strong>${esc(loadList.totalBookings)}</strong></div>
<div class="tile"><span>Wagons</span><strong>${esc(loadList.wagons.length)}${emptyWagons ? ` (${emptyWagons} empty)` : ''}</strong></div> <div class="tile"><span>Wagons</span><strong>${esc(wagons.length)}${emptyWagons ? ` (${emptyWagons} empty)` : ''}</strong></div>
<div class="tile"><span>Allocations</span><strong>${esc(totalAllocations)}</strong></div> <div class="tile"><span>Allocations</span><strong>${esc(totalAllocations)}</strong></div>
<div class="tile"><span>Total weight</span><strong>${esc(totalWeight.toFixed(3))} T</strong></div> <div class="tile"><span>Total weight</span><strong>${esc(totalWeight.toFixed(3))} T</strong></div>
<div class="tile"><span>Containers 40ft</span><strong>${esc(count40ft)}</strong></div> <div class="tile"><span>Containers 40ft</span><strong>${esc(count40ft)}</strong></div>
<div class="tile"><span>Containers 20ft</span><strong>${esc(count20ft)}</strong></div> <div class="tile"><span>Containers 20ft</span><strong>${esc(count20ft)}</strong></div>
<div class="tile"><span>Total containers</span><strong>${esc(count40ft + count20ft)}</strong></div> <div class="tile"><span>Total containers</span><strong>${esc(count40ft + count20ft)}</strong></div>
${pendingContainers ? `<div class="tile"><span>To load en route</span><strong>${esc(pendingContainers)} containers</strong></div>` : ''}
<div class="tile"><span>Gatepass granted</span><strong>${esc(date(loadList.operation.gatepassGrantedAt))}</strong></div> <div class="tile"><span>Gatepass granted</span><strong>${esc(date(loadList.operation.gatepassGrantedAt))}</strong></div>
</div> </div>