feat: Implement container hazardous-cargo surcharge logic

- Added support for container hazardous-cargo surcharge (HAZARDOUS billed PER_CONTAINER) in the rule engine.
- Introduced new method  in  to calculate and apply container hazard charges based on booking details.
- Updated  to handle per-container hazard rates, ensuring they are scoped by trade direction and lane.
- Enhanced tests to cover scenarios for container hazard rates, including validation for required fields and conflict checks.
- Created a migration to update existing rates and enforce new constraints for container hazard rates in the database.
This commit is contained in:
marshal
2026-09-06 22:28:57 +00:00
parent 1eb9f10354
commit 00c0d86a8a
11 changed files with 789 additions and 98 deletions

View File

@@ -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

View File

@@ -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<void> {
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<void> {
// 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
)
`);
}
}

View File

@@ -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({

View File

@@ -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()

View File

@@ -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;

View File

@@ -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> = {}): 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');
});
});

View File

@@ -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

View File

@@ -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);
});
});

View File

@@ -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<YardScope> {
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,