feat(train-scheduling): per-row Departure/Arrival Station on marshalling docs

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 <noreply@anthropic.com>
This commit is contained in:
Hagernesh
2026-08-29 09:49:47 +00:00
parent b6d27379b1
commit 7df96d3609
2 changed files with 78 additions and 8 deletions

View File

@@ -1242,6 +1242,44 @@ describe('TrainSchedulingService', () => {
expect(withoutChanges).not.toContain('Consist Changed At This Stop'); 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('<th>Departure Station</th>');
expect(html).toContain('<th>Arrival Station</th>');
// Whole-route wagon: schedule's own endpoints.
expect(html).toContain('<td>DCT/SGTD</td>');
expect(html).toContain('<td>GMP (Gelan Multipurpose Port)</td>');
// Leg slot: its own board/alight yard, not the schedule's endpoints.
expect(html).toContain('<td>Dire Dawa Port</td>');
expect(html).toContain('<td>Adama</td>');
});
it('lists loaded empty containers by number and states they are empty', () => { it('lists loaded empty containers by number and states they are empty', () => {
const schedule = { const schedule = {
id: 'schedule-1', id: 'schedule-1',

View File

@@ -3557,17 +3557,19 @@ export class TrainSchedulingService {
// Leg slots couple mid-corridor — this origin document must say where their // 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). // cargo boards instead of listing it as loaded here (see the import list).
const slotYardLabels = await this.yardLabelsById( // Also doubles as the per-row Departure/Arrival Station lookup below.
(schedule.trainSet?.wagons ?? []).map((wagon) => wagon.boardYardId), const yardLabelById = await this.yardLabelsById(
(schedule.trainSet?.wagons ?? []).flatMap((wagon) => [wagon.boardYardId, wagon.alightYardId]),
); );
const pendingBoardYardLabelBySlot = new Map( const pendingBoardYardLabelBySlot = new Map(
(schedule.trainSet?.wagons ?? []) (schedule.trainSet?.wagons ?? [])
.filter((wagon) => wagon.boardYardId) .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, { const html = this.buildExportLoadListHtml(schedule, {
pendingBoardYardLabelBySlot, pendingBoardYardLabelBySlot,
yardLabelById,
emptyContainers: await this.loadedEmptyContainers(scheduleId), emptyContainers: await this.loadedEmptyContainers(scheduleId),
logoImageUrl: await this.logoSettings.getLogoImageUrl(), logoImageUrl: await this.logoSettings.getLogoImageUrl(),
}); });
@@ -3761,6 +3763,12 @@ export class TrainSchedulingService {
where: { trainScheduleId: scheduleId, yardId: stop.yardId }, where: { trainScheduleId: scheduleId, yardId: stop.yardId },
order: { occurredAt: 'ASC' }, 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, { const html = this.buildExportLoadListHtml(schedule, {
title: `Intercity Marshalling Document / Load List (Marshalling ${stopIndex})`, title: `Intercity Marshalling Document / Load List (Marshalling ${stopIndex})`,
positionLabel: `At ${stop.yardLabel}`, positionLabel: `At ${stop.yardLabel}`,
@@ -3769,6 +3777,7 @@ export class TrainSchedulingService {
emptyContainers: await this.loadedEmptyContainers(scheduleId), emptyContainers: await this.loadedEmptyContainers(scheduleId),
logoImageUrl: await this.logoSettings.getLogoImageUrl(), logoImageUrl: await this.logoSettings.getLogoImageUrl(),
consistChangesAtStop: this.consistChangesAt(schedule, logRows), consistChangesAtStop: this.consistChangesAt(schedule, logRows),
yardLabelById,
}); });
// Styled table-aware fallback (marshalling grid) — see importLoadListDocument. // Styled table-aware fallback (marshalling grid) — see importLoadListDocument.
const buffer = await this.pdfDocuments.renderTabularDocument(html, `Marshalling ${stopIndex}`); const buffer = await this.pdfDocuments.renderTabularDocument(html, `Marshalling ${stopIndex}`);
@@ -3808,6 +3817,9 @@ export class TrainSchedulingService {
? `After ${last.yard?.label ?? last.yard?.code ?? 'checkpoint'}` ? `After ${last.yard?.label ?? last.yard?.code ?? 'checkpoint'}`
: `At ${schedule.originStation?.label ?? schedule.originStation?.code ?? 'origin'} — no checkpoint recorded`; : `At ${schedule.originStation?.label ?? schedule.originStation?.code ?? 'origin'} — no checkpoint recorded`;
const { wagons, unassignedBookings } = this.intercityOnBoardView(schedule); 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, { const html = this.buildExportLoadListHtml(schedule, {
title: 'Intercity Marshalling Document / Load List (Marshalling 2)', title: 'Intercity Marshalling Document / Load List (Marshalling 2)',
positionLabel, positionLabel,
@@ -3815,6 +3827,7 @@ export class TrainSchedulingService {
unassignedBookings, unassignedBookings,
emptyContainers: await this.loadedEmptyContainers(scheduleId), emptyContainers: await this.loadedEmptyContainers(scheduleId),
logoImageUrl: await this.logoSettings.getLogoImageUrl(), logoImageUrl: await this.logoSettings.getLogoImageUrl(),
yardLabelById,
}); });
// Styled table-aware fallback (marshalling grid) — see importLoadListDocument. // Styled table-aware fallback (marshalling grid) — see importLoadListDocument.
const buffer = await this.pdfDocuments.renderTabularDocument(html, 'Intercity marshalling / load list'); 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). // 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. // Their cargo renders as TO LOAD AT and stays out of the loaded tallies.
pendingBoardYardLabelBySlot?: Map<string, string>; pendingBoardYardLabelBySlot?: Map<string, string>;
// 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<string, string>;
// Numbered marshalling docs only (see marshallingDocumentAt / // Numbered marshalling docs only (see marshallingDocumentAt /
// consistChangesAt) — couples/uncouples/switches logged at THIS stop. // consistChangesAt) — couples/uncouples/switches logged at THIS stop.
// Origin import/export docs never pass this, so they render no such box. // Origin import/export docs never pass this, so they render no such box.
@@ -3924,15 +3941,28 @@ export class TrainSchedulingService {
empty, empty,
]); ]);
} }
const originLabel = schedule.originStation?.label ?? schedule.originStation?.code;
const destinationLabel = schedule.destinationStation?.label ?? schedule.destinationStation?.code;
const rows = wagons const rows = wagons
.flatMap((wagon) => { .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. // Wagon identity is the same on every row the wagon produces, loaded or not.
const wagonCells = `<td>${esc(wagon.sequenceNo)}</td> const wagonCells = `<td>${esc(wagon.sequenceNo)}</td>
<td>${esc(wagon.physicalWagon?.wagonNumber)}</td> <td>${esc(wagon.physicalWagon?.wagonNumber)}</td>
<td>${esc(wagon.wagonType?.code ?? wagon.wagonType?.name)}</td> <td>${esc(wagon.wagonType?.code ?? wagon.wagonType?.name)}</td>
<td class="num">${esc(Number(wagon.lengthMeters || 0).toFixed(3))}</td> <td class="num">${esc(Number(wagon.lengthMeters || 0).toFixed(3))}</td>
<td class="num">${esc(Number(wagon.wagonType?.tareWeightTons ?? 0).toFixed(2))}</td> <td class="num">${esc(Number(wagon.wagonType?.tareWeightTons ?? 0).toFixed(2))}</td>
<td class="num">${esc(Number(wagon.capacityTons || 0).toFixed(3))}</td>`; <td class="num">${esc(Number(wagon.capacityTons || 0).toFixed(3))}</td>
<td>${esc(departureLabel)}</td>
<td>${esc(arrivalLabel)}</td>`;
const allocations = wagon.allocations ?? []; const allocations = wagon.allocations ?? [];
// An empty wagon still runs in the consist, so it still gets a line. Staff // 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 // check this document against the physical train — a wagon with no row
@@ -3956,7 +3986,7 @@ export class TrainSchedulingService {
return [ return [
`<tr class="empty"> `<tr class="empty">
${wagonCells} ${wagonCells}
<td colspan="4">EMPTY — no cargo allocated</td> <td colspan="5">EMPTY — no cargo allocated</td>
</tr>`, </tr>`,
]; ];
} }
@@ -3984,7 +4014,7 @@ export class TrainSchedulingService {
// they are still physically on the train, so they get rows of their own. // they are still physically on the train, so they get rows of their own.
const unassigned = opts?.unassignedBookings ?? []; const unassigned = opts?.unassignedBookings ?? [];
const unassignedRows = unassigned.length const unassignedRows = unassigned.length
? `<tr class="empty"><td colspan="11">ON BOARD — WAGON NOT RECORDED</td></tr>` + ? `<tr class="empty"><td colspan="13">ON BOARD — WAGON NOT RECORDED</td></tr>` +
unassigned unassigned
.map((booking) => { .map((booking) => {
const containerNumbers = (booking.bookingContainers ?? []) const containerNumbers = (booking.bookingContainers ?? [])
@@ -3993,7 +4023,7 @@ export class TrainSchedulingService {
.join(', '); .join(', ');
const leg = `${booking.originYard?.label ?? booking.originYard?.code ?? '-'}${booking.destinationYard?.label ?? booking.destinationYard?.code ?? '-'}`; const leg = `${booking.originYard?.label ?? booking.originYard?.code ?? '-'}${booking.destinationYard?.label ?? booking.destinationYard?.code ?? '-'}`;
return `<tr> return `<tr>
<td colspan="6">${esc(booking.reference)}${esc(leg)}</td> <td colspan="8">${esc(booking.reference)}${esc(leg)}</td>
<td>${esc(booking.cargoType?.cargoTypeName ?? booking.cargoType?.code)}</td> <td>${esc(booking.cargoType?.cargoTypeName ?? booking.cargoType?.code)}</td>
<td>${esc(booking.company?.name)}</td> <td>${esc(booking.company?.name)}</td>
<td>${esc(containerNumbers)}</td> <td>${esc(containerNumbers)}</td>
@@ -4134,6 +4164,8 @@ export class TrainSchedulingService {
<th class="num">Equated Length</th> <th class="num">Equated Length</th>
<th class="num">Tare Weight</th> <th class="num">Tare Weight</th>
<th class="num">Load Capacity</th> <th class="num">Load Capacity</th>
<th>Departure Station</th>
<th>Arrival Station</th>
<th>Cargo Type</th> <th>Cargo Type</th>
<th>Company</th> <th>Company</th>
<th>Container No</th> <th>Container No</th>
@@ -4142,7 +4174,7 @@ export class TrainSchedulingService {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
${rows || '<tr><td colspan="10">No wagons on this train set.</td></tr>'} ${rows || '<tr><td colspan="13">No wagons on this train set.</td></tr>'}
${unassignedRows} ${unassignedRows}
</tbody> </tbody>
</table> </table>