feat: implement first and last mile trucking pricing logic in booking and contract services

This commit is contained in:
Marshal
2026-06-28 23:54:51 +00:00
parent 947dcbed3d
commit 4be4cc9054
4 changed files with 149 additions and 14 deletions

View File

@@ -121,9 +121,18 @@ export class BookingPricingService {
total += line.amount;
}
// First / last mile trucking — billed per the rate's unit (km / container /
// ton / flat), only for legs the booking actually carries.
const { lineItems: mileLines, usedRates: mileRates } =
await this.computeFirstLastMileLines(booking, evalInput);
for (const line of mileLines) {
lineItems.push(line);
total += line.amount;
}
const liveRates = await this.ratesService.findLiveRates();
const rateById = new Map(liveRates.map((r) => [r.id, r]));
const usedRatesMap = new Map(baseRates.map((r) => [r.id, r]));
const usedRatesMap = new Map([...baseRates, ...mileRates].map((r) => [r.id, r]));
for (const mod of ruleResult.appliedModifiers) {
const usdAmount = mod.calculatedAmount;
@@ -424,6 +433,97 @@ export class BookingPricingService {
return { lineItems: lines, usedRates: [...usedRatesMap.values()] };
}
/**
* First-mile (pick-up) and last-mile (delivery) trucking lines. Each leg is
* billed only when the booking carries that leg (an address is set) and a LIVE
* rate exists, scaled by the rate's own unit:
* PER_KM → contract-route road distance (km)
* PER_CONTAINER → total container count
* PER_TON → total bulk tonnage
* FLAT → once
* A leg whose rate value (or computed amount) is 0 contributes nothing.
*/
private async computeFirstLastMileLines(
booking: Booking,
evalInput: BookingEvaluationInput,
): Promise<{ lineItems: PriceLineItemDto[]; usedRates: Rate[] }> {
const legs: Array<{ rateType: 'FIRST_MILE' | 'LAST_MILE'; label: string; active: boolean }> = [
{
rateType: 'FIRST_MILE',
label: 'First mile (pick-up)',
active: Boolean(booking.firstMilePickupAddress),
},
{
rateType: 'LAST_MILE',
label: 'Last mile (delivery)',
active: Boolean(booking.lastMileDeliveryAddress),
},
];
if (!legs.some((l) => l.active)) {
return { lineItems: [], usedRates: [] };
}
const liveRates = await this.ratesService.findLiveRates();
const paymentCurrency = booking.paymentCurrency;
const isEtbBooking = paymentCurrency === 'ETB';
const usdToEtb = isEtbBooking ? await this.exchangeService.getRate('USD', 'ETB') : 1;
const containerCount = evalInput.containers.reduce(
(sum, c) => sum + Number(c.quantity || 0),
0,
);
const bulkTons = Number(booking.cargoTotalWeightVgm ?? 0);
const routeKm = await this.bookingsRepository.getContractRouteKm(booking.contractRouteId);
const lines: PriceLineItemDto[] = [];
const usedRatesMap = new Map<string, Rate>();
for (const leg of legs) {
if (!leg.active) continue;
const rate = liveRates.find(
(r) => r.rateType === leg.rateType && r.currency === 'USD' && r.status === 'LIVE',
);
if (!rate) continue;
const value = Number(rate.rateValue);
let quantity: number;
switch (rate.rateUnit) {
case 'PER_KM':
quantity = routeKm;
break;
case 'PER_CONTAINER':
quantity = containerCount;
break;
case 'PER_TON':
quantity = bulkTons;
break;
case 'FLAT':
default:
quantity = 1;
break;
}
const usdAmount = value * quantity;
// Skip legs that resolve to nothing (zero rate, or zero km / count / tons).
if (!(usdAmount > 0)) continue;
const amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
const unitUsd = value;
usedRatesMap.set(rate.id, rate);
lines.push({
code: leg.rateType,
description: leg.label,
amount,
unitAmount: isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd,
unit: rate.rateUnit,
quantity,
currency: paymentCurrency,
});
}
return { lineItems: lines, usedRates: [...usedRatesMap.values()] };
}
/** Friendly container-type label for the per-unit card; degrades to "Container". */
private async containerTypeLabel(containerTypeId: string): Promise<string> {
try {

View File

@@ -5,6 +5,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, EntityManager, FindOptionsWhere, In, Repository, SelectQueryBuilder } from 'typeorm';
import { ContainerType } from '../rule-engine/entities/container-type.entity';
import { ContractRoute } from '../contracts/entities/contract-route.entity';
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
import {
@@ -163,6 +164,19 @@ export class BookingsRepository extends BaseRepository<Booking> {
return Number(result?.total ?? 0);
}
/**
* The road billing distance (km) of a booking's contract route, used to price
* per-km first/last-mile trucking. Returns 0 when there is no route or no km
* recorded (rail-only lanes) so a PER_KM rate bills nothing.
*/
async getContractRouteKm(contractRouteId: string | null | undefined): Promise<number> {
if (!contractRouteId) return 0;
const route = await this.dataSource
.getRepository(ContractRoute)
.findOne({ where: { id: contractRouteId }, select: { id: true, km: true } });
return Number(route?.km ?? 0);
}
/**
* Find another booking whose container quantity complements this one to fill whole wagon(s)
* (same route, same container type, partial wagon on both sides).

View File

@@ -123,12 +123,43 @@ export class ContractPricingService {
}
}
// Conditional surcharges — shown only when the contract toggles them on.
// First / last mile trucking unit rates — shown when the contract carries
// that leg. Per-unit prices only; the actual amount (× km / containers /
// tons / flat) is computed at booking time.
if (contract.firstMilePickupAddress) {
const fm = liveRates.find(
(r) => r.rateType === 'FIRST_MILE' && r.currency === 'USD',
);
if (fm && Number(fm.rateValue) > 0) {
lineItems.push({
code: 'FIRST_MILE',
label: 'First mile (pick-up)',
unit: toContractUnit(fm.rateUnit),
unitPrice: convert(Number(fm.rateValue)),
});
}
}
if (contract.lastMileDeliveryAddress) {
const lm = liveRates.find(
(r) => r.rateType === 'LAST_MILE' && r.currency === 'USD',
);
if (lm && Number(lm.rateValue) > 0) {
lineItems.push({
code: 'LAST_MILE',
label: 'Last mile (delivery)',
unit: toContractUnit(lm.rateUnit),
unitPrice: convert(Number(lm.rateValue)),
});
}
}
// Conditional surcharges — shown only when the contract toggles them on AND
// the rate has a non-zero value (a 0 rate means "no surcharge").
if (contract.isHazardous) {
const hazard = liveRates.find(
(r) => r.rateType === 'HAZARD_SURCHARGE' && r.currency === 'USD',
);
if (hazard) {
if (hazard && Number(hazard.rateValue) > 0) {
lineItems.push({
code: 'HAZARD_SURCHARGE',
label: 'Hazardous surcharge',
@@ -142,7 +173,7 @@ export class ContractPricingService {
const reefer = liveRates.find(
(r) => r.rateType === 'REEFER_SURCHARGE' && r.currency === 'USD',
);
if (reefer) {
if (reefer && Number(reefer.rateValue) > 0) {
lineItems.push({
code: 'REEFER_SURCHARGE',
label: 'Reefer surcharge',

View File

@@ -8,7 +8,6 @@ import {
Info,
PackageCheck,
ShieldCheck,
Sparkles,
TrainFront,
Truck,
} from "lucide-react";
@@ -136,7 +135,6 @@ function ServiceTypeSelector({
<div className="grid gap-3 sm:grid-cols-2">
{services.map((s) => {
const selected = s.id === value;
const hasBonus = (s.priorityBonusPoints ?? 0) > 0;
return (
<button
key={s.id}
@@ -201,14 +199,6 @@ function ServiceTypeSelector({
<Text fz={14.5} fw={750} c={INK} style={{ lineHeight: 1.25 }}>
{s.serviceName}
</Text>
{hasBonus ? (
<Group gap={4} mt={3} wrap="nowrap">
<Sparkles size={11} color="#B26C09" />
<Text fz={11} fw={700} c="#B26C09">
Priority service
</Text>
</Group>
) : null}
</Box>
</Group>