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