change price logic on the ,rule engine ui, auto generate the contrat

This commit is contained in:
marshal
2026-06-08 10:04:23 +03:00
parent 4993453993
commit 88df20be6b
76 changed files with 4907 additions and 225 deletions

View File

@@ -14,6 +14,11 @@ export function computeNextStep(
const { status } = booking;
switch (status) {
case 'PRICE_CHANGED_PENDING_CONFIRM':
return {
action: 'CONFIRM_SUBMIT',
description: 'Price has changed since preview; confirm to submit booking',
};
case 'SUBMITTED':
return {
action: 'ACCEPT_INTAKE',

View File

@@ -14,6 +14,24 @@ import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price
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(
@@ -26,22 +44,56 @@ export class BookingPricingService {
async generatePrice(bookingId: string): Promise<GeneratePriceResponseDto> {
const booking = await this.requireBooking(bookingId);
assertBookingStatus(booking, ['DRAFT']);
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);
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);
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 item: PriceLineItemDto = {
code: mod.surchargeTypeCode,
@@ -51,30 +103,60 @@ export class BookingPricingService {
};
lineItems.push(item);
total += mod.calculatedAmount;
const rate = rateById.get(mod.rateId);
if (rate) usedRatesMap.set(rate.id, rate);
}
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,
lineItems,
totalAmount: total,
currency: booking.paymentCurrency,
lineItems,
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 ?? []).map(async (bc) => {
@@ -115,11 +197,7 @@ export class BookingPricingService {
totalAmount: number;
currency: string;
}> {
const stored = booking.pricingBreakdown as {
lineItems?: PriceLineItemDto[];
totalAmount?: number;
currency?: string;
} | null;
const stored = booking.pricingBreakdown as StoredPricingBreakdown;
if (stored?.lineItems?.length) {
return {
@@ -129,41 +207,28 @@ export class BookingPricingService {
};
}
const evalInput = await this.buildEvalInputForBooking(booking);
const ruleResult = await this.ruleEngineService.evaluate(evalInput);
const lineItems: PriceLineItemDto[] = [];
let total = 0;
const computed = await this.computePriceForBooking(booking);
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,
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,
totalAmount: total || Number(booking.totalAmount),
currency: booking.paymentCurrency,
lineItems: computed.lineItems,
totalAmount: computed.totalAmount || Number(booking.totalAmount),
currency: computed.currency,
};
}
@@ -190,14 +255,14 @@ export class BookingPricingService {
return score;
}
private async computeBaseRailLines(
private async computeBaseRailLinesWithRates(
booking: Booking,
evalInput: BookingEvaluationInput,
): Promise<PriceLineItemDto[]> {
): Promise<{ lineItems: PriceLineItemDto[]; usedRates: Rate[] }> {
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
@@ -209,18 +274,15 @@ console.log('liveRates----', liveRates);
: 'CONTAINER_EXPORT'
: 'INTERCITY_CONTAINER';
console.log('rateType----', rateType);
const lines: PriceLineItemDto[] = [];
const usedRatesMap = new Map<string, Rate>();
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;
usedRatesMap.set(rate.id, rate);
const amount = this.amountForRate(rate, container.quantity, wagonCount);
lines.push({
code: rateType,
@@ -235,6 +297,7 @@ console.log('liveRates----', liveRates);
(r) => r.rateType === rateType && r.currency === currency && r.status === 'LIVE',
);
if (fallback) {
usedRatesMap.set(fallback.id, fallback);
const amount = this.amountForRate(fallback, 1, wagonCount);
lines.push({
code: rateType,
@@ -245,7 +308,7 @@ console.log('liveRates----', liveRates);
}
}
return lines;
return { lineItems: lines, usedRates: [...usedRatesMap.values()] };
}
private pickRate(
@@ -281,31 +344,15 @@ console.log('liveRates----', liveRates);
}
}
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);
}
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)),
);
}
}

View File

@@ -8,6 +8,8 @@ import { BookingPricingService } from './booking-pricing.service';
import { BookingsRepository } from './bookings.repository';
import { assertBookingStatus } from './booking-status.util';
import { computeNextStep, type BookingNextStep } from './booking-next-step.util';
import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto';
import { PriceLineItemDto } from './dto/generate-price-response.dto';
import { Booking } from './entities/booking.entity';
import { BookingsService } from './bookings.service';
@@ -22,7 +24,7 @@ export class BookingTransitionService {
private readonly bookingsService: BookingsService,
) {}
async submit(bookingId: string): Promise<Booking> {
async submit(bookingId: string): Promise<SubmitBookingResponseDto> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['DRAFT', 'CHANGES_REQUESTED']);
@@ -32,14 +34,119 @@ export class BookingTransitionService {
);
}
const priorityScore = await this.pricingService.computeSubmitPriorityScore(booking);
await this.ruleEngineService.snapshotLiveRates(bookingId);
const computed = await this.pricingService.computePriceForBooking(booking);
this.ruleEngineService.assertNoHardBlocks({
priorityScore: computed.priorityScore,
appliedModifiers: computed.appliedModifiers,
containerWeightResults: [],
warnings: computed.warnings,
hardBlocked: computed.hardBlocked,
requiresDirectorApproval: false,
});
const stored = booking.pricingBreakdown as {
lineItems?: PriceLineItemDto[];
totalAmount?: number;
} | null;
const unchanged = this.pricingService.pricesMatch(stored, computed);
const priorityScore = await this.pricingService.computeSubmitPriorityScore(booking);
if (unchanged) {
await this.pricingService.createPricingSnapshots(
bookingId,
computed.usedRates,
computed.appliedModifiers,
);
const updated = await this.bookingsRepository.update(bookingId, {
status: 'SUBMITTED',
priorityScore,
} as never);
const finalBooking = await this.bookingsService.findById(updated!.id);
return {
bookingId: finalBooking.id,
status: finalBooking.status,
priceChanged: false,
totalAmount: Number(finalBooking.totalAmount),
currency: finalBooking.paymentCurrency,
lineItems: computed.lineItems,
};
}
const previousTotalAmount = Number(booking.totalAmount);
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(),
},
status: 'PRICE_CHANGED_PENDING_CONFIRM',
} as never);
const updatedBooking = await this.bookingsService.findById(bookingId);
return {
bookingId: updatedBooking.id,
status: updatedBooking.status,
priceChanged: true,
previousTotalAmount,
totalAmount: computed.totalAmount,
currency: computed.currency,
lineItems: computed.lineItems,
message: 'Price has changed since preview. Confirm to submit with the updated price.',
};
}
async confirmSubmit(bookingId: string): Promise<SubmitBookingResponseDto> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['PRICE_CHANGED_PENDING_CONFIRM']);
if (Number(booking.totalAmount) <= 0) {
throw new BadRequestException('No price to confirm');
}
const computed = await this.pricingService.computePriceForBooking(booking);
this.ruleEngineService.assertNoHardBlocks({
priorityScore: computed.priorityScore,
appliedModifiers: computed.appliedModifiers,
containerWeightResults: [],
warnings: computed.warnings,
hardBlocked: computed.hardBlocked,
requiresDirectorApproval: false,
});
await this.pricingService.createPricingSnapshots(
bookingId,
computed.usedRates,
computed.appliedModifiers,
);
const priorityScore = await this.pricingService.computeSubmitPriorityScore(booking);
const updated = await this.bookingsRepository.update(bookingId, {
status: 'SUBMITTED',
priorityScore,
totalAmount: computed.totalAmount,
pricingBreakdown: {
lineItems: computed.lineItems,
totalAmount: computed.totalAmount,
currency: computed.currency,
generatedAt: new Date().toISOString(),
},
} as never);
return this.bookingsService.findById(updated!.id);
const finalBooking = await this.bookingsService.findById(updated!.id);
return {
bookingId: finalBooking.id,
status: finalBooking.status,
priceChanged: false,
totalAmount: Number(finalBooking.totalAmount),
currency: finalBooking.paymentCurrency,
lineItems: computed.lineItems,
message: 'Booking submitted with confirmed price.',
};
}
async requestChanges(
@@ -279,6 +386,7 @@ export class BookingTransitionService {
assertBookingStatus(booking, [
'DRAFT',
'SUBMITTED',
'PRICE_CHANGED_PENDING_CONFIRM',
'CHANGES_REQUESTED',
'PENDING_APPROVAL',
'CONTRACT_READY',
@@ -321,4 +429,4 @@ export class BookingTransitionService {
nextStep,
};
}
}
}

View File

@@ -39,6 +39,7 @@ import { CreateBookingDto } from './dto/create-booking.dto';
import { BookingListSummaryDto } from './dto/booking-list-summary.dto';
import { FilterBookingDto } from './dto/filter-booking.dto';
import { GeneratePriceResponseDto } from './dto/generate-price-response.dto';
import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto';
import {
ApproveStepDto,
CancelBookingDto,
@@ -165,17 +166,36 @@ export class BookingsController {
}
@Post(':id/generate-price')
@ApiOperation({ summary: 'Generate price preview (DRAFT only)' })
@ApiOperation({
summary: 'Generate price preview (DRAFT or CHANGES_REQUESTED)',
description:
'Computes and stores a price preview on the booking. Does not create rate snapshots.',
})
@ApiOkResponse({ type: GeneratePriceResponseDto })
generatePrice(@Param('id', ParseUUIDPipe) id: string) {
return this.pricingService.generatePrice(id);
}
@Post(':id/submit')
@ApiOperation({ summary: 'Customer submit booking' })
async submit(@Param('id', ParseUUIDPipe) id: string) {
const booking = await this.transitionService.submit(id);
return this.transitionService.enrichBookingResponse(booking);
@ApiOperation({
summary: 'Customer submit booking',
description:
'Recomputes price against live rates. If unchanged, creates rate snapshots and submits. If changed, updates the booking price and returns priceChanged=true for confirmation.',
})
@ApiOkResponse({ type: SubmitBookingResponseDto })
submit(@Param('id', ParseUUIDPipe) id: string) {
return this.transitionService.submit(id);
}
@Post(':id/confirm-submit')
@ApiOperation({
summary: 'Confirm submit after price change',
description:
'Creates rate snapshots for the updated booking price and moves the booking to SUBMITTED.',
})
@ApiOkResponse({ type: SubmitBookingResponseDto })
confirmSubmit(@Param('id', ParseUUIDPipe) id: string) {
return this.transitionService.confirmSubmit(id);
}
@Post(':id/staff/request-changes')

View File

@@ -345,6 +345,26 @@ export class BookingsRepository extends BaseRepository<Booking> {
await this.dataSource.getRepository(BookingRateSnapshot).delete({ bookingId });
}
async hasPricingArtifacts(bookingId: string): Promise<boolean> {
const snapshotCount = await this.dataSource
.getRepository(BookingRateSnapshot)
.count({ where: { bookingId } });
const modifierCount = await this.dataSource
.getRepository(BookingCargoModifier)
.count({ where: { bookingId } });
return snapshotCount > 0 || modifierCount > 0;
}
async invalidatePricingPreview(bookingId: string): Promise<void> {
if (await this.hasPricingArtifacts(bookingId)) {
await this.clearPricingArtifacts(bookingId);
}
await this.update(bookingId, {
totalAmount: 0,
pricingBreakdown: null,
} as never);
}
/** Queue listing with optional bulk exclusion for LINE_STAFF. */
async findQueue(options: {
status: string | string[];

View File

@@ -348,6 +348,15 @@ export class BookingsService {
this.ruleEngineService.assertNoHardBlocks(ruleResult);
warnings.push(...ruleResult.warnings);
const pricingFieldsChanged = this.pricingRelevantFieldsChanged(
existing,
dto,
freightType,
cargoTypeId,
allowConsolidation,
containers,
);
const updates: Record<string, unknown> = {
...dto,
freightType,
@@ -375,6 +384,10 @@ export class BookingsService {
);
}
if (pricingFieldsChanged) {
await this.bookingsRepository.invalidatePricingPreview(id);
}
if (files.length > 0) {
await this.filesService.uploadMany(id, 'bookings', files);
}
@@ -648,4 +661,57 @@ export class BookingsService {
),
};
}
private pricingRelevantFieldsChanged(
existing: Booking,
dto: UpdateBookingDto,
freightType: FreightType,
cargoTypeId: string | null | undefined,
allowConsolidation: boolean,
containers: CreateBookingContainerDto[],
): boolean {
if (dto.freightType !== undefined && dto.freightType !== existing.freightType) {
return true;
}
if (dto.tradeDirection !== undefined && dto.tradeDirection !== existing.tradeDirection) {
return true;
}
if (dto.paymentCurrency !== undefined && dto.paymentCurrency !== existing.paymentCurrency) {
return true;
}
if (dto.isHazardous !== undefined && dto.isHazardous !== existing.isHazardous) {
return true;
}
if (
dto.allowConsolidation !== undefined &&
dto.allowConsolidation !== existing.allowConsolidation
) {
return true;
}
if (dto.shippingLineId !== undefined && dto.shippingLineId !== existing.shippingLineId) {
return true;
}
if (dto.cargoTypeId !== undefined && dto.cargoTypeId !== existing.cargoTypeId) {
return true;
}
if (dto.containers !== undefined) {
const existingContainers =
existing.bookingContainers?.map((bc) => ({
containerTypeId: bc.containerTypeId,
quantity: bc.quantity,
vgmPerUnitTons: Number(bc.vgmPerUnitTons),
})) ?? [];
if (JSON.stringify(existingContainers) !== JSON.stringify(containers)) {
return true;
}
}
if (
freightType !== existing.freightType ||
(cargoTypeId ?? null) !== (existing.cargoTypeId ?? null) ||
allowConsolidation !== existing.allowConsolidation
) {
return true;
}
return false;
}
}

View File

@@ -0,0 +1,29 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { PriceLineItemDto } from './generate-price-response.dto';
export class SubmitBookingResponseDto {
@ApiProperty()
bookingId!: string;
@ApiProperty()
status!: string;
@ApiProperty()
priceChanged!: boolean;
@ApiPropertyOptional()
previousTotalAmount?: number;
@ApiProperty()
totalAmount!: number;
@ApiProperty()
currency!: string;
@ApiPropertyOptional({ type: [PriceLineItemDto] })
lineItems?: PriceLineItemDto[];
@ApiPropertyOptional()
message?: string;
}

View File

@@ -17,6 +17,7 @@ import { BookingReviewNote } from './booking-review-note.entity';
export const BOOKING_STATUSES = [
'DRAFT',
'SUBMITTED',
'PRICE_CHANGED_PENDING_CONFIRM',
'CHANGES_REQUESTED',
'PENDING_APPROVAL',
'APPROVED_PENDING_SIGNATURE',