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 56d23ce79..97a211037 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 @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; import { RatesService } from '../modules/rule-engine/services/rates.service'; -import { Rate } from '../modules/rule-engine/entities/rate.entity'; +import { Rate, isContainerHazardRate } from '../modules/rule-engine/entities/rate.entity'; import { ContractDirection, ContractFreight, @@ -109,12 +109,21 @@ export class ContractRateScheduleBuilder { // Fuel is sold per lane + commodity — only lanes matching the contract's // direction belong on its schedule, labeled with their leg. if (rate.trigger === 'FUEL') { - if (this.fuelDirectionMatches(rate, direction)) { + if (this.laneDirectionMatches(rate, direction)) { surcharges.push(this.fuelRow(rate)); } continue; } + // The container hazard surcharge is sold per lane (+ box size) — only a + // container contract on a matching direction shows it, with its leg. + if (isContainerHazardRate(rate.trigger, rate.rateUnit)) { + if (freight === 'CON' && this.laneDirectionMatches(rate, direction)) { + surcharges.push(this.lanedSurchargeRow(rate)); + } + continue; + } + // Everything left is a trigger-based charge (surcharge / demurrage / customs). surcharges.push(this.surchargeRow(rate)); } @@ -203,12 +212,28 @@ export class ContractRateScheduleBuilder { }; } - private fuelDirectionMatches(rate: Rate, direction: ContractDirection): boolean { + /** Lane-sold surcharges (fuel, container hazard) match on the contract's direction. */ + private laneDirectionMatches(rate: Rate, direction: ContractDirection): boolean { const want = direction === 'IMP' ? 'IMPORT' : direction === 'EXP' ? 'EXPORT' : 'DOMESTIC'; return rate.tradeDirection === want; } + /** A lane-sold surcharge row — the leg rides along in the charge label. */ + private lanedSurchargeRow(rate: Rate): RateScheduleRow { + const origin = rate.originYard?.label ?? rate.originYard?.code ?? '—'; + const destination = + rate.destinationYard?.label ?? rate.destinationYard?.code ?? '—'; + const label = TRIGGER_ROUTE_LABELS[rate.trigger] ?? this.titleCase(rate.trigger); + return { + route: `${label} (${origin} → ${destination})`, + cargo: this.cargoLabel(rate), + currency: rate.currency, + amount: this.formatAmount(rate.rateValue), + unit: this.unitLabel(rate.rateUnit), + }; + } + /** * Fuel row — the lane matters, so it rides along in the charge label. * Per-liter collapses to one flat total (base liters × rate value); the diff --git a/apps/edr-freight-api/src/migrations/3960000000000-ContainerHazardRateScope.ts b/apps/edr-freight-api/src/migrations/3960000000000-ContainerHazardRateScope.ts new file mode 100644 index 000000000..ddfa43615 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3960000000000-ContainerHazardRateScope.ts @@ -0,0 +1,78 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * The container hazardous-cargo surcharge (HAZARDOUS billed PER_CONTAINER) is + * now sold per trade direction + origin → destination lane, optionally per + * container type (20ft / 40ft) — the same shape as the empty-return service. + * The per-ton (bulk) hazard rate keeps its global, unscoped shape. + * + * - CK_rates_yard_scope gains the per-container hazard rate in its + * yard-carrying branch. Drop-and-recreate is the established shape for this + * constraint — see 3890000000000-EmptyContainerRateScope. + * - Existing lane-less per-container hazard rows cannot satisfy the new + * branch and no longer match the way the engine prices container hazard + * (per lane + size), so they are SUPERSEDED — kept for the audit trail, out + * of the unique pattern index and out of pricing. The rates team re-enters + * the surcharge per lane; until then a hazardous container booking on that + * lane hard-blocks rather than shipping the service for free. + */ +export class ContainerHazardRateScope3960000000000 implements MigrationInterface { + name = "ContainerHazardRateScope3960000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + UPDATE freight.rates + SET status = 'SUPERSEDED' + WHERE trigger = 'HAZARDOUS' + AND rate_unit = 'PER_CONTAINER' + AND (origin_yard_id IS NULL OR destination_yard_id IS NULL) + AND deleted_at IS NULL + AND status <> 'SUPERSEDED' + `); + + 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') + OR (trigger = 'HAZARDOUS' AND rate_unit = 'PER_CONTAINER') + 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 { + // Lane-scoped per-container hazard rows have no place under the old + // constraint (surcharges carried no yards) — retire them the same way. + await queryRunner.query(` + UPDATE freight.rates + SET status = 'SUPERSEDED' + WHERE trigger = 'HAZARDOUS' + AND rate_unit = 'PER_CONTAINER' + AND (origin_yard_id IS NOT NULL OR destination_yard_id IS NOT NULL) + AND deleted_at IS NULL + AND status <> 'SUPERSEDED' + `); + + 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 + ) + `); + } +} diff --git a/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts index 1367e8c8a..3a5c938bb 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts @@ -215,9 +215,58 @@ export class ContractPricingService { // Conditional surcharges — shown only when the contract toggles them on AND // the rate has a non-zero value (a 0 rate means "no surcharge"). - if (contract.isHazardous) { + if (contract.isHazardous && contract.freightType === 'CONTAINER') { + // Container hazard is sold per direction + route + container type, like + // the empty-return service — one display line per contract size that has + // a configured rate (size-specific wins over the lane's catch-all). A + // size with no rate shows nothing here and hard-blocks at booking time. + // ponytail: bookings bill the live route rate, not a frozen snapshot. + const onLeg = route + ? liveRates.filter( + (r) => + r.rateType === 'HAZARD_SURCHARGE' && + r.rateUnit === 'PER_CONTAINER' && + r.currency === 'USD' && + r.tradeDirection === contract.tradeDirection && + r.originYardId === route.originYardId && + r.destinationYardId === route.destinationYardId, + ) + : []; + if (onLeg.length > 0) { + const sizes = (contract.cargoScope ?? []) + .map((c) => c.containerSize) + .filter((s): s is string => !!s); + const { items: containerTypes } = await this.containerTypesService.findAll({ + isActive: true, + pageSize: 100, + }); + for (const size of sizes) { + const sizeFt = size === '40ft' ? 40 : 20; + const matchedIds = new Set( + containerTypes.filter((ct) => ct.sizeFt === sizeFt).map((ct) => ct.id), + ); + const rate = + onLeg.find((r) => r.containerTypeId && matchedIds.has(r.containerTypeId)) ?? + onLeg.find((r) => !r.containerTypeId); + if (!rate || Number(rate.rateValue) <= 0) continue; + lineItems.push({ + code: 'HAZARD_SURCHARGE', + label: `Hazardous surcharge (${size})`, + unit: toContractUnit(rate.rateUnit), + unitPrice: convert(Number(rate.rateValue)), + containerSize: size, + conditionalOn: 'is_hazardous', + }); + } + } + } else if (contract.isHazardous) { + // Bulk hazard is the global per-ton rate; the per-container rows belong + // to container lanes and must not price a bulk contract. const hazard = liveRates.find( - (r) => r.rateType === 'HAZARD_SURCHARGE' && r.currency === 'USD', + (r) => + r.rateType === 'HAZARD_SURCHARGE' && + r.rateUnit !== 'PER_CONTAINER' && + r.currency === 'USD', ); if (hazard && Number(hazard.rateValue) > 0) { lineItems.push({ diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts index bca8ab278..7ae0b13f9 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts @@ -7,7 +7,7 @@ import { RATE_UNITS, } from '../entities/rate.entity'; -// DOMESTIC is accepted only for FUEL rates (an intercity fuel lane). +// DOMESTIC is accepted only for FUEL and per-container HAZARDOUS rates (an intercity lane). const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH', 'DOMESTIC'] as const; // ETB is accepted only for last-mile rates; the service forces USD elsewhere. const CURRENCIES = ['USD', 'ETB'] as const; @@ -26,7 +26,10 @@ export class CreateRateDto { @IsIn([...RATE_TRIGGERS]) trigger!: string; - @ApiPropertyOptional({ description: 'FK to container_types.id — set for container/intercity-container rates' }) + @ApiPropertyOptional({ + description: + 'FK to container_types.id — set for container/intercity-container rates, and optionally on the per-container HAZARDOUS surcharge (20ft / 40ft price differently; omitted = the lane catch-all)', + }) @IsOptional() @IsUUID() containerTypeId?: string; @@ -61,7 +64,7 @@ export class CreateRateDto { @ApiPropertyOptional({ description: - 'FK to yards.id — origin of the leg this rate prices. Required for base freight (bulk/container/intercity), rejected for surcharges and first/last mile.', + 'FK to yards.id — origin of the leg this rate prices. Required for base freight (bulk/container/intercity) and the lane-sold surcharges (customs clearance, empty return, fuel, per-container HAZARDOUS); rejected for every other surcharge and first/last mile.', }) @IsOptional() @IsUUID() @@ -69,7 +72,7 @@ export class CreateRateDto { @ApiPropertyOptional({ description: - 'FK to yards.id — destination of the leg this rate prices. Required for base freight (bulk/container/intercity), rejected for surcharges and first/last mile.', + 'FK to yards.id — destination of the leg this rate prices. Required for base freight (bulk/container/intercity) and the lane-sold surcharges (customs clearance, empty return, fuel, per-container HAZARDOUS); rejected for every other surcharge and first/last mile.', }) @IsOptional() @IsUUID() 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 dd6432cfa..1fffd48e6 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 @@ -88,6 +88,10 @@ export type RateAppliesTo = typeof RATE_APPLIES_TO[number]; */ export const RATE_TRIGGERS = [ 'ALWAYS', + // Hazardous cargo. Two shapes under one trigger, told apart by the unit: + // PER_CONTAINER is the container surcharge, sold per direction + lane and + // optionally per box size (20ft / 40ft) like the empty-return service; + // PER_TON is the bulk surcharge, direction-agnostic and unscoped. 'HAZARDOUS', 'OVERWEIGHT', 'REEFER', @@ -121,6 +125,16 @@ export type RateTrigger = typeof RATE_TRIGGERS[number]; export const isCustomsClearanceTrigger = (trigger: string): boolean => trigger === 'CUSTOMS_CLEARANCE' || trigger === 'ETHIOPIAN_CUSTOMS_CLEARANCE'; +/** + * The container hazardous-cargo surcharge: HAZARDOUS billed per container. + * It is sold per trade direction + origin → destination lane, optionally + * narrowed to one container type (20ft / 40ft), and priced by the + * route-matched block in RuleEngineService — never by the additive loop. + * The per-ton (bulk) hazard rate keeps the old global, unscoped shape. + */ +export const isContainerHazardRate = (trigger: string, rateUnit: string): boolean => + trigger === 'HAZARDOUS' && rateUnit === 'PER_CONTAINER'; + @Entity({ schema: 'freight', name: 'rates' }) @Index(['rateType']) @Index(['status']) @@ -159,8 +173,10 @@ export class Rate extends BaseEntity { /** * The leg this rate prices. Base freight (trigger = ALWAYS) is quoted per * route — "container import, Djibouti → Dire Dawa" — so both yards are - * required for BULK/CONTAINER/INTERCITY and NULL for everything else. The - * `CK_rates_yard_scope` DB constraint enforces both halves of that. + * required for BULK/CONTAINER/EMPTY_CONTAINER/INTERCITY, for the lane-sold + * surcharges (customs clearance, empty return, fuel, container hazard) and + * NULL for everything else. The `CK_rates_yard_scope` DB constraint enforces + * both halves of that. */ @Column({ name: 'origin_yard_id', type: 'uuid', nullable: true }) originYardId?: string | null; diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.spec.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.spec.ts index 2d91d2da7..f4f22e198 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.spec.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.spec.ts @@ -3,12 +3,14 @@ import type { BookingEvaluationInput } from './rule-engine.service'; import type { Rate } from './entities/rate.entity'; describe('RuleEngineService — requested service without a configured surcharge rate', () => { + // The bulk (per-ton) hazard rate — global, no lane. These bookings carry no + // containers, so they are bulk-shaped and price off this one. const hazardRate: Rate = { id: 'rate-hazard', rateType: 'HAZARD_SURCHARGE', trigger: 'HAZARDOUS', rateValue: 50, - rateUnit: 'PER_CONTAINER', + rateUnit: 'PER_TON', currency: 'USD', status: 'LIVE', containerTypeId: null, @@ -650,6 +652,7 @@ describe('RuleEngineService — shipping-line rates override the standard ones', shippingLineCompanyId: LINE, } as Rate; + // Container hazard is sold per direction + lane (+ optional box size). const standardHazard: Rate = { id: 'rate-hazard-standard', rateType: 'HAZARD_SURCHARGE', @@ -661,6 +664,9 @@ describe('RuleEngineService — shipping-line rates override the standard ones', containerTypeId: null, cargoTypeId: null, shippingLineCompanyId: null, + tradeDirection: 'IMPORT', + originYardId: 'yard-dj', + destinationYardId: 'yard-adama', } as Rate; const lineHazard: Rate = { @@ -764,3 +770,161 @@ describe('RuleEngineService — shipping-line rates override the standard ones', expect(result.hardBlocked[0]).toContain('hazardous'); }); }); + +describe('RuleEngineService — container hazard surcharge per lane and box size', () => { + const lane = { + tradeDirection: 'IMPORT', + originYardId: 'yard-dj', + destinationYardId: 'yard-adama', + }; + const catchAll: Rate = { + id: 'rate-hazard-lane', + rateType: 'HAZARD_SURCHARGE', + trigger: 'HAZARDOUS', + rateValue: 50, + rateUnit: 'PER_CONTAINER', + currency: 'USD', + status: 'LIVE', + containerTypeId: null, + cargoTypeId: null, + ...lane, + } as Rate; + const forty: Rate = { + ...catchAll, + id: 'rate-hazard-lane-40', + rateValue: 90, + containerTypeId: 'ct-40', + } as Rate; + const bulkHazard: Rate = { + ...catchAll, + id: 'rate-hazard-bulk', + rateValue: 3, + rateUnit: 'PER_TON', + tradeDirection: null, + originYardId: null, + destinationYardId: null, + } as Rate; + + const buildService = (rates: Rate[]) => + new RuleEngineService( + { findById: jest.fn().mockResolvedValue(null) } as never, + { findById: jest.fn().mockResolvedValue(null) } as never, + { findActiveByContainerTypeId: jest.fn().mockResolvedValue([]) } as never, + { findAllActive: jest.fn().mockResolvedValue([]) } as never, + { findLiveRates: jest.fn().mockResolvedValue(rates) } as never, + { findById: jest.fn().mockResolvedValue(null) } as never, + {} as never, + ); + + const booking = (overrides: Partial = {}): BookingEvaluationInput => ({ + serviceTypeId: 'svc-1', + paymentCurrency: 'USD', + isHazardous: false, + totalWagons: 2, + ...lane, + containers: [ + { containerTypeId: 'ct-20', quantity: 3, vgmPerUnitTons: 10, totalVgmTons: 30, hazardousQuantity: 2 }, + { containerTypeId: 'ct-40', quantity: 1, vgmPerUnitTons: 10, totalVgmTons: 10, hazardousQuantity: 1 }, + ], + ...overrides, + }); + + const hazardOf = (result: { appliedModifiers: Array<{ surchargeCode: string }> }) => + result.appliedModifiers.filter((m) => m.surchargeCode === 'HAZARD_SURCHARGE'); + + it('bills each line off the lane rate for its own box size, catch-all otherwise', async () => { + const result = await buildService([catchAll, forty]).evaluate(booking()); + expect(result.hardBlocked).toHaveLength(0); + const lines = hazardOf(result); + expect(lines).toHaveLength(2); + // 2 hazardous 20ft on the lane catch-all, 1 hazardous 40ft on the 40ft rate. + expect(lines).toEqual( + expect.arrayContaining([ + expect.objectContaining({ rateId: catchAll.id, triggerValue: 2, calculatedAmount: 100, unitPriceUsd: 50, billingUnit: 'PER_CONTAINER' }), + expect.objectContaining({ rateId: forty.id, triggerValue: 1, calculatedAmount: 90, unitPriceUsd: 90 }), + ]), + ); + }); + + it('bills the opted-in count, not the whole line', async () => { + const result = await buildService([catchAll]).evaluate( + booking({ + containers: [ + { containerTypeId: 'ct-20', quantity: 10, vgmPerUnitTons: 10, totalVgmTons: 100, hazardousQuantity: 4 }, + ], + }), + ); + expect(hazardOf(result)).toEqual([ + expect.objectContaining({ triggerValue: 4, calculatedAmount: 200 }), + ]); + }); + + it('falls back to every container when only the legacy booking-level flag is set', async () => { + const result = await buildService([catchAll]).evaluate( + booking({ + isHazardous: true, + containers: [ + { containerTypeId: 'ct-20', quantity: 3, vgmPerUnitTons: 10, totalVgmTons: 30 }, + ], + }), + ); + expect(hazardOf(result)).toEqual([ + expect.objectContaining({ triggerValue: 3, calculatedAmount: 150 }), + ]); + }); + + it('never bills the per-container rate a second time through the additive loop', async () => { + const result = await buildService([catchAll]).evaluate( + booking({ + isHazardous: true, + containers: [ + { containerTypeId: 'ct-20', quantity: 2, vgmPerUnitTons: 10, totalVgmTons: 20 }, + ], + }), + ); + expect(hazardOf(result)).toHaveLength(1); + }); + + it('hard-blocks when the lane has no per-container hazard rate', async () => { + const result = await buildService([catchAll]).evaluate( + booking({ destinationYardId: 'yard-elsewhere' }), + ); + expect(hazardOf(result)).toHaveLength(0); + expect(result.hardBlocked).toHaveLength(1); + expect(result.hardBlocked[0]).toContain('hazardous'); + expect(result.hardBlocked[0]).toContain('route'); + }); + + it('hard-blocks when the rate is for the other direction', async () => { + const result = await buildService([catchAll]).evaluate( + booking({ tradeDirection: 'EXPORT', originYardId: 'yard-adama', destinationYardId: 'yard-dj' }), + ); + expect(result.hardBlocked).toHaveLength(1); + expect(result.hardBlocked[0]).toContain('hazardous'); + }); + + it('does not let the bulk per-ton rate stand in for a container booking', async () => { + const result = await buildService([bulkHazard]).evaluate(booking()); + expect(hazardOf(result)).toHaveLength(0); + expect(result.hardBlocked).toHaveLength(1); + }); + + it('bills a bulk booking off the global per-ton rate, untouched by the lane rule', async () => { + const result = await buildService([bulkHazard, catchAll]).evaluate( + booking({ isHazardous: true, containers: [], bulkTons: 40, totalWagons: 1 }), + ); + expect(result.hardBlocked).toHaveLength(0); + expect(hazardOf(result)).toEqual([ + expect.objectContaining({ rateId: bulkHazard.id, triggerValue: 40, calculatedAmount: 120 }), + ]); + }); + + it('hard-blocks a hazardous bulk booking when only the container rate exists', async () => { + const result = await buildService([catchAll]).evaluate( + booking({ isHazardous: true, containers: [], bulkTons: 40, totalWagons: 1 }), + ); + expect(result.hardBlocked).toHaveLength(1); + expect(result.hardBlocked[0]).toContain('hazardous'); + }); +}); + diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts index f035f8a64..2eb7c658e 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts @@ -1,7 +1,7 @@ import { Inject, Injectable, BadRequestException } from '@nestjs/common'; import { DataSource } from 'typeorm'; import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.entity'; -import { Rate, RateTrigger } from './entities/rate.entity'; +import { Rate, RateTrigger, isContainerHazardRate } from './entities/rate.entity'; import { isBulkQuantityUnit } from './entities/rate-unit.util'; import { ICargoTypesRepository, @@ -366,9 +366,11 @@ export class RuleEngineService { // hard block — pricing would otherwise ship the service for free. System- // derived charges (consolidation, overweight, shipping line, lashing) stay // exempt: the customer never opted into those, so they must not block. + const isContainerBooking = input.containers.length > 0; const requestedServices: Array<{ trigger: RateTrigger; wanted: boolean; + configured: boolean; label: string; }> = [ { @@ -376,6 +378,15 @@ export class RuleEngineService { wanted: truthy(input.isHazardous) || input.containers.some((c) => Number(c.hazardousQuantity ?? 0) > 0), + // Container hazard is sold per lane + box size and checked by the + // route-matched block below (which blocks per missing lane/type rate); + // bulk hazard is the global per-ton rate this loop can vouch for. + configured: isContainerBooking + ? true + : surchargeRates.some( + (r) => + r.trigger === 'HAZARDOUS' && !isContainerHazardRate(r.trigger, r.rateUnit), + ), label: 'hazardous cargo', }, { @@ -383,11 +394,12 @@ export class RuleEngineService { wanted: hasReefer || input.containers.some((c) => Number(c.reeferQuantity ?? 0) > 0), + configured: surchargeRates.some((r) => r.trigger === 'REEFER'), label: 'refrigerated (reefer) cargo', }, ]; for (const svc of requestedServices) { - if (svc.wanted && !surchargeRates.some((r) => r.trigger === svc.trigger)) { + if (svc.wanted && !svc.configured) { hardBlocked.push( `No ${svc.label} surcharge rate is configured — the booking cannot ` + `be priced with this service. Remove the ${svc.label} option or ` + @@ -411,6 +423,10 @@ export class RuleEngineService { // Fuel is sold per lane + cargo type — billed by the route-matched // block below, never by this route-agnostic loop. if (rate.trigger === 'FUEL') continue; + // The container hazard surcharge is sold per lane + box size like the + // empty-return service — billed by its own route-matched block below. + // The per-ton bulk hazard rate stays additive here. + if (isContainerHazardRate(rate.trigger, rate.rateUnit)) continue; const triggered = this.matchesTrigger(rate.trigger, { isHazardous: input.isHazardous, hasReefer, @@ -521,6 +537,10 @@ export class RuleEngineService { appliedModifiers.push(...withReturn.modifiers); hardBlocked.push(...withReturn.blocked); + const containerHazard = this.containerHazardCharges(input, liveRates); + appliedModifiers.push(...containerHazard.modifiers); + hardBlocked.push(...containerHazard.blocked); + if (hasLashing) { appliedModifiers.push(...this.lashingCharges(input, liveRates)); } @@ -730,6 +750,80 @@ export class RuleEngineService { return { modifiers, blocked: [...new Set(blocked)] }; } + /** + * Container hazardous-cargo surcharge — sold per direction + route, optionally + * per container type, exactly like the empty-return service. Each container + * line that opted in (hazardousQuantity, or every container when only the + * legacy booking-level flag is set) bills the route-matched PER_CONTAINER + * HAZARDOUS rate for its own container type, falling back to the lane's + * catch-all (no type) rate; a line with no matching rate hard-blocks the + * booking instead of shipping the service for free. Bulk bookings never + * reach here — their per-ton hazard rate is billed by the additive loop. + * ponytail: bills the LIVE route rate, not a frozen contract snapshot — one + * HAZARD_SURCHARGE snapshot code can't hold per-size route prices. + */ + private containerHazardCharges( + input: BookingEvaluationInput, + liveRates: Rate[], + ): { modifiers: AppliedCargoModifier[]; blocked: string[] } { + const modifiers: AppliedCargoModifier[] = []; + const blocked: string[] = []; + if (input.containers.length === 0) return { modifiers, blocked }; + const bookingLevel = truthy(input.isHazardous); + const wanted = + bookingLevel || + input.containers.some((c) => Number(c.hazardousQuantity ?? 0) > 0); + if (!wanted) return { modifiers, blocked }; + + const onLeg = liveRates.filter( + (r) => + isContainerHazardRate(r.trigger, r.rateUnit) && + r.currency === 'USD' && + r.tradeDirection === input.tradeDirection && + r.originYardId === input.originYardId && + r.destinationYardId === input.destinationYardId, + ); + + for (const container of input.containers) { + const qty = + Number(container.hazardousQuantity ?? 0) > 0 + ? Number(container.hazardousQuantity) + : bookingLevel + ? Number(container.quantity || 0) + : 0; + if (!(qty > 0)) continue; + + // The rate scoped to this box size wins over the lane's catch-all. + const rate = + onLeg.find((r) => r.containerTypeId === container.containerTypeId) ?? + onLeg.find((r) => !r.containerTypeId); + if (!rate) { + blocked.push( + 'No hazardous cargo surcharge rate is configured for this container ' + + 'type on this route — remove the hazardous option or ask EDR to ' + + 'configure its per-container rate for this origin → destination.', + ); + continue; + } + + const rateValue = Number(rate.rateValue); + const amount = qty * rateValue; + if (!(amount > 0)) continue; + modifiers.push({ + rateId: rate.id, + surchargeCode: this.surchargeCode(rate), + triggerValue: qty, + calculatedAmount: amount, + currency: rate.currency, + unitPriceUsd: rateValue, + billingUnit: rate.rateUnit, + }); + } + + // Same block deduplicated — several lines missing the rate is one problem. + return { modifiers, blocked: [...new Set(blocked)] }; + } + /** * Cargo securing / lashing — BULK only, sold per trade direction, optionally * narrowed to one leaf commodity (the commodity-scoped rate wins over the diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rates.duplicate-pattern.spec.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rates.duplicate-pattern.spec.ts index 0a4108a4e..47e56b776 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/rates.duplicate-pattern.spec.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rates.duplicate-pattern.spec.ts @@ -1,4 +1,4 @@ -import { ConflictException } from '@nestjs/common'; +import { BadRequestException, ConflictException } from '@nestjs/common'; import { RatesService } from './rates.service'; import type { Rate } from '../entities/rate.entity'; @@ -101,8 +101,9 @@ describe('RatesService — one rate per pattern', () => { /** * Additive surcharges are billed per matching rate, each by its own unit, so - * hazard is legitimately per-container for boxes AND per-ton for bulk. The - * unit stays part of their identity or the second one could never be created. + * the bulk (per-ton) hazard rate coexists with the lane-sold container one. + * The unit stays part of their identity or the second one could never be + * created. */ it('keeps the unit in the key for an additive surcharge', async () => { await service.create( @@ -110,17 +111,44 @@ describe('RatesService — one rate per pattern', () => { appliesTo: 'OTHER', trigger: 'HAZARDOUS', rateValue: 300, - rateUnit: 'PER_CONTAINER', + rateUnit: 'PER_TON', } as never, 'staff-1', ); expect(repository.findByPattern.mock.calls[0][0]).toMatchObject({ rateType: 'HAZARD_SURCHARGE', - rateUnit: 'PER_CONTAINER', + rateUnit: 'PER_TON', }); }); + it('keeps the per-ton hazard rate global — direction and lane are dropped', async () => { + await service.create( + { + appliesTo: 'OTHER', + trigger: 'HAZARDOUS', + tradeDirection: 'IMPORT', + originYardId: DJ, + destinationYardId: ET, + containerTypeId: CT20, + rateValue: 5, + rateUnit: 'PER_TON', + } as never, + 'staff-1', + ); + + expect(repository.create).toHaveBeenCalledWith( + expect.objectContaining({ + rateType: 'HAZARD_SURCHARGE', + rateUnit: 'PER_TON', + tradeDirection: null, + originYardId: null, + destinationYardId: null, + containerTypeId: null, + }), + ); + }); + it('treats lashing as singly resolved — one unit per direction', async () => { await service.create( { @@ -138,3 +166,166 @@ describe('RatesService — one rate per pattern', () => { ); }); }); + +/** + * The container hazard surcharge (HAZARDOUS per container) is sold per + * direction + lane, optionally per box size — the same shape as the + * empty-return service. Pricing resolves exactly one rate per lane + size, so + * the unit leaves the identity and the lane joins it. + */ +describe('RatesService — per-container hazard is sold per lane', () => { + const DJ = '11111111-1111-4000-8000-000000000001'; + const ET = '11111111-1111-4000-8000-000000000002'; + const ET2 = '11111111-1111-4000-8000-000000000004'; + const CT20 = '11111111-1111-4000-8000-000000000003'; + + let repository: { findByPattern: jest.Mock; create: jest.Mock }; + let service: RatesService; + + beforeEach(() => { + repository = { + findByPattern: jest.fn().mockResolvedValue(null), + create: jest.fn(async (r) => ({ id: 'rate-new', ...r })), + }; + service = new RatesService( + repository as never, + { + findById: jest.fn(async (id: string) => ({ + id, + country: id === DJ ? 'Djibouti' : 'Ethiopia', + label: id === DJ ? 'Doraleh' : id === ET ? 'Gelan' : 'Dire Dawa', + })), + } as never, + { findById: jest.fn().mockResolvedValue(null) } as never, + { findById: jest.fn() } as never, + ); + }); + + const containerHazard = { + appliesTo: 'OTHER', + trigger: 'HAZARDOUS', + rateValue: 300, + rateUnit: 'PER_CONTAINER', + }; + + it('requires a trade direction', async () => { + await expect( + service.create( + { ...containerHazard, originYardId: DJ, destinationYardId: ET } as never, + 'staff-1', + ), + ).rejects.toBeInstanceOf(BadRequestException); + expect(repository.create).not.toHaveBeenCalled(); + }); + + it('requires both yards of the lane', async () => { + await expect( + service.create( + { ...containerHazard, tradeDirection: 'IMPORT' } as never, + 'staff-1', + ), + ).rejects.toBeInstanceOf(BadRequestException); + expect(repository.create).not.toHaveBeenCalled(); + }); + + it('rejects a lane that contradicts the direction', async () => { + // Export runs Ethiopia → Djibouti; this leg is the import shape. + await expect( + service.create( + { + ...containerHazard, + tradeDirection: 'EXPORT', + originYardId: DJ, + destinationYardId: ET, + } as never, + 'staff-1', + ), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('files the rate per direction + lane + box size, unit out of the key', async () => { + await service.create( + { + ...containerHazard, + tradeDirection: 'IMPORT', + originYardId: DJ, + destinationYardId: ET, + containerTypeId: CT20, + } as never, + 'staff-1', + ); + + const pattern = repository.findByPattern.mock.calls[0][0]; + expect(pattern).not.toHaveProperty('rateUnit'); + expect(pattern).toMatchObject({ + rateType: 'HAZARD_SURCHARGE', + tradeDirection: 'IMPORT', + originYardId: DJ, + destinationYardId: ET, + containerTypeId: CT20, + }); + expect(repository.create).toHaveBeenCalledWith( + expect.objectContaining({ + rateType: 'HAZARD_SURCHARGE', + rateUnit: 'PER_CONTAINER', + tradeDirection: 'IMPORT', + originYardId: DJ, + destinationYardId: ET, + containerTypeId: CT20, + cargoTypeId: null, + }), + ); + }); + + it('accepts a lane catch-all with no box size', async () => { + await service.create( + { + ...containerHazard, + tradeDirection: 'EXPORT', + originYardId: ET, + destinationYardId: DJ, + } as never, + 'staff-1', + ); + expect(repository.create).toHaveBeenCalledWith( + expect.objectContaining({ + tradeDirection: 'EXPORT', + originYardId: ET, + destinationYardId: DJ, + containerTypeId: null, + }), + ); + }); + + it('accepts a DOMESTIC (intercity) lane inside Ethiopia', async () => { + await service.create( + { + ...containerHazard, + tradeDirection: 'DOMESTIC', + originYardId: ET, + destinationYardId: ET2, + } as never, + 'staff-1', + ); + expect(repository.create).toHaveBeenCalledWith( + expect.objectContaining({ tradeDirection: 'DOMESTIC', originYardId: ET, destinationYardId: ET2 }), + ); + }); + + it('refuses a second rate for the same lane + box size', async () => { + repository.findByPattern.mockResolvedValue({ id: 'rate-existing' } as Rate); + await expect( + service.create( + { + ...containerHazard, + tradeDirection: 'IMPORT', + originYardId: DJ, + destinationYardId: ET, + containerTypeId: CT20, + } as never, + 'staff-1', + ), + ).rejects.toBeInstanceOf(ConflictException); + }); +}); + 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 87618bcdb..c2196b663 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 @@ -13,7 +13,7 @@ import { ShippingLineCompaniesService } from '../../shipping-lines/shipping-line import { CreateRateDto } from '../dto/create-rate.dto'; import { ListRatesQueryDto } from '../dto/list-rule-engine-query.dto'; import { UpdateRateDto } from '../dto/update-rate.dto'; -import { Rate, isCustomsClearanceTrigger } from '../entities/rate.entity'; +import { Rate, isContainerHazardRate, isCustomsClearanceTrigger } from '../entities/rate.entity'; import { deriveRateType } from '../entities/rate-type.util'; import { CargoUom, allowedRateUnits, isRateUnitAllowed } from '../entities/rate-unit.util'; import { @@ -39,7 +39,11 @@ const CARGO_KIND_TRIGGERS: readonly Rate['trigger'][] = [ 'ETHIOPIAN_CUSTOMS_CLEARANCE', 'CANCELLATION', ]; -/** Surcharges that keep a trade direction (everything else is direction-agnostic). */ +/** + * Surcharges that keep a trade direction (everything else is direction-agnostic). + * Container hazard (HAZARDOUS billed PER_CONTAINER) is directed too, but is + * keyed on the unit rather than the trigger — see {@link isDirectedSurcharge}. + */ const DIRECTED_SURCHARGE_TRIGGERS: readonly Rate['trigger'][] = [ 'CUSTOMS_CLEARANCE', 'ETHIOPIAN_CUSTOMS_CLEARANCE', @@ -148,18 +152,29 @@ export class RatesService { /** * Rates sold per direction + route. Base freight always; customs clearance, - * empty-container return and fuel are the surcharges that are too — their - * fee depends on the lane (and, for returns, the container type). + * empty-container return, fuel and the container hazard surcharge are the + * surcharges that are too — their fee depends on the lane (and, for returns + * and container hazard, the container type). */ - private isRouteScoped(appliesTo: Rate['appliesTo'], trigger: Rate['trigger']): boolean { + private isRouteScoped( + appliesTo: Rate['appliesTo'], + trigger: Rate['trigger'], + rateUnit: Rate['rateUnit'], + ): boolean { return ( this.isBaseFreight(appliesTo, trigger) || isCustomsClearanceTrigger(trigger) || trigger === 'WITH_RETURN' || - trigger === 'FUEL' + trigger === 'FUEL' || + isContainerHazardRate(trigger, rateUnit) ); } + /** Surcharges that carry a trade direction; everything else is direction-agnostic. */ + private isDirectedSurcharge(trigger: Rate['trigger'], rateUnit: Rate['rateUnit']): boolean { + return DIRECTED_SURCHARGE_TRIGGERS.includes(trigger) || isContainerHazardRate(trigger, rateUnit); + } + /** * True when pricing resolves exactly ONE rate for this shape (base freight, * customs clearance, lashing, empty-container return — all `find()`-based @@ -174,9 +189,10 @@ export class RatesService { private resolvesSingleRate( appliesTo: Rate['appliesTo'], trigger: Rate['trigger'], + rateUnit: Rate['rateUnit'], ): boolean { return ( - this.isRouteScoped(appliesTo, trigger) || + this.isRouteScoped(appliesTo, trigger, rateUnit) || trigger === 'LASHING' || trigger === 'CANCELLATION' ); @@ -191,8 +207,8 @@ export class RatesService { appliesTo: Rate['appliesTo'], tradeDirection: string | null, ): { origin: YardCountry; destination: YardCountry } { - // DOMESTIC only reaches here on a FUEL rate's intercity lane — it stays - // inside Ethiopia exactly like intercity base freight. + // DOMESTIC only reaches here on a FUEL or container-hazard rate's intercity + // lane — it stays inside Ethiopia exactly like intercity base freight. if (appliesTo === 'INTERCITY' || tradeDirection === 'DOMESTIC') { return { origin: YardCountry.ETHIOPIA, destination: YardCountry.ETHIOPIA }; } @@ -212,12 +228,13 @@ export class RatesService { private async resolveYardScope(input: { appliesTo: Rate['appliesTo']; trigger: Rate['trigger']; + rateUnit: Rate['rateUnit']; tradeDirection: string | null; originYardId?: string | null; destinationYardId?: string | null; }): Promise { - const { appliesTo, trigger, tradeDirection } = input; - if (!this.isRouteScoped(appliesTo, trigger)) { + const { appliesTo, trigger, rateUnit, tradeDirection } = input; + if (!this.isRouteScoped(appliesTo, trigger, rateUnit)) { return { originYardId: null, destinationYardId: null }; } @@ -263,14 +280,36 @@ export class RatesService { private assertScopeCoherent(input: { appliesTo: Rate['appliesTo']; trigger: Rate['trigger']; + rateUnit: Rate['rateUnit']; tradeDirection: string | null; intercityKind: string | null; cargoKind: string | null; containerTypeId: string | null; cargoTypeId: string | null; }): void { - const { appliesTo, trigger, tradeDirection, intercityKind, cargoKind } = input; + const { appliesTo, trigger, rateUnit, tradeDirection, intercityKind, cargoKind } = input; const { containerTypeId, cargoTypeId } = input; + if (isContainerHazardRate(trigger, rateUnit)) { + // The container hazard surcharge is sold per lane like the empty-return + // service: the direction says which countries the leg spans (DOMESTIC = + // intercity, inside Ethiopia) and the box size may narrow it (a 20ft and + // a 40ft hazardous box price differently; no size = the lane's catch-all). + if ( + tradeDirection !== 'IMPORT' && + tradeDirection !== 'EXPORT' && + tradeDirection !== 'DOMESTIC' + ) { + throw new BadRequestException( + 'A per-container hazardous surcharge must say whether it covers IMPORT, EXPORT or DOMESTIC (intercity).', + ); + } + if (cargoTypeId) { + throw new BadRequestException( + 'A per-container hazardous surcharge cannot be scoped to a bulk cargo type.', + ); + } + return; + } if (isCustomsClearanceTrigger(trigger) || trigger === 'CANCELLATION') { // Both fees are sold per direction + cargo kind + type: customs clearance // per lane, the wagon cancellation fee per direction only. @@ -602,18 +641,12 @@ export class RatesService { // Surcharges (trigger ≠ ALWAYS) carry no direction/scope — clear them so // the engine never accidentally narrows a surcharge by container/direction. // Exceptions: the directed surcharges (customs clearance, cancellation, - // empty-container return, lashing, fuel) keep direction + cargo scope. + // empty-container return, lashing, fuel, per-container hazard) keep + // direction + cargo scope. const isSurcharge = trigger !== 'ALWAYS'; const cargoKind = CARGO_KIND_TRIGGERS.includes(trigger) ? ((dto.cargoKind as 'CONTAINER' | 'BULK' | undefined) ?? null) : null; - const containerTypeId = - trigger === 'WITH_RETURN' || - (CARGO_KIND_TRIGGERS.includes(trigger) && cargoKind === 'CONTAINER') - ? (dto.containerTypeId ?? null) - : isSurcharge - ? null - : (dto.containerTypeId ?? null); const cargoTypeId = (CARGO_KIND_TRIGGERS.includes(trigger) && cargoKind === 'BULK') || trigger === 'LASHING' || @@ -622,12 +655,30 @@ export class RatesService { : isSurcharge ? null : (dto.cargoTypeId ?? null); + // The unit is resolved before the scope because for hazard it IS the shape: + // per container is the lane-sold container surcharge (direction + yards + + // optional box size), per ton the global bulk one. + const rateUnit = await this.resolveRateUnit( + appliesTo, + trigger, + dto.rateUnit as Rate['rateUnit'] | undefined, + cargoKind, + cargoTypeId, + ); + const containerTypeId = + trigger === 'WITH_RETURN' || + isContainerHazardRate(trigger, rateUnit) || + (CARGO_KIND_TRIGGERS.includes(trigger) && cargoKind === 'CONTAINER') + ? (dto.containerTypeId ?? null) + : isSurcharge + ? null + : (dto.containerTypeId ?? null); // Intercity never leaves Ethiopia, so it has no trade direction to store — - // its yard pair already says where it runs. (Fuel is the exception: its - // intercity lane is stored as DOMESTIC, since appliesTo = OTHER says - // nothing about the direction.) + // its yard pair already says where it runs. (Fuel and container hazard are + // the exception: their intercity lane is stored as DOMESTIC, since + // appliesTo = OTHER says nothing about the direction.) const tradeDirection = - DIRECTED_SURCHARGE_TRIGGERS.includes(trigger) + this.isDirectedSurcharge(trigger, rateUnit) ? (dto.tradeDirection ?? null) : isSurcharge || appliesTo === 'INTERCITY' ? null @@ -637,6 +688,7 @@ export class RatesService { this.assertScopeCoherent({ appliesTo, trigger, + rateUnit, tradeDirection, intercityKind, cargoKind, @@ -646,6 +698,7 @@ export class RatesService { const { originYardId, destinationYardId } = await this.resolveYardScope({ appliesTo, trigger, + rateUnit, tradeDirection, originYardId: dto.originYardId, destinationYardId: dto.destinationYardId, @@ -662,13 +715,6 @@ export class RatesService { tradeDirection, isBulk: this.resolvesToBulk(appliesTo, intercityKind), }); - const rateUnit = await this.resolveRateUnit( - appliesTo, - trigger, - dto.rateUnit as Rate['rateUnit'] | undefined, - cargoKind, - cargoTypeId, - ); const { minKm, maxKm } = this.resolveLastMileBand({ appliesTo, @@ -689,7 +735,7 @@ export class RatesService { await this.assertNoDuplicatePattern({ rateType, - ...(this.resolvesSingleRate(appliesTo, trigger) ? {} : { rateUnit }), + ...(this.resolvesSingleRate(appliesTo, trigger, rateUnit) ? {} : { rateUnit }), shippingLineCompanyId, containerTypeId, cargoTypeId, @@ -826,15 +872,6 @@ export class RatesService { : ((dto.cargoKind as 'CONTAINER' | 'BULK' | undefined) ?? (existing.containerTypeId ? 'CONTAINER' : 'BULK')); - const keepsContainerType = - !isSurcharge || - trigger === 'WITH_RETURN' || - (CARGO_KIND_TRIGGERS.includes(trigger) && cargoKind === 'CONTAINER'); - const containerTypeId = !keepsContainerType - ? null - : dto.containerTypeId !== undefined - ? dto.containerTypeId - : existing.containerTypeId; const keepsCargoType = !isSurcharge || (CARGO_KIND_TRIGGERS.includes(trigger) && cargoKind === 'BULK') || @@ -845,8 +882,32 @@ export class RatesService { : dto.cargoTypeId !== undefined ? dto.cargoTypeId : existing.cargoTypeId; + // Re-validate the unit against the (possibly changed) shape before the + // scope is settled: for hazard the unit decides whether the rate is the + // lane-sold container surcharge or the global bulk one. Overweight is + // forced to PER_TON. + const requestedUnit = (dto.rateUnit as Rate['rateUnit']) ?? existing.rateUnit; + const rateUnit = await this.resolveRateUnit( + appliesTo, + trigger, + requestedUnit, + cargoKind, + cargoTypeId ?? null, + ); + updates.rateUnit = rateUnit; + + const keepsContainerType = + !isSurcharge || + trigger === 'WITH_RETURN' || + isContainerHazardRate(trigger, rateUnit) || + (CARGO_KIND_TRIGGERS.includes(trigger) && cargoKind === 'CONTAINER'); + const containerTypeId = !keepsContainerType + ? null + : dto.containerTypeId !== undefined + ? dto.containerTypeId + : existing.containerTypeId; const tradeDirection = - DIRECTED_SURCHARGE_TRIGGERS.includes(trigger) + this.isDirectedSurcharge(trigger, rateUnit) ? dto.tradeDirection !== undefined ? dto.tradeDirection : existing.tradeDirection @@ -868,6 +929,7 @@ export class RatesService { this.assertScopeCoherent({ appliesTo, trigger, + rateUnit, tradeDirection: updates.tradeDirection, intercityKind, cargoKind, @@ -879,6 +941,7 @@ export class RatesService { const yardScope = await this.resolveYardScope({ appliesTo, trigger, + rateUnit, tradeDirection: updates.tradeDirection, originYardId: dto.originYardId !== undefined ? dto.originYardId : existing.originYardId, @@ -910,18 +973,6 @@ export class RatesService { }); updates.rateType = rateType; - // Re-validate the unit against the (possibly changed) shape; overweight is - // forced to PER_TON. - const requestedUnit = (dto.rateUnit as Rate['rateUnit']) ?? existing.rateUnit; - const rateUnit = await this.resolveRateUnit( - appliesTo, - trigger, - requestedUnit, - cargoKind, - updates.cargoTypeId, - ); - updates.rateUnit = rateUnit; - const { minKm, maxKm } = this.resolveLastMileBand({ appliesTo, rateUnit, @@ -948,7 +999,7 @@ export class RatesService { // Guard the pattern uniqueness for the new identity, ignoring this row. await this.assertNoDuplicatePattern({ rateType, - ...(this.resolvesSingleRate(appliesTo, trigger) ? {} : { rateUnit }), + ...(this.resolvesSingleRate(appliesTo, trigger, rateUnit) ? {} : { rateUnit }), shippingLineCompanyId, containerTypeId: updates.containerTypeId, cargoTypeId: updates.cargoTypeId, diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx index 463886a64..5d3b791d8 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx @@ -61,10 +61,10 @@ import { import { DEFAULT_CONFIGURATION_SLUG, DEFAULT_RULES_SLUG, - ROUTE_SCOPED_TRIGGERS, RULE_ENGINE_CATEGORY_BASE_PATH, RULE_ENGINE_SELECT_NONE, getRuleEngineResource, + isRouteScopedSurcharge, rateUnitOptions, type RuleEngineNavCategory, } from "@/pages/ruleEngine/config/resources"; @@ -119,17 +119,16 @@ const yardOptionsForLegEnd = ( } else if ( appliesTo === "CONTAINER" || appliesTo === "BULK" || - // Customs clearance, empty-container return and fuel are sold per - // direction + route, so their yard dropdowns narrow exactly like base - // freight. - (appliesTo === "OTHER" && - ROUTE_SCOPED_TRIGGERS.includes(String(values.trigger ?? ""))) + // Customs clearance, empty-container return, fuel and the per-container + // hazard surcharge are sold per direction + route, so their yard + // dropdowns narrow exactly like base freight. + (appliesTo === "OTHER" && isRouteScopedSurcharge(values)) ) { const direction = String(values.tradeDirection ?? ""); // Direction is what decides the countries, so offer nothing until it is set // rather than defaulting to one and letting it read as a real choice. if (direction === "DOMESTIC") { - // A fuel rate's intercity lane — stays inside Ethiopia. + // A fuel or container-hazard rate's intercity lane — stays inside Ethiopia. country = "Ethiopia"; } else { if (direction !== "IMPORT" && direction !== "EXPORT") return []; 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 c43ccb634..19488d9ab 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 @@ -307,19 +307,34 @@ export const ROUTE_SCOPED_TRIGGERS = [ ]; /** - * Rates priced per leg: base rail freight, plus the customs clearance fees and - * the empty-container return surcharge (sold per route + container type). + * The container hazardous-cargo surcharge: HAZARDOUS billed per container. It + * is sold per direction + lane, optionally per container type (20ft / 40ft), + * exactly like the empty-return service. The per-ton (bulk) hazard rate is + * global and unscoped — the unit is what tells the two shapes apart (mirrors + * the API's `isContainerHazardRate`). + */ +export const isContainerHazardRate = (values: Record) => + String(values.trigger ?? "") === "HAZARDOUS" && + String(values.rateUnit ?? "") === "PER_CONTAINER"; + +/** Surcharge triggers/shapes sold per origin → destination leg. */ +export const isRouteScopedSurcharge = (values: Record) => + ROUTE_SCOPED_TRIGGERS.includes(String(values.trigger ?? "")) || + isContainerHazardRate(values); + +/** + * Rates priced per leg: base rail freight, plus the customs clearance fees, + * the empty-container return surcharge and the per-container hazard surcharge + * (both sold per route + container type). */ const isRouteScopedRate = (values: Record) => // A shipping line's base freight is priced per leg exactly like a customer's; // its surcharges are route-scoped on the same triggers. (isShippingLineRate(values) ? hasShippingLine(values) && - (values.shippingLineRateKind === "BASE" || - ROUTE_SCOPED_TRIGGERS.includes(String(values.trigger ?? ""))) + (values.shippingLineRateKind === "BASE" || isRouteScopedSurcharge(values)) : isBaseFreightRate(values)) || - (String(values.appliesTo ?? "") === "OTHER" && - ROUTE_SCOPED_TRIGGERS.includes(String(values.trigger ?? ""))); + (String(values.appliesTo ?? "") === "OTHER" && isRouteScopedSurcharge(values)); /** * Surcharges sold per cargo kind: the admin says container or bulk, then names @@ -362,9 +377,12 @@ const unitsForShape = ( case "OVERWEIGHT": return ["PER_TON"]; case "REEFER": - case "HAZARDOUS": case "DEMURRAGE": return ["PER_CONTAINER", "PER_TON"]; + case "HAZARDOUS": + // Per container = the lane-sold container surcharge (direction + route, + // optional box size); per ton = the global bulk surcharge. + return ["PER_CONTAINER", "PER_TON"]; case "WITH_RETURN": // Container-only service — per returned container, per wagon, or flat. return ["PER_CONTAINER", "PER_WAGON", "FLAT"]; @@ -1308,9 +1326,9 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ hasShippingLine(v) && v.shippingLineRateKind === "SURCHARGE", }, // ── Trade direction — Bulk & Container base freight, plus the directed - // surcharges (customs clearance, cancellation, lashing, fuel; empty- - // container return, which is import-only for now so export is not - // offered) ───────────────────────────────────────────────────────────── + // surcharges (customs clearance, cancellation, lashing, fuel, the + // per-container hazard surcharge; empty-container return, which is + // import-only for now so export is not offered) ─────────────────────── { name: "tradeDirection", label: "Trade direction", @@ -1323,7 +1341,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ String(v.trigger ?? "") === "WITH_RETURN") ? TRADE_DIRECTIONS.filter((d) => d.value === "IMPORT") : String(v.appliesTo ?? "") === "OTHER" && - String(v.trigger ?? "") === "FUEL" + (String(v.trigger ?? "") === "FUEL" || isContainerHazardRate(v)) ? FUEL_TRADE_DIRECTIONS : TRADE_DIRECTIONS.filter((d) => d.value !== "BOTH"), showIf: (v) => @@ -1332,14 +1350,15 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ String(v.appliesTo ?? ""), ) || (String(v.appliesTo ?? "") === "OTHER" && - [ + ([ "CUSTOMS_CLEARANCE", "ETHIOPIAN_CUSTOMS_CLEARANCE", "CANCELLATION", "WITH_RETURN", "LASHING", "FUEL", - ].includes(String(v.trigger ?? "")))), + ].includes(String(v.trigger ?? "")) || + isContainerHazardRate(v)))), }, // Shipping lines only ever ship import — the export leg is sold through // the customer's contract — so the direction is stated, not asked. Shown @@ -1519,8 +1538,9 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ v.appliesTo === "LAST_MILE" && (v.lastMileMode === "CONTAINER" || v.lastMileMode === "BULK"), }, - // ── Container type — Container freight, container-kind intercity, and - // the empty-container return surcharge (20ft vs 40ft price differently) ─ + // ── Container type — Container freight, container-kind intercity, the + // empty-container return surcharge and the per-container hazard + // surcharge (20ft vs 40ft price differently; empty = the lane catch-all) ─ { name: "containerTypeId", label: "Container type", @@ -1531,7 +1551,8 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ !isShippingLineRate(v) && (v.appliesTo === "CONTAINER" || (v.appliesTo === "INTERCITY" && v.intercityKind === "CONTAINER") || - (v.appliesTo === "OTHER" && v.trigger === "WITH_RETURN")), + (v.appliesTo === "OTHER" && + (v.trigger === "WITH_RETURN" || isContainerHazardRate(v)))), }, // 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 @@ -1636,7 +1657,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ required: true, optionsFromValues: rateUnitOptions, description: - "Weighting basis — options depend on what the rate applies to, and for bulk on how the picked commodity is counted (per ton or per item).", + "Weighting basis — options depend on what the rate applies to, and for bulk on how the picked commodity is counted (per ton or per item). Hazardous: per container is sold per direction + route (optionally per 20ft / 40ft); per ton is the global bulk surcharge.", showIf: (v) => String(v.trigger ?? "") !== "OVERWEIGHT" && String(v.appliesTo ?? "") !== "LAST_MILE",