mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-02 20:53:39 +00:00
complete rule engine and booking flow
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user