feat: empty container Import

This commit is contained in:
hager
2026-09-04 22:43:20 +00:00
parent 2b6c78cf71
commit 3fb5cdf29f
36 changed files with 968 additions and 49 deletions

View File

@@ -1,6 +1,6 @@
import { BadRequestException } from '@nestjs/common';
import { FREIGHT_TYPES, FreightType } from './entities/booking.entity';
import { CARGO_CONDITIONS, CargoCondition, FREIGHT_TYPES, FreightType } from './entities/booking.entity';
import { BookingFreightShapeInput } from './dto/validators/booking-freight.validator';
/** Normalize and validate booking freight shape (used on create and after update merge). */
@@ -12,10 +12,25 @@ export function assertFreightShape(input: BookingFreightShapeInput): void {
}
//
const condition = input.cargoCondition ?? 'LADEN';
if (!CARGO_CONDITIONS.includes(condition as CargoCondition)) {
throw new BadRequestException(
`cargoCondition must be one of: ${CARGO_CONDITIONS.join(', ')}`,
);
}
const containers = input.containers ?? [];
const hasContainers = containers.length > 0;
const hasCargoType = Boolean(input.cargoTypeId);
// Empty means bare equipment: there is no commodity to name, and bulk has no
// equipment of its own to move, so EMPTY only ever rides CONTAINER freight.
if (condition === 'EMPTY' && input.freightType !== 'CONTAINER') {
throw new BadRequestException(
'An empty booking must be CONTAINER freight — bulk carries no equipment',
);
}
if (input.freightType === 'BULK') {
if (hasContainers) {
throw new BadRequestException(

View File

@@ -779,3 +779,124 @@ describe('BookingPricingService — PER_WAGON container freight', () => {
expect(line.amount).toBe(3 * 1690);
});
});
/**
* Empty container import is bare equipment moved as freight in its own right.
* It has to price off EMPTY_CONTAINER_IMPORT, never the laden CONTAINER_IMPORT
* rate for the same lane and box — the two are separate tariffs, and
* UQ_rates_pattern only lets both exist because the rateType differs.
*/
describe('BookingPricingService — empty container import', () => {
const DJIBOUTI = 'yard-djibouti';
const CT40 = 'ct-40ft';
const ladenImport40: Rate = {
id: 'rate-container-import-40',
rateType: 'CONTAINER_IMPORT',
currency: 'USD',
rateValue: 900,
rateUnit: 'PER_CONTAINER',
status: 'LIVE',
containerTypeId: CT40,
originYardId: DJIBOUTI,
destinationYardId: MOJO,
} as Rate;
const emptyImport40: Rate = {
id: 'rate-empty-container-import-40',
rateType: 'EMPTY_CONTAINER_IMPORT',
currency: 'USD',
rateValue: 250,
rateUnit: 'PER_CONTAINER',
status: 'LIVE',
containerTypeId: CT40,
originYardId: DJIBOUTI,
destinationYardId: MOJO,
} as Rate;
let service: BookingPricingService;
const priceLines = (booking: Booking) =>
(
service as unknown as {
computeBaseRailLinesWithRates: (
b: Booking,
input: { containers: Array<{ containerTypeId: string; quantity: number; wagonsPerUnit: number }> },
) => Promise<{
lineItems: Array<{ code: string; amount: number; description: string }>;
blocked: string[];
}>;
}
).computeBaseRailLinesWithRates(booking, {
containers: [{ containerTypeId: CT40, quantity: 4, wagonsPerUnit: 1 }],
});
const bookingWith = (cargoCondition: string) =>
({
id: 'b-empty-1',
freightType: 'CONTAINER',
cargoCondition,
tradeDirection: 'IMPORT',
paymentCurrency: 'USD',
// Bare equipment declares no VGM — the service zeroes it at create.
cargoTotalWeightVgm: 0,
originYardId: DJIBOUTI,
destinationYardId: MOJO,
bookingContainers: [],
}) as unknown as Booking;
beforeEach(() => {
const exchangeService = {
getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE),
getRateTable: jest.fn().mockResolvedValue({ ETB: MOCK_CBE_RATE, USD: 1, DJF: 1 }),
};
service = new BookingPricingService(
{ calculateWagonCount: jest.fn().mockResolvedValue(4) } as never,
{} as never,
{ findById: jest.fn().mockResolvedValue({ sizeFt: 40, label: '40ft' }) } as never,
{ findLiveRates: jest.fn().mockResolvedValue([ladenImport40, emptyImport40]) } as never,
exchangeService as never,
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
{} as never,
{ findById: jest.fn().mockResolvedValue({ includesEthiopianCustomsOnly: false }) } as never,
);
});
it('prices an empty booking off the empty tariff, not the laden one', async () => {
const result = await priceLines(bookingWith('EMPTY'));
expect(result.lineItems).toHaveLength(1);
expect(result.lineItems[0].code).toBe('EMPTY_CONTAINER_IMPORT');
expect(result.lineItems[0].amount).toBe(250 * 4);
expect(result.lineItems[0].description).toContain('empty');
});
it('leaves laden bookings on the laden tariff', async () => {
const result = await priceLines(bookingWith('LADEN'));
expect(result.lineItems[0].code).toBe('CONTAINER_IMPORT');
expect(result.lineItems[0].amount).toBe(900 * 4);
});
it('treats a booking with no condition set as laden', async () => {
const booking = bookingWith('LADEN');
delete (booking as unknown as Record<string, unknown>).cargoCondition;
const result = await priceLines(booking);
expect(result.lineItems[0].code).toBe('CONTAINER_IMPORT');
});
it('hard-blocks an empty booking on a lane with no empty rate configured', async () => {
(
service as unknown as { ratesService: { findLiveRates: jest.Mock } }
).ratesService.findLiveRates.mockResolvedValue([ladenImport40]);
const result = await priceLines(bookingWith('EMPTY'));
// Never silently fall through to the laden rate — that would bill an empty
// repositioning move at 900/box instead of 250.
expect(result.lineItems).toHaveLength(0);
expect(result.blocked[0]).toContain('EMPTY_CONTAINER_IMPORT');
});
});

View File

@@ -249,8 +249,10 @@ export class BookingPricingService {
// box or per wagon), bulk bookings the route's bulk fee (per ton or per
// wagon). Frozen contract snapshots win over live rates; a customs booking
// with nothing configured hard-blocks — clearance never ships for free.
// An empty box carries no declaration and no duty, so there is no clearance
// to sell even if a customs-bundled service type was somehow selected.
const clearanceBlocked: string[] = [];
if (booking.customsClearingEnabled) {
if (booking.customsClearingEnabled && booking.cargoCondition !== 'EMPTY') {
const clearance = await this.customsClearanceLines(booking, frozenRates, liveRates);
for (const line of clearance.lineItems) {
lineItems.push(line);
@@ -575,9 +577,17 @@ export class BookingPricingService {
const fx = await this.exchangeService.getRateTable(paymentCurrency as CurrencyCode);
const usdToEtb = fx['USD'];
const isBulk = booking.freightType === 'BULK';
// Bare equipment prices off its own tariff. It has to be a distinct
// rateType, not a cheaper CONTAINER_IMPORT row: UQ_rates_pattern keys on
// rate_type without applies_to, so an empty 40ft rate on a lane would
// collide with the laden 40ft rate for that same lane.
const isEmpty = booking.cargoCondition === 'EMPTY';
const rateType =
booking.tradeDirection === 'IMPORT'
const rateType = isEmpty
? booking.tradeDirection === 'EXPORT'
? 'EMPTY_CONTAINER_EXPORT'
: 'EMPTY_CONTAINER_IMPORT'
: booking.tradeDirection === 'IMPORT'
? isBulk
? 'BULK_IMPORT'
: 'CONTAINER_IMPORT'
@@ -651,7 +661,7 @@ export class BookingPricingService {
if (rate) usedRatesMap.set(rate.id, rate);
lines.push({
code: rateType,
description: `${label} rail freight`,
description: isEmpty ? `${label} empty rail freight` : `${label} rail freight`,
amount,
unitAmount,
unit: rateUnit,

View File

@@ -1106,11 +1106,13 @@ ${footer}
const containers = await Promise.all(
containerLines.map(async (c) => {
const ct = await this.containerTypesService.findById(c.containerTypeId);
const totalVgmTons = c.quantity * c.vgmPerUnitTons;
// Optional on the DTO — an empty booking states no VGM at all.
const vgmPerUnitTons = Number(c.vgmPerUnitTons ?? 0);
const totalVgmTons = c.quantity * vgmPerUnitTons;
return {
containerTypeId: c.containerTypeId,
quantity: c.quantity,
vgmPerUnitTons: c.vgmPerUnitTons,
vgmPerUnitTons,
totalVgmTons,
isReefer: ct.isReefer,
wagonsRequired: c.quantity * wagonsPerUnitForSize(ct.sizeFt),
@@ -1375,13 +1377,25 @@ ${footer}
}
}
const containers = dto.containers ?? [];
const cargoCondition = dto.cargoCondition === 'EMPTY' ? 'EMPTY' : 'LADEN';
const isEmpty = cargoCondition === 'EMPTY';
assertFreightShape({
freightType: dto.freightType,
cargoCondition,
cargoTypeId: dto.cargoTypeId,
containers,
containers: dto.containers ?? [],
});
// Bare equipment declares no VGM. Zero the lines HERE, before the rule
// engine sees them, so weight-limit and overweight evaluation, the wagon
// estimate, the persisted rows and every tonnage aggregate downstream all
// read the same figure — a stray VGM on an empty line would otherwise price
// an overweight surcharge on a box with nothing in it.
const containers = (dto.containers ?? []).map((c) => ({
...c,
vgmPerUnitTons: isEmpty ? 0 : (c.vgmPerUnitTons ?? 0),
}));
const tradeDirection = await this.resolveTradeDirectionForBooking(
dto.originYardId,
dto.destinationYardId,
@@ -1506,10 +1520,11 @@ ${footer}
destinationYardId: dto.destinationYardId,
tradeDirection,
freightType: dto.freightType,
cargoCondition,
cargoTypeId: dto.freightType === 'BULK' ? dto.cargoTypeId! : null,
cargoFreeText: dto.cargoFreeText,
shippingLineId: dto.shippingLineId,
cargoTotalWeightVgm: dto.cargoTotalWeightVgm,
cargoTotalWeightVgm: isEmpty ? 0 : dto.cargoTotalWeightVgm,
// Break-bulk actual tonnage (PER_ITEM cargo); meaningless outside BULK.
bulkTotalWeightTons:
dto.freightType === 'BULK' ? (dto.bulkTotalWeightTons ?? null) : null,
@@ -1647,6 +1662,11 @@ ${footer}
const warnings: string[] = [];
const freightType = (dto.freightType ?? existing.freightType) as FreightType;
// A draft may be switched between laden and empty; an untouched draft keeps
// whatever it was created as.
const cargoCondition =
(dto.cargoCondition ?? existing.cargoCondition) === 'EMPTY' ? 'EMPTY' : 'LADEN';
const isEmpty = cargoCondition === 'EMPTY';
let containers =
dto.containers ??
(existing.bookingContainers ?? [])
@@ -1672,7 +1692,14 @@ ${footer}
}
}
assertFreightShape({ freightType, cargoTypeId, containers });
// Same normalisation as create: zero the VGM of an empty booking before the
// rule engine, the wagon estimate or the persisted rows ever read it.
containers = containers.map((c) => ({
...c,
vgmPerUnitTons: isEmpty ? 0 : (c.vgmPerUnitTons ?? 0),
}));
assertFreightShape({ freightType, cargoCondition, cargoTypeId, containers });
const originYardId = dto.originYardId ?? existing.originYardId;
const destinationYardId = dto.destinationYardId ?? existing.destinationYardId;
@@ -1719,6 +1746,9 @@ ${footer}
const updates: Record<string, unknown> = {
...dto,
freightType,
cargoCondition,
// Bare equipment declares no VGM, whichever way the draft was edited.
cargoTotalWeightVgm: isEmpty ? 0 : cargoAmount,
cargoTypeId: freightType === 'BULK' ? cargoTypeId : null,
// Break-bulk actual tonnage; cleared when the booking leaves BULK.
bulkTotalWeightTons:
@@ -1825,10 +1855,12 @@ ${footer}
await this.bookingsRepository.deleteContainers(id);
await this.bookingsRepository.createContainers(
id,
// Index-aligned with ruleResult, which evaluated these same lines.
dto.containers.map((c, i) => ({
containerTypeId: c.containerTypeId,
quantity: c.quantity,
vgmPerUnitTons: c.vgmPerUnitTons,
// Bare equipment declares no VGM — same normalisation the rule engine saw.
vgmPerUnitTons: isEmpty ? 0 : (c.vgmPerUnitTons ?? 0),
hazardousQuantity: c.hazardousQuantity,
reeferQuantity: c.reeferQuantity,
weightResult: ruleResult.containerWeightResults[i],

View File

@@ -18,7 +18,12 @@ import {
ValidateIf,
ValidateNested,
} from 'class-validator';
import { BOOKING_STATUSES, BOOKING_TYPES, FREIGHT_TYPES } from '../entities/booking.entity';
import {
BOOKING_STATUSES,
BOOKING_TYPES,
CARGO_CONDITIONS,
FREIGHT_TYPES,
} from '../entities/booking.entity';
import { BookingFreightShapeConstraint } from './validators/booking-freight.validator';
const CONTRACT_TYPES = ['NEW', 'RENEWAL'] as const;
@@ -47,11 +52,20 @@ export class CreateBookingContainerDto {
@Transform(({ value }) => Number(value))
quantity!: number;
@ApiProperty({ description: 'VGM per container in tons', minimum: 0 })
/**
* Omitted on an empty booking — bare equipment has no verified gross mass to
* declare, and the service zeroes the line rather than trusting a stray value.
*/
@ApiPropertyOptional({
description: 'VGM per container in tons. Omit for an EMPTY booking',
minimum: 0,
default: 0,
})
@IsOptional()
@IsNumber()
@Min(0)
@Transform(({ value }) => Number(value))
vgmPerUnitTons!: number;
@Transform(({ value }) => Number(value ?? 0))
vgmPerUnitTons?: number;
@ApiPropertyOptional({
description: 'How many of this line are hazardous (0..quantity)',
@@ -312,6 +326,20 @@ export class CreateBookingDto {
@IsIn([...FREIGHT_TYPES])
freightType!: string;
/**
* LADEN (default) or EMPTY. EMPTY is container freight carrying nothing —
* the box itself is the shipment, priced per size and lane off an
* EMPTY_CONTAINER_IMPORT rate.
*/
@ApiPropertyOptional({
enum: CARGO_CONDITIONS,
default: 'LADEN',
description: 'EMPTY moves bare equipment; requires CONTAINER freight',
})
@IsOptional()
@IsIn([...CARGO_CONDITIONS])
cargoCondition?: string;
@ApiPropertyOptional({
format: 'uuid',
description: 'Required for BULK; must be omitted for CONTAINER',
@@ -330,10 +358,14 @@ export class CreateBookingDto {
@IsUUID()
shippingLineId?: string;
@ApiProperty({ description: 'Total cargo weight VGM in tons', minimum: 0 })
@ApiProperty({
description: 'Total cargo weight VGM in tons. Omit for an EMPTY booking',
minimum: 0,
})
@ValidateIf((o) => o.cargoCondition !== 'EMPTY')
@IsNumber()
@Min(0)
@Transform(({ value }) => Number(value))
@Transform(({ value }) => Number(value ?? 0))
cargoTotalWeightVgm!: number;
/**

View File

@@ -8,6 +8,8 @@ import { FREIGHT_TYPES, FreightType } from '../../entities/booking.entity';
export interface BookingFreightShapeInput {
freightType?: string;
/** LADEN (default) or EMPTY — see CARGO_CONDITIONS on the Booking entity. */
cargoCondition?: string | null;
cargoTypeId?: string | null;
containers?: Array<{ containerTypeId?: string }> | null;
}
@@ -20,6 +22,13 @@ export class BookingFreightShapeConstraint implements ValidatorConstraintInterfa
return true;
}
// Bulk carries no equipment of its own, so an empty booking is always
// container freight. Rejected here as well as in assertFreightShape so the
// 400 names the field instead of surfacing from the service layer.
if (dto.cargoCondition === 'EMPTY' && dto.freightType !== 'CONTAINER') {
return false;
}
const containers = dto.containers ?? [];
const hasContainers = containers.length > 0;
const hasCargoType =
@@ -49,6 +58,9 @@ export class BookingFreightShapeConstraint implements ValidatorConstraintInterfa
defaultMessage(args: ValidationArguments): string {
const dto = args.object as BookingFreightShapeInput;
if (dto.cargoCondition === 'EMPTY' && dto.freightType !== 'CONTAINER') {
return 'An empty booking must be CONTAINER freight — bulk carries no equipment';
}
if (dto.freightType === 'BULK') {
return 'BULK freight requires cargoTypeId and must not include container lines';
}

View File

@@ -83,6 +83,20 @@ export type PaymentStatus = (typeof PAYMENT_STATUSES)[number];
export const FREIGHT_TYPES = ['CONTAINER', 'BULK'] as const;
export type FreightType = (typeof FREIGHT_TYPES)[number];
/**
* Whether the booking moves cargo or bare equipment. EMPTY is container
* freight with nothing inside: the box IS the shipment, priced per size and
* lane off an EMPTY_CONTAINER_IMPORT rate.
*
* This is deliberately NOT a third `freightType`. An empty booking is still
* CONTAINER freight everywhere it matters physically — wagon footprint, yard
* and warehouse allocation, train scheduling, marshalling, gate passes — and
* `freightType` is read in ~880 places whose else-arm means "container". Only
* pricing, documents, customs and the contract template branch on condition.
*/
export const CARGO_CONDITIONS = ['LADEN', 'EMPTY'] as const;
export type CargoCondition = (typeof CARGO_CONDITIONS)[number];
export const SCHEDULING_STATUSES = [
SchedulingStatus.NotScheduled,
SchedulingStatus.Holding,
@@ -388,6 +402,13 @@ export class Booking extends BaseEntity {
@Column({ name: 'freight_type', type: 'varchar', length: 20, nullable: true })
freightType!: string;
/**
* LADEN (the default, and every pre-existing row) or EMPTY. Only ever EMPTY
* on CONTAINER freight — bulk has no equipment to move on its own.
*/
@Column({ name: 'cargo_condition', type: 'varchar', length: 10, default: 'LADEN' })
cargoCondition!: string;
@Column({ name: 'cargo_type_id', type: 'uuid', nullable: true })
cargoTypeId?: string | null;