diff --git a/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts b/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts index 6a64f6199..6cbb3cfd9 100644 --- a/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts +++ b/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts @@ -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 ? { diff --git a/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.spec.ts b/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.spec.ts index a7b007617..b993aa987 100644 --- a/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.spec.ts +++ b/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.spec.ts @@ -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'); + }); +}); diff --git a/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.ts b/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.ts index 8990e1b43..56d23ce79 100644 --- a/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.ts +++ b/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.ts @@ -84,7 +84,9 @@ export class ContractRateScheduleBuilder { async build( direction: ContractDirection, freight: ContractFreight, + cargoCondition?: string | null, ): Promise { + 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') { diff --git a/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts b/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts index 517934bb9..bcea28541 100644 --- a/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts +++ b/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts @@ -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(); diff --git a/apps/edr-freight-api/src/migrations/3890000000000-EmptyContainerRateScope.ts b/apps/edr-freight-api/src/migrations/3890000000000-EmptyContainerRateScope.ts new file mode 100644 index 000000000..8f14a7123 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3890000000000-EmptyContainerRateScope.ts @@ -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 { + 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 { + 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 + ) + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3900000000000-BookingCargoCondition.ts b/apps/edr-freight-api/src/migrations/3900000000000-BookingCargoCondition.ts new file mode 100644 index 000000000..34754e4f2 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3900000000000-BookingCargoCondition.ts @@ -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 { + 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 { + 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`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/3910000000000-EmptyContainerContractTemplate.ts b/apps/edr-freight-api/src/migrations/3910000000000-EmptyContainerContractTemplate.ts new file mode 100644 index 000000000..950fa64c0 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3910000000000-EmptyContainerContractTemplate.ts @@ -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 { + 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 { + 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`, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-freight.util.ts b/apps/edr-freight-api/src/modules/bookings/booking-freight.util.ts index e3e97f301..1f0da457a 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-freight.util.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-freight.util.ts @@ -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( diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts index 742088da9..dcecb0c14 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts @@ -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).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'); + }); +}); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts index e654889eb..4a745e16d 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts @@ -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, diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index ea901bb16..83daa0286 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -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 = { ...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], diff --git a/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts index a9aca53dd..a88c3f4c3 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts @@ -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; /** diff --git a/apps/edr-freight-api/src/modules/bookings/dto/validators/booking-freight.validator.ts b/apps/edr-freight-api/src/modules/bookings/dto/validators/booking-freight.validator.ts index 1365158b1..c3417d62d 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/validators/booking-freight.validator.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/validators/booking-freight.validator.ts @@ -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'; } diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index 9cf728a0d..b457dd259 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/contract-templates/contract-template-code.spec.ts b/apps/edr-freight-api/src/modules/contract-templates/contract-template-code.spec.ts index 50a912a1d..9aa071fc9 100644 --- a/apps/edr-freight-api/src/modules/contract-templates/contract-template-code.spec.ts +++ b/apps/edr-freight-api/src/modules/contract-templates/contract-template-code.spec.ts @@ -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()); }); diff --git a/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.ts b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.ts index 11cf412fa..d760333af 100644 --- a/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.ts +++ b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.ts @@ -54,6 +54,9 @@ const PREVIEW_TEMPLATE_KEYS: Record = { 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 { 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; diff --git a/apps/edr-freight-api/src/modules/contract-templates/entities/contract-template.entity.ts b/apps/edr-freight-api/src/modules/contract-templates/entities/contract-template.entity.ts index f94e5d52f..4cbf81316 100644 --- a/apps/edr-freight-api/src/modules/contract-templates/entities/contract-template.entity.ts +++ b/apps/edr-freight-api/src/modules/contract-templates/entities/contract-template.entity.ts @@ -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" diff --git a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts index ac400d9d3..5d8c24060 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts @@ -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 { diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts index 6c5390fee..dc9f8ac7b 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts @@ -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 diff --git a/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts index 88ab8beb7..1020dff13 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts index cdf6b4ecf..b776333b9 100644 --- a/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts +++ b/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.spec.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.spec.ts index 5902453a0..45c3b0abd 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.spec.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.spec.ts @@ -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'); + } + }); +}); diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.ts index 894080e81..2d7e3463a 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.ts @@ -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': diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.spec.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.spec.ts index 39d6174de..2a56c1755 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.spec.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.spec.ts @@ -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"); + }); +}); diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts index fd7754844..207359b70 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts @@ -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': diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts index cc57e4c65..dd6432cfa 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts @@ -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', diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts index a3361e526..87618bcdb 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts @@ -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.`, diff --git a/apps/edr-freight-api/src/seed/data/contract-template-defaults.ts b/apps/edr-freight-api/src/seed/data/contract-template-defaults.ts index 5ddc99cca..2a3bd17aa 100644 --- a/apps/edr-freight-api/src/seed/data/contract-template-defaults.ts +++ b/apps/edr-freight-api/src/seed/data/contract-template-defaults.ts @@ -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" }, ]; diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts index 0ec64f0bc..f72270852 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts @@ -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) => - ["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(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. diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/DraftBookingView.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/DraftBookingView.tsx index cc9084d85..458f23697 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/DraftBookingView.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/DraftBookingView.tsx @@ -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 = {}; - 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={ } @@ -264,7 +267,7 @@ export function DraftBookingView({ Documents @@ -280,7 +283,7 @@ export function DraftBookingView({ )} - {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 ( { + return cargoCondition === "EMPTY" + ? EMPTY_REQUIRED_DOC_FIELDS + : REQUIRED_DOC_FIELDS; +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx index 6b80ebd32..619f8fd36 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx @@ -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) => ({ diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts index 29e6ff117..03672f8b1 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts @@ -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; }); } diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step0-operation-type.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step0-operation-type.tsx index 75084bdbd..f19ebc04e 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step0-operation-type.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step0-operation-type.tsx @@ -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: , + 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); }} /> diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx index d8244cd60..43fbe5dbb 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx @@ -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({ } - 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 */} -
+ {/* Cargo Type — hidden for empty equipment, which is always containers. */} +
Cargo Type * {freightTypeOptions.length > 0 ? (
diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index 48b727d37..a54959041 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -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;