diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/bulk-receive.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/bulk-receive.dto.ts new file mode 100644 index 000000000..9bb734512 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/bulk-receive.dto.ts @@ -0,0 +1,32 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ArrayNotEmpty, IsArray, IsIn, IsOptional, IsString, IsUUID } from 'class-validator'; + +/** Bulk-receive eligible PAID bookings into a warehouse location, by trade direction. */ +export class BulkReceiveDto { + @ApiProperty({ enum: ['IMPORT', 'EXPORT'] }) + @IsIn(['IMPORT', 'EXPORT']) + direction!: 'IMPORT' | 'EXPORT'; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + warehouseId!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + yardId!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + zoneId!: string; + + @ApiProperty({ type: [String], format: 'uuid' }) + @IsArray() + @ArrayNotEmpty() + @IsUUID('all', { each: true }) + bookingIds!: string[]; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + performedBy?: string; +} 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 244239c37..e3dc46577 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 @@ -1,6 +1,7 @@ import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { BulkReceiveDto } from './dto/bulk-receive.dto'; import { DeliverInventoryDto } from './dto/deliver-inventory.dto'; import { FilterWarehouseInventoryDto } from './dto/filter-inventory.dto'; import { InquiryWarehouseInventoryDto } from './dto/inquiry-inventory.dto'; @@ -58,6 +59,24 @@ export class WarehouseInventoryController { return this.inventoryService.autoLoadReady(); } + @Get('eligible-bookings') + @ApiOperation({ summary: 'Eligible PAID bookings for a direction (IMPORT/EXPORT) not yet received' }) + eligibleBookings(@Query('direction') direction: 'IMPORT' | 'EXPORT') { + return this.inventoryService.eligibleBookings(direction === 'EXPORT' ? 'EXPORT' : 'IMPORT'); + } + + @Post('receive-bulk') + @ApiOperation({ summary: 'Bulk-receive selected eligible PAID bookings into a location' }) + receiveBulk(@Body() dto: BulkReceiveDto) { + return this.inventoryService.bulkReceive(dto); + } + + @Post('load-passed-export') + @ApiOperation({ summary: 'Bulk-load all EXPORT inventory that passed inspection (READY_FOR_LOADING)' }) + loadPassedExport(@Body('performedBy') performedBy?: string) { + return this.inventoryService.loadPassedExport(performedBy); + } + @Post('bookings/:bookingId/unload') @ApiOperation({ summary: 'Unload a single arrived booking into a location' }) unloadBooking( 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 b26fc9c44..bed06cda0 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 @@ -2,6 +2,7 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/comm import { DataSource, EntityManager, FindManyOptions, ILike } from 'typeorm'; import { Cargo } from '../cargoes/entities/cargoes.entity'; +import { BulkReceiveDto } from './dto/bulk-receive.dto'; import { DeliverInventoryDto } from './dto/deliver-inventory.dto'; import { FilterWarehouseInventoryDto } from './dto/filter-inventory.dto'; import { InquiryWarehouseInventoryDto } from './dto/inquiry-inventory.dto'; @@ -116,6 +117,33 @@ export interface AutoLoadResult { results: { inventoryId: string; status: string; reason?: string }[]; } +// ── Receive (Import/Export bulk) shapes ────────────────────────────────────── +export interface EligibleBookingRow { + id: string; + reference: string; + customer: string | null; + direction: string; + origin: string | null; + destination: string | null; + freightType: string | null; + cargo: string | null; + weight: string | null; + paymentStatus: string; + status: string; +} + +export interface BulkReceiveResult { + receivedCount: number; + skippedCount: number; + results: { bookingId: string; status: string; inventoryId?: string; reason?: string }[]; +} + +export interface LoadPassedExportResult { + loadedCount: number; + skippedCount: number; + results: { inventoryId: string; status: string; reason?: string }[]; +} + @Injectable() export class WarehouseInventoryService { constructor( @@ -406,6 +434,144 @@ export class WarehouseInventoryService { return result; } + // ── Receive (Import/Export bulk) ─────────────────────────────────────────── + + /** Eligible PAID bookings for a direction that have NOT been received yet. */ + eligibleBookings(direction: 'IMPORT' | 'EXPORT'): Promise { + return this.dataSource.query( + `SELECT b.id, + b.reference AS "reference", + company.name AS "customer", + b.trade_direction AS "direction", + oy.code AS "origin", + dy.code AS "destination", + b.freight_type AS "freightType", + COALESCE(ct.cargo_type_name, b.cargo_free_text) AS "cargo", + b.cargo_total_weight_vgm AS "weight", + b.payment_status AS "paymentStatus", + b.status AS "status" + FROM freight.bookings b + LEFT JOIN freight.companies company ON company.id = b.company_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.cargo_types ct ON ct.id = b.cargo_type_id + LEFT JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL + WHERE b.deleted_at IS NULL + AND b.payment_status = 'PAID' + AND b.trade_direction = $1 + AND inv.id IS NULL + ORDER BY b.scheduled_date DESC NULLS LAST`, + [direction], + ); + } + + /** Bulk-receive eligible PAID bookings into a location. Skips duplicates / wrong direction. */ + async bulkReceive(dto: BulkReceiveDto): Promise { + const result: BulkReceiveResult = { receivedCount: 0, skippedCount: 0, results: [] }; + + await this.dataSource.transaction(async (manager) => { + await this.validateLocation(manager, { + warehouseId: dto.warehouseId, + yardId: dto.yardId, + zoneId: dto.zoneId, + }); + + for (const bookingId of dto.bookingIds) { + const skip = (reason: string) => { + result.skippedCount += 1; + result.results.push({ bookingId, status: 'SKIPPED', reason }); + }; + + const [booking] = await manager.query( + `SELECT payment_status AS "paymentStatus", trade_direction AS "tradeDirection", + cargo_total_weight_vgm AS "weight" + FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL LIMIT 1`, + [bookingId], + ); + if (!booking) { skip('Booking not found'); continue; } + if (booking.paymentStatus !== 'PAID') { skip('Booking not PAID'); continue; } + if (booking.tradeDirection !== dto.direction) { + skip(`Booking is ${booking.tradeDirection}, not ${dto.direction}`); + continue; + } + + const existing = await manager.getRepository(WarehouseInventory).findOne({ where: { bookingId } }); + if (existing) { skip('Already received'); continue; } + + const saved = await manager.getRepository(WarehouseInventory).save( + manager.getRepository(WarehouseInventory).create({ + warehouseId: dto.warehouseId, + yardId: dto.yardId, + zoneId: dto.zoneId, + bookingId, + quantity: 1, + weight: Number(booking.weight) || 0, + status: 'RECEIVED', + arrivedAt: new Date(), + notes: `Bulk received (${dto.direction})`, + }), + ); + + await this.activityLog.record( + { + activityType: 'INVENTORY_RECEIVED', + inventoryId: saved.id, + warehouseId: dto.warehouseId, + description: `Bulk received ${dto.direction} booking`, + performedBy: dto.performedBy, + }, + manager, + ); + + result.receivedCount += 1; + result.results.push({ bookingId, status: 'RECEIVED', inventoryId: saved.id }); + } + }); + + return result; + } + + /** Bulk-load all EXPORT inventory that passed inspection and is READY_FOR_LOADING. */ + async loadPassedExport(performedBy?: string): Promise { + const ready = await this.inventoryRepository.findAll({ where: { status: 'READY_FOR_LOADING' } }); + const result: LoadPassedExportResult = { loadedCount: 0, skippedCount: 0, results: [] }; + + for (const item of ready) { + const skip = (reason: string) => { + result.skippedCount += 1; + result.results.push({ inventoryId: item.id, status: 'SKIPPED', reason }); + }; + + if (item.inspectionStatus !== 'PASSED') { skip('Inspection not PASSED'); continue; } + const direction = item.bookingId ? await this.getBookingDirection(item.bookingId) : null; + if (direction !== 'EXPORT') { skip('Not an EXPORT item'); continue; } + const bookingStatus = item.bookingId ? await this.getBookingStatus(item.bookingId) : null; + if (bookingStatus !== 'PAID') { skip('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: 'Bulk loaded (passed export)', + performedBy, + }, + 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-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx index 5e914754b..8a9613e7e 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx @@ -1,71 +1,66 @@ import { useEffect, useMemo, useState } from 'react'; -import { Button, Group, Modal, NumberInput, Select, Stack, Textarea, TextInput } from '@mantine/core'; +import { + Alert, + Badge, + Button, + Checkbox, + Group, + Loader, + Modal, + NumberInput, + Select, + Stack, + Table, + Tabs, + Text, + Textarea, + TextInput, +} from '@mantine/core'; +import { Info, PackageSearch, Truck } from 'lucide-react'; import { useToast } from '@/hooks/use-toast'; import { + useBulkReceive, + useEligibleBookings, + useLoadPassedExport, useReceiveInventory, useWarehouseYards, useWarehouseZones, useWarehouses, } from '@/hooks/useWarehouses'; -import type { ReceiveInventoryPayload } from '@/types/warehouse'; +import type { BulkReceiveResult, LoadPassedExportResult, ReceiveInventoryPayload } from '@/types/warehouse'; import { BookingSelect } from './BookingSelect'; -import { extractErrorMessage } from './options'; +import { extractErrorMessage, formatNumber } from './options'; interface ReceiveInventoryModalProps { opened: boolean; onClose: () => void; - /** When supplied the booking field is locked to this booking. */ + /** When supplied the modal locks to a single booking (legacy single-receive). */ bookingId?: string; bookingLabel?: string; onReceived?: () => void; } -interface FormState { - bookingId: string; +interface Location { warehouseId: string; yardId: string; zoneId: string; - quantity: number | ''; - weight: number | ''; - volume: number | ''; - notes: string; } -const emptyForm = (bookingId?: string): FormState => ({ - bookingId: bookingId ?? '', - warehouseId: '', - yardId: '', - zoneId: '', - quantity: '', - weight: '', - volume: '', - notes: '', -}); - -export function ReceiveInventoryModal({ - opened, - onClose, - bookingId, - bookingLabel, - onReceived, -}: ReceiveInventoryModalProps) { - const { toast } = useToast(); - const receiveMutation = useReceiveInventory(); - const [form, setForm] = useState(emptyForm(bookingId)); - - useEffect(() => { - if (opened) setForm(emptyForm(bookingId)); - }, [opened, bookingId]); - - // Cascading data — only ACTIVE warehouses are selectable for receiving. +/** Cascading Warehouse → Yard → Zone selectors (ACTIVE only). */ +function LocationSelects({ + value, + onChange, +}: { + value: Location; + onChange: (next: Location) => void; +}) { const warehousesQuery = useWarehouses({ status: 'ACTIVE' }); - const yardsQuery = useWarehouseYards(form.warehouseId || undefined); - const zonesQuery = useWarehouseZones(form.yardId || undefined); + const yardsQuery = useWarehouseYards(value.warehouseId || undefined); + const zonesQuery = useWarehouseZones(value.yardId || undefined); const warehouseOptions = useMemo( - () => - (warehousesQuery.data ?? []).map((w) => ({ value: w.id, label: `${w.name} (${w.code})` })), + () => (warehousesQuery.data ?? []).map((w) => ({ value: w.id, label: `${w.name} (${w.code})` })), [warehousesQuery.data], ); const yardOptions = useMemo( @@ -83,10 +78,307 @@ export function ReceiveInventoryModal({ [zonesQuery.data], ); - const submitting = receiveMutation.isPending; + return ( + + onChange({ ...value, yardId: v ?? '', zoneId: '' })} + /> + - setForm((f) => ({ ...f, warehouseId: value ?? '', yardId: '', zoneId: '' })) - } - /> - - setForm((f) => ({ ...f, zoneId: value ?? '' }))} - /> + setForm((f) => ({ ...f, ...next }))} /> { const v = e.currentTarget.value; setForm((f) => ({ ...f, notes: v })); }} + onChange={(e) => { + const v = e.currentTarget.value; + setForm((f) => ({ ...f, notes: v })); + }} /> - - @@ -212,3 +469,8 @@ export function ReceiveInventoryModal({ ); } + +export function ReceiveInventoryModal(props: ReceiveInventoryModalProps) { + // Locked to a booking → legacy single receive; otherwise the Import/Export bulk flow. + return props.bookingId ? : ; +} diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index e2095de01..bc1d1f312 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -306,6 +306,10 @@ export const URL_CONSTANTS = { MARK_READY_PICKUP: (id: string) => `/warehouse-inventory/${id}/ready-for-pickup`, RELEASE: (id: string) => `/warehouse-inventory/${id}/release`, DELIVER: (id: string) => `/warehouse-inventory/${id}/deliver`, + // Receive (Import/Export bulk) + ELIGIBLE_BOOKINGS: (direction: string) => `/warehouse-inventory/eligible-bookings?direction=${direction}`, + RECEIVE_BULK: '/warehouse-inventory/receive-bulk', + LOAD_PASSED_EXPORT: '/warehouse-inventory/load-passed-export', }, WAREHOUSE_LOADINGS: { diff --git a/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts b/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts index 2521756a1..8fb3ea04c 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts +++ b/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts @@ -14,6 +14,7 @@ import type { ReceiveInventoryPayload, ReleaseOrderPayload, DeliverInventoryPayload, + BulkReceivePayload, ReserveInventoryPayload, SaveWarehousePayload, SaveYardPayload, @@ -186,6 +187,19 @@ export const useDeliverInventory = () => warehouseService.deliver(args.id, args.payload), ); +// ── Receive (Import/Export bulk) ─────────────────────────────────────────── +export function useEligibleBookings(direction: 'IMPORT' | 'EXPORT', enabled = true) { + return useQuery({ + queryKey: ['warehouse-inventory', 'eligible-bookings', direction], + queryFn: () => warehouseService.eligibleBookings(direction).then((r) => r.data), + enabled, + }); +} +export const useBulkReceive = () => + useInventoryMutation((payload: BulkReceivePayload) => warehouseService.receiveBulk(payload)); +export const useLoadPassedExport = () => + useInventoryMutation(() => warehouseService.loadPassedExport()); + // ── Loading (Batch 3) ──────────────────────────────────────────────────────── export function useLoadableWagons(enabled = true) { diff --git a/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts b/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts index 0b53d4a1c..b1cde2c85 100644 --- a/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts @@ -29,6 +29,10 @@ import type { ReceiveInventoryPayload, ReleaseOrderPayload, DeliverInventoryPayload, + EligibleBooking, + BulkReceivePayload, + BulkReceiveResult, + LoadPassedExportResult, ReserveInventoryPayload, SaveWarehousePayload, SaveYardPayload, @@ -114,6 +118,14 @@ export const warehouseService = { apiClient.post(URL_CONSTANTS.WAREHOUSE_INVENTORY.RELEASE(id), payload), deliver: (id: string, payload: DeliverInventoryPayload) => apiClient.post(URL_CONSTANTS.WAREHOUSE_INVENTORY.DELIVER(id), payload), + + // ── Receive (Import/Export bulk) ───────────────────────────────────────── + eligibleBookings: (direction: 'IMPORT' | 'EXPORT') => + apiClient.get(URL_CONSTANTS.WAREHOUSE_INVENTORY.ELIGIBLE_BOOKINGS(direction)), + receiveBulk: (payload: BulkReceivePayload) => + apiClient.post(URL_CONSTANTS.WAREHOUSE_INVENTORY.RECEIVE_BULK, payload), + loadPassedExport: () => + apiClient.post(URL_CONSTANTS.WAREHOUSE_INVENTORY.LOAD_PASSED_EXPORT, {}), move: (id: string, payload: MoveInventoryPayload) => apiClient.post(URL_CONSTANTS.WAREHOUSE_INVENTORY.MOVE(id), payload), movements: (id: string) => diff --git a/apps/edr-freight-web/backoffice/src/types/warehouse.ts b/apps/edr-freight-web/backoffice/src/types/warehouse.ts index df2a482e7..10436b3c8 100644 --- a/apps/edr-freight-web/backoffice/src/types/warehouse.ts +++ b/apps/edr-freight-web/backoffice/src/types/warehouse.ts @@ -332,6 +332,41 @@ export interface DeliverInventoryPayload { remarks?: string; } +/** Receive (Import/Export) bulk flow. */ +export interface EligibleBooking { + id: string; + reference: string; + customer: string | null; + direction: string; + origin: string | null; + destination: string | null; + freightType: string | null; + cargo: string | null; + weight: string | null; + paymentStatus: string; + status: string; +} + +export interface BulkReceivePayload { + direction: 'IMPORT' | 'EXPORT'; + warehouseId: string; + yardId: string; + zoneId: string; + bookingIds: string[]; +} + +export interface BulkReceiveResult { + receivedCount: number; + skippedCount: number; + results: { bookingId: string; status: string; inventoryId?: string; reason?: string }[]; +} + +export interface LoadPassedExportResult { + loadedCount: number; + skippedCount: number; + results: { inventoryId: string; status: string; reason?: string }[]; +} + export interface InventoryInquiryResult { id: string; bookingId: string;