Files
edr-platform/apps/edr-freight-api/src/common/last-mile-charge.util.ts
Hagernesh 95fad6f095 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.
2026-08-06 08:56:35 +00:00

213 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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: 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 → 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) —
* 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 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);
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 })),
};
}