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 ec39cfb39..181cb4257 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 @@ -21,6 +21,9 @@ export interface ImportTrainRow { totalBookings: number; totalContainers: number; totalCargoes: number; + unloadedBookings: number; + pendingUnloadBookings: number; + fullyUnloaded: boolean; status: string; } @@ -205,7 +208,24 @@ export class SchedulingReadFacade { 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" + WHERE tsbg.train_schedule_id = ts.id AND cg.deleted_at IS NULL) AS "totalCargoes", + (SELECT count(*) FROM freight.train_schedule_bookings tsbp + JOIN freight.bookings bp ON bp.id = tsbp.booking_id AND bp.deleted_at IS NULL + WHERE tsbp.train_schedule_id = ts.id + AND tsbp.deleted_at IS NULL + AND bp.status NOT IN ('DRAFT', 'REJECTED', 'CANCELLED', 'COMPLETED') + AND ( + NOT EXISTS ( + SELECT 1 FROM freight.warehouse_inventory invp + WHERE invp.booking_id = bp.id AND invp.deleted_at IS NULL + ) + OR EXISTS ( + SELECT 1 FROM freight.warehouse_inventory invr + WHERE invr.booking_id = bp.id + AND invr.deleted_at IS NULL + AND invr.status = 'RECEIVED' + ) + )) AS "pendingUnloadBookings" 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 @@ -219,13 +239,22 @@ export class SchedulingReadFacade { (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, - })); + .map(({ originCountry: _oc, destinationCountry: _dc, ...rest }) => { + const totalBookings = Number(rest.totalBookings) || 0; + const pendingUnloadBookings = Number(rest.pendingUnloadBookings) || 0; + const unloadedBookings = Math.max(totalBookings - pendingUnloadBookings, 0); + + return { + ...rest, + totalBookings, + totalContainers: Number(rest.totalContainers) || 0, + totalCargoes: Number(rest.totalCargoes) || 0, + unloadedBookings, + pendingUnloadBookings, + fullyUnloaded: totalBookings > 0 && pendingUnloadBookings === 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. */ 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 3cd08461f..4df197c3f 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx @@ -726,6 +726,12 @@ function ImportTrainDetailTable({ scheduleId }: { scheduleId: string }) { } /** Import Arrive Queue: arrived IMPORT trains, with Open (detail) + Auto Unload per train. */ +const getPendingUnloadBookings = (train: ImportTrain) => + train.pendingUnloadBookings ?? train.totalBookings; + +const isFullyUnloaded = (train: ImportTrain) => + Boolean(train.fullyUnloaded) || (train.totalBookings > 0 && getPendingUnloadBookings(train) === 0); + function ImportArriveQueueTab({ enabled, onChanged, @@ -744,9 +750,19 @@ function ImportArriveQueueTab({ const [busyId, setBusyId] = useState(null); const autoUnload = async (train: ImportTrain) => { + if (isFullyUnloaded(train)) { + toast({ + title: 'Already unloaded', + description: `${train.trainNumber ?? 'This train'} has no remaining bookings to auto unload.`, + }); + return; + } + setBusyId(train.scheduleId); try { const r = await autoUnloadMutation.mutateAsync(train.scheduleId); + const alreadyUnloaded = r.unloadedCount === 0 && r.skippedCount > 0 && r.failedCount === 0; + const firstReason = r.results.find((item) => item.reason)?.reason; const extra = [ r.skippedCount ? `${r.skippedCount} skipped` : '', r.failedCount ? `${r.failedCount} failed` : '', @@ -754,8 +770,8 @@ function ImportArriveQueueTab({ .filter(Boolean) .join(', '); toast({ - title: `${r.unloadedCount} unloaded`, - description: extra || undefined, + title: alreadyUnloaded ? 'Already unloaded' : `${r.unloadedCount} unloaded`, + description: alreadyUnloaded ? firstReason ?? 'This train is already in warehouse inventory.' : extra || undefined, }); onChanged?.(); } catch (error) { @@ -800,6 +816,8 @@ function ImportArriveQueueTab({ {trains.map((t: ImportTrain) => { const isOpen = openId === t.scheduleId; + const fullyUnloaded = isFullyUnloaded(t); + const unloadedBookings = t.unloadedBookings ?? t.totalBookings - getPendingUnloadBookings(t); return ( @@ -817,7 +835,14 @@ function ImportArriveQueueTab({ {t.totalContainers} {t.totalCargoes} - {t.status} + + + {fullyUnloaded ? 'UNLOADED' : t.status} + + + {Math.max(unloadedBookings, 0)}/{t.totalBookings} unloaded + + @@ -831,12 +856,13 @@ function ImportArriveQueueTab({ diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/ArrivalQueuePage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/ArrivalQueuePage.tsx index d0ea713a8..2a254cf84 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/ArrivalQueuePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/ArrivalQueuePage.tsx @@ -37,6 +37,12 @@ const getErrorMessage = (error: unknown) => { return error instanceof Error ? error.message : undefined; }; +const getPendingUnloadBookings = (train: ImportTrain) => + train.pendingUnloadBookings ?? train.totalBookings; + +const isFullyUnloaded = (train: ImportTrain) => + Boolean(train.fullyUnloaded) || (train.totalBookings > 0 && getPendingUnloadBookings(train) === 0); + function ImportTrainDetailRows({ scheduleId }: { scheduleId: string }) { const { data: items = [], isLoading } = useImportTrainItems(scheduleId); @@ -105,10 +111,20 @@ export default function ArrivalQueuePage() { const [busyScheduleId, setBusyScheduleId] = useState(null); const unloadTrain = async (train: ImportTrain) => { + if (isFullyUnloaded(train)) { + toast({ + title: 'Already unloaded', + description: `${train.trainNumber ?? 'This train'} has no remaining bookings to auto unload.`, + }); + return; + } + setBusyScheduleId(train.scheduleId); try { const res = (await autoUnload.mutateAsync(train.scheduleId)) as { data: AutoUnloadArrivedResult }; const result = res.data; + const alreadyUnloaded = result.unloadedCount === 0 && result.skippedCount > 0 && result.failedCount === 0; + const firstReason = result.results.find((item) => item.reason)?.reason; const details = [ result.skippedCount ? `${result.skippedCount} skipped` : '', result.failedCount ? `${result.failedCount} failed` : '', @@ -117,8 +133,10 @@ export default function ArrivalQueuePage() { .join(', '); toast({ - title: `${result.unloadedCount} booking(s) unloaded`, - description: details || `${train.trainNumber ?? 'Train'} moved into warehouse inventory.`, + title: alreadyUnloaded ? 'Already unloaded' : `${result.unloadedCount} booking(s) unloaded`, + description: alreadyUnloaded + ? firstReason ?? `${train.trainNumber ?? 'Train'} is already in warehouse inventory.` + : details || `${train.trainNumber ?? 'Train'} moved into warehouse inventory.`, }); } catch (error) { toast({ @@ -181,6 +199,8 @@ export default function ArrivalQueuePage() { {trains.map((train: ImportTrain) => { const isOpen = openScheduleId === train.scheduleId; + const fullyUnloaded = isFullyUnloaded(train); + const unloadedBookings = train.unloadedBookings ?? train.totalBookings - getPendingUnloadBookings(train); return ( @@ -204,9 +224,14 @@ export default function ArrivalQueuePage() { {train.totalContainers} {train.totalCargoes} - - {train.status} - + + + {fullyUnloaded ? 'UNLOADED' : train.status} + + + {Math.max(unloadedBookings, 0)}/{train.totalBookings} unloaded + + @@ -220,12 +245,13 @@ export default function ArrivalQueuePage() { diff --git a/apps/edr-freight-web/backoffice/src/types/warehouse.ts b/apps/edr-freight-web/backoffice/src/types/warehouse.ts index d5141f23e..b3d24a123 100644 --- a/apps/edr-freight-web/backoffice/src/types/warehouse.ts +++ b/apps/edr-freight-web/backoffice/src/types/warehouse.ts @@ -430,6 +430,9 @@ export interface ImportTrain { totalBookings: number; totalContainers: number; totalCargoes: number; + unloadedBookings?: number; + pendingUnloadBookings?: number; + fullyUnloaded?: boolean; status: string; }