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.
This commit is contained in:
Hagernesh
2026-08-06 08:42:13 +00:00
parent 69f6fd36a9
commit 95fad6f095
5 changed files with 125 additions and 60 deletions

View File

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

View File

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