feat(warehouse): Batch 8 — Import Auto Unload Arrived Bookings (→ UNLOADED)

- New UNLOADED inventory status (train-arrival landing state) + transitions + unloaded_at column
  (idempotent migration 1791000000003) + INVENTORY_UNLOADED activity type
- POST /warehouse-inventory/import/auto-unload-arrived-bookings { scheduleId }: validates ARRIVED
  IMPORT train, unloads all eligible assigned bookings (IN_TRANSIT/ARRIVED_AT_*) into UNLOADED,
  records unloadedAt + activity. Does NOT store and does NOT inspect. Reuses allocation + inventory
  plumbing. Returns { unloadedCount, skippedCount, failedCount, results }.
- Arrive Queue "Auto Unload Arrived Bookings" button now calls the new endpoint (was per-booking loop)
- Import → Unloaded Queue tab: lists UNLOADED items via InventoryWorkbench (existing actions preserved:
  Inspect/Store/Move/History) + a Last Mile action shown ONLY when booking requested door delivery
- Batch8TestDataSeeder: sets seed import train bookings to IN_TRANSIT (unload-eligible)
- Verified: auto-unload → 1 UNLOADED (unloadedAt set, not stored, not inspected); ineligible skipped;
  idempotent re-run skips already-unloaded

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Hagernesh
2026-06-21 12:57:29 +00:00
parent 35c3341baf
commit 8b28a7c430
15 changed files with 339 additions and 32 deletions

View File

@@ -25,10 +25,12 @@ import { extractErrorMessage } from './options';
interface InventoryWorkbenchProps {
items: WarehouseInventoryItem[];
isLoading?: boolean;
/** Optional Last Mile action (Batch 8) — only shown for items whose booking requested door delivery. */
onLastMile?: (item: WarehouseInventoryItem) => void;
}
/** Inventory table + all lifecycle actions (advance / move / reserve / history). */
export function InventoryWorkbench({ items, isLoading }: InventoryWorkbenchProps) {
export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWorkbenchProps) {
const { toast } = useToast();
const [busyId, setBusyId] = useState<string | null>(null);
const [moveItem, setMoveItem] = useState<WarehouseInventoryItem | null>(null);
@@ -152,6 +154,7 @@ export function InventoryWorkbench({ items, isLoading }: InventoryWorkbenchProps
onHistory={setHistoryItem}
onInspect={setInspectItem}
onFeePreview={setFeeItem}
onLastMile={onLastMile}
selectedIds={selected}
onToggleSelect={toggleSelect}
onToggleSelectAll={toggleSelectAll}

View File

@@ -20,6 +20,7 @@ import { ChevronDown, ChevronRight, Info, PackageSearch, Train, Truck } from 'lu
import { useToast } from '@/hooks/use-toast';
import {
useAutoUnloadArrivedBookings,
useBulkDispatchExport,
useBulkReceive,
useEligibleBookings,
@@ -29,12 +30,13 @@ import {
useLoadedExport,
useReadyToLoadExport,
useReceiveInventory,
useUnloadBooking,
useWarehouseInventory,
useWarehouseYards,
useWarehouseZones,
useWarehouses,
} from '@/hooks/useWarehouses';
import type {
AutoUnloadArrivedResult,
BulkDispatchResult,
BulkReceiveResult,
ImportTrain,
@@ -43,8 +45,8 @@ import type {
ReadyToLoadRow,
ReceiveInventoryPayload,
} from '@/types/warehouse';
import { warehouseService } from '@/services/warehouse.service';
import { BookingSelect } from './BookingSelect';
import { InventoryWorkbench } from './InventoryWorkbench';
import { extractErrorMessage, formatDate, formatNumber } from './options';
interface ReceiveInventoryModalProps {
@@ -721,44 +723,34 @@ function ImportTrainDetailTable({ scheduleId }: { scheduleId: string }) {
/** 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 autoUnloadMutation = useAutoUnloadArrivedBookings();
const [openId, setOpenId] = useState<string | null>(null);
const [busyId, setBusyId] = useState<string | null>(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 */
}
}
const res = (await autoUnloadMutation.mutateAsync(train.scheduleId)) as {
data: AutoUnloadArrivedResult;
};
const r = res.data;
const extra = [
r.skippedCount ? `${r.skippedCount} skipped` : '',
r.failedCount ? `${r.failedCount} failed` : '',
]
.filter(Boolean)
.join(', ');
toast({
title: `Auto-unloaded ${ok}/${items.length} booking(s)`,
description: ok < items.length ? `${items.length - ok} skipped (already unloaded or not eligible)` : undefined,
title: `${r.unloadedCount} unloaded`,
description: extra || undefined,
});
onChanged?.();
} catch (error) {
@@ -839,7 +831,7 @@ function ImportArriveQueueTab({
loading={busyId === t.scheduleId}
onClick={() => autoUnload(t)}
>
Auto Unload
Auto Unload Arrived Bookings
</Button>
</Group>
</Table.Td>
@@ -862,6 +854,34 @@ function ImportArriveQueueTab({
);
}
/**
* Import Unloaded Queue: items unloaded off arrived trains (status UNLOADED), with the full set of
* lifecycle actions (Inspect / Store / Move / History / …) plus a Last Mile action shown only when
* the booking requested door delivery. No automatic storage happens here — the operator drives it.
*/
function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
const { toast } = useToast();
const { data: items = [], isLoading } = useWarehouseInventory(enabled ? { status: 'UNLOADED' } : undefined);
return (
<Stack gap="sm" mt="sm">
<Text size="sm" c="dimmed">
<b>{items.length}</b> unloaded item{items.length !== 1 ? 's' : ''}
</Text>
<InventoryWorkbench
items={items}
isLoading={isLoading}
onLastMile={(it) =>
toast({
title: 'Last mile delivery',
description: `Door delivery for ${it.booking?.reference ?? it.id} — handled in the next batch.`,
})
}
/>
</Stack>
);
}
/** New bulk Receive: Import / Export tabs with eligible PAID bookings. */
function BulkReceiveModal({ opened, onClose, onReceived }: ReceiveInventoryModalProps) {
const [location, setLocation] = useState<Location>({ warehouseId: '', yardId: '', zoneId: '' });
@@ -897,12 +917,10 @@ function BulkReceiveModal({ opened, onClose, onReceived }: ReceiveInventoryModal
</Tabs.List>
<Tabs.Panel value="arrive-queue">
<ImportArriveQueueTab location={location} enabled={opened} onChanged={onReceived} />
<ImportArriveQueueTab enabled={opened} onChanged={onReceived} />
</Tabs.Panel>
<Tabs.Panel value="unloaded-queue">
<Text c="dimmed" ta="center" py="lg" size="sm">
Unloaded Queue coming in the next batch.
</Text>
<ImportUnloadedQueueTab enabled={opened} />
</Tabs.Panel>
<Tabs.Panel value="dispatch-queue">
<Text c="dimmed" ta="center" py="lg" size="sm">

View File

@@ -1,5 +1,5 @@
import { ActionIcon, Badge, Button, Checkbox, Group, Table, Text, Tooltip } from '@mantine/core';
import { ArrowRightLeft, ClipboardList, Coins, History } from 'lucide-react';
import { ArrowRightLeft, ClipboardList, Coins, History, MapPin } from 'lucide-react';
import type { InventoryAction, WarehouseInventoryItem } from '@/types/warehouse';
import { getNextInventoryAction } from '@/types/warehouse';
@@ -14,6 +14,8 @@ interface WarehouseInventoryTableProps {
onHistory: (item: WarehouseInventoryItem) => void;
onInspect?: (item: WarehouseInventoryItem) => void;
onFeePreview?: (item: WarehouseInventoryItem) => void;
// Optional Last Mile action — only rendered for items whose booking requested door delivery.
onLastMile?: (item: WarehouseInventoryItem) => void;
// Optional row selection (used for bulk Mark-as-Inspected).
selectedIds?: Set<string>;
onToggleSelect?: (id: string) => void;
@@ -48,6 +50,7 @@ export function WarehouseInventoryTable({
onHistory,
onInspect,
onFeePreview,
onLastMile,
selectedIds,
onToggleSelect,
onToggleSelectAll,
@@ -171,6 +174,13 @@ export function WarehouseInventoryTable({
</ActionIcon>
</Tooltip>
)}
{onLastMile && item.booking?.lastMileDeliveryAddress && (
<Tooltip label="Last mile delivery" withArrow>
<ActionIcon variant="subtle" color="blue" onClick={() => onLastMile(item)}>
<MapPin size={16} />
</ActionIcon>
</Tooltip>
)}
<Tooltip label="History" withArrow>
<ActionIcon variant="subtle" color="gray" onClick={() => onHistory(item)}>
<History size={16} />

View File

@@ -34,6 +34,7 @@ export function WarehouseStatusBadge({ status }: { status: WarehouseStatus }) {
}
const inventoryStatusColor: Record<InventoryStatus, string> = {
UNLOADED: 'indigo',
RECEIVED: 'yellow',
STORED: 'blue',
RESERVED: 'grape',

View File

@@ -320,6 +320,7 @@ export const URL_CONSTANTS = {
IMPORT_ARRIVE_QUEUE: '/warehouse-inventory/import/arrive-queue',
IMPORT_TRAIN_ITEMS: (scheduleId: string) =>
`/warehouse-inventory/import/trains/${scheduleId}/items`,
IMPORT_AUTO_UNLOAD_ARRIVED: '/warehouse-inventory/import/auto-unload-arrived-bookings',
},
WAREHOUSE_LOADINGS: {

View File

@@ -245,6 +245,10 @@ export function useImportTrainItems(scheduleId?: string) {
});
}
/** Unload all eligible assigned bookings of an ARRIVED import train (→ UNLOADED). */
export const useAutoUnloadArrivedBookings = () =>
useInventoryMutation((scheduleId: string) => warehouseService.autoUnloadArrivedBookings(scheduleId));
// ── Loading (Batch 3) ────────────────────────────────────────────────────────
export function useLoadableWagons(enabled = true) {

View File

@@ -39,6 +39,7 @@ import type {
BulkDispatchResult,
ImportTrain,
ImportTrainItem,
AutoUnloadArrivedResult,
ReserveInventoryPayload,
SaveWarehousePayload,
SaveYardPayload,
@@ -146,6 +147,11 @@ export const warehouseService = {
apiClient.get<ImportTrain[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.IMPORT_ARRIVE_QUEUE),
importTrainItems: (scheduleId: string) =>
apiClient.get<ImportTrainItem[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.IMPORT_TRAIN_ITEMS(scheduleId)),
autoUnloadArrivedBookings: (scheduleId: string) =>
apiClient.post<AutoUnloadArrivedResult>(
URL_CONSTANTS.WAREHOUSE_INVENTORY.IMPORT_AUTO_UNLOAD_ARRIVED,
{ scheduleId },
),
move: (id: string, payload: MoveInventoryPayload) =>
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.MOVE(id), payload),
movements: (id: string) =>

View File

@@ -23,6 +23,7 @@ export const WAREHOUSE_ZONE_TYPES = [
export type WarehouseZoneType = (typeof WAREHOUSE_ZONE_TYPES)[number];
export const INVENTORY_STATUSES = [
'UNLOADED',
'RECEIVED',
'STORED',
'RESERVED',
@@ -52,6 +53,7 @@ export type InventoryAction =
* {@link getNextInventoryAction} which resolves those at runtime.
*/
export const INVENTORY_NEXT_ACTION: Record<InventoryStatus, InventoryAction | null> = {
UNLOADED: 'store',
RECEIVED: 'store',
STORED: 'reserve',
RESERVED: 'ready-for-loading',
@@ -72,6 +74,7 @@ export function getNextInventoryAction(item: WarehouseInventoryItem): InventoryA
const isImport = item.booking?.tradeDirection === 'IMPORT';
switch (item.status) {
case 'UNLOADED':
case 'RECEIVED':
// Import goods skip storage; they need inspection before pickup.
if (isImport) return inspected ? 'ready-for-pickup' : null;
@@ -206,6 +209,8 @@ export interface InventoryBookingRef {
status?: string | null;
paymentStatus?: string | null;
tradeDirection?: string | null;
// Present when the customer requested last-mile (door) delivery — gates the Last Mile action.
lastMileDeliveryAddress?: string | null;
}
export interface InventoryMovement {
@@ -414,6 +419,13 @@ export interface ImportTrain {
status: string;
}
export interface AutoUnloadArrivedResult {
unloadedCount: number;
skippedCount: number;
failedCount: number;
results: { bookingId: string; inventoryId?: string; status: string; reason?: string }[];
}
export interface ImportTrainItem {
bookingId: string;
bookingReference: string | null;