Files
edr-platform/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts
2026-08-29 20:26:16 +00:00

1005 lines
38 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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';
/** Tons carry 3 decimals in the schema; keep derived tonnage on that grid. */
const round3 = (n: number): number => Math.round(n * 1000) / 1000;
/**
* The VGM of every physical container on a line. Uses the per-unit weights the
* booking recorded; when a line has none (or fewer than its quantity — legacy
* rows only kept a line total), the remainder is spread evenly, which is the
* uniform load those bookings were entered as.
*/
export const unitWeights = (container: {
quantity: number;
totalVgmTons: number;
unitVgmTons?: number[];
}): number[] => {
const known = (container.unitVgmTons ?? [])
.slice(0, container.quantity)
.map((v) => Number(v ?? 0));
const missing = Math.max(0, Number(container.quantity || 0) - known.length);
if (missing === 0) return known;
const rest = Math.max(
0,
Number(container.totalVgmTons || 0) - known.reduce((s, v) => s + v, 0),
);
return [...known, ...Array<number>(missing).fill(rest / missing)];
};
export interface BookingContainerEvalInput {
containerTypeId: string;
quantity: number;
vgmPerUnitTons: number;
totalVgmTons: number;
isReefer?: boolean;
isOverweight?: boolean;
overweightExcessTons?: number | null;
/**
* VGM of each physical container on this line, when the booking carries
* per-unit weights. Weight limits are a per-container ceiling: 3x20ft at
* 22/18/20t against a 20t limit is 2t overweight on the first box, not
* zero because the line total happens to fit. Missing/short (legacy lines
* that only carry a line total) falls back to an even spread across
* `quantity`, which is what those bookings actually recorded.
*/
unitVgmTons?: number[];
/**
* 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 shipping line that OWNS this booking (`bookings.shipping_line_company_id`),
* when it is a shipping-line booking rather than a customer one. Such a booking
* prices exclusively off that line's own rates — see {@link ratesForOwner}.
*
* Not to be confused with `shippingLineId` above, which is cargo metadata
* naming the carrier that physically moves the goods and only feeds the
* SHIPPING_LINE double-handling trigger.
*/
shippingLineCompanyId?: 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;
}
/** One physical container that broke the per-container VGM limit. */
export interface OverweightUnit {
/** 1-based position of the container within its line. */
unitIndex: number;
vgmTons: number;
excessTons: number;
}
export interface ContainerWeightResult {
containerTypeId: string;
weightLimitRuleId: string | null;
isOverweight: boolean;
/** Sum of the per-container excesses on this line. */
overweightExcessTons: number | null;
/** Which containers of the line are over, and by how much. */
overweightUnits?: OverweightUnit[];
}
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;
let overweightUnits: OverweightUnit[] | undefined;
if (rule) {
const perUnitLimit = Number(rule.maxVgmTons);
// Per-container, never pooled: an underloaded box does not absorb the
// excess of an overloaded one — each container is billed on its own
// tons above the limit.
overweightUnits = unitWeights(container)
.map((vgmTons, i) => ({
unitIndex: i + 1,
vgmTons,
excessTons: round3(Math.max(0, vgmTons - perUnitLimit)),
}))
.filter((u) => u.excessTons > 0);
if (overweightUnits.length > 0) {
isOverweight = true;
excess = round3(
overweightUnits.reduce((sum, u) => sum + u.excessTons, 0),
);
for (const u of overweightUnits) {
warnings.push(
`Container type ${container.containerTypeId} #${u.unitIndex} VGM ${u.vgmTons}t exceeds the ${perUnitLimit}t limit by ${u.excessTons}t`,
);
}
}
containerWeightResults.push({
containerTypeId: container.containerTypeId,
weightLimitRuleId: rule.id,
isOverweight,
overweightExcessTons: excess,
overweightUnits,
});
} 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 = this.ratesForOwner(
await this.ratesRepo.findLiveRates(),
input.shippingLineCompanyId,
);
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(
...(await 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. Excess tons are billed on a
* PER-WAGON basis: (the wagon's base import freight on the booking's route)
* ÷ (2 × the container's weight limit).
*
* The rate is normalised to a wagon before dividing, because a 20ft rate
* quoted PER_CONTAINER prices only HALF a wagon — two 20ft ride one wagon —
* while a 40ft container IS the whole wagon. So a PER_CONTAINER 20ft rate is
* doubled first; 40ft (and any rate already quoted PER_WAGON) is taken as is:
* - 20ft PER_CONTAINER 845 USD, 20 t limit → (845 × 2) / (2 × 20) = 42.25
* - 40ft PER_CONTAINER 1676 USD, 40 t limit → 1676 / (2 × 40) = 20.95
* Halving over 2 × the limit keeps the original meaning: filling one wagon's
* worth of excess costs one extra wagon of freight.
*
* 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 async derivedImportOverweight(
input: BookingEvaluationInput,
weightResults: ContainerWeightResult[],
lineMaxVgmTons: Array<number | null>,
liveRates: Rate[],
): Promise<AppliedCargoModifier[]> {
const modifiers: AppliedCargoModifier[] = [];
if (!input.originYardId || !input.destinationYardId) return modifiers;
// How many of each container type ride one wagon: a 40ft fills a wagon,
// two 20ft share one. Keyed by container type so a PER_CONTAINER rate can
// be scaled up to the wagon the overweight formula prices against.
const sizeByTypeId = await this.containersPerWagonByTypeId(weightResults);
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;
// Normalise the rate to ONE WAGON before dividing. A PER_CONTAINER 20ft
// rate covers half a wagon, so it is scaled by the 2 containers that ride
// one; 40ft scales by 1. A rate already quoted PER_WAGON is the wagon
// price already — never scale it again.
const perWagonRate =
base.rateUnit === 'PER_CONTAINER'
? Number(base.rateValue) * (sizeByTypeId.get(wr.containerTypeId) ?? 1)
: Number(base.rateValue);
const perTon = perWagonRate / (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;
}
/**
* Containers of each type that ride ONE wagon, derived from the type's
* size_ft against a 40ft wagon slot: 20ft → 2, 40ft → 1. Only the types the
* caller actually needs are looked up. Unknown or non-positive sizes fall
* back to 1, which leaves a PER_CONTAINER rate unscaled — the pre-existing
* behaviour, so a missing size can never inflate a bill.
*/
private async containersPerWagonByTypeId(
weightResults: ContainerWeightResult[],
): Promise<Map<string, number>> {
const perWagon = new Map<string, number>();
const ids = [...new Set(weightResults.map((w) => w.containerTypeId).filter(Boolean))];
if (ids.length === 0) return perWagon;
let rows: Array<{ id: string; size_ft: string | number | null }> = [];
try {
rows = await this.dataSource.query(
'SELECT id, size_ft FROM freight.container_types WHERE id = ANY($1)',
[ids],
);
} catch {
// Size lookup unavailable — fall back to an unscaled (×1) rate, the
// behaviour before per-wagon normalisation. Never fail pricing over it.
return perWagon;
}
const WAGON_SLOT_FT = 40;
for (const row of rows) {
const sizeFt = Number(row.size_ft ?? 0);
perWagon.set(
row.id,
sizeFt > 0 ? Math.max(1, Math.floor(WAGON_SLOT_FT / sizeFt)) : 1,
);
}
return perWagon;
}
/**
* 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;
unitVgmTons?: 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 label = rule.containerType?.code ?? container.containerTypeId;
// Capacity is a physical ceiling on one box, so it is checked per box for
// the same reason the VGM limit is — a light container cannot carry the
// overload of a heavy one.
unitWeights(container).forEach((vgmTons, i) => {
if (vgmTons > perUnit) {
violations.push(
`${label} #${i + 1} weight ${round3(vgmTons)}t exceeds the maximum capacity of ${perUnit}t per container — 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;
}
/**
* Narrow the LIVE rate pool to the ones this booking's owner may price off.
*
* A customer booking sees only standard rates (no owner) — a shipping line's
* negotiated price must never leak into a customer quote. A shipping-line
* booking sees only that line's own rates: line rates OVERRIDE the standard
* ones rather than stacking on them, and the standard rate is deliberately
* NOT a fallback, so a lane the line has no rate for hard-blocks downstream
* (base freight already blocks on "no rate for this route") instead of
* quietly billing the line at the customer price.
*
* Filtering once, here, is what makes the override apply uniformly: every
* downstream lookup (base freight, derived overweight, empty return, lashing,
* fuel, and the additive surcharges) reads from this same pool, so none of
* them needs its own owner check.
*/
private ratesForOwner(rates: Rate[], shippingLineCompanyId?: string | null): Rate[] {
return shippingLineCompanyId
? rates.filter((r) => r.shippingLineCompanyId === shippingLineCompanyId)
: rates.filter((r) => !r.shippingLineCompanyId);
}
/**
* 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;
}
}