contrat,booking,global logestic

This commit is contained in:
Marshal
2026-06-26 23:24:48 +00:00
parent f931342f31
commit 01d53c218c
105 changed files with 19573 additions and 909 deletions

View File

@@ -0,0 +1,204 @@
import { Injectable } from '@nestjs/common';
import { RatesService } from '../rule-engine/services/rates.service';
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
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_ton' | 'per_item' | 'per_km' | 'flat';
unitPrice: number;
containerSize?: string | null;
conditionalOn?: string | null;
cargoTypeCode?: string | null;
}
/** 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_KM':
return 'per_km';
case 'PER_CONTAINER':
case 'PER_WAGON':
return 'per_container';
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 ? Math.round(usd * usdToEtb) : usd);
const lineItems: ContractUnitRateLineItem[] = [];
const baseType = this.baseRateType(contract);
if (contract.freightType === 'CONTAINER') {
const sizes = (contract.cargoScope ?? [])
.map((c) => c.containerSize)
.filter((s): s is string => !!s);
const { data: containerTypes } = await this.containerTypesService.findAll({
isActive: true,
pageSize: 500,
});
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 =
liveRates.find(
(r) =>
r.rateType === baseType &&
r.currency === 'USD' &&
r.containerTypeId &&
matchedIds.has(r.containerTypeId),
) ??
liveRates.find(
(r) => r.rateType === baseType && r.currency === 'USD' && !r.containerTypeId,
);
if (!rate) continue;
lineItems.push({
code: `CONTAINER_${size.toUpperCase()}`,
label: `${size} container`,
unit: toContractUnit(rate.rateUnit),
unitPrice: convert(Number(rate.rateValue)),
containerSize: size,
});
}
} else {
const bulkRate =
liveRates.find((r) => r.rateType === baseType && r.currency === 'USD') ?? null;
const cargoScope = (contract.cargoScope ?? []).find((c) => c.cargoTypeId);
if (bulkRate) {
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,
});
}
}
// Conditional surcharges — shown only when the contract toggles them on.
if (contract.isHazardous) {
const hazard = liveRates.find(
(r) => r.rateType === 'HAZARD_SURCHARGE' && r.currency === 'USD',
);
if (hazard) {
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) {
lineItems.push({
code: 'REEFER_SURCHARGE',
label: 'Reefer surcharge',
unit: toContractUnit(reefer.rateUnit),
unitPrice: convert(Number(reefer.rateValue)),
conditionalOn: 'is_reefer',
});
}
}
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,
});
}
}
}