mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 13:38:20 +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:
@@ -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 [];
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user