mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 07:22:53 +00:00
342 lines
10 KiB
TypeScript
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;
|
|
}
|
|
}
|
|
}
|