mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 08:20:58 +00:00
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:
121
apps/edr-freight-api/src/common/last-mile-charge.util.spec.ts
Normal file
121
apps/edr-freight-api/src/common/last-mile-charge.util.spec.ts
Normal 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 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();
|
||||
});
|
||||
});
|
||||
202
apps/edr-freight-api/src/common/last-mile-charge.util.ts
Normal file
202
apps/edr-freight-api/src/common/last-mile-charge.util.ts
Normal 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 })),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user