mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 12:58:13 +00:00
490 lines
16 KiB
TypeScript
490 lines
16 KiB
TypeScript
import { Injectable, NotFoundException } from '@nestjs/common';
|
||
|
||
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 { ExchangeService } from '@edr/api-common';
|
||
import {
|
||
AppliedCargoModifier,
|
||
BookingEvaluationInput,
|
||
RuleEngineService,
|
||
} from '../rule-engine/rule-engine.service';
|
||
import { BookingsRepository } from './bookings.repository';
|
||
import {
|
||
containersPerWagon,
|
||
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';
|
||
|
||
export interface ComputedPriceResult {
|
||
lineItems: PriceLineItemDto[];
|
||
totalAmount: number;
|
||
currency: string;
|
||
usedRates: Rate[];
|
||
appliedModifiers: AppliedCargoModifier[];
|
||
priorityScore: number;
|
||
warnings: string[];
|
||
hardBlocked: string[];
|
||
}
|
||
|
||
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,
|
||
) {}
|
||
|
||
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);
|
||
|
||
return {
|
||
bookingId,
|
||
totalAmount: computed.totalAmount,
|
||
currency: computed.currency,
|
||
lineItems: computed.lineItems,
|
||
warnings: computed.warnings,
|
||
};
|
||
}
|
||
|
||
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;
|
||
|
||
const lineItems: PriceLineItemDto[] = [];
|
||
let total = 0;
|
||
|
||
const { lineItems: baseLines, usedRates: baseRates } =
|
||
await this.computeBaseRailLinesWithRates(booking, evalInput);
|
||
for (const line of baseLines) {
|
||
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.map((r) => [r.id, r]));
|
||
|
||
for (const mod of ruleResult.appliedModifiers) {
|
||
const usdAmount = mod.calculatedAmount;
|
||
const convertedAmount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
|
||
|
||
const rate = rateById.get(mod.rateId);
|
||
const unit = rate?.rateUnit ?? 'FLAT';
|
||
const unitUsd = rate ? Number(rate.rateValue) : usdAmount;
|
||
const unitAmount = isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd;
|
||
// 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.
|
||
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;
|
||
|
||
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);
|
||
}
|
||
|
||
return {
|
||
lineItems,
|
||
totalAmount: total,
|
||
currency: booking.paymentCurrency,
|
||
usedRates: [...usedRatesMap.values()],
|
||
appliedModifiers: ruleResult.appliedModifiers,
|
||
priorityScore: ruleResult.priorityScore,
|
||
warnings: ruleResult.warnings,
|
||
hardBlocked: ruleResult.hardBlocked,
|
||
};
|
||
}
|
||
|
||
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,
|
||
},
|
||
perWagon: containersPerWagon(Number(ct.wagonsPerUnit)),
|
||
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;
|
||
|
||
// 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',
|
||
isGovernment: booking.isGovernment,
|
||
allowConsolidation,
|
||
shippingLineId: booking.shippingLineId,
|
||
totalWagons,
|
||
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): Promise<number> {
|
||
const evalInput = await this.buildEvalInputForBooking(booking);
|
||
const ruleResult = await this.ruleEngineService.evaluate(evalInput);
|
||
return ruleResult.priorityScore;
|
||
}
|
||
|
||
private async computeBaseRailLinesWithRates(
|
||
booking: Booking,
|
||
evalInput: BookingEvaluationInput,
|
||
): Promise<{ lineItems: PriceLineItemDto[]; usedRates: Rate[] }> {
|
||
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 wagonCount = await this.bookingsRepository.calculateWagonCount(booking.id);
|
||
|
||
for (const container of evalInput.containers) {
|
||
const rate = this.pickRate(liveRates, rateType, container.containerTypeId, 'USD');
|
||
if (!rate) continue;
|
||
|
||
usedRatesMap.set(rate.id, rate);
|
||
const usdAmount = this.amountForRate(rate, container.quantity, wagonCount);
|
||
const amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
|
||
const unitUsd = Number(rate.rateValue);
|
||
const label = await this.containerTypeLabel(container.containerTypeId);
|
||
lines.push({
|
||
code: rateType,
|
||
description: `${label} rail freight`,
|
||
amount,
|
||
unitAmount: isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd,
|
||
unit: rate.rateUnit,
|
||
quantity: this.effectiveUnitQuantity(rate.rateUnit, container.quantity, wagonCount),
|
||
currency: paymentCurrency,
|
||
});
|
||
}
|
||
|
||
if (lines.length === 0) {
|
||
const fallback = liveRates.find(
|
||
(r) => r.rateType === rateType && r.currency === 'USD' && r.status === 'LIVE',
|
||
);
|
||
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 usdAmount = this.amountForRate(fallback, quantity, wagonCount);
|
||
const amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
|
||
const unitUsd = Number(fallback.rateValue);
|
||
lines.push({
|
||
code: rateType,
|
||
description: isBulk ? 'Bulk rail freight' : 'Container rail freight',
|
||
amount,
|
||
unitAmount: isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd,
|
||
unit: fallback.rateUnit,
|
||
quantity: this.effectiveUnitQuantity(fallback.rateUnit, quantity, wagonCount),
|
||
currency: paymentCurrency,
|
||
});
|
||
}
|
||
}
|
||
|
||
return { lineItems: lines, usedRates: [...usedRatesMap.values()] };
|
||
}
|
||
|
||
/** 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;
|
||
}
|
||
}
|
||
|
||
private pickRate(
|
||
rates: Rate[],
|
||
rateType: string,
|
||
containerTypeId: string,
|
||
currency: string,
|
||
): Rate | undefined {
|
||
return (
|
||
rates.find(
|
||
(r) =>
|
||
r.rateType === rateType &&
|
||
r.currency === currency &&
|
||
r.containerTypeId === containerTypeId,
|
||
) ??
|
||
rates.find((r) => r.rateType === rateType && r.currency === currency && !r.containerTypeId)
|
||
);
|
||
}
|
||
|
||
private amountForRate(rate: Rate, quantity: number, wagonCount: number): number {
|
||
const value = Number(rate.rateValue);
|
||
switch (rate.rateUnit) {
|
||
case 'PER_CONTAINER':
|
||
return value * quantity;
|
||
case 'PER_WAGON':
|
||
return value * wagonCount;
|
||
case 'PER_TON':
|
||
return value * quantity;
|
||
case 'FLAT':
|
||
return value;
|
||
default:
|
||
return value * quantity;
|
||
}
|
||
}
|
||
|
||
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)),
|
||
);
|
||
}
|
||
}
|