import { Injectable } from '@nestjs/common'; import { RatesService } from '../modules/rule-engine/services/rates.service'; import { Rate } from '../modules/rule-engine/entities/rate.entity'; import { ContractDirection, ContractFreight, } from './contract-template.types'; /** One priced line in the contract's rate schedule. */ export interface RateScheduleRow { /** "Negad → Mojo Dry Port" for base freight, service name otherwise. */ route: string; /** "40ft GP", "Wheat", or "—" when the rate is not scoped to a type. */ cargo: string; currency: string; /** Pre-formatted amount, e.g. "200" (grouped, no trailing zeros). */ amount: string; /** Human unit, e.g. "per container", "per wagon", "per ton". */ unit: string; } /** * The origin → destination rate schedule shown in a generated contract's * pricing article. Grouped so the reader sees rail freight lanes first, then * pickup/delivery legs, then trigger-based surcharges and demurrage. */ export interface RateSchedule { /** Base rail freight lanes matching this contract's direction + freight. */ freightLanes: RateScheduleRow[]; /** First-mile / last-mile truck legs (route-agnostic). */ additionalServices: RateScheduleRow[]; /** Hazard, reefer, overweight, demurrage, customs, etc. */ surcharges: RateScheduleRow[]; /** True when every group is empty — the template falls back to prose. */ isEmpty: boolean; /** Currencies present across the schedule, e.g. "USD" or "USD, ETB". */ currencyLabel: string; } const UNIT_LABELS: Record = { PER_WAGON: 'per wagon', PER_TON: 'per ton', PER_CONTAINER: 'per container', PER_KM: 'per km', PER_TON_KM: 'per ton per km', PER_LITER: 'per liter', PER_INVOICE: 'per invoice', FLAT: 'flat', }; const SERVICE_ROUTE_LABELS: Partial> = { FIRST_MILE: 'First-mile pickup by truck', LAST_MILE: 'Last-mile delivery by truck', }; /** Friendly wording for the trigger-based charges shown in the surcharge group. */ const TRIGGER_ROUTE_LABELS: Partial> = { HAZARDOUS: 'Hazardous cargo surcharge', OVERWEIGHT: 'Overweight surcharge', REEFER: 'Reefer (refrigerated) surcharge', WITH_RETURN: 'Empty-container return service', SHIPPING_LINE: 'Shipping line handling', CONSOLIDATION: 'Penalty (container consolidation)', LASHING: 'Cargo lashing and securing', CANCELLATION: 'Booking cancellation fee', DEMURRAGE: 'Demurrage / wagon detention', PIL_EXTRA_FEE: 'PIL shipping line extra fee', CUSTOMS_CLEARANCE: 'Customs clearance service', ETHIOPIAN_CUSTOMS_CLEARANCE: 'Ethiopian customs clearance service', FUEL: 'Fuel surcharge', }; @Injectable() export class ContractRateScheduleBuilder { constructor(private readonly ratesService: RatesService) {} /** * Build the rate schedule for a contract of the given direction + freight. * Base-freight lanes are filtered to the matching trade direction / freight * kind so an import container contract shows import container lanes only; * additional services and surcharges are route-agnostic and always shown. */ async build( direction: ContractDirection, freight: ContractFreight, ): Promise { const rates = await this.ratesService.findLiveRatesDetailed(); const freightLanes: RateScheduleRow[] = []; const additionalServices: RateScheduleRow[] = []; const surcharges: RateScheduleRow[] = []; for (const rate of rates) { if (this.isBaseFreight(rate)) { if (this.baseFreightMatches(rate, direction, freight)) { freightLanes.push(this.laneRow(rate)); } continue; } if (rate.appliesTo === 'FIRST_MILE' || rate.appliesTo === 'LAST_MILE') { additionalServices.push(this.serviceRow(rate)); continue; } // Fuel is sold per lane + commodity — only lanes matching the contract's // direction belong on its schedule, labeled with their leg. if (rate.trigger === 'FUEL') { if (this.fuelDirectionMatches(rate, direction)) { surcharges.push(this.fuelRow(rate)); } continue; } // Everything left is a trigger-based charge (surcharge / demurrage / customs). surcharges.push(this.surchargeRow(rate)); } const currencyLabel = this.currencyLabel([ ...freightLanes, ...additionalServices, ...surcharges, ]); return { freightLanes, additionalServices, surcharges, isEmpty: freightLanes.length === 0 && additionalServices.length === 0 && surcharges.length === 0, currencyLabel, }; } private isBaseFreight(rate: Rate): boolean { return ( rate.trigger === 'ALWAYS' && (rate.appliesTo === 'BULK' || rate.appliesTo === 'CONTAINER' || rate.appliesTo === 'INTERCITY') ); } private baseFreightMatches( rate: Rate, direction: ContractDirection, freight: ContractFreight, ): boolean { // Domestic contracts price off intercity rates; the freight kind is carried // in the derived rateType (INTERCITY_BULK vs INTERCITY_CONTAINER). if (direction === 'DOM') { if (rate.appliesTo !== 'INTERCITY') return false; return freight === 'BULK' ? rate.rateType === 'INTERCITY_BULK' : rate.rateType === 'INTERCITY_CONTAINER'; } // Import / export price off BULK or CONTAINER rates matching the direction. const wantAppliesTo = freight === 'BULK' ? 'BULK' : 'CONTAINER'; if (rate.appliesTo !== wantAppliesTo) return false; const wantDirection = direction === 'IMP' ? 'IMPORT' : 'EXPORT'; return rate.tradeDirection === wantDirection; } private laneRow(rate: Rate): RateScheduleRow { const origin = rate.originYard?.label ?? rate.originYard?.code ?? '—'; const destination = rate.destinationYard?.label ?? rate.destinationYard?.code ?? '—'; return { route: `${origin} → ${destination}`, cargo: this.cargoLabel(rate), currency: rate.currency, amount: this.formatAmount(rate.rateValue), unit: this.unitLabel(rate.rateUnit), }; } private serviceRow(rate: Rate): RateScheduleRow { return { route: SERVICE_ROUTE_LABELS[rate.appliesTo] ?? rate.appliesTo, cargo: this.cargoLabel(rate), currency: rate.currency, amount: this.formatAmount(rate.rateValue), unit: this.unitLabel(rate.rateUnit), }; } private fuelDirectionMatches(rate: Rate, direction: ContractDirection): boolean { const want = direction === 'IMP' ? 'IMPORT' : direction === 'EXP' ? 'EXPORT' : 'DOMESTIC'; return rate.tradeDirection === want; } /** * Fuel row — the lane matters, so it rides along in the charge label. * Per-liter collapses to one flat total (base liters × rate value); the * customer only ever sees the final price. */ private fuelRow(rate: Rate): RateScheduleRow { const origin = rate.originYard?.label ?? rate.originYard?.code ?? '—'; const destination = rate.destinationYard?.label ?? rate.destinationYard?.code ?? '—'; const perLiter = rate.rateUnit === 'PER_LITER'; return { route: `Fuel surcharge (${origin} → ${destination})`, cargo: this.cargoLabel(rate), currency: rate.currency, amount: this.formatAmount( perLiter ? Number(rate.baseLiters ?? 0) * Number(rate.rateValue) : rate.rateValue, ), unit: perLiter ? 'flat' : this.unitLabel(rate.rateUnit), }; } private surchargeRow(rate: Rate): RateScheduleRow { return { route: TRIGGER_ROUTE_LABELS[rate.trigger] ?? this.titleCase(rate.trigger), cargo: this.cargoLabel(rate), currency: rate.currency, amount: this.formatAmount(rate.rateValue), unit: this.unitLabel(rate.rateUnit), }; } /** The type a rate is scoped to (container/cargo), or a dash when unscoped. */ private cargoLabel(rate: Rate): string { return ( rate.containerType?.label ?? rate.containerType?.code ?? rate.cargoType?.cargoTypeName ?? '—' ); } private unitLabel(unit: Rate['rateUnit']): string { return UNIT_LABELS[unit] ?? unit.toLowerCase().replace(/_/g, ' '); } /** Group thousands and drop the DB's trailing zeros: "200.0000" → "200". */ private formatAmount(value: number | string): string { const num = Number(value); if (!Number.isFinite(num)) return String(value); return num.toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 2, }); } private currencyLabel(rows: RateScheduleRow[]): string { const seen: string[] = []; for (const row of rows) { if (!seen.includes(row.currency)) seen.push(row.currency); } return seen.join(', ') || 'USD'; } private titleCase(value: string): string { return value .toLowerCase() .replace(/_/g, ' ') .replace(/\b\w/g, (c) => c.toUpperCase()); } }