mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 22:30:55 +00:00
845 lines
30 KiB
TypeScript
845 lines
30 KiB
TypeScript
import { Inject, Injectable, BadRequestException } from '@nestjs/common';
|
||
import { DataSource } from 'typeorm';
|
||
import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.entity';
|
||
import { Rate, RateTrigger } from './entities/rate.entity';
|
||
import { isBulkQuantityUnit } from './entities/rate-unit.util';
|
||
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 {
|
||
IShippingLinesRepository,
|
||
SHIPPING_LINES_REPOSITORY,
|
||
} from './interfaces/shipping-lines.repository.interface';
|
||
import { GOVERNMENT_PRIORITY_BONUS } from './government-priority.constants';
|
||
|
||
// 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';
|
||
|
||
export interface BookingContainerEvalInput {
|
||
containerTypeId: string;
|
||
quantity: number;
|
||
vgmPerUnitTons: number;
|
||
totalVgmTons: number;
|
||
isReefer?: boolean;
|
||
isOverweight?: boolean;
|
||
overweightExcessTons?: number | null;
|
||
/**
|
||
* How many individual containers on this line opted into each handling
|
||
* service. PER_CONTAINER surcharges bill these counts, not the line
|
||
* quantity — 20 containers with 10 hazardous bill hazard on 10.
|
||
*/
|
||
hazardousQuantity?: number;
|
||
reeferQuantity?: number;
|
||
returnQuantity?: number;
|
||
/**
|
||
* Wagon fraction one container of this line occupies (40ft = 1, 20ft = 0.5).
|
||
* Lets a PER_WAGON empty-return rate bill the wagons the returned empties
|
||
* ride back on. Missing ⇒ one wagon per container.
|
||
*/
|
||
wagonsPerUnit?: number;
|
||
}
|
||
|
||
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;
|
||
/**
|
||
* Booking ships with empty-container return (equipment_return = WITH_RETURN,
|
||
* container freight only). Fires the WITH_RETURN surcharge like hazard/reefer.
|
||
*/
|
||
withReturn?: boolean;
|
||
isGovernment?: boolean;
|
||
allowConsolidation?: boolean;
|
||
shippingLineId?: string | null;
|
||
/**
|
||
* The booking's rail leg. Import overweight derives its per-ton price from
|
||
* this route's own container freight rate, so the engine needs the yards.
|
||
*/
|
||
originYardId?: string | null;
|
||
destinationYardId?: string | null;
|
||
/**
|
||
* Booking's cargo type needs EDR-provided lashing/securing (cargoType
|
||
* hasLashing = true). Fires the flat LASHING surcharge. Resolved by the
|
||
* engine from cargoTypeId when omitted.
|
||
*/
|
||
hasLashing?: boolean;
|
||
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;
|
||
/**
|
||
* Wagons a BULK booking occupies (ceil(tons ÷ wagon capacity)), resolved by
|
||
* the pricing service. Scales PER_WAGON kind-scoped surcharges (lashing);
|
||
* 0/undefined when unknown — those charges then bill nothing.
|
||
*/
|
||
bulkWagons?: 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;
|
||
/**
|
||
* Effective per-unit USD price when it differs from the rate row's own value
|
||
* — set by derived charges (import overweight: base freight ÷ 2×limit) so
|
||
* the breakdown shows the real per-ton figure, not the base container price.
|
||
* Any modifier carrying it also bypasses frozen contract snapshots.
|
||
*/
|
||
unitPriceUsd?: number | null;
|
||
/** Display unit for a unitPriceUsd modifier (e.g. PER_TON for overweight). */
|
||
billingUnit?: 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(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;
|
||
}
|
||
|
||
// Lashing is a cargo-type property: a booking incurs the flat LASHING
|
||
// surcharge when its cargo type has hasLashing = true. Resolve it here so
|
||
// matchesTrigger can fire the LASHING rate. Falls back to an explicit
|
||
// input flag when no cargo type is set (e.g. container bookings).
|
||
let hasLashing = input.hasLashing === true;
|
||
// Fuel is likewise a cargo-type property (hasFuel), billed off the
|
||
// lane-scoped FUEL rate — see fuelCharges.
|
||
let hasFuel = false;
|
||
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;
|
||
}
|
||
if (cargoType.hasLashing) {
|
||
hasLashing = true;
|
||
}
|
||
if (cargoType.hasFuel) {
|
||
hasFuel = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
hardBlocked.push(
|
||
...(await this.capacityViolations(input.containers, input.tradeDirection)),
|
||
);
|
||
|
||
// Per-container-line weight limit (maxVgmTons), index-aligned with
|
||
// containerWeightResults — the derived import overweight divides by it.
|
||
const lineMaxVgmTons: Array<number | null> = [];
|
||
for (const container of input.containers) {
|
||
const rules = await this.weightLimitRulesRepo.findActiveByContainerTypeId(
|
||
container.containerTypeId,
|
||
input.tradeDirection,
|
||
);
|
||
const rule = rules[0];
|
||
lineMaxVgmTons.push(rule ? Number(rule.maxVgmTons) : null);
|
||
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'),
|
||
);
|
||
|
||
// A handling service the booking asks for (booking-level flag OR any
|
||
// per-container opt-in count) with no LIVE surcharge rate configured is a
|
||
// hard block — pricing would otherwise ship the service for free. System-
|
||
// derived charges (consolidation, overweight, shipping line, lashing) stay
|
||
// exempt: the customer never opted into those, so they must not block.
|
||
const requestedServices: Array<{
|
||
trigger: RateTrigger;
|
||
wanted: boolean;
|
||
label: string;
|
||
}> = [
|
||
{
|
||
trigger: 'HAZARDOUS',
|
||
wanted:
|
||
truthy(input.isHazardous) ||
|
||
input.containers.some((c) => Number(c.hazardousQuantity ?? 0) > 0),
|
||
label: 'hazardous cargo',
|
||
},
|
||
{
|
||
trigger: 'REEFER',
|
||
wanted:
|
||
hasReefer ||
|
||
input.containers.some((c) => Number(c.reeferQuantity ?? 0) > 0),
|
||
label: 'refrigerated (reefer) cargo',
|
||
},
|
||
];
|
||
for (const svc of requestedServices) {
|
||
if (svc.wanted && !surchargeRates.some((r) => r.trigger === svc.trigger)) {
|
||
hardBlocked.push(
|
||
`No ${svc.label} surcharge rate is configured — the booking cannot ` +
|
||
`be priced with this service. Remove the ${svc.label} option or ` +
|
||
'ask EDR to configure its rate.',
|
||
);
|
||
}
|
||
}
|
||
|
||
for (const rate of surchargeRates) {
|
||
// Import overweight never bills the configured rate — its per-ton price
|
||
// derives from the route's base container freight (see below).
|
||
if (rate.trigger === 'OVERWEIGHT' && input.tradeDirection === 'IMPORT') {
|
||
continue;
|
||
}
|
||
// Empty-container return is sold per route + container type — billed by
|
||
// the route-matched block below, never by this route-agnostic loop.
|
||
if (rate.trigger === 'WITH_RETURN') continue;
|
||
// Lashing is sold per cargo kind + container type — billed by the
|
||
// kind-aware block below, never by this generic loop.
|
||
if (rate.trigger === 'LASHING') continue;
|
||
// Fuel is sold per lane + cargo type — billed by the route-matched
|
||
// block below, never by this route-agnostic loop.
|
||
if (rate.trigger === 'FUEL') continue;
|
||
const triggered = this.matchesTrigger(rate.trigger, {
|
||
isHazardous: input.isHazardous,
|
||
hasReefer,
|
||
withReturn: input.withReturn ?? false,
|
||
hasOverweight,
|
||
shippingLineMapped,
|
||
allowConsolidation: input.allowConsolidation ?? false,
|
||
hasLashing,
|
||
});
|
||
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,
|
||
);
|
||
/**
|
||
* Containers that opted into this trigger's handling service, summed
|
||
* across lines. null when the trigger isn't per-container handling (or
|
||
* no line carries a count) so the caller falls back to the full count.
|
||
*/
|
||
const optedInCount = (trigger: string | null): number | null => {
|
||
const field =
|
||
trigger === 'HAZARDOUS'
|
||
? 'hazardousQuantity'
|
||
: trigger === 'REEFER'
|
||
? 'reeferQuantity'
|
||
: trigger === 'WITH_RETURN'
|
||
? 'returnQuantity'
|
||
: null;
|
||
if (!field) return null;
|
||
const total = input.containers.reduce(
|
||
(sum, c) => sum + Number(c[field] ?? 0),
|
||
0,
|
||
);
|
||
return total > 0 ? total : null;
|
||
};
|
||
|
||
let triggerValue: number | null = null;
|
||
let calculatedAmount: number;
|
||
|
||
switch (rate.rateUnit) {
|
||
// PER_ITEM is PER_TON for a counted (break-bulk) commodity — the bulk
|
||
// quantity is recorded in the commodity's own unit either way.
|
||
case 'PER_ITEM':
|
||
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':
|
||
// Handling surcharges bill only the containers that opted in, not the
|
||
// whole line — 20 containers with 10 hazardous bill hazard on 10.
|
||
// Legacy bookings carry no per-container counts (all 0) while their
|
||
// booking-level flag is set, so fall back to the full count there.
|
||
triggerValue = optedInCount(rate.trigger) ?? 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,
|
||
});
|
||
}
|
||
|
||
if (input.tradeDirection === 'IMPORT') {
|
||
appliedModifiers.push(
|
||
...this.derivedImportOverweight(
|
||
input,
|
||
containerWeightResults,
|
||
lineMaxVgmTons,
|
||
liveRates,
|
||
),
|
||
);
|
||
}
|
||
|
||
const withReturn = this.withReturnCharges(input, liveRates);
|
||
appliedModifiers.push(...withReturn.modifiers);
|
||
hardBlocked.push(...withReturn.blocked);
|
||
|
||
if (hasLashing) {
|
||
appliedModifiers.push(...this.lashingCharges(input, liveRates));
|
||
}
|
||
|
||
if (hasFuel) {
|
||
appliedModifiers.push(...this.fuelCharges(input, liveRates));
|
||
}
|
||
|
||
return {
|
||
priorityScore,
|
||
appliedModifiers,
|
||
containerWeightResults,
|
||
warnings,
|
||
hardBlocked,
|
||
requiresDirectorApproval,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Import overweight — derived, never configured. Each overweight container
|
||
* line bills its excess tons at (its own base import freight on the booking's
|
||
* route) ÷ (2 × its weight limit): 20ft at 1000 USD with a 20 t limit →
|
||
* 25 USD per excess ton. Export keeps the configured OVERWEIGHT rate.
|
||
* Note: derives from the LIVE route rate even for frozen-rate contract
|
||
* bookings — the frozen snapshot has no route-scoped container price to
|
||
* divide.
|
||
*/
|
||
private derivedImportOverweight(
|
||
input: BookingEvaluationInput,
|
||
weightResults: ContainerWeightResult[],
|
||
lineMaxVgmTons: Array<number | null>,
|
||
liveRates: Rate[],
|
||
): AppliedCargoModifier[] {
|
||
const modifiers: AppliedCargoModifier[] = [];
|
||
if (!input.originYardId || !input.destinationYardId) return modifiers;
|
||
|
||
for (let i = 0; i < weightResults.length; i++) {
|
||
const wr = weightResults[i];
|
||
const excess = Number(wr?.overweightExcessTons ?? 0);
|
||
const maxVgm = Number(lineMaxVgmTons[i] ?? 0);
|
||
if (!wr?.isOverweight || !(excess > 0) || !(maxVgm > 0)) continue;
|
||
|
||
// Same precedence as base freight pricing: the rate scoped to this
|
||
// container type wins over the route's catch-all rate.
|
||
const onLeg = liveRates.filter(
|
||
(r) =>
|
||
r.rateType === 'CONTAINER_IMPORT' &&
|
||
r.currency === 'USD' &&
|
||
r.originYardId === input.originYardId &&
|
||
r.destinationYardId === input.destinationYardId,
|
||
);
|
||
const base =
|
||
onLeg.find((r) => r.containerTypeId === wr.containerTypeId) ??
|
||
onLeg.find((r) => !r.containerTypeId);
|
||
// No base rate → the base-freight line hard-blocks this booking anyway.
|
||
if (!base) continue;
|
||
|
||
const perTon = Number(base.rateValue) / (2 * maxVgm);
|
||
const amount = excess * perTon;
|
||
if (!(amount > 0)) continue;
|
||
|
||
modifiers.push({
|
||
rateId: base.id,
|
||
surchargeCode: 'OVERWEIGHT_PER_TON',
|
||
triggerValue: excess,
|
||
calculatedAmount: amount,
|
||
currency: base.currency,
|
||
unitPriceUsd: perTon,
|
||
billingUnit: 'PER_TON',
|
||
});
|
||
}
|
||
return modifiers;
|
||
}
|
||
|
||
/**
|
||
* Empty-container return — sold per direction + route + container type, like
|
||
* base freight. Each container line that opted in (returnQuantity, or every
|
||
* container when only the legacy booking-level flag is set) bills the
|
||
* route-matched WITH_RETURN rate for its own container type; a line with no
|
||
* matching rate hard-blocks the booking instead of shipping the service for
|
||
* free. Rates are import-only for now, so an export booking that asks for
|
||
* return blocks too.
|
||
* ponytail: bills the LIVE route rate, not a frozen contract snapshot — one
|
||
* RETURN_SURCHARGE snapshot code can't hold per-size route prices.
|
||
*/
|
||
private withReturnCharges(
|
||
input: BookingEvaluationInput,
|
||
liveRates: Rate[],
|
||
): { modifiers: AppliedCargoModifier[]; blocked: string[] } {
|
||
const modifiers: AppliedCargoModifier[] = [];
|
||
const blocked: string[] = [];
|
||
const bookingLevel = truthy(input.withReturn);
|
||
const wanted =
|
||
bookingLevel || input.containers.some((c) => Number(c.returnQuantity ?? 0) > 0);
|
||
if (!wanted) return { modifiers, blocked };
|
||
|
||
const onLeg = liveRates.filter(
|
||
(r) =>
|
||
r.trigger === 'WITH_RETURN' &&
|
||
r.currency === 'USD' &&
|
||
r.tradeDirection === input.tradeDirection &&
|
||
r.originYardId === input.originYardId &&
|
||
r.destinationYardId === input.destinationYardId,
|
||
);
|
||
|
||
for (const container of input.containers) {
|
||
const qty =
|
||
Number(container.returnQuantity ?? 0) > 0
|
||
? Number(container.returnQuantity)
|
||
: bookingLevel
|
||
? Number(container.quantity || 0)
|
||
: 0;
|
||
if (!(qty > 0)) continue;
|
||
|
||
const rate =
|
||
onLeg.find((r) => r.containerTypeId === container.containerTypeId) ??
|
||
onLeg.find((r) => !r.containerTypeId);
|
||
if (!rate) {
|
||
blocked.push(
|
||
'No empty-container return rate is configured for this container ' +
|
||
'type on this route (return is import-only) — remove the return ' +
|
||
'option or ask EDR to configure its rate for this origin → destination.',
|
||
);
|
||
continue;
|
||
}
|
||
|
||
const rateValue = Number(rate.rateValue);
|
||
// PER_WAGON bills the wagons the returned empties occupy (two 20ft share
|
||
// one wagon), PER_CONTAINER the boxes themselves, FLAT once per line.
|
||
const billed =
|
||
rate.rateUnit === 'PER_WAGON'
|
||
? Math.ceil(qty * (container.wagonsPerUnit ?? 1))
|
||
: qty;
|
||
const amount = rate.rateUnit === 'FLAT' ? rateValue : billed * rateValue;
|
||
if (!(amount > 0)) continue;
|
||
modifiers.push({
|
||
rateId: rate.id,
|
||
surchargeCode: this.surchargeCode(rate),
|
||
triggerValue: rate.rateUnit === 'FLAT' ? qty : billed,
|
||
calculatedAmount: amount,
|
||
currency: rate.currency,
|
||
unitPriceUsd: rateValue,
|
||
billingUnit: rate.rateUnit,
|
||
});
|
||
}
|
||
|
||
// Same block deduplicated — several lines missing the rate is one problem.
|
||
return { modifiers, blocked: [...new Set(blocked)] };
|
||
}
|
||
|
||
/**
|
||
* Cargo securing / lashing — BULK only, sold per trade direction, optionally
|
||
* narrowed to one leaf commodity (the commodity-scoped rate wins over the
|
||
* commodity-wide catch-all). Bills PER_TON × tonnage or PER_WAGON × the
|
||
* wagons the bulk occupies. Container bookings never incur lashing, and an
|
||
* unconfigured rate simply bills nothing — same leniency as hazard/reefer.
|
||
*/
|
||
private lashingCharges(
|
||
input: BookingEvaluationInput,
|
||
liveRates: Rate[],
|
||
): AppliedCargoModifier[] {
|
||
const modifiers: AppliedCargoModifier[] = [];
|
||
if (input.containers.length > 0) return modifiers; // bulk-only service
|
||
|
||
const onDirection = liveRates.filter(
|
||
(r) =>
|
||
r.trigger === 'LASHING' &&
|
||
r.currency === 'USD' &&
|
||
!r.containerTypeId &&
|
||
r.tradeDirection === input.tradeDirection,
|
||
);
|
||
const rate =
|
||
(input.cargoTypeId
|
||
? onDirection.find((r) => r.cargoTypeId === input.cargoTypeId)
|
||
: undefined) ?? onDirection.find((r) => !r.cargoTypeId);
|
||
if (!rate) return modifiers;
|
||
|
||
const billedQty =
|
||
isBulkQuantityUnit(rate.rateUnit)
|
||
? Math.max(0, Number(input.bulkTons ?? 0))
|
||
: rate.rateUnit === 'PER_WAGON'
|
||
? Math.max(0, Number(input.bulkWagons ?? 0))
|
||
: 1;
|
||
const rateValue = Number(rate.rateValue);
|
||
const amount = rate.rateUnit === 'FLAT' ? rateValue : billedQty * rateValue;
|
||
if (!(amount > 0)) return modifiers;
|
||
modifiers.push({
|
||
rateId: rate.id,
|
||
surchargeCode: this.surchargeCode(rate),
|
||
triggerValue: rate.rateUnit === 'FLAT' ? 1 : billedQty,
|
||
calculatedAmount: amount,
|
||
currency: rate.currency,
|
||
unitPriceUsd: rateValue,
|
||
billingUnit: rate.rateUnit,
|
||
});
|
||
return modifiers;
|
||
}
|
||
|
||
/**
|
||
* Fuel surcharge — fires when the booking's cargo type has hasFuel = true,
|
||
* billed off the FUEL rate matching the booking's lane (trade direction +
|
||
* origin + destination) and cargo type. PER_LITER collapses to one FLAT
|
||
* amount (baseLiters × rateValue, once per booking) — the customer only ever
|
||
* sees the total, and the frozen contract snapshot stores that same flat
|
||
* figure so the snapshot-override math bills it exactly once. PER_WAGON
|
||
* bills the wagons the cargo occupies. No matching lane rate simply bills
|
||
* nothing — same leniency as lashing.
|
||
*/
|
||
private fuelCharges(
|
||
input: BookingEvaluationInput,
|
||
liveRates: Rate[],
|
||
): AppliedCargoModifier[] {
|
||
const modifiers: AppliedCargoModifier[] = [];
|
||
const rate = liveRates.find(
|
||
(r) =>
|
||
r.trigger === 'FUEL' &&
|
||
r.currency === 'USD' &&
|
||
r.tradeDirection === input.tradeDirection &&
|
||
r.originYardId === input.originYardId &&
|
||
r.destinationYardId === input.destinationYardId &&
|
||
r.cargoTypeId === input.cargoTypeId,
|
||
);
|
||
if (!rate) return modifiers;
|
||
|
||
const rateValue = Number(rate.rateValue);
|
||
const wagons = Math.max(
|
||
0,
|
||
Number(input.bulkWagons ?? 0) || Number(input.totalWagons ?? 0),
|
||
);
|
||
const perLiter = rate.rateUnit === 'PER_LITER';
|
||
const billedQty = perLiter ? 1 : wagons;
|
||
const unitPrice = perLiter
|
||
? Number(rate.baseLiters ?? 0) * rateValue
|
||
: rateValue;
|
||
const amount = billedQty * unitPrice;
|
||
if (!(amount > 0)) return modifiers;
|
||
modifiers.push({
|
||
rateId: rate.id,
|
||
surchargeCode: this.surchargeCode(rate),
|
||
triggerValue: billedQty,
|
||
calculatedAmount: amount,
|
||
currency: rate.currency,
|
||
unitPriceUsd: unitPrice,
|
||
billingUnit: perLiter ? 'FLAT' : rate.rateUnit,
|
||
});
|
||
return modifiers;
|
||
}
|
||
|
||
/**
|
||
* 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;
|
||
}
|
||
|
||
/**
|
||
* 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;
|
||
withReturn: boolean;
|
||
hasOverweight: boolean;
|
||
shippingLineMapped: boolean;
|
||
allowConsolidation: boolean;
|
||
hasLashing: boolean;
|
||
},
|
||
): boolean {
|
||
switch (trigger) {
|
||
case 'HAZARDOUS':
|
||
return truthy(state.isHazardous);
|
||
case 'REEFER':
|
||
return truthy(state.hasReefer);
|
||
case 'WITH_RETURN':
|
||
return truthy(state.withReturn);
|
||
case 'OVERWEIGHT':
|
||
return truthy(state.hasOverweight);
|
||
case 'SHIPPING_LINE':
|
||
return truthy(state.shippingLineMapped);
|
||
case 'CONSOLIDATION':
|
||
return truthy(state.allowConsolidation);
|
||
case 'LASHING':
|
||
return truthy(state.hasLashing);
|
||
// 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;
|
||
}
|
||
}
|