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',