From fe40d9f4be5b25ea1658f90f9f753f9b8e06959c Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 2 Jul 2026 13:24:39 +0000 Subject: [PATCH 1/3] 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') { From fec2a5d3208e1250418a3900243f17229e434cb0 Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 2 Jul 2026 13:27:59 +0000 Subject: [PATCH 2/3] update import gl flow --- .../contracts/GlClearanceUploadModal.tsx | 3 +- .../contracts/PhasedClearanceActionPanel.tsx | 232 ++++++++++++- .../backoffice/src/constants/URLS.ts | 4 + .../pages/contracts/GlClearanceDetailPage.tsx | 3 +- .../src/services/contracts.service.ts | 21 ++ apps/edr-freight-web/portal/package.json | 5 +- .../new-booking-form/LocationPicker.tsx | 308 ++++++++---------- apps/edr-freight-web/portal/src/vite-env.d.ts | 1 + .../src/freight/clearance-files.catalog.ts | 28 ++ packages/types/src/freight/contracts.ts | 16 + packages/types/src/freight/index.ts | 2 + pnpm-lock.yaml | 81 ++--- 12 files changed, 452 insertions(+), 252 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlClearanceUploadModal.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlClearanceUploadModal.tsx index b33729bf1..ba54e3b8b 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlClearanceUploadModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlClearanceUploadModal.tsx @@ -121,7 +121,8 @@ export function GlClearanceUploadModal({ & { operationReady?: boolean }; type MilestoneRow = NonNullable[number]; @@ -79,7 +80,10 @@ function isBookingMilestoneDone( return m?.status === "COMPLETED" || m?.status === "SKIPPED"; } -function computeImportActiveStep(clearance: ClearanceViewLike): number { +function computeImportActiveStep( + clearance: ClearanceViewLike, + bookingCreated: boolean, +): number { if (!isMilestoneDone(clearance.milestones, "DOCUMENTS_APPROVED")) return 0; if (!isMilestoneDone(clearance.milestones, "DECLARED")) return 1; if ( @@ -98,7 +102,23 @@ function computeImportActiveStep(clearance: ClearanceViewLike): number { if (!isMilestoneDone(clearance.milestones, "TRANSIT_PERMIT_UPLOADED")) return 4; if (!clearance.preClearanceFinalized) return 5; if (!isMilestoneDone(clearance.milestones, "DO_COLLECTED")) return 6; - return 7; + if (!bookingCreated) return 7; + if (!clearance.t1?.closed) return 8; + return 9; +} + +function t1FilesFromWorkflow( + workflowFiles: Freight.ClearanceWorkflowFile[], +): Array<{ code: string; label: string; file: { id: string; name: string } }> { + return workflowFiles + .filter( + (f) => f.code.toLowerCase().startsWith("t1_transport_document") && f.file, + ) + .map((f) => ({ + code: f.code, + label: f.label, + file: f.file!, + })); } function declarationFilesFromWorkflow( @@ -174,9 +194,12 @@ export function PhasedClearanceActionPanel({ const showEt = roleMode === "ET" || roleMode === "ALL"; const showDj = roleMode === "DJ" || roleMode === "ALL"; const isImport = tradeDirection === "IMPORT"; + // The server only builds the t1 block once a booking is linked — use it as the + // booking-created signal on pages that don't pass bookingCreated (GL DJ detail). + const effectiveBookingCreated = bookingCreated || Boolean(clearance.t1); const activeStep = useMemo( - () => (isImport ? computeImportActiveStep(clearance) : 0), - [clearance, isImport], + () => (isImport ? computeImportActiveStep(clearance, effectiveBookingCreated) : 0), + [clearance, isImport, effectiveBookingCreated], ); if (isImport) { @@ -401,11 +424,7 @@ export function PhasedClearanceActionPanel({ description="GL Djibouti uploads DO" icon={} > - {showDj && - canDj && - !useUploadModals && - (activeStep >= 6 || - isMilestoneDone(clearance.milestones, "DO_COLLECTED")) ? ( + {showDj && canDj && !useUploadModals ? ( {useUploadModals && showDj && canDj && onUploadDoRequest ? ( @@ -439,7 +454,6 @@ export function PhasedClearanceActionPanel({ color="edr-green" leftSection={} onClick={onUploadDoRequest} - disabled={!clearance.preClearanceFinalized && !findWorkflowFile(workflowFiles, "delivery_order")} > {findWorkflowFile(workflowFiles, "delivery_order") ? "Replace DO" @@ -472,17 +486,37 @@ export function PhasedClearanceActionPanel({ ) : ( )} + + : + } + > + + @@ -578,6 +612,170 @@ export function PhasedClearanceActionPanel({ ); } +function ImportT1Section({ + t1, + workflowFiles = [], + canDjAct, + canEtAct, + onChanged, + onViewFile, + onDownloadFile, +}: { + t1: Freight.ClearanceT1State | null; + workflowFiles?: Freight.ClearanceWorkflowFile[]; + canDjAct: boolean; + canEtAct: boolean; + onChanged?: () => void; + onViewFile?: (file: { name: string; url: string }) => void; + onDownloadFile?: (file: { id: string; name: string }) => void; +}) { + const [files, setFiles] = useState([]); + const [uploading, setUploading] = useState(false); + const [closing, setClosing] = useState(false); + + const uploaded = t1FilesFromWorkflow(workflowFiles); + const replaceMode = uploaded.length > 0; + + if (!t1) { + return ( + + ); + } + + const departed = Boolean(t1.trainDepartedAt); + const arrived = Boolean(t1.trainArrivedAt); + const canUpload = canDjAct && t1.wagonAllocated && !departed && !t1.closed; + + return ( + + {uploaded.length > 0 ? ( + + + T1 document{uploaded.length > 1 ? "s" : ""} + + {uploaded.map((row) => ( + + ))} + + ) : null} + + {t1.closed ? ( + + ) : !t1.wagonAllocated ? ( + + ) : departed ? ( + }> + The train has departed — T1 documents are locked and can no longer be changed. + + ) : uploaded.length === 0 && !canUpload ? ( + + ) : null} + + {canUpload ? ( + <> + + + + + + ) : null} + + {canEtAct && !t1.closed ? ( + arrived ? ( + + + The train has arrived — review the T1 documents and close (accept) them. + + + + ) : departed ? ( + }> + Train en route — T1 can be closed once it arrives in Ethiopia. + + ) : null + ) : null} + + ); +} + function StepStatus({ done, pendingLabel, diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index 753dcb2a5..eb48a89b1 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -211,6 +211,10 @@ export const URL_CONSTANTS = { `/contracts/bookings/${bookingId}/documents`, BOOKING_TRANSPORT_DOCUMENT: (bookingId: string) => `/contracts/bookings/${bookingId}/transport-document`, + BOOKING_T1_DOCUMENTS: (bookingId: string) => + `/contracts/bookings/${bookingId}/t1-documents`, + BOOKING_T1_CLOSE: (bookingId: string) => + `/contracts/bookings/${bookingId}/t1-close`, BOOKING_INCIDENTS: (bookingId: string) => `/contracts/bookings/${bookingId}/incidents`, }, diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/GlClearanceDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/GlClearanceDetailPage.tsx index ecf95fe89..6756ecc3e 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/GlClearanceDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/GlClearanceDetailPage.tsx @@ -112,7 +112,8 @@ export default function GlClearanceDetailPage() { const isImport = data.tradeDirection === "IMPORT"; const hasDo = Boolean(findWorkflowFile(workflowFiles, "delivery_order")); const hasRo = Boolean(findWorkflowFile(workflowFiles, "release_order")); - const canUploadDo = isImport && Boolean(data.clearance.preClearanceFinalized || hasDo); + // DO upload is un-gated — Djibouti GL may attach it at any point, any file type. + const canUploadDo = isImport; const vesselDepartureDate = "vesselDepartureDate" in data.clearance ? (data.clearance.vesselDepartureDate ?? null) diff --git a/apps/edr-freight-web/backoffice/src/services/contracts.service.ts b/apps/edr-freight-web/backoffice/src/services/contracts.service.ts index 465cc302d..fe923d016 100644 --- a/apps/edr-freight-web/backoffice/src/services/contracts.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/contracts.service.ts @@ -333,6 +333,27 @@ export const contractsService = { return unwrap(response.data); }, + /** GL Djibouti uploads T1 transit documents (multi-file, post wagon allocation). */ + uploadT1Documents: async ( + bookingId: string, + files: Record, + ) => { + const form = new FormData(); + for (const [key, file] of Object.entries(files)) { + if (file) form.append(key, file); + } + const response = await client.post(C.BOOKING_T1_DOCUMENTS(bookingId), form, { + headers: { "Content-Type": "multipart/form-data" }, + }); + return unwrap(response.data); + }, + + /** GL Ethiopia closes (accepts) the T1 document set after the train arrives. */ + closeT1: async (bookingId: string): Promise => { + const response = await client.post(C.BOOKING_T1_CLOSE(bookingId)); + return unwrap(response.data) as Freight.ClearanceT1State; + }, + // ── Path A self-clearance (Operations review) ── getOpsClearanceQueue: async (): Promise => { const response = await client.get( diff --git a/apps/edr-freight-web/portal/package.json b/apps/edr-freight-web/portal/package.json index c445d67e9..a9bcf4ecc 100644 --- a/apps/edr-freight-web/portal/package.json +++ b/apps/edr-freight-web/portal/package.json @@ -20,18 +20,17 @@ "@mantine/hooks": "^9.3.0", "@tanstack/react-query": "^5.59.0", "@tria-plc/iamui": "file:../../../local-packages/tria-plc-iamui-0.1.1.tgz", + "@vis.gl/react-google-maps": "^1.8.3", "axios": "^1.7.7", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^3.6.0", - "leaflet": "^1.9.4", "lucide-react": "^1.14.0", "radix-ui": "^1.4.3", "react": "19.2.6", "react-dom": "19.2.6", "react-hook-form": "^7.76.0", "react-hot-toast": "^2.6.0", - "react-leaflet": "^5.0.0", "react-phone-number-input": "^3.4.17", "react-router-dom": "^6.27.0", "recharts": "^3.8.1", @@ -44,7 +43,7 @@ "@edr/tsconfig": "workspace:*", "@hookform/devtools": "^4.4.0", "@tailwindcss/vite": "^4.3.0", - "@types/leaflet": "^1.9.21", + "@types/google.maps": "^3.65.2", "@types/react": "^18.3.11", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.2", diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/LocationPicker.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/LocationPicker.tsx index 4aad2be68..176cd0aa9 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/LocationPicker.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/LocationPicker.tsx @@ -1,5 +1,3 @@ -import "leaflet/dist/leaflet.css"; - import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Box, @@ -13,8 +11,14 @@ import { useCombobox, } from "@mantine/core"; import { Check, MapPin, Search } from "lucide-react"; -import L from "leaflet"; -import { MapContainer, Marker, TileLayer, useMap, useMapEvents } from "react-leaflet"; +import { + APIProvider, + Map as GoogleMap, + type MapMouseEvent, + Marker, + useMap, + useMapsLibrary, +} from "@vis.gl/react-google-maps"; import { fieldStyles } from "./shared"; @@ -25,129 +29,93 @@ export interface LocationValue { lng: number | null; } -/** A single Nominatim search result, normalised to what the UI needs. */ +/** A single geocoding result, normalised to what the UI needs. */ interface GeocodeResult { displayName: string; lat: number; lng: number; } -// Leaflet's default marker icon URLs break under bundlers; point them at the -// CDN-hosted assets once so every map instance renders a visible pin. -const markerIcon = L.icon({ - iconUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png", - iconRetinaUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png", - shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png", - iconSize: [25, 41], - iconAnchor: [12, 41], - popupAnchor: [1, -34], - shadowSize: [41, 41], -}); +// Maps JavaScript API keys are public client-side keys (lock them down by +// HTTP-referrer in the Google Cloud console). The env var lets deployments +// override the default key without a code change. +const GOOGLE_MAPS_API_KEY = + import.meta.env.VITE_GOOGLE_MAPS_API_KEY || + "AIzaSyBg4tN31-fgvH_2Ix_TPo6VSfOA2uA5CCI"; // Centre of the EDR corridor (Addis Ababa) — a sensible default view. -const DEFAULT_CENTER: [number, number] = [9.03, 38.74]; +const DEFAULT_CENTER = { lat: 9.03, lng: 38.74 }; const DEFAULT_ZOOM = 6; const PINNED_ZOOM = 14; -const NOMINATIM_URL = "https://nominatim.openstreetmap.org/search"; -const NOMINATIM_REVERSE_URL = "https://nominatim.openstreetmap.org/reverse"; // Search only fires once the user pauses typing for this long. Slightly longer // than a keystroke burst so we make one request per pause, not per character. const SEARCH_DEBOUNCE_MS = 550; const MIN_QUERY_LEN = 2; // Bias geocoding toward the EDR corridor countries so local addresses surface -// first (Nominatim still returns global matches if nothing local fits). -const SEARCH_COUNTRYCODES = "et,dj"; -// Nominatim's fair-use policy allows at most 1 request/second. We keep a hard -// floor a touch above 1s so a flurry of map clicks / searches can never trip -// the 429 ("Too Many Requests") wall. -const MIN_REQUEST_INTERVAL_MS = 1100; +// first (we retry globally if nothing local matches). +const SEARCH_COUNTRIES = ["ET", "DJ"]; +const MAX_RESULTS = 8; // Reverse-geocode precision: coordinates are rounded to ~11m before caching so // near-identical pin drags resolve from cache instead of re-hitting the API. const REVERSE_COORD_PRECISION = 4; -// ── Module-level rate-limited request queue ───────────────────────────────── -// Every Nominatim call (forward + reverse, across ALL picker instances on the -// page) funnels through one promise chain that spaces requests ≥1.1s apart. -let lastRequestAt = 0; -let queueTail: Promise = Promise.resolve(); - -function scheduleRequest(run: () => Promise): Promise { - const result = queueTail.then(async () => { - const now = Date.now(); - const wait = Math.max(0, lastRequestAt + MIN_REQUEST_INTERVAL_MS - now); - if (wait > 0) await new Promise((r) => setTimeout(r, wait)); - lastRequestAt = Date.now(); - return run(); - }); - // Keep the chain alive even if this request rejects, so one failure doesn't - // stall every queued request behind it. - queueTail = result.catch(() => undefined); - return result; -} - // Simple in-memory caches keyed by the normalized query / rounded coordinate. const searchCache = new Map(); const reverseCache = new Map(); -/** One Nominatim forward-geocode request. `countryCodes` biases to a region. */ -async function nominatimSearch( - query: string, - signal: AbortSignal, - countryCodes?: string, +/** + * One Geocoder request, normalised. The promise-based `geocode` rejects on + * ZERO_RESULTS (and any other non-OK status), so failures collapse to "no + * matches" rather than surfacing as an error state. + */ +async function geocode( + geocoder: google.maps.Geocoder, + request: google.maps.GeocoderRequest, ): Promise { - const params = new URLSearchParams({ - q: query, - format: "jsonv2", - addressdetails: "0", - limit: "8", - }); - if (countryCodes) params.set("countrycodes", countryCodes); - const res = await fetch(`${NOMINATIM_URL}?${params}`, { - signal, - headers: { Accept: "application/json", "Accept-Language": "en" }, - }); - if (!res.ok) return []; - const data = (await res.json()) as Array<{ - display_name: string; - lat: string; - lon: string; - }>; - return data.map((d) => ({ - displayName: d.display_name, - lat: Number(d.lat), - lng: Number(d.lon), - })); + try { + const { results } = await geocoder.geocode(request); + return results.slice(0, MAX_RESULTS).map((r) => ({ + displayName: r.formatted_address, + lat: r.geometry.location.lat(), + lng: r.geometry.location.lng(), + })); + } catch { + return []; + } } /** - * Forward-geocode a free-text query. Served from cache when possible; otherwise - * queued (rate-limited) and tried EDR-corridor-first, then global, so local - * addresses rank highest without the field ever looking "broken". + * Forward-geocode a free-text query. Served from cache when possible; + * otherwise tried EDR-corridor-first, then global, so local addresses rank + * highest without the field ever looking "broken". */ async function searchPlaces( + geocoder: google.maps.Geocoder, query: string, - signal: AbortSignal, ): Promise { const key = query.trim().toLowerCase(); const cached = searchCache.get(key); if (cached) return cached; - const found = await scheduleRequest(async () => { - if (signal.aborted) return []; - const local = await nominatimSearch(query, signal, SEARCH_COUNTRYCODES); - if (local.length > 0) return local; - return nominatimSearch(query, signal); - }); + // The Geocoder only accepts one country restriction per request, so the + // corridor pass fans out to one request per country and merges in order. + const perCountry = await Promise.all( + SEARCH_COUNTRIES.map((country) => + geocode(geocoder, { address: query, componentRestrictions: { country } }), + ), + ); + const local = perCountry.flat().slice(0, MAX_RESULTS); + const found = local.length > 0 ? local : await geocode(geocoder, { address: query }); if (found.length > 0) searchCache.set(key, found); return found; } -/** Reverse-geocode a dropped pin to its nearest address (cached + queued). */ +/** Reverse-geocode a dropped pin to its nearest address (cached). */ async function reverseGeocode( + geocoder: google.maps.Geocoder, lat: number, lng: number, - signal?: AbortSignal, ): Promise { const key = `${lat.toFixed(REVERSE_COORD_PRECISION)},${lng.toFixed( REVERSE_COORD_PRECISION, @@ -155,63 +123,33 @@ async function reverseGeocode( const cached = reverseCache.get(key); if (cached != null) return cached; - const params = new URLSearchParams({ - lat: String(lat), - lon: String(lng), - format: "json", - }); - try { - const address = await scheduleRequest(async () => { - if (signal?.aborted) return ""; - const res = await fetch(`${NOMINATIM_REVERSE_URL}?${params}`, { - signal, - headers: { Accept: "application/json", "Accept-Language": "en" }, - }); - if (!res.ok) return ""; - const data = (await res.json()) as { display_name?: string }; - return data.display_name ?? ""; - }); - reverseCache.set(key, address); - return address; - } catch { - return ""; - } + const [best] = await geocode(geocoder, { location: { lat, lng } }); + const address = best?.displayName ?? ""; + reverseCache.set(key, address); + return address; } -/** - * Leaflet computes its tile layout from the container size at mount. When the - * map is revealed inside a just-toggled section it can mount before layout - * settles and render grey tiles — invalidating the size on the next frame - * forces a correct redraw. - */ -function InvalidateSizeOnMount() { - const map = useMap(); - useEffect(() => { - const id = setTimeout(() => map.invalidateSize(), 0); - return () => clearTimeout(id); - }, [map]); - return null; +/** Lazily constructs a Geocoder once the geocoding library has loaded. */ +function useGeocoder(): google.maps.Geocoder | null { + const geocodingLib = useMapsLibrary("geocoding"); + return useMemo( + () => (geocodingLib ? new geocodingLib.Geocoder() : null), + [geocodingLib], + ); } /** Recenters the map imperatively when the pinned coordinate changes. */ function MapRecenter({ lat, lng }: { lat: number | null; lng: number | null }) { const map = useMap(); useEffect(() => { - if (lat != null && lng != null) { - map.setView([lat, lng], PINNED_ZOOM, { animate: true }); + if (map && lat != null && lng != null) { + map.panTo({ lat, lng }); + map.setZoom(PINNED_ZOOM); } }, [lat, lng, map]); return null; } -/** Captures map clicks and forwards the dropped coordinate. */ -function ClickToPin({ onPick }: { onPick: (lat: number, lng: number) => void }) { - useMapEvents({ - click: (e) => onPick(e.latlng.lat, e.latlng.lng), - }); - return null; -} - export interface LocationPickerProps { value: LocationValue; onChange: (value: LocationValue) => void; @@ -227,14 +165,21 @@ export interface LocationPickerProps { } /** - * Address + map location picker backed by free OpenStreetMap services: - * - type to search (Nominatim forward geocoding), - * - or click anywhere on the map to drop a pin (Nominatim reverse geocoding). + * Address + map location picker backed by Google Maps: + * - type to search (Geocoding API forward geocoding, debounced), + * - or click anywhere on the map to drop a pin (reverse geocoding). * Reports the resolved address and coordinates up via `onChange`. */ export function LocationPicker(props: LocationPickerProps) { - if (props.variant === "modal") return ; - return ; + return ( + + {props.variant === "modal" ? ( + + ) : ( + + )} + + ); } /** Compact trigger + modal wrapper around the inline picker. */ @@ -343,12 +288,13 @@ function LocationPickerInline({ withinPortal = true, }: LocationPickerProps & { mapHeight?: number; withinPortal?: boolean }) { const combobox = useCombobox(); + const geocoder = useGeocoder(); const [query, setQuery] = useState(""); const [results, setResults] = useState([]); const [searching, setSearching] = useState(false); const [resolving, setResolving] = useState(false); - const abortRef = useRef(null); - const reverseAbortRef = useRef(null); + const searchStaleRef = useRef<{ stale: boolean } | null>(null); + const reverseStaleRef = useRef<{ stale: boolean } | null>(null); const hasPin = value.lat != null && value.lng != null; @@ -366,32 +312,31 @@ function LocationPickerInline({ } setSearching(true); combobox.openDropdown(); - abortRef.current?.abort(); - const controller = new AbortController(); - abortRef.current = controller; + if (!geocoder) return; // re-runs once the geocoding library loads + // The Geocoder has no abort support, so a token marks superseded requests + // and their responses are dropped instead of overwriting newer results. + const token = { stale: false }; + searchStaleRef.current = token; const handle = setTimeout(async () => { - try { - const found = await searchPlaces(q, controller.signal); - if (controller.signal.aborted) return; - setResults(found); - combobox.openDropdown(); - } catch (err) { - // Ignore aborts (a newer keystroke superseded this request). - if ((err as Error)?.name !== "AbortError") setResults([]); - } finally { - if (!controller.signal.aborted) setSearching(false); - } + const found = await searchPlaces(geocoder, q); + if (token.stale) return; + setResults(found); + setSearching(false); + combobox.openDropdown(); }, SEARCH_DEBOUNCE_MS); - // Cancel both the pending debounce AND any in-flight request when the query - // changes, so a stale response can't overwrite newer results. return () => { clearTimeout(handle); - controller.abort(); + token.stale = true; }; - }, [query, combobox]); + }, [query, geocoder, combobox]); - // Abort any in-flight reverse lookup when the picker unmounts. - useEffect(() => () => reverseAbortRef.current?.abort(), []); + // Drop any in-flight reverse lookup when the picker unmounts. + useEffect( + () => () => { + if (reverseStaleRef.current) reverseStaleRef.current.stale = true; + }, + [], + ); const selectResult = useCallback( (r: GeocodeResult) => { @@ -407,13 +352,14 @@ function LocationPickerInline({ async (lat: number, lng: number) => { // Show the pin immediately; fill the address once reverse geocoding lands. onChange({ address: value.address, lat, lng }); - // Cancel any in-flight reverse lookup — only the latest dropped pin counts. - reverseAbortRef.current?.abort(); - const controller = new AbortController(); - reverseAbortRef.current = controller; + if (!geocoder) return; + // Mark any in-flight reverse lookup stale — only the latest pin counts. + if (reverseStaleRef.current) reverseStaleRef.current.stale = true; + const token = { stale: false }; + reverseStaleRef.current = token; setResolving(true); - const address = await reverseGeocode(lat, lng, controller.signal); - if (controller.signal.aborted) return; // a newer pin superseded this one + const address = await reverseGeocode(geocoder, lat, lng); + if (token.stale) return; // a newer pin superseded this one setResolving(false); onChange({ address: address || `${lat.toFixed(5)}, ${lng.toFixed(5)}`, @@ -421,14 +367,21 @@ function LocationPickerInline({ lng, }); }, - [onChange, value.address], + [onChange, value.address, geocoder], + ); + + const handleMapClick = useCallback( + (e: MapMouseEvent) => { + const latLng = e.detail.latLng; + if (latLng) void handlePin(latLng.lat, latLng.lng); + }, + [handlePin], ); const inputValue = query || value.address; - const center = useMemo<[number, number]>( - () => (hasPin ? [value.lat as number, value.lng as number] : DEFAULT_CENTER), - [hasPin, value.lat, value.lng], - ); + const center = hasPin + ? { lat: value.lat as number, lng: value.lng as number } + : DEFAULT_CENTER; return ( @@ -494,26 +447,23 @@ function LocationPickerInline({ border: "1px solid #E6ECF2", }} > - - - - {hasPin && ( )} - + diff --git a/apps/edr-freight-web/portal/src/vite-env.d.ts b/apps/edr-freight-web/portal/src/vite-env.d.ts index d755e510b..b24245689 100644 --- a/apps/edr-freight-web/portal/src/vite-env.d.ts +++ b/apps/edr-freight-web/portal/src/vite-env.d.ts @@ -16,6 +16,7 @@ interface Window { interface ImportMetaEnv { readonly VITE_API_URL: string; + readonly VITE_GOOGLE_MAPS_API_KEY?: string; } interface ImportMeta { diff --git a/packages/types/src/freight/clearance-files.catalog.ts b/packages/types/src/freight/clearance-files.catalog.ts index fe6e9ebbf..307965dd8 100644 --- a/packages/types/src/freight/clearance-files.catalog.ts +++ b/packages/types/src/freight/clearance-files.catalog.ts @@ -33,6 +33,13 @@ export const CLEARANCE_WORKFLOW_FILE_CATALOG: ClearanceWorkflowFileCatalogEntry[ }, { code: "delivery_order", label: "Delivery Order", uploadedBy: "gl_dj", category: "djibouti", tradeDirection: "IMPORT" }, { code: "release_order", label: "Release Order", uploadedBy: "gl_dj", category: "djibouti", tradeDirection: "EXPORT" }, + { + code: "t1_transport_document", + label: "T1 Transport Document", + uploadedBy: "gl_dj", + category: "djibouti", + tradeDirection: "IMPORT", + }, ]; /** Legacy single-type declaration codes (still shown when already uploaded). */ @@ -101,6 +108,27 @@ export function transitPermitFileLabel(code: string, index?: number): string { return code; } +/** Legacy single T1 code (GL post-booking uploader). */ +export const LEGACY_T1_TRANSPORT_CODE = "t1_transport_document"; + +/** Multi-file T1 transport uploads use `t1_transport_document_0`, `_1`, … */ +export const T1_TRANSPORT_FILE_PREFIX = "t1_transport_document_"; + +export function isT1TransportFileCode(code: string | null | undefined): boolean { + if (!code) return false; + const lower = code.toLowerCase(); + return lower === LEGACY_T1_TRANSPORT_CODE || lower.startsWith(T1_TRANSPORT_FILE_PREFIX); +} + +export function t1TransportFileLabel(code: string, index?: number): string { + const lower = code.toLowerCase(); + if (lower === LEGACY_T1_TRANSPORT_CODE) return "T1 Transport Document"; + if (lower.startsWith(T1_TRANSPORT_FILE_PREFIX)) { + return index != null ? `T1 transport document ${index + 1}` : "T1 Transport Document"; + } + return code; +} + export const LEGACY_EXPORT_TRANSPORT_CODE = "export_transport_document"; export function isExportTransportFileCode(code: string | null | undefined): boolean { diff --git a/packages/types/src/freight/contracts.ts b/packages/types/src/freight/contracts.ts index f4c0b3380..c9d698f86 100644 --- a/packages/types/src/freight/contracts.ts +++ b/packages/types/src/freight/contracts.ts @@ -242,6 +242,20 @@ export interface ContractClearanceDocument { reviewedByStaffId?: string | null; } +/** + * Post-allocation T1 transit document state for the booking linked to an import + * customs flow. GL Djibouti uploads after wagon allocation; uploads lock once the + * train departs; GL Ethiopia closes (accepts) T1 when the train arrives. + */ +export interface ClearanceT1State { + bookingId: string; + wagonAllocated: boolean; + trainDepartedAt: string | null; + trainArrivedAt: string | null; + closed: boolean; + closedAt?: string | null; +} + export interface ContractClearanceView { contractId: string; /** Overall contract status (e.g. CLEARANCE_UNDER_REVIEW). */ @@ -283,6 +297,8 @@ export interface ContractClearanceView { } | null; /** Phased customs uploads (IM4, DO, transit permit, etc.) with friendly labels. */ workflowFiles?: import("./clearance-files.catalog").ClearanceWorkflowFile[]; + /** Import post-allocation T1 transit document state (null until a booking is linked). */ + t1?: ClearanceT1State | null; } export type ClearanceActorRole = "CUSTOMER" | "GL_ET" | "GL_DJ" | "OPERATIONS"; diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index 2def8fa75..b05c9d8d1 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -548,6 +548,8 @@ export interface ClearanceView { } | null; /** Phased customs uploads (IM4, DO, transit permit, etc.) with friendly labels. */ workflowFiles?: import("./clearance-files.catalog").ClearanceWorkflowFile[]; + /** Import post-allocation T1 transit document state (null until wagon allocation). */ + t1?: import("./contracts").ClearanceT1State | null; } /** Company an invoice is billed to (minimal projection). */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8051dab4a..abd86df8f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -344,6 +344,9 @@ importers: '@tria-plc/iamui': specifier: file:../../../local-packages/tria-plc-iamui-0.1.1.tgz version: file:local-packages/tria-plc-iamui-0.1.1.tgz(0ce39b7e349029277dcd938d06eeb0f7) + '@vis.gl/react-google-maps': + specifier: ^1.8.3 + version: 1.8.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) axios: specifier: ^1.7.7 version: 1.17.0 @@ -356,9 +359,6 @@ importers: date-fns: specifier: ^3.6.0 version: 3.6.0 - leaflet: - specifier: ^1.9.4 - version: 1.9.4 lucide-react: specifier: ^1.14.0 version: 1.17.0(react@19.2.6) @@ -377,9 +377,6 @@ importers: react-hot-toast: specifier: ^2.6.0 version: 2.6.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - react-leaflet: - specifier: ^5.0.0 - version: 5.0.0(leaflet@1.9.4)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) react-phone-number-input: specifier: ^3.4.17 version: 3.4.17(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -411,9 +408,9 @@ importers: '@tailwindcss/vite': specifier: ^4.3.0 version: 4.3.0(vite@5.4.21(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0)) - '@types/leaflet': - specifier: ^1.9.21 - version: 1.9.21 + '@types/google.maps': + specifier: ^3.65.2 + version: 3.65.2 '@types/react': specifier: ^18.3.11 version: 18.3.31 @@ -1701,6 +1698,9 @@ packages: reflect-metadata: ^0.2.2 rxjs: ^7.x + '@googlemaps/js-api-loader@2.1.1': + resolution: {integrity: sha512-yUpAwksbHrlZIWD49JmveNSfBG4oAK0AwMknfSaPMnP5N7UT8oFRVCqwjGb1XQovi//7KLbPQKZpbofiLGzpDw==} + '@hello-pangea/dnd@18.0.1': resolution: {integrity: sha512-xojVWG8s/TGrKT1fC8K2tIWeejJYTAeJuj36zM//yEm/ZrnZUSFGS15BpO+jGZT1ybWvyXmeDJwPYb4dhWlbZQ==} peerDependencies: @@ -3515,13 +3515,6 @@ packages: '@radix-ui/rect@1.1.2': resolution: {integrity: sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==} - '@react-leaflet/core@3.0.0': - resolution: {integrity: sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==} - peerDependencies: - leaflet: ^1.9.0 - react: ^19.0.0 - react-dom: ^19.0.0 - '@react-pdf-viewer/attachment@3.12.0': resolution: {integrity: sha512-mhwrYJSIpCvHdERpLUotqhMgSjhtF+BTY1Yb9Fnzpcq3gLZP+Twp5Rynq21tCrVdDizPaVY7SKu400GkgdMfZw==} peerDependencies: @@ -4265,8 +4258,8 @@ packages: '@types/express@5.0.6': resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==} - '@types/geojson@7946.0.16': - resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + '@types/google.maps@3.65.2': + resolution: {integrity: sha512-e52bmOhGCQSNabFpL48iQlwJybq6rfns8NUVJ20MR7CdPlHQ2RmSCnPbJfrUYJfogrE4OiHQTZ4LXpop+eer1w==} '@types/graceful-fs@4.1.9': resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==} @@ -4303,9 +4296,6 @@ packages: '@types/jsonwebtoken@9.0.5': resolution: {integrity: sha512-VRLSGzik+Unrup6BsouBeHsf4d1hOEgYWTm/7Nmw1sXoN1+tRly/Gy/po3yeahnP4jfnQWWAhQAqcNfH7ngOkA==} - '@types/leaflet@1.9.21': - resolution: {integrity: sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==} - '@types/lodash@4.17.24': resolution: {integrity: sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==} @@ -4609,6 +4599,12 @@ packages: cpu: [x64] os: [win32] + '@vis.gl/react-google-maps@1.8.3': + resolution: {integrity: sha512-DW7nEuvOJ299DmdBnvGiUARrgS/+sTEO1iJgG9J8YaErZqLoq7S4TJ22f3EjJvR4dti4L4gft43JEK77nnKXDw==} + peerDependencies: + react: '>=16.8.0 || ^19.0 || ^19.0.0-rc' + react-dom: '>=16.8.0 || ^19.0 || ^19.0.0-rc' + '@vitejs/plugin-react@4.7.0': resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} engines: {node: ^14.18.0 || >=16.0.0} @@ -7978,9 +7974,6 @@ packages: resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==} engines: {node: '>= 0.6.3'} - leaflet@1.9.4: - resolution: {integrity: sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==} - leven@3.1.0: resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} engines: {node: '>=6'} @@ -9368,13 +9361,6 @@ packages: react-is@19.2.7: resolution: {integrity: sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==} - react-leaflet@5.0.0: - resolution: {integrity: sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==} - peerDependencies: - leaflet: ^1.9.0 - react: ^19.0.0 - react-dom: ^19.0.0 - react-number-format@5.4.5: resolution: {integrity: sha512-y8O2yHHj3w0aE9XO8d2BCcUOOdQTRSVq+WIuMlLVucAm5XNjJAy+BoOJiuQMldVYVOKTMyvVNfnbl2Oqp+YxGw==} peerDependencies: @@ -12134,6 +12120,10 @@ snapshots: reflect-metadata: 0.2.2 rxjs: 7.8.2 + '@googlemaps/js-api-loader@2.1.1': + dependencies: + '@types/google.maps': 3.65.2 + '@hello-pangea/dnd@18.0.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@babel/runtime': 7.29.7 @@ -14803,12 +14793,6 @@ snapshots: '@radix-ui/rect@1.1.2': {} - '@react-leaflet/core@3.0.0(leaflet@1.9.4)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': - dependencies: - leaflet: 1.9.4 - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) - '@react-pdf-viewer/attachment@3.12.0(pdfjs-dist@5.4.296)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@react-pdf-viewer/core': 3.12.0(pdfjs-dist@5.4.296)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -15809,7 +15793,7 @@ snapshots: '@types/express-serve-static-core': 5.1.1 '@types/serve-static': 2.2.0 - '@types/geojson@7946.0.16': {} + '@types/google.maps@3.65.2': {} '@types/graceful-fs@4.1.9': dependencies: @@ -15847,10 +15831,6 @@ snapshots: dependencies: '@types/node': 20.19.42 - '@types/leaflet@1.9.21': - dependencies: - '@types/geojson': 7946.0.16 - '@types/lodash@4.17.24': {} '@types/luxon@3.7.1': {} @@ -16142,6 +16122,14 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.12.2': optional: true + '@vis.gl/react-google-maps@1.8.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@googlemaps/js-api-loader': 2.1.1 + '@types/google.maps': 3.65.2 + fast-deep-equal: 3.1.3 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + '@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0))': dependencies: '@babel/core': 7.29.7 @@ -20147,8 +20135,6 @@ snapshots: dependencies: readable-stream: 2.3.8 - leaflet@1.9.4: {} - leven@3.1.0: {} levn@0.4.1: @@ -21629,13 +21615,6 @@ snapshots: react-is@19.2.7: {} - react-leaflet@5.0.0(leaflet@1.9.4)(react-dom@19.2.6(react@19.2.6))(react@19.2.6): - dependencies: - '@react-leaflet/core': 3.0.0(leaflet@1.9.4)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - leaflet: 1.9.4 - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) - react-number-format@5.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: react: 18.3.1 From 71894ae2d3442baa7c0984ea4cbd38b5a53104ef Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 2 Jul 2026 13:43:07 +0000 Subject: [PATCH 3/3] merge conflict --- apps/edr-freight-api/src/app.module.ts | 4 - .../seed/paid-indode-demo-bookings.seeder.ts | 1131 ----------------- .../src/pages/billing/InvoiceDetailPage.tsx | 30 - 3 files changed, 1165 deletions(-) delete mode 100644 apps/edr-freight-api/src/seed/paid-indode-demo-bookings.seeder.ts diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index a81a539ba..f40e4f40f 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -59,7 +59,6 @@ import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-k import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder"; import { GovCompaniesSeeder } from "./seed/gov-companies.seeder"; import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder"; -import { PaidIndodeDemoBookingsSeeder } from "./seed/paid-indode-demo-bookings.seeder"; //New Trains, Wagons, Container and Cargo management modules import { TrainsModule } from "./modules/trains/trains.module"; import { WagonsModule } from './modules/wagons/wagons.module'; @@ -161,7 +160,6 @@ import { ImportOperationsModule } from './modules/import-operations/import-opera ExportDjiboutiInterchangeDemoSeeder, MarshallingDemoTrainsSeeder, ApprovedFirstLastMileDemoBookingsSeeder, - PaidIndodeDemoBookingsSeeder, ], }) export class AppModule implements OnApplicationBootstrap { @@ -180,7 +178,6 @@ export class AppModule implements OnApplicationBootstrap { private readonly warehouseDemoSeeder: WarehouseDemoSeeder, private readonly exportDjiboutiInterchangeDemoSeeder: ExportDjiboutiInterchangeDemoSeeder, private readonly marshallingDemoTrainsSeeder: MarshallingDemoTrainsSeeder, - private readonly paidIndodeDemoBookingsSeeder: PaidIndodeDemoBookingsSeeder, private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder, private readonly demoFreightDataSeeder: DemoFreightDataSeeder, private readonly govCompaniesSeeder: GovCompaniesSeeder, @@ -202,7 +199,6 @@ export class AppModule implements OnApplicationBootstrap { await this.warehouseDemoSeeder.run(); await this.exportDjiboutiInterchangeDemoSeeder.run(); await this.marshallingDemoTrainsSeeder.run(); - await this.paidIndodeDemoBookingsSeeder.run(); // Idempotent demo data: ≥100 wagons/type, approval chains, 4 staff users. // Each block self-guards on an empty-table check, so this is safe every boot. // Demo data seeds (DemoBookingsSeeder, PricingDataSeeder, diff --git a/apps/edr-freight-api/src/seed/paid-indode-demo-bookings.seeder.ts b/apps/edr-freight-api/src/seed/paid-indode-demo-bookings.seeder.ts deleted file mode 100644 index e33baa7fd..000000000 --- a/apps/edr-freight-api/src/seed/paid-indode-demo-bookings.seeder.ts +++ /dev/null @@ -1,1131 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { CargoUnitOfMeasure, TrainScheduleStatus, WagonStatus } from '@edr/types'; -import { randomUUID } from 'crypto'; -import { DataSource, EntityManager, In } from 'typeorm'; - -import { BookingContainer } from '../modules/bookings/entities/booking-container.entity'; -import { Booking } from '../modules/bookings/entities/booking.entity'; -import { - Company, - CompanyKind, - CompanyNationality, - CompanyStatus, - CompanyType, -} from '../modules/companies/entities/company.entity'; -import { - CompanyProfile, - ProfileStatus, - ProfileType, -} from '../modules/companies/entities/company-profile.entity'; -import { FirstMile } from '../modules/first-mile/entities/first-mile.entity'; -import { LastMile } from '../modules/last-mile/entities/last-mile.entity'; -import { Locomotive } from '../modules/locomotives/entities/locomotive.entity'; -import { CargoType } from '../modules/rule-engine/entities/cargo-type.entity'; -import { ContainerType } from '../modules/rule-engine/entities/container-type.entity'; -import { ServiceType } from '../modules/rule-engine/entities/service-type.entity'; -import { Yard } from '../modules/rule-engine/entities/yard.entity'; -import { WagonAllocationContainerItem } from '../modules/train-schedules/entities/wagon-allocation-container-item.entity'; -import { WagonBookingAllocation } from '../modules/train-schedules/entities/wagon-booking-allocation.entity'; -import { TrainSchedule } from '../modules/train-schedules/entities/train-schedule.entity'; -import { TrainScheduleBooking } from '../modules/train-schedules/entities/train-schedule-booking.entity'; -import { TrainSetWagon } from '../modules/train-sets/entities/train-set-wagon.entity'; -import { TrainSet } from '../modules/train-sets/entities/train-set.entity'; -import { ImportDjiboutiOperation } from '../modules/train-scheduling/entities/import-djibouti-operation.entity'; -import { WagonType } from '../modules/wagon-types/entities/wagon-type.entity'; -import { Wagon } from '../modules/wagons/entities/wagon.entity'; -import { WarehouseInventory } from '../modules/warehouses/entities/warehouse-inventory.entity'; -import { WarehouseActivityLog } from '../modules/warehouses/entities/warehouse-activity-log.entity'; -import { Warehouse } from '../modules/warehouses/entities/warehouse.entity'; -import { WarehouseYard } from '../modules/warehouses/entities/warehouse-yard.entity'; -import { WarehouseZone } from '../modules/warehouses/entities/warehouse-zone.entity'; -import { Driver, DriverStatus } from '../modules/drivers/entities/driver.entity'; -import { FuelType, Vehicle, VehicleStatus, VehicleType } from '../modules/vehicles/entities/vehicle.entity'; - -const CUSTOMER_TIN = 'US12DEMO01'; - -const DEMO_TRAINS = [ - { - trainNumber: 'US12-DJI-IND-01', - direction: 'IMPORT', - originCode: 'NAGAD', - destinationCode: 'INDODE', - departureHoursAgo: 30, - arrivalHoursAgo: 14, - }, - { - trainNumber: 'US12-IND-DJI-01', - direction: 'EXPORT', - originCode: 'INDODE', - destinationCode: 'NAGAD', - departureHoursAgo: 28, - arrivalHoursAgo: 12, - }, - { - trainNumber: 'US12-DJI-IND-LM-02', - direction: 'IMPORT', - originCode: 'NAGAD', - destinationCode: 'INDODE', - departureHoursAgo: 24, - arrivalHoursAgo: 8, - }, - { - trainNumber: 'US12-IND-DJI-LM-02', - direction: 'EXPORT', - originCode: 'INDODE', - destinationCode: 'NAGAD', - departureHoursAgo: 22, - arrivalHoursAgo: 6, - }, -] as const; - -const TRAIN_DEMO_BOOKINGS = [ - { - reference: 'US12-IMP-FM-001', - trainNumber: 'US12-DJI-IND-01', - tradeDirection: 'IMPORT', - freightType: 'CONTAINER', - withFirstMile: true, - withLastMile: true, - containerCode: '40FT', - cargoCode: 'GENERAL_CARGO', - weightTons: 27, - totalAmount: 18450, - pickupAddress: 'Doraleh Container Terminal, Djibouti', - pickupLat: 11.5881, - pickupLng: 43.1372, - deliveryAddress: 'Indode bonded warehouse gate, Ethiopia', - deliveryLat: 8.7566, - deliveryLng: 38.9846, - }, - { - reference: 'US12-IMP-NOFM-001', - trainNumber: 'US12-DJI-IND-01', - tradeDirection: 'IMPORT', - freightType: 'BULK', - withFirstMile: false, - withLastMile: false, - containerCode: null, - cargoCode: 'BULK', - weightTons: 42, - totalAmount: 22100, - pickupAddress: null, - pickupLat: null, - pickupLng: null, - deliveryAddress: null, - deliveryLat: null, - deliveryLng: null, - }, - { - reference: 'US12-EXP-FM-001', - trainNumber: 'US12-IND-DJI-01', - tradeDirection: 'EXPORT', - freightType: 'CONTAINER', - withFirstMile: true, - withLastMile: true, - containerCode: '20FT', - cargoCode: 'GENERAL_CARGO', - weightTons: 19, - totalAmount: 15680, - pickupAddress: 'Indode export truck gate, Ethiopia', - pickupLat: 8.7566, - pickupLng: 38.9846, - deliveryAddress: 'Nagad Terminal customer handover yard, Djibouti', - deliveryLat: 11.5536, - deliveryLng: 43.1103, - }, - { - reference: 'US12-EXP-NOFM-001', - trainNumber: 'US12-IND-DJI-01', - tradeDirection: 'EXPORT', - freightType: 'BULK', - withFirstMile: false, - withLastMile: false, - containerCode: null, - cargoCode: 'BULK', - weightTons: 55, - totalAmount: 29800, - pickupAddress: null, - pickupLat: null, - pickupLng: null, - deliveryAddress: null, - deliveryLat: null, - deliveryLng: null, - }, - { - reference: 'US12-IMP-LM-TRAIN-001', - trainNumber: 'US12-DJI-IND-LM-02', - tradeDirection: 'IMPORT', - freightType: 'CONTAINER', - withFirstMile: false, - withLastMile: true, - containerCode: '40FT', - cargoCode: 'GENERAL_CARGO', - weightTons: 31, - totalAmount: 20300, - pickupAddress: null, - pickupLat: null, - pickupLng: null, - deliveryAddress: 'Indode last-mile customer delivery bay, Ethiopia', - deliveryLat: 8.7581, - deliveryLng: 38.9834, - }, - { - reference: 'US12-EXP-LM-TRAIN-001', - trainNumber: 'US12-IND-DJI-LM-02', - tradeDirection: 'EXPORT', - freightType: 'CONTAINER', - withFirstMile: false, - withLastMile: true, - containerCode: '20FT', - cargoCode: 'GENERAL_CARGO', - weightTons: 21, - totalAmount: 17600, - pickupAddress: null, - pickupLat: null, - pickupLng: null, - deliveryAddress: 'Nagad last-mile consignee handover yard, Djibouti', - deliveryLat: 11.5549, - deliveryLng: 43.1121, - }, -] as const; - -const CUSTOMER_TRUCK_DEMO_BOOKINGS = [ - { - reference: 'US12-EXP-FM-TRUCK-001', - trainNumber: null, - originCode: 'INDODE', - destinationCode: 'NAGAD', - tradeDirection: 'EXPORT', - freightType: 'CONTAINER', - withFirstMile: true, - withLastMile: false, - containerCode: '40FT', - cargoCode: 'GENERAL_CARGO', - weightTons: 24, - totalAmount: 14800, - pickupAddress: 'Customer factory gate, Addis Ababa', - pickupLat: 8.9806, - pickupLng: 38.8736, - deliveryAddress: null, - deliveryLat: null, - deliveryLng: null, - }, - { - reference: 'US12-EXP-NOFM-TRUCK-001', - trainNumber: null, - originCode: 'INDODE', - destinationCode: 'NAGAD', - tradeDirection: 'EXPORT', - freightType: 'CONTAINER', - withFirstMile: false, - withLastMile: false, - containerCode: '20FT', - cargoCode: 'GENERAL_CARGO', - weightTons: 18, - totalAmount: 11200, - pickupAddress: null, - pickupLat: null, - pickupLng: null, - deliveryAddress: null, - deliveryLat: null, - deliveryLng: null, - customerTruckPlateNumber: 'ET-CUS-2046', - customerTruckDriverName: 'Dawit Customer Carrier', - customerTruckType: 'Container Chassis', - customerTruckContainerNumber: 'USDU1234567', - }, -] as const; - -const DEMO_BOOKINGS = [...TRAIN_DEMO_BOOKINGS, ...CUSTOMER_TRUCK_DEMO_BOOKINGS] as const; - -@Injectable() -export class PaidIndodeDemoBookingsSeeder { - private readonly logger = new Logger(PaidIndodeDemoBookingsSeeder.name); - - constructor(private readonly dataSource: DataSource) {} - - async run(): Promise { - try { - await this.dataSource.transaction(async (manager) => { - const refs = await this.ensureReferenceData(manager); - const schedules = await this.ensureArrivedTrains(manager, refs); - const bookings = await this.ensureBookings(manager, refs, schedules); - await this.ensureTrainLinks(manager, refs, schedules, bookings); - await this.ensureGatepasses(manager, schedules); - await this.ensureImportWarehouseInventory(manager, bookings); - }); - - this.logger.log( - `US12 paid Indode demo bookings ready: ${DEMO_BOOKINGS.length} booking(s), ${DEMO_TRAINS.length} arrived train(s)`, - ); - } catch (error) { - this.logger.error( - `PaidIndodeDemoBookingsSeeder failed: ${error instanceof Error ? error.message : String(error)}`, - ); - } - } - - private async ensureReferenceData(manager: EntityManager) { - await manager.getRepository(Yard).upsert( - [ - { - code: 'INDODE', - label: 'Indode Terminal', - country: 'Ethiopia', - isActive: true, - displayOrder: 1, - }, - { - code: 'NAGAD', - label: 'Nagad Terminal, Djibouti', - country: 'Djibouti', - isActive: true, - displayOrder: 2, - }, - ], - { conflictPaths: { code: true } }, - ); - - await manager.getRepository(ServiceType).upsert( - [ - { - code: 'RAIL_CONTAINER_FIRST_LAST', - serviceName: 'Rail Freight with First and Last Mile', - description: 'Rail movement with first-mile pickup and last-mile delivery', - canBeBookedAlone: true, - includesFirstMile: true, - includesLastMile: true, - includesCustoms: false, - priorityBonusPoints: 15, - isActive: true, - displayOrder: 3, - }, - { - code: 'RAIL_CONTAINER_LAST_MILE', - serviceName: 'Rail Freight with Last Mile', - description: 'Rail movement with last-mile delivery from terminal', - canBeBookedAlone: true, - includesFirstMile: false, - includesLastMile: true, - includesCustoms: false, - priorityBonusPoints: 8, - isActive: true, - displayOrder: 4, - }, - { - code: 'RAIL_CONTAINER', - serviceName: 'Rail Freight', - description: 'Rail movement without first-mile pickup', - canBeBookedAlone: true, - includesFirstMile: false, - includesLastMile: false, - includesCustoms: false, - priorityBonusPoints: 0, - isActive: true, - displayOrder: 1, - }, - { - code: 'RAIL_CONTAINER_FIRST_MILE', - serviceName: 'Rail Freight with First Mile', - description: 'Rail movement with first-mile pickup to terminal', - canBeBookedAlone: true, - includesFirstMile: true, - includesLastMile: false, - includesCustoms: false, - priorityBonusPoints: 10, - isActive: true, - displayOrder: 2, - }, - ], - { conflictPaths: { code: true } }, - ); - - await manager.getRepository(ContainerType).upsert( - [ - { - code: '20FT', - label: '20FT Standard', - sizeFt: 20, - wagonsPerUnit: 1, - isReefer: false, - isOpenTop: false, - isActive: true, - displayOrder: 1, - }, - { - code: '40FT', - label: '40FT Standard', - sizeFt: 40, - wagonsPerUnit: 1, - isReefer: false, - isOpenTop: false, - isActive: true, - displayOrder: 2, - }, - ], - { conflictPaths: { code: true } }, - ); - - await manager.getRepository(CargoType).upsert( - [ - { - code: 'GENERAL_CARGO', - cargoTypeName: 'General Cargo', - showFreeTextBox: true, - unitOfMeasure: null, - requiresDirectorApproval: false, - isActive: true, - displayOrder: 1, - }, - { - code: 'BULK', - cargoTypeName: 'Bulk Cargo', - showFreeTextBox: true, - unitOfMeasure: CargoUnitOfMeasure.PerTon, - requiresDirectorApproval: false, - isActive: true, - displayOrder: 2, - }, - ], - { conflictPaths: { code: true } }, - ); - - await manager.getRepository(WagonType).upsert( - { - code: 'US12-DEMO', - name: 'US12 Demo Flat/Bulk Wagon', - capacityTons: 70, - lengthMeters: 14, - maxWagonsPerTrain: 53, - supportedLoadTypes: ['CONTAINER', 'BULK'], - isActive: true, - equatedLengthM: 14, - tareWeightTons: 14, - supportsContainer: true, - maxContainerGrossT: 40, - }, - { conflictPaths: { code: true } }, - ); - - await manager.getRepository(Company).upsert( - { - name: 'US12 Indode Demo Customer PLC', - type: CompanyType.Customer, - kind: CompanyKind.Commercial, - status: CompanyStatus.Active, - tin: CUSTOMER_TIN, - vatNumber: 'VAT-US12-001', - fanNumber: 'US12000000000001', - country: 'Ethiopia', - nationality: CompanyNationality.Ethiopian, - address: 'Bole Road, Addis Ababa, Ethiopia', - phone: '251911120012', - email: 'us12.indode.demo@edr.local', - website: 'https://edr.local/us12-demo', - contactPersonName: 'Aster Bekele', - contactPersonPhone: '251911120013', - generalManagerName: 'Mekonnen Desta', - generalManagerEmail: 'manager.us12.demo@edr.local', - generalManagerPhone: '251911120014', - licenceNumber: 'LIC-US12-2026', - region: 'Addis Ababa', - zone: 'Bole', - woreda: '03', - kebele: '12', - houseNo: 'US12-01', - attributes: { - seededBy: 'PaidIndodeDemoBookingsSeeder', - note: 'Paid customer with import/export demo bookings for US12.', - } as any, - }, - { conflictPaths: { tin: true } }, - ); - - const company = await manager.getRepository(Company).findOneByOrFail({ tin: CUSTOMER_TIN }); - await manager.getRepository(CompanyProfile).upsert( - [ - { - companyId: company.id, - type: ProfileType.importer, - reference: 'US12-IMP', - status: ProfileStatus.Active, - businessLicense: 'BL-US12-IMP-2026', - attributes: { seededBy: 'PaidIndodeDemoBookingsSeeder' } as any, - }, - { - companyId: company.id, - type: ProfileType.exporter, - reference: 'US12-EXP', - status: ProfileStatus.Active, - businessLicense: 'BL-US12-EXP-2026', - attributes: { seededBy: 'PaidIndodeDemoBookingsSeeder' } as any, - }, - ], - { conflictPaths: { reference: true } }, - ); - - const [yards, serviceTypes, containerTypes, cargoTypes, wagonType, importerProfile, exporterProfile] = - await Promise.all([ - manager.getRepository(Yard).find({ where: { code: In(['INDODE', 'NAGAD']) } }), - manager - .getRepository(ServiceType) - .find({ - where: { - code: In([ - 'RAIL_CONTAINER', - 'RAIL_CONTAINER_FIRST_MILE', - 'RAIL_CONTAINER_LAST_MILE', - 'RAIL_CONTAINER_FIRST_LAST', - ]), - }, - }), - manager.getRepository(ContainerType).find({ where: { code: In(['20FT', '40FT']) } }), - manager.getRepository(CargoType).find({ where: { code: In(['GENERAL_CARGO', 'BULK']) } }), - manager.getRepository(WagonType).findOneByOrFail({ code: 'US12-DEMO' }), - manager.getRepository(CompanyProfile).findOneByOrFail({ reference: 'US12-IMP' }), - manager.getRepository(CompanyProfile).findOneByOrFail({ reference: 'US12-EXP' }), - ]); - - return { - company, - importerProfile, - exporterProfile, - yards: new Map(yards.map((yard) => [yard.code, yard])), - serviceTypes: new Map(serviceTypes.map((serviceType) => [serviceType.code, serviceType])), - containerTypes: new Map(containerTypes.map((containerType) => [containerType.code, containerType])), - cargoTypes: new Map(cargoTypes.map((cargoType) => [cargoType.code, cargoType])), - wagonType, - }; - } - - private async ensureArrivedTrains( - manager: EntityManager, - refs: Awaited>, - ): Promise> { - const schedules = new Map(); - const now = new Date(); - - for (const demo of DEMO_TRAINS) { - const origin = refs.yards.get(demo.originCode); - const destination = refs.yards.get(demo.destinationCode); - if (!origin || !destination) { - throw new Error(`US12 demo train missing yard: ${demo.trainNumber}`); - } - - const departure = this.addHours(now, -demo.departureHoursAgo); - const arrival = this.addHours(now, -demo.arrivalHoursAgo); - const locomotive = await this.ensureLocomotive(manager, origin.id); - const trainSet = await this.ensureTrainSet(manager, demo.trainNumber, locomotive.id); - const schedule = await this.ensureTrainSchedule(manager, { - trainNumber: demo.trainNumber, - trainSetId: trainSet.id, - originStationId: origin.id, - destinationStationId: destination.id, - scheduledDepartureDate: departure, - scheduledArrivalDate: arrival, - actualDepartureAt: departure, - actualArrivalAt: arrival, - direction: demo.direction, - }); - - await manager.getRepository(TrainSet).update(trainSet.id, { - totalWeightTons: TRAIN_DEMO_BOOKINGS.filter((booking) => booking.trainNumber === demo.trainNumber) - .reduce((sum, booking) => sum + booking.weightTons, 0), - totalLengthMeters: 28, - wagonCount: 2, - status: 'COMPLETED', - }); - schedules.set(demo.trainNumber, schedule); - } - - return schedules; - } - - private async ensureBookings( - manager: EntityManager, - refs: Awaited>, - schedules: Map, - ): Promise> { - const bookingRepo = manager.getRepository(Booking); - const bookingContainerRepo = manager.getRepository(BookingContainer); - const firstMileRepo = manager.getRepository(FirstMile); - const lastMileRepo = manager.getRepository(LastMile); - const now = new Date(); - const references = DEMO_BOOKINGS.map((booking) => booking.reference); - const existingBookings = await bookingRepo.find({ where: { reference: In(references) } }); - const existingBookingIds = existingBookings.map((booking) => booking.id); - const firstMileVehicle = await this.ensureFirstMileVehicle(manager); - - if (existingBookingIds.length) { - const existingInventory = await manager.getRepository(WarehouseInventory).find({ - where: { bookingId: In(existingBookingIds) }, - select: { id: true }, - }); - const existingInventoryIds = existingInventory.map((item) => item.id); - if (existingInventoryIds.length) { - await manager.getRepository(WarehouseActivityLog).delete({ - inventoryId: In(existingInventoryIds), - }); - await manager.getRepository(WarehouseInventory).delete({ - id: In(existingInventoryIds), - }); - } - await this.deleteBookingTrainChildren(manager, existingBookingIds); - await bookingContainerRepo.delete({ bookingId: In(existingBookingIds) }); - await firstMileRepo.delete({ bookingId: In(existingBookingIds) }); - await lastMileRepo.delete({ bookingId: In(existingBookingIds) }); - } - - for (const demo of DEMO_BOOKINGS) { - const schedule = demo.trainNumber ? schedules.get(demo.trainNumber) : null; - if (demo.trainNumber && !schedule) { - throw new Error(`US12 demo booking missing train: ${demo.reference}`); - } - - const train = demo.trainNumber ? DEMO_TRAINS.find((item) => item.trainNumber === demo.trainNumber) : null; - const originCode = train?.originCode ?? ('originCode' in demo ? demo.originCode : undefined); - const destinationCode = train?.destinationCode ?? ('destinationCode' in demo ? demo.destinationCode : undefined); - const origin = originCode ? refs.yards.get(originCode) : null; - const destination = destinationCode ? refs.yards.get(destinationCode) : null; - const serviceType = refs.serviceTypes.get( - demo.withFirstMile && demo.withLastMile - ? 'RAIL_CONTAINER_FIRST_LAST' - : demo.withFirstMile - ? 'RAIL_CONTAINER_FIRST_MILE' - : demo.withLastMile - ? 'RAIL_CONTAINER_LAST_MILE' - : 'RAIL_CONTAINER', - ); - const cargoType = refs.cargoTypes.get(demo.cargoCode); - const profile = demo.tradeDirection === 'IMPORT' ? refs.importerProfile : refs.exporterProfile; - - if (!origin || !destination || !serviceType || !cargoType) { - throw new Error(`US12 demo booking missing reference data: ${demo.reference}`); - } - - await bookingRepo.upsert( - { - reference: demo.reference, - companyId: refs.company.id, - companyProfileId: profile.id, - isGovernment: false, - status: - 'customerTruckPlateNumber' in demo && demo.customerTruckPlateNumber - ? 'TRUCK_ASSIGNED' - : demo.trainNumber - ? 'IN_TRANSIT' - : 'PAID', - scheduledDate: schedule?.scheduledDepartureDate ?? now, - estimatedShipmentDate: schedule?.scheduledDepartureDate ?? now, - totalAmount: demo.totalAmount, - paymentStatus: 'PAID', - contractType: 'NEW', - serviceTypeId: serviceType.id, - firstMilePickupAddress: demo.pickupAddress, - firstMilePickupLat: demo.pickupLat, - firstMilePickupLng: demo.pickupLng, - lastMileDeliveryAddress: demo.deliveryAddress, - lastMileDeliveryLat: demo.deliveryLat, - lastMileDeliveryLng: demo.deliveryLng, - customerTruckPlateNumber: - 'customerTruckPlateNumber' in demo ? demo.customerTruckPlateNumber : null, - customerTruckDriverName: - 'customerTruckDriverName' in demo ? demo.customerTruckDriverName : null, - customerTruckType: - 'customerTruckType' in demo ? demo.customerTruckType : null, - customerTruckContainerNumber: - 'customerTruckContainerNumber' in demo ? demo.customerTruckContainerNumber : null, - customerTruckAssignedAt: - 'customerTruckPlateNumber' in demo && demo.customerTruckPlateNumber - ? this.addHours(now, -2) - : null, - customerTruckArrivedAt: null, - customsClearingEnabled: false, - equipmentReturn: 'WITHOUT_RETURN', - originYardId: origin.id, - destinationYardId: destination.id, - tradeDirection: demo.tradeDirection, - freightType: demo.freightType, - cargoTypeId: cargoType.id, - cargoFreeText: demo.freightType === 'BULK' ? 'Seeded paid bulk cargo' : 'Seeded paid container cargo', - shippingLineId: null, - cargoTotalWeightVgm: demo.weightTons, - isHazardous: false, - isReefer: false, - paymentCurrency: 'ETB', - pnrCode: `PNR-${demo.reference}`, - versionNumber: 1, - approvedByStaffAt: now, - customerSignedAt: now, - fullyExecutedAt: now, - pricingBreakdown: { - paid: true, - source: 'PaidIndodeDemoBookingsSeeder', - firstMileIncluded: demo.withFirstMile, - lastMileIncluded: demo.withLastMile, - }, - priorityScore: demo.withFirstMile ? 30 : demo.withLastMile ? 25 : 20, - wagonsRequired: 1, - schedulingStatus: demo.trainNumber ? 'DISPATCHED' : 'NOT_SCHEDULED', - scheduledAt: demo.trainNumber ? now : null, - trainScheduleId: schedule?.id ?? null, - paymentDeadline: null, - selectedForBatchAt: demo.trainNumber ? now : null, - }, - { conflictPaths: { reference: true } }, - ); - - const booking = await bookingRepo.findOneByOrFail({ reference: demo.reference }); - - if (demo.freightType === 'CONTAINER' && demo.containerCode) { - const containerType = refs.containerTypes.get(demo.containerCode); - if (!containerType) { - throw new Error(`US12 demo booking missing container type: ${demo.reference}`); - } - await bookingContainerRepo.insert({ - id: randomUUID(), - bookingId: booking.id, - containerTypeId: containerType.id, - containerNumber: this.containerNumber(demo.reference), - containerSize: demo.containerCode.startsWith('40') ? '40ft' : '20ft', - quantity: 1, - hazardousQuantity: 0, - reeferQuantity: 0, - vgmPerUnitTons: demo.weightTons, - totalVgmTons: demo.weightTons, - wagonsRequired: 1, - weightLimitRuleId: null, - isOverweight: false, - overweightExcessTons: null, - }); - } - - if (demo.withFirstMile) { - await firstMileRepo.insert({ - id: randomUUID(), - bookingId: booking.id, - status: 'RECEIVED_TO_PORT', - advancedPayment: demo.totalAmount, - remainingPayment: 0, - estimatedKm: demo.tradeDirection === 'IMPORT' ? 12 : 35, - exactKm: demo.tradeDirection === 'IMPORT' ? 11.8 : 34.6, - vehicleId: firstMileVehicle.id, - }); - } - - if (demo.withLastMile) { - await lastMileRepo.insert({ - id: randomUUID(), - bookingId: booking.id, - status: 'DELIVERED', - advancedPayment: demo.totalAmount, - remainingPayment: 0, - estimatedKm: demo.tradeDirection === 'IMPORT' ? 18 : 14, - exactKm: demo.tradeDirection === 'IMPORT' ? 17.5 : 13.8, - vehicleId: null, - }); - } - } - - const savedBookings = await bookingRepo.find({ where: { reference: In(references) } }); - return new Map(savedBookings.map((booking) => [booking.reference, booking])); - } - - private async ensureTrainLinks( - manager: EntityManager, - refs: Awaited>, - schedules: Map, - bookings: Map, - ): Promise { - const scheduleBookingRepo = manager.getRepository(TrainScheduleBooking); - const trainSetWagonRepo = manager.getRepository(TrainSetWagon); - const allocationRepo = manager.getRepository(WagonBookingAllocation); - const containerItemRepo = manager.getRepository(WagonAllocationContainerItem); - const wagonCapacity = Number(refs.wagonType.capacityTons) || 70; - const wagonLength = Number(refs.wagonType.lengthMeters) || 14; - const tareWeight = Number(refs.wagonType.tareWeightTons) || 14; - - for (const demo of TRAIN_DEMO_BOOKINGS) { - const schedule = schedules.get(demo.trainNumber); - const booking = bookings.get(demo.reference); - if (!schedule || !booking) continue; - - const trainBookings = TRAIN_DEMO_BOOKINGS.filter((item) => item.trainNumber === demo.trainNumber); - const sequence = trainBookings.findIndex((item) => item.reference === demo.reference) + 1; - const wagon = await this.ensureWagon(manager, { - wagonNumber: `${demo.trainNumber}-W${String(sequence).padStart(2, '0')}`, - wagonTypeId: refs.wagonType.id, - yardId: schedule.destinationStationId, - trainScheduleId: schedule.id, - trainSetWagonId: null, - tareWeight, - capacityTons: wagonCapacity, - }); - - let trainSetWagon = await trainSetWagonRepo.findOne({ - where: { trainSetId: schedule.trainSetId, sequenceNo: sequence }, - }); - trainSetWagon = await trainSetWagonRepo.save( - trainSetWagonRepo.create({ - ...(trainSetWagon ? { id: trainSetWagon.id } : {}), - trainSetId: schedule.trainSetId, - wagonTypeId: refs.wagonType.id, - physicalWagonId: wagon.id, - sequenceNo: sequence, - capacityTons: wagonCapacity, - lengthMeters: wagonLength, - assignedWeightTons: demo.weightTons, - status: 'DEPARTED', - }), - ); - - await manager.getRepository(Wagon).update(wagon.id, { - trainSetWagonId: trainSetWagon.id, - currentTrainScheduleId: schedule.id, - currentYardId: schedule.destinationStationId, - status: WagonStatus.Assigned, - }); - - const allocation = await allocationRepo.save( - allocationRepo.create({ - trainSetWagonId: trainSetWagon.id, - bookingId: booking.id, - allocatedWeightTons: demo.weightTons, - loadType: demo.freightType, - status: 'DEPARTED', - confirmedAt: schedule.actualDepartureAt ?? new Date(), - }), - ); - - if (demo.freightType === 'CONTAINER') { - const bookingContainer = await manager.getRepository(BookingContainer).findOne({ - where: { bookingId: booking.id }, - }); - const containerType = demo.containerCode ? refs.containerTypes.get(demo.containerCode) : null; - await containerItemRepo.insert({ - id: randomUUID(), - wagonBookingAllocationId: allocation.id, - bookingContainerId: bookingContainer?.id ?? null, - containerNumber: this.containerNumber(demo.reference), - containerTypeId: containerType?.id ?? null, - positionOnWagon: 1, - sealNumber: `SEAL-${demo.reference}`, - chassisNumber: `CHS-${demo.reference}`, - grossWeightTons: demo.weightTons, - }); - } - - await scheduleBookingRepo.insert({ - id: randomUUID(), - trainScheduleId: schedule.id, - bookingId: booking.id, - }); - } - } - - private async ensureGatepasses( - manager: EntityManager, - schedules: Map, - ): Promise { - const repo = manager.getRepository(ImportDjiboutiOperation); - const securedAt = this.addHours(new Date(), -20); - - for (const schedule of schedules.values()) { - const existing = await repo.findOne({ where: { trainScheduleId: schedule.id } }); - await repo.save( - repo.create({ - ...(existing ? { id: existing.id } : {}), - trainScheduleId: schedule.id, - documents: { - ...(existing?.documents ?? {}), - GATE_PASS: { - reference: `GP-${schedule.trainNumber}`, - uploadedAt: securedAt.toISOString(), - uploadedBy: 'PaidIndodeDemoBookingsSeeder', - notes: 'Seeded secured gate pass for import/export Djibouti port entry testing.', - }, - }, - gatepassGrantedAt: securedAt, - performedBy: 'PaidIndodeDemoBookingsSeeder', - notes: 'Seeded SECURED gate pass for US12 warehouse workflow testing.', - }), - ); - } - } - - private async ensureImportWarehouseInventory( - manager: EntityManager, - bookings: Map, - ): Promise { - const warehouse = await manager.getRepository(Warehouse).findOne({ where: { code: 'INDODE_OPEN' } }); - if (!warehouse) { - this.logger.warn('INDODE_OPEN warehouse missing; skipping US12 import warehouse inventory seed'); - return; - } - - for (const demo of TRAIN_DEMO_BOOKINGS.filter((booking) => booking.tradeDirection === 'IMPORT')) { - const booking = bookings.get(demo.reference); - if (!booking) continue; - - const yard = await this.findWarehouseYard(manager, warehouse.id, demo.freightType); - if (!yard) { - this.logger.warn(`No warehouse yard found for ${warehouse.code}; skipping ${demo.reference}`); - continue; - } - const zone = await manager.getRepository(WarehouseZone).findOne({ where: { yardId: yard.id } }); - if (!zone) { - this.logger.warn(`No warehouse zone found for ${yard.code}; skipping ${demo.reference}`); - continue; - } - - const arrivedAt = this.addHours(new Date(), -Number(demo.trainNumber.includes('LM') ? 7 : 13)); - const grnNumber = `GRN-IMP-${demo.reference.replace(/[^A-Z0-9]/g, '')}`; - const saved = await manager.getRepository(WarehouseInventory).save( - manager.getRepository(WarehouseInventory).create({ - warehouseId: warehouse.id, - yardId: yard.id, - zoneId: zone.id, - bookingId: booking.id, - quantity: demo.freightType === 'CONTAINER' ? 1 : 1, - weight: demo.weightTons, - volume: null, - grnNumber, - status: 'UNLOADED', - inspectionStatus: null, - arrivedAt, - unloadedAt: arrivedAt, - notes: [ - `GRN Number: ${grnNumber}`, - 'Direction: IMPORT', - `Train: ${demo.trainNumber}`, - `Seeded For: ${demo.withLastMile ? 'Import with last mile' : 'Import terminal pickup / no last mile'}`, - 'Seeded by PaidIndodeDemoBookingsSeeder for Receive at Warehouse testing.', - ].join('\n'), - }), - ); - - await manager.getRepository(WarehouseActivityLog).save( - manager.getRepository(WarehouseActivityLog).create({ - inventoryId: saved.id, - warehouseId: warehouse.id, - activityType: 'INVENTORY_UNLOADED', - description: `Seeded import train arrival ${demo.trainNumber} into warehouse queue`, - performedBy: 'PaidIndodeDemoBookingsSeeder', - }), - ); - } - } - - private async findWarehouseYard( - manager: EntityManager, - warehouseId: string, - freightType: string, - ): Promise { - const preferredType = freightType === 'CONTAINER' ? 'CONTAINER_YARD' : 'BULK_YARD'; - return ( - (await manager.getRepository(WarehouseYard).findOne({ - where: { warehouseId, type: preferredType as any }, - })) ?? - (await manager.getRepository(WarehouseYard).findOne({ - where: { warehouseId }, - })) - ); - } - - private async deleteBookingTrainChildren(manager: EntityManager, bookingIds: string[]): Promise { - const allocationRepo = manager.getRepository(WagonBookingAllocation); - const allocations = await allocationRepo.find({ - where: { bookingId: In(bookingIds) }, - select: { id: true }, - }); - const allocationIds = allocations.map((allocation) => allocation.id); - if (allocationIds.length) { - await manager.getRepository(WagonAllocationContainerItem).delete({ - wagonBookingAllocationId: In(allocationIds), - }); - } - await allocationRepo.delete({ bookingId: In(bookingIds) }); - await manager.getRepository(TrainScheduleBooking).delete({ bookingId: In(bookingIds) }); - } - - private async ensureLocomotive( - manager: EntityManager, - currentYardId: string, - ): Promise { - const repo = manager.getRepository(Locomotive); - const existing = await repo.findOne({ where: { code: 'US12-DEMO-LOCO' } }); - if (existing) { - await repo.update(existing.id, { currentYardId, status: 'AVAILABLE' }); - return { ...existing, currentYardId, status: 'AVAILABLE' }; - } - - return repo.save( - repo.create({ - code: 'US12-DEMO-LOCO', - name: 'US12 Demo Locomotive', - locomotiveType: 'DIESEL', - maxPullWeightTons: 4200, - maxTrainLengthMeters: 760, - status: 'AVAILABLE', - currentYardId, - }), - ); - } - - private async ensureTrainSet( - manager: EntityManager, - trainNumber: string, - locomotiveId: string, - ): Promise { - const schedule = await manager.getRepository(TrainSchedule).findOne({ - where: { trainNumber }, - }); - if (schedule) { - const existing = await manager.getRepository(TrainSet).findOneByOrFail({ - id: schedule.trainSetId, - }); - await manager.getRepository(TrainSet).update(existing.id, { - locomotiveId, - status: 'COMPLETED', - }); - return { ...existing, locomotiveId, status: 'COMPLETED' }; - } - - return manager.getRepository(TrainSet).save( - manager.getRepository(TrainSet).create({ - locomotiveId, - totalWeightTons: 0, - totalLengthMeters: 0, - wagonCount: 0, - status: 'COMPLETED', - }), - ); - } - - private async ensureTrainSchedule( - manager: EntityManager, - input: { - trainNumber: string; - trainSetId: string; - originStationId: string; - destinationStationId: string; - scheduledDepartureDate: Date; - scheduledArrivalDate: Date; - actualDepartureAt: Date; - actualArrivalAt: Date; - direction: 'IMPORT' | 'EXPORT'; - }, - ): Promise { - const repo = manager.getRepository(TrainSchedule); - const existing = await repo.findOne({ where: { trainNumber: input.trainNumber } }); - const nextSchedule = repo.create({ - ...(existing ? { id: existing.id } : {}), - trainSetId: input.trainSetId, - originStationId: input.originStationId, - destinationStationId: input.destinationStationId, - scheduledDepartureDate: input.scheduledDepartureDate, - scheduledArrivalDate: input.scheduledArrivalDate, - actualDepartureAt: input.actualDepartureAt, - actualArrivalAt: input.actualArrivalAt, - status: TrainScheduleStatus.Arrived, - trainNumber: input.trainNumber, - direction: input.direction, - maxWagons: 53, - bookingWindowStatus: 'CLOSED', - }); - return repo.save(nextSchedule); - } - - private async ensureWagon( - manager: EntityManager, - input: { - wagonNumber: string; - wagonTypeId: string; - yardId: string; - trainScheduleId: string; - trainSetWagonId: string | null; - tareWeight: number; - capacityTons: number; - }, - ): Promise { - const repo = manager.getRepository(Wagon); - const existing = await repo.findOne({ where: { wagonNumber: input.wagonNumber } }); - return repo.save( - repo.create({ - ...(existing ? { id: existing.id } : {}), - wagonNumber: input.wagonNumber, - wagonTypeId: input.wagonTypeId, - currentYardId: input.yardId, - currentTrainScheduleId: input.trainScheduleId, - trainSetWagonId: input.trainSetWagonId, - tareWeight: input.tareWeight, - maxPayloadWeight: input.capacityTons, - status: WagonStatus.Assigned, - notes: 'US12 paid Indode demo seed wagon', - }), - ); - } - - private async ensureFirstMileVehicle(manager: EntityManager): Promise { - const driverRepo = manager.getRepository(Driver); - const vehicleRepo = manager.getRepository(Vehicle); - const licenseNumber = 'US12-FM-LIC-001'; - const plateNumber = 'ET-FM-1201'; - - await driverRepo.upsert( - { - licenseNumber, - firstName: 'Tesfaye', - lastName: 'Firstmile', - email: 'tesfaye.firstmile@edr.local', - phoneNumber: '251911120120', - licenseExpiryDate: this.addHours(new Date(), 24 * 365), - status: DriverStatus.ACTIVE, - vehicleTypesAuthorized: [VehicleType.TRUCK, VehicleType.FLATBED], - notes: 'Seeded first-mile driver for US12 receive-to-warehouse testing', - }, - { conflictPaths: { licenseNumber: true } }, - ); - const driver = await driverRepo.findOneByOrFail({ licenseNumber }); - - await vehicleRepo.upsert( - { - plateNumber, - registrationNumber: 'US12-FM-REG-001', - vehicleType: VehicleType.TRUCK, - manufacturer: 'Sinotruk', - model: 'HOWO Container Carrier', - year: 2024, - fuelType: FuelType.DIESEL, - capacity: 40, - status: VehicleStatus.ACTIVE, - assignedDriverId: driver.id, - assignedDriverName: `${driver.firstName} ${driver.lastName}`, - description: 'Seeded first-mile truck for US12 receive-to-warehouse testing', - estimatedDistanceKm: 35, - actualDistanceKm: 34.6, - }, - { conflictPaths: { plateNumber: true } }, - ); - const vehicle = await vehicleRepo.findOneByOrFail({ plateNumber }); - await manager.query( - `UPDATE freight.vehicles - SET trailer_plate_no = $2, - assigned_driver_id = $3, - assigned_driver_name = $4, - updated_at = NOW() - WHERE id = $1`, - [vehicle.id, 'ET-TRL-1201', driver.id, `${driver.firstName} ${driver.lastName}`], - ); - return vehicleRepo.findOneByOrFail({ plateNumber }); - } - - private containerNumber(reference: string): string { - const suffix = reference.replace(/[^A-Z0-9]/g, '').slice(-7); - return `US12${suffix}`; - } - - private addHours(date: Date, hours: number): Date { - return new Date(date.getTime() + hours * 60 * 60 * 1000); - } -} diff --git a/apps/edr-freight-web/portal/src/pages/billing/InvoiceDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/billing/InvoiceDetailPage.tsx index 888a09c1a..a06c4c775 100644 --- a/apps/edr-freight-web/portal/src/pages/billing/InvoiceDetailPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/billing/InvoiceDetailPage.tsx @@ -130,11 +130,6 @@ export default function InvoiceDetailPage() { const amountDue = Number(invoice.balanceAmount ?? invoice.totalAmount); const handlePay = () => { -<<<<<<< HEAD - const returnUrl = `${window.location.origin}/payment/success`; - const failureUrl = `${window.location.origin}/payment/failure`; - payMutation.mutate({ id, payload: { method: paymentMethod, returnUrl, failureUrl } }); -======= setPayModalOpen(true); }; @@ -184,7 +179,6 @@ export default function InvoiceDetailPage() { } toast.error("This invoice's source isn't linked to a booking."); } ->>>>>>> 03740ee719f22f9617379a652b26f13b6870f671 }; return ( @@ -214,20 +208,6 @@ export default function InvoiceDetailPage() { -<<<<<<< HEAD - {payable && ( - -