mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
269 lines
8.8 KiB
TypeScript
269 lines
8.8 KiB
TypeScript
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<string, string> = {
|
||
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<Record<Rate['appliesTo'], string>> = {
|
||
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<Record<Rate['trigger'], string>> = {
|
||
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<RateSchedule> {
|
||
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());
|
||
}
|
||
}
|