import { BadRequestException, ConflictException, forwardRef, Inject, Injectable, Logger, Optional, } from "@nestjs/common"; import { EventEmitter2, OnEvent } from "@nestjs/event-emitter"; import { DataSource } from "typeorm"; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; import { BookingBatchService, type ExportTrainOption, } 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 { adHocLabel, clearanceCodesForBooking, clearanceDocumentsOpen, } from './clearance.util'; import { buildClearanceDocHistory, type ClearanceDocEvent, } from './clearance-doc-history.util'; import { ClearanceEventService } from './clearance-event.service'; 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 { BookingInvoiceService } from "./booking-invoice.service"; // Type-only: the DI edge stays event-based to keep the module graph acyclic. import type { ShippingLineBookingAcceptedPayload } from "../shipping-lines/shipping-line-credits.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, @Inject(forwardRef(() => BookingContractService)) 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, // forwardRef: booking-invoice.service now pulls in the wagon-cancellation // service, whose cross-module imports close a require cycle through this // file — without it the class is undefined at decorator time. @Inject(forwardRef(() => BookingInvoiceService)) private readonly invoiceService: BookingInvoiceService, private readonly containerValidationService: ContainerValidationService, private readonly notifier: BookingLifecycleNotifierService, private readonly clearanceEvents: ClearanceEventService, private readonly events: EventEmitter2, @Optional() private readonly milestoneService?: ClearanceMilestoneService, // Optional + last so the hand-constructed service in *.spec.ts files keeps // compiling; Nest injects it normally at runtime. @Optional() private readonly dataSource?: DataSource, ) {} private isPhasedCustoms(booking: Booking): boolean { return this.bookingClearanceService.isPhasedCustomsBooking(booking); } /** Reject submit when the booking's 20ft containers can't be balanced onto wagons. */ private async assert20ftPairable(booking: Booking): Promise { // Odd 20ft totals are not rejected here: runConsolidationOnSubmit (called // right after this gate) auto-pairs the odd leftover with another // customer's odd booking or parks the booking as PENDING_CONSOLIDATION. // Only the weight-pairing rule hard-blocks. 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; } 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); // Bookings no longer run a multi-step approval chain — accepting the intake // approves the booking outright and generates its contract. (The approval // chain is a contract-only concern now; see contract-transition.service.) await this.bookingsRepository.update(bookingId, { status: "APPROVED", approvedByStaffId: actorId, approvedByStaffAt: validFrom, contractValidityDays: validityDays, contractValidFrom: validFrom, contractValidUntil: validUntil, } as never); // Generating the contract is best-effort: the acceptance is already // committed, so a failure here must not roll it back. The booking stays // APPROVED and staff can retry generation from the booking page. try { await this.contractService.generateContract(bookingId); } catch (err) { this.logger.warn( `Contract generation failed after accepting booking ${bookingId}: ${err}. ` + `The booking is APPROVED — retry generation from the booking page.`, ); } const fresh = await this.bookingsService.findById(bookingId); this.notifier.accepted(fresh); this.notifier.approved(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 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); // Paid is read from the PAYMENT status only; the booking status merely // guards against re-entering transit from a later stage. if (booking.paymentStatus !== "PAID") { throw new ConflictException( `Booking must be paid before it can start transit (payment status "${booking.paymentStatus ?? "PENDING"}")`, ); } assertBookingStatus(booking, [ "PAID", "FULLY_EXECUTED", "PNR_GENERATED", "WAGON_ASSIGNED", "READY_FOR_ASSIGNMENT", "APPROVED", ]); 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; } /** * Import EDR last-mile: every handover signed + every truck departed ⇒ the * warehouses module delivered the goods and asks the booking to complete. * Best-effort — a booking already COMPLETED (or not yet in transit) just logs. */ @OnEvent('import.handover.completed') async onImportHandoverCompleted(payload: { bookingId: string }): Promise { try { await this.complete(payload.bookingId); } catch (err) { this.logger.log( `Booking ${payload.bookingId} not auto-completed on handover sign: ${(err as Error).message}`, ); } } async complete(bookingId: string): Promise { const booking = await this.bookingsService.findById(bookingId); assertBookingStatus(booking, ["IN_TRANSIT", "ARRIVED"]); 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); // A ONE_TIME contract closes on its single shipment being delivered. this.events.emit('booking.completed', { bookingId }); // 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; } /** * Customer cancels their own unpaid hold (SELECTED_FOR_BATCH): the wagons * release immediately instead of tying up the train until the pay window * lapses. Ends CANCELLED; the freed capacity tops up from the waiting list. */ async cancelHold(bookingId: string, reason?: string): Promise { const booking = await this.bookingsService.findById(bookingId); assertBookingStatus(booking, ["SELECTED_FOR_BATCH"]); // Consolidated pair: the shared wagon dies with this hold. An unpaid // partner's hold is released with it (both cancel, no fee); a PAID partner // cannot board alone, so the partnerLapsed listener cancels it too, with // the cancellation fee — this unpaid canceller owes nothing (fees only // apply to paid bookings). const partnerId = booking.consolidationPartnerId; if (partnerId) { const partner = await this.bookingsService.findById(partnerId); const partnerPaid = partner.paymentStatus === "PAID" || partner.status === "PAID"; await this.bookingsRepository.clearConsolidationPair( booking.id, partnerId, ); if (partnerPaid) { this.events.emit("booking.consolidation.partnerLapsed", { paidBookingId: partnerId, }); } else if (!["CANCELLED", "EXPIRED"].includes(partner.status)) { const partnerReason = "Cancelled with its consolidation partner"; await this.bookingsRepository.createReviewNote( partnerId, partnerReason, "REJECTION", ); if (partner.status === "SELECTED_FOR_BATCH") { await this.bookingBatchService.cancelReservation(partnerId); } else { await this.invoiceService.expireOpenInvoices(partnerId); await this.bookingsRepository.update(partnerId, { status: "CANCELLED", } as never); } this.notifier.cancelled( await this.bookingsService.findById(partnerId), partnerReason, ); } } await this.bookingsRepository.createReviewNote( bookingId, reason ?? "Customer cancelled before payment", "REJECTION", ); await this.bookingBatchService.cancelReservation(bookingId); const fresh = await this.bookingsService.findById(bookingId); this.notifier.cancelled(fresh, reason ?? "Cancelled before payment"); return fresh; } /** * Customer self-service cancel, allowed only before payment — no fee. * SELECTED_FOR_BATCH releases the wagon hold immediately; earlier statuses * take the plain cancel path (open invoices expired, nothing reserved yet). * Anything past payment falls through to cancel()'s status assertion. */ async customerCancel(bookingId: string, reason?: string): Promise { const booking = await this.bookingsService.findById(bookingId); if (booking.status === "SELECTED_FOR_BATCH") { return this.cancelHold(bookingId, reason); } return this.cancel(bookingId, reason ?? "Customer cancelled before payment"); } /** * Run a staff decision across BOTH halves of a consolidated pair. * * Two bookings that share a wagon must move together: accepting one while the * other stays behind would put half a wagon into the approval chain, and * cancelling one alone would strand the other on a wagon it can no longer * fill. All-or-nothing — if either half throws, the transaction rolls back and * neither booking moved. * * Each half still runs the ordinary single-booking transition, so pricing, * invoicing and notifications stay per booking: the customers are billed and * notified separately, exactly as they are today. */ async applyPairedDecision( bookingId: string, decision: "accept" | "cancel" | "operationAccept" | "requestChanges", actorId: string, options: { reason?: string; note?: string; validityDays?: number } = {}, ): Promise<{ booking: Booking; partner: Booking }> { const booking = await this.bookingsService.findById(bookingId); const partnerId = booking.consolidationPartnerId; if (!partnerId) { throw new BadRequestException( "This booking has no consolidation partner — use the single-booking action.", ); } // cancel() carries its own pair cascade (it settles the partner too), so // running it twice would trip on the already-cancelled partner. if (decision === "cancel") { const own = await this.cancel( bookingId, options.reason ?? "Cancelled with its consolidation partner", ); const other = await this.bookingsService.findById(partnerId); return { booking: own, partner: other }; } const runOne = async (id: string): Promise => { switch (decision) { case "accept": // Same requirement as the single-booking accept: the approval chain // needs a contract validity window. if (!(Number(options.validityDays) > 0)) { throw new BadRequestException( "Contract validity (days) is required to accept.", ); } return this.acceptIntake(id, actorId, Number(options.validityDays)); case "operationAccept": return this.reviewOperationRequest(id, "ACCEPT", actorId, { note: options.note, }); case "requestChanges": return this.requestChanges(id, options.note ?? "", actorId); } }; // Without a DataSource (unit tests hand-construct this service) fall back to // running the two halves directly — the ordering guarantee still holds, only // the rollback does not. if (!this.dataSource) { const own = await runOne(bookingId); const other = await runOne(partnerId); return { booking: own, partner: other }; } return this.dataSource.transaction(async () => { // Sequential: one connection per transaction context. const own = await runOne(bookingId); const other = await runOne(partnerId); return { booking: own, partner: other }; }); } 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", // A booking parked waiting for a consolidation partner can be walked // away from — nothing is reserved yet. "PENDING_CONSOLIDATION", ]); // Consolidated pair: a shared wagon never ships half-full, so cancelling // one half settles the other too. Neither paid → both cancel, no fee. A // PAID partner cannot board alone, so the partnerLapsed listener cancels // it too, with the cancellation fee — the unpaid canceller owes nothing // (fees only apply to paid bookings). A PAID booking itself never comes // through here (status gate above) — it cancels via wagon cancellation, // where the fee machinery lives. const partnerId = booking.consolidationPartnerId; if (partnerId) { const partner = await this.bookingsService.findById(partnerId); const partnerPaid = partner.paymentStatus === "PAID" || partner.status === "PAID"; await this.bookingsRepository.clearConsolidationPair( booking.id, partnerId, ); if (partnerPaid) { this.events.emit("booking.consolidation.partnerLapsed", { paidBookingId: partnerId, }); } else if (!["CANCELLED", "EXPIRED"].includes(partner.status)) { const partnerReason = "Cancelled with its consolidation partner"; await this.bookingsRepository.createReviewNote( partnerId, partnerReason, "REJECTION", ); await this.invoiceService.expireOpenInvoices(partnerId); if (partner.status === "SELECTED_FOR_BATCH") { // Reserved hold: release the wagons through the batch engine. await this.bookingBatchService.cancelReservation(partnerId); } else { await this.bookingsRepository.update(partnerId, { status: "CANCELLED", } as never); } this.notifier.cancelled( await this.bookingsService.findById(partnerId), partnerReason, ); } } await this.bookingsRepository.createReviewNote( bookingId, reason, "REJECTION", ); // Stop the open-invoice leak: a cancelled booking must not leave a payable // invoice open. Mirror the pay-window-expiry path (billing.expirePayable). await this.invoiceService.expireOpenInvoices(bookingId); 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", ); // Stop the open-invoice leak: a rejected booking must not leave a payable // invoice open. Mirror the pay-window-expiry path (billing.expirePayable). await this.invoiceService.expireOpenInvoices(bookingId); 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; uploadedAt: string | null; reviewedAt: string | null; reviewedByName: string | null; history: ClearanceDocEvent[]; }>; allApproved: boolean; documentsOpen: boolean; docRequests: Array<{ id: string; note: string; byName: string | null; at: string; }>; 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.isPhasedCustoms(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 allVersions = await this.filesService.findAllVersionsByResource( bookingId, "bookings", ); const queryNotes = await this.bookingsRepository.findReviewNotes( bookingId, "CHANGES_REQUESTED", ); const docRequestNotes = await this.bookingsRepository.findReviewNotes( bookingId, "ADDITIONAL_DOC_REQUEST", ); const reviewerNames = await this.bookingsRepository.resolveStaffNames([ ...reviews.map((r) => r.reviewedByStaffId), ...queryNotes.map((n) => n.authorId), ...docRequestNotes.map((n) => n.authorId), ]); 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, uploadedAt: file?.createdAt ? file.createdAt.toISOString() : null, reviewedAt: review?.reviewedAt ? review.reviewedAt.toISOString() : null, reviewedByName: review?.reviewedByStaffId ? (reviewerNames.get(review.reviewedByStaffId) ?? null) : null, history: buildClearanceDocHistory({ fileKey: field.fileKey, allVersions, queryNotes, review, names: reviewerNames, }), }); } }; 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, // What the customer called it, falling back to the filename for rows // uploaded before the name was carried through. label: f.title || adHocLabel(f.code) || 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, uploadedAt: f.createdAt ? f.createdAt.toISOString() : null, reviewedAt: review?.reviewedAt ? review.reviewedAt.toISOString() : null, reviewedByName: review?.reviewedByStaffId ? (reviewerNames.get(review.reviewedByStaffId) ?? null) : null, history: buildClearanceDocHistory({ fileKey: f.code, allVersions, queryNotes, review, names: reviewerNames, }), }); } const allApproved = await this.isClearanceFullyApproved(booking); return { status: booking.status, includesCustoms, inputCode, outputCode, documents, allApproved, documentsOpen: clearanceDocumentsOpen(booking), docRequests: docRequestNotes.map((n) => ({ id: n.id, note: n.note, byName: n.authorId ? (reviewerNames.get(n.authorId) ?? null) : null, at: n.createdAt.toISOString(), })), }; } /** * GL asks the customer for additional clearance document(s). Stored as a * review-note thread shown on both the GL clearance page and the customer's * portal; the customer answers with an ad-hoc upload. Allowed for as long as * documents are open (until the shipment is paid). */ async requestAdditionalDocuments( bookingId: string, note: string, staffId: string, ): Promise { const booking = await this.bookingsService.findById(bookingId); if (!clearanceDocumentsOpen(booking)) { throw new ConflictException( `Clearance documents are closed for this booking (status "${booking.status}").`, ); } if (!note?.trim()) { throw new BadRequestException("Describe the document(s) you need."); } await this.bookingsRepository.createReviewNote( bookingId, note.trim(), "ADDITIONAL_DOC_REQUEST", staffId, ); await this.clearanceEvents.record({ bookingId, action: "ADDITIONAL_DOCS_REQUESTED", label: "Requested additional document(s) from the customer", actorId: staffId, metadata: { note: note.trim() }, }); this.notifier.additionalDocsRequested(booking, note.trim()); } /** * 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[], userId?: string, ): Promise { const booking = await this.bookingsService.findById(bookingId); // Documents stay open until the shipment is paid — a customs shipment keeps // collecting paperwork (amended invoices, port documents) well past // clearance finalization. See {@link clearanceDocumentsOpen}. if (!clearanceDocumentsOpen(booking)) { throw new ConflictException( `Clearance documents are closed for this booking (status "${booking.status}").`, ); } 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 uploads carry the name the customer typed (fieldname // `custom_