mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 11:18:17 +00:00
feat: empty container Import
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -53,24 +53,60 @@ describe('contractTemplateCodeFor', () => {
|
||||
it('only ever resolves to a code that exists', () => {
|
||||
const directions = ['IMPORT', 'EXPORT', 'DOMESTIC', null];
|
||||
const freights = ['BULK', 'CONTAINER', 'BREAK_BULK', null];
|
||||
const conditions = ['LADEN', 'EMPTY', null, undefined];
|
||||
for (const d of directions) {
|
||||
for (const f of freights) {
|
||||
for (const c of [true, false]) {
|
||||
for (const e of [true, false, undefined]) {
|
||||
expect(CONTRACT_TEMPLATE_CODES).toContain(
|
||||
contractTemplateCodeFor(d, f, c, e),
|
||||
);
|
||||
for (const cond of conditions) {
|
||||
expect(CONTRACT_TEMPLATE_CODES).toContain(
|
||||
contractTemplateCodeFor(d, f, c, e, cond),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Empty equipment is a carriage agreement, not a cargo contract: no cargo
|
||||
// liability, no VGM declaration, no commercial documents, no customs leg.
|
||||
it('gives empty container import its own customs-free paper', () => {
|
||||
for (const customs of [true, false]) {
|
||||
for (const ethiopian of [true, false, undefined]) {
|
||||
expect(
|
||||
contractTemplateCodeFor('IMPORT', 'CONTAINER', customs, ethiopian, 'EMPTY'),
|
||||
).toBe('IMPORT_EMPTY_CONTAINER');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('leaves laden contracts on the laden codes', () => {
|
||||
expect(
|
||||
contractTemplateCodeFor('IMPORT', 'CONTAINER', false, false, 'LADEN'),
|
||||
).toBe('IMPORT_CONTAINER_NO_CUSTOMS');
|
||||
expect(contractTemplateCodeFor('IMPORT', 'CONTAINER', false, false)).toBe(
|
||||
'IMPORT_CONTAINER_NO_CUSTOMS',
|
||||
);
|
||||
});
|
||||
|
||||
// Empty rates and empty bookings are import-only, so a stray EMPTY on any
|
||||
// other direction must fall through rather than resolve a template that
|
||||
// describes a Djibouti-to-Ethiopia movement.
|
||||
it('ignores the empty condition outside import', () => {
|
||||
expect(
|
||||
contractTemplateCodeFor('EXPORT', 'CONTAINER', false, false, 'EMPTY'),
|
||||
).toBe('EXPORT_CONTAINER_NO_CUSTOMS');
|
||||
expect(
|
||||
contractTemplateCodeFor('DOMESTIC', 'CONTAINER', false, false, 'EMPTY'),
|
||||
).toBe('INTERCITY_CONTAINER');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CONTRACT_TEMPLATE_DEFAULTS', () => {
|
||||
it('seeds exactly the fourteen declared codes, once each', () => {
|
||||
it('seeds exactly the fifteen declared codes, once each', () => {
|
||||
const seeded = CONTRACT_TEMPLATE_DEFAULTS.map((t) => t.code).sort();
|
||||
expect(seeded).toHaveLength(14);
|
||||
expect(seeded).toHaveLength(15);
|
||||
expect(seeded).toEqual([...CONTRACT_TEMPLATE_CODES].sort());
|
||||
});
|
||||
|
||||
|
||||
@@ -54,6 +54,9 @@ const PREVIEW_TEMPLATE_KEYS: Record<ContractTemplateCode, string> = {
|
||||
EXPORT_CONTAINER_ETHIOPIAN_CUSTOMS: "EXP_CON_USD_FORWARDING",
|
||||
EXPORT_CONTAINER_NO_CUSTOMS: "EXP_CON_USD_TRANSPORT_ONLY",
|
||||
INTERCITY_CONTAINER: "DOM_CON_USD_TRANSPORT_ONLY",
|
||||
// Carriage of the equipment itself — no cargo, no clearing, so it previews
|
||||
// against the transport-only scope like every other non-customs code.
|
||||
IMPORT_EMPTY_CONTAINER: "IMP_CON_USD_TRANSPORT_ONLY",
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
@@ -216,6 +219,7 @@ export class ContractTemplatesService {
|
||||
customsClearingEnabled?: boolean | null,
|
||||
cargoTypeId?: string | null,
|
||||
ethiopianCustomsOnly?: boolean | null,
|
||||
cargoCondition?: string | null,
|
||||
): Promise<ContractTemplate | null> {
|
||||
const isBulk = (freightType ?? "").toUpperCase().includes("BULK");
|
||||
if (isBulk) {
|
||||
@@ -235,6 +239,7 @@ export class ContractTemplatesService {
|
||||
freightType,
|
||||
customsClearingEnabled,
|
||||
ethiopianCustomsOnly,
|
||||
cargoCondition,
|
||||
);
|
||||
const template = await this.repository.findByCode(code);
|
||||
return template?.isActive ? template : null;
|
||||
|
||||
@@ -41,6 +41,14 @@ export const CONTRACT_TEMPLATE_CODES = [
|
||||
"EXPORT_CONTAINER_ETHIOPIAN_CUSTOMS",
|
||||
"EXPORT_CONTAINER_NO_CUSTOMS",
|
||||
"INTERCITY_CONTAINER",
|
||||
/**
|
||||
* Empty container import — bare equipment railed north from Djibouti. No
|
||||
* customs split: an empty box carries no declaration to clear, the same
|
||||
* reason intercity has a single unsuffixed code. Import-only, matching the
|
||||
* rate rule (southbound empties are served by the WITH_RETURN surcharge and
|
||||
* empty_return_requests instead).
|
||||
*/
|
||||
"IMPORT_EMPTY_CONTAINER",
|
||||
] as const;
|
||||
|
||||
export type ContractTemplateCode = (typeof CONTRACT_TEMPLATE_CODES)[number];
|
||||
@@ -74,7 +82,14 @@ export function contractTemplateCodeFor(
|
||||
freightType?: string | null,
|
||||
customsClearingEnabled?: boolean | null,
|
||||
ethiopianCustomsOnly?: boolean | null,
|
||||
cargoCondition?: string | null,
|
||||
): ContractTemplateCode {
|
||||
// Empty equipment is its own paper: a straight carriage agreement with no
|
||||
// cargo liability, no VGM declaration and no customs leg. Import-only, so
|
||||
// anything else falls through to the laden codes below.
|
||||
if (cargoCondition === "EMPTY" && tradeDirection === "IMPORT") {
|
||||
return "IMPORT_EMPTY_CONTAINER";
|
||||
}
|
||||
const direction =
|
||||
tradeDirection === "IMPORT"
|
||||
? "IMPORT"
|
||||
|
||||
@@ -430,6 +430,8 @@ export class ContractTransitionService {
|
||||
(contract.cargoScope ?? []).find((c) => c.cargoTypeId)?.cargoTypeId,
|
||||
// Ethiopian-customs-only service types resolve to the Ethiopian variant.
|
||||
contract.serviceType?.includesEthiopianCustomsOnly,
|
||||
// An empty-equipment contract resolves to the carriage-only paper.
|
||||
contract.cargoCondition,
|
||||
);
|
||||
if (!active) return null;
|
||||
return {
|
||||
|
||||
@@ -433,6 +433,7 @@ export class ContractsService {
|
||||
renewalOfId: dto.renewalOfId ?? null,
|
||||
tradeDirection: dto.tradeDirection,
|
||||
freightType: dto.freightType,
|
||||
cargoCondition: dto.cargoCondition === 'EMPTY' ? 'EMPTY' : 'LADEN',
|
||||
serviceTypeId: dto.serviceTypeId,
|
||||
// A contract is always QUOTED in USD — the billing currency is chosen per
|
||||
// booking (or on the shipment request when GL books for the customer), so
|
||||
|
||||
@@ -23,6 +23,7 @@ import { CONTRACT_KINDS } from '../entities/contract.entity';
|
||||
|
||||
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'DOMESTIC'] as const;
|
||||
const FREIGHT_TYPES = ['CONTAINER', 'BULK'] as const;
|
||||
const CARGO_CONDITIONS = ['LADEN', 'EMPTY'] as const;
|
||||
const PAYMENT_CURRENCIES = ['ETB', 'USD'] as const;
|
||||
// Canonical UPPERCASE — everything downstream (booking gating, pricing
|
||||
// surcharge, GL/portal booking forms) compares contract.equipmentReturn
|
||||
@@ -154,6 +155,15 @@ export class CreateContractDto {
|
||||
@IsIn([...FREIGHT_TYPES])
|
||||
freightType!: string;
|
||||
|
||||
/**
|
||||
* LADEN (default) or EMPTY. EMPTY commits to moving bare equipment and is
|
||||
* container freight only.
|
||||
*/
|
||||
@ApiPropertyOptional({ enum: CARGO_CONDITIONS, default: 'LADEN' })
|
||||
@IsOptional()
|
||||
@IsIn([...CARGO_CONDITIONS])
|
||||
cargoCondition?: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid', description: 'FK to service_types.id' })
|
||||
@IsUUID()
|
||||
serviceTypeId!: string;
|
||||
|
||||
@@ -150,6 +150,14 @@ export class Contract extends BaseEntity {
|
||||
@Column({ name: 'freight_type', type: 'varchar', length: 20 })
|
||||
freightType!: string;
|
||||
|
||||
/**
|
||||
* LADEN (the default, and every pre-existing row) or EMPTY. An EMPTY contract
|
||||
* commits to moving bare equipment and resolves the IMPORT_EMPTY_CONTAINER
|
||||
* template — a straight carriage agreement with no cargo or customs articles.
|
||||
*/
|
||||
@Column({ name: 'cargo_condition', type: 'varchar', length: 10, default: 'LADEN' })
|
||||
cargoCondition!: string;
|
||||
|
||||
@Column({ name: 'service_type_id', type: 'uuid' })
|
||||
serviceTypeId!: string;
|
||||
|
||||
|
||||
@@ -27,3 +27,29 @@ describe('deriveRateType — surcharge triggers', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deriveRateType — empty container freight', () => {
|
||||
it('splits empty freight from laden freight by direction', () => {
|
||||
expect(deriveRateType({ appliesTo: 'EMPTY_CONTAINER', trigger: 'ALWAYS' })).toBe(
|
||||
'EMPTY_CONTAINER_IMPORT',
|
||||
);
|
||||
expect(
|
||||
deriveRateType({
|
||||
appliesTo: 'EMPTY_CONTAINER',
|
||||
trigger: 'ALWAYS',
|
||||
tradeDirection: 'EXPORT',
|
||||
}),
|
||||
).toBe('EMPTY_CONTAINER_EXPORT');
|
||||
});
|
||||
|
||||
// UQ_rates_pattern keys on rate_type but not on applies_to, so an empty rate
|
||||
// sharing CONTAINER_IMPORT would collide with the laden rate for the same
|
||||
// lane and container type. The distinct rateType is what keeps both fileable.
|
||||
it('never resolves to the laden container rate type', () => {
|
||||
for (const tradeDirection of ['IMPORT', 'EXPORT']) {
|
||||
expect(
|
||||
deriveRateType({ appliesTo: 'EMPTY_CONTAINER', trigger: 'ALWAYS', tradeDirection }),
|
||||
).not.toBe(tradeDirection === 'EXPORT' ? 'CONTAINER_EXPORT' : 'CONTAINER_IMPORT');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -58,6 +58,8 @@ export function deriveRateType(input: {
|
||||
switch (appliesTo) {
|
||||
case 'CONTAINER':
|
||||
return isExport ? 'CONTAINER_EXPORT' : 'CONTAINER_IMPORT';
|
||||
case 'EMPTY_CONTAINER':
|
||||
return isExport ? 'EMPTY_CONTAINER_EXPORT' : 'EMPTY_CONTAINER_IMPORT';
|
||||
case 'BULK':
|
||||
return isExport ? 'BULK_EXPORT' : 'BULK_IMPORT';
|
||||
case 'INTERCITY':
|
||||
|
||||
@@ -84,3 +84,25 @@ describe("allowedRateUnits — bulk unit of measure", () => {
|
||||
expect(isBulkQuantityUnit("FLAT")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Empty equipment carries no cargo, so no weighed unit applies — only the box
|
||||
* and the wagon it rides on.
|
||||
*/
|
||||
describe("allowedRateUnits — empty container freight", () => {
|
||||
it("offers per-container and per-wagon only", () => {
|
||||
expect(
|
||||
allowedRateUnits({ appliesTo: "EMPTY_CONTAINER", trigger: "ALWAYS" }),
|
||||
).toEqual(["PER_CONTAINER", "PER_WAGON"]);
|
||||
});
|
||||
|
||||
it("never offers a weighed unit, even for a per-item commodity scope", () => {
|
||||
expect(
|
||||
allowedRateUnits({
|
||||
appliesTo: "EMPTY_CONTAINER",
|
||||
trigger: "ALWAYS",
|
||||
cargoUnitOfMeasure: "PER_ITEM",
|
||||
}),
|
||||
).not.toContain("PER_ITEM");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -98,6 +98,10 @@ function unitsForShape(input: {
|
||||
switch (appliesTo) {
|
||||
case 'CONTAINER':
|
||||
return ['PER_CONTAINER', 'PER_WAGON'];
|
||||
case 'EMPTY_CONTAINER':
|
||||
// Empty equipment carries no cargo to weigh, so the only bases that mean
|
||||
// anything are the box itself and the wagon it rides on.
|
||||
return ['PER_CONTAINER', 'PER_WAGON'];
|
||||
case 'BULK':
|
||||
return ['PER_TON', 'PER_WAGON'];
|
||||
case 'INTERCITY':
|
||||
|
||||
@@ -8,6 +8,12 @@ import { Yard } from './yard.entity';
|
||||
export const RATE_TYPES = [
|
||||
'CONTAINER_IMPORT',
|
||||
'CONTAINER_EXPORT',
|
||||
// Empty equipment moved as freight in its own right — no cargo, priced per
|
||||
// box by size. Distinct from CONTAINER_IMPORT because UQ_rates_pattern keys
|
||||
// on rate_type: an empty 40ft Djibouti->Modjo rate filed as CONTAINER_IMPORT
|
||||
// would collide with the laden 40ft rate for the same lane.
|
||||
'EMPTY_CONTAINER_IMPORT',
|
||||
'EMPTY_CONTAINER_EXPORT',
|
||||
'BULK_IMPORT',
|
||||
'BULK_EXPORT',
|
||||
'INTERCITY_BULK',
|
||||
@@ -59,12 +65,14 @@ export type RateUnit = typeof RATE_UNITS[number];
|
||||
* lookup and snapshots).
|
||||
*
|
||||
* - BULK / CONTAINER / INTERCITY : base rail freight (trigger = ALWAYS)
|
||||
* - EMPTY_CONTAINER : base rail freight for empty equipment
|
||||
* - FIRST_MILE / LAST_MILE : pickup / delivery legs
|
||||
* - OTHER : trigger-based surcharges (hazard, reefer …)
|
||||
*/
|
||||
export const RATE_APPLIES_TO = [
|
||||
'BULK',
|
||||
'CONTAINER',
|
||||
'EMPTY_CONTAINER',
|
||||
'INTERCITY',
|
||||
'FIRST_MILE',
|
||||
'LAST_MILE',
|
||||
|
||||
@@ -24,7 +24,12 @@ import { IRatesRepository, RATES_REPOSITORY } from '../interfaces/rates.reposito
|
||||
import { IYardsRepository, YARDS_REPOSITORY } from '../interfaces/yards.repository.interface';
|
||||
|
||||
/** Categories priced per rail leg — they carry an origin → destination yard pair. */
|
||||
const BASE_FREIGHT_CATEGORIES: readonly Rate['appliesTo'][] = ['BULK', 'CONTAINER', 'INTERCITY'];
|
||||
const BASE_FREIGHT_CATEGORIES: readonly Rate['appliesTo'][] = [
|
||||
'BULK',
|
||||
'CONTAINER',
|
||||
'EMPTY_CONTAINER',
|
||||
'INTERCITY',
|
||||
];
|
||||
/**
|
||||
* Surcharges sold per cargo kind: the admin says container or bulk, a
|
||||
* container fee then names its container type and a bulk fee its commodity.
|
||||
@@ -381,6 +386,30 @@ export class RatesService {
|
||||
return;
|
||||
}
|
||||
|
||||
if (appliesTo === 'EMPTY_CONTAINER') {
|
||||
// Northbound repositioning only. Southbound empties are already sold by
|
||||
// the WITH_RETURN surcharge and empty_return_requests; a second path to
|
||||
// the same movement would let the business double-sell it.
|
||||
if (tradeDirection !== 'IMPORT') {
|
||||
throw new BadRequestException(
|
||||
'An empty container rate is import-only for now.',
|
||||
);
|
||||
}
|
||||
// Size is the entire scope of an empty rate — there is no cargo to narrow
|
||||
// by, so the box type must be named and a commodity must not be.
|
||||
if (!containerTypeId) {
|
||||
throw new BadRequestException(
|
||||
'An empty container rate must name the container type it covers.',
|
||||
);
|
||||
}
|
||||
if (cargoTypeId) {
|
||||
throw new BadRequestException(
|
||||
'An empty container rate cannot be scoped to a bulk cargo type.',
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (tradeDirection !== 'IMPORT' && tradeDirection !== 'EXPORT') {
|
||||
throw new BadRequestException(
|
||||
`${appliesTo === 'BULK' ? 'Bulk' : 'Container'} freight must be either IMPORT or EXPORT.`,
|
||||
|
||||
Reference in New Issue
Block a user