From 7ab5e9edd5125ecbb6f91332a85ead656f9bec4b Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Thu, 9 Jul 2026 15:06:24 +0000 Subject: [PATCH] feat(warehouses): handover-driven approve delivery, weigh-skip, 5-min sign reminders Approve delivery now tracks the handover lifecycle exactly: the button appears (detail page + portal dashboard) the moment a handover is generated and disappears when the customer signs. The bookings list attaches the handoverAwaitingSignature flag via one batched query per page; status heuristics (COMPLETED / TRUCK_ASSIGNED+arrived) are gone. - generate the arrival handover for ANY self-haul truck: portal-assigned OR walk-in registered at the gate (isSelfHaulBooking: assigned_at set, or no EDR last-mile leg). Same rule now guards the exit paper. - remind every 5 minutes (in-app + SMS + email) until the handover is signed (@Cron in HandoverService; one reminder per booking per tick) - Truck Leaving no longer opens blank: the import queue mapper now carries inv.notes, so the saved arrival renders read-only with only gate-out time and gross weight editable - container bookings get "Weigh truck? Yes/No": No skips tare/gross and the container weight match (weighingSkipped on ReleaseOrderDto, decision made at arrival sticks for the exit via the Weighing: SKIPPED note). Bulk always weighs, unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/modules/bookings/bookings.service.ts | 28 +++++++- .../warehouses/dto/release-order.dto.ts | 11 ++- .../modules/warehouses/handover.service.ts | 32 +++++++++ .../warehouses/warehouse-inventory.service.ts | 71 +++++++++++++------ .../warehouses/ReceiveInventoryModal.tsx | 3 + .../warehouses/ReleaseOrderModal.tsx | 55 ++++++++++---- .../backoffice/src/types/warehouse.ts | 2 + .../MyPortalPage/components/BookingRow.tsx | 4 +- .../BookingDetailPage/ReadonlyBookingView.tsx | 10 +-- 9 files changed, 174 insertions(+), 42 deletions(-) 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 48a29988a..c804b2579 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -1125,6 +1125,30 @@ 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. + */ + private async attachHandoverFlags(bookings: Booking[]): Promise { + const ids = bookings.map((b) => b.id); + if (!ids.length) return; + const rows: Array<{ bookingId: string }> = await this.dataSource.query( + `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'`, + [ids], + ); + const pending = new Set(rows.map((r) => r.bookingId)); + for (const b of bookings) { + (b as Booking & { handoverAwaitingSignature?: boolean }).handoverAwaitingSignature = + pending.has(b.id); + } + } + async findAll( filter: FilterBookingDto, forceCompanyId?: string, @@ -1135,7 +1159,7 @@ export class BookingsService { const statusFilter = this.parseStatusFilter(filter); const schedulingStatusFilter = this.parseSchedulingStatusFilter(filter); - return this.bookingsRepository.findAllPaginated({ + const result = await this.bookingsRepository.findAllPaginated({ page, pageSize, ...statusFilter, @@ -1162,6 +1186,8 @@ export class BookingsService { sortBy: filter.sortBy, sortOrder: filter.sortOrder, }); + await this.attachHandoverFlags(result.items ?? []); + return result; } /** Booking statuses at which a customer can pay (mirrors booking-payment.service). */ diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/release-order.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/release-order.dto.ts index 681b30284..17b75b424 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/release-order.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/release-order.dto.ts @@ -1,5 +1,5 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; -import { IsDateString, IsNumber, IsOptional, IsString, Min } from 'class-validator'; +import { IsBoolean, IsDateString, IsNumber, IsOptional, IsString, Min } from 'class-validator'; /** Records a DO / release order being sent to the customer for import pickup. */ export class ReleaseOrderDto { @@ -90,4 +90,13 @@ export class ReleaseOrderDto { @IsOptional() @IsDateString() gateOutTime?: string; + + @ApiPropertyOptional({ + description: + 'Container bookings only: the operator chose not to weigh this truck. ' + + 'Tare/gross become optional and the container weight match is skipped. Bulk always weighs.', + }) + @IsOptional() + @IsBoolean() + weighingSkipped?: boolean; } diff --git a/apps/edr-freight-api/src/modules/warehouses/handover.service.ts b/apps/edr-freight-api/src/modules/warehouses/handover.service.ts index 5df15910e..48ab3ac48 100644 --- a/apps/edr-freight-api/src/modules/warehouses/handover.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/handover.service.ts @@ -1,4 +1,5 @@ import { Injectable, Logger } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; import { NotificationAudience, NotificationType } from '@edr/types'; import { DataSource, EntityManager, IsNull } from 'typeorm'; @@ -150,6 +151,37 @@ export class HandoverService { ); } + /** + * Reminder loop: until a self-haul handover is signed, re-send the sign + * notification (in-app + SMS + email) every 5 minutes. One reminder per + * booking per tick, newest unsigned handover's reference. Stops the moment + * signForBooking() stamps signed_at. + * + * NB: runs in every API instance — keep a single instance in dev or the + * customer is reminded once per instance per tick. + */ + @Cron(CronExpression.EVERY_5_MINUTES, { name: 'handover-sign-reminder' }) + async remindUnsignedHandovers(): Promise { + try { + const rows: Array<{ bookingId: string; reference: string }> = await this.dataSource.query( + `SELECT DISTINCT ON (booking_id) + booking_id AS "bookingId", reference + FROM freight.booking_handovers + WHERE signed_at IS NULL + AND deleted_at IS NULL + AND mile_type = 'SELF_HAUL' + ORDER BY booking_id, generated_at DESC`, + ); + if (!rows.length) return; + this.logger.log(`Handover sign reminder: ${rows.length} booking(s) still unsigned`); + for (const row of rows) { + await this.notifySignNeeded(row.bookingId, row.reference); + } + } catch (err) { + this.logger.warn(`Handover sign reminder tick failed: ${(err as Error).message}`); + } + } + /** Sign all unsigned handovers on a booking (self-haul: before the truck leaves). */ async signForBooking(bookingId: string, userId?: string | null): Promise { await this.dataSource 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 06e84d455..2b98856d0 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 @@ -2357,6 +2357,27 @@ export class WarehouseInventoryService { }); } + /** + * Self-haul = the customer's own truck collects the goods: either a truck + * assigned via the portal (customer_truck_assigned_at), or a walk-in truck + * registered at the gate on a booking with no EDR last-mile leg. EDR + * last-mile bookings are never self-haul. + */ + private async isSelfHaulBooking(bookingId: string, manager?: EntityManager): Promise { + const runner = manager ?? this.dataSource; + const [row]: Array<{ ok: number }> = await runner.query( + `SELECT 1 AS ok + FROM freight.bookings b + LEFT JOIN freight.service_types st ON st.id = b.service_type_id + WHERE b.id = $1 AND b.deleted_at IS NULL + AND (b.customer_truck_assigned_at IS NOT NULL + OR (NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NULL + AND COALESCE(st.includes_last_mile, false) = false))`, + [bookingId], + ); + return Boolean(row); + } + /** Record a DO / release order sent to the customer. Item stays READY_FOR_PICKUP. */ async release(id: string, dto: ReleaseOrderDto): Promise { const item = await this.findById(id); @@ -2366,19 +2387,17 @@ export class WarehouseInventoryService { ); } - const isTruckLeaving = dto.grossWeight !== undefined && Boolean(dto.gateOutTime); + // Leaving = gate-out captured, with either a weighed gross or an explicit + // container weighing skip (bulk always weighs). + const isTruckLeaving = + Boolean(dto.gateOutTime) && (dto.grossWeight !== undefined || dto.weighingSkipped === true); if (isTruckLeaving) { await this.invoices.assertClearanceAllowed(id); if (item.bookingId) { - const [truckInfo]: Array<{ customerTruckAssignedAt: string | null }> = - await this.dataSource.query( - `SELECT customer_truck_assigned_at AS "customerTruckAssignedAt" - FROM freight.bookings - WHERE id = $1 AND deleted_at IS NULL`, - [item.bookingId], - ); - const usesCustomerTruck = Boolean(truckInfo?.customerTruckAssignedAt); + // Self-haul = customer collects: a truck assigned via the portal, OR a + // walk-in truck registered at the gate on a booking with no EDR last mile. + const usesCustomerTruck = await this.isSelfHaulBooking(item.bookingId); // Self-haul: the handover must be signed before the exit paper is issued. // Prefer the structured handover record; fall back to the legacy note. const handoverSigned = @@ -2392,7 +2411,8 @@ export class WarehouseInventoryService { // Authoritative weight match: the truck's net (gross − tare) must equal the // total VGM cargo weight of the containers selected as loaded on it. - if (dto.containerNumber && dto.grossWeight != null && dto.tareWeight != null) { + // Skipped when the operator chose not to weigh (containers only). + if (!dto.weighingSkipped && dto.containerNumber && dto.grossWeight != null && dto.tareWeight != null) { const selected = dto.containerNumber .split(/[,;\n]+/) .map((n) => n.trim()) @@ -2462,13 +2482,10 @@ export class WarehouseInventoryService { [item.bookingId], ); // Self-haul: generate the per-booking handover on first truck arrival - // (idempotent). It must be signed before the truck leaves. - const [selfHaul]: Array<{ ok: number }> = await manager.query( - `SELECT 1 AS ok FROM freight.bookings - WHERE id = $1 AND customer_truck_assigned_at IS NOT NULL AND deleted_at IS NULL`, - [item.bookingId], - ); - if (selfHaul) { + // (idempotent) and notify the customer to sign it. Covers BOTH portal- + // assigned trucks and walk-in trucks registered manually at the gate + // (no portal assignment, no EDR last mile). Must be signed before leaving. + if (await this.isSelfHaulBooking(item.bookingId, manager)) { await this.handover.ensureForArrivedTruck(item.bookingId, {}, manager); } } @@ -4453,14 +4470,17 @@ export class WarehouseInventoryService { if (!dto.driverName?.trim()) { throw new BadRequestException('Driver name is required for exit inspection'); } - if (dto.tareWeight === undefined) { + // Container bookings may skip the weighbridge entirely (weighingSkipped); + // bulk always weighs. + const weighingSkipped = dto.weighingSkipped === true; + if (dto.tareWeight === undefined && !weighingSkipped) { throw new BadRequestException('Tare weight is required for truck arrival'); } - const tareWeight = Number(dto.tareWeight); + const tareWeight = dto.tareWeight === undefined ? null : Number(dto.tareWeight); const grossWeight = dto.grossWeight === undefined ? null : Number(dto.grossWeight); const computedNetWeight = - grossWeight == null ? null : Number((grossWeight - tareWeight).toFixed(3)); + grossWeight == null || tareWeight == null ? null : Number((grossWeight - tareWeight).toFixed(3)); const submittedNetWeight = dto.netWeight === undefined || computedNetWeight == null ? computedNetWeight : Number(dto.netWeight); @@ -4472,7 +4492,11 @@ export class WarehouseInventoryService { throw new BadRequestException('Weight mismatch: net weight must equal gross weight minus tare weight.'); } } - if ((dto.grossWeight !== undefined || dto.gateOutTime || dto.netWeight !== undefined) && grossWeight == null) { + if ( + !weighingSkipped && + (dto.grossWeight !== undefined || dto.gateOutTime || dto.netWeight !== undefined) && + grossWeight == null + ) { throw new BadRequestException('Gross weight is required for truck exit'); } @@ -4488,7 +4512,8 @@ export class WarehouseInventoryService { dto.truckType?.trim() ? `Truck Type: ${dto.truckType.trim()}` : null, dto.containerNumber?.trim() ? `Container Number: ${dto.containerNumber.trim()}` : null, dto.gateInTime ? `Gate In Time: ${dto.gateInTime}` : null, - `Tare Weight: ${tareWeight} t`, + weighingSkipped ? 'Weighing: SKIPPED' : null, + tareWeight == null ? null : `Tare Weight: ${tareWeight} t`, grossWeight == null ? null : `Gross Weight: ${grossWeight} t`, computedNetWeight == null ? null : `Net Weight: ${computedNetWeight} t`, dto.gateOutTime ? `Gate Out Time: ${dto.gateOutTime}` : null, @@ -4512,6 +4537,8 @@ export class WarehouseInventoryService { containerNumber: this.extractExitInspectionLine(inspection, 'Container Number') || dto.containerNumber, gateInTime: this.extractExitInspectionLine(inspection, 'Gate In Time') || dto.gateInTime, tareWeight: this.extractExitInspectionNumber(inspection, 'Tare Weight') ?? dto.tareWeight, + // The weigh/skip decision is made at arrival and sticks for the exit. + weighingSkipped: dto.weighingSkipped || /^Weighing:\s*SKIPPED/im.test(inspection) || undefined, }; } 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 dd04cd3ff..d2540751e 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx @@ -2245,6 +2245,9 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { handoverDocumentReference: row.handoverDocumentReference, handoverDocumentDate: row.handoverDocumentDate, deliveredAt: row.deliveredAt, + // Carries the saved [Exit Inspection] block so Truck Leaving opens with the + // arrival details (plate, driver, tare, gate-in) read-only instead of blank. + notes: row.notes, booking: row.bookingId ? { id: row.bookingId, diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx index 5cca0d3b2..d986e2aba 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react'; -import { Alert, Button, Group, Modal, MultiSelect, NumberInput, Select, SimpleGrid, Stack, Text, TextInput } from '@mantine/core'; +import { Alert, Button, Group, Modal, MultiSelect, NumberInput, SegmentedControl, Select, SimpleGrid, Stack, Text, TextInput } from '@mantine/core'; import { Info, Scale } from 'lucide-react'; import { useMutation, useQuery } from '@tanstack/react-query'; @@ -105,6 +105,7 @@ const parseInspectionNote = (notes: string | null | undefined) => { grossWeight: lineNumber(note, 'Gross Weight'), netWeight: lineNumber(note, 'Net Weight'), gateOutTime: toLocalDateTimeInput(lineValue(note, 'Gate Out Time')), + weighingSkipped: /^Weighing:\s*SKIPPED/im.test(note ?? ''), }; }; @@ -135,6 +136,8 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea const [containerNumbers, setContainerNumbers] = useState(['']); const [gateInTime, setGateInTime] = useState(''); const [tareWeight, setTareWeight] = useState(''); + // Containers may skip the weighbridge (decided at arrival, sticks for exit). Bulk always weighs. + const [weighTruck, setWeighTruck] = useState<'yes' | 'no'>('yes'); const [grossWeight, setGrossWeight] = useState(''); const [netWeight, setNetWeight] = useState(''); const [gateOutTime, setGateOutTime] = useState(''); @@ -158,6 +161,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea setContainerNumbers(initialContainerNumbers(item, inspection.containerNumber || prefillContainerNumber || assignedContainerNumber)); setGateInTime(inspection.gateInTime); setTareWeight(inspection.tareWeight); + setWeighTruck(inspection.weighingSkipped ? 'no' : 'yes'); setGrossWeight(inspection.grossWeight); setNetWeight(item?.weight == null ? inspection.netWeight : Number(item.weight)); setGateOutTime(inspection.gateOutTime); @@ -165,7 +169,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea }, [opened, item, truckPrefill]); const savedInspection = parseInspectionNote(item?.notes); - const isExitStep = savedInspection.tareWeight !== ''; + const isExitStep = savedInspection.tareWeight !== '' || savedInspection.weighingSkipped; const isEntranceLocked = isExitStep; const isCustomerAssignedTruck = Boolean(item?.booking?.customerTruckAssignedAt); const hasLastMileTruckPrefill = Boolean(truckPrefill?.truckPlateNumber || truckPrefill?.trailerPlateNumber); @@ -220,7 +224,9 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea .reduce((sum, n) => sum + (containerWeightByNumber.get(n.toUpperCase()) ?? 0), 0) .toFixed(3), ); - const useContainerNet = hasContainerWeights && selectedContainerNumbers.length > 0; + // Skip is only offered for container bookings; bulk always weighs. + const skipWeighing = hasContainerWeights && weighTruck === 'no'; + const useContainerNet = hasContainerWeights && selectedContainerNumbers.length > 0 && !skipWeighing; const systemNetWeight = useContainerNet ? selectedCargoWeight @@ -230,6 +236,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea const computedNetWeight = tareWeight !== '' && grossWeight !== '' ? Number((Number(grossWeight) - Number(tareWeight)).toFixed(3)) : null; const weightMismatch = + !skipWeighing && computedNetWeight != null && systemNetWeight !== '' && Math.abs(Number(systemNetWeight) - computedNetWeight) > 0.001; const title = isExitStep ? 'Customer truck leaving and exit weighing' : 'Customer truck arrival weighing'; @@ -239,19 +246,25 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea toast({ variant: 'destructive', title: 'Truck plate and driver name are required' }); return; } - if (!gateInTime || tareWeight === '') { - toast({ variant: 'destructive', title: 'Gate in time and tare weight are required' }); + if (!gateInTime || (!skipWeighing && tareWeight === '')) { + toast({ + variant: 'destructive', + title: skipWeighing ? 'Gate in time is required' : 'Gate in time and tare weight are required', + }); return; } - if (isExitStep && (!gateOutTime || grossWeight === '')) { - toast({ variant: 'destructive', title: 'Gate out time and gross weight are required' }); + if (isExitStep && (!gateOutTime || (!skipWeighing && grossWeight === ''))) { + toast({ + variant: 'destructive', + title: skipWeighing ? 'Gate out time is required' : 'Gate out time and gross weight are required', + }); return; } if (isExitStep && hasContainerWeights && selectedContainerNumbers.length === 0) { toast({ variant: 'destructive', title: 'Select the containers loaded on this truck' }); return; } - if (isExitStep && systemNetWeight === '') { + if (isExitStep && !skipWeighing && systemNetWeight === '') { toast({ variant: 'destructive', title: 'System recorded net weight is missing' }); return; } @@ -279,9 +292,10 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea truckType: truckType.trim() || undefined, containerNumber: containerNumbers.map((number) => number.trim()).filter(Boolean).join(', ') || undefined, gateInTime: toIsoDateTime(gateInTime), - tareWeight: Number(tareWeight), - grossWeight: grossWeight === '' ? undefined : Number(grossWeight), - netWeight: isExitStep && systemNetWeight !== '' ? Number(systemNetWeight) : undefined, + weighingSkipped: skipWeighing || undefined, + tareWeight: skipWeighing ? undefined : Number(tareWeight), + grossWeight: skipWeighing || grossWeight === '' ? undefined : Number(grossWeight), + netWeight: !skipWeighing && isExitStep && systemNetWeight !== '' ? Number(systemNetWeight) : undefined, gateOutTime: isExitStep ? toIsoDateTime(gateOutTime) : undefined, }, }); @@ -421,9 +435,24 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea )} setGateInTime(e.currentTarget.value)} readOnly={isEntranceLocked} /> + {hasContainerWeights && ( + + Weigh truck? + setWeighTruck((v as 'yes' | 'no') ?? 'yes')} + disabled={isEntranceLocked} + /> + {skipWeighing && ( + Weighbridge skipped — container passes without tare/gross. + )} + + )} - setTareWeight(v === '' ? '' : Number(v))} readOnly={isEntranceLocked} /> - setGrossWeight(v === '' ? '' : Number(v))} disabled={!isExitStep} /> + setTareWeight(v === '' ? '' : Number(v))} readOnly={isEntranceLocked} disabled={skipWeighing} /> + setGrossWeight(v === '' ? '' : Number(v))} disabled={!isExitStep || skipWeighing} />