import { BadRequestException, ConflictException, Injectable } from '@nestjs/common'; import { ContractDocPhase, type ClearanceFinalInvoiceSummary, type ClearanceSecondDuty, type ClearanceT1State, type ClearanceTrainState, } 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 { ContractsRepository } from './contracts.repository'; import { ContractsService, PaginatedContracts } from './contracts.service'; import { BookingsService } from '../bookings/bookings.service'; import { contractClearanceCodes } from './contract-clearance.util'; import { ClearanceWorkflowService } from './clearance-workflow.service'; import { ClearanceMilestoneService } from './clearance-milestone.service'; import { ContractNotifierService } from './contract-notifier.service'; import { GlOperationsService } from './gl-operations.service'; import { ClearanceMilestone } from './entities/clearance-milestone.entity'; import { Contract } from './entities/contract.entity'; import { ContractDocReviewStatus } from './entities/contract-document-review.entity'; import { FilterContractDto } from './dto/filter-contract.dto'; import { AdviseContractDutyDto } from './dto/phased-clearance.dto'; import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_CONTRACT_QUEUE_STATUSES, persistDeclarationUploads, persistTransitPermitUploads, PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES } from './phased-clearance.util'; const RO_VESSEL_MIN_DAYS_CODE = 'ro_vessel_min_days'; export interface ContractClearanceDocument { fileKey: string; label: string; required: boolean; uploadedBy: 'customer' | 'gl'; settingCode: string; file: { id: string; name: string; url: string } | null; reviewStatus: ContractDocReviewStatus | null; note: string | null; /** When the review decision (approve/query) was recorded. */ reviewedAt: string | null; /** Staff id that recorded the decision (no user directory to resolve names). */ reviewedByStaffId: string | null; } export interface ContractClearanceView { contractId: string; status: string; clearanceStatus: string; cycleNumber: number; includesCustoms: boolean; inputCode: string | null; outputCode: string | null; documents: ContractClearanceDocument[]; 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; roAmendmentRequestedAt?: string | null; bookingReady?: boolean; preClearanceFinalized?: boolean; /** Export post-booking clearance finalized (transit permit uploaded + GL confirmed). */ exportClearanceFinalized?: boolean; linkedBookingId?: string | null; /** Reference + status of the GL-created shipment booking, once it exists. */ linkedBookingReference?: string | null; linkedBookingStatus?: string | null; dutyAdvice?: { amount: number; currency: string; declarationSerial?: string | null; noticeFile?: { id: string; name: string; url: string } | null; } | null; workflowFiles?: ReturnType; /** Import post-allocation T1 transit document state (null until a booking is linked). */ t1?: ClearanceT1State | null; /** Train link state for the booking (both directions; null until a booking is linked). */ train?: ClearanceTrainState | null; gatepassGranted?: boolean; gatepassAt?: string | null; t1Closed?: boolean; t1ClosedAt?: string | null; offloaded?: boolean; /** 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; /** Post-arrival additional duty/tax round (import). */ secondDuty?: ClearanceSecondDuty | null; importReleaseGranted?: boolean; } @Injectable() export class ContractClearanceService { constructor( private readonly contractsRepository: ContractsRepository, private readonly contractsService: ContractsService, 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: ContractNotifierService, ) {} private isPhasedCustoms(contract: Contract): boolean { return contract.customsClearingEnabled && contract.contractKind === 'ONE_TIME'; } private assertPhasedCustoms(contract: Contract): void { if (!this.isPhasedCustoms(contract)) { throw new BadRequestException( 'Phased clearance (Phase 1) applies to one-time customs contracts.', ); } } /** * Legacy finalize() used to set CLEARANCE_READY_FOR_BOOKING without completing * phased milestones. Revert that state so declaration / DO steps can proceed. */ private async reconcilePrematureBookingReady( contractId: string, contract: Contract, bookingReady: boolean, ): Promise { if ( !this.isPhasedCustoms(contract) || contract.status !== 'CLEARANCE_READY_FOR_BOOKING' || bookingReady ) { return contract; } const cycle = await this.contractsRepository.currentCycle(contractId); await this.contractsRepository.update(contractId, { status: 'CLEARANCE_UNDER_REVIEW', clearanceStatus: 'DOCUMENTS_UNDER_REVIEW', } as never); if (cycle?.status === 'CLEARANCE_READY_FOR_BOOKING') { await this.contractsRepository.setCycleStatus(cycle.id, 'DOCUMENTS_UNDER_REVIEW'); } return this.contractsService.findById(contractId); } /** The pre-booking clearance document grid for a contract (Path B). */ async getClearanceView(contractId: string): Promise { let contract = await this.contractsService.findById(contractId); const { inputCode, outputCode, includesCustoms } = contractClearanceCodes(contract); const cycle = await this.contractsRepository.currentCycle(contractId); const files = await this.filesService.findByResource(contractId, 'contracts'); const fileByCode = new Map(files.map((f) => [f.code, f])); const reviews = await this.contractsRepository.findDocumentReviews( contractId, cycle?.id ?? null, ); const reviewByKey = new Map(reviews.map((r) => [`${r.settingCode}:${r.fileKey}`, r])); const documents: ContractClearanceDocument[] = []; 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, reviewedAt: review?.reviewedAt ? review.reviewedAt.toISOString() : null, reviewedByStaffId: review?.reviewedByStaffId ?? 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, reviewedAt: review?.reviewedAt ? review.reviewedAt.toISOString() : null, reviewedByStaffId: review?.reviewedByStaffId ?? null, }); } const allApproved = await this.isClearanceFullyApproved(contract); const milestones = await this.workflowService.listMilestones(contractId); let boundary = await this.workflowService.isBoundaryComplete( contractId, contract.tradeDirection, ); contract = await this.reconcilePrematureBookingReady(contractId, contract, boundary); const phase = this.workflowService.resolvePhase(contract, cycle, milestones); const dutyAdvice = this.buildDutyAdvice(files, milestones); let workflowFiles = buildWorkflowFiles( files, contract.tradeDirection ?? 'IMPORT', ); let bookingFiles: Awaited> = []; if (cycle?.bookingId) { bookingFiles = await this.filesService.findByResource( cycle.bookingId, 'bookings', ); const bookingWorkflow = buildWorkflowFiles( bookingFiles, contract.tradeDirection ?? 'IMPORT', ); const byCode = new Map(workflowFiles.map((f) => [f.code, f])); for (const row of bookingWorkflow) { if (row.file) byCode.set(row.code, row); } workflowFiles = [...byCode.values()]; } let t1: ClearanceT1State | null = null; if (cycle?.bookingId && contract.tradeDirection === 'IMPORT') { try { t1 = await this.glOperationsService.t1State(cycle.bookingId); } catch { t1 = null; // linked booking missing — view stays usable } } let train: ClearanceTrainState | null = null; let bookingMilestones: ClearanceMilestone[] = []; let finalInvoice: ClearanceFinalInvoiceSummary | null = null; if (cycle?.bookingId) { try { train = await this.glOperationsService.trainState(cycle.bookingId); } catch { train = null; } bookingMilestones = await this.workflowService.listMilestonesForBooking( cycle.bookingId, ); finalInvoice = await this.glOperationsService.finalInvoiceSummary(cycle.bookingId); } const bookingMilestone = (code: string) => bookingMilestones.find((m) => m.milestoneCode === code); const gatepass = cycle?.bookingId ? await this.glOperationsService.gatepassForBooking(cycle.bookingId) : { granted: false, grantedAt: null }; const t1ClosedMilestone = bookingMilestone('T1_CLOSED'); const riskMilestone = bookingMilestone('RISK_ASSIGNED'); const secondDuty = this.glOperationsService.secondDutyState( bookingMilestones, bookingFiles, ); let nextAction = this.workflowService.computeNextAction(contract, cycle, milestones); // Once GL creates the shipment booking, surface its reference + status so the // customer sees the concrete booking instead of a stale "will be created // shortly" message. Reuse the export booking load; fetch for import too. let linkedBookingReference: string | null = null; let linkedBookingStatus: string | null = null; if (cycle?.bookingId) { const booking = await this.bookingsService.findById(cycle.bookingId); if (booking) { linkedBookingReference = booking.reference ?? null; linkedBookingStatus = booking.status ?? null; if (contract.tradeDirection === 'EXPORT') { nextAction = this.workflowService.computeNextActionForBooking( booking, bookingMilestones, ); } } } return { contractId, status: contract.status, clearanceStatus: contract.clearanceStatus, cycleNumber: cycle?.cycleNumber ?? contract.clearanceCycleNumber, 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: cycle?.dutyRequired ?? null, roHold: Boolean(cycle?.roHoldReason), roHoldReason: cycle?.roHoldReason ?? null, vesselDepartureDate: cycle?.vesselDepartureDate ?? null, roAmendmentRequestedAt: cycle?.roAmendmentRequestedAt ? cycle.roAmendmentRequestedAt.toISOString() : null, bookingReady: boundary, preClearanceFinalized: Boolean(cycle?.preClearanceFinalizedAt), exportClearanceFinalized: Boolean(cycle?.completedAt), linkedBookingId: cycle?.bookingId ?? null, linkedBookingReference, linkedBookingStatus, dutyAdvice, workflowFiles, 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', finalInvoice, riskLevel: riskMilestone?.status === 'COMPLETED' ? ((riskMilestone.metadata?.riskLevel as string | undefined) ?? null) : null, riskAssignedAt: riskMilestone?.status === 'COMPLETED' && riskMilestone.triggeredAt ? riskMilestone.triggeredAt.toISOString() : null, secondDuty, importReleaseGranted: bookingMilestone('IMPORT_RELEASE_GRANTED')?.status === 'COMPLETED', }; } private buildDutyAdvice( files: Array<{ code?: string | null; id: string; name: string; url: string }>, milestones: ClearanceMilestone[], ): ContractClearanceView['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, }; } /** * True when every REQUIRED customer-input field has an APPROVED review row in * the current cycle. The 100% gate before clearance can be finalized. */ private async isClearanceFullyApproved(contract: Contract): Promise { const { inputCode } = contractClearanceCodes(contract); 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 cycle = await this.contractsRepository.currentCycle(contract.id); const reviews = await this.contractsRepository.findDocumentReviews( contract.id, cycle?.id ?? null, ); return required.every((field) => reviews.some( (r) => r.settingCode === inputCode && r.fileKey === field.fileKey && r.status === 'APPROVED', ), ); } /** Staff may approve/query documents during review, after a query cycle, or post-finalize re-query. */ private assertClearanceReviewableStatus(contract: Contract): void { const allowed = [ 'CLEARANCE_UNDER_REVIEW', 'AWAITING_CLEARANCE_DOCUMENTS', 'CLEARANCE_READY_FOR_BOOKING', ]; if (!allowed.includes(contract.status)) { throw new ConflictException( `Cannot review clearance documents on status "${contract.status}".`, ); } } /** Finalize when docs are under review or all approved after a partial query cycle. */ private assertClearanceFinalizableStatus(contract: Contract): void { const allowed = ['CLEARANCE_UNDER_REVIEW', 'AWAITING_CLEARANCE_DOCUMENTS']; if (!allowed.includes(contract.status)) { throw new ConflictException( `Cannot finalize clearance on status "${contract.status}".`, ); } } private assertClearanceOutputUploadableStatus(contract: Contract): void { const allowed = ['CLEARANCE_UNDER_REVIEW', 'AWAITING_CLEARANCE_DOCUMENTS']; if (!allowed.includes(contract.status)) { throw new ConflictException( `Cannot upload output documents on status "${contract.status}".`, ); } } private async bumpToUnderReviewWhenFullyApproved(contractId: string): Promise { const refreshed = await this.contractsService.findById(contractId); const allApproved = await this.isClearanceFullyApproved(refreshed); if ( !allApproved || (refreshed.status !== 'AWAITING_CLEARANCE_DOCUMENTS' && refreshed.status !== 'CLEARANCE_READY_FOR_BOOKING') ) { return; } await this.contractsRepository.update(contractId, { status: 'CLEARANCE_UNDER_REVIEW', clearanceStatus: 'DOCUMENTS_UNDER_REVIEW', } as never); const cycle = await this.contractsRepository.currentCycle(contractId); if (cycle) { await this.contractsRepository.setCycleStatus(cycle.id, 'DOCUMENTS_UNDER_REVIEW'); } } /** * Customer uploads clearance documents on the contract. When every required * input is present, auto-advance to CLEARANCE_UNDER_REVIEW for GL ET. */ async uploadDocuments( contractId: string, files: Express.Multer.File[], ): Promise { const contract = await this.contractsService.findById(contractId); if (contract.status === 'AWAITING_CLEARANCE_PAYMENT') { throw new ConflictException( 'The customs clearance service fee has not been paid yet — pay it from the portal to unlock document upload.', ); } if ( contract.status !== 'AWAITING_CLEARANCE_DOCUMENTS' && contract.status !== 'CLEARANCE_UNDER_REVIEW' ) { throw new ConflictException( `Cannot upload clearance documents on status "${contract.status}".`, ); } const { inputCode } = contractClearanceCodes(contract); if (!inputCode) { throw new BadRequestException('This contract has no document-clearance step'); } if (files.length === 0) { throw new BadRequestException('No documents uploaded'); } const cycle = await this.contractsRepository.currentCycle(contractId); // First submission: every required input field must be present. if (contract.status === 'AWAITING_CLEARANCE_DOCUMENTS') { await this.assertRequiredInputsPresent(contractId, inputCode, files); } for (const file of files) { const record = await this.filesService.upsertByCode({ resourceId: contractId, resource: 'contracts', code: file.fieldname, file, }); const settingCode = file.fieldname.startsWith('custom_') ? 'custom' : inputCode; await this.contractsRepository.upsertDocumentReviewPending({ contractId, clearanceCycleId: cycle?.id ?? null, settingCode, fileKey: file.fieldname, fileRecordId: record.id, uploadedByRole: 'CUSTOMER', }); } await this.contractsRepository.update(contractId, { status: 'CLEARANCE_UNDER_REVIEW', clearanceStatus: 'DOCUMENTS_UNDER_REVIEW', } as never); if (cycle) { await this.contractsRepository.setCycleStatus(cycle.id, 'DOCUMENTS_UNDER_REVIEW', { currentPhase: ContractDocPhase.GlEtReview, }); } if (contract.customsClearingEnabled && contract.contractKind === 'ONE_TIME') { await this.workflowService.onCustomerDocsUploaded(contractId, contract.tradeDirection); await this.workflowService.onDocumentReviewReopened(contractId); } const updated = await this.contractsService.findById(contractId); this.notifier.clearanceDocsUploadedToStaff(updated); return updated; } private async assertRequiredInputsPresent( contractId: string, inputCode: string, files: Express.Multer.File[], ): Promise { let setting; try { setting = await this.fileUploadSettingsService.getByCode(inputCode); } catch { return; } const required = (setting.fields ?? []).filter((f) => f.isRequired); if (required.length === 0) return; const existing = await this.filesService.findByResource(contractId, 'contracts'); 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}`, ); } } /** * Path A (no customs) — the customer clears the cargo himself and uploads his * own clearance proof, reviewed by Operations rather than GL. True when a * clearance doc set resolves for a non-customs contract. */ private isSelfClear(contract: Contract): boolean { if (contract.customsClearingEnabled) return false; return contractClearanceCodes(contract).inputCode != null; } /** GL ET (Path B) reviews a single document: APPROVED or QUERIED. */ async reviewDocument( contractId: string, fileKey: string, status: 'APPROVED' | 'QUERIED', staffId: string, note?: string, ): Promise { return this.applyReview(contractId, fileKey, status, staffId, 'GL_ET', note); } /** * Operations (Path A) reviews a customer self-clearance document. Identical * approve/query loop to {@link reviewDocument}; rejects customs (Path B) * contracts, which are GL-reviewed. */ async opsReviewDocument( contractId: string, fileKey: string, status: 'APPROVED' | 'QUERIED', staffId: string, note?: string, ): Promise { const contract = await this.contractsService.findById(contractId); if (!this.isSelfClear(contract)) { throw new ConflictException( 'Operations review applies only to self-clearance (non-customs) contracts.', ); } return this.applyReview(contractId, fileKey, status, staffId, 'OPERATIONS', note); } private async applyReview( contractId: string, fileKey: string, status: 'APPROVED' | 'QUERIED', staffId: string, reviewerRole: 'GL_ET' | 'OPERATIONS', note?: string, ): Promise { const contract = await this.contractsService.findById(contractId); this.assertClearanceReviewableStatus(contract); if (status === 'QUERIED' && !note?.trim()) { throw new BadRequestException('A note is required when querying a document'); } const { inputCode, outputCode } = contractClearanceCodes(contract); const cycle = await this.contractsRepository.currentCycle(contractId); if ( status === 'QUERIED' && this.isPhasedCustoms(contract) && cycle?.preClearanceFinalizedAt ) { throw new BadRequestException( 'Customer documents cannot be queried after pre-clearance is finalized.', ); } const reviews = await this.contractsRepository.findDocumentReviews( contractId, cycle?.id ?? null, ); const match = reviews.find((r) => r.fileKey === fileKey); const settingCode = match?.settingCode ?? (fileKey.startsWith('custom_') ? 'custom' : (inputCode ?? outputCode ?? 'custom')); await this.contractsRepository.setDocumentReviewStatus({ contractId, clearanceCycleId: cycle?.id ?? null, settingCode, fileKey, status, staffId, note, }); if (status === 'QUERIED') { await this.contractsRepository.createReviewNote( contractId, `Document "${fileKey}" queried: ${note}`, 'CHANGES_REQUESTED', staffId, reviewerRole, ); // Return the contract to the customer to re-upload the queried document. await this.contractsRepository.update(contractId, { status: 'AWAITING_CLEARANCE_DOCUMENTS', clearanceStatus: 'AWAITING_DOCUMENTS', } as never); this.notifier.clearanceDocumentQueried(contract, fileKey, note ?? ''); if (cycle) { await this.contractsRepository.setCycleStatus(cycle.id, 'AWAITING_DOCUMENTS'); } if (this.isPhasedCustoms(contract)) { await this.workflowService.onDocumentReviewReopened(contractId); if (cycle) { await this.contractsRepository.updateCycle(cycle.id, { currentPhase: ContractDocPhase.GlEtReview, }); } } } else if (status === 'APPROVED') { await this.bumpToUnderReviewWhenFullyApproved(contractId); const refreshed = await this.contractsService.findById(contractId); if ( refreshed.customsClearingEnabled && refreshed.contractKind === 'ONE_TIME' && (await this.isClearanceFullyApproved(refreshed)) ) { await this.workflowService.onAllDocsApproved(contractId); const c = await this.contractsRepository.currentCycle(contractId); if (c) { await this.contractsRepository.updateCycle(c.id, { currentPhase: refreshed.tradeDirection === 'EXPORT' ? ContractDocPhase.GlDjCollection : ContractDocPhase.GlEtOutput, }); } } } return this.contractsService.findById(contractId); } /** GL uploads customs output documents (IM4/IM5/EX3/etc.) during clearance. */ async uploadOutputDocuments( contractId: string, files: Express.Multer.File[], ): Promise { const contract = await this.contractsService.findById(contractId); this.assertClearanceOutputUploadableStatus(contract); const { outputCode } = contractClearanceCodes(contract); if (!outputCode) { throw new BadRequestException('This contract 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: contractId, resource: 'contracts', code: file.fieldname, file, }); } return this.contractsService.findById(contractId); } /** * GL ET finalizes Path B pre-booking clearance: requires every customer * document APPROVED. For phased customs (ONE_TIME), document review completes * here — booking readiness is set only after delivery order (import) or export * release via the milestone workflow. Non-phased customs still jump straight to * CLEARANCE_READY_FOR_BOOKING. Rejects self-clearance (Path A) contracts. */ async finalize(contractId: string): Promise { const contract = await this.contractsService.findById(contractId); if (this.isSelfClear(contract)) { throw new ConflictException( 'Self-clearance (Path A) contracts are finalized by Operations, not GL.', ); } this.assertClearanceFinalizableStatus(contract); const approved = await this.isClearanceFullyApproved(contract); if (!approved) { throw new BadRequestException( 'All required documents must be approved before clearance can be finalized', ); } if (this.isPhasedCustoms(contract)) { await this.workflowService.onAllDocsApproved(contractId); const cycle = await this.contractsRepository.currentCycle(contractId); if (cycle) { await this.contractsRepository.updateCycle(cycle.id, { currentPhase: contract.tradeDirection === 'EXPORT' ? ContractDocPhase.GlDjCollection : ContractDocPhase.GlEtOutput, }); } await this.contractsRepository.update(contractId, { status: 'CLEARANCE_UNDER_REVIEW', clearanceStatus: 'DOCUMENTS_UNDER_REVIEW', } as never); return this.contractsService.findById(contractId); } const { outputCode } = contractClearanceCodes(contract); if (outputCode) { const setting = await this.fileUploadSettingsService.getByCode(outputCode); const files = await this.filesService.findByResource(contractId, 'contracts'); 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(', ')}`, ); } } const cycle = await this.contractsRepository.currentCycle(contractId); await this.contractsRepository.update(contractId, { status: 'CLEARANCE_READY_FOR_BOOKING', clearanceStatus: 'CLEARANCE_READY_FOR_BOOKING', } as never); if (cycle) { await this.contractsRepository.setCycleStatus( cycle.id, 'CLEARANCE_READY_FOR_BOOKING', { clearanceReadyAt: new Date() }, ); } return this.contractsService.findById(contractId); } /** * Operations finalizes Path A self-clearance: requires every customer document * APPROVED, then the contract becomes bookable BY THE CUSTOMER. There is no GL * output phase on Path A, so the contract goes straight to FULLY_EXECUTED * (ONE_TIME) / CONTRACT_ACTIVE (GENERAL). */ async opsFinalize(contractId: string): Promise { const contract = await this.contractsService.findById(contractId); if (!this.isSelfClear(contract)) { throw new ConflictException( 'Operations finalize applies only to self-clearance (non-customs) contracts.', ); } this.assertClearanceFinalizableStatus(contract); const approved = await this.isClearanceFullyApproved(contract); if (!approved) { throw new BadRequestException( 'All required documents must be approved before clearance can be finalized', ); } const cycle = await this.contractsRepository.currentCycle(contractId); await this.contractsRepository.update(contractId, { status: contract.contractKind === 'GENERAL' ? 'CONTRACT_ACTIVE' : 'FULLY_EXECUTED', clearanceStatus: 'SELF_CLEARED', } as never); if (cycle) { await this.contractsRepository.setCycleStatus(cycle.id, 'CLEARANCE_READY_FOR_BOOKING', { clearanceReadyAt: new Date(), }); } return this.contractsService.findById(contractId); } /** * GL ET clearance hub, Contracts tab: ONE_TIME customs (Path B) contracts in * phased clearance that already carry at least one uploaded clearance * document — a contract still waiting for its first document has nothing to * review, and GENERAL contracts clear per booking, not at contract level. */ async queue(filter: FilterContractDto): Promise { return this.contractsRepository.findAllPaginated({ page: filter.page ?? 1, pageSize: filter.pageSize ?? 100, statuses: [...PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES], customsClearingEnabled: true, contractKind: 'ONE_TIME', hasClearanceDocuments: true, sortBy: filter.sortBy, sortOrder: filter.sortOrder, }); } /** * Operations queue: self-clearance (Path A) contracts awaiting Operations * review of the customer's own clearance documents. */ /** * Statuses a non-customs contract passes through around Operations * clearance review — the set a caller may narrow {@link opsQueue} to. */ private static readonly OPS_CLEARANCE_STATUSES = [ 'AWAITING_CLEARANCE_DOCUMENTS', 'CLEARANCE_UNDER_REVIEW', 'CLEARANCE_READY_FOR_BOOKING', 'FULLY_EXECUTED', 'CONTRACT_ACTIVE', 'ACTIVE_SHIPMENT_IN_PROGRESS', 'CONTRACT_CLOSED', 'CANCELLED', ]; async opsQueue(filter: FilterContractDto): Promise { // Callers may narrow to any subset of the ops-clearance lifecycle (the // hub's status filter sends an explicit list); anything outside the // whitelist is dropped so this endpoint can't become a general contract // browser. No statuses given → the original under-review queue. const requested = (filter.statuses ?? filter.status ?? '') .split(',') .map((s) => s.trim()) .filter((s) => ContractClearanceService.OPS_CLEARANCE_STATUSES.includes(s), ); return this.contractsRepository.findAllPaginated({ page: filter.page ?? 1, pageSize: filter.pageSize ?? 100, statuses: requested.length ? requested : ['CLEARANCE_UNDER_REVIEW'], customsClearingEnabled: false, search: filter.search, sortBy: filter.sortBy, sortOrder: filter.sortOrder, }); } /** GL ET history: contracts that completed Path B clearance. */ async history(filter: FilterContractDto): Promise { return this.contractsRepository.findAllPaginated({ page: filter.page ?? 1, pageSize: filter.pageSize ?? 50, statuses: ['CLEARANCE_READY_FOR_BOOKING', 'ACTIVE', 'CLOSED', 'CANCELLED'], customsClearingEnabled: true, sortBy: filter.sortBy ?? 'createdAt', sortOrder: filter.sortOrder ?? 'DESC', }); } /** Operations history: contracts that completed Path A self-clearance review. */ async opsHistory(filter: FilterContractDto): Promise { return this.contractsRepository.findAllPaginated({ page: filter.page ?? 1, pageSize: filter.pageSize ?? 50, statuses: ['CLEARANCE_READY_FOR_BOOKING', 'ACTIVE', 'CLOSED', 'CANCELLED'], customsClearingEnabled: false, search: filter.search, sortBy: filter.sortBy ?? 'createdAt', sortOrder: filter.sortOrder ?? 'DESC', }); } // ── Phased clearance actions (ONE_TIME customs, Phase 1) ─────────────────── /** Sync DOCUMENTS_APPROVED when reviews are done but the milestone row lags. */ private async ensureDeclarationPrerequisites( contractId: string, contract: Contract, ): Promise { const allApproved = await this.isClearanceFullyApproved(contract); if (!allApproved) { throw new BadRequestException( 'All required customer documents must be approved before uploading a declaration.', ); } const milestones = await this.workflowService.listMilestones(contractId); const docsApproved = milestones.find((m) => m.milestoneCode === 'DOCUMENTS_APPROVED'); if (docsApproved?.status !== 'COMPLETED' && docsApproved?.status !== 'SKIPPED') { await this.workflowService.onAllDocsApproved(contractId); } } async uploadDeclaration( contractId: string, files: Express.Multer.File[], userId?: string, ): Promise { const contract = await this.contractsService.findById(contractId); this.assertPhasedCustoms(contract); await this.ensureDeclarationPrerequisites(contractId, contract); await this.workflowService.assertPriorComplete( contractId, contract.tradeDirection, 'UNDER_CUSTOMS_CLEARANCE', ); if (files.length === 0) { throw new BadRequestException('No declaration documents uploaded'); } await persistDeclarationUploads( this.filesService, contractId, 'contracts', files, ); await this.workflowService.onDeclarationUploaded(contractId, userId); const cycle = await this.contractsRepository.currentCycle(contractId); if (cycle) { await this.contractsRepository.updateCycle(cycle.id, { currentPhase: contract.tradeDirection === 'EXPORT' ? ContractDocPhase.GlEtPostClearance : ContractDocPhase.CustomerDuty, }); } // Export: the declaration is the last GL ET pre-booking action — release // immediately so booking creation unlocks without a separate confirm click. if (contract.tradeDirection === 'EXPORT') { await this.workflowService.onExportReleased(contractId, userId); } return this.contractsService.findById(contractId); } async adviseDuty( contractId: string, dto: AdviseContractDutyDto, userId?: string, attachment?: Express.Multer.File, ): Promise { const contract = await this.contractsService.findById(contractId); this.assertPhasedCustoms(contract); if (contract.tradeDirection !== 'IMPORT') { throw new BadRequestException('Duty advice applies only to import contracts.'); } await this.workflowService.assertPriorComplete(contractId, 'IMPORT', 'DUTY_TAXES_ADVISED'); const cycle = await this.contractsRepository.currentCycle(contractId); if (!cycle) throw new BadRequestException('No clearance cycle found'); await this.contractsRepository.updateCycle(cycle.id, { dutyRequired: dto.dutyRequired, currentPhase: dto.dutyRequired ? ContractDocPhase.CustomerDuty : ContractDocPhase.GlEtPostClearance, }); if (!dto.dutyRequired) { await this.workflowService.onDutySkipped(contractId); } 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: contractId, resource: 'contracts', code: 'duty_tax_notice', file: attachment, }); await this.milestoneService.adviseDutyForContract( contractId, { amount: dto.amount, currency: dto.currency ?? 'ETB', declarationSerial: dto.declarationSerial, }, userId, ); this.notifier.dutyAdvised(contract, dto.amount, dto.currency ?? 'ETB'); } return this.contractsService.findById(contractId); } async uploadDutySlip( contractId: string, file: Express.Multer.File, ): Promise { const contract = await this.contractsService.findById(contractId); this.assertPhasedCustoms(contract); if (contract.tradeDirection !== 'IMPORT') { throw new BadRequestException('Duty slip upload applies only to import contracts.'); } const cycle = await this.contractsRepository.currentCycle(contractId); if (!cycle?.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: contractId, resource: 'contracts', code: 'duty_tax_receipt', file, }); await this.workflowService.completeMilestone(contractId, 'DUTY_TAX_PAID'); if (cycle) { await this.contractsRepository.updateCycle(cycle.id, { currentPhase: ContractDocPhase.GlEtPostClearance, }); } const updated = await this.contractsService.findById(contractId); this.notifier.dutySlipUploadedToStaff(updated); return updated; } async uploadTransitPermit( contractId: string, files: Express.Multer.File[], userId?: string, ): Promise { const contract = await this.contractsService.findById(contractId); this.assertPhasedCustoms(contract); if (contract.tradeDirection !== 'IMPORT') { throw new BadRequestException('Transit permit applies only to import contracts.'); } await this.workflowService.assertPriorComplete( contractId, 'IMPORT', 'TRANSIT_PERMIT_UPLOADED', ); if (files.length === 0) { throw new BadRequestException('No transit permit documents uploaded'); } await persistTransitPermitUploads( this.filesService, contractId, 'contracts', files, ); await this.workflowService.completeMilestone(contractId, 'TRANSIT_PERMIT_UPLOADED', userId); const cycle = await this.contractsRepository.currentCycle(contractId); if (cycle) { await this.contractsRepository.updateCycle(cycle.id, { currentPhase: ContractDocPhase.GlEtPostClearance, }); } return this.contractsService.findById(contractId); } async finalizePreClearance(contractId: string): Promise { const contract = await this.contractsService.findById(contractId); this.assertPhasedCustoms(contract); if (contract.tradeDirection !== 'IMPORT') { throw new BadRequestException('Pre-clearance finalize applies only to import contracts.'); } await this.workflowService.assertPriorComplete( contractId, 'IMPORT', 'TRANSIT_PERMIT_UPLOADED', ); const cycle = await this.contractsRepository.currentCycle(contractId); if (!cycle) throw new BadRequestException('No clearance cycle found'); if (cycle.preClearanceFinalizedAt) { return this.contractsService.findById(contractId); } await this.contractsRepository.updateCycle(cycle.id, { preClearanceFinalizedAt: new Date(), currentPhase: ContractDocPhase.GlDjCollection, }); // GL Djibouti may have uploaded the DO early (un-gated) — count it now. const files = await this.filesService.findByResource(contractId, 'contracts'); if (files.some((f) => f.code === 'delivery_order')) { await this.workflowService.completeMilestone(contractId, 'DO_COLLECTED'); await this.workflowService.markReadyForBooking(contractId); } this.notifier.preClearanceFinalized(contract); return this.contractsService.findById(contractId); } async uploadDeliveryOrder( contractId: string, file: Express.Multer.File, userId?: string, vesselDepartureDate?: string, ): Promise { const contract = await this.contractsService.findById(contractId); this.assertPhasedCustoms(contract); if (contract.tradeDirection !== 'IMPORT') { throw new BadRequestException('Delivery Order applies only to import contracts.'); } if (!file) throw new BadRequestException('No Delivery Order uploaded'); // DO upload is deliberately un-gated: GL Djibouti may attach it at any point, // any file type. The DO_COLLECTED milestone (and booking readiness) still waits // for GL Ethiopia to finalize pre-clearance so the workflow order holds. await this.filesService.upsertByCode({ resourceId: contractId, resource: 'contracts', code: 'delivery_order', file, }); const cycle = await this.contractsRepository.currentCycle(contractId); if (cycle && vesselDepartureDate?.trim()) { await this.contractsRepository.updateCycle(cycle.id, { vesselDepartureDate: vesselDepartureDate.trim(), }); } if (cycle?.preClearanceFinalizedAt) { await this.workflowService.completeMilestone(contractId, 'DO_COLLECTED', userId); await this.workflowService.markReadyForBooking(contractId); } return this.contractsService.findById(contractId); } 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( contractId: string, file: Express.Multer.File, vesselDepartureDate: string, userId?: string, ): Promise<{ contract: Contract; hold: boolean; holdReason?: string }> { const contract = await this.contractsService.findById(contractId); this.assertPhasedCustoms(contract); if (contract.tradeDirection !== 'EXPORT') { throw new BadRequestException('Release Order applies only to export contracts.'); } await this.workflowService.assertPriorComplete( contractId, 'EXPORT', 'RELEASE_ORDER_SECURED', ); if (!file) throw new BadRequestException('No Release Order uploaded'); if (!vesselDepartureDate?.trim()) { throw new BadRequestException('Vessel departure date is required'); } const minDays = await this.resolveRoMinDays(); const leadDays = this.daysUntil(vesselDepartureDate); const cycle = await this.contractsRepository.currentCycle(contractId); if (!cycle) throw new BadRequestException('No clearance cycle found'); await this.filesService.upsertByCode({ resourceId: contractId, resource: 'contracts', code: 'release_order', file, }); await this.contractsRepository.updateCycle(cycle.id, { vesselDepartureDate, roAmendmentRequestedAt: null, }); 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.contractsRepository.updateCycle(cycle.id, { roHoldReason: reason, currentPhase: ContractDocPhase.GlDjCollection, }); return { contract: await this.contractsService.findById(contractId), hold: true, holdReason: reason }; } await this.contractsRepository.updateCycle(cycle.id, { roHoldReason: null, currentPhase: ContractDocPhase.GlEtOutput, }); await this.workflowService.completeMilestone(contractId, 'RELEASE_ORDER_SECURED', userId); return { contract: await this.contractsService.findById(contractId), hold: false }; } async requestRoAmendment( contractId: string, note?: string, userId?: string, ): Promise { const contract = await this.contractsService.findById(contractId); this.assertPhasedCustoms(contract); if (contract.tradeDirection !== 'EXPORT') { throw new BadRequestException('RO amendment applies only to export contracts.'); } const cycle = await this.contractsRepository.currentCycle(contractId); if (!cycle) throw new BadRequestException('No clearance cycle found'); const reason = note?.trim() || 'Port amendment requested — vessel departure window is too short. A new Release Order will be required.'; await this.contractsRepository.updateCycle(cycle.id, { roAmendmentRequestedAt: new Date(), roHoldReason: reason, currentPhase: ContractDocPhase.GlDjCollection, }); if (userId) { await this.contractsRepository.createReviewNote( contractId, reason, 'CHANGES_REQUESTED', userId, 'GL_DJ', ); } return this.contractsService.findById(contractId); } async confirmExportRelease(contractId: string, userId?: string): Promise { const contract = await this.contractsService.findById(contractId); this.assertPhasedCustoms(contract); if (contract.tradeDirection !== 'EXPORT') { throw new BadRequestException('Export release applies only to export contracts.'); } await this.workflowService.assertPriorComplete(contractId, 'EXPORT', 'EXPORT_RELEASED'); await this.workflowService.onExportReleased(contractId, userId); return this.contractsService.findById(contractId); } /** GL ET finalizes export clearance after post-booking transit permit is uploaded. */ async finalizeExportClearance(contractId: string, userId?: string): Promise { const contract = await this.contractsService.findById(contractId); this.assertPhasedCustoms(contract); if (contract.tradeDirection !== 'EXPORT') { throw new BadRequestException('Export clearance finalize applies only to export contracts.'); } const cycle = await this.contractsRepository.currentCycle(contractId); if (!cycle?.bookingId) { throw new BadRequestException( 'A shipment booking must exist before export clearance can be finalized.', ); } if (cycle.completedAt) { return this.contractsService.findById(contractId); } const bookingMilestones = await this.workflowService.listMilestonesForBooking( cycle.bookingId, ); const transportDone = bookingMilestones.some( (m) => m.milestoneCode === 'EXPORT_TRANSPORT_ISSUED' && m.status === 'COMPLETED', ); if (!transportDone) { throw new BadRequestException( 'Upload the transit permit before finalizing export clearance.', ); } await this.contractsRepository.updateCycle(cycle.id, { completedAt: new Date(), currentPhase: ContractDocPhase.GlEtPostClearance, }); void userId; return this.contractsService.findById(contractId); } /** GL ET queue: customs ONE_TIME contracts in phased clearance (persistent after booking). */ async etQueue(filter: FilterContractDto): Promise { const base = await this.contractsRepository.findAllPaginated({ page: 1, pageSize: 500, statuses: [...PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES], customsClearingEnabled: true, contractKind: 'ONE_TIME', sortBy: filter.sortBy, sortOrder: filter.sortOrder, }); const filtered: typeof base.items = []; for (const c of base.items) { const milestones = await this.workflowService.listMilestones(c.id); if (belongsOnEtClearanceQueue(milestones)) filtered.push(c); } const page = filter.page ?? 1; const pageSize = filter.pageSize ?? 50; const start = (page - 1) * pageSize; const items = filtered.slice(start, start + pageSize); return { items, total: filtered.length, meta: { page, pageSize, total: filtered.length, totalPages: Math.ceil(filtered.length / pageSize) || 1, hasNextPage: start + pageSize < filtered.length, hasPreviousPage: page > 1, }, }; } /** GL DJ queue: customs ONE_TIME contracts handed off to or handled by Djibouti GL. */ async djQueue(filter: FilterContractDto): Promise { const base = await this.contractsRepository.findAllPaginated({ page: 1, pageSize: 500, statuses: [...DJ_CONTRACT_QUEUE_STATUSES], customsClearingEnabled: true, contractKind: 'ONE_TIME', sortBy: filter.sortBy, sortOrder: filter.sortOrder, }); const filtered: typeof base.items = []; for (const c of base.items) { const cycle = await this.contractsRepository.currentCycle(c.id); const milestones = await this.workflowService.listMilestones(c.id); if (belongsOnDjClearanceQueue(c.tradeDirection, cycle, milestones)) { filtered.push(c); } } const page = filter.page ?? 1; const pageSize = filter.pageSize ?? 50; const start = (page - 1) * pageSize; const items = filtered.slice(start, start + pageSize); return { items, total: filtered.length, meta: { page, pageSize, total: filtered.length, totalPages: Math.ceil(filtered.length / pageSize) || 1, hasNextPage: start + pageSize < filtered.length, hasPreviousPage: page > 1, }, }; } }