mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 09:58:12 +00:00
448 lines
15 KiB
TypeScript
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,
|
|
};
|
|
}
|
|
} |