From 7df96d3609e04e74acd042b4fa3e98dab8602e1a Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Sat, 29 Aug 2026 09:49:47 +0000 Subject: [PATCH] feat(train-scheduling): per-row Departure/Arrival Station on marshalling docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buildExportLoadListHtml's wagon table (origin export doc, Marshalling 2/3, and the intercity fallback) had no station columns — only the import doc did. Adds Departure Station / Arrival Station per row, mirroring the import doc's existing pattern: a whole-route wagon reads the schedule's own origin/destination, a leg-slot wagon reads its own boardYardId/alightYardId instead (resolved via a new yardLabelById opt, computed once per document from board+alight yard ids across the trainSet). Also fixes two pre-existing off-by-one colspans on the EMPTY row and the 'No wagons on this train set' placeholder, now that the real column count changed with the two new ones. Co-Authored-By: Claude Sonnet 5 --- .../services/train-scheduling.service.spec.ts | 38 +++++++++++++++ .../services/train-scheduling.service.ts | 48 +++++++++++++++---- 2 files changed, 78 insertions(+), 8 deletions(-) diff --git a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts index 9f839f613..64af1a56f 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts @@ -1242,6 +1242,44 @@ describe('TrainSchedulingService', () => { expect(withoutChanges).not.toContain('Consist Changed At This Stop'); }); + it('shows per-row Departure/Arrival Station — schedule endpoints for a whole-route wagon, its own board/alight yard for a leg slot', () => { + const wholeRoute = { ...makeWagon(1, 'W-001', [loadedAllocation]), id: 'slot-1' }; + const legSlot = { + ...makeWagon(2, 'W-LEG', [loadedAllocation]), + id: 'slot-leg', + boardYardId: 'yard-dire', + alightYardId: 'yard-adama', + }; + const schedule = { + id: 'schedule-1', + trainNumber: '8302', + direction: 'EXPORT', + originStation: { label: 'DCT/SGTD' }, + destinationStation: { label: 'GMP (Gelan Multipurpose Port)' }, + trainSet: { wagons: [wholeRoute, legSlot] }, + scheduleBookings: [], + }; + const build = (service as never as { + buildExportLoadListHtml: (s: unknown, o?: unknown) => string; + }).buildExportLoadListHtml.bind(service); + + const html = build(schedule, { + yardLabelById: new Map([ + ['yard-dire', 'Dire Dawa Port'], + ['yard-adama', 'Adama'], + ]), + }); + + expect(html).toContain('Departure Station'); + expect(html).toContain('Arrival Station'); + // Whole-route wagon: schedule's own endpoints. + expect(html).toContain('DCT/SGTD'); + expect(html).toContain('GMP (Gelan Multipurpose Port)'); + // Leg slot: its own board/alight yard, not the schedule's endpoints. + expect(html).toContain('Dire Dawa Port'); + expect(html).toContain('Adama'); + }); + it('lists loaded empty containers by number and states they are empty', () => { const schedule = { id: 'schedule-1', diff --git a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts index ccbaa08c7..07d386df3 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts @@ -3557,17 +3557,19 @@ export class TrainSchedulingService { // Leg slots couple mid-corridor — this origin document must say where their // cargo boards instead of listing it as loaded here (see the import list). - const slotYardLabels = await this.yardLabelsById( - (schedule.trainSet?.wagons ?? []).map((wagon) => wagon.boardYardId), + // Also doubles as the per-row Departure/Arrival Station lookup below. + const yardLabelById = await this.yardLabelsById( + (schedule.trainSet?.wagons ?? []).flatMap((wagon) => [wagon.boardYardId, wagon.alightYardId]), ); const pendingBoardYardLabelBySlot = new Map( (schedule.trainSet?.wagons ?? []) .filter((wagon) => wagon.boardYardId) - .map((wagon) => [wagon.id, slotYardLabels.get(wagon.boardYardId!) ?? 'en route']), + .map((wagon) => [wagon.id, yardLabelById.get(wagon.boardYardId!) ?? 'en route']), ); const html = this.buildExportLoadListHtml(schedule, { pendingBoardYardLabelBySlot, + yardLabelById, emptyContainers: await this.loadedEmptyContainers(scheduleId), logoImageUrl: await this.logoSettings.getLogoImageUrl(), }); @@ -3761,6 +3763,12 @@ export class TrainSchedulingService { where: { trainScheduleId: scheduleId, yardId: stop.yardId }, order: { occurredAt: 'ASC' }, }); + // Per-row Departure/Arrival Station: a whole-route wagon reads the + // schedule's own origin/destination, a leg-slot wagon reads where IT + // boards/alights instead. + const yardLabelById = await this.yardLabelsById( + (schedule.trainSet?.wagons ?? []).flatMap((wagon) => [wagon.boardYardId, wagon.alightYardId]), + ); const html = this.buildExportLoadListHtml(schedule, { title: `Intercity Marshalling Document / Load List (Marshalling ${stopIndex})`, positionLabel: `At ${stop.yardLabel}`, @@ -3769,6 +3777,7 @@ export class TrainSchedulingService { emptyContainers: await this.loadedEmptyContainers(scheduleId), logoImageUrl: await this.logoSettings.getLogoImageUrl(), consistChangesAtStop: this.consistChangesAt(schedule, logRows), + yardLabelById, }); // Styled table-aware fallback (marshalling grid) — see importLoadListDocument. const buffer = await this.pdfDocuments.renderTabularDocument(html, `Marshalling ${stopIndex}`); @@ -3808,6 +3817,9 @@ export class TrainSchedulingService { ? `After ${last.yard?.label ?? last.yard?.code ?? 'checkpoint'}` : `At ${schedule.originStation?.label ?? schedule.originStation?.code ?? 'origin'} — no checkpoint recorded`; const { wagons, unassignedBookings } = this.intercityOnBoardView(schedule); + const yardLabelById = await this.yardLabelsById( + (schedule.trainSet?.wagons ?? []).flatMap((wagon) => [wagon.boardYardId, wagon.alightYardId]), + ); const html = this.buildExportLoadListHtml(schedule, { title: 'Intercity Marshalling Document / Load List (Marshalling 2)', positionLabel, @@ -3815,6 +3827,7 @@ export class TrainSchedulingService { unassignedBookings, emptyContainers: await this.loadedEmptyContainers(scheduleId), logoImageUrl: await this.logoSettings.getLogoImageUrl(), + yardLabelById, }); // Styled table-aware fallback (marshalling grid) — see importLoadListDocument. const buffer = await this.pdfDocuments.renderTabularDocument(html, 'Intercity marshalling / load list'); @@ -3884,6 +3897,10 @@ export class TrainSchedulingService { // Slots that couple to the train downstream (slot id → board yard label). // Their cargo renders as TO LOAD AT and stays out of the loaded tallies. pendingBoardYardLabelBySlot?: Map; + // yardId → label, for the per-row Departure/Arrival Station columns + // (falls back to the schedule's own origin/destination when a wagon's + // boardYardId/alightYardId is null — i.e. it rides the whole corridor). + yardLabelById?: Map; // Numbered marshalling docs only (see marshallingDocumentAt / // consistChangesAt) — couples/uncouples/switches logged at THIS stop. // Origin import/export docs never pass this, so they render no such box. @@ -3924,15 +3941,28 @@ export class TrainSchedulingService { empty, ]); } + const originLabel = schedule.originStation?.label ?? schedule.originStation?.code; + const destinationLabel = schedule.destinationStation?.label ?? schedule.destinationStation?.code; const rows = wagons .flatMap((wagon) => { + // Departure/Arrival Station per row: a leg-slot wagon boards/alights + // somewhere other than the schedule's own endpoints; a whole-route + // wagon just reads origin/destination. + const departureLabel = wagon.boardYardId + ? (opts?.yardLabelById?.get(wagon.boardYardId) ?? 'en route') + : originLabel; + const arrivalLabel = wagon.alightYardId + ? (opts?.yardLabelById?.get(wagon.alightYardId) ?? 'en route') + : destinationLabel; // 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))}`; + ${esc(Number(wagon.capacityTons || 0).toFixed(3))} + ${esc(departureLabel)} + ${esc(arrivalLabel)}`; 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 @@ -3956,7 +3986,7 @@ export class TrainSchedulingService { return [ ` ${wagonCells} - EMPTY — no cargo allocated + EMPTY — no cargo allocated `, ]; } @@ -3984,7 +4014,7 @@ export class TrainSchedulingService { // they are still physically on the train, so they get rows of their own. const unassigned = opts?.unassignedBookings ?? []; const unassignedRows = unassigned.length - ? `ON BOARD — WAGON NOT RECORDED` + + ? `ON BOARD — WAGON NOT RECORDED` + unassigned .map((booking) => { const containerNumbers = (booking.bookingContainers ?? []) @@ -3993,7 +4023,7 @@ export class TrainSchedulingService { .join(', '); const leg = `${booking.originYard?.label ?? booking.originYard?.code ?? '-'} → ${booking.destinationYard?.label ?? booking.destinationYard?.code ?? '-'}`; return ` - ${esc(booking.reference)} — ${esc(leg)} + ${esc(booking.reference)} — ${esc(leg)} ${esc(booking.cargoType?.cargoTypeName ?? booking.cargoType?.code)} ${esc(booking.company?.name)} ${esc(containerNumbers)} @@ -4134,6 +4164,8 @@ export class TrainSchedulingService { Equated Length Tare Weight Load Capacity + Departure Station + Arrival Station Cargo Type Company Container No @@ -4142,7 +4174,7 @@ export class TrainSchedulingService { - ${rows || 'No wagons on this train set.'} + ${rows || 'No wagons on this train set.'} ${unassignedRows}