diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index 106b7ed5b..eda249868 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -64,6 +64,7 @@ import { ContractViewDto } from './dto/contract-view.dto'; import { CustomerTruckAssignmentDto } from './dto/customer-truck-assignment.dto'; import { AddCustomerTruckDto } from './dto/add-customer-truck.dto'; import { DepartCustomerTruckDto } from './dto/depart-customer-truck.dto'; +import { LoadCustomerTruckDto } from './dto/load-customer-truck.dto'; import { CustomerTruckService } from './customer-truck.service'; import { GenerateGrnDto } from './dto/generate-grn.dto'; import { ContainerReceiptService } from './container-receipt.service'; @@ -358,6 +359,33 @@ export class BookingsController { return this.customerTruckService.removeTruck(id, assignmentId); } + @Get(':id/customer-trucks/loadable-containers') + @ApiOperation({ summary: 'Booking containers not yet loaded onto a truck' }) + async loadableContainers( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: TCurrentUser, + ) { + const booking = await this.bookingsService.findById(id); + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + } + return this.customerTruckService.getLoadableContainers(id); + } + + @Post(':id/customer-trucks/:assignmentId/load') + @ApiOperation({ summary: 'Truck_dispatch: load selected containers onto a truck (staff)' }) + async loadCustomerTruck( + @Param('id', ParseUUIDPipe) id: string, + @Param('assignmentId', ParseUUIDPipe) assignmentId: string, + @Body() dto: LoadCustomerTruckDto, + @CurrentUser() user: TCurrentUser, + ) { + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + throw new ForbiddenException('Only warehouse staff can load a truck'); + } + return this.customerTruckService.loadTruck(id, assignmentId, dto); + } + @Post(':id/customer-trucks/:assignmentId/depart') @ApiOperation({ summary: 'Register an import truck leaving: containers loaded + weighed gross (staff)', diff --git a/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts b/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts index 5d0650219..4274352fb 100644 --- a/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts @@ -198,6 +198,87 @@ export class CustomerTruckService { return this.listTrucks(bookingId); } + /** Booking container numbers not yet loaded onto any truck. */ + async getLoadableContainers(bookingId: string): Promise { + const [all, assigned] = await Promise.all([ + this.bookingContainerNumbers(bookingId), + this.assignedContainerNumbers(bookingId), + ]); + const taken = new Set(assigned); + return all.filter((n) => !taken.has(n)); + } + + /** + * Truck_dispatch (load): assign the selected containers to a truck after it has + * arrived, and set a provisional gross weight from their VGM. The truck is + * weighed for real on departure. Locked once the truck has left. + */ + async loadTruck( + bookingId: string, + assignmentId: string, + dto: { containerNumbers: string[] }, + ): Promise { + await this.loadBookingGuard(bookingId); + const assignment = await this.assignments.findByIdWithContainers(assignmentId); + if (!assignment || assignment.bookingId !== bookingId) { + throw new NotFoundException('Truck assignment not found for this booking'); + } + if (assignment.departedAt) { + throw new ConflictException('This truck has already left — its load is locked'); + } + + const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase()); + if (!requested.length) { + throw new BadRequestException('Select at least one container to load onto the truck'); + } + const bookingNumbers = await this.bookingContainerNumbers(bookingId); + for (const n of requested) { + if (!bookingNumbers.includes(n)) { + throw new BadRequestException(`Container ${n} is not one of this booking's containers`); + } + } + const elsewhere = await this.assignedContainerNumbersExcept(bookingId, assignmentId); + for (const n of requested) { + if (elsewhere.includes(n)) { + throw new ConflictException(`Container ${n} is already loaded onto another truck`); + } + } + + const grossKg = await this.vgmKgForContainers(bookingId, requested); + await this.dataSource.transaction(async (manager) => { + await manager.getRepository(CustomerTruckContainer).softDelete({ assignmentId }); + await manager.getRepository(CustomerTruckContainer).save( + requested.map((containerNumber) => + manager.getRepository(CustomerTruckContainer).create({ + assignmentId, + bookingId, + containerNumber, + }), + ), + ); + // Provisional gross from the loaded containers' VGM — overridden by the + // weighed gross on departure. + await manager.getRepository(CustomerTruckAssignment).update(assignmentId, { + grossWeightKg: grossKg, + }); + }); + return this.listTrucks(bookingId); + } + + private async vgmKgForContainers(bookingId: string, numbers: string[]): Promise { + const [row]: Array<{ kg: string }> = await this.dataSource.query( + `SELECT COALESCE(SUM(bcu.vgm_tons), 0) * 1000 AS kg + FROM freight.booking_container_units bcu + JOIN freight.booking_containers bc + ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL + WHERE bc.booking_id = $1 + AND bcu.container_number = ANY($2::varchar[]) + AND bcu.deleted_at IS NULL`, + [bookingId, numbers], + ); + return Number(row?.kg ?? 0); + } + /** * Mark the truck carrying `containerNumber` as arrived. Called by the warehouse * receive flow. When every truck on the booking has arrived, the booking-level diff --git a/apps/edr-freight-api/src/modules/bookings/dto/load-customer-truck.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/load-customer-truck.dto.ts new file mode 100644 index 000000000..11c80f687 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/load-customer-truck.dto.ts @@ -0,0 +1,13 @@ +import { ArrayMinSize, ArrayUnique, IsArray, Matches } from 'class-validator'; + +/** Containers loaded onto a truck at Truck_dispatch (after arrival, before it leaves). */ +export class LoadCustomerTruckDto { + @IsArray() + @ArrayMinSize(1) + @ArrayUnique() + @Matches(/^[A-Z]{4}\d{7}$/, { + each: true, + message: 'each container number must match ISO container format, e.g. ABCD1234567', + }) + containerNumbers!: 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 aab2fccbb..cd1aa432c 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 @@ -285,6 +285,19 @@ export class WarehouseInventoryController { return res.send(buffer); } + @Get('customer-truck-exit-paper/:assignmentId') + @ApiOperation({ summary: 'Per-truck exit paper PDF (containers loaded on one customer truck)' }) + async truckExitPaper( + @Param('assignmentId', ParseUUIDPipe) assignmentId: string, + @Res() res: Response, + ) { + const { filename, buffer } = await this.inventoryService.truckExitPaper(assignmentId); + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', `inline; filename="${filename}"`); + res.setHeader('Content-Length', buffer.length); + return res.send(buffer); + } + @Get(':id/grn-document') @ApiOperation({ summary: 'View goods received note PDF' }) async grnDocument(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) { 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 b16454f9d..7e41e82ec 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 @@ -2308,6 +2308,115 @@ export class WarehouseInventoryService { }; } + /** + * Per-truck exit paper: one paper covering the containers loaded on a specific + * customer truck (used when multiple trucks leave separately). Gated on the + * handover being signed and warehouse fees paid. + */ + async truckExitPaper(assignmentId: string): Promise<{ filename: string; buffer: Buffer }> { + const [truck] = await this.dataSource.query( + `SELECT a.booking_id AS "bookingId", a.plate_number AS "plateNumber", + a.driver_name AS "driverName", a.truck_type AS "truckType", + a.gross_weight_kg AS "grossWeightKg", a.departed_at AS "departedAt", + b.reference AS "bookingReference", company.name AS "customerName" + FROM freight.customer_truck_assignments a + JOIN freight.bookings b ON b.id = a.booking_id AND b.deleted_at IS NULL + LEFT JOIN freight.companies company ON company.id = b.company_id + WHERE a.id = $1 AND a.deleted_at IS NULL`, + [assignmentId], + ); + if (!truck) throw new NotFoundException(`Truck assignment ${assignmentId} not found`); + + if (!(await this.handover.isFullySigned(truck.bookingId))) { + throw new BadRequestException('Handover must be signed before the exit paper can be generated'); + } + const [inv]: Array<{ id: string }> = await this.dataSource.query( + `SELECT id FROM freight.warehouse_inventory + WHERE booking_id = $1 AND deleted_at IS NULL ORDER BY created_at LIMIT 1`, + [truck.bookingId], + ); + if (inv?.id) await this.invoices.assertClearanceAllowed(inv.id); + + const containers: Array<{ containerNumber: string; goods: string | null }> = + await this.dataSource.query( + `SELECT c.container_number AS "containerNumber", + COALESCE(ct.cargo_type_name, b.cargo_free_text) AS goods + FROM freight.customer_truck_containers c + JOIN freight.bookings b ON b.id = c.booking_id + LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id + WHERE c.assignment_id = $1 AND c.deleted_at IS NULL + ORDER BY c.container_number`, + [assignmentId], + ); + + const html = this.buildTruckExitPaperHtml({ + reference: `REL-${String(truck.bookingReference).replace(/^BK-?/i, '')}-${truck.plateNumber}`, + bookingReference: truck.bookingReference, + customerName: truck.customerName, + plateNumber: truck.plateNumber, + driverName: truck.driverName, + truckType: truck.truckType, + grossWeightKg: Number(truck.grossWeightKg ?? 0), + gateOut: truck.departedAt, + containers, + }); + return { + filename: `exit-${String(truck.plateNumber).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`, + buffer: await this.releaseDocuments.renderDocumentHtml(html, 'Warehouse exit paper'), + }; + } + + private buildTruckExitPaperHtml(data: { + reference: string; + bookingReference: string; + customerName: string | null; + plateNumber: string; + driverName: string; + truckType: string; + grossWeightKg: number; + gateOut: string | Date | null; + containers: Array<{ containerNumber: string; goods: string | null }>; + }): string { + const esc = (v: unknown) => + String(v ?? '-').replace(/&/g, '&').replace(//g, '>'); + const gateOut = data.gateOut ? new Date(data.gateOut).toLocaleString('en-GB') : '-'; + const rows: Array<[string, string]> = [ + ['Booking Reference', data.bookingReference], + ['Customer / Consignee', data.customerName ?? '-'], + ['Pickup Truck Plate', data.plateNumber], + ['Driver', data.driverName], + ['Truck Type', data.truckType], + ['Gross Weight (Loaded on Truck)', `${data.grossWeightKg.toLocaleString()} kg`], + ['Gate-Out Time', gateOut], + ['Clearance Status', 'CLEARED FOR WAREHOUSE EXIT'], + ]; + const containerRows = data.containers.length + ? data.containers + .map((c) => `${esc(c.containerNumber)}${esc(c.goods)}`) + .join('') + : 'No containers loaded on this truck.'; + return `Warehouse Exit Paper + + +
Ethio-Djibouti Railway S.C.
+

Warehouse Release / Exit Paper

+
Document / Release No. ${esc(data.reference)}
+
Release Particulars
+ ${rows.map(([l, v]) => ``).join('')}
${esc(l)}${esc(v)}
+
Containers Leaving on This Truck
+ + ${containerRows}
Container NumberGoods
+ `; + } + /** Hand import goods to the customer + capture proof of delivery (READY_FOR_PICKUP → DELIVERED). */ async grnDocument(id: string): Promise<{ filename: string; buffer: Buffer }> { const [row] = await this.dataSource.query( 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 2002f3261..e1e12e915 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx @@ -61,6 +61,7 @@ import type { } from '@/types/warehouse'; import { BookingSelect } from './BookingSelect'; import { DeliverInventoryModal } from './DeliverInventoryModal'; +import { TruckDispatchModal } from './TruckDispatchModal'; import { FeePreviewModal } from './FeePreviewModal'; import { InspectionReportModal } from './InspectionReportModal'; import { InventoryDetailModal } from './InventoryDetailModal'; @@ -2151,7 +2152,6 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { ); const storeMutation = useMutation(api.warehouses.store.mutationOptions()); const readyMutation = useMutation(api.warehouses.markReadyForPickup.mutationOptions()); - const dispatchMutation = useMutation(api.warehouses.dispatch.mutationOptions()); const [selected, setSelected] = useState>(new Set()); const [inspectId, setInspectId] = useState(null); const [busyId, setBusyId] = useState(null); @@ -2160,6 +2160,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { const [feeItem, setFeeItem] = useState(null); const [releaseItem, setReleaseItem] = useState(null); const [deliverItem, setDeliverItem] = useState(null); + const [loadTruckItem, setLoadTruckItem] = useState(null); const allSelected = rows.length > 0 && selected.size === rows.length; const someSelected = selected.size > 0 && !allSelected; @@ -2419,7 +2420,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { variant="light" color="green" loading={busyId === r.id} - onClick={() => runRowAction(r, 'Inventory dispatched', () => dispatchMutation.mutateAsync(r.id))} + onClick={() => setLoadTruckItem(toInventoryItem(r))} > Truck_dispatch @@ -2493,6 +2494,12 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { /> setReleaseItem(null)} item={releaseItem} /> setDeliverItem(null)} item={deliverItem} /> + setLoadTruckItem(null)} + bookingId={loadTruckItem?.booking?.id ?? null} + bookingReference={loadTruckItem?.booking?.reference ?? null} + /> ); } diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/TruckDispatchModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/TruckDispatchModal.tsx new file mode 100644 index 000000000..a1517de78 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/TruckDispatchModal.tsx @@ -0,0 +1,128 @@ +import { Alert, Badge, Button, Group, Loader, Modal, MultiSelect, Stack, Text } from '@mantine/core'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { Truck } from 'lucide-react'; +import { useState } from 'react'; + +import { useToast } from '@/hooks/use-toast'; +import { warehouseService } from '@/services/warehouse.service'; +import { extractErrorMessage } from './options'; + +interface TruckDispatchModalProps { + opened: boolean; + onClose: () => void; + bookingId: string | null; + bookingReference?: string | null; +} + +/** + * Truck_dispatch: after a self-haul truck arrives, staff select which of the + * booking's containers ride each truck. The loaded set drives the truck's gross + * weight; the truck is weighed for real on departure. + */ +export function TruckDispatchModal({ opened, onClose, bookingId, bookingReference }: TruckDispatchModalProps) { + const { toast } = useToast(); + const queryClient = useQueryClient(); + const [selectedByTruck, setSelectedByTruck] = useState>({}); + + const trucksKey = ['td-customer-trucks', bookingId]; + const loadableKey = ['td-loadable', bookingId]; + + const { data: trucks = [], isLoading: trucksLoading } = useQuery({ + queryKey: trucksKey, + queryFn: () => warehouseService.getCustomerTrucks(bookingId as string), + enabled: opened && Boolean(bookingId), + }); + const { data: loadable = [], isLoading: loadableLoading } = useQuery({ + queryKey: loadableKey, + queryFn: () => warehouseService.getLoadableContainers(bookingId as string), + enabled: opened && Boolean(bookingId), + }); + + const loadMutation = useMutation({ + mutationFn: ({ assignmentId, containerNumbers }: { assignmentId: string; containerNumbers: string[] }) => + warehouseService.loadTruck(bookingId as string, assignmentId, containerNumbers), + onSuccess: (_res, vars) => { + queryClient.invalidateQueries({ queryKey: trucksKey }); + queryClient.invalidateQueries({ queryKey: loadableKey }); + setSelectedByTruck((s) => ({ ...s, [vars.assignmentId]: [] })); + toast({ title: 'Truck loaded' }); + }, + onError: (e) => toast({ variant: 'destructive', title: 'Load failed', description: extractErrorMessage(e) }), + }); + + return ( + + + Truck_dispatch — load containers {bookingReference ? `· ${bookingReference}` : ''} + + } + > + {trucksLoading || loadableLoading ? ( + + + + ) : trucks.length === 0 ? ( + + No customer truck is assigned to this booking yet. + + ) : ( + + {trucks.map((t) => { + const alreadyLoaded = (t.containers ?? []).map((c) => c.containerNumber); + // Options = still-loadable + this truck's own already-loaded (so they stay visible). + const options = Array.from(new Set([...loadable, ...alreadyLoaded])); + const selected = selectedByTruck[t.id] ?? alreadyLoaded; + const departed = Boolean(t.arrivedAt) && Boolean((t as { departedAt?: string }).departedAt); + return ( + + + {t.plateNumber} + + {t.driverName} · {t.truckType} + {t.arrivedAt ? Arrived : Not arrived} + + + setSelectedByTruck((s) => ({ ...s, [t.id]: v }))} + searchable + disabled={departed || !t.arrivedAt} + nothingFoundMessage="No loadable containers" + /> + + + + + ); + })} + + )} + + ); +} 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 40b084d27..56f3879e1 100644 --- a/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts @@ -75,6 +75,27 @@ export const warehouseService = { return data?.data ?? data ?? []; }, + /** Booking container numbers not yet loaded onto any truck. */ + getLoadableContainers: async (bookingId: string): Promise => { + const { data } = await apiClient.get( + `/api/bookings/${bookingId}/customer-trucks/loadable-containers`, + ); + return data?.data ?? data ?? []; + }, + + /** Truck_dispatch: load selected containers onto a truck (after arrival). */ + loadTruck: async ( + bookingId: string, + assignmentId: string, + containerNumbers: string[], + ): Promise => { + const { data } = await apiClient.post( + `/api/bookings/${bookingId}/customer-trucks/${assignmentId}/load`, + { containerNumbers }, + ); + return data?.data ?? data ?? []; + }, + // ── Warehouses ────────────────────────────────────────────────────────── list: (filter?: WarehouseFilter) => apiClient.get(URL_CONSTANTS.WAREHOUSES.BASE, {