mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
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:
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) ─
|
||||
|
||||
Reference in New Issue
Block a user