mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 12:28:21 +00:00
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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user