From ed503f1e6b19fe34fc4aac7ac4c9c82b2f84323f Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Tue, 21 Jul 2026 11:38:57 +0000 Subject: [PATCH 1/3] feat(portal): show warehouse location for arrived cargo Display cargo warehouse location (warehouse, yard, zone, arrival time) on the portal booking detail page once the cargo has been received and assigned to a warehouse location. Co-Authored-By: Claude Haiku 4.5 --- .../BookingDetailPage/ReadonlyBookingView.tsx | 3 + .../components/WarehouseLocationCard.tsx | 71 +++++++++++++++++++ .../portal/src/services/warehouse.service.ts | 56 +++++++++++++++ 3 files changed, 130 insertions(+) create mode 100644 apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WarehouseLocationCard.tsx create mode 100644 apps/edr-freight-web/portal/src/services/warehouse.service.ts diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx index fc701b924..ce218f84b 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx @@ -34,6 +34,7 @@ import { HeaderButton, PageHeader } from "./components/PageHeader"; import { PaymentMethodModal } from "./components/PaymentMethodModal"; import { ScheduleCard } from "./components/ScheduleCard"; import { WarehousePaymentsSection } from "./components/WarehousePaymentsSection"; +import { WarehouseLocationCard } from "./components/WarehouseLocationCard"; import { ShipmentDetailsCard } from "./components/ShipmentDetailsCard"; import { ShipmentTrackingCard } from "./components/ShipmentTrackingCard"; import { StatusHero } from "./components/StatusHero"; @@ -273,6 +274,8 @@ export function ReadonlyBookingView({ + + diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WarehouseLocationCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WarehouseLocationCard.tsx new file mode 100644 index 000000000..64320a968 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WarehouseLocationCard.tsx @@ -0,0 +1,71 @@ +import { Group, Paper, Stack, Text, Divider } from "@mantine/core"; +import { useQuery } from "@tanstack/react-query"; +import { Warehouse as WarehouseIcon } from "lucide-react"; + +import { warehouseService } from "@/services/warehouse.service"; + +interface WarehouseLocationCardProps { + bookingId: string; +} + +function Row({ label, value }: { label: string; value: React.ReactNode }) { + return ( + + + {label} + + + {value} + + + ); +} + +export function WarehouseLocationCard({ bookingId }: WarehouseLocationCardProps) { + const { data, isLoading } = useQuery({ + queryKey: ["warehouse-inventory", bookingId], + queryFn: () => warehouseService.listInventory({ bookingId }), + }); + + const items = data ?? []; + const latest = items[0]; + + return ( + + + + + Warehouse Location + + + + + {isLoading ? ( + + Loading… + + ) : !latest ? ( + + Your cargo will appear here once it arrives at the warehouse. + + ) : ( + + + + + + + )} + + + ); +} diff --git a/apps/edr-freight-web/portal/src/services/warehouse.service.ts b/apps/edr-freight-web/portal/src/services/warehouse.service.ts new file mode 100644 index 000000000..dfb6042cc --- /dev/null +++ b/apps/edr-freight-web/portal/src/services/warehouse.service.ts @@ -0,0 +1,56 @@ +import { client } from "@/utils/api"; + +export interface InventoryFilter { + bookingId?: string; +} + +export interface WarehouseInventoryItem { + id: string; + bookingId: string | null; + warehouseId: string; + yardId: string; + zoneId: string; + status: string; + arrivedAt: string | null; + warehouse?: { + id: string; + name: string; + code: string; + } | null; + yard?: { + id: string; + name: string; + code: string; + } | null; + zone?: { + id: string; + name: string; + code: string; + } | null; +} + +export interface BookingScheduleView { + schedule: { + status: string; + scheduledDepartureDate: string | null; + scheduledArrivalDate: string | null; + } | null; + wagon?: { + wagonNumber: string | null; + sequenceNo: number | null; + } | null; +} + +export const warehouseService = { + listInventory: async (filter?: InventoryFilter): Promise => { + const { data } = await client.get("/warehouse-inventory", { + params: filter, + }); + return data?.data ?? data ?? []; + }, + + bookingSchedule: async (bookingId: string): Promise => { + const { data } = await client.get(`/warehouse-inventory/booking-schedule/${bookingId}`); + return data?.data ?? data ?? { schedule: null, wagon: null }; + }, +}; From 86569f7490c78305fecd8f32cf6a9d84df562e72 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Tue, 21 Jul 2026 11:46:00 +0000 Subject: [PATCH 2/3] Customer portal card displays warehouse location data (warehouse/yard/zone + arrival time) once cargo arrives at warehouse. Shows loading state during fetch, placeholder text if not yet received. Type-check passes. --- .../bookings/booking-transition.service.ts | 17 + .../src/modules/bookings/bookings.service.ts | 17 +- .../warehouse-inventory.controller.ts | 28 +- .../warehouses/warehouse-inventory.service.ts | 343 +++++++++++++++++- .../delivery/ApproveDeliveryModal.tsx | 160 ++++++-- .../portal/src/services/bookings.service.ts | 47 ++- 6 files changed, 557 insertions(+), 55 deletions(-) diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index 1896034cc..36705daa0 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -7,6 +7,7 @@ import { Logger, Optional, } from "@nestjs/common"; +import { OnEvent } from "@nestjs/event-emitter"; import { BookingBatchService } from '../train-scheduling/booking-batch.service'; import { eatDay } from '../train-scheduling/batch-window.util'; @@ -349,6 +350,22 @@ export class BookingTransitionService { return fresh; } + /** + * Import EDR last-mile: every handover signed + every truck departed ⇒ the + * warehouses module delivered the goods and asks the booking to complete. + * Best-effort — a booking already COMPLETED (or not yet in transit) just logs. + */ + @OnEvent('import.handover.completed') + async onImportHandoverCompleted(payload: { bookingId: string }): Promise { + try { + await this.complete(payload.bookingId); + } catch (err) { + this.logger.log( + `Booking ${payload.bookingId} not auto-completed on handover sign: ${(err as Error).message}`, + ); + } + } + async complete(bookingId: string): Promise { const booking = await this.bookingsService.findById(bookingId); assertBookingStatus(booking, ["IN_TRANSIT", "ARRIVED"]); 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 d9dd53f94..393f892f6 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -1228,9 +1228,9 @@ export class BookingsService { /** * Batched version of the findById flag: marks each page item whose booking - * has a generated-but-unsigned SELF_HAUL handover, so list rows (portal - * dashboard) can show "Approve delivery" for exactly the generated→signed - * window. One query for the whole page. + * has a generated-but-unsigned handover (self-haul or EDR last-mile), so list + * rows (portal dashboard) can show "Approve delivery" for exactly the + * generated→signed window. One query for the whole page. */ private async attachHandoverFlags(bookings: Booking[]): Promise { const ids = bookings.map((b) => b.id); @@ -1239,8 +1239,7 @@ export class BookingsService { `SELECT DISTINCT booking_id AS "bookingId" FROM freight.booking_handovers WHERE booking_id = ANY($1::uuid[]) - AND signed_at IS NULL AND deleted_at IS NULL - AND mile_type = 'SELF_HAUL'`, + AND signed_at IS NULL AND deleted_at IS NULL`, [ids], ); const pending = new Set(rows.map((r) => r.bookingId)); @@ -1583,14 +1582,12 @@ export class BookingsService { schedule?.status ?? null; } - // 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. + // A generated-but-unsigned handover means the customer must approve delivery + // from the portal. Self-haul: booking-based, one per booking. EDR last-mile: + // per delivering truck (generated on truck exit), signed one by one. 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/warehouses/warehouse-inventory.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts index 2373cb6d8..acd31bd51 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 @@ -486,6 +486,21 @@ export class WarehouseInventoryController { return this.handoverService.list(bookingId); } + @Post('handovers/:handoverId/sign') + @ApiOperation({ summary: 'Customer signs one handover (EDR last-mile: one signature per truck)' }) + signHandover( + @Param('handoverId', ParseUUIDPipe) handoverId: string, + @Body() dto: ApproveDeliveryDto, + @Request() req: { user?: { id?: string; sub?: string } }, + @CurrentUser() user: TCurrentUser, + ) { + return this.inventoryService.signHandover( + handoverId, + user?.id ?? req.user?.id ?? req.user?.sub, + dto.signerName, + ); + } + @Post('bookings/:bookingId/request-handover-signature') @ApiOperation({ summary: 'Ask the customer to sign the handover (creates one if none, then notifies)' }) requestHandoverSignature(@Param('bookingId', ParseUUIDPipe) bookingId: string) { @@ -513,9 +528,16 @@ export class WarehouseInventoryController { } @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); + @ApiOperation({ summary: 'View import goods handover document PDF (resolved by booking; ?handoverId= for the per-truck variant)' }) + async bookingHandoverDocument( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @Res() res: Response, + @Query('handoverId') handoverId?: string, + ) { + const { filename, buffer } = await this.inventoryService.handoverDocumentForBooking( + bookingId, + handoverId || undefined, + ); res.setHeader('Content-Type', 'application/pdf'); res.setHeader('Content-Disposition', `inline; filename="${filename}"`); res.setHeader('Content-Length', buffer.length); 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 c45764bbd..a144d21bf 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 @@ -1,4 +1,5 @@ import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; import { Cron, CronExpression } from '@nestjs/schedule'; import { Between, @@ -25,6 +26,7 @@ import { CargoType } from '../rule-engine/entities/cargo-type.entity'; import { InterchangeDocumentsService } from '../interchange-documents/interchange-documents.service'; import type { InterchangeDocument } from '../interchange-documents/entities/interchange-document.entity'; import { LastMileService } from '../last-mile/last-mile.service'; +import { UpdateLastMileDto } from '../last-mile/dto/update-last-mile.dto'; import { NotificationsService } from '../notifications/notifications.service'; import { sendCompanyChannels } from '../notifications/notify-company.util'; import { @@ -409,6 +411,7 @@ export class WarehouseInventoryService { private readonly signatures: SignaturesService, private readonly handover: HandoverService, private readonly inbox: NotificationInboxService, + private readonly events: EventEmitter2, ) {} /** @@ -3148,7 +3151,7 @@ export class WarehouseInventoryService { // EDR last-mile: this truck is leaving — record its exit and the load it // actually took. net_weight_tons drives the bulk drawdown (booking VGM // minus everything already hauled away). - await manager.query( + const [edrDeparted] = (await manager.query( `UPDATE freight.last_mile_vehicle_assignments va SET departed_at = COALESCE($3::timestamptz, NOW()), arrived_at = COALESCE(va.arrived_at, NOW()), @@ -3162,7 +3165,8 @@ export class WarehouseInventoryService { AND v.id = va.vehicle_id AND (UPPER(v.power_plate_no) = UPPER($2) OR UPPER(v.plate_number) = UPPER($2)) AND va.departed_at IS NULL - AND va.deleted_at IS NULL`, + AND va.deleted_at IS NULL + RETURNING va.id`, [ item.bookingId, dto.truckPlateNumber.trim(), @@ -3170,7 +3174,31 @@ export class WarehouseInventoryService { grossTons, netTons, ], - ); + )) as [Array<{ id: string }>, unknown]; + // EDR last-mile: the handover is generated the moment the truck exits + // (with its exit paper) — one per truck — and the customer is asked to + // sign it from the portal. Booking-level fallback when the plate matched + // no live assignment (e.g. exit re-recorded) but the booking is EDR-hauled. + for (const row of edrDeparted) { + await this.handover.ensureForDepartedEdrTruck( + item.bookingId, + { truckPlate: dto.truckPlateNumber.trim(), edrAssignmentId: row.id }, + manager, + ); + } + if (!edrDeparted.length) { + const [lm]: Array<{ id: string }> = await manager.query( + `SELECT id FROM freight.last_mile WHERE booking_id = $1 AND deleted_at IS NULL LIMIT 1`, + [item.bookingId], + ); + if (lm) { + await this.handover.ensureForDepartedEdrTruck( + item.bookingId, + { truckPlate: dto.truckPlateNumber.trim() }, + manager, + ); + } + } // Customer self-haul: the same exit record on the customer's own truck. // Without it a self-haul bulk booking never draws down — hauled tonnage // summed to zero and the booking could take unlimited trucks. Matched by @@ -3220,11 +3248,43 @@ export class WarehouseInventoryService { // the transaction and fire-and-forget: notifying must never fail the exit. if (isTruckLeaving && item.bookingId) { void this.notifyTruckDeparture(item.bookingId, dto.truckPlateNumber?.trim() ?? null, netTons); + } else if (item.bookingId) { + // Gate-in: same single-path hook for the arrival side (self-haul + EDR). + void this.notifyTruckArrival(item.bookingId, dto.truckPlateNumber?.trim() ?? null); } return this.findById(id); } + /** Best-effort truck-arrival notification (gate-in), mirror of the departure one. */ + private async notifyTruckArrival(bookingId: string, plateNumber: string | null): Promise { + try { + const [booking]: Array<{ companyId: string | null; reference: string | null }> = + await this.dataSource.query( + `SELECT company_id AS "companyId", reference + FROM freight.bookings + WHERE id = $1 AND deleted_at IS NULL`, + [bookingId], + ); + if (!booking?.companyId) return; + const ref = booking.reference ?? bookingId; + const truck = plateNumber ? `Truck ${plateNumber}` : 'A truck'; + const body = `${truck} has arrived at the warehouse for booking ${ref}.`; + await this.inbox.notify({ + recipients: { companyId: booking.companyId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.BOOKING_STATUS, + title: 'Truck arrived at the warehouse', + body, + link: `/bookings/${bookingId}`, + data: { bookingId, plateNumber, action: 'TRUCK_ARRIVED' }, + }); + await sendCompanyChannels(this.dataSource, this.notifications, booking.companyId, body); + } catch (err) { + this.logger.warn(`Truck-arrival notify failed for ${bookingId}: ${(err as Error).message}`); + } + } + /** * Best-effort truck-departure notification to the booking's company across * every channel: in-app (portal inbox) + SMS + email. Never throws — a missing @@ -3906,8 +3966,205 @@ export class WarehouseInventoryService { }; } - /** Handover PDF resolved by booking (for the portal, which only has bookingId). */ - async handoverDocumentForBooking(bookingId: string): Promise<{ filename: string; buffer: Buffer }> { + /** + * Customer signs ONE handover from the portal (EDR last-mile: one per truck). + * When the last one is signed — and every EDR truck has left the warehouse — + * the delivery completes automatically: inventory + cargo delivered, last-mile + * leg DELIVERED (trucks freed), booking completed ("shipment delivered"). + */ + async signHandover( + handoverId: string, + userId?: string, + signerName?: string, + ): Promise<{ + handoverId: string; + bookingId: string; + signedAt: string | null; + signerDisplayName: string; + allSigned: boolean; + }> { + if (!userId) { + throw new BadRequestException('Authentication is required to sign the handover'); + } + const name = signerName?.trim(); + if (!name) { + throw new BadRequestException('Please enter your full name to sign the handover'); + } + + const [h]: Array<{ + bookingId: string; + reference: string; + truckPlate: string | null; + mileType: string; + edrAssignmentId: string | null; + }> = await this.dataSource.query( + `SELECT booking_id AS "bookingId", reference, truck_plate AS "truckPlate", + mile_type AS "mileType", edr_assignment_id AS "edrAssignmentId" + FROM freight.booking_handovers + WHERE id = $1 AND deleted_at IS NULL`, + [handoverId], + ); + if (!h) throw new NotFoundException(`Handover ${handoverId} not found`); + + // Same gate as approve-delivery: storage/demurrage must be settled first. + const [inv]: Array<{ id: string; warehouseId: string | null }> = await this.dataSource.query( + `SELECT id, warehouse_id AS "warehouseId" 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`, + [h.bookingId], + ); + if (inv) await this.invoices.assertClearanceAllowed(inv.id); + + const signed = await this.handover.sign(handoverId, userId, name); + const allSigned = await this.handover.isFullySigned(h.bookingId); + + if (inv) { + await this.activityLog.record({ + activityType: 'INVENTORY_RELEASED', + inventoryId: inv.id, + warehouseId: inv.warehouseId, + description: `Customer signed handover ${h.reference}${h.truckPlate ? ` (truck ${h.truckPlate})` : ''} as ${name}`, + performedBy: name, + }); + } + + // EDR last-mile delivers PER TRUCK: this signature confirms receipt of the + // goods THIS truck carried, so only its containers become DELIVERED now. + // (Self-haul keeps the single booking-level handover + manual Deliver.) + if (h.mileType === 'EDR_LAST_MILE') { + try { + await this.deliverEdrTruckContainers(h, name); + } catch (err) { + this.logger.warn( + `Per-truck auto-deliver after handover sign failed for ${h.bookingId}: ${(err as Error).message}`, + ); + } + } + + if (allSigned) { + void this.completeEdrDeliveryIfReady(h.bookingId, name).catch((err: Error) => + this.logger.warn(`Auto-complete after handover sign failed for ${h.bookingId}: ${err.message}`), + ); + } + + return { + handoverId, + bookingId: h.bookingId, + signedAt: signed.signedAt ? new Date(signed.signedAt).toISOString() : null, + signerDisplayName: name, + allSigned, + }; + } + + /** + * EDR last-mile auto-completion: once every handover is signed and every EDR + * truck has departed, deliver the remaining inventory, mark the last-mile leg + * DELIVERED and complete the booking. Self-haul bookings keep their manual + * Deliver flow (no last_mile record ⇒ no-op). + */ + private async completeEdrDeliveryIfReady(bookingId: string, signerName: string): Promise { + const [lm]: Array<{ id: string; status: string }> = await this.dataSource.query( + `SELECT id, status FROM freight.last_mile + WHERE booking_id = $1 AND deleted_at IS NULL LIMIT 1`, + [bookingId], + ); + if (!lm) return; + + const [pending]: Array<{ notDeparted: string }> = await this.dataSource.query( + `SELECT COUNT(*) FILTER (WHERE va.departed_at IS NULL) AS "notDeparted" + FROM freight.last_mile_vehicle_assignments va + JOIN freight.last_mile l ON l.id = va.last_mile_id AND l.deleted_at IS NULL + WHERE l.booking_id = $1 AND va.deleted_at IS NULL`, + [bookingId], + ); + if (Number(pending?.notDeparted ?? 0) > 0) return; + if (!(await this.handover.isFullySigned(bookingId))) return; + + const items: Array<{ id: string }> = await this.dataSource.query( + `SELECT id FROM freight.warehouse_inventory + WHERE booking_id = $1 AND status = 'READY_FOR_PICKUP' AND deleted_at IS NULL`, + [bookingId], + ); + for (const it of items) { + try { + await this.deliver(it.id, { + receiverName: signerName, + remarks: 'Auto-delivered on customer handover signature', + performedBy: signerName, + } as DeliverInventoryDto); + } catch (err) { + this.logger.warn(`Auto-deliver of inventory ${it.id} failed: ${(err as Error).message}`); + } + } + + if (lm.status !== 'DELIVERED') { + try { + await this.lastMileService.update(lm.id, { status: 'DELIVERED' } as UpdateLastMileDto); + } catch (err) { + this.logger.warn(`Auto-deliver of last-mile ${lm.id} failed: ${(err as Error).message}`); + } + } + + // Booking → COMPLETED ("shipment delivered" notification) — owned by the + // bookings module; evented to avoid a warehouses→bookings service dependency. + this.events.emit('import.handover.completed', { bookingId }); + } + + /** + * EDR last-mile per-truck delivery: the customer signed THIS truck's handover, + * so only the container items that truck carried become DELIVERED. Bulk cargo + * (no container rows) is delivered by completeEdrDeliveryIfReady once every + * truck is signed off. + */ + private async deliverEdrTruckContainers( + h: { bookingId: string; edrAssignmentId: string | null; truckPlate: string | null }, + signerName: string, + ): Promise { + if (!h.edrAssignmentId && !h.truckPlate) return; + const items: Array<{ id: string }> = await this.dataSource.query( + `SELECT DISTINCT inv.id + FROM freight.last_mile_vehicle_assignments va + JOIN freight.last_mile l ON l.id = va.last_mile_id AND l.deleted_at IS NULL + LEFT JOIN freight.last_mile_vehicle_containers vc + ON vc.assignment_id = va.id AND vc.deleted_at IS NULL + LEFT JOIN freight.vehicles v ON v.id = va.vehicle_id + JOIN freight.containers c + ON c.container_number = COALESCE(vc.container_number, va.container_number) + AND c.deleted_at IS NULL + JOIN freight.warehouse_inventory inv + ON inv.container_id = c.id AND inv.booking_id = l.booking_id AND inv.deleted_at IS NULL + WHERE l.booking_id = $1 + AND va.deleted_at IS NULL + AND inv.status = 'READY_FOR_PICKUP' + AND (va.id = $2::uuid + OR ($2::uuid IS NULL + AND (UPPER(v.power_plate_no) = UPPER($3) OR UPPER(v.plate_number) = UPPER($3))))`, + [h.bookingId, h.edrAssignmentId, h.truckPlate ?? ''], + ); + for (const it of items) { + try { + await this.deliver(it.id, { + receiverName: signerName, + remarks: `Auto-delivered on customer handover signature${h.truckPlate ? ` (truck ${h.truckPlate})` : ''}`, + performedBy: signerName, + } as DeliverInventoryDto); + } catch (err) { + this.logger.warn( + `Per-truck auto-deliver of inventory ${it.id} failed: ${(err as Error).message}`, + ); + } + } + } + + /** + * Handover PDF resolved by booking (for the portal, which only has bookingId). + * With `handoverId` the document is rendered for that specific handover — the + * per-truck EDR last-mile variant (truck plate + that truck's signature state). + */ + async handoverDocumentForBooking( + bookingId: string, + handoverId?: 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 @@ -3918,7 +4175,29 @@ export class WarehouseInventoryService { if (!inv) { throw new NotFoundException(`No warehouse inventory found for booking ${bookingId}`); } - return this.handoverDocument(inv.id); + if (!handoverId) return this.handoverDocument(inv.id); + + const [h]: Array<{ + reference: string; + truckPlate: string | null; + signedAt: string | null; + signerName: string | null; + }> = await this.dataSource.query( + `SELECT reference, truck_plate AS "truckPlate", + signed_at AS "signedAt", signer_name AS "signerName" + FROM freight.booking_handovers + WHERE id = $1 AND booking_id = $2 AND deleted_at IS NULL`, + [handoverId, bookingId], + ); + if (!h) { + throw new NotFoundException(`Handover ${handoverId} not found for booking ${bookingId}`); + } + return this.handoverDocument(inv.id, { + reference: h.reference, + truckPlate: h.truckPlate, + signedAt: h.signedAt ? new Date(h.signedAt) : null, + signerName: h.signerName, + }); } /** Resolve the primary warehouse-inventory item for a booking (most recent). */ @@ -3946,7 +4225,15 @@ export class WarehouseInventoryService { return this.releaseDocument(await this.primaryInventoryIdForBooking(bookingId)); } - async handoverDocument(id: string): Promise<{ filename: string; buffer: Buffer }> { + async handoverDocument( + id: string, + perTruck?: { + reference: string; + truckPlate: string | null; + signedAt: Date | null; + signerName: string | null; + }, + ): Promise<{ filename: string; buffer: Buffer }> { const [row] = await this.dataSource.query( `SELECT inv.id, inv.booking_id AS "bookingId", @@ -4033,12 +4320,14 @@ export class WarehouseInventoryService { const bookingReference = row.bookingReference || row.bookingId || 'N/A'; const reference = + perTruck?.reference || this.extractHandoverDocumentLine(row.notes, 'Handover Reference') || `HND-${String(bookingReference).replace(/[^a-zA-Z0-9_-]+/g, '-')}`; const generatedAtValue = this.extractHandoverDocumentLine(row.notes, 'Generated At'); const generatedAt = generatedAtValue ? new Date(generatedAtValue) : new Date(); const handedOverAt = Number.isNaN(generatedAt.getTime()) ? new Date() : generatedAt; - if (!generatedAtValue) { + // Per-truck renders must not stamp their reference into the shared item notes. + if (!generatedAtValue && !perTruck) { await this.inventoryRepository.update(id, { notes: this.replaceHandoverDocumentNote(row.notes, this.buildHandoverDocumentNote(reference, handedOverAt)), }); @@ -4072,7 +4361,16 @@ export class WarehouseInventoryService { releaseDate: row.releaseDate ? new Date(row.releaseDate) : null, trainSchedule: row.trainSchedule ?? null, lastMileDeliveryAddress: row.lastMileDeliveryAddress ?? null, - customerApproval: this.extractCustomerDeliveryApproval(row.notes), + truckPlate: perTruck?.truckPlate ?? null, + customerApproval: perTruck + ? perTruck.signedAt + ? { + approvedAt: perTruck.signedAt.toISOString(), + signerDisplayName: perTruck.signerName ?? '-', + signatureImageUrl: null, + } + : null + : this.extractCustomerDeliveryApproval(row.notes), }); return { @@ -4131,6 +4429,23 @@ export class WarehouseInventoryService { ); } } + // EDR last-mile: same per-truck rule — each assigned truck is weighed out + // separately, and deliver waits until the last one has left. + const [lm]: Array<{ total: string; left: string }> = await this.dataSource.query( + `SELECT COUNT(*) AS total, + COUNT(*) FILTER (WHERE va.departed_at IS NOT NULL) AS "left" + FROM freight.last_mile_vehicle_assignments va + JOIN freight.last_mile l ON l.id = va.last_mile_id AND l.deleted_at IS NULL + WHERE l.booking_id = $1 AND va.deleted_at IS NULL`, + [item.bookingId], + ); + const lmTotal = Number(lm?.total ?? 0); + const lmLeft = Number(lm?.left ?? 0); + if (lmTotal > 0 && lmLeft < lmTotal) { + throw new BadRequestException( + `Deliver is available only after every assigned EDR truck has left (${lmLeft} of ${lmTotal} so far)`, + ); + } } const receiverName = dto.receiverName.trim(); @@ -4225,6 +4540,12 @@ export class WarehouseInventoryService { } }); + // "Approve delivery" nudge: on Deliver the customer is reminded to sign any + // handover still unsigned (per truck for EDR last-mile). Fire-and-forget. + if (item.bookingId) { + void this.handover.notifyUnsignedForBooking(item.bookingId).catch(() => undefined); + } + return this.findById(id); } @@ -4952,10 +5273,11 @@ export class WarehouseInventoryService { releaseDate: Date | null; trainSchedule: string | null; lastMileDeliveryAddress: string | null; + truckPlate?: string | null; customerApproval: { approvedAt: string; signerDisplayName: string; - signatureImageUrl: string; + signatureImageUrl: string | null; } | null; }): string { const esc = (value: unknown) => @@ -5001,6 +5323,7 @@ export class WarehouseInventoryService { ['Release Order', data.releaseOrderReference], ['Release Date', fmt(data.releaseDate)], ['Last-mile Delivery Address', data.lastMileDeliveryAddress], + ...(data.truckPlate ? [['Delivering Truck Plate', data.truckPlate]] : []), ]; const approval = data.customerApproval; 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 index 90e95369f..9be64aad4 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/delivery/ApproveDeliveryModal.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/delivery/ApproveDeliveryModal.tsx @@ -1,4 +1,15 @@ -import { Alert, Button, Group, Loader, Modal, Stack, Text, TextInput } from "@mantine/core"; +import { + Alert, + Badge, + Button, + Group, + Loader, + Modal, + Stack, + Text, + TextInput, + UnstyledButton, +} from "@mantine/core"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { CheckCircle2, Info } from "lucide-react"; import { useEffect, useState } from "react"; @@ -36,7 +47,10 @@ const downloadBlob = (blob: Blob, filename: string) => { /** * Approve-delivery flow: open the handover document for the customer to review, - * then apply their saved signature (approve) and hand back the signed PDF. + * then sign it with their typed full name (saved signature applied when present). + * Self-haul: one booking-level handover, signed once. EDR last-mile: one + * handover per delivering truck — the customer signs each; when the last one is + * signed the delivery completes automatically. */ export function ApproveDeliveryModal({ bookingId, @@ -48,15 +62,33 @@ export function ApproveDeliveryModal({ const queryClient = useQueryClient(); const [pdfUrl, setPdfUrl] = useState(null); const [signerName, setSignerName] = useState(""); + const [selectedId, setSelectedId] = useState(null); + + const { data: handovers } = useQuery({ + queryKey: ["booking-handovers", bookingId], + queryFn: () => bookingsService.listBookingHandovers(bookingId), + enabled: opened && Boolean(bookingId), + staleTime: 0, + }); + + // Per-truck mode: any EDR last-mile handover means one signature per truck. + const edrMode = (handovers ?? []).some((h) => h.mileType === "EDR_LAST_MILE"); + const unsigned = (handovers ?? []).filter((h) => !h.signedAt); + const selected = + (handovers ?? []).find((h) => h.id === selectedId && !h.signedAt) ?? unsigned[0] ?? null; const { data: docBlob, isLoading, isError, } = useQuery({ - queryKey: ["booking-handover-doc", bookingId], - queryFn: () => bookingsService.downloadBookingHandoverDocument(bookingId), - enabled: opened && Boolean(bookingId), + queryKey: ["booking-handover-doc", bookingId, edrMode ? selected?.id : "booking"], + queryFn: () => + bookingsService.downloadBookingHandoverDocument( + bookingId, + edrMode ? selected?.id : undefined, + ), + enabled: opened && Boolean(bookingId) && (!edrMode || Boolean(selected)), staleTime: 0, }); @@ -70,10 +102,32 @@ export function ApproveDeliveryModal({ return () => URL.revokeObjectURL(url); }, [docBlob]); + const invalidateBooking = () => + Promise.all([ + queryClient.invalidateQueries({ + queryKey: api.bookings.get.queryKey({ id: bookingId }), + }), + queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() }), + queryClient.invalidateQueries({ queryKey: ["companies", "getDashboard"] }), + ]); + + const onSignError = (error: unknown) => { + 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 handoverMutation = useMutation( api.bookings.downloadHandoverDocument.mutationOptions(), ); + // Booking-level (self-haul) approval — signs every handover at once. const approve = useMutation({ ...api.bookings.approveDelivery.mutationOptions(), onSuccess: async (result) => { @@ -87,30 +141,35 @@ export function ApproveDeliveryModal({ 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"] }), - ]); + await invalidateBooking(); 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"); - } - }, + onError: onSignError, }); - const busy = approve.isPending || handoverMutation.isPending; + // Per-truck (EDR last-mile) signature — one handover at a time. + const signOne = useMutation({ + mutationFn: ({ handoverId, name }: { handoverId: string; name: string }) => + bookingsService.signHandover(handoverId, name), + onSuccess: async (result) => { + await queryClient.invalidateQueries({ + queryKey: ["booking-handovers", bookingId], + }); + setSelectedId(null); + if (result.allSigned) { + toast.success("All handovers signed — delivery confirmed"); + await invalidateBooking(); + onApproved?.(); + onClose(); + } else { + toast.success("Handover signed — please sign the remaining truck(s)"); + } + }, + onError: onSignError, + }); + + const busy = approve.isPending || handoverMutation.isPending || signOne.isPending; return ( }> - Review the handover document below, then type your full name to sign and - confirm you received the goods. Your saved signature is applied automatically - if you have one. + {edrMode + ? "Your goods were delivered by EDR truck(s). Review and sign the handover for each truck to confirm you received the goods — delivery completes once every truck is signed." + : "Review the handover document below, then type your full name to sign and confirm you received the goods. Your saved signature is applied automatically if you have one."} + {edrMode && (handovers?.length ?? 0) > 0 && ( + + {handovers!.map((h) => ( + !h.signedAt && setSelectedId(h.id)} + style={{ + padding: "8px 12px", + borderRadius: 8, + border: + selected?.id === h.id + ? "1px solid var(--mantine-color-edr-green-6)" + : "1px solid var(--mantine-color-gray-3)", + cursor: h.signedAt ? "default" : "pointer", + }} + > + + + {h.truckPlate ? `Truck ${h.truckPlate}` : "Booking handover"} —{" "} + {h.reference} + + + {h.signedAt ? `Signed${h.signerName ? ` — ${h.signerName}` : ""}` : "Awaiting signature"} + + + + ))} + + )} + {isLoading ? ( @@ -170,10 +259,21 @@ export function ApproveDeliveryModal({ color="edr-green" leftSection={} loading={busy} - disabled={isLoading || isError || !signerName.trim()} - onClick={() => approve.mutate({ id: bookingId, signerName: signerName.trim() })} + disabled={ + isLoading || + isError || + !signerName.trim() || + (edrMode && !selected) + } + onClick={() => + edrMode && selected + ? signOne.mutate({ handoverId: selected.id, name: signerName.trim() }) + : approve.mutate({ id: bookingId, signerName: signerName.trim() }) + } > - Approve & sign delivery + {edrMode && selected?.truckPlate + ? `Sign for truck ${selected.truckPlate}` + : "Approve & sign delivery"} diff --git a/apps/edr-freight-web/portal/src/services/bookings.service.ts b/apps/edr-freight-web/portal/src/services/bookings.service.ts index 51e87c024..fbb84e1c0 100644 --- a/apps/edr-freight-web/portal/src/services/bookings.service.ts +++ b/apps/edr-freight-web/portal/src/services/bookings.service.ts @@ -137,6 +137,26 @@ export interface ApproveDeliveryResponse { signerDisplayName: string; } +/** One import handover record — booking-level or per truck (EDR last-mile). */ +export interface BookingHandoverRecord { + id: string; + reference: string; + truckPlate: string | null; + mileType: "SELF_HAUL" | "EDR_LAST_MILE"; + generatedAt: string; + signedAt: string | null; + signerName: string | null; + deliveredAt: string | null; +} + +export interface SignHandoverResponse { + handoverId: string; + bookingId: string; + signedAt: string | null; + signerDisplayName: string; + allSigned: boolean; +} + export interface CustomerTruckAssignmentPayload { truckPlateNumber: string; driverName: string; @@ -206,13 +226,36 @@ export const bookingsService = { ); return data; }, - downloadBookingHandoverDocument: async (bookingId: string): Promise => { + downloadBookingHandoverDocument: async ( + bookingId: string, + handoverId?: string, + ): Promise => { const { data } = await client.get( `/api/warehouse-inventory/bookings/${bookingId}/handover-document`, - { responseType: "blob" }, + { responseType: "blob", params: handoverId ? { handoverId } : undefined }, ); return data; }, + + listBookingHandovers: async ( + bookingId: string, + ): Promise => { + const { data } = await client.get( + `/api/warehouse-inventory/bookings/${bookingId}/handovers`, + ); + return data.data ?? data; + }, + + signHandover: async ( + handoverId: string, + signerName: string, + ): Promise => { + const { data } = await client.post( + `/api/warehouse-inventory/handovers/${handoverId}/sign`, + { signerName }, + ); + return data.data ?? data; + }, downloadBookingGrnDocument: async (bookingId: string): Promise => { const { data } = await client.get( `/api/warehouse-inventory/bookings/${bookingId}/grn-document`, From 7566c57ed997c82df78e62ce852d8b2c33c21cb3 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Tue, 21 Jul 2026 11:54:35 +0000 Subject: [PATCH 3/3] feat(warehouses): per-truck EDR handover signing with auto-delivery on completion EDR truck gate-out now generates a per-truck handover (new edr_assignment_id link) and notifies the customer to sign from the portal. New sign endpoint delivers the signed truck's containers; last signature auto-delivers remaining inventory, frees trucks and completes the booking via import.handover.completed. Adds gate-in truck-arrival notification and per-truck handover PDFs. --- .../warehouses/warehouse-inventory.service.ts | 49 +++++++++++++------ 1 file changed, 34 insertions(+), 15 deletions(-) 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 a144d21bf..7e03eb6a2 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 @@ -4429,22 +4429,41 @@ export class WarehouseInventoryService { ); } } - // EDR last-mile: same per-truck rule — each assigned truck is weighed out - // separately, and deliver waits until the last one has left. - const [lm]: Array<{ total: string; left: string }> = await this.dataSource.query( - `SELECT COUNT(*) AS total, - COUNT(*) FILTER (WHERE va.departed_at IS NOT NULL) AS "left" - FROM freight.last_mile_vehicle_assignments va - JOIN freight.last_mile l ON l.id = va.last_mile_id AND l.deleted_at IS NULL - WHERE l.booking_id = $1 AND va.deleted_at IS NULL`, - [item.bookingId], - ); - const lmTotal = Number(lm?.total ?? 0); - const lmLeft = Number(lm?.left ?? 0); - if (lmTotal > 0 && lmLeft < lmTotal) { - throw new BadRequestException( - `Deliver is available only after every assigned EDR truck has left (${lmLeft} of ${lmTotal} so far)`, + // EDR last-mile delivers per truck: a container item only needs the truck + // CARRYING IT to have left; bulk (no container) waits for every truck. + if (item.containerId) { + const [own]: Array<{ pending: string }> = await this.dataSource.query( + `SELECT COUNT(*) FILTER (WHERE va.departed_at IS NULL) AS pending + FROM freight.last_mile_vehicle_assignments va + JOIN freight.last_mile l ON l.id = va.last_mile_id AND l.deleted_at IS NULL + LEFT JOIN freight.last_mile_vehicle_containers vc + ON vc.assignment_id = va.id AND vc.deleted_at IS NULL + JOIN freight.containers c ON c.id = $2 AND c.deleted_at IS NULL + WHERE l.booking_id = $1 AND va.deleted_at IS NULL + AND COALESCE(vc.container_number, va.container_number) = c.container_number`, + [item.bookingId, item.containerId], ); + if (Number(own?.pending ?? 0) > 0) { + throw new BadRequestException( + 'Deliver is available only after the EDR truck carrying this container has left', + ); + } + } else { + const [lm]: Array<{ total: string; left: string }> = await this.dataSource.query( + `SELECT COUNT(*) AS total, + COUNT(*) FILTER (WHERE va.departed_at IS NOT NULL) AS "left" + FROM freight.last_mile_vehicle_assignments va + JOIN freight.last_mile l ON l.id = va.last_mile_id AND l.deleted_at IS NULL + WHERE l.booking_id = $1 AND va.deleted_at IS NULL`, + [item.bookingId], + ); + const lmTotal = Number(lm?.total ?? 0); + const lmLeft = Number(lm?.left ?? 0); + if (lmTotal > 0 && lmLeft < lmTotal) { + throw new BadRequestException( + `Deliver is available only after every assigned EDR truck has left (${lmLeft} of ${lmTotal} so far)`, + ); + } } }