import { BadRequestException, Injectable } from '@nestjs/common'; import { ContractDocPhase, isDeliveryOrderFileCode, isDraftDeclarationFileCode, type ClearanceFinalInvoiceSummary, type ClearanceOffloadState, type ClearanceSecondDuty, type ClearanceT1State, type ClearanceTrainState, type GlExchangeDocument, } from '@edr/types'; import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service'; import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service'; import { FilesService } from '../files/files.service'; import { BookingsRepository } from '../bookings/bookings.repository'; import { BookingsService } from '../bookings/bookings.service'; import { BookingLifecycleNotifierService } from '../bookings/booking-lifecycle-notifier.service'; import { ClearanceMilestone, type RiskAssignmentRecord, } from './entities/clearance-milestone.entity'; import { Booking } from '../bookings/entities/booking.entity'; import { clearanceCodesForBooking } from '../bookings/clearance.util'; import { assertDoCollectionDates } from './contract-clearance.util'; import { ClearanceWorkflowService } from './clearance-workflow.service'; import { ClearanceMilestoneService } from './clearance-milestone.service'; import { GlOperationsService } from './gl-operations.service'; import { GlExchangeService } from './gl-exchange.service'; import { TransitAgentsService } from '../transit-agents/transit-agents.service'; import { AdviseContractDutyDto } from './dto/phased-clearance.dto'; import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_BOOKING_QUEUE_STATUSES, persistDeclarationUploads, persistDeliveryOrderUploads, persistDraftDeclarationUploads, persistReleaseOrderUploads, persistTransitPermitUploads, PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES } from './phased-clearance.util'; const RO_VESSEL_MIN_DAYS_CODE = 'ro_vessel_min_days'; export interface BookingClearanceView { bookingId: string; 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?: Array<{ id: string; milestoneCode: string; milestoneLabel: string; status: string; ownerRegion?: string | null; metadata?: Record | null; sortOrder: number; }>; nextAction?: { actor: string; action: string; milestoneCode?: string | null; blockedReason?: string | null; } | null; dutyRequired?: boolean | null; roHold?: boolean; roHoldReason?: string | null; vesselDepartureDate?: string | null; /** Import DO dates recorded by GL Djibouti on upload. */ vesselArrivalDate?: string | null; doCollectedDate?: string | null; roAmendmentRequestedAt?: string | null; operationReady?: boolean; preClearanceFinalized?: boolean; /** * Pre-declaration handshake with GL Djibouti: who handles this shipment in * transit. `name` stays null until Djibouti answers, and GL Ethiopia cannot * file the import customs declaration before it is set. */ transitAssignee?: { requestedAt: string | null; requestNote: string | null; name: string | null; assignedAt: string | null; } | null; dutyAdvice?: { amount: number; currency: string; declarationSerial?: string | null; noticeFile?: { id: string; name: string; url: string } | null; } | null; /** * Import only: the draft customs declaration GL Ethiopia sends before filing * the real one. Present once a draft has been uploaded, regardless of * accept state — `accepted` tells the caller which. */ draftDeclaration?: { price: number; currency: string; files: Array<{ id: string; name: string; url: string }>; accepted: boolean; } | null; /** * The customer's open change request on the current draft declaration. * Present only until GL sends a corrected draft; `rounds` counts how many * times it has been sent back. */ draftDeclarationChangeRequest?: { note: string; raisedAt: string; rounds: number; } | null; workflowFiles?: ReturnType; /** Import post-allocation T1 transit document state (null until wagon allocation). */ t1?: ClearanceT1State | null; /** Train link state for the booking (both directions). */ train?: ClearanceTrainState | null; gatepassGranted?: boolean; gatepassAt?: string | null; t1Closed?: boolean; t1ClosedAt?: string | null; offloaded?: boolean; /** Offload stats for this booking (what came off the train, and where). */ offload?: ClearanceOffloadState | null; /** GL Djibouti post-offload final invoice (export). */ finalInvoice?: ClearanceFinalInvoiceSummary | null; /** Customs risk level assigned by GL ET (import; visible to the customer). */ riskLevel?: string | null; riskAssignedAt?: string | null; /** Every risk decision, oldest first; the last entry is the current level. */ riskHistory?: RiskAssignmentRecord[]; /** Post-arrival additional duty/tax round (import). */ secondDuty?: ClearanceSecondDuty | null; importReleaseGranted?: boolean; /** GL-shared documents this booking's uploader marked visible to the customer. */ exchangeDocuments?: GlExchangeDocument[]; } @Injectable() export class BookingClearanceService { constructor( private readonly bookingsRepository: BookingsRepository, private readonly bookingsService: BookingsService, private readonly filesService: FilesService, private readonly fileUploadSettingsService: FileUploadSettingsService, private readonly workflowService: ClearanceWorkflowService, private readonly milestoneService: ClearanceMilestoneService, private readonly dropdownSettingsService: DropdownSettingsService, private readonly glOperationsService: GlOperationsService, private readonly notifier: BookingLifecycleNotifierService, private readonly glExchangeService: GlExchangeService, private readonly transitAgentsService: TransitAgentsService, ) {} private async assertPhasedCustoms(booking: Booking): Promise { if (!booking.customsClearingEnabled) { throw new BadRequestException('Phased clearance applies only to customs bookings.'); } if (!booking.contractId) { throw new BadRequestException('Booking is not linked to a contract.'); } } private async loadBooking(bookingId: string): Promise { const booking = await this.bookingsService.findById(bookingId); await this.assertPhasedCustoms(booking); return booking; } async getClearanceView(bookingId: string): Promise { const booking = await this.loadBooking(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: BookingClearanceView['documents'] = []; const pushSetting = async (code: string | null, uploadedBy: 'customer' | 'gl') => { if (!code) return; let setting; try { setting = await this.fileUploadSettingsService.getByCode(code); } catch { return; } 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'); 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); let milestones = await this.workflowService.listMilestonesForBooking(bookingId); // Self-heal: a booking that has settled its freight payment must have // FREIGHT_PAYMENT_SETTLED completed. The batch settle path writes it, but an // export FCFS booking (linked to its train at booking time) paid via the // prepaid invoice can leave the milestone PENDING — the clearance "Payment & // wagon allocation" step then never ticks. Backfill it here so already-stuck // rows recover without a migration; idempotent (no-op once COMPLETED). const paymentSettled = milestones.find( (m) => m.milestoneCode === 'FREIGHT_PAYMENT_SETTLED', ); if ( paymentSettled && paymentSettled.status === 'PENDING' && (booking.paymentStatus === 'PAID' || booking.status === 'PAID') ) { await this.workflowService.completeMilestoneForBooking( bookingId, 'FREIGHT_PAYMENT_SETTLED', ); milestones = await this.workflowService.listMilestonesForBooking(bookingId); } const phase = this.workflowService.resolvePhaseForBooking(booking, milestones); const nextAction = this.workflowService.computeNextActionForBooking(booking, milestones); const boundary = await this.workflowService.isBoundaryCompleteForBooking( bookingId, booking.tradeDirection ?? 'IMPORT', ); const dutyAdvice = this.buildDutyAdvice(files, milestones); const draftDeclaration = this.buildDraftDeclaration(files, milestones); const draftDeclarationChangeRequest = await this.buildDraftDeclarationChangeRequest( bookingId, milestones, ); const workflowFiles = buildWorkflowFiles( files, booking.tradeDirection ?? 'IMPORT', ); let t1: ClearanceT1State | null = null; if ((booking.tradeDirection ?? 'IMPORT') === 'IMPORT') { try { t1 = await this.glOperationsService.t1State(bookingId); } catch { t1 = null; } } let train: ClearanceTrainState | null = null; try { train = await this.glOperationsService.trainState(bookingId); } catch { train = null; } const finalInvoice = await this.glOperationsService.finalInvoiceSummary(bookingId); const bookingMilestone = (code: string) => milestones.find((m) => m.milestoneCode === code); const gatepass = await this.glOperationsService.gatepassForBooking(bookingId); const t1ClosedMilestone = bookingMilestone('T1_CLOSED'); const riskMilestone = bookingMilestone('RISK_ASSIGNED'); const secondDuty = this.glOperationsService.secondDutyState(milestones, files); // GL↔GL exchange documents shared with the customer. The two desks may work // the thread on the booking (per-booking customs) or on its contract // (pre-booking clearance), so the customer's view spans both. const exchangeDocuments = await this.glExchangeService.listVisibleToCustomer( [bookingId, booking.contractId ?? ''], ); return { bookingId, status: booking.status, includesCustoms, inputCode, outputCode, documents, allApproved, phase, milestones: milestones.map((m) => ({ id: m.id, milestoneCode: m.milestoneCode, milestoneLabel: m.milestoneLabel, status: m.status, ownerRegion: m.ownerRegion, metadata: (m.metadata ?? null) as Record | null, sortOrder: m.sortOrder, })), nextAction, dutyRequired: booking.dutyRequired ?? null, roHold: Boolean(booking.roHoldReason), roHoldReason: booking.roHoldReason ?? null, vesselDepartureDate: booking.vesselDepartureDate ?? null, vesselArrivalDate: booking.vesselArrivalDate ?? null, doCollectedDate: booking.doCollectedDate ?? null, roAmendmentRequestedAt: booking.roAmendmentRequestedAt ? booking.roAmendmentRequestedAt.toISOString() : null, operationReady: boundary, preClearanceFinalized: Boolean(booking.preClearanceFinalizedAt), transitAssignee: { requestedAt: booking.transitAssigneeRequestedAt ? booking.transitAssigneeRequestedAt.toISOString() : null, requestNote: booking.transitAssigneeRequestNote ?? null, name: booking.transitAssigneeName ?? null, assignedAt: booking.transitAssigneeAssignedAt ? booking.transitAssigneeAssignedAt.toISOString() : null, }, dutyAdvice, draftDeclaration, draftDeclarationChangeRequest, workflowFiles, exchangeDocuments, t1, train, gatepassGranted: gatepass.granted, gatepassAt: gatepass.grantedAt, t1Closed: t1ClosedMilestone?.status === 'COMPLETED', t1ClosedAt: t1ClosedMilestone?.status === 'COMPLETED' && t1ClosedMilestone.triggeredAt ? t1ClosedMilestone.triggeredAt.toISOString() : null, offloaded: bookingMilestone('OFFLOADED')?.status === 'COMPLETED', offload: await this.glOperationsService.offloadState(bookingId, milestones), finalInvoice, riskLevel: riskMilestone?.status === 'COMPLETED' ? ((riskMilestone.metadata?.riskLevel as string | undefined) ?? null) : null, riskAssignedAt: riskMilestone?.status === 'COMPLETED' && riskMilestone.triggeredAt ? riskMilestone.triggeredAt.toISOString() : null, // Every risk decision, oldest first. `riskLevel`/`riskAssignedAt` above are // the current one; this is the trail behind it. riskHistory: riskMilestone?.status === 'COMPLETED' ? (riskMilestone.metadata?.riskHistory ?? []) : [], secondDuty, importReleaseGranted: bookingMilestone('IMPORT_RELEASE_GRANTED')?.status === 'COMPLETED', }; } private buildDutyAdvice( files: Array<{ code?: string | null; id: string; name: string; url: string }>, milestones: ClearanceMilestone[], ): BookingClearanceView['dutyAdvice'] { const advised = milestones.find( (m) => m.milestoneCode === 'DUTY_TAXES_ADVISED' && m.status === 'COMPLETED', ); if (!advised?.metadata) return null; const amount = advised.metadata.dutyAmount; const currency = advised.metadata.dutyCurrency; if (typeof amount !== 'number' || typeof currency !== 'string') return null; const notice = files.find((f) => f.code === 'duty_tax_notice'); return { amount, currency, declarationSerial: typeof advised.metadata.declarationSerial === 'string' ? advised.metadata.declarationSerial : null, noticeFile: notice ? { id: notice.id, name: notice.name, url: notice.url } : null, }; } private buildDraftDeclaration( files: Array<{ code?: string | null; id: string; name: string; url: string }>, milestones: ClearanceMilestone[], ): BookingClearanceView['draftDeclaration'] { const uploaded = milestones.find( (m) => m.milestoneCode === 'DRAFT_DECLARATION_UPLOADED' && m.status === 'COMPLETED', ); if (!uploaded?.metadata) return null; const price = uploaded.metadata.draftDeclarationPrice; const currency = uploaded.metadata.draftDeclarationCurrency; if (typeof price !== 'number' || typeof currency !== 'string') return null; const draftFiles = files .filter((f) => f.code && isDraftDeclarationFileCode(f.code)) .sort((a, b) => (a.code ?? '').localeCompare(b.code ?? '')) .map((f) => ({ id: f.id, name: f.name, url: f.url })); const accepted = milestones.find((m) => m.milestoneCode === 'DRAFT_DECLARATION_ACCEPTED')?.status === 'COMPLETED'; return { price, currency, files: draftFiles, accepted }; } private async buildDraftDeclarationChangeRequest( bookingId: string, milestones: ClearanceMilestone[], ): Promise { const uploaded = milestones.find((m) => m.milestoneCode === 'DRAFT_DECLARATION_UPLOADED'); if (!uploaded || uploaded.status === 'COMPLETED') return null; const notes = await this.bookingsRepository.findReviewNotes( bookingId, 'DRAFT_DECL_CHANGE_REQUEST', ); const latest = notes[0]; if (!latest) return null; return { note: latest.note, raisedAt: latest.createdAt.toISOString(), rounds: notes.length, }; } private async isClearanceFullyApproved(booking: Booking): Promise { const { inputCode } = clearanceCodesForBooking(booking); if (!inputCode) return true; 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', ), ); } /** Any contract booking (ONE_TIME or GENERAL) whose service bundles customs. */ isPhasedCustomsBooking(booking: Booking): boolean { return ( Boolean(booking.customsClearingEnabled) && Boolean(booking.contractId) ); } /** * GL Ethiopia asks Djibouti to name the officer who will handle this shipment * in transit. The import declaration is gated on the answer, so this is the * first thing ET does once the customer documents are approved. Re-requesting * is allowed (a nudge) and simply restamps the ask. */ async requestTransitAssignee( bookingId: string, note: string | undefined, ): Promise { const booking = await this.loadBooking(bookingId); await this.bookingsRepository.update(bookingId, { transitAssigneeRequestedAt: new Date(), transitAssigneeRequestNote: note?.trim() || null, } as never); this.notifier.transitAssigneeRequested(booking, note?.trim() ?? null); return this.bookingsService.findById(bookingId); } /** * GL Djibouti picks the transit officer from the admin-managed roster — * rejected unless the agent is active and inside its validity window. * Answering unblocks the declaration for Ethiopia. A later call overwrites * the name (reassignment) and re-notifies. */ async assignTransitAssignee(bookingId: string, transitAgentId: string): Promise { const booking = await this.loadBooking(bookingId); if (!booking.transitAssigneeRequestedAt) { throw new BadRequestException( 'GL Ethiopia has not requested a transit assignee for this shipment yet.', ); } const agent = await this.transitAgentsService.getAssignable(transitAgentId); const previous = booking.transitAssigneeName ?? null; await this.bookingsRepository.update(bookingId, { transitAssigneeName: agent.name, transitAssigneeAssignedAt: new Date(), } as never); this.notifier.transitAssigneeAssigned(booking, agent.name, previous); return this.bookingsService.findById(bookingId); } async uploadDeclaration( bookingId: string, files: Express.Multer.File[], userId?: string, ): Promise { const booking = await this.loadBooking(bookingId); const tradeDirection = booking.tradeDirection ?? 'IMPORT'; const allApproved = await this.isClearanceFullyApproved(booking); if (!allApproved) { throw new BadRequestException( 'All required customer documents must be approved before uploading a declaration.', ); } // Import only: the declaration is filed against whoever physically handles // the shipment in Djibouti, so that name must be in first. Exports have no // such handshake — their Djibouti steps come after the declaration. if (tradeDirection === 'IMPORT' && !booking.transitAssigneeName) { throw new BadRequestException( booking.transitAssigneeRequestedAt ? 'GL Djibouti has not assigned the transit officer yet — the declaration cannot be filed until they do.' : 'Request a transit assignee from GL Djibouti before filing the customs declaration.', ); } const milestones = await this.workflowService.listMilestonesForBooking(bookingId); const docsApproved = milestones.find((m) => m.milestoneCode === 'DOCUMENTS_APPROVED'); if (docsApproved?.status !== 'COMPLETED' && docsApproved?.status !== 'SKIPPED') { await this.workflowService.onAllDocsApprovedForBooking(bookingId); } await this.workflowService.assertPriorCompleteForBooking( bookingId, tradeDirection, 'UNDER_CUSTOMS_CLEARANCE', ); if (files.length === 0) { throw new BadRequestException('No declaration documents uploaded'); } await persistDeclarationUploads(this.filesService, bookingId, 'bookings', files); await this.workflowService.onDeclarationUploadedForBooking(bookingId, userId); await this.bookingsRepository.update(bookingId, { clearanceCurrentPhase: tradeDirection === 'EXPORT' ? ContractDocPhase.GlEtPostClearance : ContractDocPhase.CustomerDuty, } as never); return this.bookingsService.findById(bookingId); } async adviseDuty( bookingId: string, dto: AdviseContractDutyDto, userId?: string, attachment?: Express.Multer.File, ): Promise { const booking = await this.loadBooking(bookingId); if (booking.tradeDirection !== 'IMPORT') { throw new BadRequestException('Duty advice applies only to import bookings.'); } await this.workflowService.assertPriorCompleteForBooking( bookingId, 'IMPORT', 'DUTY_TAXES_ADVISED', ); await this.bookingsRepository.update(bookingId, { dutyRequired: dto.dutyRequired, clearanceCurrentPhase: dto.dutyRequired ? ContractDocPhase.CustomerDuty : ContractDocPhase.GlEtPostClearance, } as never); if (!dto.dutyRequired) { await this.workflowService.onDutySkippedForBooking(bookingId); } else { if (dto.amount == null || dto.amount < 0) { throw new BadRequestException('Duty amount is required when duty applies.'); } if (!attachment) { throw new BadRequestException('Duty notice attachment is required when duty applies.'); } await this.filesService.upsertByCode({ resourceId: bookingId, resource: 'bookings', code: 'duty_tax_notice', file: attachment, }); await this.milestoneService.adviseDuty( bookingId, { amount: dto.amount, currency: dto.currency ?? 'ETB', declarationSerial: dto.declarationSerial, }, userId, ); this.notifier.dutyAdvised(booking, dto.amount, dto.currency ?? 'ETB'); } return this.bookingsService.findById(bookingId); } /** * GL Ethiopia sends a draft customs declaration (estimated price + files) for * the customer to review before the real declaration is filed. Repeatable — * each call replaces the previous draft's files/price and re-arms the step, * which is what a re-send after a change request needs. */ async uploadDraftDeclaration( bookingId: string, files: Express.Multer.File[], price: number, currency: string, userId?: string, ): Promise { const booking = await this.loadBooking(bookingId); if (booking.tradeDirection !== 'IMPORT') { throw new BadRequestException('Draft declaration applies only to import bookings.'); } if (files.length === 0) { throw new BadRequestException('No draft declaration documents uploaded'); } if (!Number.isFinite(price) || price < 0) { throw new BadRequestException('A valid estimated price is required.'); } // Backfills the two new milestone rows for bookings seeded before this step // existed — a blind complete() 404s on a booking with no such row yet. await this.milestoneService.ensureBookingMilestones(bookingId, 'IMPORT'); await this.workflowService.assertPriorCompleteForBooking( bookingId, 'IMPORT', 'DRAFT_DECLARATION_UPLOADED', ); await persistDraftDeclarationUploads(this.filesService, bookingId, 'bookings', files); await this.milestoneService.completeWithMetadataForBooking( bookingId, 'DRAFT_DECLARATION_UPLOADED', { draftDeclarationPrice: price, draftDeclarationCurrency: currency }, userId, ); await this.bookingsRepository.update(bookingId, { clearanceCurrentPhase: ContractDocPhase.GlEtOutput, } as never); const updated = await this.bookingsService.findById(bookingId); this.notifier.draftDeclarationReady(updated, price, currency); return updated; } /** * The customer accepts the draft declaration — GL Ethiopia may now file the * real customs declaration. */ async acceptDraftDeclaration(bookingId: string): Promise { const booking = await this.loadBooking(bookingId); if (booking.tradeDirection !== 'IMPORT') { throw new BadRequestException('Draft declaration applies only to import bookings.'); } const milestones = await this.workflowService.listMilestonesForBooking(bookingId); const uploaded = milestones.find((m) => m.milestoneCode === 'DRAFT_DECLARATION_UPLOADED'); if (uploaded?.status !== 'COMPLETED') { throw new BadRequestException('There is no draft declaration to accept yet.'); } await this.workflowService.completeMilestoneForBooking(bookingId, 'DRAFT_DECLARATION_ACCEPTED'); return this.bookingsService.findById(bookingId); } /** * The customer sends the draft declaration back with a reason. Nothing is * filed; the upload milestone reopens so the step becomes actionable again * for GL Ethiopia, with the customer's message shown beside it. GL re-sends * (same endpoint as the first time), which closes the request — the loop may * run as many rounds as it takes. */ async requestDraftDeclarationChange( bookingId: string, note: string, userId?: string, ): Promise { const booking = await this.loadBooking(bookingId); if (booking.tradeDirection !== 'IMPORT') { throw new BadRequestException('Draft declaration applies only to import bookings.'); } if (!note?.trim()) { throw new BadRequestException( 'Say what needs to change so GL can correct the draft.', ); } const milestones = await this.workflowService.listMilestonesForBooking(bookingId); const byCode = new Map(milestones.map((m) => [m.milestoneCode, m])); if (byCode.get('DRAFT_DECLARATION_UPLOADED')?.status !== 'COMPLETED') { throw new BadRequestException('There is no draft declaration to request a change on yet.'); } if (byCode.get('DRAFT_DECLARATION_ACCEPTED')?.status === 'COMPLETED') { throw new BadRequestException( 'The draft declaration has already been accepted — contact GL Ethiopia directly.', ); } await this.bookingsRepository.createReviewNote( bookingId, note.trim(), 'DRAFT_DECL_CHANGE_REQUEST', userId, ); // Back to GL: reopening the milestone is what re-arms the step (the // stepper picks its active step from milestone completion). await this.milestoneService.reopenForBooking(bookingId, 'DRAFT_DECLARATION_UPLOADED'); await this.bookingsRepository.update(bookingId, { clearanceCurrentPhase: ContractDocPhase.GlEtOutput, } as never); const updated = await this.bookingsService.findById(bookingId); this.notifier.draftDeclarationChangeRequested(updated, note.trim()); return updated; } async uploadDutySlip(bookingId: string, file: Express.Multer.File): Promise { const booking = await this.loadBooking(bookingId); if (booking.tradeDirection !== 'IMPORT') { throw new BadRequestException('Duty slip upload applies only to import bookings.'); } if (!booking.dutyRequired) { throw new BadRequestException('Duty/tax is not required for this clearance.'); } if (!file) throw new BadRequestException('No payment slip uploaded'); await this.filesService.upsertByCode({ resourceId: bookingId, resource: 'bookings', code: 'duty_tax_receipt', file, }); await this.workflowService.completeMilestoneForBooking(bookingId, 'DUTY_TAX_PAID'); await this.bookingsRepository.update(bookingId, { clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance, } as never); this.notifier.dutySlipUploadedToStaff(booking, 'first'); return this.bookingsService.findById(bookingId); } async uploadTransitPermit( bookingId: string, files: Express.Multer.File[], userId?: string, ): Promise { const booking = await this.loadBooking(bookingId); if (booking.tradeDirection !== 'IMPORT') { throw new BadRequestException('Transit permit applies only to import bookings.'); } await this.workflowService.assertPriorCompleteForBooking( bookingId, 'IMPORT', 'TRANSIT_PERMIT_UPLOADED', ); if (files.length === 0) { throw new BadRequestException('No transit permit documents uploaded'); } await persistTransitPermitUploads(this.filesService, bookingId, 'bookings', files); await this.workflowService.completeMilestoneForBooking( bookingId, 'TRANSIT_PERMIT_UPLOADED', userId, ); await this.bookingsRepository.update(bookingId, { clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance, } as never); return this.bookingsService.findById(bookingId); } async finalizePreClearance(bookingId: string): Promise { const booking = await this.loadBooking(bookingId); if (booking.tradeDirection !== 'IMPORT') { throw new BadRequestException('Pre-clearance finalize applies only to import bookings.'); } await this.workflowService.assertPriorCompleteForBooking( bookingId, 'IMPORT', 'TRANSIT_PERMIT_UPLOADED', ); if (booking.preClearanceFinalizedAt) { return this.bookingsService.findById(bookingId); } await this.bookingsRepository.update(bookingId, { preClearanceFinalizedAt: new Date(), clearanceCurrentPhase: ContractDocPhase.GlDjCollection, } as never); // GL Djibouti may have uploaded the DO early (un-gated) — count it now. const files = await this.filesService.findByResource(bookingId, 'bookings'); if (files.some((f) => isDeliveryOrderFileCode(f.code))) { await this.workflowService.completeMilestoneForBooking(bookingId, 'DO_COLLECTED'); await this.workflowService.markReadyForOperation(bookingId); } return this.bookingsService.findById(bookingId); } async uploadDeliveryOrder( bookingId: string, files: Express.Multer.File[], userId?: string, dates?: { vesselArrivalDate?: string; doCollectedDate?: string }, ): Promise { const booking = await this.loadBooking(bookingId); if (booking.tradeDirection !== 'IMPORT') { throw new BadRequestException('Delivery Order applies only to import bookings.'); } const { vesselArrivalDate, doCollectedDate } = assertDoCollectionDates(dates); // DO upload is deliberately un-gated: GL Djibouti may attach it at any point, // any file type. The DO_COLLECTED milestone (and operation readiness) still // waits for GL Ethiopia to finalize pre-clearance so the workflow order holds. await persistDeliveryOrderUploads(this.filesService, bookingId, 'bookings', files ?? []); await this.bookingsRepository.update(bookingId, { vesselArrivalDate, doCollectedDate, } as never); if (booking.preClearanceFinalizedAt) { await this.workflowService.completeMilestoneForBooking(bookingId, 'DO_COLLECTED', userId); await this.workflowService.markReadyForOperation(bookingId); } return this.bookingsService.findById(bookingId); } private async resolveRoMinDays(): Promise { try { const setting = await this.dropdownSettingsService.getByCode(RO_VESSEL_MIN_DAYS_CODE); const first = setting.children?.[0]; const n = Number(first?.value); return Number.isFinite(n) && n > 0 ? n : 2; } catch { return 2; } } private daysUntil(dateStr: string): number { const target = new Date(dateStr); const today = new Date(); today.setHours(0, 0, 0, 0); target.setHours(0, 0, 0, 0); return Math.floor((target.getTime() - today.getTime()) / (24 * 60 * 60 * 1000)); } async uploadReleaseOrder( bookingId: string, files: Express.Multer.File[], vesselDepartureDate: string, userId?: string, ): Promise<{ booking: Booking; hold: boolean; holdReason?: string }> { const booking = await this.loadBooking(bookingId); if (booking.tradeDirection !== 'EXPORT') { throw new BadRequestException('Release Order applies only to export bookings.'); } await this.workflowService.assertPriorCompleteForBooking( bookingId, 'EXPORT', 'RELEASE_ORDER_SECURED', ); if (!vesselDepartureDate?.trim()) { throw new BadRequestException('Vessel departure date is required'); } const minDays = await this.resolveRoMinDays(); const leadDays = this.daysUntil(vesselDepartureDate); await persistReleaseOrderUploads(this.filesService, bookingId, 'bookings', files ?? []); await this.bookingsRepository.update(bookingId, { vesselDepartureDate, roAmendmentRequestedAt: null, } as never); if (leadDays < minDays) { const reason = `Vessel departs in ${leadDays} day(s) — minimum lead time is ${minDays} day(s). Request a port amendment or upload a new RO with a later date.`; await this.bookingsRepository.update(bookingId, { roHoldReason: reason, clearanceCurrentPhase: ContractDocPhase.GlDjCollection, } as never); return { booking: await this.bookingsService.findById(bookingId), hold: true, holdReason: reason, }; } await this.bookingsRepository.update(bookingId, { roHoldReason: null, clearanceCurrentPhase: ContractDocPhase.GlEtOutput, } as never); await this.workflowService.completeMilestoneForBooking( bookingId, 'RELEASE_ORDER_SECURED', userId, ); // Release Order is now the last GL DJ pre-operation action (it follows the // declaration) — release immediately so booking creation unlocks without a // separate confirm click. await this.workflowService.onExportReleasedForBooking(bookingId, userId); return { booking: await this.bookingsService.findById(bookingId), hold: false }; } async requestRoAmendment( bookingId: string, note?: string, userId?: string, ): Promise { const booking = await this.loadBooking(bookingId); if (booking.tradeDirection !== 'EXPORT') { throw new BadRequestException('RO amendment applies only to export bookings.'); } const reason = note?.trim() || 'Port amendment requested — vessel departure window is too short. A new Release Order will be required.'; await this.bookingsRepository.update(bookingId, { roAmendmentRequestedAt: new Date(), roHoldReason: reason, clearanceCurrentPhase: ContractDocPhase.GlDjCollection, } as never); if (userId) { await this.bookingsRepository.createReviewNote( bookingId, reason, 'CHANGES_REQUESTED', userId, ); } return this.bookingsService.findById(bookingId); } async confirmExportRelease(bookingId: string, userId?: string): Promise { const booking = await this.loadBooking(bookingId); if (booking.tradeDirection !== 'EXPORT') { throw new BadRequestException('Export release applies only to export bookings.'); } await this.workflowService.assertPriorCompleteForBooking( bookingId, 'EXPORT', 'EXPORT_RELEASED', ); await this.workflowService.onExportReleasedForBooking(bookingId, userId); return this.bookingsService.findById(bookingId); } async etQueue(): Promise { const candidates = await this.bookingsRepository.findByStatuses([ ...PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES, ]); const filtered: Booking[] = []; for (const b of candidates) { if (!this.isPhasedCustomsBooking(b)) continue; const milestones = await this.workflowService.listMilestonesForBooking(b.id); if (belongsOnEtClearanceQueue(milestones)) filtered.push(b); } return filtered; } async djQueue(): Promise { const candidates = await this.bookingsRepository.findByStatuses([ ...DJ_BOOKING_QUEUE_STATUSES, ]); const filtered: Booking[] = []; for (const b of candidates) { if (!this.isPhasedCustomsBooking(b)) continue; const milestones = await this.workflowService.listMilestonesForBooking(b.id); if ( belongsOnDjClearanceQueue(b.tradeDirection, null, milestones, { roHoldReason: b.roHoldReason, preClearanceFinalizedAt: b.preClearanceFinalizedAt, }) ) { filtered.push(b); } } return filtered; } }