From 4be4cc90541ff12239ca3da468067d677be47f31 Mon Sep 17 00:00:00 2001 From: Marshal Date: Sun, 28 Jun 2026 23:54:51 +0000 Subject: [PATCH] feat: implement first and last mile trucking pricing logic in booking and contract services --- .../bookings/booking-pricing.service.ts | 102 +++++++++++++++++- .../modules/bookings/bookings.repository.ts | 14 +++ .../contracts/contract-pricing.service.ts | 37 ++++++- .../new-contract-form/step2-service-type.tsx | 10 -- 4 files changed, 149 insertions(+), 14 deletions(-) diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts index f9d8fb17e..c5e5b710e 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts @@ -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(); + + 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 { try { diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index 8b50e5a49..57e811e34 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -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 { 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 { + 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). diff --git a/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts index 60fc9dc99..8ce7ad2ea 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts @@ -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', diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step2-service-type.tsx b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step2-service-type.tsx index 430d1239a..93c8dddfc 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step2-service-type.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step2-service-type.tsx @@ -8,7 +8,6 @@ import { Info, PackageCheck, ShieldCheck, - Sparkles, TrainFront, Truck, } from "lucide-react"; @@ -136,7 +135,6 @@ function ServiceTypeSelector({
{services.map((s) => { const selected = s.id === value; - const hasBonus = (s.priorityBonusPoints ?? 0) > 0; return (