import { BadRequestException, Injectable } from '@nestjs/common'; import { ContractDocPhase } from '@edr/types'; import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service'; import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service'; import { FilesService } from '../files/files.service'; import { BookingsRepository } from '../bookings/bookings.repository'; import { BookingsService } from '../bookings/bookings.service'; import { ClearanceMilestone } from './entities/clearance-milestone.entity'; import { Booking } from '../bookings/entities/booking.entity'; import { clearanceCodesForBooking } from '../bookings/clearance.util'; import { ClearanceWorkflowService } from './clearance-workflow.service'; import { ClearanceMilestoneService } from './clearance-milestone.service'; import { AdviseContractDutyDto } from './dto/phased-clearance.dto'; import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_BOOKING_QUEUE_STATUSES, persistDeclarationUploads, persistTransitPermitUploads, PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES } from './phased-clearance.util'; const RO_VESSEL_MIN_DAYS_CODE = 'ro_vessel_min_days'; export interface BookingClearanceView { bookingId: string; status: string; includesCustoms: boolean; inputCode: string | null; outputCode: string | null; documents: Array<{ fileKey: string; label: string; required: boolean; uploadedBy: 'customer' | 'gl'; settingCode: string; file: { id: string; name: string; url: string } | null; reviewStatus: 'PENDING' | 'APPROVED' | 'QUERIED' | null; note: string | null; }>; allApproved: boolean; phase?: string | null; milestones?: Array<{ id: string; milestoneCode: string; milestoneLabel: string; status: string; ownerRegion?: string | null; metadata?: Record | null; sortOrder: number; }>; nextAction?: { actor: string; action: string; milestoneCode?: string | null; blockedReason?: string | null; } | null; dutyRequired?: boolean | null; roHold?: boolean; roHoldReason?: string | null; vesselDepartureDate?: string | null; roAmendmentRequestedAt?: string | null; operationReady?: boolean; preClearanceFinalized?: boolean; dutyAdvice?: { amount: number; currency: string; declarationSerial?: string | null; noticeFile?: { id: string; name: string; url: string } | null; } | null; workflowFiles?: ReturnType; } @Injectable() export class BookingClearanceService { constructor( private readonly bookingsRepository: BookingsRepository, private readonly bookingsService: BookingsService, private readonly filesService: FilesService, private readonly fileUploadSettingsService: FileUploadSettingsService, private readonly workflowService: ClearanceWorkflowService, private readonly milestoneService: ClearanceMilestoneService, private readonly dropdownSettingsService: DropdownSettingsService, ) {} private async assertPhasedGeneralCustoms(booking: Booking): Promise { if (!booking.customsClearingEnabled) { throw new BadRequestException('Phased clearance applies only to customs bookings.'); } if (booking.contractKind !== 'GENERAL') { throw new BadRequestException('Per-booking phased clearance applies to general contracts.'); } if (!booking.contractId) { throw new BadRequestException('Booking is not linked to a contract.'); } } private async loadBooking(bookingId: string): Promise { const booking = await this.bookingsService.findById(bookingId); await this.assertPhasedGeneralCustoms(booking); return booking; } async getClearanceView(bookingId: string): Promise { const booking = await this.loadBooking(bookingId); const { inputCode, outputCode, includesCustoms } = clearanceCodesForBooking(booking); const files = await this.filesService.findByResource(bookingId, 'bookings'); const fileByCode = new Map(files.map((f) => [f.code, f])); const reviews = await this.bookingsRepository.findDocumentReviews(bookingId); const reviewByKey = new Map(reviews.map((r) => [`${r.settingCode}:${r.fileKey}`, r])); const documents: BookingClearanceView['documents'] = []; const pushSetting = async (code: string | null, uploadedBy: 'customer' | 'gl') => { if (!code) return; let setting; try { setting = await this.fileUploadSettingsService.getByCode(code); } catch { return; } for (const field of setting.fields ?? []) { const file = fileByCode.get(field.fileKey) ?? null; const review = reviewByKey.get(`${code}:${field.fileKey}`) ?? null; documents.push({ fileKey: field.fileKey, label: field.fileLabel, required: field.isRequired, uploadedBy, settingCode: code, file: file ? { id: file.id, name: file.name, url: file.url } : null, reviewStatus: review?.status ?? null, note: review?.note ?? null, }); } }; await pushSetting(inputCode, 'customer'); await pushSetting(outputCode, 'gl'); for (const f of files) { if (!f.code?.startsWith('custom_')) continue; const review = reviewByKey.get(`custom:${f.code}`) ?? null; documents.push({ fileKey: f.code, label: f.name, required: false, uploadedBy: 'customer', settingCode: 'custom', file: { id: f.id, name: f.name, url: f.url }, reviewStatus: review?.status ?? null, note: review?.note ?? null, }); } const allApproved = await this.isClearanceFullyApproved(booking); const milestones = await this.workflowService.listMilestonesForBooking(bookingId); const phase = this.workflowService.resolvePhaseForBooking(booking, milestones); const nextAction = this.workflowService.computeNextActionForBooking(booking, milestones); const boundary = await this.workflowService.isBoundaryCompleteForBooking( bookingId, booking.tradeDirection ?? 'IMPORT', ); const dutyAdvice = this.buildDutyAdvice(files, milestones); const workflowFiles = buildWorkflowFiles( files, booking.tradeDirection ?? 'IMPORT', ); return { bookingId, status: booking.status, includesCustoms, inputCode, outputCode, documents, allApproved, phase, milestones: milestones.map((m) => ({ id: m.id, milestoneCode: m.milestoneCode, milestoneLabel: m.milestoneLabel, status: m.status, ownerRegion: m.ownerRegion, metadata: (m.metadata ?? null) as Record | null, sortOrder: m.sortOrder, })), nextAction, dutyRequired: booking.dutyRequired ?? null, roHold: Boolean(booking.roHoldReason), roHoldReason: booking.roHoldReason ?? null, vesselDepartureDate: booking.vesselDepartureDate ?? null, roAmendmentRequestedAt: booking.roAmendmentRequestedAt ? booking.roAmendmentRequestedAt.toISOString() : null, operationReady: boundary, preClearanceFinalized: Boolean(booking.preClearanceFinalizedAt), dutyAdvice, workflowFiles, }; } private buildDutyAdvice( files: Array<{ code?: string | null; id: string; name: string; url: string }>, milestones: ClearanceMilestone[], ): BookingClearanceView['dutyAdvice'] { const advised = milestones.find( (m) => m.milestoneCode === 'DUTY_TAXES_ADVISED' && m.status === 'COMPLETED', ); if (!advised?.metadata) return null; const amount = advised.metadata.dutyAmount; const currency = advised.metadata.dutyCurrency; if (typeof amount !== 'number' || typeof currency !== 'string') return null; const notice = files.find((f) => f.code === 'duty_tax_notice'); return { amount, currency, declarationSerial: typeof advised.metadata.declarationSerial === 'string' ? advised.metadata.declarationSerial : null, noticeFile: notice ? { id: notice.id, name: notice.name, url: notice.url } : null, }; } private async isClearanceFullyApproved(booking: Booking): Promise { const { inputCode } = clearanceCodesForBooking(booking); if (!inputCode) return true; let setting; try { setting = await this.fileUploadSettingsService.getByCode(inputCode); } catch { return false; } const required = (setting.fields ?? []).filter((f) => f.isRequired); if (required.length === 0) return true; const reviews = await this.bookingsRepository.findDocumentReviews(booking.id); return required.every((field) => reviews.some( (r) => r.settingCode === inputCode && r.fileKey === field.fileKey && r.status === 'APPROVED', ), ); } isPhasedGeneralCustomsBooking(booking: Booking): boolean { return ( Boolean(booking.customsClearingEnabled) && booking.contractKind === 'GENERAL' && Boolean(booking.contractId) ); } async uploadDeclaration( bookingId: string, files: Express.Multer.File[], userId?: string, ): Promise { const booking = await this.loadBooking(bookingId); const tradeDirection = booking.tradeDirection ?? 'IMPORT'; const allApproved = await this.isClearanceFullyApproved(booking); if (!allApproved) { throw new BadRequestException( 'All required customer documents must be approved before uploading a declaration.', ); } const milestones = await this.workflowService.listMilestonesForBooking(bookingId); const docsApproved = milestones.find((m) => m.milestoneCode === 'DOCUMENTS_APPROVED'); if (docsApproved?.status !== 'COMPLETED' && docsApproved?.status !== 'SKIPPED') { await this.workflowService.onAllDocsApprovedForBooking(bookingId); } await this.workflowService.assertPriorCompleteForBooking( bookingId, tradeDirection, 'UNDER_CUSTOMS_CLEARANCE', ); if (files.length === 0) { throw new BadRequestException('No declaration documents uploaded'); } await persistDeclarationUploads(this.filesService, bookingId, 'bookings', files); await this.workflowService.onDeclarationUploadedForBooking(bookingId, userId); await this.bookingsRepository.update(bookingId, { clearanceCurrentPhase: tradeDirection === 'EXPORT' ? ContractDocPhase.GlEtPostClearance : ContractDocPhase.CustomerDuty, } as never); return this.bookingsService.findById(bookingId); } async adviseDuty( bookingId: string, dto: AdviseContractDutyDto, userId?: string, attachment?: Express.Multer.File, ): Promise { const booking = await this.loadBooking(bookingId); if (booking.tradeDirection !== 'IMPORT') { throw new BadRequestException('Duty advice applies only to import bookings.'); } await this.workflowService.assertPriorCompleteForBooking( bookingId, 'IMPORT', 'DUTY_TAXES_ADVISED', ); await this.bookingsRepository.update(bookingId, { dutyRequired: dto.dutyRequired, clearanceCurrentPhase: dto.dutyRequired ? ContractDocPhase.CustomerDuty : ContractDocPhase.GlEtPostClearance, } as never); if (!dto.dutyRequired) { await this.workflowService.onDutySkippedForBooking(bookingId); } else { if (dto.amount == null || dto.amount < 0) { throw new BadRequestException('Duty amount is required when duty applies.'); } if (!attachment) { throw new BadRequestException('Duty notice attachment is required when duty applies.'); } await this.filesService.upsertByCode({ resourceId: bookingId, resource: 'bookings', code: 'duty_tax_notice', file: attachment, }); await this.milestoneService.adviseDuty( bookingId, { amount: dto.amount, currency: dto.currency ?? 'ETB', declarationSerial: dto.declarationSerial, }, userId, ); } return this.bookingsService.findById(bookingId); } async uploadDutySlip(bookingId: string, file: Express.Multer.File): Promise { const booking = await this.loadBooking(bookingId); if (booking.tradeDirection !== 'IMPORT') { throw new BadRequestException('Duty slip upload applies only to import bookings.'); } if (!booking.dutyRequired) { throw new BadRequestException('Duty/tax is not required for this clearance.'); } if (!file) throw new BadRequestException('No payment slip uploaded'); await this.filesService.upsertByCode({ resourceId: bookingId, resource: 'bookings', code: 'duty_tax_receipt', file, }); await this.workflowService.completeMilestoneForBooking(bookingId, 'DUTY_TAX_PAID'); await this.bookingsRepository.update(bookingId, { clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance, } as never); return this.bookingsService.findById(bookingId); } async uploadTransitPermit( bookingId: string, files: Express.Multer.File[], userId?: string, ): Promise { const booking = await this.loadBooking(bookingId); if (booking.tradeDirection !== 'IMPORT') { throw new BadRequestException('Transit permit applies only to import bookings.'); } await this.workflowService.assertPriorCompleteForBooking( bookingId, 'IMPORT', 'TRANSIT_PERMIT_UPLOADED', ); if (files.length === 0) { throw new BadRequestException('No transit permit documents uploaded'); } await persistTransitPermitUploads(this.filesService, bookingId, 'bookings', files); await this.workflowService.completeMilestoneForBooking( bookingId, 'TRANSIT_PERMIT_UPLOADED', userId, ); await this.bookingsRepository.update(bookingId, { clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance, } as never); return this.bookingsService.findById(bookingId); } async finalizePreClearance(bookingId: string): Promise { const booking = await this.loadBooking(bookingId); if (booking.tradeDirection !== 'IMPORT') { throw new BadRequestException('Pre-clearance finalize applies only to import bookings.'); } await this.workflowService.assertPriorCompleteForBooking( bookingId, 'IMPORT', 'TRANSIT_PERMIT_UPLOADED', ); if (booking.preClearanceFinalizedAt) { return this.bookingsService.findById(bookingId); } await this.bookingsRepository.update(bookingId, { preClearanceFinalizedAt: new Date(), clearanceCurrentPhase: ContractDocPhase.GlDjCollection, } as never); return this.bookingsService.findById(bookingId); } async uploadDeliveryOrder( bookingId: string, file: Express.Multer.File, userId?: string, ): Promise { const booking = await this.loadBooking(bookingId); if (booking.tradeDirection !== 'IMPORT') { throw new BadRequestException('Delivery Order applies only to import bookings.'); } if (!booking.preClearanceFinalizedAt) { throw new BadRequestException( 'GL Ethiopia must finalize pre-clearance before the Delivery Order can be uploaded.', ); } await this.workflowService.assertPriorCompleteForBooking(bookingId, 'IMPORT', 'DO_COLLECTED'); if (!file) throw new BadRequestException('No Delivery Order uploaded'); await this.filesService.upsertByCode({ resourceId: bookingId, resource: 'bookings', code: 'delivery_order', file, }); await this.workflowService.completeMilestoneForBooking(bookingId, 'DO_COLLECTED', userId); await this.workflowService.markReadyForOperation(bookingId); return this.bookingsService.findById(bookingId); } private async resolveRoMinDays(): Promise { try { const setting = await this.dropdownSettingsService.getByCode(RO_VESSEL_MIN_DAYS_CODE); const first = setting.children?.[0]; const n = Number(first?.value); return Number.isFinite(n) && n > 0 ? n : 2; } catch { return 2; } } private daysUntil(dateStr: string): number { const target = new Date(dateStr); const today = new Date(); today.setHours(0, 0, 0, 0); target.setHours(0, 0, 0, 0); return Math.floor((target.getTime() - today.getTime()) / (24 * 60 * 60 * 1000)); } async uploadReleaseOrder( bookingId: string, file: Express.Multer.File, vesselDepartureDate: string, userId?: string, ): Promise<{ booking: Booking; hold: boolean; holdReason?: string }> { const booking = await this.loadBooking(bookingId); if (booking.tradeDirection !== 'EXPORT') { throw new BadRequestException('Release Order applies only to export bookings.'); } await this.workflowService.assertPriorCompleteForBooking( bookingId, 'EXPORT', 'RELEASE_ORDER_SECURED', ); if (!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); await this.filesService.upsertByCode({ resourceId: bookingId, resource: 'bookings', code: 'release_order', file, }); await this.bookingsRepository.update(bookingId, { vesselDepartureDate, roAmendmentRequestedAt: null, } as never); if (leadDays < minDays) { const reason = `Vessel departs in ${leadDays} day(s) — minimum lead time is ${minDays} day(s). Request a port amendment or upload a new RO with a later date.`; await this.bookingsRepository.update(bookingId, { roHoldReason: reason, clearanceCurrentPhase: ContractDocPhase.GlDjCollection, } as never); return { booking: await this.bookingsService.findById(bookingId), hold: true, holdReason: reason, }; } await this.bookingsRepository.update(bookingId, { roHoldReason: null, clearanceCurrentPhase: ContractDocPhase.GlEtOutput, } as never); await this.workflowService.completeMilestoneForBooking( bookingId, 'RELEASE_ORDER_SECURED', userId, ); return { booking: await this.bookingsService.findById(bookingId), hold: false }; } async requestRoAmendment( bookingId: string, note?: string, userId?: string, ): Promise { const booking = await this.loadBooking(bookingId); if (booking.tradeDirection !== 'EXPORT') { throw new BadRequestException('RO amendment applies only to export bookings.'); } const reason = note?.trim() || 'Port amendment requested — vessel departure window is too short. A new Release Order will be required.'; await this.bookingsRepository.update(bookingId, { roAmendmentRequestedAt: new Date(), roHoldReason: reason, clearanceCurrentPhase: ContractDocPhase.GlDjCollection, } as never); if (userId) { await this.bookingsRepository.createReviewNote( bookingId, reason, 'CHANGES_REQUESTED', userId, ); } return this.bookingsService.findById(bookingId); } async confirmExportRelease(bookingId: string, userId?: string): Promise { const booking = await this.loadBooking(bookingId); if (booking.tradeDirection !== 'EXPORT') { throw new BadRequestException('Export release applies only to export bookings.'); } await this.workflowService.assertPriorCompleteForBooking( bookingId, 'EXPORT', 'EXPORT_RELEASED', ); await this.workflowService.onExportReleasedForBooking(bookingId, userId); return this.bookingsService.findById(bookingId); } async etQueue(): Promise { const candidates = await this.bookingsRepository.findByStatuses([ ...PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES, ]); const filtered: Booking[] = []; for (const b of candidates) { if (!this.isPhasedGeneralCustomsBooking(b)) continue; const milestones = await this.workflowService.listMilestonesForBooking(b.id); if (belongsOnEtClearanceQueue(milestones)) filtered.push(b); } return filtered; } async djQueue(): Promise { const candidates = await this.bookingsRepository.findByStatuses([ ...DJ_BOOKING_QUEUE_STATUSES, ]); const filtered: Booking[] = []; for (const b of candidates) { if (!this.isPhasedGeneralCustomsBooking(b)) continue; const milestones = await this.workflowService.listMilestonesForBooking(b.id); if ( belongsOnDjClearanceQueue(b.tradeDirection, null, milestones, { roHoldReason: b.roHoldReason, preClearanceFinalizedAt: b.preClearanceFinalizedAt, }) ) { filtered.push(b); } } return filtered; } }