mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 05:18:11 +00:00
implement rule engine module with dynamic booking evaluation, 7 entities, and Postman endpoints
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user