mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
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:
@@ -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: [],
|
||||
};
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}>
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
Reference in New Issue
Block a user