mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 11:18:17 +00:00
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.
164 lines
4.5 KiB
TypeScript
164 lines
4.5 KiB
TypeScript
import { computeLastMileCharge } from './last-mile-charge.util';
|
||
import type { Rate } from '../modules/rule-engine/entities/rate.entity';
|
||
|
||
const rate = (over: Partial<Rate>): Rate =>
|
||
({
|
||
appliesTo: 'LAST_MILE',
|
||
status: 'LIVE',
|
||
currency: 'ETB',
|
||
trigger: 'ALWAYS',
|
||
rateType: 'LAST_MILE',
|
||
...over,
|
||
}) as Rate;
|
||
|
||
const band20a = rate({
|
||
rateUnit: 'PER_KM',
|
||
rateValue: 1800,
|
||
minKm: 0,
|
||
maxKm: 30,
|
||
containerType: { sizeFt: 20 } as Rate['containerType'],
|
||
});
|
||
const band20b = rate({
|
||
rateUnit: 'PER_KM',
|
||
rateValue: 1500,
|
||
minKm: 30,
|
||
maxKm: null,
|
||
containerType: { sizeFt: 20 } as Rate['containerType'],
|
||
});
|
||
const band40a = rate({
|
||
rateUnit: 'PER_KM',
|
||
rateValue: 2200,
|
||
minKm: 0,
|
||
maxKm: 30,
|
||
containerType: { sizeFt: 40 } as Rate['containerType'],
|
||
});
|
||
const bulkRate = rate({ rateUnit: 'PER_TON_KM', rateValue: 25 });
|
||
|
||
describe('computeLastMileCharge', () => {
|
||
it('prices containers per band × size × quantity', () => {
|
||
const charge = computeLastMileCharge({
|
||
freightType: 'CONTAINER',
|
||
tons: 0,
|
||
km: 13,
|
||
containers: [
|
||
{ sizeLabel: '20DC', qty: 5 },
|
||
{ sizeLabel: '40HC', qty: 1 },
|
||
],
|
||
liveRates: [band20a, band20b, band40a],
|
||
});
|
||
// 13 × 1800 × 5 + 13 × 2200 × 1
|
||
expect(charge).toMatchObject({ mode: 'CONTAINER', total: 117000 + 28600, currency: 'ETB' });
|
||
expect(charge!.lines).toHaveLength(2);
|
||
});
|
||
|
||
it('band boundary is half-open: km = 30 falls in the 30+ band', () => {
|
||
const charge = computeLastMileCharge({
|
||
freightType: 'CONTAINER',
|
||
tons: 0,
|
||
km: 30,
|
||
containers: [{ sizeLabel: '20DC', qty: 1 }],
|
||
liveRates: [band20a, band20b],
|
||
});
|
||
expect(charge!.lines[0].unitRate).toBe(1500);
|
||
expect(charge!.total).toBe(30 * 1500);
|
||
});
|
||
|
||
it('returns null when a size has no matching band', () => {
|
||
const charge = computeLastMileCharge({
|
||
freightType: 'CONTAINER',
|
||
tons: 0,
|
||
km: 50,
|
||
containers: [{ sizeLabel: '40HC', qty: 2 }],
|
||
liveRates: [band40a], // 40ft only covers 0–30
|
||
});
|
||
expect(charge).toBeNull();
|
||
});
|
||
|
||
it('prices bulk as tons × km × rate', () => {
|
||
const charge = computeLastMileCharge({
|
||
freightType: 'BULK',
|
||
tons: 60,
|
||
km: 26,
|
||
containers: [],
|
||
liveRates: [bulkRate],
|
||
});
|
||
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(
|
||
computeLastMileCharge({
|
||
freightType: 'CONTAINER',
|
||
tons: 0,
|
||
km: 10,
|
||
containers: [
|
||
{ sizeLabel: '20DC', qty: 1 },
|
||
{ sizeLabel: '40HC', qty: 1 },
|
||
],
|
||
liveRates: [band20a, usd40],
|
||
}),
|
||
).toBeNull();
|
||
expect(
|
||
computeLastMileCharge({
|
||
freightType: 'BULK',
|
||
tons: 10,
|
||
km: 0,
|
||
containers: [],
|
||
liveRates: [bulkRate],
|
||
}),
|
||
).toBeNull();
|
||
expect(
|
||
computeLastMileCharge({
|
||
freightType: 'BREAK_BULK',
|
||
tons: 10,
|
||
km: 10,
|
||
containers: [],
|
||
liveRates: [bulkRate],
|
||
}),
|
||
).toBeNull();
|
||
});
|
||
});
|