import { BadRequestException, Injectable } from '@nestjs/common'; import { ContractDocPhase } from '@edr/types'; import { ContractsRepository } from './contracts.repository'; import { ClearanceMilestoneService } from './clearance-milestone.service'; import { MilestoneMetadata } from './entities/clearance-milestone.entity'; import { splitMilestones } from './clearance-milestone.catalog'; import { Contract } from './entities/contract.entity'; import { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity'; import { ClearanceMilestone } from './entities/clearance-milestone.entity'; import { BookingsRepository } from '../bookings/bookings.repository'; import { BookingLifecycleNotifierService } from '../bookings/booking-lifecycle-notifier.service'; import { Booking } from '../bookings/entities/booking.entity'; import type { ClearanceMetaState } from './clearance-workflow.types'; import { metaFromBooking } from './clearance-workflow.types'; export type ClearanceActorRole = 'CUSTOMER' | 'GL_ET' | 'GL_DJ' | 'OPERATIONS'; export interface ClearanceNextAction { actor: ClearanceActorRole; action: string; milestoneCode?: string | null; blockedReason?: string | null; } const IMPORT_BOUNDARY = 'DO_COLLECTED'; const EXPORT_BOUNDARY = 'EXPORT_RELEASED'; const IMPORT_DOC_UPLOADED = 'IMPORT_DOCS_UPLOADED'; const EXPORT_DOC_UPLOADED = 'EXPORT_DOCS_UPLOADED'; @Injectable() export class ClearanceWorkflowService { constructor( private readonly contractsRepository: ContractsRepository, private readonly milestoneService: ClearanceMilestoneService, private readonly bookingsRepository: BookingsRepository, private readonly notifier: BookingLifecycleNotifierService, ) {} boundaryMilestone(tradeDirection: string): string { return tradeDirection === 'IMPORT' ? IMPORT_BOUNDARY : EXPORT_BOUNDARY; } // ── Contract scope (ONE_TIME) ───────────────────────────────────────────── async listMilestones(contractId: string): Promise { return this.milestoneService.listForContract(contractId); } async listMilestonesForBooking(bookingId: string): Promise { return this.milestoneService.listForBooking(bookingId); } async isBoundaryComplete(contractId: string, tradeDirection: string): Promise { return this.isBoundaryCompleteForMilestones( await this.listMilestones(contractId), tradeDirection, ); } async isBoundaryCompleteForBooking( bookingId: string, tradeDirection: string, ): Promise { return this.isBoundaryCompleteForMilestones( await this.listMilestonesForBooking(bookingId), tradeDirection, ); } private isBoundaryCompleteForMilestones( milestones: ClearanceMilestone[], tradeDirection: string, ): boolean { const code = this.boundaryMilestone(tradeDirection); const m = milestones.find((x) => x.milestoneCode === code); return m?.status === 'COMPLETED'; } async assertBoundaryComplete(contract: Contract): Promise { const ok = await this.isBoundaryComplete(contract.id, contract.tradeDirection); if (!ok) { throw new BadRequestException( `Pre-booking clearance is not complete — ${this.boundaryMilestone(contract.tradeDirection)} must be finished before booking.`, ); } } async assertPriorComplete( contractId: string, tradeDirection: string, targetCode: string, ): Promise { await this.assertPriorCompleteOnMilestones( await this.listMilestones(contractId), tradeDirection, targetCode, ); } async assertPriorCompleteForBooking( bookingId: string, tradeDirection: string, targetCode: string, ): Promise { await this.assertPriorCompleteOnMilestones( await this.listMilestonesForBooking(bookingId), tradeDirection, targetCode, ); } private async assertPriorCompleteOnMilestones( milestones: ClearanceMilestone[], tradeDirection: string, targetCode: string, ): Promise { const { preBooking } = splitMilestones(tradeDirection); const byCode = new Map(milestones.map((m) => [m.milestoneCode, m])); const targetIdx = preBooking.findIndex((d) => d.code === targetCode); if (targetIdx < 0) return; for (let i = 0; i < targetIdx; i++) { const code = preBooking[i]!.code; const m = byCode.get(code); if (!m) continue; if (m.status === 'SKIPPED') continue; if (m.status !== 'COMPLETED') { throw new BadRequestException( `Complete "${preBooking[i]!.label}" before proceeding.`, ); } } } async skipMilestones(contractId: string, codes: string[]): Promise { for (const code of codes) { await this.milestoneService.skipForContract(contractId, code); } } async skipMilestonesForBooking(bookingId: string, codes: string[]): Promise { for (const code of codes) { await this.milestoneService.skipForBooking(bookingId, code); } } async completeMilestone( contractId: string, code: string, userId?: string, metadata?: MilestoneMetadata, ): Promise { if (metadata && Object.keys(metadata).length > 0) { return this.milestoneService.completeWithMetadataForContract( contractId, code, metadata, userId, ); } return this.milestoneService.completeForContract(contractId, code, userId); } async completeMilestoneForBooking( bookingId: string, code: string, userId?: string, metadata?: MilestoneMetadata, ): Promise { if (metadata && Object.keys(metadata).length > 0) { return this.milestoneService.completeWithMetadataForBooking( bookingId, code, metadata, userId, ); } return this.milestoneService.completeForBooking(bookingId, code, userId); } async onCustomerDocsUploaded(contractId: string, tradeDirection: string): Promise { const uploaded = tradeDirection === 'IMPORT' ? IMPORT_DOC_UPLOADED : EXPORT_DOC_UPLOADED; await this.completeMilestone(contractId, uploaded); await this.completeMilestone(contractId, 'PENDING_DOCUMENT_REVIEW'); } async onCustomerDocsUploadedForBooking( bookingId: string, tradeDirection: string, ): Promise { const uploaded = tradeDirection === 'IMPORT' ? IMPORT_DOC_UPLOADED : EXPORT_DOC_UPLOADED; await this.completeMilestoneForBooking(bookingId, uploaded); await this.completeMilestoneForBooking(bookingId, 'PENDING_DOCUMENT_REVIEW'); } async onAllDocsApproved(contractId: string): Promise { await this.completeMilestone(contractId, 'DOCUMENTS_APPROVED'); } async onAllDocsApprovedForBooking(bookingId: string): Promise { await this.completeMilestoneForBooking(bookingId, 'DOCUMENTS_APPROVED'); } /** Customer doc queried or re-uploaded — document approval milestone must reopen. */ async onDocumentReviewReopened(contractId: string): Promise { await this.milestoneService.reopenForContract(contractId, 'DOCUMENTS_APPROVED'); } async onDocumentReviewReopenedForBooking(bookingId: string): Promise { await this.milestoneService.reopenForBooking(bookingId, 'DOCUMENTS_APPROVED'); } async onDeclarationUploaded(contractId: string, userId?: string): Promise { await this.completeMilestone(contractId, 'UNDER_CUSTOMS_CLEARANCE'); await this.completeMilestone(contractId, 'DECLARED', userId); } async onDeclarationUploadedForBooking(bookingId: string, userId?: string): Promise { await this.completeMilestoneForBooking(bookingId, 'UNDER_CUSTOMS_CLEARANCE'); await this.completeMilestoneForBooking(bookingId, 'DECLARED', userId); } async onDutySkipped(contractId: string): Promise { await this.skipMilestones(contractId, ['DUTY_TAXES_ADVISED', 'DUTY_TAX_PAID']); } async onDutySkippedForBooking(bookingId: string): Promise { await this.skipMilestonesForBooking(bookingId, ['DUTY_TAXES_ADVISED', 'DUTY_TAX_PAID']); } async onExportReleased(contractId: string, userId?: string): Promise { await this.completeMilestone(contractId, 'EXPORT_RELEASED', userId); await this.markReadyForBooking(contractId); } async onExportReleasedForBooking(bookingId: string, userId?: string): Promise { await this.completeMilestoneForBooking(bookingId, 'EXPORT_RELEASED', userId); await this.markReadyForOperation(bookingId); } async markReadyForBooking(contractId: string): Promise { 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(), currentPhase: ContractDocPhase.GlEtPostClearance, }); await this.contractsRepository.updateCycle(cycle.id, { currentPhase: ContractDocPhase.GlEtPostClearance, }); } } /** GENERAL per-booking: boundary complete → customer may proceed to operations. */ async markReadyForOperation(bookingId: string): Promise { await this.bookingsRepository.update(bookingId, { status: 'CLEARANCE_READY', clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance, } as never); // Tell the customer clearance is done and operation can be requested. Load // failure only skips the notice — the status change above already committed. try { const booking = await this.bookingsRepository.findByIdWithFiles(bookingId); if (booking) this.notifier.clearanceReady(booking); } catch { /* notification is best-effort */ } } resolvePhase( contract: Contract, cycle: ContractClearanceCycle | null, milestones: ClearanceMilestone[], ): ContractDocPhase { const meta: ClearanceMetaState = { dutyRequired: cycle?.dutyRequired ?? null, vesselDepartureDate: cycle?.vesselDepartureDate ?? null, roHoldReason: cycle?.roHoldReason ?? null, currentPhase: cycle?.currentPhase ?? null, }; return this.resolvePhaseFromMeta(contract.tradeDirection, meta, milestones); } resolvePhaseForBooking( booking: Booking, milestones: ClearanceMilestone[], ): ContractDocPhase { return this.resolvePhaseFromMeta( booking.tradeDirection ?? 'IMPORT', metaFromBooking(booking), milestones, ); } private resolvePhaseFromMeta( tradeDirection: string, meta: ClearanceMetaState, milestones: ClearanceMilestone[], ): ContractDocPhase { if (meta.currentPhase) { return meta.currentPhase as ContractDocPhase; } return this.inferPhaseFromMeta(tradeDirection, meta, milestones); } inferPhase( contract: Contract, cycle: ContractClearanceCycle | null, milestones: ClearanceMilestone[], ): ContractDocPhase { return this.inferPhaseFromMeta( contract.tradeDirection, { dutyRequired: cycle?.dutyRequired ?? null, roHoldReason: cycle?.roHoldReason ?? null, }, milestones, ); } inferPhaseForBooking(booking: Booking, milestones: ClearanceMilestone[]): ContractDocPhase { return this.inferPhaseFromMeta( booking.tradeDirection ?? 'IMPORT', metaFromBooking(booking), milestones, ); } private inferPhaseFromMeta( tradeDirection: string, meta: ClearanceMetaState, milestones: ClearanceMilestone[], ): ContractDocPhase { const byCode = new Map(milestones.map((m) => [m.milestoneCode, m])); const isDone = (code: string) => byCode.get(code)?.status === 'COMPLETED' || byCode.get(code)?.status === 'SKIPPED'; const docUploaded = tradeDirection === 'IMPORT' ? isDone(IMPORT_DOC_UPLOADED) : isDone(EXPORT_DOC_UPLOADED); if (!docUploaded) return ContractDocPhase.CustomerIntake; if (!isDone('DOCUMENTS_APPROVED')) return ContractDocPhase.GlEtReview; if (tradeDirection === 'EXPORT') { if (!isDone('RELEASE_ORDER_SECURED')) { return ContractDocPhase.GlDjCollection; } if (!isDone('DECLARED')) return ContractDocPhase.GlEtOutput; if (!isDone(EXPORT_BOUNDARY)) return ContractDocPhase.GlEtPostClearance; return ContractDocPhase.GlEtPostClearance; } if (!isDone('DECLARED')) return ContractDocPhase.GlEtOutput; if (meta.dutyRequired === true && !isDone('DUTY_TAX_PAID')) { return ContractDocPhase.CustomerDuty; } if (!isDone('TRANSIT_PERMIT_UPLOADED')) return ContractDocPhase.GlEtPostClearance; if (!meta.preClearanceFinalizedAt) return ContractDocPhase.GlEtPostClearance; if (!isDone(IMPORT_BOUNDARY)) return ContractDocPhase.GlDjCollection; return ContractDocPhase.GlEtPostClearance; } computeNextAction( contract: Contract, cycle: ContractClearanceCycle | null, milestones: ClearanceMilestone[], ): ClearanceNextAction | null { return this.computeNextActionFromMeta( contract.tradeDirection, { dutyRequired: cycle?.dutyRequired ?? null, roHoldReason: cycle?.roHoldReason ?? null, preClearanceFinalizedAt: cycle?.preClearanceFinalizedAt ?? null, }, milestones, 'contract', ); } computeNextActionForBooking( booking: Booking, milestones: ClearanceMilestone[], ): ClearanceNextAction | null { return this.computeNextActionFromMeta( booking.tradeDirection ?? 'IMPORT', metaFromBooking(booking), milestones, 'booking', ); } private computeNextActionFromMeta( tradeDirection: string, meta: ClearanceMetaState, milestones: ClearanceMilestone[], terminalScope: 'contract' | 'booking', ): ClearanceNextAction | null { if (meta.roHoldReason) { return { actor: 'GL_DJ', action: 'Re-upload Release Order or request port amendment', milestoneCode: 'RELEASE_ORDER_SECURED', blockedReason: meta.roHoldReason, }; } const byCode = new Map(milestones.map((m) => [m.milestoneCode, m])); const pending = (code: string) => { const m = byCode.get(code); return m && m.status === 'PENDING'; }; const isDone = (code: string) => { const m = byCode.get(code); return m?.status === 'COMPLETED' || m?.status === 'SKIPPED'; }; const docCode = tradeDirection === 'IMPORT' ? IMPORT_DOC_UPLOADED : EXPORT_DOC_UPLOADED; if (pending(docCode) || !isDone(docCode)) { return { actor: 'CUSTOMER', action: 'Upload clearance documents', milestoneCode: docCode, }; } if (!isDone('DOCUMENTS_APPROVED')) { return { actor: 'GL_ET', action: 'Review and approve customer documents', milestoneCode: 'DOCUMENTS_APPROVED', }; } const terminalAction = terminalScope === 'contract' ? 'Create shipment booking' : 'Proceed to request operation'; if (tradeDirection === 'EXPORT') { if (!isDone('RELEASE_ORDER_SECURED')) { return { actor: 'GL_DJ', action: 'Upload Release Order and vessel departure date', milestoneCode: 'RELEASE_ORDER_SECURED', }; } if (!isDone('DECLARED')) { return { actor: 'GL_ET', action: 'Upload customs declaration documents', milestoneCode: 'DECLARED', }; } if (!isDone(EXPORT_BOUNDARY)) { return { actor: 'GL_ET', action: 'Confirm export release', milestoneCode: EXPORT_BOUNDARY, }; } if (terminalScope === 'booking') { if (!isDone('FREIGHT_PAYMENT_SETTLED')) { return { actor: 'CUSTOMER', action: 'Pay freight charges', milestoneCode: 'FREIGHT_PAYMENT_SETTLED', }; } if (!isDone('WAGON_ALLOCATED')) { return { actor: 'OPERATIONS', action: 'Allocate wagon', milestoneCode: 'WAGON_ALLOCATED', }; } if (!isDone('EXPORT_TRANSPORT_ISSUED')) { return { actor: 'GL_ET', action: 'Upload transit permit', milestoneCode: 'EXPORT_TRANSPORT_ISSUED', }; } return null; } return { actor: terminalScope === 'contract' ? 'GL_ET' : 'CUSTOMER', action: terminalAction, milestoneCode: EXPORT_BOUNDARY, }; } if (!isDone('DECLARED')) { return { actor: 'GL_ET', action: 'Upload customs declaration documents', milestoneCode: 'DECLARED', }; } if (meta.dutyRequired === null || meta.dutyRequired === undefined) { return { actor: 'GL_ET', action: 'Set whether duty/tax applies', milestoneCode: 'DUTY_TAXES_ADVISED', }; } if (meta.dutyRequired && !isDone('DUTY_TAX_PAID')) { if (!isDone('DUTY_TAXES_ADVISED')) { return { actor: 'GL_ET', action: 'Advise duty and tax amount', milestoneCode: 'DUTY_TAXES_ADVISED', }; } return { actor: 'CUSTOMER', action: 'Upload duty/tax payment slip', milestoneCode: 'DUTY_TAX_PAID', }; } if (!isDone('TRANSIT_PERMIT_UPLOADED')) { return { actor: 'GL_ET', action: 'Upload transit permit screenshot', milestoneCode: 'TRANSIT_PERMIT_UPLOADED', }; } if (!meta.preClearanceFinalizedAt) { return { actor: 'GL_ET', action: 'Finalize pre-clearance', milestoneCode: 'TRANSIT_PERMIT_UPLOADED', }; } if (!isDone(IMPORT_BOUNDARY)) { return { actor: 'GL_DJ', action: 'Upload Delivery Order', milestoneCode: IMPORT_BOUNDARY, }; } return { actor: terminalScope === 'contract' ? 'GL_ET' : 'CUSTOMER', action: terminalAction, milestoneCode: IMPORT_BOUNDARY, }; } etPendingMilestoneCodes(milestones: ClearanceMilestone[]): string | null { const pending = milestones.find((m) => m.status === 'PENDING' && m.ownerRegion === 'ET'); return pending?.milestoneCode ?? null; } djPendingMilestoneCodes(milestones: ClearanceMilestone[]): string | null { const pending = milestones.find((m) => m.status === 'PENDING' && m.ownerRegion === 'DJ'); return pending?.milestoneCode ?? null; } }