mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 16:28:12 +00:00
312 lines
9.8 KiB
TypeScript
312 lines
9.8 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 {
|
|
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';
|
|
|
|
@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,
|
|
) {}
|
|
|
|
async generatePrice(bookingId: string): Promise<GeneratePriceResponseDto> {
|
|
const booking = await this.requireBooking(bookingId);
|
|
assertBookingStatus(booking, ['DRAFT']);
|
|
|
|
const evalInput = await this.buildEvalInputForBooking(booking);
|
|
console.log('evalInput----', evalInput);
|
|
const ruleResult = await this.ruleEngineService.evaluate(evalInput);
|
|
this.ruleEngineService.assertNoHardBlocks(ruleResult);
|
|
|
|
const lineItems: PriceLineItemDto[] = [];
|
|
let total = 0;
|
|
|
|
const baseLines = await this.computeBaseRailLines(booking, evalInput);
|
|
for (const line of baseLines) {
|
|
lineItems.push(line);
|
|
total += line.amount;
|
|
}
|
|
|
|
for (const mod of ruleResult.appliedModifiers) {
|
|
const item: PriceLineItemDto = {
|
|
code: mod.surchargeTypeCode,
|
|
description: `Surcharge: ${mod.surchargeTypeCode}`,
|
|
amount: mod.calculatedAmount,
|
|
currency: mod.currency,
|
|
};
|
|
lineItems.push(item);
|
|
total += mod.calculatedAmount;
|
|
}
|
|
|
|
await this.persistPriceRun(bookingId, ruleResult.appliedModifiers, total);
|
|
|
|
await this.bookingsRepository.update(bookingId, {
|
|
totalAmount: total,
|
|
priorityScore: ruleResult.priorityScore,
|
|
pricingBreakdown: {
|
|
lineItems,
|
|
totalAmount: total,
|
|
currency: booking.paymentCurrency,
|
|
generatedAt: new Date().toISOString(),
|
|
},
|
|
} as never);
|
|
|
|
return {
|
|
bookingId,
|
|
totalAmount: total,
|
|
currency: booking.paymentCurrency,
|
|
lineItems,
|
|
warnings: ruleResult.warnings,
|
|
};
|
|
}
|
|
|
|
async buildEvalInputForBooking(booking: Booking): Promise<BookingEvaluationInput> {
|
|
const containers = await Promise.all(
|
|
(booking.bookingContainers ?? []).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,
|
|
};
|
|
}),
|
|
);
|
|
return {
|
|
freightType: booking.freightType as 'CONTAINER' | 'BULK',
|
|
cargoTypeId: booking.cargoTypeId ?? null,
|
|
serviceTypeId: booking.serviceTypeId,
|
|
paymentCurrency: booking.paymentCurrency,
|
|
tradeDirection: booking.tradeDirection,
|
|
isHazardous: booking.isHazardous,
|
|
allowConsolidation: booking.allowConsolidation,
|
|
shippingLineId: booking.shippingLineId,
|
|
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 {
|
|
lineItems?: PriceLineItemDto[];
|
|
totalAmount?: number;
|
|
currency?: string;
|
|
} | null;
|
|
|
|
if (stored?.lineItems?.length) {
|
|
return {
|
|
lineItems: stored.lineItems,
|
|
totalAmount: Number(stored.totalAmount ?? booking.totalAmount),
|
|
currency: stored.currency ?? booking.paymentCurrency,
|
|
};
|
|
}
|
|
|
|
const evalInput = await this.buildEvalInputForBooking(booking);
|
|
const ruleResult = await this.ruleEngineService.evaluate(evalInput);
|
|
const lineItems: PriceLineItemDto[] = [];
|
|
let total = 0;
|
|
|
|
const baseLines = await this.computeBaseRailLines(booking, evalInput);
|
|
for (const line of baseLines) {
|
|
lineItems.push(line);
|
|
total += line.amount;
|
|
}
|
|
|
|
for (const mod of ruleResult.appliedModifiers) {
|
|
lineItems.push({
|
|
code: mod.surchargeTypeCode,
|
|
description: `Surcharge: ${mod.surchargeTypeCode}`,
|
|
amount: mod.calculatedAmount,
|
|
currency: mod.currency,
|
|
});
|
|
total += mod.calculatedAmount;
|
|
}
|
|
|
|
if (lineItems.length === 0) {
|
|
total = Number(booking.totalAmount);
|
|
lineItems.push({
|
|
code: 'TOTAL',
|
|
description: 'Contract total',
|
|
amount: total,
|
|
currency: booking.paymentCurrency,
|
|
});
|
|
}
|
|
|
|
return {
|
|
lineItems,
|
|
totalAmount: total || Number(booking.totalAmount),
|
|
currency: booking.paymentCurrency,
|
|
};
|
|
}
|
|
|
|
/** 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 computeBaseRailLines(
|
|
booking: Booking,
|
|
evalInput: BookingEvaluationInput,
|
|
): Promise<PriceLineItemDto[]> {
|
|
const liveRates = await this.ratesService.findLiveRates();
|
|
const currency = booking.paymentCurrency;
|
|
const isBulk = booking.freightType === 'BULK';
|
|
console.log('liveRates----', liveRates);
|
|
const rateType =
|
|
booking.tradeDirection === 'IMPORT'
|
|
? isBulk
|
|
? 'BULK_IMPORT'
|
|
: 'CONTAINER_IMPORT'
|
|
: booking.tradeDirection === 'EXPORT'
|
|
? isBulk
|
|
? 'BULK_EXPORT'
|
|
: 'CONTAINER_EXPORT'
|
|
: 'INTERCITY_CONTAINER';
|
|
|
|
|
|
console.log('rateType----', rateType);
|
|
|
|
const lines: PriceLineItemDto[] = [];
|
|
const wagonCount = await this.bookingsRepository.calculateWagonCount(booking.id);
|
|
|
|
for (const container of evalInput.containers) {
|
|
console.log('container----', container);
|
|
const rate = this.pickRate(liveRates, rateType, container.containerTypeId, currency);
|
|
console.log('rate----', rate);
|
|
if (!rate) continue;
|
|
|
|
const amount = this.amountForRate(rate, container.quantity, wagonCount);
|
|
lines.push({
|
|
code: rateType,
|
|
description: `Base rail (${rateType})`,
|
|
amount,
|
|
currency: rate.currency,
|
|
});
|
|
}
|
|
|
|
if (lines.length === 0) {
|
|
const fallback = liveRates.find(
|
|
(r) => r.rateType === rateType && r.currency === currency && r.status === 'LIVE',
|
|
);
|
|
if (fallback) {
|
|
const amount = this.amountForRate(fallback, 1, wagonCount);
|
|
lines.push({
|
|
code: rateType,
|
|
description: `Base rail (${rateType})`,
|
|
amount,
|
|
currency: fallback.currency,
|
|
});
|
|
}
|
|
}
|
|
|
|
return lines;
|
|
}
|
|
|
|
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 async persistPriceRun(
|
|
bookingId: string,
|
|
modifiers: AppliedCargoModifier[],
|
|
_total: number,
|
|
): Promise<void> {
|
|
await this.bookingsRepository.clearPricingArtifacts(bookingId);
|
|
const snapshots = await this.ruleEngineService.snapshotLiveRates(bookingId);
|
|
|
|
const snapshotByRateId = new Map(snapshots.map((s) => [s.rateId, s.id]));
|
|
const rows = modifiers
|
|
.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);
|
|
}
|
|
}
|
|
}
|