mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 03:40:56 +00:00
Merge branch 'dev' into freight/nati-2
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user