mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
1119 lines
41 KiB
TypeScript
1119 lines
41 KiB
TypeScript
import { Injectable, NotFoundException } from '@nestjs/common';
|
||
|
||
import { CargoTypesService } from '../rule-engine/services/cargo-types.service';
|
||
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,
|
||
BookingEvaluationInput,
|
||
RuleEngineService,
|
||
} from '../rule-engine/rule-engine.service';
|
||
import {
|
||
containersPerWagonForSize,
|
||
wagonsPerUnitForSize,
|
||
} from '../rule-engine/container-type.util';
|
||
import { BookingsRepository } from './bookings.repository';
|
||
import { wagonRemainder } from './consolidation.service';
|
||
import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price-response.dto';
|
||
import { Booking } from './entities/booking.entity';
|
||
import { assertBookingStatus } from './booking-status.util';
|
||
import { ContainerValidationService } from './container-validation.service';
|
||
|
||
export interface OverweightLine {
|
||
containerTypeCode: string;
|
||
totalVgmTons: number;
|
||
maxAllowedTons: number;
|
||
excessTons: number;
|
||
}
|
||
|
||
export interface ComputedPriceResult {
|
||
lineItems: PriceLineItemDto[];
|
||
totalAmount: number;
|
||
currency: string;
|
||
usedRates: Rate[];
|
||
appliedModifiers: AppliedCargoModifier[];
|
||
priorityScore: number;
|
||
warnings: string[];
|
||
hardBlocked: string[];
|
||
overweightLines: OverweightLine[];
|
||
}
|
||
|
||
type StoredPricingBreakdown = {
|
||
lineItems?: PriceLineItemDto[];
|
||
totalAmount?: number;
|
||
currency?: string;
|
||
generatedAt?: string;
|
||
} | null;
|
||
|
||
/** Friendly labels for the per-unit rate card shown at the confirm step. */
|
||
const SURCHARGE_LABELS: Record<string, string> = {
|
||
HAZARD_SURCHARGE: 'Hazardous cargo',
|
||
HAZARDOUS_CARGO: 'Hazardous cargo',
|
||
REEFER_SURCHARGE: 'Refrigerated (reefer)',
|
||
REEFER_CARGO: 'Refrigerated (reefer)',
|
||
OVERWEIGHT_PER_TON: 'Overweight excess',
|
||
DOUBLE_HANDLING: 'Double handling',
|
||
LASHING: 'Lashing',
|
||
PIL_EXTRA_FEE: 'Shipping line fee',
|
||
};
|
||
|
||
function surchargeLabel(code: string): string {
|
||
return (
|
||
SURCHARGE_LABELS[code] ??
|
||
code
|
||
.toLowerCase()
|
||
.split('_')
|
||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
||
.join(' ')
|
||
);
|
||
}
|
||
|
||
@Injectable()
|
||
export class BookingPricingService {
|
||
constructor(
|
||
private readonly bookingsRepository: BookingsRepository,
|
||
private readonly ruleEngineService: RuleEngineService,
|
||
private readonly containerTypesService: ContainerTypesService,
|
||
private readonly ratesService: RatesService,
|
||
private readonly exchangeService: ExchangeService,
|
||
private readonly containerValidationService: ContainerValidationService,
|
||
private readonly cargoTypesService: CargoTypesService,
|
||
) {}
|
||
|
||
async generatePrice(bookingId: string): Promise<GeneratePriceResponseDto> {
|
||
const booking = await this.requireBooking(bookingId);
|
||
assertBookingStatus(booking, ['DRAFT', 'CHANGES_REQUESTED']);
|
||
|
||
const computed = await this.computePriceForBooking(booking);
|
||
this.ruleEngineService.assertNoHardBlocks({
|
||
priorityScore: computed.priorityScore,
|
||
appliedModifiers: computed.appliedModifiers,
|
||
containerWeightResults: [],
|
||
warnings: computed.warnings,
|
||
hardBlocked: computed.hardBlocked,
|
||
requiresDirectorApproval: false,
|
||
});
|
||
|
||
await this.bookingsRepository.update(bookingId, {
|
||
totalAmount: computed.totalAmount,
|
||
priorityScore: computed.priorityScore,
|
||
pricingBreakdown: {
|
||
lineItems: computed.lineItems,
|
||
totalAmount: computed.totalAmount,
|
||
currency: computed.currency,
|
||
generatedAt: new Date().toISOString(),
|
||
},
|
||
} as never);
|
||
|
||
// 20ft weight-pairing preview: surfaced now so the customer sees the problem
|
||
// (and the overweight warning + surcharge) at the confirm step, before submit.
|
||
// Submit re-runs this and HARD-BLOCKS on a non-empty result.
|
||
const pairing = await this.containerValidationService.validate20ftPairing(booking);
|
||
|
||
return {
|
||
bookingId,
|
||
totalAmount: computed.totalAmount,
|
||
currency: computed.currency,
|
||
lineItems: computed.lineItems,
|
||
warnings: computed.warnings,
|
||
overweightLines: computed.overweightLines,
|
||
pairingErrors: pairing.map((p) => p.message),
|
||
};
|
||
}
|
||
|
||
async computePriceForBooking(booking: Booking): Promise<ComputedPriceResult> {
|
||
const evalInput = await this.buildEvalInputForBooking(booking);
|
||
const ruleResult = await this.ruleEngineService.evaluate(evalInput);
|
||
|
||
const paymentCurrency = booking.paymentCurrency;
|
||
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,
|
||
warnings: baseWarnings,
|
||
blocked: baseBlocked,
|
||
} = await this.computeBaseRailLinesWithRates(booking, evalInput, frozenRates);
|
||
for (const line of baseLines) {
|
||
lineItems.push(line);
|
||
total += line.amount;
|
||
}
|
||
|
||
// 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, frozenRates);
|
||
for (const line of mileLines) {
|
||
lineItems.push(line);
|
||
total += line.amount;
|
||
}
|
||
|
||
const liveRates = await this.ratesService.findLiveRates();
|
||
const rateById = new Map(liveRates.map((r) => [r.id, r]));
|
||
const usedRatesMap = new Map([...baseRates, ...mileRates].map((r) => [r.id, r]));
|
||
|
||
for (const mod of ruleResult.appliedModifiers) {
|
||
const usdAmount = mod.calculatedAmount;
|
||
|
||
const rate = rateById.get(mod.rateId);
|
||
// Derived/route-matched charges (import overweight, empty-container
|
||
// return) carry their own unit price + billing unit — bill and display
|
||
// those, not whatever the referenced rate row says.
|
||
const isDerived = mod.unitPriceUsd != null;
|
||
const unit = mod.billingUnit ?? rate?.rateUnit ?? 'FLAT';
|
||
const unitUsd = isDerived
|
||
? Number(mod.unitPriceUsd)
|
||
: rate
|
||
? Number(rate.rateValue)
|
||
: usdAmount;
|
||
// 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 (the live unit price — a count, not a
|
||
// currency amount, so it is snapshot-independent).
|
||
const quantity =
|
||
unit === 'FLAT' || unit === 'PER_INVOICE'
|
||
? 1
|
||
: mod.triggerValue != null && mod.triggerValue > 0
|
||
? mod.triggerValue
|
||
: unitUsd > 0
|
||
? 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.
|
||
// Derived charges skip the snapshot — import overweight prices off the
|
||
// route's container freight, never a frozen OVERWEIGHT_PER_TON value.
|
||
const frozen = isDerived
|
||
? null
|
||
: 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),
|
||
amount: convertedAmount,
|
||
unitAmount,
|
||
unit,
|
||
quantity,
|
||
currency: paymentCurrency,
|
||
};
|
||
lineItems.push(item);
|
||
total += convertedAmount;
|
||
|
||
if (rate) usedRatesMap.set(rate.id, rate);
|
||
}
|
||
|
||
// Customs clearance service fee (Path B) — billed HERE, on the booking
|
||
// invoice with the freight; no separate prepaid clearance invoice. Sold per
|
||
// cargo kind: container bookings bill each container type's own fee (per
|
||
// box or per wagon), bulk bookings the route's bulk fee (per ton or per
|
||
// wagon). Frozen contract snapshots win over live rates; a customs booking
|
||
// with nothing configured hard-blocks — clearance never ships for free.
|
||
const clearanceBlocked: string[] = [];
|
||
if (booking.customsClearingEnabled) {
|
||
const clearance = await this.customsClearanceLines(booking, frozenRates, liveRates);
|
||
for (const line of clearance.lineItems) {
|
||
lineItems.push(line);
|
||
total += line.amount;
|
||
}
|
||
for (const rate of clearance.usedRates) usedRatesMap.set(rate.id, rate);
|
||
clearanceBlocked.push(...clearance.blocked);
|
||
}
|
||
|
||
// Overweight detail for the customer: map the engine's per-line results back
|
||
// to the booking's container lines (same order) for code + weights. maxAllowed
|
||
// is derived from the line total minus the excess the engine computed.
|
||
const overweightLines: OverweightLine[] = [];
|
||
const containerLines = (booking.bookingContainers ?? []).filter(
|
||
(bc) => bc.containerTypeId != null,
|
||
);
|
||
for (let i = 0; i < ruleResult.containerWeightResults.length; i++) {
|
||
const wr = ruleResult.containerWeightResults[i];
|
||
if (!wr?.isOverweight) continue;
|
||
const line = containerLines[i];
|
||
const totalVgmTons = Number(line?.totalVgmTons ?? 0);
|
||
const excessTons = Number(wr.overweightExcessTons ?? 0);
|
||
let code = line?.containerSize ?? '';
|
||
if (line?.containerTypeId) {
|
||
try {
|
||
code = (await this.containerTypesService.findById(line.containerTypeId)).code;
|
||
} catch {
|
||
// fall back to the container size label
|
||
}
|
||
}
|
||
overweightLines.push({
|
||
containerTypeCode: code,
|
||
totalVgmTons,
|
||
maxAllowedTons: Math.max(0, totalVgmTons - excessTons),
|
||
excessTons,
|
||
});
|
||
}
|
||
|
||
return {
|
||
lineItems,
|
||
totalAmount: total,
|
||
currency: booking.paymentCurrency,
|
||
usedRates: [...usedRatesMap.values()],
|
||
appliedModifiers: ruleResult.appliedModifiers,
|
||
priorityScore: ruleResult.priorityScore,
|
||
warnings: [...ruleResult.warnings, ...baseWarnings],
|
||
hardBlocked: [...ruleResult.hardBlocked, ...baseBlocked, ...clearanceBlocked],
|
||
overweightLines,
|
||
};
|
||
}
|
||
|
||
pricesMatch(stored: StoredPricingBreakdown, computed: ComputedPriceResult): boolean {
|
||
if (!stored?.lineItems?.length) return false;
|
||
if (Number(stored.totalAmount) !== computed.totalAmount) return false;
|
||
return (
|
||
this.lineItemsSignature(stored.lineItems) ===
|
||
this.lineItemsSignature(computed.lineItems)
|
||
);
|
||
}
|
||
|
||
async createPricingSnapshots(
|
||
bookingId: string,
|
||
usedRates: Rate[],
|
||
appliedModifiers: AppliedCargoModifier[],
|
||
): Promise<void> {
|
||
await this.bookingsRepository.clearPricingArtifacts(bookingId);
|
||
const snapshots = await this.ruleEngineService.snapshotRates(bookingId, usedRates);
|
||
|
||
const snapshotByRateId = new Map(snapshots.map((s) => [s.rateId, s.id]));
|
||
const rows = appliedModifiers
|
||
.map((m) => {
|
||
const snapshotId = snapshotByRateId.get(m.rateId);
|
||
if (!snapshotId) return null;
|
||
return {
|
||
bookingId,
|
||
rateId: m.rateId,
|
||
triggerValue: m.triggerValue,
|
||
calculatedAmount: m.calculatedAmount,
|
||
rateSnapshotId: snapshotId,
|
||
};
|
||
})
|
||
.filter((r): r is NonNullable<typeof r> => r !== null);
|
||
|
||
if (rows.length > 0) {
|
||
await this.bookingsRepository.createCargoModifiers(rows);
|
||
}
|
||
}
|
||
|
||
async buildEvalInputForBooking(booking: Booking): Promise<BookingEvaluationInput> {
|
||
const lines = await Promise.all(
|
||
(booking.bookingContainers ?? [])
|
||
.filter((bc): bc is typeof bc & { containerTypeId: string } => bc.containerTypeId != null)
|
||
.map(async (bc) => {
|
||
const ct = await this.containerTypesService.findById(bc.containerTypeId);
|
||
const vgm = Number(bc.vgmPerUnitTons);
|
||
const qty = bc.quantity;
|
||
return {
|
||
container: {
|
||
containerTypeId: bc.containerTypeId,
|
||
quantity: qty,
|
||
vgmPerUnitTons: vgm,
|
||
totalVgmTons: qty * vgm,
|
||
isReefer: ct.isReefer,
|
||
// Per-container opt-ins — PER_CONTAINER surcharges bill these.
|
||
hazardousQuantity: Number(bc.hazardousQuantity ?? 0),
|
||
reeferQuantity: Number(bc.reeferQuantity ?? 0),
|
||
returnQuantity: Number(bc.returnQuantity ?? 0),
|
||
// Wagon share per box — a PER_WAGON empty-return rate bills on it.
|
||
wagonsPerUnit: wagonsPerUnitForSize(ct.sizeFt),
|
||
},
|
||
perWagon: containersPerWagonForSize(ct.sizeFt),
|
||
quantity: qty,
|
||
};
|
||
}),
|
||
);
|
||
const containers = lines.map((l) => l.container);
|
||
// Wagon count is persisted per container line at booking creation; sum it.
|
||
const totalWagons =
|
||
booking.freightType === 'CONTAINER'
|
||
? Math.ceil(
|
||
(booking.bookingContainers ?? []).reduce(
|
||
(sum, bc) => sum + Number(bc.wagonsRequired ?? 0),
|
||
0,
|
||
),
|
||
)
|
||
: 0;
|
||
// Bulk wagon estimate for PER_WAGON kind-scoped surcharges (lashing).
|
||
// Deliberately NOT totalWagons — that would shift wagon-count priority
|
||
// scoring for bulk bookings.
|
||
const bulkWagons =
|
||
booking.freightType === 'BULK'
|
||
? ((await this.bulkWagonCount(booking)) ?? 0)
|
||
: 0;
|
||
|
||
// Consolidation is system-managed: the CONSOLIDATION surcharge fires whenever
|
||
// a container type leaves a wagon partially filled. Aggregate by type first —
|
||
// two lines of the same type share wagons, so 2× 20FT (= one full wagon) must
|
||
// NOT count as a partial wagon. Mirrors ConsolidationService.slotsFromContainerLines.
|
||
const remainderByType = new Map<string, { quantity: number; perWagon: number }>();
|
||
for (const l of lines) {
|
||
const prev = remainderByType.get(l.container.containerTypeId);
|
||
remainderByType.set(l.container.containerTypeId, {
|
||
quantity: (prev?.quantity ?? 0) + Number(l.quantity || 0),
|
||
perWagon: l.perWagon,
|
||
});
|
||
}
|
||
const allowConsolidation =
|
||
booking.freightType === 'CONTAINER' &&
|
||
[...remainderByType.values()].some(
|
||
(t) => wagonRemainder(t.quantity, t.perWagon) > 0,
|
||
);
|
||
|
||
return {
|
||
freightType: booking.freightType as 'CONTAINER' | 'BULK',
|
||
cargoTypeId: booking.cargoTypeId ?? null,
|
||
serviceTypeId: booking.serviceTypeId,
|
||
paymentCurrency: booking.paymentCurrency,
|
||
tradeDirection: booking.tradeDirection,
|
||
// Coerce defensively in case the stored flag is a string ("true"/"false").
|
||
isHazardous: booking.isHazardous === true || (booking.isHazardous as unknown) === 'true',
|
||
// Booking-level reefer flag (set by contract drawdown orders that carry a
|
||
// reefer quantity) applies the REEFER surcharge even for non-reefer
|
||
// container types. ORed with per-container reefer in the engine.
|
||
isReefer: booking.isReefer === true || (booking.isReefer as unknown) === 'true',
|
||
// Empty-container return service (container freight only) — bills the
|
||
// WITH_RETURN surcharge per container, like hazard/reefer.
|
||
withReturn:
|
||
booking.freightType === 'CONTAINER' &&
|
||
booking.equipmentReturn === 'WITH_RETURN',
|
||
isGovernment: booking.isGovernment,
|
||
allowConsolidation,
|
||
shippingLineId: booking.shippingLineId,
|
||
originYardId: booking.originYardId,
|
||
destinationYardId: booking.destinationYardId,
|
||
totalWagons,
|
||
// Bulk tonnage scales PER_TON surcharges (e.g. the bulk reefer surcharge).
|
||
// Container freight carries 0 here — its surcharges scale by container count.
|
||
bulkTons:
|
||
booking.freightType === 'BULK'
|
||
? Number(booking.cargoTotalWeightVgm ?? 0)
|
||
: 0,
|
||
bulkWagons,
|
||
containers,
|
||
};
|
||
}
|
||
|
||
private async requireBooking(id: string): Promise<Booking> {
|
||
const booking = await this.bookingsRepository.findByIdWithFiles(id);
|
||
if (!booking) throw new NotFoundException(`Booking ${id} not found`);
|
||
return booking;
|
||
}
|
||
|
||
/** Line items for contract schedule (uses stored breakdown or recomputes). */
|
||
async computeContractLineItems(booking: Booking): Promise<{
|
||
lineItems: PriceLineItemDto[];
|
||
totalAmount: number;
|
||
currency: string;
|
||
}> {
|
||
const stored = booking.pricingBreakdown as StoredPricingBreakdown;
|
||
|
||
if (stored?.lineItems?.length) {
|
||
return {
|
||
lineItems: stored.lineItems,
|
||
totalAmount: Number(stored.totalAmount ?? booking.totalAmount),
|
||
currency: stored.currency ?? booking.paymentCurrency,
|
||
};
|
||
}
|
||
|
||
const computed = await this.computePriceForBooking(booking);
|
||
|
||
if (computed.lineItems.length === 0) {
|
||
const total = Number(booking.totalAmount);
|
||
return {
|
||
lineItems: [
|
||
{
|
||
code: 'TOTAL',
|
||
description: 'Contract total',
|
||
amount: total,
|
||
unitAmount: total,
|
||
unit: 'FLAT',
|
||
quantity: 1,
|
||
currency: booking.paymentCurrency,
|
||
},
|
||
],
|
||
totalAmount: total,
|
||
currency: booking.paymentCurrency,
|
||
};
|
||
}
|
||
|
||
return {
|
||
lineItems: computed.lineItems,
|
||
totalAmount: computed.totalAmount || Number(booking.totalAmount),
|
||
currency: computed.currency,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Recompute priority on submit.
|
||
*
|
||
* The full priority model is additive and capped at 100:
|
||
* service-type bonus (≤ 15) + wagon block (≤ 50) + currency block (≤ 35).
|
||
* All three components are produced by RuleEngineService.evaluate, so submit
|
||
* simply re-runs the engine — there is no extra submit-time inflation.
|
||
*/
|
||
async computeSubmitPriorityScore(
|
||
booking: Booking,
|
||
totalWagonsOverride?: number,
|
||
): Promise<number> {
|
||
const evalInput = await this.buildEvalInputForBooking(booking);
|
||
// BULK bookings have no container lines, so buildEvalInputForBooking yields
|
||
// totalWagons = 0 and every wagon-range priority config misses. The batch
|
||
// engine derives a bulk booking's wagon footprint from tonnage vs. live
|
||
// wagon capacity and passes it here to score the booking properly.
|
||
if (totalWagonsOverride != null && totalWagonsOverride > 0) {
|
||
evalInput.totalWagons = totalWagonsOverride;
|
||
}
|
||
const ruleResult = await this.ruleEngineService.evaluate(evalInput);
|
||
return ruleResult.priorityScore;
|
||
}
|
||
|
||
private async computeBaseRailLinesWithRates(
|
||
booking: Booking,
|
||
evalInput: BookingEvaluationInput,
|
||
frozenRates: Map<string, ContractRateSnapshot> | null = null,
|
||
): Promise<{
|
||
lineItems: PriceLineItemDto[];
|
||
usedRates: Rate[];
|
||
warnings: string[];
|
||
blocked: string[];
|
||
}> {
|
||
const liveRates = await this.ratesService.findLiveRates();
|
||
const paymentCurrency = booking.paymentCurrency;
|
||
const isEtbBooking = paymentCurrency === 'ETB';
|
||
const usdToEtb = isEtbBooking ? await this.exchangeService.getRate('USD', 'ETB') : 1;
|
||
const isBulk = booking.freightType === 'BULK';
|
||
|
||
const rateType =
|
||
booking.tradeDirection === 'IMPORT'
|
||
? isBulk
|
||
? 'BULK_IMPORT'
|
||
: 'CONTAINER_IMPORT'
|
||
: booking.tradeDirection === 'EXPORT'
|
||
? isBulk
|
||
? 'BULK_EXPORT'
|
||
: 'CONTAINER_EXPORT'
|
||
: isBulk
|
||
? 'INTERCITY_BULK'
|
||
: 'INTERCITY_CONTAINER';
|
||
|
||
const lines: PriceLineItemDto[] = [];
|
||
const usedRatesMap = new Map<string, Rate>();
|
||
const warnings: string[] = [];
|
||
const blocked: string[] = [];
|
||
const wagonCount = await this.resolveWagonCount(booking);
|
||
|
||
for (const container of evalInput.containers) {
|
||
const rate = this.pickRate(
|
||
liveRates,
|
||
rateType,
|
||
container.containerTypeId,
|
||
'USD',
|
||
booking.originYardId,
|
||
booking.destinationYardId,
|
||
);
|
||
// H15: frozen contract rate for this container size, when present — its
|
||
// unitPrice is already in the booking currency (no USD→currency convert).
|
||
// It also stands on its own: a contract line prices off the agreed rate
|
||
// even when nobody configured a live rate for this leg + type yet.
|
||
const frozen = await this.frozenRateForContainer(
|
||
frozenRates,
|
||
container.containerTypeId,
|
||
paymentCurrency,
|
||
);
|
||
const label = await this.containerTypeLabel(container.containerTypeId);
|
||
if (!rate && !frozen) {
|
||
// Never price this line off another container type's (or another
|
||
// route's) rate, and never let an unpriced line through: a booking
|
||
// that ships a container type nobody configured a rate for would be
|
||
// carried for free. Hard-block instead — the customer drops the line
|
||
// or EDR configures the rate.
|
||
blocked.push(
|
||
`No ${rateType} rate is configured for ${label} on this route — ` +
|
||
`the booking cannot be priced. Remove the ${label} line or ask EDR ` +
|
||
'to configure its rate for this origin → destination.',
|
||
);
|
||
continue;
|
||
}
|
||
|
||
const rateUnit = rate?.rateUnit ?? 'PER_CONTAINER';
|
||
let amount: number;
|
||
let unitAmount: number;
|
||
if (frozen) {
|
||
unitAmount = Number(frozen.unitPrice);
|
||
amount = this.amountForUnit(
|
||
rateUnit,
|
||
unitAmount,
|
||
container.quantity,
|
||
wagonCount,
|
||
);
|
||
} else {
|
||
const unitUsd = Number(rate!.rateValue);
|
||
const usdAmount = this.amountForRate(rate!, container.quantity, wagonCount);
|
||
amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
|
||
unitAmount = isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd;
|
||
}
|
||
if (rate) usedRatesMap.set(rate.id, rate);
|
||
lines.push({
|
||
code: rateType,
|
||
description: `${label} rail freight`,
|
||
amount,
|
||
unitAmount,
|
||
unit: rateUnit,
|
||
quantity: this.effectiveUnitQuantity(rateUnit, container.quantity, wagonCount),
|
||
currency: paymentCurrency,
|
||
});
|
||
}
|
||
|
||
if (lines.length === 0 && evalInput.containers.length === 0) {
|
||
// Bulk (and any booking with no container lines) still has to price off a
|
||
// rate configured for this leg — never one belonging to another route.
|
||
// Container bookings never reach this fallback: their lines price per
|
||
// container type above or stay unpriced with a warning — falling back to
|
||
// a corridor rate of a DIFFERENT container type billed once (qty 1) is
|
||
// how a 38-container booking was invoiced 40 USD instead of 1900.
|
||
const fallback = liveRates.find(
|
||
(r) =>
|
||
r.rateType === rateType &&
|
||
r.currency === 'USD' &&
|
||
r.status === 'LIVE' &&
|
||
r.originYardId === booking.originYardId &&
|
||
r.destinationYardId === booking.destinationYardId,
|
||
);
|
||
if (fallback) {
|
||
usedRatesMap.set(fallback.id, fallback);
|
||
const bulkTons = Number(booking.cargoTotalWeightVgm ?? 0);
|
||
const quantity =
|
||
isBulk && fallback.rateUnit === 'PER_TON' ? Math.max(bulkTons, 0) : 1;
|
||
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,
|
||
unit: fallback.rateUnit,
|
||
quantity: this.effectiveUnitQuantity(fallback.rateUnit, quantity, wagonCount),
|
||
currency: paymentCurrency,
|
||
});
|
||
} else if (isBulk) {
|
||
// Same rule as container lines: bulk freight with no rate on this leg
|
||
// must not proceed unpriced.
|
||
blocked.push(
|
||
`No ${rateType} rate is configured for this route — the booking ` +
|
||
'cannot be priced. Ask EDR to configure the rate for this ' +
|
||
'origin → destination.',
|
||
);
|
||
}
|
||
}
|
||
|
||
return { lineItems: lines, usedRates: [...usedRatesMap.values()], warnings, blocked };
|
||
}
|
||
|
||
/**
|
||
* First-mile (pick-up) and last-mile (delivery) trucking lines. Each leg is
|
||
* billed only when the booking carries that leg (an address is set) and a LIVE
|
||
* rate exists, scaled by the rate's own unit:
|
||
* PER_KM → contract-route road distance (km)
|
||
* PER_CONTAINER → total container count
|
||
* PER_TON → total bulk tonnage
|
||
* FLAT → once
|
||
* A leg whose rate value (or computed amount) is 0 contributes nothing.
|
||
*/
|
||
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 }> = [
|
||
{
|
||
rateType: 'FIRST_MILE',
|
||
label: 'First mile (pick-up)',
|
||
active: Boolean(booking.firstMilePickupAddress),
|
||
},
|
||
{
|
||
rateType: 'LAST_MILE',
|
||
label: 'Last mile (delivery)',
|
||
active: Boolean(booking.lastMileDeliveryAddress),
|
||
},
|
||
];
|
||
if (!legs.some((l) => l.active)) {
|
||
return { lineItems: [], usedRates: [] };
|
||
}
|
||
|
||
const liveRates = await this.ratesService.findLiveRates();
|
||
const paymentCurrency = booking.paymentCurrency;
|
||
const isEtbBooking = paymentCurrency === 'ETB';
|
||
const usdToEtb = isEtbBooking ? await this.exchangeService.getRate('USD', 'ETB') : 1;
|
||
|
||
const containerCount = evalInput.containers.reduce(
|
||
(sum, c) => sum + Number(c.quantity || 0),
|
||
0,
|
||
);
|
||
const bulkTons = Number(booking.cargoTotalWeightVgm ?? 0);
|
||
const routeKm = await this.bookingsRepository.getContractRouteKm(booking.contractRouteId);
|
||
|
||
const lines: PriceLineItemDto[] = [];
|
||
const usedRatesMap = new Map<string, Rate>();
|
||
|
||
for (const leg of legs) {
|
||
if (!leg.active) continue;
|
||
const rate = liveRates.find(
|
||
(r) => r.rateType === leg.rateType && r.currency === 'USD' && r.status === 'LIVE',
|
||
);
|
||
if (!rate) continue;
|
||
|
||
const value = Number(rate.rateValue);
|
||
let quantity: number;
|
||
switch (rate.rateUnit) {
|
||
case 'PER_KM':
|
||
quantity = routeKm;
|
||
break;
|
||
case 'PER_CONTAINER':
|
||
quantity = containerCount;
|
||
break;
|
||
case 'PER_TON':
|
||
quantity = bulkTons;
|
||
break;
|
||
case 'FLAT':
|
||
default:
|
||
quantity = 1;
|
||
break;
|
||
}
|
||
|
||
// 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 (!(amount > 0)) continue;
|
||
|
||
usedRatesMap.set(rate.id, rate);
|
||
lines.push({
|
||
code: leg.rateType,
|
||
description: leg.label,
|
||
amount,
|
||
unitAmount,
|
||
unit: rate.rateUnit,
|
||
quantity,
|
||
currency: paymentCurrency,
|
||
});
|
||
}
|
||
|
||
return { lineItems: lines, usedRates: [...usedRatesMap.values()] };
|
||
}
|
||
|
||
/**
|
||
* Wagon count for PER_WAGON rates. A persisted booking uses the SQL aggregate;
|
||
* an unsaved preview booking (no id) sums the wagonsRequired already computed
|
||
* on its in-memory container lines — same math, no DB row needed.
|
||
*/
|
||
private async resolveWagonCount(booking: Booking): Promise<number> {
|
||
if (!booking.id) {
|
||
return Math.ceil(
|
||
(booking.bookingContainers ?? []).reduce(
|
||
(sum, bc) => sum + Number(bc.wagonsRequired ?? 0),
|
||
0,
|
||
),
|
||
);
|
||
}
|
||
return this.bookingsRepository.calculateWagonCount(booking.id);
|
||
}
|
||
|
||
/** Friendly container-type label for the per-unit card; degrades to "Container". */
|
||
private async containerTypeLabel(containerTypeId: string): Promise<string> {
|
||
try {
|
||
const ct = await this.containerTypesService?.findById?.(containerTypeId);
|
||
return ct?.label ?? 'Container';
|
||
} catch {
|
||
return 'Container';
|
||
}
|
||
}
|
||
|
||
/** How many units a rate's total is divided into, by rate unit (for the per-unit card). */
|
||
private effectiveUnitQuantity(
|
||
rateUnit: string,
|
||
quantity: number,
|
||
wagonCount: number,
|
||
): number {
|
||
switch (rateUnit) {
|
||
case 'PER_WAGON':
|
||
return wagonCount;
|
||
case 'FLAT':
|
||
return 1;
|
||
case 'PER_CONTAINER':
|
||
case 'PER_TON':
|
||
default:
|
||
return quantity;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Base freight is quoted per leg, so a rate only applies to a booking running
|
||
* the exact origin → destination it was configured for. There is deliberately
|
||
* no route-agnostic fallback: charging a Dire Dawa price for a Mojo shipment
|
||
* because nobody configured Mojo yet is worse than surfacing no line at all.
|
||
* Within the leg, a rate scoped to the container type wins over one that
|
||
* covers every type.
|
||
*/
|
||
private pickRate(
|
||
rates: Rate[],
|
||
rateType: string,
|
||
containerTypeId: string,
|
||
currency: string,
|
||
originYardId: string,
|
||
destinationYardId: string,
|
||
): Rate | undefined {
|
||
const onLeg = rates.filter(
|
||
(r) =>
|
||
r.rateType === rateType &&
|
||
r.currency === currency &&
|
||
r.originYardId === originYardId &&
|
||
r.destinationYardId === destinationYardId,
|
||
);
|
||
return (
|
||
onLeg.find((r) => r.containerTypeId === containerTypeId) ??
|
||
onLeg.find((r) => !r.containerTypeId)
|
||
);
|
||
}
|
||
|
||
private amountForRate(rate: Rate, quantity: number, wagonCount: number): number {
|
||
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 unitValue * quantity;
|
||
case 'PER_WAGON':
|
||
return unitValue * wagonCount;
|
||
case 'PER_TON':
|
||
return unitValue * quantity;
|
||
case 'FLAT':
|
||
return unitValue;
|
||
default:
|
||
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);
|
||
}
|
||
|
||
/**
|
||
* Customs clearance service fee lines for a customs booking (Path B), billed
|
||
* with the freight. Container bookings bill each container line at its own
|
||
* container type's fee — PER_CONTAINER × boxes or PER_WAGON × the wagons the
|
||
* line occupies (two 20ft share one). Bulk bookings bill the route's type-less
|
||
* fee — PER_TON × tonnage or PER_WAGON × wagons the bulk occupies. Frozen
|
||
* contract snapshots (CUSTOMS_CLEARANCE_20FT / _40FT / CUSTOMS_CLEARANCE)
|
||
* win over live rates; contracts frozen before the per-kind model carry one
|
||
* FLAT CUSTOMS_CLEARANCE snapshot, honoured once for the whole booking.
|
||
*/
|
||
private async customsClearanceLines(
|
||
booking: Booking,
|
||
frozenRates: Map<string, ContractRateSnapshot> | null,
|
||
liveRates: Rate[],
|
||
): Promise<{ lineItems: PriceLineItemDto[]; usedRates: Rate[]; blocked: string[] }> {
|
||
const lineItems: PriceLineItemDto[] = [];
|
||
const usedRates: Rate[] = [];
|
||
const blocked: string[] = [];
|
||
const currency = booking.paymentCurrency;
|
||
const isEtb = currency === 'ETB';
|
||
const usdToEtb = isEtb ? await this.exchangeService.getRate('USD', 'ETB') : 1;
|
||
const convert = (usd: number): number => (isEtb ? Math.round(usd * usdToEtb) : usd);
|
||
|
||
const onLeg = liveRates.filter(
|
||
(r) =>
|
||
r.rateType === 'CUSTOMS_CLEARANCE' &&
|
||
r.currency === 'USD' &&
|
||
r.tradeDirection === booking.tradeDirection &&
|
||
r.originYardId === booking.originYardId &&
|
||
r.destinationYardId === booking.destinationYardId,
|
||
);
|
||
const missingRateMessage = (scope: string): string =>
|
||
`No customs clearance service fee is configured for ${scope} on this ` +
|
||
'origin → destination. Ask EDR to configure the CUSTOMS_CLEARANCE rate for this route.';
|
||
|
||
if (booking.freightType === 'CONTAINER') {
|
||
// Legacy short-circuit: an old contract froze one flat fee — bill it once.
|
||
const hasPerSizeSnapshot =
|
||
frozenRates?.has('CUSTOMS_CLEARANCE_20FT') ||
|
||
frozenRates?.has('CUSTOMS_CLEARANCE_40FT');
|
||
const legacyFlat = this.frozenRateByCode(frozenRates, 'CUSTOMS_CLEARANCE', currency);
|
||
if (legacyFlat && !hasPerSizeSnapshot) {
|
||
const amount = Number(legacyFlat.unitPrice);
|
||
if (amount > 0) {
|
||
lineItems.push({
|
||
code: 'CUSTOMS_CLEARANCE',
|
||
description: 'Customs clearance service',
|
||
amount,
|
||
unitAmount: amount,
|
||
unit: 'FLAT',
|
||
quantity: 1,
|
||
currency,
|
||
});
|
||
}
|
||
return { lineItems, usedRates, blocked };
|
||
}
|
||
|
||
for (const bc of booking.bookingContainers ?? []) {
|
||
if (!bc.containerTypeId) continue;
|
||
const qty = Number(bc.quantity || 0);
|
||
if (!(qty > 0)) continue;
|
||
let sizeFt = 0;
|
||
try {
|
||
sizeFt =
|
||
Number((await this.containerTypesService.findById(bc.containerTypeId)).sizeFt) || 0;
|
||
} catch {
|
||
// unknown type — falls through to the live per-type lookup below
|
||
}
|
||
const frozen = sizeFt
|
||
? this.frozenRateByCode(frozenRates, `CUSTOMS_CLEARANCE_${sizeFt}FT`, currency)
|
||
: null;
|
||
const live = onLeg.find((r) => r.containerTypeId === bc.containerTypeId);
|
||
if (!frozen && !live) {
|
||
blocked.push(missingRateMessage(`${sizeFt || '?'}ft containers`));
|
||
continue;
|
||
}
|
||
const unit = frozen
|
||
? this.rateUnitFromSnapshot(frozen.unitOfMeasure)
|
||
: live!.rateUnit;
|
||
const unitAmount = frozen
|
||
? Number(frozen.unitPrice)
|
||
: convert(Number(live!.rateValue));
|
||
const billedQty =
|
||
unit === 'PER_WAGON' ? Math.ceil(qty * wagonsPerUnitForSize(sizeFt)) : qty;
|
||
const amount = unit === 'FLAT' ? unitAmount : unitAmount * billedQty;
|
||
if (!(amount > 0)) continue;
|
||
lineItems.push({
|
||
code: sizeFt ? `CUSTOMS_CLEARANCE_${sizeFt}FT` : 'CUSTOMS_CLEARANCE',
|
||
description: `Customs clearance service${sizeFt ? ` (${sizeFt}ft)` : ''}`,
|
||
amount,
|
||
unitAmount,
|
||
unit,
|
||
quantity: unit === 'FLAT' ? 1 : billedQty,
|
||
currency,
|
||
});
|
||
if (live && !frozen) usedRates.push(live);
|
||
}
|
||
return { lineItems, usedRates, blocked };
|
||
}
|
||
|
||
// Bulk — one fee for the whole booking. The bulk snapshot and the legacy
|
||
// flat snapshot share the CUSTOMS_CLEARANCE code; both are the agreed fee.
|
||
const frozen = this.frozenRateByCode(frozenRates, 'CUSTOMS_CLEARANCE', currency);
|
||
const live = onLeg.find((r) => !r.containerTypeId);
|
||
if (!frozen && !live) {
|
||
blocked.push(missingRateMessage('bulk cargo'));
|
||
return { lineItems, usedRates, blocked };
|
||
}
|
||
const unit = frozen ? this.rateUnitFromSnapshot(frozen.unitOfMeasure) : live!.rateUnit;
|
||
const unitAmount = frozen ? Number(frozen.unitPrice) : convert(Number(live!.rateValue));
|
||
let billedQty = 1;
|
||
if (unit === 'PER_TON') {
|
||
billedQty = Math.max(0, Number(booking.cargoTotalWeightVgm ?? 0));
|
||
} else if (unit === 'PER_WAGON') {
|
||
const wagons = await this.bulkWagonCount(booking);
|
||
if (wagons == null) {
|
||
blocked.push(
|
||
'The bulk customs clearance fee is per wagon, but this cargo type has ' +
|
||
'no wagon type with a capacity configured — the wagon count cannot ' +
|
||
'be derived. Ask EDR to configure the cargo type’s wagon types.',
|
||
);
|
||
return { lineItems, usedRates, blocked };
|
||
}
|
||
billedQty = wagons;
|
||
}
|
||
const amount = unit === 'FLAT' ? unitAmount : unitAmount * billedQty;
|
||
if (amount > 0) {
|
||
lineItems.push({
|
||
code: 'CUSTOMS_CLEARANCE',
|
||
description: 'Customs clearance service (bulk)',
|
||
amount,
|
||
unitAmount,
|
||
unit,
|
||
quantity: unit === 'FLAT' ? 1 : billedQty,
|
||
currency,
|
||
});
|
||
if (live && !frozen) usedRates.push(live);
|
||
}
|
||
return { lineItems, usedRates, blocked };
|
||
}
|
||
|
||
/** Snapshot unit-of-measure → the rate unit the billing math applies. */
|
||
private rateUnitFromSnapshot(unitOfMeasure: string): string {
|
||
switch (unitOfMeasure) {
|
||
case 'per_wagon':
|
||
return 'PER_WAGON';
|
||
case 'per_ton':
|
||
return 'PER_TON';
|
||
case 'per_container':
|
||
return 'PER_CONTAINER';
|
||
default:
|
||
return 'FLAT';
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Wagons a bulk booking occupies — ceil(tons ÷ rated capacity), using the
|
||
* largest-capacity wagon type its cargo type allows. Null when the chain is
|
||
* unconfigured (no cargo type, no wagon types, no capacity).
|
||
* ponytail: pricing-time estimate off the biggest allowed wagon; scheduling
|
||
* may stock a smaller type and use more wagons.
|
||
*/
|
||
private async bulkWagonCount(booking: Booking): Promise<number | null> {
|
||
const tons = Number(booking.cargoTotalWeightVgm ?? 0);
|
||
if (!(tons > 0) || !booking.cargoTypeId) return null;
|
||
try {
|
||
const cargo = await this.cargoTypesService.findById(booking.cargoTypeId);
|
||
const capacity = Math.max(
|
||
0,
|
||
...(cargo.wagonTypes ?? []).map((w) => Number(w.capacityTons) || 0),
|
||
);
|
||
if (!(capacity > 0)) return null;
|
||
return Math.max(1, Math.ceil(tons / capacity));
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
private lineItemsSignature(items: PriceLineItemDto[]): string {
|
||
return JSON.stringify(
|
||
[...items]
|
||
.map((item) => ({
|
||
code: item.code,
|
||
amount: item.amount,
|
||
currency: item.currency,
|
||
}))
|
||
.sort((a, b) => a.code.localeCompare(b.code)),
|
||
);
|
||
}
|
||
}
|