mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 15:25:45 +00:00
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:
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -34,6 +34,7 @@ export function WarehouseStatusBadge({ status }: { status: WarehouseStatus }) {
|
||||
}
|
||||
|
||||
const inventoryStatusColor: Record<InventoryStatus, string> = {
|
||||
UNLOADED: 'indigo',
|
||||
RECEIVED: 'yellow',
|
||||
STORED: 'blue',
|
||||
RESERVED: 'grape',
|
||||
|
||||
Reference in New Issue
Block a user