From fe40d9f4be5b25ea1658f90f9f753f9b8e06959c Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 2 Jul 2026 13:24:39 +0000 Subject: [PATCH] update import gl flow --- .../bookings/booking-invoice.service.ts | 2 +- .../bookings/entities/booking.entity.ts | 2 +- .../booking-clearance.service.spec.ts | 11 ++ .../contracts/booking-clearance.service.ts | 39 ++++-- .../contracts/contract-clearance.service.ts | 42 +++++-- .../modules/contracts/contracts.controller.ts | 27 ++++ .../contracts/gl-operations.service.ts | 118 +++++++++++++++++- .../contracts/phased-clearance.util.ts | 68 +++++++++- 8 files changed, 280 insertions(+), 29 deletions(-) diff --git a/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts index 3bfb838b4..e01e6992a 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts @@ -135,7 +135,7 @@ export class BookingInvoiceService { ); return; } - if (booking.paymentStatus === "PAID") return; + // if (booking.paymentStatus === "PAID") return; await this.dataSource.transaction(async (mg) => { await mg.update( diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index 3ae0fb64f..484b368fb 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts @@ -272,7 +272,7 @@ export class Booking extends BaseEntity { @Column({ name: 'origin_yard_id', type: 'uuid' }) originYardId!: string; - @ManyToOne(() => Yard) + @ManyToOne(() => Yard) @JoinColumn({ name: 'origin_yard_id' }) originYard?: Yard; diff --git a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts index 7ea818e34..2b0126003 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts @@ -65,6 +65,16 @@ function makeService(overrides?: { children: [{ value: '2' }], }), }; + const glOperationsService = { + t1State: jest.fn().mockResolvedValue({ + bookingId: 'b-general', + wagonAllocated: false, + trainDepartedAt: null, + trainArrivedAt: null, + closed: false, + closedAt: null, + }), + }; const service = new BookingClearanceService( bookingsRepository as never, @@ -74,6 +84,7 @@ function makeService(overrides?: { workflowService as never, milestoneService as never, dropdownSettingsService as never, + glOperationsService as never, ); return { diff --git a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts index 7bb835df2..4e9a7b69d 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts @@ -1,5 +1,5 @@ import { BadRequestException, Injectable } from '@nestjs/common'; -import { ContractDocPhase } from '@edr/types'; +import { ContractDocPhase, type ClearanceT1State } from '@edr/types'; import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service'; import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service'; @@ -11,6 +11,7 @@ import { Booking } from '../bookings/entities/booking.entity'; import { clearanceCodesForBooking } from '../bookings/clearance.util'; import { ClearanceWorkflowService } from './clearance-workflow.service'; import { ClearanceMilestoneService } from './clearance-milestone.service'; +import { GlOperationsService } from './gl-operations.service'; import { AdviseContractDutyDto } from './dto/phased-clearance.dto'; import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_BOOKING_QUEUE_STATUSES, persistDeclarationUploads, persistTransitPermitUploads, PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES } from './phased-clearance.util'; @@ -63,6 +64,8 @@ export interface BookingClearanceView { noticeFile?: { id: string; name: string; url: string } | null; } | null; workflowFiles?: ReturnType; + /** Import post-allocation T1 transit document state (null until wagon allocation). */ + t1?: ClearanceT1State | null; } @Injectable() @@ -75,6 +78,7 @@ export class BookingClearanceService { private readonly workflowService: ClearanceWorkflowService, private readonly milestoneService: ClearanceMilestoneService, private readonly dropdownSettingsService: DropdownSettingsService, + private readonly glOperationsService: GlOperationsService, ) {} private async assertPhasedGeneralCustoms(booking: Booking): Promise { @@ -162,6 +166,15 @@ export class BookingClearanceService { booking.tradeDirection ?? 'IMPORT', ); + let t1: ClearanceT1State | null = null; + if ((booking.tradeDirection ?? 'IMPORT') === 'IMPORT') { + try { + t1 = await this.glOperationsService.t1State(bookingId); + } catch { + t1 = null; + } + } + return { bookingId, status: booking.status, @@ -192,6 +205,7 @@ export class BookingClearanceService { preClearanceFinalized: Boolean(booking.preClearanceFinalizedAt), dutyAdvice, workflowFiles, + t1, }; } @@ -421,6 +435,13 @@ export class BookingClearanceService { clearanceCurrentPhase: ContractDocPhase.GlDjCollection, } as never); + // GL Djibouti may have uploaded the DO early (un-gated) — count it now. + const files = await this.filesService.findByResource(bookingId, 'bookings'); + if (files.some((f) => f.code === 'delivery_order')) { + await this.workflowService.completeMilestoneForBooking(bookingId, 'DO_COLLECTED'); + await this.workflowService.markReadyForOperation(bookingId); + } + return this.bookingsService.findById(bookingId); } @@ -434,15 +455,11 @@ export class BookingClearanceService { throw new BadRequestException('Delivery Order applies only to import bookings.'); } - if (!booking.preClearanceFinalizedAt) { - throw new BadRequestException( - 'GL Ethiopia must finalize pre-clearance before the Delivery Order can be uploaded.', - ); - } - - await this.workflowService.assertPriorCompleteForBooking(bookingId, 'IMPORT', 'DO_COLLECTED'); if (!file) throw new BadRequestException('No Delivery Order uploaded'); + // DO upload is deliberately un-gated: GL Djibouti may attach it at any point, + // any file type. The DO_COLLECTED milestone (and operation readiness) still + // waits for GL Ethiopia to finalize pre-clearance so the workflow order holds. await this.filesService.upsertByCode({ resourceId: bookingId, resource: 'bookings', @@ -450,8 +467,10 @@ export class BookingClearanceService { file, }); - await this.workflowService.completeMilestoneForBooking(bookingId, 'DO_COLLECTED', userId); - await this.workflowService.markReadyForOperation(bookingId); + if (booking.preClearanceFinalizedAt) { + await this.workflowService.completeMilestoneForBooking(bookingId, 'DO_COLLECTED', userId); + await this.workflowService.markReadyForOperation(bookingId); + } return this.bookingsService.findById(bookingId); } diff --git a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts index a09de407c..34e3d3d94 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts @@ -1,5 +1,5 @@ import { BadRequestException, ConflictException, Injectable } from '@nestjs/common'; -import { ContractDocPhase } from '@edr/types'; +import { ContractDocPhase, type ClearanceT1State } from '@edr/types'; import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service'; import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service'; @@ -10,6 +10,7 @@ import { BookingsService } from '../bookings/bookings.service'; import { contractClearanceCodes } from './contract-clearance.util'; import { ClearanceWorkflowService } from './clearance-workflow.service'; import { ClearanceMilestoneService } from './clearance-milestone.service'; +import { GlOperationsService } from './gl-operations.service'; import { ClearanceMilestone } from './entities/clearance-milestone.entity'; import { Contract } from './entities/contract.entity'; import { ContractDocReviewStatus } from './entities/contract-document-review.entity'; @@ -77,6 +78,8 @@ export interface ContractClearanceView { noticeFile?: { id: string; name: string; url: string } | null; } | null; workflowFiles?: ReturnType; + /** Import post-allocation T1 transit document state (null until a booking is linked). */ + t1?: ClearanceT1State | null; } @Injectable() @@ -90,6 +93,7 @@ export class ContractClearanceService { private readonly workflowService: ClearanceWorkflowService, private readonly milestoneService: ClearanceMilestoneService, private readonly dropdownSettingsService: DropdownSettingsService, + private readonly glOperationsService: GlOperationsService, ) {} private isPhasedCustoms(contract: Contract): boolean { @@ -224,6 +228,15 @@ export class ContractClearanceService { workflowFiles = [...byCode.values()]; } + let t1: ClearanceT1State | null = null; + if (cycle?.bookingId && contract.tradeDirection === 'IMPORT') { + try { + t1 = await this.glOperationsService.t1State(cycle.bookingId); + } catch { + t1 = null; // linked booking missing — view stays usable + } + } + let nextAction = this.workflowService.computeNextAction(contract, cycle, milestones); if (cycle?.bookingId && contract.tradeDirection === 'EXPORT') { const bookingMilestones = await this.workflowService.listMilestonesForBooking( @@ -272,6 +285,7 @@ export class ContractClearanceService { linkedBookingId: cycle?.bookingId ?? null, dutyAdvice, workflowFiles, + t1, }; } @@ -1011,6 +1025,13 @@ export class ContractClearanceService { currentPhase: ContractDocPhase.GlDjCollection, }); + // GL Djibouti may have uploaded the DO early (un-gated) — count it now. + const files = await this.filesService.findByResource(contractId, 'contracts'); + if (files.some((f) => f.code === 'delivery_order')) { + await this.workflowService.completeMilestone(contractId, 'DO_COLLECTED'); + await this.workflowService.markReadyForBooking(contractId); + } + return this.contractsService.findById(contractId); } @@ -1025,17 +1046,11 @@ export class ContractClearanceService { throw new BadRequestException('Delivery Order applies only to import contracts.'); } - const cycle = await this.contractsRepository.currentCycle(contractId); - if (!cycle?.preClearanceFinalizedAt) { - throw new BadRequestException( - 'GL Ethiopia must finalize pre-clearance before the Delivery Order can be uploaded.', - ); - } - - await this.workflowService.assertPriorComplete(contractId, 'IMPORT', 'DO_COLLECTED'); - if (!file) throw new BadRequestException('No Delivery Order uploaded'); + // DO upload is deliberately un-gated: GL Djibouti may attach it at any point, + // any file type. The DO_COLLECTED milestone (and booking readiness) still waits + // for GL Ethiopia to finalize pre-clearance so the workflow order holds. await this.filesService.upsertByCode({ resourceId: contractId, resource: 'contracts', @@ -1043,8 +1058,11 @@ export class ContractClearanceService { file, }); - await this.workflowService.completeMilestone(contractId, 'DO_COLLECTED', userId); - await this.workflowService.markReadyForBooking(contractId); + const cycle = await this.contractsRepository.currentCycle(contractId); + if (cycle?.preClearanceFinalizedAt) { + await this.workflowService.completeMilestone(contractId, 'DO_COLLECTED', userId); + await this.workflowService.markReadyForBooking(contractId); + } return this.contractsService.findById(contractId); } diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts index 5e712a177..872d13a0c 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -873,6 +873,33 @@ export class ContractsController { return this.glOperationsService.uploadTransportDocument(bookingId, files ?? []); } + @Post('bookings/:bookingId/t1-documents') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) + @UseInterceptors(AnyFilesInterceptor()) + @ApiConsumes('multipart/form-data') + @ApiOperation({ + summary: + 'GL Djibouti uploads T1 transit documents (multi-file) after wagon allocation; locked once the train departs', + }) + uploadT1Documents( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @UploadedFiles() files: Express.Multer.File[], + ) { + return this.glOperationsService.uploadT1Documents(bookingId, files ?? []); + } + + @Post('bookings/:bookingId/t1-close') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) + @ApiOperation({ + summary: 'GL Ethiopia closes (accepts) the T1 document set after the train arrives', + }) + closeT1( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @CurrentUser() user: AuthUserPayload, + ) { + return this.glOperationsService.closeT1(bookingId, resolveAuthUserId(user)); + } + @Post('bookings/:bookingId/documents') @BookingStaff(FREIGHT_PERMS.bookings.uploadClearanceOutput) @UseInterceptors(AnyFilesInterceptor()) diff --git a/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts b/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts index 73ca3a65d..f900c564f 100644 --- a/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts @@ -1,14 +1,19 @@ 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 } from './phased-clearance.util'; +import { + persistExportTransportUploads, + persistT1TransportUploads, +} from './phased-clearance.util'; /** * Maps a GL post-booking document `code` to the milestone it auto-completes when @@ -18,7 +23,8 @@ import { persistExportTransportUploads } from './phased-clearance.util'; const DOC_CODE_TO_MILESTONE: Record = { release_order: 'RELEASE_ORDER_SECURED', // export — GL DJ delivery_order: 'DO_COLLECTED', // import — GL DJ - t1_transport_document: 'T1_CLOSED', // import — GL ET + // 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 @@ -161,6 +167,114 @@ export class GlOperationsService { 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). */ diff --git a/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.ts b/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.ts index 89fbf797e..2e8682951 100644 --- a/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.ts +++ b/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.ts @@ -5,7 +5,9 @@ import { isDeclarationFileCode, isImportTransitPermitFileCode, isExportTransportFileCode, + isT1TransportFileCode, exportTransportFileLabel, + t1TransportFileLabel, transitPermitFileLabel, type ClearanceWorkflowFile, } from '@edr/types'; @@ -160,6 +162,50 @@ export async function persistExportTransportUploads( ); } +/** Require at least one T1 transport document in the upload batch. */ +export function assertT1TransportFiles(files: Express.Multer.File[]): void { + if (files.length === 0) { + throw new BadRequestException('No T1 transport documents uploaded'); + } +} + +export function normalizeT1TransportFieldNames( + files: Express.Multer.File[], +): Express.Multer.File[] { + return files.map((file, index) => ({ + ...file, + fieldname: `t1_transport_document_${index}`, + })); +} + +/** Replace all T1 transport documents on a booking with a new multi-file batch. */ +export async function persistT1TransportUploads( + store: DeclarationFileStore, + bookingId: string, + files: Express.Multer.File[], +): Promise { + const normalized = normalizeT1TransportFieldNames(files); + assertT1TransportFiles(normalized); + + const existing = await store.findByResource(bookingId, 'bookings'); + await Promise.all( + existing + .filter((f) => f.code && isT1TransportFileCode(f.code)) + .map((f) => store.deleteByCode(bookingId, 'bookings', f.code!)), + ); + + await Promise.all( + normalized.map((file, index) => + store.upload({ + resourceId: bookingId, + resource: 'bookings', + code: `t1_transport_document_${index}`, + file, + }), + ), + ); +} + export function parseDutyRequiredForm(value: string | boolean | undefined): boolean { if (typeof value === 'boolean') return value; if (value === undefined || value === '') return false; @@ -194,9 +240,9 @@ export function belongsOnDjClearanceQueue( ); if (hasDjActivity) return true; - const preFinalized = - cycle?.preClearanceFinalizedAt ?? extras?.preClearanceFinalizedAt ?? null; - if (tradeDirection === 'IMPORT' && preFinalized) return true; + // Import DO upload is un-gated — Djibouti GL must see import customs items from + // the start, not only after Ethiopia finalizes pre-clearance. + if (tradeDirection === 'IMPORT') return true; return false; } @@ -295,6 +341,22 @@ export function buildWorkflowFiles( file: { id: file.id, name: file.name, url: file.url }, }); }); + + const extraT1 = files + .filter((f) => f.code && isT1TransportFileCode(f.code) && !included.has(f.code)) + .sort((a, b) => (a.code ?? '').localeCompare(b.code ?? '')); + + extraT1.forEach((file, index) => { + if (!file.code) return; + included.add(file.code); + out.push({ + code: file.code, + label: t1TransportFileLabel(file.code, index), + uploadedBy: 'gl_dj', + category: 'djibouti', + file: { id: file.id, name: file.name, url: file.url }, + }); + }); } if (tradeDirection === 'EXPORT') {