feat: fuel surcharge per lane and cargo type

This commit is contained in:
Marshal
2026-08-12 13:47:53 +00:00
parent dd39c2be7c
commit ff3cee12e3
4 changed files with 42 additions and 21 deletions

View File

@@ -193,17 +193,26 @@ export class ContractRateScheduleBuilder {
return rate.tradeDirection === want; return rate.tradeDirection === want;
} }
/** Fuel row — the lane matters, so it rides along in the charge label. */ /**
* Fuel row — the lane matters, so it rides along in the charge label.
* Per-liter collapses to one flat total (base liters × rate value); the
* customer only ever sees the final price.
*/
private fuelRow(rate: Rate): RateScheduleRow { private fuelRow(rate: Rate): RateScheduleRow {
const origin = rate.originYard?.label ?? rate.originYard?.code ?? '—'; const origin = rate.originYard?.label ?? rate.originYard?.code ?? '—';
const destination = const destination =
rate.destinationYard?.label ?? rate.destinationYard?.code ?? '—'; rate.destinationYard?.label ?? rate.destinationYard?.code ?? '—';
const perLiter = rate.rateUnit === 'PER_LITER';
return { return {
route: `Fuel surcharge (${origin}${destination})`, route: `Fuel surcharge (${origin}${destination})`,
cargo: this.cargoLabel(rate), cargo: this.cargoLabel(rate),
currency: rate.currency, currency: rate.currency,
amount: this.formatAmount(rate.rateValue), amount: this.formatAmount(
unit: this.unitLabel(rate.rateUnit), perLiter
? Number(rate.baseLiters ?? 0) * Number(rate.rateValue)
: rate.rateValue,
),
unit: perLiter ? 'flat' : this.unitLabel(rate.rateUnit),
}; };
} }

View File

@@ -302,15 +302,18 @@ export class ContractPricingService {
r.cargoTypeId === scope.cargoTypeId, r.cargoTypeId === scope.cargoTypeId,
); );
if (fuel && Number(fuel.rateValue) > 0) { if (fuel && Number(fuel.rateValue) > 0) {
const base = Number(fuel.baseLiters ?? 0); // Per-liter collapses to one flat total (base liters × rate value) —
// the customer only sees the final price, and booking pricing bills
// the same flat figure once (see RuleEngineService.fuelCharges).
const perLiter = fuel.rateUnit === 'PER_LITER';
const total = perLiter
? Number(fuel.baseLiters ?? 0) * Number(fuel.rateValue)
: Number(fuel.rateValue);
lineItems.push({ lineItems.push({
code: 'FUEL_SURCHARGE', code: 'FUEL_SURCHARGE',
label: label: `Fuel surcharge (${scope.cargoType.cargoTypeName})`,
fuel.rateUnit === 'PER_LITER' unit: perLiter ? 'flat' : toContractUnit(fuel.rateUnit),
? `Fuel surcharge (${scope.cargoType.cargoTypeName}, ${base} liters)` unitPrice: convert(total),
: `Fuel surcharge (${scope.cargoType.cargoTypeName})`,
unit: toContractUnit(fuel.rateUnit),
unitPrice: convert(Number(fuel.rateValue)),
cargoTypeCode: scope.cargoType.code ?? null, cargoTypeCode: scope.cargoType.code ?? null,
conditionalOn: 'has_fuel', conditionalOn: 'has_fuel',
}); });

View File

@@ -475,13 +475,16 @@ describe('RuleEngineService — fuel surcharge (per lane + cargo type)', () => {
const fuelMods = (result: Awaited<ReturnType<RuleEngineService['evaluate']>>) => const fuelMods = (result: Awaited<ReturnType<RuleEngineService['evaluate']>>) =>
result.appliedModifiers.filter((m) => m.surchargeCode === 'FUEL_SURCHARGE'); result.appliedModifiers.filter((m) => m.surchargeCode === 'FUEL_SURCHARGE');
it('PER_LITER bills base liters × rate value once, regardless of wagons', async () => { it('PER_LITER collapses to one flat total (base liters × rate value), regardless of wagons', async () => {
const result = await buildService([fuelPerLiter]).evaluate(fuelInput()); const result = await buildService([fuelPerLiter]).evaluate(fuelInput());
const mods = fuelMods(result); const mods = fuelMods(result);
expect(mods).toHaveLength(1); expect(mods).toHaveLength(1);
expect(mods[0].triggerValue).toBe(100); // Flat: the customer sees only the total, and a frozen contract snapshot
// (also stored flat) multiplies it by quantity 1 — never by the liters.
expect(mods[0].triggerValue).toBe(1);
expect(mods[0].unitPriceUsd).toBe(200);
expect(mods[0].calculatedAmount).toBe(200); expect(mods[0].calculatedAmount).toBe(200);
expect(mods[0].billingUnit).toBe('PER_LITER'); expect(mods[0].billingUnit).toBe('FLAT');
}); });
it('PER_WAGON bills the wagons the cargo occupies', async () => { it('PER_WAGON bills the wagons the cargo occupies', async () => {

View File

@@ -648,9 +648,12 @@ export class RuleEngineService {
/** /**
* Fuel surcharge — fires when the booking's cargo type has hasFuel = true, * Fuel surcharge — fires when the booking's cargo type has hasFuel = true,
* billed off the FUEL rate matching the booking's lane (trade direction + * billed off the FUEL rate matching the booking's lane (trade direction +
* origin + destination) and cargo type. PER_LITER bills baseLiters × * origin + destination) and cargo type. PER_LITER collapses to one FLAT
* rateValue once per booking; PER_WAGON bills the wagons the cargo occupies. * amount (baseLiters × rateValue, once per booking) — the customer only ever
* No matching lane rate simply bills nothing — same leniency as lashing. * 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( private fuelCharges(
input: BookingEvaluationInput, input: BookingEvaluationInput,
@@ -673,9 +676,12 @@ export class RuleEngineService {
0, 0,
Number(input.bulkWagons ?? 0) || Number(input.totalWagons ?? 0), Number(input.bulkWagons ?? 0) || Number(input.totalWagons ?? 0),
); );
const billedQty = const perLiter = rate.rateUnit === 'PER_LITER';
rate.rateUnit === 'PER_LITER' ? Number(rate.baseLiters ?? 0) : wagons; const billedQty = perLiter ? 1 : wagons;
const amount = billedQty * rateValue; const unitPrice = perLiter
? Number(rate.baseLiters ?? 0) * rateValue
: rateValue;
const amount = billedQty * unitPrice;
if (!(amount > 0)) return modifiers; if (!(amount > 0)) return modifiers;
modifiers.push({ modifiers.push({
rateId: rate.id, rateId: rate.id,
@@ -683,8 +689,8 @@ export class RuleEngineService {
triggerValue: billedQty, triggerValue: billedQty,
calculatedAmount: amount, calculatedAmount: amount,
currency: rate.currency, currency: rate.currency,
unitPriceUsd: rateValue, unitPriceUsd: unitPrice,
billingUnit: rate.rateUnit, billingUnit: perLiter ? 'FLAT' : rate.rateUnit,
}); });
return modifiers; return modifiers;
} }