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 b61d0078d..f366faa96 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 @@ -328,18 +328,21 @@ 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', - ); - } + // Loading a truck at the warehouse implies it is physically present, so a + // truck that is still only assigned (not yet marked arrived) is auto-arrived + // here rather than blocking the operator — the real gross is weighed on + // departure anyway. + const needsArrival = !assignment.arrivedAt; 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'); } + // Capacity is size-based: a truck carries at most 2 containers, and a 40ft + // container fills the truck (max 1) — mirror the addTruck/updateTruck rule. + if (requested.length > 2) { + throw new BadRequestException('A truck carries at most 2 containers'); + } const bookingNumbers = await this.bookingContainerNumbers(bookingId); for (const n of requested) { if (!bookingNumbers.includes(n)) { @@ -352,6 +355,12 @@ export class CustomerTruckService { throw new ConflictException(`Container ${n} is already loaded onto another truck`); } } + const sizes = await this.containerSizes(bookingId, requested); + if (sizes.some((s) => s.includes('40')) && requested.length > 1) { + throw new BadRequestException( + 'A 40ft container fills the truck — load only 1 container onto this truck', + ); + } const grossTons = await this.vgmTonsForContainers(bookingId, requested); await this.dataSource.transaction(async (manager) => { @@ -371,9 +380,20 @@ export class CustomerTruckService { ); // Provisional gross (tonnes) from the loaded containers' VGM — overridden // by the weighed gross on departure. (Column is *_kg but holds tonnes.) + // Auto-stamp arrival if the truck was still only assigned. await manager.getRepository(CustomerTruckAssignment).update(assignmentId, { grossWeightKg: grossTons, + ...(needsArrival ? { arrivedAt: new Date() } : {}), }); + if (needsArrival) { + await manager.query( + `UPDATE freight.bookings + SET customer_truck_arrived_at = COALESCE(customer_truck_arrived_at, NOW()), + updated_at = NOW() + WHERE id = $1`, + [bookingId], + ); + } }); return this.listTrucks(bookingId); } 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 index 11c80f687..1386e5bd4 100644 --- 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 @@ -1,9 +1,11 @@ -import { ArrayMinSize, ArrayUnique, IsArray, Matches } from 'class-validator'; +import { ArrayMaxSize, 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) + // A truck carries at most 2 containers (two 20ft, or one 40ft). + @ArrayMaxSize(2) @ArrayUnique() @Matches(/^[A-Z]{4}\d{7}$/, { each: true, diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts index abee4d53d..fa7ee59ec 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts @@ -67,6 +67,12 @@ export class LastMileController { return this.lastMileService.findById(id); } + @Get('booking/:bookingId/arrival-trucks') + @ApiOperation({ summary: "Assigned EDR last-mile trucks for a booking (arrival/exit weighing prefill)" }) + arrivalTrucks(@Param('bookingId', ParseUUIDPipe) bookingId: string) { + return this.lastMileService.arrivalTrucksForBooking(bookingId); + } + @Post('accept/:reference') @BookingStaff(FREIGHT_PERMS.lastMile.accept) @ApiOperation({ summary: 'Accept a paid booking and create a last-mile leg' }) diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index 999137e6e..1ee31c63a 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts @@ -256,7 +256,95 @@ export class LastMileService { return this.findById(id); } + /** + * The EDR last-mile trucks assigned to a booking, joined with driver details, + * shaped for the arrival/exit weighing prefill (plate, driver, type, container). + * Returns [] when the booking has no last-mile truck assigned. Lets the + * warehouse arrival/load modals surface an assigned EDR truck the same way the + * self-haul customer trucks are surfaced. + */ + async arrivalTrucksForBooking(bookingId: string): Promise< + Array<{ + vehicleId: string; + truckPlateNumber: string | null; + trailerPlateNumber: string | null; + driverName: string | null; + driverLicense: string | null; + driverPhone: string | null; + truckType: string | null; + containerNumber: string | null; + }> + > { + const [lm] = await this.lastMileRepository.findAll({ + where: { bookingId }, + relations: { vehicle: true, vehicleAssignments: { vehicle: true } }, + take: 1, + }); + if (!lm) return []; + + // Prefer the multi-truck junction; fall back to the legacy single vehicle. + const sources = lm.vehicleAssignments?.length + ? lm.vehicleAssignments.map((va) => ({ + vehicle: va.vehicle, + containerNumber: va.containerNumber ?? null, + })) + : lm.vehicle + ? [{ vehicle: lm.vehicle, containerNumber: null }] + : []; + + const out: Array<{ + vehicleId: string; + truckPlateNumber: string | null; + trailerPlateNumber: string | null; + driverName: string | null; + driverLicense: string | null; + driverPhone: string | null; + truckType: string | null; + containerNumber: string | null; + }> = []; + for (const { vehicle, containerNumber } of sources) { + if (!vehicle) continue; + let driverName = vehicle.assignedDriverName ?? null; + let driverLicense: string | null = null; + let driverPhone: string | null = null; + if (vehicle.assignedDriverId) { + try { + const d = await this.driversService.findById(vehicle.assignedDriverId); + driverName = driverName || `${d.firstName ?? ''} ${d.lastName ?? ''}`.trim() || null; + driverLicense = d.licenseNumber ?? null; + driverPhone = d.phoneNumber ?? null; + } catch { + /* driver lookup is best-effort — plate still prefills */ + } + } + out.push({ + vehicleId: vehicle.id, + truckPlateNumber: vehicle.powerPlateNo || vehicle.plateNumber || null, + trailerPlateNumber: vehicle.trailerPlateNo || null, + driverName, + driverLicense, + driverPhone, + truckType: vehicle.vehicleType || null, + containerNumber, + }); + } + return out; + } + async create(dto: CreateLastMileDto): Promise { + // Idempotent: a booking gets exactly one last-mile record. Extra trucks live + // inside that record (vehicleAssignments), never as additional rows — so if a + // last-mile already exists for this booking, return it instead of inserting a + // duplicate delivery row (which is what made the same booking appear twice in + // the Assign-Mile list). + const [existing] = await this.lastMileRepository.findAll({ + where: { bookingId: dto.bookingId }, + take: 1, + }); + if (existing) { + return existing; + } + const record = await this.lastMileRepository.create({ bookingId: dto.bookingId, status: dto.status ?? 'READY_TO_TRANSIT', @@ -318,6 +406,17 @@ export class LastMileService { } } + // A last-mile truck must have a driver before it can be assigned (same rule + // as setVehicles) — block driverless single-vehicle (re)assignment too. + if (dto.vehicleId && dto.vehicleId !== existing.vehicleId) { + const vehicle = await this.vehiclesService.findById(dto.vehicleId); + if (!vehicle?.assignedDriverId) { + throw new BadRequestException( + `Truck ${vehicle?.plateNumber ?? dto.vehicleId} has no assigned driver — assign a driver to the truck before adding it to this last-mile delivery`, + ); + } + } + const dtoAny = dto as any; const updated = await this.lastMileRepository.update(id, { ...(dto.bookingId !== undefined ? { bookingId: dto.bookingId } : {}), @@ -478,6 +577,17 @@ export class LastMileService { )]; const added = desired.filter((v) => !junctionSet.has(v)); const removed = releaseIds.filter((v) => !desiredSet.has(v)); + + // A last-mile truck must have a driver before it can be assigned — a delivery + // can't run driverless, and the arrival/exit weighing needs the driver. + for (const vehicleId of added) { + const vehicle = await this.vehiclesService.findById(vehicleId); + if (!vehicle?.assignedDriverId) { + throw new BadRequestException( + `Truck ${vehicle?.plateNumber ?? vehicleId} has no assigned driver — assign a driver to the truck before adding it to this last-mile delivery`, + ); + } + } // Vehicles that stay but whose container number changed. const changed = current.filter( (a) => 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 1c1be66ec..9a42bc895 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 @@ -2830,6 +2830,7 @@ export class WarehouseInventoryService { Array<{ containerNumber: string; goods: string | null; + containerSize: string | null; stage: 'PENDING' | 'RECEIVED' | 'GRN' | 'ASSIGNED' | 'LOADED' | 'LEFT' | 'DELIVERED'; grnNumber: string | null; truckAssignmentId: string | null; @@ -2846,6 +2847,7 @@ export class WarehouseInventoryService { const rows: Array<{ containerNumber: string; goods: string | null; + containerSize: string | null; received: boolean; grnNumber: string | null; truckAssignmentId: string | null; @@ -2860,6 +2862,7 @@ export class WarehouseInventoryService { }> = await this.dataSource.query( `SELECT bcu.container_number AS "containerNumber", COALESCE(ct.cargo_type_name, b.cargo_free_text) AS goods, + bc.container_size AS "containerSize", bcu.received_to_port AS received, bcu.grn_number AS "grnNumber", ctc.assignment_id AS "truckAssignmentId", @@ -2896,6 +2899,7 @@ export class WarehouseInventoryService { return rows.map((r) => ({ containerNumber: r.containerNumber, goods: r.goods, + containerSize: r.containerSize, // 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. 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 57be67d68..0a2dd5b7f 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ContainerItemsModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ContainerItemsModal.tsx @@ -80,14 +80,17 @@ 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. + // Any assigned, not-yet-departed truck can be loaded here — loading a truck at + // the warehouse auto-marks it arrived on the backend, so assigned-but-not-yet- + // arrived trucks are selectable too (labelled "assigned" until they arrive). const truckOptions = trucks - .filter( - (t) => - Boolean((t as { arrivedAt?: string }).arrivedAt) && - !(t as { departedAt?: string }).departedAt, - ) - .map((t) => ({ value: t.id, label: `${t.plateNumber} · ${t.driverName}` })); + .filter((t) => !(t as { departedAt?: string }).departedAt) + .map((t) => ({ + value: t.id, + label: `${t.plateNumber} · ${t.driverName}${ + (t as { arrivedAt?: string }).arrivedAt ? '' : ' (assigned)' + }`, + })); const loadMutation = useMutation({ mutationFn: () => warehouseService.loadTruck(bookingId as string, truckId as string, selected), @@ -125,7 +128,28 @@ export function ContainerItemsModal({ opened, onClose, bookingId, bookingReferen } }; - const toggle = (n: string) => setSelected((s) => (s.includes(n) ? s.filter((x) => x !== n) : [...s, n])); + const is40 = (n: string) => + (items.find((i) => i.containerNumber === n)?.containerSize ?? '').includes('40'); + + // A truck carries at most 2 containers, and a 40ft fills the truck (max 1). + const toggle = (n: string) => + setSelected((s) => { + if (s.includes(n)) return s.filter((x) => x !== n); + const next = [...s, n]; + if (next.length > 2) { + toast({ variant: 'destructive', title: 'A truck carries at most 2 containers' }); + return s; + } + if (next.length > 1 && next.some(is40)) { + toast({ + variant: 'destructive', + title: 'A 40ft container fills the truck', + description: 'Load only one 40ft container per truck.', + }); + return s; + } + return next; + }); return ( Container + Size Goods Stage Truck @@ -182,6 +207,15 @@ export function ContainerItemsModal({ opened, onClose, bookingId, bookingReferen /> {i.containerNumber} + + {i.containerSize ? ( + + {i.containerSize} + + ) : ( + bulk + )} + {i.goods ?? '—'} {i.stage} {i.truckPlate ?? '—'} @@ -221,11 +255,17 @@ export function ContainerItemsModal({ opened, onClose, bookingId, bookingReferen {/* Multiselect → load onto a truck */} - {selected.length} selected + + {selected.length} selected + {(() => { + const pending = items.filter((i) => !i.truckAssignmentId).length; + return pending > 0 ? ` · ${pending} container${pending === 1 ? '' : 's'} pending assignment` : ''; + })()} +