mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 23:28: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:
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
@@ -76,6 +76,21 @@ export function LastMileRequestsPanel() {
|
||||
queryFn: async () => (await lastMileRequestsService.freeTruckCount()).data,
|
||||
});
|
||||
|
||||
// Rule-based estimate for the approve dialog (estimated km × live last-mile
|
||||
// rates). Prefills the advance once, without clobbering a typed value.
|
||||
const { data: estimate } = useQuery({
|
||||
queryKey: QUERY_KEYS.LAST_MILE_REQUESTS.priceEstimate(approveTarget?.id ?? ""),
|
||||
queryFn: async () =>
|
||||
(await lastMileRequestsService.priceEstimate(approveTarget!.id)).data,
|
||||
enabled: Boolean(approveTarget),
|
||||
});
|
||||
useEffect(() => {
|
||||
if (approveTarget && estimate?.total != null && advanceAmount === "") {
|
||||
setAdvanceAmount(estimate.total);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [estimate, approveTarget]);
|
||||
|
||||
const invalidate = () =>
|
||||
qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE_REQUESTS.ROOT });
|
||||
|
||||
@@ -227,11 +242,29 @@ export function LastMileRequestsPanel() {
|
||||
|
||||
<Modal
|
||||
opened={Boolean(approveTarget)}
|
||||
onClose={() => setApproveTarget(null)}
|
||||
onClose={() => {
|
||||
setApproveTarget(null);
|
||||
setAdvanceAmount("");
|
||||
}}
|
||||
title={<Text fw={700}>Approve request{approveTarget?.booking?.reference ? ` · ${approveTarget.booking.reference}` : ""}</Text>}
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
{estimate?.total != null && (
|
||||
<Stack gap={4}>
|
||||
{estimate.lines.map((line) => (
|
||||
<Text key={line.description} size="xs" c="dimmed">
|
||||
{line.description} — {line.amount.toLocaleString()}
|
||||
</Text>
|
||||
))}
|
||||
<Text size="sm" fw={600}>
|
||||
Estimated total: {estimate.total.toLocaleString()} {estimate.currency}
|
||||
{estimate.estimatedKm != null
|
||||
? ` · ${estimate.estimatedKm} km (straight-line estimate)`
|
||||
: ""}
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
<NumberInput
|
||||
label="Advance amount"
|
||||
placeholder="0.00"
|
||||
@@ -241,7 +274,13 @@ export function LastMileRequestsPanel() {
|
||||
onChange={setAdvanceAmount}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setApproveTarget(null)}>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => {
|
||||
setApproveTarget(null);
|
||||
setAdvanceAmount("");
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
|
||||
@@ -160,6 +160,8 @@ export const QUERY_KEYS = {
|
||||
list: (filter?: Record<string, unknown>) =>
|
||||
["last-mile-requests", "list", filter ?? {}] as const,
|
||||
freeTruckCount: ["last-mile-requests", "free-truck-count"] as const,
|
||||
priceEstimate: (id: string) =>
|
||||
["last-mile-requests", "price-estimate", id] as const,
|
||||
},
|
||||
|
||||
RULE_ENGINE: {
|
||||
|
||||
@@ -711,6 +711,7 @@ export const URL_CONSTANTS = {
|
||||
BASE: "/last-mile-requests",
|
||||
BY_ID: (id: string) => `/last-mile-requests/${id}`,
|
||||
FREE_TRUCK_COUNT: "/last-mile-requests/free-truck-count",
|
||||
PRICE_ESTIMATE: (id: string) => `/last-mile-requests/${id}/price-estimate`,
|
||||
APPROVE: (id: string) => `/last-mile-requests/${id}/approve`,
|
||||
REJECT: (id: string) => `/last-mile-requests/${id}/reject`,
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -37,6 +37,15 @@ export interface LastMileRequestListResponse {
|
||||
meta: { total: number; page: number; pageSize: number; totalPages: number };
|
||||
}
|
||||
|
||||
/** Rule-based estimate for the approve dialog — all nulls when no rule covers the job. */
|
||||
export interface LastMilePriceEstimate {
|
||||
estimatedKm: number | null;
|
||||
mode: 'BULK' | 'CONTAINER' | null;
|
||||
currency: string | null;
|
||||
total: number | null;
|
||||
lines: Array<{ description: string; amount: number }>;
|
||||
}
|
||||
|
||||
const LMR = URL_CONSTANTS.LAST_MILE_REQUESTS;
|
||||
|
||||
export const lastMileRequestsService = {
|
||||
@@ -44,6 +53,7 @@ export const lastMileRequestsService = {
|
||||
api.get<LastMileRequestListResponse>(LMR.BASE, { params }),
|
||||
getById: (id: string) => api.get<LastMileRequest>(LMR.BY_ID(id)),
|
||||
freeTruckCount: () => api.get<{ count: number }>(LMR.FREE_TRUCK_COUNT),
|
||||
priceEstimate: (id: string) => api.get<LastMilePriceEstimate>(LMR.PRICE_ESTIMATE(id)),
|
||||
approve: (id: string, advanceAmount: number) =>
|
||||
api.post<LastMileRequest>(LMR.APPROVE(id), { advanceAmount }),
|
||||
reject: (id: string, reason: string) =>
|
||||
|
||||
Reference in New Issue
Block a user