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:
Hagernesh
2026-08-05 22:12:40 +00:00
parent 6125f644b1
commit 5c8c68e990
25 changed files with 880 additions and 20 deletions

View File

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

View File

@@ -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 030 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",
},
],
},