mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 14:08:11 +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.
213 lines
7.1 KiB
TypeScript
213 lines
7.1 KiB
TypeScript
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 })),
|
||
};
|
||
}
|