feat(warehouse): Batch 9 — Import Unloaded Queue + bulk destination inspection

- bulkMarkInspected: UNLOADED added to eligible statuses; IMPORT passed items now advance to
  READY_FOR_PICKUP (pickup ready) instead of READY_FOR_LOADING (export unchanged)
- GET /warehouse-inventory/import/unloaded-queue: UNLOADED import items with the inspection-screen
  columns (booking, customer, arrival, container, cargo type, weight, train schedule, inspection
  status, pickup option, last-mile, current status); route-derived import filter
- Dedicated ImportUnloadedQueueTab replaces the Batch 8 workbench reuse: full spec columns,
  Select All / Unselect All / selected count / Mark Selected as Inspected, plus per-row
  Inspect / Report (InspectionReportModal) for damage / image / weight-loss detail
- Verified: import item UNLOADED → bulk inspect → READY_FOR_PICKUP (PASSED), drops out of queue

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Hagernesh
2026-06-21 14:15:22 +00:00
parent 8b28a7c430
commit 040ba6d495
7 changed files with 285 additions and 21 deletions

View File

@@ -136,6 +136,12 @@ export class WarehouseInventoryController {
return this.inventoryService.autoUnloadArrivedBookings(dto.scheduleId, dto.performedBy);
}
@Get('import/unloaded-queue')
@ApiOperation({ summary: 'IMPORT inventory in the Unloaded Queue (UNLOADED / destination inspection)' })
importUnloadedQueue() {
return this.inventoryService.importUnloadedQueue();
}
@Get('loadable-wagons')
@ApiOperation({ summary: 'List wagons usable for loading (read-only from scheduling)' })
loadableWagons() {

View File

@@ -182,6 +182,23 @@ export interface AutoUnloadArrivedResult {
results: { bookingId: string; inventoryId?: string; status: string; reason?: string }[];
}
export interface ImportUnloadedRow {
id: string;
bookingId: string | null;
bookingReference: string | null;
customerId: string | null;
customerName: string | null;
arrivalTime: string | null;
containerNumber: string | null;
cargoType: string | null;
weight: number | null;
trainSchedule: string | null;
inspectionStatus: string | null;
pickupOption: string;
lastMileRequested: boolean;
currentStatus: string;
}
@Injectable()
export class WarehouseInventoryService {
constructor(
@@ -689,6 +706,55 @@ 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[]> {
const rows: Array<
ImportUnloadedRow & { originCountry: string | null; destinationCountry: string | null }
> = await this.dataSource.query(
`SELECT inv.id,
inv.booking_id AS "bookingId",
b.reference AS "bookingReference",
b.company_id AS "customerId",
company.name AS "customerName",
COALESCE(inv.unloaded_at, inv.arrived_at) AS "arrivalTime",
(SELECT c.container_number FROM freight.containers c
WHERE c.booking_id = b.id AND c.deleted_at IS NULL
ORDER BY c.container_number LIMIT 1) AS "containerNumber",
COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType",
inv.weight AS "weight",
ts.train_number AS "trainSchedule",
inv.inspection_status AS "inspectionStatus",
CASE WHEN b.last_mile_delivery_address IS NOT NULL
THEN 'DOOR_DELIVERY' ELSE 'TERMINAL_PICKUP' END AS "pickupOption",
(b.last_mile_delivery_address IS NOT NULL) AS "lastMileRequested",
inv.status AS "currentStatus",
oy.country AS "originCountry",
dy.country AS "destinationCountry"
FROM freight.warehouse_inventory inv
LEFT JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL
LEFT JOIN freight.companies company ON company.id = b.company_id
LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id
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')
ORDER BY inv.created_at DESC`,
);
return rows
.filter(
(r) =>
deriveTradeDirection({ country: r.originCountry }, { country: r.destinationCountry }) === 'IMPORT',
)
.map(({ originCountry: _oc, destinationCountry: _dc, ...rest }) => rest);
}
/**
* Bulk-dispatch loaded EXPORT inventory. Reuses the single-item dispatch transition
* (status LOADED → DISPATCHED, capacity freed, movement/activity logged). Items not
@@ -876,7 +942,8 @@ export class WarehouseInventoryService {
*/
async bulkMarkInspected(dto: BulkInspectDto): Promise<BulkInspectResult> {
const result: BulkInspectResult = { inspectedCount: 0, skippedCount: 0, results: [] };
const eligible = ['RECEIVED', 'STORED', 'RESERVED'];
// UNLOADED added for Batch 9 import destination inspection (arrived-train unload landing state).
const eligible = ['UNLOADED', 'RECEIVED', 'STORED', 'RESERVED'];
for (const inventoryId of dto.inventoryIds) {
const skip = (reason: string) => {
@@ -897,7 +964,9 @@ export class WarehouseInventoryService {
inspectedById: dto.inspectedBy,
});
// EXPORT: a passed item moves straight to Ready To Load.
// A passed item advances by trade direction:
// EXPORT → Ready To Load (READY_FOR_LOADING)
// IMPORT → Pickup Ready (READY_FOR_PICKUP) — NOT ready-for-loading.
const direction = item.bookingId ? await this.getBookingDirection(item.bookingId) : null;
if (direction === 'EXPORT') {
await this.dataSource.transaction(async (manager) => {
@@ -917,6 +986,24 @@ export class WarehouseInventoryService {
);
});
result.results.push({ inventoryId, status: 'READY_FOR_LOADING' });
} else if (direction === 'IMPORT') {
await this.dataSource.transaction(async (manager) => {
await manager.getRepository(WarehouseInventory).update(inventoryId, {
status: 'READY_FOR_PICKUP',
readyForPickupAt: new Date(),
});
await this.activityLog.record(
{
activityType: 'READY_FOR_PICKUP',
inventoryId,
warehouseId: item.warehouseId,
description: 'Destination inspection passed → pickup ready',
performedBy: dto.inspectedBy,
},
manager,
);
});
result.results.push({ inventoryId, status: 'READY_FOR_PICKUP' });
} else {
result.results.push({ inventoryId, status: 'INSPECTED' });
}

View File

@@ -16,21 +16,22 @@ import {
Textarea,
TextInput,
} from '@mantine/core';
import { ChevronDown, ChevronRight, Info, PackageSearch, Train, Truck } from 'lucide-react';
import { ChevronDown, ChevronRight, ClipboardCheck, Info, PackageSearch, Train, Truck } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import {
useAutoUnloadArrivedBookings,
useBulkDispatchExport,
useBulkMarkInspected,
useBulkReceive,
useEligibleBookings,
useImportArriveQueue,
useImportTrainItems,
useImportUnloadedQueue,
useLoadPassedExport,
useLoadedExport,
useReadyToLoadExport,
useReceiveInventory,
useWarehouseInventory,
useWarehouseYards,
useWarehouseZones,
useWarehouses,
@@ -38,15 +39,17 @@ import {
import type {
AutoUnloadArrivedResult,
BulkDispatchResult,
BulkInspectResult,
BulkReceiveResult,
ImportTrain,
ImportTrainItem,
ImportUnloadedItem,
LoadPassedExportResult,
ReadyToLoadRow,
ReceiveInventoryPayload,
} from '@/types/warehouse';
import { BookingSelect } from './BookingSelect';
import { InventoryWorkbench } from './InventoryWorkbench';
import { InspectionReportModal } from './InspectionReportModal';
import { extractErrorMessage, formatDate, formatNumber } from './options';
interface ReceiveInventoryModalProps {
@@ -855,28 +858,166 @@ 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.
* Import Unloaded Queue (Batch 9): all unloaded import items with the destination-inspection columns.
* Multi-select + Mark Selected as Inspected (import passed → READY_FOR_PICKUP), and the per-item
* Inspect / Report action stays for damage / images / weight-loss detail.
*/
function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
const { toast } = useToast();
const { data: items = [], isLoading } = useWarehouseInventory(enabled ? { status: 'UNLOADED' } : undefined);
const { data: rows = [], isLoading } = useImportUnloadedQueue(enabled);
const inspectMutation = useBulkMarkInspected();
const [selected, setSelected] = useState<Set<string>>(new Set());
const [inspectId, setInspectId] = useState<string | null>(null);
const allSelected = rows.length > 0 && selected.size === rows.length;
const someSelected = selected.size > 0 && !allSelected;
const selectAll = () => setSelected(new Set(rows.map((r) => r.id)));
const unselectAll = () => setSelected(new Set());
const toggleOne = (id: string) =>
setSelected((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
const markInspected = async () => {
if (selected.size === 0) {
toast({ variant: 'destructive', title: 'Select at least one item' });
return;
}
try {
const res = (await inspectMutation.mutateAsync({ inventoryIds: [...selected] })) as {
data: BulkInspectResult;
};
const r = res.data;
toast({
title: `${r.inspectedCount} marked inspected`,
description: r.skippedCount ? `${r.skippedCount} skipped` : undefined,
});
setSelected(new Set());
} catch (error) {
toast({ variant: 'destructive', title: 'Mark inspected failed', description: extractErrorMessage(error) });
}
};
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.`,
})
}
<Group justify="space-between">
<Text size="sm" c="dimmed">
Selected: <b>{selected.size}</b> / {rows.length} unloaded
</Text>
<Group gap="xs">
<Button size="compact-sm" variant="default" disabled={rows.length === 0} onClick={selectAll}>
Select All
</Button>
<Button size="compact-sm" variant="default" disabled={selected.size === 0} onClick={unselectAll}>
Unselect All
</Button>
<Button
size="compact-sm"
color="indigo"
leftSection={<ClipboardCheck size={14} />}
disabled={selected.size === 0}
loading={inspectMutation.isPending}
onClick={markInspected}
>
Mark Selected as Inspected
</Button>
</Group>
</Group>
{isLoading ? (
<Group justify="center" py="lg">
<Loader size="sm" />
</Group>
) : rows.length === 0 ? (
<Text c="dimmed" ta="center" py="lg" size="sm">
No unloaded import items. Items appear here after Auto Unload on an arrived train.
</Text>
) : (
<Table.ScrollContainer minWidth={2000}>
<Table highlightOnHover verticalSpacing="xs" striped>
<Table.Thead>
<Table.Tr>
<Table.Th w={40}>
<Checkbox
aria-label="Select all"
checked={allSelected}
indeterminate={someSelected}
onChange={() => (allSelected ? unselectAll() : selectAll())}
/>
</Table.Th>
<Table.Th>Booking ID</Table.Th>
<Table.Th>Booking Ref</Table.Th>
<Table.Th>Customer ID</Table.Th>
<Table.Th>Customer Name</Table.Th>
<Table.Th>Arrival Time</Table.Th>
<Table.Th>Container #</Table.Th>
<Table.Th>Cargo Type</Table.Th>
<Table.Th>Weight</Table.Th>
<Table.Th>Train Schedule</Table.Th>
<Table.Th>Inspection</Table.Th>
<Table.Th>Pickup Option</Table.Th>
<Table.Th>Last Mile</Table.Th>
<Table.Th>Current Status</Table.Th>
<Table.Th ta="right">Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rows.map((r: ImportUnloadedItem) => (
<Table.Tr key={r.id}>
<Table.Td>
<Checkbox
aria-label={`Select ${r.bookingReference ?? r.id}`}
checked={selected.has(r.id)}
onChange={() => toggleOne(r.id)}
/>
</Table.Td>
<Table.Td>
<Text size="xs" c="dimmed">{r.bookingId ? `${r.bookingId.slice(0, 8)}` : '—'}</Text>
</Table.Td>
<Table.Td>
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
</Table.Td>
<Table.Td>
<Text size="xs" c="dimmed">{r.customerId ? `${r.customerId.slice(0, 8)}` : '—'}</Text>
</Table.Td>
<Table.Td>{r.customerName ?? '—'}</Table.Td>
<Table.Td>{formatDate(r.arrivalTime)}</Table.Td>
<Table.Td>{r.containerNumber ?? '—'}</Table.Td>
<Table.Td>{r.cargoType ?? '—'}</Table.Td>
<Table.Td>{formatNumber(Number(r.weight))}</Table.Td>
<Table.Td>{r.trainSchedule ?? '—'}</Table.Td>
<Table.Td>
<Badge color={r.inspectionStatus === 'PASSED' ? 'green' : 'gray'} variant="light" size="sm">
{r.inspectionStatus ?? 'Not inspected'}
</Badge>
</Table.Td>
<Table.Td>{r.pickupOption}</Table.Td>
<Table.Td>
<Badge color={r.lastMileRequested ? 'blue' : 'gray'} variant="light" size="sm">
{r.lastMileRequested ? 'Yes' : 'No'}
</Badge>
</Table.Td>
<Table.Td>
<Badge color="indigo" variant="light" size="sm">{r.currentStatus}</Badge>
</Table.Td>
<Table.Td ta="right">
<Button size="compact-xs" variant="light" color="orange" onClick={() => setInspectId(r.id)}>
Inspect / Report
</Button>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
<InspectionReportModal
opened={Boolean(inspectId)}
onClose={() => setInspectId(null)}
inventoryId={inspectId}
/>
</Stack>
);

View File

@@ -321,6 +321,7 @@ export const URL_CONSTANTS = {
IMPORT_TRAIN_ITEMS: (scheduleId: string) =>
`/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',
},
WAREHOUSE_LOADINGS: {

View File

@@ -249,6 +249,15 @@ export function useImportTrainItems(scheduleId?: string) {
export const useAutoUnloadArrivedBookings = () =>
useInventoryMutation((scheduleId: string) => warehouseService.autoUnloadArrivedBookings(scheduleId));
/** IMPORT inventory in the Unloaded Queue (UNLOADED / destination inspection). Read-only. */
export function useImportUnloadedQueue(enabled = true) {
return useQuery({
queryKey: ['warehouse-inventory', 'import-unloaded-queue'],
queryFn: () => warehouseService.importUnloadedQueue().then((r) => r.data),
enabled,
});
}
// ── Loading (Batch 3) ────────────────────────────────────────────────────────
export function useLoadableWagons(enabled = true) {

View File

@@ -39,6 +39,7 @@ import type {
BulkDispatchResult,
ImportTrain,
ImportTrainItem,
ImportUnloadedItem,
AutoUnloadArrivedResult,
ReserveInventoryPayload,
SaveWarehousePayload,
@@ -152,6 +153,8 @@ export const warehouseService = {
URL_CONSTANTS.WAREHOUSE_INVENTORY.IMPORT_AUTO_UNLOAD_ARRIVED,
{ scheduleId },
),
importUnloadedQueue: () =>
apiClient.get<ImportUnloadedItem[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.IMPORT_UNLOADED_QUEUE),
move: (id: string, payload: MoveInventoryPayload) =>
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.MOVE(id), payload),
movements: (id: string) =>

View File

@@ -426,6 +426,23 @@ export interface AutoUnloadArrivedResult {
results: { bookingId: string; inventoryId?: string; status: string; reason?: string }[];
}
export interface ImportUnloadedItem {
id: string;
bookingId: string | null;
bookingReference: string | null;
customerId: string | null;
customerName: string | null;
arrivalTime: string | null;
containerNumber: string | null;
cargoType: string | null;
weight: number | null;
trainSchedule: string | null;
inspectionStatus: string | null;
pickupOption: string;
lastMileRequested: boolean;
currentStatus: string;
}
export interface ImportTrainItem {
bookingId: string;
bookingReference: string | null;