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

@@ -44,6 +44,7 @@ const UNIT_LABELS: Record<string, string> = {
PER_CONTAINER: 'per container',
PER_KM: 'per km',
PER_TON_KM: 'per ton per km',
PER_LITER: 'per liter',
PER_INVOICE: 'per invoice',
FLAT: 'flat',
};
@@ -66,6 +67,7 @@ const TRIGGER_ROUTE_LABELS: Partial<Record<Rate['trigger'], string>> = {
DEMURRAGE: 'Demurrage / wagon detention',
PIL_EXTRA_FEE: 'PIL shipping line extra fee',
CUSTOMS_CLEARANCE: 'Customs clearance service',
FUEL: 'Fuel surcharge',
};
@Injectable()
@@ -101,6 +103,15 @@ export class ContractRateScheduleBuilder {
continue;
}
// Fuel is sold per lane + commodity — only lanes matching the contract's
// direction belong on its schedule, labeled with their leg.
if (rate.trigger === 'FUEL') {
if (this.fuelDirectionMatches(rate, direction)) {
surcharges.push(this.fuelRow(rate));
}
continue;
}
// Everything left is a trigger-based charge (surcharge / demurrage / customs).
surcharges.push(this.surchargeRow(rate));
}
@@ -176,6 +187,26 @@ export class ContractRateScheduleBuilder {
};
}
private fuelDirectionMatches(rate: Rate, direction: ContractDirection): boolean {
const want =
direction === 'IMP' ? 'IMPORT' : direction === 'EXP' ? 'EXPORT' : 'DOMESTIC';
return rate.tradeDirection === want;
}
/** Fuel row — the lane matters, so it rides along in the charge label. */
private fuelRow(rate: Rate): RateScheduleRow {
const origin = rate.originYard?.label ?? rate.originYard?.code ?? '—';
const destination =
rate.destinationYard?.label ?? rate.destinationYard?.code ?? '—';
return {
route: `Fuel surcharge (${origin}${destination})`,
cargo: this.cargoLabel(rate),
currency: rate.currency,
amount: this.formatAmount(rate.rateValue),
unit: this.unitLabel(rate.rateUnit),
};
}
private surchargeRow(rate: Rate): RateScheduleRow {
return {
route: TRIGGER_ROUTE_LABELS[rate.trigger] ?? this.titleCase(rate.trigger),

View File

@@ -0,0 +1,62 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Fuel surcharge, sold per lane + commodity:
*
* - cargo_types.has_fuel marks the commodities that incur it (same shape as
* has_lashing — the booking's cargo type flag is what fires the charge).
* - rates.base_liters carries the liters a PER_LITER fuel rate bills
* (price = base_liters × rate_value, once per booking). NULL on every other
* rate shape, including PER_WAGON fuel rates (wagons × rate_value).
* - CK_rates_yard_scope gains FUEL in its yard-carrying branch: fuel is priced
* per origin → destination leg like customs clearance and container return.
*/
export class FuelSurcharge3430000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.cargo_types
ADD COLUMN IF NOT EXISTS has_fuel boolean NOT NULL DEFAULT false
`);
await queryRunner.query(`
ALTER TABLE freight.rates
ADD COLUMN IF NOT EXISTS base_liters numeric(14,4)
`);
await queryRunner.query(
`ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope"`,
);
await queryRunner.query(`
ALTER TABLE freight.rates ADD CONSTRAINT "CK_rates_yard_scope" CHECK (
deleted_at IS NOT NULL OR status = 'SUPERSEDED' OR
CASE
WHEN (trigger = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY'))
OR trigger IN ('CUSTOMS_CLEARANCE', 'WITH_RETURN', 'FUEL')
THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL
ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL
END
)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope"`,
);
await queryRunner.query(`
ALTER TABLE freight.rates ADD CONSTRAINT "CK_rates_yard_scope" CHECK (
deleted_at IS NOT NULL OR status = 'SUPERSEDED' OR
CASE
WHEN (trigger = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY'))
OR trigger IN ('CUSTOMS_CLEARANCE', 'WITH_RETURN')
THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL
ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL
END
)
`);
await queryRunner.query(`ALTER TABLE freight.rates DROP COLUMN IF EXISTS base_liters`);
await queryRunner.query(
`ALTER TABLE freight.cargo_types DROP COLUMN IF EXISTS has_fuel`,
);
}
}

View File

@@ -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:

View File

@@ -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).',

View File

@@ -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

View File

@@ -46,6 +46,8 @@ export function deriveRateType(input: {
return 'PIL_EXTRA_FEE';
case 'CUSTOMS_CLEARANCE':
return 'CUSTOMS_CLEARANCE';
case 'FUEL':
return 'FUEL_SURCHARGE';
}
}

View File

@@ -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':

View File

@@ -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;

View File

@@ -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);
});
});

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

View File

@@ -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')

View File

@@ -55,6 +55,8 @@ interface CargoNode extends RuleEngineRecord {
requiresDirectorApproval?: boolean;
/** When true, bookings of this cargo type incur the flat LASHING surcharge. */
hasLashing?: boolean;
/** When true, bookings of this cargo type incur the lane-scoped FUEL surcharge. */
hasFuel?: boolean;
/** Staff may write bulk contract templates for this cargo type (parent XOR children). */
hasContractTemplate?: boolean;
/** How this cargo is measured (PER_TON / PER_ITEM); null for groups/unset. */
@@ -114,6 +116,9 @@ const FORM_FIELDS: FormFieldDef[] = [
// When on, every booking of this cargo type is charged the flat LASHING
// surcharge (a rate with trigger = Lashing).
{ name: "hasLashing", label: "Charge lashing fee", type: "boolean" },
// When on, bookings of this cargo type incur the fuel surcharge, billed off
// the lane-scoped FUEL rate (direction + route + cargo type) under Rates.
{ name: "hasFuel", label: "Charge fuel fee", type: "boolean" },
// Lets staff write bulk contract templates for this cargo type. The API
// rejects the save when the parent group (or a child) already has it on —
// the template must live on exactly one level.

View File

@@ -100,19 +100,27 @@ const yardOptionsForLegEnd = (
} else if (
appliesTo === "CONTAINER" ||
appliesTo === "BULK" ||
// Customs clearance + empty-container return are sold per direction +
// route, so their yard dropdowns narrow exactly like base freight.
// Customs clearance, empty-container return and fuel are sold per
// direction + route, so their yard dropdowns narrow exactly like base
// freight.
(appliesTo === "OTHER" &&
["CUSTOMS_CLEARANCE", "WITH_RETURN"].includes(String(values.trigger ?? "")))
["CUSTOMS_CLEARANCE", "WITH_RETURN", "FUEL"].includes(
String(values.trigger ?? ""),
))
) {
const direction = String(values.tradeDirection ?? "");
// Direction is what decides the countries, so offer nothing until it is set
// rather than defaulting to one and letting it read as a real choice.
if (direction !== "IMPORT" && direction !== "EXPORT") return [];
const startsInEthiopia = direction === "EXPORT";
country = (end === "origin" ? startsInEthiopia : !startsInEthiopia)
? "Ethiopia"
: "Djibouti";
if (direction === "DOMESTIC") {
// A fuel rate's intercity lane — stays inside Ethiopia.
country = "Ethiopia";
} else {
if (direction !== "IMPORT" && direction !== "EXPORT") return [];
const startsInEthiopia = direction === "EXPORT";
country = (end === "origin" ? startsInEthiopia : !startsInEthiopia)
? "Ethiopia"
: "Djibouti";
}
}
if (!country) return [];
return yards

View File

@@ -181,6 +181,17 @@ const RATE_TRIGGERS = [
{ label: "Cancellation", value: "CANCELLATION" },
{ label: "Shipping line extra fee (PIL)", value: "PIL_EXTRA_FEE" },
{ label: "Customs clearance service fee (billed with the booking)", value: "CUSTOMS_CLEARANCE" },
{ label: "Fuel (per lane + cargo type)", value: "FUEL" },
];
/**
* Fuel lanes: import/export like base freight, plus a domestic intercity lane
* (stored as DOMESTIC — matches the booking's own trade direction).
*/
const FUEL_TRADE_DIRECTIONS = [
{ label: "Import", value: "IMPORT" },
{ label: "Export", value: "EXPORT" },
{ label: "Intercity", value: "DOMESTIC" },
];
/**
@@ -205,7 +216,7 @@ const isBaseFreightRate = (values: Record<string, unknown>) =>
const isRouteScopedRate = (values: Record<string, unknown>) =>
isBaseFreightRate(values) ||
(String(values.appliesTo ?? "") === "OTHER" &&
["CUSTOMS_CLEARANCE", "WITH_RETURN"].includes(String(values.trigger ?? "")));
["CUSTOMS_CLEARANCE", "WITH_RETURN", "FUEL"].includes(String(values.trigger ?? "")));
const unitOption = (value: string) => ({ label: value.replace(/_/g, " "), value });
@@ -255,6 +266,9 @@ const unitsForShape = (
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 (base liters × rate, once).
return ["PER_WAGON", "PER_LITER"];
case "CONSOLIDATION":
case "SHIPPING_LINE":
case "PIL_EXTRA_FEE":
@@ -371,6 +385,13 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
},
{ name: "requiresDirectorApproval", label: "Requires director approval", type: "boolean" },
{ name: "hasLashing", label: "Charge lashing fee", type: "boolean" },
{
name: "hasFuel",
label: "Charge fuel fee",
type: "boolean",
description:
"Bookings of this cargo incur the fuel surcharge (configure the FUEL rate per lane under Rates).",
},
{ name: "isActive", label: "Active", type: "boolean" },
],
},
@@ -834,7 +855,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
filters: {
appliesTo: "OTHER",
trigger:
"HAZARDOUS,OVERWEIGHT,REEFER,SHIPPING_LINE,CONSOLIDATION,LASHING,CANCELLATION,PIL_EXTRA_FEE",
"HAZARDOUS,OVERWEIGHT,REEFER,SHIPPING_LINE,CONSOLIDATION,LASHING,CANCELLATION,PIL_EXTRA_FEE,FUEL",
},
},
],
@@ -886,14 +907,17 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
type: "select",
required: true,
optionsFromValues: (v: Record<string, unknown>) =>
String(v.trigger ?? "") === "WITH_RETURN" &&
String(v.appliesTo ?? "") === "OTHER"
String(v.appliesTo ?? "") === "OTHER" &&
String(v.trigger ?? "") === "WITH_RETURN"
? TRADE_DIRECTIONS.filter((d) => d.value === "IMPORT")
: TRADE_DIRECTIONS.filter((d) => d.value !== "BOTH"),
: String(v.appliesTo ?? "") === "OTHER" &&
String(v.trigger ?? "") === "FUEL"
? FUEL_TRADE_DIRECTIONS
: TRADE_DIRECTIONS.filter((d) => d.value !== "BOTH"),
showIf: (v) =>
["BULK", "CONTAINER"].includes(String(v.appliesTo ?? "")) ||
(String(v.appliesTo ?? "") === "OTHER" &&
["CUSTOMS_CLEARANCE", "WITH_RETURN", "LASHING"].includes(
["CUSTOMS_CLEARANCE", "WITH_RETURN", "LASHING", "FUEL"].includes(
String(v.trigger ?? ""),
)),
},
@@ -939,6 +963,18 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
v.trigger === "CUSTOMS_CLEARANCE" &&
v.cargoKind === "BULK",
},
// ── Cargo type — a fuel rate names the commodity it covers (different
// commodities price differently on the same lane) ──────────────────────
{
name: "cargoTypeId",
label: "Cargo type",
type: "select",
required: true,
placeholder: "Which cargo type this fuel rate covers",
description:
"Fuel is charged for bookings of this cargo type (needs “Charge fuel fee” enabled on the cargo type).",
showIf: (v) => v.appliesTo === "OTHER" && v.trigger === "FUEL",
},
// ── Bulk cargo type — lashing is bulk-only; may narrow to one leaf
// commodity (specific wins over the commodity-wide catch-all) ──────────
{
@@ -1123,6 +1159,20 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
String(v.trigger ?? "") !== "OVERWEIGHT" &&
String(v.appliesTo ?? "") !== "LAST_MILE",
},
// ── Base liters — per-liter fuel rates only ────────────────────────────
{
name: "baseLiters",
label: "Base (liters)",
type: "number",
required: true,
placeholder: "e.g. 100",
description:
"Liters the surcharge covers — price = base liters × rate value, charged once per booking.",
showIf: (v) =>
v.appliesTo === "OTHER" &&
v.trigger === "FUEL" &&
v.rateUnit === "PER_LITER",
},
],
},
{

View File

@@ -11,6 +11,8 @@ export interface Rate {
currency: string;
rateValue: string;
rateUnit: string;
/** PER_LITER fuel rates only: price = baseLiters × rateValue, once per booking. */
baseLiters?: string | null;
status: string;
proposedByStaffId: string;
approvedByCeoId: string | null;