From 95fad6f095336584ed6f232db621550ba14b1e2d Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Thu, 6 Aug 2026 08:42:13 +0000 Subject: [PATCH] Bulk (per ton per km) last-mile rates now carry From/To km bands like container mode: the Add Rate dialog offers the multi-tier editor in both modes, each tier is created as its own rate row, and overlapping bulk bands are rejected. Pricing picks the tier whose half-open band holds the trip km, falling back to the legacy bandless bulk rate. --- .../src/common/last-mile-charge.util.spec.ts | 42 +++++++++++++ .../src/common/last-mile-charge.util.ts | 18 ++++-- .../rule-engine/services/rates.service.ts | 63 ++++++++++++------- .../ruleEngine/RuleEngineResourcePage.tsx | 51 ++++++--------- .../src/pages/ruleEngine/config/resources.ts | 11 ++-- 5 files changed, 125 insertions(+), 60 deletions(-) diff --git a/apps/edr-freight-api/src/common/last-mile-charge.util.spec.ts b/apps/edr-freight-api/src/common/last-mile-charge.util.spec.ts index e2ff1bfd9..e60bf4f4e 100644 --- a/apps/edr-freight-api/src/common/last-mile-charge.util.spec.ts +++ b/apps/edr-freight-api/src/common/last-mile-charge.util.spec.ts @@ -85,6 +85,48 @@ describe('computeLastMileCharge', () => { expect(charge).toMatchObject({ mode: 'BULK', total: 60 * 26 * 25, currency: 'ETB' }); }); + it('picks the bulk rate whose distance band holds the km (half-open boundary)', () => { + const bulkNear = rate({ rateUnit: 'PER_TON_KM', rateValue: 30, minKm: 0, maxKm: 30 }); + const bulkFar = rate({ rateUnit: 'PER_TON_KM', rateValue: 22, minKm: 30, maxKm: null }); + const near = computeLastMileCharge({ + freightType: 'BULK', + tons: 10, + km: 12, + containers: [], + liveRates: [bulkNear, bulkFar], + }); + expect(near).toMatchObject({ mode: 'BULK', total: 10 * 12 * 30 }); + const boundary = computeLastMileCharge({ + freightType: 'BULK', + tons: 10, + km: 30, + containers: [], + liveRates: [bulkNear, bulkFar], + }); + expect(boundary).toMatchObject({ total: 10 * 30 * 22 }); + }); + + it('bulk falls back to the legacy bandless rate when no band holds the km, null when nothing covers it', () => { + const bulkNear = rate({ rateUnit: 'PER_TON_KM', rateValue: 30, minKm: 0, maxKm: 30 }); + const fallback = computeLastMileCharge({ + freightType: 'BULK', + tons: 10, + km: 50, + containers: [], + liveRates: [bulkNear, bulkRate], // bulkRate has no band + }); + expect(fallback).toMatchObject({ total: 10 * 50 * 25 }); + expect( + computeLastMileCharge({ + freightType: 'BULK', + tons: 10, + km: 50, + containers: [], + liveRates: [bulkNear], + }), + ).toBeNull(); + }); + it('returns null on mixed currencies, unknown km, and uncovered freight types', () => { const usd40 = rate({ ...band40a, currency: 'USD' }); expect( diff --git a/apps/edr-freight-api/src/common/last-mile-charge.util.ts b/apps/edr-freight-api/src/common/last-mile-charge.util.ts index 706e21238..055e8e5f9 100644 --- a/apps/edr-freight-api/src/common/last-mile-charge.util.ts +++ b/apps/edr-freight-api/src/common/last-mile-charge.util.ts @@ -30,10 +30,12 @@ const round2 = (n: number): number => Math.round(n * 100) / 100; /** * Price a last-mile leg off the LIVE rate rules. Pure — pass the live rates in. * - * BULK: one PER_TON_KM rate → price = tons × km × rate. + * BULK: the PER_TON_KM rate whose distance band holds the km (a legacy + * bandless row — NULL minKm — is the fallback and prices every distance) → + * price = tons × km × rate. * CONTAINER: per container size, the PER_KM rate whose distance band holds the - * km (bands are half-open [minKm, maxKm), NULL maxKm = open-ended) → price = - * km × rate × quantity, summed across sizes. + * km → price = km × rate × quantity, summed across sizes. + * Bands are half-open [minKm, maxKm), NULL maxKm = open-ended. * * Returns null whenever the rules don't fully cover the shipment (no rate, a * container size without a matching band, mixed currencies, km/tons unknown) — @@ -56,7 +58,15 @@ export function computeLastMileCharge(input: { if (freightType === 'BULK') { if (!tons || tons <= 0) return null; - const rate = candidates.find((r) => r.rateUnit === 'PER_TON_KM'); + const bulkRates = candidates.filter((r) => r.rateUnit === 'PER_TON_KM'); + const rate = + bulkRates.find( + (r) => + r.minKm !== null && + r.minKm !== undefined && + Number(r.minKm) <= km && + (r.maxKm === null || r.maxKm === undefined || km < Number(r.maxKm)), + ) ?? bulkRates.find((r) => r.minKm === null || r.minKm === undefined); if (!rate) return null; const unitRate = Number(rate.rateValue); const amount = round2(tons * km * unitRate); diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts index 5645334e5..971097377 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts @@ -7,7 +7,7 @@ import { NotFoundException, } from '@nestjs/common'; import { PaginatedResponse, YardCountry } from '@edr/types'; -import { Not } from 'typeorm'; +import { IsNull, Not } from 'typeorm'; import { CreateRateDto } from '../dto/create-rate.dto'; import { ListRatesQueryDto } from '../dto/list-rule-engine-query.dto'; import { UpdateRateDto } from '../dto/update-rate.dto'; @@ -344,11 +344,12 @@ export class RatesService { /** * Validate and normalise the last-mile band fields for a rate shape. * - * Last-mile rates come in two calculation modes: bulk (PER_TON_KM — price = - * tons × km × rate, one row, no scope) and container (PER_KM — one row per - * container type per distance band, price = km × rate × quantity). Every - * other rate shape has its band fields cleared, mirroring how yard scope is - * cleared for non-route rates. + * Last-mile rates come in two calculation modes: bulk (PER_TON_KM — one row + * per distance band, price = tons × km × rate) and container (PER_KM — one + * row per container type per distance band, price = km × rate × quantity). + * A bandless bulk row (NULL minKm) is the legacy pre-band shape and still + * prices every distance. Every other rate shape has its band fields cleared, + * mirroring how yard scope is cleared for non-route rates. */ private resolveLastMileBand(input: { appliesTo: Rate['appliesTo']; @@ -366,7 +367,21 @@ export class RatesService { 'A bulk last-mile rate (per ton per km) cannot be scoped to a container type.', ); } - return { minKm: null, maxKm: null }; + const minKm = input.minKm ?? null; + const maxKm = input.maxKm ?? null; + if (minKm === null) { + if (maxKm !== null) { + throw new BadRequestException( + '"To km" needs a "From km" — set the band start (0 for the first tier).', + ); + } + // Legacy bandless bulk rate — prices every distance. + return { minKm: null, maxKm: null }; + } + if (maxKm !== null && maxKm <= minKm) { + throw new BadRequestException('"To km" must be greater than "From km".'); + } + return { minKm, maxKm }; } if (rateUnit === 'PER_KM') { @@ -393,14 +408,16 @@ export class RatesService { } /** - * Reject a container last-mile band that overlaps an existing band for the - * same container type. Bands are half-open [minKm, maxKm) with NULL maxKm = - * open-ended, so 0–30 and 30–∞ tile cleanly. Checked across every - * non-superseded row (DRAFT included) — two drafts with colliding bands would - * only defer the conflict to approval. + * Reject a last-mile band that overlaps an existing band for the same scope — + * container bands collide per container type (PER_KM), bulk bands collide + * with each other (PER_TON_KM, no container scope). Bands are half-open + * [minKm, maxKm) with NULL maxKm = open-ended, so 0–30 and 30–∞ tile + * cleanly. Checked across every non-superseded row (DRAFT included) — two + * drafts with colliding bands would only defer the conflict to approval. */ private async assertNoBandOverlap(input: { - containerTypeId: string; + rateUnit: 'PER_KM' | 'PER_TON_KM'; + containerTypeId: string | null; minKm: number; maxKm: number | null; ignoreId?: string; @@ -408,8 +425,8 @@ export class RatesService { const siblings = await this.repository.findAll({ where: { rateType: 'LAST_MILE', - rateUnit: 'PER_KM', - containerTypeId: input.containerTypeId, + rateUnit: input.rateUnit, + containerTypeId: input.containerTypeId ?? IsNull(), status: Not('SUPERSEDED'), }, }); @@ -425,7 +442,7 @@ export class RatesService { if (input.minKm < sibMax && sibMin < newMax) { const sibLabel = `${sibMin}–${sibMax === Number.POSITIVE_INFINITY ? 'open' : sibMax} km`; throw new ConflictException( - `This distance band overlaps the existing ${sibLabel} band for this container type. Adjust the ranges so each distance falls in exactly one band.`, + `This distance band overlaps the existing ${sibLabel} band for ${input.rateUnit === 'PER_TON_KM' ? 'bulk last-mile' : 'this container type'}. Adjust the ranges so each distance falls in exactly one band.`, ); } } @@ -538,8 +555,12 @@ export class RatesService { minKm: dto.minKm, maxKm: dto.maxKm, }); - if (appliesTo === 'LAST_MILE' && rateUnit === 'PER_KM' && containerTypeId && minKm !== null) { - await this.assertNoBandOverlap({ containerTypeId, minKm, maxKm }); + if ( + appliesTo === 'LAST_MILE' && + (rateUnit === 'PER_KM' || rateUnit === 'PER_TON_KM') && + minKm !== null + ) { + await this.assertNoBandOverlap({ rateUnit, containerTypeId, minKm, maxKm }); } await this.assertNoDuplicatePattern({ @@ -742,12 +763,12 @@ export class RatesService { updates.maxKm = maxKm; if ( appliesTo === 'LAST_MILE' && - rateUnit === 'PER_KM' && - updates.containerTypeId && + (rateUnit === 'PER_KM' || rateUnit === 'PER_TON_KM') && minKm !== null ) { await this.assertNoBandOverlap({ - containerTypeId: updates.containerTypeId, + rateUnit, + containerTypeId: updates.containerTypeId ?? null, minKm, maxKm, ignoreId: id, diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx index 5d1d45889..885fcb82b 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx @@ -306,34 +306,27 @@ const RuleEngineResourcePage = () => { const formFields = useMemo(() => { if (!config) return []; - // Last-mile container bands: creating uses the multi-row tier list (one - // rate per tier); editing an existing band row keeps the single - // From/To/value fields (a rate row IS one band). + // Last-mile bands (both modes): creating uses the multi-row tier list (one + // rate per tier, each tier carrying its own rate value); editing an + // existing band row keeps the single From/To/value fields (a rate row IS + // one band). const bandFields = config.formFields.filter((field) => { if (config.slug !== "rates") return true; if (field.type === "tierList") return !editing; if (editing) return true; - return field.name !== "minKm" && field.name !== "maxKm"; - }); - return bandFields.map((field) => { - // On create, the tier rows carry the per-band rate values — the single - // last-mile "Rate value" field then only applies to bulk mode. + // On create the tier rows carry From/To/value — drop the single fields, + // including the last-mile "Rate value" (the non-last-mile one keeps its + // own showIf). if ( - config.slug === "rates" && - !editing && field.name === "rateValue" && field.showWhen?.field === "appliesTo" && field.showWhen.equals.includes("LAST_MILE") ) { - return { - ...field, - showWhen: undefined, - showIf: (values: Record) => - values.appliesTo === "LAST_MILE" && values.lastMileMode === "BULK", - }; + return false; } - return field; - }).map((field) => { + return field.name !== "minKm" && field.name !== "maxKm"; + }); + return bandFields.map((field) => { if (isPriorityRules && field.name === "minWagonCount") { return { ...field, @@ -621,19 +614,15 @@ const RuleEngineResourcePage = () => { 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, - } + ? { + // Empty "To km" means an open-ended band — send null so an + // edit can clear a previously-set ceiling. On create the tier + // spread below overrides the band fields per tier. + maxKm: values.maxKm ?? null, + ...(lastMileMode === "BULK" + ? { rateUnit: "PER_TON_KM", containerTypeId: undefined } + : { rateUnit: "PER_KM" }), + } : {}), }; // Editing a LIVE rate files a change request — the rate keeps charging diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts index a674a21eb..d5998515e 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts @@ -1003,7 +1003,8 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ placeholder: "0", description: "Band start (inclusive). Use 0 for the first band.", showIf: (v) => - v.appliesTo === "LAST_MILE" && v.lastMileMode === "CONTAINER", + v.appliesTo === "LAST_MILE" && + (v.lastMileMode === "CONTAINER" || v.lastMileMode === "BULK"), }, { name: "maxKm", @@ -1013,7 +1014,8 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ 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", + v.appliesTo === "LAST_MILE" && + (v.lastMileMode === "CONTAINER" || v.lastMileMode === "BULK"), }, { name: "currency", @@ -1035,9 +1037,10 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ type: "tierList", required: true, description: - "One rate per distance range. To km is exclusive (0–30 then 30+); leave the last tier's To km empty for no upper limit.", + "One rate per distance range — the rate value is per km (container mode) or per ton per km (bulk mode). To km is exclusive (0–30 then 30+); leave the last tier's To km empty for no upper limit.", showIf: (v) => - v.appliesTo === "LAST_MILE" && v.lastMileMode === "CONTAINER", + 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) ─