From c0fdffa7efd11a9a86318aafe5fcb7036001d08a Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Fri, 17 Jul 2026 14:20:34 +0000 Subject: [PATCH] Marshalling document empty wagon rendering --- .../train-scheduling.service.spec.ts | 116 ++++++++++++++++++ .../train-scheduling.service.ts | 109 +++++++++++----- 2 files changed, 191 insertions(+), 34 deletions(-) diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts index c0ff19b25..af1a9eb45 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts @@ -965,4 +965,120 @@ describe('TrainSchedulingService', () => { expect(result).toHaveLength(2); }); }); + + describe('marshalling documents', () => { + // Staff check these against the physical consist, so every wagon on the + // train set has to appear — an empty wagon that renders no row reads as a + // wagon that is not on the train. + const makeWagon = (sequenceNo: number, wagonNumber: string, allocations: unknown[]) => ({ + sequenceNo, + wagonNumber, + physicalWagon: { wagonNumber }, + wagonType: { code: 'NW5', name: 'Flat Wagon', tareWeightTons: 22 }, + lengthMeters: 14, + capacityTons: 70, + allocations, + }); + + const loadedAllocation = { + bookingId: 'booking-1', + bookingReference: 'BK-2026-000001', + loadType: 'CONTAINER', + allocatedWeightTons: 24.5, + containerNumbers: ['CONT-001'], + booking: { id: 'booking-1', reference: 'BK-2026-000001', companyId: 'company-1' }, + containerItems: [{ containerNumber: 'CONT-001', sealNumber: 'SEAL-1', chassisNumber: 'CH-1' }], + }; + + const countRows = (html: string) => (html.match(/\s* { + const schedule = { + id: 'schedule-1', + trainNumber: '8302', + direction: 'EXPORT', + trainSet: { + wagons: [ + makeWagon(1, 'W-001', [loadedAllocation]), + makeWagon(2, 'W-002', []), + makeWagon(3, 'W-003', []), + ], + }, + scheduleBookings: [], + }; + + const html = (service as never as { + buildExportLoadListHtml: (s: unknown) => string; + }).buildExportLoadListHtml(schedule); + + expect(countRows(html)).toBe(3); + expect(html).toContain('W-002'); + expect(html).toContain('W-003'); + expect(html.match(/EMPTY — no cargo allocated/g)).toHaveLength(2); + // The wagon count must agree with the rows the reader can see. + expect(html).toContain('3 (2 empty)'); + }); + + it('lists an empty wagon on the import document and marks it EMPTY', () => { + const loadList = { + generatedAt: '2026-07-17T08:00:00.000Z', + trainScheduleId: 'schedule-1', + trainNumber: '8002', + route: 'Djibouti → Indode', + origin: 'Djibouti Port', + destination: 'Indode', + totalBookings: 1, + wagons: [ + { sequenceNo: 1, wagonNumber: 'W-001', allocations: [loadedAllocation] }, + { sequenceNo: 2, wagonNumber: 'W-002', allocations: [] }, + ], + operation: { status: {} }, + }; + + const html = (service as never as { + buildImportLoadListHtml: (l: unknown) => string; + }).buildImportLoadListHtml(loadList); + + expect(countRows(html)).toBe(2); + expect(html).toContain('W-002'); + expect(html.match(/EMPTY — no cargo allocated/g)).toHaveLength(1); + expect(html).toContain('2 (1 empty)'); + }); + + it('renders wagons in consist order regardless of the order the relation returns', () => { + const schedule = { + id: 'schedule-1', + trainNumber: '8302', + direction: 'EXPORT', + trainSet: { + wagons: [makeWagon(3, 'W-003', []), makeWagon(1, 'W-001', []), makeWagon(2, 'W-002', [])], + }, + scheduleBookings: [], + }; + + const html = (service as never as { + buildExportLoadListHtml: (s: unknown) => string; + }).buildExportLoadListHtml(schedule); + + expect(html.indexOf('W-001')).toBeLessThan(html.indexOf('W-002')); + expect(html.indexOf('W-002')).toBeLessThan(html.indexOf('W-003')); + }); + + it('omits the empty-count suffix when every wagon is loaded', () => { + const schedule = { + id: 'schedule-1', + trainNumber: '8302', + direction: 'EXPORT', + trainSet: { wagons: [makeWagon(1, 'W-001', [loadedAllocation])] }, + scheduleBookings: [], + }; + + const html = (service as never as { + buildExportLoadListHtml: (s: unknown) => string; + }).buildExportLoadListHtml(schedule); + + expect(html).not.toContain('empty)'); + expect(html).not.toContain('EMPTY'); + }); + }); }); 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 cccf9e57e..9d426077e 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 @@ -2686,19 +2686,24 @@ export class TrainSchedulingService { origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null, destination: schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null, totalBookings: schedule.scheduleBookings?.length ?? 0, - wagons: (schedule.trainSet?.wagons ?? []).map((wagon) => ({ - sequenceNo: wagon.sequenceNo, - wagonNumber: wagon.physicalWagon?.wagonNumber ?? null, - allocations: (wagon.allocations ?? []).map((allocation) => ({ - bookingId: allocation.bookingId, - bookingReference: allocation.booking?.reference ?? null, - loadType: allocation.loadType ?? null, - allocatedWeightTons: Number(allocation.allocatedWeightTons) || 0, - containerNumbers: (allocation.containerItems ?? []) - .map((item) => item.containerNumber) - .filter(Boolean), + // Every wagon on the train set, loaded or not, in consist order. An empty + // wagon has an empty `allocations` array — it is still part of the train + // and still belongs on the marshalling document. + wagons: [...(schedule.trainSet?.wagons ?? [])] + .sort((a, b) => Number(a.sequenceNo ?? 0) - Number(b.sequenceNo ?? 0)) + .map((wagon) => ({ + sequenceNo: wagon.sequenceNo, + wagonNumber: wagon.physicalWagon?.wagonNumber ?? null, + allocations: (wagon.allocations ?? []).map((allocation) => ({ + bookingId: allocation.bookingId, + bookingReference: allocation.booking?.reference ?? null, + loadType: allocation.loadType ?? null, + allocatedWeightTons: Number(allocation.allocatedWeightTons) || 0, + containerNumbers: (allocation.containerItems ?? []) + .map((item) => item.containerNumber) + .filter(Boolean), + })), })), - })), operation: await this.getImportDjiboutiOperation(schedule.id), }; } @@ -2748,9 +2753,33 @@ export class TrainSchedulingService { const date = (value: unknown) => (value ? new Date(value as string | Date).toLocaleDateString('en-GB') : '-'); 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 rows = (schedule.trainSet?.wagons ?? []) - .flatMap((wagon) => - (wagon.allocations ?? []).map((allocation) => { + // The document is checked against the physical train, so it has to run in + // consist order — the relation comes back unordered. + const wagons = [...(schedule.trainSet?.wagons ?? [])].sort( + (a, b) => Number(a.sequenceNo ?? 0) - Number(b.sequenceNo ?? 0), + ); + const rows = wagons + .flatMap((wagon) => { + // Wagon identity is the same on every row the wagon produces, loaded or not. + const wagonCells = `${esc(wagon.sequenceNo)} + ${esc(wagon.physicalWagon?.wagonNumber)} + ${esc(wagon.wagonType?.code ?? wagon.wagonType?.name)} + ${esc(Number(wagon.lengthMeters || 0).toFixed(3))} + ${esc(Number(wagon.wagonType?.tareWeightTons ?? 0).toFixed(2))} + ${esc(Number(wagon.capacityTons || 0).toFixed(3))}`; + const allocations = wagon.allocations ?? []; + // An empty wagon still runs in the consist, so it still gets a line. Staff + // check this document against the physical train — a wagon with no row + // reads as a wagon that is not there, and the count stops matching. + if (allocations.length === 0) { + return [ + ` + ${wagonCells} + EMPTY — no cargo allocated + `, + ]; + } + return allocations.map((allocation) => { const booking = allocation.booking ?? bookingById.get(allocation.bookingId); const company = booking?.company as Record | null | undefined; const cargoType = (booking as unknown as { cargoType?: { name?: string; code?: string } } | undefined)?.cargoType; @@ -2760,12 +2789,7 @@ export class TrainSchedulingService { const sealNumbers = containerItems.map((item) => item.sealNumber).filter(Boolean).join(', '); const chassisNumbers = containerItems.map((item) => item.chassisNumber).filter(Boolean).join(', '); return ` - ${esc(wagon.sequenceNo)} - ${esc(wagon.physicalWagon?.wagonNumber)} - ${esc(wagon.wagonType?.code ?? wagon.wagonType?.name)} - ${esc(Number(wagon.lengthMeters || 0).toFixed(3))} - ${esc(Number(wagon.wagonType?.tareWeightTons ?? 0).toFixed(2))} - ${esc(Number(wagon.capacityTons || 0).toFixed(3))} + ${wagonCells} ${esc(company?.name ?? company?.legalName ?? company?.tradeName ?? booking?.companyId)} ${esc(booking?.companyId)} ${esc(cargoType?.name ?? cargoType?.code ?? allocation.loadType)} @@ -2773,10 +2797,11 @@ export class TrainSchedulingService { ${esc(chassisNumbers)} ${esc(sealNumbers)} `; - }), - ) + }); + }) .join(''); - const totalWeight = (schedule.trainSet?.wagons ?? []).reduce( + const emptyWagons = wagons.filter((wagon) => (wagon.allocations ?? []).length === 0).length; + const totalWeight = wagons.reduce( (sum, wagon) => sum + (wagon.allocations ?? []).reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0), 0, @@ -2804,6 +2829,8 @@ export class TrainSchedulingService { th { background: #f8fafc; color: #475569; text-align: left; } th, td { border: 1px solid #cbd5e1; padding: 5px 6px; font-size: 9.5px; vertical-align: top; } .num { text-align: right; } + tr.empty td { background: #f8fafc; color: #64748b; } + tr.empty td[colspan] { font-weight: 700; letter-spacing: .04em; } .notice { margin-top: 10px; border-left: 4px solid #0f766e; background: #f0fdfa; padding: 8px 10px; font-size: 10px; color: #134e4a; } .signatures { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; margin-top: 34px; } .line { border-top: 1px solid #334155; padding-top: 7px; font-size: 9px; color: #475569; min-height: 34px; } @@ -2831,7 +2858,7 @@ export class TrainSchedulingService {
Total loaded weight${esc(totalWeight.toFixed(3))} T
Prepared person${esc(schedule.preparedByUserId)}
Check person${esc(schedule.checkedByUserId)}
-
Wagons${esc(schedule.trainSet?.wagons?.length ?? 0)}
+
Wagons${esc(wagons.length)}${emptyWagons ? ` (${emptyWagons} empty)` : ''}
Bookings${esc(schedule.scheduleBookings?.length ?? 0)}
Status${esc(schedule.status)}
Direction${esc(schedule.direction)}
@@ -2855,7 +2882,7 @@ export class TrainSchedulingService { - ${rows || 'No wagon allocations found for this export train.'} + ${rows || 'No wagons on this train set.'} @@ -2898,19 +2925,31 @@ export class TrainSchedulingService { sum + wagon.allocations.reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0), 0, ); + const emptyWagons = loadList.wagons.filter((wagon) => wagon.allocations.length === 0).length; const allocationRows = loadList.wagons - .flatMap((wagon) => - wagon.allocations.map( + .flatMap((wagon) => { + const wagonCells = `${esc(wagon.sequenceNo)} + ${esc(wagon.wagonNumber)}`; + // An empty wagon still runs in the consist, so it still gets a line — see + // buildExportLoadListHtml. + if (wagon.allocations.length === 0) { + return [ + ` + ${wagonCells} + EMPTY — no cargo allocated + `, + ]; + } + return wagon.allocations.map( (allocation) => ` - ${esc(wagon.sequenceNo)} - ${esc(wagon.wagonNumber)} + ${wagonCells} ${esc(allocation.bookingReference ?? allocation.bookingId)} ${esc(allocation.loadType)} ${esc(allocation.containerNumbers.length ? allocation.containerNumbers.join(', ') : '-')} ${esc(Number(allocation.allocatedWeightTons || 0).toFixed(3))} `, - ), - ) + ); + }) .join(''); return ` @@ -2942,6 +2981,8 @@ export class TrainSchedulingService { th { background: #f8fafc; color: #475569; text-align: left; } th, td { border: 1px solid #cbd5e1; padding: 7px 8px; font-size: 11px; vertical-align: top; } .num { text-align: right; } + tr.empty td { background: #f8fafc; color: #64748b; } + tr.empty td[colspan] { font-weight: 700; letter-spacing: .04em; } .notice { margin-top: 16px; border-left: 4px solid #0f766e; background: #f0fdfa; padding: 10px 12px; font-size: 11px; color: #134e4a; } .signatures { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 22px; margin-top: 44px; } .line { border-top: 1px solid #334155; padding-top: 8px; font-size: 10px; color: #475569; min-height: 42px; } @@ -2968,7 +3009,7 @@ export class TrainSchedulingService {
Origin${esc(loadList.origin)}
Destination${esc(loadList.destination)}
Total bookings${esc(loadList.totalBookings)}
-
Wagons${esc(loadList.wagons.length)}
+
Wagons${esc(loadList.wagons.length)}${emptyWagons ? ` (${emptyWagons} empty)` : ''}
Allocations${esc(totalAllocations)}
Total weight${esc(totalWeight.toFixed(3))} T
Gatepass granted${esc(date(loadList.operation.gatepassGrantedAt))}
@@ -2996,7 +3037,7 @@ export class TrainSchedulingService { - ${allocationRows || 'No wagon allocations found for this train.'} + ${allocationRows || 'No wagons on this train set.'}