import { Injectable, NotFoundException } from '@nestjs/common'; import { ContainerTypesService } from '../rule-engine/services/container-types.service'; import { RatesService } from '../rule-engine/services/rates.service'; import { ServiceTypesService } from '../rule-engine/services/service-types.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 { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price-response.dto'; import { Booking } from './entities/booking.entity'; import { assertBookingStatus } from './booking-status.util'; export interface ComputedPriceResult { lineItems: PriceLineItemDto[]; totalAmount: number; currency: string; usedRates: Rate[]; appliedModifiers: AppliedCargoModifier[]; priorityScore: number; warnings: string[]; hardBlocked: string[]; } type StoredPricingBreakdown = { lineItems?: PriceLineItemDto[]; totalAmount?: number; currency?: string; generatedAt?: string; } | null; @Injectable() export class BookingPricingService { constructor( private readonly bookingsRepository: BookingsRepository, private readonly ruleEngineService: RuleEngineService, private readonly containerTypesService: ContainerTypesService, private readonly ratesService: RatesService, private readonly serviceTypesService: ServiceTypesService, private readonly exchangeService: ExchangeService, ) {} 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); return { bookingId, totalAmount: computed.totalAmount, currency: computed.currency, lineItems: computed.lineItems, warnings: computed.warnings, }; } 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; } 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])); for (const mod of ruleResult.appliedModifiers) { const usdAmount = mod.calculatedAmount; const convertedAmount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount; const item: PriceLineItemDto = { code: mod.surchargeTypeCode, description: `Surcharge: ${mod.surchargeTypeCode}`, amount: convertedAmount, currency: paymentCurrency, }; lineItems.push(item); total += convertedAmount; const rate = rateById.get(mod.rateId); if (rate) usedRatesMap.set(rate.id, rate); } return { lineItems, totalAmount: total, currency: booking.paymentCurrency, usedRates: [...usedRatesMap.values()], appliedModifiers: ruleResult.appliedModifiers, priorityScore: ruleResult.priorityScore, warnings: ruleResult.warnings, hardBlocked: ruleResult.hardBlocked, }; } 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, surchargeTypeId: m.surchargeTypeId, 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 containers = 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 { containerTypeId: bc.containerTypeId, quantity: qty, vgmPerUnitTons: vgm, totalVgmTons: qty * vgm, isReefer: ct.isReefer, }; }), ); // 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; return { freightType: booking.freightType as 'CONTAINER' | 'BULK', cargoTypeId: booking.cargoTypeId ?? null, serviceTypeId: booking.serviceTypeId, paymentCurrency: booking.paymentCurrency, tradeDirection: booking.tradeDirection, isHazardous: booking.isHazardous, isGovernment: booking.isGovernment, allowConsolidation: booking.allowConsolidation, shippingLineId: booking.shippingLineId, totalWagons, 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, 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 (USD + service tier). */ async computeSubmitPriorityScore(booking: Booking): Promise { const evalInput = await this.buildEvalInputForBooking(booking); const ruleResult = await this.ruleEngineService.evaluate(evalInput); let score = ruleResult.priorityScore; const serviceType = await this.serviceTypesService.findById(booking.serviceTypeId); if (booking.paymentCurrency === 'USD' && serviceType) { const code = (serviceType.code ?? '').toUpperCase(); const hasForwarding = serviceType.includesFirstMile || serviceType.includesLastMile || code.includes('FORWARD') || code.includes('Y'); const railOnly = code.includes('RAIL') && !hasForwarding; if (hasForwarding) score += 1000; else if (railOnly || code.includes('X')) score += 500; } return score; } 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; lines.push({ code: rateType, description: `Base rail (${rateType})`, amount, 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; lines.push({ code: rateType, description: `Base rail (${rateType})`, amount, currency: paymentCurrency, }); } } return { lineItems: lines, usedRates: [...usedRatesMap.values()] }; } 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)), ); } }