feat(rates): last-mile rate rules with bulk per-ton-km and container distance bands

- 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)
This commit is contained in:
Hagernesh
2026-08-05 22:12:40 +00:00
parent 6125f644b1
commit 5c8c68e990
25 changed files with 880 additions and 20 deletions

View File

@@ -0,0 +1,121 @@
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 030
});
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();
});
});

View File

@@ -0,0 +1,202 @@
import type { DataSource } from 'typeorm';
import type { Rate } from '../modules/rule-engine/entities/rate.entity';
import { bookingContainerSizes } from './truck-load.util';
/** One priced line of a rule-based last-mile charge. */
export interface LastMileChargeLine {
description: string;
quantity: number;
unitRate: number;
amount: number;
}
/** A fully-resolved rule-based last-mile charge. */
export interface LastMileCharge {
mode: 'BULK' | 'CONTAINER';
total: number;
currency: string;
lines: LastMileChargeLine[];
}
/** What the last-mile leg is hauling, in the shape the rate rules price. */
export interface LastMileShipmentShape {
freightType: string | null;
tons: number;
containers: Array<{ sizeLabel: string; qty: number }>;
}
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.
* 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.
*
* 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) —
* callers keep their existing pricing as the fallback. Never throws.
*/
export function computeLastMileCharge(input: {
freightType: string | null;
tons: number;
km: number;
containers: Array<{ sizeLabel: string; qty: number }>;
/** LIVE rates with the containerType relation loaded (findLiveRatesDetailed). */
liveRates: Rate[];
}): LastMileCharge | null {
const { freightType, tons, km, containers, liveRates } = input;
if (!km || km <= 0) return null;
const candidates = liveRates.filter(
(rate) => rate.appliesTo === 'LAST_MILE' && rate.status === 'LIVE',
);
if (freightType === 'BULK') {
if (!tons || tons <= 0) return null;
const rate = candidates.find((r) => r.rateUnit === 'PER_TON_KM');
if (!rate) return null;
const unitRate = Number(rate.rateValue);
const amount = round2(tons * km * unitRate);
return {
mode: 'BULK',
total: amount,
currency: rate.currency,
lines: [
{
description: `Last-mile bulk delivery — ${tons} t × ${km} km × ${unitRate}/t·km`,
quantity: tons,
unitRate,
amount,
},
],
};
}
if (freightType === 'CONTAINER') {
if (!containers.length) return null;
const lines: LastMileChargeLine[] = [];
const currencies = new Set<string>();
for (const group of containers) {
const rate = candidates.find(
(r) =>
r.rateUnit === 'PER_KM' &&
r.minKm !== null &&
r.minKm !== undefined &&
r.containerType?.sizeFt !== null &&
r.containerType?.sizeFt !== undefined &&
group.sizeLabel.includes(String(r.containerType.sizeFt)) &&
Number(r.minKm) <= km &&
(r.maxKm === null || r.maxKm === undefined || km < Number(r.maxKm)),
);
// A size the rules don't cover means the rule set can't price this job.
if (!rate) return null;
const unitRate = Number(rate.rateValue);
const amount = round2(km * unitRate * group.qty);
currencies.add(rate.currency);
lines.push({
description: `Last-mile delivery — ${group.qty} × ${group.sizeLabel} container, ${km} km @ ${unitRate}/km`,
quantity: group.qty,
unitRate,
amount,
});
}
// A charge can't mix birr and dollar lines on one invoice.
if (currencies.size !== 1) return null;
return {
mode: 'CONTAINER',
total: round2(lines.reduce((sum, line) => sum + line.amount, 0)),
currency: [...currencies][0],
lines,
};
}
return null;
}
/**
* Rule-based charge for an operational last-mile record: prices what its
* trucks actually haul (last_mile_vehicle_containers / weighed net tons)
* against the given km. Shared by setDistances (writes remainingPayment) and
* DELIVERY_FEE invoicing so the two never disagree on the math. Null = the
* rules don't cover this job — callers keep the per-vehicle price/km path.
*/
export async function ruleBasedLastMileCharge(
dataSource: DataSource,
liveRates: Rate[],
lastMileId: string,
km: number,
): Promise<LastMileCharge | null> {
if (!km || km <= 0) return null;
const [record]: Array<{ bookingId: string }> = await dataSource.query(
`SELECT booking_id AS "bookingId"
FROM freight.last_mile
WHERE id = $1 AND deleted_at IS NULL`,
[lastMileId],
);
if (!record) return null;
const containerRows: Array<{ containerNumber: string }> = await dataSource.query(
`SELECT container_number AS "containerNumber"
FROM freight.last_mile_vehicle_containers
WHERE last_mile_id = $1 AND deleted_at IS NULL`,
[lastMileId],
);
const shape = await lastMileShipmentShape(
dataSource,
record.bookingId,
containerRows.map((r) => r.containerNumber),
);
// Bulk: bill the weighed tonnage on this record's trucks when known,
// falling back to the booking's declared VGM total.
const [tonsRow]: Array<{ tons: string | null }> = await dataSource.query(
`SELECT SUM(net_weight_tons) AS "tons"
FROM freight.last_mile_vehicle_assignments
WHERE last_mile_id = $1 AND deleted_at IS NULL`,
[lastMileId],
);
const weighedTons = Number(tonsRow?.tons ?? 0);
return computeLastMileCharge({
...shape,
tons: weighedTons > 0 ? weighedTons : shape.tons,
km,
liveRates,
});
}
/**
* Load a booking's shipment shape for the charge resolver: freight type, bulk
* tonnage, and the container numbers grouped into size × quantity.
*/
export async function lastMileShipmentShape(
dataSource: DataSource,
bookingId: string,
containerNumbers: string[],
): Promise<LastMileShipmentShape> {
const [booking]: Array<{ freightType: string | null; tons: string | null }> =
await dataSource.query(
`SELECT freight_type AS "freightType", cargo_total_weight_vgm AS "tons"
FROM freight.bookings
WHERE id = $1 AND deleted_at IS NULL`,
[bookingId],
);
const sizes = await bookingContainerSizes(
dataSource,
bookingId,
containerNumbers.map((n) => n.trim().toUpperCase()),
);
const bySize = new Map<string, number>();
for (const size of sizes) {
if (!size) continue;
bySize.set(size, (bySize.get(size) ?? 0) + 1);
}
return {
freightType: booking?.freightType ?? null,
tons: Number(booking?.tons ?? 0),
containers: [...bySize.entries()].map(([sizeLabel, qty]) => ({ sizeLabel, qty })),
};
}