import { Inject, Injectable, BadRequestException } from '@nestjs/common'; import { DataSource } from 'typeorm'; import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.entity'; import { Rate, RateTrigger } from './entities/rate.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 { IPriorityConfigsRepository, PRIORITY_CONFIGS_REPOSITORY, } from './interfaces/priority-configs.repository.interface'; import { IRatesRepository, RATES_REPOSITORY, } from './interfaces/rates.repository.interface'; import { IShippingLinesRepository, SHIPPING_LINES_REPOSITORY, } from './interfaces/shipping-lines.repository.interface'; import { GOVERNMENT_PRIORITY_BONUS } from './government-priority.constants'; // Coerce defensively: a flag may arrive as the string "true"/"false" (e.g. // from multipart form-data) and a non-empty "false" string is truthy. const truthy = (v: unknown): boolean => v === true || v === 'true'; export interface BookingContainerEvalInput { containerTypeId: string; quantity: number; vgmPerUnitTons: number; totalVgmTons: number; isReefer?: boolean; isOverweight?: boolean; overweightExcessTons?: number | null; /** * How many individual containers on this line opted into each handling * service. PER_CONTAINER surcharges bill these counts, not the line * quantity — 20 containers with 10 hazardous bill hazard on 10. */ hazardousQuantity?: number; reeferQuantity?: number; returnQuantity?: number; } export interface BookingEvaluationInput { cargoTypeId?: string | null; freightType?: 'CONTAINER' | 'BULK'; serviceTypeId: string; paymentCurrency: string; tradeDirection: string; isHazardous: boolean; /** Booking-level reefer flag; ORed with per-container reefer. */ isReefer?: boolean; /** * Booking ships with empty-container return (equipment_return = WITH_RETURN, * container freight only). Fires the WITH_RETURN surcharge like hazard/reefer. */ withReturn?: boolean; isGovernment?: boolean; allowConsolidation?: boolean; shippingLineId?: string | null; /** * Booking's cargo type needs EDR-provided lashing/securing (cargoType * hasLashing = true). Fires the flat LASHING surcharge. Resolved by the * engine from cargoTypeId when omitted. */ hasLashing?: boolean; totalWagons: number; /** * Total bulk tonnage on the booking (cargoTotalWeightVgm). Used to scale * PER_TON surcharges (e.g. the bulk reefer surcharge). 0/undefined for * container freight, which is scaled by container count instead. */ bulkTons?: number; containers: BookingContainerEvalInput[]; } export interface AppliedCargoModifier { /** The trigger-based rate that produced this surcharge line. */ rateId: string; /** Stable display/audit code, derived from the rate's trigger + rateType. */ surchargeCode: string; triggerValue: number | null; calculatedAmount: number; 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_CONFIGS_REPOSITORY) private readonly priorityConfigsRepo: IPriorityConfigsRepository, @Inject(RATES_REPOSITORY) private readonly ratesRepo: IRatesRepository, @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; } // Lashing is a cargo-type property: a booking incurs the flat LASHING // surcharge when its cargo type has hasLashing = true. Resolve it here so // matchesTrigger can fire the LASHING rate. Falls back to an explicit // input flag when no cargo type is set (e.g. container bookings). let hasLashing = input.hasLashing === 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; } if (cargoType.hasLashing) { hasLashing = true; } } } hardBlocked.push( ...(await this.capacityViolations(input.containers, input.tradeDirection)), ); 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); const includesCustoms = serviceType?.includesCustoms ?? false; // Additive priority blocks, each keyed on the booking's total wagon count: // - WAGON rules apply regardless of currency. // - CURRENCY rules apply only when the payment currency matches. // - CUSTOMS rules apply only when the service type includes customs. const priorityConfigs = await this.priorityConfigsRepo.findAllActive(); const wagonsInRange = (cfg: { minWagonCount: number; maxWagonCount: number }) => input.totalWagons >= cfg.minWagonCount && input.totalWagons <= cfg.maxWagonCount; for (const cfg of priorityConfigs) { const applies = cfg.type === 'WAGON' || (cfg.type === 'CURRENCY' && cfg.currency === input.paymentCurrency) || (cfg.type === 'CUSTOMS' && includesCustoms); if (applies && wagonsInRange(cfg)) { priorityScore += cfg.scorePoints; } } if (input.isGovernment) { priorityScore += GOVERNMENT_PRIORITY_BONUS; } let shippingLineMapped = false; if (input.shippingLineId) { const line = await this.shippingLinesRepo.findById(input.shippingLineId); shippingLineMapped = Boolean(line?.mappedToCode); } const hasReefer = input.isReefer === true || input.containers.some((c) => c.isReefer); const hasOverweight = containerWeightResults.some((r) => r.isOverweight); // Surcharges are now self-describing rates: any LIVE rate whose `trigger` // is not ALWAYS. Each fires independently and stacks on top of base freight // — hazard + reefer + overweight all add together, each with its own unit. // // A given surcharge identity (same trigger + rateType + unit + value + // scope) must contribute exactly ONE line. Duplicate LIVE rate rows — e.g. // from a non-idempotent seeder — would otherwise repeat the same surcharge // many times and inflate the total, so we collapse them to one row each. const liveRates = await this.ratesRepo.findLiveRates(); const surchargeRates = this.dedupeRatesBySignature( liveRates.filter((r) => r.trigger && r.trigger !== 'ALWAYS'), ); // A handling service the booking asks for (booking-level flag OR any // per-container opt-in count) with no LIVE surcharge rate configured is a // hard block — pricing would otherwise ship the service for free. System- // derived charges (consolidation, overweight, shipping line, lashing) stay // exempt: the customer never opted into those, so they must not block. const requestedServices: Array<{ trigger: RateTrigger; wanted: boolean; label: string; }> = [ { trigger: 'HAZARDOUS', wanted: truthy(input.isHazardous) || input.containers.some((c) => Number(c.hazardousQuantity ?? 0) > 0), label: 'hazardous cargo', }, { trigger: 'REEFER', wanted: hasReefer || input.containers.some((c) => Number(c.reeferQuantity ?? 0) > 0), label: 'refrigerated (reefer) cargo', }, { trigger: 'WITH_RETURN', wanted: truthy(input.withReturn) || input.containers.some((c) => Number(c.returnQuantity ?? 0) > 0), label: 'empty-container return', }, ]; for (const svc of requestedServices) { if (svc.wanted && !surchargeRates.some((r) => r.trigger === svc.trigger)) { hardBlocked.push( `No ${svc.label} surcharge rate is configured — the booking cannot ` + `be priced with this service. Remove the ${svc.label} option or ` + 'ask EDR to configure its rate.', ); } } for (const rate of surchargeRates) { const triggered = this.matchesTrigger(rate.trigger, { isHazardous: input.isHazardous, hasReefer, withReturn: input.withReturn ?? false, hasOverweight, shippingLineMapped, allowConsolidation: input.allowConsolidation ?? false, hasLashing, }); if (!triggered) continue; // Surcharges scale by their own rateUnit, so the same trigger can bill the // right way per freight shape — e.g. a PER_TON reefer rate multiplies the // bulk tonnage, while a PER_CONTAINER reefer rate multiplies the container // count. triggerValue records the quantity billed (shown on the breakdown). const rateValue = Number(rate.rateValue); const containerCount = input.containers.reduce( (sum, c) => sum + Number(c.quantity || 0), 0, ); const overweightExcessTons = containerWeightResults.reduce( (sum, r) => sum + (r.overweightExcessTons ?? 0), 0, ); /** * Containers that opted into this trigger's handling service, summed * across lines. null when the trigger isn't per-container handling (or * no line carries a count) so the caller falls back to the full count. */ const optedInCount = (trigger: string | null): number | null => { const field = trigger === 'HAZARDOUS' ? 'hazardousQuantity' : trigger === 'REEFER' ? 'reeferQuantity' : trigger === 'WITH_RETURN' ? 'returnQuantity' : null; if (!field) return null; const total = input.containers.reduce( (sum, c) => sum + Number(c[field] ?? 0), 0, ); return total > 0 ? total : null; }; let triggerValue: number | null = null; let calculatedAmount: number; switch (rate.rateUnit) { case 'PER_TON': // OVERWEIGHT bills the excess tons; every other PER_TON surcharge // (e.g. bulk reefer) bills the full bulk tonnage. triggerValue = rate.trigger === 'OVERWEIGHT' ? overweightExcessTons : Number(input.bulkTons ?? 0); calculatedAmount = triggerValue * rateValue; break; case 'PER_CONTAINER': // Handling surcharges bill only the containers that opted in, not the // whole line — 20 containers with 10 hazardous bill hazard on 10. // Legacy bookings carry no per-container counts (all 0) while their // booking-level flag is set, so fall back to the full count there. triggerValue = optedInCount(rate.trigger) ?? containerCount; calculatedAmount = triggerValue * rateValue; break; case 'PER_WAGON': triggerValue = input.totalWagons; calculatedAmount = triggerValue * rateValue; break; case 'FLAT': default: // FLAT (and any unknown unit) bills once. calculatedAmount = rateValue; break; } // Safety guard: never include a surcharge with a non-positive amount (a // zero-rate or zero-trigger line would otherwise show as a confusing // "free" surcharge on the breakdown). if (!(calculatedAmount > 0)) continue; appliedModifiers.push({ rateId: rate.id, surchargeCode: this.surchargeCode(rate), triggerValue, calculatedAmount, currency: rate.currency, }); } return { priorityScore, appliedModifiers, containerWeightResults, warnings, hardBlocked, requiresDirectorApproval, }; } /** * Messages for container lines whose total weight exceeds the hard capacity * ceiling (weight_limit_rules.max_capacity_tons). Non-empty ⇒ the booking * must not be created at all. Overweight (above maxVgmTons but within * capacity) is NOT reported here — that is a surcharge, not a block. */ async capacityViolations( containers: Array<{ containerTypeId: string; quantity: number; totalVgmTons: number; }>, tradeDirection: string, ): Promise { const violations: string[] = []; for (const container of containers) { const rules = await this.weightLimitRulesRepo.findActiveByContainerTypeId( container.containerTypeId, tradeDirection, ); const rule = rules[0]; if (!rule || rule.maxCapacityTons == null) continue; const perUnit = Number(rule.maxCapacityTons); const maxTotal = perUnit * container.quantity; if (container.totalVgmTons > maxTotal) { const label = rule.containerType?.code ?? container.containerTypeId; violations.push( `${label} total weight ${container.totalVgmTons}t exceeds the maximum capacity of ${maxTotal}t (${perUnit}t per unit) — the booking cannot be created; reduce the cargo weight`, ); } } return violations; } /** * Snapshot only the rates used in a booking's final price. */ async snapshotRates( bookingId: string, rates: Array<{ id: string; rateType: string; rateValue: number; rateUnit: string; currency: string; }>, ): Promise { const snapshotRepo = this.dataSource.getRepository(BookingRateSnapshot); const now = new Date(); const seen = new Set(); const snapshots: BookingRateSnapshot[] = []; for (const rate of rates) { if (seen.has(rate.id)) continue; seen.add(rate.id); 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( trigger: RateTrigger, state: { isHazardous: boolean; hasReefer: boolean; withReturn: boolean; hasOverweight: boolean; shippingLineMapped: boolean; allowConsolidation: boolean; hasLashing: boolean; }, ): boolean { switch (trigger) { case 'HAZARDOUS': return truthy(state.isHazardous); case 'REEFER': return truthy(state.hasReefer); case 'WITH_RETURN': return truthy(state.withReturn); case 'OVERWEIGHT': return truthy(state.hasOverweight); case 'SHIPPING_LINE': return truthy(state.shippingLineMapped); case 'CONSOLIDATION': return truthy(state.allowConsolidation); case 'LASHING': return truthy(state.hasLashing); // CANCELLATION / DEMURRAGE / PIL_EXTRA_FEE are contextual charges applied // explicitly elsewhere (not auto-triggered by a booking's cargo flags). default: return false; } } /** Stable surcharge code for display + audit, derived from the rate. */ private surchargeCode(rate: Rate): string { return rate.rateType ?? rate.trigger; } /** * Collapse rates that describe the same charge to a single representative. * * Two rates are "the same" when they would produce an identical price line: * same trigger, rateType, unit, value, currency, and scoping (container / * cargo type). Duplicate rows (e.g. a seeder run more than once) therefore * stack into one line instead of repeating — keeping the breakdown clean and * the total correct. The first row of each signature is kept so an existing * rateId is preserved for snapshotting. */ private dedupeRatesBySignature(rates: Rate[]): Rate[] { const seen = new Set(); const result: Rate[] = []; for (const rate of rates) { const signature = [ rate.trigger, rate.rateType, rate.rateUnit, Number(rate.rateValue), rate.currency, rate.containerTypeId ?? '', rate.cargoTypeId ?? '', ].join('|'); if (seen.has(signature)) continue; seen.add(signature); result.push(rate); } return result; } }