feat(train-scheduling): show loaded empties on the marshalling document

Empty containers loaded onto an export departure carry no booking and no
wagon allocation, so their wagons printed as EMPTY — no cargo allocated —
staff checking the paper against the consist found boxes the list denied.
Those wagons now print the container numbers with cargo type EMPTY
CONTAINER, count toward the 40ft/20ft tallies, and get their own summary
tile; only genuinely bare wagons keep the empty wording.
This commit is contained in:
Hagernesh
2026-08-14 11:30:25 +00:00
parent b4f2ec1c75
commit 1adc19baef
2 changed files with 84 additions and 2 deletions

View File

@@ -91,6 +91,7 @@ import {
ImportDjiboutiOperation,
type ImportDjiboutiDocumentType,
} from '../entities/import-djibouti-operation.entity';
import { EmptyContainerReturn } from '../../import-operations/entities/empty-container-return.entity';
import {
ImportDjiboutiActionDto,
UploadImportDjiboutiDocumentDto,
@@ -3106,6 +3107,7 @@ export class TrainSchedulingService {
}
const html = this.buildExportLoadListHtml(schedule, {
emptyContainers: await this.loadedEmptyContainers(scheduleId),
logoImageUrl: await this.logoSettings.getLogoImageUrl(),
});
// Styled table-aware fallback (marshalling grid) — see importLoadListDocument.
@@ -3179,6 +3181,7 @@ export class TrainSchedulingService {
positionLabel,
wagons,
unassignedBookings,
emptyContainers: await this.loadedEmptyContainers(scheduleId),
logoImageUrl: await this.logoSettings.getLogoImageUrl(),
});
// Styled table-aware fallback (marshalling grid) — see importLoadListDocument.
@@ -3216,6 +3219,18 @@ export class TrainSchedulingService {
return null;
}
/**
* Empty containers riding this departure back to Djibouti. They carry no
* booking and no wagon allocation, so the marshalling document would show
* their wagons as bare — staff checking the paper against the train would
* find boxes that the list denies are there.
*/
private loadedEmptyContainers(scheduleId: string): Promise<EmptyContainerReturn[]> {
return this.dataSource
.getRepository(EmptyContainerReturn)
.find({ where: { trainScheduleId: scheduleId } });
}
private buildExportLoadListHtml(
schedule: TrainSchedule,
opts?: {
@@ -3223,6 +3238,7 @@ export class TrainSchedulingService {
positionLabel?: string;
wagons?: TrainSetWagon[];
unassignedBookings?: Booking[];
emptyContainers?: EmptyContainerReturn[];
logoImageUrl?: string | null;
},
): string {
@@ -3241,6 +3257,16 @@ export class TrainSchedulingService {
const wagons = [...(opts?.wagons ?? schedule.trainSet?.wagons ?? [])].sort(
(a, b) => Number(a.sequenceNo ?? 0) - Number(b.sequenceNo ?? 0),
);
// Empties sit on wagons that carry no booking allocation, keyed by the wagon
// slot recorded when they were loaded.
const emptiesByWagon = new Map<number, EmptyContainerReturn[]>();
for (const empty of opts?.emptyContainers ?? []) {
if (empty.wagonSequenceNo == null) continue;
emptiesByWagon.set(empty.wagonSequenceNo, [
...(emptiesByWagon.get(empty.wagonSequenceNo) ?? []),
empty,
]);
}
const rows = wagons
.flatMap((wagon) => {
// Wagon identity is the same on every row the wagon produces, loaded or not.
@@ -3255,6 +3281,21 @@ export class TrainSchedulingService {
// 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) {
const empties = emptiesByWagon.get(Number(wagon.sequenceNo)) ?? [];
// Empty boxes returning to Djibouti: numbers listed like any other
// container, state spelled out so nobody reads them as laden.
if (empties.length) {
return [
`<tr>
${wagonCells}
<td>EMPTY CONTAINER</td>
<td>-</td>
<td>${esc(empties.map((empty) => empty.containerNumber).filter(Boolean).join(', '))}</td>
<td>-</td>
<td>-</td>
</tr>`,
];
}
return [
`<tr class="empty">
${wagonCells}
@@ -3305,14 +3346,19 @@ export class TrainSchedulingService {
})
.join('')
: '';
const emptyWagons = wagons.filter((wagon) => (wagon.allocations ?? []).length === 0).length;
const emptyWagons = wagons.filter(
(wagon) =>
(wagon.allocations ?? []).length === 0 &&
!emptiesByWagon.get(Number(wagon.sequenceNo))?.length,
).length;
const totalWeight = wagons.reduce(
(sum, wagon) =>
sum + (wagon.allocations ?? []).reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0),
0,
);
// Container count summary (40ft, 20ft)
// Container count summary (40ft, 20ft) — empties returning to Djibouti are
// physically on the train, so they count, and are called out on their own tile.
let count40ft = 0, count20ft = 0;
wagons.forEach((wagon) => {
(wagon.allocations ?? []).forEach((allocation) => {
@@ -3323,6 +3369,11 @@ export class TrainSchedulingService {
});
});
});
const emptyContainers = [...emptiesByWagon.values()].flat();
for (const empty of emptyContainers) {
if (empty.containerSize?.includes('20')) count20ft++;
else count40ft++;
}
return `<!doctype html>
<html>
@@ -3378,6 +3429,7 @@ export class TrainSchedulingService {
<div class="tile"><span>Containers 40ft</span><strong>${esc(count40ft)}</strong></div>
<div class="tile"><span>Containers 20ft</span><strong>${esc(count20ft)}</strong></div>
<div class="tile"><span>Total containers</span><strong>${esc(count40ft + count20ft)}</strong></div>
${emptyContainers.length ? `<div class="tile"><span>Empty containers</span><strong>${esc(emptyContainers.length)}</strong></div>` : ''}
<div class="tile"><span>Prepared person</span><strong>${esc(schedule.preparedByUserId)}</strong></div>
<div class="tile"><span>Check person</span><strong>${esc(schedule.checkedByUserId)}</strong></div>
<div class="tile"><span>Wagons</span><strong>${esc(wagons.length)}${emptyWagons ? ` (${emptyWagons} empty)` : ''}</strong></div>