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 { isBulkQuantityUnit } from './entities/rate-unit.util'; 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; /** * Wagon fraction one container of this line occupies (40ft = 1, 20ft = 0.5). * Lets a PER_WAGON empty-return rate bill the wagons the returned empties * ride back on. Missing ⇒ one wagon per container. */ wagonsPerUnit?: 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; /** * The booking's rail leg. Import overweight derives its per-ton price from * this route's own container freight rate, so the engine needs the yards. */ originYardId?: string | null; destinationYardId?: 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; /** * Wagons a BULK booking occupies (ceil(tons ÷ wagon capacity)), resolved by * the pricing service. Scales PER_WAGON kind-scoped surcharges (lashing); * 0/undefined when unknown — those charges then bill nothing. */ bulkWagons?: 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; /** * Effective per-unit USD price when it differs from the rate row's own value * — set by derived charges (import overweight: base freight ÷ 2×limit) so * the breakdown shows the real per-ton figure, not the base container price. * Any modifier carrying it also bypasses frozen contract snapshots. */ unitPriceUsd?: number | null; /** Display unit for a unitPriceUsd modifier (e.g. PER_TON for overweight). */ billingUnit?: 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; // Fuel is likewise a cargo-type property (hasFuel), billed off the // lane-scoped FUEL rate — see fuelCharges. let hasFuel = false; 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; } if (cargoType.hasFuel) { hasFuel = true; } } } hardBlocked.push( ...(await this.capacityViolations(input.containers, input.tradeDirection)), ); // Per-container-line weight limit (maxVgmTons), index-aligned with // containerWeightResults — the derived import overweight divides by it. const lineMaxVgmTons: Array = []; for (const container of input.containers) { const rules = await this.weightLimitRulesRepo.findActiveByContainerTypeId( container.containerTypeId, input.tradeDirection, ); const rule = rules[0]; lineMaxVgmTons.push(rule ? Number(rule.maxVgmTons) : null); 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', }, ]; 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) { // Import overweight never bills the configured rate — its per-ton price // derives from the route's base container freight (see below). if (rate.trigger === 'OVERWEIGHT' && input.tradeDirection === 'IMPORT') { continue; } // Empty-container return is sold per route + container type — billed by // the route-matched block below, never by this route-agnostic loop. if (rate.trigger === 'WITH_RETURN') continue; // Lashing is sold per cargo kind + container type — billed by the // kind-aware block below, never by this generic loop. if (rate.trigger === 'LASHING') continue; // Fuel is sold per lane + cargo type — billed by the route-matched // block below, never by this route-agnostic loop. if (rate.trigger === 'FUEL') continue; 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) { // PER_ITEM is PER_TON for a counted (break-bulk) commodity — the bulk // quantity is recorded in the commodity's own unit either way. case 'PER_ITEM': 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, }); } if (input.tradeDirection === 'IMPORT') { appliedModifiers.push( ...this.derivedImportOverweight( input, containerWeightResults, lineMaxVgmTons, liveRates, ), ); } const withReturn = this.withReturnCharges(input, liveRates); appliedModifiers.push(...withReturn.modifiers); hardBlocked.push(...withReturn.blocked); if (hasLashing) { appliedModifiers.push(...this.lashingCharges(input, liveRates)); } if (hasFuel) { appliedModifiers.push(...this.fuelCharges(input, liveRates)); } return { priorityScore, appliedModifiers, containerWeightResults, warnings, hardBlocked, requiresDirectorApproval, }; } /** * Import overweight — derived, never configured. Each overweight container * line bills its excess tons at (its own base import freight on the booking's * route) ÷ (2 × its weight limit): 20ft at 1000 USD with a 20 t limit → * 25 USD per excess ton. Export keeps the configured OVERWEIGHT rate. * Note: derives from the LIVE route rate even for frozen-rate contract * bookings — the frozen snapshot has no route-scoped container price to * divide. */ private derivedImportOverweight( input: BookingEvaluationInput, weightResults: ContainerWeightResult[], lineMaxVgmTons: Array, liveRates: Rate[], ): AppliedCargoModifier[] { const modifiers: AppliedCargoModifier[] = []; if (!input.originYardId || !input.destinationYardId) return modifiers; for (let i = 0; i < weightResults.length; i++) { const wr = weightResults[i]; const excess = Number(wr?.overweightExcessTons ?? 0); const maxVgm = Number(lineMaxVgmTons[i] ?? 0); if (!wr?.isOverweight || !(excess > 0) || !(maxVgm > 0)) continue; // Same precedence as base freight pricing: the rate scoped to this // container type wins over the route's catch-all rate. const onLeg = liveRates.filter( (r) => r.rateType === 'CONTAINER_IMPORT' && r.currency === 'USD' && r.originYardId === input.originYardId && r.destinationYardId === input.destinationYardId, ); const base = onLeg.find((r) => r.containerTypeId === wr.containerTypeId) ?? onLeg.find((r) => !r.containerTypeId); // No base rate → the base-freight line hard-blocks this booking anyway. if (!base) continue; const perTon = Number(base.rateValue) / (2 * maxVgm); const amount = excess * perTon; if (!(amount > 0)) continue; modifiers.push({ rateId: base.id, surchargeCode: 'OVERWEIGHT_PER_TON', triggerValue: excess, calculatedAmount: amount, currency: base.currency, unitPriceUsd: perTon, billingUnit: 'PER_TON', }); } return modifiers; } /** * Empty-container return — sold per direction + route + container type, like * base freight. Each container line that opted in (returnQuantity, or every * container when only the legacy booking-level flag is set) bills the * route-matched WITH_RETURN rate for its own container type; a line with no * matching rate hard-blocks the booking instead of shipping the service for * free. Rates are import-only for now, so an export booking that asks for * return blocks too. * ponytail: bills the LIVE route rate, not a frozen contract snapshot — one * RETURN_SURCHARGE snapshot code can't hold per-size route prices. */ private withReturnCharges( input: BookingEvaluationInput, liveRates: Rate[], ): { modifiers: AppliedCargoModifier[]; blocked: string[] } { const modifiers: AppliedCargoModifier[] = []; const blocked: string[] = []; const bookingLevel = truthy(input.withReturn); const wanted = bookingLevel || input.containers.some((c) => Number(c.returnQuantity ?? 0) > 0); if (!wanted) return { modifiers, blocked }; const onLeg = liveRates.filter( (r) => r.trigger === 'WITH_RETURN' && r.currency === 'USD' && r.tradeDirection === input.tradeDirection && r.originYardId === input.originYardId && r.destinationYardId === input.destinationYardId, ); for (const container of input.containers) { const qty = Number(container.returnQuantity ?? 0) > 0 ? Number(container.returnQuantity) : bookingLevel ? Number(container.quantity || 0) : 0; if (!(qty > 0)) continue; const rate = onLeg.find((r) => r.containerTypeId === container.containerTypeId) ?? onLeg.find((r) => !r.containerTypeId); if (!rate) { blocked.push( 'No empty-container return rate is configured for this container ' + 'type on this route (return is import-only) — remove the return ' + 'option or ask EDR to configure its rate for this origin → destination.', ); continue; } const rateValue = Number(rate.rateValue); // PER_WAGON bills the wagons the returned empties occupy (two 20ft share // one wagon), PER_CONTAINER the boxes themselves, FLAT once per line. const billed = rate.rateUnit === 'PER_WAGON' ? Math.ceil(qty * (container.wagonsPerUnit ?? 1)) : qty; const amount = rate.rateUnit === 'FLAT' ? rateValue : billed * rateValue; if (!(amount > 0)) continue; modifiers.push({ rateId: rate.id, surchargeCode: this.surchargeCode(rate), triggerValue: rate.rateUnit === 'FLAT' ? qty : billed, calculatedAmount: amount, currency: rate.currency, unitPriceUsd: rateValue, billingUnit: rate.rateUnit, }); } // Same block deduplicated — several lines missing the rate is one problem. return { modifiers, blocked: [...new Set(blocked)] }; } /** * Cargo securing / lashing — BULK only, sold per trade direction, optionally * narrowed to one leaf commodity (the commodity-scoped rate wins over the * commodity-wide catch-all). Bills PER_TON × tonnage or PER_WAGON × the * wagons the bulk occupies. Container bookings never incur lashing, and an * unconfigured rate simply bills nothing — same leniency as hazard/reefer. */ private lashingCharges( input: BookingEvaluationInput, liveRates: Rate[], ): AppliedCargoModifier[] { const modifiers: AppliedCargoModifier[] = []; if (input.containers.length > 0) return modifiers; // bulk-only service const onDirection = liveRates.filter( (r) => r.trigger === 'LASHING' && r.currency === 'USD' && !r.containerTypeId && r.tradeDirection === input.tradeDirection, ); const rate = (input.cargoTypeId ? onDirection.find((r) => r.cargoTypeId === input.cargoTypeId) : undefined) ?? onDirection.find((r) => !r.cargoTypeId); if (!rate) return modifiers; const billedQty = isBulkQuantityUnit(rate.rateUnit) ? Math.max(0, Number(input.bulkTons ?? 0)) : rate.rateUnit === 'PER_WAGON' ? Math.max(0, Number(input.bulkWagons ?? 0)) : 1; const rateValue = Number(rate.rateValue); const amount = rate.rateUnit === 'FLAT' ? rateValue : billedQty * rateValue; if (!(amount > 0)) return modifiers; modifiers.push({ rateId: rate.id, surchargeCode: this.surchargeCode(rate), triggerValue: rate.rateUnit === 'FLAT' ? 1 : billedQty, calculatedAmount: amount, currency: rate.currency, unitPriceUsd: rateValue, billingUnit: rate.rateUnit, }); return modifiers; } /** * Fuel surcharge — fires when the booking's cargo type has hasFuel = true, * billed off the FUEL rate matching the booking's lane (trade direction + * origin + destination) and cargo type. PER_LITER collapses to one FLAT * amount (baseLiters × rateValue, once per booking) — the customer only ever * sees the total, and the frozen contract snapshot stores that same flat * figure so the snapshot-override math bills it exactly once. PER_WAGON * bills the wagons the cargo occupies. No matching lane rate simply bills * nothing — same leniency as lashing. */ private fuelCharges( input: BookingEvaluationInput, liveRates: Rate[], ): AppliedCargoModifier[] { const modifiers: AppliedCargoModifier[] = []; const rate = liveRates.find( (r) => r.trigger === 'FUEL' && r.currency === 'USD' && r.tradeDirection === input.tradeDirection && r.originYardId === input.originYardId && r.destinationYardId === input.destinationYardId && r.cargoTypeId === input.cargoTypeId, ); if (!rate) return modifiers; const rateValue = Number(rate.rateValue); const wagons = Math.max( 0, Number(input.bulkWagons ?? 0) || Number(input.totalWagons ?? 0), ); const perLiter = rate.rateUnit === 'PER_LITER'; const billedQty = perLiter ? 1 : wagons; const unitPrice = perLiter ? Number(rate.baseLiters ?? 0) * rateValue : rateValue; const amount = billedQty * unitPrice; if (!(amount > 0)) return modifiers; modifiers.push({ rateId: rate.id, surchargeCode: this.surchargeCode(rate), triggerValue: billedQty, calculatedAmount: amount, currency: rate.currency, unitPriceUsd: unitPrice, billingUnit: perLiter ? 'FLAT' : rate.rateUnit, }); return modifiers; } /** * 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; } }