Files
edr-platform/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts

197 lines
7.2 KiB
TypeScript

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<RuleEvaluationResult> {
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,
};
}
}