mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 11:55:42 +00:00
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:
@@ -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',
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user