import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { DataSource } from 'typeorm'; import { isT1TransportFileCode, type Freight } from '@edr/types'; import { FilesService } from '../files/files.service'; import { Booking } from '../bookings/entities/booking.entity'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; import { ClearanceIncident, IncidentType, } from './entities/clearance-incident.entity'; import { ClearanceMilestoneService } from './clearance-milestone.service'; import { persistExportTransportUploads, persistT1TransportUploads, } from './phased-clearance.util'; /** * Maps a GL post-booking document `code` to the milestone it auto-completes when * uploaded (doc §11.3/§12.2 — doc-triggered milestones). Uploading the document * marks the milestone done so the timeline advances without a separate click. */ const DOC_CODE_TO_MILESTONE: Record = { release_order: 'RELEASE_ORDER_SECURED', // export — GL DJ delivery_order: 'DO_COLLECTED', // import — GL DJ // t1_transport_document intentionally NOT doc-triggered: T1_CLOSED completes only // when GL Ethiopia accepts the T1 set after the train arrives (closeT1). import_release: 'IMPORT_RELEASE_GRANTED', // import — GL ET full_in_interchange: 'OFFLOADED', // export — GL DJ final_declaration: 'IMPORT_PROCESS_COMPLETED', // import — GL ET export_transport_document: 'EXPORT_TRANSPORT_ISSUED', // export — GL ET post-allocation }; /** * Operational Global Logistics actions that hang off a shipment booking after GL * creates it: station routing, damage/incident reporting, and the phased GL * document uploads (Release Order, Delivery Order, T1, etc.) that advance * doc-triggered milestones. See docs/new-doc.md §11–§13, gap matrix #14/#16/#18. */ @Injectable() export class GlOperationsService { constructor( private readonly dataSource: DataSource, private readonly filesService: FilesService, private readonly milestoneService: ClearanceMilestoneService, ) {} private get bookings() { return this.dataSource.getRepository(Booking); } private get incidents() { return this.dataSource.getRepository(ClearanceIncident); } private async getBooking(bookingId: string): Promise { const booking = await this.bookings.findOne({ where: { id: bookingId } }); if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`); return booking; } /** * Route a shipment to an origin station and (optionally) bind a GL staff user * to it (GL US-02). Setting both moves the shipment to that station's queue. */ async assignStation( bookingId: string, input: { stationYardId: string; staffId?: string }, ): Promise { const booking = await this.getBooking(bookingId); booking.glStationYardId = input.stationYardId; if (input.staffId) { booking.glAssignedStaffId = input.staffId; booking.glAssignedAt = new Date(); } return this.bookings.save(booking); } /** Log a cargo exception (seal broken, container damaged, etc.) with photos. */ async reportIncident( bookingId: string, input: { incidentType: IncidentType; description: string; files: Express.Multer.File[]; }, userId?: string, ): Promise { await this.getBooking(bookingId); if (!input.description?.trim()) { throw new BadRequestException('A description is required for an incident report.'); } const photoFileIds: string[] = []; for (const file of input.files ?? []) { const record = await this.filesService.upload({ resourceId: bookingId, resource: 'bookings', code: 'incident_photo', file, }); photoFileIds.push(record.id); } const incident = this.incidents.create({ bookingId, incidentType: input.incidentType, description: input.description.trim(), photoFileIds, reportedByUserId: userId ?? null, reportedAt: new Date(), }); return this.incidents.save(incident); } async listIncidents(bookingId: string): Promise { return this.incidents.find({ where: { bookingId }, order: { reportedAt: 'DESC' }, }); } /** * Customer uploads the duty/tax payment slip after GL advised the amount. The * slip attaches to the booking and doc-triggers DUTY_TAX_PAID (§11.3 #7). */ async uploadDutySlip( bookingId: string, file: Express.Multer.File, ): Promise<{ milestoneCompleted: boolean }> { await this.getBooking(bookingId); if (!file) throw new BadRequestException('No payment slip uploaded'); await this.filesService.upsertByCode({ resourceId: bookingId, resource: 'bookings', code: 'duty_tax_receipt', file, }); await this.milestoneService.completeByDocTrigger({ bookingId }, 'DUTY_TAX_PAID'); return { milestoneCompleted: true }; } /** * GL uploads a post-booking operational document (DO, RO, T1, import release, * interchange…). The file attaches to the booking; if the code maps to a * doc-triggered milestone, that milestone auto-completes. */ async uploadDocuments( bookingId: string, files: Express.Multer.File[], ): Promise<{ uploaded: number; completedMilestones: string[] }> { await this.getBooking(bookingId); if (!files?.length) throw new BadRequestException('No documents uploaded'); const completedMilestones: string[] = []; for (const file of files) { await this.filesService.upsertByCode({ resourceId: bookingId, resource: 'bookings', code: file.fieldname, file, }); const milestoneCode = DOC_CODE_TO_MILESTONE[file.fieldname]; if (milestoneCode) { await this.milestoneService.completeByDocTrigger({ bookingId }, milestoneCode); completedMilestones.push(milestoneCode); } } return { uploaded: files.length, completedMilestones }; } /** * T1 transit-document lifecycle state for an import shipment booking. Wagon * allocation opens the upload window; train departure locks it; train arrival * lets GL Ethiopia close (accept) the T1 set. */ async t1State(bookingId: string): Promise { const booking = await this.getBooking(bookingId); const milestones = await this.milestoneService.listForBooking(bookingId); const wagonMilestone = milestones.find((m) => m.milestoneCode === 'WAGON_ALLOCATED'); const wagonAllocated = wagonMilestone?.status === 'COMPLETED' || booking.schedulingStatus === 'SCHEDULED' || booking.schedulingStatus === 'DISPATCHED' || Boolean(booking.trainScheduleId); let schedule: TrainSchedule | null = null; if (booking.trainScheduleId) { schedule = await this.dataSource .getRepository(TrainSchedule) .findOne({ where: { id: booking.trainScheduleId } }); } const closedMilestone = milestones.find( (m) => m.milestoneCode === 'T1_CLOSED' && m.status === 'COMPLETED', ); return { bookingId, wagonAllocated, trainDepartedAt: schedule?.actualDepartureAt ? new Date(schedule.actualDepartureAt).toISOString() : null, trainArrivedAt: schedule?.actualArrivalAt ? new Date(schedule.actualArrivalAt).toISOString() : null, closed: Boolean(closedMilestone), closedAt: closedMilestone?.triggeredAt ? new Date(closedMilestone.triggeredAt).toISOString() : null, }; } /** * GL Djibouti uploads T1 transport documents (multi-file) after wagon allocation. * Replaces the previous batch; locked once the train departs or T1 is closed. */ async uploadT1Documents( bookingId: string, files: Express.Multer.File[], ): Promise<{ uploaded: number }> { const booking = await this.getBooking(bookingId); if (booking.tradeDirection !== 'IMPORT') { throw new BadRequestException('T1 transport documents apply to import shipments only.'); } const state = await this.t1State(bookingId); if (!state.wagonAllocated) { throw new BadRequestException( 'Wagons must be allocated before T1 transport documents can be uploaded.', ); } if (state.closed) { throw new BadRequestException('T1 has been closed by GL Ethiopia — documents are final.'); } if (state.trainDepartedAt) { throw new BadRequestException( 'The train has departed — T1 transport documents can no longer be changed.', ); } await persistT1TransportUploads(this.filesService, bookingId, files); return { uploaded: files.length }; } /** * GL Ethiopia closes (accepts) the T1 document set once the train has arrived. * Completes the T1_CLOSED milestone; the document set becomes final. */ async closeT1( bookingId: string, userId?: string, ): Promise { const booking = await this.getBooking(bookingId); if (booking.tradeDirection !== 'IMPORT') { throw new BadRequestException('T1 closure applies to import shipments only.'); } const state = await this.t1State(bookingId); if (state.closed) return state; if (!state.trainArrivedAt) { throw new BadRequestException( 'The train has not arrived yet — T1 can be closed only after arrival.', ); } const files = await this.filesService.findByResource(bookingId, 'bookings'); const hasT1 = files.some((f) => isT1TransportFileCode(f.code)); if (!hasT1) { throw new BadRequestException( 'No T1 transport documents on file — GL Djibouti must upload them first.', ); } await this.milestoneService.completeForBooking(bookingId, 'T1_CLOSED', userId); return this.t1State(bookingId); } /** * GL ET uploads export transport document after wagon allocation (export ONE_TIME). */ async uploadTransportDocument( bookingId: string, files: Express.Multer.File[], ): Promise<{ uploaded: boolean; milestoneCompleted: boolean }> { const booking = await this.getBooking(bookingId); if (booking.tradeDirection !== 'EXPORT') { throw new BadRequestException('Transport document upload applies to export shipments only.'); } const milestones = await this.milestoneService.listForBooking(bookingId); const wagonAllocated = milestones.find((m) => m.milestoneCode === 'WAGON_ALLOCATED'); const wagonDone = wagonAllocated?.status === 'COMPLETED' || booking.schedulingStatus === 'SCHEDULED'; if (!wagonDone) { throw new BadRequestException( 'Wagon must be allocated before the transport document can be uploaded.', ); } if (files.length === 0) { throw new BadRequestException('No transit permit documents uploaded'); } await persistExportTransportUploads(this.filesService, bookingId, files); if (wagonAllocated && wagonAllocated.status !== 'COMPLETED') { await this.milestoneService.completeForBooking(bookingId, 'WAGON_ALLOCATED'); } await this.milestoneService.completeByDocTrigger( { bookingId }, 'EXPORT_TRANSPORT_ISSUED', ); return { uploaded: true, milestoneCompleted: true }; } }