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

View File

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

View File

@@ -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<string, unknown>) =>
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

View File

@@ -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 030 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 (030 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 (030 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) ─