import { BadRequestException } from '@nestjs/common'; import { catalogEntriesForTradeDirection, declarationFileLabel, isDeclarationFileCode, isImportTransitPermitFileCode, isExportTransportFileCode, isT1TransportFileCode, exportTransportFileLabel, t1TransportFileLabel, transitPermitFileLabel, type ClearanceWorkflowFile, } from '@edr/types'; /** Require at least one declaration file in the upload batch. */ export function assertDeclarationFiles(files: Express.Multer.File[]): void { if (files.length === 0) { throw new BadRequestException('No declaration documents uploaded'); } } /** Assign stable `declaration_*` codes so multi-file uploads always pass validation. */ export function normalizeDeclarationFieldNames( files: Express.Multer.File[], ): Express.Multer.File[] { return files.map((file, index) => ({ ...file, fieldname: `declaration_${index}`, })); } type DeclarationFileStore = { findByResource( resourceId: string, resource: string, ): Promise>; deleteByCode(resourceId: string, resource: string, code: string): Promise; upload(input: { resourceId: string; resource: string; code: string; file: Express.Multer.File; }): Promise; }; /** Replace all declaration files on a resource with a new multi-file upload batch. */ export async function persistDeclarationUploads( store: DeclarationFileStore, resourceId: string, resource: string, files: Express.Multer.File[], ): Promise { const normalized = normalizeDeclarationFieldNames(files); assertDeclarationFiles(normalized); const existing = await store.findByResource(resourceId, resource); await Promise.all( existing .filter((f) => f.code && isDeclarationFileCode(f.code)) .map((f) => store.deleteByCode(resourceId, resource, f.code!)), ); await Promise.all( normalized.map((file, index) => store.upload({ resourceId, resource, code: `declaration_${index}`, file, }), ), ); } /** Require at least one transit permit file in the upload batch. */ export function assertTransitPermitFiles(files: Express.Multer.File[]): void { if (files.length === 0) { throw new BadRequestException('No transit permit documents uploaded'); } } /** Assign stable `transit_permit_*` codes for multi-file import transit uploads. */ export function normalizeTransitPermitFieldNames( files: Express.Multer.File[], ): Express.Multer.File[] { return files.map((file, index) => ({ ...file, fieldname: `transit_permit_${index}`, })); } /** Replace all import transit permit files on a resource with a new multi-file batch. */ export async function persistTransitPermitUploads( store: DeclarationFileStore, resourceId: string, resource: string, files: Express.Multer.File[], ): Promise { const normalized = normalizeTransitPermitFieldNames(files); assertTransitPermitFiles(normalized); const existing = await store.findByResource(resourceId, resource); await Promise.all( existing .filter((f) => f.code && isImportTransitPermitFileCode(f.code)) .map((f) => store.deleteByCode(resourceId, resource, f.code!)), ); await Promise.all( normalized.map((file, index) => store.upload({ resourceId, resource, code: `transit_permit_${index}`, file, }), ), ); } /** Require at least one export transport document in the upload batch. */ export function assertExportTransportFiles(files: Express.Multer.File[]): void { if (files.length === 0) { throw new BadRequestException('No transit permit documents uploaded'); } } export function normalizeExportTransportFieldNames( files: Express.Multer.File[], ): Express.Multer.File[] { return files.map((file, index) => ({ ...file, fieldname: `export_transport_document_${index}`, })); } /** Replace all export transport documents on a booking with a new multi-file batch. */ export async function persistExportTransportUploads( store: DeclarationFileStore, bookingId: string, files: Express.Multer.File[], ): Promise { const normalized = normalizeExportTransportFieldNames(files); assertExportTransportFiles(normalized); const existing = await store.findByResource(bookingId, 'bookings'); await Promise.all( existing .filter((f) => f.code && isExportTransportFileCode(f.code)) .map((f) => store.deleteByCode(bookingId, 'bookings', f.code!)), ); await Promise.all( normalized.map((file, index) => store.upload({ resourceId: bookingId, resource: 'bookings', code: `export_transport_document_${index}`, file, }), ), ); } /** Require at least one T1 transport document in the upload batch. */ export function assertT1TransportFiles(files: Express.Multer.File[]): void { if (files.length === 0) { throw new BadRequestException('No T1 transport documents uploaded'); } } export function normalizeT1TransportFieldNames( files: Express.Multer.File[], ): Express.Multer.File[] { return files.map((file, index) => ({ ...file, fieldname: `t1_transport_document_${index}`, })); } /** Replace all T1 transport documents on a booking with a new multi-file batch. */ export async function persistT1TransportUploads( store: DeclarationFileStore, bookingId: string, files: Express.Multer.File[], ): Promise { const normalized = normalizeT1TransportFieldNames(files); assertT1TransportFiles(normalized); const existing = await store.findByResource(bookingId, 'bookings'); await Promise.all( existing .filter((f) => f.code && isT1TransportFileCode(f.code)) .map((f) => store.deleteByCode(bookingId, 'bookings', f.code!)), ); await Promise.all( normalized.map((file, index) => store.upload({ resourceId: bookingId, resource: 'bookings', code: `t1_transport_document_${index}`, file, }), ), ); } export function parseDutyRequiredForm(value: string | boolean | undefined): boolean { if (typeof value === 'boolean') return value; if (value === undefined || value === '') return false; return value === 'true' || value === '1'; } type DjQueueMilestone = { ownerRegion?: string | null; status: string; }; type DjQueueCycle = { preClearanceFinalizedAt?: Date | null; roHoldReason?: string | null; } | null | undefined; /** Whether a customs clearance item belongs on the persistent GL Djibouti list. */ export function belongsOnDjClearanceQueue( tradeDirection: string | null | undefined, cycle: DjQueueCycle, milestones: DjQueueMilestone[], extras?: { roHoldReason?: string | null; preClearanceFinalizedAt?: Date | null; }, ): boolean { const roHold = cycle?.roHoldReason ?? extras?.roHoldReason; if (roHold) return true; const hasDjActivity = milestones.some( (m) => m.ownerRegion === 'DJ' && (m.status === 'COMPLETED' || m.status === 'PENDING'), ); if (hasDjActivity) return true; // Import DO upload is un-gated — Djibouti GL must see import customs items from // the start, not only after Ethiopia finalizes pre-clearance. if (tradeDirection === 'IMPORT') return true; return false; } /** Contract statuses for persistent phased customs clearance lists (ET + DJ). */ export const PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES = [ 'AWAITING_CLEARANCE_DOCUMENTS', 'CLEARANCE_UNDER_REVIEW', 'CLEARANCE_READY_FOR_BOOKING', 'ACTIVE_SHIPMENT_IN_PROGRESS', 'FULLY_EXECUTED', 'CONTRACT_ACTIVE', 'CONTRACT_CLOSED', // Terminal contracts stay on the list — the clearance hub is GL's history of // everything that passed through, not just the live work queue. 'EXPIRED', 'CANCELLED', 'REJECTED', ] as const; /** Whether a customs clearance item belongs on the persistent GL Ethiopia list. */ export function belongsOnEtClearanceQueue(milestones: DjQueueMilestone[]): boolean { return milestones.some((m) => m.status === 'PENDING' || m.status === 'COMPLETED'); } /** Contract statuses that may appear on the GL Djibouti clearance list (includes post-booking). */ export const DJ_CONTRACT_QUEUE_STATUSES = PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES; /** Booking statuses for persistent phased customs clearance lists (ET + DJ). */ export const PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES = [ 'AWAITING_DOCUMENTS', 'DOCUMENTS_UNDER_REVIEW', 'CLEARANCE_READY', 'FULLY_EXECUTED', 'OPERATION_REQUEST_PENDING', 'OPERATION_CHANGES_REQUESTED', 'ROAD_DISPATCH_PENDING', // Payment phase — the booking is selected/awaiting the customer's payment. 'SELECTED_FOR_BATCH', 'PNR_GENERATED', 'AWAITING_PAYMENT', 'PAYMENT_VERIFICATION_IN_PROGRESS', 'IN_TRANSIT', 'ARRIVED', 'PAID', 'COMPLETED', 'CONTRACT_ACTIVE', 'CONTRACT_CLOSED', // Terminal bookings stay on the list — EXPIRED especially: GL rebooks it // from here, and the hub doubles as clearance history. 'EXPIRED', 'CANCELLED', 'REJECTED', ] as const; /** Booking statuses that may appear on the GL Djibouti clearance list (includes post-clearance). */ export const DJ_BOOKING_QUEUE_STATUSES = PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES; /** Build labeled phased-customs file rows from resource files. */ export function buildWorkflowFiles( files: Array<{ code?: string | null; id: string; name: string; url: string }>, tradeDirection: string, ): ClearanceWorkflowFile[] { const fileByCode = new Map( files.filter((f) => f.code).map((f) => [f.code as string, f]), ); const out: ClearanceWorkflowFile[] = []; const included = new Set(); for (const entry of catalogEntriesForTradeDirection(tradeDirection)) { const file = fileByCode.get(entry.code) ?? null; if (!file) continue; included.add(entry.code); out.push({ code: entry.code, label: entry.label, uploadedBy: entry.uploadedBy, category: entry.category, file: { id: file.id, name: file.name, url: file.url }, }); } const extraDeclarations = files .filter((f) => f.code && isDeclarationFileCode(f.code) && !included.has(f.code)) .sort((a, b) => (a.code ?? '').localeCompare(b.code ?? '')); extraDeclarations.forEach((file, index) => { if (!file.code) return; included.add(file.code); out.push({ code: file.code, label: declarationFileLabel(file.code, index), uploadedBy: 'gl_et', category: 'declaration', file: { id: file.id, name: file.name, url: file.url }, }); }); if (tradeDirection === 'IMPORT') { const extraTransit = files .filter((f) => f.code && isImportTransitPermitFileCode(f.code) && !included.has(f.code)) .sort((a, b) => (a.code ?? '').localeCompare(b.code ?? '')); extraTransit.forEach((file, index) => { if (!file.code) return; included.add(file.code); out.push({ code: file.code, label: transitPermitFileLabel(file.code, index), uploadedBy: 'gl_et', category: 'transit', file: { id: file.id, name: file.name, url: file.url }, }); }); const extraT1 = files .filter((f) => f.code && isT1TransportFileCode(f.code) && !included.has(f.code)) .sort((a, b) => (a.code ?? '').localeCompare(b.code ?? '')); extraT1.forEach((file, index) => { if (!file.code) return; included.add(file.code); out.push({ code: file.code, label: t1TransportFileLabel(file.code, index), uploadedBy: 'gl_dj', category: 'djibouti', file: { id: file.id, name: file.name, url: file.url }, }); }); } if (tradeDirection === 'EXPORT') { const extraExportTransport = files .filter((f) => f.code && isExportTransportFileCode(f.code) && !included.has(f.code)) .sort((a, b) => (a.code ?? '').localeCompare(b.code ?? '')); extraExportTransport.forEach((file, index) => { if (!file.code) return; included.add(file.code); out.push({ code: file.code, label: exportTransportFileLabel(file.code, index), uploadedBy: 'gl_et', category: 'transit', file: { id: file.id, name: file.name, url: file.url }, }); }); } return out; }