import { BadRequestException, forwardRef, Inject, Injectable, Logger, Optional, } 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 './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 { BookingLifecycleNotifierService } from './booking-lifecycle-notifier.service'; import { BookingPricingService } from './booking-pricing.service'; import { ContainerValidationService } from './container-validation.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'; import { BookingClearanceService } from '../contracts/booking-clearance.service'; import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service'; import { ClearanceWorkflowService } from '../contracts/clearance-workflow.service'; import { ContractDocPhase } from '@edr/types'; import { Freight } from "@edr/types"; import { BookingInvoiceService } from "./booking-invoice.service"; @Injectable() export class BookingTransitionService { private readonly logger = new Logger(BookingTransitionService.name); 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, @Inject(forwardRef(() => BookingClearanceService)) private readonly bookingClearanceService: BookingClearanceService, @Inject(forwardRef(() => ClearanceWorkflowService)) private readonly workflowService: ClearanceWorkflowService, private readonly invoiceService: BookingInvoiceService, private readonly containerValidationService: ContainerValidationService, private readonly notifier: BookingLifecycleNotifierService, @Optional() private readonly milestoneService?: ClearanceMilestoneService, ) {} private isPhasedGeneralCustoms(booking: Booking): boolean { return this.bookingClearanceService.isPhasedGeneralCustomsBooking(booking); } /** Reject submit when the booking's 20ft containers can't be balanced onto wagons. */ private async assert20ftPairable(booking: Booking): Promise { const violations = await this.containerValidationService.validate20ftPairing(booking); if (violations.length) { throw new BadRequestException( `Cannot submit — 20ft containers cannot be paired on wagons: ${violations .map((v) => v.message) .join(' ')}`, ); } } 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, }); // 20ft weight-pairing hard block: two 20ft on a wagon must differ ≤ the cap. // If no balanced pairing exists the booking cannot proceed (overweight only // warns; this rejects). An odd leftover 20ft is fine — it goes to consolidation. await this.assert20ftPairable(booking); 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, ); if (finalBooking.status === "SUBMITTED") { this.notifier.submittedToStaff(finalBooking); } 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.assert20ftPairable(booking); 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, ); if (finalBooking.status === "SUBMITTED") { this.notifier.submittedToStaff(finalBooking); } 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); const fresh = await this.bookingsService.findById(updated!.id); this.notifier.changesRequested(fresh, note); return fresh; } /** 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); const fresh = await this.bookingsService.findById(updated!.id); this.notifier.accepted(fresh); return fresh; } 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); const fresh = await this.bookingsService.findById(updated!.id); this.notifier.rejected(fresh, reason); return fresh; } 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); const fresh = await this.bookingsService.findById(generated.id); this.notifier.approved(fresh); return fresh; } 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); const fresh = await this.bookingsService.findById(updated!.id); this.notifier.rejected(fresh, reason); return fresh; } 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); const fresh = await this.bookingsService.findById(updated!.id); this.notifier.customerSignedToStaff(fresh); return fresh; } 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); const fresh = await this.bookingsService.findById(updated!.id); this.notifier.inTransit(fresh); return fresh; } 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); const fresh = await this.bookingsService.findById(updated!.id); this.notifier.completed(fresh); // Customer tracking: close out the tail milestones so a finished shipment // never shows a forever-pending timeline. EXIT_NOTE/PROCESS_COMPLETED are // implied by delivery; a storage invoice that was never raised is skipped // (storage billing does not apply to every shipment). All doc-trigger / // best-effort — a booking without milestone rows is untouched. if (this.milestoneService) { for (const code of ["IMPORT_PROCESS_COMPLETED", "EXIT_NOTE_GENERATED"]) { try { await this.milestoneService.completeByDocTrigger({ bookingId }, code); } catch { /* tracking must never block completion */ } } try { await this.milestoneService.skipForBooking(bookingId, "STORAGE_INVOICE_RAISED"); } catch { /* no such milestone row (export / non-customs) — fine */ } } return fresh; } 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", "OPERATION_REQUEST_PENDING", ]); await this.bookingsRepository.createReviewNote( bookingId, reason, "REJECTION", ); const updated = await this.bookingsRepository.update(bookingId, { status: "CANCELLED", } as never); const fresh = await this.bookingsService.findById(updated!.id); this.notifier.cancelled(fresh, reason); return fresh; } /** * 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); } // ── 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; phase?: string | null; milestones?: unknown[]; nextAction?: unknown; dutyRequired?: boolean | null; roHold?: boolean; roHoldReason?: string | null; vesselDepartureDate?: string | null; operationReady?: boolean; }> { const booking = await this.bookingsService.findById(bookingId); if (this.isPhasedGeneralCustoms(booking)) { return this.bookingClearanceService.getClearanceView(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); if (this.isPhasedGeneralCustoms(booking)) { await this.workflowService.onCustomerDocsUploadedForBooking( bookingId, booking.tradeDirection ?? 'IMPORT', ); await this.workflowService.onDocumentReviewReopenedForBooking(bookingId); await this.bookingsRepository.update(bookingId, { clearanceCurrentPhase: ContractDocPhase.GlEtReview, } as never); } const fresh = await this.bookingsService.findById(bookingId); this.notifier.clearanceDocsUploadedToStaff(fresh); return fresh; } /** * 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", ); } if ( status === 'QUERIED' && this.isPhasedGeneralCustoms(booking) && booking.preClearanceFinalizedAt ) { throw new BadRequestException( 'Customer documents cannot be queried after pre-clearance is finalized.', ); } 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, ); if (this.isPhasedGeneralCustoms(booking)) { await this.workflowService.onDocumentReviewReopenedForBooking(bookingId); await this.bookingsRepository.update(bookingId, { clearanceCurrentPhase: ContractDocPhase.GlEtReview, } as never); } } const updated = await this.bookingsService.findById(bookingId); if (status === "QUERIED") { this.notifier.documentQueried(updated, fileKey, note ?? ''); } if (this.isPhasedGeneralCustoms(updated)) { const allApproved = await this.isClearanceFullyApproved(updated); if (allApproved) { await this.workflowService.onAllDocsApprovedForBooking(bookingId); const phase = updated.tradeDirection === 'EXPORT' ? ContractDocPhase.GlDjCollection : ContractDocPhase.GlEtOutput; await this.bookingsRepository.update(bookingId, { clearanceCurrentPhase: phase, } as never); } } return updated; } /** 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); if (this.isPhasedGeneralCustoms(booking)) { throw new BadRequestException( 'General customs bookings use phased clearance — complete milestones via the phased actions instead of finalize.', ); } 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); const fresh = await this.bookingsService.findById(bookingId); this.notifier.clearanceReady(fresh); return fresh; } /** * 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); const fresh = await this.bookingsService.findById(bookingId); this.notifier.operationRequestedToStaff(fresh); return fresh; } /** * Operations team reviews a pending operation request (capacity, documents, * route). Two outcomes: * - ACCEPT → booking enters the batch holding pool (FULLY_EXECUTED). * - REQUEST_CHANGES → returned to the customer with a note to fix and resubmit. * * The booking price is computed from the contract and is never adjusted here. */ async reviewOperationRequest( bookingId: string, decision: "ACCEPT" | "REQUEST_CHANGES", actorId: string, options: { note?: string } = {}, ): 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); const fresh = await this.bookingsService.findById(bookingId); this.notifier.operationChangesRequested(fresh, options.note); return fresh; } // ACCEPT — enter the batch holding pool. 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(); // Export is FCFS: fail the accept up-front (409) when no export train on the // booking's day still has capacity — nothing below runs and the request stays // pending for staff to move/decline. (For a consolidated pair this is a rough // solo pre-check; the real combined-capacity reservation happens after the // booking is FULLY_EXECUTED, once both partners are ready.) const isExportTrain = booking.tradeDirection === "EXPORT" && !isRoadService(booking.serviceType); if (isExportTrain) { await this.bookingBatchService.pickExportSchedule(booking); } const invoice = await this.invoiceService.ensureInvoiceForBooking(booking); this.logger.log( `Generated invoice ${invoice.invoiceNumber} (${invoice.id}) for ${booking.reference}:${booking.id}`, ); await this.invoiceService.updateStatus( invoice.id, Freight.InvoiceStatus.Pending, ); if (isRoadService(booking.serviceType)) { await this.bookingsRepository.update(booking.id, { status: "ROAD_DISPATCH_PENDING", fullyExecutedAt: now, lockedAt: booking.lockedAt ?? now, } as never); const roadFresh = await this.bookingsService.findById(booking.id); this.notifier.operationAccepted(roadFresh); return roadFresh; } await this.bookingsRepository.update(booking.id, { status: "FULLY_EXECUTED", fullyExecutedAt: now, lockedAt: booking.lockedAt ?? now, } as never); if (isExportTrain) { // FCFS: reserve the slot and send the payment notification immediately; // paid → auto-allocated by the settle/paid pipeline. Consolidated bookings // only reserve once both partners are FULLY_EXECUTED (handled inside). const fresh = await this.bookingsService.findById(booking.id); try { await this.bookingBatchService.acceptExportBooking(fresh); } catch (err) { // The status update above already committed. Without compensation the // client gets an error for a booking that reads as accepted after a // refresh — half-applied state. Put the request back so staff can retry. await this.bookingsRepository.update(booking.id, { status: "OPERATION_REQUEST_PENDING", fullyExecutedAt: null, lockedAt: booking.lockedAt ?? null, } as never); this.logger.warn( `Export accept failed post-commit for ${booking.reference}:${booking.id}; reverted to OPERATION_REQUEST_PENDING: ${(err as Error).message}`, ); throw err; } } // IMPORT and DOMESTIC bookings wait for their booking-day window cycle — the // batch runs after the window closes + staff document review, never at accept // time. (Legacy pre-migration schedules with no window phase are still served // by the periodic legacy fill.) const trainFresh = await this.bookingsService.findById(booking.id); this.notifier.operationAccepted(trainFresh); return trainFresh; } async enrichBookingResponse(booking: Booking): Promise< Booking & { latestChangeRequestNote?: string | null; contractSummary?: string | null; nextStep: BookingNextStep | null; activeBatchOffer?: { offeredWagons: number; totalWagons: number; offeredAmount: number; paymentDeadline: Date; } | null; /** Flat list of physical container numbers on this booking (for the * customer truck-assignment container picker). */ containerNumbers: string[]; } > { // This enrichment runs AFTER the transition has committed. A failure here // must never 500 the response — the client would report "failed" for a // transition that actually succeeded (visible only after a refresh). // Degrade each fragile field to null instead. let note: Awaited< ReturnType > = null; try { note = await this.bookingsRepository.findLatestReviewNote( booking.id, "CHANGES_REQUESTED", ); } catch (err) { this.logger.warn( `enrichBookingResponse: review-note lookup failed for ${booking.id}: ${(err as Error).message}`, ); } let summary: string | null = booking.contractSummary ?? null; try { summary = booking.contractSummary ?? this.contractService.buildContractSummary(booking); } catch (err) { this.logger.warn( `enrichBookingResponse: contract summary failed for ${booking.id}: ${(err as Error).message}`, ); } let nextStep: BookingNextStep | null = null; try { const nextPending = booking.status === "PENDING_APPROVAL" || booking.status === "APPROVED_PENDING_SIGNATURE" ? await this.bookingsRepository.findNextPendingApprovalStep(booking.id) : null; nextStep = computeNextStep(booking, nextPending); } catch (err) { this.logger.warn( `enrichBookingResponse: next-step lookup failed for ${booking.id}: ${(err as Error).message}`, ); } let activeBatchOffer: Awaited< ReturnType > = null; try { activeBatchOffer = booking.status === "SELECTED_FOR_BATCH" ? await this.bookingBatchService.getOpenOfferSummary(booking.id) : null; } catch (err) { this.logger.warn( `enrichBookingResponse: batch-offer lookup failed for ${booking.id}: ${(err as Error).message}`, ); } // Physical container numbers entered at booking time (booking_container // units), flattened for the customer truck-assignment container picker. const containerNumbers = (booking.bookingContainers ?? []) .flatMap((bc) => bc.units ?? []) .map((unit) => unit.containerNumber) .filter((n): n is string => Boolean(n)); return { ...booking, latestChangeRequestNote: note?.note ?? null, contractSummary: summary, nextStep, activeBatchOffer, containerNumbers, }; } }