import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { In, Not, Repository } from 'typeorm'; import { NotificationAudience, NotificationType } from '@edr/types'; import { LogoSettingsService } from '../logo-settings/logo-settings.service'; import { logoImageCss, logoMarkup } from '../billing/documents/logo-markup.util'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; import { NotificationsService } from '../notifications/notifications.service'; import { sendCompanyChannels } from '../notifications/notify-company.util'; import { WarehouseReleaseDocumentService } from '../warehouses/warehouse-release-document.service'; import { BulkCreateEmptyContainerReturnsDto, 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, type EmptyContainerReturnStatus, } 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 { private readonly logger = new Logger(ImportOperationsService.name); constructor( @InjectRepository(DjiboutiIncident) private readonly incidents: Repository, @InjectRepository(ImportCustomsFinalization) private readonly customs: Repository, @InjectRepository(EmptyContainerReturn) private readonly emptyReturns: Repository, private readonly pdfDocuments: WarehouseReleaseDocumentService, private readonly logoSettings: LogoSettingsService, private readonly inbox: NotificationInboxService, private readonly notifications: NotificationsService, ) {} 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 }); } listEmptyReturnsForBooking(bookingId: string) { return this.emptyReturns.find({ where: { bookingId }, order: { createdAt: 'DESC' } as never }); } async createEmptyReturn(dto: CreateEmptyContainerReturnDto) { const returnDate = dto.returnDate ? new Date(dto.returnDate) : new Date(); const saved = await this.emptyReturns.save( this.emptyReturns.create({ containerNumber: dto.containerNumber, bookingId: dto.bookingId ?? null, customerId: dto.customerId ?? null, companyName: dto.companyName ?? 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 }, ], }), ); // RETURNED is the physical interchange itself — the customer's/trucker's // custody of the box ends here, EDR's begins. The receipt exists from this // point on, so tell the customer now, not at some later internal status. // Standalone returns (no booking) have no company to notify. if (saved.bookingId) { await this.notifyEquipmentInterchangeReady(saved); } return saved; } /** * Bulk backfill of empties already sitting in a yard but never recorded. * All-or-nothing: if any container number already has an open (not COMPLETED) * return, nothing is written — re-uploading the same sheet must not duplicate * boxes. No interchange notification is sent; these are historical rows, not * a live handover. */ async bulkCreateEmptyReturns(dto: BulkCreateEmptyContainerReturnsDto) { const numbers = dto.returns.map((r) => r.containerNumber.trim().toUpperCase()); const seen = new Set(); const dupInFile = numbers.filter((n) => (seen.has(n) ? true : (seen.add(n), false))); if (dupInFile.length > 0) { throw new BadRequestException( `Container number(s) repeated in the upload: ${[...new Set(dupInFile)].join(', ')}`, ); } const existing = await this.emptyReturns.find({ where: { containerNumber: In(numbers), status: Not('COMPLETED' as EmptyContainerReturnStatus), }, select: { containerNumber: true }, }); if (existing.length > 0) { throw new BadRequestException( `Already recorded as returned: ${existing.map((r) => r.containerNumber).join(', ')}`, ); } const rows = dto.returns.map((r, i) => { const returnDate = r.returnDate ? new Date(r.returnDate) : new Date(); return this.emptyReturns.create({ containerNumber: numbers[i], bookingId: r.bookingId ?? null, customerId: r.customerId ?? null, companyName: r.companyName ?? null, returnDate, containerSize: r.containerSize ?? null, facility: r.facility ?? null, yard: r.yard ?? null, zone: r.zone ?? null, condition: r.condition ?? null, handoverNote: r.handoverNote ?? null, performedBy: r.performedBy ?? null, returnedBy: r.returnedBy ?? null, statusHistory: [ { status: 'RETURNED' as const, changedAt: returnDate.toISOString(), performedBy: r.performedBy ?? null, }, ], }); }); return this.emptyReturns.save(rows); } /** * 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 notifyEquipmentInterchangeReady(row: EmptyContainerReturn): Promise { try { const [booking]: Array<{ companyId: string | null; reference: string }> = await this.emptyReturns.manager.query( `SELECT company_id AS "companyId", reference FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`, [row.bookingId], ); if (!booking?.companyId) return; const body = `Container ${row.containerNumber} was handed over${ row.facility ? ` at ${row.facility}` : '' }. Your equipment interchange receipt for booking ${booking.reference} is ready to download from the portal.`; await this.inbox.notify({ recipients: { companyId: booking.companyId }, audience: NotificationAudience.PORTAL, type: NotificationType.DOCUMENT_ACTION, title: 'Equipment interchange receipt ready', body, link: `/bookings/${row.bookingId}`, data: { bookingId: row.bookingId, emptyContainerReturnId: row.id }, }); await sendCompanyChannels(this.emptyReturns.manager.connection, this.notifications, booking.companyId, body); } catch (err) { this.logger.warn( `Failed to notify equipment interchange ready for return ${row.id}: ${(err as Error).message}`, ); } } async getEmptyReturnOrThrow(id: string): Promise { const row = await this.emptyReturns.findOne({ where: { id } }); if (!row) { throw new NotFoundException(`Empty container return ${id} not found`); } return row; } /** * Equipment Interchange Receipt — container number/size, exact return * timestamp, depot, condition, and the carrier/booking reference that ties * the box back to its bill of lading. Handed to the customer to download. */ async equipmentInterchangeDocument( row: EmptyContainerReturn, ): Promise<{ filename: string; buffer: Buffer }> { const booking = row.bookingId ? (( await this.emptyReturns.manager.query( `SELECT b.reference, c.name AS company_name FROM freight.bookings b LEFT JOIN freight.companies c ON c.id = b.company_id WHERE b.id = $1`, [row.bookingId], ) )[0] as { reference: string; company_name: string | null } | undefined) : undefined; const html = this.buildEquipmentInterchangeHtml(row, booking, { logoImageUrl: await this.logoSettings.getLogoImageUrl(), }); const buffer = await this.pdfDocuments.renderDocumentHtml(html, 'Equipment interchange receipt'); return { filename: `equipment-interchange-${row.containerNumber || row.id.slice(0, 8)}.pdf`, buffer, }; } private buildEquipmentInterchangeHtml( row: EmptyContainerReturn, booking: { reference: string; company_name: string | null } | undefined, opts: { logoImageUrl?: string | null }, ): string { const esc = (value: unknown) => String(value ?? '-') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); const dateTime = (value: unknown) => value ? new Date(value as string | Date).toLocaleString('en-GB', { dateStyle: 'medium', timeStyle: 'short' }) : '-'; const carrier = row.returnedBy === 'EDR' ? 'EDR Last Mile' : row.returnedBy === 'CUSTOMER' ? 'Customer Self-Haul' : '-'; const rows: Array<[string, string]> = [ ['Container Number', row.containerNumber], ['Container Size', row.containerSize ? `${row.containerSize}ft` : 'Not recorded'], ['Date & Time of Return', dateTime(row.returnDate)], ['Depot / Location', [row.facility, row.yard, row.zone].filter(Boolean).join(' — ') || '-'], ['Condition Status', row.condition || 'Good — no exceptions noted'], ['Carrier', carrier], ['Booking / BOL Reference', booking?.reference || 'Standalone — no booking'], ['Shipping Line / Customer', booking?.company_name || '-'], ['Current Status', row.status.replace(/_/g, ' ')], ['Handover Note', row.handoverNote || '-'], ]; const rowsHtml = rows .map( ([label, value]) => `${esc(label)}${esc(value)}`, ) .join(''); return ` Equipment Interchange Receipt
${logoMarkup(opts.logoImageUrl)}
Ethio-Djibouti Railway S.C.

Equipment Interchange Receipt

Receipt No. ${esc(`EIR-${row.id.slice(0, 8).toUpperCase()}`)} Generated: ${esc(new Date().toLocaleString('en-GB'))}
${rowsHtml}
This receipt confirms the physical interchange of the equipment described above at the depot/location and time stated. Both parties should verify the container number, size, and condition recorded here before signing.
Depot officer name / signature / date
Customer or driver name / signature / date
`; } 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'); } } }