Files
edr-platform/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts

448 lines
15 KiB
TypeScript

import {
BadRequestException,
forwardRef,
Inject,
Injectable,
} from '@nestjs/common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { assertCanApproveBookingStep } from '../../common/freight-permission.util';
import { RuleEngineService } from '../rule-engine/rule-engine.service';
import { BookingContractService } from './booking-contract.service';
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';
@Injectable()
export class BookingTransitionService {
constructor(
private readonly bookingsRepository: BookingsRepository,
private readonly ruleEngineService: RuleEngineService,
private readonly pricingService: BookingPricingService,
private readonly contractService: BookingContractService,
@Inject(forwardRef(() => BookingsService))
private readonly bookingsService: BookingsService,
) {}
async submit(bookingId: string): Promise<SubmitBookingResponseDto> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['DRAFT', 'CHANGES_REQUESTED']);
if (Number(booking.totalAmount) <= 0) {
throw new BadRequestException(
'Generate a price before submitting (POST /bookings/:id/generate-price)',
);
}
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);
// Auto-consolidate now: a partial-wagon booking either pairs with a waiting
// partner (both → SUBMITTED) or is parked as PENDING_CONSOLIDATION until one
// arrives. The returned status reflects that outcome.
const finalBooking = await this.bookingsService.runConsolidationOnSubmit(
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);
// Same consolidation treatment as the direct submit path.
const finalBooking = await this.bookingsService.runConsolidationOnSubmit(
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(
bookingId: string,
note: string,
actorId: string,
): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['SUBMITTED']);
await this.bookingsRepository.createReviewNote(
bookingId,
note,
'CHANGES_REQUESTED',
actorId,
);
const updated = await this.bookingsRepository.update(bookingId, {
status: 'CHANGES_REQUESTED',
} as never);
return this.bookingsService.findById(updated!.id);
}
/** Auto-create booking approval steps from system rules when none exist yet. */
private async ensureBookingApprovalSteps(booking: Booking): Promise<void> {
if ((booking.approvalSteps?.length ?? 0) > 0) return;
await this.ruleEngineService.instantiateApprovalSteps(booking.id, {
freightType: booking.freightType as 'CONTAINER' | 'BULK',
cargoTypeId: booking.cargoTypeId,
});
}
async acceptIntake(bookingId: string, actorId: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
// Only SUBMITTED bookings are acceptable. A booking that still needs
// consolidation sits in PENDING_CONSOLIDATION (resolved at submit time) and
// is therefore never offered for accept until a partner moves it to SUBMITTED.
assertBookingStatus(booking, ['SUBMITTED']);
await this.ruleEngineService.instantiateApprovalSteps(bookingId, {
freightType: booking.freightType as 'CONTAINER' | 'BULK',
cargoTypeId: booking.cargoTypeId,
});
const updated = await this.bookingsRepository.update(bookingId, {
status: 'PENDING_APPROVAL',
approvedByStaffId: actorId,
approvedByStaffAt: new Date(),
} as never);
return this.bookingsService.findById(updated!.id);
}
async staffReject(
bookingId: string,
reason: string,
actorId: string,
): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['SUBMITTED', 'PENDING_APPROVAL']);
await this.bookingsRepository.createReviewNote(
bookingId,
reason,
'REJECTION',
actorId,
);
const updated = await this.bookingsRepository.update(bookingId, {
status: 'REJECTED',
} as never);
return this.bookingsService.findById(updated!.id);
}
async approveStep(
bookingId: string,
stepId: string,
actorId: string,
requiredRole: string,
authUser?: TCurrentUser,
): Promise<Booking> {
if (authUser) {
assertCanApproveBookingStep(authUser, requiredRole);
}
let booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, [
'PENDING_APPROVAL',
'APPROVED_PENDING_SIGNATURE',
]);
if ((booking.approvalSteps?.length ?? 0) === 0) {
await this.ensureBookingApprovalSteps(booking);
booking = await this.bookingsService.findById(bookingId);
}
const step = await this.bookingsRepository.findApprovalStepById(
bookingId,
stepId,
);
if (!step || step.status !== 'PENDING') {
throw new BadRequestException('Approval step not found or already actioned');
}
const next = await this.bookingsRepository.findNextPendingApprovalStep(bookingId);
if (!next || next.id !== step.id) {
throw new BadRequestException(
'Approval steps must be completed in order',
);
}
if (step.requiredRole !== requiredRole) {
throw new BadRequestException(
`Step requires role ${step.requiredRole}, not ${requiredRole}`,
);
}
const blocksRole = step.blocksRole;
if (blocksRole && blocksRole === requiredRole) {
throw new BadRequestException(`Role ${requiredRole} is blocked for this step`);
}
await this.bookingsRepository.completeApprovalStep(step.id, actorId, 'APPROVED');
const updates: Record<string, unknown> = {};
const now = new Date();
if (requiredRole === 'LINE_STAFF') {
updates.status = 'APPROVED_PENDING_SIGNATURE';
updates.approvedByStaffId = actorId;
updates.approvedByStaffAt = now;
} else if (requiredRole === 'DIRECTOR') {
updates.signedByDirectorId = actorId;
updates.signedByDirectorAt = now;
} else if (requiredRole === 'CEO') {
updates.signedByCeoId = actorId;
updates.signedByCeoAt = now;
}
const allDone = await this.bookingsRepository.allApprovalStepsComplete(bookingId);
if (allDone) {
updates.status = 'APPROVED';
}
if (Object.keys(updates).length > 0) {
await this.bookingsRepository.update(bookingId, updates as never);
}
if (allDone) {
const generated = await this.contractService.generateContract(bookingId);
return this.bookingsService.findById(generated.id);
}
return this.bookingsService.findById(bookingId);
}
async rejectStep(
bookingId: string,
stepId: string,
actorId: string,
reason: string,
): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE']);
const step = await this.bookingsRepository.findApprovalStepById(
bookingId,
stepId,
);
if (!step) throw new BadRequestException('Approval step not found');
await this.bookingsRepository.completeApprovalStep(
step.id,
actorId,
'REJECTED',
reason,
);
await this.bookingsRepository.createReviewNote(
bookingId,
reason,
'REJECTION',
actorId,
);
const updated = await this.bookingsRepository.update(bookingId, {
status: 'REJECTED',
} as never);
return this.bookingsService.findById(updated!.id);
}
async customerSign(bookingId: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['CONTRACT_READY']);
const updated = await this.bookingsRepository.update(bookingId, {
status: 'SIGNED_CUSTOMER',
customerSignedAt: new Date(),
} as never);
return this.bookingsService.findById(updated!.id);
}
async marketingApprove(bookingId: string, actorId: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['SIGNED_CUSTOMER']);
const updated = await this.bookingsRepository.update(bookingId, {
status: 'FULLY_EXECUTED',
fullyExecutedAt: new Date(),
marketingApprovedById: actorId,
marketingApprovedAt: new Date(),
lockedAt: new Date(),
} as never);
return this.bookingsService.findById(updated!.id);
}
async startTransit(bookingId: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['PAID']);
const updated = await this.bookingsRepository.update(bookingId, {
status: 'IN_TRANSIT',
} as never);
return this.bookingsService.findById(updated!.id);
}
async complete(bookingId: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['IN_TRANSIT']);
const updated = await this.bookingsRepository.update(bookingId, {
status: 'COMPLETED',
endDate: new Date(),
} as never);
return this.bookingsService.findById(updated!.id);
}
async cancel(bookingId: string, reason: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, [
'DRAFT',
'SUBMITTED',
'PRICE_CHANGED_PENDING_CONFIRM',
'CHANGES_REQUESTED',
'PENDING_APPROVAL',
'CONTRACT_READY',
]);
await this.bookingsRepository.createReviewNote(
bookingId,
reason,
'REJECTION',
);
const updated = await this.bookingsRepository.update(bookingId, {
status: 'CANCELLED',
} as never);
return this.bookingsService.findById(updated!.id);
}
async enrichBookingResponse(booking: Booking): Promise<Booking & {
latestChangeRequestNote?: string | null;
contractSummary?: string | null;
nextStep: BookingNextStep | null;
}> {
const note = await this.bookingsRepository.findLatestReviewNote(
booking.id,
'CHANGES_REQUESTED',
);
const summary =
booking.contractSummary ??
this.contractService.buildContractSummary(booking);
const nextPending =
booking.status === 'PENDING_APPROVAL' ||
booking.status === 'APPROVED_PENDING_SIGNATURE'
? await this.bookingsRepository.findNextPendingApprovalStep(booking.id)
: null;
const nextStep = computeNextStep(booking, nextPending);
return {
...booking,
latestChangeRequestNote: note?.note ?? null,
contractSummary: summary,
nextStep,
};
}
}