From e0010695be5f81c894ea1f471fb2d8131c050cdc Mon Sep 17 00:00:00 2001 From: natib21 Date: Thu, 2 Jul 2026 09:41:52 +0000 Subject: [PATCH 01/86] fix --- .../src/pages/operations/FirstMilePage.tsx | 73 ++++++++++--------- .../src/pages/operations/LastMilePage.tsx | 65 +++++++++-------- 2 files changed, 72 insertions(+), 66 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx index 35618fa3b..01e5ded57 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx @@ -122,42 +122,45 @@ const InfoRow = ({ label, value }: { label: string; value: string }) => ( ); -const BookingInfo = ({ record }: { record: FirstMileRecord }) => ( - - - - {bookingRef(record)} - - - {STATUS_META[record.status].label} - - - {isAssigned(record) ? "Assigned" : "Unassigned"} - +const BookingInfo = ({ record }: { record: FirstMileRecord }) => { + const hasPickupAddress = record.booking?.firstMilePickupAddress != null; + return ( + + + + {bookingRef(record)} + + + {STATUS_META[record.status].label} + + + {isAssigned(record) ? "Assigned" : "Unassigned"} + + - - - - - - - - - - - - - - - - - - -); + + + + {hasPickupAddress && } + + + + + + + + + + + + + + ); +}; const tripSlipRows = (record: FirstMileRecord): [string, string][] => [ ["Customer", customerName(record)], diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index 70d9e9105..e85fbfd2d 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -117,38 +117,41 @@ const InfoRow = ({ label, value }: { label: string; value: string }) => ( ); -const BookingInfo = ({ record }: { record: LastMileRecord }) => ( - - - - {bookingRef(record)} - - - {STATUS_META[record.status].label} - - - {isAssigned(record) ? "Assigned" : "Unassigned"} - +const BookingInfo = ({ record }: { record: LastMileRecord }) => { + const hasDeliveryAddress = record.booking?.lastMileDeliveryAddress != null; + return ( + + + + {bookingRef(record)} + + + {STATUS_META[record.status].label} + + + {isAssigned(record) ? "Assigned" : "Unassigned"} + + - - - - - - - - - - - - - - - - - - -); + + + + + {hasDeliveryAddress && } + + + + + + + + + + + + + ); +}; const tripSlipRows = (record: LastMileRecord): [string, string][] => [ ["Customer", customerName(record)], From 10e442deb1a8a0a627fa2e97c6c79cdecf2b067b Mon Sep 17 00:00:00 2001 From: yaschalew Date: Thu, 2 Jul 2026 13:18:49 +0300 Subject: [PATCH 02/86] fix --- .../src/modules/first-mile/first-mile.controller.ts | 6 ++++++ .../src/modules/first-mile/first-mile.service.ts | 11 ++++------- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts index 680bb5d09..d5f53ae0c 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts @@ -65,6 +65,12 @@ export class FirstMileController { return this.firstMileService.findById(id); } + @Get('acceptitem/:id') + @ApiOperation({ summary: 'Get a first-mile accep by ID' }) + acceptItem(@Param('id', ParseUUIDPipe) id: string) { + return this.firstMileService.acceptBooking(id); + } + @Post('accept/:reference') @TrainSchedulingManage() @ApiOperation({ summary: 'Accept a paid booking and create a first-mile leg' }) diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts index ae0ada831..a7a4f9100 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts @@ -43,7 +43,7 @@ export class FirstMileService { private readonly vehiclesService: VehiclesService, private readonly driversService: DriversService, private readonly smsClient: SmsClientService, - ) {} + ) { } /** * Look up a booking by its human-readable reference and confirm it has been @@ -95,7 +95,7 @@ export class FirstMileService { if (booking.paymentStatus !== 'PAID') { throw new BadRequestException(`Booking ${label} is not paid`); } - + console.log("-------------------", booking) if (!this.bookingRequestsFirstMile(booking)) { throw new BadRequestException(`Booking ${label} does not require a first mile`); } @@ -212,11 +212,8 @@ export class FirstMileService { }): boolean { // Export bookings always need a first mile (pickup → origin yard); the // pickup address is captured at assignment time, not required upfront. - return Boolean( - booking.tradeDirection === 'EXPORT' || - booking.firstMilePickupAddress?.trim() || - booking.serviceType?.includesFirstMile, - ); + return Boolean(booking.firstMilePickupAddress?.trim() || + booking.serviceType?.includesFirstMile); } async update(id: string, dto: UpdateFirstMileDto): Promise { From 1c88bd8d686b4b97cd5d26d39f0aa3a7bcef3adb Mon Sep 17 00:00:00 2001 From: natib21 Date: Thu, 2 Jul 2026 10:27:01 +0000 Subject: [PATCH 03/86] fix --- .../backoffice/src/pages/operations/FirstMilePage.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx index 01e5ded57..9f6e82d03 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx @@ -469,9 +469,14 @@ const FirstMilePage = () => { const firstMileEligiblePaidBookings = useMemo( () => paidBookings.filter( - (booking) => - booking.tradeDirection === "EXPORT" && - !existingFirstMileBookingIds.has(booking.id), + (booking) => { + if (existingFirstMileBookingIds.has(booking.id)) return false; + return ( + booking.tradeDirection === "EXPORT" || + (booking.firstMilePickupAddress?.trim() ?? false) || + booking.serviceType?.includesFirstMile + ); + }, ), [existingFirstMileBookingIds, paidBookings], ); From 4519a1d75d4b7dc09646cec9be45404429a7d863 Mon Sep 17 00:00:00 2001 From: natib21 Date: Thu, 2 Jul 2026 10:33:32 +0000 Subject: [PATCH 04/86] fix --- .../src/modules/first-mile/first-mile.service.ts | 9 +++++---- .../backoffice/src/pages/operations/FirstMilePage.tsx | 10 +++++----- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts index a7a4f9100..bc8801067 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts @@ -210,10 +210,11 @@ export class FirstMileService { firstMilePickupAddress?: string | null; serviceType?: { includesFirstMile?: boolean | null } | null; }): boolean { - // Export bookings always need a first mile (pickup → origin yard); the - // pickup address is captured at assignment time, not required upfront. - return Boolean(booking.firstMilePickupAddress?.trim() || - booking.serviceType?.includesFirstMile); + return Boolean( + booking.tradeDirection === 'EXPORT' && + (booking.firstMilePickupAddress?.trim() || + booking.serviceType?.includesFirstMile), + ); } async update(id: string, dto: UpdateFirstMileDto): Promise { diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx index 9f6e82d03..93e3e51b0 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx @@ -471,11 +471,11 @@ const FirstMilePage = () => { paidBookings.filter( (booking) => { if (existingFirstMileBookingIds.has(booking.id)) return false; - return ( - booking.tradeDirection === "EXPORT" || - (booking.firstMilePickupAddress?.trim() ?? false) || - booking.serviceType?.includesFirstMile - ); + if (booking.paymentStatus !== "PAID") return false; + if (booking.tradeDirection !== "EXPORT") return false; + const hasPickupAddress = booking.firstMilePickupAddress?.trim() ?? false; + const includesFirstMile = booking.serviceType?.includesFirstMile ?? false; + return hasPickupAddress || includesFirstMile; }, ), [existingFirstMileBookingIds, paidBookings], From f4f98575f118216219c4dfbcda4eaf0dbab052f9 Mon Sep 17 00:00:00 2001 From: natib21 Date: Thu, 2 Jul 2026 10:48:31 +0000 Subject: [PATCH 05/86] fix error --- .../backoffice/src/pages/operations/LastMilePage.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index e85fbfd2d..9d54326e1 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -354,6 +354,10 @@ const LastMilePage = () => { }); const records = listData?.data ?? []; + const existingLastMileBookingIds = useMemo( + () => new Set(records.map((record) => record.bookingId)), + [records], + ); const vehicleOptions = useMemo( () => @@ -414,15 +418,16 @@ const LastMilePage = () => { const arrivalQueue = arrivalQueueData ?? []; const filteredArrivalQueue = useMemo(() => { + let filtered = arrivalQueue.filter((item) => !existingLastMileBookingIds.has(item.bookingId)); const term = arrivalSearch.trim().toLowerCase(); - if (!term) return arrivalQueue; - return arrivalQueue.filter((item) => + if (!term) return filtered; + return filtered.filter((item) => [item.bookingReference, item.customer, item.cargo, item.warehouse, item.yard] .join(" ") .toLowerCase() .includes(term), ); - }, [arrivalQueue, arrivalSearch]); + }, [arrivalQueue, arrivalSearch, existingLastMileBookingIds]); const acceptMutation = useMutation({ mutationFn: async ({ items, vehicleId }: { items: ArrivalQueueItem[]; vehicleId: string | null }) => { From e59a8f980244fa15f90944ffb548657f11a7dd15 Mon Sep 17 00:00:00 2001 From: natib21 Date: Thu, 2 Jul 2026 10:54:43 +0000 Subject: [PATCH 06/86] fix delete api --- .../src/pages/operations/FirstMilePage.tsx | 29 +++++++++++++++++++ .../src/pages/operations/LastMilePage.tsx | 29 +++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx index 93e3e51b0..198fe50a3 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx @@ -7,6 +7,7 @@ import { Printer, RefreshCw, Ruler, + Trash, Truck, } from "lucide-react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; @@ -420,6 +421,17 @@ const FirstMilePage = () => { }, }); + const deleteMutation = useMutation({ + mutationFn: (id: string) => firstMileService.remove(id), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.list() }); + toast({ title: "Record deleted", description: "First-mile record removed successfully." }); + }, + onError: () => { + toast({ title: "Delete failed", variant: "destructive" }); + }, + }); + const acceptMutation = useMutation({ mutationFn: async ({ reference, vehicleId }: { reference: string; vehicleId: string | null }) => { const res = await firstMileService.accept(reference); @@ -837,6 +849,7 @@ const FirstMilePage = () => { const assigned = isAssigned(row.original); const nextStatus = NEXT_STATUS[row.original.status]; const canPrint = row.original.status !== "PAYMENT_PENDING"; + const isPaid = (row.original as any).paid; return ( @@ -889,6 +902,22 @@ const FirstMilePage = () => { Print trip slip )} + {!isPaid && ( + <> + + } + color="red" + onClick={() => { + if (confirm(`Delete first-mile record ${bookingRef(row.original)}?`)) { + deleteMutation.mutate(row.original.id); + } + }} + > + Delete + + + )} diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index 9d54326e1..eb00c0904 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -6,6 +6,7 @@ import { Printer, RefreshCw, Ruler, + Trash, Truck, } from "lucide-react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; @@ -397,6 +398,17 @@ const LastMilePage = () => { }, }); + const deleteMutation = useMutation({ + mutationFn: (id: string) => lastMileService.remove(id), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.list() }); + toast({ title: "Record deleted", description: "Last-mile record removed successfully." }); + }, + onError: () => { + toast({ title: "Delete failed", variant: "destructive" }); + }, + }); + const allocateMutation = useMutation({ mutationFn: (data: Array<{ containerId: string; vehicleId: string }>) => api.post(`/last-mile/${activeId}/allocate-containers`, data), @@ -817,6 +829,7 @@ const LastMilePage = () => { const assigned = isAssigned(row.original); const nextStatus = NEXT_STATUS[row.original.status]; const canPrint = row.original.status !== "PAYMENT_PENDING"; + const isPaid = (row.original as any).paid; return ( @@ -869,6 +882,22 @@ const LastMilePage = () => { Print trip slip )} + {!isPaid && ( + <> + + } + color="red" + onClick={() => { + if (confirm(`Delete last-mile record ${bookingRef(row.original)}?`)) { + deleteMutation.mutate(row.original.id); + } + }} + > + Delete + + + )} From bab329e8e5f14bf20ce6771169f4be4708ea26b1 Mon Sep 17 00:00:00 2001 From: natib21 Date: Thu, 2 Jul 2026 10:57:35 +0000 Subject: [PATCH 07/86] fix --- .../backoffice/src/services/first-mile.service.ts | 4 +++- .../backoffice/src/services/last-mile.service.ts | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/services/first-mile.service.ts b/apps/edr-freight-web/backoffice/src/services/first-mile.service.ts index b182a16d0..1c81f5dc5 100644 --- a/apps/edr-freight-web/backoffice/src/services/first-mile.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/first-mile.service.ts @@ -60,8 +60,10 @@ export const firstMileService = { list: (pageSize = 1000) => api.get(`${FM.BASE}?pageSize=${pageSize}`), getById: (id: string) => api.get(FM.BY_ID(id)), - update: (id: string, data: { status?: FirstMileApiStatus; vehicleId?: string | null; estimatedKm?: number | null; exactKm?: number | null }) => + update: (id: string, data: { status?: FirstMileApiStatus; vehicleId?: string | null; estimatedKm?: number | null; exactKm?: number | null; paid?: boolean }) => api.patch(FM.BY_ID(id), data), accept: (bookingReference: string) => api.post(FM.ACCEPT(bookingReference)), + remove: (id: string) => + api.delete(FM.BY_ID(id)), }; diff --git a/apps/edr-freight-web/backoffice/src/services/last-mile.service.ts b/apps/edr-freight-web/backoffice/src/services/last-mile.service.ts index cb9e61b52..158056e46 100644 --- a/apps/edr-freight-web/backoffice/src/services/last-mile.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/last-mile.service.ts @@ -60,8 +60,10 @@ export const lastMileService = { list: (pageSize = 1000) => api.get(`${LM.BASE}?pageSize=${pageSize}`), getById: (id: string) => api.get(LM.BY_ID(id)), - update: (id: string, data: { status?: LastMileApiStatus; vehicleId?: string | null; estimatedKm?: number | null; exactKm?: number | null }) => + update: (id: string, data: { status?: LastMileApiStatus; vehicleId?: string | null; estimatedKm?: number | null; exactKm?: number | null; paid?: boolean }) => api.patch(LM.BY_ID(id), data), accept: (bookingReference: string) => api.post(LM.ACCEPT(encodeURIComponent(bookingReference))), + remove: (id: string) => + api.delete(LM.BY_ID(id)), }; From ff7e20d2ee3a1cf25c038ae60d5d829104fbae69 Mon Sep 17 00:00:00 2001 From: natib21 Date: Thu, 2 Jul 2026 11:22:35 +0000 Subject: [PATCH 08/86] fix --- .../1870000000000-AddLocationToVehicles.ts | 22 +++++++++++++++++++ .../vehicles/entities/vehicle.entity.ts | 3 +++ .../src/pages/fleet/config/vehicles.ts | 2 ++ .../src/services/vehicles.service.ts | 3 ++- 4 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 apps/edr-freight-api/src/migrations/1870000000000-AddLocationToVehicles.ts diff --git a/apps/edr-freight-api/src/migrations/1870000000000-AddLocationToVehicles.ts b/apps/edr-freight-api/src/migrations/1870000000000-AddLocationToVehicles.ts new file mode 100644 index 000000000..3434631a2 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1870000000000-AddLocationToVehicles.ts @@ -0,0 +1,22 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Add location_id column to vehicles table to track vehicle base location. + */ +export class AddLocationToVehicles1870000000000 implements MigrationInterface { + name = "AddLocationToVehicles1870000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.vehicles + ADD COLUMN IF NOT EXISTS location_id uuid; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.vehicles + DROP COLUMN IF EXISTS location_id; + `); + } +} diff --git a/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts b/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts index fef078ef8..1e4121802 100644 --- a/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts +++ b/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts @@ -68,4 +68,7 @@ export class Vehicle extends BaseEntity { @Column({ name: 'actual_distance_km', type: 'numeric', nullable: true }) actualDistanceKm?: number; + + @Column({ name: 'location_id', type: 'uuid', nullable: true }) + locationId?: string; } diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts b/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts index 92da9b9dc..648c0579d 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts @@ -74,6 +74,7 @@ export const vehiclesConfig: FleetResourceConfig = { { name: "year", label: "Year", type: "number", required: true }, { name: "fuelType", label: "Fuel Type", type: "select", required: true, options: FUEL_TYPE_OPTIONS }, { name: "capacity", label: "Capacity", type: "number", required: true }, + { name: "locationId", label: "Location", type: "select", dataSource: "yards" }, { name: "estimatedDistanceKm", label: "Estimated Distance (KM)", type: "number" }, { name: "actualDistanceKm", label: "Actual Distance (KM)", type: "number" }, { name: "status", label: "Status", type: "select", required: true, options: VEHICLE_STATUS_OPTIONS }, @@ -90,6 +91,7 @@ export const vehiclesConfig: FleetResourceConfig = { year: new Date().getFullYear(), fuelType: "DIESEL", capacity: 0, + locationId: null, estimatedDistanceKm: "", actualDistanceKm: "", status: "ACTIVE", diff --git a/apps/edr-freight-web/backoffice/src/services/vehicles.service.ts b/apps/edr-freight-web/backoffice/src/services/vehicles.service.ts index b9d5129e3..3cb2ad358 100644 --- a/apps/edr-freight-web/backoffice/src/services/vehicles.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/vehicles.service.ts @@ -29,6 +29,7 @@ export interface Vehicle { code?: string | null; powerPlateNo?: string | null; trailerPlateNo?: string | null; + locationId?: string | null; createdAt: string; updatedAt: string; } @@ -55,7 +56,7 @@ export const vehiclesService = { getById: (id: string) => apiClient.get(URL_CONSTANTS.VEHICLES.BY_ID(id)), create: (data: Partial) => apiClient.post(URL_CONSTANTS.VEHICLES.BASE, data), - update: (id: string, data: Partial) => + update: (id: string, data: Partial) => apiClient.patch(URL_CONSTANTS.VEHICLES.BY_ID(id), data), delete: (id: string) => apiClient.delete(URL_CONSTANTS.VEHICLES.BY_ID(id)), }; From adde5962895699d5ef23e986adb83873c88a04b7 Mon Sep 17 00:00:00 2001 From: natib21 Date: Thu, 2 Jul 2026 11:32:16 +0000 Subject: [PATCH 09/86] fix --- .../backoffice/src/pages/fleet/config/vehicles.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts b/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts index 648c0579d..6ac04e7aa 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts @@ -74,7 +74,7 @@ export const vehiclesConfig: FleetResourceConfig = { { name: "year", label: "Year", type: "number", required: true }, { name: "fuelType", label: "Fuel Type", type: "select", required: true, options: FUEL_TYPE_OPTIONS }, { name: "capacity", label: "Capacity", type: "number", required: true }, - { name: "locationId", label: "Location", type: "select", dataSource: "yards" }, + { name: "locationId", label: "Location", type: "select", dynamicOptions: "yards" }, { name: "estimatedDistanceKm", label: "Estimated Distance (KM)", type: "number" }, { name: "actualDistanceKm", label: "Actual Distance (KM)", type: "number" }, { name: "status", label: "Status", type: "select", required: true, options: VEHICLE_STATUS_OPTIONS }, From 787da1ccc08d009fba9fa79bbf7736d161c7e6f6 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Thu, 2 Jul 2026 12:35:43 +0000 Subject: [PATCH 10/86] chore: fix the invoice event --- .../src/modules/billing/billing.service.ts | 8 +++++++- .../src/modules/bookings/booking-invoice.service.ts | 9 ++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index 278d4cca9..b68db5974 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -691,7 +691,13 @@ export class BillingService { status: invoice.status, paymentId: invoice.paymentId ?? null, }; - this.events.emit(`${invoice.source}.invoice.${event}`, payload); + this.events + .emitAsync(`${invoice.source}.invoice.${event}`, payload) + .catch((err) => + this.logger.error( + `Listener for ${invoice.source}.invoice.${event} (invoice ${invoice.id}) failed: ${err instanceof Error ? err.message : String(err)}`, + ), + ); } // ── Payment reconciliation (by source) ─────────────────────────────────────── 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..30f41813b 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 @@ -143,9 +143,16 @@ export class BookingInvoiceService { { id: bookingId }, { paymentStatus: "PAID", status: "PAID" }, ); - await this.firstMile.acceptBooking(bookingId); }); + try { + await this.firstMile.acceptBooking(bookingId); + } catch (err) { + this.logger.error( + `Error accepting first-mile after payment: ${err instanceof Error ? err.message : String(err)}`, + ); + } + try { await this.bookingBatch.ensurePaidBookingAllocated(bookingId); } catch (err) { From a0f4de05f859113cb26699b7d4ca7fb8d06afaa0 Mon Sep 17 00:00:00 2001 From: natib21 Date: Thu, 2 Jul 2026 12:37:02 +0000 Subject: [PATCH 11/86] log full error --- .../src/modules/vehicles/dto/create-vehicle.dto.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts b/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts index b5f9abcb8..5651a3906 100644 --- a/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts +++ b/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts @@ -57,4 +57,8 @@ export class CreateVehicleDto { @IsOptional() @IsNumber() actualDistanceKm?: number; + + @IsOptional() + @IsUUID() + locationId?: string; } From 4098b5476fd39652c479fbfb569662ae2415fb0e Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Thu, 2 Jul 2026 15:37:49 +0300 Subject: [PATCH 12/86] fix(temp): invoice no for the telebirr --- .../edr-freight-api/src/modules/billing/billing.service.ts | 7 +++++-- .../src/modules/bookings/booking-invoice.service.ts | 5 +++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index b68db5974..baf3fa3f3 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -829,7 +829,10 @@ export class BillingService { ): Promise { const mg = manager ?? this.dataSource.manager; const invoice = await mg.findOne(Invoice, { - where: { id: invoiceId, status: In([Freight.InvoiceStatus.Draft, ...OPEN_STATUSES]) }, + where: { + id: invoiceId, + status: In([Freight.InvoiceStatus.Draft, ...OPEN_STATUSES]), + }, }); if (!invoice) return; await mg.update( @@ -882,7 +885,7 @@ export class BillingService { // in the domain via `${source}.invoice.paid`. Neither billing nor the payment // service branches on a domain-specific reference type. referenceType: PaymentReferenceType.SHIPMENT, - orderRef: invoice.invoiceNumber, + orderRef: invoice.invoiceNumber.replace("-", "_"), amountMinor: Math.round(Number(invoice.balanceAmount)), currency: invoice.currency, reason: `Payment for invoice ${invoice.invoiceNumber}`, 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 30f41813b..1b8eddeca 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 @@ -5,7 +5,6 @@ import { Injectable, Logger, } from "@nestjs/common"; -import { OnEvent } from "@nestjs/event-emitter"; import { Freight } from "@edr/types"; import { DataSource, EntityManager } from "typeorm"; @@ -95,8 +94,10 @@ export class BookingInvoiceService { * reactions live here (not in the payment process): each invoice type advances * the booking its own way. Only PREPAID exists today. */ - @OnEvent("booking.invoice.paid") async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise { + this.logger.log( + `onBookingInvoicePaid trigger for ${payload.sourceId} from ${payload.invoiceId}`, + ); switch (payload.type) { case "PREPAID": await this.advanceBookingOnPayment(payload.sourceId); From 657f3bd2ab962b1b0ffb8210e641dcf474889fe6 Mon Sep 17 00:00:00 2001 From: natib21 Date: Thu, 2 Jul 2026 12:39:47 +0000 Subject: [PATCH 13/86] fix --- .../src/pages/fleet/config/vehicles.ts | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts b/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts index 6ac04e7aa..6f65224bc 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts @@ -49,18 +49,11 @@ export const vehiclesConfig: FleetResourceConfig = { columns: [ { id: "code", header: "Code", accessorKey: "code", format: "code", size: 110 }, { id: "plateNumber", header: "Plate Number", accessorKey: "plateNumber", format: "code", size: 130 }, - { id: "powerPlateNo", header: "Power Plate No", accessorKey: "powerPlateNo", format: "code", size: 140 }, - { id: "trailerPlateNo", header: "Trailer Plate No", accessorKey: "trailerPlateNo", format: "code", size: 140 }, - { id: "registrationNumber", header: "Registration", accessorKey: "registrationNumber", format: "code", size: 140 }, - { id: "manufacturer", header: "Manufacturer", accessorKey: "manufacturer", format: "code", size: 140 }, - { id: "model", header: "Model", accessorKey: "model", format: "code", size: 120 }, - { id: "vehicleType", header: "Type", accessorKey: "vehicleType", format: "code", size: 100 }, - { id: "year", header: "Year", accessorKey: "year", format: "number", size: 80 }, - { id: "fuelType", header: "Fuel Type", accessorKey: "fuelType", format: "code", size: 110 }, - { id: "capacity", header: "Capacity (tons)", accessorKey: "capacity", format: "number", size: 130 }, - { id: "assignedDriverName", header: "Assigned Driver", accessorKey: "assignedDriverName", format: "code", size: 140 }, - { id: "estimatedDistanceKm", header: "Est. Distance (KM)", accessorKey: "estimatedDistanceKm", format: "number", size: 150 }, - { id: "actualDistanceKm", header: "Actual Distance (KM)", accessorKey: "actualDistanceKm", format: "number", size: 150 }, + { id: "manufacturer", header: "Manufacturer", accessorKey: "manufacturer", format: "code", size: 120 }, + { id: "model", header: "Model", accessorKey: "model", format: "code", size: 100 }, + { id: "vehicleType", header: "Type", accessorKey: "vehicleType", format: "code", size: 80 }, + { id: "capacity", header: "Capacity (tons)", accessorKey: "capacity", format: "number", size: 100 }, + { id: "locationId", header: "Location", accessorKey: "locationId", format: "code", size: 130 }, { id: "status", header: "Status", accessorKey: "status", format: "statusBadge", size: 100 }, ], formFields: [ From 58b3805d0bd4d60eb3f6e362adf2d92910a354eb Mon Sep 17 00:00:00 2001 From: natib21 Date: Thu, 2 Jul 2026 12:43:03 +0000 Subject: [PATCH 14/86] fix --- .../1880000000000-AddVehicleStatuses.ts | 22 +++++++++++++++++++ .../vehicles/entities/vehicle.entity.ts | 2 ++ .../src/components/fleet/fleetFormat.tsx | 2 ++ .../src/pages/fleet/FleetResourcePage.tsx | 1 + .../src/pages/fleet/config/vehicles.ts | 14 +++++++----- .../src/services/vehicles.service.ts | 2 +- 6 files changed, 36 insertions(+), 7 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/1880000000000-AddVehicleStatuses.ts diff --git a/apps/edr-freight-api/src/migrations/1880000000000-AddVehicleStatuses.ts b/apps/edr-freight-api/src/migrations/1880000000000-AddVehicleStatuses.ts new file mode 100644 index 000000000..29da135c3 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1880000000000-AddVehicleStatuses.ts @@ -0,0 +1,22 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Add FREE and BUSY statuses to vehicle status enum. + */ +export class AddVehicleStatuses1880000000000 implements MigrationInterface { + name = "AddVehicleStatuses1880000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TYPE freight.vehicles_status_enum ADD VALUE IF NOT EXISTS 'FREE' BEFORE 'MAINTENANCE'; + `); + await queryRunner.query(` + ALTER TYPE freight.vehicles_status_enum ADD VALUE IF NOT EXISTS 'BUSY' AFTER 'FREE'; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + // Note: Postgres cannot drop individual enum values, so the down migration is a no-op + // The enum values FREE and BUSY will remain but will be unused after downgrade + } +} diff --git a/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts b/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts index 1e4121802..a94210e65 100644 --- a/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts +++ b/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts @@ -20,6 +20,8 @@ export enum FuelType { export enum VehicleStatus { ACTIVE = 'ACTIVE', + FREE = 'FREE', + BUSY = 'BUSY', MAINTENANCE = 'MAINTENANCE', RETIRED = 'RETIRED', OUT_OF_SERVICE = 'OUT_OF_SERVICE', diff --git a/apps/edr-freight-web/backoffice/src/components/fleet/fleetFormat.tsx b/apps/edr-freight-web/backoffice/src/components/fleet/fleetFormat.tsx index 5c997af9f..709f288ce 100644 --- a/apps/edr-freight-web/backoffice/src/components/fleet/fleetFormat.tsx +++ b/apps/edr-freight-web/backoffice/src/components/fleet/fleetFormat.tsx @@ -25,6 +25,8 @@ export const formatFleetCell = ( const getStatusColor = (st: string): string => { const s = st.toUpperCase(); if (s === "ACTIVE" || s === "AVAILABLE") return "green"; + if (s === "FREE") return "teal"; + if (s === "BUSY") return "blue"; if (s === "INACTIVE") return "gray"; if (s === "SUSPENDED" || s === "OUT_OF_SERVICE") return "red"; if (s === "MAINTENANCE" || s === "ON_LEAVE") return "orange"; diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx index 615e706f5..5310a6f12 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx @@ -189,6 +189,7 @@ const FleetResourcePage = () => { registerFleetOptionLabels("wagonId", dynamicOptions.wagons); registerFleetOptionLabels("containerId", dynamicOptions.containers); registerFleetOptionLabels("currentYardId", dynamicOptions.yards); + registerFleetOptionLabels("locationId", dynamicOptions.yards); }, [dynamicOptions]); const formFields = useMemo((): FleetFormFieldDef[] => { diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts b/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts index 6f65224bc..3643e390f 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts @@ -19,6 +19,8 @@ const FUEL_TYPE_OPTIONS = [ const VEHICLE_STATUS_OPTIONS = [ { label: "Active", value: "ACTIVE" }, + { label: "Free", value: "FREE" }, + { label: "Busy", value: "BUSY" }, { label: "Maintenance", value: "MAINTENANCE" }, { label: "Retired", value: "RETIRED" }, { label: "Out of service", value: "OUT_OF_SERVICE" }, @@ -47,13 +49,13 @@ export const vehiclesConfig: FleetResourceConfig = { ], searchKeys: ["plateNumber", "registrationNumber", "manufacturer", "model", "vehicleType", "status"], columns: [ - { id: "code", header: "Code", accessorKey: "code", format: "code", size: 110 }, - { id: "plateNumber", header: "Plate Number", accessorKey: "plateNumber", format: "code", size: 130 }, - { id: "manufacturer", header: "Manufacturer", accessorKey: "manufacturer", format: "code", size: 120 }, - { id: "model", header: "Model", accessorKey: "model", format: "code", size: 100 }, - { id: "vehicleType", header: "Type", accessorKey: "vehicleType", format: "code", size: 80 }, + { id: "code", header: "Code", accessorKey: "code", size: 90 }, + { id: "plateNumber", header: "Plate Number", accessorKey: "plateNumber", size: 120 }, + { id: "manufacturer", header: "Manufacturer", accessorKey: "manufacturer", size: 120 }, + { id: "model", header: "Model", accessorKey: "model", size: 100 }, + { id: "vehicleType", header: "Type", accessorKey: "vehicleType", size: 75 }, { id: "capacity", header: "Capacity (tons)", accessorKey: "capacity", format: "number", size: 100 }, - { id: "locationId", header: "Location", accessorKey: "locationId", format: "code", size: 130 }, + { id: "locationId", header: "Location", accessorKey: "locationId", size: 140 }, { id: "status", header: "Status", accessorKey: "status", format: "statusBadge", size: 100 }, ], formFields: [ diff --git a/apps/edr-freight-web/backoffice/src/services/vehicles.service.ts b/apps/edr-freight-web/backoffice/src/services/vehicles.service.ts index 3cb2ad358..d5e9693d0 100644 --- a/apps/edr-freight-web/backoffice/src/services/vehicles.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/vehicles.service.ts @@ -3,7 +3,7 @@ import { URL_CONSTANTS } from '@/constants/URLS'; export type VehicleType = 'TRUCK' | 'VAN' | 'CAR' | 'BUS' | 'TRAILER' | 'TANKER' | 'FLATBED'; export type FuelType = 'PETROL' | 'DIESEL' | 'ELECTRIC' | 'HYBRID'; -export type VehicleStatus = 'ACTIVE' | 'MAINTENANCE' | 'RETIRED' | 'OUT_OF_SERVICE'; +export type VehicleStatus = 'ACTIVE' | 'FREE' | 'BUSY' | 'MAINTENANCE' | 'RETIRED' | 'OUT_OF_SERVICE'; export interface VehicleListFilters { status?: VehicleStatus; From 43a32f192fe4cb9c70d53066246637747866a313 Mon Sep 17 00:00:00 2001 From: natib21 Date: Thu, 2 Jul 2026 12:47:10 +0000 Subject: [PATCH 15/86] fix --- .../backoffice/src/pages/fleet/config/vehicles.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts b/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts index 3643e390f..1f1ae1412 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts @@ -49,8 +49,8 @@ export const vehiclesConfig: FleetResourceConfig = { ], searchKeys: ["plateNumber", "registrationNumber", "manufacturer", "model", "vehicleType", "status"], columns: [ - { id: "code", header: "Code", accessorKey: "code", size: 90 }, { id: "plateNumber", header: "Plate Number", accessorKey: "plateNumber", size: 120 }, + { id: "trailerPlateNo", header: "Trailer Plate No", accessorKey: "trailerPlateNo", size: 130 }, { id: "manufacturer", header: "Manufacturer", accessorKey: "manufacturer", size: 120 }, { id: "model", header: "Model", accessorKey: "model", size: 100 }, { id: "vehicleType", header: "Type", accessorKey: "vehicleType", size: 75 }, From c8b7d76073526ce1b65048ed8652b3100fd49b89 Mon Sep 17 00:00:00 2001 From: natib21 Date: Thu, 2 Jul 2026 12:50:49 +0000 Subject: [PATCH 16/86] fix --- .../backoffice/src/pages/fleet/config/vehicles.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts b/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts index 1f1ae1412..c6923d9fa 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts @@ -49,6 +49,7 @@ export const vehiclesConfig: FleetResourceConfig = { ], searchKeys: ["plateNumber", "registrationNumber", "manufacturer", "model", "vehicleType", "status"], columns: [ + { id: "code", header: "Code", accessorKey: "code", size: 90 }, { id: "plateNumber", header: "Plate Number", accessorKey: "plateNumber", size: 120 }, { id: "trailerPlateNo", header: "Trailer Plate No", accessorKey: "trailerPlateNo", size: 130 }, { id: "manufacturer", header: "Manufacturer", accessorKey: "manufacturer", size: 120 }, From 64e346324b81c0380061c73d004d27cbe393e0cd Mon Sep 17 00:00:00 2001 From: natib21 Date: Thu, 2 Jul 2026 13:11:12 +0000 Subject: [PATCH 17/86] fix issue --- .../src/migrations/1880000000000-AddVehicleStatuses.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/edr-freight-api/src/migrations/1880000000000-AddVehicleStatuses.ts b/apps/edr-freight-api/src/migrations/1880000000000-AddVehicleStatuses.ts index 29da135c3..ce9af151b 100644 --- a/apps/edr-freight-api/src/migrations/1880000000000-AddVehicleStatuses.ts +++ b/apps/edr-freight-api/src/migrations/1880000000000-AddVehicleStatuses.ts @@ -15,7 +15,7 @@ export class AddVehicleStatuses1880000000000 implements MigrationInterface { `); } - public async down(queryRunner: QueryRunner): Promise { + public async down(_queryRunner: QueryRunner): Promise { // Note: Postgres cannot drop individual enum values, so the down migration is a no-op // The enum values FREE and BUSY will remain but will be unused after downgrade } From e429a2cebff69900473b6bd96f0e74e472fc59e7 Mon Sep 17 00:00:00 2001 From: natib21 Date: Thu, 2 Jul 2026 13:15:44 +0000 Subject: [PATCH 18/86] fix --- ...8427600000-AddServiceTypesAndCargoTypes.ts | 33 +++++++++++-------- .../1880000000000-AddVehicleStatuses.ts | 15 ++++++--- 2 files changed, 30 insertions(+), 18 deletions(-) diff --git a/apps/edr-freight-api/src/migrations/1748427600000-AddServiceTypesAndCargoTypes.ts b/apps/edr-freight-api/src/migrations/1748427600000-AddServiceTypesAndCargoTypes.ts index 65052bad4..c3698aed6 100644 --- a/apps/edr-freight-api/src/migrations/1748427600000-AddServiceTypesAndCargoTypes.ts +++ b/apps/edr-freight-api/src/migrations/1748427600000-AddServiceTypesAndCargoTypes.ts @@ -93,20 +93,25 @@ export class AddServiceTypesAndCargoTypes1748427600000 implements MigrationInter ); // Create indexes for service_types - await queryRunner.createIndex( - "freight.service_types", - new TableIndex({ - name: "IDX_SERVICE_TYPES_IS_ACTIVE", - columnNames: ["is_active"], - }), - ); - await queryRunner.createIndex( - "freight.service_types", - new TableIndex({ - name: "IDX_SERVICE_TYPES_DISPLAY_ORDER", - columnNames: ["display_order"], - }), - ); + const table = await queryRunner.getTable("freight.service_types"); + if (table && !(await queryRunner.hasIndex("freight.service_types", "IDX_SERVICE_TYPES_IS_ACTIVE"))) { + await queryRunner.createIndex( + "freight.service_types", + new TableIndex({ + name: "IDX_SERVICE_TYPES_IS_ACTIVE", + columnNames: ["is_active"], + }), + ); + } + if (table && !(await queryRunner.hasIndex("freight.service_types", "IDX_SERVICE_TYPES_DISPLAY_ORDER"))) { + await queryRunner.createIndex( + "freight.service_types", + new TableIndex({ + name: "IDX_SERVICE_TYPES_DISPLAY_ORDER", + columnNames: ["display_order"], + }), + ); + } // Create cargo_types table if (!(await queryRunner.hasTable("freight.cargo_types"))) await queryRunner.createTable( diff --git a/apps/edr-freight-api/src/migrations/1880000000000-AddVehicleStatuses.ts b/apps/edr-freight-api/src/migrations/1880000000000-AddVehicleStatuses.ts index ce9af151b..484a2686b 100644 --- a/apps/edr-freight-api/src/migrations/1880000000000-AddVehicleStatuses.ts +++ b/apps/edr-freight-api/src/migrations/1880000000000-AddVehicleStatuses.ts @@ -7,11 +7,18 @@ export class AddVehicleStatuses1880000000000 implements MigrationInterface { name = "AddVehicleStatuses1880000000000"; public async up(queryRunner: QueryRunner): Promise { + // Create enum type if it doesn't exist await queryRunner.query(` - ALTER TYPE freight.vehicles_status_enum ADD VALUE IF NOT EXISTS 'FREE' BEFORE 'MAINTENANCE'; - `); - await queryRunner.query(` - ALTER TYPE freight.vehicles_status_enum ADD VALUE IF NOT EXISTS 'BUSY' AFTER 'FREE'; + DO $$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'vehicles_status_enum' AND typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'freight')) THEN + CREATE TYPE freight.vehicles_status_enum AS ENUM ('ACTIVE', 'FREE', 'BUSY', 'MAINTENANCE', 'RETIRED', 'OUT_OF_SERVICE'); + ELSE + -- Add values if enum already exists but doesn't have them + ALTER TYPE freight.vehicles_status_enum ADD VALUE IF NOT EXISTS 'FREE' BEFORE 'MAINTENANCE'; + ALTER TYPE freight.vehicles_status_enum ADD VALUE IF NOT EXISTS 'BUSY' AFTER 'FREE'; + END IF; + END $$; `); } From 1869790eab76c84ff3ccfa3b660ca02c0e633b1c Mon Sep 17 00:00:00 2001 From: natib21 Date: Thu, 2 Jul 2026 13:18:34 +0000 Subject: [PATCH 19/86] fix --- .../migrations/1748427600000-AddServiceTypesAndCargoTypes.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/edr-freight-api/src/migrations/1748427600000-AddServiceTypesAndCargoTypes.ts b/apps/edr-freight-api/src/migrations/1748427600000-AddServiceTypesAndCargoTypes.ts index c3698aed6..08ce634eb 100644 --- a/apps/edr-freight-api/src/migrations/1748427600000-AddServiceTypesAndCargoTypes.ts +++ b/apps/edr-freight-api/src/migrations/1748427600000-AddServiceTypesAndCargoTypes.ts @@ -94,7 +94,7 @@ export class AddServiceTypesAndCargoTypes1748427600000 implements MigrationInter // Create indexes for service_types const table = await queryRunner.getTable("freight.service_types"); - if (table && !(await queryRunner.hasIndex("freight.service_types", "IDX_SERVICE_TYPES_IS_ACTIVE"))) { + if (table && !table.indices.some((idx) => idx.name === "IDX_SERVICE_TYPES_IS_ACTIVE")) { await queryRunner.createIndex( "freight.service_types", new TableIndex({ @@ -103,7 +103,7 @@ export class AddServiceTypesAndCargoTypes1748427600000 implements MigrationInter }), ); } - if (table && !(await queryRunner.hasIndex("freight.service_types", "IDX_SERVICE_TYPES_DISPLAY_ORDER"))) { + if (table && !table.indices.some((idx) => idx.name === "IDX_SERVICE_TYPES_DISPLAY_ORDER")) { await queryRunner.createIndex( "freight.service_types", new TableIndex({ From 79c3293a72b2a97f4ea351fc866ed39ae4829d49 Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Thu, 2 Jul 2026 16:18:46 +0300 Subject: [PATCH 20/86] fix: booking paid trigger --- .../bookings/booking-invoice.service.ts | 2 + .../src/modules/payment/payment.service.ts | 77 ++++++++++--------- 2 files changed, 43 insertions(+), 36 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 1b8eddeca..21fef08ea 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 @@ -5,6 +5,7 @@ import { Injectable, Logger, } from "@nestjs/common"; +import { OnEvent } from "@nestjs/event-emitter"; import { Freight } from "@edr/types"; import { DataSource, EntityManager } from "typeorm"; @@ -94,6 +95,7 @@ export class BookingInvoiceService { * reactions live here (not in the payment process): each invoice type advances * the booking its own way. Only PREPAID exists today. */ + @OnEvent("booking.invoice.paid") async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise { this.logger.log( `onBookingInvoicePaid trigger for ${payload.sourceId} from ${payload.invoiceId}`, diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index d92af7a3e..d773ebe1f 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -189,48 +189,53 @@ export class PaymentService { * has stored the intent id, avoiding a settle-before-correlation race. */ async initiate(input: InitiateIntentInput): Promise { - const snapshot = await this.paymentClient.initiate({ - service: PaymentServiceEnum.FREIGHT, - referenceType: PaymentReferenceType.SHIPMENT, - referenceId: input.referenceId, - orderRef: input.orderRef, - amountMinor: input.amountMinor, - currency: input.currency, - provider: input.method as ProviderMethod, - platform: input.platform, - payerAccount: input.payerAccount, - returnUrl: - input.returnUrl ?? "https://edrfreight.triaplc.com/payment/success", - failureUrl: - input.failureUrl ?? "https://edrfreight.triaplc.com/payment/failure", - }); + try { + const snapshot = await this.paymentClient.initiate({ + service: PaymentServiceEnum.FREIGHT, + referenceType: PaymentReferenceType.SHIPMENT, + referenceId: input.referenceId, + orderRef: input.orderRef, + amountMinor: input.amountMinor, + currency: input.currency, + provider: input.method as ProviderMethod, + platform: input.platform, + payerAccount: input.payerAccount, + returnUrl: + input.returnUrl ?? "https://edrfreight.triaplc.com/payment/success", + failureUrl: + input.failureUrl ?? "https://edrfreight.triaplc.com/payment/failure", + }); - const immediateSuccess = - snapshot.status === ProviderPaymentStatus.SUCCEEDED; - const paidAt = snapshot.paidAt ? new Date(snapshot.paidAt) : undefined; + const immediateSuccess = + snapshot.status === ProviderPaymentStatus.SUCCEEDED; + const paidAt = snapshot.paidAt ? new Date(snapshot.paidAt) : undefined; - const intent = await this.upsertIntent(input, snapshot); + const intent = await this.upsertIntent(input, snapshot); - if (immediateSuccess) { - // Settle the projection but DO NOT notify billing — billing settles - // inline once it has stored intentId on the invoice (see payInvoice), - // avoiding a settle-before-correlation race. - await this.markIntentSucceeded(intent.id, { + if (immediateSuccess) { + // Settle the projection but DO NOT notify billing — billing settles + // inline once it has stored intentId on the invoice (see payInvoice), + // avoiding a settle-before-correlation race. + await this.markIntentSucceeded(intent.id, { + providerTxnId: snapshot.providerTxnId, + paidAt, + notify: false, + }); + } + + return { + intentId: intent.id, + // `intent` still reflects the projection status ("processing" on immediate + // success — settlement is applied by the caller, not shown synchronously). + response: this.formatIntentResponse(intent), + immediateSuccess, providerTxnId: snapshot.providerTxnId, paidAt, - notify: false, - }); + }; + } catch (err) { + console.log(err); + throw err; } - - return { - intentId: intent.id, - // `intent` still reflects the projection status ("processing" on immediate - // success — settlement is applied by the caller, not shown synchronously). - response: this.formatIntentResponse(intent), - immediateSuccess, - providerTxnId: snapshot.providerTxnId, - paidAt, - }; } /** Create or update the local intent projection from a provider snapshot. */ From fe40d9f4be5b25ea1658f90f9f753f9b8e06959c Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 2 Jul 2026 13:24:39 +0000 Subject: [PATCH 21/86] 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 b75a3ab54b24e4c94726d0e71c998768fe811b0a Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Thu, 2 Jul 2026 16:25:45 +0300 Subject: [PATCH 22/86] fix: first-mile error --- .../modules/first-mile/first-mile.service.ts | 169 ++++++++++++------ 1 file changed, 116 insertions(+), 53 deletions(-) diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts index ae0ada831..3ba1e4e75 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts @@ -1,19 +1,25 @@ -import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common'; -import { FindOptionsWhere } from 'typeorm'; -import { InjectDataSource } from '@nestjs/typeorm'; -import { DataSource } from 'typeorm'; +import { + BadRequestException, + ConflictException, + Injectable, + Logger, + NotFoundException, +} from "@nestjs/common"; +import { FindOptionsWhere } from "typeorm"; +import { InjectDataSource } from "@nestjs/typeorm"; +import { DataSource } from "typeorm"; -import { BookingsRepository } from '../bookings/bookings.repository'; -import { DriversService } from '../drivers/drivers.service'; -import { SmsClientService } from '../notifications/sms-client.service'; -import { VehiclesService } from '../vehicles/vehicles.service'; -import { CreateFirstMileDto } from './dto/create-first-mile.dto'; -import { UpdateFirstMileDto } from './dto/update-first-mile.dto'; -import { FirstMile, FirstMileStatus } from './entities/first-mile.entity'; -import { FirstMileContainerAllocation } from './entities/first-mile-container-allocation.entity'; -import { FirstMileRepository } from './first-mile.repository'; -import { OnEvent } from '@nestjs/event-emitter'; -import { InvoiceEventPayload } from '../billing/billing.service'; +import { BookingsRepository } from "../bookings/bookings.repository"; +import { DriversService } from "../drivers/drivers.service"; +import { SmsClientService } from "../notifications/sms-client.service"; +import { VehiclesService } from "../vehicles/vehicles.service"; +import { CreateFirstMileDto } from "./dto/create-first-mile.dto"; +import { UpdateFirstMileDto } from "./dto/update-first-mile.dto"; +import { FirstMile, FirstMileStatus } from "./entities/first-mile.entity"; +import { FirstMileContainerAllocation } from "./entities/first-mile-container-allocation.entity"; +import { FirstMileRepository } from "./first-mile.repository"; +import { OnEvent } from "@nestjs/event-emitter"; +import { InvoiceEventPayload } from "../billing/billing.service"; type FirstMileListFilter = { status?: FirstMileStatus; @@ -26,10 +32,10 @@ type FirstMileListFilter = { }; const SORTABLE_FIELDS: (keyof FirstMile)[] = [ - 'status', - 'advancedPayment', - 'remainingPayment', - 'createdAt', + "status", + "advancedPayment", + "remainingPayment", + "createdAt", ]; @Injectable() @@ -43,26 +49,28 @@ export class FirstMileService { private readonly vehiclesService: VehiclesService, private readonly driversService: DriversService, private readonly smsClient: SmsClientService, - ) {} + ) { } /** * Look up a booking by its human-readable reference and confirm it has been * paid before any first-mile work proceeds. Throws if the reference is * unknown or the booking has not reached PAID status. */ - async acceptBooking(bookingId: string): Promise { + async acceptBooking(bookingId: string): Promise { const booking = await this.bookingsRepository.findById(bookingId, { relations: { serviceType: true }, }); if (!booking) { - throw new NotFoundException(`Booking ${bookingId} not found`); + return null; } return this.acceptEligibleBooking(booking); } - async acceptBookingByReference(bookingReference: string): Promise { + async acceptBookingByReference( + bookingReference: string, + ): Promise { const [booking] = await this.bookingsRepository.findAll({ where: { reference: bookingReference }, relations: { serviceType: true }, @@ -89,20 +97,20 @@ export class FirstMileService { tradeDirection?: string | null; firstMilePickupAddress?: string | null; serviceType?: { includesFirstMile?: boolean | null } | null; - }): Promise { + }): Promise { const label = booking.reference ?? booking.id; - if (booking.paymentStatus !== 'PAID') { - throw new BadRequestException(`Booking ${label} is not paid`); + if (booking.paymentStatus !== "PAID") { + return null; } if (!this.bookingRequestsFirstMile(booking)) { - throw new BadRequestException(`Booking ${label} does not require a first mile`); + return null; } const existing = await this.findByBookingId(booking.id); if (existing) { - throw new ConflictException(`Booking ${label} already has a first-mile assignment`); + return null; } return this.create({ @@ -118,8 +126,9 @@ export class FirstMileService { const pageSize = filter.pageSize ?? 50; const sortBy = SORTABLE_FIELDS.includes(filter.sortBy as keyof FirstMile) ? (filter.sortBy as keyof FirstMile) - : 'createdAt'; - const sortOrder = filter.sortOrder?.toUpperCase() === 'ASC' ? 'ASC' : 'DESC'; + : "createdAt"; + const sortOrder = + filter.sortOrder?.toUpperCase() === "ASC" ? "ASC" : "DESC"; const where: FindOptionsWhere = {}; if (filter.status) where.status = filter.status; @@ -129,7 +138,13 @@ export class FirstMileService { const [data, total] = await this.firstMileRepository.findAndCount({ where, relations: { - booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true }, + booking: { + company: true, + serviceType: true, + originYard: true, + destinationYard: true, + cargoType: true, + }, vehicle: true, }, order: { [sortBy]: sortOrder }, @@ -151,8 +166,12 @@ export class FirstMileService { @OnEvent("firstmile.invoice.paid") async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise { try { - await this.firstMileRepository.update(payload.sourceId, { paid: true } as any); - this.logger.log(`Marked first-mile record ${payload.sourceId} as paid (invoice ${payload.invoiceId})`); + await this.firstMileRepository.update(payload.sourceId, { + paid: true, + } as any); + this.logger.log( + `Marked first-mile record ${payload.sourceId} as paid (invoice ${payload.invoiceId})`, + ); } catch (err) { this.logger.error( `Failed to update first-mile payment status for record ${payload.sourceId}: ${String(err)}`, @@ -163,7 +182,13 @@ export class FirstMileService { async findById(id: string): Promise { const record = await this.firstMileRepository.findById(id, { relations: { - booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true }, + booking: { + company: true, + serviceType: true, + originYard: true, + destinationYard: true, + cargoType: true, + }, vehicle: true, }, }); @@ -183,7 +208,7 @@ export class FirstMileService { return this.firstMileRepository.create({ bookingId: dto.bookingId, - status: dto.status ?? 'READY_TO_TRANSIT', + status: dto.status ?? "READY_TO_TRANSIT", advancedPayment: dto.advancedPayment ?? 0, remainingPayment: dto.remainingPayment ?? 0, estimatedKm: dto.estimatedKm ?? null, @@ -197,7 +222,13 @@ export class FirstMileService { const [records] = await this.firstMileRepository.findAndCount({ where: { bookingId }, relations: { - booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true }, + booking: { + company: true, + serviceType: true, + originYard: true, + destinationYard: true, + cargoType: true, + }, vehicle: true, }, take: 1, @@ -213,9 +244,9 @@ export class FirstMileService { // Export bookings always need a first mile (pickup → origin yard); the // pickup address is captured at assignment time, not required upfront. return Boolean( - booking.tradeDirection === 'EXPORT' || - booking.firstMilePickupAddress?.trim() || - booking.serviceType?.includesFirstMile, + booking.tradeDirection === "EXPORT" || + booking.firstMilePickupAddress?.trim() || + booking.serviceType?.includesFirstMile, ); } @@ -226,9 +257,15 @@ export class FirstMileService { const updated = await this.firstMileRepository.update(id, { ...(dto.bookingId !== undefined ? { bookingId: dto.bookingId } : {}), ...(dto.status !== undefined ? { status: dto.status } : {}), - ...(dto.advancedPayment !== undefined ? { advancedPayment: dto.advancedPayment } : {}), - ...(dto.remainingPayment !== undefined ? { remainingPayment: dto.remainingPayment } : {}), - ...(dto.estimatedKm !== undefined ? { estimatedKm: dto.estimatedKm } : {}), + ...(dto.advancedPayment !== undefined + ? { advancedPayment: dto.advancedPayment } + : {}), + ...(dto.remainingPayment !== undefined + ? { remainingPayment: dto.remainingPayment } + : {}), + ...(dto.estimatedKm !== undefined + ? { estimatedKm: dto.estimatedKm } + : {}), ...(dto.exactKm !== undefined ? { exactKm: dto.exactKm } : {}), ...(dto.vehicleId !== undefined ? { vehicleId: dto.vehicleId } : {}), ...(dtoAny.paid !== undefined ? { paid: dtoAny.paid } : {}), @@ -256,37 +293,63 @@ export class FirstMileService { return updated; } - private async notifyDriverAssignment(vehicleId: string, record: FirstMile): Promise { + private async notifyDriverAssignment( + vehicleId: string, + record: FirstMile, + ): Promise { try { const vehicle = await this.vehiclesService.findById(vehicleId); if (!vehicle.assignedDriverId) { - this.logger.warn(`Vehicle ${vehicleId} has no assigned driver — skipping SMS`); + this.logger.warn( + `Vehicle ${vehicleId} has no assigned driver — skipping SMS`, + ); return; } - const driver = await this.driversService.findById(vehicle.assignedDriverId); + const driver = await this.driversService.findById( + vehicle.assignedDriverId, + ); if (!driver.phoneNumber) { - this.logger.warn(`Driver ${vehicle.assignedDriverId} has no phone number — skipping SMS`); + this.logger.warn( + `Driver ${vehicle.assignedDriverId} has no phone number — skipping SMS`, + ); return; } - const booking = (record as FirstMile & { booking?: { reference?: string; firstMilePickupAddress?: string | null; originYard?: { label?: string } | null } }).booking; + const booking = ( + record as FirstMile & { + booking?: { + reference?: string; + firstMilePickupAddress?: string | null; + originYard?: { label?: string } | null; + }; + } + ).booking; - const driverName = `${driver.firstName ?? ''} ${driver.lastName ?? ''}`.trim(); + const driverName = + `${driver.firstName ?? ""} ${driver.lastName ?? ""}`.trim(); const message = `Dear ${driverName}, you have been assigned to a first-mile pickup. ` + `Booking: ${booking?.reference ?? record.bookingId}. Vehicle: ${vehicle.plateNumber ?? vehicleId}. ` + - (booking?.firstMilePickupAddress ? `Pickup: ${booking.firstMilePickupAddress}. ` : '') + - (booking?.originYard?.label ? `Destination: ${booking.originYard.label}.` : ''); + (booking?.firstMilePickupAddress + ? `Pickup: ${booking.firstMilePickupAddress}. ` + : "") + + (booking?.originYard?.label + ? `Destination: ${booking.originYard.label}.` + : ""); void this.smsClient.sendSms({ to: driver.phoneNumber, message, }); - this.logger.log(`SMS queued to driver ${driver.phoneNumber} for vehicle ${vehicleId} assignment`); + this.logger.log( + `SMS queued to driver ${driver.phoneNumber} for vehicle ${vehicleId} assignment`, + ); } catch (err) { - this.logger.error(`Failed to notify driver for vehicle ${vehicleId}: ${String(err)}`); + this.logger.error( + `Failed to notify driver for vehicle ${vehicleId}: ${String(err)}`, + ); } } @@ -314,7 +377,7 @@ export class FirstMileService { firstMileId, containerId: allocation.containerId, vehicleId: allocation.vehicleId, - containerType: 'CONTAINER', + containerType: "CONTAINER", quantity: 1, }); } From fec2a5d3208e1250418a3900243f17229e434cb0 Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 2 Jul 2026 13:27:59 +0000 Subject: [PATCH 23/86] 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 df60c4750e93247f016502f780eeb32479810fd5 Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Thu, 2 Jul 2026 16:32:35 +0300 Subject: [PATCH 24/86] fix: warehouse query --- .../modules/first-mile/first-mile.service.ts | 10 +- .../warehouses/warehouse-invoice.service.ts | 370 ++++++++++++------ 2 files changed, 250 insertions(+), 130 deletions(-) diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts index 3ba1e4e75..d05964878 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts @@ -1,10 +1,4 @@ -import { - BadRequestException, - ConflictException, - Injectable, - Logger, - NotFoundException, -} from "@nestjs/common"; +import { Injectable, Logger, NotFoundException } from "@nestjs/common"; import { FindOptionsWhere } from "typeorm"; import { InjectDataSource } from "@nestjs/typeorm"; import { DataSource } from "typeorm"; @@ -98,8 +92,6 @@ export class FirstMileService { firstMilePickupAddress?: string | null; serviceType?: { includesFirstMile?: boolean | null } | null; }): Promise { - const label = booking.reference ?? booking.id; - if (booking.paymentStatus !== "PAID") { return null; } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts index 6f7219781..c5a75a8c5 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts @@ -1,29 +1,39 @@ -import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common'; -import { OnEvent } from '@nestjs/event-emitter'; -import { Freight } from '@edr/types'; -import { DataSource } from 'typeorm'; +import { + BadRequestException, + ConflictException, + Injectable, + Logger, + NotFoundException, +} from "@nestjs/common"; +import { OnEvent } from "@nestjs/event-emitter"; +import { Freight } from "@edr/types"; +import { DataSource } from "typeorm"; -import { BillingService, InvoiceEventPayload, InvoiceLineInput } from '../billing/billing.service'; -import { Invoice } from '../billing/entities/invoice.entity'; -import { InvoiceLine } from '../billing/entities/invoice-line.entity'; +import { + BillingService, + InvoiceEventPayload, + InvoiceLineInput, +} from "../billing/billing.service"; +import { Invoice } from "../billing/entities/invoice.entity"; +import { InvoiceLine } from "../billing/entities/invoice-line.entity"; import { InvoiceDocumentModel, InvoiceDocumentService, -} from '../billing/documents/invoice-document.service'; -import { NotificationsService } from '../notifications/notifications.service'; -import { WarehouseFeeService } from './warehouse-fee.service'; +} from "../billing/documents/invoice-document.service"; +import { NotificationsService } from "../notifications/notifications.service"; +import { WarehouseFeeService } from "./warehouse-fee.service"; import { WarehouseFeeInvoiceView, WarehouseFeeType, WarehouseInvoiceItemView, WarehouseInvoiceStatus, WarehouseInvoiceType, -} from './warehouse-invoice.types'; +} from "./warehouse-invoice.types"; interface GenerateOptions { confirmZero?: boolean; performedBy?: string; - billingCurrency?: 'ETB' | 'USD'; + billingCurrency?: "ETB" | "USD"; } export interface PayInvoiceDto { @@ -45,7 +55,10 @@ const BLOCKING_STATUSES: Freight.InvoiceStatus[] = [ Freight.InvoiceStatus.Overdue, ]; /** Global statuses considered an "active" invoice for per-inventory dedup. */ -const ACTIVE_STATUSES: Freight.InvoiceStatus[] = [...BLOCKING_STATUSES, Freight.InvoiceStatus.Paid]; +const ACTIVE_STATUSES: Freight.InvoiceStatus[] = [ + ...BLOCKING_STATUSES, + Freight.InvoiceStatus.Paid, +]; export interface InvoiceDocumentDetails { bookingReference: string | null; @@ -120,10 +133,13 @@ export class WarehouseInvoiceService { private readonly invoiceDocuments: InvoiceDocumentService, private readonly feeService: WarehouseFeeService, private readonly notifications: NotificationsService, - ) {} + ) { } // ── Generation ─────────────────────────────────────────────────────────── - async generateForInventory(inventoryId: string, opts: GenerateOptions = {}): Promise { + async generateForInventory( + inventoryId: string, + opts: GenerateOptions = {}, + ): Promise { const [item] = await this.dataSource.query( `SELECT inv.id, inv.booking_id AS "bookingId", inv.warehouse_id AS "warehouseId", inv.yard_id AS "yardId", inv.zone_id AS "zoneId", inv.arrived_at AS "arrivedAt", @@ -136,43 +152,47 @@ export class WarehouseInvoiceService { WHERE inv.id = $1 AND inv.deleted_at IS NULL`, [inventoryId], ); - if (!item) throw new NotFoundException(`Inventory item ${inventoryId} not found`); + if (!item) + throw new NotFoundException(`Inventory item ${inventoryId} not found`); // Routing through the global invoice requires a billable company + profile, // both of which come from the inventory's booking. if (!item.companyId || !item.companyProfileId) { throw new BadRequestException( - 'Cannot generate a warehouse fee invoice: the inventory item has no billable company (no associated booking).', + "Cannot generate a warehouse fee invoice: the inventory item has no billable company (no associated booking).", ); } // Dedup: only one active (non-cancelled) invoice per inventory item. if (await this.hasActiveInvoice(inventoryId)) { throw new ConflictException( - 'An active warehouse fee invoice already exists for this item. Cancel it before generating a new one.', + "An active warehouse fee invoice already exists for this item. Cancel it before generating a new one.", ); } - const billingCurrency = opts.billingCurrency === 'ETB' ? 'ETB' : 'USD'; - const previews = await this.feeService.previewForInventory(inventoryId, billingCurrency); - const isContainer = (item.freightType ?? '').toUpperCase() === 'CONTAINER'; + const billingCurrency = opts.billingCurrency === "ETB" ? "ETB" : "USD"; + const previews = await this.feeService.previewForInventory( + inventoryId, + billingCurrency, + ); + const isContainer = (item.freightType ?? "").toUpperCase() === "CONTAINER"; const items = previews .filter((p) => p.amount > 0) .map((p) => { const feeType: WarehouseFeeType = - p.ruleType === 'STORAGE_FEE' - ? 'STORAGE_FEE' + p.ruleType === "STORAGE_FEE" + ? "STORAGE_FEE" : isContainer - ? 'CONTAINER_DEMURRAGE' - : 'BULK_DEMURRAGE'; + ? "CONTAINER_DEMURRAGE" + : "BULK_DEMURRAGE"; return { feeRuleId: p.ruleId, feeType, description: - p.ruleType === 'STORAGE_FEE' + p.ruleType === "STORAGE_FEE" ? `Storage fee - ${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s) after ${p.freeDays} free` - : `${isContainer ? 'Container' : 'Bulk'} demurrage - ${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s) after ${p.freeDays} free`, + : `${isContainer ? "Container" : "Bulk"} demurrage - ${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s) after ${p.freeDays} free`, quantity: p.billableUnits, unitRate: p.ratePerDay, amount: p.amount, @@ -184,13 +204,19 @@ export class WarehouseInvoiceService { const total = items.reduce((s, i) => s + i.amount, 0); if (total <= 0 && !opts.confirmZero) { - throw new BadRequestException('No payable warehouse fee found for this item.'); + throw new BadRequestException( + "No payable warehouse fee found for this item.", + ); } - const hasDemurrage = items.some((i) => i.feeType !== 'STORAGE_FEE'); - const hasStorage = items.some((i) => i.feeType === 'STORAGE_FEE'); + const hasDemurrage = items.some((i) => i.feeType !== "STORAGE_FEE"); + const hasStorage = items.some((i) => i.feeType === "STORAGE_FEE"); const invoiceType: WarehouseInvoiceType = - hasDemurrage && hasStorage ? 'MIXED_WAREHOUSE_FEES' : hasStorage ? 'STORAGE_FEE' : 'DEMURRAGE'; + hasDemurrage && hasStorage + ? "MIXED_WAREHOUSE_FEES" + : hasStorage + ? "STORAGE_FEE" + : "DEMURRAGE"; const lines: InvoiceLineInput[] = items.map((it) => ({ chargeType: it.feeType, @@ -232,18 +258,23 @@ export class WarehouseInvoiceService { } listForInventory(inventoryId: string): Promise { - return this.queryViews('AND i.source_id = $1', [inventoryId]); + return this.queryViews("AND i.source_id = $1", [inventoryId]); } listForBooking(bookingId: string): Promise { - return this.queryViews('AND inv.booking_id = $1', [bookingId]); + return this.queryViews("AND inv.booking_id = $1", [bookingId]); } async findAll( filter: Partial< Pick< WarehouseFeeInvoiceView, - 'status' | 'invoiceType' | 'warehouseId' | 'facilityId' | 'customerId' | 'bookingId' + | "status" + | "invoiceType" + | "warehouseId" + | "facilityId" + | "customerId" + | "bookingId" > >, ): Promise { @@ -254,41 +285,56 @@ export class WarehouseInvoiceService { conditions.push(sql(`$${params.length}`)); }; - if (filter.status) add((p) => `i.status::text = ${p}`, this.toGlobalStatus(filter.status as WarehouseInvoiceStatus)); + if (filter.status) + add( + (p) => `i.status::text = ${p}`, + this.toGlobalStatus(filter.status as WarehouseInvoiceStatus), + ); if (filter.invoiceType) add((p) => `i.type = ${p}`, filter.invoiceType); if (filter.customerId) add((p) => `i.company_id = ${p}`, filter.customerId); - if (filter.warehouseId) add((p) => `inv.warehouse_id = ${p}`, filter.warehouseId); - if (filter.facilityId) add((p) => `w.facility_id = ${p}`, filter.facilityId); + if (filter.warehouseId) + add((p) => `inv.warehouse_id = ${p}`, filter.warehouseId); + if (filter.facilityId) + add((p) => `w.facility_id = ${p}`, filter.facilityId); if (filter.bookingId) add((p) => `inv.booking_id = ${p}`, filter.bookingId); - return this.queryViews(conditions.map((c) => `AND ${c}`).join(' '), params); + return this.queryViews(conditions.map((c) => `AND ${c}`).join(" "), params); } async document(id: string): Promise<{ filename: string; buffer: Buffer }> { const invoice = await this.findById(id); - return this.invoiceDocuments.render(this.toDocumentModel(invoice, 'INVOICE')); + return this.invoiceDocuments.render( + this.toDocumentModel(invoice, "INVOICE"), + ); } async receipt(id: string): Promise<{ filename: string; buffer: Buffer }> { const invoice = await this.findById(id); if (Number(invoice.paidAmount) <= 0) { - throw new BadRequestException('A receipt is available only after payment is recorded.'); + throw new BadRequestException( + "A receipt is available only after payment is recorded.", + ); } - return this.invoiceDocuments.render(this.toDocumentModel(invoice, 'RECEIPT')); + return this.invoiceDocuments.render( + this.toDocumentModel(invoice, "RECEIPT"), + ); } // ── State changes ──────────────────────────────────────────────────────── async cancel(id: string): Promise { const invoice = await this.loadWarehouseInvoice(id); if (invoice.status === Freight.InvoiceStatus.Paid) { - throw new BadRequestException('A paid invoice cannot be cancelled.'); + throw new BadRequestException("A paid invoice cannot be cancelled."); } await this.billing.cancelInvoice(id); return this.findById(id); } /** Record a payment against the invoice (delegates settlement to billing). */ - async pay(id: string, dto: PayInvoiceDto): Promise { + async pay( + id: string, + dto: PayInvoiceDto, + ): Promise { // Guard that this is a warehouse invoice before recording (404 otherwise). await this.loadWarehouseInvoice(id); await this.billing.recordPayment(id, { @@ -297,7 +343,10 @@ export class WarehouseInvoiceService { reference: dto.reference ?? null, metadata: dto.driverName || dto.driverPhone - ? { driverName: dto.driverName ?? null, driverPhone: dto.driverPhone ?? null } + ? { + driverName: dto.driverName ?? null, + driverPhone: dto.driverPhone ?? null, + } : null, }); const detail = await this.findById(id); @@ -313,16 +362,20 @@ export class WarehouseInvoiceService { * counter settlement leaves it null. Skipping null-`paymentId` events avoids * double-notifying a counter payment that already sent its SMS. */ - @OnEvent('warehouse.invoice.paid') + @OnEvent("warehouse.invoice.paid") async onWarehouseInvoicePaid(payload: InvoiceEventPayload): Promise { if (!payload.paymentId) return; const detail = await this.findById(payload.invoiceId); - await this.notifyWarehouseFeePayment(detail, { amount: Number(detail.totalAmount) }); + await this.notifyWarehouseFeePayment(detail, { + amount: Number(detail.totalAmount), + }); } // ── Release blocking ────────────────────────────────────────────────────── /** Returns the first unpaid invoice that blocks terminal release, or null. */ - async findBlockingInvoice(inventoryId: string): Promise { + async findBlockingInvoice( + inventoryId: string, + ): Promise { const blocking = await this.queryViews( `AND i.source_id = $1 AND i.status::text = ANY($2::text[])`, [inventoryId, BLOCKING_STATUSES], @@ -331,21 +384,31 @@ export class WarehouseInvoiceService { } async assertClearanceAllowed(inventoryId: string): Promise { - const invoices = await this.queryViews('AND i.source_id = $1', [inventoryId]); - const blocking = invoices.find((inv) => inv.status === 'ISSUED' || inv.status === 'PARTIALLY_PAID'); + const invoices = await this.queryViews("AND i.source_id = $1", [ + inventoryId, + ]); + const blocking = invoices.find( + (inv) => inv.status === "ISSUED" || inv.status === "PARTIALLY_PAID", + ); if (blocking) { throw new BadRequestException( `Warehouse demurrage/storage invoice ${blocking.invoiceNumber} must be fully paid before terminal release.`, ); } - if (invoices.some((inv) => inv.status === 'PAID')) return; + if (invoices.some((inv) => inv.status === "PAID")) return; - const previews = await this.feeService.previewForInventory(inventoryId, 'USD'); - const payableAmount = previews.reduce((sum, fee) => sum + Number(fee.amount || 0), 0); + const previews = await this.feeService.previewForInventory( + inventoryId, + "USD", + ); + const payableAmount = previews.reduce( + (sum, fee) => sum + Number(fee.amount || 0), + 0, + ); if (payableAmount > 0) { throw new BadRequestException( - 'Generate and fully pay the warehouse demurrage/storage invoice before terminal release.', + "Generate and fully pay the warehouse demurrage/storage invoice before terminal release.", ); } } @@ -353,7 +416,9 @@ export class WarehouseInvoiceService { // ── Internal: loading & projection ───────────────────────────────────────── /** Load a global invoice (+lines) and assert it is a warehouse fee invoice. */ - private async loadWarehouseInvoice(id: string): Promise { + private async loadWarehouseInvoice( + id: string, + ): Promise { const invoice = await this.billing.findById(id); if (invoice.source !== SOURCE) { throw new NotFoundException(`Invoice ${id} not found`); @@ -376,7 +441,10 @@ export class WarehouseInvoiceService { * Project warehouse-source global invoices into the historical view, joined to * their inventory item for the typed FKs. Powers every list/filter read. */ - private async queryViews(extraWhere: string, params: unknown[]): Promise { + private async queryViews( + extraWhere: string, + params: unknown[], + ): Promise { const rows = await this.dataSource.query( `SELECT i.id, i.invoice_number AS "invoiceNumber", i.company_id AS "companyId", i.source_id AS "sourceId", i.type, i.status, @@ -389,7 +457,7 @@ export class WarehouseInvoiceService { inv.yard_id AS "yardId", inv.zone_id AS "zoneId", inv.arrived_at AS "periodStart", w.facility_id AS "facilityId" FROM freight.invoices i - LEFT JOIN freight.warehouse_inventory inv ON inv.id = i.source_id AND inv.deleted_at IS NULL + LEFT JOIN freight.warehouse_inventory inv ON inv.id::text = i.source_id AND inv.deleted_at IS NULL LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id WHERE i.source = $${params.length + 1} AND i.deleted_at IS NULL ${extraWhere} ORDER BY i.created_at DESC`, @@ -409,7 +477,10 @@ export class WarehouseInvoiceService { } /** Reshape a global invoice (+ derived inventory context) into the warehouse view. */ - private buildView(inv: ViewSource, ctx: InventoryContext): WarehouseFeeInvoiceView { + private buildView( + inv: ViewSource, + ctx: InventoryContext, + ): WarehouseFeeInvoiceView { const status = this.toWarehouseStatus(inv.status); return { id: inv.id, @@ -436,7 +507,7 @@ export class WarehouseInvoiceService { issuedAt: inv.issuedAt ?? null, dueDate: inv.dueAt ?? null, paidAt: inv.paidAt ?? null, - cancelledAt: status === 'CANCELLED' ? inv.updatedAt : null, + cancelledAt: status === "CANCELLED" ? inv.updatedAt : null, payments: (inv.payments ?? []).map((p) => ({ amount: Number(p.amount), method: p.method ?? null, @@ -458,7 +529,7 @@ export class WarehouseInvoiceService { return { feeRuleId: meta.feeRuleId ?? null, feeType: line.chargeType as WarehouseFeeType, - description: line.description ?? '', + description: line.description ?? "", quantity: Number(line.quantity), unitRate: Number(line.unitRate), amount: Number(line.amount), @@ -468,32 +539,36 @@ export class WarehouseInvoiceService { }; } - private toWarehouseStatus(status: Freight.InvoiceStatus | string): WarehouseInvoiceStatus { + private toWarehouseStatus( + status: Freight.InvoiceStatus | string, + ): WarehouseInvoiceStatus { switch (status) { case Freight.InvoiceStatus.Draft: - return 'DRAFT'; + return "DRAFT"; case Freight.InvoiceStatus.PartiallyPaid: - return 'PARTIALLY_PAID'; + return "PARTIALLY_PAID"; case Freight.InvoiceStatus.Paid: - return 'PAID'; + return "PAID"; case Freight.InvoiceStatus.Cancelled: case Freight.InvoiceStatus.Refunded: - return 'CANCELLED'; + return "CANCELLED"; default: // Issued / Pending / Overdue → an issued, still-owed invoice. - return 'ISSUED'; + return "ISSUED"; } } - private toGlobalStatus(status: WarehouseInvoiceStatus): Freight.InvoiceStatus { + private toGlobalStatus( + status: WarehouseInvoiceStatus, + ): Freight.InvoiceStatus { switch (status) { - case 'DRAFT': + case "DRAFT": return Freight.InvoiceStatus.Draft; - case 'PARTIALLY_PAID': + case "PARTIALLY_PAID": return Freight.InvoiceStatus.PartiallyPaid; - case 'PAID': + case "PAID": return Freight.InvoiceStatus.Paid; - case 'CANCELLED': + case "CANCELLED": return Freight.InvoiceStatus.Cancelled; default: return Freight.InvoiceStatus.Issued; @@ -503,39 +578,54 @@ export class WarehouseInvoiceService { /** Map a warehouse fee invoice view onto the shared document model. */ private toDocumentModel( invoice: WarehouseFeeInvoiceDetail, - kind: 'INVOICE' | 'RECEIPT', + kind: "INVOICE" | "RECEIPT", ): InvoiceDocumentModel { const lastPayment = [...(invoice.payments ?? [])].pop(); const date = (value: unknown) => - value ? new Date(value as string | Date).toLocaleDateString('en-GB') : null; + value + ? new Date(value as string | Date).toLocaleDateString("en-GB") + : null; return { kind, - title: 'Warehouse Fee', + title: "Warehouse Fee", documentNumber: invoice.invoiceNumber, issuedAt: invoice.issuedAt ?? invoice.createdAt, status: invoice.status, currency: invoice.currency, summary: [ - { label: 'Status', value: invoice.status.replace(/_/g, ' ') }, - { label: 'Invoice type', value: invoice.invoiceType.replace(/_/g, ' ') }, - { label: 'Booking reference', value: invoice.bookingReference ?? null }, - { label: 'Customer', value: invoice.customerName ?? null }, - { label: 'Inventory reference', value: invoice.inventoryReference ?? null }, - { label: 'Inventory info', value: invoice.inventoryInfo ?? null }, - { label: 'Clearance', value: invoice.clearanceStatus ?? null }, - { label: 'Warehouse', value: invoice.warehouseName ?? null }, + { label: "Status", value: invoice.status.replace(/_/g, " ") }, { - label: 'Yard / Zone', - value: [invoice.yardName, invoice.zoneName].filter(Boolean).join(' / ') || null, + label: "Invoice type", + value: invoice.invoiceType.replace(/_/g, " "), }, - { label: 'Period', value: `${date(invoice.periodStart) ?? '-'} - ${date(invoice.periodEnd) ?? '-'}` }, + { label: "Booking reference", value: invoice.bookingReference ?? null }, + { label: "Customer", value: invoice.customerName ?? null }, { - label: 'Payment', - value: lastPayment ? `${lastPayment.method ?? 'MANUAL'} / ${date(lastPayment.paidAt) ?? '-'}` : null, + label: "Inventory reference", + value: invoice.inventoryReference ?? null, + }, + { label: "Inventory info", value: invoice.inventoryInfo ?? null }, + { label: "Clearance", value: invoice.clearanceStatus ?? null }, + { label: "Warehouse", value: invoice.warehouseName ?? null }, + { + label: "Yard / Zone", + value: + [invoice.yardName, invoice.zoneName].filter(Boolean).join(" / ") || + null, + }, + { + label: "Period", + value: `${date(invoice.periodStart) ?? "-"} - ${date(invoice.periodEnd) ?? "-"}`, + }, + { + label: "Payment", + value: lastPayment + ? `${lastPayment.method ?? "MANUAL"} / ${date(lastPayment.paidAt) ?? "-"}` + : null, }, ], - categoryHeader: 'Fee type', + categoryHeader: "Fee type", lines: invoice.items.map((item) => ({ description: item.description ?? null, category: item.feeType ?? null, @@ -545,17 +635,19 @@ export class WarehouseInvoiceService { currency: item.currency ?? invoice.currency, })), totals: [ - { label: 'Subtotal', amount: Number(invoice.subtotalAmount) }, - { label: 'Tax', amount: Number(invoice.taxAmount) }, - { label: 'Total', amount: Number(invoice.totalAmount), grand: true }, - { label: 'Paid', amount: Number(invoice.paidAmount) }, - { label: 'Balance', amount: Number(invoice.balanceAmount) }, + { label: "Subtotal", amount: Number(invoice.subtotalAmount) }, + { label: "Tax", amount: Number(invoice.taxAmount) }, + { label: "Total", amount: Number(invoice.totalAmount), grand: true }, + { label: "Paid", amount: Number(invoice.paidAmount) }, + { label: "Balance", amount: Number(invoice.balanceAmount) }, ], }; } /** Warehouse-specific display details, derived from the linked inventory item. */ - private async getInvoiceDocumentDetails(invoice: ViewSource): Promise { + private async getInvoiceDocumentDetails( + invoice: ViewSource, + ): Promise { const [row] = await this.dataSource.query( `SELECT b.reference AS "bookingReference", company.name AS "customerName", @@ -591,12 +683,12 @@ export class WarehouseInvoiceService { [invoice.sourceId], ); - const fullyPaid = this.toWarehouseStatus(invoice.status) === 'PAID'; + const fullyPaid = this.toWarehouseStatus(invoice.status) === "PAID"; const clearanceStatus = row?.releaseDate - ? 'RELEASE ISSUED' + ? "RELEASE ISSUED" : fullyPaid - ? 'FEE PAID - READY FOR RELEASE' - : 'PENDING PAYMENT'; + ? "FEE PAID - READY FOR RELEASE" + : "PENDING PAYMENT"; return { bookingReference: row?.bookingReference ?? null, @@ -613,7 +705,9 @@ export class WarehouseInvoiceService { }; } - private async getInventoryContext(inventoryId: string): Promise { + private async getInventoryContext( + inventoryId: string, + ): Promise { const [row] = await this.dataSource.query( `SELECT inv.booking_id AS "bookingId", inv.warehouse_id AS "warehouseId", inv.yard_id AS "yardId", inv.zone_id AS "zoneId", inv.arrived_at AS "periodStart", @@ -701,55 +795,89 @@ export class WarehouseInvoiceService { }; } - private async sendSms(recipient: string | null | undefined, message: string, context: string): Promise { + private async sendSms( + recipient: string | null | undefined, + message: string, + context: string, + ): Promise { const phone = recipient?.trim(); if (!phone) return; try { - await this.notifications.directSend('sms', phone, message); + await this.notifications.directSend("sms", phone, message); } catch (error) { - this.logger.error(`Failed to send ${context} SMS to ${phone}: ${String(error)}`); + this.logger.error( + `Failed to send ${context} SMS to ${phone}: ${String(error)}`, + ); } } - private async notifyWarehouseFeeIssued(invoice: WarehouseFeeInvoiceView): Promise { - const contacts = await this.getInvoiceNotificationContacts(invoice.inventoryId); - const customerName = contacts.customerName?.trim() || 'Customer'; - const bookingReference = contacts.bookingReference ? ` Booking: ${contacts.bookingReference}.` : ''; + private async notifyWarehouseFeeIssued( + invoice: WarehouseFeeInvoiceView, + ): Promise { + const contacts = await this.getInvoiceNotificationContacts( + invoice.inventoryId, + ); + const customerName = contacts.customerName?.trim() || "Customer"; + const bookingReference = contacts.bookingReference + ? ` Booking: ${contacts.bookingReference}.` + : ""; const cargo = contacts.containerNumber || contacts.cargoDescription; - const cargoText = cargo ? ` Cargo: ${cargo}.` : ''; + const cargoText = cargo ? ` Cargo: ${cargo}.` : ""; const message = - `Dear ${customerName}, warehouse ${invoice.invoiceType.replace(/_/g, ' ').toLowerCase()} fee ` + + `Dear ${customerName}, warehouse ${invoice.invoiceType.replace(/_/g, " ").toLowerCase()} fee ` + `${invoice.invoiceNumber} is due.${bookingReference}${cargoText} Amount: ` + `${Number(invoice.totalAmount).toLocaleString()} ${invoice.currency}. Please pay before cargo pickup.`; - await this.sendSms(contacts.customerPhone, message, `warehouse fee invoice ${invoice.invoiceNumber}`); + await this.sendSms( + contacts.customerPhone, + message, + `warehouse fee invoice ${invoice.invoiceNumber}`, + ); } - private async notifyWarehouseFeePayment(invoice: WarehouseFeeInvoiceView, dto: PayInvoiceDto): Promise { - const contacts = await this.getInvoiceNotificationContacts(invoice.inventoryId); - const customerName = contacts.customerName?.trim() || 'Customer'; - const bookingReference = contacts.bookingReference ? ` Booking: ${contacts.bookingReference}.` : ''; + private async notifyWarehouseFeePayment( + invoice: WarehouseFeeInvoiceView, + dto: PayInvoiceDto, + ): Promise { + const contacts = await this.getInvoiceNotificationContacts( + invoice.inventoryId, + ); + const customerName = contacts.customerName?.trim() || "Customer"; + const bookingReference = contacts.bookingReference + ? ` Booking: ${contacts.bookingReference}.` + : ""; const statusText = - invoice.status === 'PAID' - ? 'fully paid and ready for pickup release' + invoice.status === "PAID" + ? "fully paid and ready for pickup release" : `partially paid. Balance: ${Number(invoice.balanceAmount).toLocaleString()} ${invoice.currency}`; const customerMessage = `Dear ${customerName}, payment of ${Number(dto.amount).toLocaleString()} ${invoice.currency} ` + `was recorded for warehouse fee ${invoice.invoiceNumber}.${bookingReference} Status: ${statusText}.`; - await this.sendSms(contacts.customerPhone, customerMessage, `warehouse fee payment ${invoice.invoiceNumber}`); + await this.sendSms( + contacts.customerPhone, + customerMessage, + `warehouse fee payment ${invoice.invoiceNumber}`, + ); - if (invoice.status !== 'PAID') return; + if (invoice.status !== "PAID") return; const driverPhone = dto.driverPhone?.trim() || contacts.driverPhone; - const driverName = dto.driverName?.trim() || contacts.driverName || 'Driver'; + const driverName = + dto.driverName?.trim() || contacts.driverName || "Driver"; const cargo = contacts.containerNumber || contacts.cargoDescription; const driverMessage = `Dear ${driverName}, warehouse demurrage/storage fee ${invoice.invoiceNumber} is paid.` + - (contacts.bookingReference ? ` Booking: ${contacts.bookingReference}.` : '') + - (cargo ? ` Cargo: ${cargo}.` : '') + - ' Proceed with pickup after gate verification.'; + (contacts.bookingReference + ? ` Booking: ${contacts.bookingReference}.` + : "") + + (cargo ? ` Cargo: ${cargo}.` : "") + + " Proceed with pickup after gate verification."; - await this.sendSms(driverPhone, driverMessage, `warehouse pickup driver ${invoice.invoiceNumber}`); + await this.sendSms( + driverPhone, + driverMessage, + `warehouse pickup driver ${invoice.invoiceNumber}`, + ); } } From 71894ae2d3442baa7c0984ea4cbd38b5a53104ef Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 2 Jul 2026 13:43:07 +0000 Subject: [PATCH 25/86] 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 && ( - - @@ -1202,12 +1208,18 @@ const FirstMilePage = () => { @@ -1143,11 +1149,17 @@ const LastMilePage = () => { setPaymentMethod((value as "TELEBIRR" | "WAAFI") ?? "TELEBIRR")} - data={[ - { value: "TELEBIRR", label: "Telebirr" }, - { value: "WAAFI", label: "Waafi" }, - ]} - w={170} - /> -======= {canViewSource && ( - - )} -======= styles={{ root: { fontWeight: 600, height: 42, paddingInline: 18 }, }} @@ -296,7 +267,6 @@ export default function InvoiceDetailPage() { )} ->>>>>>> 03740ee719f22f9617379a652b26f13b6870f671 {/* Summary */} From c2c29f0735723e6d953abd07d28c8c320e2033b2 Mon Sep 17 00:00:00 2001 From: natib21 Date: Thu, 2 Jul 2026 14:14:13 +0000 Subject: [PATCH 31/86] fix --- ...90000000000-SeparateVehicleAvailability.ts | 40 +++++++++++++++++++ .../modules/first-mile/first-mile.service.ts | 8 ++-- .../modules/last-mile/last-mile.service.ts | 8 ++-- .../vehicles/entities/vehicle.entity.ts | 10 ++++- .../modules/vehicles/vehicles.controller.ts | 2 + .../src/modules/vehicles/vehicles.service.ts | 13 ++++-- .../components/ContainerAllocationTable.tsx | 2 +- .../FirstMileContainerAllocationTable.tsx | 2 +- .../LastMileContainerAllocationTable.tsx | 2 +- .../src/pages/fleet/config/vehicles.ts | 10 +++-- .../src/pages/operations/FirstMilePage.tsx | 2 +- .../src/pages/operations/LastMilePage.tsx | 2 +- .../src/services/vehicles.service.ts | 6 ++- 13 files changed, 84 insertions(+), 23 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/1890000000000-SeparateVehicleAvailability.ts diff --git a/apps/edr-freight-api/src/migrations/1890000000000-SeparateVehicleAvailability.ts b/apps/edr-freight-api/src/migrations/1890000000000-SeparateVehicleAvailability.ts new file mode 100644 index 000000000..c0015c3c6 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1890000000000-SeparateVehicleAvailability.ts @@ -0,0 +1,40 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Split the mixed vehicle status into two fields: + * - status: operational state (ACTIVE, MAINTENANCE, RETIRED, OUT_OF_SERVICE) + * - availability: assignment state (FREE, BUSY) + * + * Existing FREE/BUSY statuses are moved to availability and the status is + * normalized back to ACTIVE. + */ +export class SeparateVehicleAvailability1890000000000 implements MigrationInterface { + name = "SeparateVehicleAvailability1890000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.vehicles + ADD COLUMN IF NOT EXISTS availability varchar DEFAULT 'FREE' + `); + await queryRunner.query(` + UPDATE freight.vehicles SET availability = 'BUSY' WHERE status = 'BUSY' + `); + await queryRunner.query(` + UPDATE freight.vehicles SET availability = 'FREE' WHERE availability IS NULL + `); + await queryRunner.query(` + UPDATE freight.vehicles SET status = 'ACTIVE' WHERE status IN ('FREE', 'BUSY') + `); + } + + public async down(queryRunner: QueryRunner): Promise { + // Fold availability back into status before dropping the column + await queryRunner.query(` + UPDATE freight.vehicles SET status = availability + WHERE status = 'ACTIVE' AND availability IN ('FREE', 'BUSY') + `); + await queryRunner.query(` + ALTER TABLE freight.vehicles DROP COLUMN IF EXISTS availability + `); + } +} diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts index 6fd1bc618..bffdefecf 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts @@ -7,7 +7,7 @@ import { BookingsRepository } from '../bookings/bookings.repository'; import { DriversService } from '../drivers/drivers.service'; import { SmsClientService } from '../notifications/sms-client.service'; import { VehiclesService } from '../vehicles/vehicles.service'; -import { VehicleStatus } from '../vehicles/entities/vehicle.entity'; +import { VehicleAvailability } from '../vehicles/entities/vehicle.entity'; import { CreateFirstMileDto } from './dto/create-first-mile.dto'; import { UpdateFirstMileDto } from './dto/update-first-mile.dto'; import { FirstMile, FirstMileStatus } from './entities/first-mile.entity'; @@ -194,7 +194,7 @@ export class FirstMileService { }); if (dto.vehicleId) { - await this.vehiclesService.setStatus(dto.vehicleId, VehicleStatus.BUSY); + await this.vehiclesService.setAvailability(dto.vehicleId, VehicleAvailability.BUSY); } return record; @@ -246,7 +246,7 @@ export class FirstMileService { // Keep vehicle statuses in sync: new vehicle goes BUSY, replaced one goes back to FREE if (dto.vehicleId !== undefined && dto.vehicleId !== existing.vehicleId) { if (dto.vehicleId) { - await this.vehiclesService.setStatus(dto.vehicleId, VehicleStatus.BUSY); + await this.vehiclesService.setAvailability(dto.vehicleId, VehicleAvailability.BUSY); } if (existing.vehicleId) { await this.vehiclesService.releaseIfUnused([existing.vehicleId]); @@ -374,7 +374,7 @@ export class FirstMileService { const vehicleIds = new Set(allocations.map((a) => a.vehicleId)); await Promise.all( - [...vehicleIds].map((vehicleId) => this.vehiclesService.setStatus(vehicleId, VehicleStatus.BUSY)), + [...vehicleIds].map((vehicleId) => this.vehiclesService.setAvailability(vehicleId, VehicleAvailability.BUSY)), ); await this.vehiclesService.releaseIfUnused( previousVehicleIds.filter((id) => !vehicleIds.has(id)), diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index 61e732bba..dec6c47b4 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts @@ -5,7 +5,7 @@ import { BookingsRepository } from '../bookings/bookings.repository'; import { DriversService } from '../drivers/drivers.service'; import { SmsClientService } from '../notifications/sms-client.service'; import { VehiclesService } from '../vehicles/vehicles.service'; -import { VehicleStatus } from '../vehicles/entities/vehicle.entity'; +import { VehicleAvailability } from '../vehicles/entities/vehicle.entity'; import { CreateLastMileDto } from './dto/create-last-mile.dto'; import { UpdateLastMileDto } from './dto/update-last-mile.dto'; import { LastMile, LastMileStatus } from './entities/last-mile.entity'; @@ -144,7 +144,7 @@ export class LastMileService { }); if (dto.vehicleId) { - await this.vehiclesService.setStatus(dto.vehicleId, VehicleStatus.BUSY); + await this.vehiclesService.setAvailability(dto.vehicleId, VehicleAvailability.BUSY); } return record; @@ -184,7 +184,7 @@ export class LastMileService { // Keep vehicle statuses in sync: new vehicle goes BUSY, replaced one goes back to FREE if (dto.vehicleId !== undefined && dto.vehicleId !== existing.vehicleId) { if (dto.vehicleId) { - await this.vehiclesService.setStatus(dto.vehicleId, VehicleStatus.BUSY); + await this.vehiclesService.setAvailability(dto.vehicleId, VehicleAvailability.BUSY); } if (existing.vehicleId) { await this.vehiclesService.releaseIfUnused([existing.vehicleId]); @@ -302,7 +302,7 @@ export class LastMileService { const vehicleIds = new Set(allocations.map((a) => a.vehicleId)); await Promise.all( - [...vehicleIds].map((vehicleId) => this.vehiclesService.setStatus(vehicleId, VehicleStatus.BUSY)), + [...vehicleIds].map((vehicleId) => this.vehiclesService.setAvailability(vehicleId, VehicleAvailability.BUSY)), ); await this.vehiclesService.releaseIfUnused( previousVehicleIds.filter((id) => !vehicleIds.has(id)), diff --git a/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts b/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts index a94210e65..5427fac94 100644 --- a/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts +++ b/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts @@ -20,13 +20,16 @@ export enum FuelType { export enum VehicleStatus { ACTIVE = 'ACTIVE', - FREE = 'FREE', - BUSY = 'BUSY', MAINTENANCE = 'MAINTENANCE', RETIRED = 'RETIRED', OUT_OF_SERVICE = 'OUT_OF_SERVICE', } +export enum VehicleAvailability { + FREE = 'FREE', + BUSY = 'BUSY', +} + @Entity({ name: 'vehicles', schema: 'freight' }) export class Vehicle extends BaseEntity { @Column({ name: 'plate_number', unique: true, nullable: true }) @@ -56,6 +59,9 @@ export class Vehicle extends BaseEntity { @Column({ name: 'status', type: 'varchar', default: VehicleStatus.ACTIVE, nullable: true }) status?: VehicleStatus; + @Column({ name: 'availability', type: 'varchar', default: VehicleAvailability.FREE, nullable: true }) + availability?: VehicleAvailability; + @Column({ type: 'text', nullable: true }) description?: string | null; diff --git a/apps/edr-freight-api/src/modules/vehicles/vehicles.controller.ts b/apps/edr-freight-api/src/modules/vehicles/vehicles.controller.ts index 24ff2d022..8e6d8a0a8 100644 --- a/apps/edr-freight-api/src/modules/vehicles/vehicles.controller.ts +++ b/apps/edr-freight-api/src/modules/vehicles/vehicles.controller.ts @@ -34,6 +34,7 @@ export class VehiclesController { findAll( @Query('search') search?: string, @Query('status') status?: string, + @Query('availability') availability?: string, @Query('page') page?: string, @Query('limit') limit?: string, @Query('sortBy') sortBy?: string, @@ -42,6 +43,7 @@ export class VehiclesController { return this.vehiclesService.findAll({ search, status: status as any, + availability: availability as any, page: page ? parseInt(page) : undefined, limit: limit ? parseInt(limit) : undefined, sortBy, diff --git a/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts b/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts index 61765a88d..a27969e2b 100644 --- a/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts +++ b/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts @@ -3,7 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Not, Repository } from 'typeorm'; import { CreateVehicleDto } from './dto/create-vehicle.dto'; import { UpdateVehicleDto } from './dto/update-vehicle.dto'; -import { Vehicle, VehicleStatus } from './entities/vehicle.entity'; +import { Vehicle, VehicleAvailability, VehicleStatus } from './entities/vehicle.entity'; import { FirstMile, FirstMileStatus } from '../first-mile/entities/first-mile.entity'; import { FirstMileContainerAllocation } from '../first-mile/entities/first-mile-container-allocation.entity'; import { LastMile, LastMileStatus } from '../last-mile/entities/last-mile.entity'; @@ -39,6 +39,7 @@ export class VehiclesService { async findAll(query: { search?: string; status?: VehicleStatus | string; + availability?: VehicleAvailability | string; page?: number; limit?: number; sortBy?: string; @@ -57,6 +58,10 @@ export class VehiclesService { qb = qb.andWhere('v.status = :status', { status: query.status }); } + if (query.availability) { + qb = qb.andWhere('v.availability = :availability', { availability: query.availability }); + } + const sortBy = ['plateNumber', 'status', 'year', 'createdAt'].includes( query.sortBy ?? '', ) @@ -95,8 +100,8 @@ export class VehiclesService { return this.vehicleRepo.save(vehicle); } - async setStatus(id: string, status: VehicleStatus): Promise { - await this.vehicleRepo.update(id, { status }); + async setAvailability(id: string, availability: VehicleAvailability): Promise { + await this.vehicleRepo.update(id, { availability }); } /** @@ -131,7 +136,7 @@ export class VehiclesService { .getCount(), ]); if (fmRecords + lmRecords + fmAllocations + lmAllocations === 0) { - await this.setStatus(vehicleId, VehicleStatus.FREE); + await this.setAvailability(vehicleId, VehicleAvailability.FREE); } } } diff --git a/apps/edr-freight-web/backoffice/src/components/ContainerAllocationTable.tsx b/apps/edr-freight-web/backoffice/src/components/ContainerAllocationTable.tsx index ab1fd028d..8971d4ccb 100644 --- a/apps/edr-freight-web/backoffice/src/components/ContainerAllocationTable.tsx +++ b/apps/edr-freight-web/backoffice/src/components/ContainerAllocationTable.tsx @@ -43,7 +43,7 @@ export function ContainerAllocationTable({ const { data: vehicles = [], isLoading: vehiclesLoading } = useQuery({ queryKey: ["vehicles", "free"], - queryFn: () => vehiclesService.getAll({ status: "FREE" }), + queryFn: () => vehiclesService.getAll({ status: "ACTIVE", availability: "FREE" }), }); const vehicleOptions = useMemo( diff --git a/apps/edr-freight-web/backoffice/src/components/FirstMileContainerAllocationTable.tsx b/apps/edr-freight-web/backoffice/src/components/FirstMileContainerAllocationTable.tsx index a1fc03539..78c5160ca 100644 --- a/apps/edr-freight-web/backoffice/src/components/FirstMileContainerAllocationTable.tsx +++ b/apps/edr-freight-web/backoffice/src/components/FirstMileContainerAllocationTable.tsx @@ -43,7 +43,7 @@ export function FirstMileContainerAllocationTable({ const { data: vehicles = [], isLoading: vehiclesLoading } = useQuery({ queryKey: ["vehicles", "free"], - queryFn: () => vehiclesService.getAll({ status: "FREE" }), + queryFn: () => vehiclesService.getAll({ status: "ACTIVE", availability: "FREE" }), }); const vehicleOptions = useMemo( diff --git a/apps/edr-freight-web/backoffice/src/components/LastMileContainerAllocationTable.tsx b/apps/edr-freight-web/backoffice/src/components/LastMileContainerAllocationTable.tsx index 1795aa793..cc619cb9a 100644 --- a/apps/edr-freight-web/backoffice/src/components/LastMileContainerAllocationTable.tsx +++ b/apps/edr-freight-web/backoffice/src/components/LastMileContainerAllocationTable.tsx @@ -43,7 +43,7 @@ export function LastMileContainerAllocationTable({ const { data: vehicles = [], isLoading: vehiclesLoading } = useQuery({ queryKey: ["vehicles", "free"], - queryFn: () => vehiclesService.getAll({ status: "FREE" }), + queryFn: () => vehiclesService.getAll({ status: "ACTIVE", availability: "FREE" }), }); const vehicleOptions = useMemo( diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts b/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts index c6923d9fa..344eec6eb 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts @@ -19,13 +19,16 @@ const FUEL_TYPE_OPTIONS = [ const VEHICLE_STATUS_OPTIONS = [ { label: "Active", value: "ACTIVE" }, - { label: "Free", value: "FREE" }, - { label: "Busy", value: "BUSY" }, { label: "Maintenance", value: "MAINTENANCE" }, { label: "Retired", value: "RETIRED" }, { label: "Out of service", value: "OUT_OF_SERVICE" }, ]; +const VEHICLE_AVAILABILITY_OPTIONS = [ + { label: "Free", value: "FREE" }, + { label: "Busy", value: "BUSY" }, +]; + export const vehiclesConfig: FleetResourceConfig = { slug: "vehicles", label: "Vehicles", @@ -58,6 +61,7 @@ export const vehiclesConfig: FleetResourceConfig = { { id: "capacity", header: "Capacity (tons)", accessorKey: "capacity", format: "number", size: 100 }, { id: "locationId", header: "Location", accessorKey: "locationId", size: 140 }, { id: "status", header: "Status", accessorKey: "status", format: "statusBadge", size: 100 }, + { id: "availability", header: "Availability", accessorKey: "availability", format: "statusBadge", size: 100 }, ], formFields: [ { name: "code", label: "Code", type: "text" }, @@ -95,4 +99,4 @@ export const vehiclesConfig: FleetResourceConfig = { }, }; -export { VEHICLE_TYPE_OPTIONS, FUEL_TYPE_OPTIONS, VEHICLE_STATUS_OPTIONS }; +export { VEHICLE_TYPE_OPTIONS, FUEL_TYPE_OPTIONS, VEHICLE_STATUS_OPTIONS, VEHICLE_AVAILABILITY_OPTIONS }; diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx index b956c2f31..4f401b3bc 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx @@ -357,7 +357,7 @@ const FirstMilePage = () => { const { data: vehiclesData } = useQuery({ queryKey: ["vehicles", "free"], queryFn: async () => { - const res = await vehiclesService.getAll({ status: "FREE" }); + const res = await vehiclesService.getAll({ status: "ACTIVE", availability: "FREE" }); return res.data; }, }); diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index cfb73a860..ae03aab75 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -341,7 +341,7 @@ const LastMilePage = () => { const { data: vehiclesData } = useQuery({ queryKey: ["vehicles", "free"], queryFn: async () => { - const res = await vehiclesService.getAll({ status: "FREE" }); + const res = await vehiclesService.getAll({ status: "ACTIVE", availability: "FREE" }); return res.data; }, }); diff --git a/apps/edr-freight-web/backoffice/src/services/vehicles.service.ts b/apps/edr-freight-web/backoffice/src/services/vehicles.service.ts index d5e9693d0..6de124df6 100644 --- a/apps/edr-freight-web/backoffice/src/services/vehicles.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/vehicles.service.ts @@ -3,10 +3,12 @@ import { URL_CONSTANTS } from '@/constants/URLS'; export type VehicleType = 'TRUCK' | 'VAN' | 'CAR' | 'BUS' | 'TRAILER' | 'TANKER' | 'FLATBED'; export type FuelType = 'PETROL' | 'DIESEL' | 'ELECTRIC' | 'HYBRID'; -export type VehicleStatus = 'ACTIVE' | 'FREE' | 'BUSY' | 'MAINTENANCE' | 'RETIRED' | 'OUT_OF_SERVICE'; +export type VehicleStatus = 'ACTIVE' | 'MAINTENANCE' | 'RETIRED' | 'OUT_OF_SERVICE'; +export type VehicleAvailability = 'FREE' | 'BUSY'; export interface VehicleListFilters { status?: VehicleStatus; + availability?: VehicleAvailability; search?: string; page?: number; limit?: number; @@ -25,6 +27,7 @@ export interface Vehicle { fuelType: FuelType; capacity: number; status: VehicleStatus; + availability: VehicleAvailability; description?: string | null; code?: string | null; powerPlateNo?: string | null; @@ -43,6 +46,7 @@ export const vehiclesService = { getAll: (filters: VehicleListFilters = {}) => { const params = new URLSearchParams(); if (filters.status) params.set('status', filters.status); + if (filters.availability) params.set('availability', filters.availability); if (filters.search) params.set('search', filters.search); if (filters.page) params.set('page', filters.page.toString()); if (filters.limit) params.set('limit', filters.limit.toString()); From 7d85f5dc2ec589a9244920e30c93399ae39c25e4 Mon Sep 17 00:00:00 2001 From: natib21 Date: Thu, 2 Jul 2026 14:18:53 +0000 Subject: [PATCH 32/86] fix --- .../src/modules/vehicles/dto/create-vehicle.dto.ts | 6 +++++- .../backoffice/src/pages/fleet/config/vehicles.ts | 2 ++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts b/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts index 5651a3906..9158ecca0 100644 --- a/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts +++ b/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts @@ -1,5 +1,5 @@ import { IsString, IsEnum, IsNumber, IsOptional, IsUUID } from 'class-validator'; -import { VehicleType, FuelType, VehicleStatus } from '../entities/vehicle.entity'; +import { VehicleType, FuelType, VehicleStatus, VehicleAvailability } from '../entities/vehicle.entity'; export class CreateVehicleDto { @IsString() @@ -26,6 +26,10 @@ export class CreateVehicleDto { @IsEnum(VehicleStatus) status!: VehicleStatus; + @IsOptional() + @IsEnum(VehicleAvailability) + availability?: VehicleAvailability; + @IsOptional() @IsString() description?: string; diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts b/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts index 344eec6eb..a8c8e90dd 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts @@ -78,6 +78,7 @@ export const vehiclesConfig: FleetResourceConfig = { { name: "estimatedDistanceKm", label: "Estimated Distance (KM)", type: "number" }, { name: "actualDistanceKm", label: "Actual Distance (KM)", type: "number" }, { name: "status", label: "Status", type: "select", required: true, options: VEHICLE_STATUS_OPTIONS }, + { name: "availability", label: "Availability", type: "select", required: true, options: VEHICLE_AVAILABILITY_OPTIONS }, { name: "description", label: "Description", type: "textarea" }, ], emptyValues: { @@ -95,6 +96,7 @@ export const vehiclesConfig: FleetResourceConfig = { estimatedDistanceKm: "", actualDistanceKm: "", status: "ACTIVE", + availability: "FREE", description: "", }, }; From e5fed081fccd45f26fa62c2edbd34f6c95062331 Mon Sep 17 00:00:00 2001 From: natib21 Date: Thu, 2 Jul 2026 14:27:09 +0000 Subject: [PATCH 33/86] fix --- .../src/modules/bookings/bookings.module.ts | 2 ++ .../src/modules/bookings/bookings.service.ts | 23 +++++++++++++++++++ .../src/modules/vehicles/vehicles.service.ts | 6 +++-- .../src/pages/bookings/BookingDetailPage.tsx | 1 + .../src/pages/operations/FirstMilePage.tsx | 3 +++ .../src/pages/operations/LastMilePage.tsx | 3 +++ 6 files changed, 36 insertions(+), 2 deletions(-) diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts index 4cd5d10df..72dbab183 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -39,6 +39,7 @@ import { ContractRendererService } from "../../contracts/contract-renderer.servi import { ContractTemplateResolver } from "../../contracts/contract-template.resolver"; import { ContractViewModelBuilder } from "../../contracts/contract-view-model.builder"; import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.module"; +import { VehiclesModule } from "../vehicles/vehicles.module"; @Module({ imports: [ @@ -58,6 +59,7 @@ import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.modu forwardRef(() => TrainSchedulingModule), FilesModule, MinioModule, + VehiclesModule, CompaniesModule, // CustomersModule, RuleEngineModule, diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index a4636cbf7..99d69c25f 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -31,6 +31,8 @@ import { ServiceType } from '../rule-engine/entities/service-type.entity'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; import { BookingsRepository } from './bookings.repository'; import { ConsolidationService } from './consolidation.service'; +import { VehiclesService } from '../vehicles/vehicles.service'; +import { VehicleAvailability } from '../vehicles/entities/vehicle.entity'; import { assertFreightShape } from './booking-freight.util'; import { CreateBookingContainerDto, CreateBookingDto } from './dto/create-booking.dto'; import { mapStatusCountsToTabs } from './booking-list-tabs.config'; @@ -81,6 +83,7 @@ export class BookingsService { private readonly ruleEngineService: RuleEngineService, private readonly containerTypesService: ContainerTypesService, private readonly consolidationService: ConsolidationService, + private readonly vehiclesService: VehiclesService, ) {} /** Resolve trade direction from yard countries; reject client mismatch. */ @@ -1348,6 +1351,16 @@ export class BookingsService { throw new NotFoundException(`Booking ${bookingId} not found`); } + const previousAllocations = await this.dataSource.manager.find(BookingContainerAllocation, { + where: { + bookingId, + containerId: In(allocations.map((a) => a.containerId)), + }, + }); + const previousVehicleIds = previousAllocations + .map((a) => a.vehicleId) + .filter((id): id is string => Boolean(id)); + await this.dataSource.transaction(async (manager) => { for (const allocation of allocations) { await manager.delete(BookingContainerAllocation, { @@ -1364,6 +1377,16 @@ export class BookingsService { } }); + const vehicleIds = new Set(allocations.map((a) => a.vehicleId)); + await Promise.all( + [...vehicleIds].map((vehicleId) => + this.vehiclesService.setAvailability(vehicleId, VehicleAvailability.BUSY), + ), + ); + await this.vehiclesService.releaseIfUnused( + previousVehicleIds.filter((id) => !vehicleIds.has(id)), + ); + return { success: true, allocated: allocations.length, diff --git a/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts b/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts index a27969e2b..5da3b448d 100644 --- a/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts +++ b/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts @@ -8,6 +8,7 @@ import { FirstMile, FirstMileStatus } from '../first-mile/entities/first-mile.en import { FirstMileContainerAllocation } from '../first-mile/entities/first-mile-container-allocation.entity'; import { LastMile, LastMileStatus } from '../last-mile/entities/last-mile.entity'; import { LastMileContainerAllocation } from '../last-mile/entities/last-mile-container-allocation.entity'; +import { BookingContainerAllocation } from '../bookings/entities/booking-container-allocation.entity'; @Injectable() export class VehiclesService { @@ -113,7 +114,7 @@ export class VehiclesService { async releaseIfUnused(vehicleIds: string[]): Promise { const manager = this.vehicleRepo.manager; for (const vehicleId of [...new Set(vehicleIds)]) { - const [fmRecords, lmRecords, fmAllocations, lmAllocations] = await Promise.all([ + const [fmRecords, lmRecords, fmAllocations, lmAllocations, bookingAllocations] = await Promise.all([ manager.count(FirstMile, { where: { vehicleId, status: Not('RECEIVED_TO_PORT') }, }), @@ -134,8 +135,9 @@ export class VehiclesService { .andWhere('lm.status != :done', { done: 'DELIVERED' }) .andWhere('lm.deletedAt IS NULL') .getCount(), + manager.count(BookingContainerAllocation, { where: { vehicleId } }), ]); - if (fmRecords + lmRecords + fmAllocations + lmAllocations === 0) { + if (fmRecords + lmRecords + fmAllocations + lmAllocations + bookingAllocations === 0) { await this.setAvailability(vehicleId, VehicleAvailability.FREE); } } diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingDetailPage.tsx index bf01c4818..d49e24f95 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingDetailPage.tsx @@ -33,6 +33,7 @@ const BookingDetailPage = () => { onSuccess: () => { toast.success("Containers allocated"); qc.invalidateQueries({ queryKey: QUERY_KEYS.BOOKINGS.byId(id ?? "") }); + qc.invalidateQueries({ queryKey: ["vehicles"] }); }, onError: () => { toast.error("Failed to allocate containers"); diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx index 4f401b3bc..78b15525b 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx @@ -400,6 +400,7 @@ const FirstMilePage = () => { firstMileService.update(id, data), onSuccess: () => { void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.list() }); + void qc.invalidateQueries({ queryKey: ["vehicles"] }); }, onError: () => { toast({ title: "Update failed", variant: "destructive" }); @@ -444,6 +445,7 @@ const FirstMilePage = () => { }, onSuccess: () => { void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.ROOT }); + void qc.invalidateQueries({ queryKey: ["vehicles"] }); toast({ title: "Booking accepted", description: "First-mile leg created successfully." }); closeAccept(); }, @@ -460,6 +462,7 @@ const FirstMilePage = () => { onSuccess: () => { toast({ title: "Containers allocated" }); void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.byId(containerAllocationFirstMileId ?? "") }); + void qc.invalidateQueries({ queryKey: ["vehicles"] }); setContainerAllocationOpen(false); setContainerAllocationFirstMileId(null); }, diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index ae03aab75..9284c4dae 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -377,6 +377,7 @@ const LastMilePage = () => { lastMileService.update(id, data), onSuccess: () => { void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.ROOT }); + void qc.invalidateQueries({ queryKey: ["vehicles"] }); }, onError: () => { toast({ title: "Update failed", variant: "destructive" }); @@ -415,6 +416,7 @@ const LastMilePage = () => { onSuccess: () => { toast({ title: "Containers allocated", variant: "default" }); void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.byId(activeId ?? "") }); + void qc.invalidateQueries({ queryKey: ["vehicles"] }); closeAllocation(); }, onError: () => { @@ -453,6 +455,7 @@ const LastMilePage = () => { }, onSuccess: (created) => { void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.ROOT }); + void qc.invalidateQueries({ queryKey: ["vehicles"] }); toast({ title: "Last-mile leg created", description: `${created.length} ${created.length === 1 ? "delivery" : "deliveries"} accepted successfully.`, From 2424e96e74619ac05eba6f46b5408c31f3ae99fe Mon Sep 17 00:00:00 2001 From: natib21 Date: Thu, 2 Jul 2026 14:34:22 +0000 Subject: [PATCH 34/86] fix --- .../src/modules/drivers/drivers.service.ts | 10 ++++------ .../src/modules/vehicles/vehicles.service.ts | 2 +- .../backoffice/src/pages/fleet/FleetResourcePage.tsx | 9 +++++++-- .../backoffice/src/pages/fleet/config/vehicles.ts | 6 ++++++ 4 files changed, 18 insertions(+), 9 deletions(-) diff --git a/apps/edr-freight-api/src/modules/drivers/drivers.service.ts b/apps/edr-freight-api/src/modules/drivers/drivers.service.ts index d5176d14b..2464a26ac 100644 --- a/apps/edr-freight-api/src/modules/drivers/drivers.service.ts +++ b/apps/edr-freight-api/src/modules/drivers/drivers.service.ts @@ -48,12 +48,10 @@ export class DriversService { const qb = this.driverRepo.createQueryBuilder('d'); if (query.search) { - const searchTerm = `%${query.search}%`; - qb.where('d.firstName ILIKE :search', { search: searchTerm }) - .orWhere('d.lastName ILIKE :search', { search: searchTerm }) - .orWhere('d.email ILIKE :search', { search: searchTerm }) - .orWhere('d.licenseNumber ILIKE :search', { search: searchTerm }) - .orWhere('d.phoneNumber ILIKE :search', { search: searchTerm }); + qb.where( + '(d.firstName ILIKE :search OR d.lastName ILIKE :search OR d.email ILIKE :search OR d.licenseNumber ILIKE :search OR d.phoneNumber ILIKE :search)', + { search: `%${query.search}%` }, + ); } if (query.status) { diff --git a/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts b/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts index 5da3b448d..5e151f1ea 100644 --- a/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts +++ b/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts @@ -50,7 +50,7 @@ export class VehiclesService { if (query.search) { qb = qb.where( - 'v.plateNumber ILIKE :search OR v.manufacturer ILIKE :search', + '(v.plateNumber ILIKE :search OR v.manufacturer ILIKE :search OR v.model ILIKE :search)', { search: `%${query.search}%` }, ); } diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx index 5310a6f12..13be9ef71 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx @@ -46,17 +46,22 @@ const FleetResourcePage = () => { const { viewMode, setViewMode } = useFleetViewMode(slug); const serverListFilters = useMemo((): FleetListFilters | undefined => { - if (slug !== "wagons" && slug !== "locomotives") return undefined; + const serverFilteredSlugs: FleetResourceSlug[] = ["wagons", "locomotives", "vehicles", "drivers"]; + if (!serverFilteredSlugs.includes(slug)) return undefined; const filters: FleetListFilters = {}; const status = listFilterValues.status; const currentYardId = listFilterValues.currentYardId; + const availability = listFilterValues.availability; if (status && status !== "ALL") { (filters as { status?: string }).status = status; } if (currentYardId && currentYardId !== "ALL") { filters.currentYardId = currentYardId; } - if (slug === "wagons" && search.trim()) { + if (availability && availability !== "ALL") { + (filters as { availability?: string }).availability = availability; + } + if (slug !== "locomotives" && search.trim()) { filters.search = search.trim(); } return filters; diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts b/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts index a8c8e90dd..de6eb09c7 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts @@ -49,6 +49,12 @@ export const vehiclesConfig: FleetResourceConfig = { allLabel: "All statuses", options: VEHICLE_STATUS_OPTIONS, }, + { + key: "availability", + label: "Availability", + allLabel: "All availability", + options: VEHICLE_AVAILABILITY_OPTIONS, + }, ], searchKeys: ["plateNumber", "registrationNumber", "manufacturer", "model", "vehicleType", "status"], columns: [ From b7da02496883d18f2d31d71040e013ab5c590f15 Mon Sep 17 00:00:00 2001 From: natib21 Date: Thu, 2 Jul 2026 14:37:38 +0000 Subject: [PATCH 35/86] fix --- .../1890000000001-AddVehicleCodeAndPlates.ts | 28 +++++++++++++++++++ .../vehicles/entities/vehicle.entity.ts | 9 ++++++ .../src/modules/vehicles/vehicles.service.ts | 2 +- 3 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 apps/edr-freight-api/src/migrations/1890000000001-AddVehicleCodeAndPlates.ts diff --git a/apps/edr-freight-api/src/migrations/1890000000001-AddVehicleCodeAndPlates.ts b/apps/edr-freight-api/src/migrations/1890000000001-AddVehicleCodeAndPlates.ts new file mode 100644 index 000000000..6f9faa1f8 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1890000000001-AddVehicleCodeAndPlates.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Add code, power_plate_no and trailer_plate_no columns to vehicles. + * These fields existed in the DTO and UI form but had no entity columns, + * so submitted values were silently dropped. + */ +export class AddVehicleCodeAndPlates1890000000001 implements MigrationInterface { + name = "AddVehicleCodeAndPlates1890000000001"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.vehicles + ADD COLUMN IF NOT EXISTS code varchar, + ADD COLUMN IF NOT EXISTS power_plate_no varchar, + ADD COLUMN IF NOT EXISTS trailer_plate_no varchar + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.vehicles + DROP COLUMN IF EXISTS code, + DROP COLUMN IF EXISTS power_plate_no, + DROP COLUMN IF EXISTS trailer_plate_no + `); + } +} diff --git a/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts b/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts index 5427fac94..416cddee6 100644 --- a/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts +++ b/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts @@ -32,9 +32,18 @@ export enum VehicleAvailability { @Entity({ name: 'vehicles', schema: 'freight' }) export class Vehicle extends BaseEntity { + @Column({ nullable: true }) + code?: string; + @Column({ name: 'plate_number', unique: true, nullable: true }) plateNumber?: string; + @Column({ name: 'power_plate_no', nullable: true }) + powerPlateNo?: string; + + @Column({ name: 'trailer_plate_no', nullable: true }) + trailerPlateNo?: string; + @Column({ name: 'registration_number', unique: true, nullable: true }) registrationNumber?: string; diff --git a/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts b/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts index 5e151f1ea..69568e483 100644 --- a/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts +++ b/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts @@ -50,7 +50,7 @@ export class VehiclesService { if (query.search) { qb = qb.where( - '(v.plateNumber ILIKE :search OR v.manufacturer ILIKE :search OR v.model ILIKE :search)', + '(v.plateNumber ILIKE :search OR v.manufacturer ILIKE :search OR v.model ILIKE :search OR v.code ILIKE :search OR v.trailerPlateNo ILIKE :search)', { search: `%${query.search}%` }, ); } From eb5d8a7411d76c03e9787366eeee85017959d027 Mon Sep 17 00:00:00 2001 From: natib21 Date: Thu, 2 Jul 2026 14:42:04 +0000 Subject: [PATCH 36/86] fix --- .../src/modules/last-mile/last-mile.service.ts | 14 ++++++++++++-- .../src/pages/operations/LastMilePage.tsx | 7 +++++-- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index dec6c47b4..54d992bd1 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { DataSource, FindOptionsWhere, In } from 'typeorm'; import { BookingsRepository } from '../bookings/bookings.repository'; @@ -165,6 +165,13 @@ export class LastMileService { async update(id: string, dto: UpdateLastMileDto): Promise { const existing = await this.findById(id); + if ( + existing.status === 'DELIVERED' && + (dto.vehicleId !== undefined || dto.exactKm !== undefined) + ) { + throw new BadRequestException('Delivered records cannot be reassigned or have distance changed'); + } + const dtoAny = dto as any; const updated = await this.lastMileRepository.update(id, { ...(dto.bookingId !== undefined ? { bookingId: dto.bookingId } : {}), @@ -261,7 +268,10 @@ export class LastMileService { } async remove(id: string): Promise { - await this.findById(id); + const existing = await this.findById(id); + if (existing.status === 'DELIVERED') { + throw new BadRequestException('Delivered records cannot be deleted'); + } await this.lastMileRepository.softDelete(id); } diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index 9284c4dae..eb6d88a92 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -833,6 +833,7 @@ const LastMilePage = () => { const nextStatus = NEXT_STATUS[row.original.status]; const canPrint = row.original.status !== "PAYMENT_PENDING"; const isPaid = (row.original as any).paid; + const delivered = row.original.status === "DELIVERED"; return ( @@ -852,14 +853,14 @@ const LastMilePage = () => { } - disabled={assigned} + disabled={assigned || delivered} onClick={() => openAssign(row.original.id)} > Assign } - disabled={!assigned} + disabled={!assigned || delivered} onClick={() => openAssign(row.original.id)} > Reassign @@ -873,6 +874,7 @@ const LastMilePage = () => { } + disabled={delivered} onClick={() => openDistance(row.original.id)} > Add distance @@ -891,6 +893,7 @@ const LastMilePage = () => { } color="red" + disabled={delivered} onClick={() => { if (confirm(`Delete last-mile record ${bookingRef(row.original)}?`)) { deleteMutation.mutate(row.original.id); From 26e2974d87443c09d1b8e7ea99b5e28f57e98546 Mon Sep 17 00:00:00 2001 From: yaschalew Date: Thu, 2 Jul 2026 18:02:23 +0300 Subject: [PATCH 37/86] fix --- .../modules/last-mile/last-mile.service.ts | 84 +------------------ 1 file changed, 4 insertions(+), 80 deletions(-) diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index dbdba14a5..69eec29ae 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts @@ -1,11 +1,10 @@ -import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; -import { DataSource, FindOptionsWhere, In } from 'typeorm'; +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { DataSource, FindOptionsWhere } from 'typeorm'; import { BookingsRepository } from '../bookings/bookings.repository'; import { DriversService } from '../drivers/drivers.service'; import { SmsClientService } from '../notifications/sms-client.service'; import { VehiclesService } from '../vehicles/vehicles.service'; -import { VehicleAvailability } from '../vehicles/entities/vehicle.entity'; import { CreateLastMileDto } from './dto/create-last-mile.dto'; import { UpdateLastMileDto } from './dto/update-last-mile.dto'; import { LastMile, LastMileStatus } from './entities/last-mile.entity'; @@ -72,6 +71,7 @@ export class LastMileService { return null; } + return this.create({ bookingId: booking.id, advancedPayment: 0, @@ -132,17 +132,7 @@ export class LastMileService { } async create(dto: CreateLastMileDto): Promise { -<<<<<<< HEAD - const record = await this.lastMileRepository.create({ -======= - const [existing] = await this.lastMileRepository.findAll({ - where: { bookingId: dto.bookingId }, - take: 1, - }); - if (existing) return existing; - return this.lastMileRepository.create({ ->>>>>>> 9d14414bf10079b38a04a709aa918c7e470dce34 bookingId: dto.bookingId, status: dto.status ?? 'READY_TO_TRANSIT', advancedPayment: dto.advancedPayment ?? 0, @@ -152,12 +142,6 @@ export class LastMileService { vehicleId: dto.vehicleId ?? null, paid: (dto as any).paid ?? false, }); - - if (dto.vehicleId) { - await this.vehiclesService.setAvailability(dto.vehicleId, VehicleAvailability.BUSY); - } - - return record; } @OnEvent("lastmile.invoice.paid") @@ -175,13 +159,6 @@ export class LastMileService { async update(id: string, dto: UpdateLastMileDto): Promise { const existing = await this.findById(id); - if ( - existing.status === 'DELIVERED' && - (dto.vehicleId !== undefined || dto.exactKm !== undefined) - ) { - throw new BadRequestException('Delivered records cannot be reassigned or have distance changed'); - } - const dtoAny = dto as any; const updated = await this.lastMileRepository.update(id, { ...(dto.bookingId !== undefined ? { bookingId: dto.bookingId } : {}), @@ -198,46 +175,14 @@ export class LastMileService { throw new NotFoundException(`Last-mile record ${id} not found`); } - // Keep vehicle statuses in sync: new vehicle goes BUSY, replaced one goes back to FREE - if (dto.vehicleId !== undefined && dto.vehicleId !== existing.vehicleId) { - if (dto.vehicleId) { - await this.vehiclesService.setAvailability(dto.vehicleId, VehicleAvailability.BUSY); - } - if (existing.vehicleId) { - await this.vehiclesService.releaseIfUnused([existing.vehicleId]); - } - } - // Notify assigned driver on every explicit vehicle assignment or reassignment if (dto.vehicleId) { void this.notifyDriverAssignment(dto.vehicleId, existing); } - // Trip finished — release the vehicles it was holding - if (dto.status === 'DELIVERED' && existing.status !== 'DELIVERED') { - await this.releaseVehicles(updated); - } - return updated; } - /** - * Free every vehicle held by this record (direct assignment + container - * allocations), unless still in use by another active trip. - */ - private async releaseVehicles(record: LastMile): Promise { - const recordAllocations = await this.dataSource.manager.find(LastMileContainerAllocation, { - where: { lastMileId: record.id }, - }); - const vehicleIds = recordAllocations - .map((a) => a.vehicleId) - .filter((id): id is string => Boolean(id)); - if (record.vehicleId) { - vehicleIds.push(record.vehicleId); - } - await this.vehiclesService.releaseIfUnused(vehicleIds); - } - private async notifyDriverAssignment(vehicleId: string, record: LastMile): Promise { try { const vehicle = await this.vehiclesService.findById(vehicleId); @@ -278,10 +223,7 @@ export class LastMileService { } async remove(id: string): Promise { - const existing = await this.findById(id); - if (existing.status === 'DELIVERED') { - throw new BadRequestException('Delivered records cannot be deleted'); - } + await this.findById(id); await this.lastMileRepository.softDelete(id); } @@ -294,16 +236,6 @@ export class LastMileService { throw new NotFoundException(`Last-mile record ${lastMileId} not found`); } - const previousAllocations = await this.dataSource.manager.find(LastMileContainerAllocation, { - where: { - lastMileId, - containerId: In(allocations.map((a) => a.containerId)), - }, - }); - const previousVehicleIds = previousAllocations - .map((a) => a.vehicleId) - .filter((id): id is string => Boolean(id)); - await this.dataSource.transaction(async (manager) => { for (const allocation of allocations) { await manager.delete(LastMileContainerAllocation, { @@ -320,14 +252,6 @@ export class LastMileService { } }); - const vehicleIds = new Set(allocations.map((a) => a.vehicleId)); - await Promise.all( - [...vehicleIds].map((vehicleId) => this.vehiclesService.setAvailability(vehicleId, VehicleAvailability.BUSY)), - ); - await this.vehiclesService.releaseIfUnused( - previousVehicleIds.filter((id) => !vehicleIds.has(id)), - ); - return { success: true, allocated: allocations.length, From 49036abf7fad248ccd34eba5b74e2730daa10432 Mon Sep 17 00:00:00 2001 From: yaschalew Date: Thu, 2 Jul 2026 18:07:21 +0300 Subject: [PATCH 38/86] fix --- .../src/modules/first-mile/first-mile.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts index fe478e572..40d163220 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts @@ -1,4 +1,4 @@ -import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { FindOptionsWhere, In } from 'typeorm'; import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; From dd47068a4be1c447b739f36f88d34e5ac6be0572 Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 2 Jul 2026 17:55:59 +0000 Subject: [PATCH 39/86] finilize gl flow for export --- .../contracts/booking-clearance.service.ts | 50 +- .../contracts/clearance-milestone.catalog.ts | 1 + .../contracts/clearance-milestone.service.ts | 33 + .../contracts/contract-clearance.service.ts | 60 +- .../modules/contracts/contracts.controller.ts | 102 +- .../src/modules/contracts/contracts.module.ts | 2 + .../contracts/dto/phased-clearance.dto.ts | 9 + .../entities/clearance-milestone.entity.ts | 2 + .../contracts/gl-operations.service.ts | 467 ++++++- .../contracts/phased-clearance.util.spec.ts | 8 +- .../contracts/ExportClearanceStepper.tsx | 1130 +++++++++++++++++ .../contracts/PhasedClearanceActionPanel.tsx | 535 +------- .../backoffice/src/constants/QUERY_KEYS.ts | 1 + .../backoffice/src/constants/URLS.ts | 9 + .../src/hooks/contracts/useContracts.ts | 9 + .../pages/contracts/GlClearanceDetailPage.tsx | 19 +- .../contracts/GlDjiboutiClearanceListPage.tsx | 309 ++++- .../src/services/contracts.service.ts | 53 + .../portal/src/constants/URLS.ts | 2 + .../pages/contracts/ContractDetailPage.tsx | 160 ++- .../portal/src/services/contracts.service.ts | 15 + .../src/freight/clearance-files.catalog.ts | 14 + packages/types/src/freight/contracts.ts | 61 + packages/types/src/freight/index.ts | 9 + 24 files changed, 2462 insertions(+), 598 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/components/contracts/ExportClearanceStepper.tsx 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 4e9a7b69d..45c96977e 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,10 @@ import { BadRequestException, Injectable } from '@nestjs/common'; -import { ContractDocPhase, type ClearanceT1State } from '@edr/types'; +import { + ContractDocPhase, + type ClearanceFinalInvoiceSummary, + type ClearanceT1State, + type ClearanceTrainState, +} from '@edr/types'; import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service'; import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service'; @@ -66,6 +71,15 @@ export interface BookingClearanceView { workflowFiles?: ReturnType; /** Import post-allocation T1 transit document state (null until wagon allocation). */ t1?: ClearanceT1State | null; + /** Train link state for the booking (both directions). */ + train?: ClearanceTrainState | null; + gatepassGranted?: boolean; + gatepassAt?: string | null; + t1Closed?: boolean; + t1ClosedAt?: string | null; + offloaded?: boolean; + /** GL Djibouti post-offload final invoice (export). */ + finalInvoice?: ClearanceFinalInvoiceSummary | null; } @Injectable() @@ -175,6 +189,18 @@ export class BookingClearanceService { } } + let train: ClearanceTrainState | null = null; + try { + train = await this.glOperationsService.trainState(bookingId); + } catch { + train = null; + } + const finalInvoice = await this.glOperationsService.finalInvoiceSummary(bookingId); + const bookingMilestone = (code: string) => + milestones.find((m) => m.milestoneCode === code); + const gatepassMilestone = bookingMilestone('GATEPASS_GRANTED'); + const t1ClosedMilestone = bookingMilestone('T1_CLOSED'); + return { bookingId, status: booking.status, @@ -206,6 +232,22 @@ export class BookingClearanceService { dutyAdvice, workflowFiles, t1, + train, + gatepassGranted: gatepassMilestone?.status === 'COMPLETED', + gatepassAt: + gatepassMilestone?.status === 'COMPLETED' + ? (gatepassMilestone.metadata?.gatepassAt ?? + (gatepassMilestone.triggeredAt + ? gatepassMilestone.triggeredAt.toISOString() + : null)) + : null, + t1Closed: t1ClosedMilestone?.status === 'COMPLETED', + t1ClosedAt: + t1ClosedMilestone?.status === 'COMPLETED' && t1ClosedMilestone.triggeredAt + ? t1ClosedMilestone.triggeredAt.toISOString() + : null, + offloaded: bookingMilestone('OFFLOADED')?.status === 'COMPLETED', + finalInvoice, }; } @@ -302,6 +344,12 @@ export class BookingClearanceService { : ContractDocPhase.CustomerDuty, } as never); + // Export: the declaration is the last GL ET pre-operation action — release + // immediately so the customer can proceed without a separate confirm click. + if (tradeDirection === 'EXPORT') { + await this.workflowService.onExportReleasedForBooking(bookingId, userId); + } + return this.bookingsService.findById(bookingId); } diff --git a/apps/edr-freight-api/src/modules/contracts/clearance-milestone.catalog.ts b/apps/edr-freight-api/src/modules/contracts/clearance-milestone.catalog.ts index ce6f7bea4..e9ad726fd 100644 --- a/apps/edr-freight-api/src/modules/contracts/clearance-milestone.catalog.ts +++ b/apps/edr-freight-api/src/modules/contracts/clearance-milestone.catalog.ts @@ -68,6 +68,7 @@ const EXPORT_DEFS: Record> = { DEPARTED_TO_DJIBOUTI: { label: 'Departed to Djibouti', ownerRegion: 'OPS', triggeredByDoc: false }, ARRIVED_AT_DJIBOUTI: { label: 'Arrived at Djibouti', ownerRegion: 'DJ', triggeredByDoc: false }, GATEPASS_GRANTED: { label: 'Gatepass Granted', ownerRegion: 'DJ', triggeredByDoc: false }, + T1_CLOSED: { label: 'T1 Closed', ownerRegion: 'DJ', triggeredByDoc: false }, OFFLOADED: { label: 'Offloaded', ownerRegion: 'DJ', triggeredByDoc: true }, }; diff --git a/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts b/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts index 7d30b1c5b..e033f8e9b 100644 --- a/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts @@ -91,6 +91,39 @@ export class ClearanceMilestoneService { }); } + /** + * Find-or-create a post-booking milestone row from the catalog. Needed for codes + * added to the catalog after a booking's rows were seeded (e.g. export T1_CLOSED). + */ + async ensureForBooking( + bookingId: string, + code: string, + tradeDirection: string, + ): Promise { + const existing = await this.repo.findOne({ where: { bookingId, milestoneCode: code } }); + if (existing) return existing; + + const { postBooking } = splitMilestones(tradeDirection); + const idx = postBooking.findIndex((d) => d.code === code); + if (idx < 0) { + throw new NotFoundException( + `Milestone ${code} is not a ${tradeDirection} post-booking milestone`, + ); + } + const def = postBooking[idx]!; + return this.repo.save( + this.repo.create({ + bookingId, + milestoneCode: def.code, + milestoneLabel: def.label, + ownerRegion: def.ownerRegion, + triggeredByDoc: def.triggeredByDoc, + status: 'PENDING', + sortOrder: idx, + }), + ); + } + /** Mark a milestone complete (by code) on a booking. */ async completeForBooking( bookingId: string, 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 34e3d3d94..9f8a5af7c 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,10 @@ import { BadRequestException, ConflictException, Injectable } from '@nestjs/common'; -import { ContractDocPhase, type ClearanceT1State } from '@edr/types'; +import { + ContractDocPhase, + type ClearanceFinalInvoiceSummary, + type ClearanceT1State, + type ClearanceTrainState, +} from '@edr/types'; import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service'; import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service'; @@ -80,6 +85,15 @@ export interface ContractClearanceView { workflowFiles?: ReturnType; /** Import post-allocation T1 transit document state (null until a booking is linked). */ t1?: ClearanceT1State | null; + /** Train link state for the booking (both directions; null until a booking is linked). */ + train?: ClearanceTrainState | null; + gatepassGranted?: boolean; + gatepassAt?: string | null; + t1Closed?: boolean; + t1ClosedAt?: string | null; + offloaded?: boolean; + /** GL Djibouti post-offload final invoice (export). */ + finalInvoice?: ClearanceFinalInvoiceSummary | null; } @Injectable() @@ -237,11 +251,27 @@ export class ContractClearanceService { } } - let nextAction = this.workflowService.computeNextAction(contract, cycle, milestones); - if (cycle?.bookingId && contract.tradeDirection === 'EXPORT') { - const bookingMilestones = await this.workflowService.listMilestonesForBooking( + let train: ClearanceTrainState | null = null; + let bookingMilestones: ClearanceMilestone[] = []; + let finalInvoice: ClearanceFinalInvoiceSummary | null = null; + if (cycle?.bookingId) { + try { + train = await this.glOperationsService.trainState(cycle.bookingId); + } catch { + train = null; + } + bookingMilestones = await this.workflowService.listMilestonesForBooking( cycle.bookingId, ); + finalInvoice = await this.glOperationsService.finalInvoiceSummary(cycle.bookingId); + } + const bookingMilestone = (code: string) => + bookingMilestones.find((m) => m.milestoneCode === code); + const gatepassMilestone = bookingMilestone('GATEPASS_GRANTED'); + const t1ClosedMilestone = bookingMilestone('T1_CLOSED'); + + let nextAction = this.workflowService.computeNextAction(contract, cycle, milestones); + if (cycle?.bookingId && contract.tradeDirection === 'EXPORT') { const booking = await this.bookingsService.findById(cycle.bookingId); if (booking) { nextAction = this.workflowService.computeNextActionForBooking( @@ -286,6 +316,22 @@ export class ContractClearanceService { dutyAdvice, workflowFiles, t1, + train, + gatepassGranted: gatepassMilestone?.status === 'COMPLETED', + gatepassAt: + gatepassMilestone?.status === 'COMPLETED' + ? (gatepassMilestone.metadata?.gatepassAt ?? + (gatepassMilestone.triggeredAt + ? gatepassMilestone.triggeredAt.toISOString() + : null)) + : null, + t1Closed: t1ClosedMilestone?.status === 'COMPLETED', + t1ClosedAt: + t1ClosedMilestone?.status === 'COMPLETED' && t1ClosedMilestone.triggeredAt + ? t1ClosedMilestone.triggeredAt.toISOString() + : null, + offloaded: bookingMilestone('OFFLOADED')?.status === 'COMPLETED', + finalInvoice, }; } @@ -873,6 +919,12 @@ export class ContractClearanceService { }); } + // Export: the declaration is the last GL ET pre-booking action — release + // immediately so booking creation unlocks without a separate confirm click. + if (contract.tradeDirection === 'EXPORT') { + await this.workflowService.onExportReleased(contractId, userId); + } + 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 872d13a0c..6cfec95f3 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -77,6 +77,7 @@ import { } from './dto/gl-operations.dto'; import { AdviseContractDutyDto, + GatepassDto, RoAmendmentDto, } from './dto/phased-clearance.dto'; @@ -681,6 +682,30 @@ export class ContractsController { return this.clearanceService.djQueue(filter); } + @Get('clearance/dj-schedules') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) + @ApiOperation({ summary: 'Train schedules carrying customs bookings — GL DJ gate-pass table' }) + djClearanceSchedules() { + return this.glOperationsService.djSchedules(); + } + + @Post('clearance/schedules/:scheduleId/gatepass') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) + @ApiOperation({ + summary: 'GL DJ grants the gate pass for every customs booking on a train schedule', + }) + grantScheduleGatepass( + @Param('scheduleId', ParseUUIDPipe) scheduleId: string, + @Body() dto: GatepassDto, + @CurrentUser() user: AuthUserPayload, + ) { + return this.glOperationsService.grantScheduleGatepass( + scheduleId, + dto?.gatepassAt, + resolveAuthUserId(user), + ); + } + // ── Path A self-clearance — Operations reviews the customer's own docs ─────── @Get('clearance/ops-queue') @@ -889,9 +914,13 @@ export class ContractsController { } @Post('bookings/:bookingId/t1-close') - @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) + @BookingStaff([ + FREIGHT_PERMS.contracts.clearanceEtActions, + FREIGHT_PERMS.contracts.clearanceDjActions, + ]) @ApiOperation({ - summary: 'GL Ethiopia closes (accepts) the T1 document set after the train arrives', + summary: + 'Close (accept) the T1 set — GL ET after arrival (import) / GL DJ after gate pass (export)', }) closeT1( @Param('bookingId', ParseUUIDPipe) bookingId: string, @@ -900,6 +929,75 @@ export class ContractsController { return this.glOperationsService.closeT1(bookingId, resolveAuthUserId(user)); } + @Post('bookings/:bookingId/gatepass') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) + @ApiOperation({ summary: 'GL DJ grants the gate pass for a customs booking (captures time)' }) + grantGatepass( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @Body() dto: GatepassDto, + @CurrentUser() user: AuthUserPayload, + ) { + return this.glOperationsService.grantGatepass( + bookingId, + dto?.gatepassAt, + resolveAuthUserId(user), + ); + } + + @Post('bookings/:bookingId/final-invoice') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) + @UseInterceptors(FileInterceptor('file')) + @ApiConsumes('multipart/form-data') + @ApiOperation({ + summary: 'GL DJ raises the post-offload final invoice (amount + invoice document)', + }) + createFinalInvoice( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @Body('amount') amountRaw: string, + @Body('currency') currency: string | undefined, + @Body('description') description: string | undefined, + @UploadedFile() file: Express.Multer.File, + @CurrentUser() user: AuthUserPayload, + ) { + return this.glOperationsService.createFinalInvoice( + bookingId, + { + amount: Number(amountRaw), + currency: currency?.trim() || 'ETB', + description, + }, + file, + resolveAuthUserId(user), + ); + } + + @Post('bookings/:bookingId/final-invoice-slip') + @UseInterceptors(FileInterceptor('file')) + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: 'Customer attaches the payment slip for the final invoice' }) + uploadFinalInvoiceSlip( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @UploadedFile() file: Express.Multer.File, + ) { + return this.glOperationsService.uploadFinalInvoiceSlip(bookingId, file); + } + + @Post('bookings/:bookingId/final-invoice/confirm') + @BookingStaff([ + FREIGHT_PERMS.contracts.clearanceDjActions, + FREIGHT_PERMS.contracts.clearanceEtActions, + ]) + @ApiOperation({ summary: 'GL (ET or DJ) confirms the payment slip — settles the final invoice' }) + confirmFinalInvoicePaid( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @CurrentUser() user: AuthUserPayload, + ) { + return this.glOperationsService.confirmFinalInvoicePaid( + bookingId, + resolveAuthUserId(user), + ); + } + @Post('bookings/:bookingId/documents') @BookingStaff(FREIGHT_PERMS.bookings.uploadClearanceOutput) @UseInterceptors(AnyFilesInterceptor()) diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.module.ts b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts index 94f469112..e0eece986 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.module.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts @@ -3,6 +3,7 @@ import { ConfigService } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; import { ExchangeModule, ExchangeOptions } from '@edr/api-common'; +import { BillingModule } from '../billing/billing.module'; import { CompaniesModule } from '../companies/companies.module'; import { FilesModule } from '../files/files.module'; import { MinioModule } from '../minio/minio.module'; @@ -64,6 +65,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum Booking, BookingContainerUnit, ]), + BillingModule, RuleEngineModule, FileUploadSettingsModule, DropdownSettingsModule, diff --git a/apps/edr-freight-api/src/modules/contracts/dto/phased-clearance.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/phased-clearance.dto.ts index f48653822..6b784073b 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/phased-clearance.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/phased-clearance.dto.ts @@ -35,3 +35,12 @@ export class RoAmendmentDto { @IsString() note?: string; } + +export class GatepassDto { + @ApiPropertyOptional({ + description: 'When the gate pass was granted (ISO datetime; defaults to now)', + }) + @IsOptional() + @IsString() + gatepassAt?: string; +} diff --git a/apps/edr-freight-api/src/modules/contracts/entities/clearance-milestone.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/clearance-milestone.entity.ts index 502afaf6a..d4676b8cd 100644 --- a/apps/edr-freight-api/src/modules/contracts/entities/clearance-milestone.entity.ts +++ b/apps/edr-freight-api/src/modules/contracts/entities/clearance-milestone.entity.ts @@ -23,6 +23,8 @@ export interface MilestoneMetadata { dutyAmount?: number; dutyCurrency?: string; declarationSerial?: string; + /** When the gate pass was physically granted (GL DJ captures the time). */ + gatepassAt?: string; } /** 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 f900c564f..581257f18 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,24 @@ -import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; -import { DataSource } from 'typeorm'; -import { isT1TransportFileCode, type Freight } from '@edr/types'; +import { + BadRequestException, + ConflictException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { DataSource, In, IsNull } from 'typeorm'; +import { Freight, GL_FINAL_INVOICE_TYPE, isT1TransportFileCode } from '@edr/types'; +import { BillingService } from '../billing/billing.service'; +import { InvoiceLine } from '../billing/entities/invoice-line.entity'; import { FilesService } from '../files/files.service'; import { Booking } from '../bookings/entities/booking.entity'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; +import { ImportDjiboutiOperation } from '../train-scheduling/entities/import-djibouti-operation.entity'; import { ClearanceIncident, IncidentType, } from './entities/clearance-incident.entity'; +import { ClearanceMilestone } from './entities/clearance-milestone.entity'; +import { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity'; import { ClearanceMilestoneService } from './clearance-milestone.service'; import { persistExportTransportUploads, @@ -43,6 +53,7 @@ export class GlOperationsService { private readonly dataSource: DataSource, private readonly filesService: FilesService, private readonly milestoneService: ClearanceMilestoneService, + private readonly billingService: BillingService, ) {} private get bookings() { @@ -167,12 +178,8 @@ 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 { + /** Wagon-allocation + train-schedule actuals for a booking (both directions). */ + async trainState(bookingId: string): Promise { const booking = await this.getBooking(bookingId); const milestones = await this.milestoneService.listForBooking(bookingId); @@ -190,19 +197,35 @@ export class GlOperationsService { .findOne({ where: { id: booking.trainScheduleId } }); } + return { + wagonAllocated, + departedAt: schedule?.actualDepartureAt + ? new Date(schedule.actualDepartureAt).toISOString() + : null, + arrivedAt: schedule?.actualArrivalAt + ? new Date(schedule.actualArrivalAt).toISOString() + : null, + }; + } + + /** + * 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 train = await this.trainState(bookingId); + const milestones = await this.milestoneService.listForBooking(bookingId); + const closedMilestone = milestones.find( (m) => m.milestoneCode === 'T1_CLOSED' && m.status === 'COMPLETED', ); return { bookingId, - wagonAllocated, - trainDepartedAt: schedule?.actualDepartureAt - ? new Date(schedule.actualDepartureAt).toISOString() - : null, - trainArrivedAt: schedule?.actualArrivalAt - ? new Date(schedule.actualArrivalAt).toISOString() - : null, + wagonAllocated: train.wagonAllocated, + trainDepartedAt: train.departedAt, + trainArrivedAt: train.arrivedAt, closed: Boolean(closedMilestone), closedAt: closedMilestone?.triggeredAt ? new Date(closedMilestone.triggeredAt).toISOString() @@ -243,38 +266,418 @@ export class GlOperationsService { } /** - * GL Ethiopia closes (accepts) the T1 document set once the train has arrived. - * Completes the T1_CLOSED milestone; the document set becomes final. + * Close (accept) the T1/transport document set. + * Import: GL Ethiopia closes once the train has arrived (T1 files required). + * Export: GL Djibouti closes after the gate pass (transport document required). */ 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 tradeDirection = booking.tradeDirection ?? 'IMPORT'; 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.', - ); + if (tradeDirection === 'IMPORT') { + if (!state.trainArrivedAt) { + throw new BadRequestException( + 'The train has not arrived yet — T1 can be closed only after arrival.', + ); + } + const files = await this.filesService.findByResource(bookingId, 'bookings'); + const hasT1 = files.some((f) => isT1TransportFileCode(f.code)); + if (!hasT1) { + throw new BadRequestException( + 'No T1 transport documents on file — GL Djibouti must upload them first.', + ); + } + } else { + const milestones = await this.milestoneService.listForBooking(bookingId); + const done = (code: string) => + milestones.find((m) => m.milestoneCode === code)?.status === 'COMPLETED'; + if (!done('EXPORT_TRANSPORT_ISSUED')) { + throw new BadRequestException( + 'The transport document must be uploaded before T1 can be closed.', + ); + } + if (!done('GATEPASS_GRANTED')) { + throw new BadRequestException('Grant the gate pass before closing T1.'); + } + // Export bookings seeded before T1_CLOSED joined the catalog lack the row. + await this.milestoneService.ensureForBooking(bookingId, 'T1_CLOSED', tradeDirection); } await this.milestoneService.completeForBooking(bookingId, 'T1_CLOSED', userId); return this.t1State(bookingId); } + /** Milestones GL DJ implicitly confirms when granting an export gate pass. */ + private static readonly EXPORT_ARRIVAL_CHAIN = [ + 'CARGO_ARRIVED', + 'READY_FOR_LOADING', + 'LOADED', + 'DEPARTED_TO_DJIBOUTI', + 'ARRIVED_AT_DJIBOUTI', + ]; + + /** + * GL Djibouti grants the gate pass for a customs booking, capturing the time. + * Export: requires the train to have arrived at Djibouti; back-fills the + * arrival-chain milestones. Import: requires wagon allocation (pre-loading). + */ + async grantGatepass( + bookingId: string, + gatepassAt?: string, + userId?: string, + ): Promise<{ bookingId: string; gatepassAt: string }> { + const booking = await this.getBooking(bookingId); + if (!booking.customsClearingEnabled) { + throw new BadRequestException('Gate pass applies to customs bookings only.'); + } + const tradeDirection = booking.tradeDirection ?? 'IMPORT'; + const milestones = await this.milestoneService.listForBooking(bookingId); + const byCode = new Map(milestones.map((m) => [m.milestoneCode, m])); + + const existing = byCode.get('GATEPASS_GRANTED'); + if (existing?.status === 'COMPLETED') { + return { + bookingId, + gatepassAt: + existing.metadata?.gatepassAt ?? + (existing.triggeredAt ? new Date(existing.triggeredAt).toISOString() : ''), + }; + } + + const train = await this.trainState(bookingId); + if (tradeDirection === 'EXPORT') { + if (!train.arrivedAt) { + throw new BadRequestException( + 'The train has not arrived at Djibouti yet — gate pass can be granted after arrival.', + ); + } + for (const code of GlOperationsService.EXPORT_ARRIVAL_CHAIN) { + if (byCode.get(code)?.status === 'PENDING') { + await this.milestoneService.completeForBooking(bookingId, code, userId); + } + } + } else if (!train.wagonAllocated) { + throw new BadRequestException( + 'Wagons must be allocated before the gate pass can be granted.', + ); + } + + const at = gatepassAt?.trim() || new Date().toISOString(); + await this.milestoneService.completeWithMetadataForBooking( + bookingId, + 'GATEPASS_GRANTED', + { gatepassAt: at }, + userId, + ); + return { bookingId, gatepassAt: at }; + } + + /** Train schedules carrying ≥1 customs booking — the GL Djibouti gate-pass table. */ + async djSchedules(): Promise { + const schedules = await this.dataSource.getRepository(TrainSchedule).find({ + relations: { + scheduleBookings: { booking: true }, + originStation: true, + destinationStation: true, + }, + order: { scheduledDepartureDate: 'DESC' }, + }); + + const withCustoms = schedules + .filter((s) => s.status !== 'CANCELLED') + .map((s) => ({ + schedule: s, + customs: (s.scheduleBookings ?? []) + .map((sb) => sb.booking) + .filter((b): b is Booking => Boolean(b?.customsClearingEnabled)), + })) + .filter((s) => s.customs.length > 0); + + const bookingIds = withCustoms.flatMap((s) => s.customs.map((b) => b.id)); + const gatepassRows = bookingIds.length + ? await this.dataSource.getRepository(ClearanceMilestone).find({ + where: { bookingId: In(bookingIds), milestoneCode: 'GATEPASS_GRANTED' }, + }) + : []; + const gatepassByBooking = new Map(gatepassRows.map((m) => [m.bookingId, m])); + + return withCustoms.map(({ schedule, customs }) => { + const freightTypes = [...new Set(customs.map((b) => b.freightType).filter(Boolean))]; + return { + id: schedule.id, + trainNumber: schedule.trainNumber ?? null, + routeName: null, + origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null, + destination: + schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null, + status: schedule.status, + scheduledDepartureDate: schedule.scheduledDepartureDate + ? new Date(schedule.scheduledDepartureDate).toISOString() + : null, + actualDepartureAt: schedule.actualDepartureAt + ? new Date(schedule.actualDepartureAt).toISOString() + : null, + actualArrivalAt: schedule.actualArrivalAt + ? new Date(schedule.actualArrivalAt).toISOString() + : null, + freightType: + freightTypes.length === 1 ? (freightTypes[0] as string) : freightTypes.length ? 'MIXED' : null, + customsBookings: customs.map((b) => { + const m = gatepassByBooking.get(b.id); + const granted = m?.status === 'COMPLETED'; + return { + bookingId: b.id, + reference: b.reference ?? b.id, + tradeDirection: b.tradeDirection ?? 'IMPORT', + contractId: b.contractId ?? null, + gatepassGranted: granted, + gatepassAt: granted + ? (m?.metadata?.gatepassAt ?? + (m?.triggeredAt ? new Date(m.triggeredAt).toISOString() : null)) + : null, + }; + }), + }; + }); + } + + /** + * One-click gate pass for every customs booking on a train schedule. Per-booking + * guard failures are collected, not fatal. Import schedules also get the + * schedule-level ImportDjiboutiOperation gate pass so loading unblocks. + */ + async grantScheduleGatepass( + scheduleId: string, + gatepassAt?: string, + userId?: string, + ): Promise<{ granted: number; skipped: Array<{ bookingId: string; error: string }> }> { + const schedule = await this.dataSource.getRepository(TrainSchedule).findOne({ + where: { id: scheduleId }, + relations: { scheduleBookings: { booking: true } }, + }); + if (!schedule) throw new NotFoundException(`Train schedule ${scheduleId} not found`); + + const customs = (schedule.scheduleBookings ?? []) + .map((sb) => sb.booking) + .filter((b): b is Booking => Boolean(b?.customsClearingEnabled)); + if (customs.length === 0) { + throw new BadRequestException('No customs bookings ride this schedule.'); + } + + let granted = 0; + const skipped: Array<{ bookingId: string; error: string }> = []; + for (const booking of customs) { + try { + await this.grantGatepass(booking.id, gatepassAt, userId); + granted += 1; + } catch (e) { + skipped.push({ + bookingId: booking.id, + error: e instanceof Error ? e.message : 'Failed', + }); + } + } + + if (granted > 0 && customs.some((b) => (b.tradeDirection ?? 'IMPORT') === 'IMPORT')) { + const opRepo = this.dataSource.getRepository(ImportDjiboutiOperation); + let operation = await opRepo.findOne({ where: { trainScheduleId: scheduleId } }); + if (!operation) { + operation = opRepo.create({ trainScheduleId: scheduleId }); + } + if (!operation.gatepassGrantedAt) { + operation.gatepassGrantedAt = gatepassAt ? new Date(gatepassAt) : new Date(); + await opRepo.save(operation); + } + } + + return { granted, skipped }; + } + + /** + * GL Djibouti raises the post-offload final invoice (export): manual amount + + * attached invoice document. The customer pays offline and attaches a slip; + * GL (ET or DJ) then confirms to settle it. + */ + async createFinalInvoice( + bookingId: string, + input: { amount: number; currency: string; description?: string }, + file: Express.Multer.File, + userId?: string, + ): Promise { + const booking = await this.getBooking(bookingId); + if (!booking.customsClearingEnabled) { + throw new BadRequestException('Final invoice applies to customs bookings only.'); + } + if (!(input.amount > 0)) { + throw new BadRequestException('Invoice amount must be greater than zero.'); + } + if (!file) throw new BadRequestException('Attach the invoice document.'); + + const milestones = await this.milestoneService.listForBooking(bookingId); + const offloaded = milestones.find( + (m) => m.milestoneCode === 'OFFLOADED' && m.status === 'COMPLETED', + ); + if (!offloaded) { + throw new BadRequestException( + 'Cargo must be offloaded before the final invoice can be raised.', + ); + } + + const existing = await this.billingService.findInvoice( + Freight.InvoiceSource.Booking, + bookingId, + GL_FINAL_INVOICE_TYPE, + ); + if ( + existing && + existing.status !== Freight.InvoiceStatus.Cancelled && + existing.status !== Freight.InvoiceStatus.Expired + ) { + throw new ConflictException('A final invoice already exists for this shipment.'); + } + + const description = input.description?.trim() || 'Post-offload charges (Djibouti)'; + await this.billingService.generateInvoice({ + source: Freight.InvoiceSource.Booking, + sourceId: bookingId, + type: GL_FINAL_INVOICE_TYPE, + companyId: booking.companyId, + companyProfileId: booking.companyProfileId, + currency: input.currency, + lines: [ + { + chargeType: GL_FINAL_INVOICE_TYPE, + description, + quantity: 1, + unitRate: input.amount, + amount: input.amount, + }, + ], + status: Freight.InvoiceStatus.Issued, + }); + + await this.filesService.upsertByCode({ + resourceId: bookingId, + resource: 'bookings', + code: 'final_invoice', + file, + }); + + // Export clearance is administratively done once the final invoice goes out. + await this.dataSource + .getRepository(ContractClearanceCycle) + .update({ bookingId, completedAt: IsNull() }, { completedAt: new Date() }); + + void userId; + const summary = await this.finalInvoiceSummary(bookingId); + if (!summary) throw new NotFoundException('Final invoice could not be created.'); + return summary; + } + + /** Customer attaches the payment slip for the final invoice. */ + async uploadFinalInvoiceSlip( + bookingId: string, + file: Express.Multer.File, + ): Promise<{ uploaded: boolean }> { + await this.getBooking(bookingId); + if (!file) throw new BadRequestException('No payment slip uploaded'); + + const invoice = await this.billingService.findInvoice( + Freight.InvoiceSource.Booking, + bookingId, + GL_FINAL_INVOICE_TYPE, + ); + if (!invoice) { + throw new BadRequestException('No final invoice has been issued for this shipment.'); + } + if (invoice.status === Freight.InvoiceStatus.Paid) { + throw new BadRequestException('The final invoice is already paid.'); + } + if ( + invoice.status === Freight.InvoiceStatus.Cancelled || + invoice.status === Freight.InvoiceStatus.Expired + ) { + throw new BadRequestException('The final invoice is no longer payable.'); + } + + await this.filesService.upsertByCode({ + resourceId: bookingId, + resource: 'bookings', + code: 'final_invoice_slip', + file, + }); + return { uploaded: true }; + } + + /** GL (ET or DJ) confirms the customer's slip — settles the final invoice. */ + async confirmFinalInvoicePaid( + bookingId: string, + userId?: string, + ): Promise { + await this.getBooking(bookingId); + const invoice = await this.billingService.findInvoice( + Freight.InvoiceSource.Booking, + bookingId, + GL_FINAL_INVOICE_TYPE, + ); + if (!invoice) { + throw new BadRequestException('No final invoice has been issued for this shipment.'); + } + if (invoice.status !== Freight.InvoiceStatus.Paid) { + const files = await this.filesService.findByResource(bookingId, 'bookings'); + if (!files.some((f) => f.code === 'final_invoice_slip')) { + throw new BadRequestException( + 'The customer has not attached a payment slip yet.', + ); + } + await this.billingService.markInvoiceAsPaid(invoice.id); + } + + void userId; + const summary = await this.finalInvoiceSummary(bookingId); + if (!summary) throw new NotFoundException('Final invoice not found.'); + return summary; + } + + /** Final-invoice state joined with its document + slip files, for clearance views. */ + async finalInvoiceSummary( + bookingId: string, + ): Promise { + const invoice = await this.billingService.findInvoice( + Freight.InvoiceSource.Booking, + bookingId, + GL_FINAL_INVOICE_TYPE, + ); + if (!invoice) return null; + + const files = await this.filesService.findByResource(bookingId, 'bookings'); + const toRef = (code: string) => { + const f = files.find((x) => x.code === code); + return f ? { id: f.id, name: f.name, url: f.url } : null; + }; + const line = await this.dataSource + .getRepository(InvoiceLine) + .findOne({ where: { invoiceId: invoice.id } }); + + return { + id: invoice.id, + invoiceNumber: invoice.invoiceNumber, + status: invoice.status, + totalAmount: Number(invoice.totalAmount), + currency: invoice.currency, + description: line?.description ?? null, + invoiceFile: toRef('final_invoice'), + slipFile: toRef('final_invoice_slip'), + confirmedAt: invoice.paidAt ? new Date(invoice.paidAt).toISOString() : null, + }; + } + /** * 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.spec.ts b/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.spec.ts index cf044d6a9..40646ac47 100644 --- a/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.spec.ts @@ -107,8 +107,12 @@ describe('belongsOnDjClearanceQueue', () => { ).toBe(true); }); - it('excludes import contracts still on Ethiopia-side clearance only', () => { - expect(belongsOnDjClearanceQueue('IMPORT', null, [])).toBe(false); + it('keeps import contracts from the start — DO upload is un-gated', () => { + expect(belongsOnDjClearanceQueue('IMPORT', null, [])).toBe(true); + }); + + it('excludes export contracts with no DJ activity or RO hold', () => { + expect(belongsOnDjClearanceQueue('EXPORT', null, [])).toBe(false); }); }); diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ExportClearanceStepper.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ExportClearanceStepper.tsx new file mode 100644 index 000000000..d7cd9207b --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ExportClearanceStepper.tsx @@ -0,0 +1,1130 @@ +import { useMemo, useState } from "react"; +import { + Alert, + Badge, + Button, + FileInput, + Group, + Modal, + NumberInput, + Paper, + Select, + Stack, + Stepper, + Text, + Textarea, +} from "@mantine/core"; +import { DateInput, DateTimePicker } from "@mantine/dates"; +import { + AlertTriangle, + CheckCircle2, + FileText, + PackageCheck, + Receipt, + Ship, + Train, + Truck, + Upload, +} from "lucide-react"; +import type { Freight } from "@edr/types"; +import toast from "react-hot-toast"; + +import { SectionCard } from "@/components/bookings/detail/SectionCard"; +import { PhasedFileDropzone } from "@/components/contracts/PhasedFileDropzone"; +import { + TransitPermitMultiUpload, + type TransitPermitUploadedRow, +} from "@/components/contracts/TransitPermitMultiUpload"; +import { + findWorkflowFile, + PhasedUploadedFileRow, +} from "@/components/contracts/PhasedUploadedFileRow"; +import { + DeclarationStep, + StepStatus, + isBookingMilestoneDone, + isMilestoneDone, + type ClearanceViewLike, + type MilestoneRow, +} from "@/components/contracts/PhasedClearanceActionPanel"; +import { contractsService } from "@/services/contracts.service"; +import { bookingsService } from "@/services/bookings.service"; + +/** + * Export customs flow, ordered per the stakeholder process: + * customer docs → RO (DJ) → declaration (ET, auto-releases) → create booking (ET) + * → payment + wagons → transport document (ET) → train to Djibouti → gate pass (DJ) + * → accept T1 (DJ) → final invoice (DJ) + customer slip + GL confirm. + */ +export function computeExportActiveStep( + clearance: ClearanceViewLike, + bookingMilestones: MilestoneRow[], + bookingCreated: boolean, +): number { + const released = Boolean(clearance.bookingReady || clearance.operationReady); + if (!isMilestoneDone(clearance.milestones, "DOCUMENTS_APPROVED")) return 0; + if (!isMilestoneDone(clearance.milestones, "RELEASE_ORDER_SECURED")) return 1; + if (!isMilestoneDone(clearance.milestones, "DECLARED") || !released) return 2; + if (!bookingCreated) return 3; + if ( + !isBookingMilestoneDone(bookingMilestones, "FREIGHT_PAYMENT_SETTLED") || + !( + isBookingMilestoneDone(bookingMilestones, "WAGON_ALLOCATED") || + clearance.train?.wagonAllocated + ) + ) { + return 4; + } + if (!isBookingMilestoneDone(bookingMilestones, "EXPORT_TRANSPORT_ISSUED")) return 5; + if (!clearance.train?.arrivedAt) return 6; + if (!clearance.gatepassGranted) return 7; + if (!clearance.t1Closed) return 8; + if (clearance.finalInvoice?.status !== "PAID") return 9; + return 10; +} + +export function exportTransitFilesFromWorkflow( + workflowFiles: Freight.ClearanceWorkflowFile[], +): TransitPermitUploadedRow[] { + return workflowFiles + .filter( + (f) => + f.category === "transit" && + f.code.toLowerCase().startsWith("export_transport_document") && + f.file, + ) + .map((f) => ({ + code: f.code, + label: f.label, + file: f.file!, + })); +} + +export function ExportClearanceStepper({ + contractId, + bookingId, + clearance, + workflowFiles = [], + showEt, + canEt, + showDj, + canDj, + onChanged, + bookingCreateHref, + onViewFile, + onDownloadFile, + useUploadModals = false, + onUploadRoRequest, + bookingCreated = false, + bookingMilestones = [], +}: { + contractId?: string; + bookingId?: string; + clearance: ClearanceViewLike; + workflowFiles?: Freight.ClearanceWorkflowFile[]; + showEt: boolean; + canEt: boolean; + showDj: boolean; + canDj: boolean; + onChanged?: () => void; + bookingCreateHref?: string; + onViewFile?: (file: { name: string; url: string }) => void; + onDownloadFile?: (file: { id: string; name: string }) => void; + useUploadModals?: boolean; + onUploadRoRequest?: () => void; + bookingCreated?: boolean; + bookingMilestones?: MilestoneRow[]; +}) { + // Pre-booking actions (RO, declaration, release) target the contract when one + // is present; GENERAL customs bookings run the same flow keyed on the booking. + const isBooking = Boolean(bookingId) && !contractId; + const entityId = contractId ?? bookingId ?? ""; + // The booking that carries the post-booking steps (gate pass, T1, invoice). + const actionBookingId = clearance.linkedBookingId ?? bookingId ?? null; + const effectiveBookingCreated = + bookingCreated || Boolean(clearance.linkedBookingId) || isBooking; + + const activeStep = useMemo( + () => computeExportActiveStep(clearance, bookingMilestones, effectiveBookingCreated), + [clearance, bookingMilestones, effectiveBookingCreated], + ); + + const released = Boolean(clearance.bookingReady || clearance.operationReady); + const declared = isMilestoneDone(clearance.milestones, "DECLARED"); + const paymentSettled = isBookingMilestoneDone(bookingMilestones, "FREIGHT_PAYMENT_SETTLED"); + const wagonAllocated = + isBookingMilestoneDone(bookingMilestones, "WAGON_ALLOCATED") || + Boolean(clearance.train?.wagonAllocated); + const transportIssued = isBookingMilestoneDone(bookingMilestones, "EXPORT_TRANSPORT_ISSUED"); + + return ( + + {clearance.roHold && clearance.roHoldReason ? ( + } title="Release Order on hold"> + {clearance.roHoldReason} + + ) : null} + + {clearance.nextAction ? ( + + + {clearance.nextAction.actor.replace("_", " ")} —{" "} + {clearance.nextAction.action} + + + ) : null} + + + + Export customs clearance + + + + ) : undefined + } + > + + + + } + > + {showDj && canDj ? ( + useUploadModals ? ( + + ) : ( + + ) + ) : ( + + {findWorkflowFile(workflowFiles, "release_order") ? ( + + ) : null} + {clearance.vesselDepartureDate ? ( + + Vessel departure:{" "} + {new Date(clearance.vesselDepartureDate).toLocaleDateString()} + + ) : null} + + + )} + + + : } + > + {showEt && canEt && !effectiveBookingCreated && (activeStep >= 2 || declared) ? ( + + + {declared && !released ? ( + + ) : null} + + ) : ( + + )} + + + } + > + {released && bookingCreateHref && !effectiveBookingCreated && showEt && canEt ? ( + + + Export is released. Create the shipment booking for the customer. + + + + ) : ( + + )} + + + } + > + + + + + + + : } + > + {showEt && canEt && actionBookingId && wagonAllocated ? ( + + ) : ( + + {exportTransitFilesFromWorkflow(workflowFiles).map((row) => ( + + ))} + + + )} + + + } + > + + + + + + + : } + > + + + + : } + > + + + + + ) : ( + + ) + } + > + + + + + + ); +} + +/** Legacy in-flight contracts: declaration done before auto-release existed. */ +function ConfirmExportReleaseFallback({ + entityId, + isBooking, + onChanged, +}: { + entityId: string; + isBooking: boolean; + onChanged?: () => void; +}) { + const [loading, setLoading] = useState(false); + return ( + + ); +} + +function GatepassStep({ + bookingId, + clearance, + canAct, + onChanged, +}: { + bookingId: string | null; + clearance: ClearanceViewLike; + canAct: boolean; + onChanged?: () => void; +}) { + const [opened, setOpened] = useState(false); + const [at, setAt] = useState(new Date()); + const [loading, setLoading] = useState(false); + + if (clearance.gatepassGranted) { + return ( + + ); + } + + const arrived = Boolean(clearance.train?.arrivedAt); + + return ( + + + {canAct && bookingId ? ( + <> + + setOpened(false)} + title={Grant gate pass} + radius="md" + size="sm" + > + + setAt(v ? new Date(v) : null)} + required + /> + + + + + + + + ) : null} + + ); +} + +function AcceptT1Step({ + bookingId, + clearance, + transportIssued, + workflowFiles = [], + canAct, + onChanged, + onViewFile, + onDownloadFile, +}: { + bookingId: string | null; + clearance: ClearanceViewLike; + transportIssued: boolean; + workflowFiles?: Freight.ClearanceWorkflowFile[]; + canAct: boolean; + onChanged?: () => void; + onViewFile?: (file: { name: string; url: string }) => void; + onDownloadFile?: (file: { id: string; name: string }) => void; +}) { + const [loading, setLoading] = useState(false); + const files = exportTransitFilesFromWorkflow(workflowFiles); + + return ( + + {files.map((row) => ( + + ))} + {clearance.t1Closed ? ( + + ) : ( + <> + + {canAct && bookingId ? ( + + ) : null} + + )} + + ); +} + +function FinalInvoiceStep({ + bookingId, + clearance, + canDjAct, + canConfirm, + onChanged, + onViewFile, + onDownloadFile, +}: { + bookingId: string | null; + clearance: ClearanceViewLike; + canDjAct: boolean; + canConfirm: boolean; + onChanged?: () => void; + onViewFile?: (file: { name: string; url: string }) => void; + onDownloadFile?: (file: { id: string; name: string }) => void; +}) { + const [opened, setOpened] = useState(false); + const [amount, setAmount] = useState(""); + const [currency, setCurrency] = useState("ETB"); + const [description, setDescription] = useState(""); + const [file, setFile] = useState(null); + const [sending, setSending] = useState(false); + const [confirming, setConfirming] = useState(false); + + const invoice = clearance.finalInvoice ?? null; + const paid = invoice?.status === "PAID"; + + if (!clearance.offloaded && !invoice) { + return ( + + ); + } + + return ( + + {invoice ? ( + + +
+ + {invoice.invoiceNumber} + + + {invoice.totalAmount.toLocaleString()} {invoice.currency} + {invoice.description ? ` — ${invoice.description}` : ""} + +
+ + {invoice.status} + +
+
+ ) : null} + + {invoice?.invoiceFile ? ( + + ) : null} + {invoice?.slipFile ? ( + + ) : null} + + {paid ? ( + + ) : invoice ? ( + <> + + {canConfirm && bookingId && invoice.slipFile ? ( + + ) : null} + + ) : canDjAct && bookingId ? ( + <> + + Cargo offloaded — send the final invoice to the customer. + + + setOpened(false)} + title={Send final invoice} + radius="md" + size="md" + > + + + +