import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { In, Repository } from 'typeorm'; import { CreateDjiboutiIncidentDto, CreateEmptyContainerReturnDto, ImportOperationActionDto, LoadEmptyContainersOnTrainDto, RecordDeclarationDto, AssignCustomsRiskDto, UpdateEmptyContainerReturnStatusDto, UploadImportCustomsDocumentDto, } from './dto/import-operations.dto'; import { DjiboutiIncident, type DjiboutiIncidentType, } from './entities/djibouti-incident.entity'; import { assertWagonLoad } from './empty-container-wagon.util'; import { EmptyContainerReturn } from './entities/empty-container-return.entity'; import { ImportCustomsFinalization, type ImportCustomsDocumentType, } from './entities/import-customs-finalization.entity'; const DAMAGE_INCIDENTS: DjiboutiIncidentType[] = [ 'SEAL_BROKEN', 'CONTAINER_OPENED', 'CONTAINER_DAMAGED', 'FLUID_LEAKING', ]; @Injectable() export class ImportOperationsService { constructor( @InjectRepository(DjiboutiIncident) private readonly incidents: Repository, @InjectRepository(ImportCustomsFinalization) private readonly customs: Repository, @InjectRepository(EmptyContainerReturn) private readonly emptyReturns: Repository, ) {} listIncidents(bookingId?: string) { return this.incidents.find({ where: bookingId ? { bookingId } : {}, order: { reportedAt: 'DESC', createdAt: 'DESC' } as never, }); } async createIncident(dto: CreateDjiboutiIncidentDto) { const photos = dto.photos ?? []; if (DAMAGE_INCIDENTS.includes(dto.incidentType) && photos.length === 0) { throw new BadRequestException('Photos are required for damage-related Djibouti incidents'); } const incident = await this.incidents.save( this.incidents.create({ bookingId: dto.bookingId, containerNumber: dto.containerNumber ?? null, cargoId: dto.cargoId ?? null, facility: dto.facility ?? null, station: dto.station ?? null, incidentType: dto.incidentType, description: dto.description, photos, reportedBy: dto.reportedBy ?? null, reportedAt: dto.reportedAt ? new Date(dto.reportedAt) : new Date(), }), ); console.log( `[NOTIFY] Djibouti incident ${incident.incidentType} for booking ${incident.bookingId}; notify Global Logistics Ethiopia and customer.`, ); console.log( `[MOVEMENT] Attach incident ${incident.id} to booking ${incident.bookingId} movement history.`, ); return incident; } async getCustoms(bookingId: string) { return this.getOrCreateCustoms(bookingId); } async uploadCustomsDocument(bookingId: string, dto: UploadImportCustomsDocumentDto) { const row = await this.getOrCreateCustoms(bookingId); const documents = { ...(row.documents ?? {}), [dto.documentType]: dto.fileId }; await this.customs.update(row.id, { documents, performedBy: dto.performedBy ?? row.performedBy ?? null, }); return this.getCustoms(bookingId); } async recordDeclaration(bookingId: string, dto: RecordDeclarationDto) { const row = await this.getOrCreateCustoms(bookingId); await this.customs.update(row.id, { declarationSerialNumber: dto.declarationSerialNumber, performedBy: dto.performedBy ?? row.performedBy ?? null, }); return this.getCustoms(bookingId); } async notifyDutiesTaxes(bookingId: string, dto: ImportOperationActionDto = {}) { const row = await this.getOrCreateCustoms(bookingId); await this.customs.update(row.id, { dutiesTaxesNotifiedAt: row.dutiesTaxesNotifiedAt ?? new Date(), performedBy: dto.performedBy ?? row.performedBy ?? null, notes: dto.notes ?? row.notes ?? null, }); console.log(`[NOTIFY] Duties and taxes notification sent for booking ${bookingId}.`); return this.getCustoms(bookingId); } async markDutiesTaxesPaid(bookingId: string, dto: ImportOperationActionDto = {}) { const row = await this.getOrCreateCustoms(bookingId); this.assertDocument(row, 'CUSTOMER_PAYMENT_SLIP', 'Customer payment slip is required before marking duties and taxes paid'); await this.customs.update(row.id, { dutiesTaxesPaidAt: row.dutiesTaxesPaidAt ?? new Date(), performedBy: dto.performedBy ?? row.performedBy ?? null, notes: dto.notes ?? row.notes ?? null, }); return this.getCustoms(bookingId); } async assignRisk(bookingId: string, dto: AssignCustomsRiskDto) { const row = await this.getOrCreateCustoms(bookingId); await this.customs.update(row.id, { customsRisk: dto.risk, performedBy: dto.performedBy ?? row.performedBy ?? null, }); console.log(`[NOTIFY] Customs risk ${dto.risk} assigned for booking ${bookingId}; notify customer.`); return this.getCustoms(bookingId); } async markReleasePermitted(bookingId: string, dto: ImportOperationActionDto = {}) { const row = await this.getOrCreateCustoms(bookingId); this.assertReleaseReady(row); await this.customs.update(row.id, { importReleasePermittedAt: row.importReleasePermittedAt ?? new Date(), completedAt: row.completedAt ?? new Date(), performedBy: dto.performedBy ?? row.performedBy ?? null, notes: dto.notes ?? row.notes ?? null, }); console.log(`[NOTIFY] Import release permitted for booking ${bookingId}; notify customer.`); return this.getCustoms(bookingId); } listEmptyReturns() { return this.emptyReturns.find({ order: { createdAt: 'DESC' } as never }); } async createEmptyReturn(dto: CreateEmptyContainerReturnDto) { const returnDate = dto.returnDate ? new Date(dto.returnDate) : new Date(); return this.emptyReturns.save( this.emptyReturns.create({ containerNumber: dto.containerNumber, bookingId: dto.bookingId ?? null, customerId: dto.customerId ?? null, returnDate, containerSize: dto.containerSize ?? null, facility: dto.facility ?? null, yard: dto.yard ?? null, zone: dto.zone ?? null, condition: dto.condition ?? null, handoverNote: dto.handoverNote ?? null, performedBy: dto.performedBy ?? null, returnedBy: dto.returnedBy ?? null, statusHistory: [ { status: 'RETURNED', changedAt: returnDate.toISOString(), performedBy: dto.performedBy ?? null }, ], }), ); } /** * Load returned empties onto an export departure. A wagon takes ONE 40ft or * TWO 20ft — never a mix, never three. Empties already sitting on a wagon of * the same schedule count against that wagon, so incremental loads cannot * quietly double-book a slot. * * ponytail: does not check the wagon is free of cargo bookings — the loading * UI picks only unallocated wagons from the schedule's plan. Cross-check here * if empties ever get loaded from another client. */ async loadEmptyReturnsOnTrain(dto: LoadEmptyContainersOnTrainDto) { const ids = dto.items.map((item) => item.id); const rows = await this.emptyReturns.find({ where: { id: In(ids) } }); const missing = ids.filter((id) => !rows.some((row) => row.id === id)); if (missing.length) { throw new NotFoundException(`Empty container return(s) not found: ${missing.join(', ')}`); } const alreadyOnTrain = await this.emptyReturns.find({ where: { trainScheduleId: dto.trainScheduleId }, }); const byWagon = new Map(); for (const row of alreadyOnTrain) { if (row.wagonSequenceNo == null || ids.includes(row.id)) continue; byWagon.set(row.wagonSequenceNo, [ ...(byWagon.get(row.wagonSequenceNo) ?? []), row.containerSize ?? '40', ]); } for (const item of dto.items) { byWagon.set(item.wagonSequenceNo, [ ...(byWagon.get(item.wagonSequenceNo) ?? []), item.containerSize, ]); } assertWagonLoad(byWagon); const changedAt = new Date().toISOString(); for (const item of dto.items) { const row = rows.find((candidate) => candidate.id === item.id)!; await this.emptyReturns.update(item.id, { status: 'WAGON_ALLOCATED', containerSize: item.containerSize, trainScheduleId: dto.trainScheduleId, wagonSequenceNo: item.wagonSequenceNo, wagonAllocationReference: dto.trainNumber ?? dto.trainScheduleId, performedBy: dto.performedBy ?? row.performedBy ?? null, statusHistory: [ ...(row.statusHistory ?? []), { status: 'WAGON_ALLOCATED' as const, changedAt, performedBy: dto.performedBy ?? null }, ], }); } return this.emptyReturns.find({ where: { trainScheduleId: dto.trainScheduleId } }); } async updateEmptyReturnStatus(id: string, dto: UpdateEmptyContainerReturnStatusDto) { const row = await this.emptyReturns.findOne({ where: { id } }); if (!row) { throw new NotFoundException(`Empty container return ${id} not found`); } await this.emptyReturns.update(id, { status: dto.status, wagonAllocationReference: dto.wagonAllocationReference ?? row.wagonAllocationReference ?? null, handoverNote: dto.handoverNote ?? row.handoverNote ?? null, performedBy: dto.performedBy ?? row.performedBy ?? null, statusHistory: [ ...(row.statusHistory ?? []), { status: dto.status, changedAt: new Date().toISOString(), performedBy: dto.performedBy ?? row.performedBy ?? null }, ], }); return this.emptyReturns.findOneOrFail({ where: { id } }); } private async getOrCreateCustoms(bookingId: string) { const existing = await this.customs.findOne({ where: { bookingId } }); if (existing) return existing; return this.customs.save(this.customs.create({ bookingId, documents: {} })); } private assertDocument( row: ImportCustomsFinalization, type: ImportCustomsDocumentType, message: string, ) { if (!row.documents?.[type]) { throw new BadRequestException(message); } } private assertReleaseReady(row: ImportCustomsFinalization) { this.assertDocument(row, 'T1_CLOSURE_PROOF', 'T1 closure proof is required before import release'); this.assertDocument(row, 'IMPORT_RELEASE_PERMIT', 'Import release permit upload is required before release is permitted'); if (!row.declarationSerialNumber?.trim()) { throw new BadRequestException('Declaration serial number is required before import release'); } if (!row.customsRisk) { throw new BadRequestException('Customs risk must be assigned before import release'); } if (!row.dutiesTaxesPaidAt) { throw new BadRequestException('Duties and taxes must be paid before import release'); } } }