mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-06 05:23:38 +00:00
Merge pull request #1499 from Tria-plc/Empty-Container-Import
feat: empty container Import
This commit is contained in:
@@ -124,6 +124,8 @@ export class ContractDocumentViewModelBuilder {
|
||||
(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,
|
||||
);
|
||||
dynamicTemplate = dynamicSource
|
||||
? {
|
||||
|
||||
@@ -96,3 +96,55 @@ describe('ContractRateScheduleBuilder', () => {
|
||||
expect(s.isEmpty).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Empty and laden freight are separate tariffs on the same lanes. Each
|
||||
* contract's schedule must show only its own, or the printed paper quotes a
|
||||
* price the customer is not being charged.
|
||||
*/
|
||||
describe('ContractRateScheduleBuilder — empty container contracts', () => {
|
||||
const ladenImport = rate({
|
||||
appliesTo: 'CONTAINER',
|
||||
tradeDirection: 'IMPORT',
|
||||
rateType: 'CONTAINER_IMPORT',
|
||||
rateValue: 900,
|
||||
originYard: { label: 'Negad' } as never,
|
||||
destinationYard: { label: 'Mojo Dry Port' } as never,
|
||||
containerType: { label: '40ft GP' } as never,
|
||||
});
|
||||
|
||||
const emptyImport = rate({
|
||||
appliesTo: 'EMPTY_CONTAINER',
|
||||
tradeDirection: 'IMPORT',
|
||||
rateType: 'EMPTY_CONTAINER_IMPORT',
|
||||
rateValue: 250,
|
||||
originYard: { label: 'Negad' } as never,
|
||||
destinationYard: { label: 'Mojo Dry Port' } as never,
|
||||
containerType: { label: '40ft GP' } as never,
|
||||
});
|
||||
|
||||
const builder = new ContractRateScheduleBuilder({
|
||||
findLiveRatesDetailed: jest.fn().mockResolvedValue([ladenImport, emptyImport]),
|
||||
} as never);
|
||||
|
||||
it('shows only the empty lane on an empty contract', async () => {
|
||||
const schedule = await builder.build('IMP', 'CON', 'EMPTY');
|
||||
|
||||
expect(schedule.freightLanes).toHaveLength(1);
|
||||
expect(schedule.freightLanes[0].amount).toBe('250');
|
||||
});
|
||||
|
||||
it('shows only the laden lane on a laden contract', async () => {
|
||||
const schedule = await builder.build('IMP', 'CON', 'LADEN');
|
||||
|
||||
expect(schedule.freightLanes).toHaveLength(1);
|
||||
expect(schedule.freightLanes[0].amount).toBe('900');
|
||||
});
|
||||
|
||||
it('treats a contract with no condition as laden', async () => {
|
||||
const schedule = await builder.build('IMP', 'CON');
|
||||
|
||||
expect(schedule.freightLanes).toHaveLength(1);
|
||||
expect(schedule.freightLanes[0].amount).toBe('900');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -84,7 +84,9 @@ export class ContractRateScheduleBuilder {
|
||||
async build(
|
||||
direction: ContractDirection,
|
||||
freight: ContractFreight,
|
||||
cargoCondition?: string | null,
|
||||
): Promise<RateSchedule> {
|
||||
const isEmpty = cargoCondition === 'EMPTY';
|
||||
const rates = await this.ratesService.findLiveRatesDetailed();
|
||||
|
||||
const freightLanes: RateScheduleRow[] = [];
|
||||
@@ -93,7 +95,7 @@ export class ContractRateScheduleBuilder {
|
||||
|
||||
for (const rate of rates) {
|
||||
if (this.isBaseFreight(rate)) {
|
||||
if (this.baseFreightMatches(rate, direction, freight)) {
|
||||
if (this.baseFreightMatches(rate, direction, freight, isEmpty)) {
|
||||
freightLanes.push(this.laneRow(rate));
|
||||
}
|
||||
continue;
|
||||
@@ -140,6 +142,7 @@ export class ContractRateScheduleBuilder {
|
||||
rate.trigger === 'ALWAYS' &&
|
||||
(rate.appliesTo === 'BULK' ||
|
||||
rate.appliesTo === 'CONTAINER' ||
|
||||
rate.appliesTo === 'EMPTY_CONTAINER' ||
|
||||
rate.appliesTo === 'INTERCITY')
|
||||
);
|
||||
}
|
||||
@@ -148,7 +151,19 @@ export class ContractRateScheduleBuilder {
|
||||
rate: Rate,
|
||||
direction: ContractDirection,
|
||||
freight: ContractFreight,
|
||||
isEmpty = false,
|
||||
): boolean {
|
||||
// Empty and laden are separate tariffs on the same lanes, so each contract
|
||||
// shows only its own. Without this an empty contract would print the laden
|
||||
// lane prices it is not being charged.
|
||||
if (isEmpty) {
|
||||
return (
|
||||
rate.appliesTo === 'EMPTY_CONTAINER' &&
|
||||
rate.tradeDirection === (direction === 'EXP' ? 'EXPORT' : 'IMPORT')
|
||||
);
|
||||
}
|
||||
if (rate.appliesTo === 'EMPTY_CONTAINER') return false;
|
||||
|
||||
// Domestic contracts price off intercity rates; the freight kind is carried
|
||||
// in the derived rateType (INTERCITY_BULK vs INTERCITY_CONTAINER).
|
||||
if (direction === 'DOM') {
|
||||
|
||||
@@ -140,6 +140,8 @@ export class ContractViewModelBuilder {
|
||||
const rateSchedule = await this.rateScheduleBuilder.build(
|
||||
template.direction,
|
||||
template.freight,
|
||||
// Empty bookings print the empty tariff, never the laden lane prices.
|
||||
booking.cargoCondition,
|
||||
);
|
||||
const signatures = await this.loadSignatures(bookingId);
|
||||
const logoImageUrl = await this.logoSettings.getLogoImageUrl();
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Empty container import is base rail freight for equipment carrying no cargo,
|
||||
* so it is sold per lane exactly like laden container freight.
|
||||
*
|
||||
* CK_rates_yard_scope gains EMPTY_CONTAINER in its yard-carrying branch: an
|
||||
* empty rate prices a leg (Djibouti -> Modjo), so both yards stay required.
|
||||
* Drop-and-recreate is the established shape for this constraint — see
|
||||
* 3430000000000-FuelSurcharge and 3640000000000-EthiopianCustomsClearance.
|
||||
*/
|
||||
export class EmptyContainerRateScope3890000000000 implements MigrationInterface {
|
||||
name = 'EmptyContainerRateScope3890000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope"`,
|
||||
);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.rates ADD CONSTRAINT "CK_rates_yard_scope" CHECK (
|
||||
deleted_at IS NOT NULL OR status = 'SUPERSEDED' OR
|
||||
CASE
|
||||
WHEN (trigger = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'EMPTY_CONTAINER', 'INTERCITY'))
|
||||
OR trigger IN ('CUSTOMS_CLEARANCE', 'ETHIOPIAN_CUSTOMS_CLEARANCE', 'WITH_RETURN', 'FUEL')
|
||||
THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL
|
||||
ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL
|
||||
END
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope"`,
|
||||
);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.rates ADD CONSTRAINT "CK_rates_yard_scope" CHECK (
|
||||
deleted_at IS NOT NULL OR status = 'SUPERSEDED' OR
|
||||
CASE
|
||||
WHEN (trigger = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY'))
|
||||
OR trigger IN ('CUSTOMS_CLEARANCE', 'ETHIOPIAN_CUSTOMS_CLEARANCE', 'WITH_RETURN', 'FUEL')
|
||||
THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL
|
||||
ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL
|
||||
END
|
||||
)
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Whether a booking moves cargo or bare equipment.
|
||||
*
|
||||
* EMPTY is container freight carrying nothing — the box itself is the shipment,
|
||||
* priced per size and lane off an EMPTY_CONTAINER_IMPORT rate. Deliberately a
|
||||
* separate column rather than a third `freight_type`: an empty booking is still
|
||||
* CONTAINER freight for wagon footprint, yard and warehouse allocation, train
|
||||
* scheduling, marshalling and gate passes, and `freight_type` is read in ~880
|
||||
* places whose else-arm means "container".
|
||||
*
|
||||
* Every existing row is LADEN, which the default supplies — no backfill needed.
|
||||
*/
|
||||
export class BookingCargoCondition3900000000000 implements MigrationInterface {
|
||||
name = 'BookingCargoCondition3900000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ADD COLUMN IF NOT EXISTS cargo_condition varchar(10) NOT NULL DEFAULT 'LADEN'
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
DROP CONSTRAINT IF EXISTS "CK_bookings_cargo_condition"
|
||||
`);
|
||||
// Bulk carries no equipment of its own, so EMPTY only ever rides CONTAINER
|
||||
// freight. Enforced here so no API path can file the combination.
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ADD CONSTRAINT "CK_bookings_cargo_condition" CHECK (
|
||||
cargo_condition IN ('LADEN', 'EMPTY')
|
||||
AND (cargo_condition = 'LADEN' OR freight_type = 'CONTAINER')
|
||||
)
|
||||
`);
|
||||
|
||||
// The booking queues filter empties out of (and into) the laden lists.
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_bookings_cargo_condition
|
||||
ON freight.bookings (cargo_condition)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DROP INDEX IF EXISTS freight.idx_bookings_cargo_condition`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings DROP CONSTRAINT IF EXISTS "CK_bookings_cargo_condition"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS cargo_condition`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
import { CONTRACT_TEMPLATE_DEFAULTS } from '../seed/data/contract-template-defaults';
|
||||
|
||||
/**
|
||||
* Contract paper for empty container import.
|
||||
*
|
||||
* - contracts.cargo_condition mirrors bookings.cargo_condition, so a general
|
||||
* contract can commit to moving bare equipment.
|
||||
* - Seeds IMPORT_EMPTY_CONTAINER, the system template the document renderer
|
||||
* resolves for those contracts. It carries no customs variant: an empty box
|
||||
* has no declaration to clear, the same reason intercity is unsuffixed.
|
||||
*/
|
||||
const SEEDED_CODES = ['IMPORT_EMPTY_CONTAINER'] as const;
|
||||
|
||||
export class EmptyContainerContractTemplate3910000000000 implements MigrationInterface {
|
||||
name = 'EmptyContainerContractTemplate3910000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.contracts
|
||||
ADD COLUMN IF NOT EXISTS cargo_condition varchar(10) NOT NULL DEFAULT 'LADEN'
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.contracts
|
||||
DROP CONSTRAINT IF EXISTS "CK_contracts_cargo_condition"
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.contracts
|
||||
ADD CONSTRAINT "CK_contracts_cargo_condition" CHECK (
|
||||
cargo_condition IN ('LADEN', 'EMPTY')
|
||||
AND (cargo_condition = 'LADEN' OR freight_type = 'CONTAINER')
|
||||
)
|
||||
`);
|
||||
|
||||
for (const code of SEEDED_CODES) {
|
||||
const seed = CONTRACT_TEMPLATE_DEFAULTS.find((t) => t.code === code);
|
||||
if (!seed) throw new Error(`Missing contract template default for ${code}`);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO freight.contract_templates
|
||||
(id, code, name, description, document_title, whereas_clauses, articles,
|
||||
is_active, is_system, created_at, updated_at)
|
||||
SELECT gen_random_uuid(), $1::varchar, $2, $3, $4, $5::jsonb, $6::jsonb,
|
||||
true, true, now(), now()
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM freight.contract_templates
|
||||
WHERE code = $1::varchar AND deleted_at IS NULL
|
||||
)`,
|
||||
[
|
||||
seed.code,
|
||||
seed.name,
|
||||
seed.description,
|
||||
seed.documentTitle,
|
||||
JSON.stringify(seed.whereasClauses),
|
||||
JSON.stringify(
|
||||
seed.articles.map((article, index) => ({ ...article, order: index + 1 })),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DELETE FROM freight.contract_templates WHERE code = ANY($1::varchar[]) AND is_system = true`,
|
||||
[[...SEEDED_CODES]],
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.contracts DROP CONSTRAINT IF EXISTS "CK_contracts_cargo_condition"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.contracts DROP COLUMN IF EXISTS cargo_condition`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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.`,
|
||||
|
||||
@@ -912,6 +912,129 @@ Settle assessed duties and taxes within the period notified by the Service Provi
|
||||
),
|
||||
];
|
||||
|
||||
/* ────────────────────────── IMPORT / EMPTY CONTAINER ─────────────────────── */
|
||||
|
||||
/**
|
||||
* Empty container import — bare equipment railed north from Djibouti for
|
||||
* repositioning inland. Not a variant of the laden import pack: there is no
|
||||
* cargo to describe, no VGM to declare, no commercial documents to lodge and no
|
||||
* customs leg to sell, so the paper is a straight equipment-carriage agreement.
|
||||
* Priced per box by size (20ft / 40ft) and lane, off an EMPTY_CONTAINER_IMPORT
|
||||
* rate.
|
||||
*/
|
||||
const IMPORT_EMPTY_CONTAINER_BASE: ContractTemplateBase = {
|
||||
name: "Empty Container Import Contract",
|
||||
description:
|
||||
"Railway transport of empty containers from Djibouti (DMP/Nagad) to the agreed Ethiopian terminal for repositioning. Priced per container by size; no cargo, no customs clearing.",
|
||||
documentTitle: "Empty Container Transportation Service by Railway",
|
||||
whereasClauses: [
|
||||
"The Client has requested and agreed to the transportation of empty containers from the Djibouti railway terminals (DMP or Nagad) to the agreed Ethiopian destination terminal using the Addis Ababa\u2013Djibouti railway line.",
|
||||
"The containers covered by this Agreement carry no cargo, and the Service Provider is engaged for the carriage of the equipment itself.",
|
||||
"The Service Provider has agreed to transport the empty containers as per the terms of this contract.",
|
||||
],
|
||||
articles: [
|
||||
a(
|
||||
"objective",
|
||||
"Objective and Scope of the Services",
|
||||
`To provide railway transportation services for empty 20ft and/or 40ft containers from the agreed Djibouti loading terminal (DMP or Nagad Railway Station) to the agreed Ethiopian destination terminal.
|
||||
The scope of the services comprises:
|
||||
- Terminal handling and loading of the empty containers onto flat wagons at the Djibouti loading terminal.
|
||||
- Railway transport between the agreed origin and destination terminals.
|
||||
- Unloading of the empty containers at the destination terminal.
|
||||
The containers covered by this Agreement carry no cargo. Any container found to be laden at loading falls outside this Agreement and shall be handled and priced as a laden shipment.`,
|
||||
),
|
||||
a(
|
||||
"client-obligations",
|
||||
"Obligations of the Client",
|
||||
`Give written/email/electronic shipment instructions to the Service Provider stating the number of empty containers by size (20ft and/or 40ft), the loading terminal and the destination terminal.
|
||||
Provide the container release order or equivalent instruction from the container owner or its agent, together with the container numbers, before loading.
|
||||
Warrant that every container tendered is empty, free of residue, and holds no cargo, dunnage or personal effects.
|
||||
Ensure the containers are presented at the loading terminal, in a condition fit for rail carriage, one day before the planned loading date.
|
||||
One flat wagon carries either one 40ft container or two 20ft containers.
|
||||
Book wagons at least five (5) days in advance.
|
||||
Assign representatives at both ends to oversee container handover.
|
||||
Collect the empty containers from the destination terminal within three (3) calendar days from the day following the arrival notice.
|
||||
If the Client fails to collect the containers within the specified period, the Client shall be liable to pay the applicable demurrage, storage and double handling charges of the destination terminal.
|
||||
Settle all charges due under this Agreement in accordance with the agreed payment terms.`,
|
||||
),
|
||||
a(
|
||||
"provider-obligations",
|
||||
"Obligations of the Service Provider",
|
||||
`Provide the agreed number of flat wagons on the agreed loading date, subject to wagon availability and the allocation priority applicable to the booking.
|
||||
Handle and load the empty containers at the Djibouti loading terminal and unload them at the destination terminal.
|
||||
Transport the empty containers to the agreed destination terminal and issue an arrival notice to the Client.
|
||||
Record the condition of each container at handover, and hand over the containers at destination in the condition in which they were received, fair wear and tear from carriage excepted.
|
||||
Issue the consignment note and the interchange documentation for each shipment.
|
||||
Notify the Client without delay of any incident affecting the containers in the Service Provider's custody.`,
|
||||
),
|
||||
a(
|
||||
"liability",
|
||||
"Liability for the Equipment",
|
||||
`The Service Provider's liability under this Agreement is limited to loss of, or physical damage to, the containers while in its custody between loading at the origin terminal and handover at the destination terminal.
|
||||
Because the containers carry no cargo, no cargo liability, cargo insurance obligation or cargo declaration arises under this Agreement.
|
||||
The Service Provider shall not be liable for pre-existing damage recorded at loading, nor for damage arising from a defect in the container itself.
|
||||
The Client shall indemnify the Service Provider against any claim arising from a container tendered as empty that is later found to contain cargo, residue or prohibited goods.`,
|
||||
),
|
||||
a(
|
||||
"force-majeure",
|
||||
"Force Majeure",
|
||||
`Neither party shall be liable for failure to perform its obligations under this Agreement where such failure results from an event beyond its reasonable control, including natural disaster, war, civil unrest, government action, or closure of the railway line or terminals.
|
||||
The affected party shall notify the other in writing within five (5) calendar days of the occurrence and shall resume performance as soon as the event ceases.`,
|
||||
),
|
||||
a(
|
||||
"pricing",
|
||||
"Contract Price and Terms of Payment",
|
||||
`The price is charged per empty container carried, at the agreed rate for each container size (20ft and 40ft) on the agreed origin\u2013destination lane, as set out in the rate schedule to this Agreement.
|
||||
The price covers terminal handling, loading, railway carriage and unloading as described in the Scope of the Services. It excludes any charge levied by the destination terminal after the free period, and any first-mile or last-mile road leg unless separately agreed.
|
||||
Payment shall be made in accordance with the payment terms stated in this Agreement; where the price is quoted in USD and settled in Birr, conversion applies the Commercial Bank of Ethiopia's daily selling exchange rate on the date of payment.
|
||||
The Service Provider may revise the rates on prior written notice to the Client.`,
|
||||
),
|
||||
a(
|
||||
"contract-documents",
|
||||
"Contract Documents",
|
||||
`The following form an integral part of this Agreement:
|
||||
- This Agreement and its rate schedule.
|
||||
- The container release order or equivalent instruction from the container owner or its agent.
|
||||
- The shipment instruction given by the Client for each consignment.
|
||||
- The consignment note and interchange documents issued for each shipment.`,
|
||||
),
|
||||
a(
|
||||
"consignment-notes",
|
||||
"Consignment Notes",
|
||||
`A consignment note shall be issued for each shipment, stating the container numbers, sizes, the origin and destination terminals and the recorded condition of each container.
|
||||
The consignment note is evidence of the containers received for carriage and of their condition at handover.`,
|
||||
),
|
||||
a(
|
||||
"amendment",
|
||||
"Amendment",
|
||||
`Any amendment to this Agreement shall be valid only if made in writing and signed by the authorised representatives of both parties.`,
|
||||
),
|
||||
a(
|
||||
"termination",
|
||||
"Termination of Contract",
|
||||
`Either party may terminate this Agreement by giving thirty (30) calendar days' prior written notice to the other party.
|
||||
Either party may terminate this Agreement with immediate effect where the other party commits a material breach and fails to remedy it within fifteen (15) calendar days of written notice.
|
||||
Termination does not affect any obligation accrued before the effective date of termination, including payment for shipments already performed or in transit.`,
|
||||
),
|
||||
a(
|
||||
"effectiveness",
|
||||
"Contract Effectiveness",
|
||||
`This Agreement becomes effective on the date it is signed by the authorised representatives of both parties.`,
|
||||
),
|
||||
a(
|
||||
"duration",
|
||||
"Contract Period",
|
||||
`This Agreement shall remain in force for the period stated in the Agreement, unless terminated earlier in accordance with the Termination article.`,
|
||||
),
|
||||
a(
|
||||
"disputes",
|
||||
"Settlement of Disputes",
|
||||
`The parties shall attempt to settle any dispute arising out of or in connection with this Agreement amicably.
|
||||
Failing amicable settlement, the dispute shall be resolved in accordance with the laws of the Federal Democratic Republic of Ethiopia before the competent courts of Ethiopia.`,
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
/** Build the stored `_CUSTOMS` / `_ETHIOPIAN_CUSTOMS` / `_NO_CUSTOMS` trio for one base pack. */
|
||||
function splitByCustoms(
|
||||
base: ContractTemplateBase,
|
||||
@@ -944,10 +1067,11 @@ function splitByCustoms(
|
||||
}
|
||||
|
||||
/**
|
||||
* Fourteen templates: import and export each split by customs clearing option
|
||||
* Fifteen templates: import and export each split by customs clearing option
|
||||
* (full, Ethiopian-only, none), intercity
|
||||
* not split at all — it is a domestic Ethiopian movement that crosses no
|
||||
* border, so there is no customs leg to contract for.
|
||||
* border, so there is no customs leg to contract for. Empty container import
|
||||
* is unsplit for the same reason: bare equipment carries no declaration.
|
||||
*/
|
||||
export const CONTRACT_TEMPLATE_DEFAULTS: ContractTemplateSeed[] = [
|
||||
...splitByCustoms(IMPORT_BULK_BASE, "IMPORT_BULK"),
|
||||
@@ -956,4 +1080,5 @@ export const CONTRACT_TEMPLATE_DEFAULTS: ContractTemplateSeed[] = [
|
||||
...splitByCustoms(IMPORT_CONTAINER_BASE, "IMPORT_CONTAINER"),
|
||||
...splitByCustoms(EXPORT_CONTAINER_BASE, "EXPORT_CONTAINER"),
|
||||
{ ...INTERCITY_CONTAINER_BASE, code: "INTERCITY_CONTAINER" },
|
||||
{ ...IMPORT_EMPTY_CONTAINER_BASE, code: "IMPORT_EMPTY_CONTAINER" },
|
||||
];
|
||||
|
||||
@@ -206,6 +206,10 @@ export const LEGACY_APPROVAL_ROLES = [
|
||||
const RATE_APPLIES_TO = [
|
||||
{ label: "Bulk (base freight)", value: "BULK" },
|
||||
{ label: "Container (base freight)", value: "CONTAINER" },
|
||||
{
|
||||
label: "Empty container (base freight, import)",
|
||||
value: "EMPTY_CONTAINER",
|
||||
},
|
||||
{ label: "Intercity (base freight)", value: "INTERCITY" },
|
||||
{ label: "First mile", value: "FIRST_MILE" },
|
||||
{ label: "Last mile", value: "LAST_MILE" },
|
||||
@@ -290,7 +294,9 @@ const SHIPPING_LINE_CARGO_KINDS = [
|
||||
|
||||
/** True when the rate being edited is base rail freight, which is priced per leg. */
|
||||
const isBaseFreightRate = (values: Record<string, unknown>) =>
|
||||
["BULK", "CONTAINER", "INTERCITY"].includes(String(values.appliesTo ?? ""));
|
||||
["BULK", "CONTAINER", "EMPTY_CONTAINER", "INTERCITY"].includes(
|
||||
String(values.appliesTo ?? ""),
|
||||
);
|
||||
|
||||
/** Surcharges sold per origin → destination leg (mirrors RatesService.isRouteScoped). */
|
||||
export const ROUTE_SCOPED_TRIGGERS = [
|
||||
@@ -388,6 +394,9 @@ const unitsForShape = (
|
||||
switch (appliesTo) {
|
||||
case "CONTAINER":
|
||||
return ["PER_CONTAINER", "PER_WAGON"];
|
||||
case "EMPTY_CONTAINER":
|
||||
// No cargo to weigh — only the box and the wagon it rides on.
|
||||
return ["PER_CONTAINER", "PER_WAGON"];
|
||||
case "BULK":
|
||||
return ["PER_TON", "PER_WAGON"];
|
||||
case "INTERCITY":
|
||||
@@ -1144,6 +1153,11 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
},
|
||||
{ key: "container", label: "Container", filters: { appliesTo: "CONTAINER", isShippingLineRate: "false" } },
|
||||
{ key: "bulk", label: "Bulk", filters: { appliesTo: "BULK", isShippingLineRate: "false" } },
|
||||
{
|
||||
key: "empty-container",
|
||||
label: "Empty container",
|
||||
filters: { appliesTo: "EMPTY_CONTAINER", isShippingLineRate: "false" },
|
||||
},
|
||||
{ key: "intercity", label: "Intercity", filters: { appliesTo: "INTERCITY", isShippingLineRate: "false" } },
|
||||
{
|
||||
key: "trucking",
|
||||
@@ -1301,8 +1315,10 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
type: "select",
|
||||
required: true,
|
||||
optionsFromValues: (v: Record<string, unknown>) =>
|
||||
String(v.appliesTo ?? "") === "OTHER" &&
|
||||
String(v.trigger ?? "") === "WITH_RETURN"
|
||||
// Empty freight and the empty-return surcharge are both import-only.
|
||||
String(v.appliesTo ?? "") === "EMPTY_CONTAINER" ||
|
||||
(String(v.appliesTo ?? "") === "OTHER" &&
|
||||
String(v.trigger ?? "") === "WITH_RETURN")
|
||||
? TRADE_DIRECTIONS.filter((d) => d.value === "IMPORT")
|
||||
: String(v.appliesTo ?? "") === "OTHER" &&
|
||||
String(v.trigger ?? "") === "FUEL"
|
||||
@@ -1310,7 +1326,9 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
: TRADE_DIRECTIONS.filter((d) => d.value !== "BOTH"),
|
||||
showIf: (v) =>
|
||||
!isShippingLineRate(v) &&
|
||||
(["BULK", "CONTAINER"].includes(String(v.appliesTo ?? "")) ||
|
||||
(["BULK", "CONTAINER", "EMPTY_CONTAINER"].includes(
|
||||
String(v.appliesTo ?? ""),
|
||||
) ||
|
||||
(String(v.appliesTo ?? "") === "OTHER" &&
|
||||
[
|
||||
"CUSTOMS_CLEARANCE",
|
||||
@@ -1513,6 +1531,19 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
(v.appliesTo === "INTERCITY" && v.intercityKind === "CONTAINER") ||
|
||||
(v.appliesTo === "OTHER" && v.trigger === "WITH_RETURN")),
|
||||
},
|
||||
// Empty freight has no cargo to narrow by, so the box size IS the scope —
|
||||
// required here, unlike the laden catch-all above. The API rejects an
|
||||
// unscoped empty rate for the same reason.
|
||||
{
|
||||
name: "containerTypeId",
|
||||
label: "Container type",
|
||||
type: "select",
|
||||
required: true,
|
||||
placeholder: "Which container type this rate covers",
|
||||
description: "20ft and 40ft price differently — one rate per size per lane.",
|
||||
showIf: (v) =>
|
||||
!isShippingLineRate(v) && v.appliesTo === "EMPTY_CONTAINER",
|
||||
},
|
||||
// Container type for a shipping-line base-freight rate. Required here,
|
||||
// unlike the customer form's optional catch-all: a line negotiates a
|
||||
// price per box size, so an unscoped line rate has no meaning.
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
} from "@/services/bookings.service";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { REQUIRED_DOC_FIELDS } from "./constants";
|
||||
import { requiredDocFieldsFor } from "./constants";
|
||||
import { CardTitle, PageShell, SectionCard } from "./components/layout";
|
||||
import { CompanyInfoCard } from "./components/CompanyInfoCard";
|
||||
import { ContainersCard } from "./components/ContainersCard";
|
||||
@@ -73,10 +73,13 @@ export function DraftBookingView({
|
||||
() => new Set(booking.files?.map((f) => f.code) ?? []),
|
||||
[booking.files],
|
||||
);
|
||||
const uploadedCount = REQUIRED_DOC_FIELDS.filter((d) =>
|
||||
// An empty booking is measured against the equipment documents, not the
|
||||
// trade documents a laden shipment carries.
|
||||
const requiredDocs = requiredDocFieldsFor(booking.cargoCondition);
|
||||
const uploadedCount = requiredDocs.filter((d) =>
|
||||
uploadedCodes.has(d.key),
|
||||
).length;
|
||||
const allDocsUploaded = uploadedCount === REQUIRED_DOC_FIELDS.length;
|
||||
const allDocsUploaded = uploadedCount === requiredDocs.length;
|
||||
|
||||
const { data: generatedPricing } = useQuery(
|
||||
api.bookings.generatePrice.queryOptions({
|
||||
@@ -135,7 +138,7 @@ export function DraftBookingView({
|
||||
|
||||
function handleUploadAll() {
|
||||
const filesToUpload: Record<string, File | null> = {};
|
||||
for (const doc of REQUIRED_DOC_FIELDS) {
|
||||
for (const doc of requiredDocs) {
|
||||
if (selectedFiles[doc.key])
|
||||
filesToUpload[doc.key] = selectedFiles[doc.key]!;
|
||||
}
|
||||
@@ -144,7 +147,7 @@ export function DraftBookingView({
|
||||
}
|
||||
|
||||
function handleSubmitRequest() {
|
||||
const missing = REQUIRED_DOC_FIELDS.filter(
|
||||
const missing = requiredDocs.filter(
|
||||
(doc) => !uploadedCodes.has(doc.key),
|
||||
);
|
||||
if (missing.length > 0) {
|
||||
@@ -212,7 +215,7 @@ export function DraftBookingView({
|
||||
? "Required documents"
|
||||
: "Upload required documents"
|
||||
}
|
||||
desc={`${uploadedCount} of ${REQUIRED_DOC_FIELDS.length} uploaded.`}
|
||||
desc={`${uploadedCount} of ${requiredDocs.length} uploaded.`}
|
||||
action={
|
||||
<StepGhostButton
|
||||
icon={<Upload size={16} color="#334155" />}
|
||||
@@ -264,7 +267,7 @@ export function DraftBookingView({
|
||||
<CardTitle>Documents</CardTitle>
|
||||
<CountChip
|
||||
uploaded={uploadedCount}
|
||||
total={REQUIRED_DOC_FIELDS.length}
|
||||
total={requiredDocs.length}
|
||||
/>
|
||||
</Group>
|
||||
</Group>
|
||||
@@ -280,7 +283,7 @@ export function DraftBookingView({
|
||||
)}
|
||||
|
||||
<Box>
|
||||
{REQUIRED_DOC_FIELDS.map((doc, i) => {
|
||||
{requiredDocs.map((doc, i) => {
|
||||
const isUploaded = uploadedCodes.has(doc.key);
|
||||
const selected = selectedFiles[doc.key];
|
||||
const file = booking.files?.find((f) => f.code === doc.key);
|
||||
@@ -290,7 +293,7 @@ export function DraftBookingView({
|
||||
return (
|
||||
<DocRow
|
||||
key={doc.key}
|
||||
last={i === REQUIRED_DOC_FIELDS.length - 1}
|
||||
last={i === requiredDocs.length - 1}
|
||||
title={doc.label}
|
||||
meta={
|
||||
isUploaded
|
||||
|
||||
@@ -459,3 +459,22 @@ export const REQUIRED_DOC_FIELDS = [
|
||||
{ key: "certificate_of_origin", label: "Certificate of Origin" },
|
||||
{ key: "letter_of_credit", label: "Letter of Credit / LC" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Empty container import moves bare equipment: there is no sale behind it, so
|
||||
* none of the trade documents above exist. What EDR needs instead is the
|
||||
* instruction authorising the move and the interchange record for the boxes.
|
||||
*/
|
||||
export const EMPTY_REQUIRED_DOC_FIELDS = [
|
||||
{ key: "container_release_order", label: "Container Release Order" },
|
||||
{ key: "equipment_interchange_receipt", label: "Equipment Interchange Receipt (EIR)" },
|
||||
];
|
||||
|
||||
/** The document checklist a booking is measured against, by cargo condition. */
|
||||
export function requiredDocFieldsFor(
|
||||
cargoCondition?: string | null,
|
||||
): Array<{ key: string; label: string }> {
|
||||
return cargoCondition === "EMPTY"
|
||||
? EMPTY_REQUIRED_DOC_FIELDS
|
||||
: REQUIRED_DOC_FIELDS;
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
getRouteDirection,
|
||||
initialBookingFormValues,
|
||||
isForwarderOperation,
|
||||
operationToCargoCondition,
|
||||
operationToProfileType,
|
||||
operationToTradeDirection,
|
||||
stepFields,
|
||||
@@ -554,6 +555,9 @@ export default function NewBookingPage() {
|
||||
data.cargoType === "container"
|
||||
? ("CONTAINER" as const)
|
||||
: ("BULK" as const),
|
||||
// Bare equipment: the box is the shipment. The API prices it off the
|
||||
// EMPTY_CONTAINER_IMPORT tariff and skips customs entirely.
|
||||
cargoCondition: operationToCargoCondition(data.operationType),
|
||||
containers:
|
||||
data.cargoType === "container"
|
||||
? data.containers.map((c) => ({
|
||||
|
||||
@@ -21,9 +21,25 @@ export const OPERATION_TYPES = [
|
||||
// direct importer/exporter profile.
|
||||
"import_ff",
|
||||
"export_ff",
|
||||
// Empty container import: bare equipment railed north from Djibouti for
|
||||
// repositioning. IMPORT direction, container freight, no cargo — priced per
|
||||
// box by size off its own tariff.
|
||||
"empty_import",
|
||||
] as const;
|
||||
export type OperationType = (typeof OPERATION_TYPES)[number];
|
||||
|
||||
/** Whether the operation moves cargo or bare equipment. */
|
||||
export function operationToCargoCondition(
|
||||
op: OperationType | undefined,
|
||||
): "LADEN" | "EMPTY" {
|
||||
return op === "empty_import" ? "EMPTY" : "LADEN";
|
||||
}
|
||||
|
||||
/** True when the wizard should collect equipment only — no cargo, no customs. */
|
||||
export function isEmptyOperation(op: OperationType | undefined): boolean {
|
||||
return operationToCargoCondition(op) === "EMPTY";
|
||||
}
|
||||
|
||||
/**
|
||||
* Shipment documents collected during booking creation. The fileKeys mirror
|
||||
* `REQUIRED_DOC_FIELDS` in BookingDetailPage/constants.ts.
|
||||
@@ -552,8 +568,13 @@ export function allowedOperationsForProfiles(
|
||||
}
|
||||
}
|
||||
|
||||
// Any customer-side profile can also run domestic (intercity).
|
||||
if (isForwarder || isDirect) ops.add("intercity");
|
||||
// Any customer-side profile can also run domestic (intercity) and buy empty
|
||||
// equipment repositioning — an exporter needs boxes inland to stuff, an
|
||||
// importer and a forwarder both reposition on a client's behalf.
|
||||
if (isForwarder || isDirect) {
|
||||
ops.add("intercity");
|
||||
ops.add("empty_import");
|
||||
}
|
||||
|
||||
// Preserve a stable display order.
|
||||
return OPERATION_TYPES.filter((o) => ops.has(o));
|
||||
@@ -582,7 +603,9 @@ export function isForwarderOperation(
|
||||
export function operationToTradeDirection(
|
||||
op: OperationType,
|
||||
): Freight.ScheduleTradeDirection {
|
||||
if (op === "import" || op === "import_ff") return "IMPORT";
|
||||
if (op === "import" || op === "import_ff" || op === "empty_import") {
|
||||
return "IMPORT";
|
||||
}
|
||||
if (op === "export" || op === "export_ff") return "EXPORT";
|
||||
return "DOMESTIC";
|
||||
}
|
||||
@@ -597,6 +620,11 @@ export function operationToProfileType(
|
||||
): string {
|
||||
if (op === "import_ff" || op === "export_ff") return "freight_forwarder";
|
||||
if (isForwarderOperation(op, profileTypes)) return "freight_forwarder";
|
||||
// Empties are bought by importers, exporters restocking equipment and
|
||||
// forwarders alike; stamp it to whichever direct profile the company holds.
|
||||
if (op === "empty_import") {
|
||||
return profileTypes.includes("importer") ? "importer" : "freight_forwarder";
|
||||
}
|
||||
if (op === "import") return "importer";
|
||||
if (op === "export") return "exporter";
|
||||
return "freight_forwarder";
|
||||
@@ -616,6 +644,8 @@ export function filterBookableServices(
|
||||
return services.filter((s) => {
|
||||
if (!s.canBeBookedAlone) return false;
|
||||
if (operationType === "intercity" && s.includesCustoms) return false;
|
||||
// An empty box carries no declaration, so there is no clearance to sell.
|
||||
if (isEmptyOperation(operationType) && s.includesCustoms) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Controller, type UseFormReturn } from "react-hook-form";
|
||||
import {
|
||||
ArrowDownToLine,
|
||||
ArrowUpFromLine,
|
||||
Container,
|
||||
PackageCheck,
|
||||
PackageOpen,
|
||||
Truck,
|
||||
@@ -74,6 +75,15 @@ const OPTIONS: Array<{
|
||||
iconBg: "#EAF1FB",
|
||||
iconColor: "#2E5B96",
|
||||
},
|
||||
{
|
||||
value: "empty_import",
|
||||
title: "Empty Container Import",
|
||||
description:
|
||||
"Empty containers railed from Djibouti for repositioning. No cargo, no customs.",
|
||||
icon: <Container className="h-5 w-5" />,
|
||||
iconBg: "#FBF3E7",
|
||||
iconColor: "#A05A00",
|
||||
},
|
||||
];
|
||||
|
||||
export function Step0OperationType({
|
||||
@@ -119,6 +129,16 @@ export function Step0OperationType({
|
||||
description={opt.description}
|
||||
onClick={() => {
|
||||
field.onChange(opt.value);
|
||||
// Empty equipment is always containerised — set the cargo
|
||||
// kind here so the cargo step (which hides the picker) and
|
||||
// the submitted payload agree without the customer
|
||||
// choosing something that has no alternative.
|
||||
if (opt.value === "empty_import") {
|
||||
form.setValue("cargoType", "container", {
|
||||
shouldDirty: true,
|
||||
});
|
||||
form.setValue("cargoTypePath", [], { shouldDirty: true });
|
||||
}
|
||||
onSelect?.(opt.value);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { Freight } from "@edr/types";
|
||||
import {
|
||||
BookingFormInputValues,
|
||||
calcWagons,
|
||||
isEmptyOperation,
|
||||
type BookingFormValues,
|
||||
} from "./schema";
|
||||
import {
|
||||
@@ -46,6 +47,10 @@ export function Step5CargoDetails({
|
||||
isLoading?: boolean;
|
||||
}) {
|
||||
const cargoType = form.watch("cargoType");
|
||||
// Empty container import moves bare equipment: there is no commodity to pick
|
||||
// and bulk is not on offer, so the wizard locks the cargo kind to container
|
||||
// and asks only for sizes and counts.
|
||||
const isEmpty = isEmptyOperation(form.watch("operationType"));
|
||||
const cargoTypePath = form.watch("cargoTypePath") ?? [];
|
||||
const parentId = cargoTypePath[0];
|
||||
const childId = cargoTypePath[1];
|
||||
@@ -193,12 +198,16 @@ export function Step5CargoDetails({
|
||||
<StepCard>
|
||||
<StepHeader
|
||||
icon={<Package size={22} />}
|
||||
title="Cargo Details"
|
||||
description="Choose your cargo type and configuration. Container weight is captured later in operations."
|
||||
title={isEmpty ? "Container Details" : "Cargo Details"}
|
||||
description={
|
||||
isEmpty
|
||||
? "Tell us how many empty containers you are moving, by size. Empty containers carry no cargo, so no weight or commodity is collected."
|
||||
: "Choose your cargo type and configuration. Container weight is captured later in operations."
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Cargo Type */}
|
||||
<div className="space-y-3">
|
||||
{/* Cargo Type — hidden for empty equipment, which is always containers. */}
|
||||
<div className={isEmpty ? "hidden" : "space-y-3"}>
|
||||
<StepLabel>Cargo Type *</StepLabel>
|
||||
<Controller
|
||||
name="cargoType"
|
||||
@@ -239,7 +248,7 @@ export function Step5CargoDetails({
|
||||
|
||||
{/* Bulk freight type — pick the commodity FIRST so we know whether the
|
||||
cargo is measured in tons or items before asking for the quantity. */}
|
||||
{cargoType === "bulk" && (
|
||||
{cargoType === "bulk" && !isEmpty && (
|
||||
<div className="space-y-3">
|
||||
{freightTypeOptions.length > 0 ? (
|
||||
<Controller
|
||||
@@ -486,7 +495,7 @@ export function Step5CargoDetails({
|
||||
)}
|
||||
|
||||
{/* Container list — type + quantity only; no weight is collected here. */}
|
||||
{cargoType === "container" && (
|
||||
{(cargoType === "container" || isEmpty) && (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
@@ -56,6 +56,18 @@ export enum FreightType {
|
||||
Bulk = "BULK",
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a booking or contract moves cargo or bare equipment. EMPTY is
|
||||
* container freight carrying nothing — the box itself is the shipment, priced
|
||||
* per size and lane off an EMPTY_CONTAINER_IMPORT rate. Deliberately separate
|
||||
* from FreightType: an empty booking is still CONTAINER freight for wagon
|
||||
* footprint, yard allocation, scheduling and gate passes.
|
||||
*/
|
||||
export enum CargoCondition {
|
||||
Laden = "LADEN",
|
||||
Empty = "EMPTY",
|
||||
}
|
||||
|
||||
/**
|
||||
* Distinguishes a normal one-time booking from a general contract — an umbrella
|
||||
* commitment that is signed and paid once, then drawn down by many orders over
|
||||
@@ -900,6 +912,8 @@ export interface IBooking extends BaseEntity {
|
||||
bulkTotalWeightTons?: number | null;
|
||||
|
||||
freightType: FreightType;
|
||||
/** LADEN (default) or EMPTY — see {@link CargoCondition}. */
|
||||
cargoCondition?: CargoCondition | string | null;
|
||||
freightSubtype?: string | null;
|
||||
|
||||
isHazardous: boolean;
|
||||
@@ -1603,6 +1617,8 @@ export interface CreateBookingDto {
|
||||
destinationYardId: string;
|
||||
tradeDirection: string;
|
||||
freightType: string;
|
||||
/** LADEN (default) or EMPTY — see {@link CargoCondition}. */
|
||||
cargoCondition?: string | undefined;
|
||||
cargoTypeId?: string | undefined;
|
||||
cargoFreeText?: string | undefined;
|
||||
shippingLineId?: string | undefined;
|
||||
|
||||
Reference in New Issue
Block a user