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

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