From 4fefe4f827143a8762a9f9d2676ec26dcb1d3fe8 Mon Sep 17 00:00:00 2001 From: marshal Date: Thu, 2 Jul 2026 12:06:17 +0300 Subject: [PATCH] finilize gl --- .../bookings/booking-transition.service.ts | 16 +- .../modules/bookings/bookings.controller.ts | 6 +- .../contracts/booking-clearance.service.ts | 46 +- .../contracts/clearance-milestone.service.ts | 20 + .../contracts/clearance-workflow.service.ts | 37 +- .../contracts/contract-clearance.service.ts | 166 ++- .../modules/contracts/contracts.controller.ts | 26 +- .../modules/contracts/contracts.repository.ts | 1 + .../contracts/phased-clearance.util.spec.ts | 125 +++ .../contracts/phased-clearance.util.ts | 228 +++- .../src/modules/files/files.service.ts | 8 + .../src/modules/payment/payment.service.ts | 14 +- apps/edr-freight-web/backoffice/src/App.tsx | 61 +- .../detail/ClearanceReviewSection.tsx | 51 +- .../components/contracts/ClearanceOpsTabs.tsx | 128 +++ .../ClearanceUploadedDocumentsPanel.tsx | 205 ++++ .../ContractClearanceReviewSection.tsx | 54 +- .../contracts/GlClearanceUploadModal.tsx | 154 +++ .../contracts/GlCreateBookingForm.tsx | 985 ++++++++---------- .../contracts/PhasedClearanceActionPanel.tsx | 882 +++++++++++++--- .../contracts/PhasedDocumentUploadField.tsx | 122 +++ .../contracts/PhasedFileDropzone.tsx | 403 +++++++ .../contracts/PhasedUploadedFileRow.tsx | 94 ++ .../gl-actions/TransportDocumentCard.tsx | 4 +- .../ContractCapacityNotice.tsx | 52 + .../contracts/gl-booking-form/form-ui.tsx | 93 ++ .../backoffice/src/constants/URLS.ts | 2 + .../bookings/BookingRequestDetailPage.tsx | 4 +- .../bookings/DocumentClearanceDetailPage.tsx | 172 +-- .../contracts/ContractClearanceDetailPage.tsx | 275 ++--- .../contracts/ContractClearanceListPage.tsx | 82 +- .../pages/contracts/GlClearanceDetailPage.tsx | 262 +++++ .../contracts/GlDjiboutiClearanceListPage.tsx | 123 +++ .../src/services/bookings.service.ts | 9 +- .../src/services/contracts.service.ts | 9 +- .../backoffice/src/types/booking.ts | 1 + .../contracts/ClearanceAdHocUploadSection.tsx | 88 ++ .../contracts/ClearanceDocumentUploadCard.tsx | 210 ++++ .../ClearanceUploadedDocumentsPanel.tsx | 393 +++++++ .../ClearanceWorkflowFilesSection.tsx | 133 --- .../contracts/PortalFileDropzone.tsx | 232 +++++ .../BookingClearanceWorkflowBanner.tsx | 18 + .../bookings/clearance/ClearanceFlow.tsx | 239 ++--- .../bookings/clearance/useClearanceFlow.ts | 13 +- .../pages/contracts/ContractClearanceFlow.tsx | 7 +- .../contracts/ContractClearancePanel.tsx | 355 ++----- .../ContractClearanceWorkflowBanner.tsx | 218 ++-- .../pages/contracts/ContractDetailPage.tsx | 56 +- .../src/freight/clearance-files.catalog.ts | 58 +- packages/types/src/freight/contracts.ts | 3 + 50 files changed, 5150 insertions(+), 1793 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/contracts/phased-clearance.util.spec.ts create mode 100644 apps/edr-freight-web/backoffice/src/components/contracts/ClearanceOpsTabs.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/contracts/ClearanceUploadedDocumentsPanel.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/contracts/GlClearanceUploadModal.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/contracts/PhasedDocumentUploadField.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/contracts/PhasedFileDropzone.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/contracts/PhasedUploadedFileRow.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/ContractCapacityNotice.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/form-ui.tsx create mode 100644 apps/edr-freight-web/backoffice/src/pages/contracts/GlClearanceDetailPage.tsx create mode 100644 apps/edr-freight-web/backoffice/src/pages/contracts/GlDjiboutiClearanceListPage.tsx create mode 100644 apps/edr-freight-web/portal/src/components/contracts/ClearanceAdHocUploadSection.tsx create mode 100644 apps/edr-freight-web/portal/src/components/contracts/ClearanceDocumentUploadCard.tsx create mode 100644 apps/edr-freight-web/portal/src/components/contracts/ClearanceUploadedDocumentsPanel.tsx delete mode 100644 apps/edr-freight-web/portal/src/components/contracts/ClearanceWorkflowFilesSection.tsx create mode 100644 apps/edr-freight-web/portal/src/components/contracts/PortalFileDropzone.tsx diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index 444cb8678..2eed5100d 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -699,6 +699,7 @@ export class BookingTransitionService { bookingId, booking.tradeDirection ?? 'IMPORT', ); + await this.workflowService.onDocumentReviewReopenedForBooking(bookingId); await this.bookingsRepository.update(bookingId, { clearanceCurrentPhase: ContractDocPhase.GlEtReview, } as never); @@ -763,6 +764,15 @@ export class BookingTransitionService { if (status === 'QUERIED' && !note?.trim()) { throw new BadRequestException('A note is required when querying a document'); } + if ( + status === 'QUERIED' && + this.isPhasedGeneralCustoms(booking) && + booking.preClearanceFinalizedAt + ) { + throw new BadRequestException( + 'Customer documents cannot be queried after pre-clearance is finalized.', + ); + } await this.bookingsRepository.setDocumentReviewStatus( bookingId, @@ -779,10 +789,10 @@ export class BookingTransitionService { 'CHANGES_REQUESTED', staffId, ); - if (this.isPhasedGeneralCustoms(booking) && booking.preClearanceFinalizedAt) { + if (this.isPhasedGeneralCustoms(booking)) { + await this.workflowService.onDocumentReviewReopenedForBooking(bookingId); await this.bookingsRepository.update(bookingId, { - preClearanceFinalizedAt: null, - clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance, + clearanceCurrentPhase: ContractDocPhase.GlEtReview, } as never); } } diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index d835c1ecf..01452d036 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -543,16 +543,16 @@ export class BookingsController { @Post(':id/clearance/transit-permit') @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) - @UseInterceptors(FileInterceptor('file')) + @UseInterceptors(AnyFilesInterceptor()) @ApiConsumes('multipart/form-data') async uploadBookingTransitPermit( @Param('id', ParseUUIDPipe) id: string, - @UploadedFile() file: Express.Multer.File, + @UploadedFiles() files: Express.Multer.File[], @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingClearanceService.uploadTransitPermit( id, - file, + files ?? [], resolveAuthUserId(user), ); return this.transitionService.enrichBookingResponse(booking); 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 4774aa200..7bb835df2 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 @@ -12,7 +12,7 @@ import { clearanceCodesForBooking } from '../bookings/clearance.util'; import { ClearanceWorkflowService } from './clearance-workflow.service'; import { ClearanceMilestoneService } from './clearance-milestone.service'; import { AdviseContractDutyDto } from './dto/phased-clearance.dto'; -import { assertDeclarationFiles, buildWorkflowFiles } from './phased-clearance.util'; +import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_BOOKING_QUEUE_STATUSES, persistDeclarationUploads, persistTransitPermitUploads, PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES } from './phased-clearance.util'; const RO_VESSEL_MIN_DAYS_CODE = 'ro_vessel_min_days'; @@ -157,11 +157,9 @@ export class BookingClearanceService { booking.tradeDirection ?? 'IMPORT', ); const dutyAdvice = this.buildDutyAdvice(files, milestones); - const documentFileKeys = new Set(documents.map((d) => d.fileKey)); const workflowFiles = buildWorkflowFiles( files, booking.tradeDirection ?? 'IMPORT', - documentFileKeys, ); return { @@ -279,16 +277,8 @@ export class BookingClearanceService { if (files.length === 0) { throw new BadRequestException('No declaration documents uploaded'); } - assertDeclarationFiles(files, tradeDirection); - for (const file of files) { - await this.filesService.upsertByCode({ - resourceId: bookingId, - resource: 'bookings', - code: file.fieldname, - file, - }); - } + await persistDeclarationUploads(this.filesService, bookingId, 'bookings', files); await this.workflowService.onDeclarationUploadedForBooking(bookingId, userId); await this.bookingsRepository.update(bookingId, { @@ -380,7 +370,7 @@ export class BookingClearanceService { async uploadTransitPermit( bookingId: string, - file: Express.Multer.File, + files: Express.Multer.File[], userId?: string, ): Promise { const booking = await this.loadBooking(bookingId); @@ -392,14 +382,11 @@ export class BookingClearanceService { 'IMPORT', 'TRANSIT_PERMIT_UPLOADED', ); - if (!file) throw new BadRequestException('No transit permit uploaded'); + if (files.length === 0) { + throw new BadRequestException('No transit permit documents uploaded'); + } - await this.filesService.upsertByCode({ - resourceId: bookingId, - resource: 'bookings', - code: 'transit_permitted', - file, - }); + await persistTransitPermitUploads(this.filesService, bookingId, 'bookings', files); await this.workflowService.completeMilestoneForBooking( bookingId, @@ -598,32 +585,31 @@ export class BookingClearanceService { async etQueue(): Promise { const candidates = await this.bookingsRepository.findByStatuses([ - 'AWAITING_DOCUMENTS', - 'DOCUMENTS_UNDER_REVIEW', - 'CLEARANCE_READY', + ...PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES, ]); const filtered: Booking[] = []; for (const b of candidates) { if (!this.isPhasedGeneralCustomsBooking(b)) continue; const milestones = await this.workflowService.listMilestonesForBooking(b.id); - const next = this.workflowService.computeNextActionForBooking(b, milestones); - if (next?.actor === 'GL_ET') filtered.push(b); + if (belongsOnEtClearanceQueue(milestones)) filtered.push(b); } return filtered; } async djQueue(): Promise { const candidates = await this.bookingsRepository.findByStatuses([ - 'AWAITING_DOCUMENTS', - 'DOCUMENTS_UNDER_REVIEW', - 'CLEARANCE_READY', + ...DJ_BOOKING_QUEUE_STATUSES, ]); const filtered: Booking[] = []; for (const b of candidates) { if (!this.isPhasedGeneralCustomsBooking(b)) continue; const milestones = await this.workflowService.listMilestonesForBooking(b.id); - const pending = this.workflowService.djPendingMilestoneCodes(milestones); - if (b.roHoldReason || pending || this.workflowService.computeNextActionForBooking(b, milestones)?.actor === 'GL_DJ') { + if ( + belongsOnDjClearanceQueue(b.tradeDirection, null, milestones, { + roHoldReason: b.roHoldReason, + preClearanceFinalizedAt: b.preClearanceFinalizedAt, + }) + ) { filtered.push(b); } } diff --git a/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts b/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts index 7add860d2..7d30b1c5b 100644 --- a/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts @@ -197,6 +197,26 @@ export class ClearanceMilestoneService { } /** Skip optional milestones (e.g. duty when not required). */ + /** Reopen a completed contract milestone so review can continue after a query. */ + async reopenForContract(contractId: string, code: string): Promise { + const milestone = await this.repo.findOne({ where: { contractId, milestoneCode: code } }); + if (!milestone || milestone.status !== 'COMPLETED') return; + milestone.status = 'PENDING'; + milestone.triggeredAt = null; + milestone.triggeredByUserId = null; + await this.repo.save(milestone); + } + + /** Reopen a completed booking milestone so review can continue after a query. */ + async reopenForBooking(bookingId: string, code: string): Promise { + const milestone = await this.repo.findOne({ where: { bookingId, milestoneCode: code } }); + if (!milestone || milestone.status !== 'COMPLETED') return; + milestone.status = 'PENDING'; + milestone.triggeredAt = null; + milestone.triggeredByUserId = null; + await this.repo.save(milestone); + } + async skipForContract(contractId: string, code: string): Promise { const milestone = await this.repo.findOne({ where: { contractId, milestoneCode: code } }); if (!milestone) { diff --git a/apps/edr-freight-api/src/modules/contracts/clearance-workflow.service.ts b/apps/edr-freight-api/src/modules/contracts/clearance-workflow.service.ts index b8e1d81ca..758da27ff 100644 --- a/apps/edr-freight-api/src/modules/contracts/clearance-workflow.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/clearance-workflow.service.ts @@ -204,6 +204,15 @@ export class ClearanceWorkflowService { await this.completeMilestoneForBooking(bookingId, 'DOCUMENTS_APPROVED'); } + /** Customer doc queried or re-uploaded — document approval milestone must reopen. */ + async onDocumentReviewReopened(contractId: string): Promise { + await this.milestoneService.reopenForContract(contractId, 'DOCUMENTS_APPROVED'); + } + + async onDocumentReviewReopenedForBooking(bookingId: string): Promise { + await this.milestoneService.reopenForBooking(bookingId, 'DOCUMENTS_APPROVED'); + } + async onDeclarationUploaded(contractId: string, userId?: string): Promise { await this.completeMilestone(contractId, 'UNDER_CUSTOMS_CLEARANCE'); await this.completeMilestone(contractId, 'DECLARED', userId); @@ -441,7 +450,7 @@ export class ClearanceWorkflowService { if (!isDone('DECLARED')) { return { actor: 'GL_ET', - action: 'Upload customs declaration (EX3/EX8)', + action: 'Upload customs declaration documents', milestoneCode: 'DECLARED', }; } @@ -452,6 +461,30 @@ export class ClearanceWorkflowService { milestoneCode: EXPORT_BOUNDARY, }; } + if (terminalScope === 'booking') { + if (!isDone('FREIGHT_PAYMENT_SETTLED')) { + return { + actor: 'CUSTOMER', + action: 'Pay freight charges', + milestoneCode: 'FREIGHT_PAYMENT_SETTLED', + }; + } + if (!isDone('WAGON_ALLOCATED')) { + return { + actor: 'OPERATIONS', + action: 'Allocate wagon', + milestoneCode: 'WAGON_ALLOCATED', + }; + } + if (!isDone('EXPORT_TRANSPORT_ISSUED')) { + return { + actor: 'GL_ET', + action: 'Upload transit permit', + milestoneCode: 'EXPORT_TRANSPORT_ISSUED', + }; + } + return null; + } return { actor: terminalScope === 'contract' ? 'GL_ET' : 'CUSTOMER', action: terminalAction, @@ -462,7 +495,7 @@ export class ClearanceWorkflowService { if (!isDone('DECLARED')) { return { actor: 'GL_ET', - action: 'Upload customs declaration (IM4/IM5)', + action: 'Upload customs declaration documents', milestoneCode: 'DECLARED', }; } 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 7a6ad3c4e..a09de407c 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 @@ -6,6 +6,7 @@ import { FileUploadSettingsService } from '../file-upload-settings/file-upload-s import { FilesService } from '../files/files.service'; import { ContractsRepository } from './contracts.repository'; import { ContractsService, PaginatedContracts } from './contracts.service'; +import { BookingsService } from '../bookings/bookings.service'; import { contractClearanceCodes } from './contract-clearance.util'; import { ClearanceWorkflowService } from './clearance-workflow.service'; import { ClearanceMilestoneService } from './clearance-milestone.service'; @@ -14,7 +15,7 @@ import { Contract } from './entities/contract.entity'; import { ContractDocReviewStatus } from './entities/contract-document-review.entity'; import { FilterContractDto } from './dto/filter-contract.dto'; import { AdviseContractDutyDto } from './dto/phased-clearance.dto'; -import { assertDeclarationFiles, buildWorkflowFiles } from './phased-clearance.util'; +import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_CONTRACT_QUEUE_STATUSES, persistDeclarationUploads, persistTransitPermitUploads, PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES } from './phased-clearance.util'; const RO_VESSEL_MIN_DAYS_CODE = 'ro_vessel_min_days'; @@ -66,6 +67,9 @@ export interface ContractClearanceView { roAmendmentRequestedAt?: string | null; bookingReady?: boolean; preClearanceFinalized?: boolean; + /** Export post-booking clearance finalized (transit permit uploaded + GL confirmed). */ + exportClearanceFinalized?: boolean; + linkedBookingId?: string | null; dutyAdvice?: { amount: number; currency: string; @@ -80,6 +84,7 @@ export class ContractClearanceService { constructor( private readonly contractsRepository: ContractsRepository, private readonly contractsService: ContractsService, + private readonly bookingsService: BookingsService, private readonly filesService: FilesService, private readonly fileUploadSettingsService: FileUploadSettingsService, private readonly workflowService: ClearanceWorkflowService, @@ -198,14 +203,40 @@ export class ContractClearanceService { ); contract = await this.reconcilePrematureBookingReady(contractId, contract, boundary); const phase = this.workflowService.resolvePhase(contract, cycle, milestones); - const nextAction = this.workflowService.computeNextAction(contract, cycle, milestones); const dutyAdvice = this.buildDutyAdvice(files, milestones); - const documentFileKeys = new Set(documents.map((d) => d.fileKey)); - const workflowFiles = buildWorkflowFiles( + let workflowFiles = buildWorkflowFiles( files, contract.tradeDirection ?? 'IMPORT', - documentFileKeys, ); + if (cycle?.bookingId) { + const bookingFiles = await this.filesService.findByResource( + cycle.bookingId, + 'bookings', + ); + const bookingWorkflow = buildWorkflowFiles( + bookingFiles, + contract.tradeDirection ?? 'IMPORT', + ); + const byCode = new Map(workflowFiles.map((f) => [f.code, f])); + for (const row of bookingWorkflow) { + if (row.file) byCode.set(row.code, row); + } + workflowFiles = [...byCode.values()]; + } + + let nextAction = this.workflowService.computeNextAction(contract, cycle, milestones); + if (cycle?.bookingId && contract.tradeDirection === 'EXPORT') { + const bookingMilestones = await this.workflowService.listMilestonesForBooking( + cycle.bookingId, + ); + const booking = await this.bookingsService.findById(cycle.bookingId); + if (booking) { + nextAction = this.workflowService.computeNextActionForBooking( + booking, + bookingMilestones, + ); + } + } return { contractId, @@ -237,6 +268,8 @@ export class ContractClearanceService { : null, bookingReady: boundary, preClearanceFinalized: Boolean(cycle?.preClearanceFinalizedAt), + exportClearanceFinalized: Boolean(cycle?.completedAt), + linkedBookingId: cycle?.bookingId ?? null, dutyAdvice, workflowFiles, }; @@ -413,6 +446,7 @@ export class ContractClearanceService { if (contract.customsClearingEnabled && contract.contractKind === 'ONE_TIME') { await this.workflowService.onCustomerDocsUploaded(contractId, contract.tradeDirection); + await this.workflowService.onDocumentReviewReopened(contractId); } return this.contractsService.findById(contractId); @@ -505,6 +539,15 @@ export class ContractClearanceService { const { inputCode, outputCode } = contractClearanceCodes(contract); const cycle = await this.contractsRepository.currentCycle(contractId); + if ( + status === 'QUERIED' && + this.isPhasedCustoms(contract) && + cycle?.preClearanceFinalizedAt + ) { + throw new BadRequestException( + 'Customer documents cannot be queried after pre-clearance is finalized.', + ); + } const reviews = await this.contractsRepository.findDocumentReviews( contractId, cycle?.id ?? null, @@ -539,10 +582,12 @@ export class ContractClearanceService { } as never); if (cycle) { await this.contractsRepository.setCycleStatus(cycle.id, 'AWAITING_DOCUMENTS'); - if (this.isPhasedCustoms(contract) && cycle.preClearanceFinalizedAt) { + } + if (this.isPhasedCustoms(contract)) { + await this.workflowService.onDocumentReviewReopened(contractId); + if (cycle) { await this.contractsRepository.updateCycle(cycle.id, { - preClearanceFinalizedAt: null, - currentPhase: ContractDocPhase.GlEtPostClearance, + currentPhase: ContractDocPhase.GlEtReview, }); } } @@ -704,19 +749,14 @@ export class ContractClearanceService { } /** - * GL ET clearance hub: every customs (Path B) contract that still needs - * customs clearance — awaiting the customer's documents, under GL review, or - * finalized and waiting for the customer to create the booking in the portal. + * GL ET clearance hub: every customs (Path B) contract in phased clearance, + * including after booking is created. */ async queue(filter: FilterContractDto): Promise { return this.contractsRepository.findAllPaginated({ page: filter.page ?? 1, pageSize: filter.pageSize ?? 100, - statuses: [ - 'AWAITING_CLEARANCE_DOCUMENTS', - 'CLEARANCE_UNDER_REVIEW', - 'CLEARANCE_READY_FOR_BOOKING', - ], + statuses: [...PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES], customsClearingEnabled: true, sortBy: filter.sortBy, sortOrder: filter.sortOrder, @@ -799,16 +839,13 @@ export class ContractClearanceService { if (files.length === 0) { throw new BadRequestException('No declaration documents uploaded'); } - assertDeclarationFiles(files, contract.tradeDirection); - for (const file of files) { - await this.filesService.upsertByCode({ - resourceId: contractId, - resource: 'contracts', - code: file.fieldname, - file, - }); - } + await persistDeclarationUploads( + this.filesService, + contractId, + 'contracts', + files, + ); await this.workflowService.onDeclarationUploaded(contractId, userId); @@ -913,7 +950,7 @@ export class ContractClearanceService { async uploadTransitPermit( contractId: string, - file: Express.Multer.File, + files: Express.Multer.File[], userId?: string, ): Promise { const contract = await this.contractsService.findById(contractId); @@ -927,14 +964,16 @@ export class ContractClearanceService { 'TRANSIT_PERMIT_UPLOADED', ); - if (!file) throw new BadRequestException('No transit permit uploaded'); + if (files.length === 0) { + throw new BadRequestException('No transit permit documents uploaded'); + } - await this.filesService.upsertByCode({ - resourceId: contractId, - resource: 'contracts', - code: 'transit_permitted', - file, - }); + await persistTransitPermitUploads( + this.filesService, + contractId, + 'contracts', + files, + ); await this.workflowService.completeMilestone(contractId, 'TRANSIT_PERMIT_UPLOADED', userId); @@ -1135,12 +1174,51 @@ export class ContractClearanceService { return this.contractsService.findById(contractId); } - /** GL ET queue: customs ONE_TIME contracts with a pending ET-owned milestone. */ + /** GL ET finalizes export clearance after post-booking transit permit is uploaded. */ + async finalizeExportClearance(contractId: string, userId?: string): Promise { + const contract = await this.contractsService.findById(contractId); + this.assertPhasedCustoms(contract); + if (contract.tradeDirection !== 'EXPORT') { + throw new BadRequestException('Export clearance finalize applies only to export contracts.'); + } + + const cycle = await this.contractsRepository.currentCycle(contractId); + if (!cycle?.bookingId) { + throw new BadRequestException( + 'A shipment booking must exist before export clearance can be finalized.', + ); + } + if (cycle.completedAt) { + return this.contractsService.findById(contractId); + } + + const bookingMilestones = await this.workflowService.listMilestonesForBooking( + cycle.bookingId, + ); + const transportDone = bookingMilestones.some( + (m) => m.milestoneCode === 'EXPORT_TRANSPORT_ISSUED' && m.status === 'COMPLETED', + ); + if (!transportDone) { + throw new BadRequestException( + 'Upload the transit permit before finalizing export clearance.', + ); + } + + await this.contractsRepository.updateCycle(cycle.id, { + completedAt: new Date(), + currentPhase: ContractDocPhase.GlEtPostClearance, + }); + + void userId; + return this.contractsService.findById(contractId); + } + + /** GL ET queue: customs ONE_TIME contracts in phased clearance (persistent after booking). */ async etQueue(filter: FilterContractDto): Promise { const base = await this.contractsRepository.findAllPaginated({ page: 1, pageSize: 500, - statuses: ['AWAITING_CLEARANCE_DOCUMENTS', 'CLEARANCE_UNDER_REVIEW', 'CLEARANCE_READY_FOR_BOOKING'], + statuses: [...PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES], customsClearingEnabled: true, contractKind: 'ONE_TIME', sortBy: filter.sortBy, @@ -1150,13 +1228,7 @@ export class ContractClearanceService { const filtered: typeof base.items = []; for (const c of base.items) { const milestones = await this.workflowService.listMilestones(c.id); - const pending = this.workflowService.etPendingMilestoneCodes(milestones); - const next = this.workflowService.computeNextAction( - c, - await this.contractsRepository.currentCycle(c.id), - milestones, - ); - if (pending || next?.actor === 'GL_ET') filtered.push(c); + if (belongsOnEtClearanceQueue(milestones)) filtered.push(c); } const page = filter.page ?? 1; @@ -1178,12 +1250,12 @@ export class ContractClearanceService { }; } - /** GL DJ queue: customs ONE_TIME contracts with a pending DJ-owned milestone or RO hold. */ + /** GL DJ queue: customs ONE_TIME contracts handed off to or handled by Djibouti GL. */ async djQueue(filter: FilterContractDto): Promise { const base = await this.contractsRepository.findAllPaginated({ page: 1, pageSize: 500, - statuses: ['AWAITING_CLEARANCE_DOCUMENTS', 'CLEARANCE_UNDER_REVIEW', 'CLEARANCE_READY_FOR_BOOKING'], + statuses: [...DJ_CONTRACT_QUEUE_STATUSES], customsClearingEnabled: true, contractKind: 'ONE_TIME', sortBy: filter.sortBy, @@ -1194,9 +1266,9 @@ export class ContractClearanceService { for (const c of base.items) { const cycle = await this.contractsRepository.currentCycle(c.id); const milestones = await this.workflowService.listMilestones(c.id); - const pending = this.workflowService.djPendingMilestoneCodes(milestones); - const next = this.workflowService.computeNextAction(c, cycle, milestones); - if (cycle?.roHoldReason || pending || next?.actor === 'GL_DJ') filtered.push(c); + if (belongsOnDjClearanceQueue(c.tradeDirection, cycle, milestones)) { + filtered.push(c); + } } const page = filter.page ?? 1; 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 738f9d9de..eebdd2eba 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -532,7 +532,7 @@ export class ContractsController { @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) @UseInterceptors(AnyFilesInterceptor()) @ApiConsumes('multipart/form-data') - @ApiOperation({ summary: 'GL ET uploads customs declaration (IM4/IM5 or EX3/EX8)' }) + @ApiOperation({ summary: 'GL ET uploads customs declaration documents (multi-file)' }) uploadDeclaration( @Param('id', ParseUUIDPipe) id: string, @UploadedFiles() files: Express.Multer.File[], @@ -591,15 +591,15 @@ export class ContractsController { @Post(':id/clearance/transit-permit') @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) - @UseInterceptors(FileInterceptor('file')) + @UseInterceptors(AnyFilesInterceptor()) @ApiConsumes('multipart/form-data') - @ApiOperation({ summary: 'GL ET uploads transit permit screenshot (import)' }) + @ApiOperation({ summary: 'GL ET uploads import transit permit documents (multi-file)' }) uploadTransitPermit( @Param('id', ParseUUIDPipe) id: string, - @UploadedFile() file: Express.Multer.File, + @UploadedFiles() files: Express.Multer.File[], @CurrentUser() user: AuthUserPayload, ) { - return this.clearanceService.uploadTransitPermit(id, file, resolveAuthUserId(user)); + return this.clearanceService.uploadTransitPermit(id, files ?? [], resolveAuthUserId(user)); } @Post(':id/clearance/delivery-order') @@ -655,16 +655,28 @@ export class ContractsController { return this.clearanceService.confirmExportRelease(id, resolveAuthUserId(user)); } + @Post(':id/clearance/finalize-export-clearance') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) + @ApiOperation({ + summary: 'GL ET finalizes export clearance after post-booking transit permit upload', + }) + finalizeExportClearance( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: AuthUserPayload, + ) { + return this.clearanceService.finalizeExportClearance(id, resolveAuthUserId(user)); + } + @Get('clearance/et-queue') @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) - @ApiOperation({ summary: 'GL Ethiopia phased clearance queue' }) + @ApiOperation({ summary: 'GL Ethiopia phased clearance list (persistent after booking)' }) etClearanceQueue(@Query() filter: FilterContractDto) { return this.clearanceService.etQueue(filter); } @Get('clearance/dj-queue') @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) - @ApiOperation({ summary: 'GL Djibouti phased clearance queue' }) + @ApiOperation({ summary: 'GL Djibouti phased clearance list (persistent after booking)' }) djClearanceQueue(@Query() filter: FilterContractDto) { return this.clearanceService.djQueue(filter); } diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts index 45603ac76..9e464db51 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts @@ -548,6 +548,7 @@ export class ContractsRepository extends BaseRepository { | 'currentPhase' | 'status' | 'preClearanceFinalizedAt' + | 'completedAt' > >, ): Promise { diff --git a/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.spec.ts b/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.spec.ts new file mode 100644 index 000000000..cf044d6a9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.spec.ts @@ -0,0 +1,125 @@ +import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue } from './phased-clearance.util'; + +describe('buildWorkflowFiles', () => { + const resourceFiles = [ + { code: 'im4', id: 'f-im4', name: 'im4.pdf', url: '/files/im4' }, + { code: 'im5', id: 'f-im5', name: 'im5.pdf', url: '/files/im5' }, + { + code: 'transit_permitted', + id: 'f-transit', + name: 'transit.png', + url: '/files/transit', + }, + { + code: 'duty_tax_notice', + id: 'f-duty', + name: 'notice.pdf', + url: '/files/duty', + }, + { code: 'commercial_invoice', id: 'f-inv', name: 'inv.pdf', url: '/files/inv' }, + ]; + + it('includes declaration and transit files even when they also appear in GL output document settings', () => { + const result = buildWorkflowFiles(resourceFiles, 'IMPORT'); + + expect(result.map((f) => f.code)).toEqual( + expect.arrayContaining(['im4', 'im5', 'transit_permitted', 'duty_tax_notice']), + ); + }); + + it('includes multi-file declaration uploads alongside catalog codes', () => { + const result = buildWorkflowFiles( + [ + ...resourceFiles, + { + code: 'declaration_0', + id: 'f-dec-0', + name: 'decl-a.pdf', + url: '/files/decl-a', + }, + { + code: 'declaration_1', + id: 'f-dec-1', + name: 'decl-b.pdf', + url: '/files/decl-b', + }, + ], + 'IMPORT', + ); + + expect(result.map((f) => f.code)).toEqual( + expect.arrayContaining(['im4', 'im5', 'declaration_0', 'declaration_1']), + ); + expect(result.find((f) => f.code === 'declaration_0')?.label).toBe( + 'Declaration document 1', + ); + }); + + it('includes multi-file import transit permit uploads', () => { + const result = buildWorkflowFiles( + [ + ...resourceFiles, + { + code: 'transit_permit_0', + id: 'f-tp-0', + name: 'permit-a.pdf', + url: '/files/tp-a', + }, + { + code: 'transit_permit_1', + id: 'f-tp-1', + name: 'permit-b.pdf', + url: '/files/tp-b', + }, + ], + 'IMPORT', + ); + + expect(result.map((f) => f.code)).toEqual( + expect.arrayContaining(['transit_permitted', 'transit_permit_0', 'transit_permit_1']), + ); + expect(result.find((f) => f.code === 'transit_permit_0')?.label).toBe('Transit permit 1'); + }); + + it('does not include non-catalog customer document codes', () => { + const result = buildWorkflowFiles(resourceFiles, 'IMPORT'); + + expect(result.some((f) => f.code === 'commercial_invoice')).toBe(false); + }); +}); + +describe('belongsOnDjClearanceQueue', () => { + it('keeps import contracts after pre-clearance is finalized (even post-booking)', () => { + expect( + belongsOnDjClearanceQueue( + 'IMPORT', + { preClearanceFinalizedAt: new Date('2026-01-01') }, + [], + ), + ).toBe(true); + }); + + it('keeps contracts with completed Djibouti milestones', () => { + expect( + belongsOnDjClearanceQueue('IMPORT', null, [ + { ownerRegion: 'DJ', status: 'COMPLETED' }, + ]), + ).toBe(true); + }); + + it('excludes import contracts still on Ethiopia-side clearance only', () => { + expect(belongsOnDjClearanceQueue('IMPORT', null, [])).toBe(false); + }); +}); + +describe('belongsOnEtClearanceQueue', () => { + it('keeps contracts once phased clearance milestones exist', () => { + expect( + belongsOnEtClearanceQueue([{ ownerRegion: 'ET', status: 'COMPLETED' }]), + ).toBe(true); + }); + + it('excludes contracts with no clearance milestones', () => { + expect(belongsOnEtClearanceQueue([])).toBe(false); + }); +}); 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 b48cf274a..ce02bd26f 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 @@ -1,53 +1,213 @@ import { BadRequestException } from '@nestjs/common'; import { catalogEntriesForTradeDirection, + declarationFileLabel, + isDeclarationFileCode, + isImportTransitPermitFileCode, + transitPermitFileLabel, type ClearanceWorkflowFile, } from '@edr/types'; -const IMPORT_DECLARATION_CODES = new Set(['im4', 'im5']); -const EXPORT_DECLARATION_CODES = new Set(['ex3', 'ex8']); - -/** Require at least one declaration file for the trade direction (IM4 or IM5, EX3 or EX8). */ -export function assertDeclarationFiles( - files: Express.Multer.File[], - tradeDirection: string, -): void { +/** Require at least one declaration file in the upload batch. */ +export function assertDeclarationFiles(files: Express.Multer.File[]): void { if (files.length === 0) { throw new BadRequestException('No declaration documents uploaded'); } +} - const allowed = - tradeDirection === 'EXPORT' ? EXPORT_DECLARATION_CODES : IMPORT_DECLARATION_CODES; - const labels = tradeDirection === 'EXPORT' ? 'EX3 or EX8' : 'IM4 or IM5'; +/** Assign stable `declaration_*` codes so multi-file uploads always pass validation. */ +export function normalizeDeclarationFieldNames( + files: Express.Multer.File[], +): Express.Multer.File[] { + return files.map((file, index) => ({ + ...file, + fieldname: `declaration_${index}`, + })); +} - const uploaded = new Set(files.map((f) => f.fieldname?.toLowerCase())); - const hasValid = [...allowed].some((code) => uploaded.has(code)); - if (!hasValid) { - throw new BadRequestException(`Upload at least one declaration document (${labels}).`); +type DeclarationFileStore = { + findByResource( + resourceId: string, + resource: string, + ): Promise>; + deleteByCode(resourceId: string, resource: string, code: string): Promise; + upload(input: { + resourceId: string; + resource: string; + code: string; + file: Express.Multer.File; + }): Promise; +}; + +/** Replace all declaration files on a resource with a new multi-file upload batch. */ +export async function persistDeclarationUploads( + store: DeclarationFileStore, + resourceId: string, + resource: string, + files: Express.Multer.File[], +): Promise { + const normalized = normalizeDeclarationFieldNames(files); + assertDeclarationFiles(normalized); + + const existing = await store.findByResource(resourceId, resource); + await Promise.all( + existing + .filter((f) => f.code && isDeclarationFileCode(f.code)) + .map((f) => store.deleteByCode(resourceId, resource, f.code!)), + ); + + await Promise.all( + normalized.map((file, index) => + store.upload({ + resourceId, + resource, + code: `declaration_${index}`, + file, + }), + ), + ); +} + +/** Require at least one transit permit file in the upload batch. */ +export function assertTransitPermitFiles(files: Express.Multer.File[]): void { + if (files.length === 0) { + throw new BadRequestException('No transit permit documents uploaded'); } } +/** Assign stable `transit_permit_*` codes for multi-file import transit uploads. */ +export function normalizeTransitPermitFieldNames( + files: Express.Multer.File[], +): Express.Multer.File[] { + return files.map((file, index) => ({ + ...file, + fieldname: `transit_permit_${index}`, + })); +} + +/** Replace all import transit permit files on a resource with a new multi-file batch. */ +export async function persistTransitPermitUploads( + store: DeclarationFileStore, + resourceId: string, + resource: string, + files: Express.Multer.File[], +): Promise { + const normalized = normalizeTransitPermitFieldNames(files); + assertTransitPermitFiles(normalized); + + const existing = await store.findByResource(resourceId, resource); + await Promise.all( + existing + .filter((f) => f.code && isImportTransitPermitFileCode(f.code)) + .map((f) => store.deleteByCode(resourceId, resource, f.code!)), + ); + + await Promise.all( + normalized.map((file, index) => + store.upload({ + resourceId, + resource, + code: `transit_permit_${index}`, + file, + }), + ), + ); +} + export function parseDutyRequiredForm(value: string | boolean | undefined): boolean { if (typeof value === 'boolean') return value; if (value === undefined || value === '') return false; return value === 'true' || value === '1'; } +type DjQueueMilestone = { + ownerRegion?: string | null; + status: string; +}; + +type DjQueueCycle = { + preClearanceFinalizedAt?: Date | null; + roHoldReason?: string | null; +} | null | undefined; + +/** Whether a customs clearance item belongs on the persistent GL Djibouti list. */ +export function belongsOnDjClearanceQueue( + tradeDirection: string | null | undefined, + cycle: DjQueueCycle, + milestones: DjQueueMilestone[], + extras?: { + roHoldReason?: string | null; + preClearanceFinalizedAt?: Date | null; + }, +): boolean { + const roHold = cycle?.roHoldReason ?? extras?.roHoldReason; + if (roHold) return true; + + const hasDjActivity = milestones.some( + (m) => m.ownerRegion === 'DJ' && (m.status === 'COMPLETED' || m.status === 'PENDING'), + ); + if (hasDjActivity) return true; + + const preFinalized = + cycle?.preClearanceFinalizedAt ?? extras?.preClearanceFinalizedAt ?? null; + if (tradeDirection === 'IMPORT' && preFinalized) return true; + + return false; +} + +/** Contract statuses for persistent phased customs clearance lists (ET + DJ). */ +export const PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES = [ + 'AWAITING_CLEARANCE_DOCUMENTS', + 'CLEARANCE_UNDER_REVIEW', + 'CLEARANCE_READY_FOR_BOOKING', + 'ACTIVE_SHIPMENT_IN_PROGRESS', + 'FULLY_EXECUTED', + 'CONTRACT_ACTIVE', + 'CONTRACT_CLOSED', +] as const; + +/** Whether a customs clearance item belongs on the persistent GL Ethiopia list. */ +export function belongsOnEtClearanceQueue(milestones: DjQueueMilestone[]): boolean { + return milestones.some((m) => m.status === 'PENDING' || m.status === 'COMPLETED'); +} + +/** Contract statuses that may appear on the GL Djibouti clearance list (includes post-booking). */ +export const DJ_CONTRACT_QUEUE_STATUSES = PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES; + +/** Booking statuses for persistent phased customs clearance lists (ET + DJ). */ +export const PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES = [ + 'AWAITING_DOCUMENTS', + 'DOCUMENTS_UNDER_REVIEW', + 'CLEARANCE_READY', + 'FULLY_EXECUTED', + 'OPERATION_REQUEST_PENDING', + 'OPERATION_CHANGES_REQUESTED', + 'ROAD_DISPATCH_PENDING', + 'IN_TRANSIT', + 'PAID', + 'COMPLETED', + 'CONTRACT_ACTIVE', + 'CONTRACT_CLOSED', +] as const; + +/** Booking statuses that may appear on the GL Djibouti clearance list (includes post-clearance). */ +export const DJ_BOOKING_QUEUE_STATUSES = PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES; + /** Build labeled phased-customs file rows from resource files. */ export function buildWorkflowFiles( files: Array<{ code?: string | null; id: string; name: string; url: string }>, tradeDirection: string, - documentFileKeys: Set = new Set(), ): ClearanceWorkflowFile[] { const fileByCode = new Map( files.filter((f) => f.code).map((f) => [f.code as string, f]), ); const out: ClearanceWorkflowFile[] = []; + const included = new Set(); for (const entry of catalogEntriesForTradeDirection(tradeDirection)) { - if (documentFileKeys.has(entry.code)) continue; const file = fileByCode.get(entry.code) ?? null; if (!file) continue; + included.add(entry.code); out.push({ code: entry.code, label: entry.label, @@ -57,5 +217,39 @@ export function buildWorkflowFiles( }); } + const extraDeclarations = files + .filter((f) => f.code && isDeclarationFileCode(f.code) && !included.has(f.code)) + .sort((a, b) => (a.code ?? '').localeCompare(b.code ?? '')); + + extraDeclarations.forEach((file, index) => { + if (!file.code) return; + included.add(file.code); + out.push({ + code: file.code, + label: declarationFileLabel(file.code, index), + uploadedBy: 'gl_et', + category: 'declaration', + file: { id: file.id, name: file.name, url: file.url }, + }); + }); + + if (tradeDirection === 'IMPORT') { + const extraTransit = files + .filter((f) => f.code && isImportTransitPermitFileCode(f.code) && !included.has(f.code)) + .sort((a, b) => (a.code ?? '').localeCompare(b.code ?? '')); + + extraTransit.forEach((file, index) => { + if (!file.code) return; + included.add(file.code); + out.push({ + code: file.code, + label: transitPermitFileLabel(file.code, index), + uploadedBy: 'gl_et', + category: 'transit', + file: { id: file.id, name: file.name, url: file.url }, + }); + }); + } + return out; } diff --git a/apps/edr-freight-api/src/modules/files/files.service.ts b/apps/edr-freight-api/src/modules/files/files.service.ts index 4bf8362f9..ec1fb6fa9 100644 --- a/apps/edr-freight-api/src/modules/files/files.service.ts +++ b/apps/edr-freight-api/src/modules/files/files.service.ts @@ -61,6 +61,14 @@ export class FilesService { return this.upload(input); } + async deleteByCode( + resourceId: string, + resource: string, + code: string, + ): Promise { + await this.filesRepository.deleteByCode(resourceId, resource, code); + } + async uploadMany( resourceId: string, resource: string, diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index 35cebf057..55e338a58 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -219,13 +219,13 @@ export class PaymentService { //////////////// fake - await this.datasource.manager.update( - Booking, - { id: input.referenceId }, - { status: "PAID", paymentStatus: "PAID" }, - ); - await this.firstMileService.acceptBooking(input.referenceId); - await this.bookingBatchService.ensurePaidBookingAllocated(input.referenceId); + // await this.datasource.manager.update( + // Booking, + // { id: input.referenceId }, + // { status: "PAID", paymentStatus: "PAID" }, + // ); + // await this.firstMileService.acceptBooking(input.referenceId); + // await this.bookingBatchService.ensurePaidBookingAllocated(input.referenceId); //////////////// fake diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index a008452e4..fe2586d24 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -14,6 +14,7 @@ import { Send, Settings, ShieldCheck, + Ship, SlidersHorizontal, Train, Truck, @@ -43,10 +44,11 @@ import ContractRequestDetailPage from "./pages/contracts/ContractRequestDetailPa import ContractViewPage from "./pages/contracts/ContractViewPage"; import ContractClearanceListPage from "./pages/contracts/ContractClearanceListPage"; import ContractClearanceDetailPage from "./pages/contracts/ContractClearanceDetailPage"; +import GlDjiboutiClearanceListPage from "./pages/contracts/GlDjiboutiClearanceListPage"; +import GlClearanceDetailPage from "./pages/contracts/GlClearanceDetailPage"; import ShipmentRequestsPage from "./pages/contracts/ShipmentRequestsPage"; import ShipmentRequestDetailPage from "./pages/contracts/ShipmentRequestDetailPage"; import GlCreateBookingForm from "./components/contracts/GlCreateBookingForm"; -import BookingMilestonesPage from "./pages/contracts/BookingMilestonesPage"; import DocumentClearanceDetailPage from "./pages/bookings/DocumentClearanceDetailPage"; import CustomerDetailPage from "./pages/customers/CustomerDetailPage"; import CustomersPage from "./pages/customers/CustomersPage"; @@ -148,14 +150,19 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ permission: [ FREIGHT_PERMS.contracts.clearanceReview, FREIGHT_PERMS.contracts.clearanceEtActions, - FREIGHT_PERMS.contracts.clearanceDjActions, ], }, + // { + // label: "Shipment Requests", + // href: "/dashboard/shipment-requests", + // icon: , + // permission: FREIGHT_PERMS.contracts.createBooking, + // }, { - label: "Shipment Requests", - href: "/dashboard/shipment-requests", - icon: , - permission: FREIGHT_PERMS.contracts.createBooking, + label: "GL Djibouti Clearance", + href: "/dashboard/gl-djibouti/clearance", + icon: , + permission: FREIGHT_PERMS.contracts.clearanceDjActions, }, { label: "Train Schedules", @@ -549,7 +556,6 @@ const App = () => { permission={[ FREIGHT_PERMS.contracts.clearanceReview, FREIGHT_PERMS.contracts.clearanceEtActions, - FREIGHT_PERMS.contracts.clearanceDjActions, ]} > @@ -563,17 +569,30 @@ const App = () => { permission={[ FREIGHT_PERMS.contracts.clearanceReview, FREIGHT_PERMS.contracts.clearanceEtActions, - FREIGHT_PERMS.contracts.clearanceDjActions, ]} > } /> - } /> - } /> - } /> - } /> + } /> + } /> + + + + } + /> + + + + } + /> {/* Path A ops queue out of scope for now → fold into the GL hub. */} { /> - - - } + element={} /> } /> } /> @@ -917,8 +932,16 @@ const App = () => { ); }; -/** Redirect legacy GL Ethiopia/Djibouti clearance URLs to the unified hub. */ -function LegacyGlClearanceRedirect() { +/** Redirect removed milestones page to document clearance. */ +function BookingMilestonesRedirect() { + const { id } = useParams(); + return ( + + ); +} + +/** Redirect legacy GL Ethiopia clearance URLs to the unified document clearance hub. */ +function LegacyGlEthiopiaClearanceRedirect() { const { id } = useParams(); if (id) { return ; diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/ClearanceReviewSection.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/ClearanceReviewSection.tsx index 0f9f42217..78cc5efd6 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/ClearanceReviewSection.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/ClearanceReviewSection.tsx @@ -41,6 +41,12 @@ export interface ClearanceReviewSectionProps { onChanged?: () => void; /** Hide the inline progress summary (e.g. when the parent renders its own). */ hideSummary?: boolean; + /** Lock approve actions after document review phase completes. */ + approvalsLocked?: boolean; + /** Block new queries after pre-clearance finalization. */ + queriesLocked?: boolean; + /** Read-only audit view — no approve/query actions. */ + readOnly?: boolean; } const STATUS_META: Record< @@ -64,6 +70,9 @@ export function ClearanceReviewSection({ bookingId, onChanged, hideSummary, + approvalsLocked = false, + queriesLocked = false, + readOnly = false, }: ClearanceReviewSectionProps) { const qc = useQueryClient(); const [queryNotes, setQueryNotes] = useState>({}); @@ -147,6 +156,11 @@ export function ClearanceReviewSection({ return { total, approved, queried, pending, pct }; }, [customerDocs]); + const hasDocsAwaitingApproval = customerDocs.some( + (d) => d.file && d.reviewStatus !== "APPROVED", + ); + const effectiveApprovalsLocked = approvalsLocked && !hasDocsAwaitingApproval; + if (isLoading || !clearance) { return ( @@ -194,6 +208,9 @@ export function ClearanceReviewSection({ @@ -397,6 +414,9 @@ function StatPill({ function DocReviewCard({ doc, + approvalsLocked, + queriesLocked, + readOnly, note, queryOpen, onToggleQuery, @@ -407,6 +427,9 @@ function DocReviewCard({ busy, }: { doc: Freight.ClearanceDocument; + approvalsLocked: boolean; + queriesLocked: boolean; + readOnly: boolean; note: string; queryOpen: boolean; onToggleQuery: (open: boolean) => void; @@ -500,22 +523,24 @@ function DocReviewCard({ )} - {hasFile && ( + {hasFile && !readOnly && ( {!queryOpen ? ( - - {!isApproved && ( + {!queriesLocked && ( + + )} + {!isApproved && !approvalsLocked && ( + {!queriesLocked && ( + + )} {!isApproved && !approvalsLocked && ( + + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx index e6130d558..f9bd7683d 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useNavigate, useParams, @@ -6,14 +6,11 @@ import { } from "react-router-dom"; import { useQuery } from "@tanstack/react-query"; import { - ActionIcon, Alert, - Badge, Box, Button, Center, Divider, - Grid, Group, Loader, Modal, @@ -28,13 +25,13 @@ import { } from "@mantine/core"; import { AlertCircle, + CalendarDays, CheckCircle2, - Container as ContainerIcon, + ChevronLeft, FileText, + MapPin, Package, - Plus, Receipt, - Trash2, X, } from "lucide-react"; import type { Freight } from "@edr/types"; @@ -43,19 +40,23 @@ import { OperationDatePicker } from "@edr/ui-common"; import { api } from "@/services/api"; import { PageContainer } from "@/components/page"; import { PageHeader } from "@/components/page/PageHeader"; -import { SectionCard } from "@/components/bookings/detail/SectionCard"; import { contractsService } from "@/services/contracts.service"; import { - useContractCapacity, useContractDetail, useContractMutations, } from "@/hooks/contracts/useContracts"; -import { Boxes } from "lucide-react"; import { computeGlShipmentTotal, formatRateUnit, type GlShipmentQuantities, } from "./gl-booking-form/total"; +import { ContractCapacityNotice } from "./gl-booking-form/ContractCapacityNotice"; +import { + fieldStyles, + StepCard, + StepHeader, + StepLabel, +} from "./gl-booking-form/form-ui"; interface UnitDraft { containerNumber: string; @@ -75,22 +76,28 @@ interface BulkLineDraft { cargoWeightTons: number | string; itemCount: number | string; hazardousQuantity: number | string; + reeferQuantity: number | string; } function emptyUnit(): UnitDraft { return { containerNumber: "", sealNumber: "", vgmTons: "" }; } +function bulkUnitOfMeasure( + contract: Freight.IContract, +): "PER_TON" | "PER_ITEM" { + const hasPerItem = contract.pricingBreakdown?.lineItems?.some( + (li) => li.unit === "per_item", + ); + return hasPerItem ? "PER_ITEM" : "PER_TON"; +} + export default function GlCreateBookingForm() { const { id } = useParams<{ id: string }>(); const [searchParams] = useSearchParams(); - // When GL accepts a shipment request, the form opens with ?requestId=… so it - // can prefill the requested quantities/date and mark the request accepted on - // success. const requestId = searchParams.get("requestId"); const navigate = useNavigate(); const { data: contract, isLoading } = useContractDetail(id); - const { data: capacity = [] } = useContractCapacity(id); const mutations = useContractMutations(id ?? ""); const { data: bookingRequest } = useQuery({ @@ -105,42 +112,8 @@ export default function GlCreateBookingForm() { const [containerLines, setContainerLines] = useState([]); const [bulkLines, setBulkLines] = useState([]); const [prefilled, setPrefilled] = useState(false); - - // Prefill once from an accepted shipment request: size/qty container lines - // (one blank unit per requested container) + bulk + route + notes. GL still - // enters per-unit container numbers + sets the binding shipment date. - useEffect(() => { - if (!bookingRequest || prefilled) return; - setPrefilled(true); - const lines = bookingRequest.requestedLines ?? {}; - if (lines.containers?.length) { - setContainerLines( - lines.containers.map((c) => ({ - containerSize: c.containerSize, - hazardousQuantity: c.hazardousQuantity ?? "", - reeferQuantity: c.reeferQuantity ?? "", - units: Array.from({ length: Math.max(1, c.quantity) }, () => - emptyUnit(), - ), - })), - ); - } else if (lines.bulk) { - setBulkLines([ - { - cargoTypeId: lines.bulk.cargoTypeId ?? "", - cargoWeightTons: lines.bulk.cargoWeightTons ?? "", - itemCount: lines.bulk.itemCount ?? "", - hazardousQuantity: lines.bulk.hazardousQuantity ?? "", - }, - ]); - } - if (bookingRequest.contractRouteId) - setContractRouteId(bookingRequest.contractRouteId); - if (bookingRequest.notes) setNotes(bookingRequest.notes); - }, [bookingRequest, prefilled]); - // Price-confirm modal — GL reviews the estimate before booking on behalf of - // the customer, mirroring the portal customer flow. const [priceOpen, setPriceOpen] = useState(false); + const seededRef = useRef(false); const isContainer = contract?.freightType === "CONTAINER"; const routes = useMemo( @@ -158,7 +131,6 @@ export default function GlCreateBookingForm() { return [...sizes]; }, [contract?.cargoScope]); - // Bulk cargo types declared on the contract scope (prefill, no free-text). const bulkCargoOptions = useMemo(() => { const seen = new Map(); (contract?.cargoScope ?? []).forEach((s) => { @@ -172,11 +144,73 @@ export default function GlCreateBookingForm() { const defaultBulkCargoTypeId = bulkCargoOptions[0]?.value ?? ""; - // Normalized quantities for the client-side price estimate (same source the - // portal customer sees: the contract's frozen unit rates × entered qty). + useEffect(() => { + if (!bookingRequest || prefilled) return; + setPrefilled(true); + const lines = bookingRequest.requestedLines ?? {}; + if (lines.containers?.length) { + setContainerLines( + lines.containers.map((c) => ({ + containerSize: c.containerSize, + hazardousQuantity: c.hazardousQuantity ?? "0", + reeferQuantity: c.reeferQuantity ?? "", + units: Array.from({ length: Math.max(1, c.quantity) }, () => + emptyUnit(), + ), + })), + ); + } else if (lines.bulk) { + setBulkLines([ + { + cargoTypeId: lines.bulk.cargoTypeId ?? defaultBulkCargoTypeId, + cargoWeightTons: lines.bulk.cargoWeightTons ?? "", + itemCount: lines.bulk.itemCount ?? "", + hazardousQuantity: lines.bulk.hazardousQuantity ?? "0", + reeferQuantity: "", + }, + ]); + } + if (bookingRequest.contractRouteId) + setContractRouteId(bookingRequest.contractRouteId); + if (bookingRequest.notes) setNotes(bookingRequest.notes); + }, [bookingRequest, prefilled, defaultBulkCargoTypeId]); + + useEffect(() => { + if (!contract || prefilled || seededRef.current) return; + seededRef.current = true; + if (isContainer && containerSizes.length > 0 && containerLines.length === 0) { + setContainerLines( + containerSizes.map((size) => ({ + containerSize: size, + hazardousQuantity: "0", + reeferQuantity: "0", + units: [emptyUnit()], + })), + ); + } else if (!isContainer && bulkLines.length === 0) { + setBulkLines([ + { + cargoTypeId: defaultBulkCargoTypeId, + cargoWeightTons: "", + itemCount: "", + hazardousQuantity: "0", + reeferQuantity: "0", + }, + ]); + } + }, [ + contract, + prefilled, + isContainer, + containerSizes, + containerLines.length, + bulkLines.length, + defaultBulkCargoTypeId, + ]); + const quantities: GlShipmentQuantities = useMemo( () => ({ - isContainer, + isContainer: Boolean(isContainer), containers: containerLines.map((l) => ({ containerSize: l.containerSize, quantity: l.units.length, @@ -200,17 +234,11 @@ export default function GlCreateBookingForm() { [contract, quantities], ); - // The route this shipment ships on (for the cargo-aware day list). For a - // single-route contract there's exactly one; for GENERAL multi-route, the - // selected route (defaults to the first). const selectedRoute = useMemo( () => routes.find((r) => r.id === contractRouteId) ?? routes[0], [routes, contractRouteId], ); - // Cargo-aware availability query: only days where a train has remaining - // capacity AND enough matching-type wagons for the entered cargo. Null until - // the cargo is entered (so the Schedule section stays empty first). const cargoQuery = useMemo(() => { if (!selectedRoute?.originYardId || !selectedRoute?.destinationYardId) return null; @@ -247,18 +275,113 @@ export default function GlCreateBookingForm() { const { data: availableDays, isLoading: daysLoading } = useQuery({ ...api.trainScheduling.availableDaysForCargo.queryOptions({ - input: cargoQuery ?? { - freightType: "BULK" as const, - }, + input: cargoQuery ?? { freightType: "BULK" as const }, }), enabled: cargoQuery !== null, }); + const syncUnits = (lineIdx: number, qty: number) => { + setContainerLines((prev) => + prev.map((line, i) => { + if (i !== lineIdx) return line; + const next = [...line.units]; + while (next.length < qty) next.push(emptyUnit()); + next.length = Math.max(0, qty); + return { ...line, units: next }; + }), + ); + }; + + const patchLine = (idx: number, patch: Partial) => + setContainerLines((prev) => + prev.map((l, i) => (i === idx ? { ...l, ...patch } : l)), + ); + + const patchUnit = ( + lineIdx: number, + unitIdx: number, + patch: Partial, + ) => + patchLine(lineIdx, { + units: containerLines[lineIdx].units.map((u, i) => + i === unitIdx ? { ...u, ...patch } : u, + ), + }); + + const patchBulk = (idx: number, patch: Partial) => + setBulkLines((prev) => + prev.map((l, i) => (i === idx ? { ...l, ...patch } : l)), + ); + + const canSubmit = + Boolean(scheduledDate) && + (!needsRouteSelect || Boolean(contractRouteId)) && + (isContainer ? containerLines.some((l) => l.units.length > 0) : bulkLines.length > 0); + + const handleSubmit = () => { + if (!scheduledDate || !contract) return; + + const payload: Freight.CreateBookingUnderContractDto = { + scheduledDate, + ...(contractRouteId ? { contractRouteId } : {}), + ...(notes.trim() ? { notes: notes.trim() } : {}), + }; + + if (isContainer) { + payload.containers = containerLines + .filter((l) => l.units.length > 0) + .map((l) => ({ + containerSize: l.containerSize, + quantity: l.units.length, + ...(l.hazardousQuantity !== "" + ? { hazardousQuantity: Number(l.hazardousQuantity) } + : {}), + ...(l.reeferQuantity !== "" + ? { reeferQuantity: Number(l.reeferQuantity) } + : {}), + units: l.units.map((u) => ({ + containerNumber: u.containerNumber, + ...(u.sealNumber ? { sealNumber: u.sealNumber } : {}), + vgmTons: Number(u.vgmTons) || 0, + })), + })); + } else { + payload.bulkLines = bulkLines.map((l) => ({ + ...(l.cargoTypeId ? { cargoTypeId: l.cargoTypeId } : {}), + ...(l.cargoWeightTons !== "" + ? { cargoWeightTons: Number(l.cargoWeightTons) } + : {}), + ...(l.itemCount !== "" ? { itemCount: Number(l.itemCount) } : {}), + ...(l.hazardousQuantity !== "" + ? { hazardousQuantity: Number(l.hazardousQuantity) } + : {}), + ...(l.reeferQuantity !== "" + ? { reeferQuantity: Number(l.reeferQuantity) } + : {}), + })); + } + + mutations.createBooking.mutate(payload, { + onSuccess: async (booking) => { + if (requestId) { + try { + await contractsService.acceptBookingRequest(requestId, booking.id); + } catch { + // Non-fatal + } + navigate(`/dashboard/bookings/${booking.id}/clearance`); + } else { + navigate(`/dashboard/contracts/clearance/${contract.id}`); + } + }, + }); + }; + if (isLoading) { return (
- +
); @@ -275,194 +398,74 @@ export default function GlCreateBookingForm() { ); } - // ── Container line helpers ── - const addContainerLine = () => - setContainerLines((prev) => [ - ...prev, - { - containerSize: containerSizes[0] ?? "20ft", - hazardousQuantity: "", - reeferQuantity: "", - units: [emptyUnit()], - }, - ]); - const removeContainerLine = (idx: number) => - setContainerLines((prev) => prev.filter((_, i) => i !== idx)); - const patchLine = (idx: number, patch: Partial) => - setContainerLines((prev) => - prev.map((l, i) => (i === idx ? { ...l, ...patch } : l)), - ); - const addUnit = (lineIdx: number) => - patchLine(lineIdx, { - units: [...containerLines[lineIdx].units, emptyUnit()], - }); - const removeUnit = (lineIdx: number, unitIdx: number) => - patchLine(lineIdx, { - units: containerLines[lineIdx].units.filter((_, i) => i !== unitIdx), - }); - const patchUnit = ( - lineIdx: number, - unitIdx: number, - patch: Partial, - ) => - patchLine(lineIdx, { - units: containerLines[lineIdx].units.map((u, i) => - i === unitIdx ? { ...u, ...patch } : u, - ), - }); - - // ── Bulk line helpers ── - const addBulkLine = () => - setBulkLines((prev) => [ - ...prev, - { - cargoTypeId: defaultBulkCargoTypeId, - cargoWeightTons: "", - itemCount: "", - hazardousQuantity: "", - }, - ]); - const removeBulkLine = (idx: number) => - setBulkLines((prev) => prev.filter((_, i) => i !== idx)); - const patchBulk = (idx: number, patch: Partial) => - setBulkLines((prev) => - prev.map((l, i) => (i === idx ? { ...l, ...patch } : l)), - ); - - const canSubmit = - Boolean(scheduledDate) && - (!needsRouteSelect || Boolean(contractRouteId)) && - (isContainer ? containerLines.length > 0 : bulkLines.length > 0); - - const handleSubmit = () => { - if (!scheduledDate) return; - - const payload: Freight.CreateBookingUnderContractDto = { - scheduledDate, - ...(contractRouteId ? { contractRouteId } : {}), - ...(notes.trim() ? { notes: notes.trim() } : {}), - }; - - if (isContainer) { - payload.containers = containerLines.map((l) => ({ - containerSize: l.containerSize, - quantity: l.units.length, - ...(l.hazardousQuantity !== "" - ? { hazardousQuantity: Number(l.hazardousQuantity) } - : {}), - ...(l.reeferQuantity !== "" - ? { reeferQuantity: Number(l.reeferQuantity) } - : {}), - units: l.units.map((u) => ({ - containerNumber: u.containerNumber, - ...(u.sealNumber ? { sealNumber: u.sealNumber } : {}), - vgmTons: Number(u.vgmTons) || 0, - })), - })); - } else { - payload.bulkLines = bulkLines.map((l) => ({ - ...(l.cargoTypeId ? { cargoTypeId: l.cargoTypeId } : {}), - ...(l.cargoWeightTons !== "" - ? { cargoWeightTons: Number(l.cargoWeightTons) } - : {}), - ...(l.itemCount !== "" ? { itemCount: Number(l.itemCount) } : {}), - ...(l.hazardousQuantity !== "" - ? { hazardousQuantity: Number(l.hazardousQuantity) } - : {}), - })); - } - - mutations.createBooking.mutate(payload, { - onSuccess: async (booking) => { - if (requestId) { - // GENERAL+customs accept flow: mark the request accepted + link the - // booking, then hand off to the per-booking clearance review. - try { - await contractsService.acceptBookingRequest(requestId, booking.id); - } catch { - // Non-fatal — the booking exists; the request link can be retried. - } - navigate(`/dashboard/bookings/${booking.id}/clearance`); - } else { - navigate(`/dashboard/bookings/${booking.id}/milestones`); - } - }, - }); - }; + const bulkUom = bulkUnitOfMeasure(contract); return ( - + + + + New Shipment Booking + + + Book a shipment on behalf of the customer for contract {contract.reference}. + + + + - - {capacity.length > 0 && ( - c.remaining === 0) ? "red" : "blue"} - variant="light" - radius="md" - icon={} - title="Contract draw-down capacity" - > - - {capacity.map((c, i) => ( - - {c.containerSize ?? "Bulk"}: {c.remaining} of {c.cap} left - - ))} - - - )} - {bookingRequest ? ( - } - title="From shipment request" - > - Booking on behalf of the customer for request{" "} - {bookingRequest.reference}. - {bookingRequest.scheduledDate ? ( - <> - {" "} - Customer requested{" "} - - {new Intl.DateTimeFormat("en-GB", { - day: "2-digit", - month: "short", - year: "numeric", - }).format(new Date(bookingRequest.scheduledDate))} - {" "} - — set the binding shipment date below. - - ) : null} - - ) : null} - + {bookingRequest ? ( + } + title="From shipment request" + mb="lg" + > + Booking on behalf of the customer for request{" "} + {bookingRequest.reference}. + {bookingRequest.scheduledDate ? ( + <> + {" "} + Customer requested{" "} + + {new Intl.DateTimeFormat("en-GB", { + day: "2-digit", + month: "short", + year: "numeric", + }).format(new Date(bookingRequest.scheduledDate))} + {" "} + — set the binding shipment date below. + + ) : null} + + ) : null} + + + + } + title="Route" + description={ + needsRouteSelect + ? "Choose which contracted route this shipment ships on." + : "This shipment ships on the contract's only route." + } + /> {needsRouteSelect ? ( - patchLine(lineIdx, { - containerSize: v ?? line.containerSize, - }) - } - data={ - containerSizes.length > 0 - ? containerSizes - : ["20ft", "40ft"] - } - /> - - + + {line.containerSize} containers + + + syncUnits(lineIdx, Number(v) || 0)} + radius={10} + styles={fieldStyles} + /> + {contract.isHazardous ? ( patchLine(lineIdx, { hazardousQuantity: v }) } + radius={10} + styles={fieldStyles} /> - - + ) : null} + {contract.isReefer ? ( patchLine(lineIdx, { reeferQuantity: v }) } + radius={10} + styles={fieldStyles} /> - - - - - - - {line.units.map((unit, unitIdx) => ( - - - - patchUnit(lineIdx, unitIdx, { - containerNumber: e.currentTarget.value, - }) - } - /> - - - - patchUnit(lineIdx, unitIdx, { - sealNumber: e.currentTarget.value, - }) - } - /> - - - - patchUnit(lineIdx, unitIdx, { vgmTons: v }) - } - /> - - - removeUnit(lineIdx, unitIdx)} - aria-label="Remove unit" - > - - - - - ))} - - -
- ))} - - )} - - ) : ( - } - onClick={addBulkLine} - > - Add line - - } - > - {bulkLines.length === 0 ? ( - - Add at least one bulk line. - - ) : ( - - {bulkLines.map((line, idx) => ( - - - - Line {idx + 1} - - removeBulkLine(idx)} - aria-label="Remove line" - > - - + ) : null} - - - {bulkCargoOptions.length > 0 ? ( - patchBulk(idx, { cargoTypeId: v ?? "" })} + data={bulkCargoOptions} + radius={10} + styles={fieldStyles} + /> + ) : null} + {bulkUom === "PER_TON" ? ( + patchBulk(idx, { cargoWeightTons: v })} + radius={10} + styles={fieldStyles} + /> + ) : ( + patchBulk(idx, { itemCount: v })} + radius={10} + styles={fieldStyles} + /> + )} + {contract.isHazardous ? ( + patchBulk(idx, { hazardousQuantity: v })} + radius={10} + styles={fieldStyles} + /> + ) : null} + {contract.isReefer ? ( + patchBulk(idx, { reeferQuantity: v })} + radius={10} + styles={fieldStyles} + /> + ) : null} + + ))} + + )} - + + } + title="Schedule" + description="Pick the binding shipment day. Only days with an open train that has enough matching wagons for the cargo can be selected." + /> {cargoQuery === null ? ( } > - Enter the cargo details first — available shipment days depend on - the wagons the cargo needs. + Enter your cargo details first — available shipment days depend on + the wagons your cargo needs. ) : ( - <> - {bookingRequest?.scheduledDate ? ( - - Customer requested{" "} - {new Intl.DateTimeFormat("en-GB", { - day: "2-digit", - month: "short", - year: "numeric", - }).format(new Date(bookingRequest.scheduledDate))}{" "} - — pick the binding shipment day below. - - ) : null} - - + + Shipment day * + + + + )} - + - +