Merge pull request #1291 from Tria-plc/eims-integration

feat(train-scheduling): show loaded empties on the marshalling document
This commit is contained in:
Hagernesh Tadesse
2026-08-14 14:39:22 +03:00
committed by GitHub
2 changed files with 84 additions and 2 deletions

View File

@@ -1132,6 +1132,36 @@ describe('TrainSchedulingService', () => {
expect(html).toContain('2 (1 empty)');
});
it('lists loaded empty containers by number and states they are empty', () => {
const schedule = {
id: 'schedule-1',
trainNumber: '8301',
direction: 'EXPORT',
trainSet: {
wagons: [makeWagon(1, 'W-001', []), makeWagon(2, 'W-002', []), makeWagon(3, 'W-003', [])],
},
scheduleBookings: [],
};
const html = (service as never as {
buildExportLoadListHtml: (s: unknown, o?: unknown) => string;
}).buildExportLoadListHtml(schedule, {
emptyContainers: [
{ containerNumber: 'CMU9876543', containerSize: '40', wagonSequenceNo: 1 },
{ containerNumber: 'TEMU1112223', containerSize: '20', wagonSequenceNo: 2 },
{ containerNumber: 'TEMU4445556', containerSize: '20', wagonSequenceNo: 2 },
],
});
expect(html).toContain('CMU9876543');
expect(html).toContain('TEMU1112223, TEMU4445556');
expect(html.match(/EMPTY CONTAINER/g)).toHaveLength(2);
// Wagon 3 carries nothing at all, so it keeps the bare-wagon wording.
expect(html.match(/EMPTY — no cargo allocated/g)).toHaveLength(1);
expect(html).toContain('3 (1 empty)');
expect(html).toContain('<span>Empty containers</span><strong>3</strong>');
});
it('renders wagons in consist order regardless of the order the relation returns', () => {
const schedule = {
id: 'schedule-1',

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>