diff --git a/apps/edr-freight-api/src/migrations/2050000000000-AddCustomerTruckContainerLoadedAt.ts b/apps/edr-freight-api/src/migrations/2050000000000-AddCustomerTruckContainerLoadedAt.ts new file mode 100644 index 000000000..332df2423 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2050000000000-AddCustomerTruckContainerLoadedAt.ts @@ -0,0 +1,35 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Adds customer_truck_containers.loaded_at so an assignment (customer planning + * which containers ride which truck) is distinct from the container actually + * being loaded. Stage LOADED now requires loaded_at; customer assignment alone + * keeps the container at its prior stage (RECEIVED/GRN) with its planned truck + * shown. Backfills containers on already-departed trucks (they left loaded). + */ +export class AddCustomerTruckContainerLoadedAt2050000000000 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.customer_truck_containers + ADD COLUMN IF NOT EXISTS loaded_at TIMESTAMPTZ; + `); + + await queryRunner.query(` + UPDATE freight.customer_truck_containers ctc + SET loaded_at = a.departed_at + FROM freight.customer_truck_assignments a + WHERE a.id = ctc.assignment_id + AND a.departed_at IS NOT NULL + AND ctc.deleted_at IS NULL + AND ctc.loaded_at IS NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.customer_truck_containers DROP COLUMN IF EXISTS loaded_at; + `); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index 578e73278..48a29988a 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -1424,12 +1424,14 @@ export class BookingsService { schedule?.status ?? null; } - // A generated-but-unsigned handover means the customer must approve delivery. - // Surfaced so the portal shows "Approve delivery" as soon as the handover - // exists, independent of the truck-arrival flag. + // A generated-but-unsigned SELF_HAUL handover means the customer must approve + // delivery from the portal (booking-based, one per booking). EDR last-mile + // handovers are per delivering truck and signed by the receiver at the door, + // so they never surface the portal "Approve delivery" action. const [pendingHandover] = await this.dataSource.query( `SELECT 1 FROM freight.booking_handovers WHERE booking_id = $1 AND signed_at IS NULL AND deleted_at IS NULL + AND mile_type = 'SELF_HAUL' LIMIT 1`, [id], ); 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 14402f830..4e364a03a 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 @@ -312,6 +312,13 @@ export class CustomerTruckService { if (assignment.departedAt) { throw new ConflictException('This truck has already left — its load is locked'); } + // Containers can only be loaded after the truck has physically arrived at the + // warehouse (arrival weighing recorded). Assignment alone is just planning. + if (!assignment.arrivedAt) { + throw new BadRequestException( + 'Record the truck arrival before loading — containers can only be loaded onto an arrived truck', + ); + } const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase()); if (!requested.length) { @@ -333,12 +340,16 @@ export class CustomerTruckService { const grossTons = await this.vgmTonsForContainers(bookingId, requested); await this.dataSource.transaction(async (manager) => { await manager.getRepository(CustomerTruckContainer).softDelete({ assignmentId }); + // Operator loading the truck: stamp loaded_at so these containers move to + // the LOADED stage (customer assignment alone leaves loaded_at null). + const loadedAt = new Date(); await manager.getRepository(CustomerTruckContainer).save( requested.map((containerNumber) => manager.getRepository(CustomerTruckContainer).create({ assignmentId, bookingId, containerNumber, + loadedAt, }), ), ); diff --git a/apps/edr-freight-api/src/modules/bookings/entities/customer-truck-container.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/customer-truck-container.entity.ts index 110e31671..8b6ecc8b9 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/customer-truck-container.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/customer-truck-container.entity.ts @@ -23,4 +23,12 @@ export class CustomerTruckContainer extends BaseEntity { @Column({ name: 'container_number', type: 'varchar', length: 64 }) containerNumber!: string; + + /** + * When the container was actually loaded onto the truck by the operator. + * Null = customer-assigned (planned) but not yet loaded. Stage LOADED requires + * this to be set, so customer assignment alone does not mark a container loaded. + */ + @Column({ name: 'loaded_at', type: 'timestamptz', nullable: true }) + loadedAt?: Date | null; } diff --git a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts index e3130da95..f220cae0d 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts @@ -9,14 +9,19 @@ import { IsOptional, IsString, IsUUID, + Matches, Min, ValidateNested, } from 'class-validator'; /** One physical container under a booking line — entered at booking time. */ export class CreateContainerUnitDto { - @ApiProperty() + @ApiProperty({ description: 'ISO 6346 container number, e.g. ABCD1234567' }) @IsString() + @Transform(({ value }) => (typeof value === 'string' ? value.trim().toUpperCase() : value)) + @Matches(/^[A-Z]{4}\d{7}$/, { + message: 'containerNumber must match ISO container format, e.g. ABCD1234567', + }) containerNumber!: string; @ApiPropertyOptional() 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 9eb4ea502..242cecc76 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 @@ -361,6 +361,16 @@ export class WarehouseInventoryController { return this.handoverService.requestSignature(bookingId); } + @Get('bookings/:bookingId/handover-document') + @ApiOperation({ summary: 'View import goods handover document PDF (resolved by booking)' }) + async bookingHandoverDocument(@Param('bookingId', ParseUUIDPipe) bookingId: string, @Res() res: Response) { + const { filename, buffer } = await this.inventoryService.handoverDocumentForBooking(bookingId); + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', `inline; filename="${filename}"`); + res.setHeader('Content-Length', buffer.length); + return res.send(buffer); + } + @Get('bookings/:bookingId/container-items') @ApiOperation({ summary: 'Per-container/bulk items of a booking with lifecycle stage + refs' }) containerItems(@Param('bookingId', ParseUUIDPipe) bookingId: string) { 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 14f4e27f3..9b2f53a99 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 @@ -2603,12 +2603,13 @@ export class WarehouseInventoryService { Array<{ containerNumber: string; goods: string | null; - stage: 'PENDING' | 'RECEIVED' | 'GRN' | 'LOADED' | 'LEFT' | 'DELIVERED'; + stage: 'PENDING' | 'RECEIVED' | 'GRN' | 'ASSIGNED' | 'LOADED' | 'LEFT' | 'DELIVERED'; grnNumber: string | null; truckAssignmentId: string | null; truckPlate: string | null; truckArrived: boolean; truckLeft: boolean; + loaded: boolean; bookingReference: string | null; contractId: string | null; hasLastMile: boolean; @@ -2624,6 +2625,7 @@ export class WarehouseInventoryService { truckPlate: string | null; truckArrived: boolean; truckLeft: boolean; + loaded: boolean; bookingReference: string | null; contractId: string | null; hasLastMile: boolean; @@ -2637,6 +2639,7 @@ export class WarehouseInventoryService { a.plate_number AS "truckPlate", (a.arrived_at IS NOT NULL) AS "truckArrived", (a.departed_at IS NOT NULL) AS "truckLeft", + (ctc.loaded_at IS NOT NULL) AS loaded, b.reference AS "bookingReference", b.contract_id AS "contractId", (b.last_mile_delivery_address IS NOT NULL) AS "hasLastMile", @@ -2666,22 +2669,28 @@ export class WarehouseInventoryService { return rows.map((r) => ({ containerNumber: r.containerNumber, goods: r.goods, + // A container the customer assigned to a truck is ASSIGNED (planned); it + // only becomes LOADED once the operator loads it (loaded_at) on truck + // leaving. Departed → LEFT, delivered → DELIVERED. stage: r.delivered ? 'DELIVERED' : r.truckLeft ? 'LEFT' - : r.truckAssignmentId + : r.loaded ? 'LOADED' - : r.grnNumber - ? 'GRN' - : r.received - ? 'RECEIVED' - : 'PENDING', + : r.truckAssignmentId + ? 'ASSIGNED' + : r.grnNumber + ? 'GRN' + : r.received + ? 'RECEIVED' + : 'PENDING', grnNumber: r.grnNumber, truckAssignmentId: r.truckAssignmentId, truckPlate: r.truckPlate, truckArrived: r.truckArrived, truckLeft: r.truckLeft, + loaded: r.loaded, bookingReference: r.bookingReference, contractId: r.contractId, hasLastMile: r.hasLastMile, @@ -3016,6 +3025,21 @@ export class WarehouseInventoryService { }; } + /** Handover PDF resolved by booking (for the portal, which only has bookingId). */ + async handoverDocumentForBooking(bookingId: string): Promise<{ filename: string; buffer: Buffer }> { + 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 updated_at DESC NULLS LAST, created_at DESC + LIMIT 1`, + [bookingId], + ); + if (!inv) { + throw new NotFoundException(`No warehouse inventory found for booking ${bookingId}`); + } + return this.handoverDocument(inv.id); + } + async handoverDocument(id: string): Promise<{ filename: string; buffer: Buffer }> { const [row] = await this.dataSource.query( `SELECT inv.id, @@ -3242,7 +3266,22 @@ export class WarehouseInventoryService { [item.bookingId], ); } else { - await this.handover.ensureAtDelivery(item.bookingId, {}, manager); + // EDR last-mile: the handover is per delivering truck. Resolve the + // vehicle that carried this item's container so each truck gets its own + // handover (falls back to a booking-level one when unresolvable). + let truckPlate: string | null = null; + if (item.containerId) { + const [veh]: Array<{ plate: string | null }> = await manager.query( + `SELECT COALESCE(v.power_plate_no, v.plate_number) AS plate + FROM freight.last_mile_container_allocations lca + JOIN freight.vehicles v ON v.id = lca.vehicle_id + WHERE lca.container_id = $1 AND lca.vehicle_id IS NOT NULL + LIMIT 1`, + [item.containerId], + ); + truckPlate = veh?.plate ?? null; + } + await this.handover.ensureAtDelivery(item.bookingId, { truckPlate }, manager); } } }); diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ContainerItemsModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ContainerItemsModal.tsx index bb769df46..57be67d68 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ContainerItemsModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ContainerItemsModal.tsx @@ -37,6 +37,7 @@ const STAGE_TABS: Array<{ value: string; label: string }> = [ { value: 'ALL', label: 'All' }, { value: 'RECEIVED', label: 'Received' }, { value: 'GRN', label: "GRN'd" }, + { value: 'ASSIGNED', label: 'Assigned' }, { value: 'LOADED', label: 'Loaded' }, { value: 'LEFT', label: 'Left' }, { value: 'DELIVERED', label: 'Delivered' }, @@ -46,13 +47,15 @@ const STAGE_COLOR: Record = { PENDING: 'gray', RECEIVED: 'blue', GRN: 'teal', + ASSIGNED: 'indigo', LOADED: 'grape', LEFT: 'orange', DELIVERED: 'green', }; -/** Loadable = not yet on a truck (before LOADED). */ -const isLoadable = (i: ContainerItem) => i.stage === 'PENDING' || i.stage === 'RECEIVED' || i.stage === 'GRN'; +/** Loadable = not yet loaded (PENDING/RECEIVED/GRN, or customer-ASSIGNED awaiting load). */ +const isLoadable = (i: ContainerItem) => + i.stage === 'PENDING' || i.stage === 'RECEIVED' || i.stage === 'GRN' || i.stage === 'ASSIGNED'; export function ContainerItemsModal({ opened, onClose, bookingId, bookingReference }: ContainerItemsModalProps) { const { toast } = useToast(); @@ -77,8 +80,13 @@ export function ContainerItemsModal({ opened, onClose, bookingId, bookingReferen () => (tab === 'ALL' ? items : items.filter((i) => i.stage === tab)), [items, tab], ); + // Only arrived, not-yet-departed trucks can be loaded. const truckOptions = trucks - .filter((t) => !(t as { departedAt?: string }).departedAt) + .filter( + (t) => + Boolean((t as { arrivedAt?: string }).arrivedAt) && + !(t as { departedAt?: string }).departedAt, + ) .map((t) => ({ value: t.id, label: `${t.plateNumber} · ${t.driverName}` })); const loadMutation = useMutation({ @@ -181,7 +189,7 @@ export function ContainerItemsModal({ opened, onClose, bookingId, bookingReferen {i.contractId ? Contract : '—'} {i.hasLastMile ? EDR : Self-haul} - {i.truckAssignmentId && ( + {i.loaded && i.truckAssignmentId && ( truck.value === truckPlateNumber) ? truckPlateNumber : null} onChange={(value) => { const truck = truckSelectOptions.find((row) => row.value === value); 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 6e99c639c..366d8b9f3 100644 --- a/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts @@ -64,7 +64,7 @@ import type { WarehouseZone, } from '@/types/warehouse'; -export type ContainerItemStage = 'PENDING' | 'RECEIVED' | 'GRN' | 'LOADED' | 'LEFT' | 'DELIVERED'; +export type ContainerItemStage = 'PENDING' | 'RECEIVED' | 'GRN' | 'ASSIGNED' | 'LOADED' | 'LEFT' | 'DELIVERED'; export interface ContainerItem { containerNumber: string; @@ -75,6 +75,8 @@ export interface ContainerItem { truckPlate: string | null; truckArrived: boolean; truckLeft: boolean; + /** Operator has loaded this container onto the truck (customer assignment alone is not "loaded"). */ + loaded: boolean; bookingReference: string | null; contractId: string | null; hasLastMile: boolean; diff --git a/apps/edr-freight-web/portal/src/pages/bookings/delivery/ApproveDeliveryButton.tsx b/apps/edr-freight-web/portal/src/pages/bookings/delivery/ApproveDeliveryButton.tsx index e9ee31722..c3a0a8ccd 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/delivery/ApproveDeliveryButton.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/delivery/ApproveDeliveryButton.tsx @@ -1,11 +1,8 @@ import { Button, type ButtonProps } from "@mantine/core"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; import { CheckCircle2 } from "lucide-react"; -import type { MouseEvent } from "react"; -import toast from "react-hot-toast"; -import { useNavigate } from "react-router-dom"; +import { type MouseEvent, useState } from "react"; -import { api } from "@/services/api"; +import { ApproveDeliveryModal } from "./ApproveDeliveryModal"; type ApproveDeliveryButtonProps = ButtonProps & { bookingId: string; @@ -13,25 +10,6 @@ type ApproveDeliveryButtonProps = ButtonProps & { onApproved?: () => void; }; -const errorMessage = (error: unknown) => { - const data = (error as { response?: { data?: { message?: string | string[] } } }) - ?.response?.data; - if (Array.isArray(data?.message)) return data.message.join(", "); - if (data?.message) return data.message; - return error instanceof Error ? error.message : "Could not approve delivery"; -}; - -const downloadBlob = (blob: Blob, filename: string) => { - const url = URL.createObjectURL(blob); - const link = document.createElement("a"); - link.href = url; - link.download = filename; - document.body.appendChild(link); - link.click(); - link.remove(); - URL.revokeObjectURL(url); -}; - export function ApproveDeliveryButton({ bookingId, stopPropagation, @@ -40,56 +18,31 @@ export function ApproveDeliveryButton({ variant = "filled", ...props }: ApproveDeliveryButtonProps) { - const navigate = useNavigate(); - const queryClient = useQueryClient(); - - const handoverMutation = useMutation(api.bookings.downloadHandoverDocument.mutationOptions()); - - const mutation = useMutation({ - ...api.bookings.approveDelivery.mutationOptions(), - onSuccess: async (result) => { - try { - const blob = await handoverMutation.mutateAsync({ inventoryId: result.inventoryId }); - downloadBlob(blob, `handover-${bookingId}.pdf`); - toast.success("Delivery approved and signed handover downloaded"); - } catch { - toast.success("Delivery approved and handover signed"); - toast.error("Signed handover document could not be downloaded"); - } - await Promise.all([ - queryClient.invalidateQueries({ queryKey: api.bookings.get.queryKey({ id: bookingId }) }), - queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() }), - queryClient.invalidateQueries({ queryKey: ["companies", "getDashboard"] }), - ]); - onApproved?.(); - }, - onError: (error) => { - const message = errorMessage(error); - toast.error(message); - if (message.toLowerCase().includes("save your signature")) { - navigate("/signature"); - } else if (/demurrage|storage|warehouse invoice|fully paid/i.test(message)) { - navigate("/billing"); - } - }, - }); + const [opened, setOpened] = useState(false); const handleClick = (event: MouseEvent) => { if (stopPropagation) event.stopPropagation(); - mutation.mutate({ id: bookingId }); + setOpened(true); }; return ( - + <> + + setOpened(false)} + onApproved={onApproved} + /> + ); } diff --git a/apps/edr-freight-web/portal/src/pages/bookings/delivery/ApproveDeliveryModal.tsx b/apps/edr-freight-web/portal/src/pages/bookings/delivery/ApproveDeliveryModal.tsx new file mode 100644 index 000000000..a3723b650 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/delivery/ApproveDeliveryModal.tsx @@ -0,0 +1,171 @@ +import { Alert, Button, Group, Loader, Modal, Stack, Text } from "@mantine/core"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { CheckCircle2, Info } from "lucide-react"; +import { useEffect, useState } from "react"; +import toast from "react-hot-toast"; +import { useNavigate } from "react-router-dom"; + +import { api } from "@/services/api"; +import { bookingsService } from "@/services/bookings.service"; + +type ApproveDeliveryModalProps = { + bookingId: string; + opened: boolean; + onClose: () => void; + onApproved?: () => void; +}; + +const errorMessage = (error: unknown) => { + const data = (error as { response?: { data?: { message?: string | string[] } } }) + ?.response?.data; + if (Array.isArray(data?.message)) return data.message.join(", "); + if (data?.message) return data.message; + return error instanceof Error ? error.message : "Could not approve delivery"; +}; + +const downloadBlob = (blob: Blob, filename: string) => { + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); +}; + +/** + * Approve-delivery flow: open the handover document for the customer to review, + * then apply their saved signature (approve) and hand back the signed PDF. + */ +export function ApproveDeliveryModal({ + bookingId, + opened, + onClose, + onApproved, +}: ApproveDeliveryModalProps) { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const [pdfUrl, setPdfUrl] = useState(null); + + const { + data: docBlob, + isLoading, + isError, + } = useQuery({ + queryKey: ["booking-handover-doc", bookingId], + queryFn: () => bookingsService.downloadBookingHandoverDocument(bookingId), + enabled: opened && Boolean(bookingId), + staleTime: 0, + }); + + useEffect(() => { + if (!docBlob) { + setPdfUrl(null); + return; + } + const url = URL.createObjectURL(docBlob); + setPdfUrl(url); + return () => URL.revokeObjectURL(url); + }, [docBlob]); + + const handoverMutation = useMutation( + api.bookings.downloadHandoverDocument.mutationOptions(), + ); + + const approve = useMutation({ + ...api.bookings.approveDelivery.mutationOptions(), + onSuccess: async (result) => { + try { + const signed = await handoverMutation.mutateAsync({ + inventoryId: result.inventoryId, + }); + downloadBlob(signed, `handover-${bookingId}.pdf`); + toast.success("Delivery approved and signed handover downloaded"); + } catch { + toast.success("Delivery approved and handover signed"); + toast.error("Signed handover document could not be downloaded"); + } + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: api.bookings.get.queryKey({ id: bookingId }), + }), + queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() }), + queryClient.invalidateQueries({ queryKey: ["companies", "getDashboard"] }), + ]); + onApproved?.(); + onClose(); + }, + onError: (error) => { + const message = errorMessage(error); + toast.error(message); + if (message.toLowerCase().includes("save your signature")) { + onClose(); + navigate("/signature"); + } else if (/demurrage|storage|warehouse invoice|fully paid/i.test(message)) { + onClose(); + navigate("/billing"); + } + }, + }); + + const busy = approve.isPending || handoverMutation.isPending; + + return ( + + + }> + + Review the handover document below. Approving applies your saved signature + and confirms you received the goods. + + + + {isLoading ? ( + + + + Loading handover document… + + + ) : isError || !pdfUrl ? ( + + Could not load the handover document. It may not be generated yet. + + ) : ( +