From 503878110f4bfe2ca968edc90529452d91033ddc Mon Sep 17 00:00:00 2001 From: hagiye Date: Wed, 24 Jun 2026 23:25:34 +0300 Subject: [PATCH] DJbouti port Export Unloading --- .../entities/warehouse-inventory.entity.ts | 4 +- .../warehouses/scheduling-read.facade.ts | 220 +++++++++++ .../warehouse-inventory.controller.ts | 30 ++ .../warehouses/warehouse-inventory.service.ts | 249 ++++++++++++ apps/edr-freight-web/backoffice/src/App.tsx | 7 + .../warehouses/ActivityTimeline.tsx | 5 + .../src/components/warehouses/badges.tsx | 1 + .../backoffice/src/constants/URLS.ts | 4 + .../backoffice/src/hooks/useWarehouses.ts | 22 ++ .../ExportDjiboutiUnloadingQueuePage.tsx | 353 ++++++++++++++++++ .../src/services/warehouse.service.ts | 14 + .../backoffice/src/types/warehouse.ts | 45 +++ 12 files changed, 953 insertions(+), 1 deletion(-) create mode 100644 apps/edr-freight-web/backoffice/src/pages/warehouses/ExportDjiboutiUnloadingQueuePage.tsx diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts index a5d4e1eeb..815841e54 100644 --- a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts @@ -15,6 +15,7 @@ import { WarehouseZone } from './warehouse-zone.entity'; // IMPORT: READY_FOR_PICKUP → DELIVERED (release order + proof of delivery) export const WAREHOUSE_INVENTORY_STATUSES = [ 'UNLOADED', + 'UNLOADED_AT_DJIBOUTI_PORT', 'RECEIVED', 'STORED', 'RESERVED', @@ -31,12 +32,13 @@ export const WAREHOUSE_INVENTORY_TRANSITIONS: Record + normalized.includes(token), + ); + } + /** Look up a single physical wagon. Returns null if it does not exist. */ async findWagon(wagonId: string): Promise { const rows = await this.dataSource.query( @@ -220,4 +257,187 @@ export class SchedulingReadFacade { ); return rows; } + + /** + * ARRIVED export train schedules at Djibouti-side destinations, with assigned item counts. + * Read-only: this only selects from scheduling/booking/inventory tables. + */ + async exportDjiboutiArrivalQueue( + filter: ExportDjiboutiQueueFilter = {}, + ): Promise { + const params: unknown[] = []; + const where = [ + 'ts.deleted_at IS NULL', + "ts.status = ANY($1)", + `EXISTS ( + SELECT 1 + FROM freight.train_schedule_bookings tsb_exists + JOIN freight.bookings b_exists ON b_exists.id = tsb_exists.booking_id AND b_exists.deleted_at IS NULL + LEFT JOIN freight.warehouse_inventory inv_exists ON inv_exists.booking_id = b_exists.id AND inv_exists.deleted_at IS NULL + WHERE tsb_exists.train_schedule_id = ts.id + AND tsb_exists.deleted_at IS NULL + AND (inv_exists.status = ANY($2) OR b_exists.status = ANY($2)) + )`, + ]; + params.push( + filter.status ? [filter.status] : ['ARRIVED', 'ARRIVED_AT_DJIBOUTI'], + ['DISPATCHED', 'IN_TRANSIT', 'ARRIVED_AT_DJIBOUTI', 'ARRIVED_AT_PORT', 'ARRIVED_AT_DESTINATION'], + ); + + if (filter.scheduleId) { + params.push(filter.scheduleId); + where.push(`ts.id = $${params.length}`); + } + if (filter.destination) { + params.push(`%${filter.destination}%`); + where.push(`(dy.code ILIKE $${params.length} OR dy.name ILIKE $${params.length})`); + } + if (filter.dateFrom) { + params.push(filter.dateFrom); + where.push(`COALESCE(ts.actual_arrival_at, ts.scheduled_arrival_date) >= $${params.length}`); + } + if (filter.dateTo) { + params.push(filter.dateTo); + where.push(`COALESCE(ts.actual_arrival_at, ts.scheduled_arrival_date) <= $${params.length}`); + } + + const rows: Array< + ExportTrainRow & { + originCountry: string | null; + destinationCountry: string | null; + destinationName: string | null; + } + > = await this.dataSource.query( + `SELECT ts.id AS "scheduleId", + ts.train_number AS "trainNumber", + oy.code AS "origin", + dy.code AS "destination", + dy.name AS "destinationName", + oy.country AS "originCountry", + dy.country AS "destinationCountry", + ts.scheduled_departure_date AS "departureTime", + COALESCE(ts.actual_arrival_at, ts.scheduled_arrival_date) AS "arrivalTime", + ts.status, + (SELECT count(*) FROM freight.train_schedule_bookings tsb + WHERE tsb.train_schedule_id = ts.id AND tsb.deleted_at IS NULL) AS "totalBookings", + (SELECT count(*) FROM freight.containers c + JOIN freight.train_schedule_bookings tsbc ON tsbc.booking_id = c.booking_id AND tsbc.deleted_at IS NULL + WHERE tsbc.train_schedule_id = ts.id AND c.deleted_at IS NULL) AS "totalContainers", + (SELECT count(*) FROM freight.cargoes cg + JOIN freight.train_schedule_bookings tsbg ON tsbg.booking_id = cg.booking_id AND tsbg.deleted_at IS NULL + WHERE tsbg.train_schedule_id = ts.id AND cg.deleted_at IS NULL) AS "totalCargoes" + FROM freight.train_schedules ts + LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id + LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id + WHERE ${where.join(' AND ')} + ORDER BY COALESCE(ts.actual_arrival_at, ts.scheduled_arrival_date) DESC NULLS LAST`, + params, + ); + + return rows + .filter((r) => { + const direction = deriveTradeDirection( + { country: r.originCountry }, + { country: r.destinationCountry }, + ); + return ( + direction === 'EXPORT' && + this.isDjiboutiPortDestination(`${r.destination ?? ''} ${r.destinationName ?? ''}`) + ); + }) + .map(({ originCountry: _oc, destinationCountry: _dc, destinationName: _dn, ...rest }) => ({ + ...rest, + totalBookings: Number(rest.totalBookings) || 0, + totalContainers: Number(rest.totalContainers) || 0, + totalCargoes: Number(rest.totalCargoes) || 0, + route: rest.origin || rest.destination ? `${rest.origin ?? '?'} -> ${rest.destination ?? '?'}` : null, + })); + } + + /** Assigned export booking items for an arrived Djibouti-side export train. Read-only. */ + async exportDjiboutiTrainDetail(scheduleId: string): Promise { + const rows: ExportTrainItemRow[] = await this.dataSource.query( + `WITH assigned AS ( + SELECT b.id AS booking_id, + b.reference, + b.company_id, + company.name AS customer_name, + COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS cargo_type, + b.cargo_total_weight_vgm AS booking_weight, + oy.code AS origin, + dy.code AS destination, + ts.train_number, + COALESCE(ts.actual_arrival_at, ts.scheduled_arrival_date) AS arrival_time, + inv.id AS inventory_id, + COALESCE(inv.status, b.status) AS current_status + FROM freight.train_schedule_bookings tsb + JOIN freight.train_schedules ts ON ts.id = tsb.train_schedule_id + JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL + LEFT JOIN freight.companies company ON company.id = b.company_id + LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id + LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id + LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id + LEFT JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL + WHERE tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL + ) + SELECT a.booking_id AS "bookingId", + a.reference AS "bookingReference", + a.company_id AS "customerId", + a.customer_name AS "customerName", + 'CONTAINER' AS "itemType", + c.id AS "itemId", + a.inventory_id AS "inventoryId", + c.container_number AS "containerNumber", + a.cargo_type AS "cargoType", + a.booking_weight AS "weight", + a.origin, + a.destination, + a.train_number AS "trainSchedule", + a.arrival_time AS "arrivalTime", + a.current_status AS "currentStatus" + FROM assigned a + JOIN freight.containers c ON c.booking_id = a.booking_id AND c.deleted_at IS NULL + UNION ALL + SELECT a.booking_id AS "bookingId", + a.reference AS "bookingReference", + a.company_id AS "customerId", + a.customer_name AS "customerName", + 'CARGO' AS "itemType", + cg.id AS "itemId", + a.inventory_id AS "inventoryId", + NULL AS "containerNumber", + COALESCE(cgt.cargo_type_name, a.cargo_type) AS "cargoType", + COALESCE(cg.weight, a.booking_weight) AS "weight", + a.origin, + a.destination, + a.train_number AS "trainSchedule", + a.arrival_time AS "arrivalTime", + a.current_status AS "currentStatus" + FROM assigned a + JOIN freight.cargoes cg ON cg.booking_id = a.booking_id AND cg.deleted_at IS NULL + LEFT JOIN freight.cargo_types cgt ON cgt.id = cg.cargo_type_id + UNION ALL + SELECT a.booking_id AS "bookingId", + a.reference AS "bookingReference", + a.company_id AS "customerId", + a.customer_name AS "customerName", + CASE WHEN a.cargo_type ILIKE '%container%' THEN 'CONTAINER' ELSE 'CARGO' END AS "itemType", + a.inventory_id AS "itemId", + a.inventory_id AS "inventoryId", + NULL AS "containerNumber", + a.cargo_type AS "cargoType", + a.booking_weight AS "weight", + a.origin, + a.destination, + a.train_number AS "trainSchedule", + a.arrival_time AS "arrivalTime", + a.current_status AS "currentStatus" + FROM assigned a + WHERE NOT EXISTS (SELECT 1 FROM freight.containers c WHERE c.booking_id = a.booking_id AND c.deleted_at IS NULL) + AND NOT EXISTS (SELECT 1 FROM freight.cargoes cg WHERE cg.booking_id = a.booking_id AND cg.deleted_at IS NULL) + ORDER BY "bookingReference" ASC NULLS LAST, "itemType" ASC`, + [scheduleId], + ); + return rows; + } } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts index 6e2f17b39..230192a91 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts @@ -143,6 +143,36 @@ export class WarehouseInventoryController { return this.inventoryService.importUnloadedQueue(); } + @Get('export/djibouti-arrival-queue') + @ApiOperation({ summary: 'Arrived EXPORT train schedules at Djibouti-side ports, ready for unloading' }) + exportDjiboutiArrivalQueue( + @Query('scheduleId') scheduleId?: string, + @Query('destination') destination?: string, + @Query('status') status?: string, + @Query('dateFrom') dateFrom?: string, + @Query('dateTo') dateTo?: string, + ) { + return this.scheduling.exportDjiboutiArrivalQueue({ + scheduleId, + destination, + status, + dateFrom, + dateTo, + }); + } + + @Get('export/djibouti-trains/:scheduleId/items') + @ApiOperation({ summary: 'Assigned export bookings/items for an arrived Djibouti-side train' }) + exportDjiboutiTrainDetail(@Param('scheduleId', ParseUUIDPipe) scheduleId: string) { + return this.scheduling.exportDjiboutiTrainDetail(scheduleId); + } + + @Post('export/auto-unload-at-djibouti') + @ApiOperation({ summary: 'Unload all eligible export items assigned to an arrived Djibouti-side train' }) + autoUnloadExportAtDjibouti(@Body() dto: { scheduleId: string; performedBy?: string }) { + return this.inventoryService.autoUnloadExportAtDjibouti(dto.scheduleId, dto.performedBy); + } + @Get('import/pickup-ready-queue') @ApiOperation({ summary: 'IMPORT inventory that is PICKUP_READY (READY_FOR_PICKUP) awaiting pickup/dispatch' }) importPickupReadyQueue() { diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index bc47e535c..26b459525 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -235,6 +235,22 @@ export interface AutoUnloadArrivedResult { results: { bookingId: string; inventoryId?: string; status: string; reason?: string }[]; } +export interface AutoUnloadExportDjiboutiResult { + unloadedCount: number; + skippedCount: number; + failedCount: number; + results: Array<{ + bookingId: string; + itemType: 'CONTAINER' | 'CARGO'; + itemId?: string | null; + inventoryId?: string; + containerNumber?: string | null; + status: string; + message?: string; + reason?: string; + }>; +} + export interface ImportUnloadedRow { id: string; bookingId: string | null; @@ -869,6 +885,20 @@ export class WarehouseInventoryService { /** Booking statuses that must never be unloaded into warehouse inventory. */ private readonly IMPORT_UNLOAD_BLOCKED_STATUSES = ['DRAFT', 'REJECTED', 'CANCELLED', 'COMPLETED']; + private readonly EXPORT_DJIBOUTI_UNLOAD_ELIGIBLE_STATUSES = [ + 'DISPATCHED', + 'IN_TRANSIT', + 'ARRIVED_AT_DJIBOUTI', + 'ARRIVED_AT_PORT', + 'ARRIVED_AT_DESTINATION', + ]; + + private isDjiboutiPortDestination(value: string | null | undefined): boolean { + const normalized = (value ?? '').toUpperCase(); + return ['DJIBOUTI', 'DORALEH', 'DMP', 'DCT', 'NAGAD'].some((token) => + normalized.includes(token), + ); + } /** * Batch 8 — unload all eligible assigned bookings of an ARRIVED import train into UNLOADED state. @@ -1010,6 +1040,225 @@ export class WarehouseInventoryService { return result; } + /** + * Unload eligible EXPORT inventory from an arrived Djibouti-side train. + * This only advances warehouse inventory items assigned to the train and does not write to + * train schedules, wagon assignment, rescheduling, or booking payment state. + */ + async autoUnloadExportAtDjibouti( + scheduleId: string, + performedBy?: string, + ): Promise { + const result: AutoUnloadExportDjiboutiResult = { + unloadedCount: 0, + skippedCount: 0, + failedCount: 0, + results: [], + }; + + const [schedule] = await this.dataSource.query( + `SELECT ts.id, + ts.status, + oy.country AS "originCountry", + dy.country AS "destinationCountry", + dy.code AS "destinationCode", + dy.name AS "destinationName" + FROM freight.train_schedules ts + LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id + LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id + WHERE ts.id = $1 AND ts.deleted_at IS NULL + LIMIT 1`, + [scheduleId], + ); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + + const direction = deriveTradeDirection( + { country: schedule.originCountry }, + { country: schedule.destinationCountry }, + ); + if (direction !== 'EXPORT') { + throw new BadRequestException(`Train schedule route is ${direction}, not EXPORT`); + } + if (!this.isDjiboutiPortDestination(`${schedule.destinationCode ?? ''} ${schedule.destinationName ?? ''}`)) { + throw new BadRequestException('Train schedule destination is not Djibouti / Doraleh / DMP / DCT / Nagad'); + } + if (!['ARRIVED', 'ARRIVED_AT_DJIBOUTI'].includes(schedule.status)) { + throw new BadRequestException(`Train schedule is ${schedule.status}, not ARRIVED`); + } + + const items: Array<{ + bookingId: string; + inventoryId: string | null; + inventoryStatus: string | null; + bookingStatus: string | null; + warehouseId: string | null; + yardId: string | null; + zoneId: string | null; + itemType: 'CONTAINER' | 'CARGO'; + itemId: string | null; + containerNumber: string | null; + }> = await this.dataSource.query( + `WITH assigned AS ( + SELECT b.id AS booking_id, + b.status AS booking_status, + inv.id AS inventory_id, + inv.status AS inventory_status, + inv.warehouse_id, + inv.yard_id, + inv.zone_id + FROM freight.train_schedule_bookings tsb + JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL + LEFT JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL + WHERE tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL + ) + SELECT a.booking_id AS "bookingId", + a.inventory_id AS "inventoryId", + a.inventory_status AS "inventoryStatus", + a.booking_status AS "bookingStatus", + a.warehouse_id AS "warehouseId", + a.yard_id AS "yardId", + a.zone_id AS "zoneId", + 'CONTAINER' AS "itemType", + c.id AS "itemId", + c.container_number AS "containerNumber" + FROM assigned a + JOIN freight.containers c ON c.booking_id = a.booking_id AND c.deleted_at IS NULL + UNION ALL + SELECT a.booking_id AS "bookingId", + a.inventory_id AS "inventoryId", + a.inventory_status AS "inventoryStatus", + a.booking_status AS "bookingStatus", + a.warehouse_id AS "warehouseId", + a.yard_id AS "yardId", + a.zone_id AS "zoneId", + 'CARGO' AS "itemType", + cg.id AS "itemId", + NULL AS "containerNumber" + FROM assigned a + JOIN freight.cargoes cg ON cg.booking_id = a.booking_id AND cg.deleted_at IS NULL + UNION ALL + SELECT a.booking_id AS "bookingId", + a.inventory_id AS "inventoryId", + a.inventory_status AS "inventoryStatus", + a.booking_status AS "bookingStatus", + a.warehouse_id AS "warehouseId", + a.yard_id AS "yardId", + a.zone_id AS "zoneId", + 'CARGO' AS "itemType", + a.inventory_id AS "itemId", + NULL AS "containerNumber" + FROM assigned a + WHERE NOT EXISTS (SELECT 1 FROM freight.containers c WHERE c.booking_id = a.booking_id AND c.deleted_at IS NULL) + AND NOT EXISTS (SELECT 1 FROM freight.cargoes cg WHERE cg.booking_id = a.booking_id AND cg.deleted_at IS NULL)`, + [scheduleId], + ); + + const seenInventory = new Set(); + const now = new Date(); + + await this.dataSource.transaction(async (manager) => { + for (const item of items) { + const skip = (reason: string) => { + result.skippedCount += 1; + result.results.push({ + bookingId: item.bookingId, + itemType: item.itemType, + itemId: item.itemId, + inventoryId: item.inventoryId ?? undefined, + containerNumber: item.containerNumber, + status: 'SKIPPED', + reason, + }); + }; + const fail = (reason: string) => { + result.failedCount += 1; + result.results.push({ + bookingId: item.bookingId, + itemType: item.itemType, + itemId: item.itemId, + inventoryId: item.inventoryId ?? undefined, + containerNumber: item.containerNumber, + status: 'FAILED', + reason, + }); + }; + + if (!item.inventoryId || !item.warehouseId || !item.yardId || !item.zoneId) { + skip('No warehouse inventory found for assigned export item'); + continue; + } + if (seenInventory.has(item.inventoryId)) { + result.results.push({ + bookingId: item.bookingId, + itemType: item.itemType, + itemId: item.itemId, + inventoryId: item.inventoryId, + containerNumber: item.containerNumber, + status: 'UNLOADED_AT_DJIBOUTI_PORT', + message: 'Unloaded at Djibouti Port', + }); + continue; + } + + const currentStatus = item.inventoryStatus ?? item.bookingStatus; + if (!currentStatus || !this.EXPORT_DJIBOUTI_UNLOAD_ELIGIBLE_STATUSES.includes(currentStatus)) { + skip(`Status ${currentStatus ?? 'UNKNOWN'} is not eligible for Djibouti export unloading`); + continue; + } + + try { + await manager.getRepository(WarehouseInventory).update(item.inventoryId, { + status: 'UNLOADED_AT_DJIBOUTI_PORT', + unloadedAt: now, + arrivedAt: now, + notes: 'Unloaded at Djibouti Port', + }); + await manager.getRepository(WarehouseInventoryMovement).save( + manager.getRepository(WarehouseInventoryMovement).create({ + inventoryId: item.inventoryId, + fromWarehouseId: item.warehouseId, + fromYardId: item.yardId, + fromZoneId: item.zoneId, + toWarehouseId: item.warehouseId, + toYardId: item.yardId, + toZoneId: item.zoneId, + remarks: 'EXPORT_UNLOADED_AT_DJIBOUTI_PORT', + movedBy: performedBy ?? null, + movedAt: now, + }), + ); + await this.activityLog.record( + { + activityType: 'INVENTORY_UNLOADED', + inventoryId: item.inventoryId, + warehouseId: item.warehouseId, + description: 'EXPORT_UNLOADED_AT_DJIBOUTI_PORT', + performedBy, + }, + manager, + ); + seenInventory.add(item.inventoryId); + result.unloadedCount += 1; + result.results.push({ + bookingId: item.bookingId, + itemType: item.itemType, + itemId: item.itemId, + inventoryId: item.inventoryId, + containerNumber: item.containerNumber, + status: 'UNLOADED_AT_DJIBOUTI_PORT', + message: 'Unloaded at Djibouti Port', + }); + } catch (error) { + fail(error instanceof Error ? error.message : String(error)); + } + } + }); + + return result; + } + /** * Bulk-mark received items inspection PASSED (reusing the inspection service to create a minimal * report + sync inspectionStatus/inspectedAt). EXPORT items advance straight to READY_FOR_LOADING. diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index ba465b962..f1c3cc3c0 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -63,6 +63,7 @@ import LastMilePage from "./pages/operations/LastMilePage"; import TrainDetailPage from "./pages/trains/TrainDetailPage"; import ArrivalQueuePage from "./pages/warehouses/ArrivalQueuePage"; import DispatchQueuePage from "./pages/warehouses/DispatchQueuePage"; +import ExportDjiboutiUnloadingQueuePage from "./pages/warehouses/ExportDjiboutiUnloadingQueuePage"; import InventoryInquiryPage from "./pages/warehouses/InventoryInquiryPage"; import LoadedInventoryPage from "./pages/warehouses/LoadedInventoryPage"; import LoadingQueuePage from "./pages/warehouses/LoadingQueuePage"; @@ -224,6 +225,11 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ href: "/dashboard/dispatch-queue", icon: , }, + { + label: "Djibouti Unloading", + href: "/dashboard/export-djibouti-unloading", + icon: , + }, { label: "Inventory Inquiry", href: "/dashboard/inventory-inquiry", @@ -383,6 +389,7 @@ const App = () => { } /> } /> } /> + } /> } /> } /> } /> diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ActivityTimeline.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ActivityTimeline.tsx index 5fac23ea7..5fc554b20 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ActivityTimeline.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ActivityTimeline.tsx @@ -4,6 +4,7 @@ import { ClipboardCheck, PackageCheck, PackagePlus, + PackageOpen, Send, Truck, Warehouse, @@ -17,12 +18,16 @@ import { formatDate, humanizeEnum } from './options'; const activityIcon: Record = { INVENTORY_RECEIVED: , + INVENTORY_UNLOADED: , INVENTORY_STORED: , INVENTORY_MOVED: , INVENTORY_RESERVED: , READY_FOR_LOADING: , INVENTORY_LOADED: , INVENTORY_DISPATCHED: , + READY_FOR_PICKUP: , + INVENTORY_RELEASED: , + INVENTORY_DELIVERED: , }; export function ActivityTimeline({ inventoryId }: { inventoryId: string }) { diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/badges.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/badges.tsx index 8f34de7e7..1f06acd6f 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/badges.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/badges.tsx @@ -54,6 +54,7 @@ export function WarehouseStatusBadge({ status }: { status: WarehouseStatus }) { const inventoryStatusColor: Record = { UNLOADED: "indigo", + UNLOADED_AT_DJIBOUTI_PORT: "edr-green", RECEIVED: "yellow", STORED: "blue", RESERVED: "grape", diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index ede28be37..2ae92efe2 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -331,6 +331,10 @@ export const URL_CONSTANTS = { IMPORT_AUTO_UNLOAD_ARRIVED: '/warehouse-inventory/import/auto-unload-arrived-bookings', IMPORT_UNLOADED_QUEUE: '/warehouse-inventory/import/unloaded-queue', IMPORT_PICKUP_READY_QUEUE: '/warehouse-inventory/import/pickup-ready-queue', + EXPORT_DJIBOUTI_ARRIVAL_QUEUE: '/warehouse-inventory/export/djibouti-arrival-queue', + EXPORT_DJIBOUTI_TRAIN_ITEMS: (scheduleId: string) => + `/warehouse-inventory/export/djibouti-trains/${scheduleId}/items`, + EXPORT_AUTO_UNLOAD_AT_DJIBOUTI: '/warehouse-inventory/export/auto-unload-at-djibouti', }, WAREHOUSE_LOADINGS: { diff --git a/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts b/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts index 2bd7b552b..89c183b62 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts +++ b/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts @@ -282,6 +282,28 @@ export function useImportTrainItems(scheduleId?: string) { export const useAutoUnloadArrivedBookings = () => useInventoryMutation((scheduleId: string) => warehouseService.autoUnloadArrivedBookings(scheduleId)); +/** Arrived EXPORT trains at Djibouti-side ports. Read-only. */ +export function useExportDjiboutiArrivalQueue(enabled = true) { + return useQuery({ + queryKey: ['warehouse-inventory', 'export-djibouti-arrival-queue'], + queryFn: () => warehouseService.exportDjiboutiArrivalQueue().then((r) => r.data), + enabled, + }); +} + +/** Assigned export bookings/items for a Djibouti-side arrived export train. Read-only. */ +export function useExportDjiboutiTrainItems(scheduleId?: string) { + return useQuery({ + queryKey: ['warehouse-inventory', 'export-djibouti-train-items', scheduleId], + queryFn: () => warehouseService.exportDjiboutiTrainItems(scheduleId as string).then((r) => r.data), + enabled: Boolean(scheduleId), + }); +} + +/** Unload eligible export items assigned to an arrived Djibouti-side train. */ +export const useAutoUnloadExportAtDjibouti = () => + useInventoryMutation((scheduleId: string) => warehouseService.autoUnloadExportAtDjibouti(scheduleId)); + /** IMPORT inventory in the Unloaded Queue (UNLOADED / destination inspection). Read-only. */ export function useImportUnloadedQueue(enabled = true) { return useQuery({ diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/ExportDjiboutiUnloadingQueuePage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/ExportDjiboutiUnloadingQueuePage.tsx new file mode 100644 index 000000000..c9064395b --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/ExportDjiboutiUnloadingQueuePage.tsx @@ -0,0 +1,353 @@ +import { Fragment, useState } from 'react'; +import { + Badge, + Button, + Card, + Container, + Group, + Loader, + Modal, + Stack, + Table, + Tabs, + Text, +} from '@mantine/core'; +import { useNavigate } from 'react-router-dom'; +import { ChevronDown, ChevronRight, Eye, History, PackageOpen, Truck } from 'lucide-react'; + +import { PageHeader } from '@/components/page'; +import Breadcrumbs from '@/components/ui/Breadcrumbs'; +import { + ActivityTimeline, + InventoryMovementHistoryTable, + VisualEmptyState, + WarehouseHero, + formatDate, + formatNumber, +} from '@/components/warehouses'; +import { + useAutoUnloadExportAtDjibouti, + useExportDjiboutiArrivalQueue, + useExportDjiboutiTrainItems, +} from '@/hooks/useWarehouses'; +import { useToast } from '@/hooks/use-toast'; +import type { + AutoUnloadExportDjiboutiResult, + ExportTrain, + ExportTrainItem, +} from '@/types/warehouse'; + +const getErrorMessage = (error: unknown) => { + if (error && typeof error === 'object' && 'response' in error) { + const response = (error as { response?: { data?: { message?: unknown } } }).response; + const message = response?.data?.message; + if (Array.isArray(message)) return message.join(', '); + if (typeof message === 'string') return message; + } + return error instanceof Error ? error.message : undefined; +}; + +const statusColor = (status?: string | null) => { + if (status === 'UNLOADED_AT_DJIBOUTI_PORT') return 'green'; + if (status === 'ARRIVED_AT_DJIBOUTI' || status === 'ARRIVED') return 'blue'; + if (status === 'FAILED') return 'red'; + if (status === 'SKIPPED') return 'orange'; + return 'gray'; +}; + +const statusLabel = (status?: string | null) => + status === 'UNLOADED_AT_DJIBOUTI_PORT' + ? 'Unloaded at Djibouti Port' + : (status ?? 'PENDING').replace(/_/g, ' '); + +function ExportTrainDetailRows({ + scheduleId, + onOpenHistory, +}: { + scheduleId: string; + onOpenHistory: (inventoryId: string) => void; +}) { + const navigate = useNavigate(); + const { data: items = [], isLoading } = useExportDjiboutiTrainItems(scheduleId); + + if (isLoading) { + return ( + + + + ); + } + + if (items.length === 0) { + return ( + + No assigned export bookings found for this train. + + ); + } + + return ( + + + + + Booking ID + Booking Reference + Customer ID + Customer Name + Item Type + Container Number + Cargo Type + Weight + Origin + Destination + Train Schedule + Arrival Time at Djibouti + Current Status + Actions + + + + {items.map((item: ExportTrainItem) => ( + + {item.bookingId.slice(0, 8)} + + + {item.bookingReference ?? '-'} + + + {item.customerId?.slice(0, 8) ?? '-'} + {item.customerName ?? '-'} + {item.itemType} + {item.containerNumber ?? '-'} + {item.cargoType ?? '-'} + {formatNumber(item.weight)} + {item.origin ?? '-'} + {item.destination ?? '-'} + {item.trainSchedule ?? '-'} + {formatDate(item.arrivalTime)} + + + {statusLabel(item.currentStatus)} + + + + + + + + + + ))} + +
+
+ ); +} + +export default function ExportDjiboutiUnloadingQueuePage() { + const { toast } = useToast(); + const { data: trains = [], isLoading } = useExportDjiboutiArrivalQueue(); + const autoUnload = useAutoUnloadExportAtDjibouti(); + const [openScheduleId, setOpenScheduleId] = useState(null); + const [busyScheduleId, setBusyScheduleId] = useState(null); + const [historyInventoryId, setHistoryInventoryId] = useState(null); + + const unloadTrain = async (train: ExportTrain) => { + setBusyScheduleId(train.scheduleId); + try { + const res = (await autoUnload.mutateAsync(train.scheduleId)) as { + data: AutoUnloadExportDjiboutiResult; + }; + const result = res.data; + const details = [ + result.skippedCount ? `${result.skippedCount} skipped` : '', + result.failedCount ? `${result.failedCount} failed` : '', + ] + .filter(Boolean) + .join(', '); + + toast({ + title: `${result.unloadedCount} export item(s) unloaded`, + description: details || `${train.trainNumber ?? 'Train'} unloaded at Djibouti Port.`, + }); + } catch (error) { + toast({ + variant: 'destructive', + title: 'Auto unload failed', + description: getErrorMessage(error), + }); + } finally { + setBusyScheduleId(null); + } + }; + + return ( + + + + + + + + + + + {trains.length} arrived export train(s) + + Open a train to review assigned export items, then auto unload it. + + + + {isLoading ? ( + + + + ) : trains.length === 0 ? ( + + ) : ( + + + + + Train Schedule ID + Train Number + Route + Origin + Destination + Departure Time + Arrival Time + Total Bookings + Total Containers + Total Cargoes + Current Status + Actions + + + + {trains.map((train: ExportTrain) => { + const isOpen = openScheduleId === train.scheduleId; + return ( + + + {train.scheduleId.slice(0, 8)} + + + {train.trainNumber ?? '-'} + + + {train.route ?? '-'} + {train.origin ?? '-'} + {train.destination ?? '-'} + {formatDate(train.departureTime)} + {formatDate(train.arrivalTime)} + {train.totalBookings} + {train.totalContainers} + {train.totalCargoes} + + + {statusLabel(train.status)} + + + + + + + + + + {isOpen && ( + + + + + + )} + + ); + })} + +
+
+ )} +
+
+ + setHistoryInventoryId(null)} + title="Movement history" + size="xl" + > + {historyInventoryId ? ( + + + Activity + Movements + + + + + + + + + ) : null} + +
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts b/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts index f3ffb134d..a130ac6ae 100644 --- a/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts @@ -37,6 +37,9 @@ import type { BulkInspectResult, ReadyToLoadRow, BulkDispatchResult, + AutoUnloadExportDjiboutiResult, + ExportTrain, + ExportTrainItem, ImportTrain, ImportTrainItem, ImportUnloadedItem, @@ -167,6 +170,17 @@ export const warehouseService = { apiClient.get(URL_CONSTANTS.WAREHOUSE_INVENTORY.IMPORT_UNLOADED_QUEUE), importPickupReadyQueue: () => apiClient.get(URL_CONSTANTS.WAREHOUSE_INVENTORY.IMPORT_PICKUP_READY_QUEUE), + exportDjiboutiArrivalQueue: () => + apiClient.get(URL_CONSTANTS.WAREHOUSE_INVENTORY.EXPORT_DJIBOUTI_ARRIVAL_QUEUE), + exportDjiboutiTrainItems: (scheduleId: string) => + apiClient.get( + URL_CONSTANTS.WAREHOUSE_INVENTORY.EXPORT_DJIBOUTI_TRAIN_ITEMS(scheduleId), + ), + autoUnloadExportAtDjibouti: (scheduleId: string) => + apiClient.post( + URL_CONSTANTS.WAREHOUSE_INVENTORY.EXPORT_AUTO_UNLOAD_AT_DJIBOUTI, + { scheduleId }, + ), move: (id: string, payload: MoveInventoryPayload) => apiClient.post(URL_CONSTANTS.WAREHOUSE_INVENTORY.MOVE(id), payload), movements: (id: string) => diff --git a/apps/edr-freight-web/backoffice/src/types/warehouse.ts b/apps/edr-freight-web/backoffice/src/types/warehouse.ts index 274b2b3de..c06ec07cb 100644 --- a/apps/edr-freight-web/backoffice/src/types/warehouse.ts +++ b/apps/edr-freight-web/backoffice/src/types/warehouse.ts @@ -24,6 +24,7 @@ export type WarehouseZoneType = (typeof WAREHOUSE_ZONE_TYPES)[number]; export const INVENTORY_STATUSES = [ 'UNLOADED', + 'UNLOADED_AT_DJIBOUTI_PORT', 'RECEIVED', 'STORED', 'RESERVED', @@ -56,6 +57,7 @@ export type InventoryAction = */ export const INVENTORY_NEXT_ACTION: Record = { UNLOADED: 'store', + UNLOADED_AT_DJIBOUTI_PORT: null, RECEIVED: 'store', STORED: 'reserve', RESERVED: 'ready-for-loading', @@ -236,12 +238,16 @@ export interface InventoryMovement { export const ACTIVITY_TYPES = [ 'INVENTORY_RECEIVED', + 'INVENTORY_UNLOADED', 'INVENTORY_STORED', 'INVENTORY_MOVED', 'INVENTORY_RESERVED', 'READY_FOR_LOADING', 'INVENTORY_LOADED', 'INVENTORY_DISPATCHED', + 'READY_FOR_PICKUP', + 'INVENTORY_RELEASED', + 'INVENTORY_DELIVERED', ] as const; export type ActivityType = (typeof ACTIVITY_TYPES)[number]; @@ -419,6 +425,7 @@ export interface ImportTrain { route: string | null; origin: string | null; destination: string | null; + departureTime?: string | null; arrivalTime: string | null; totalBookings: number; totalContainers: number; @@ -433,6 +440,44 @@ export interface AutoUnloadArrivedResult { results: { bookingId: string; inventoryId?: string; status: string; reason?: string }[]; } +export type ExportTrain = ImportTrain & { + departureTime: string | null; +}; + +export interface ExportTrainItem { + bookingId: string; + bookingReference: string | null; + customerId: string | null; + customerName: string | null; + itemType: 'CONTAINER' | 'CARGO'; + itemId: string | null; + inventoryId: string | null; + containerNumber: string | null; + cargoType: string | null; + weight: number | null; + origin: string | null; + destination: string | null; + trainSchedule: string | null; + arrivalTime: string | null; + currentStatus: string | null; +} + +export interface AutoUnloadExportDjiboutiResult { + unloadedCount: number; + skippedCount: number; + failedCount: number; + results: Array<{ + bookingId: string; + itemType: 'CONTAINER' | 'CARGO'; + itemId?: string | null; + inventoryId?: string; + containerNumber?: string | null; + status: string; + message?: string; + reason?: string; + }>; +} + export interface ImportUnloadedItem { id: string; bookingId: string | null;