import { Inject, Injectable, BadRequestException } from '@nestjs/common'; import { Freight } from '@edr/types'; import { Booking } from '../bookings/entities/booking.entity'; import { ICargoTypesRepository, CARGO_TYPES_REPOSITORY, } from './interfaces/cargo-types.repository.interface'; import { IServiceTypesRepository, SERVICE_TYPES_REPOSITORY, } from './interfaces/service-types.repository.interface'; import { ISurchargesRepository, SURCHARGES_REPOSITORY, } from './interfaces/surcharges.repository.interface'; import { IWeightLimitRulesRepository, WEIGHT_LIMIT_RULES_REPOSITORY, } from './interfaces/weight-limit-rules.repository.interface'; import { IPriorityRulesRepository, PRIORITY_RULES_REPOSITORY, } from './interfaces/priority-rules.repository.interface'; export interface AppliedSurcharge { feeName: string; rate: number; currency: string; calculationMethod: Freight.CalculationMethod; applyToRail: boolean; applyToFirstMile: boolean; applyToLastMile: boolean; } export interface RuleEvaluationResult { priorityScore: number; appliedSurcharges: AppliedSurcharge[]; warnings: string[]; hardBlocked: string[]; requiresDirectorApproval: boolean; } @Injectable() export class RuleEngineService { constructor( @Inject(CARGO_TYPES_REPOSITORY) private readonly cargoTypesRepo: ICargoTypesRepository, @Inject(SERVICE_TYPES_REPOSITORY) private readonly serviceTypesRepo: IServiceTypesRepository, @Inject(SURCHARGES_REPOSITORY) private readonly surchargesRepo: ISurchargesRepository, @Inject(WEIGHT_LIMIT_RULES_REPOSITORY) private readonly weightLimitRulesRepo: IWeightLimitRulesRepository, @Inject(PRIORITY_RULES_REPOSITORY) private readonly priorityRulesRepo: IPriorityRulesRepository, ) {} /** * Evaluate all rule engine rules against a booking snapshot. * Returns the computed priority score, surcharges to apply, warnings, * hard-block messages, and whether director approval is required. * Callers must throw BadRequestException if hardBlocked is non-empty. */ async evaluate( booking: Pick< Booking, | 'freightType' | 'serviceType' | 'paymentCurrency' | 'cargoTotalWeightVgm' | 'tradeDirection' | 'isHazardous' | 'isRefrigerated' | 'containers' >, ): Promise { const warnings: string[] = []; const hardBlocked: string[] = []; const appliedSurcharges: AppliedSurcharge[] = []; let priorityScore = 0; let requiresDirectorApproval = false; // ── 1. Cargo routing ───────────────────────────────────────────────── // Look up CargoType by code to determine director-approval routing. if (booking.freightType) { const cargoType = await this.cargoTypesRepo.findByCode(booking.freightType); if (cargoType?.requiresDirectorApproval) { requiresDirectorApproval = true; } } // ── 2. Weight-limit check ──────────────────────────────────────────── // For each container group in the booking, find matching active rules // and check whether the per-container VGM exceeds the max weight. const containers = booking.containers ?? []; for (const container of containers) { const rules = await this.weightLimitRulesRepo.findActiveByContainerTypeAndDirection( container.type, booking.tradeDirection, ); for (const rule of rules) { if (container.vgm > rule.maxWeightTons) { const msg = `${container.type} container VGM ${container.vgm}t exceeds max ` + `${rule.maxWeightTons}t (${booking.tradeDirection})`; if (rule.exceededAction === Freight.ExceededAction.HARD_BLOCK) { hardBlocked.push(msg); } else { warnings.push(msg); } if (rule.surcharge) { appliedSurcharges.push(this.mapSurcharge(rule.surcharge)); } } else if (container.vgm > rule.warningThresholdTons) { warnings.push( `${container.type} container VGM ${container.vgm}t is approaching limit ` + `of ${rule.maxWeightTons}t (${booking.tradeDirection})`, ); } } } // ── 3. Surcharge flags ─────────────────────────────────────────────── if (booking.isHazardous) { const surcharge = await this.surchargesRepo.findByTypeCode('HAZARDOUS'); if (surcharge) appliedSurcharges.push(this.mapSurcharge(surcharge)); } if (booking.isRefrigerated) { const surcharge = await this.surchargesRepo.findByTypeCode('REFRIGERATED'); if (surcharge) appliedSurcharges.push(this.mapSurcharge(surcharge)); } // ── 4. Priority scoring ────────────────────────────────────────────── const priorityRules = await this.priorityRulesRepo.findAllActive(); for (const rule of priorityRules) { switch (rule.priorityType) { case Freight.PriorityType.USD_PAYER: if (booking.paymentCurrency === 'USD') { priorityScore += rule.bonusPoints; } break; case Freight.PriorityType.RAIL_AND_FORWARDING: { // Read bonus points from the matching ServiceType DB row const serviceType = await this.serviceTypesRepo.findByCode(booking.serviceType); if (serviceType && serviceType.priorityBonusPoints > 0) { priorityScore += serviceType.priorityBonusPoints; } else if (booking.serviceType === 'RAIL_AND_FORWARDING') { // Fall back to the rule's own bonus_points if no ServiceType found priorityScore += rule.bonusPoints; } break; } case Freight.PriorityType.HIGH_VOLUME_SHIPMENT: if (booking.cargoTotalWeightVgm >= 300) { priorityScore += rule.bonusPoints; } break; case Freight.PriorityType.GOVERNMENT_ACCOUNT: // TODO: integrate customer accountTier — evaluate when Customer entity is extended break; } } return { priorityScore, appliedSurcharges, warnings, hardBlocked, requiresDirectorApproval }; } /** * Guard helper — throws BadRequestException if hardBlocked is non-empty. * Call this immediately after evaluate() in BookingsService. */ assertNoHardBlocks(result: RuleEvaluationResult): void { if (result.hardBlocked.length > 0) { throw new BadRequestException(result.hardBlocked.join('; ')); } } private mapSurcharge(s: { feeName: string; rate: number; currency: string; calculationMethod: Freight.CalculationMethod; applyToRail: boolean; applyToFirstMile: boolean; applyToLastMile: boolean }): AppliedSurcharge { return { feeName: s.feeName, rate: s.rate, currency: s.currency, calculationMethod: s.calculationMethod, applyToRail: s.applyToRail, applyToFirstMile: s.applyToFirstMile, applyToLastMile: s.applyToLastMile, }; } }