mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
508 lines
20 KiB
TypeScript
508 lines
20 KiB
TypeScript
import { Injectable, UnprocessableEntityException } from '@nestjs/common';
|
||
|
||
import { RatesService } from '../rule-engine/services/rates.service';
|
||
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
||
import { round2 } from '../billing/invoice-settlement.util';
|
||
import { ExchangeService } from '@edr/api-common';
|
||
import { ContractsRepository } from './contracts.repository';
|
||
import { Contract } from './entities/contract.entity';
|
||
|
||
/** A single unit-rate line at contract phase — NO quantities, NO totals. */
|
||
export interface ContractUnitRateLineItem {
|
||
code: string;
|
||
label: string;
|
||
unit:
|
||
| 'per_container'
|
||
| 'per_wagon'
|
||
| 'per_ton'
|
||
| 'per_item'
|
||
| 'per_km'
|
||
| 'per_liter'
|
||
| 'flat';
|
||
unitPrice: number;
|
||
containerSize?: string | null;
|
||
conditionalOn?: string | null;
|
||
cargoTypeCode?: string | null;
|
||
/**
|
||
* Customs clearance service fee — billed separately in advance (before the
|
||
* clearance document step), never part of shipment booking totals.
|
||
*/
|
||
isClearance?: boolean;
|
||
}
|
||
|
||
/** The contract `pricing_breakdown` shape (doc §9.1). */
|
||
export interface ContractPricingBreakdown {
|
||
displayMode: 'UNIT_RATES';
|
||
currency: string;
|
||
lineItems: ContractUnitRateLineItem[];
|
||
generatedAt: string;
|
||
}
|
||
|
||
/** Map a rate's storage unit to the contract-display unit. */
|
||
function toContractUnit(rateUnit: string): ContractUnitRateLineItem['unit'] {
|
||
switch (rateUnit) {
|
||
case 'PER_TON':
|
||
return 'per_ton';
|
||
case 'PER_ITEM':
|
||
return 'per_item';
|
||
case 'PER_KM':
|
||
return 'per_km';
|
||
case 'PER_WAGON':
|
||
return 'per_wagon';
|
||
case 'PER_CONTAINER':
|
||
return 'per_container';
|
||
case 'PER_LITER':
|
||
return 'per_liter';
|
||
default:
|
||
return 'flat';
|
||
}
|
||
}
|
||
|
||
@Injectable()
|
||
export class ContractPricingService {
|
||
constructor(
|
||
private readonly contractsRepository: ContractsRepository,
|
||
private readonly ratesService: RatesService,
|
||
private readonly containerTypesService: ContainerTypesService,
|
||
private readonly exchangeService: ExchangeService,
|
||
) {}
|
||
|
||
/** Base rail rate type for the contract's direction + freight. */
|
||
private baseRateType(contract: Contract): string {
|
||
const isBulk = contract.freightType === 'BULK';
|
||
if (contract.tradeDirection === 'IMPORT') {
|
||
return isBulk ? 'BULK_IMPORT' : 'CONTAINER_IMPORT';
|
||
}
|
||
if (contract.tradeDirection === 'EXPORT') {
|
||
return isBulk ? 'BULK_EXPORT' : 'CONTAINER_EXPORT';
|
||
}
|
||
return isBulk ? 'INTERCITY_BULK' : 'INTERCITY_CONTAINER';
|
||
}
|
||
|
||
/**
|
||
* Build the unit-rate breakdown from live rates. Emits per-unit prices only
|
||
* (one per container size, conditional hazard/reefer surcharges, and bulk
|
||
* commodity rate) — NO totals or quantities (doc §9.1).
|
||
*/
|
||
async buildBreakdown(contract: Contract): Promise<ContractPricingBreakdown> {
|
||
const liveRates = await this.ratesService.findLiveRates();
|
||
const currency = contract.paymentCurrency;
|
||
const isEtb = currency === 'ETB';
|
||
const usdToEtb = isEtb ? await this.exchangeService.getRate('USD', 'ETB') : 1;
|
||
const convert = (usd: number): number => (isEtb ? round2(usd * usdToEtb) : usd);
|
||
|
||
const lineItems: ContractUnitRateLineItem[] = [];
|
||
const baseType = this.baseRateType(contract);
|
||
|
||
// Base rail freight is quoted per route (CK_rates_yard_scope) — only rates
|
||
// on the contract's own lane may price it. Matching without the yard filter
|
||
// is how a DCT → Sebeta contract froze DCT → GMP (Indode) prices, and the
|
||
// frozen snapshot then bills bookings that the route-scoped booking lookup
|
||
// would have hard-blocked (CTR-2026-00065).
|
||
// ponytail: multi-route contracts price the first lane (same as customs
|
||
// clearance below); per-lane pricing needs per-route breakdowns.
|
||
const route = [...(contract.routes ?? [])].sort(
|
||
(a, b) => a.sortOrder - b.sortOrder,
|
||
)[0];
|
||
const onLane = route
|
||
? liveRates.filter(
|
||
(r) =>
|
||
r.rateType === baseType &&
|
||
r.currency === 'USD' &&
|
||
r.originYardId === route.originYardId &&
|
||
r.destinationYardId === route.destinationYardId,
|
||
)
|
||
: [];
|
||
|
||
if (contract.freightType === 'CONTAINER') {
|
||
const sizes = (contract.cargoScope ?? [])
|
||
.map((c) => c.containerSize)
|
||
.filter((s): s is string => !!s);
|
||
const { items: containerTypes } = await this.containerTypesService.findAll({
|
||
isActive: true,
|
||
pageSize: 100,
|
||
});
|
||
for (const size of sizes) {
|
||
const sizeFt = size === '40ft' ? 40 : 20;
|
||
const matchedTypes = containerTypes.filter((ct) => ct.sizeFt === sizeFt);
|
||
const matchedIds = new Set(matchedTypes.map((ct) => ct.id));
|
||
const rate =
|
||
onLane.find(
|
||
(r) => r.containerTypeId && matchedIds.has(r.containerTypeId),
|
||
) ?? onLane.find((r) => !r.containerTypeId);
|
||
if (!rate || Number(rate.rateValue) <= 0) {
|
||
throw new UnprocessableEntityException(
|
||
`No rail freight rate is configured for ${size} containers on this direction and route — the contract cannot be priced. Ask the rates team to set a live ${baseType} rate for this container type and origin → destination.`,
|
||
);
|
||
}
|
||
lineItems.push({
|
||
code: `CONTAINER_${size.toUpperCase()}`,
|
||
label: `${size} container`,
|
||
unit: toContractUnit(rate.rateUnit),
|
||
unitPrice: convert(Number(rate.rateValue)),
|
||
containerSize: size,
|
||
});
|
||
}
|
||
} else {
|
||
const cargoScope = (contract.cargoScope ?? []).find((c) => c.cargoTypeId);
|
||
// Freeze the rate for the contract's own commodity when one is configured
|
||
// — a per-item machinery rate and a per-ton wheat rate live side by side.
|
||
// No arbitrary-rate fallback: another commodity's rate must never price
|
||
// this contract.
|
||
const bulkRate =
|
||
(cargoScope?.cargoTypeId
|
||
? onLane.find((r) => r.cargoTypeId === cargoScope.cargoTypeId)
|
||
: undefined) ??
|
||
onLane.find((r) => !r.cargoTypeId) ??
|
||
null;
|
||
if (!bulkRate || Number(bulkRate.rateValue) <= 0) {
|
||
throw new UnprocessableEntityException(
|
||
'No bulk rail freight rate is configured for this cargo type on this direction and route — the contract cannot be priced. Ask the rates team to set a live rate for this commodity and origin → destination.',
|
||
);
|
||
}
|
||
lineItems.push({
|
||
code: 'BULK_FREIGHT',
|
||
label: cargoScope?.cargoType?.cargoTypeName ?? 'Bulk cargo',
|
||
unit: toContractUnit(bulkRate.rateUnit),
|
||
unitPrice: convert(Number(bulkRate.rateValue)),
|
||
cargoTypeCode: cargoScope?.cargoType?.code ?? null,
|
||
});
|
||
}
|
||
|
||
// First / last mile trucking unit rates — shown when the contract carries
|
||
// that leg. Per-unit prices only; the actual amount (× km / containers /
|
||
// tons / flat) is computed at booking time.
|
||
if (contract.firstMilePickupAddress) {
|
||
const fm = liveRates.find(
|
||
(r) => r.rateType === 'FIRST_MILE' && r.currency === 'USD',
|
||
);
|
||
if (fm && Number(fm.rateValue) > 0) {
|
||
lineItems.push({
|
||
code: 'FIRST_MILE',
|
||
label: 'First mile (pick-up)',
|
||
unit: toContractUnit(fm.rateUnit),
|
||
unitPrice: convert(Number(fm.rateValue)),
|
||
});
|
||
}
|
||
}
|
||
if (contract.lastMileDeliveryAddress) {
|
||
// New-style last-mile rates (PER_TON_KM / distance-banded PER_KM) are
|
||
// priced operationally per job, not as a single contract unit price.
|
||
const lm = liveRates.find(
|
||
(r) =>
|
||
r.rateType === 'LAST_MILE' &&
|
||
r.currency === 'USD' &&
|
||
r.rateUnit !== 'PER_TON_KM' &&
|
||
r.minKm == null,
|
||
);
|
||
if (lm && Number(lm.rateValue) > 0) {
|
||
lineItems.push({
|
||
code: 'LAST_MILE',
|
||
label: 'Last mile (delivery)',
|
||
unit: toContractUnit(lm.rateUnit),
|
||
unitPrice: convert(Number(lm.rateValue)),
|
||
});
|
||
}
|
||
}
|
||
|
||
// Conditional surcharges — shown only when the contract toggles them on AND
|
||
// the rate has a non-zero value (a 0 rate means "no surcharge").
|
||
if (contract.isHazardous) {
|
||
const hazard = liveRates.find(
|
||
(r) => r.rateType === 'HAZARD_SURCHARGE' && r.currency === 'USD',
|
||
);
|
||
if (hazard && Number(hazard.rateValue) > 0) {
|
||
lineItems.push({
|
||
code: 'HAZARD_SURCHARGE',
|
||
label: 'Hazardous surcharge',
|
||
unit: toContractUnit(hazard.rateUnit),
|
||
unitPrice: convert(Number(hazard.rateValue)),
|
||
conditionalOn: 'is_hazardous',
|
||
});
|
||
}
|
||
}
|
||
if (contract.isReefer) {
|
||
const reefer = liveRates.find(
|
||
(r) => r.rateType === 'REEFER_SURCHARGE' && r.currency === 'USD',
|
||
);
|
||
if (reefer && Number(reefer.rateValue) > 0) {
|
||
lineItems.push({
|
||
code: 'REEFER_SURCHARGE',
|
||
label: 'Reefer surcharge',
|
||
unit: toContractUnit(reefer.rateUnit),
|
||
unitPrice: convert(Number(reefer.rateValue)),
|
||
conditionalOn: 'is_reefer',
|
||
});
|
||
}
|
||
}
|
||
// Overweight surcharge — EXPORT contracts freeze the OVERWEIGHT_PER_TON
|
||
// rate so booking pricing bills the contract's price on excess tons
|
||
// (frozenRateByCode wins over the live rate). Always included, no toggle:
|
||
// overweight is system-detected at booking, never customer-opted. IMPORT
|
||
// never reads this snapshot — its overweight price derives from the
|
||
// route's base container freight (see RuleEngineService).
|
||
if (contract.tradeDirection === 'EXPORT') {
|
||
const overweight = liveRates.find(
|
||
(r) => r.trigger === 'OVERWEIGHT' && r.currency === 'USD',
|
||
);
|
||
if (overweight && Number(overweight.rateValue) > 0) {
|
||
lineItems.push({
|
||
code: 'OVERWEIGHT_PER_TON',
|
||
label: 'Overweight surcharge (per excess ton)',
|
||
unit: toContractUnit(overweight.rateUnit),
|
||
unitPrice: convert(Number(overweight.rateValue)),
|
||
conditionalOn: 'is_overweight',
|
||
});
|
||
}
|
||
}
|
||
// Lashing / cargo securing — BULK only, shown when the contract's commodity
|
||
// needs lashing (cargoType.hasLashing). The commodity-scoped rate for the
|
||
// contract's direction wins over the commodity-wide catch-all; billed at
|
||
// booking on the live rate (per ton / per wagon), this line is display.
|
||
if (contract.freightType === 'BULK') {
|
||
const scope = (contract.cargoScope ?? []).find((c) => c.cargoTypeId);
|
||
if (scope?.cargoType?.hasLashing) {
|
||
const onDirection = liveRates.filter(
|
||
(r) =>
|
||
r.trigger === 'LASHING' &&
|
||
r.currency === 'USD' &&
|
||
!r.containerTypeId &&
|
||
r.tradeDirection === contract.tradeDirection,
|
||
);
|
||
const lashing =
|
||
onDirection.find((r) => r.cargoTypeId === scope.cargoTypeId) ??
|
||
onDirection.find((r) => !r.cargoTypeId);
|
||
if (lashing && Number(lashing.rateValue) > 0) {
|
||
lineItems.push({
|
||
code: 'LASHING',
|
||
label: `Lashing / cargo securing (${scope.cargoType.cargoTypeName})`,
|
||
unit: toContractUnit(lashing.rateUnit),
|
||
unitPrice: convert(Number(lashing.rateValue)),
|
||
cargoTypeCode: scope.cargoType.code ?? null,
|
||
conditionalOn: 'has_lashing',
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
// Fuel surcharge — shown when the contract's commodity incurs fuel
|
||
// (cargoType.hasFuel), sold per lane + commodity. Billed at booking on the
|
||
// frozen/live rate (per wagon × wagons, or per liter × base liters, once);
|
||
// this line freezes the agreed unit price.
|
||
{
|
||
const scope = (contract.cargoScope ?? []).find((c) => c.cargoTypeId);
|
||
if (scope?.cargoType?.hasFuel && route) {
|
||
const fuel = liveRates.find(
|
||
(r) =>
|
||
r.trigger === 'FUEL' &&
|
||
r.currency === 'USD' &&
|
||
r.tradeDirection === contract.tradeDirection &&
|
||
r.originYardId === route.originYardId &&
|
||
r.destinationYardId === route.destinationYardId &&
|
||
r.cargoTypeId === scope.cargoTypeId,
|
||
);
|
||
if (fuel && Number(fuel.rateValue) > 0) {
|
||
// Per-liter collapses to one flat total (base liters × rate value) —
|
||
// the customer only sees the final price, and booking pricing bills
|
||
// the same flat figure once (see RuleEngineService.fuelCharges).
|
||
const perLiter = fuel.rateUnit === 'PER_LITER';
|
||
const total = perLiter
|
||
? Number(fuel.baseLiters ?? 0) * Number(fuel.rateValue)
|
||
: Number(fuel.rateValue);
|
||
lineItems.push({
|
||
code: 'FUEL_SURCHARGE',
|
||
label: `Fuel surcharge (${scope.cargoType.cargoTypeName})`,
|
||
unit: perLiter ? 'flat' : toContractUnit(fuel.rateUnit),
|
||
unitPrice: convert(total),
|
||
cargoTypeCode: scope.cargoType.code ?? null,
|
||
conditionalOn: 'has_fuel',
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
// Empty-container return service — container contracts only, toggled on the
|
||
// contract like hazard/reefer. Billed at booking per WITH_RETURN container.
|
||
if (
|
||
contract.freightType === 'CONTAINER' &&
|
||
contract.equipmentReturn === 'WITH_RETURN'
|
||
) {
|
||
// Return is sold per direction + route + container type (import-only) —
|
||
// one display line per contract size that has a configured rate. A size
|
||
// with no rate shows nothing here and hard-blocks at booking time.
|
||
// ponytail: bookings bill the live route rate, not a frozen snapshot.
|
||
const onLeg = route
|
||
? liveRates.filter(
|
||
(r) =>
|
||
r.rateType === 'RETURN_SURCHARGE' &&
|
||
r.currency === 'USD' &&
|
||
r.tradeDirection === contract.tradeDirection &&
|
||
r.originYardId === route.originYardId &&
|
||
r.destinationYardId === route.destinationYardId,
|
||
)
|
||
: [];
|
||
if (onLeg.length > 0) {
|
||
const sizes = (contract.cargoScope ?? [])
|
||
.map((c) => c.containerSize)
|
||
.filter((s): s is string => !!s);
|
||
const { items: containerTypes } = await this.containerTypesService.findAll({
|
||
isActive: true,
|
||
pageSize: 100,
|
||
});
|
||
for (const size of sizes) {
|
||
const sizeFt = size === '40ft' ? 40 : 20;
|
||
const matchedIds = new Set(
|
||
containerTypes.filter((ct) => ct.sizeFt === sizeFt).map((ct) => ct.id),
|
||
);
|
||
const rate =
|
||
onLeg.find((r) => r.containerTypeId && matchedIds.has(r.containerTypeId)) ??
|
||
onLeg.find((r) => !r.containerTypeId);
|
||
if (!rate || Number(rate.rateValue) <= 0) continue;
|
||
lineItems.push({
|
||
code: 'RETURN_SURCHARGE',
|
||
label: `Empty container return (${size})`,
|
||
unit: toContractUnit(rate.rateUnit),
|
||
unitPrice: convert(Number(rate.rateValue)),
|
||
containerSize: size,
|
||
conditionalOn: 'with_return',
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
// Customs clearance service fee (Path B) — billed on the booking invoice
|
||
// together with the freight. Sold per direction + route + cargo kind:
|
||
// container contracts freeze one fee line per contract size (each size's
|
||
// own container-type rate), bulk contracts freeze the route's bulk fee.
|
||
// A customs contract may not proceed without the fee(s) configured.
|
||
if (contract.customsClearingEnabled) {
|
||
// An Ethiopian-side-only customs service prices off its own rate; the
|
||
// snapshot codes carry the same prefix so booking pricing finds them.
|
||
const customsType = contract.serviceType?.includesEthiopianCustomsOnly
|
||
? 'ETHIOPIAN_CUSTOMS_CLEARANCE'
|
||
: 'CUSTOMS_CLEARANCE';
|
||
const customsLabel =
|
||
customsType === 'ETHIOPIAN_CUSTOMS_CLEARANCE'
|
||
? 'Ethiopian customs clearance service'
|
||
: 'Customs clearance service';
|
||
// Strict, no route-less fallback.
|
||
// ponytail: multi-route contracts bill the first lane's fee; per-lane fees need per-route snapshots.
|
||
const onLeg = route
|
||
? liveRates.filter(
|
||
(r) =>
|
||
r.rateType === customsType &&
|
||
r.currency === 'USD' &&
|
||
r.tradeDirection === contract.tradeDirection &&
|
||
r.originYardId === route.originYardId &&
|
||
r.destinationYardId === route.destinationYardId,
|
||
)
|
||
: [];
|
||
if (contract.freightType === 'CONTAINER') {
|
||
const sizes = (contract.cargoScope ?? [])
|
||
.map((c) => c.containerSize)
|
||
.filter((s): s is string => !!s);
|
||
const { items: containerTypes } = await this.containerTypesService.findAll({
|
||
isActive: true,
|
||
pageSize: 100,
|
||
});
|
||
for (const size of sizes) {
|
||
const sizeFt = size === '40ft' ? 40 : 20;
|
||
const matchedIds = new Set(
|
||
containerTypes.filter((ct) => ct.sizeFt === sizeFt).map((ct) => ct.id),
|
||
);
|
||
const rate = onLeg.find(
|
||
(r) => r.containerTypeId && matchedIds.has(r.containerTypeId),
|
||
);
|
||
if (!rate || Number(rate.rateValue) <= 0) {
|
||
throw new UnprocessableEntityException(
|
||
`No customs clearance service fee is configured for ${size} containers on this direction and route. Ask the rates team to set a live ${customsType} rate for this container type and origin → destination.`,
|
||
);
|
||
}
|
||
lineItems.push({
|
||
// Distinct code per size so the frozen snapshots don't collide —
|
||
// booking pricing looks each size up by <customsType>_<FT>FT.
|
||
code: `${customsType}_${sizeFt}FT`,
|
||
label: `${customsLabel} (${size})`,
|
||
unit: toContractUnit(rate.rateUnit),
|
||
unitPrice: convert(Number(rate.rateValue)),
|
||
containerSize: size,
|
||
isClearance: true,
|
||
});
|
||
}
|
||
} else {
|
||
// Bulk fee — the rate scoped to the contract's commodity wins; a
|
||
// commodity-less rate (legacy) is the catch-all fallback.
|
||
const scope = (contract.cargoScope ?? []).find((c) => c.cargoTypeId);
|
||
const rate =
|
||
(scope?.cargoTypeId
|
||
? onLeg.find(
|
||
(r) => !r.containerTypeId && r.cargoTypeId === scope.cargoTypeId,
|
||
)
|
||
: undefined) ?? onLeg.find((r) => !r.containerTypeId && !r.cargoTypeId);
|
||
if (!rate || Number(rate.rateValue) <= 0) {
|
||
throw new UnprocessableEntityException(
|
||
`No bulk customs clearance service fee is configured for this cargo type on this direction and route. Ask the rates team to set a live bulk ${customsType} rate for this commodity and origin → destination.`,
|
||
);
|
||
}
|
||
lineItems.push({
|
||
code: customsType,
|
||
label: `${customsLabel} (${scope?.cargoType?.cargoTypeName ?? 'bulk'})`,
|
||
unit: toContractUnit(rate.rateUnit),
|
||
unitPrice: convert(Number(rate.rateValue)),
|
||
cargoTypeCode: scope?.cargoType?.code ?? null,
|
||
isClearance: true,
|
||
});
|
||
}
|
||
}
|
||
|
||
return {
|
||
displayMode: 'UNIT_RATES',
|
||
currency,
|
||
lineItems,
|
||
generatedAt: new Date().toISOString(),
|
||
};
|
||
}
|
||
|
||
/** Generate (and persist) the unit-rate breakdown for a contract. */
|
||
async generatePrice(contractId: string): Promise<ContractPricingBreakdown> {
|
||
const contract = await this.contractsRepository.findByIdWithRelations(contractId);
|
||
if (!contract) {
|
||
throw new Error(`Contract ${contractId} not found`);
|
||
}
|
||
const breakdown = await this.buildBreakdown(contract);
|
||
await this.contractsRepository.update(contractId, {
|
||
pricingBreakdown: breakdown as never,
|
||
pricingDisplayMode: 'UNIT_RATES',
|
||
} as never);
|
||
return breakdown;
|
||
}
|
||
|
||
/**
|
||
* Freeze the contract's unit rates into contract_rate_snapshots (one row per
|
||
* rate line) at submit time. The booking later computes totals from these.
|
||
*/
|
||
async freezeRateSnapshots(contractId: string): Promise<void> {
|
||
const contract = await this.contractsRepository.findByIdWithRelations(contractId);
|
||
if (!contract) return;
|
||
const breakdown =
|
||
(contract.pricingBreakdown as ContractPricingBreakdown | null) ??
|
||
(await this.buildBreakdown(contract));
|
||
|
||
await this.contractsRepository.clearRateSnapshots(contractId);
|
||
for (const line of breakdown.lineItems) {
|
||
await this.contractsRepository.createRateSnapshot({
|
||
contractId,
|
||
rateCode: line.code,
|
||
description: line.label,
|
||
unitPrice: line.unitPrice,
|
||
unitOfMeasure: line.unit,
|
||
currency: breakdown.currency,
|
||
containerSize: line.containerSize ?? null,
|
||
isSurcharge: !!line.conditionalOn,
|
||
conditionalOn: line.conditionalOn ?? null,
|
||
isClearance: !!line.isClearance,
|
||
});
|
||
}
|
||
}
|
||
}
|