mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
197 lines
7.2 KiB
TypeScript
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,
|
|
};
|
|
}
|
|
}
|