Files
edr-platform/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts

520 lines
17 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 { Rate, RateTrigger } from './entities/rate.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 {
IPriorityConfigsRepository,
PRIORITY_CONFIGS_REPOSITORY,
} from './interfaces/priority-configs.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';
import { DEFAULT_APPROVAL_RULE_ROWS } from './approval-rules.defaults';
import { GOVERNMENT_PRIORITY_BONUS } from './government-priority.constants';
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;
/** Booking-level reefer flag; ORed with per-container reefer. */
isReefer?: boolean;
isGovernment?: boolean;
allowConsolidation?: boolean;
shippingLineId?: string | null;
totalWagons: number;
/**
* Total bulk tonnage on the booking (cargoTotalWeightVgm). Used to scale
* PER_TON surcharges (e.g. the bulk reefer surcharge). 0/undefined for
* container freight, which is scaled by container count instead.
*/
bulkTons?: number;
containers: BookingContainerEvalInput[];
}
export interface AppliedCargoModifier {
/** The trigger-based rate that produced this surcharge line. */
rateId: string;
/** Stable display/audit code, derived from the rate's trigger + rateType. */
surchargeCode: string;
triggerValue: number | null;
calculatedAmount: number;
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_CONFIGS_REPOSITORY)
private readonly priorityConfigsRepo: IPriorityConfigsRepository,
@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;
}
}
hardBlocked.push(
...(await this.capacityViolations(input.containers, input.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;
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);
const includesCustoms = serviceType?.includesCustoms ?? false;
// Additive priority blocks, each keyed on the booking's total wagon count:
// - WAGON rules apply regardless of currency.
// - CURRENCY rules apply only when the payment currency matches.
// - CUSTOMS rules apply only when the service type includes customs.
const priorityConfigs = await this.priorityConfigsRepo.findAllActive();
const wagonsInRange = (cfg: { minWagonCount: number; maxWagonCount: number }) =>
input.totalWagons >= cfg.minWagonCount &&
input.totalWagons <= cfg.maxWagonCount;
for (const cfg of priorityConfigs) {
const applies =
cfg.type === 'WAGON' ||
(cfg.type === 'CURRENCY' && cfg.currency === input.paymentCurrency) ||
(cfg.type === 'CUSTOMS' && includesCustoms);
if (applies && wagonsInRange(cfg)) {
priorityScore += cfg.scorePoints;
}
}
if (input.isGovernment) {
priorityScore += GOVERNMENT_PRIORITY_BONUS;
}
let shippingLineMapped = false;
if (input.shippingLineId) {
const line = await this.shippingLinesRepo.findById(input.shippingLineId);
shippingLineMapped = Boolean(line?.mappedToCode);
}
const hasReefer =
input.isReefer === true || input.containers.some((c) => c.isReefer);
const hasOverweight = containerWeightResults.some((r) => r.isOverweight);
// Surcharges are now self-describing rates: any LIVE rate whose `trigger`
// is not ALWAYS. Each fires independently and stacks on top of base freight
// — hazard + reefer + overweight all add together, each with its own unit.
//
// A given surcharge identity (same trigger + rateType + unit + value +
// scope) must contribute exactly ONE line. Duplicate LIVE rate rows — e.g.
// from a non-idempotent seeder — would otherwise repeat the same surcharge
// many times and inflate the total, so we collapse them to one row each.
const liveRates = await this.ratesRepo.findLiveRates();
const surchargeRates = this.dedupeRatesBySignature(
liveRates.filter((r) => r.trigger && r.trigger !== 'ALWAYS'),
);
for (const rate of surchargeRates) {
const triggered = this.matchesTrigger(rate.trigger, {
isHazardous: input.isHazardous,
hasReefer,
hasOverweight,
shippingLineMapped,
allowConsolidation: input.allowConsolidation ?? false,
});
if (!triggered) continue;
// Surcharges scale by their own rateUnit, so the same trigger can bill the
// right way per freight shape — e.g. a PER_TON reefer rate multiplies the
// bulk tonnage, while a PER_CONTAINER reefer rate multiplies the container
// count. triggerValue records the quantity billed (shown on the breakdown).
const rateValue = Number(rate.rateValue);
const containerCount = input.containers.reduce(
(sum, c) => sum + Number(c.quantity || 0),
0,
);
const overweightExcessTons = containerWeightResults.reduce(
(sum, r) => sum + (r.overweightExcessTons ?? 0),
0,
);
let triggerValue: number | null = null;
let calculatedAmount: number;
switch (rate.rateUnit) {
case 'PER_TON':
// OVERWEIGHT bills the excess tons; every other PER_TON surcharge
// (e.g. bulk reefer) bills the full bulk tonnage.
triggerValue =
rate.trigger === 'OVERWEIGHT'
? overweightExcessTons
: Number(input.bulkTons ?? 0);
calculatedAmount = triggerValue * rateValue;
break;
case 'PER_CONTAINER':
triggerValue = containerCount;
calculatedAmount = triggerValue * rateValue;
break;
case 'PER_WAGON':
triggerValue = input.totalWagons;
calculatedAmount = triggerValue * rateValue;
break;
case 'FLAT':
default:
// FLAT (and any unknown unit) bills once.
calculatedAmount = rateValue;
break;
}
// Safety guard: never include a surcharge with a non-positive amount (a
// zero-rate or zero-trigger line would otherwise show as a confusing
// "free" surcharge on the breakdown).
if (!(calculatedAmount > 0)) continue;
appliedModifiers.push({
rateId: rate.id,
surchargeCode: this.surchargeCode(rate),
triggerValue,
calculatedAmount,
currency: rate.currency,
});
}
return {
priorityScore,
appliedModifiers,
containerWeightResults,
warnings,
hardBlocked,
requiresDirectorApproval,
};
}
/**
* Messages for container lines whose total weight exceeds the hard capacity
* ceiling (weight_limit_rules.max_capacity_tons). Non-empty ⇒ the booking
* must not be created at all. Overweight (above maxVgmTons but within
* capacity) is NOT reported here — that is a surcharge, not a block.
*/
async capacityViolations(
containers: Array<{
containerTypeId: string;
quantity: number;
totalVgmTons: number;
}>,
tradeDirection: string,
): Promise<string[]> {
const violations: string[] = [];
for (const container of containers) {
const rules = await this.weightLimitRulesRepo.findActiveByContainerTypeId(
container.containerTypeId,
tradeDirection,
);
const rule = rules[0];
if (!rule || rule.maxCapacityTons == null) continue;
const perUnit = Number(rule.maxCapacityTons);
const maxTotal = perUnit * container.quantity;
if (container.totalVgmTons > maxTotal) {
const label = rule.containerType?.code ?? container.containerTypeId;
violations.push(
`${label} total weight ${container.totalVgmTons}t exceeds the maximum capacity of ${maxTotal}t (${perUnit}t per unit) — the booking cannot be created; reduce the cargo weight`,
);
}
}
return violations;
}
/**
* Ensure ITMLS default approval chains exist (container + bulk). Idempotent.
*/
async ensureDefaultApprovalRules(): Promise<void> {
for (const flag of [false, true] as const) {
const existing = await this.approvalRulesRepo.findChainForCargo(flag);
if (existing.length > 0) continue;
const rows = DEFAULT_APPROVAL_RULE_ROWS.filter(
(r) => r.requiresDirectorApproval === flag,
);
for (const row of rows) {
await this.approvalRulesRepo.create({
requiresDirectorApproval: row.requiresDirectorApproval,
stepOrder: row.stepOrder,
requiredRole: row.requiredRole,
actionLabel: row.actionLabel,
blocksRole: row.blocksRole,
});
}
}
}
/**
* Instantiate booking_approval_step rows from approval_rules by freight type.
*/
async instantiateApprovalSteps(
bookingId: string,
options: {
freightType: 'CONTAINER' | 'BULK';
cargoTypeId?: string | null;
},
): Promise<BookingApprovalStep[]> {
await this.ensureDefaultApprovalRules();
let requiresDirectorApproval = false;
if (options.cargoTypeId) {
const cargoType = await this.cargoTypesRepo.findById(options.cargoTypeId);
if (!cargoType) {
throw new BadRequestException(`Cargo type ${options.cargoTypeId} not found`);
}
requiresDirectorApproval = cargoType.requiresDirectorApproval;
}
const chain = await this.approvalRulesRepo.findChainForCargo(
requiresDirectorApproval,
);
if (chain.length === 0) {
throw new BadRequestException(
`Approval chain could not be loaded for requiresDirectorApproval=${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,
blocksRole: rule.blocksRole ?? null,
status: 'PENDING',
});
steps.push(await stepRepo.save(step));
}
return steps;
}
/**
* Snapshot only the rates used in a booking's final price.
*/
async snapshotRates(
bookingId: string,
rates: Array<{
id: string;
rateType: string;
rateValue: number;
rateUnit: string;
currency: string;
}>,
): Promise<BookingRateSnapshot[]> {
const snapshotRepo = this.dataSource.getRepository(BookingRateSnapshot);
const now = new Date();
const seen = new Set<string>();
const snapshots: BookingRateSnapshot[] = [];
for (const rate of rates) {
if (seen.has(rate.id)) continue;
seen.add(rate.id);
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(
trigger: RateTrigger,
state: {
isHazardous: boolean;
hasReefer: boolean;
hasOverweight: boolean;
shippingLineMapped: boolean;
allowConsolidation: boolean;
},
): boolean {
// Coerce defensively: a flag may arrive as the string "true"/"false" (e.g.
// from multipart form-data) and a non-empty "false" string is truthy.
const truthy = (v: unknown): boolean => v === true || v === 'true';
switch (trigger) {
case 'HAZARDOUS':
return truthy(state.isHazardous);
case 'REEFER':
return truthy(state.hasReefer);
case 'OVERWEIGHT':
return truthy(state.hasOverweight);
case 'SHIPPING_LINE':
return truthy(state.shippingLineMapped);
case 'CONSOLIDATION':
return truthy(state.allowConsolidation);
// CANCELLATION / DEMURRAGE / PIL_EXTRA_FEE are contextual charges applied
// explicitly elsewhere (not auto-triggered by a booking's cargo flags).
default:
return false;
}
}
/** Stable surcharge code for display + audit, derived from the rate. */
private surchargeCode(rate: Rate): string {
return rate.rateType ?? rate.trigger;
}
/**
* Collapse rates that describe the same charge to a single representative.
*
* Two rates are "the same" when they would produce an identical price line:
* same trigger, rateType, unit, value, currency, and scoping (container /
* cargo type). Duplicate rows (e.g. a seeder run more than once) therefore
* stack into one line instead of repeating — keeping the breakdown clean and
* the total correct. The first row of each signature is kept so an existing
* rateId is preserved for snapshotting.
*/
private dedupeRatesBySignature(rates: Rate[]): Rate[] {
const seen = new Set<string>();
const result: Rate[] = [];
for (const rate of rates) {
const signature = [
rate.trigger,
rate.rateType,
rate.rateUnit,
Number(rate.rateValue),
rate.currency,
rate.containerTypeId ?? '',
rate.cargoTypeId ?? '',
].join('|');
if (seen.has(signature)) continue;
seen.add(signature);
result.push(rate);
}
return result;
}
}