mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 16:28:12 +00:00
391 lines
13 KiB
TypeScript
391 lines
13 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 { ServiceTypesService } from '../rule-engine/services/service-types.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 { 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;
|
|
|
|
@Injectable()
|
|
export class BookingPricingService {
|
|
constructor(
|
|
private readonly bookingsRepository: BookingsRepository,
|
|
private readonly ruleEngineService: RuleEngineService,
|
|
private readonly containerTypesService: ContainerTypesService,
|
|
private readonly ratesService: RatesService,
|
|
private readonly serviceTypesService: ServiceTypesService,
|
|
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 item: PriceLineItemDto = {
|
|
code: mod.surchargeTypeCode,
|
|
description: `Surcharge: ${mod.surchargeTypeCode}`,
|
|
amount: convertedAmount,
|
|
currency: paymentCurrency,
|
|
};
|
|
lineItems.push(item);
|
|
total += convertedAmount;
|
|
|
|
const rate = rateById.get(mod.rateId);
|
|
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,
|
|
surchargeTypeId: m.surchargeTypeId,
|
|
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 containers = 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 {
|
|
containerTypeId: bc.containerTypeId,
|
|
quantity: qty,
|
|
vgmPerUnitTons: vgm,
|
|
totalVgmTons: qty * vgm,
|
|
isReefer: ct.isReefer,
|
|
};
|
|
}),
|
|
);
|
|
// 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;
|
|
|
|
return {
|
|
freightType: booking.freightType as 'CONTAINER' | 'BULK',
|
|
cargoTypeId: booking.cargoTypeId ?? null,
|
|
serviceTypeId: booking.serviceTypeId,
|
|
paymentCurrency: booking.paymentCurrency,
|
|
tradeDirection: booking.tradeDirection,
|
|
isHazardous: booking.isHazardous,
|
|
isGovernment: booking.isGovernment,
|
|
allowConsolidation: booking.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,
|
|
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 (USD + service tier). */
|
|
async computeSubmitPriorityScore(booking: Booking): Promise<number> {
|
|
const evalInput = await this.buildEvalInputForBooking(booking);
|
|
const ruleResult = await this.ruleEngineService.evaluate(evalInput);
|
|
let score = ruleResult.priorityScore;
|
|
|
|
const serviceType = await this.serviceTypesService.findById(booking.serviceTypeId);
|
|
if (booking.paymentCurrency === 'USD' && serviceType) {
|
|
const code = (serviceType.code ?? '').toUpperCase();
|
|
const hasForwarding =
|
|
serviceType.includesFirstMile ||
|
|
serviceType.includesLastMile ||
|
|
code.includes('FORWARD') ||
|
|
code.includes('Y');
|
|
const railOnly = code.includes('RAIL') && !hasForwarding;
|
|
|
|
if (hasForwarding) score += 1000;
|
|
else if (railOnly || code.includes('X')) score += 500;
|
|
}
|
|
|
|
return score;
|
|
}
|
|
|
|
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;
|
|
lines.push({
|
|
code: rateType,
|
|
description: `Base rail (${rateType})`,
|
|
amount,
|
|
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;
|
|
lines.push({
|
|
code: rateType,
|
|
description: `Base rail (${rateType})`,
|
|
amount,
|
|
currency: paymentCurrency,
|
|
});
|
|
}
|
|
}
|
|
|
|
return { lineItems: lines, usedRates: [...usedRatesMap.values()] };
|
|
}
|
|
|
|
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)),
|
|
);
|
|
}
|
|
}
|