Merge branch 'dev' into freight/nati-2

This commit is contained in:
Nathnael
2026-08-14 13:52:23 +00:00
19 changed files with 892 additions and 12 deletions

View File

@@ -513,6 +513,17 @@ export class BillingService {
// MoR EIMS reference — only once actually registered, never a placeholder row.
if (invoice.eimsIrn) summary.push({ label: "EIMS IRN", value: invoice.eimsIrn });
// PNR — the CBE_BILL reference the customer pays against, written onto the booking at
// payment-initiation time (see initiatePayment()). Not a column on Invoice/Payment, so
// look it up by source id; only shown once a payment actually generated one.
if (invoice.source === Freight.InvoiceSource.Booking) {
const booking = await this.dataSource.getRepository(Booking).findOne({
where: { id: invoice.sourceId },
select: ["id", "pnrCode"],
});
if (booking?.pnrCode) summary.push({ label: "PNR", value: booking.pnrCode });
}
return {
kind,
title,

View File

@@ -1,5 +1,17 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsArray, IsDateString, IsIn, IsOptional, IsString, IsUUID } from 'class-validator';
import { Type } from 'class-transformer';
import {
ArrayNotEmpty,
IsArray,
IsDateString,
IsIn,
IsInt,
IsOptional,
IsString,
IsUUID,
Min,
ValidateNested,
} from 'class-validator';
import { DJIBOUTI_INCIDENT_TYPES, type DjiboutiIncidentType } from '../entities/djibouti-incident.entity';
import {
@@ -120,6 +132,9 @@ export class ImportOperationActionDto {
notes?: string;
}
export const EMPTY_CONTAINER_SIZES = ['20', '40'] as const;
export type EmptyContainerSize = (typeof EMPTY_CONTAINER_SIZES)[number];
export class CreateEmptyContainerReturnDto {
@ApiProperty()
@IsString()
@@ -140,6 +155,11 @@ export class CreateEmptyContainerReturnDto {
@IsDateString()
returnDate?: string;
@ApiPropertyOptional({ enum: EMPTY_CONTAINER_SIZES })
@IsOptional()
@IsIn(EMPTY_CONTAINER_SIZES)
containerSize?: EmptyContainerSize;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@@ -176,6 +196,39 @@ export class CreateEmptyContainerReturnDto {
returnedBy?: 'EDR' | 'CUSTOMER';
}
export class LoadEmptyContainerItemDto {
@ApiProperty({ format: 'uuid' })
@IsUUID()
id!: string;
@ApiProperty({ enum: EMPTY_CONTAINER_SIZES })
@IsIn(EMPTY_CONTAINER_SIZES)
containerSize!: EmptyContainerSize;
@ApiProperty()
@IsInt()
@Min(1)
wagonSequenceNo!: number;
}
export class LoadEmptyContainersOnTrainDto extends ImportOperationActionDto {
@ApiProperty({ format: 'uuid' })
@IsUUID()
trainScheduleId!: string;
@ApiPropertyOptional({ description: 'Run number shown on the return record.' })
@IsOptional()
@IsString()
trainNumber?: string;
@ApiProperty({ type: [LoadEmptyContainerItemDto] })
@IsArray()
@ArrayNotEmpty()
@ValidateNested({ each: true })
@Type(() => LoadEmptyContainerItemDto)
items!: LoadEmptyContainerItemDto[];
}
export class UpdateEmptyContainerReturnStatusDto extends ImportOperationActionDto {
@ApiProperty({ enum: EMPTY_CONTAINER_RETURN_STATUSES })
@IsIn(EMPTY_CONTAINER_RETURN_STATUSES)

View File

@@ -0,0 +1,23 @@
import { assertWagonLoad } from './empty-container-wagon.util';
describe('assertWagonLoad', () => {
it('accepts one 40ft or two 20ft per wagon', () => {
expect(() =>
assertWagonLoad(
new Map([
[1, ['40']],
[2, ['20', '20']],
[3, ['20']],
]),
),
).not.toThrow();
});
it('rejects a 40ft sharing a wagon', () => {
expect(() => assertWagonLoad(new Map([[4, ['40', '20']]]))).toThrow(/Wagon 4/);
});
it('rejects three containers on a wagon', () => {
expect(() => assertWagonLoad(new Map([[5, ['20', '20', '20']]]))).toThrow(/Wagon 5/);
});
});

View File

@@ -0,0 +1,16 @@
import { BadRequestException } from '@nestjs/common';
/**
* A wagon carries ONE 40ft OR TWO 20ft empties — never a mix, never three.
* Throws on the first wagon that breaks the rule.
*/
export function assertWagonLoad(sizesByWagon: Map<number, string[]>): void {
for (const [wagon, sizes] of sizesByWagon) {
const has40 = sizes.some((size) => size === '40');
if ((has40 && sizes.length > 1) || sizes.length > 2) {
throw new BadRequestException(
`Wagon ${wagon} takes one 40ft or two 20ft containers — got ${sizes.join('ft + ')}ft`,
);
}
}
}

View File

@@ -51,6 +51,17 @@ export class EmptyContainerReturn extends BaseEntity {
@Column({ name: 'wagon_allocation_reference', type: 'varchar', length: 120, nullable: true })
wagonAllocationReference?: string | null;
/** '20' or '40' — drives the one-40ft-or-two-20ft-per-wagon loading rule. */
@Column({ name: 'container_size', type: 'varchar', length: 10, nullable: true })
containerSize?: string | null;
/** Export departure carrying this empty back to Djibouti. */
@Column({ name: 'train_schedule_id', type: 'uuid', nullable: true })
trainScheduleId?: string | null;
@Column({ name: 'wagon_sequence_no', type: 'int', nullable: true })
wagonSequenceNo?: number | null;
@Column({ name: 'performed_by', type: 'varchar', length: 120, nullable: true })
performedBy?: string | null;

View File

@@ -8,6 +8,7 @@ import {
CreateDjiboutiIncidentDto,
CreateEmptyContainerReturnDto,
ImportOperationActionDto,
LoadEmptyContainersOnTrainDto,
RecordDeclarationDto,
UpdateEmptyContainerReturnStatusDto,
UploadImportCustomsDocumentDto,
@@ -104,6 +105,14 @@ export class ImportOperationsController {
return this.service.createEmptyReturn(dto);
}
@Post('empty-container-returns/load-on-train')
@ApiOperation({
summary: 'Load returned empties onto an export train (1×40ft or 2×20ft per wagon)',
})
loadEmptyReturnsOnTrain(@Body() dto: LoadEmptyContainersOnTrainDto) {
return this.service.loadEmptyReturnsOnTrain(dto);
}
@Post('empty-container-returns/:id/status')
@ApiOperation({ summary: 'Batch 16: advance empty container return workflow' })
updateEmptyReturnStatus(

View File

@@ -1,11 +1,12 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { In, Repository } from 'typeorm';
import {
CreateDjiboutiIncidentDto,
CreateEmptyContainerReturnDto,
ImportOperationActionDto,
LoadEmptyContainersOnTrainDto,
RecordDeclarationDto,
AssignCustomsRiskDto,
UpdateEmptyContainerReturnStatusDto,
@@ -15,6 +16,7 @@ import {
DjiboutiIncident,
type DjiboutiIncidentType,
} from './entities/djibouti-incident.entity';
import { assertWagonLoad } from './empty-container-wagon.util';
import { EmptyContainerReturn } from './entities/empty-container-return.entity';
import {
ImportCustomsFinalization,
@@ -156,6 +158,7 @@ export class ImportOperationsService {
bookingId: dto.bookingId ?? null,
customerId: dto.customerId ?? null,
returnDate,
containerSize: dto.containerSize ?? null,
facility: dto.facility ?? null,
yard: dto.yard ?? null,
zone: dto.zone ?? null,
@@ -170,6 +173,63 @@ export class ImportOperationsService {
);
}
/**
* Load returned empties onto an export departure. A wagon takes ONE 40ft or
* TWO 20ft — never a mix, never three. Empties already sitting on a wagon of
* the same schedule count against that wagon, so incremental loads cannot
* quietly double-book a slot.
*
* ponytail: does not check the wagon is free of cargo bookings — the loading
* UI picks only unallocated wagons from the schedule's plan. Cross-check here
* if empties ever get loaded from another client.
*/
async loadEmptyReturnsOnTrain(dto: LoadEmptyContainersOnTrainDto) {
const ids = dto.items.map((item) => item.id);
const rows = await this.emptyReturns.find({ where: { id: In(ids) } });
const missing = ids.filter((id) => !rows.some((row) => row.id === id));
if (missing.length) {
throw new NotFoundException(`Empty container return(s) not found: ${missing.join(', ')}`);
}
const alreadyOnTrain = await this.emptyReturns.find({
where: { trainScheduleId: dto.trainScheduleId },
});
const byWagon = new Map<number, string[]>();
for (const row of alreadyOnTrain) {
if (row.wagonSequenceNo == null || ids.includes(row.id)) continue;
byWagon.set(row.wagonSequenceNo, [
...(byWagon.get(row.wagonSequenceNo) ?? []),
row.containerSize ?? '40',
]);
}
for (const item of dto.items) {
byWagon.set(item.wagonSequenceNo, [
...(byWagon.get(item.wagonSequenceNo) ?? []),
item.containerSize,
]);
}
assertWagonLoad(byWagon);
const changedAt = new Date().toISOString();
for (const item of dto.items) {
const row = rows.find((candidate) => candidate.id === item.id)!;
await this.emptyReturns.update(item.id, {
status: 'WAGON_ALLOCATED',
containerSize: item.containerSize,
trainScheduleId: dto.trainScheduleId,
wagonSequenceNo: item.wagonSequenceNo,
wagonAllocationReference: dto.trainNumber ?? dto.trainScheduleId,
performedBy: dto.performedBy ?? row.performedBy ?? null,
statusHistory: [
...(row.statusHistory ?? []),
{ status: 'WAGON_ALLOCATED' as const, changedAt, performedBy: dto.performedBy ?? null },
],
});
}
return this.emptyReturns.find({ where: { trainScheduleId: dto.trainScheduleId } });
}
async updateEmptyReturnStatus(id: string, dto: UpdateEmptyContainerReturnStatusDto) {
const row = await this.emptyReturns.findOne({ where: { id } });
if (!row) {

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>