import { Injectable, NotFoundException } from '@nestjs/common'; import { CargoTypesService } from '../rule-engine/services/cargo-types.service'; 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 { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity'; import { ExchangeService } from '@edr/api-common'; import { AppliedCargoModifier, BookingEvaluationInput, RuleEngineService, } from '../rule-engine/rule-engine.service'; import { containersPerWagonForSize, wagonsPerUnitForSize, } from '../rule-engine/container-type.util'; import { BookingsRepository } from './bookings.repository'; import { 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, private readonly cargoTypesService: CargoTypesService, ) {} 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; // H15: a booking created under a contract prices from that contract's FROZEN // rate snapshots (the agreed rates), not the live rate of the day. Loaded // once and threaded through the line builders; each rate code that has a // snapshot uses it, and any code without one falls back to the live rate. // Non-contract bookings resolve to null and keep the live-rate path. const frozenRates = await this.loadFrozenContractRates(booking); const lineItems: PriceLineItemDto[] = []; let total = 0; const { lineItems: baseLines, usedRates: baseRates, warnings: baseWarnings, blocked: baseBlocked, } = await this.computeBaseRailLinesWithRates(booking, evalInput, frozenRates); 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, frozenRates); 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 rate = rateById.get(mod.rateId); // Derived/route-matched charges (import overweight, empty-container // return) carry their own unit price + billing unit — bill and display // those, not whatever the referenced rate row says. const isDerived = mod.unitPriceUsd != null; const unit = mod.billingUnit ?? rate?.rateUnit ?? 'FLAT'; const unitUsd = isDerived ? Number(mod.unitPriceUsd) : rate ? Number(rate.rateValue) : usdAmount; // 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 (the live unit price — a count, not a // currency amount, so it is snapshot-independent). 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; // H15: bill the frozen contract surcharge rate (already in the booking // currency) when this code has a snapshot; else keep the live amount. // Derived charges skip the snapshot — import overweight prices off the // route's container freight, never a frozen OVERWEIGHT_PER_TON value. const frozen = isDerived ? null : this.frozenRateByCode(frozenRates, mod.surchargeCode, paymentCurrency); const unitAmount = frozen ? Number(frozen.unitPrice) : isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd; const convertedAmount = frozen ? isEtbBooking ? Math.round(unitAmount * quantity) : unitAmount * quantity : isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount; 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); } // Customs clearance service fee (Path B) — billed HERE, on the booking // invoice with the freight; no separate prepaid clearance invoice. Sold per // cargo kind: container bookings bill each container type's own fee (per // box or per wagon), bulk bookings the route's bulk fee (per ton or per // wagon). Frozen contract snapshots win over live rates; a customs booking // with nothing configured hard-blocks — clearance never ships for free. const clearanceBlocked: string[] = []; if (booking.customsClearingEnabled) { const clearance = await this.customsClearanceLines(booking, frozenRates, liveRates); for (const line of clearance.lineItems) { lineItems.push(line); total += line.amount; } for (const rate of clearance.usedRates) usedRatesMap.set(rate.id, rate); clearanceBlocked.push(...clearance.blocked); } // 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, ...baseWarnings], hardBlocked: [...ruleResult.hardBlocked, ...baseBlocked, ...clearanceBlocked], 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, // Per-container opt-ins — PER_CONTAINER surcharges bill these. hazardousQuantity: Number(bc.hazardousQuantity ?? 0), reeferQuantity: Number(bc.reeferQuantity ?? 0), returnQuantity: Number(bc.returnQuantity ?? 0), // Wagon share per box — a PER_WAGON empty-return rate bills on it. wagonsPerUnit: wagonsPerUnitForSize(ct.sizeFt), }, perWagon: containersPerWagonForSize(ct.sizeFt), 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; // Bulk wagon estimate for PER_WAGON kind-scoped surcharges (lashing). // Deliberately NOT totalWagons — that would shift wagon-count priority // scoring for bulk bookings. const bulkWagons = booking.freightType === 'BULK' ? ((await this.bulkWagonCount(booking)) ?? 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', // Empty-container return service (container freight only) — bills the // WITH_RETURN surcharge per container, like hazard/reefer. withReturn: booking.freightType === 'CONTAINER' && booking.equipmentReturn === 'WITH_RETURN', isGovernment: booking.isGovernment, allowConsolidation, shippingLineId: booking.shippingLineId, originYardId: booking.originYardId, destinationYardId: booking.destinationYardId, 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, bulkWagons, 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, totalWagonsOverride?: number, ): Promise { const evalInput = await this.buildEvalInputForBooking(booking); // BULK bookings have no container lines, so buildEvalInputForBooking yields // totalWagons = 0 and every wagon-range priority config misses. The batch // engine derives a bulk booking's wagon footprint from tonnage vs. live // wagon capacity and passes it here to score the booking properly. if (totalWagonsOverride != null && totalWagonsOverride > 0) { evalInput.totalWagons = totalWagonsOverride; } const ruleResult = await this.ruleEngineService.evaluate(evalInput); return ruleResult.priorityScore; } private async computeBaseRailLinesWithRates( booking: Booking, evalInput: BookingEvaluationInput, frozenRates: Map | null = null, ): Promise<{ lineItems: PriceLineItemDto[]; usedRates: Rate[]; warnings: string[]; blocked: string[]; }> { 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 warnings: string[] = []; const blocked: string[] = []; const wagonCount = await this.resolveWagonCount(booking); for (const container of evalInput.containers) { const rate = this.pickRate( liveRates, rateType, container.containerTypeId, 'USD', booking.originYardId, booking.destinationYardId, ); // H15: frozen contract rate for this container size, when present — its // unitPrice is already in the booking currency (no USD→currency convert). // It also stands on its own: a contract line prices off the agreed rate // even when nobody configured a live rate for this leg + type yet. const frozen = await this.frozenRateForContainer( frozenRates, container.containerTypeId, paymentCurrency, ); const label = await this.containerTypeLabel(container.containerTypeId); if (!rate && !frozen) { // Never price this line off another container type's (or another // route's) rate, and never let an unpriced line through: a booking // that ships a container type nobody configured a rate for would be // carried for free. Hard-block instead — the customer drops the line // or EDR configures the rate. blocked.push( `No ${rateType} rate is configured for ${label} on this route — ` + `the booking cannot be priced. Remove the ${label} line or ask EDR ` + 'to configure its rate for this origin → destination.', ); continue; } const rateUnit = rate?.rateUnit ?? 'PER_CONTAINER'; let amount: number; let unitAmount: number; if (frozen) { unitAmount = Number(frozen.unitPrice); amount = this.amountForUnit( rateUnit, unitAmount, container.quantity, wagonCount, ); } else { const unitUsd = Number(rate!.rateValue); const usdAmount = this.amountForRate(rate!, container.quantity, wagonCount); amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount; unitAmount = isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd; } if (rate) usedRatesMap.set(rate.id, rate); lines.push({ code: rateType, description: `${label} rail freight`, amount, unitAmount, unit: rateUnit, quantity: this.effectiveUnitQuantity(rateUnit, container.quantity, wagonCount), currency: paymentCurrency, }); } if (lines.length === 0 && evalInput.containers.length === 0) { // Bulk (and any booking with no container lines) still has to price off a // rate configured for this leg — never one belonging to another route. // Container bookings never reach this fallback: their lines price per // container type above or stay unpriced with a warning — falling back to // a corridor rate of a DIFFERENT container type billed once (qty 1) is // how a 38-container booking was invoiced 40 USD instead of 1900. const fallback = liveRates.find( (r) => r.rateType === rateType && r.currency === 'USD' && r.status === 'LIVE' && r.originYardId === booking.originYardId && r.destinationYardId === booking.destinationYardId, ); 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 unitUsd = Number(fallback.rateValue); // H15: bulk freight uses the frozen BULK_FREIGHT snapshot when present. const frozen = isBulk ? this.frozenRateByCode(frozenRates, 'BULK_FREIGHT', paymentCurrency) : null; let amount: number; let unitAmount: number; if (frozen) { unitAmount = Number(frozen.unitPrice); amount = this.amountForUnit( fallback.rateUnit, unitAmount, quantity, wagonCount, ); } else { const usdAmount = this.amountForRate(fallback, quantity, wagonCount); amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount; unitAmount = isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd; } lines.push({ code: rateType, description: isBulk ? 'Bulk rail freight' : 'Container rail freight', amount, unitAmount, unit: fallback.rateUnit, quantity: this.effectiveUnitQuantity(fallback.rateUnit, quantity, wagonCount), currency: paymentCurrency, }); } else if (isBulk) { // Same rule as container lines: bulk freight with no rate on this leg // must not proceed unpriced. blocked.push( `No ${rateType} rate is configured for this route — the booking ` + 'cannot be priced. Ask EDR to configure the rate for this ' + 'origin → destination.', ); } } return { lineItems: lines, usedRates: [...usedRatesMap.values()], warnings, blocked }; } /** * 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, frozenRates: Map | null = null, ): 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; } // H15: frozen mile rate (already in booking currency) when the contract // has one; else the live USD rate converted as before. const frozen = this.frozenRateByCode( frozenRates, leg.rateType, paymentCurrency, ); let amount: number; let unitAmount: number; if (frozen) { unitAmount = Number(frozen.unitPrice); amount = isEtbBooking ? Math.round(unitAmount * quantity) : unitAmount * quantity; } else { const usdAmount = value * quantity; amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount; unitAmount = isEtbBooking ? Math.round(value * usdToEtb) : value; } // Skip legs that resolve to nothing (zero rate, or zero km / count / tons). if (!(amount > 0)) continue; usedRatesMap.set(rate.id, rate); lines.push({ code: leg.rateType, description: leg.label, amount, unitAmount, unit: rate.rateUnit, quantity, currency: paymentCurrency, }); } return { lineItems: lines, usedRates: [...usedRatesMap.values()] }; } /** * Wagon count for PER_WAGON rates. A persisted booking uses the SQL aggregate; * an unsaved preview booking (no id) sums the wagonsRequired already computed * on its in-memory container lines — same math, no DB row needed. */ private async resolveWagonCount(booking: Booking): Promise { if (!booking.id) { return Math.ceil( (booking.bookingContainers ?? []).reduce( (sum, bc) => sum + Number(bc.wagonsRequired ?? 0), 0, ), ); } return this.bookingsRepository.calculateWagonCount(booking.id); } /** 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; } } /** * Base freight is quoted per leg, so a rate only applies to a booking running * the exact origin → destination it was configured for. There is deliberately * no route-agnostic fallback: charging a Dire Dawa price for a Mojo shipment * because nobody configured Mojo yet is worse than surfacing no line at all. * Within the leg, a rate scoped to the container type wins over one that * covers every type. */ private pickRate( rates: Rate[], rateType: string, containerTypeId: string, currency: string, originYardId: string, destinationYardId: string, ): Rate | undefined { const onLeg = rates.filter( (r) => r.rateType === rateType && r.currency === currency && r.originYardId === originYardId && r.destinationYardId === destinationYardId, ); return ( onLeg.find((r) => r.containerTypeId === containerTypeId) ?? onLeg.find((r) => !r.containerTypeId) ); } private amountForRate(rate: Rate, quantity: number, wagonCount: number): number { return this.amountForUnit( rate.rateUnit, Number(rate.rateValue), quantity, wagonCount, ); } /** Apply a unit value by rate unit — shared by live and frozen-snapshot lines. */ private amountForUnit( rateUnit: string, unitValue: number, quantity: number, wagonCount: number, ): number { switch (rateUnit) { case 'PER_CONTAINER': return unitValue * quantity; case 'PER_WAGON': return unitValue * wagonCount; case 'PER_TON': return unitValue * quantity; case 'FLAT': return unitValue; default: return unitValue * quantity; } } // ── H15: frozen contract rate snapshots ──────────────────────────────────── /** * Load a contract's frozen rate snapshots into a by-rate-code lookup, or null * for a non-contract booking (or a contract with no snapshots). The pricing * line builders prefer a matching snapshot's unit price over the live rate. */ private async loadFrozenContractRates( booking: Booking, ): Promise | null> { if (!booking.contractId) return null; const snapshots = await this.bookingsRepository.findContractRateSnapshots( booking.contractId, ); if (!snapshots.length) return null; const byCode = new Map(); for (const snap of snapshots) byCode.set(snap.rateCode, snap); return byCode; } /** * The frozen snapshot for a rate code, or null when there is none, its price * is negative, or it is in a different currency than the booking (in which * case the live-rate path is safer than a mis-converted frozen price). */ private frozenRateByCode( frozenRates: Map | null, code: string, bookingCurrency: string, ): ContractRateSnapshot | null { const snap = frozenRates?.get(code); if (!snap) return null; if (snap.currency !== bookingCurrency) return null; if (!(Number(snap.unitPrice) >= 0)) return null; return snap; } /** * The frozen base-rail snapshot for a container line, matched by the * container's size (CONTAINER_20FT / CONTAINER_40FT — the codes * ContractPricingService freezes). Null when there is no snapshot. */ private async frozenRateForContainer( frozenRates: Map | null, containerTypeId: string, bookingCurrency: string, ): Promise { if (!frozenRates) return null; let sizeFt: number | null = null; try { sizeFt = Number((await this.containerTypesService.findById(containerTypeId)).sizeFt) || null; } catch { return null; } if (!sizeFt) return null; return this.frozenRateByCode(frozenRates, `CONTAINER_${sizeFt}FT`, bookingCurrency); } /** * Customs clearance service fee lines for a customs booking (Path B), billed * with the freight. Container bookings bill each container line at its own * container type's fee — PER_CONTAINER × boxes or PER_WAGON × the wagons the * line occupies (two 20ft share one). Bulk bookings bill the route's type-less * fee — PER_TON × tonnage or PER_WAGON × wagons the bulk occupies. Frozen * contract snapshots (CUSTOMS_CLEARANCE_20FT / _40FT / CUSTOMS_CLEARANCE) * win over live rates; contracts frozen before the per-kind model carry one * FLAT CUSTOMS_CLEARANCE snapshot, honoured once for the whole booking. */ private async customsClearanceLines( booking: Booking, frozenRates: Map | null, liveRates: Rate[], ): Promise<{ lineItems: PriceLineItemDto[]; usedRates: Rate[]; blocked: string[] }> { const lineItems: PriceLineItemDto[] = []; const usedRates: Rate[] = []; const blocked: string[] = []; const currency = booking.paymentCurrency; const isEtb = currency === 'ETB'; const usdToEtb = isEtb ? await this.exchangeService.getRate('USD', 'ETB') : 1; const convert = (usd: number): number => (isEtb ? Math.round(usd * usdToEtb) : usd); const onLeg = liveRates.filter( (r) => r.rateType === 'CUSTOMS_CLEARANCE' && r.currency === 'USD' && r.tradeDirection === booking.tradeDirection && r.originYardId === booking.originYardId && r.destinationYardId === booking.destinationYardId, ); const missingRateMessage = (scope: string): string => `No customs clearance service fee is configured for ${scope} on this ` + 'origin → destination. Ask EDR to configure the CUSTOMS_CLEARANCE rate for this route.'; if (booking.freightType === 'CONTAINER') { // Legacy short-circuit: an old contract froze one flat fee — bill it once. const hasPerSizeSnapshot = frozenRates?.has('CUSTOMS_CLEARANCE_20FT') || frozenRates?.has('CUSTOMS_CLEARANCE_40FT'); const legacyFlat = this.frozenRateByCode(frozenRates, 'CUSTOMS_CLEARANCE', currency); if (legacyFlat && !hasPerSizeSnapshot) { const amount = Number(legacyFlat.unitPrice); if (amount > 0) { lineItems.push({ code: 'CUSTOMS_CLEARANCE', description: 'Customs clearance service', amount, unitAmount: amount, unit: 'FLAT', quantity: 1, currency, }); } return { lineItems, usedRates, blocked }; } for (const bc of booking.bookingContainers ?? []) { if (!bc.containerTypeId) continue; const qty = Number(bc.quantity || 0); if (!(qty > 0)) continue; let sizeFt = 0; try { sizeFt = Number((await this.containerTypesService.findById(bc.containerTypeId)).sizeFt) || 0; } catch { // unknown type — falls through to the live per-type lookup below } const frozen = sizeFt ? this.frozenRateByCode(frozenRates, `CUSTOMS_CLEARANCE_${sizeFt}FT`, currency) : null; const live = onLeg.find((r) => r.containerTypeId === bc.containerTypeId); if (!frozen && !live) { blocked.push(missingRateMessage(`${sizeFt || '?'}ft containers`)); continue; } const unit = frozen ? this.rateUnitFromSnapshot(frozen.unitOfMeasure) : live!.rateUnit; const unitAmount = frozen ? Number(frozen.unitPrice) : convert(Number(live!.rateValue)); const billedQty = unit === 'PER_WAGON' ? Math.ceil(qty * wagonsPerUnitForSize(sizeFt)) : qty; const amount = unit === 'FLAT' ? unitAmount : unitAmount * billedQty; if (!(amount > 0)) continue; lineItems.push({ code: sizeFt ? `CUSTOMS_CLEARANCE_${sizeFt}FT` : 'CUSTOMS_CLEARANCE', description: `Customs clearance service${sizeFt ? ` (${sizeFt}ft)` : ''}`, amount, unitAmount, unit, quantity: unit === 'FLAT' ? 1 : billedQty, currency, }); if (live && !frozen) usedRates.push(live); } return { lineItems, usedRates, blocked }; } // Bulk — one fee for the whole booking. The bulk snapshot and the legacy // flat snapshot share the CUSTOMS_CLEARANCE code; both are the agreed fee. const frozen = this.frozenRateByCode(frozenRates, 'CUSTOMS_CLEARANCE', currency); const live = onLeg.find((r) => !r.containerTypeId); if (!frozen && !live) { blocked.push(missingRateMessage('bulk cargo')); return { lineItems, usedRates, blocked }; } const unit = frozen ? this.rateUnitFromSnapshot(frozen.unitOfMeasure) : live!.rateUnit; const unitAmount = frozen ? Number(frozen.unitPrice) : convert(Number(live!.rateValue)); let billedQty = 1; if (unit === 'PER_TON') { billedQty = Math.max(0, Number(booking.cargoTotalWeightVgm ?? 0)); } else if (unit === 'PER_WAGON') { const wagons = await this.bulkWagonCount(booking); if (wagons == null) { blocked.push( 'The bulk customs clearance fee is per wagon, but this cargo type has ' + 'no wagon type with a capacity configured — the wagon count cannot ' + 'be derived. Ask EDR to configure the cargo type’s wagon types.', ); return { lineItems, usedRates, blocked }; } billedQty = wagons; } const amount = unit === 'FLAT' ? unitAmount : unitAmount * billedQty; if (amount > 0) { lineItems.push({ code: 'CUSTOMS_CLEARANCE', description: 'Customs clearance service (bulk)', amount, unitAmount, unit, quantity: unit === 'FLAT' ? 1 : billedQty, currency, }); if (live && !frozen) usedRates.push(live); } return { lineItems, usedRates, blocked }; } /** Snapshot unit-of-measure → the rate unit the billing math applies. */ private rateUnitFromSnapshot(unitOfMeasure: string): string { switch (unitOfMeasure) { case 'per_wagon': return 'PER_WAGON'; case 'per_ton': return 'PER_TON'; case 'per_container': return 'PER_CONTAINER'; default: return 'FLAT'; } } /** * Wagons a bulk booking occupies — ceil(tons ÷ rated capacity), using the * largest-capacity wagon type its cargo type allows. Null when the chain is * unconfigured (no cargo type, no wagon types, no capacity). * ponytail: pricing-time estimate off the biggest allowed wagon; scheduling * may stock a smaller type and use more wagons. */ private async bulkWagonCount(booking: Booking): Promise { const tons = Number(booking.cargoTotalWeightVgm ?? 0); if (!(tons > 0) || !booking.cargoTypeId) return null; try { const cargo = await this.cargoTypesService.findById(booking.cargoTypeId); const capacity = Math.max( 0, ...(cargo.wagonTypes ?? []).map((w) => Number(w.capacityTons) || 0), ); if (!(capacity > 0)) return null; return Math.max(1, Math.ceil(tons / capacity)); } catch { return null; } } 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)), ); } }