mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 16:28:12 +00:00
feat(rule-engine): add fuel surcharge rate trigger and cargo flag
This commit is contained in:
@@ -70,6 +70,15 @@ export class CreateCargoTypeDto {
|
||||
@IsBoolean()
|
||||
hasLashing?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
default: false,
|
||||
description:
|
||||
'When true, bookings of this cargo type incur the lane-scoped FUEL surcharge.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
hasFuel?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
default: false,
|
||||
description:
|
||||
|
||||
@@ -7,7 +7,8 @@ import {
|
||||
RATE_UNITS,
|
||||
} from '../entities/rate.entity';
|
||||
|
||||
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH'] as const;
|
||||
// DOMESTIC is accepted only for FUEL rates (an intercity fuel lane).
|
||||
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH', 'DOMESTIC'] as const;
|
||||
// ETB is accepted only for last-mile rates; the service forces USD elsewhere.
|
||||
const CURRENCIES = ['USD', 'ETB'] as const;
|
||||
export const INTERCITY_KINDS = ['CONTAINER', 'BULK'] as const;
|
||||
@@ -94,6 +95,17 @@ export class CreateRateDto {
|
||||
@IsIn([...RATE_UNITS])
|
||||
rateUnit?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'FUEL rates billed PER_LITER only: liters the surcharge covers — price = baseLiters × rateValue, once per booking. Required there, rejected elsewhere.',
|
||||
minimum: 0,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Transform(({ value }) => (value === null || value === undefined || value === '' ? undefined : Number(value)))
|
||||
baseLiters?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Distance band start (km, inclusive). Container last-mile rates only (rateUnit = PER_KM).',
|
||||
|
||||
@@ -82,6 +82,14 @@ export class CargoType extends BaseEntity {
|
||||
@Column({ name: 'has_lashing', type: 'boolean', default: false })
|
||||
hasLashing!: boolean;
|
||||
|
||||
/**
|
||||
* Whether bookings of this cargo incur the fuel surcharge. Billed off the
|
||||
* lane-scoped FUEL rate for the booking's direction + route + this cargo
|
||||
* type (per liter or per wagon).
|
||||
*/
|
||||
@Column({ name: 'has_fuel', type: 'boolean', default: false })
|
||||
hasFuel!: boolean;
|
||||
|
||||
/**
|
||||
* Whether staff may write bulk contract templates against this cargo type.
|
||||
* Mutually exclusive between a parent group and its children: if the parent
|
||||
|
||||
@@ -46,6 +46,8 @@ export function deriveRateType(input: {
|
||||
return 'PIL_EXTRA_FEE';
|
||||
case 'CUSTOMS_CLEARANCE':
|
||||
return 'CUSTOMS_CLEARANCE';
|
||||
case 'FUEL':
|
||||
return 'FUEL_SURCHARGE';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -74,6 +74,9 @@ function unitsForShape(input: {
|
||||
case 'LASHING':
|
||||
// Bulk-only cargo securing — per ton or per wagon.
|
||||
return ['PER_TON', 'PER_WAGON'];
|
||||
case 'FUEL':
|
||||
// Per wagon (wagons × rate) or per liter (baseLiters × rate, once).
|
||||
return ['PER_WAGON', 'PER_LITER'];
|
||||
case 'CONSOLIDATION':
|
||||
return ['PER_CONTAINER', 'FLAT'];
|
||||
case 'SHIPPING_LINE':
|
||||
|
||||
@@ -24,6 +24,7 @@ export const RATE_TYPES = [
|
||||
'RETURN_SURCHARGE',
|
||||
'PIL_EXTRA_FEE',
|
||||
'CUSTOMS_CLEARANCE',
|
||||
'FUEL_SURCHARGE',
|
||||
] as const;
|
||||
|
||||
export type RateType = typeof RATE_TYPES[number];
|
||||
@@ -41,6 +42,8 @@ export const RATE_UNITS = [
|
||||
'PER_KM',
|
||||
// Last-mile bulk: price = tons × km × rateValue.
|
||||
'PER_TON_KM',
|
||||
// Fuel surcharge only: price = baseLiters × rateValue, once per booking.
|
||||
'PER_LITER',
|
||||
'PER_INVOICE',
|
||||
'FLAT',
|
||||
] as const;
|
||||
@@ -92,6 +95,9 @@ export const RATE_TRIGGERS = [
|
||||
// Customs clearance service fee — billed up front via a clearance invoice,
|
||||
// never auto-applied to booking pricing (matchesTrigger returns false).
|
||||
'CUSTOMS_CLEARANCE',
|
||||
// Fuel surcharge — fires when the booking's cargo type has hasFuel = true,
|
||||
// billed off the lane-scoped rate (direction + route + cargo type).
|
||||
'FUEL',
|
||||
] as const;
|
||||
export type RateTrigger = typeof RATE_TRIGGERS[number];
|
||||
|
||||
@@ -163,6 +169,14 @@ export class Rate extends BaseEntity {
|
||||
* containerTypeId): the rate applies when minKm <= km < maxKm (maxKm NULL =
|
||||
* open-ended). NULL on every other rate shape.
|
||||
*/
|
||||
/**
|
||||
* FUEL rates billed PER_LITER only: the liters the surcharge covers —
|
||||
* price = baseLiters × rateValue, once per booking. NULL on every other
|
||||
* rate shape (a PER_WAGON fuel rate bills wagons × rateValue instead).
|
||||
*/
|
||||
@Column({ name: 'base_liters', type: 'numeric', precision: 14, scale: 4, nullable: true })
|
||||
baseLiters?: number | null;
|
||||
|
||||
@Column({ name: 'min_km', type: 'numeric', precision: 10, scale: 2, nullable: true })
|
||||
minKm?: number | null;
|
||||
|
||||
|
||||
@@ -422,3 +422,105 @@ describe('RuleEngineService — lashing (bulk-only, per direction + commodity)',
|
||||
expect(lashingMods(result)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('RuleEngineService — fuel surcharge (per lane + cargo type)', () => {
|
||||
const fuelPerLiter: Rate = {
|
||||
id: 'rate-fuel-liter',
|
||||
rateType: 'FUEL_SURCHARGE',
|
||||
trigger: 'FUEL',
|
||||
rateValue: 2,
|
||||
rateUnit: 'PER_LITER',
|
||||
baseLiters: 100,
|
||||
currency: 'USD',
|
||||
status: 'LIVE',
|
||||
containerTypeId: null,
|
||||
cargoTypeId: 'cargo-steel',
|
||||
tradeDirection: 'IMPORT',
|
||||
originYardId: 'yard-nagad',
|
||||
destinationYardId: 'yard-mojo',
|
||||
} as Rate;
|
||||
|
||||
const buildService = (rates: Rate[], hasFuel = true): RuleEngineService =>
|
||||
new RuleEngineService(
|
||||
{
|
||||
findById: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ hasFuel, hasLashing: false, requiresDirectorApproval: false }),
|
||||
} as never,
|
||||
{ findById: jest.fn().mockResolvedValue(null) } as never,
|
||||
{ findActiveByContainerTypeId: jest.fn().mockResolvedValue([]) } as never,
|
||||
{ findAllActive: jest.fn().mockResolvedValue([]) } as never,
|
||||
{ findLiveRates: jest.fn().mockResolvedValue(rates) } as never,
|
||||
{ findById: jest.fn().mockResolvedValue(null) } as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
const fuelInput = (
|
||||
overrides: Partial<BookingEvaluationInput> = {},
|
||||
): BookingEvaluationInput => ({
|
||||
serviceTypeId: 'svc-1',
|
||||
paymentCurrency: 'USD',
|
||||
tradeDirection: 'IMPORT',
|
||||
isHazardous: false,
|
||||
cargoTypeId: 'cargo-steel',
|
||||
originYardId: 'yard-nagad',
|
||||
destinationYardId: 'yard-mojo',
|
||||
totalWagons: 0,
|
||||
bulkTons: 100,
|
||||
bulkWagons: 4,
|
||||
containers: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const fuelMods = (result: Awaited<ReturnType<RuleEngineService['evaluate']>>) =>
|
||||
result.appliedModifiers.filter((m) => m.surchargeCode === 'FUEL_SURCHARGE');
|
||||
|
||||
it('PER_LITER bills base liters × rate value once, regardless of wagons', async () => {
|
||||
const result = await buildService([fuelPerLiter]).evaluate(fuelInput());
|
||||
const mods = fuelMods(result);
|
||||
expect(mods).toHaveLength(1);
|
||||
expect(mods[0].triggerValue).toBe(100);
|
||||
expect(mods[0].calculatedAmount).toBe(200);
|
||||
expect(mods[0].billingUnit).toBe('PER_LITER');
|
||||
});
|
||||
|
||||
it('PER_WAGON bills the wagons the cargo occupies', async () => {
|
||||
const result = await buildService([
|
||||
{ ...fuelPerLiter, rateUnit: 'PER_WAGON', baseLiters: null, rateValue: 50 } as Rate,
|
||||
]).evaluate(fuelInput());
|
||||
const mods = fuelMods(result);
|
||||
expect(mods[0].triggerValue).toBe(4);
|
||||
expect(mods[0].calculatedAmount).toBe(200);
|
||||
});
|
||||
|
||||
it('a rate for another lane, direction or commodity never bills', async () => {
|
||||
for (const wrong of [
|
||||
{ tradeDirection: 'EXPORT' },
|
||||
{ originYardId: 'yard-other' },
|
||||
{ destinationYardId: 'yard-other' },
|
||||
{ cargoTypeId: 'cargo-wheat' },
|
||||
]) {
|
||||
const result = await buildService([{ ...fuelPerLiter, ...wrong } as Rate]).evaluate(
|
||||
fuelInput(),
|
||||
);
|
||||
expect(fuelMods(result)).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('a domestic booking bills the DOMESTIC fuel lane', async () => {
|
||||
const result = await buildService([
|
||||
{ ...fuelPerLiter, tradeDirection: 'DOMESTIC' } as Rate,
|
||||
]).evaluate(fuelInput({ tradeDirection: 'DOMESTIC' }));
|
||||
expect(fuelMods(result)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('no fuel charge when the cargo type does not have hasFuel', async () => {
|
||||
const result = await buildService([fuelPerLiter], false).evaluate(fuelInput());
|
||||
expect(fuelMods(result)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('no matching lane rate bills nothing (lenient, like lashing)', async () => {
|
||||
const result = await buildService([]).evaluate(fuelInput());
|
||||
expect(fuelMods(result)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -121,15 +121,16 @@ export class RatesService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Rates sold per direction + route. Base freight always; customs clearance
|
||||
* and empty-container return are the surcharges that are too — their fee
|
||||
* depends on the lane (and, for returns, the container type).
|
||||
* Rates sold per direction + route. Base freight always; customs clearance,
|
||||
* empty-container return and fuel are the surcharges that are too — their
|
||||
* fee depends on the lane (and, for returns, the container type).
|
||||
*/
|
||||
private isRouteScoped(appliesTo: Rate['appliesTo'], trigger: Rate['trigger']): boolean {
|
||||
return (
|
||||
this.isBaseFreight(appliesTo, trigger) ||
|
||||
trigger === 'CUSTOMS_CLEARANCE' ||
|
||||
trigger === 'WITH_RETURN'
|
||||
trigger === 'WITH_RETURN' ||
|
||||
trigger === 'FUEL'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -160,7 +161,9 @@ export class RatesService {
|
||||
appliesTo: Rate['appliesTo'],
|
||||
tradeDirection: string | null,
|
||||
): { origin: YardCountry; destination: YardCountry } {
|
||||
if (appliesTo === 'INTERCITY') {
|
||||
// DOMESTIC only reaches here on a FUEL rate's intercity lane — it stays
|
||||
// inside Ethiopia exactly like intercity base freight.
|
||||
if (appliesTo === 'INTERCITY' || tradeDirection === 'DOMESTIC') {
|
||||
return { origin: YardCountry.ETHIOPIA, destination: YardCountry.ETHIOPIA };
|
||||
}
|
||||
return tradeDirection === 'EXPORT'
|
||||
@@ -291,6 +294,31 @@ export class RatesService {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (trigger === 'FUEL') {
|
||||
// Fuel is sold per lane + commodity: the direction says which countries
|
||||
// the leg spans (DOMESTIC = intercity, inside Ethiopia) and the cargo
|
||||
// type names the commodity — different commodities price differently.
|
||||
if (
|
||||
tradeDirection !== 'IMPORT' &&
|
||||
tradeDirection !== 'EXPORT' &&
|
||||
tradeDirection !== 'DOMESTIC'
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
'A fuel rate must say whether it covers IMPORT, EXPORT or DOMESTIC (intercity).',
|
||||
);
|
||||
}
|
||||
if (containerTypeId) {
|
||||
throw new BadRequestException(
|
||||
'A fuel rate cannot be scoped to a container type.',
|
||||
);
|
||||
}
|
||||
if (!cargoTypeId) {
|
||||
throw new BadRequestException(
|
||||
'A fuel rate must name the cargo type it covers.',
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (trigger === 'WITH_RETURN') {
|
||||
// Returning the empty box only exists on imports (the box goes back to
|
||||
// the port) — export return rates are rejected until the business sells
|
||||
@@ -502,15 +530,21 @@ export class RatesService {
|
||||
: (dto.containerTypeId ?? null);
|
||||
const cargoTypeId =
|
||||
(trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'BULK') ||
|
||||
trigger === 'LASHING'
|
||||
trigger === 'LASHING' ||
|
||||
trigger === 'FUEL'
|
||||
? (dto.cargoTypeId ?? null)
|
||||
: isSurcharge
|
||||
? null
|
||||
: (dto.cargoTypeId ?? null);
|
||||
// Intercity never leaves Ethiopia, so it has no trade direction to store —
|
||||
// its yard pair already says where it runs.
|
||||
// its yard pair already says where it runs. (Fuel is the exception: its
|
||||
// intercity lane is stored as DOMESTIC, since appliesTo = OTHER says
|
||||
// nothing about the direction.)
|
||||
const tradeDirection =
|
||||
trigger === 'CUSTOMS_CLEARANCE' || trigger === 'WITH_RETURN' || trigger === 'LASHING'
|
||||
trigger === 'CUSTOMS_CLEARANCE' ||
|
||||
trigger === 'WITH_RETURN' ||
|
||||
trigger === 'LASHING' ||
|
||||
trigger === 'FUEL'
|
||||
? (dto.tradeDirection ?? null)
|
||||
: isSurcharge || appliesTo === 'INTERCITY'
|
||||
? null
|
||||
@@ -563,6 +597,8 @@ export class RatesService {
|
||||
await this.assertNoBandOverlap({ rateUnit, containerTypeId, minKm, maxKm });
|
||||
}
|
||||
|
||||
const baseLiters = this.resolveBaseLiters(rateUnit, dto.baseLiters);
|
||||
|
||||
await this.assertNoDuplicatePattern({
|
||||
rateType,
|
||||
...(this.resolvesSingleRate(appliesTo, trigger) ? {} : { rateUnit }),
|
||||
@@ -588,6 +624,7 @@ export class RatesService {
|
||||
currency: appliesTo === 'LAST_MILE' ? (dto.currency ?? 'ETB') : 'USD',
|
||||
rateValue: dto.rateValue,
|
||||
rateUnit,
|
||||
baseLiters,
|
||||
minKm,
|
||||
maxKm,
|
||||
status: 'DRAFT',
|
||||
@@ -595,6 +632,25 @@ export class RatesService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The liters a PER_LITER fuel rate bills (price = baseLiters × rateValue,
|
||||
* once per booking). Required there; cleared on every other rate shape —
|
||||
* a PER_WAGON fuel rate bills wagons × rateValue and carries none.
|
||||
*/
|
||||
private resolveBaseLiters(
|
||||
rateUnit: Rate['rateUnit'],
|
||||
baseLiters?: number | null,
|
||||
): number | null {
|
||||
if (rateUnit !== 'PER_LITER') return null;
|
||||
const liters = Number(baseLiters);
|
||||
if (!(liters > 0)) {
|
||||
throw new BadRequestException(
|
||||
'A per-liter fuel rate needs a base liters amount — the price is base liters × rate value.',
|
||||
);
|
||||
}
|
||||
return liters;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a DRAFT rate in place. Nothing prices off a draft, so a direct edit
|
||||
* is safe. A LIVE rate cannot take this path — see `applyApprovedUpdate`.
|
||||
@@ -680,14 +736,18 @@ export class RatesService {
|
||||
const keepsCargoType =
|
||||
!isSurcharge ||
|
||||
(trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'BULK') ||
|
||||
trigger === 'LASHING';
|
||||
trigger === 'LASHING' ||
|
||||
trigger === 'FUEL';
|
||||
const cargoTypeId = !keepsCargoType
|
||||
? null
|
||||
: dto.cargoTypeId !== undefined
|
||||
? dto.cargoTypeId
|
||||
: existing.cargoTypeId;
|
||||
const tradeDirection =
|
||||
trigger === 'CUSTOMS_CLEARANCE' || trigger === 'WITH_RETURN' || trigger === 'LASHING'
|
||||
trigger === 'CUSTOMS_CLEARANCE' ||
|
||||
trigger === 'WITH_RETURN' ||
|
||||
trigger === 'LASHING' ||
|
||||
trigger === 'FUEL'
|
||||
? dto.tradeDirection !== undefined
|
||||
? dto.tradeDirection
|
||||
: existing.tradeDirection
|
||||
@@ -788,6 +848,11 @@ export class RatesService {
|
||||
ignoreId: id,
|
||||
});
|
||||
|
||||
updates.baseLiters = this.resolveBaseLiters(
|
||||
rateUnit,
|
||||
dto.baseLiters !== undefined ? dto.baseLiters : existing.baseLiters,
|
||||
);
|
||||
|
||||
updates.currency =
|
||||
appliesTo === 'LAST_MILE'
|
||||
? (dto.currency ?? existing.currency ?? 'ETB')
|
||||
|
||||
Reference in New Issue
Block a user