automation

This commit is contained in:
Hagernesh
2026-06-17 22:00:53 +00:00
parent 1db1467ea1
commit 5b8cb16c1a
12 changed files with 705 additions and 1 deletions

View File

@@ -97,6 +97,10 @@ export class WarehouseInventory extends BaseEntity {
@Column({ name: 'status', type: 'varchar', length: 32, default: 'RECEIVED' })
status!: WarehouseInventoryStatus;
// Batch 4.5: latest inspection outcome (PASSED | FAILED | NEEDS_REVIEW). Null = not yet inspected.
@Column({ name: 'inspection_status', type: 'varchar', length: 20, nullable: true })
inspectionStatus?: string | null;
@Column({ name: 'arrived_at', type: 'timestamptz', nullable: true })
arrivedAt?: Date | null;

View File

@@ -7,6 +7,7 @@ import { LoadInventoryDto } from './dto/load-inventory.dto';
import { MoveInventoryDto } from './dto/move-inventory.dto';
import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto';
import { ReserveInventoryDto } from './dto/reserve-inventory.dto';
import { UnloadBookingDto } from './dto/unload-booking.dto';
import { SchedulingReadFacade } from './scheduling-read.facade';
import { WarehouseInventoryService } from './warehouse-inventory.service';
@@ -37,6 +38,33 @@ export class WarehouseInventoryController {
return this.inventoryService.inquiry(filter);
}
@Get('arrival-queue')
@ApiOperation({ summary: 'Arrived bookings awaiting unload / inspection' })
arrivalQueue() {
return this.inventoryService.arrivalQueue();
}
@Post('auto-unload-arrived')
@ApiOperation({ summary: 'Bulk auto-unload all arrived bookings into the warehouse' })
autoUnloadArrived() {
return this.inventoryService.autoUnloadArrived();
}
@Post('auto-load-ready')
@ApiOperation({ summary: 'Auto-load READY_FOR_LOADING inventory with PAID bookings' })
autoLoadReady() {
return this.inventoryService.autoLoadReady();
}
@Post('bookings/:bookingId/unload')
@ApiOperation({ summary: 'Unload a single arrived booking into a location' })
unloadBooking(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@Body() dto: UnloadBookingDto,
) {
return this.inventoryService.unloadBooking(bookingId, dto);
}
@Get('loadable-wagons')
@ApiOperation({ summary: 'List wagons usable for loading (read-only from scheduling)' })
loadableWagons() {

View File

@@ -7,6 +7,7 @@ import { LoadInventoryDto } from './dto/load-inventory.dto';
import { MoveInventoryDto } from './dto/move-inventory.dto';
import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto';
import { ReserveInventoryDto } from './dto/reserve-inventory.dto';
import { UnloadBookingDto } from './dto/unload-booking.dto';
import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity';
import { WarehouseInventoryMovement } from './entities/warehouse-inventory-movement.entity';
import {
@@ -55,6 +56,61 @@ interface LocationNode {
currentContainers: number;
}
// ── Batch 4.5 result/queue shapes ────────────────────────────────────────────
interface ArrivalQueueRow {
bookingId: string;
bookingReference: string;
customer: string | null;
cargo: string | null;
container: string | null;
arrivalDate: Date | null;
bookingStatus: string;
inventoryId: string | null;
currentStatus: string | null;
inspectionStatus: string | null;
facility: string | null;
warehouse: string | null;
yard: string | null;
zone: string | null;
}
export interface ArrivalQueueItem {
bookingId: string;
bookingReference: string;
customer: string | null;
cargo: string | null;
container: string | null;
facility: string | null;
warehouse: string | null;
yard: string | null;
zone: string | null;
inventoryId: string | null;
currentStatus: string | null;
arrivalDate: Date | null;
inspectionStatus: string | null;
unloaded: boolean;
}
interface DefaultLocation {
warehouseId: string;
yardId: string;
zoneId: string;
facilityId: string | null;
}
export interface AutoUnloadResult {
processedCount: number;
skippedCount: number;
failedCount: number;
results: { bookingId: string; inventoryId?: string; status: string; reason?: string }[];
}
export interface AutoLoadResult {
loadedCount: number;
skippedCount: number;
results: { inventoryId: string; status: string; reason?: string }[];
}
@Injectable()
export class WarehouseInventoryService {
constructor(
@@ -107,6 +163,200 @@ export class WarehouseInventoryService {
return item;
}
// ── Batch 4.5: Arrival / Unload / Load automation ──────────────────────────
/** Bookings whose goods have arrived and may be unloaded into the warehouse. */
private readonly ARRIVED_BOOKING_STATUSES = ['IN_TRANSIT'];
/** Arrived bookings + their current inventory/inspection state (queue view). */
async arrivalQueue(): Promise<ArrivalQueueItem[]> {
const rows: ArrivalQueueRow[] = await this.dataSource.query(
`SELECT b.id AS "bookingId",
b.reference AS "bookingReference",
company.name AS "customer",
b.cargo_free_text AS "cargo",
ct.container_number AS "container",
b.scheduled_date AS "arrivalDate",
b.status AS "bookingStatus",
inv.id AS "inventoryId",
inv.status AS "currentStatus",
inv.inspection_status AS "inspectionStatus",
fac.name AS "facility",
wh.name AS "warehouse",
yard.name AS "yard",
zone.name AS "zone"
FROM freight.bookings b
LEFT JOIN freight.companies company ON company.id = b.company_id
LEFT JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL
LEFT JOIN freight.warehouses wh ON wh.id = inv.warehouse_id
LEFT JOIN freight.warehouse_yards yard ON yard.id = inv.yard_id
LEFT JOIN freight.warehouse_zones zone ON zone.id = inv.zone_id
LEFT JOIN freight.facilities fac ON fac.id = wh.facility_id
LEFT JOIN freight.containers ct ON ct.id = inv.container_id
WHERE b.status = ANY($1) AND b.deleted_at IS NULL
ORDER BY b.scheduled_date DESC NULLS LAST`,
[this.ARRIVED_BOOKING_STATUSES],
);
return rows.map((r) => ({
bookingId: r.bookingId,
bookingReference: r.bookingReference,
customer: r.customer ?? null,
cargo: r.cargo ?? null,
container: r.container ?? null,
facility: r.facility ?? null,
warehouse: r.warehouse ?? null,
yard: r.yard ?? null,
zone: r.zone ?? null,
inventoryId: r.inventoryId ?? null,
currentStatus: r.currentStatus ?? null,
arrivalDate: r.arrivalDate ?? null,
inspectionStatus: r.inspectionStatus ?? null,
unloaded: Boolean(r.inventoryId),
}));
}
/** First warehouse that has at least one yard + zone (fallback location for auto-unload). */
private async pickDefaultLocation(): Promise<DefaultLocation | null> {
const [row]: DefaultLocation[] = await this.dataSource.query(
`SELECT wh.id AS "warehouseId", wh.facility_id AS "facilityId",
yard.id AS "yardId", zone.id AS "zoneId"
FROM freight.warehouses wh
JOIN freight.warehouse_yards yard ON yard.warehouse_id = wh.id AND yard.deleted_at IS NULL
JOIN freight.warehouse_zones zone ON zone.yard_id = yard.id AND zone.deleted_at IS NULL
WHERE wh.deleted_at IS NULL
ORDER BY wh.created_at ASC
LIMIT 1`,
);
return row ?? null;
}
/** Bulk-create inventory (RECEIVED) for arrived bookings that are not yet unloaded. */
async autoUnloadArrived(): Promise<AutoUnloadResult> {
const arrived: { id: string; weight: string | null }[] = await this.dataSource.query(
`SELECT b.id, b.cargo_total_weight_vgm AS weight
FROM freight.bookings b
LEFT JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL
WHERE b.status = ANY($1) AND b.deleted_at IS NULL AND inv.id IS NULL`,
[this.ARRIVED_BOOKING_STATUSES],
);
const result: AutoUnloadResult = { processedCount: 0, skippedCount: 0, failedCount: 0, results: [] };
if (arrived.length === 0) return result;
const location = await this.pickDefaultLocation();
if (!location) {
return {
...result,
failedCount: arrived.length,
results: arrived.map((b) => ({ bookingId: b.id, status: 'FAILED', reason: 'No warehouse/yard/zone configured' })),
};
}
for (const booking of arrived) {
try {
const saved = await this.inventoryRepository.create({
warehouseId: location.warehouseId,
yardId: location.yardId,
zoneId: location.zoneId,
bookingId: booking.id,
quantity: 1,
weight: Number(booking.weight) || 0,
status: 'RECEIVED',
arrivedAt: new Date(),
notes: 'Auto-unloaded from arrival queue',
});
result.processedCount += 1;
result.results.push({ bookingId: booking.id, inventoryId: saved.id, status: 'PROCESSED' });
} catch (error) {
result.failedCount += 1;
result.results.push({
bookingId: booking.id,
status: 'FAILED',
reason: error instanceof Error ? error.message : String(error),
});
}
}
return result;
}
/** Unload a single arrived booking into a chosen (or default) location. */
async unloadBooking(bookingId: string, dto: UnloadBookingDto): Promise<WarehouseInventory> {
const existing = await this.inventoryRepository.findAll({ where: { bookingId } });
let location: DefaultLocation | null =
dto.warehouseId && dto.yardId && dto.zoneId
? { warehouseId: dto.warehouseId, yardId: dto.yardId, zoneId: dto.zoneId, facilityId: dto.facilityId ?? null }
: null;
if (!location) location = await this.pickDefaultLocation();
if (!location) {
throw new BadRequestException('No warehouse/yard/zone provided or configured for unloading');
}
const arrivedAt = dto.unloadedAt ? new Date(dto.unloadedAt) : new Date();
if (existing[0]) {
await this.inventoryRepository.update(existing[0].id, {
warehouseId: location.warehouseId,
yardId: location.yardId,
zoneId: location.zoneId,
status: 'RECEIVED',
arrivedAt,
notes: dto.notes ?? existing[0].notes ?? 'Unloaded',
});
return this.findById(existing[0].id);
}
const saved = await this.inventoryRepository.create({
warehouseId: location.warehouseId,
yardId: location.yardId,
zoneId: location.zoneId,
bookingId,
quantity: 1,
weight: 0,
status: 'RECEIVED',
arrivedAt,
notes: dto.notes ?? 'Unloaded',
});
return this.findById(saved.id);
}
/** Auto-load all READY_FOR_LOADING inventory whose booking is PAID. Unpaid stay pending. */
async autoLoadReady(): Promise<AutoLoadResult> {
const ready = await this.inventoryRepository.findAll({ where: { status: 'READY_FOR_LOADING' } });
const result: AutoLoadResult = { loadedCount: 0, skippedCount: 0, results: [] };
for (const item of ready) {
const bookingStatus = item.bookingId ? await this.getBookingStatus(item.bookingId) : null;
if (bookingStatus !== 'PAID') {
result.skippedCount += 1;
result.results.push({ inventoryId: item.id, status: 'SKIPPED', reason: 'Booking not PAID' });
continue;
}
await this.dataSource.transaction(async (manager) => {
await manager.getRepository(WarehouseInventory).update(item.id, {
status: 'LOADED',
loadedAt: new Date(),
});
await this.activityLog.record(
{
activityType: 'INVENTORY_LOADED',
inventoryId: item.id,
warehouseId: item.warehouseId,
description: 'Auto-loaded (PAID booking)',
},
manager,
);
});
result.loadedCount += 1;
result.results.push({ inventoryId: item.id, status: 'LOADED' });
}
return result;
}
// ── Receive ──────────────────────────────────────────────────────────────
async receive(dto: ReceiveWarehouseInventoryDto): Promise<WarehouseInventory> {

View File

@@ -1,7 +1,9 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { FilesModule } from '../files/files.module';
import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity';
import { WarehouseInspectionReport } from './entities/warehouse-inspection-report.entity';
import { WarehouseInventory } from './entities/warehouse-inventory.entity';
import { WarehouseInventoryMovement } from './entities/warehouse-inventory-movement.entity';
import { WarehouseLoading } from './entities/warehouse-loading.entity';
@@ -12,6 +14,9 @@ import { SchedulingReadFacade } from './scheduling-read.facade';
import { WarehouseActivityLogRepository } from './warehouse-activity-log.repository';
import { WarehouseActivityLogService } from './warehouse-activity-log.service';
import { WarehouseDashboardService } from './warehouse-dashboard.service';
import { WarehouseInspectionController } from './warehouse-inspection.controller';
import { WarehouseInspectionRepository } from './warehouse-inspection.repository';
import { WarehouseInspectionService } from './warehouse-inspection.service';
import { WarehouseInventoryController } from './warehouse-inventory.controller';
import { WarehouseInventoryMovementRepository } from './warehouse-inventory-movement.repository';
import { WarehouseInventoryRepository } from './warehouse-inventory.repository';
@@ -39,7 +44,9 @@ import { WarehousesService } from './warehouses.service';
WarehouseInventoryMovement,
WarehouseActivityLog,
WarehouseLoading,
WarehouseInspectionReport,
]),
FilesModule,
],
controllers: [
WarehousesController,
@@ -47,6 +54,7 @@ import { WarehousesService } from './warehouses.service';
WarehouseZonesController,
WarehouseInventoryController,
WarehouseLoadingsController,
WarehouseInspectionController,
],
providers: [
WarehousesRepository,
@@ -56,12 +64,14 @@ import { WarehousesService } from './warehouses.service';
WarehouseInventoryMovementRepository,
WarehouseActivityLogRepository,
WarehouseLoadingRepository,
WarehouseInspectionRepository,
WarehousesService,
WarehouseYardsService,
WarehouseZonesService,
WarehouseInventoryService,
WarehouseActivityLogService,
WarehouseDashboardService,
WarehouseInspectionService,
WarehouseSchedulingAdapterService,
SchedulingReadFacade,
],

View File

@@ -0,0 +1,214 @@
import { useState } from 'react';
import {
Button,
Divider,
FileInput,
Group,
Modal,
NumberInput,
Select,
Switch,
Textarea,
} from '@mantine/core';
import { Upload } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import { useCreateInspectionReport, useUploadInspectionAttachments } from '@/hooks/useWarehouses';
import {
INSPECTION_REPORT_TYPES,
INSPECTION_STATUSES,
type InspectionReportType,
type InspectionResultStatus,
} from '@/types/warehouse';
import { extractErrorMessage } from './options';
interface InspectionReportModalProps {
opened: boolean;
onClose: () => void;
inventoryId: string | null;
}
const REPORT_TYPE_LABELS: Record<InspectionReportType, string> = {
INSPECTION: 'Inspection',
DAMAGE: 'Damage',
WEIGHT_LOSS: 'Weight loss',
MISSING_ITEM: 'Missing item',
GENERAL: 'General',
};
const STATUS_LABELS: Record<InspectionResultStatus, string> = {
PASSED: 'Passed',
FAILED: 'Failed',
NEEDS_REVIEW: 'Needs review',
};
/** Batch 4.5 — record an inspection / damage report with optional image upload. */
export function InspectionReportModal({ opened, onClose, inventoryId }: InspectionReportModalProps) {
const { toast } = useToast();
const createReport = useCreateInspectionReport();
const uploadAttachments = useUploadInspectionAttachments();
const [reportType, setReportType] = useState<InspectionReportType>('INSPECTION');
const [inspectionStatus, setInspectionStatus] = useState<InspectionResultStatus>('PASSED');
const [hasDamage, setHasDamage] = useState(false);
const [damageDescription, setDamageDescription] = useState('');
const [hasWeightLoss, setHasWeightLoss] = useState(false);
const [expectedWeight, setExpectedWeight] = useState<number | ''>('');
const [actualWeight, setActualWeight] = useState<number | ''>('');
const [hasMissingItems, setHasMissingItems] = useState(false);
const [missingItemsDescription, setMissingItemsDescription] = useState('');
const [remarks, setRemarks] = useState('');
const [files, setFiles] = useState<File[]>([]);
const submitting = createReport.isPending || uploadAttachments.isPending;
const reset = () => {
setReportType('INSPECTION');
setInspectionStatus('PASSED');
setHasDamage(false);
setDamageDescription('');
setHasWeightLoss(false);
setExpectedWeight('');
setActualWeight('');
setHasMissingItems(false);
setMissingItemsDescription('');
setRemarks('');
setFiles([]);
};
const handleSubmit = async () => {
if (!inventoryId) return;
try {
const report = await createReport.mutateAsync({
inventoryId,
payload: {
reportType,
inspectionStatus,
hasDamage,
damageDescription: damageDescription.trim() || undefined,
hasWeightLoss,
expectedWeight: expectedWeight === '' ? undefined : Number(expectedWeight),
actualWeight: actualWeight === '' ? undefined : Number(actualWeight),
hasMissingItems,
missingItemsDescription: missingItemsDescription.trim() || undefined,
remarks: remarks.trim() || undefined,
},
});
if (files.length > 0) {
await uploadAttachments.mutateAsync({ reportId: report.id, files });
}
toast({ title: 'Inspection report saved' });
reset();
onClose();
} catch (error) {
toast({ variant: 'destructive', title: 'Save failed', description: extractErrorMessage(error) });
}
};
return (
<Modal opened={opened} onClose={onClose} title="Inspection / Report" centered size="lg">
<Group grow>
<Select
label="Report type"
data={INSPECTION_REPORT_TYPES.map((t) => ({ value: t, label: REPORT_TYPE_LABELS[t] }))}
value={reportType}
onChange={(v) => setReportType((v as InspectionReportType) ?? 'INSPECTION')}
allowDeselect={false}
/>
<Select
label="Inspection status"
data={INSPECTION_STATUSES.map((s) => ({ value: s, label: STATUS_LABELS[s] }))}
value={inspectionStatus}
onChange={(v) => setInspectionStatus((v as InspectionResultStatus) ?? 'PASSED')}
allowDeselect={false}
/>
</Group>
<Divider my="md" label="Damage" labelPosition="left" />
<Switch
label="Has damage"
checked={hasDamage}
onChange={(e) => setHasDamage(e.currentTarget.checked)}
color="orange"
/>
{hasDamage && (
<Textarea
mt="xs"
label="Damage description"
value={damageDescription}
onChange={(e) => setDamageDescription(e.currentTarget.value)}
/>
)}
<Divider my="md" label="Weight loss" labelPosition="left" />
<Switch
label="Has weight loss"
checked={hasWeightLoss}
onChange={(e) => setHasWeightLoss(e.currentTarget.checked)}
color="orange"
/>
{hasWeightLoss && (
<Group grow mt="xs">
<NumberInput
label="Expected weight (kg)"
min={0}
value={expectedWeight}
onChange={(v) => setExpectedWeight(v === '' ? '' : Number(v))}
/>
<NumberInput
label="Actual weight (kg)"
min={0}
value={actualWeight}
onChange={(v) => setActualWeight(v === '' ? '' : Number(v))}
/>
</Group>
)}
<Divider my="md" label="Missing items" labelPosition="left" />
<Switch
label="Has missing items"
checked={hasMissingItems}
onChange={(e) => setHasMissingItems(e.currentTarget.checked)}
color="orange"
/>
{hasMissingItems && (
<Textarea
mt="xs"
label="Missing items description"
value={missingItemsDescription}
onChange={(e) => setMissingItemsDescription(e.currentTarget.value)}
/>
)}
<Divider my="md" />
<Textarea
label="Remarks"
value={remarks}
onChange={(e) => setRemarks(e.currentTarget.value)}
/>
<FileInput
mt="md"
label="Images / documents"
placeholder="Upload jpg, png or pdf"
accept="image/jpeg,image/png,application/pdf"
leftSection={<Upload size={16} />}
multiple
value={files}
onChange={setFiles}
clearable
/>
<Group justify="flex-end" mt="lg">
<Button variant="default" onClick={onClose} disabled={submitting}>
Cancel
</Button>
<Button color="green" onClick={handleSubmit} loading={submitting} disabled={!inventoryId}>
Save report
</Button>
</Group>
</Modal>
);
}

View File

@@ -8,6 +8,7 @@ import {
useStoreInventory,
} from '@/hooks/useWarehouses';
import type { InventoryAction, WarehouseInventoryItem } from '@/types/warehouse';
import { InspectionReportModal } from './InspectionReportModal';
import { InventoryHistoryModal } from './InventoryHistoryModal';
import { LoadInventoryModal } from './LoadInventoryModal';
import { MoveInventoryModal } from './MoveInventoryModal';
@@ -28,6 +29,7 @@ export function InventoryWorkbench({ items, isLoading }: InventoryWorkbenchProps
const [reserveItem, setReserveItem] = useState<WarehouseInventoryItem | null>(null);
const [loadItem, setLoadItem] = useState<WarehouseInventoryItem | null>(null);
const [historyItem, setHistoryItem] = useState<WarehouseInventoryItem | null>(null);
const [inspectItem, setInspectItem] = useState<WarehouseInventoryItem | null>(null);
const storeMutation = useStoreInventory();
const readyMutation = useMarkReadyForLoading();
@@ -80,6 +82,7 @@ export function InventoryWorkbench({ items, isLoading }: InventoryWorkbenchProps
onAdvance={advance}
onMove={setMoveItem}
onHistory={setHistoryItem}
onInspect={setInspectItem}
/>
<MoveInventoryModal opened={Boolean(moveItem)} onClose={() => setMoveItem(null)} item={moveItem} />
@@ -94,6 +97,11 @@ export function InventoryWorkbench({ items, isLoading }: InventoryWorkbenchProps
onClose={() => setHistoryItem(null)}
item={historyItem}
/>
<InspectionReportModal
opened={Boolean(inspectItem)}
onClose={() => setInspectItem(null)}
inventoryId={inspectItem?.id ?? null}
/>
</>
);
}

View File

@@ -1,5 +1,5 @@
import { ActionIcon, Badge, Button, Group, Table, Text, Tooltip } from '@mantine/core';
import { ArrowRightLeft, History } from 'lucide-react';
import { ArrowRightLeft, ClipboardList, History } from 'lucide-react';
import type { InventoryAction, WarehouseInventoryItem } from '@/types/warehouse';
import { INVENTORY_NEXT_ACTION } from '@/types/warehouse';
@@ -12,6 +12,7 @@ interface WarehouseInventoryTableProps {
onAdvance: (item: WarehouseInventoryItem, action: InventoryAction) => void;
onMove: (item: WarehouseInventoryItem) => void;
onHistory: (item: WarehouseInventoryItem) => void;
onInspect?: (item: WarehouseInventoryItem) => void;
}
const itemKind = (item: WarehouseInventoryItem) => {
@@ -35,6 +36,7 @@ export function WarehouseInventoryTable({
onAdvance,
onMove,
onHistory,
onInspect,
}: WarehouseInventoryTableProps) {
if (items.length === 0) {
return (
@@ -119,6 +121,13 @@ export function WarehouseInventoryTable({
</ActionIcon>
</Tooltip>
)}
{onInspect && (
<Tooltip label="Inspection / Report" withArrow>
<ActionIcon variant="subtle" color="orange" onClick={() => onInspect(item)}>
<ClipboardList size={16} />
</ActionIcon>
</Tooltip>
)}
<Tooltip label="History" withArrow>
<ActionIcon variant="subtle" color="gray" onClick={() => onHistory(item)}>
<History size={16} />

View File

@@ -25,3 +25,4 @@ export type { FreightVisualVariant } from './FreightVisual';
export { WarehouseHero } from './WarehouseHero';
export { VisualEmptyState } from './VisualEmptyState';
export { WarehouseDashboardCharts } from './WarehouseDashboardCharts';
export { InspectionReportModal } from './InspectionReportModal';

View File

@@ -209,6 +209,11 @@ export const URL_CONSTANTS = {
BASE: '/warehouse-inventory',
RECEIVE: '/warehouse-inventory/receive',
RESERVE: '/warehouse-inventory/reserve',
ARRIVAL_QUEUE: '/warehouse-inventory/arrival-queue',
AUTO_UNLOAD_ARRIVED: '/warehouse-inventory/auto-unload-arrived',
AUTO_LOAD_READY: '/warehouse-inventory/auto-load-ready',
UNLOAD_BOOKING: (bookingId: string) => `/warehouse-inventory/bookings/${bookingId}/unload`,
INSPECTION_REPORTS: (inventoryId: string) => `/warehouse-inventory/${inventoryId}/inspection-reports`,
READY_FOR_LOADING: '/warehouse-inventory/ready-for-loading',
INQUIRY: '/warehouse-inventory/inquiry',
LOADABLE_WAGONS: '/warehouse-inventory/loadable-wagons',
@@ -226,4 +231,9 @@ export const URL_CONSTANTS = {
WAREHOUSE_LOADINGS: {
BASE: '/warehouse-loadings',
},
WAREHOUSE_INSPECTION: {
BY_ID: (id: string) => `/warehouse-inspection-reports/${id}`,
ATTACHMENTS: (id: string) => `/warehouse-inspection-reports/${id}/attachments`,
},
};

View File

@@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { warehouseService } from '@/services/warehouse.service';
import type {
InspectionReportPayload,
InventoryFilter,
InventoryInquiryFilter,
LoadInventoryPayload,
@@ -222,3 +223,58 @@ export function useInventoryInquiry(filter: InventoryInquiryFilter, enabled = tr
enabled,
});
}
// ── Batch 4.5: Arrival / Unload / Inspection ────────────────────────────────
export function useArrivalQueue() {
return useQuery({
queryKey: ['warehouse-inventory', 'arrival-queue'],
queryFn: () => warehouseService.arrivalQueue().then((r) => r.data),
});
}
function useArrivalMutation<TArgs>(fn: (args: TArgs) => Promise<unknown>) {
const qc = useQueryClient();
return useMutation({
mutationFn: fn,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
qc.invalidateQueries({ queryKey: warehouseKeys.all });
},
});
}
export const useAutoUnloadArrived = () =>
useArrivalMutation(() => warehouseService.autoUnloadArrived());
export const useAutoLoadReady = () => useArrivalMutation(() => warehouseService.autoLoadReady());
export const useUnloadBooking = () =>
useArrivalMutation((args: { bookingId: string; payload?: Record<string, unknown> }) =>
warehouseService.unloadBooking(args.bookingId, args.payload),
);
export function useInspectionReports(inventoryId?: string) {
return useQuery({
queryKey: ['warehouse-inventory', inventoryId, 'inspection-reports'],
queryFn: () => warehouseService.listInspectionReports(inventoryId as string).then((r) => r.data),
enabled: Boolean(inventoryId),
});
}
export function useCreateInspectionReport() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ inventoryId, payload }: { inventoryId: string; payload: InspectionReportPayload }) =>
warehouseService.createInspectionReport(inventoryId, payload).then((r) => r.data),
onSuccess: (_, { inventoryId }) => {
qc.invalidateQueries({ queryKey: ['warehouse-inventory', inventoryId, 'inspection-reports'] });
qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
},
});
}
export function useUploadInspectionAttachments() {
return useMutation({
mutationFn: ({ reportId, files }: { reportId: string; files: File[] }) =>
warehouseService.uploadInspectionAttachments(reportId, files),
});
}

View File

@@ -2,6 +2,12 @@ import { api as apiClient } from '../auth/http';
import { URL_CONSTANTS } from '@/constants/URLS';
import type {
ArrivalQueueItem,
AutoLoadResult,
AutoUnloadResult,
InspectionAttachment,
InspectionReport,
InspectionReportPayload,
BookingScheduleView,
InventoryFilter,
InventoryInquiryFilter,
@@ -106,4 +112,37 @@ export const warehouseService = {
apiClient.get<WarehouseLoading[]>(URL_CONSTANTS.WAREHOUSE_LOADINGS.BASE, {
params: cleanParams(params ?? {}),
}),
// ── Batch 4.5: Arrival / Unload / Load automation ──────────────────────────
arrivalQueue: () =>
apiClient.get<ArrivalQueueItem[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.ARRIVAL_QUEUE),
autoUnloadArrived: () =>
apiClient.post<AutoUnloadResult>(URL_CONSTANTS.WAREHOUSE_INVENTORY.AUTO_UNLOAD_ARRIVED),
autoLoadReady: () =>
apiClient.post<AutoLoadResult>(URL_CONSTANTS.WAREHOUSE_INVENTORY.AUTO_LOAD_READY),
unloadBooking: (bookingId: string, payload?: Record<string, unknown>) =>
apiClient.post<WarehouseInventoryItem>(
URL_CONSTANTS.WAREHOUSE_INVENTORY.UNLOAD_BOOKING(bookingId),
payload ?? {},
),
// ── Batch 4.5: Inspection reports ──────────────────────────────────────────
listInspectionReports: (inventoryId: string) =>
apiClient.get<InspectionReport[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.INSPECTION_REPORTS(inventoryId)),
createInspectionReport: (inventoryId: string, payload: InspectionReportPayload) =>
apiClient.post<InspectionReport>(
URL_CONSTANTS.WAREHOUSE_INVENTORY.INSPECTION_REPORTS(inventoryId),
payload,
),
getInspectionReport: (id: string) =>
apiClient.get<InspectionReport>(URL_CONSTANTS.WAREHOUSE_INSPECTION.BY_ID(id)),
uploadInspectionAttachments: (id: string, files: File[]) => {
const form = new FormData();
files.forEach((file) => form.append('files', file));
return apiClient.post<InspectionAttachment[]>(
URL_CONSTANTS.WAREHOUSE_INSPECTION.ATTACHMENTS(id),
form,
{ headers: { 'Content-Type': 'multipart/form-data' } },
);
},
};

View File

@@ -284,6 +284,81 @@ export interface InventoryInquiryResult {
readyForLoadingAt: string | null;
}
// ── Batch 4.5: Arrival / Unload / Inspection ────────────────────────────────
export interface ArrivalQueueItem {
bookingId: string;
bookingReference: string;
customer: string | null;
cargo: string | null;
container: string | null;
facility: string | null;
warehouse: string | null;
yard: string | null;
zone: string | null;
inventoryId: string | null;
currentStatus: string | null;
arrivalDate: string | null;
inspectionStatus: string | null;
unloaded: boolean;
}
export interface AutoUnloadResult {
processedCount: number;
skippedCount: number;
failedCount: number;
results: { bookingId: string; inventoryId?: string; status: string; reason?: string }[];
}
export interface AutoLoadResult {
loadedCount: number;
skippedCount: number;
results: { inventoryId: string; status: string; reason?: string }[];
}
export const INSPECTION_REPORT_TYPES = [
'INSPECTION',
'DAMAGE',
'WEIGHT_LOSS',
'MISSING_ITEM',
'GENERAL',
] as const;
export type InspectionReportType = (typeof INSPECTION_REPORT_TYPES)[number];
export const INSPECTION_STATUSES = ['PASSED', 'FAILED', 'NEEDS_REVIEW'] as const;
export type InspectionResultStatus = (typeof INSPECTION_STATUSES)[number];
export interface InspectionReportPayload {
reportType: InspectionReportType;
inspectionStatus: InspectionResultStatus;
hasDamage?: boolean;
damageDescription?: string;
hasWeightLoss?: boolean;
expectedWeight?: number;
actualWeight?: number;
hasMissingItems?: boolean;
missingItemsDescription?: string;
remarks?: string;
}
export interface InspectionAttachment {
id: string;
name: string;
url: string;
mimeType: string;
size: number;
}
export interface InspectionReport extends InspectionReportPayload {
id: string;
inventoryId: string;
bookingId: string | null;
weightLoss?: number | null;
inspectedAt: string | null;
createdAt: string;
attachments?: InspectionAttachment[];
}
// ── Payloads ───────────────────────────────────────────────────────────────
export interface SaveWarehousePayload {