fix issue

This commit is contained in:
Marshal
2026-07-16 00:33:31 +00:00
parent 234a74e812
commit 41fe04652f
51 changed files with 1895 additions and 206 deletions

View File

@@ -3,6 +3,7 @@ import { Injectable, NotFoundException } from '@nestjs/common';
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
import { RatesService } from '../rule-engine/services/rates.service';
import { Rate } from '../rule-engine/entities/rate.entity';
import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity';
import { ExchangeService } from '@edr/api-common';
import {
AppliedCargoModifier,
@@ -128,11 +129,18 @@ export class BookingPricingService {
const isEtbBooking = paymentCurrency === 'ETB';
const usdToEtb = isEtbBooking ? await this.exchangeService.getRate('USD', 'ETB') : 1;
// H15: a booking created under a contract prices from that contract's FROZEN
// rate snapshots (the agreed rates), not the live rate of the day. Loaded
// once and threaded through the line builders; each rate code that has a
// snapshot uses it, and any code without one falls back to the live rate.
// Non-contract bookings resolve to null and keep the live-rate path.
const frozenRates = await this.loadFrozenContractRates(booking);
const lineItems: PriceLineItemDto[] = [];
let total = 0;
const { lineItems: baseLines, usedRates: baseRates } =
await this.computeBaseRailLinesWithRates(booking, evalInput);
await this.computeBaseRailLinesWithRates(booking, evalInput, frozenRates);
for (const line of baseLines) {
lineItems.push(line);
total += line.amount;
@@ -141,7 +149,7 @@ export class BookingPricingService {
// First / last mile trucking — billed per the rate's unit (km / container /
// ton / flat), only for legs the booking actually carries.
const { lineItems: mileLines, usedRates: mileRates } =
await this.computeFirstLastMileLines(booking, evalInput);
await this.computeFirstLastMileLines(booking, evalInput, frozenRates);
for (const line of mileLines) {
lineItems.push(line);
total += line.amount;
@@ -153,15 +161,14 @@ export class BookingPricingService {
for (const mod of ruleResult.appliedModifiers) {
const usdAmount = mod.calculatedAmount;
const convertedAmount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
const rate = rateById.get(mod.rateId);
const unit = rate?.rateUnit ?? 'FLAT';
const unitUsd = rate ? Number(rate.rateValue) : usdAmount;
const unitAmount = isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd;
// Per-unit count: FLAT and PER_INVOICE are billed once (qty 1); an
// explicit trigger (e.g. overweight tons) wins when present; otherwise
// derive from total ÷ unit price.
// derive from total ÷ unit price (the live unit price — a count, not a
// currency amount, so it is snapshot-independent).
const quantity =
unit === 'FLAT' || unit === 'PER_INVOICE'
? 1
@@ -171,6 +178,26 @@ export class BookingPricingService {
? Math.max(1, Math.round(usdAmount / unitUsd))
: 1;
// H15: bill the frozen contract surcharge rate (already in the booking
// currency) when this code has a snapshot; else keep the live amount.
const frozen = this.frozenRateByCode(
frozenRates,
mod.surchargeCode,
paymentCurrency,
);
const unitAmount = frozen
? Number(frozen.unitPrice)
: isEtbBooking
? Math.round(unitUsd * usdToEtb)
: unitUsd;
const convertedAmount = frozen
? isEtbBooking
? Math.round(unitAmount * quantity)
: unitAmount * quantity
: isEtbBooking
? Math.round(usdAmount * usdToEtb)
: usdAmount;
const item: PriceLineItemDto = {
code: mod.surchargeCode,
description: surchargeLabel(mod.surchargeCode),
@@ -424,6 +451,7 @@ export class BookingPricingService {
private async computeBaseRailLinesWithRates(
booking: Booking,
evalInput: BookingEvaluationInput,
frozenRates: Map<string, ContractRateSnapshot> | null = null,
): Promise<{ lineItems: PriceLineItemDto[]; usedRates: Rate[] }> {
const liveRates = await this.ratesService.findLiveRates();
const paymentCurrency = booking.paymentCurrency;
@@ -453,15 +481,35 @@ export class BookingPricingService {
if (!rate) continue;
usedRatesMap.set(rate.id, rate);
const usdAmount = this.amountForRate(rate, container.quantity, wagonCount);
const amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
const unitUsd = Number(rate.rateValue);
// H15: frozen contract rate for this container size, when present — its
// unitPrice is already in the booking currency (no USD→currency convert).
const frozen = await this.frozenRateForContainer(
frozenRates,
container.containerTypeId,
paymentCurrency,
);
let amount: number;
let unitAmount: number;
if (frozen) {
unitAmount = Number(frozen.unitPrice);
amount = this.amountForUnit(
rate.rateUnit,
unitAmount,
container.quantity,
wagonCount,
);
} else {
const usdAmount = this.amountForRate(rate, container.quantity, wagonCount);
amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
unitAmount = isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd;
}
const label = await this.containerTypeLabel(container.containerTypeId);
lines.push({
code: rateType,
description: `${label} rail freight`,
amount,
unitAmount: isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd,
unitAmount,
unit: rate.rateUnit,
quantity: this.effectiveUnitQuantity(rate.rateUnit, container.quantity, wagonCount),
currency: paymentCurrency,
@@ -477,14 +525,31 @@ export class BookingPricingService {
const bulkTons = Number(booking.cargoTotalWeightVgm ?? 0);
const quantity =
isBulk && fallback.rateUnit === 'PER_TON' ? Math.max(bulkTons, 0) : 1;
const usdAmount = this.amountForRate(fallback, quantity, wagonCount);
const amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
const unitUsd = Number(fallback.rateValue);
// H15: bulk freight uses the frozen BULK_FREIGHT snapshot when present.
const frozen = isBulk
? this.frozenRateByCode(frozenRates, 'BULK_FREIGHT', paymentCurrency)
: null;
let amount: number;
let unitAmount: number;
if (frozen) {
unitAmount = Number(frozen.unitPrice);
amount = this.amountForUnit(
fallback.rateUnit,
unitAmount,
quantity,
wagonCount,
);
} else {
const usdAmount = this.amountForRate(fallback, quantity, wagonCount);
amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
unitAmount = isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd;
}
lines.push({
code: rateType,
description: isBulk ? 'Bulk rail freight' : 'Container rail freight',
amount,
unitAmount: isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd,
unitAmount,
unit: fallback.rateUnit,
quantity: this.effectiveUnitQuantity(fallback.rateUnit, quantity, wagonCount),
currency: paymentCurrency,
@@ -508,6 +573,7 @@ export class BookingPricingService {
private async computeFirstLastMileLines(
booking: Booking,
evalInput: BookingEvaluationInput,
frozenRates: Map<string, ContractRateSnapshot> | null = null,
): Promise<{ lineItems: PriceLineItemDto[]; usedRates: Rate[] }> {
const legs: Array<{ rateType: 'FIRST_MILE' | 'LAST_MILE'; label: string; active: boolean }> = [
{
@@ -565,18 +631,34 @@ export class BookingPricingService {
break;
}
const usdAmount = value * quantity;
// H15: frozen mile rate (already in booking currency) when the contract
// has one; else the live USD rate converted as before.
const frozen = this.frozenRateByCode(
frozenRates,
leg.rateType,
paymentCurrency,
);
let amount: number;
let unitAmount: number;
if (frozen) {
unitAmount = Number(frozen.unitPrice);
amount = isEtbBooking
? Math.round(unitAmount * quantity)
: unitAmount * quantity;
} else {
const usdAmount = value * quantity;
amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
unitAmount = isEtbBooking ? Math.round(value * usdToEtb) : value;
}
// Skip legs that resolve to nothing (zero rate, or zero km / count / tons).
if (!(usdAmount > 0)) continue;
if (!(amount > 0)) continue;
const amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
const unitUsd = value;
usedRatesMap.set(rate.id, rate);
lines.push({
code: leg.rateType,
description: leg.label,
amount,
unitAmount: isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd,
unitAmount,
unit: rate.rateUnit,
quantity,
currency: paymentCurrency,
@@ -649,21 +731,93 @@ export class BookingPricingService {
}
private amountForRate(rate: Rate, quantity: number, wagonCount: number): number {
const value = Number(rate.rateValue);
switch (rate.rateUnit) {
return this.amountForUnit(
rate.rateUnit,
Number(rate.rateValue),
quantity,
wagonCount,
);
}
/** Apply a unit value by rate unit — shared by live and frozen-snapshot lines. */
private amountForUnit(
rateUnit: string,
unitValue: number,
quantity: number,
wagonCount: number,
): number {
switch (rateUnit) {
case 'PER_CONTAINER':
return value * quantity;
return unitValue * quantity;
case 'PER_WAGON':
return value * wagonCount;
return unitValue * wagonCount;
case 'PER_TON':
return value * quantity;
return unitValue * quantity;
case 'FLAT':
return value;
return unitValue;
default:
return value * quantity;
return unitValue * quantity;
}
}
// ── H15: frozen contract rate snapshots ────────────────────────────────────
/**
* Load a contract's frozen rate snapshots into a by-rate-code lookup, or null
* for a non-contract booking (or a contract with no snapshots). The pricing
* line builders prefer a matching snapshot's unit price over the live rate.
*/
private async loadFrozenContractRates(
booking: Booking,
): Promise<Map<string, ContractRateSnapshot> | null> {
if (!booking.contractId) return null;
const snapshots = await this.bookingsRepository.findContractRateSnapshots(
booking.contractId,
);
if (!snapshots.length) return null;
const byCode = new Map<string, ContractRateSnapshot>();
for (const snap of snapshots) byCode.set(snap.rateCode, snap);
return byCode;
}
/**
* The frozen snapshot for a rate code, or null when there is none, its price
* is negative, or it is in a different currency than the booking (in which
* case the live-rate path is safer than a mis-converted frozen price).
*/
private frozenRateByCode(
frozenRates: Map<string, ContractRateSnapshot> | null,
code: string,
bookingCurrency: string,
): ContractRateSnapshot | null {
const snap = frozenRates?.get(code);
if (!snap) return null;
if (snap.currency !== bookingCurrency) return null;
if (!(Number(snap.unitPrice) >= 0)) return null;
return snap;
}
/**
* The frozen base-rail snapshot for a container line, matched by the
* container's size (CONTAINER_20FT / CONTAINER_40FT — the codes
* ContractPricingService freezes). Null when there is no snapshot.
*/
private async frozenRateForContainer(
frozenRates: Map<string, ContractRateSnapshot> | null,
containerTypeId: string,
bookingCurrency: string,
): Promise<ContractRateSnapshot | null> {
if (!frozenRates) return null;
let sizeFt: number | null = null;
try {
sizeFt = Number((await this.containerTypesService.findById(containerTypeId)).sizeFt) || null;
} catch {
return null;
}
if (!sizeFt) return null;
return this.frozenRateByCode(frozenRates, `CONTAINER_${sizeFt}FT`, bookingCurrency);
}
private lineItemsSignature(items: PriceLineItemDto[]): string {
return JSON.stringify(
[...items]