mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 09:35:44 +00:00
- rates: min_km/max_km columns, PER_TON_KM unit, ETB|USD currency for last mile - extend UQ_rates_pattern with band start; overlap + shape validation - shared last-mile charge resolver; approve-dialog price estimate endpoint - delivery-fee invoice prices via rules, falls back to vehicle price/km - backoffice: last-mile rate form (mode, container type, band, currency)
122 lines
3.2 KiB
TypeScript
122 lines
3.2 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('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();
|
||
});
|
||
});
|