feat(warehouse): Batch 3+4 — export receive-selected label + bulk-mark-inspected workflow

- BulkReceiveModal: Receive Selected shows "received at facility" for EXPORT
- Export inspection: POST /warehouse-inventory/bulk-mark-inspected reuses WarehouseInspectionService.create
- EXPORT items advance to READY_FOR_LOADING after inspection PASSED
- InventoryWorkbench: selection state + "Mark Selected as Inspected" bulk button
- WarehouseInventoryTable: optional Checkbox column for bulk selection
- Route-based direction in eligibleBookings, bulkReceive, getBookingDirection
- New BulkInspectDto; service + controller wired

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Hagernesh
2026-06-20 17:52:01 +00:00
parent b7a831b063
commit 50c6381341
10 changed files with 220 additions and 12 deletions

View File

@@ -0,0 +1,26 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { ArrayNotEmpty, IsArray, IsOptional, IsString, IsUUID } from 'class-validator';
/** Bulk-mark received inventory items as inspection PASSED. */
export class BulkInspectDto {
@ApiProperty({ type: [String], format: 'uuid' })
@IsArray()
@ArrayNotEmpty()
@IsUUID('all', { each: true })
inventoryIds!: string[];
@ApiPropertyOptional({ description: 'Inspection type label (e.g. ORIGIN_INSPECTION).' })
@IsOptional()
@IsString()
inspectionType?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
remarks?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
inspectedBy?: string;
}

View File

@@ -2,6 +2,7 @@ import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { BulkReceiveDto } from './dto/bulk-receive.dto';
import { BulkInspectDto } from './dto/bulk-inspect.dto';
import { DeliverInventoryDto } from './dto/deliver-inventory.dto';
import { FilterWarehouseInventoryDto } from './dto/filter-inventory.dto';
import { InquiryWarehouseInventoryDto } from './dto/inquiry-inventory.dto';
@@ -77,6 +78,12 @@ export class WarehouseInventoryController {
return this.inventoryService.loadPassedExport(performedBy);
}
@Post('bulk-mark-inspected')
@ApiOperation({ summary: 'Bulk mark received inventory inspection PASSED (EXPORT → READY_FOR_LOADING)' })
bulkMarkInspected(@Body() dto: BulkInspectDto) {
return this.inventoryService.bulkMarkInspected(dto);
}
@Post('bookings/:bookingId/unload')
@ApiOperation({ summary: 'Unload a single arrived booking into a location' })
unloadBooking(

View File

@@ -3,6 +3,7 @@ import { DataSource, EntityManager, FindManyOptions, ILike } from 'typeorm';
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
import { Cargo } from '../cargoes/entities/cargoes.entity';
import { BulkInspectDto } from './dto/bulk-inspect.dto';
import { BulkReceiveDto } from './dto/bulk-receive.dto';
import { DeliverInventoryDto } from './dto/deliver-inventory.dto';
import { FilterWarehouseInventoryDto } from './dto/filter-inventory.dto';
@@ -14,6 +15,7 @@ import { ReleaseOrderDto } from './dto/release-order.dto';
import { ReserveInventoryDto } from './dto/reserve-inventory.dto';
import { UnloadBookingDto } from './dto/unload-booking.dto';
import { WarehouseAllocationService } from './warehouse-allocation.service';
import { WarehouseInspectionService } from './warehouse-inspection.service';
import { WarehouseInvoiceService } from './warehouse-invoice.service';
import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity';
import { WarehouseInventoryMovement } from './entities/warehouse-inventory-movement.entity';
@@ -146,6 +148,12 @@ export interface LoadPassedExportResult {
results: { inventoryId: string; status: string; reason?: string }[];
}
export interface BulkInspectResult {
inspectedCount: number;
skippedCount: number;
results: { inventoryId: string; status: string; reason?: string }[];
}
@Injectable()
export class WarehouseInventoryService {
constructor(
@@ -156,6 +164,7 @@ export class WarehouseInventoryService {
private readonly scheduling: SchedulingReadFacade,
private readonly allocation: WarehouseAllocationService,
private readonly invoices: WarehouseInvoiceService,
private readonly inspectionService: WarehouseInspectionService,
) {}
/**
@@ -592,6 +601,63 @@ export class WarehouseInventoryService {
return result;
}
/**
* Bulk-mark received items inspection PASSED (reusing the inspection service to create a minimal
* report + sync inspectionStatus/inspectedAt). EXPORT items advance straight to READY_FOR_LOADING.
* For damage / weight-loss / images, use the per-item Inspect / Report action instead.
*/
async bulkMarkInspected(dto: BulkInspectDto): Promise<BulkInspectResult> {
const result: BulkInspectResult = { inspectedCount: 0, skippedCount: 0, results: [] };
const eligible = ['RECEIVED', 'STORED', 'RESERVED'];
for (const inventoryId of dto.inventoryIds) {
const skip = (reason: string) => {
result.skippedCount += 1;
result.results.push({ inventoryId, status: 'SKIPPED', reason });
};
const item = await this.inventoryRepository.findById(inventoryId);
if (!item) { skip('Inventory not found'); continue; }
if (item.inspectionStatus === 'PASSED') { skip('Already inspected'); continue; }
if (!eligible.includes(item.status)) { skip(`Status ${item.status} not eligible for inspection`); continue; }
// Reuse the existing inspection service: creates a minimal PASSED report + sets inspectionStatus/inspectedAt.
await this.inspectionService.create(inventoryId, {
reportType: 'INSPECTION',
inspectionStatus: 'PASSED',
remarks: dto.remarks ?? 'Bulk marked inspected (PASSED).',
inspectedById: dto.inspectedBy,
});
// EXPORT: a passed item moves straight to Ready To Load.
const direction = item.bookingId ? await this.getBookingDirection(item.bookingId) : null;
if (direction === 'EXPORT') {
await this.dataSource.transaction(async (manager) => {
await manager.getRepository(WarehouseInventory).update(inventoryId, {
status: 'READY_FOR_LOADING',
readyForLoadingAt: new Date(),
});
await this.activityLog.record(
{
activityType: 'READY_FOR_LOADING',
inventoryId,
warehouseId: item.warehouseId,
description: 'Inspection passed → ready for loading',
performedBy: dto.inspectedBy,
},
manager,
);
});
result.results.push({ inventoryId, status: 'READY_FOR_LOADING' });
} else {
result.results.push({ inventoryId, status: 'INSPECTED' });
}
result.inspectedCount += 1;
}
return result;
}
// ── Receive ──────────────────────────────────────────────────────────────
async receive(dto: ReceiveWarehouseInventoryDto): Promise<WarehouseInventory> {

View File

@@ -1,8 +1,10 @@
import { useState } from 'react';
import { Center, Loader } from '@mantine/core';
import { Button, Center, Group, Loader, Stack, Text } from '@mantine/core';
import { ClipboardCheck } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import {
useBulkMarkInspected,
useDispatchInventory,
useMarkReadyForLoading,
useMarkReadyForPickup,
@@ -42,6 +44,39 @@ export function InventoryWorkbench({ items, isLoading }: InventoryWorkbenchProps
const readyMutation = useMarkReadyForLoading();
const pickupMutation = useMarkReadyForPickup();
const dispatchMutation = useDispatchInventory();
const inspectMutation = useBulkMarkInspected();
const [selected, setSelected] = useState<Set<string>>(new Set());
const allSelected = items.length > 0 && selected.size === items.length;
const someSelected = selected.size > 0 && !allSelected;
const toggleSelect = (id: string) =>
setSelected((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
const toggleSelectAll = () =>
setSelected(allSelected ? new Set() : new Set(items.map((i) => i.id)));
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: { inspectedCount: number; skippedCount: number };
};
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) });
}
};
const runDirect = async (item: WarehouseInventoryItem, fn: () => Promise<unknown>, label: string) => {
setBusyId(item.id);
@@ -92,15 +127,38 @@ export function InventoryWorkbench({ items, isLoading }: InventoryWorkbenchProps
return (
<>
<WarehouseInventoryTable
items={items}
busyId={busyId}
onAdvance={advance}
onMove={setMoveItem}
onHistory={setHistoryItem}
onInspect={setInspectItem}
onFeePreview={setFeeItem}
/>
<Stack gap="sm">
<Group justify="space-between">
<Text size="sm" c="dimmed">
Selected: <b>{selected.size}</b>
</Text>
<Button
size="compact-sm"
variant="light"
leftSection={<ClipboardCheck size={14} />}
disabled={selected.size === 0}
loading={inspectMutation.isPending}
onClick={markInspected}
>
Mark Selected as Inspected
</Button>
</Group>
<WarehouseInventoryTable
items={items}
busyId={busyId}
onAdvance={advance}
onMove={setMoveItem}
onHistory={setHistoryItem}
onInspect={setInspectItem}
onFeePreview={setFeeItem}
selectedIds={selected}
onToggleSelect={toggleSelect}
onToggleSelectAll={toggleSelectAll}
allSelected={allSelected}
someSelected={someSelected}
/>
</Stack>
<MoveInventoryModal opened={Boolean(moveItem)} onClose={() => setMoveItem(null)} item={moveItem} />
<ReserveInventoryModal

View File

@@ -159,7 +159,7 @@ function EligibleTab({
};
const r = res.data;
toast({
title: `${r.receivedCount} received`,
title: `${r.receivedCount} received at ${direction === 'EXPORT' ? 'facility' : 'warehouse'}`,
description: r.skippedCount ? `${r.skippedCount} skipped` : undefined,
});
setSelected(new Set());

View File

@@ -1,4 +1,4 @@
import { ActionIcon, Badge, Button, Group, Table, Text, Tooltip } from '@mantine/core';
import { ActionIcon, Badge, Button, Checkbox, Group, Table, Text, Tooltip } from '@mantine/core';
import { ArrowRightLeft, ClipboardList, Coins, History } from 'lucide-react';
import type { InventoryAction, WarehouseInventoryItem } from '@/types/warehouse';
@@ -14,6 +14,12 @@ interface WarehouseInventoryTableProps {
onHistory: (item: WarehouseInventoryItem) => void;
onInspect?: (item: WarehouseInventoryItem) => void;
onFeePreview?: (item: WarehouseInventoryItem) => void;
// Optional row selection (used for bulk Mark-as-Inspected).
selectedIds?: Set<string>;
onToggleSelect?: (id: string) => void;
onToggleSelectAll?: () => void;
allSelected?: boolean;
someSelected?: boolean;
}
const itemKind = (item: WarehouseInventoryItem) => {
@@ -42,7 +48,13 @@ export function WarehouseInventoryTable({
onHistory,
onInspect,
onFeePreview,
selectedIds,
onToggleSelect,
onToggleSelectAll,
allSelected,
someSelected,
}: WarehouseInventoryTableProps) {
const selectable = Boolean(onToggleSelect);
if (items.length === 0) {
return (
<Text c="dimmed" ta="center" py="xl">
@@ -56,6 +68,16 @@ export function WarehouseInventoryTable({
<Table highlightOnHover verticalSpacing="sm" striped>
<Table.Thead>
<Table.Tr>
{selectable && (
<Table.Th w={40}>
<Checkbox
aria-label="Select all"
checked={allSelected}
indeterminate={someSelected}
onChange={onToggleSelectAll}
/>
</Table.Th>
)}
<Table.Th>Booking</Table.Th>
<Table.Th>Facility</Table.Th>
<Table.Th>Warehouse</Table.Th>
@@ -76,6 +98,15 @@ export function WarehouseInventoryTable({
const nextAction = getNextInventoryAction(item);
return (
<Table.Tr key={item.id}>
{selectable && (
<Table.Td>
<Checkbox
aria-label={`Select ${item.bookingId ?? item.id}`}
checked={selectedIds?.has(item.id) ?? false}
onChange={() => onToggleSelect?.(item.id)}
/>
</Table.Td>
)}
<Table.Td>
{item.bookingId ? (
<Tooltip label={item.bookingId} withArrow>

View File

@@ -310,6 +310,7 @@ export const URL_CONSTANTS = {
ELIGIBLE_BOOKINGS: (direction: string) => `/warehouse-inventory/eligible-bookings?direction=${direction}`,
RECEIVE_BULK: '/warehouse-inventory/receive-bulk',
LOAD_PASSED_EXPORT: '/warehouse-inventory/load-passed-export',
BULK_MARK_INSPECTED: '/warehouse-inventory/bulk-mark-inspected',
},
WAREHOUSE_LOADINGS: {

View File

@@ -15,6 +15,7 @@ import type {
ReleaseOrderPayload,
DeliverInventoryPayload,
BulkReceivePayload,
BulkInspectPayload,
ReserveInventoryPayload,
SaveWarehousePayload,
SaveYardPayload,
@@ -199,6 +200,8 @@ export const useBulkReceive = () =>
useInventoryMutation((payload: BulkReceivePayload) => warehouseService.receiveBulk(payload));
export const useLoadPassedExport = () =>
useInventoryMutation(() => warehouseService.loadPassedExport());
export const useBulkMarkInspected = () =>
useInventoryMutation((payload: BulkInspectPayload) => warehouseService.bulkMarkInspected(payload));
// ── Loading (Batch 3) ────────────────────────────────────────────────────────

View File

@@ -33,6 +33,8 @@ import type {
BulkReceivePayload,
BulkReceiveResult,
LoadPassedExportResult,
BulkInspectPayload,
BulkInspectResult,
ReserveInventoryPayload,
SaveWarehousePayload,
SaveYardPayload,
@@ -126,6 +128,8 @@ export const warehouseService = {
apiClient.post<BulkReceiveResult>(URL_CONSTANTS.WAREHOUSE_INVENTORY.RECEIVE_BULK, payload),
loadPassedExport: () =>
apiClient.post<LoadPassedExportResult>(URL_CONSTANTS.WAREHOUSE_INVENTORY.LOAD_PASSED_EXPORT, {}),
bulkMarkInspected: (payload: BulkInspectPayload) =>
apiClient.post<BulkInspectResult>(URL_CONSTANTS.WAREHOUSE_INVENTORY.BULK_MARK_INSPECTED, payload),
move: (id: string, payload: MoveInventoryPayload) =>
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.MOVE(id), payload),
movements: (id: string) =>

View File

@@ -368,6 +368,18 @@ export interface LoadPassedExportResult {
results: { inventoryId: string; status: string; reason?: string }[];
}
export interface BulkInspectPayload {
inventoryIds: string[];
inspectionType?: string;
remarks?: string;
}
export interface BulkInspectResult {
inspectedCount: number;
skippedCount: number;
results: { inventoryId: string; status: string; reason?: string }[];
}
export interface InventoryInquiryResult {
id: string;
bookingId: string;