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. |
'}