import { Injectable, NotFoundException } from '@nestjs/common'; import { ContainerTypesService } from '../rule-engine/services/container-types.service'; import { RatesService } from '../rule-engine/services/rates.service'; import { Rate } from '../rule-engine/entities/rate.entity'; import { ExchangeService } from '@edr/api-common'; import { AppliedCargoModifier, BookingEvaluationInput, RuleEngineService, } from '../rule-engine/rule-engine.service'; import { BookingsRepository } from './bookings.repository'; import { containersPerWagon, wagonRemainder, } from './consolidation.service'; import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price-response.dto'; import { Booking } from './entities/booking.entity'; import { assertBookingStatus } from './booking-status.util'; import { ContainerValidationService } from './container-validation.service'; export interface OverweightLine { containerTypeCode: string; totalVgmTons: number; maxAllowedTons: number; excessTons: number; } export interface ComputedPriceResult { lineItems: PriceLineItemDto[]; totalAmount: number; currency: string; usedRates: Rate[]; appliedModifiers: AppliedCargoModifier[]; priorityScore: number; warnings: string[]; hardBlocked: string[]; overweightLines: OverweightLine[]; } type StoredPricingBreakdown = { lineItems?: PriceLineItemDto[]; totalAmount?: number; currency?: string; generatedAt?: string; } | null; /** Friendly labels for the per-unit rate card shown at the confirm step. */ const SURCHARGE_LABELS: Record = { HAZARD_SURCHARGE: 'Hazardous cargo', HAZARDOUS_CARGO: 'Hazardous cargo', REEFER_SURCHARGE: 'Refrigerated (reefer)', REEFER_CARGO: 'Refrigerated (reefer)', OVERWEIGHT_PER_TON: 'Overweight excess', DOUBLE_HANDLING: 'Double handling', LASHING: 'Lashing', PIL_EXTRA_FEE: 'Shipping line fee', }; function surchargeLabel(code: string): string { return ( SURCHARGE_LABELS[code] ?? code .toLowerCase() .split('_') .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) .join(' ') ); } @Injectable() export class BookingPricingService { constructor( private readonly bookingsRepository: BookingsRepository, private readonly ruleEngineService: RuleEngineService, private readonly containerTypesService: ContainerTypesService, private readonly ratesService: RatesService, private readonly exchangeService: ExchangeService, private readonly containerValidationService: ContainerValidationService, ) {} async generatePrice(bookingId: string): Promise { const booking = await this.requireBooking(bookingId); assertBookingStatus(booking, ['DRAFT', 'CHANGES_REQUESTED']); const computed = await this.computePriceForBooking(booking); this.ruleEngineService.assertNoHardBlocks({ priorityScore: computed.priorityScore, appliedModifiers: computed.appliedModifiers, containerWeightResults: [], warnings: computed.warnings, hardBlocked: computed.hardBlocked, requiresDirectorApproval: false, }); await this.bookingsRepository.update(bookingId, { totalAmount: computed.totalAmount, priorityScore: computed.priorityScore, pricingBreakdown: { lineItems: computed.lineItems, totalAmount: computed.totalAmount, currency: computed.currency, generatedAt: new Date().toISOString(), }, } as never); // 20ft weight-pairing preview: surfaced now so the customer sees the problem // (and the overweight warning + surcharge) at the confirm step, before submit. // Submit re-runs this and HARD-BLOCKS on a non-empty result. const pairing = await this.containerValidationService.validate20ftPairing(booking); return { bookingId, totalAmount: computed.totalAmount, currency: computed.currency, lineItems: computed.lineItems, warnings: computed.warnings, overweightLines: computed.overweightLines, pairingErrors: pairing.map((p) => p.message), }; } async computePriceForBooking(booking: Booking): Promise { const evalInput = await this.buildEvalInputForBooking(booking); const ruleResult = await this.ruleEngineService.evaluate(evalInput); const paymentCurrency = booking.paymentCurrency; const isEtbBooking = paymentCurrency === 'ETB'; const usdToEtb = isEtbBooking ? await this.exchangeService.getRate('USD', 'ETB') : 1; const lineItems: PriceLineItemDto[] = []; let total = 0; const { lineItems: baseLines, usedRates: baseRates } = await this.computeBaseRailLinesWithRates(booking, evalInput); for (const line of baseLines) { lineItems.push(line); 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, ...mileRates].map((r) => [r.id, r])); for (const mod of ruleResult.appliedModifiers) { const usdAmount = mod.calculatedAmount; const convertedAmount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount; const rate = rateById.get(mod.rateId); const unit = rate?.rateUnit ?? 'FLAT'; const unitUsd = rate ? Number(rate.rateValue) : usdAmount; const unitAmount = isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd; // Per-unit count: FLAT and PER_INVOICE are billed once (qty 1); an // explicit trigger (e.g. overweight tons) wins when present; otherwise // derive from total ÷ unit price. const quantity = unit === 'FLAT' || unit === 'PER_INVOICE' ? 1 : mod.triggerValue != null && mod.triggerValue > 0 ? mod.triggerValue : unitUsd > 0 ? Math.max(1, Math.round(usdAmount / unitUsd)) : 1; const item: PriceLineItemDto = { code: mod.surchargeCode, description: surchargeLabel(mod.surchargeCode), amount: convertedAmount, unitAmount, unit, quantity, currency: paymentCurrency, }; lineItems.push(item); total += convertedAmount; if (rate) usedRatesMap.set(rate.id, rate); } // Overweight detail for the customer: map the engine's per-line results back // to the booking's container lines (same order) for code + weights. maxAllowed // is derived from the line total minus the excess the engine computed. const overweightLines: OverweightLine[] = []; const containerLines = (booking.bookingContainers ?? []).filter( (bc) => bc.containerTypeId != null, ); for (let i = 0; i < ruleResult.containerWeightResults.length; i++) { const wr = ruleResult.containerWeightResults[i]; if (!wr?.isOverweight) continue; const line = containerLines[i]; const totalVgmTons = Number(line?.totalVgmTons ?? 0); const excessTons = Number(wr.overweightExcessTons ?? 0); let code = line?.containerSize ?? ''; if (line?.containerTypeId) { try { code = (await this.containerTypesService.findById(line.containerTypeId)).code; } catch { // fall back to the container size label } } overweightLines.push({ containerTypeCode: code, totalVgmTons, maxAllowedTons: Math.max(0, totalVgmTons - excessTons), excessTons, }); } return { lineItems, totalAmount: total, currency: booking.paymentCurrency, usedRates: [...usedRatesMap.values()], appliedModifiers: ruleResult.appliedModifiers, priorityScore: ruleResult.priorityScore, warnings: ruleResult.warnings, hardBlocked: ruleResult.hardBlocked, overweightLines, }; } pricesMatch(stored: StoredPricingBreakdown, computed: ComputedPriceResult): boolean { if (!stored?.lineItems?.length) return false; if (Number(stored.totalAmount) !== computed.totalAmount) return false; return ( this.lineItemsSignature(stored.lineItems) === this.lineItemsSignature(computed.lineItems) ); } async createPricingSnapshots( bookingId: string, usedRates: Rate[], appliedModifiers: AppliedCargoModifier[], ): Promise { await this.bookingsRepository.clearPricingArtifacts(bookingId); const snapshots = await this.ruleEngineService.snapshotRates(bookingId, usedRates); const snapshotByRateId = new Map(snapshots.map((s) => [s.rateId, s.id])); const rows = appliedModifiers .map((m) => { const snapshotId = snapshotByRateId.get(m.rateId); if (!snapshotId) return null; return { bookingId, rateId: m.rateId, triggerValue: m.triggerValue, calculatedAmount: m.calculatedAmount, rateSnapshotId: snapshotId, }; }) .filter((r): r is NonNullable => r !== null); if (rows.length > 0) { await this.bookingsRepository.createCargoModifiers(rows); } } async buildEvalInputForBooking(booking: Booking): Promise { const lines = await Promise.all( (booking.bookingContainers ?? []) .filter((bc): bc is typeof bc & { containerTypeId: string } => bc.containerTypeId != null) .map(async (bc) => { const ct = await this.containerTypesService.findById(bc.containerTypeId); const vgm = Number(bc.vgmPerUnitTons); const qty = bc.quantity; return { container: { containerTypeId: bc.containerTypeId, quantity: qty, vgmPerUnitTons: vgm, totalVgmTons: qty * vgm, isReefer: ct.isReefer, }, perWagon: containersPerWagon(Number(ct.wagonsPerUnit)), quantity: qty, }; }), ); const containers = lines.map((l) => l.container); // Wagon count is persisted per container line at booking creation; sum it. const totalWagons = booking.freightType === 'CONTAINER' ? Math.ceil( (booking.bookingContainers ?? []).reduce( (sum, bc) => sum + Number(bc.wagonsRequired ?? 0), 0, ), ) : 0; // Consolidation is system-managed: the CONSOLIDATION surcharge fires whenever // a container type leaves a wagon partially filled. Aggregate by type first — // two lines of the same type share wagons, so 2× 20FT (= one full wagon) must // NOT count as a partial wagon. Mirrors ConsolidationService.slotsFromContainerLines. const remainderByType = new Map(); for (const l of lines) { const prev = remainderByType.get(l.container.containerTypeId); remainderByType.set(l.container.containerTypeId, { quantity: (prev?.quantity ?? 0) + Number(l.quantity || 0), perWagon: l.perWagon, }); } const allowConsolidation = booking.freightType === 'CONTAINER' && [...remainderByType.values()].some( (t) => wagonRemainder(t.quantity, t.perWagon) > 0, ); return { freightType: booking.freightType as 'CONTAINER' | 'BULK', cargoTypeId: booking.cargoTypeId ?? null, serviceTypeId: booking.serviceTypeId, paymentCurrency: booking.paymentCurrency, tradeDirection: booking.tradeDirection, // Coerce defensively in case the stored flag is a string ("true"/"false"). isHazardous: booking.isHazardous === true || (booking.isHazardous as unknown) === 'true', // Booking-level reefer flag (set by contract drawdown orders that carry a // reefer quantity) applies the REEFER surcharge even for non-reefer // container types. ORed with per-container reefer in the engine. isReefer: booking.isReefer === true || (booking.isReefer as unknown) === 'true', isGovernment: booking.isGovernment, allowConsolidation, shippingLineId: booking.shippingLineId, totalWagons, // Bulk tonnage scales PER_TON surcharges (e.g. the bulk reefer surcharge). // Container freight carries 0 here — its surcharges scale by container count. bulkTons: booking.freightType === 'BULK' ? Number(booking.cargoTotalWeightVgm ?? 0) : 0, containers, }; } private async requireBooking(id: string): Promise { const booking = await this.bookingsRepository.findByIdWithFiles(id); if (!booking) throw new NotFoundException(`Booking ${id} not found`); return booking; } /** Line items for contract schedule (uses stored breakdown or recomputes). */ async computeContractLineItems(booking: Booking): Promise<{ lineItems: PriceLineItemDto[]; totalAmount: number; currency: string; }> { const stored = booking.pricingBreakdown as StoredPricingBreakdown; if (stored?.lineItems?.length) { return { lineItems: stored.lineItems, totalAmount: Number(stored.totalAmount ?? booking.totalAmount), currency: stored.currency ?? booking.paymentCurrency, }; } const computed = await this.computePriceForBooking(booking); if (computed.lineItems.length === 0) { const total = Number(booking.totalAmount); return { lineItems: [ { code: 'TOTAL', description: 'Contract total', amount: total, unitAmount: total, unit: 'FLAT', quantity: 1, currency: booking.paymentCurrency, }, ], totalAmount: total, currency: booking.paymentCurrency, }; } return { lineItems: computed.lineItems, totalAmount: computed.totalAmount || Number(booking.totalAmount), currency: computed.currency, }; } /** * Recompute priority on submit. * * The full priority model is additive and capped at 100: * service-type bonus (≤ 15) + wagon block (≤ 50) + currency block (≤ 35). * All three components are produced by RuleEngineService.evaluate, so submit * simply re-runs the engine — there is no extra submit-time inflation. */ async computeSubmitPriorityScore(booking: Booking): Promise { const evalInput = await this.buildEvalInputForBooking(booking); const ruleResult = await this.ruleEngineService.evaluate(evalInput); return ruleResult.priorityScore; } private async computeBaseRailLinesWithRates( booking: Booking, evalInput: BookingEvaluationInput, ): Promise<{ lineItems: PriceLineItemDto[]; usedRates: Rate[] }> { 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 isBulk = booking.freightType === 'BULK'; const rateType = booking.tradeDirection === 'IMPORT' ? isBulk ? 'BULK_IMPORT' : 'CONTAINER_IMPORT' : booking.tradeDirection === 'EXPORT' ? isBulk ? 'BULK_EXPORT' : 'CONTAINER_EXPORT' : isBulk ? 'INTERCITY_BULK' : 'INTERCITY_CONTAINER'; const lines: PriceLineItemDto[] = []; const usedRatesMap = new Map(); const wagonCount = await this.bookingsRepository.calculateWagonCount(booking.id); for (const container of evalInput.containers) { const rate = this.pickRate(liveRates, rateType, container.containerTypeId, 'USD'); if (!rate) continue; usedRatesMap.set(rate.id, rate); const usdAmount = this.amountForRate(rate, container.quantity, wagonCount); const amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount; const unitUsd = Number(rate.rateValue); const label = await this.containerTypeLabel(container.containerTypeId); lines.push({ code: rateType, description: `${label} rail freight`, amount, unitAmount: isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd, unit: rate.rateUnit, quantity: this.effectiveUnitQuantity(rate.rateUnit, container.quantity, wagonCount), currency: paymentCurrency, }); } if (lines.length === 0) { const fallback = liveRates.find( (r) => r.rateType === rateType && r.currency === 'USD' && r.status === 'LIVE', ); if (fallback) { usedRatesMap.set(fallback.id, fallback); const bulkTons = Number(booking.cargoTotalWeightVgm ?? 0); const quantity = isBulk && fallback.rateUnit === 'PER_TON' ? Math.max(bulkTons, 0) : 1; const usdAmount = this.amountForRate(fallback, quantity, wagonCount); const amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount; const unitUsd = Number(fallback.rateValue); lines.push({ code: rateType, description: isBulk ? 'Bulk rail freight' : 'Container rail freight', amount, unitAmount: isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd, unit: fallback.rateUnit, quantity: this.effectiveUnitQuantity(fallback.rateUnit, quantity, wagonCount), currency: paymentCurrency, }); } } 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 { const ct = await this.containerTypesService?.findById?.(containerTypeId); return ct?.label ?? 'Container'; } catch { return 'Container'; } } /** How many units a rate's total is divided into, by rate unit (for the per-unit card). */ private effectiveUnitQuantity( rateUnit: string, quantity: number, wagonCount: number, ): number { switch (rateUnit) { case 'PER_WAGON': return wagonCount; case 'FLAT': return 1; case 'PER_CONTAINER': case 'PER_TON': default: return quantity; } } private pickRate( rates: Rate[], rateType: string, containerTypeId: string, currency: string, ): Rate | undefined { return ( rates.find( (r) => r.rateType === rateType && r.currency === currency && r.containerTypeId === containerTypeId, ) ?? rates.find((r) => r.rateType === rateType && r.currency === currency && !r.containerTypeId) ); } private amountForRate(rate: Rate, quantity: number, wagonCount: number): number { const value = Number(rate.rateValue); switch (rate.rateUnit) { case 'PER_CONTAINER': return value * quantity; case 'PER_WAGON': return value * wagonCount; case 'PER_TON': return value * quantity; case 'FLAT': return value; default: return value * quantity; } } private lineItemsSignature(items: PriceLineItemDto[]): string { return JSON.stringify( [...items] .map((item) => ({ code: item.code, amount: item.amount, currency: item.currency, })) .sort((a, b) => a.code.localeCompare(b.code)), ); } }