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

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

View File

@@ -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, unknown>) =>
String(values.trigger ?? "") === "HAZARDOUS" &&
String(values.rateUnit ?? "") === "PER_CONTAINER";
/** Surcharge triggers/shapes sold per origin → destination leg. */
export const isRouteScopedSurcharge = (values: Record<string, unknown>) =>
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<string, unknown>) =>
// 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",