import { BadRequestException, ConflictException, Injectable } from '@nestjs/common'; 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 { contractClearanceCodes } from './contract-clearance.util'; import { Contract } from './entities/contract.entity'; import { ContractDocReviewStatus } from './entities/contract-document-review.entity'; import { FilterContractDto } from './dto/filter-contract.dto'; 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; } export interface ContractClearanceView { contractId: string; status: string; clearanceStatus: string; cycleNumber: number; includesCustoms: boolean; inputCode: string | null; outputCode: string | null; documents: ContractClearanceDocument[]; allApproved: boolean; } @Injectable() export class ContractClearanceService { constructor( private readonly contractsRepository: ContractsRepository, private readonly contractsService: ContractsService, private readonly filesService: FilesService, private readonly fileUploadSettingsService: FileUploadSettingsService, ) {} /** The pre-booking clearance document grid for a contract (Path B). */ async getClearanceView(contractId: string): Promise { const 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, }); } }; 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, }); } const allApproved = await this.isClearanceFullyApproved(contract); return { contractId, status: contract.status, clearanceStatus: contract.clearanceStatus, cycleNumber: cycle?.cycleNumber ?? contract.clearanceCycleNumber, includesCustoms, inputCode, outputCode, documents, allApproved, }; } /** * 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', ), ); } /** * 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'); } return this.contractsService.findById(contractId); } private async assertRequiredInputsPresent( contractId: string, inputCode: string, files: Express.Multer.File[], ): Promise { let setting; try { setting = await this.fileUploadSettingsService.getByCode(inputCode); } catch { return; } const required = (setting.fields ?? []).filter((f) => f.isRequired); if (required.length === 0) return; const existing = await this.filesService.findByResource(contractId, 'contracts'); const presentKeys = new Set([ ...existing.map((f) => f.code), ...files.map((f) => f.fieldname), ]); const missing = required.filter((f) => !presentKeys.has(f.fileKey)); if (missing.length > 0) { const labels = missing.map((f) => f.fileLabel).join(', '); throw new BadRequestException( `Please upload all required documents before submitting: ${labels}`, ); } } /** * Path A (no customs) — the customer clears the cargo himself and uploads his * own clearance proof, reviewed by Operations rather than GL. True when a * clearance doc set resolves for a non-customs contract. */ private isSelfClear(contract: Contract): boolean { if (contract.customsClearingEnabled) return false; return contractClearanceCodes(contract).inputCode != null; } /** GL ET (Path B) reviews a single document: APPROVED or QUERIED. */ async reviewDocument( contractId: string, fileKey: string, status: 'APPROVED' | 'QUERIED', staffId: string, note?: string, ): Promise { return this.applyReview(contractId, fileKey, status, staffId, 'GL_ET', note); } /** * Operations (Path A) reviews a customer self-clearance document. Identical * approve/query loop to {@link reviewDocument}; rejects customs (Path B) * contracts, which are GL-reviewed. */ async opsReviewDocument( contractId: string, fileKey: string, status: 'APPROVED' | 'QUERIED', staffId: string, note?: string, ): Promise { const contract = await this.contractsService.findById(contractId); if (!this.isSelfClear(contract)) { throw new ConflictException( 'Operations review applies only to self-clearance (non-customs) contracts.', ); } return this.applyReview(contractId, fileKey, status, staffId, 'OPERATIONS', note); } private async applyReview( contractId: string, fileKey: string, status: 'APPROVED' | 'QUERIED', staffId: string, reviewerRole: 'GL_ET' | 'OPERATIONS', note?: string, ): Promise { const contract = await this.contractsService.findById(contractId); if (contract.status !== 'CLEARANCE_UNDER_REVIEW') { throw new ConflictException( `Cannot review clearance documents on status "${contract.status}".`, ); } 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); 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); if (cycle) { await this.contractsRepository.setCycleStatus(cycle.id, 'AWAITING_DOCUMENTS'); } } 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); if (contract.status !== 'CLEARANCE_UNDER_REVIEW') { throw new ConflictException( `Cannot upload output documents on status "${contract.status}".`, ); } 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 and required output docs present → CLEARANCE_READY_FOR_BOOKING * (GL then creates the 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.', ); } if (contract.status !== 'CLEARANCE_UNDER_REVIEW') { throw new ConflictException( `Cannot finalize clearance on status "${contract.status}".`, ); } const approved = await this.isClearanceFullyApproved(contract); if (!approved) { throw new BadRequestException( 'All required documents must be approved before clearance can be finalized', ); } 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.', ); } if (contract.status !== 'CLEARANCE_UNDER_REVIEW') { throw new ConflictException( `Cannot finalize clearance on status "${contract.status}".`, ); } 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 queue: customs (Path B) contracts awaiting pre-booking document review. */ async queue( filter: FilterContractDto, region?: string, ): Promise { void region; // single ET pre-booking queue today; region reserved for split return this.contractsRepository.findAllPaginated({ page: filter.page ?? 1, pageSize: filter.pageSize ?? 100, statuses: ['CLEARANCE_UNDER_REVIEW'], customsClearingEnabled: true, sortBy: filter.sortBy, sortOrder: filter.sortOrder, }); } /** * Operations queue: self-clearance (Path A) contracts awaiting Operations * review of the customer's own clearance documents. */ async opsQueue(filter: FilterContractDto): Promise { return this.contractsRepository.findAllPaginated({ page: filter.page ?? 1, pageSize: filter.pageSize ?? 100, statuses: ['CLEARANCE_UNDER_REVIEW'], customsClearingEnabled: false, sortBy: filter.sortBy, sortOrder: filter.sortOrder, }); } }