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'; 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; /** 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, ) {} 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 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); } 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, 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', isGovernment: booking.isGovernment, 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, 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()] }; } /** 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)), ); } }