mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 14:15:44 +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;
|
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 liveRates = await this.ratesService.findLiveRates();
|
||||||
const rateById = new Map(liveRates.map((r) => [r.id, r]));
|
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) {
|
for (const mod of ruleResult.appliedModifiers) {
|
||||||
const usdAmount = mod.calculatedAmount;
|
const usdAmount = mod.calculatedAmount;
|
||||||
@@ -424,6 +433,97 @@ export class BookingPricingService {
|
|||||||
return { lineItems: lines, usedRates: [...usedRatesMap.values()] };
|
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". */
|
/** Friendly container-type label for the per-unit card; degrades to "Container". */
|
||||||
private async containerTypeLabel(containerTypeId: string): Promise<string> {
|
private async containerTypeLabel(containerTypeId: string): Promise<string> {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { InjectRepository } from '@nestjs/typeorm';
|
|||||||
import { DataSource, EntityManager, FindOptionsWhere, In, Repository, SelectQueryBuilder } from 'typeorm';
|
import { DataSource, EntityManager, FindOptionsWhere, In, Repository, SelectQueryBuilder } from 'typeorm';
|
||||||
|
|
||||||
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
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 { BookingApprovalStep } from './entities/booking-approval-step.entity';
|
||||||
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
|
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
|
||||||
import {
|
import {
|
||||||
@@ -163,6 +164,19 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
return Number(result?.total ?? 0);
|
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)
|
* Find another booking whose container quantity complements this one to fill whole wagon(s)
|
||||||
* (same route, same container type, partial wagon on both sides).
|
* (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) {
|
if (contract.isHazardous) {
|
||||||
const hazard = liveRates.find(
|
const hazard = liveRates.find(
|
||||||
(r) => r.rateType === 'HAZARD_SURCHARGE' && r.currency === 'USD',
|
(r) => r.rateType === 'HAZARD_SURCHARGE' && r.currency === 'USD',
|
||||||
);
|
);
|
||||||
if (hazard) {
|
if (hazard && Number(hazard.rateValue) > 0) {
|
||||||
lineItems.push({
|
lineItems.push({
|
||||||
code: 'HAZARD_SURCHARGE',
|
code: 'HAZARD_SURCHARGE',
|
||||||
label: 'Hazardous surcharge',
|
label: 'Hazardous surcharge',
|
||||||
@@ -142,7 +173,7 @@ export class ContractPricingService {
|
|||||||
const reefer = liveRates.find(
|
const reefer = liveRates.find(
|
||||||
(r) => r.rateType === 'REEFER_SURCHARGE' && r.currency === 'USD',
|
(r) => r.rateType === 'REEFER_SURCHARGE' && r.currency === 'USD',
|
||||||
);
|
);
|
||||||
if (reefer) {
|
if (reefer && Number(reefer.rateValue) > 0) {
|
||||||
lineItems.push({
|
lineItems.push({
|
||||||
code: 'REEFER_SURCHARGE',
|
code: 'REEFER_SURCHARGE',
|
||||||
label: 'Reefer surcharge',
|
label: 'Reefer surcharge',
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
Info,
|
Info,
|
||||||
PackageCheck,
|
PackageCheck,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Sparkles,
|
|
||||||
TrainFront,
|
TrainFront,
|
||||||
Truck,
|
Truck,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@@ -136,7 +135,6 @@ function ServiceTypeSelector({
|
|||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
{services.map((s) => {
|
{services.map((s) => {
|
||||||
const selected = s.id === value;
|
const selected = s.id === value;
|
||||||
const hasBonus = (s.priorityBonusPoints ?? 0) > 0;
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={s.id}
|
key={s.id}
|
||||||
@@ -201,14 +199,6 @@ function ServiceTypeSelector({
|
|||||||
<Text fz={14.5} fw={750} c={INK} style={{ lineHeight: 1.25 }}>
|
<Text fz={14.5} fw={750} c={INK} style={{ lineHeight: 1.25 }}>
|
||||||
{s.serviceName}
|
{s.serviceName}
|
||||||
</Text>
|
</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>
|
</Box>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user