mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat: implement first and last mile trucking pricing logic in booking and contract services
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user