feat(warehouse): Batch 10 — import post-inspection actions (pickup / last-mile / store / dispatch)

- READY_FOR_PICKUP transitions now → DELIVERED (customer pickup) OR DISPATCHED (dispatch out) OR
  STORED (operator-chosen storage) — pickup and dispatch kept separate. No auto-storage.
- GET /warehouse-inventory/import/pickup-ready-queue: READY_FOR_PICKUP import items (shared query
  with the unloaded queue, route-derived import filter)
- WarehouseInventoryTable: PICKUP_READY rows now show explicit Store + Dispatch buttons alongside
  the customer-pickup (release/deliver) next action; reuses the existing advance() dispatcher
- Import → Dispatch Queue tab renders pickup-ready items via InventoryWorkbench (all existing
  actions + modals intact) with Last Mile shown only when door delivery was requested
- Verified: pickup-ready item → Store → STORED; → Dispatch → DISPATCHED

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Hagernesh
2026-06-21 14:38:23 +00:00
parent 040ba6d495
commit 41d21edae5
8 changed files with 100 additions and 11 deletions

View File

@@ -37,7 +37,10 @@ export const WAREHOUSE_INVENTORY_TRANSITIONS: Record<WarehouseInventoryStatus, W
READY_FOR_LOADING: ['LOADED'],
LOADED: ['DISPATCHED'],
DISPATCHED: [],
READY_FOR_PICKUP: ['DELIVERED'],
// Batch 10: an inspected import item can leave by customer pickup (DELIVERED) or be dispatched
// out by EDR (DISPATCHED) — kept separate — or be put into storage (STORED) if no one collects
// it / customs or inspection hold / operator chooses to store.
READY_FOR_PICKUP: ['DELIVERED', 'STORED', 'DISPATCHED'],
DELIVERED: [],
};

View File

@@ -142,6 +142,12 @@ export class WarehouseInventoryController {
return this.inventoryService.importUnloadedQueue();
}
@Get('import/pickup-ready-queue')
@ApiOperation({ summary: 'IMPORT inventory that is PICKUP_READY (READY_FOR_PICKUP) awaiting pickup/dispatch' })
importPickupReadyQueue() {
return this.inventoryService.importPickupReadyQueue();
}
@Get('loadable-wagons')
@ApiOperation({ summary: 'List wagons usable for loading (read-only from scheduling)' })
loadableWagons() {

View File

@@ -706,12 +706,8 @@ export class WarehouseInventoryService {
return this.exportInventoryByStatus('LOADED');
}
/**
* Batch 9 — IMPORT inventory sitting in the Unloaded Queue (UNLOADED / destination-inspection
* states), with the columns the inspection screen needs. Direction is route-derived so only
* import items appear. Read-only.
*/
async importUnloadedQueue(): Promise<ImportUnloadedRow[]> {
/** Shared query for the import queues — IMPORT inventory at the given statuses, inspection columns. */
private async importQueueByStatuses(statuses: string[]): Promise<ImportUnloadedRow[]> {
const rows: Array<
ImportUnloadedRow & { originCountry: string | null; destinationCountry: string | null }
> = await this.dataSource.query(
@@ -743,8 +739,9 @@ export class WarehouseInventoryService {
LEFT JOIN freight.train_schedule_bookings tsb ON tsb.booking_id = b.id AND tsb.deleted_at IS NULL
LEFT JOIN freight.train_schedules ts ON ts.id = tsb.train_schedule_id
WHERE inv.deleted_at IS NULL
AND inv.status IN ('UNLOADED', 'DESTINATION_INSPECTION', 'UNDER_INSPECTION')
AND inv.status = ANY($1)
ORDER BY inv.created_at DESC`,
[statuses],
);
return rows
@@ -755,6 +752,22 @@ export class WarehouseInventoryService {
.map(({ originCountry: _oc, destinationCountry: _dc, ...rest }) => rest);
}
/**
* Batch 9 — IMPORT inventory sitting in the Unloaded Queue (UNLOADED / destination-inspection
* states), with the columns the inspection screen needs. Read-only.
*/
importUnloadedQueue(): Promise<ImportUnloadedRow[]> {
return this.importQueueByStatuses(['UNLOADED', 'DESTINATION_INSPECTION', 'UNDER_INSPECTION']);
}
/**
* Batch 10 — IMPORT inventory that passed inspection and is PICKUP_READY (READY_FOR_PICKUP),
* awaiting customer pickup / last mile / store / dispatch. Read-only.
*/
importPickupReadyQueue(): Promise<ImportUnloadedRow[]> {
return this.importQueueByStatuses(['READY_FOR_PICKUP']);
}
/**
* Bulk-dispatch loaded EXPORT inventory. Reuses the single-item dispatch transition
* (status LOADED → DISPATCHED, capacity freed, movement/activity logged). Items not

View File

@@ -32,6 +32,7 @@ import {
useLoadedExport,
useReadyToLoadExport,
useReceiveInventory,
useWarehouseInventory,
useWarehouseYards,
useWarehouseZones,
useWarehouses,
@@ -50,6 +51,7 @@ import type {
} from '@/types/warehouse';
import { BookingSelect } from './BookingSelect';
import { InspectionReportModal } from './InspectionReportModal';
import { InventoryWorkbench } from './InventoryWorkbench';
import { extractErrorMessage, formatDate, formatNumber } from './options';
interface ReceiveInventoryModalProps {
@@ -1023,6 +1025,37 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
);
}
/**
* Import Dispatch Queue (Batch 10): inspected import items that are PICKUP_READY (READY_FOR_PICKUP),
* awaiting Customer Pickup (release → deliver), Store, or Dispatch. Reuses InventoryWorkbench so all
* existing actions + modals stay intact; Last Mile shows only when the booking requested door delivery.
* Nothing is stored automatically — Store is an explicit operator action.
*/
function ImportDispatchQueueTab({ enabled }: { enabled: boolean }) {
const { toast } = useToast();
const { data: items = [], isLoading } = useWarehouseInventory(
enabled ? { status: 'READY_FOR_PICKUP' } : undefined,
);
return (
<Stack gap="sm" mt="sm">
<Text size="sm" c="dimmed">
<b>{items.length}</b> pickup-ready 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: '' });
@@ -1064,9 +1097,7 @@ function BulkReceiveModal({ opened, onClose, onReceived }: ReceiveInventoryModal
<ImportUnloadedQueueTab enabled={opened} />
</Tabs.Panel>
<Tabs.Panel value="dispatch-queue">
<Text c="dimmed" ta="center" py="lg" size="sm">
Dispatch Queue coming in the next batch.
</Text>
<ImportDispatchQueueTab enabled={opened} />
</Tabs.Panel>
</Tabs>
</Tabs.Panel>

View File

@@ -153,6 +153,30 @@ export function WarehouseInventoryTable({
{humanizeEnum(nextAction.replace(/-/g, '_'))}
</Button>
)}
{/* Batch 10 — a PICKUP_READY import item can also be stored or dispatched,
kept separate from the customer-pickup (release/deliver) next action. */}
{item.status === 'READY_FOR_PICKUP' && (
<>
<Button
size="compact-xs"
variant="light"
color="blue"
loading={busy}
onClick={() => onAdvance(item, 'store')}
>
Store
</Button>
<Button
size="compact-xs"
variant="light"
color="green"
loading={busy}
onClick={() => onAdvance(item, 'dispatch')}
>
Dispatch
</Button>
</>
)}
{item.status !== 'DISPATCHED' && (
<Tooltip label="Move" withArrow>
<ActionIcon variant="subtle" color="gray" onClick={() => onMove(item)}>

View File

@@ -322,6 +322,7 @@ export const URL_CONSTANTS = {
`/warehouse-inventory/import/trains/${scheduleId}/items`,
IMPORT_AUTO_UNLOAD_ARRIVED: '/warehouse-inventory/import/auto-unload-arrived-bookings',
IMPORT_UNLOADED_QUEUE: '/warehouse-inventory/import/unloaded-queue',
IMPORT_PICKUP_READY_QUEUE: '/warehouse-inventory/import/pickup-ready-queue',
},
WAREHOUSE_LOADINGS: {

View File

@@ -258,6 +258,15 @@ export function useImportUnloadedQueue(enabled = true) {
});
}
/** IMPORT inventory that is PICKUP_READY (READY_FOR_PICKUP) awaiting pickup/dispatch. Read-only. */
export function useImportPickupReadyQueue(enabled = true) {
return useQuery({
queryKey: ['warehouse-inventory', 'import-pickup-ready-queue'],
queryFn: () => warehouseService.importPickupReadyQueue().then((r) => r.data),
enabled,
});
}
// ── Loading (Batch 3) ────────────────────────────────────────────────────────
export function useLoadableWagons(enabled = true) {

View File

@@ -155,6 +155,8 @@ export const warehouseService = {
),
importUnloadedQueue: () =>
apiClient.get<ImportUnloadedItem[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.IMPORT_UNLOADED_QUEUE),
importPickupReadyQueue: () =>
apiClient.get<ImportUnloadedItem[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.IMPORT_PICKUP_READY_QUEUE),
move: (id: string, payload: MoveInventoryPayload) =>
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.MOVE(id), payload),
movements: (id: string) =>