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 3fbecc036..f035f8a64 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 @@ -508,12 +508,12 @@ export class RuleEngineService { if (input.tradeDirection === 'IMPORT') { appliedModifiers.push( - ...this.derivedImportOverweight( + ...(await this.derivedImportOverweight( input, containerWeightResults, lineMaxVgmTons, liveRates, - ), + )), ); } @@ -540,23 +540,37 @@ export class RuleEngineService { } /** - * Import overweight — derived, never configured. Each overweight container - * line bills its excess tons at (its own base import freight on the booking's - * route) ÷ (2 × its weight limit): 20ft at 1000 USD with a 20 t limit → - * 25 USD per excess ton. Export keeps the configured OVERWEIGHT rate. - * Note: derives from the LIVE route rate even for frozen-rate contract - * bookings — the frozen snapshot has no route-scoped container price to - * divide. + * Import overweight — derived, never configured. Excess tons are billed on a + * PER-WAGON basis: (the wagon's base import freight on the booking's route) + * ÷ (2 × the container's weight limit). + * + * The rate is normalised to a wagon before dividing, because a 20ft rate + * quoted PER_CONTAINER prices only HALF a wagon — two 20ft ride one wagon — + * while a 40ft container IS the whole wagon. So a PER_CONTAINER 20ft rate is + * doubled first; 40ft (and any rate already quoted PER_WAGON) is taken as is: + * - 20ft PER_CONTAINER 845 USD, 20 t limit → (845 × 2) / (2 × 20) = 42.25 + * - 40ft PER_CONTAINER 1676 USD, 40 t limit → 1676 / (2 × 40) = 20.95 + * Halving over 2 × the limit keeps the original meaning: filling one wagon's + * worth of excess costs one extra wagon of freight. + * + * Export keeps the configured OVERWEIGHT rate. Note: derives from the LIVE + * route rate even for frozen-rate contract bookings — the frozen snapshot has + * no route-scoped container price to divide. */ - private derivedImportOverweight( + private async derivedImportOverweight( input: BookingEvaluationInput, weightResults: ContainerWeightResult[], lineMaxVgmTons: Array, liveRates: Rate[], - ): AppliedCargoModifier[] { + ): Promise { const modifiers: AppliedCargoModifier[] = []; if (!input.originYardId || !input.destinationYardId) return modifiers; + // How many of each container type ride one wagon: a 40ft fills a wagon, + // two 20ft share one. Keyed by container type so a PER_CONTAINER rate can + // be scaled up to the wagon the overweight formula prices against. + const sizeByTypeId = await this.containersPerWagonByTypeId(weightResults); + for (let i = 0; i < weightResults.length; i++) { const wr = weightResults[i]; const excess = Number(wr?.overweightExcessTons ?? 0); @@ -578,7 +592,16 @@ export class RuleEngineService { // No base rate → the base-freight line hard-blocks this booking anyway. if (!base) continue; - const perTon = Number(base.rateValue) / (2 * maxVgm); + // Normalise the rate to ONE WAGON before dividing. A PER_CONTAINER 20ft + // rate covers half a wagon, so it is scaled by the 2 containers that ride + // one; 40ft scales by 1. A rate already quoted PER_WAGON is the wagon + // price already — never scale it again. + const perWagonRate = + base.rateUnit === 'PER_CONTAINER' + ? Number(base.rateValue) * (sizeByTypeId.get(wr.containerTypeId) ?? 1) + : Number(base.rateValue); + + const perTon = perWagonRate / (2 * maxVgm); const amount = excess * perTon; if (!(amount > 0)) continue; @@ -595,6 +618,42 @@ export class RuleEngineService { return modifiers; } + /** + * Containers of each type that ride ONE wagon, derived from the type's + * size_ft against a 40ft wagon slot: 20ft → 2, 40ft → 1. Only the types the + * caller actually needs are looked up. Unknown or non-positive sizes fall + * back to 1, which leaves a PER_CONTAINER rate unscaled — the pre-existing + * behaviour, so a missing size can never inflate a bill. + */ + private async containersPerWagonByTypeId( + weightResults: ContainerWeightResult[], + ): Promise> { + const perWagon = new Map(); + const ids = [...new Set(weightResults.map((w) => w.containerTypeId).filter(Boolean))]; + if (ids.length === 0) return perWagon; + + let rows: Array<{ id: string; size_ft: string | number | null }> = []; + try { + rows = await this.dataSource.query( + 'SELECT id, size_ft FROM freight.container_types WHERE id = ANY($1)', + [ids], + ); + } catch { + // Size lookup unavailable — fall back to an unscaled (×1) rate, the + // behaviour before per-wagon normalisation. Never fail pricing over it. + return perWagon; + } + const WAGON_SLOT_FT = 40; + for (const row of rows) { + const sizeFt = Number(row.size_ft ?? 0); + perWagon.set( + row.id, + sizeFt > 0 ? Math.max(1, Math.floor(WAGON_SLOT_FT / sizeFt)) : 1, + ); + } + return perWagon; + } + /** * Empty-container return — sold per direction + route + container type, like * base freight. Each container line that opted in (returnQuantity, or every