feat(rule-engine): add fuel surcharge rate trigger and cargo flag

This commit is contained in:
Marshal
2026-08-12 11:22:51 +00:00
parent 4efd65c105
commit a02d24806b
15 changed files with 455 additions and 25 deletions

View File

@@ -175,6 +175,9 @@ export class RuleEngineService {
// 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) {
@@ -186,6 +189,9 @@ export class RuleEngineService {
if (cargoType.hasLashing) {
hasLashing = true;
}
if (cargoType.hasFuel) {
hasFuel = true;
}
}
}
@@ -328,6 +334,9 @@ export class RuleEngineService {
// 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,
@@ -442,6 +451,10 @@ export class RuleEngineService {
appliedModifiers.push(...this.lashingCharges(input, liveRates));
}
if (hasFuel) {
appliedModifiers.push(...this.fuelCharges(input, liveRates));
}
return {
priorityScore,
appliedModifiers,
@@ -632,6 +645,50 @@ export class RuleEngineService {
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 bills baseLiters ×
* rateValue once per booking; 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 billedQty =
rate.rateUnit === 'PER_LITER' ? Number(rate.baseLiters ?? 0) : wagons;
const amount = billedQty * rateValue;
if (!(amount > 0)) return modifiers;
modifiers.push({
rateId: rate.id,
surchargeCode: this.surchargeCode(rate),
triggerValue: billedQty,
calculatedAmount: amount,
currency: rate.currency,
unitPriceUsd: rateValue,
billingUnit: 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