mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
refactor pricing data seeder to fold surcharge types into rates update route meta subtitle to remove surcharge types enhance RuleEngineFormDialog to support conditional field visibility remove surcharge types from URL constants and related services add cargo leaf options query for bulk cargo type selection update RuleEngineResourcePage to utilize cargo leaf options modify resources configuration to remove surcharge types implement migration to fold surcharge types into rates create utility to derive legacy rate types from new rate structure
479 lines
16 KiB
TypeScript
479 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_ENABLED surcharge fires
|
|
// whenever any container line leaves a wagon partially filled. Derived from
|
|
// the container quantities — there is no persisted opt-in flag.
|
|
const allowConsolidation =
|
|
booking.freightType === 'CONTAINER' &&
|
|
lines.some((l) => wagonRemainder(l.quantity, l.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)),
|
|
);
|
|
}
|
|
}
|