complete rule engine and booking flow

This commit is contained in:
marshal
2026-05-30 10:27:59 +03:00
parent 800f036005
commit 430dc44937
74 changed files with 4304 additions and 1248 deletions

View File

@@ -1,6 +1,8 @@
import { Inject, Injectable, BadRequestException } from '@nestjs/common';
import { Freight } from '@edr/types';
import { Booking } from '../bookings/entities/booking.entity';
import { DataSource } from 'typeorm';
import { BookingApprovalStep } from '../bookings/entities/booking-approval-step.entity';
import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.entity';
import { TriggerCondition } from './entities/surcharge-type.entity';
import {
ICargoTypesRepository,
CARGO_TYPES_REPOSITORY,
@@ -9,10 +11,6 @@ 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,
@@ -21,20 +19,64 @@ import {
IPriorityRulesRepository,
PRIORITY_RULES_REPOSITORY,
} from './interfaces/priority-rules.repository.interface';
import {
ISurchargeTypesRepository,
SURCHARGE_TYPES_REPOSITORY,
} from './interfaces/surcharge-types.repository.interface';
import {
IRatesRepository,
RATES_REPOSITORY,
} from './interfaces/rates.repository.interface';
import {
IApprovalRulesRepository,
APPROVAL_RULES_REPOSITORY,
} from './interfaces/approval-rules.repository.interface';
import {
IShippingLinesRepository,
SHIPPING_LINES_REPOSITORY,
} from './interfaces/shipping-lines.repository.interface';
export interface AppliedSurcharge {
feeName: string;
rate: number;
export interface BookingContainerEvalInput {
containerTypeId: string;
quantity: number;
vgmPerUnitTons: number;
totalVgmTons: number;
isReefer?: boolean;
isOverweight?: boolean;
overweightExcessTons?: number | null;
}
export interface BookingEvaluationInput {
cargoTypeId: string;
serviceTypeId: string;
paymentCurrency: string;
tradeDirection: string;
isHazardous: boolean;
allowConsolidation?: boolean;
shippingLineId?: string | null;
containers: BookingContainerEvalInput[];
}
export interface AppliedCargoModifier {
surchargeTypeId: string;
surchargeTypeCode: string;
triggerValue: number | null;
calculatedAmount: number;
rateId: string;
currency: string;
calculationMethod: Freight.CalculationMethod;
applyToRail: boolean;
applyToFirstMile: boolean;
applyToLastMile: boolean;
}
export interface ContainerWeightResult {
containerTypeId: string;
weightLimitRuleId: string | null;
isOverweight: boolean;
overweightExcessTons: number | null;
}
export interface RuleEvaluationResult {
priorityScore: number;
appliedSurcharges: AppliedSurcharge[];
appliedModifiers: AppliedCargoModifier[];
containerWeightResults: ContainerWeightResult[];
warnings: string[];
hardBlocked: string[];
requiresDirectorApproval: boolean;
@@ -47,150 +89,233 @@ export class RuleEngineService {
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,
@Inject(SURCHARGE_TYPES_REPOSITORY)
private readonly surchargeTypesRepo: ISurchargeTypesRepository,
@Inject(RATES_REPOSITORY)
private readonly ratesRepo: IRatesRepository,
@Inject(APPROVAL_RULES_REPOSITORY)
private readonly approvalRulesRepo: IApprovalRulesRepository,
@Inject(SHIPPING_LINES_REPOSITORY)
private readonly shippingLinesRepo: IShippingLinesRepository,
private readonly dataSource: DataSource,
) {}
/**
* 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> {
async evaluate(input: BookingEvaluationInput): Promise<RuleEvaluationResult> {
const warnings: string[] = [];
const hardBlocked: string[] = [];
const appliedSurcharges: AppliedSurcharge[] = [];
const appliedModifiers: AppliedCargoModifier[] = [];
const containerWeightResults: ContainerWeightResult[] = [];
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;
}
const cargoType = await this.cargoTypesRepo.findById(input.cargoTypeId);
if (!cargoType) {
hardBlocked.push(`Cargo type ${input.cargoTypeId} not found`);
} else 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 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;
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) {
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 VGM ${container.vgm}t is approaching limit ` +
`of ${rule.maxWeightTons}t (${booking.tradeDirection})`,
`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,
});
}
}
// ── 3. Surcharge flags ───────────────────────────────────────────────
if (booking.isHazardous) {
const surcharge = await this.surchargesRepo.findByTypeCode('HAZARDOUS');
if (surcharge) appliedSurcharges.push(this.mapSurcharge(surcharge));
const serviceType = await this.serviceTypesRepo.findById(input.serviceTypeId);
if (serviceType) {
priorityScore += serviceType.priorityBonusPoints;
}
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;
if (
rule.conditionCurrency === null ||
rule.conditionCurrency === input.paymentCurrency
) {
priorityScore += rule.score;
}
}
return { priorityScore, appliedSurcharges, warnings, hardBlocked, requiresDirectorApproval };
let shippingLineMapped = false;
if (input.shippingLineId) {
const line = await this.shippingLinesRepo.findById(input.shippingLineId);
shippingLineMapped = Boolean(line?.mappedToCode);
}
const hasReefer = input.containers.some((c) => c.isReefer);
const hasOverweight = containerWeightResults.some((r) => r.isOverweight);
const surchargeTypes = await this.surchargeTypesRepo.findAllActiveWithRate();
const liveRates = await this.ratesRepo.findLiveRates();
const rateById = new Map(liveRates.map((r) => [r.id, r]));
for (const st of surchargeTypes) {
const triggered = this.matchesTrigger(st.triggerCondition, {
isHazardous: input.isHazardous,
hasReefer,
hasOverweight,
shippingLineMapped,
allowConsolidation: input.allowConsolidation ?? false,
});
if (!triggered) continue;
const rate = st.rate ?? rateById.get(st.rateId);
if (!rate) continue;
let triggerValue: number | null = null;
let calculatedAmount = Number(rate.rateValue);
if (st.triggerCondition === 'VGM_EXCEEDS_LIMIT') {
triggerValue = containerWeightResults.reduce(
(sum, r) => sum + (r.overweightExcessTons ?? 0),
0,
);
if (rate.rateUnit === 'PER_TON') {
calculatedAmount = triggerValue * Number(rate.rateValue);
}
}
appliedModifiers.push({
surchargeTypeId: st.id,
surchargeTypeCode: st.code,
triggerValue,
calculatedAmount,
rateId: rate.id,
currency: rate.currency,
});
}
return {
priorityScore,
appliedModifiers,
containerWeightResults,
warnings,
hardBlocked,
requiresDirectorApproval,
};
}
/**
* Guard helper — throws BadRequestException if hardBlocked is non-empty.
* Call this immediately after evaluate() in BookingsService.
* Instantiate booking_approval_step rows from approval_rules for a cargo type.
*/
async instantiateApprovalSteps(bookingId: string, cargoTypeId: string): Promise<BookingApprovalStep[]> {
const cargoType = await this.cargoTypesRepo.findById(cargoTypeId);
if (!cargoType) {
throw new BadRequestException(`Cargo type ${cargoTypeId} not found`);
}
const chain = await this.approvalRulesRepo.findChainForCargo(
cargoType.requiresDirectorApproval,
);
const stepRepo = this.dataSource.getRepository(BookingApprovalStep);
const steps: BookingApprovalStep[] = [];
for (const rule of chain) {
const step = stepRepo.create({
bookingId,
approvalRuleId: rule.id,
stepOrder: rule.stepOrder,
requiredRole: rule.requiredRole,
status: 'PENDING',
});
steps.push(await stepRepo.save(step));
}
return steps;
}
/**
* Snapshot all LIVE rates into booking_rate_snapshot for a booking.
*/
async snapshotLiveRates(bookingId: string): Promise<BookingRateSnapshot[]> {
const liveRates = await this.ratesRepo.findLiveRates();
const snapshotRepo = this.dataSource.getRepository(BookingRateSnapshot);
const now = new Date();
const snapshots: BookingRateSnapshot[] = [];
for (const rate of liveRates) {
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 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,
};
private matchesTrigger(
condition: TriggerCondition,
state: {
isHazardous: boolean;
hasReefer: boolean;
hasOverweight: boolean;
shippingLineMapped: boolean;
allowConsolidation: boolean;
},
): boolean {
switch (condition) {
case 'CARGO_FLAG_HAZARDOUS':
return state.isHazardous;
case 'CARGO_FLAG_REEFER':
return state.hasReefer;
case 'VGM_EXCEEDS_LIMIT':
return state.hasOverweight;
case 'SHIPPING_LINE_MAPPED':
return state.shippingLineMapped;
case 'CONSOLIDATION_ENABLED':
return state.allowConsolidation;
default:
return false;
}
}
}