diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index b6687f276..d85903b40 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -50,6 +50,7 @@ import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder"; import { IndodeFacilitySeeder } from "./seed/indode-facility.seeder"; import { Batch14TestDataSeeder } from "./seed/batch1-4-test-data.seeder"; import { Batch5TestDataSeeder } from "./seed/batch5-test-data.seeder"; +import { Batch7TestDataSeeder } from "./seed/batch7-test-data.seeder"; import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-key-migration.seeder"; import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder"; //New Trains, Wagons, Container and Cargo management modules @@ -133,6 +134,7 @@ import { OverviewModule } from './modules/overview/overview.module'; IndodeFacilitySeeder, Batch14TestDataSeeder, Batch5TestDataSeeder, + Batch7TestDataSeeder, ], }) export class AppModule implements OnApplicationBootstrap { @@ -147,6 +149,7 @@ export class AppModule implements OnApplicationBootstrap { private readonly indodeFacilitySeeder: IndodeFacilitySeeder, private readonly batch14TestDataSeeder: Batch14TestDataSeeder, private readonly batch5TestDataSeeder: Batch5TestDataSeeder, + private readonly batch7TestDataSeeder: Batch7TestDataSeeder, private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder, private readonly demoFreightDataSeeder: DemoFreightDataSeeder, ) { } @@ -163,6 +166,7 @@ export class AppModule implements OnApplicationBootstrap { await this.indodeFacilitySeeder.run(); await this.batch14TestDataSeeder.run(); await this.batch5TestDataSeeder.run(); + await this.batch7TestDataSeeder.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. await this.demoFreightDataSeeder.run(); diff --git a/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts b/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts index de5a791c1..76e1fe892 100644 --- a/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts +++ b/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts @@ -1,6 +1,8 @@ import { Injectable } from '@nestjs/common'; import { DataSource } from 'typeorm'; +import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; + /** * READ-ONLY view into the train-scheduling / wagons domain for the warehouse module. * @@ -9,6 +11,32 @@ import { DataSource } from 'typeorm'; * It is intentionally decoupled (raw SQL) so it does not import the scheduling * services/entities and cannot accidentally write to them. */ +export interface ImportTrainRow { + scheduleId: string; + trainNumber: string | null; + route: string | null; + origin: string | null; + destination: string | null; + arrivalTime: string | null; + totalBookings: number; + totalContainers: number; + totalCargoes: number; + status: string; +} + +export interface ImportTrainItemRow { + bookingId: string; + bookingReference: string | null; + customerId: string | null; + customerName: string | null; + containerNumber: string | null; + cargoType: string | null; + weight: number | null; + arrivalTime: string | null; + currentStatus: string | null; + lastMileRequested: boolean; + pickupOption: string; +} export interface WagonView { id: string; wagonNumber: string; @@ -115,4 +143,81 @@ export class SchedulingReadFacade { departureStatus: schedule?.status ?? null, }; } + + /** + * ARRIVED train schedules whose route is IMPORT (origin country = Djibouti), with per-train + * booking/container/cargo counts. Direction is derived from the origin/destination station + * countries (route-based), so EXPORT/DOMESTIC trains never appear. Read-only. + */ + async importArriveQueue(): Promise { + const rows: Array< + ImportTrainRow & { originCountry: string | null; destinationCountry: string | null } + > = await this.dataSource.query( + `SELECT ts.id AS "scheduleId", + ts.train_number AS "trainNumber", + oy.code AS "origin", + dy.code AS "destination", + oy.country AS "originCountry", + dy.country AS "destinationCountry", + 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 ts.deleted_at IS NULL + AND ts.status = 'ARRIVED' + ORDER BY COALESCE(ts.actual_arrival_at, ts.scheduled_arrival_date) DESC NULLS LAST`, + ); + + return rows + .filter( + (r) => + deriveTradeDirection({ country: r.originCountry }, { country: r.destinationCountry }) === 'IMPORT', + ) + .map(({ originCountry: _oc, destinationCountry: _dc, ...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 bookings/items for an arrived import train (one row per booking). Read-only. */ + async importTrainDetail(scheduleId: string): Promise { + const rows: ImportTrainItemRow[] = await this.dataSource.query( + `SELECT b.id AS "bookingId", + b.reference AS "bookingReference", + b.company_id AS "customerId", + company.name AS "customerName", + (SELECT c.container_number FROM freight.containers c + WHERE c.booking_id = b.id AND c.deleted_at IS NULL + ORDER BY c.container_number LIMIT 1) AS "containerNumber", + COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType", + b.cargo_total_weight_vgm AS "weight", + COALESCE(ts.actual_arrival_at, ts.scheduled_arrival_date) AS "arrivalTime", + COALESCE(inv.status, b.status) AS "currentStatus", + (b.last_mile_delivery_address IS NOT NULL) AS "lastMileRequested", + CASE WHEN b.last_mile_delivery_address IS NOT NULL + THEN 'DOOR_DELIVERY' ELSE 'TERMINAL_PICKUP' END AS "pickupOption" + 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.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 + ORDER BY b.reference ASC NULLS LAST`, + [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 2e282d770..9a4a6411d 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 @@ -118,6 +118,18 @@ export class WarehouseInventoryController { return this.inventoryService.gateClearance(id, performedBy); } + @Get('import/arrive-queue') + @ApiOperation({ summary: 'Arrived IMPORT train schedules (route-derived), read-only from scheduling' }) + importArriveQueue() { + return this.scheduling.importArriveQueue(); + } + + @Get('import/trains/:scheduleId/items') + @ApiOperation({ summary: 'Assigned bookings/items for an arrived import train (read-only)' }) + importTrainDetail(@Param('scheduleId', ParseUUIDPipe) scheduleId: string) { + return this.scheduling.importTrainDetail(scheduleId); + } + @Get('loadable-wagons') @ApiOperation({ summary: 'List wagons usable for loading (read-only from scheduling)' }) loadableWagons() { diff --git a/apps/edr-freight-api/src/seed/batch7-test-data.seeder.ts b/apps/edr-freight-api/src/seed/batch7-test-data.seeder.ts new file mode 100644 index 000000000..dec3febe3 --- /dev/null +++ b/apps/edr-freight-api/src/seed/batch7-test-data.seeder.ts @@ -0,0 +1,103 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { DataSource } from 'typeorm'; + +import { Booking } from '../modules/bookings/entities/booking.entity'; +import { Locomotive } from '../modules/locomotives/entities/locomotive.entity'; +import { TrainSchedule } from '../modules/train-schedules/entities/train-schedule.entity'; +import { TrainScheduleBooking } from '../modules/train-schedules/entities/train-schedule-booking.entity'; +import { TrainSet } from '../modules/train-sets/entities/train-set.entity'; + +/** + * Seeds two ARRIVED train schedules so the Import Arrive Queue (Batch 7) is demonstrable: + * - SEED-IMP-TRAIN-01: DJIB_PORT → MOJO (IMPORT) linked to booking SEED-IMP-001 → SHOWS + * - SEED-EXP-TRAIN-01: MOJO → DJIB_PORT (EXPORT) linked to booking SEED-EXP-001 → must NOT show + * + * Read-only train-schedule SERVICE logic is untouched; this only inserts fixture rows. + * Idempotent: guards on the import train number. + */ +@Injectable() +export class Batch7TestDataSeeder { + private readonly logger = new Logger(Batch7TestDataSeeder.name); + + constructor(private readonly dataSource: DataSource) {} + + async run(): Promise { + const scheduleRepo = this.dataSource.getRepository(TrainSchedule); + + const existing = await scheduleRepo.findOne({ where: { trainNumber: 'SEED-IMP-TRAIN-01' } }); + if (existing) { + this.logger.log('Batch 7 test data already seeded, skipping'); + return; + } + + try { + const bookingRepo = this.dataSource.getRepository(Booking); + const locoRepo = this.dataSource.getRepository(Locomotive); + const trainSetRepo = this.dataSource.getRepository(TrainSet); + const scheduleBookingRepo = this.dataSource.getRepository(TrainScheduleBooking); + + const importBooking = await bookingRepo.findOne({ where: { reference: 'SEED-IMP-001' } }); + const exportBooking = await bookingRepo.findOne({ where: { reference: 'SEED-EXP-001' } }); + if (!importBooking) { + this.logger.warn('SEED-IMP-001 booking not found; skipping Batch 7 seed'); + return; + } + + // One shared locomotive is fine — train_set.locomotive_id is not unique. + const loco = + (await locoRepo.findOne({ where: { code: 'SEED-LOCO-01' } })) ?? + (await locoRepo.save( + locoRepo.create({ code: 'SEED-LOCO-01', name: 'Seed Locomotive', maxPullWeightTons: 4000 }), + )); + + const now = new Date(); + const arrival = new Date(now.getTime() - 3600 * 1000); + const departure = new Date(now.getTime() - 6 * 3600 * 1000); + + const makeArrivedTrain = async ( + trainNumber: string, + booking: Booking, + ): Promise => { + const trainSet = await trainSetRepo.save( + trainSetRepo.create({ + locomotiveId: loco.id, + totalWeightTons: 500, + totalLengthMeters: 300, + wagonCount: 10, + status: 'COMPLETED', + }), + ); + + const schedule = await scheduleRepo.save( + scheduleRepo.create({ + trainSetId: trainSet.id, + originStationId: booking.originYardId, + destinationStationId: booking.destinationYardId, + scheduledDepartureDate: departure, + scheduledArrivalDate: arrival, + actualArrivalAt: arrival, + status: 'ARRIVED' as TrainSchedule['status'], + trainNumber, + }), + ); + + await scheduleBookingRepo.save( + scheduleBookingRepo.create({ trainScheduleId: schedule.id, bookingId: booking.id }), + ); + + this.logger.log(`Seeded arrived train ${trainNumber} → booking ${booking.reference}`); + }; + + await makeArrivedTrain('SEED-IMP-TRAIN-01', importBooking); + if (exportBooking) { + await makeArrivedTrain('SEED-EXP-TRAIN-01', exportBooking); + } + + this.logger.log('✅ Batch 7 arrive-queue test data seeded successfully'); + } catch (error) { + this.logger.error( + `Batch7TestDataSeeder failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } +} diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx index 52b18d4be..42fd23cfd 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react'; +import { Fragment, useEffect, useMemo, useState } from 'react'; import { Alert, Badge, @@ -16,17 +16,20 @@ import { Textarea, TextInput, } from '@mantine/core'; -import { Info, PackageSearch, Truck } from 'lucide-react'; +import { ChevronDown, ChevronRight, Info, PackageSearch, Train, Truck } from 'lucide-react'; import { useToast } from '@/hooks/use-toast'; import { useBulkDispatchExport, useBulkReceive, useEligibleBookings, + useImportArriveQueue, + useImportTrainItems, useLoadPassedExport, useLoadedExport, useReadyToLoadExport, useReceiveInventory, + useUnloadBooking, useWarehouseYards, useWarehouseZones, useWarehouses, @@ -34,12 +37,15 @@ import { import type { BulkDispatchResult, BulkReceiveResult, + ImportTrain, + ImportTrainItem, LoadPassedExportResult, ReadyToLoadRow, ReceiveInventoryPayload, } from '@/types/warehouse'; +import { warehouseService } from '@/services/warehouse.service'; import { BookingSelect } from './BookingSelect'; -import { extractErrorMessage, formatNumber } from './options'; +import { extractErrorMessage, formatDate, formatNumber } from './options'; interface ReceiveInventoryModalProps { opened: boolean; @@ -644,6 +650,218 @@ function LoadedExportTab({ ); } +/** Assigned bookings/items for an arrived import train (read-only detail view). */ +function ImportTrainDetailTable({ scheduleId }: { scheduleId: string }) { + const { data: items = [], isLoading } = useImportTrainItems(scheduleId); + + if (isLoading) { + return ( + + + + ); + } + if (items.length === 0) { + return ( + + No assigned bookings on this train. + + ); + } + + return ( + + + + Booking ID + Booking Ref + Customer ID + Customer Name + Container # + Cargo Type + Weight + Arrival + Current Status + Last Mile + Pickup Option + + + + {items.map((it: ImportTrainItem) => ( + + + {it.bookingId.slice(0, 8)}… + + + {it.bookingReference ?? '—'} + + + {it.customerId ? `${it.customerId.slice(0, 8)}…` : '—'} + + {it.customerName ?? '—'} + {it.containerNumber ?? '—'} + {it.cargoType ?? '—'} + {formatNumber(Number(it.weight))} + {formatDate(it.arrivalTime)} + + {it.currentStatus ?? '—'} + + + + {it.lastMileRequested ? 'Yes' : 'No'} + + + {it.pickupOption} + + ))} + +
+ ); +} + +/** Import Arrive Queue: arrived IMPORT trains, with Open (detail) + Auto Unload per train. */ +function ImportArriveQueueTab({ + location, + enabled, + onChanged, +}: { + location: Location; + enabled: boolean; + onChanged?: () => void; +}) { + const { toast } = useToast(); + const { data: trains = [], isLoading } = useImportArriveQueue(enabled); + const unloadBooking = useUnloadBooking(); + const [openId, setOpenId] = useState(null); + const [busyId, setBusyId] = useState(null); + + const locationPayload = location.warehouseId + ? { warehouseId: location.warehouseId, yardId: location.yardId, zoneId: location.zoneId } + : undefined; + + const autoUnload = async (train: ImportTrain) => { + setBusyId(train.scheduleId); + try { + const items = (await warehouseService.importTrainItems(train.scheduleId)).data; + if (items.length === 0) { + toast({ title: 'No bookings to unload on this train' }); + return; + } + let ok = 0; + for (const it of items) { + try { + await unloadBooking.mutateAsync({ bookingId: it.bookingId, payload: locationPayload }); + ok += 1; + } catch { + /* already unloaded / not eligible — skip */ + } + } + toast({ + title: `Auto-unloaded ${ok}/${items.length} booking(s)`, + description: ok < items.length ? `${items.length - ok} skipped (already unloaded or not eligible)` : undefined, + }); + onChanged?.(); + } catch (error) { + toast({ variant: 'destructive', title: 'Auto unload failed', description: extractErrorMessage(error) }); + } finally { + setBusyId(null); + } + }; + + return ( + + + {trains.length} arrived import train{trains.length !== 1 ? 's' : ''} + + + {isLoading ? ( + + + + ) : trains.length === 0 ? ( + + No arrived import trains. Trains appear here once their schedule status is ARRIVED. + + ) : ( + + + + + Schedule ID + Train # + Route + Origin + Destination + Arrival Time + Bookings + Containers + Cargoes + Status + Actions + + + + {trains.map((t: ImportTrain) => { + const isOpen = openId === t.scheduleId; + return ( + + + + {t.scheduleId.slice(0, 8)}… + + + {t.trainNumber ?? '—'} + + {t.route ?? '—'} + {t.origin ?? '—'} + {t.destination ?? '—'} + {formatDate(t.arrivalTime)} + {t.totalBookings} + {t.totalContainers} + {t.totalCargoes} + + {t.status} + + + + + + + + + {isOpen && ( + + + + + + )} + + ); + })} + +
+
+ )} +
+ ); +} + /** New bulk Receive: Import / Export tabs with eligible PAID bookings. */ function BulkReceiveModal({ opened, onClose, onReceived }: ReceiveInventoryModalProps) { const [location, setLocation] = useState({ warehouseId: '', yardId: '', zoneId: '' }); @@ -669,7 +887,29 @@ function BulkReceiveModal({ opened, onClose, onReceived }: ReceiveInventoryModal - + + + }> + Arrive Queue + + Unloaded Queue + Dispatch Queue + + + + + + + + Unloaded Queue — coming in the next batch. + + + + + Dispatch Queue — coming in the next batch. + + + diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index 3ee96d9c3..e8f176e7b 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -317,6 +317,9 @@ export const URL_CONSTANTS = { READY_TO_LOAD_EXPORT: '/warehouse-inventory/ready-to-load-export', LOADED_EXPORT: '/warehouse-inventory/loaded-export', BULK_DISPATCH_EXPORT: '/warehouse-inventory/bulk-dispatch-export', + IMPORT_ARRIVE_QUEUE: '/warehouse-inventory/import/arrive-queue', + IMPORT_TRAIN_ITEMS: (scheduleId: string) => + `/warehouse-inventory/import/trains/${scheduleId}/items`, }, 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 6b625fc33..6511dd522 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts +++ b/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts @@ -227,6 +227,24 @@ export function useLoadedExport(enabled = true) { export const useBulkDispatchExport = () => useInventoryMutation((inventoryIds: string[]) => warehouseService.bulkDispatchExport(inventoryIds)); +/** Arrived IMPORT trains (route-derived). Read-only. */ +export function useImportArriveQueue(enabled = true) { + return useQuery({ + queryKey: ['warehouse-inventory', 'import-arrive-queue'], + queryFn: () => warehouseService.importArriveQueue().then((r) => r.data), + enabled, + }); +} + +/** Assigned bookings/items for an arrived import train. Read-only. */ +export function useImportTrainItems(scheduleId?: string) { + return useQuery({ + queryKey: ['warehouse-inventory', 'import-train-items', scheduleId], + queryFn: () => warehouseService.importTrainItems(scheduleId as string).then((r) => r.data), + enabled: Boolean(scheduleId), + }); +} + // ── Loading (Batch 3) ──────────────────────────────────────────────────────── export function useLoadableWagons(enabled = true) { 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 7769c0969..8befdcdd2 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,8 @@ import type { BulkInspectResult, ReadyToLoadRow, BulkDispatchResult, + ImportTrain, + ImportTrainItem, ReserveInventoryPayload, SaveWarehousePayload, SaveYardPayload, @@ -140,6 +142,10 @@ export const warehouseService = { apiClient.post(URL_CONSTANTS.WAREHOUSE_INVENTORY.BULK_DISPATCH_EXPORT, { inventoryIds, }), + importArriveQueue: () => + apiClient.get(URL_CONSTANTS.WAREHOUSE_INVENTORY.IMPORT_ARRIVE_QUEUE), + importTrainItems: (scheduleId: string) => + apiClient.get(URL_CONSTANTS.WAREHOUSE_INVENTORY.IMPORT_TRAIN_ITEMS(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 e0d39f559..f9ce8c588 100644 --- a/apps/edr-freight-web/backoffice/src/types/warehouse.ts +++ b/apps/edr-freight-web/backoffice/src/types/warehouse.ts @@ -401,6 +401,33 @@ export interface BulkDispatchResult { results: { inventoryId: string; status: string; reason?: string }[]; } +export interface ImportTrain { + scheduleId: string; + trainNumber: string | null; + route: string | null; + origin: string | null; + destination: string | null; + arrivalTime: string | null; + totalBookings: number; + totalContainers: number; + totalCargoes: number; + status: string; +} + +export interface ImportTrainItem { + bookingId: string; + bookingReference: string | null; + customerId: string | null; + customerName: string | null; + containerNumber: string | null; + cargoType: string | null; + weight: number | null; + arrivalTime: string | null; + currentStatus: string | null; + lastMileRequested: boolean; + pickupOption: string; +} + export interface InventoryInquiryResult { id: string; bookingId: string;