import { BadRequestException, ConflictException, Injectable, NotFoundException, } 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 { assertDoCollectionDates, 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, type RiskAssignmentRecord, } 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, 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; /** * Pre-declaration handshake with GL Djibouti: who will handle the shipment in * transit. `name` is null until Djibouti answers, and the declaration step is * shut until it is set. */ transitAssignee?: { requestedAt: string | null; requestNote: string | null; name: string | null; assignedAt: string | null; } | 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; 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; /** * Operations' latest "needs changes" note on that booking. GL created the * booking, so GL is the one who has to act on it — surfaced here because the * clearance page is where GL works, not the portal. */ linkedBookingReviewNote?: string | null; /** Shipment day the booking currently holds — the default when GL resubmits. */ linkedBookingScheduledDate?: string | null; dutyAdvice?: { amount: number; currency: string; declarationSerial?: string | null; noticeFile?: { id: string; name: string; url: string } | null; } | null; /** * The customer's open objection to the advised duty — present only while GL * has not re-advised (the advice milestone is back to PENDING). `rounds` is * how many times it has been sent back, so both sides can see the loop. */ dutyDispute?: { note: string; raisedAt: string; rounds: number; } | 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; /** 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; } @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); const dutyDispute = await this.buildDutyDispute(contractId, milestones); const transitAssignee = cycle ? { requestedAt: cycle.transitAssigneeRequestedAt ? cycle.transitAssigneeRequestedAt.toISOString() : null, requestNote: cycle.transitAssigneeRequestNote ?? null, name: cycle.transitAssigneeName ?? null, assignedAt: cycle.transitAssigneeAssignedAt ? cycle.transitAssigneeAssignedAt.toISOString() : null, } : null; 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; let linkedBookingReviewNote: string | null = null; let linkedBookingScheduledDate: string | null = null; if (cycle?.bookingId) { const booking = await this.bookingsService.findById(cycle.bookingId); if (booking) { linkedBookingReference = booking.reference ?? null; linkedBookingStatus = booking.status ?? null; linkedBookingScheduledDate = booking.scheduledDate ? new Date(booking.scheduledDate).toISOString() : null; // Newest changes-requested note (reviewNotes ride along on findById). linkedBookingReviewNote = [...(booking.reviewNotes ?? [])] .filter((n) => n.type === 'CHANGES_REQUESTED') .sort( (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), )[0]?.note ?? 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, vesselArrivalDate: cycle?.vesselArrivalDate ?? null, doCollectedDate: cycle?.doCollectedDate ?? null, roAmendmentRequestedAt: cycle?.roAmendmentRequestedAt ? cycle.roAmendmentRequestedAt.toISOString() : null, bookingReady: boundary, preClearanceFinalized: Boolean(cycle?.preClearanceFinalizedAt), exportClearanceFinalized: Boolean(cycle?.completedAt), linkedBookingId: cycle?.bookingId ?? null, linkedBookingReference, linkedBookingStatus, linkedBookingReviewNote, linkedBookingScheduledDate, dutyAdvice, dutyDispute, transitAssignee, 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, // Every risk decision, oldest first — see booking-clearance.service. 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[], ): 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, }; } /** * The customer's duty objection, but only while it is still OPEN — i.e. the * advice milestone sits back at PENDING because nobody has re-advised yet. * Re-advising completes that milestone again, which closes the dispute here * without any extra state to keep in sync; the notes stay as the audit trail * and their count is the round number. */ private async buildDutyDispute( contractId: string, milestones: ClearanceMilestone[], ): Promise { const advised = milestones.find((m) => m.milestoneCode === 'DUTY_TAXES_ADVISED'); if (!advised || advised.status === 'COMPLETED') return null; const notes = await this.contractsRepository.findReviewNotes( contractId, 'DUTY_DISPUTE', ); const latest = notes[0]; if (!latest) return null; return { note: latest.body, raisedAt: latest.createdAt.toISOString(), rounds: notes.length, }; } /** * 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 document approval 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_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); } /** * GL corrects a clearance document in place instead of bouncing it back to * the customer. The customer's upload is NOT lost — it is retired into the * document's version history, stamped with who replaced it and why — and the * new version starts unreviewed, so GL still has to approve it (or query it) * before clearance can be finalized. * * Use this for the small fixes staff can make faster than the customer can * (a wrong page order, a missing stamp scan); a query is still the right tool * when only the customer can produce the correct document. */ async replaceDocument( contractId: string, fileKey: string, file: Express.Multer.File, staffId: string, reason?: string, ): Promise { const contract = await this.contractsService.findById(contractId); this.assertClearanceReviewableStatus(contract); if (!file) throw new BadRequestException('No replacement file uploaded'); if (!reason?.trim()) { throw new BadRequestException( 'Say why the document is being replaced — it is kept on the file history.', ); } const cycle = await this.contractsRepository.currentCycle(contractId); if (this.isPhasedCustoms(contract) && cycle?.preClearanceFinalizedAt) { throw new BadRequestException( 'Documents cannot be changed after pre-clearance is finalized.', ); } const existing = await this.filesService.findByCode( contractId, 'contracts', fileKey, ); if (!existing) { throw new NotFoundException( `No document is stored under "${fileKey}" on this contract.`, ); } await this.filesService.upsertByCode( { resourceId: contractId, resource: 'contracts', code: fileKey, file }, { userId: staffId, reason: reason.trim() }, ); // A fresh version is unreviewed by definition: clear any earlier verdict so // the corrected file is signed off explicitly rather than inheriting a tick. const { inputCode, outputCode } = contractClearanceCodes(contract); const reviews = await this.contractsRepository.findDocumentReviews( contractId, cycle?.id ?? null, ); const settingCode = reviews.find((r) => r.fileKey === fileKey)?.settingCode ?? (fileKey.startsWith('custom_') ? 'custom' : (inputCode ?? outputCode ?? 'custom')); await this.contractsRepository.setDocumentReviewStatus({ contractId, clearanceCycleId: cycle?.id ?? null, settingCode, fileKey, status: 'PENDING', staffId, note: `Replaced by staff: ${reason.trim()}`, }); await this.contractsRepository.createReviewNote( contractId, `Document "${fileKey}" replaced by staff: ${reason.trim()}`, 'STAFF_NOTE', staffId, 'GL_ET', ); return this.contractsService.findById(contractId); } /** Every stored version of one clearance document, newest first. */ async documentVersions(contractId: string, fileKey: string) { return this.filesService.versionHistory(contractId, 'contracts', fileKey); } 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, }); } /** 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. */ /** * GL Ethiopia asks Djibouti to name the officer who will handle the shipment * in transit. Nothing else moves until Djibouti answers — the declaration is * gated on it — so this is the first thing ET does once the documents are * approved. Re-requesting is allowed (a nudge) and simply restamps the ask. */ async requestTransitAssignee( contractId: string, note: string | undefined, userId?: string, ): Promise { const contract = await this.contractsService.findById(contractId); this.assertPhasedCustoms(contract); const cycle = await this.contractsRepository.currentCycle(contractId); if (!cycle) throw new BadRequestException('No clearance cycle found'); await this.contractsRepository.updateCycle(cycle.id, { transitAssigneeRequestedAt: new Date(), transitAssigneeRequestedByUserId: userId ?? null, transitAssigneeRequestNote: note?.trim() || null, }); const updated = await this.contractsService.findById(contractId); this.notifier.transitAssigneeRequested(updated, note?.trim() ?? null); return updated; } /** * GL Djibouti names the transit officer — free text, because the person is * not a platform user. Answering unblocks the declaration for Ethiopia. A * later call overwrites the name (reassignment) and re-notifies. */ async assignTransitAssignee( contractId: string, assignee: string, userId?: string, ): Promise { const contract = await this.contractsService.findById(contractId); this.assertPhasedCustoms(contract); if (!assignee?.trim()) { throw new BadRequestException('Name the officer who will handle the transit.'); } const cycle = await this.contractsRepository.currentCycle(contractId); if (!cycle) throw new BadRequestException('No clearance cycle found'); if (!cycle.transitAssigneeRequestedAt) { throw new BadRequestException( 'GL Ethiopia has not requested a transit assignee for this clearance yet.', ); } const previous = cycle.transitAssigneeName ?? null; await this.contractsRepository.updateCycle(cycle.id, { transitAssigneeName: assignee.trim(), transitAssigneeAssignedAt: new Date(), transitAssigneeAssignedByUserId: userId ?? null, }); const updated = await this.contractsService.findById(contractId); this.notifier.transitAssigneeAssigned(updated, assignee.trim(), previous); return updated; } 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.', ); } // The transit officer must be named by Djibouti first — the declaration is // filed against whoever will physically handle the shipment there. const cycle = await this.contractsRepository.currentCycle(contractId); if (!cycle?.transitAssigneeName) { throw new BadRequestException( cycle?.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.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); } /** * The customer disagrees with the advised duty & tax and asks GL Ethiopia to * correct it. Nothing is paid; the advice milestone reopens so the Duty & tax * step becomes actionable again on the GL clearance page, with the customer's * message shown beside it. GL re-advises (same endpoint as the first time), * which closes the dispute — the loop may run as many rounds as it takes. */ async disputeDuty( contractId: string, note: string, userId?: string, ): Promise { const contract = await this.contractsService.findById(contractId); this.assertPhasedCustoms(contract); if (contract.tradeDirection !== 'IMPORT') { throw new BadRequestException('Duty applies only to import contracts.'); } if (!note?.trim()) { throw new BadRequestException( 'Say what is wrong with the advised amount so GL can correct it.', ); } const cycle = await this.contractsRepository.currentCycle(contractId); if (!cycle?.dutyRequired) { throw new BadRequestException('Duty/tax is not required for this clearance.'); } const milestones = await this.workflowService.listMilestones(contractId); const byCode = new Map(milestones.map((m) => [m.milestoneCode, m])); if (byCode.get('DUTY_TAXES_ADVISED')?.status !== 'COMPLETED') { throw new BadRequestException( 'There is no advised duty amount to dispute yet.', ); } // Once the slip is in, the money is paid — a dispute then is a refund // conversation, not a re-advice. if (byCode.get('DUTY_TAX_PAID')?.status === 'COMPLETED') { throw new BadRequestException( 'The duty payment slip has already been submitted — contact GL Ethiopia directly.', ); } await this.contractsRepository.createReviewNote( contractId, note.trim(), 'DUTY_DISPUTE', userId, 'CUSTOMER', ); // Back to GL: reopening the milestone is what re-arms the Duty & tax step // (the stepper picks its active step from milestone completion). await this.milestoneService.reopenForContract(contractId, 'DUTY_TAXES_ADVISED'); await this.contractsRepository.updateCycle(cycle.id, { currentPhase: ContractDocPhase.GlEtOutput, }); const updated = await this.contractsService.findById(contractId); this.notifier.dutyDisputed(updated, note.trim()); return updated; } 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, dates?: { vesselArrivalDate?: string; doCollectedDate?: 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'); 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 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) { await this.contractsRepository.updateCycle(cycle.id, { vesselArrivalDate, doCollectedDate, }); } 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); } }