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(); 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 { 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 { 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(); 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 })), }; }