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 { BookingBatchService } from '../train-scheduling/booking-batch.service'; import { eatDay } from '../train-scheduling/batch-window.util'; import { isRoadService } from '../booking-orders/road.util'; import { RuleEngineService } from '../rule-engine/rule-engine.service'; import { FilesService } from '../files/files.service'; import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.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 { clearanceCodesForBooking } from './clearance.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, private readonly filesService: FilesService, private readonly fileUploadSettingsService: FileUploadSettingsService, @Inject(forwardRef(() => BookingBatchService)) private readonly bookingBatchService: BookingBatchService, @Inject(forwardRef(() => BookingsService)) private readonly bookingsService: BookingsService, ) {} async submit(bookingId: string): Promise { 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 { 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 { 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 { 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, validityDays: number, ): Promise { 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']); // The backoffice must define how long the accepted contract stays valid. // Without a window the contract has no end date and cannot be relied on, so // accept is blocked until a positive number of days is supplied. if (!Number.isInteger(validityDays) || validityDays < 1) { throw new BadRequestException( 'A contract validity (in days) is required to accept this booking.', ); } // Validity runs from the accept moment through accept + N days. const validFrom = new Date(); const validUntil = new Date(validFrom); validUntil.setDate(validUntil.getDate() + validityDays); 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: validFrom, contractValidityDays: validityDays, contractValidFrom: validFrom, contractValidUntil: validUntil, } as never); return this.bookingsService.findById(updated!.id); } async staffReject( bookingId: string, reason: string, actorId: string, ): Promise { 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 { 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 = {}; 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 { 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 { 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 { 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 { 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 { 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 { 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); } /** * Customer rejects the priced booking at the confirm step. The booking becomes * REJECTED (terminal) — the customer starts a new booking rather than editing * this one. Only a not-yet-committed booking can be rejected this way. */ async reject(bookingId: string, reason?: string): Promise { const booking = await this.bookingsService.findById(bookingId); assertBookingStatus(booking, [ 'DRAFT', 'SUBMITTED', 'PRICE_CHANGED_PENDING_CONFIRM', 'PENDING_CONSOLIDATION', ]); await this.bookingsRepository.createReviewNote( bookingId, reason?.trim() || 'Customer rejected the price estimate.', 'REJECTION', ); const updated = await this.bookingsRepository.update(bookingId, { status: 'REJECTED', } as never); return this.bookingsService.findById(updated!.id); } /** * Staff adjusts a booking's total price. Stores an override (with who/when/why) * that supersedes the computed total for the customer, who sees an * "Adjusted by EDR" badge. Passing null clears the adjustment. */ async adjustPrice( bookingId: string, amount: number | null, staffId: string, reason?: string, ): Promise { await this.bookingsService.findById(bookingId); if (amount != null && amount < 0) { throw new BadRequestException('Adjusted amount cannot be negative'); } await this.bookingsRepository.update(bookingId, { adjustedTotalAmount: amount, adjustedByStaffId: amount == null ? null : staffId, adjustedAt: amount == null ? null : new Date(), adjustmentReason: amount == null ? null : (reason ?? null), } as never); return this.bookingsService.findById(bookingId); } // ── Document clearance gate (post counter-sign) ─────────────────────────── /** * The clearance document grid for a booking: each required field from the * resolved customer-input set (and the GL-output set for customs) with its * uploaded file and GL review status. Drives both portals' clearance UI. */ async getClearanceView(bookingId: string): Promise<{ status: string; includesCustoms: boolean; inputCode: string | null; outputCode: string | null; documents: Array<{ fileKey: string; label: string; required: boolean; uploadedBy: 'customer' | 'gl'; settingCode: string; file: { id: string; name: string; url: string } | null; reviewStatus: 'PENDING' | 'APPROVED' | 'QUERIED' | null; note: string | null; }>; allApproved: boolean; }> { const booking = await this.bookingsService.findById(bookingId); const { inputCode, outputCode, includesCustoms } = clearanceCodesForBooking(booking); const files = await this.filesService.findByResource(bookingId, 'bookings'); const fileByCode = new Map(files.map((f) => [f.code, f])); const reviews = await this.bookingsRepository.findDocumentReviews(bookingId); const reviewByKey = new Map( reviews.map((r) => [`${r.settingCode}:${r.fileKey}`, r]), ); const documents: Awaited< ReturnType >['documents'] = []; const pushSetting = async ( code: string | null, uploadedBy: 'customer' | 'gl', ) => { if (!code) return; let setting; try { setting = await this.fileUploadSettingsService.getByCode(code); } catch { return; // setting not seeded — skip gracefully } for (const field of setting.fields ?? []) { const file = fileByCode.get(field.fileKey) ?? null; const review = reviewByKey.get(`${code}:${field.fileKey}`) ?? null; documents.push({ fileKey: field.fileKey, label: field.fileLabel, required: field.isRequired, uploadedBy, settingCode: code, file: file ? { id: file.id, name: file.name, url: file.url } : null, reviewStatus: review?.status ?? null, note: review?.note ?? null, }); } }; await pushSetting(inputCode, 'customer'); await pushSetting(outputCode, 'gl'); // Ad-hoc / unknown documents (code custom_*) appear alongside the seeded set. for (const f of files) { if (!f.code?.startsWith('custom_')) continue; const review = reviewByKey.get(`custom:${f.code}`) ?? null; documents.push({ fileKey: f.code, label: f.name, required: false, uploadedBy: 'customer', settingCode: 'custom', file: { id: f.id, name: f.name, url: f.url }, reviewStatus: review?.status ?? null, note: review?.note ?? null, }); } const allApproved = await this.isClearanceFullyApproved(booking); return { status: booking.status, includesCustoms, inputCode, outputCode, documents, allApproved, }; } /** * True when every REQUIRED field of the booking's customer-input clearance set * has an APPROVED review row. The 100% gate before clearance can be finalized. */ private async isClearanceFullyApproved(booking: Booking): Promise { const { inputCode } = clearanceCodesForBooking(booking); if (!inputCode) return true; // no gate applies (e.g. domestic) let setting; try { setting = await this.fileUploadSettingsService.getByCode(inputCode); } catch { return false; } const required = (setting.fields ?? []).filter((f) => f.isRequired); if (required.length === 0) return true; const reviews = await this.bookingsRepository.findDocumentReviews(booking.id); return required.every((field) => reviews.some( (r) => r.settingCode === inputCode && r.fileKey === field.fileKey && r.status === 'APPROVED', ), ); } /** * Customer uploads clearance documents. Each multipart file's fieldname is the * field's fileKey (or custom_ for ad-hoc). Saves FileRecords, refreshes the * per-document review rows to PENDING, and moves the booking into review. */ async submitClearanceDocuments( bookingId: string, files: Express.Multer.File[], ): Promise { const booking = await this.bookingsService.findById(bookingId); assertBookingStatus(booking, ['AWAITING_DOCUMENTS', 'DOCUMENTS_UNDER_REVIEW']); const { inputCode } = clearanceCodesForBooking(booking); if (!inputCode) { throw new BadRequestException('This booking has no document-clearance step'); } if (files.length === 0) { throw new BadRequestException('No documents uploaded'); } // First submission (nothing in review yet): every required input field must // be provided. Once review has started (DOCUMENTS_UNDER_REVIEW) the customer // is only fixing queried/pending docs, so the already-uploaded required docs // stay in place and we don't re-gate on the full required set. if (booking.status === 'AWAITING_DOCUMENTS') { await this.assertRequiredInputsPresent(bookingId, inputCode, files); } for (const file of files) { const record = await this.filesService.upsertByCode({ resourceId: bookingId, resource: 'bookings', code: file.fieldname, file, }); // Ad-hoc docs (custom_*) are not part of the required gate; still tracked. const settingCode = file.fieldname.startsWith('custom_') ? 'custom' : inputCode; await this.bookingsRepository.upsertDocumentReviewPending({ bookingId, settingCode, fileKey: file.fieldname, fileRecordId: record.id, }); } await this.bookingsRepository.update(bookingId, { status: 'DOCUMENTS_UNDER_REVIEW', } as never); return this.bookingsService.findById(bookingId); } /** * Guard for the first clearance submission: every required field of the * booking's customer-input set must be covered, either by a file already on * the booking or by one in this upload batch. Keeps the customer from starting * review with required documents missing. */ private async assertRequiredInputsPresent( bookingId: string, inputCode: string, files: Express.Multer.File[], ): Promise { let setting; try { setting = await this.fileUploadSettingsService.getByCode(inputCode); } catch { return; // setting not seeded — nothing to enforce } const required = (setting.fields ?? []).filter((f) => f.isRequired); if (required.length === 0) return; const existing = await this.filesService.findByResource(bookingId, 'bookings'); const presentKeys = new Set([ ...existing.map((f) => f.code), ...files.map((f) => f.fieldname), ]); const missing = required.filter((f) => !presentKeys.has(f.fileKey)); if (missing.length > 0) { const labels = missing.map((f) => f.fileLabel).join(', '); throw new BadRequestException( `Please upload all required documents before submitting: ${labels}`, ); } } /** GL reviews a single document: APPROVED or QUERIED (with a note). */ async reviewDocument( bookingId: string, fileKey: string, status: 'APPROVED' | 'QUERIED', staffId: string, note?: string, ): Promise { const booking = await this.bookingsService.findById(bookingId); assertBookingStatus(booking, ['DOCUMENTS_UNDER_REVIEW']); const { inputCode, outputCode } = clearanceCodesForBooking(booking); const existing = await this.bookingsRepository.findDocumentReviews(bookingId); const match = existing.find((r) => r.fileKey === fileKey); const settingCode = match?.settingCode ?? (fileKey.startsWith('custom_') ? 'custom' : (inputCode ?? outputCode ?? 'custom')); if (status === 'QUERIED' && !note?.trim()) { throw new BadRequestException('A note is required when querying a document'); } await this.bookingsRepository.setDocumentReviewStatus( bookingId, settingCode, fileKey, status, staffId, note, ); if (status === 'QUERIED') { await this.bookingsRepository.createReviewNote( bookingId, `Document "${fileKey}" queried: ${note}`, 'CHANGES_REQUESTED', staffId, ); } return this.bookingsService.findById(bookingId); } /** GL uploads the customs output documents (IM4/IM5/EX3/etc.). */ async uploadClearanceOutputDocuments( bookingId: string, files: Express.Multer.File[], ): Promise { const booking = await this.bookingsService.findById(bookingId); assertBookingStatus(booking, ['DOCUMENTS_UNDER_REVIEW']); const { outputCode } = clearanceCodesForBooking(booking); if (!outputCode) { throw new BadRequestException('This booking has no customs output documents'); } if (files.length === 0) { throw new BadRequestException('No documents uploaded'); } for (const file of files) { await this.filesService.upsertByCode({ resourceId: bookingId, resource: 'bookings', code: file.fieldname, file, }); } return this.bookingsService.findById(bookingId); } /** * GL confirms clearance: requires every customer document APPROVED (100% gate) * and, for customs, the required output documents present → CLEARANCE_READY. */ async finalizeClearance(bookingId: string): Promise { const booking = await this.bookingsService.findById(bookingId); assertBookingStatus(booking, ['DOCUMENTS_UNDER_REVIEW']); const approved = await this.isClearanceFullyApproved(booking); if (!approved) { throw new BadRequestException( 'All required documents must be approved before clearance can be finalized', ); } const { outputCode } = clearanceCodesForBooking(booking); if (outputCode) { const setting = await this.fileUploadSettingsService.getByCode(outputCode); const files = await this.filesService.findByResource(bookingId, 'bookings'); const uploaded = new Set(files.map((f) => f.code)); const missing = (setting.fields ?? []).filter( (f) => f.isRequired && !uploaded.has(f.fileKey), ); if (missing.length > 0) { throw new BadRequestException( `Upload all required customs output documents first: ${missing .map((m) => m.fileLabel) .join(', ')}`, ); } } await this.bookingsRepository.update(bookingId, { status: 'CLEARANCE_READY', } as never); return this.bookingsService.findById(bookingId); } /** * Customer proceeds to operation once clearance is ready. They pick the * schedule day (the train departure day) for the shipment; the request then * sits at OPERATION_REQUEST_PENDING for the operations team to review * (capacity, documents, route) before it enters the batch holding pool. * * Allowed from CLEARANCE_READY (first request) and OPERATION_CHANGES_REQUESTED * (resubmit after the operations team returned it for changes). */ async requestOperation( bookingId: string, scheduledDate: string, ): Promise { const booking = await this.bookingsService.findById(bookingId); assertBookingStatus(booking, ['CLEARANCE_READY', 'OPERATION_CHANGES_REQUESTED']); const date = new Date(scheduledDate); if (Number.isNaN(date.getTime())) { throw new BadRequestException('A valid schedule date is required'); } // The binding shipment day must have at least one OPEN departure on the // route — only schedule-backed days are selectable. The batch engine // assigns the specific train within that (route, day) pool later. const hasDeparture = await this.bookingsService.hasOpenDepartureOnDay( booking.originYardId, booking.destinationYardId, eatDay(date), ); if (!hasDeparture) { throw new BadRequestException( 'No departures available on the selected day for this route', ); } await this.bookingsRepository.update(bookingId, { status: 'OPERATION_REQUEST_PENDING', scheduledDate: date, } as never); return this.bookingsService.findById(bookingId); } /** * Operations team reviews a pending operation request (capacity, documents, * route). Three outcomes: * - ACCEPT → booking enters the batch holding pool (FULLY_EXECUTED). * - REQUEST_CHANGES → returned to the customer with a note to fix and resubmit. * - ADJUST_PRICE → a new total is set; the customer must re-confirm it * before the booking can enter the pool. */ async reviewOperationRequest( bookingId: string, decision: 'ACCEPT' | 'REQUEST_CHANGES' | 'ADJUST_PRICE', actorId: string, options: { note?: string; amount?: number } = {}, ): Promise { const booking = await this.bookingsService.findById(bookingId); assertBookingStatus(booking, ['OPERATION_REQUEST_PENDING']); if (decision === 'REQUEST_CHANGES') { if (!options.note?.trim()) { throw new BadRequestException( 'A note is required when requesting changes', ); } await this.bookingsRepository.createReviewNote( bookingId, options.note, 'CHANGES_REQUESTED', actorId, ); await this.bookingsRepository.update(bookingId, { status: 'OPERATION_CHANGES_REQUESTED', } as never); return this.bookingsService.findById(bookingId); } if (decision === 'ADJUST_PRICE') { if (options.amount == null || options.amount < 0) { throw new BadRequestException( 'A non-negative adjusted amount is required to adjust the price', ); } await this.bookingsRepository.update(bookingId, { adjustedTotalAmount: options.amount, adjustedByStaffId: actorId, adjustedAt: new Date(), adjustmentReason: options.note ?? null, status: 'OPERATION_PRICE_PENDING_CONFIRM', } as never); return this.bookingsService.findById(bookingId); } // ACCEPT — enter the batch holding pool. return this.acceptOperationRequest(booking); } /** * Customer re-confirms (or rejects) an operations price adjustment. Accepting * pushes the booking into the pool; rejecting returns it to the customer as an * operation change request so they can resubmit or cancel. */ async confirmOperationPrice( bookingId: string, accept: boolean, ): Promise { const booking = await this.bookingsService.findById(bookingId); assertBookingStatus(booking, ['OPERATION_PRICE_PENDING_CONFIRM']); if (!accept) { await this.bookingsRepository.update(bookingId, { status: 'OPERATION_CHANGES_REQUESTED', } as never); return this.bookingsService.findById(bookingId); } return this.acceptOperationRequest(booking); } /** * Move a reviewed operation request forward after Marketing accepts. * * - Train services enter the batch holding pool: the pool query * (findBatchPoolByRouteDay) keys on FULLY_EXECUTED + scheduled_date, so we * set those and kick the day-level fill immediately instead of waiting for * cron. * - Road (truck) services skip the train batch entirely and wait for truck * dispatch at ROAD_DISPATCH_PENDING; they are billed by KM, not wagons. */ private async acceptOperationRequest(booking: Booking): Promise { const now = new Date(); if (isRoadService(booking.serviceType)) { await this.bookingsRepository.update(booking.id, { status: 'ROAD_DISPATCH_PENDING', fullyExecutedAt: now, lockedAt: booking.lockedAt ?? now, } as never); return this.bookingsService.findById(booking.id); } await this.bookingsRepository.update(booking.id, { status: 'FULLY_EXECUTED', fullyExecutedAt: now, lockedAt: booking.lockedAt ?? now, } as never); if (booking.scheduledDate) { this.bookingBatchService.enqueueRouteDayProcessing( booking.originYardId, booking.destinationYardId, eatDay(new Date(booking.scheduledDate)), ); } return this.bookingsService.findById(booking.id); } async enrichBookingResponse(booking: Booking): Promise { 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, }; } }