From 5b8cb16c1a40d088f30bb188d1f6e30c30973ae3 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Wed, 17 Jun 2026 22:00:53 +0000 Subject: [PATCH] automation --- .../entities/warehouse-inventory.entity.ts | 4 + .../warehouse-inventory.controller.ts | 28 ++ .../warehouses/warehouse-inventory.service.ts | 250 ++++++++++++++++++ .../modules/warehouses/warehouses.module.ts | 10 + .../warehouses/InspectionReportModal.tsx | 214 +++++++++++++++ .../warehouses/InventoryWorkbench.tsx | 8 + .../warehouses/WarehouseInventoryTable.tsx | 11 +- .../src/components/warehouses/index.ts | 1 + .../backoffice/src/constants/URLS.ts | 10 + .../backoffice/src/hooks/useWarehouses.ts | 56 ++++ .../src/services/warehouse.service.ts | 39 +++ .../backoffice/src/types/warehouse.ts | 75 ++++++ 12 files changed, 705 insertions(+), 1 deletion(-) create mode 100644 apps/edr-freight-web/backoffice/src/components/warehouses/InspectionReportModal.tsx diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts index d957f794d..d43f499d9 100644 --- a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts index b05475dab..c4f40c1f3 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts @@ -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() { diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 7fa735642..47b3e47a4 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts b/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts index 527855aab..fe07ccb57 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts @@ -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, ], diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/InspectionReportModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/InspectionReportModal.tsx new file mode 100644 index 000000000..708b1f662 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/InspectionReportModal.tsx @@ -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 = { + INSPECTION: 'Inspection', + DAMAGE: 'Damage', + WEIGHT_LOSS: 'Weight loss', + MISSING_ITEM: 'Missing item', + GENERAL: 'General', +}; + +const STATUS_LABELS: Record = { + 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('INSPECTION'); + const [inspectionStatus, setInspectionStatus] = useState('PASSED'); + const [hasDamage, setHasDamage] = useState(false); + const [damageDescription, setDamageDescription] = useState(''); + const [hasWeightLoss, setHasWeightLoss] = useState(false); + const [expectedWeight, setExpectedWeight] = useState(''); + const [actualWeight, setActualWeight] = useState(''); + const [hasMissingItems, setHasMissingItems] = useState(false); + const [missingItemsDescription, setMissingItemsDescription] = useState(''); + const [remarks, setRemarks] = useState(''); + const [files, setFiles] = useState([]); + + 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 ( + + + ({ value: s, label: STATUS_LABELS[s] }))} + value={inspectionStatus} + onChange={(v) => setInspectionStatus((v as InspectionResultStatus) ?? 'PASSED')} + allowDeselect={false} + /> + + + + setHasDamage(e.currentTarget.checked)} + color="orange" + /> + {hasDamage && ( +