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 2c4fc256a..a1fa835df 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 @@ -1582,335 +1582,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, @@ -1919,90 +1996,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, + } + : {}), + }); + } } }); 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 {