From 031efce92b343252d284e96c525a6d55586a5f51 Mon Sep 17 00:00:00 2001 From: hagiye Date: Wed, 2 Sep 2026 01:41:08 +0300 Subject: [PATCH] Truck assiggnment and per truc --- .../src/common/truck-load.util.spec.ts | 10 + .../src/common/truck-load.util.ts | 7 +- .../src/modules/bookings/bookings.service.ts | 39 +- .../bookings/customer-truck.service.ts | 37 +- .../warehouses/dto/bulk-receive.dto.ts | 31 +- .../warehouses/warehouse-inventory.service.ts | 380 +++++++++++++++--- .../warehouses/ReceiveInventoryModal.tsx | 302 +++++++++++++- .../warehouses/ReleaseOrderModal.tsx | 28 +- .../src/services/warehouse.service.ts | 2 +- .../backoffice/src/types/warehouse.ts | 21 +- .../BookingDetailPage/ReadonlyBookingView.tsx | 2 +- .../CustomerTruckAssignmentCard.tsx | 7 + 12 files changed, 769 insertions(+), 97 deletions(-) diff --git a/apps/edr-freight-api/src/common/truck-load.util.spec.ts b/apps/edr-freight-api/src/common/truck-load.util.spec.ts index fbb3a436a..de5ec9550 100644 --- a/apps/edr-freight-api/src/common/truck-load.util.spec.ts +++ b/apps/edr-freight-api/src/common/truck-load.util.spec.ts @@ -45,6 +45,16 @@ describe('assertTruckLoad', () => { ).toThrow(BadRequestException); }); + it('allows two containers only when both are explicitly 20ft', () => { + expect(() => + assertTruckLoad({ + containers: ['ABCD1234567', 'ABCD7654321'], + bookingContainers: booking, + sizes: ['20ft', '45ft'], + }), + ).toThrow(BadRequestException); + }); + it('rejects more than two containers', () => { expect(() => assertTruckLoad({ diff --git a/apps/edr-freight-api/src/common/truck-load.util.ts b/apps/edr-freight-api/src/common/truck-load.util.ts index b65bc12fb..6380786bd 100644 --- a/apps/edr-freight-api/src/common/truck-load.util.ts +++ b/apps/edr-freight-api/src/common/truck-load.util.ts @@ -54,10 +54,11 @@ export function assertTruckLoad({ } } - // A 40ft fills the bed, so it travels alone. - if (containers.length > 1 && sizes.some((size) => size.includes('40'))) { + // A truck may pair containers only when BOTH are explicitly 20ft. A 40ft + // (and any legacy/unknown larger size) fills the bed and travels alone. + if (containers.length > 1 && sizes.some((size) => !size.includes('20'))) { throw new BadRequestException( - 'A 40ft container fills the truck — assign only 1 container to this truck', + 'Truck capacity is either 1 x 40ft container or up to 2 x 20ft containers', ); } } 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 da7811ecb..9b213d675 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -312,12 +312,27 @@ export class BookingsService { LEFT JOIN freight.yards ay ON ay.id = tsw.alight_yard_id LEFT JOIN freight.wagon_allocation_container_items ci ON ci.wagon_booking_allocation_id = a.id AND ci.deleted_at IS NULL + AND ( + $2 <> 'EXPORT' OR $3 <> 'CONTAINER' OR EXISTS ( + SELECT 1 + FROM freight.booking_container_units received_unit + JOIN freight.booking_container received_line + ON received_line.id = received_unit.booking_container_id + AND received_line.deleted_at IS NULL + WHERE received_line.booking_id = a.booking_id + AND received_unit.container_number = ci.container_number + AND received_unit.received_to_port = true + AND NULLIF(TRIM(received_unit.grn_number), '') IS NOT NULL + AND received_unit.deleted_at IS NULL + ) + ) WHERE a.booking_id = $1 AND a.deleted_at IS NULL GROUP BY tsw.id, a.id, a.status, wt.code, wt.name, w.wagon_number, wt.tare_weight_tons, s.train_number, s.scheduled_departure_date, so.label, sd.label, by_.label, ay.label + HAVING $2 <> 'EXPORT' OR $3 <> 'CONTAINER' OR COUNT(ci.id) > 0 ORDER BY tsw.sequence_no`, - [bookingId], + [bookingId, booking.tradeDirection, booking.freightType], ); // Export acceptance happens at the warehouse gate, not at marshalling: EDR // takes custody of the cargo when it receives it, and the customer is handed @@ -352,17 +367,17 @@ export class BookingsService { ) : booking.tradeDirection === 'EXPORT' ? await this.dataSource.query( - `SELECT inv.weight AS "allocatedWeightTons", - c.container_number AS "containerNumbers" - FROM freight.warehouse_inventory inv - LEFT JOIN freight.containers c - ON c.id = inv.container_id AND c.deleted_at IS NULL - WHERE inv.booking_id = $1 AND inv.deleted_at IS NULL - AND COALESCE( - NULLIF(TRIM(inv.grn_number), ''), - substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)') - ) IS NOT NULL - ORDER BY inv.created_at`, + `SELECT unit.vgm_tons AS "allocatedWeightTons", + unit.container_number AS "containerNumbers", + unit.seal_number AS "sealNumbers" + FROM freight.booking_container_units unit + JOIN freight.booking_container line + ON line.id = unit.booking_container_id AND line.deleted_at IS NULL + WHERE line.booking_id = $1 + AND unit.deleted_at IS NULL + AND unit.received_to_port = true + AND NULLIF(TRIM(unit.grn_number), '') IS NOT NULL + ORDER BY unit.received_at, unit.container_number`, [bookingId], ) : []; 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 3df681d9c..d8b94efad 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 @@ -35,6 +35,7 @@ interface BookingGuardRow { lastMile: string | null; paymentStatus: string | null; status: string | null; + trainScheduleStatus: string | null; } /** @@ -294,19 +295,16 @@ export class CustomerTruckService { } const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase()); + if (booking.freightType === 'CONTAINER' && !requested.length) { + throw new BadRequestException('Select the containers loaded on this truck'); + } if (requested.length) { - const bookingNumbers = await this.bookingContainerNumbers(bookingId); - for (const n of requested) { - if (!bookingNumbers.includes(n)) { - throw new BadRequestException(`Container ${n} is not one of this booking's containers`); - } - } - const elsewhere = await this.assignedContainerNumbersExcept(bookingId, assignmentId); - for (const n of requested) { - if (elsewhere.includes(n)) { - throw new ConflictException(`Container ${n} is already loaded onto another truck`); - } - } + assertTruckLoad({ + containers: requested, + bookingContainers: await this.bookingContainerNumbers(bookingId), + sizes: await bookingContainerSizes(this.dataSource, bookingId, requested), + assignedElsewhere: await this.assignedContainerNumbersExcept(bookingId, assignmentId), + }); } await this.dataSource.transaction(async (manager) => { @@ -542,9 +540,16 @@ export class CustomerTruckService { first_mile_pickup_address AS "firstMile", last_mile_delivery_address AS "lastMile", payment_status AS "paymentStatus", - status - FROM freight.bookings - WHERE id = $1 AND deleted_at IS NULL`, + b.status, + (SELECT ts.status + FROM freight.train_schedule_bookings tsb + JOIN freight.train_schedules ts + ON ts.id = tsb.train_schedule_id AND ts.deleted_at IS NULL + WHERE tsb.booking_id = b.id AND tsb.deleted_at IS NULL + ORDER BY ts.updated_at DESC + LIMIT 1) AS "trainScheduleStatus" + FROM freight.bookings b + WHERE b.id = $1 AND b.deleted_at IS NULL`, [bookingId], ); if (!row) throw new NotFoundException(`Booking ${bookingId} not found`); @@ -575,7 +580,7 @@ export class CustomerTruckService { private assertAssignmentWindow(booking: BookingGuardRow): void { const status = booking.status ?? ''; if (booking.tradeDirection === 'IMPORT') { - if (status !== 'ARRIVED') { + if (status !== 'ARRIVED' && booking.trainScheduleStatus !== 'ARRIVED') { throw new BadRequestException( 'Import pickup trucks can only be assigned after the train has arrived', ); diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/bulk-receive.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/bulk-receive.dto.ts index 8dd0681ce..85417ea87 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/bulk-receive.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/bulk-receive.dto.ts @@ -1,6 +1,19 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { ArrayNotEmpty, IsArray, IsBoolean, IsIn, IsNumber, IsOptional, IsString, IsUUID, Min } from 'class-validator'; +import { + ArrayMaxSize, + ArrayNotEmpty, + ArrayUnique, + IsArray, + IsBoolean, + IsIn, + IsNumber, + IsOptional, + IsString, + IsUUID, + Matches, + Min, +} from 'class-validator'; import { ValidateNested } from 'class-validator'; export class TruckEntranceDto { @@ -187,6 +200,22 @@ export class BulkReceiveDto { @IsUUID('all', { each: true }) bookingIds!: string[]; + /** + * The physical containers delivered by this truck. Container exports are + * received one truck at a time: either one 40ft box or up to two 20ft boxes. + */ + @ApiPropertyOptional({ type: [String] }) + @IsOptional() + @IsArray() + @ArrayNotEmpty() + @ArrayMaxSize(2) + @ArrayUnique() + @Matches(/^[A-Z]{4}\d{7}$/, { + each: true, + message: 'each container number must match ISO container format, e.g. ABCD1234567', + }) + containerNumbers?: string[]; + @ApiPropertyOptional({ type: TruckEntranceDto }) @IsOptional() @ValidateNested() 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 5956f0bcf..eb3ae97b3 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 @@ -17,6 +17,7 @@ import { import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; import { generateGrnNumber } from '../../common/grn.util'; +import { assertTruckLoad } from '../../common/truck-load.util'; import { SCHEDULE_BOOKINGS_CTE } from '../../common/schedule-bookings.sql'; import { Booking } from '../bookings/entities/booking.entity'; import type { StationWorkLog } from '../train-schedules/entities/train-schedule.entity'; @@ -275,12 +276,30 @@ export interface EligibleBookingRow { customerTruckType: string | null; customerTruckContainerNumber: string | null; customerTruckAssignedAt: string | null; + containerUnits: Array<{ + containerNumber: string; + containerSize: string | null; + weightTons: number; + received: boolean; + grnNumber: string | null; + }>; + receivedContainerCount: number; + remainingContainerCount: number; } export interface BulkReceiveResult { receivedCount: number; skippedCount: number; - results: { bookingId: string; status: string; inventoryId?: string; grnNumber?: string; reason?: string }[]; + results: { + bookingId: string; + status: string; + inventoryId?: string; + inventoryIds?: string[]; + grnNumber?: string; + receivedContainers?: number; + remainingContainers?: number; + reason?: string; + }[]; } @@ -1406,6 +1425,9 @@ export class WarehouseInventoryService { ${companyNotifyPhoneExpr('company')} AS "customerPhone", COALESCE(bcu.unit_numbers, bc.container_numbers) AS "containerNumber", bcu.seal_numbers AS "sealNumbers", + COALESCE(bcu.container_units, '[]'::json) AS "containerUnits", + COALESCE(bcu.received_count, 0)::int AS "receivedContainerCount", + COALESCE(bcu.remaining_count, 0)::int AS "remainingContainerCount", bc.container_quantity AS "containerQuantity", bc.container_packaging_type AS "containerPackagingType", -- service_types.includes_last_mile/first_mile are NOT read here: every @@ -1457,7 +1479,6 @@ export class WarehouseInventoryService { LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id - LEFT JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL LEFT JOIN LATERAL ( SELECT string_agg(NULLIF(booking_container.container_number, ''), ', ' ORDER BY booking_container.container_number) AS container_numbers, SUM(booking_container.quantity)::int AS container_quantity, @@ -1476,7 +1497,18 @@ export class WarehouseInventoryService { ) bc ON true LEFT JOIN LATERAL ( SELECT string_agg(NULLIF(unit.container_number, ''), ', ' ORDER BY unit.container_number) AS unit_numbers, - string_agg(DISTINCT NULLIF(unit.seal_number, ''), ', ') AS seal_numbers + string_agg(DISTINCT NULLIF(unit.seal_number, ''), ', ') AS seal_numbers, + COUNT(*) FILTER (WHERE unit.received_to_port)::int AS received_count, + COUNT(*) FILTER (WHERE NOT unit.received_to_port)::int AS remaining_count, + json_agg( + json_build_object( + 'containerNumber', unit.container_number, + 'containerSize', line.container_size, + 'weightTons', unit.vgm_tons, + 'received', unit.received_to_port, + 'grnNumber', unit.grn_number + ) ORDER BY unit.container_number + ) AS container_units FROM freight.booking_container_units unit JOIN freight.booking_container line ON line.id = unit.booking_container_id AND line.deleted_at IS NULL @@ -1493,7 +1525,14 @@ export class WarehouseInventoryService { LEFT JOIN freight.drivers driver ON driver.id = v.assigned_driver_id WHERE b.deleted_at IS NULL AND b.payment_status = 'PAID' - AND inv.id IS NULL + AND ( + (b.freight_type = 'CONTAINER' AND COALESCE(bcu.remaining_count, 0) > 0) + OR + (b.freight_type <> 'CONTAINER' AND NOT EXISTS ( + SELECT 1 FROM freight.warehouse_inventory inv + WHERE inv.booking_id = b.id AND inv.deleted_at IS NULL + )) + ) -- Direct truck-to-train cargo never comes to the warehouse, so never -- offer it for receipt. AND COALESCE(b.export_handover_mode, 'WAREHOUSE') <> 'DIRECT_TO_TRAIN' @@ -1650,9 +1689,6 @@ export class WarehouseInventoryService { } } - const existing = await manager.getRepository(WarehouseInventory).findOne({ where: { bookingId } }); - if (existing) { skip('Already received'); continue; } - const containerQuantity = Number(booking.containerQuantity ?? 0); if (booking.freightType === 'CONTAINER' && containerQuantity <= 0) { skip('Container booking has no container quantity'); @@ -1660,62 +1696,256 @@ export class WarehouseInventoryService { } const now = new Date(); - const grnNumber = this.generateGrnNumber(dto.direction, bookingId, now, booking.customer); const truckEntrance = dto.truckEntrance ? this.mergeSystemTruckEntrance(dto.truckEntrance, booking) : undefined; + // Multi-truck self-haul is selected explicitly at the gate. The booking + // source contains comma-joined legacy summary fields, which must never + // replace the one physical truck the receiver selected. + if (truckEntrance && !booking.hasFirstMile && dto.truckEntrance) { + truckEntrance.truckPlateNumber = dto.truckEntrance.truckPlateNumber; + truckEntrance.driverName = dto.truckEntrance.driverName; + truckEntrance.driverPhone = dto.truckEntrance.driverPhone; + truckEntrance.truckType = dto.truckEntrance.truckType; + } if (dto.direction === 'EXPORT') { this.assertTruckEntrance(truckEntrance); } + + type ReceiveContainerUnit = { + containerNumber: string; + containerSize: string | null; + weightTons: string | number; + sealNumber: string | null; + bookingContainerId: string; + containerTypeId: string | null; + received: boolean; + }; + let selectedUnits: ReceiveContainerUnit[] = []; + let grnNumber = this.generateGrnNumber(dto.direction, bookingId, now, booking.customer); + + if (booking.freightType === 'CONTAINER') { + if (dto.bookingIds.length !== 1) { + throw new BadRequestException( + 'Receive one container booking per arriving truck so its containers and documents stay separate', + ); + } + const selectedNumbers = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase()); + if (!selectedNumbers.length) { + throw new BadRequestException('Select the containers arriving on this truck'); + } + const allUnits: ReceiveContainerUnit[] = await manager.query( + `SELECT UPPER(bcu.container_number) AS "containerNumber", + bc.container_size AS "containerSize", + bcu.vgm_tons AS "weightTons", + bcu.seal_number AS "sealNumber", + bc.id AS "bookingContainerId", + bc.container_type_id AS "containerTypeId", + bcu.received_to_port AS received + FROM freight.booking_container_units bcu + JOIN freight.booking_container bc + ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL + WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL + FOR UPDATE OF bcu`, + [bookingId], + ); + assertTruckLoad({ + containers: selectedNumbers, + bookingContainers: allUnits.map((unit) => unit.containerNumber), + sizes: allUnits + .filter((unit) => selectedNumbers.includes(unit.containerNumber)) + .map((unit) => unit.containerSize ?? ''), + }); + selectedUnits = allUnits.filter((unit) => selectedNumbers.includes(unit.containerNumber)); + if (selectedUnits.some((unit) => unit.received)) { + const repeated = selectedUnits.filter((unit) => unit.received).map((unit) => unit.containerNumber); + throw new BadRequestException(`Container(s) already received: ${repeated.join(', ')}`); + } + + // If this is a customer-assigned truck, it may only deliver the boxes + // assigned to that plate. Manual/unassigned arrivals retain the same + // physical capacity validation but have no assignment list to check. + if (truckEntrance?.truckPlateNumber) { + const assigned: Array<{ containerNumber: string }> = await manager.query( + `SELECT UPPER(ctc.container_number) AS "containerNumber" + FROM freight.customer_truck_assignments cta + JOIN freight.customer_truck_containers ctc + ON ctc.assignment_id = cta.id AND ctc.deleted_at IS NULL + WHERE cta.booking_id = $1 + AND UPPER(cta.plate_number) = UPPER($2) + AND cta.deleted_at IS NULL`, + [bookingId, truckEntrance.truckPlateNumber], + ); + if ( + assigned.length > 0 && + selectedNumbers.some( + (number) => !assigned.some((container) => container.containerNumber === number), + ) + ) { + throw new BadRequestException( + `Selected containers are not assigned to truck ${truckEntrance.truckPlateNumber}`, + ); + } + } + + const [{ batches }]: Array<{ batches: string }> = await manager.query( + `SELECT COUNT(DISTINCT inv.grn_number) AS batches + FROM freight.warehouse_inventory inv + WHERE inv.booking_id = $1 + AND inv.grn_number IS NOT NULL + AND inv.deleted_at IS NULL`, + [bookingId], + ); + grnNumber = `${grnNumber}-${String(Number(batches ?? 0) + 1).padStart(2, '0')}`; + if (truckEntrance) { + truckEntrance.assignedEquipmentNumber = selectedNumbers.join(', '); + truckEntrance.unitCount = selectedNumbers.length; + truckEntrance.netWeightKg = selectedUnits.reduce( + (total, unit) => total + Number(unit.weightTons || 0), + 0, + ); + } + } else { + const existing = await manager + .getRepository(WarehouseInventory) + .findOne({ where: { bookingId } }); + if (existing) { + skip('Already received'); + continue; + } + } + + const receivedBefore = + booking.freightType === 'CONTAINER' + ? Number( + ( + await manager.query( + `SELECT COUNT(*) AS count + FROM freight.booking_container_units bcu + JOIN freight.booking_container bc + ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL + WHERE bc.booking_id = $1 + AND bcu.received_to_port = true + AND bcu.deleted_at IS NULL`, + [bookingId], + ) + )[0]?.count ?? 0, + ) + : 0; + const receivedAfter = receivedBefore + selectedUnits.length; + const remainingAfter = Math.max(0, containerQuantity - receivedAfter); const receiveNote = this.buildReceiveNote({ grnNumber, direction: dto.direction, - notes: `Bulk received (${dto.direction})`, + notes: + booking.freightType === 'CONTAINER' + ? `${selectedUnits.length} container(s) arrived: ${selectedUnits + .map((unit) => unit.containerNumber) + .join(', ')}. ${remainingAfter} container(s) left.` + : `Bulk received (${dto.direction})`, truckEntrance, }); // Validate capacity before saving - const weight = Number(booking.weight) || 0; - const containerCount = booking.freightType === 'CONTAINER' ? containerQuantity : 0; + const weight = + booking.freightType === 'CONTAINER' + ? selectedUnits.reduce((total, unit) => total + Number(unit.weightTons || 0), 0) + : Number(booking.weight) || 0; + const containerCount = booking.freightType === 'CONTAINER' ? selectedUnits.length : 0; this.assertCapacity('Warehouse', warehouse, weight, 0, containerCount); this.assertCapacity('Yard', yard, weight, 0, containerCount); this.assertCapacity('Zone', zone, weight, 0, containerCount); - const saved = await manager.getRepository(WarehouseInventory).save( - manager.getRepository(WarehouseInventory).create({ - warehouseId: dto.warehouseId, - yardId: dto.yardId, - zoneId: dto.zoneId, - bookingId, - quantity: booking.freightType === 'CONTAINER' ? containerQuantity : 1, - weight, - grnNumber, - status: 'RECEIVED', - arrivedAt: now, - notes: receiveNote, - }), - ); + const inventoryIds: string[] = []; + if (booking.freightType === 'CONTAINER') { + const containers = manager.getRepository(Container); + for (const unit of selectedUnits) { + let container = await containers.findOne({ + where: { containerNumber: unit.containerNumber }, + withDeleted: true, + }); + if (!container && !unit.containerTypeId) { + throw new BadRequestException( + `Container ${unit.containerNumber} has no container type and cannot be received`, + ); + } + if (!container) { + container = await containers.save( + containers.create({ + containerNumber: unit.containerNumber, + containerTypeId: unit.containerTypeId as string, + bookingContainerId: unit.bookingContainerId, + bookingId, + sealNumber: unit.sealNumber, + tareWeight: 0, + maxGrossWeight: Number(unit.weightTons || 0), + status: 'LOADED', + wagonId: null, + position: null, + wagonBookingAllocationId: null, + }), + ); + } else { + await containers.update(container.id, { + bookingId, + bookingContainerId: unit.bookingContainerId, + sealNumber: unit.sealNumber, + status: 'LOADED', + deletedAt: null, + }); + } + const saved = await manager.getRepository(WarehouseInventory).save( + manager.getRepository(WarehouseInventory).create({ + warehouseId: dto.warehouseId, + yardId: dto.yardId, + zoneId: dto.zoneId, + bookingId, + containerId: container.id, + quantity: 1, + weight: Number(unit.weightTons || 0), + grnNumber, + status: 'RECEIVED', + arrivedAt: now, + notes: receiveNote, + }), + ); + inventoryIds.push(saved.id); + } + await manager.query( + `UPDATE freight.booking_container_units bcu + SET received_to_port = true, + received_at = COALESCE(bcu.received_at, NOW()), + grn_number = $3, + updated_at = NOW() + FROM freight.booking_container bc + WHERE bc.id = bcu.booking_container_id + AND bc.booking_id = $1 + AND UPPER(bcu.container_number) = ANY($2::varchar[]) + AND bc.deleted_at IS NULL + AND bcu.deleted_at IS NULL`, + [bookingId, selectedUnits.map((unit) => unit.containerNumber), grnNumber], + ); + } else { + const saved = await manager.getRepository(WarehouseInventory).save( + manager.getRepository(WarehouseInventory).create({ + warehouseId: dto.warehouseId, + yardId: dto.yardId, + zoneId: dto.zoneId, + bookingId, + quantity: 1, + weight, + grnNumber, + status: 'RECEIVED', + arrivedAt: now, + notes: receiveNote, + }), + ); + inventoryIds.push(saved.id); + } // Update warehouse/yard/zone capacity counters await this.applyCapacityDelta(manager, dto, weight, 0, containerCount); - // Receiving the booking flags every container unit as received into the - // port (self-haul export: the delivering truck's goods are now in) so - // staff can raise the per-container GRN over what's received. - await manager.query( - `UPDATE freight.booking_container_units bcu - SET received_to_port = true, - received_at = COALESCE(bcu.received_at, NOW()), - updated_at = NOW() - FROM freight.booking_container bc - WHERE bc.id = bcu.booking_container_id - AND bc.booking_id = $1 - AND bc.deleted_at IS NULL - AND bcu.deleted_at IS NULL - AND bcu.received_to_port = false`, - [bookingId], - ); - // Export self-haul: this receive IS the truck's arrival — see // markCustomerTruckArrived / receive()'s single-booking mirror. if (dto.direction === 'EXPORT') { @@ -1725,7 +1955,7 @@ export class WarehouseInventoryService { await this.activityLog.record( { activityType: 'INVENTORY_RECEIVED', - inventoryId: saved.id, + inventoryId: inventoryIds[0], warehouseId: dto.warehouseId, description: truckEntrance?.truckPlateNumber ? `GRN ${grnNumber}: bulk received ${dto.direction} booking via truck ${truckEntrance.truckPlateNumber}` @@ -1752,7 +1982,19 @@ export class WarehouseInventoryService { }); result.receivedCount += 1; - result.results.push({ bookingId, status: 'RECEIVED', inventoryId: saved.id, grnNumber }); + result.results.push({ + bookingId, + status: 'RECEIVED', + inventoryId: inventoryIds[0], + inventoryIds, + grnNumber, + ...(booking.freightType === 'CONTAINER' + ? { + receivedContainers: receivedAfter, + remainingContainers: remainingAfter, + } + : {}), + }); } }); @@ -4026,7 +4268,7 @@ export class WarehouseInventoryService { `SELECT inv.id, inv.release_date AS "releaseDate", inv.release_order_reference AS "releaseOrderReference", - inv.quantity, + COALESCE(receipt_batch.quantity, inv.quantity) AS quantity, inv.weight, inv.status, inv.notes, @@ -4355,11 +4597,12 @@ export class WarehouseInventoryService { */ async bookingContainerWeights( bookingId: string, - ): Promise> { - const rows: Array<{ containerNumber: string; weightTons: string }> = + ): Promise> { + const rows: Array<{ containerNumber: string; weightTons: string; containerSize: string | null }> = await this.dataSource.query( `SELECT bcu.container_number AS "containerNumber", - MAX(COALESCE(bcu.vgm_tons, 0)) AS "weightTons" + MAX(COALESCE(bcu.vgm_tons, 0)) AS "weightTons", + MAX(bc.container_size) AS "containerSize" FROM freight.booking_container_units bcu JOIN freight.booking_container bc ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL @@ -4371,6 +4614,7 @@ export class WarehouseInventoryService { return rows.map((r) => ({ containerNumber: r.containerNumber, weightTons: Number(r.weightTons) || 0, + containerSize: r.containerSize ?? null, })); } @@ -4572,7 +4816,7 @@ export class WarehouseInventoryService { -- An unweighed item still reports the cargo weight it holds: fall -- back to the item's container VGM, then the booking's declared -- weight, so a GRN never prints "0 t" for goods that are present. - COALESCE(NULLIF(inv.weight, 0), item_vgm.tons, b.cargo_total_weight_vgm, 0) AS weight, + COALESCE(NULLIF(receipt_batch.weight, 0), NULLIF(inv.weight, 0), item_vgm.tons, b.cargo_total_weight_vgm, 0) AS weight, inv.volume, inv.status, inv.notes, @@ -4589,8 +4833,8 @@ export class WarehouseInventoryService { origin_yard.code AS "originYardCode", destination_yard.label AS "destinationYardLabel", destination_yard.code AS "destinationYardCode", - COALESCE(container.container_number, booking_container.container_number) AS "containerNumber", - booking_container."containerSummary" AS "bookingContainerSummary", + COALESCE(receipt_batch.container_numbers, container.container_number, booking_container.container_number) AS "containerNumber", + COALESCE(receipt_batch.container_summary, booking_container."containerSummary") AS "bookingContainerSummary", COALESCE(cargo_type.cargo_type_name, b.cargo_free_text, cargo.description) AS "cargoDescription", wh.name AS "warehouseName", wh.code AS "warehouseCode", @@ -4620,6 +4864,40 @@ export class WarehouseInventoryService { WHERE bc.booking_id = b.id AND bc.deleted_at IS NULL ) booking_container ON true + LEFT JOIN LATERAL ( + SELECT COUNT(*)::int AS quantity, + SUM(batch.weight) AS weight, + string_agg(batch.container_number, ', ' ORDER BY batch.container_number) + FILTER (WHERE batch.container_number IS NOT NULL) AS container_numbers, + string_agg( + CONCAT(batch.container_number, ' (', COALESCE(batch.container_size, 'size unknown'), ')'), + ', ' ORDER BY batch.container_number + ) FILTER (WHERE batch.container_number IS NOT NULL) AS container_summary + FROM ( + SELECT inv2.id, + inv2.weight, + c2.container_number, + bc2.container_size + FROM freight.warehouse_inventory inv2 + LEFT JOIN freight.containers c2 + ON c2.id = inv2.container_id AND c2.deleted_at IS NULL + LEFT JOIN freight.booking_container_units bcu2 + ON bcu2.container_number = c2.container_number AND bcu2.deleted_at IS NULL + LEFT JOIN freight.booking_container bc2 + ON bc2.id = bcu2.booking_container_id + AND bc2.booking_id = inv2.booking_id + AND bc2.deleted_at IS NULL + WHERE inv2.booking_id = inv.booking_id + AND inv2.deleted_at IS NULL + AND COALESCE( + NULLIF(TRIM(inv2.grn_number), ''), + substring(inv2.notes FROM 'GRN Number: ([^\\n\\r]+)') + ) = COALESCE( + NULLIF(TRIM(inv.grn_number), ''), + substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)') + ) + ) batch + ) receipt_batch ON true LEFT JOIN LATERAL ( SELECT SUM(bcu.vgm_tons) AS tons FROM freight.booking_container_units bcu 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 ba75f0c35..b10e937c3 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx @@ -58,6 +58,7 @@ import { QUERY_KEYS } from '@/constants/QUERY_KEYS'; import { useToast } from '@/hooks/use-toast'; import { useAllWarehouseYards, useAllWarehouseZones, useInventoryInquiry } from '@/hooks/useWarehouses'; import { firstMileService } from '@/services/first-mile.service'; +import { bookingsService } from '@/services/bookings.service'; import { warehouseService } from '@/services/warehouse.service'; import type { EligibleBooking, @@ -858,6 +859,8 @@ function EligibleTab({ const [truckForm, setTruckForm] = useState(emptyTruckEntrance()); const [lockedTruckFields, setLockedTruckFields] = useState({}); const [packagingFreightType, setPackagingFreightType] = useState('MIXED'); + const [selectedCustomerTruckId, setSelectedCustomerTruckId] = useState(null); + const [selectedContainerNumbers, setSelectedContainerNumbers] = useState([]); const locationReady = Boolean(location.warehouseId && location.yardId && location.zoneId); const canReceiveBooking = (row: EligibleBooking) => @@ -944,6 +947,105 @@ function EligibleTab({ const pendingHasFirstMile = pendingReceiveRows.some((row) => row.hasFirstMile); const pendingUsesFirstMile = pendingReceiveRows.length > 0 && pendingReceiveRows.every((row) => row.hasFirstMile); + const pendingContainerBooking = + pendingReceiveRows.length === 1 && pendingReceiveRows[0]?.freightType === 'CONTAINER' + ? pendingReceiveRows[0] + : null; + const { data: assignedCustomerTrucks = [] } = useQuery({ + queryKey: ['receive-customer-trucks', pendingContainerBooking?.id], + queryFn: () => warehouseService.getCustomerTrucks(pendingContainerBooking?.id as string), + enabled: truckOpen && Boolean(pendingContainerBooking) && !pendingUsesFirstMile, + }); + const pendingContainerUnits = (pendingContainerBooking?.containerUnits ?? []).filter( + (unit) => !unit.received, + ); + const selectedCustomerTruck = assignedCustomerTrucks.find( + (truck) => truck.id === selectedCustomerTruckId, + ); + const assignedNumbersForSelectedTruck = new Set( + (selectedCustomerTruck?.containers ?? []).map((container) => container.containerNumber.toUpperCase()), + ); + const selectableContainerUnits = pendingContainerUnits.filter( + (unit) => + assignedNumbersForSelectedTruck.size === 0 || + assignedNumbersForSelectedTruck.has(unit.containerNumber.toUpperCase()), + ); + const selectedContainerUnits = pendingContainerUnits.filter((unit) => + selectedContainerNumbers.includes(unit.containerNumber), + ); + const selectedContainerWeight = selectedContainerUnits.reduce( + (total, unit) => total + Number(unit.weightTons || 0), + 0, + ); + const containerCapacityError = + selectedContainerNumbers.length > 2 + ? 'A truck carries no more than 2 containers.' + : selectedContainerNumbers.length > 1 && + selectedContainerUnits.some((unit) => !String(unit.containerSize ?? '').includes('20')) + ? 'Truck capacity is either 1 x 40ft container or up to 2 x 20ft containers.' + : null; + + useEffect(() => { + if (!truckOpen || pendingUsesFirstMile || !pendingContainerBooking) return; + if (selectedCustomerTruckId || assignedCustomerTrucks.length === 0) return; + const pendingNumbers = new Set( + pendingContainerUnits.map((unit) => unit.containerNumber.toUpperCase()), + ); + const truck = + assignedCustomerTrucks.find( + (candidate) => + !candidate.arrivedAt && + (candidate.containers ?? []).some((container) => + pendingNumbers.has(container.containerNumber.toUpperCase()), + ), + ) ?? assignedCustomerTrucks[0]; + const truckContainers = (truck.containers ?? []) + .map((container) => container.containerNumber.toUpperCase()) + .filter((number) => pendingNumbers.has(number)); + setSelectedCustomerTruckId(truck.id); + setSelectedContainerNumbers(truckContainers); + setTruckForm((current) => ({ + ...current, + truckPlateNumber: truck.plateNumber, + driverName: truck.driverName, + truckType: truck.truckType, + assignedEquipmentNumber: truckContainers.join(', '), + unitCount: truckContainers.length, + netWeightKg: pendingContainerUnits + .filter((unit) => truckContainers.includes(unit.containerNumber.toUpperCase())) + .reduce((total, unit) => total + Number(unit.weightTons || 0), 0), + })); + setLockedTruckFields((current) => ({ + ...current, + truckPlateNumber: true, + driverName: true, + truckType: true, + assignedEquipmentNumber: true, + unitCount: true, + })); + }, [ + assignedCustomerTrucks, + pendingContainerBooking, + pendingContainerUnits, + pendingUsesFirstMile, + selectedCustomerTruckId, + truckOpen, + ]); + + useEffect(() => { + if (!truckOpen || !pendingContainerBooking) return; + setTruckForm((current) => ({ + ...current, + assignedEquipmentNumber: selectedContainerNumbers.join(', '), + unitCount: selectedContainerNumbers.length, + netWeightKg: selectedContainerWeight, + })); + }, [ + pendingContainerBooking, + selectedContainerNumbers, + selectedContainerWeight, + truckOpen, + ]); const toggleAll = () => setSelected(allSelected ? new Set() : new Set(selectableRows.map((r) => r.id))); @@ -954,29 +1056,60 @@ function EligibleTab({ return next; }); - const receiveBookings = async (bookingIds: string[], truckEntrance?: TruckEntrancePayload) => { + const receiveBookings = async ( + bookingIds: string[], + truckEntrance?: TruckEntrancePayload, + containerNumbers?: string[], + ) => { + const documentBookingId = direction === 'EXPORT' && bookingIds.length === 1 ? bookingIds[0] : null; + const grnWindow = documentBookingId ? window.open('', '_blank') : null; + const acceptanceWindow = documentBookingId ? window.open('', '_blank') : null; try { const r = await bulkReceive.mutateAsync({ direction, ...location, bookingIds, + ...(containerNumbers?.length ? { containerNumbers } : {}), ...(truckEntrance ? { truckEntrance } : {}), }); + const receivedProgress = r.results.find( + (item) => item.receivedContainers != null && item.remainingContainers != null, + ); toast({ title: `${r.receivedCount} received at ${direction === 'EXPORT' ? 'facility' : 'warehouse'}`, - description: r.results.find((item) => item.grnNumber)?.grnNumber ?? skippedSummary(r.skippedCount, r.results), + description: receivedProgress + ? `${containerNumbers?.length ?? 0} container(s) arrived. ${receivedProgress.remainingContainers} container(s) left. GRN ${receivedProgress.grnNumber}.` + : r.results.find((item) => item.grnNumber)?.grnNumber ?? skippedSummary(r.skippedCount, r.results), }); const firstGrn = r.results.find((item) => item.inventoryId && item.grnNumber); - if (focusedBookingId && firstGrn?.inventoryId && firstGrn.grnNumber) { - const pdfWindow = window.open('', '_blank'); + if (documentBookingId && firstGrn?.inventoryId && firstGrn.grnNumber) { try { const response = await warehouseService.downloadGrnDocument(firstGrn.inventoryId); - const opened = openPdfBlob(response.data, `grn-${firstGrn.grnNumber}.pdf`, pdfWindow); + const opened = openPdfBlob(response.data, `grn-${firstGrn.grnNumber}.pdf`, grnWindow); toast({ title: opened ? 'GRN document opened' : 'GRN document downloaded' }); } catch (error) { - pdfWindow?.close(); + grnWindow?.close(); toast({ variant: 'destructive', title: 'GRN document failed', description: await extractDownloadErrorMessage(error) }); } + try { + const acceptance = await bookingsService.downloadCarriageAcceptanceSheet(documentBookingId); + const opened = openPdfBlob( + acceptance, + `carriage-acceptance-${pendingReceiveRows[0]?.reference ?? documentBookingId}.pdf`, + acceptanceWindow, + ); + toast({ title: opened ? 'Carriage acceptance sheet opened' : 'Carriage acceptance sheet downloaded' }); + } catch (error) { + acceptanceWindow?.close(); + toast({ + variant: 'destructive', + title: 'Carriage acceptance sheet failed', + description: await extractDownloadErrorMessage(error), + }); + } + } else { + grnWindow?.close(); + acceptanceWindow?.close(); } setSelected(new Set()); setTruckOpen(false); @@ -984,8 +1117,12 @@ function EligibleTab({ setReceivedAt(null); setLockedTruckFields({}); setPackagingFreightType('MIXED'); + setSelectedCustomerTruckId(null); + setSelectedContainerNumbers([]); onChanged?.(); } catch (error) { + grnWindow?.close(); + acceptanceWindow?.close(); toast({ variant: 'destructive', title: 'Receive failed', description: extractErrorMessage(error) }); } }; @@ -1012,6 +1149,18 @@ function EligibleTab({ void receiveBookings(filteredIds); return; } + if ( + direction === 'EXPORT' && + selectedRows.some((row) => row.freightType === 'CONTAINER') && + selectedRows.length !== 1 + ) { + toast({ + variant: 'destructive', + title: 'Receive one container booking per truck', + description: 'Select the arriving truck and its 1 x 40ft or up to 2 x 20ft containers.', + }); + return; + } const hasFirstMileRows = selectedRows.some((row) => row.hasFirstMile); const hasCustomerTruckRows = selectedRows.some((row) => !row.hasFirstMile); if (hasFirstMileRows && hasCustomerTruckRows) { @@ -1041,6 +1190,8 @@ function EligibleTab({ ...form, }; setPendingReceiveIds(filteredIds); + setSelectedCustomerTruckId(null); + setSelectedContainerNumbers([]); setReceivedAt(new Date().toISOString()); setTruckForm(normalizedForm); setLockedTruckFields({ @@ -1073,7 +1224,70 @@ function EligibleTab({ toast({ variant: 'destructive', title: 'Gross weight and exit tare weight are required when weighing is Yes' }); return; } - await receiveBookings(pendingReceiveIds, toTruckEntrancePayload(truckForm)); + if (pendingContainerBooking && selectedContainerNumbers.length === 0) { + toast({ variant: 'destructive', title: 'Select the containers arriving on this truck' }); + return; + } + if (containerCapacityError) { + toast({ variant: 'destructive', title: 'Truck capacity exceeded', description: containerCapacityError }); + return; + } + await receiveBookings( + pendingReceiveIds, + toTruckEntrancePayload({ + ...truckForm, + ...(pendingContainerBooking + ? { + assignedEquipmentNumber: selectedContainerNumbers.join(', '), + unitCount: selectedContainerNumbers.length, + netWeightKg: selectedContainerWeight, + } + : {}), + }), + pendingContainerBooking ? selectedContainerNumbers : undefined, + ); + }; + + const chooseCustomerTruck = (truckId: string | null) => { + setSelectedCustomerTruckId(truckId); + const truck = assignedCustomerTrucks.find((candidate) => candidate.id === truckId); + if (!truck) { + setSelectedContainerNumbers([]); + setLockedTruckFields((current) => ({ + ...current, + truckPlateNumber: false, + driverName: false, + truckType: false, + })); + return; + } + const pendingNumbers = new Set( + pendingContainerUnits.map((unit) => unit.containerNumber.toUpperCase()), + ); + const containers = (truck.containers ?? []) + .map((container) => container.containerNumber.toUpperCase()) + .filter((number) => pendingNumbers.has(number)); + const weight = pendingContainerUnits + .filter((unit) => containers.includes(unit.containerNumber.toUpperCase())) + .reduce((total, unit) => total + Number(unit.weightTons || 0), 0); + setSelectedContainerNumbers(containers); + setTruckForm((current) => ({ + ...current, + truckPlateNumber: truck.plateNumber, + driverName: truck.driverName, + truckType: truck.truckType, + assignedEquipmentNumber: containers.join(', '), + unitCount: containers.length, + netWeightKg: weight, + })); + setLockedTruckFields((current) => ({ + ...current, + truckPlateNumber: true, + driverName: true, + truckType: true, + assignedEquipmentNumber: true, + unitCount: true, + })); }; @@ -1296,6 +1510,76 @@ function EligibleTab({ : 'Register the customer or third-party truck and driver before export receiving and GRN.'} + {pendingContainerBooking && ( + + } color="teal" variant="light"> + + + {pendingContainerBooking.receivedContainerCount + selectedContainerNumbers.length} containers arrived + + + · {Math.max( + 0, + pendingContainerBooking.remainingContainerCount - selectedContainerNumbers.length, + )} left after this receipt + + + This truck: {selectedContainerNumbers.length} + + + + {assignedCustomerTrucks.length > 0 && !pendingUsesFirstMile && ( +