import { Inject, Injectable, BadRequestException } from '@nestjs/common'; import { DataSource } from 'typeorm'; import { BookingApprovalStep } from '../bookings/entities/booking-approval-step.entity'; import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.entity'; import { TriggerCondition } from './entities/surcharge-type.entity'; import { ICargoTypesRepository, CARGO_TYPES_REPOSITORY, } from './interfaces/cargo-types.repository.interface'; import { IServiceTypesRepository, SERVICE_TYPES_REPOSITORY, } from './interfaces/service-types.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'; import { ISurchargeTypesRepository, SURCHARGE_TYPES_REPOSITORY, } from './interfaces/surcharge-types.repository.interface'; import { IRatesRepository, RATES_REPOSITORY, } from './interfaces/rates.repository.interface'; import { IApprovalRulesRepository, APPROVAL_RULES_REPOSITORY, } from './interfaces/approval-rules.repository.interface'; import { IShippingLinesRepository, SHIPPING_LINES_REPOSITORY, } from './interfaces/shipping-lines.repository.interface'; export interface BookingContainerEvalInput { containerTypeId: string; quantity: number; vgmPerUnitTons: number; totalVgmTons: number; isReefer?: boolean; isOverweight?: boolean; overweightExcessTons?: number | null; } export interface BookingEvaluationInput { cargoTypeId?: string | null; freightType?: 'CONTAINER' | 'BULK'; serviceTypeId: string; paymentCurrency: string; tradeDirection: string; isHazardous: boolean; allowConsolidation?: boolean; shippingLineId?: string | null; containers: BookingContainerEvalInput[]; } export interface AppliedCargoModifier { surchargeTypeId: string; surchargeTypeCode: string; triggerValue: number | null; calculatedAmount: number; rateId: string; currency: string; } export interface ContainerWeightResult { containerTypeId: string; weightLimitRuleId: string | null; isOverweight: boolean; overweightExcessTons: number | null; } export interface RuleEvaluationResult { priorityScore: number; appliedModifiers: AppliedCargoModifier[]; containerWeightResults: ContainerWeightResult[]; 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(WEIGHT_LIMIT_RULES_REPOSITORY) private readonly weightLimitRulesRepo: IWeightLimitRulesRepository, @Inject(PRIORITY_RULES_REPOSITORY) private readonly priorityRulesRepo: IPriorityRulesRepository, @Inject(SURCHARGE_TYPES_REPOSITORY) private readonly surchargeTypesRepo: ISurchargeTypesRepository, @Inject(RATES_REPOSITORY) private readonly ratesRepo: IRatesRepository, @Inject(APPROVAL_RULES_REPOSITORY) private readonly approvalRulesRepo: IApprovalRulesRepository, @Inject(SHIPPING_LINES_REPOSITORY) private readonly shippingLinesRepo: IShippingLinesRepository, private readonly dataSource: DataSource, ) {} /** * Evaluate all rule engine rules against a booking snapshot. */ async evaluate(input: BookingEvaluationInput): Promise { const warnings: string[] = []; const hardBlocked: string[] = []; const appliedModifiers: AppliedCargoModifier[] = []; const containerWeightResults: ContainerWeightResult[] = []; let priorityScore = 0; let requiresDirectorApproval = false; if (input.freightType === 'BULK') { requiresDirectorApproval = true; } if (input.cargoTypeId) { const cargoType = await this.cargoTypesRepo.findById(input.cargoTypeId); if (!cargoType) { hardBlocked.push(`Cargo type ${input.cargoTypeId} not found`); } else if (cargoType.requiresDirectorApproval) { requiresDirectorApproval = true; } } for (const container of input.containers) { const rules = await this.weightLimitRulesRepo.findActiveByContainerTypeId( container.containerTypeId, input.tradeDirection, ); const rule = rules[0]; let isOverweight = container.isOverweight ?? false; let excess = container.overweightExcessTons ?? null; if (rule) { const maxTotal = Number(rule.maxVgmTons) * container.quantity; const totalVgm = container.totalVgmTons; if (totalVgm > maxTotal) { isOverweight = true; excess = Math.max(0, totalVgm - maxTotal); warnings.push( `Container type ${container.containerTypeId} VGM ${totalVgm}t exceeds limit ${maxTotal}t`, ); } containerWeightResults.push({ containerTypeId: container.containerTypeId, weightLimitRuleId: rule.id, isOverweight, overweightExcessTons: excess, }); } else { containerWeightResults.push({ containerTypeId: container.containerTypeId, weightLimitRuleId: null, isOverweight, overweightExcessTons: excess, }); } } const serviceType = await this.serviceTypesRepo.findById(input.serviceTypeId); if (serviceType) { priorityScore += serviceType.priorityBonusPoints; } const priorityRules = await this.priorityRulesRepo.findAllActive(); for (const rule of priorityRules) { if ( rule.conditionCurrency === null || rule.conditionCurrency === input.paymentCurrency ) { priorityScore += rule.score; } } let shippingLineMapped = false; if (input.shippingLineId) { const line = await this.shippingLinesRepo.findById(input.shippingLineId); shippingLineMapped = Boolean(line?.mappedToCode); } const hasReefer = input.containers.some((c) => c.isReefer); const hasOverweight = containerWeightResults.some((r) => r.isOverweight); const surchargeTypes = await this.surchargeTypesRepo.findAllActiveWithRate(); const liveRates = await this.ratesRepo.findLiveRates(); const rateById = new Map(liveRates.map((r) => [r.id, r])); for (const st of surchargeTypes) { const triggered = this.matchesTrigger(st.triggerCondition, { isHazardous: input.isHazardous, hasReefer, hasOverweight, shippingLineMapped, allowConsolidation: input.allowConsolidation ?? false, }); if (!triggered) continue; const rate = st.rate ?? rateById.get(st.rateId); if (!rate) continue; let triggerValue: number | null = null; let calculatedAmount = Number(rate.rateValue); if (st.triggerCondition === 'VGM_EXCEEDS_LIMIT') { triggerValue = containerWeightResults.reduce( (sum, r) => sum + (r.overweightExcessTons ?? 0), 0, ); if (rate.rateUnit === 'PER_TON') { calculatedAmount = triggerValue * Number(rate.rateValue); } } appliedModifiers.push({ surchargeTypeId: st.id, surchargeTypeCode: st.code, triggerValue, calculatedAmount, rateId: rate.id, currency: rate.currency, }); } return { priorityScore, appliedModifiers, containerWeightResults, warnings, hardBlocked, requiresDirectorApproval, }; } /** * Instantiate booking_approval_step rows from approval_rules by freight type. */ async instantiateApprovalSteps( bookingId: string, options: { freightType: 'CONTAINER' | 'BULK'; cargoTypeId?: string | null; }, ): Promise { let requiresDirectorApproval = options.freightType === 'BULK'; if (options.cargoTypeId) { const cargoType = await this.cargoTypesRepo.findById(options.cargoTypeId); if (!cargoType) { throw new BadRequestException(`Cargo type ${options.cargoTypeId} not found`); } if (cargoType.requiresDirectorApproval) { requiresDirectorApproval = true; } } const chain = await this.approvalRulesRepo.findChainForCargo( requiresDirectorApproval, ); const stepRepo = this.dataSource.getRepository(BookingApprovalStep); const steps: BookingApprovalStep[] = []; for (const rule of chain) { const step = stepRepo.create({ bookingId, approvalRuleId: rule.id, stepOrder: rule.stepOrder, requiredRole: rule.requiredRole, status: 'PENDING', }); steps.push(await stepRepo.save(step)); } return steps; } /** * Snapshot all LIVE rates into booking_rate_snapshot for a booking. */ async snapshotLiveRates(bookingId: string): Promise { const liveRates = await this.ratesRepo.findLiveRates(); const snapshotRepo = this.dataSource.getRepository(BookingRateSnapshot); const now = new Date(); const snapshots: BookingRateSnapshot[] = []; for (const rate of liveRates) { const snapshot = snapshotRepo.create({ bookingId, rateId: rate.id, rateType: rate.rateType, rateValue: rate.rateValue, rateUnit: rate.rateUnit, currency: rate.currency, snapshottedAt: now, }); snapshots.push(await snapshotRepo.save(snapshot)); } return snapshots; } /** Guard helper — throws BadRequestException if hardBlocked is non-empty. */ assertNoHardBlocks(result: RuleEvaluationResult): void { if (result.hardBlocked.length > 0) { throw new BadRequestException(result.hardBlocked.join('; ')); } } private matchesTrigger( condition: TriggerCondition, state: { isHazardous: boolean; hasReefer: boolean; hasOverweight: boolean; shippingLineMapped: boolean; allowConsolidation: boolean; }, ): boolean { switch (condition) { case 'CARGO_FLAG_HAZARDOUS': return state.isHazardous; case 'CARGO_FLAG_REEFER': return state.hasReefer; case 'VGM_EXCEEDS_LIMIT': return state.hasOverweight; case 'SHIPPING_LINE_MAPPED': return state.shippingLineMapped; case 'CONSOLIDATION_ENABLED': return state.allowConsolidation; default: return false; } } }