diff --git a/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.ts b/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.ts index c62a875bf..c644d6117 100644 --- a/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.ts +++ b/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.ts @@ -44,6 +44,7 @@ const UNIT_LABELS: Record = { 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> = { 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), diff --git a/apps/edr-freight-api/src/migrations/3430000000000-FuelSurcharge.ts b/apps/edr-freight-api/src/migrations/3430000000000-FuelSurcharge.ts new file mode 100644 index 000000000..dfd0d25cf --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3430000000000-FuelSurcharge.ts @@ -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 { + 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 { + 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`, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts index d116a4b4a..67f5d2547 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts @@ -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: diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts index 45a5838bd..eccb14017 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts @@ -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).', diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts index b7717684a..7084e233a 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts @@ -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 diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.ts index a5b5bfc30..536e35527 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.ts @@ -46,6 +46,8 @@ export function deriveRateType(input: { return 'PIL_EXTRA_FEE'; case 'CUSTOMS_CLEARANCE': return 'CUSTOMS_CLEARANCE'; + case 'FUEL': + return 'FUEL_SURCHARGE'; } } diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts index a3d336605..3d5b1401e 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts @@ -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': diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts index e7089f08c..925a3555a 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.spec.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.spec.ts index a736d4b05..440616298 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.spec.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.spec.ts @@ -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 => ({ + 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>) => + 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); + }); +}); diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts index ac2e854e4..a290aeb33 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts @@ -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 diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts index 971097377..232cd9299 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts @@ -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') diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/CargoTypesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/CargoTypesPage.tsx index 2b5839a42..769ae757c 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/CargoTypesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/CargoTypesPage.tsx @@ -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. diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx index 3751d17a6..e345fb65a 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx @@ -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 diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts index 22e1046e8..4e00a930b 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts @@ -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) => const isRouteScopedRate = (values: Record) => 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(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", + }, ], }, { diff --git a/apps/edr-freight-web/backoffice/src/services/rates.service.ts b/apps/edr-freight-web/backoffice/src/services/rates.service.ts index b7e3dd377..c832b6f7e 100644 --- a/apps/edr-freight-web/backoffice/src/services/rates.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/rates.service.ts @@ -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;