mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 08:48:11 +00:00
feat(rates): last-mile rate rules with bulk per-ton-km and container distance bands
- rates: min_km/max_km columns, PER_TON_KM unit, ETB|USD currency for last mile - extend UQ_rates_pattern with band start; overlap + shape validation - shared last-mile charge resolver; approve-dialog price estimate endpoint - delivery-fee invoice prices via rules, falls back to vehicle price/km - backoffice: last-mile rate form (mode, container type, band, currency)
This commit is contained in:
@@ -584,10 +584,30 @@ const RuleEngineResourcePage = () => {
|
||||
// treats them as ALWAYS. Surcharges (Applies to = Other) keep their
|
||||
// chosen trigger.
|
||||
const isSurcharge = values.appliesTo === "OTHER";
|
||||
// Last mile: the form's calculation mode picks the unit (bulk = per
|
||||
// ton·km, container = per km + distance band) and the currency stays as
|
||||
// chosen (birr or dollar). Everything else remains USD-only.
|
||||
const isLastMile = values.appliesTo === "LAST_MILE";
|
||||
const { lastMileMode, ...rest } = values;
|
||||
payload = {
|
||||
...values,
|
||||
currency: "USD",
|
||||
...rest,
|
||||
currency: isLastMile ? (values.currency ?? "ETB") : "USD",
|
||||
trigger: isSurcharge ? values.trigger : "ALWAYS",
|
||||
...(isLastMile
|
||||
? lastMileMode === "BULK"
|
||||
? {
|
||||
rateUnit: "PER_TON_KM",
|
||||
containerTypeId: undefined,
|
||||
minKm: undefined,
|
||||
maxKm: undefined,
|
||||
}
|
||||
: {
|
||||
rateUnit: "PER_KM",
|
||||
// Empty "To km" means an open-ended band — send null so an
|
||||
// edit can clear a previously-set ceiling.
|
||||
maxKm: values.maxKm ?? null,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
// Editing a LIVE rate files a change request — the rate keeps charging
|
||||
// its current value until an approver applies it. DRAFT rates fall
|
||||
|
||||
@@ -267,8 +267,10 @@ const unitsForShape = (
|
||||
case "INTERCITY":
|
||||
return ["PER_CONTAINER", "PER_TON", "PER_WAGON", "PER_KM"];
|
||||
case "FIRST_MILE":
|
||||
case "LAST_MILE":
|
||||
return ["PER_CONTAINER", "PER_TON", "PER_KM", "FLAT"];
|
||||
case "LAST_MILE":
|
||||
// PER_KM = container mode (distance-banded), PER_TON_KM = bulk mode.
|
||||
return ["PER_KM", "PER_TON_KM", "PER_CONTAINER", "PER_TON", "FLAT"];
|
||||
default:
|
||||
return ["FLAT"];
|
||||
}
|
||||
@@ -846,6 +848,9 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
},
|
||||
{ id: "rateValue", header: "Value", accessorKey: "rateValue", format: "currency" },
|
||||
{ id: "rateUnit", header: "Unit", accessorKey: "rateUnit" },
|
||||
// Container last-mile distance bands; blank for every other rate shape.
|
||||
{ id: "minKm", header: "From km", accessorKey: "minKm", format: "number" },
|
||||
{ id: "maxKm", header: "To km", accessorKey: "maxKm", format: "number" },
|
||||
{ id: "status", header: "Status", accessorKey: "status", format: "rateStatus" },
|
||||
],
|
||||
formFields: [
|
||||
@@ -958,6 +963,65 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
getInitialValue: (record) =>
|
||||
record.rateType === "INTERCITY_BULK" ? "BULK" : "CONTAINER",
|
||||
},
|
||||
// ── Last mile — two calculation modes ─────────────────────────────────
|
||||
// Bulk bills per ton per km (price = tons × km × rate); Container bills
|
||||
// per km, banded by distance range with one rate row per container type
|
||||
// per band (price = km × rate × quantity).
|
||||
{
|
||||
name: "lastMileMode",
|
||||
label: "Calculation mode",
|
||||
type: "select",
|
||||
required: true,
|
||||
options: [
|
||||
{ label: "Bulk (per ton per km)", value: "BULK" },
|
||||
{ label: "Container (per km, distance-banded)", value: "CONTAINER" },
|
||||
],
|
||||
description:
|
||||
"Bulk: price = tons × km × rate. Container: price = km × band rate × quantity, one rate per container type per distance band.",
|
||||
showWhen: { field: "appliesTo", equals: ["LAST_MILE"] },
|
||||
// Not a stored column: the mode is recorded in the unit the API keeps.
|
||||
getInitialValue: (record) =>
|
||||
record.rateUnit === "PER_TON_KM" ? "BULK" : "CONTAINER",
|
||||
},
|
||||
{
|
||||
name: "containerTypeId",
|
||||
label: "Container type",
|
||||
type: "select",
|
||||
required: true,
|
||||
placeholder: "Which container type this band prices",
|
||||
description: "20ft and 40ft price differently — one rate per type per band.",
|
||||
showIf: (v) =>
|
||||
v.appliesTo === "LAST_MILE" && v.lastMileMode === "CONTAINER",
|
||||
},
|
||||
{
|
||||
name: "minKm",
|
||||
label: "From km",
|
||||
type: "number",
|
||||
required: true,
|
||||
placeholder: "0",
|
||||
description: "Band start (inclusive). Use 0 for the first band.",
|
||||
showIf: (v) =>
|
||||
v.appliesTo === "LAST_MILE" && v.lastMileMode === "CONTAINER",
|
||||
},
|
||||
{
|
||||
name: "maxKm",
|
||||
label: "To km",
|
||||
type: "number",
|
||||
optional: true,
|
||||
placeholder: "Leave empty for no upper limit",
|
||||
description: "Band end (exclusive) — a 0–30 band covers up to but not including 30 km.",
|
||||
showIf: (v) =>
|
||||
v.appliesTo === "LAST_MILE" && v.lastMileMode === "CONTAINER",
|
||||
},
|
||||
{
|
||||
name: "currency",
|
||||
label: "Currency",
|
||||
type: "select",
|
||||
required: true,
|
||||
options: CURRENCIES,
|
||||
showWhen: { field: "appliesTo", equals: ["LAST_MILE"] },
|
||||
getInitialValue: (record) => String(record.currency ?? "ETB"),
|
||||
},
|
||||
// ── Container type — Container freight, container-kind intercity, and
|
||||
// the empty-container return surcharge (20ft vs 40ft price differently) ─
|
||||
{
|
||||
@@ -1002,10 +1066,29 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
placeholder: "Where the leg ends",
|
||||
showIf: isRouteScopedRate,
|
||||
},
|
||||
{ name: "rateValue", label: "Rate value", type: "number", required: true, suffix: "USD" },
|
||||
{
|
||||
name: "rateValue",
|
||||
label: "Rate value",
|
||||
type: "number",
|
||||
required: true,
|
||||
suffix: "USD",
|
||||
showIf: (v) => v.appliesTo !== "LAST_MILE",
|
||||
},
|
||||
// Last-mile rates carry their own currency (birr or dollar) and the
|
||||
// value is a per-km / per-ton·km price, so no hardcoded USD suffix.
|
||||
{
|
||||
name: "rateValue",
|
||||
label: "Rate value",
|
||||
type: "number",
|
||||
required: true,
|
||||
description:
|
||||
"Container mode: price per km for this band. Bulk mode: price per ton per km.",
|
||||
showWhen: { field: "appliesTo", equals: ["LAST_MILE"] },
|
||||
},
|
||||
// Unit choices are driven by the rate shape (appliesTo + trigger). Overweight
|
||||
// is always per excess ton, so the unit field is hidden for it — the API
|
||||
// forces PER_TON regardless.
|
||||
// forces PER_TON regardless. Last mile derives its unit from the
|
||||
// calculation mode instead.
|
||||
{
|
||||
name: "rateUnit",
|
||||
label: "Rate unit",
|
||||
@@ -1014,7 +1097,9 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
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).",
|
||||
hideWhen: { field: "trigger", equals: ["OVERWEIGHT"] },
|
||||
showIf: (v) =>
|
||||
String(v.trigger ?? "") !== "OVERWEIGHT" &&
|
||||
String(v.appliesTo ?? "") !== "LAST_MILE",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user