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

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