Files
edr-platform/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts
2026-06-04 15:16:27 +03:00

342 lines
10 KiB
TypeScript

import { Inject, Injectable, BadRequestException } from '@nestjs/common';
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,
} from './interfaces/cargo-types.repository.interface';
import {
IServiceTypesRepository,
SERVICE_TYPES_REPOSITORY,
} from './interfaces/service-types.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';
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 BookingContainerEvalInput {
containerTypeId: string;
quantity: number;
vgmPerUnitTons: number;
totalVgmTons: number;
isReefer?: boolean;
isOverweight?: boolean;
overweightExcessTons?: number | null;
}
export interface BookingEvaluationInput {
cargoTypeId?: string | null;
freightType?: 'CONTAINER' | 'BULK';
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;
}
export interface ContainerWeightResult {
containerTypeId: string;
weightLimitRuleId: string | null;
isOverweight: boolean;
overweightExcessTons: number | null;
}
export interface RuleEvaluationResult {
priorityScore: number;
appliedModifiers: AppliedCargoModifier[];
containerWeightResults: ContainerWeightResult[];
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(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.
*/
async evaluate(input: BookingEvaluationInput): Promise<RuleEvaluationResult> {
const warnings: string[] = [];
const hardBlocked: string[] = [];
const appliedModifiers: AppliedCargoModifier[] = [];
const containerWeightResults: ContainerWeightResult[] = [];
let priorityScore = 0;
let requiresDirectorApproval = false;
if (input.freightType === 'BULK') {
requiresDirectorApproval = true;
}
if (input.cargoTypeId) {
const cargoType = await this.cargoTypesRepo.findById(input.cargoTypeId);
if (!cargoType) {
hardBlocked.push(`Cargo type ${input.cargoTypeId} not found`);
} else if (cargoType.requiresDirectorApproval) {
requiresDirectorApproval = true;
}
}
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;
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.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,
});
}
}
const serviceType = await this.serviceTypesRepo.findById(input.serviceTypeId);
if (serviceType) {
priorityScore += serviceType.priorityBonusPoints;
}
const priorityRules = await this.priorityRulesRepo.findAllActive();
for (const rule of priorityRules) {
if (
rule.conditionCurrency === null ||
rule.conditionCurrency === input.paymentCurrency
) {
priorityScore += rule.score;
}
}
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,
};
}
/**
* Instantiate booking_approval_step rows from approval_rules by freight type.
*/
async instantiateApprovalSteps(
bookingId: string,
options: {
freightType: 'CONTAINER' | 'BULK';
cargoTypeId?: string | null;
},
): Promise<BookingApprovalStep[]> {
let requiresDirectorApproval = options.freightType === 'BULK';
if (options.cargoTypeId) {
const cargoType = await this.cargoTypesRepo.findById(options.cargoTypeId);
if (!cargoType) {
throw new BadRequestException(`Cargo type ${options.cargoTypeId} not found`);
}
if (cargoType.requiresDirectorApproval) {
requiresDirectorApproval = true;
}
}
const chain = await this.approvalRulesRepo.findChainForCargo(
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 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;
}
}
}