Merge branch 'multi-truck-receive' into dev

This commit is contained in:
hager
2026-09-06 20:13:50 +00:00
4 changed files with 666 additions and 406 deletions

View File

@@ -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()

View File

@@ -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<string>();
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,
}
: {}),
});
}
}
});

View File

@@ -72,6 +72,7 @@ import type {
ImportUnloadedItem,
ReadyToLoadRow,
ReceiveInventoryPayload,
ReceiveTruckPayload,
TruckEntrancePayload,
Warehouse,
WarehouseInventoryItem,
@@ -864,6 +865,12 @@ function EligibleTab({
const [packagingFreightType, setPackagingFreightType] = useState<PackagingFreightType>('MIXED');
const [selectedCustomerTruckId, setSelectedCustomerTruckId] = useState<string | null>(null);
const [selectedContainerNumbers, setSelectedContainerNumbers] = useState<string[]>([]);
/**
* 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<ReceiveTruckPayload[]>([]);
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({
</Table.Tbody>
</Table>
</Table.ScrollContainer>
{stagedTrucks.length > 0 ? (
<Stack gap={6}>
<Text size="sm" fw={600}>
Trucks in this arrival ({stagedTrucks.length})
</Text>
{stagedTrucks.map((truck, index) => (
<Group
key={`${truck.truckEntrance.truckPlateNumber}-${index}`}
justify="space-between"
wrap="nowrap"
px="sm"
py={6}
style={{ border: '1px solid var(--mantine-color-gray-3)', borderRadius: 8 }}
>
<Stack gap={0} style={{ minWidth: 0 }}>
<Text size="sm" fw={600}>
{truck.truckEntrance.truckPlateNumber}
{truck.truckEntrance.driverName ? `${truck.truckEntrance.driverName}` : ''}
</Text>
<Text size="xs" c="dimmed">
{truck.containerNumbers?.length
? truck.containerNumbers.join(', ')
: 'Bulk arrival'}
</Text>
</Stack>
<Button
variant="subtle"
color="red"
size="compact-sm"
onClick={() => removeStagedTruck(index)}
disabled={bulkReceive.isPending}
>
Remove
</Button>
</Group>
))}
</Stack>
) : null}
<TruckEntranceFields
value={truckForm}
onChange={setTruckForm}
@@ -1636,15 +1760,29 @@ function EligibleTab({
packagingFreightType={packagingFreightType}
allowTruckWeighing={!pendingUsesFirstMile}
/>
<Group justify="flex-end">
<Button variant="default" onClick={() => setTruckOpen(false)} disabled={bulkReceive.isPending}>
Cancel
</Button>
<Button leftSection={<Truck size={16} />} loading={bulkReceive.isPending} onClick={receive}>
{pendingContainerBooking
? 'Receive Selected Containers & Generate CAS + GRN'
: 'Register Arrival & Generate GRN'}
<Group justify="space-between">
{/* Staging a truck clears the form for the next one; the arrival is
received once every truck has been entered. */}
<Button
variant="light"
leftSection={<Truck size={16} />}
onClick={addTruck}
disabled={bulkReceive.isPending || selectableContainerUnits.length === 0}
>
Add another truck
</Button>
<Group>
<Button variant="default" onClick={() => setTruckOpen(false)} disabled={bulkReceive.isPending}>
Cancel
</Button>
<Button leftSection={<Truck size={16} />} loading={bulkReceive.isPending} onClick={receive}>
{stagedTrucks.length > 0
? `Receive ${stagedTrucks.length} Truck(s) & Generate CAS + GRN`
: pendingContainerBooking
? 'Receive Selected Containers & Generate CAS + GRN'
: 'Register Arrival & Generate GRN'}
</Button>
</Group>
</Group>
</Stack>
</Modal>

View File

@@ -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 {