From c2fb320709c40a7049385e617ad780459fa11a4a Mon Sep 17 00:00:00 2001 From: hager Date: Sun, 6 Sep 2026 19:42:59 +0000 Subject: [PATCH 1/2] feat(warehouses): receive an arrival from multiple trucks A customer's containers routinely arrive together on several trucks, but bulk receive accepted one truck per operation: a single truckEntrance and a flat containerNumbers list capped at two boxes. Receiving a six-container arrival meant six separate operations. Model the arrival as a list of trucks instead. Each entry carries its own truckEntrance, its own containers and, optionally, its own bookingIds, defaulting to the operation's. The receive loop iterates trucks, so every per-truck invariant is preserved rather than pooled: the physical capacity check (one 40ft or up to two 20ft), the container-to-plate assignment check, the GRN batch number and the capacity assertions all still apply per truck. Callers sending the existing truckEntrance and containerNumbers fields collapse to a one-element list and behave exactly as before. Reject a container listed on more than one truck up front. The per-booking check only catches this once a unit is marked received, so a duplicate would otherwise surface from whichever truck happened to be processed second and read as a duplicate gate entry rather than a data-entry slip. Verified: freight-api type-check passes; all 13 warehouse suites pass (73 tests). Co-Authored-By: Claude Opus 5 --- .../warehouses/dto/bulk-receive.dto.ts | 58 ++ .../warehouses/warehouse-inventory.service.ts | 791 ++++++++++-------- 2 files changed, 477 insertions(+), 372 deletions(-) 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 85417ea87..0a04b040b 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 @@ -176,6 +176,49 @@ export class TruckEntranceDto { warehouseManagerName?: string; } +/** + * One physical truck at the gate, with the containers it is carrying. + * + * A customer whose containers arrive together sends several trucks, and each + * carries its own load: the plate, driver and boxes belong to that truck, not + * to the receive operation as a whole. Each truck is validated and given its + * own GRN batch exactly as a single-truck receive always was. + */ +export class ReceiveTruckDto { + /** + * The physical containers delivered by this truck: either one 40ft box or up + * to two 20ft boxes, the same physical limit a single-truck receive enforces. + */ + @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[]; + + @ApiProperty({ type: TruckEntranceDto }) + @ValidateNested() + @Type(() => TruckEntranceDto) + truckEntrance!: TruckEntranceDto; + + /** + * The bookings this truck delivers against. Defaults to the operation's + * `bookingIds` when omitted; container freight still requires exactly one, + * so its containers and documents stay separate per truck. + */ + @ApiPropertyOptional({ type: [String], format: 'uuid' }) + @IsOptional() + @IsArray() + @ArrayNotEmpty() + @IsUUID('all', { each: true }) + bookingIds?: string[]; +} + /** Bulk-receive eligible PAID bookings into a warehouse location, by trade direction. */ export class BulkReceiveDto { @ApiProperty({ enum: ['IMPORT', 'EXPORT'] }) @@ -200,9 +243,24 @@ export class BulkReceiveDto { @IsUUID('all', { each: true }) bookingIds!: string[]; + /** + * Several trucks arriving together, each with its own plate, driver and + * containers. When present this supersedes the single-truck + * `containerNumbers` / `truckEntrance` pair below, which is kept so existing + * callers (and single-truck arrivals) keep working unchanged. + */ + @ApiPropertyOptional({ type: [ReceiveTruckDto] }) + @IsOptional() + @IsArray() + @ArrayNotEmpty() + @ValidateNested({ each: true }) + @Type(() => ReceiveTruckDto) + trucks?: ReceiveTruckDto[]; + /** * 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. + * Ignored when `trucks` is given. */ @ApiPropertyOptional({ type: [String] }) @IsOptional() 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 ef68f3673..3925e923e 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 @@ -1580,335 +1580,412 @@ export class WarehouseInventoryService { bookingId: string; }> = []; + // A customer's containers often arrive on several trucks at once. Each + // truck carries its own load, so the operation is a list of trucks; the + // legacy single-truck fields collapse to a one-element list so existing + // callers behave exactly as before. + const trucks: Array<{ + truckEntrance?: BulkReceiveDto['truckEntrance']; + containerNumbers?: string[]; + bookingIds: string[]; + }> = dto.trucks?.length + ? dto.trucks.map((truck) => ({ + truckEntrance: truck.truckEntrance, + containerNumbers: truck.containerNumbers, + bookingIds: truck.bookingIds?.length ? truck.bookingIds : dto.bookingIds, + })) + : [ + { + truckEntrance: dto.truckEntrance, + containerNumbers: dto.containerNumbers, + bookingIds: dto.bookingIds, + }, + ]; + + // Two trucks cannot deliver the same box. The per-booking check below only + // catches this once a unit is marked received, which would let a duplicate + // through on the truck that happens to be processed first. + const seenContainers = new Set(); + for (const truck of trucks) { + for (const raw of truck.containerNumbers ?? []) { + const number = raw.trim().toUpperCase(); + if (seenContainers.has(number)) { + throw new BadRequestException( + `Container ${number} is listed on more than one truck`, + ); + } + seenContainers.add(number); + } + } + await this.dataSource.transaction(async (manager) => { const { warehouse, yard, zone } = await this.validateLocation(manager, { warehouseId: dto.warehouseId, yardId: dto.yardId, zoneId: dto.zoneId, }); - // The receive location is whatever the operator selected above — never a - // hand-typed string. Stamp it on the truck entrance for the GRN/notes. - if (dto.truckEntrance && !dto.truckEntrance.warehouseCodeLocation) { - dto.truckEntrance.warehouseCodeLocation = [warehouse.code, yard.code, zone.code] - .filter(Boolean) - .join(' / '); - } - - for (const bookingId of dto.bookingIds) { - const skip = (reason: string) => { - result.skippedCount += 1; - result.results.push({ bookingId, status: 'SKIPPED', reason }); - }; - - const [booking] = await manager.query( - `SELECT b.reference AS "reference", - b.payment_status AS "paymentStatus", - b.freight_type AS "freightType", - b.cargo_total_weight_vgm AS "weight", - company.name AS "customer", - company.tin AS "customerTin", - ${companyNotifyPhoneExpr('company')} AS "customerPhone", - bc.container_numbers AS "containerNumber", - bc.container_quantity AS "containerQuantity", - bc.container_packaging_type AS "containerPackagingType", - COALESCE(ct.cargo_type_name, b.cargo_free_text) AS "cargoDescription", - oy.country AS "originCountry", dy.country AS "destinationCountry", - -- No service_types OR here either — see eligibleBookings above. - (NULLIF(TRIM(COALESCE(b.first_mile_pickup_address, '')), '') IS NOT NULL) AS "hasFirstMile", - fm.id AS "firstMileRequestId", - fm.status AS "firstMileStatus", - v.plate_number AS "firstMileTruckPlateNumber", - v.trailer_plate_no AS "firstMileTrailerPlateNumber", - COALESCE( - NULLIF(TRIM(CONCAT(COALESCE(driver.first_name, ''), ' ', COALESCE(driver.last_name, ''))), ''), - v.assigned_driver_name - ) AS "firstMileDriverName", - driver.phone_number AS "firstMileDriverPhone", - driver.license_number AS "firstMileDriverLicenseNumber", - v.vehicle_type AS "firstMileTruckType", - COALESCE((SELECT string_agg(DISTINCT cta.plate_number, ', ') - FROM freight.customer_truck_assignments cta - WHERE cta.booking_id = b.id AND cta.deleted_at IS NULL), - b.customer_truck_plate_number) AS "customerTruckPlateNumber", - COALESCE((SELECT string_agg(DISTINCT cta.driver_name, ', ') - FROM freight.customer_truck_assignments cta - WHERE cta.booking_id = b.id AND cta.deleted_at IS NULL), - b.customer_truck_driver_name) AS "customerTruckDriverName", - b.customer_truck_type AS "customerTruckType", - b.customer_truck_container_number AS "customerTruckContainerNumber", - b.customer_truck_assigned_at AS "customerTruckAssignedAt", - b.company_id AS "companyId", - (NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL) AS "hasLastMile" - FROM freight.bookings b - LEFT JOIN freight.companies company ON company.id = b.company_id - ${primaryContactUserJoin('company')} - 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 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, - CASE - WHEN COUNT(booking_container.id) = 0 THEN NULL - WHEN bool_or(container_type.is_reefer) THEN 'REEFER_CONTAINER' - WHEN bool_or(container_type.is_open_top) THEN 'OPEN_TOP_CONTAINER' - WHEN MIN(container_type.size_ft) = 20 THEN 'CONTAINER_20FT' - WHEN MIN(container_type.size_ft) = 40 THEN 'CONTAINER_40FT' - WHEN MIN(container_type.size_ft) = 45 THEN 'CONTAINER_45FT' - ELSE 'OTHER_CONTAINER' - END AS container_packaging_type - FROM freight.booking_container booking_container - LEFT JOIN freight.container_types container_type ON container_type.id = booking_container.container_type_id - WHERE booking_container.booking_id = b.id AND booking_container.deleted_at IS NULL - ) bc ON true - LEFT JOIN LATERAL ( - SELECT first_mile.id, first_mile.status, first_mile.vehicle_id - FROM freight.first_mile first_mile - WHERE first_mile.booking_id = b.id AND first_mile.deleted_at IS NULL - ORDER BY first_mile.created_at DESC - LIMIT 1 - ) fm ON true - LEFT JOIN freight.vehicles v ON v.id = fm.vehicle_id - LEFT JOIN freight.drivers driver ON driver.id = v.assigned_driver_id - WHERE b.id = $1 AND b.deleted_at IS NULL LIMIT 1`, - [bookingId], - ); - if (!booking) { skip('Booking not found'); continue; } - if (booking.paymentStatus !== 'PAID') { skip('Booking not PAID'); continue; } - // Direction is derived from the route (yard countries), not the stored field. - const bookingDirection = deriveTradeDirection( - { country: booking.originCountry }, - { country: booking.destinationCountry }, - ); - if (bookingDirection !== dto.direction) { - skip(`Booking route is ${bookingDirection}, not ${dto.direction}`); - continue; - } - if (dto.direction === 'EXPORT' && booking.hasFirstMile) { - if (!booking.firstMileRequestId) { - skip('First-mile request not created'); - continue; - } - if (booking.firstMileStatus !== 'RECEIVED_TO_PORT') { - skip('First-mile truck has not arrived'); - continue; - } + // Each arriving truck is its own unit of work: its own plate and driver, + // its own containers, its own physical-load check and its own GRN batch. + // A single-truck arrival is just the one-element case, so the per-truck + // body below is unchanged from when this only ever handled one truck. + for (const truck of trucks) { + const truckEntranceInput = truck.truckEntrance; + const truckContainerNumbers = truck.containerNumbers; + const truckBookingIds = truck.bookingIds; + // The receive location is whatever the operator selected above — never a + // hand-typed string. Stamp it on the truck entrance for the GRN/notes. + if (truckEntranceInput && !truckEntranceInput.warehouseCodeLocation) { + truckEntranceInput.warehouseCodeLocation = [warehouse.code, yard.code, zone.code] + .filter(Boolean) + .join(' / '); } - const containerQuantity = Number(booking.containerQuantity ?? 0); - if (booking.freightType === 'CONTAINER' && containerQuantity <= 0) { - skip('Container booking has no container quantity'); - continue; - } + for (const bookingId of truckBookingIds) { + const skip = (reason: string) => { + result.skippedCount += 1; + result.results.push({ bookingId, status: 'SKIPPED', reason }); + }; - const now = new Date(); - 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`, + const [booking] = await manager.query( + `SELECT b.reference AS "reference", + b.payment_status AS "paymentStatus", + b.freight_type AS "freightType", + b.cargo_total_weight_vgm AS "weight", + company.name AS "customer", + company.tin AS "customerTin", + ${companyNotifyPhoneExpr('company')} AS "customerPhone", + bc.container_numbers AS "containerNumber", + bc.container_quantity AS "containerQuantity", + bc.container_packaging_type AS "containerPackagingType", + COALESCE(ct.cargo_type_name, b.cargo_free_text) AS "cargoDescription", + oy.country AS "originCountry", dy.country AS "destinationCountry", + -- No service_types OR here either — see eligibleBookings above. + (NULLIF(TRIM(COALESCE(b.first_mile_pickup_address, '')), '') IS NOT NULL) AS "hasFirstMile", + fm.id AS "firstMileRequestId", + fm.status AS "firstMileStatus", + v.plate_number AS "firstMileTruckPlateNumber", + v.trailer_plate_no AS "firstMileTrailerPlateNumber", + COALESCE( + NULLIF(TRIM(CONCAT(COALESCE(driver.first_name, ''), ' ', COALESCE(driver.last_name, ''))), ''), + v.assigned_driver_name + ) AS "firstMileDriverName", + driver.phone_number AS "firstMileDriverPhone", + driver.license_number AS "firstMileDriverLicenseNumber", + v.vehicle_type AS "firstMileTruckType", + COALESCE((SELECT string_agg(DISTINCT cta.plate_number, ', ') + FROM freight.customer_truck_assignments cta + WHERE cta.booking_id = b.id AND cta.deleted_at IS NULL), + b.customer_truck_plate_number) AS "customerTruckPlateNumber", + COALESCE((SELECT string_agg(DISTINCT cta.driver_name, ', ') + FROM freight.customer_truck_assignments cta + WHERE cta.booking_id = b.id AND cta.deleted_at IS NULL), + b.customer_truck_driver_name) AS "customerTruckDriverName", + b.customer_truck_type AS "customerTruckType", + b.customer_truck_container_number AS "customerTruckContainerNumber", + b.customer_truck_assigned_at AS "customerTruckAssignedAt", + b.company_id AS "companyId", + (NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL) AS "hasLastMile" + FROM freight.bookings b + LEFT JOIN freight.companies company ON company.id = b.company_id + ${primaryContactUserJoin('company')} + 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 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, + CASE + WHEN COUNT(booking_container.id) = 0 THEN NULL + WHEN bool_or(container_type.is_reefer) THEN 'REEFER_CONTAINER' + WHEN bool_or(container_type.is_open_top) THEN 'OPEN_TOP_CONTAINER' + WHEN MIN(container_type.size_ft) = 20 THEN 'CONTAINER_20FT' + WHEN MIN(container_type.size_ft) = 40 THEN 'CONTAINER_40FT' + WHEN MIN(container_type.size_ft) = 45 THEN 'CONTAINER_45FT' + ELSE 'OTHER_CONTAINER' + END AS container_packaging_type + FROM freight.booking_container booking_container + LEFT JOIN freight.container_types container_type ON container_type.id = booking_container.container_type_id + WHERE booking_container.booking_id = b.id AND booking_container.deleted_at IS NULL + ) bc ON true + LEFT JOIN LATERAL ( + SELECT first_mile.id, first_mile.status, first_mile.vehicle_id + FROM freight.first_mile first_mile + WHERE first_mile.booking_id = b.id AND first_mile.deleted_at IS NULL + ORDER BY first_mile.created_at DESC + LIMIT 1 + ) fm ON true + LEFT JOIN freight.vehicles v ON v.id = fm.vehicle_id + LEFT JOIN freight.drivers driver ON driver.id = v.assigned_driver_id + WHERE b.id = $1 AND b.deleted_at IS NULL LIMIT 1`, [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 (!booking) { skip('Booking not found'); continue; } + if (booking.paymentStatus !== 'PAID') { skip('Booking not PAID'); continue; } + // Direction is derived from the route (yard countries), not the stored field. + const bookingDirection = deriveTradeDirection( + { country: booking.originCountry }, + { country: booking.destinationCountry }, + ); + if (bookingDirection !== dto.direction) { + skip(`Booking route is ${bookingDirection}, not ${dto.direction}`); + continue; } - - // 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}`, - ); + if (dto.direction === 'EXPORT' && booking.hasFirstMile) { + if (!booking.firstMileRequestId) { + skip('First-mile request not created'); + continue; + } + if (booking.firstMileStatus !== 'RECEIVED_TO_PORT') { + skip('First-mile truck has not arrived'); + continue; } } - 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'); + const containerQuantity = Number(booking.containerQuantity ?? 0); + if (booking.freightType === 'CONTAINER' && containerQuantity <= 0) { + skip('Container booking has no container quantity'); 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: - booking.freightType === 'CONTAINER' - ? `${selectedUnits.length} container(s) arrived: ${selectedUnits - .map((unit) => unit.containerNumber) - .join(', ')}. ${remainingAfter} container(s) left.` - : `Bulk received (${dto.direction})`, - truckEntrance, - }); + const now = new Date(); + const truckEntrance = truckEntranceInput + ? this.mergeSystemTruckEntrance(truckEntranceInput, 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 && truckEntranceInput) { + truckEntrance.truckPlateNumber = truckEntranceInput.truckPlateNumber; + truckEntrance.driverName = truckEntranceInput.driverName; + truckEntrance.driverPhone = truckEntranceInput.driverPhone; + truckEntrance.truckType = truckEntranceInput.truckType; + } + if (dto.direction === 'EXPORT') { + this.assertTruckEntrance(truckEntrance); + } - // Validate capacity before saving - 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); + 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); - 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 (booking.freightType === 'CONTAINER') { + if (truckBookingIds.length !== 1) { + throw new BadRequestException( + 'Receive one container booking per arriving truck so its containers and documents stay separate', + ); + } + const selectedNumbers = (truckContainerNumbers ?? []).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 ?? ''), }); - if (!container && !unit.containerTypeId) { - throw new BadRequestException( - `Container ${unit.containerNumber} has no container type and cannot be received`, + 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, ); } - if (!container) { - container = await containers.save( - containers.create({ - containerNumber: unit.containerNumber, - containerTypeId: unit.containerTypeId as string, - bookingContainerId: unit.bookingContainerId, + } 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: + 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 = + 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 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, - tareWeight: 0, - maxGrossWeight: Number(unit.weightTons || 0), status: 'LOADED', - wagonId: null, - position: null, - wagonBookingAllocationId: null, + 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, }), ); - } else { - await containers.update(container.id, { - bookingId, - bookingContainerId: unit.bookingContainerId, - sealNumber: unit.sealNumber, - status: 'LOADED', - deletedAt: null, - }); + 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, - containerId: container.id, quantity: 1, - weight: Number(unit.weightTons || 0), + weight, grnNumber, status: 'RECEIVED', arrivedAt: now, @@ -1917,90 +1994,60 @@ export class WarehouseInventoryService { ); 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({ + + // Update warehouse/yard/zone capacity counters + await this.applyCapacityDelta(manager, dto, weight, 0, containerCount); + + // Export self-haul: this receive IS the truck's arrival — see + // markCustomerTruckArrived / receive()'s single-booking mirror. + if (dto.direction === 'EXPORT') { + await this.markCustomerTruckArrived(manager, bookingId, truckEntrance?.truckPlateNumber); + } + + await this.activityLog.record( + { + activityType: 'INVENTORY_RECEIVED', + inventoryId: inventoryIds[0], warehouseId: dto.warehouseId, - yardId: dto.yardId, - zoneId: dto.zoneId, - bookingId, - quantity: 1, - weight, - grnNumber, - status: 'RECEIVED', - arrivedAt: now, - notes: receiveNote, - }), + description: truckEntrance?.truckPlateNumber + ? `GRN ${grnNumber}: bulk received ${dto.direction} booking via truck ${truckEntrance.truckPlateNumber}` + : `GRN ${grnNumber}: bulk received ${dto.direction} booking`, + performedBy: dto.performedBy, + }, + manager, ); - inventoryIds.push(saved.id); - } - // Update warehouse/yard/zone capacity counters - await this.applyCapacityDelta(manager, dto, weight, 0, containerCount); - - // Export self-haul: this receive IS the truck's arrival — see - // markCustomerTruckArrived / receive()'s single-booking mirror. - if (dto.direction === 'EXPORT') { - await this.markCustomerTruckArrived(manager, bookingId, truckEntrance?.truckPlateNumber); - } - - await this.activityLog.record( - { - activityType: 'INVENTORY_RECEIVED', - inventoryId: inventoryIds[0], - warehouseId: dto.warehouseId, - description: truckEntrance?.truckPlateNumber - ? `GRN ${grnNumber}: bulk received ${dto.direction} booking via truck ${truckEntrance.truckPlateNumber}` - : `GRN ${grnNumber}: bulk received ${dto.direction} booking`, - performedBy: dto.performedBy, - }, - manager, - ); - - // Queued, not sent here: an SMS/email round-trip inside the transaction - // holds capacity/location locks open for the whole gateway latency. - pendingNotifications.push({ - owner: { - phone: truckEntrance?.customerPhone ?? booking.customerPhone, - ownerName: truckEntrance?.ownerName ?? booking.customer, - bookingReference: truckEntrance?.edrDigitalBookingId ?? booking.reference, - grnNumber, - direction: dto.direction, - warehouseId: dto.warehouseId, + // Queued, not sent here: an SMS/email round-trip inside the transaction + // holds capacity/location locks open for the whole gateway latency. + pendingNotifications.push({ + owner: { + phone: truckEntrance?.customerPhone ?? booking.customerPhone, + ownerName: truckEntrance?.ownerName ?? booking.customer, + bookingReference: truckEntrance?.edrDigitalBookingId ?? booking.reference, + grnNumber, + direction: dto.direction, + warehouseId: dto.warehouseId, + bookingId, + }, + booking, bookingId, - }, - booking, - bookingId, - }); + }); - result.receivedCount += 1; - result.results.push({ - bookingId, - status: 'RECEIVED', - inventoryId: inventoryIds[0], - inventoryIds, - grnNumber, - ...(booking.freightType === 'CONTAINER' - ? { - receivedContainers: receivedAfter, - remainingContainers: remainingAfter, - } - : {}), - }); + result.receivedCount += 1; + result.results.push({ + bookingId, + status: 'RECEIVED', + inventoryId: inventoryIds[0], + inventoryIds, + grnNumber, + ...(booking.freightType === 'CONTAINER' + ? { + receivedContainers: receivedAfter, + remainingContainers: remainingAfter, + } + : {}), + }); + } } }); From 6ebb402131777a2e24de8128930abf1b0edd75b8 Mon Sep 17 00:00:00 2001 From: hager Date: Sun, 6 Sep 2026 19:52:46 +0000 Subject: [PATCH 2/2] feat(freight-backoffice): enter several trucks on one arrival The receive drawer captured one truck per arrival, so a customer whose containers came on three trucks had to be received three times over. Stage trucks instead. "Add another truck" validates the form exactly as a single-truck receive does, files the truck with its own plate, driver and containers, and clears the form for the next one. The staged trucks are listed above the form with their boxes, each removable, and the arrival is received in one operation. Containers already staged on an earlier truck drop out of the picker, so the same box cannot be sent twice. Receiving with a part-filled form and trucks already staged is treated as the operator still typing rather than a finished arrival: it asks them to finish or clear the truck instead of silently dropping what they entered. An untouched form receives just the staged trucks. With no trucks staged the drawer sends the original truckEntrance and containerNumbers fields, so a single-truck arrival takes exactly the path it did before. Verified: backoffice type-check reports 982 errors both with and without this change (all pre-existing, none in the touched files); 13 warehouse API suites pass (73 tests). Co-Authored-By: Claude Opus 5 --- .../warehouses/ReceiveInventoryModal.tsx | 206 +++++++++++++++--- .../backoffice/src/types/warehouse.ts | 17 ++ 2 files changed, 189 insertions(+), 34 deletions(-) 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 1f3a1cf81..63035a34f 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx @@ -72,6 +72,7 @@ import type { ImportUnloadedItem, ReadyToLoadRow, ReceiveInventoryPayload, + ReceiveTruckPayload, TruckEntrancePayload, Warehouse, WarehouseInventoryItem, @@ -864,6 +865,12 @@ function EligibleTab({ const [packagingFreightType, setPackagingFreightType] = useState('MIXED'); const [selectedCustomerTruckId, setSelectedCustomerTruckId] = useState(null); const [selectedContainerNumbers, setSelectedContainerNumbers] = useState([]); + /** + * Trucks already staged for this arrival. A customer's containers often come + * on several trucks at once; each is captured with its own plate, driver and + * boxes, then the whole arrival is received in one operation. + */ + const [stagedTrucks, setStagedTrucks] = useState([]); const locationReady = Boolean(location.warehouseId && location.yardId && location.zoneId); const canReceiveBooking = (row: EligibleBooking) => @@ -968,10 +975,18 @@ function EligibleTab({ const assignedNumbersForSelectedTruck = new Set( (selectedCustomerTruck?.containers ?? []).map((container) => container.containerNumber.toUpperCase()), ); + // A box already staged on an earlier truck is spoken for — offering it again + // would send the same container twice and be rejected by the API. + const stagedContainerNumbers = new Set( + stagedTrucks.flatMap((truck) => + (truck.containerNumbers ?? []).map((number) => number.toUpperCase()), + ), + ); const selectableContainerUnits = pendingContainerUnits.filter( (unit) => - assignedNumbersForSelectedTruck.size === 0 || - assignedNumbersForSelectedTruck.has(unit.containerNumber.toUpperCase()), + !stagedContainerNumbers.has(unit.containerNumber.toUpperCase()) && + (assignedNumbersForSelectedTruck.size === 0 || + assignedNumbersForSelectedTruck.has(unit.containerNumber.toUpperCase())), ); const selectedContainerUnits = pendingContainerUnits.filter((unit) => selectedContainerNumbers.includes(unit.containerNumber), @@ -1063,17 +1078,25 @@ function EligibleTab({ bookingIds: string[], truckEntrance?: TruckEntrancePayload, containerNumbers?: string[], + trucks?: ReceiveTruckPayload[], ) => { 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 { + // A multi-truck arrival sends `trucks` and nothing else: the API reads the + // single-truck fields only when `trucks` is absent, so sending both would + // silently drop the staged list. const r = await bulkReceive.mutateAsync({ direction, ...location, bookingIds, - ...(containerNumbers?.length ? { containerNumbers } : {}), - ...(truckEntrance ? { truckEntrance } : {}), + ...(trucks?.length + ? { trucks } + : { + ...(containerNumbers?.length ? { containerNumbers } : {}), + ...(truckEntrance ? { truckEntrance } : {}), + }), }); const receivedProgress = r.results.find( (item) => item.receivedContainers != null && item.remainingContainers != null, @@ -1081,7 +1104,11 @@ function EligibleTab({ toast({ title: `${r.receivedCount} received at ${direction === 'EXPORT' ? 'facility' : 'warehouse'}`, description: receivedProgress - ? `${containerNumbers?.length ?? 0} container(s) arrived. ${receivedProgress.remainingContainers} container(s) left. GRN ${receivedProgress.grnNumber}.` + ? `${ + trucks?.length + ? trucks.reduce((sum, t) => sum + (t.containerNumbers?.length ?? 0), 0) + : (containerNumbers?.length ?? 0) + } container(s) arrived on ${trucks?.length ?? 1} truck(s). ${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); @@ -1195,6 +1222,7 @@ function EligibleTab({ setPendingReceiveIds(filteredIds); setSelectedCustomerTruckId(null); setSelectedContainerNumbers([]); + setStagedTrucks([]); setReceivedAt(new Date().toISOString()); setTruckForm(normalizedForm); setLockedTruckFields({ @@ -1214,40 +1242,98 @@ function EligibleTab({ setTruckOpen(true); }; - const receive = async () => { + /** + * Validate whatever is currently in the truck form. Shared by "Add truck" and + * the final receive so a staged truck is held to exactly the same rules as a + * single-truck arrival. + */ + const truckFormError = (): string | null => { if (!truckForm.truckPlateNumber.trim() || !truckForm.driverName.trim() || !truckForm.driverPhone.trim()) { - toast({ variant: 'destructive', title: 'Truck and driver information are required' }); - return; + return 'Truck and driver information are required'; } if (!pendingUsesFirstMile && truckForm.weighingRequired == null) { - toast({ variant: 'destructive', title: 'Select whether customer truck weighing is required' }); - return; + return 'Select whether customer truck weighing is required'; } if (truckForm.weighingRequired && (truckForm.grossWeightKg === '' || truckForm.exitTareWeightKg === '')) { - toast({ variant: 'destructive', title: 'Gross weight and exit tare weight are required when weighing is Yes' }); - return; + return 'Gross weight and exit tare weight are required when weighing is Yes'; } if (pendingContainerBooking && selectedContainerNumbers.length === 0) { - toast({ variant: 'destructive', title: 'Select the containers arriving on this truck' }); + return 'Select the containers arriving on this truck'; + } + return containerCapacityError; + }; + + /** The current form as a payload, with the container summary fields filled in. */ + const currentTruckPayload = (): ReceiveTruckPayload => ({ + truckEntrance: toTruckEntrancePayload({ + ...truckForm, + ...(pendingContainerBooking + ? { + assignedEquipmentNumber: selectedContainerNumbers.join(', '), + unitCount: selectedContainerNumbers.length, + netWeightKg: selectedContainerWeight, + } + : {}), + }), + ...(pendingContainerBooking ? { containerNumbers: selectedContainerNumbers } : {}), + }); + + /** Stage the truck on screen and clear the form for the next one. */ + const addTruck = () => { + const error = truckFormError(); + if (error) { + toast({ variant: 'destructive', title: error }); return; } - if (containerCapacityError) { - toast({ variant: 'destructive', title: 'Truck capacity exceeded', description: containerCapacityError }); + setStagedTrucks((current) => [...current, currentTruckPayload()]); + setTruckForm(emptyTruckEntrance()); + setSelectedContainerNumbers([]); + setSelectedCustomerTruckId(null); + setLockedTruckFields({}); + }; + + const removeStagedTruck = (index: number) => + setStagedTrucks((current) => current.filter((_, position) => position !== index)); + + const receive = async () => { + // With trucks staged, a part-filled form is the operator still typing the + // next truck — receiving would silently drop it, so make them finish or + // clear it. An empty form just means every truck is already staged. + const formTouched = + truckForm.truckPlateNumber.trim() !== '' || + truckForm.driverName.trim() !== '' || + selectedContainerNumbers.length > 0; + + if (stagedTrucks.length > 0 && !formTouched) { + await receiveBookings(pendingReceiveIds, undefined, undefined, stagedTrucks); return; } + + const error = truckFormError(); + if (error) { + toast({ + variant: 'destructive', + title: error, + ...(stagedTrucks.length > 0 + ? { description: 'Finish this truck or clear it, then receive the arrival.' } + : {}), + }); + return; + } + + if (stagedTrucks.length > 0) { + await receiveBookings(pendingReceiveIds, undefined, undefined, [ + ...stagedTrucks, + currentTruckPayload(), + ]); + return; + } + + const single = currentTruckPayload(); await receiveBookings( pendingReceiveIds, - toTruckEntrancePayload({ - ...truckForm, - ...(pendingContainerBooking - ? { - assignedEquipmentNumber: selectedContainerNumbers.join(', '), - unitCount: selectedContainerNumbers.length, - netWeightKg: selectedContainerWeight, - } - : {}), - }), - pendingContainerBooking ? selectedContainerNumbers : undefined, + single.truckEntrance, + single.containerNumbers, ); }; @@ -1629,6 +1715,44 @@ function EligibleTab({ + {stagedTrucks.length > 0 ? ( + + + Trucks in this arrival ({stagedTrucks.length}) + + {stagedTrucks.map((truck, index) => ( + + + + {truck.truckEntrance.truckPlateNumber} + {truck.truckEntrance.driverName ? ` — ${truck.truckEntrance.driverName}` : ''} + + + {truck.containerNumbers?.length + ? truck.containerNumbers.join(', ') + : 'Bulk arrival'} + + + + + ))} + + ) : null} - - - + + + + diff --git a/apps/edr-freight-web/backoffice/src/types/warehouse.ts b/apps/edr-freight-web/backoffice/src/types/warehouse.ts index 47107ff9a..dae85dad9 100644 --- a/apps/edr-freight-web/backoffice/src/types/warehouse.ts +++ b/apps/edr-freight-web/backoffice/src/types/warehouse.ts @@ -588,14 +588,31 @@ export interface EligibleBooking { remainingContainerCount: number; } +/** + * One truck in a multi-truck arrival, with the containers it carries. Each + * truck keeps its own plate, driver and load, and the API validates capacity + * and container-to-plate assignment per truck. + */ +export interface ReceiveTruckPayload { + truckEntrance: TruckEntrancePayload; + containerNumbers?: string[]; + /** Defaults to the operation's bookingIds when omitted. */ + bookingIds?: string[]; +} + export interface BulkReceivePayload { direction: 'IMPORT' | 'EXPORT'; warehouseId: string; yardId: string; zoneId: string; bookingIds: string[]; + /** + * Single-truck arrival. Superseded by `trucks` when several trucks deliver + * the same arrival; the API accepts either shape. + */ containerNumbers?: string[]; truckEntrance?: TruckEntrancePayload; + trucks?: ReceiveTruckPayload[]; } export interface BulkReceiveResult {