import { BadRequestException, ConflictException, Injectable, NotFoundException, } from '@nestjs/common'; import { DataSource, IsNull } from 'typeorm'; import { Freight, GL_FINAL_INVOICE_TYPE, isT1TransportFileCode } from '@edr/types'; import { BillingService } from '../billing/billing.service'; import { InvoiceLine } from '../billing/entities/invoice-line.entity'; import { FilesService } from '../files/files.service'; import { Booking } from '../bookings/entities/booking.entity'; import { BookingLifecycleNotifierService } from '../bookings/booking-lifecycle-notifier.service'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; import { ImportDjiboutiOperation } from '../train-scheduling/entities/import-djibouti-operation.entity'; import { ClearanceIncident, IncidentType, } from './entities/clearance-incident.entity'; import { ContractClearanceCycle } from './entities/contract-clearance-cycle.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 readonly billingService: BillingService, private readonly notifier: BookingLifecycleNotifierService, ) {} private get bookings() { return this.dataSource.getRepository(Booking); } private get incidents() { return this.dataSource.getRepository(ClearanceIncident); } private async getBooking(bookingId: string): Promise { // company is loaded so customer notifications have a phone/email to target. const booking = await this.bookings.findOne({ where: { id: bookingId }, relations: { company: true }, }); 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 }; } /** Wagon-allocation + train-schedule actuals for a booking (both directions). */ async trainState(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 } }); } // Per-booking journey first: a booking rides only its own leg, so ITS // loaded/arrived timestamps gate clearance — a Dire→Djibouti booking that // unloaded at its own destination clears while the train keeps rolling, // and a booking still on board does NOT clear just because the train // arrived. The schedule actuals remain only as fallback for legacy // in-flight bookings that predate per-booking load/unload (no loadedAt). const departedAt = booking.loadedAt ?? schedule?.actualDepartureAt ?? null; const arrivedAt = booking.arrivedAt ?? (booking.loadedAt ? null : (schedule?.actualArrivalAt ?? null)); return { scheduleId: schedule?.id ?? null, wagonAllocated, departedAt: departedAt ? new Date(departedAt).toISOString() : null, arrivedAt: arrivedAt ? new Date(arrivedAt).toISOString() : null, }; } /** * Gate pass status for a booking, sourced from the train schedule's Djibouti * gate-pass operation (secured via the train-scheduling "Save as Secured" * action) rather than a clearance milestone. For EXPORT bookings this also * backfills the arrival-chain milestones once secured, same as the retired * clearance-side grant action used to. */ async gatepassForBooking( bookingId: string, ): Promise<{ granted: boolean; grantedAt: string | null }> { const train = await this.trainState(bookingId); if (!train.scheduleId) return { granted: false, grantedAt: null }; const operation = await this.dataSource .getRepository(ImportDjiboutiOperation) .findOne({ where: { trainScheduleId: train.scheduleId } }); const grantedAt = operation?.gatepassGrantedAt ? new Date(operation.gatepassGrantedAt).toISOString() : null; if (grantedAt) { const booking = await this.getBooking(bookingId); if ((booking.tradeDirection ?? 'IMPORT') === 'EXPORT') { const milestones = await this.milestoneService.listForBooking(bookingId); const byCode = new Map(milestones.map((m) => [m.milestoneCode, m])); for (const code of GlOperationsService.EXPORT_ARRIVAL_CHAIN) { if (byCode.get(code)?.status === 'PENDING') { await this.milestoneService.completeForBooking(bookingId, code); } } } } return { granted: Boolean(grantedAt), grantedAt }; } /** * T1 transit-document lifecycle state for an import shipment booking. The * gate pass (secured on the train schedule after 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 train = await this.trainState(bookingId); const milestones = await this.milestoneService.listForBooking(bookingId); const closedMilestone = milestones.find( (m) => m.milestoneCode === 'T1_CLOSED' && m.status === 'COMPLETED', ); return { bookingId, wagonAllocated: train.wagonAllocated, trainDepartedAt: train.departedAt, trainArrivedAt: train.arrivedAt, closed: Boolean(closedMilestone), closedAt: closedMilestone?.triggeredAt ? new Date(closedMilestone.triggeredAt).toISOString() : null, }; } /** * Offload facts for a booking, read-only: what came off the train at its * destination (containers, wagons, tonnes) and where the goods went. Sourced * from the booking's warehouse-inventory row — written by the auto-unload * that runs on train arrival for both directions. */ async offloadState( bookingId: string, milestones: Array<{ milestoneCode: string; status: string; triggeredAt?: Date | null }>, ): Promise { const [row]: Array<{ destination: string | null; containers: number; wagons: number; bookedWeight: string | null; inventoryStatus: string | null; unloadedAt: Date | null; grnNumber: string | null; offloadedWeight: string | null; warehouse: string | null; warehouseYard: string | null; zone: string | null; }> = await this.dataSource.query( `SELECT COALESCE(dy.label, dy.code) AS "destination", (SELECT COUNT(*)::int FROM freight.booking_container bc JOIN freight.booking_container_units bcu ON bcu.booking_container_id = bc.id AND bcu.deleted_at IS NULL WHERE bc.booking_id = b.id AND bc.deleted_at IS NULL) AS "containers", (SELECT COUNT(*)::int FROM freight.wagon_booking_allocations wba WHERE wba.booking_id = b.id AND wba.deleted_at IS NULL) AS "wagons", b.cargo_total_weight_vgm AS "bookedWeight", inv.status AS "inventoryStatus", inv.unloaded_at AS "unloadedAt", inv.grn_number AS "grnNumber", inv.weight AS "offloadedWeight", wh.name AS "warehouse", wy.name AS "warehouseYard", wz.name AS "zone" FROM freight.bookings b LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id LEFT JOIN LATERAL ( SELECT i.* FROM freight.warehouse_inventory i WHERE i.booking_id = b.id AND i.deleted_at IS NULL ORDER BY i.unloaded_at DESC NULLS LAST, i.created_at DESC LIMIT 1 ) inv ON TRUE LEFT JOIN freight.warehouses wh ON wh.id = inv.warehouse_id LEFT JOIN freight.warehouse_yards wy ON wy.id = inv.yard_id LEFT JOIN freight.warehouse_zones wz ON wz.id = inv.zone_id WHERE b.id = $1 AND b.deleted_at IS NULL`, [bookingId], ); const milestone = milestones.find((m) => m.milestoneCode === 'OFFLOADED'); const offloadedAt = milestone?.status === 'COMPLETED' && milestone.triggeredAt ? new Date(milestone.triggeredAt).toISOString() : (row?.unloadedAt ? new Date(row.unloadedAt).toISOString() : null); // The warehouse records the real offloaded tonnage; before it does, the // booked VGM is the best number we have. const weight = Number(row?.offloadedWeight ?? 0) || Number(row?.bookedWeight ?? 0); const location = [row?.warehouse, row?.warehouseYard, row?.zone].filter(Boolean).join(' › '); return { offloaded: milestone?.status === 'COMPLETED' || Boolean(row?.unloadedAt), offloadedAt, destination: row?.destination ?? null, containers: row?.containers ?? 0, wagons: row?.wagons ?? 0, weightTons: weight || null, grnNumber: row?.grnNumber ?? null, location: location || null, inventoryStatus: row?.inventoryStatus ?? null, }; } /** * GL Djibouti uploads T1 transport documents (multi-file) once the gate pass * is secured on the train schedule (which itself follows wagon allocation). * Replaces the previous batch; locked only once GL Ethiopia closes the T1. */ 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.', ); } const gatepass = await this.gatepassForBooking(bookingId); if (!gatepass.granted) { throw new BadRequestException( 'Secure the Djibouti gate pass on the train schedule before uploading T1 transport documents.', ); } if (state.closed) { throw new BadRequestException('T1 has been closed by GL Ethiopia — documents are final.'); } // Departure no longer locks T1 docs — GL DJ may replace them any time until // GL Ethiopia closes/accepts the T1. await persistT1TransportUploads(this.filesService, bookingId, files); return { uploaded: files.length }; } /** * Close (accept) the T1/transport document set. * Import: GL Ethiopia closes once the train has arrived (T1 files required). * Export: GL Djibouti closes once the train arrives at Djibouti (transport * document required). */ async closeT1( bookingId: string, userId?: string, ): Promise { const booking = await this.getBooking(bookingId); const tradeDirection = booking.tradeDirection ?? 'IMPORT'; const state = await this.t1State(bookingId); if (state.closed) return state; if (tradeDirection === 'IMPORT') { 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.', ); } } else { const milestones = await this.milestoneService.listForBooking(bookingId); const done = (code: string) => milestones.find((m) => m.milestoneCode === code)?.status === 'COMPLETED'; if (!done('EXPORT_TRANSPORT_ISSUED')) { throw new BadRequestException( 'The transport document must be uploaded before T1 can be closed.', ); } if (!state.trainArrivedAt) { throw new BadRequestException( 'The train has not arrived at Djibouti yet — T1 can be closed only after arrival.', ); } // Export bookings seeded before T1_CLOSED joined the catalog lack the row. await this.milestoneService.ensureForBooking(bookingId, 'T1_CLOSED', tradeDirection); } await this.milestoneService.completeForBooking(bookingId, 'T1_CLOSED', userId); return this.t1State(bookingId); } /** Milestones GL DJ implicitly confirms when granting an export gate pass. */ private static readonly EXPORT_ARRIVAL_CHAIN = [ 'CARGO_ARRIVED', 'READY_FOR_LOADING', 'LOADED', 'DEPARTED_TO_DJIBOUTI', 'ARRIVED_AT_DJIBOUTI', ]; /** * GL Djibouti raises the post-offload final invoice (export): manual amount + * attached invoice document. It is issued as a DRAFT the customer must approve * first; only then do they pay offline and attach a slip, and GL (ET or DJ) * confirms to settle it. */ async createFinalInvoice( bookingId: string, input: { amount: number; currency: string; description?: string }, file: Express.Multer.File, userId?: string, ): Promise { const booking = await this.getBooking(bookingId); if (!booking.customsClearingEnabled) { throw new BadRequestException('Final invoice applies to customs bookings only.'); } if (!(input.amount > 0)) { throw new BadRequestException('Invoice amount must be greater than zero.'); } if (!file) throw new BadRequestException('Attach the invoice document.'); // Invoiceable once cargo is offloaded, or — for export, where OFFLOADED is a // DJ doc milestone that may never be recorded — once the Djibouti gate pass // is secured. The invoice itself stays optional; nothing forces GL DJ to send one. const milestones = await this.milestoneService.listForBooking(bookingId); const offloaded = milestones.find( (m) => m.milestoneCode === 'OFFLOADED' && m.status === 'COMPLETED', ); if (!offloaded) { const gatepass = await this.gatepassForBooking(bookingId); if (!gatepass.granted) { throw new BadRequestException( 'Cargo must be offloaded (or the gate pass secured) before the final invoice can be raised.', ); } } const existing = await this.billingService.findInvoice( Freight.InvoiceSource.Booking, bookingId, GL_FINAL_INVOICE_TYPE, ); if ( existing && existing.status !== Freight.InvoiceStatus.Cancelled && existing.status !== Freight.InvoiceStatus.Expired ) { throw new ConflictException('A final invoice already exists for this shipment.'); } const description = input.description?.trim() || 'Post-offload charges (Djibouti)'; await this.billingService.generateInvoice({ source: Freight.InvoiceSource.Booking, sourceId: bookingId, type: GL_FINAL_INVOICE_TYPE, companyId: booking.companyId, companyProfileId: booking.companyProfileId, currency: input.currency, lines: [ { chargeType: GL_FINAL_INVOICE_TYPE, description, quantity: 1, unitRate: input.amount, amount: input.amount, }, ], // DRAFT until the customer approves it — approveFinalInvoice issues it. status: Freight.InvoiceStatus.Draft, }); await this.filesService.upsertByCode({ resourceId: bookingId, resource: 'bookings', code: 'final_invoice', file, }); // Export clearance is administratively done once the final invoice goes out. await this.dataSource .getRepository(ContractClearanceCycle) .update({ bookingId, completedAt: IsNull() }, { completedAt: new Date() }); void userId; const summary = await this.finalInvoiceSummary(bookingId); if (!summary) throw new NotFoundException('Final invoice could not be created.'); this.notifier.finalInvoiceCreated(booking, input.amount, input.currency); return summary; } /** * Customer approves the drafted final invoice — issues it, which is what * unlocks the payment slip upload. Idempotent: approving twice is a no-op. */ async approveFinalInvoice( bookingId: string, userId?: string, ): Promise { const booking = await this.getBooking(bookingId); const invoice = await this.billingService.findInvoice( Freight.InvoiceSource.Booking, bookingId, GL_FINAL_INVOICE_TYPE, ); if (!invoice) { throw new BadRequestException('No final invoice has been raised for this shipment.'); } if ( invoice.status === Freight.InvoiceStatus.Cancelled || invoice.status === Freight.InvoiceStatus.Expired ) { throw new BadRequestException('The final invoice is no longer payable.'); } if (invoice.status === Freight.InvoiceStatus.Draft) { await this.billingService.updateStatus(invoice.id, Freight.InvoiceStatus.Issued); this.notifier.finalInvoiceApprovedToStaff(booking); } void userId; const summary = await this.finalInvoiceSummary(bookingId); if (!summary) throw new NotFoundException('Final invoice not found.'); return summary; } /** Customer attaches the payment slip for the final invoice. */ async uploadFinalInvoiceSlip( bookingId: string, file: Express.Multer.File, ): Promise<{ uploaded: boolean }> { const booking = await this.getBooking(bookingId); if (!file) throw new BadRequestException('No payment slip uploaded'); const invoice = await this.billingService.findInvoice( Freight.InvoiceSource.Booking, bookingId, GL_FINAL_INVOICE_TYPE, ); if (!invoice) { throw new BadRequestException('No final invoice has been issued for this shipment.'); } if (invoice.status === Freight.InvoiceStatus.Draft) { throw new BadRequestException( 'Approve the final invoice before attaching a payment slip.', ); } if (invoice.status === Freight.InvoiceStatus.Paid) { throw new BadRequestException('The final invoice is already paid.'); } if ( invoice.status === Freight.InvoiceStatus.Cancelled || invoice.status === Freight.InvoiceStatus.Expired ) { throw new BadRequestException('The final invoice is no longer payable.'); } await this.filesService.upsertByCode({ resourceId: bookingId, resource: 'bookings', code: 'final_invoice_slip', file, }); this.notifier.dutySlipUploadedToStaff(booking, 'final'); return { uploaded: true }; } /** GL (ET or DJ) confirms the customer's slip — settles the final invoice. */ async confirmFinalInvoicePaid( bookingId: string, userId?: string, ): Promise { const booking = await this.getBooking(bookingId); const invoice = await this.billingService.findInvoice( Freight.InvoiceSource.Booking, bookingId, GL_FINAL_INVOICE_TYPE, ); if (!invoice) { throw new BadRequestException('No final invoice has been issued for this shipment.'); } if (invoice.status !== Freight.InvoiceStatus.Paid) { const files = await this.filesService.findByResource(bookingId, 'bookings'); if (!files.some((f) => f.code === 'final_invoice_slip')) { throw new BadRequestException( 'The customer has not attached a payment slip yet.', ); } await this.billingService.markInvoiceAsPaid(invoice.id); this.notifier.finalInvoicePaid(booking); } void userId; const summary = await this.finalInvoiceSummary(bookingId); if (!summary) throw new NotFoundException('Final invoice not found.'); return summary; } /** * GL ET advises (or skips) the post-arrival additional duty/tax round (import). * Customer then attaches a slip; SECOND_DUTY_PAID completes on that upload. */ async adviseSecondDuty( bookingId: string, input: { dutyRequired: boolean; amount?: number; currency?: string; declarationSerial?: string; }, attachment?: Express.Multer.File, userId?: string, ): Promise<{ advised: boolean; skipped: boolean }> { const booking = await this.getBooking(bookingId); if (!booking.customsClearingEnabled) { throw new BadRequestException('Additional duty applies to customs bookings only.'); } const tradeDirection = booking.tradeDirection ?? 'IMPORT'; if (tradeDirection !== 'IMPORT') { throw new BadRequestException('Additional duty applies to import shipments only.'); } await this.milestoneService.ensureForBooking(bookingId, 'SECOND_DUTY_ADVISED', tradeDirection); await this.milestoneService.ensureForBooking(bookingId, 'SECOND_DUTY_PAID', tradeDirection); if (!input.dutyRequired) { await this.milestoneService.skipForBooking(bookingId, 'SECOND_DUTY_ADVISED'); await this.milestoneService.skipForBooking(bookingId, 'SECOND_DUTY_PAID'); return { advised: false, skipped: true }; } if (!input.amount || input.amount <= 0) { throw new BadRequestException('Duty amount must be greater than zero.'); } const files = await this.filesService.findByResource(bookingId, 'bookings'); const hasNotice = files.some((f) => f.code === 'duty_tax_notice_2'); if (!attachment && !hasNotice) { throw new BadRequestException('Attach the additional duty/tax notice.'); } if (attachment) { await this.filesService.upsertByCode({ resourceId: bookingId, resource: 'bookings', code: 'duty_tax_notice_2', file: attachment, }); } await this.milestoneService.completeWithMetadataForBooking( bookingId, 'SECOND_DUTY_ADVISED', { dutyAmount: input.amount, dutyCurrency: input.currency ?? 'ETB', declarationSerial: input.declarationSerial, }, userId, ); this.notifier.secondDutyAdvised(booking, input.amount, input.currency ?? 'ETB'); return { advised: true, skipped: false }; } /** Customer attaches the payment slip for the additional duty round. */ async uploadSecondDutySlip( bookingId: string, file: Express.Multer.File, ): Promise<{ milestoneCompleted: boolean }> { const booking = await this.getBooking(bookingId); if (!file) throw new BadRequestException('No payment slip uploaded'); const milestones = await this.milestoneService.listForBooking(bookingId); const advised = milestones.find((m) => m.milestoneCode === 'SECOND_DUTY_ADVISED'); if (advised?.status !== 'COMPLETED') { throw new BadRequestException('No additional duty has been advised for this shipment.'); } await this.filesService.upsertByCode({ resourceId: bookingId, resource: 'bookings', code: 'duty_tax_receipt_2', file, }); await this.milestoneService.ensureForBooking( bookingId, 'SECOND_DUTY_PAID', booking.tradeDirection ?? 'IMPORT', ); await this.milestoneService.completeForBooking(bookingId, 'SECOND_DUTY_PAID'); this.notifier.dutySlipUploadedToStaff(booking, 'second'); return { milestoneCompleted: true }; } /** Second duty round state for clearance views. */ secondDutyState( milestones: Array<{ milestoneCode: string; status: string; metadata?: { dutyAmount?: number; dutyCurrency?: string; declarationSerial?: string } | null; }>, files: Array<{ code?: string | null; id: string; name: string; url: string }>, ): Freight.ClearanceSecondDuty | null { const advised = milestones.find((m) => m.milestoneCode === 'SECOND_DUTY_ADVISED'); const paid = milestones.find((m) => m.milestoneCode === 'SECOND_DUTY_PAID'); if (!advised && !paid) return null; const toRef = (code: string) => { const f = files.find((x) => x.code === code); return f ? { id: f.id, name: f.name, url: f.url } : null; }; return { advised: advised?.status === 'COMPLETED', skipped: advised?.status === 'SKIPPED', amount: advised?.metadata?.dutyAmount ?? null, currency: advised?.metadata?.dutyCurrency ?? null, declarationSerial: advised?.metadata?.declarationSerial ?? null, noticeFile: toRef('duty_tax_notice_2'), slipFile: toRef('duty_tax_receipt_2'), paid: paid?.status === 'COMPLETED', }; } /** Final-invoice state joined with its document + slip files, for clearance views. */ async finalInvoiceSummary( bookingId: string, ): Promise { const invoice = await this.billingService.findInvoice( Freight.InvoiceSource.Booking, bookingId, GL_FINAL_INVOICE_TYPE, ); if (!invoice) return null; const files = await this.filesService.findByResource(bookingId, 'bookings'); const toRef = (code: string) => { const f = files.find((x) => x.code === code); return f ? { id: f.id, name: f.name, url: f.url } : null; }; const line = await this.dataSource .getRepository(InvoiceLine) .findOne({ where: { invoiceId: invoice.id } }); return { id: invoice.id, invoiceNumber: invoice.invoiceNumber, status: invoice.status, totalAmount: Number(invoice.totalAmount), currency: invoice.currency, description: line?.description ?? null, invoiceFile: toRef('final_invoice'), slipFile: toRef('final_invoice_slip'), // Issuing IS the customer approval (createFinalInvoice leaves it DRAFT). approvedAt: invoice.issuedAt ? new Date(invoice.issuedAt).toISOString() : null, confirmedAt: invoice.paidAt ? new Date(invoice.paidAt).toISOString() : null, }; } /** * 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 }; } }