mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 16:35:42 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
@@ -176,6 +176,49 @@ export class TruckEntranceDto {
|
|||||||
warehouseManagerName?: string;
|
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. */
|
/** Bulk-receive eligible PAID bookings into a warehouse location, by trade direction. */
|
||||||
export class BulkReceiveDto {
|
export class BulkReceiveDto {
|
||||||
@ApiProperty({ enum: ['IMPORT', 'EXPORT'] })
|
@ApiProperty({ enum: ['IMPORT', 'EXPORT'] })
|
||||||
@@ -200,9 +243,24 @@ export class BulkReceiveDto {
|
|||||||
@IsUUID('all', { each: true })
|
@IsUUID('all', { each: true })
|
||||||
bookingIds!: string[];
|
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
|
* 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.
|
* received one truck at a time: either one 40ft box or up to two 20ft boxes.
|
||||||
|
* Ignored when `trucks` is given.
|
||||||
*/
|
*/
|
||||||
@ApiPropertyOptional({ type: [String] })
|
@ApiPropertyOptional({ type: [String] })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@@ -1580,335 +1580,412 @@ export class WarehouseInventoryService {
|
|||||||
bookingId: string;
|
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) => {
|
await this.dataSource.transaction(async (manager) => {
|
||||||
const { warehouse, yard, zone } = await this.validateLocation(manager, {
|
const { warehouse, yard, zone } = await this.validateLocation(manager, {
|
||||||
warehouseId: dto.warehouseId,
|
warehouseId: dto.warehouseId,
|
||||||
yardId: dto.yardId,
|
yardId: dto.yardId,
|
||||||
zoneId: dto.zoneId,
|
zoneId: dto.zoneId,
|
||||||
});
|
});
|
||||||
// The receive location is whatever the operator selected above — never a
|
// Each arriving truck is its own unit of work: its own plate and driver,
|
||||||
// hand-typed string. Stamp it on the truck entrance for the GRN/notes.
|
// its own containers, its own physical-load check and its own GRN batch.
|
||||||
if (dto.truckEntrance && !dto.truckEntrance.warehouseCodeLocation) {
|
// A single-truck arrival is just the one-element case, so the per-truck
|
||||||
dto.truckEntrance.warehouseCodeLocation = [warehouse.code, yard.code, zone.code]
|
// body below is unchanged from when this only ever handled one truck.
|
||||||
.filter(Boolean)
|
for (const truck of trucks) {
|
||||||
.join(' / ');
|
const truckEntranceInput = truck.truckEntrance;
|
||||||
}
|
const truckContainerNumbers = truck.containerNumbers;
|
||||||
|
const truckBookingIds = truck.bookingIds;
|
||||||
for (const bookingId of dto.bookingIds) {
|
// The receive location is whatever the operator selected above — never a
|
||||||
const skip = (reason: string) => {
|
// hand-typed string. Stamp it on the truck entrance for the GRN/notes.
|
||||||
result.skippedCount += 1;
|
if (truckEntranceInput && !truckEntranceInput.warehouseCodeLocation) {
|
||||||
result.results.push({ bookingId, status: 'SKIPPED', reason });
|
truckEntranceInput.warehouseCodeLocation = [warehouse.code, yard.code, zone.code]
|
||||||
};
|
.filter(Boolean)
|
||||||
|
.join(' / ');
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const containerQuantity = Number(booking.containerQuantity ?? 0);
|
for (const bookingId of truckBookingIds) {
|
||||||
if (booking.freightType === 'CONTAINER' && containerQuantity <= 0) {
|
const skip = (reason: string) => {
|
||||||
skip('Container booking has no container quantity');
|
result.skippedCount += 1;
|
||||||
continue;
|
result.results.push({ bookingId, status: 'SKIPPED', reason });
|
||||||
}
|
};
|
||||||
|
|
||||||
const now = new Date();
|
const [booking] = await manager.query(
|
||||||
const truckEntrance = dto.truckEntrance
|
`SELECT b.reference AS "reference",
|
||||||
? this.mergeSystemTruckEntrance(dto.truckEntrance, booking)
|
b.payment_status AS "paymentStatus",
|
||||||
: undefined;
|
b.freight_type AS "freightType",
|
||||||
// Multi-truck self-haul is selected explicitly at the gate. The booking
|
b.cargo_total_weight_vgm AS "weight",
|
||||||
// source contains comma-joined legacy summary fields, which must never
|
company.name AS "customer",
|
||||||
// replace the one physical truck the receiver selected.
|
company.tin AS "customerTin",
|
||||||
if (truckEntrance && !booking.hasFirstMile && dto.truckEntrance) {
|
${companyNotifyPhoneExpr('company')} AS "customerPhone",
|
||||||
truckEntrance.truckPlateNumber = dto.truckEntrance.truckPlateNumber;
|
bc.container_numbers AS "containerNumber",
|
||||||
truckEntrance.driverName = dto.truckEntrance.driverName;
|
bc.container_quantity AS "containerQuantity",
|
||||||
truckEntrance.driverPhone = dto.truckEntrance.driverPhone;
|
bc.container_packaging_type AS "containerPackagingType",
|
||||||
truckEntrance.truckType = dto.truckEntrance.truckType;
|
COALESCE(ct.cargo_type_name, b.cargo_free_text) AS "cargoDescription",
|
||||||
}
|
oy.country AS "originCountry", dy.country AS "destinationCountry",
|
||||||
if (dto.direction === 'EXPORT') {
|
-- No service_types OR here either — see eligibleBookings above.
|
||||||
this.assertTruckEntrance(truckEntrance);
|
(NULLIF(TRIM(COALESCE(b.first_mile_pickup_address, '')), '') IS NOT NULL) AS "hasFirstMile",
|
||||||
}
|
fm.id AS "firstMileRequestId",
|
||||||
|
fm.status AS "firstMileStatus",
|
||||||
type ReceiveContainerUnit = {
|
v.plate_number AS "firstMileTruckPlateNumber",
|
||||||
containerNumber: string;
|
v.trailer_plate_no AS "firstMileTrailerPlateNumber",
|
||||||
containerSize: string | null;
|
COALESCE(
|
||||||
weightTons: string | number;
|
NULLIF(TRIM(CONCAT(COALESCE(driver.first_name, ''), ' ', COALESCE(driver.last_name, ''))), ''),
|
||||||
sealNumber: string | null;
|
v.assigned_driver_name
|
||||||
bookingContainerId: string;
|
) AS "firstMileDriverName",
|
||||||
containerTypeId: string | null;
|
driver.phone_number AS "firstMileDriverPhone",
|
||||||
received: boolean;
|
driver.license_number AS "firstMileDriverLicenseNumber",
|
||||||
};
|
v.vehicle_type AS "firstMileTruckType",
|
||||||
let selectedUnits: ReceiveContainerUnit[] = [];
|
COALESCE((SELECT string_agg(DISTINCT cta.plate_number, ', ')
|
||||||
let grnNumber = this.generateGrnNumber(dto.direction, bookingId, now, booking.customer);
|
FROM freight.customer_truck_assignments cta
|
||||||
|
WHERE cta.booking_id = b.id AND cta.deleted_at IS NULL),
|
||||||
if (booking.freightType === 'CONTAINER') {
|
b.customer_truck_plate_number) AS "customerTruckPlateNumber",
|
||||||
if (dto.bookingIds.length !== 1) {
|
COALESCE((SELECT string_agg(DISTINCT cta.driver_name, ', ')
|
||||||
throw new BadRequestException(
|
FROM freight.customer_truck_assignments cta
|
||||||
'Receive one container booking per arriving truck so its containers and documents stay separate',
|
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",
|
||||||
const selectedNumbers = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase());
|
b.customer_truck_container_number AS "customerTruckContainerNumber",
|
||||||
if (!selectedNumbers.length) {
|
b.customer_truck_assigned_at AS "customerTruckAssignedAt",
|
||||||
throw new BadRequestException('Select the containers arriving on this truck');
|
b.company_id AS "companyId",
|
||||||
}
|
(NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL) AS "hasLastMile"
|
||||||
const allUnits: ReceiveContainerUnit[] = await manager.query(
|
FROM freight.bookings b
|
||||||
`SELECT UPPER(bcu.container_number) AS "containerNumber",
|
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||||||
bc.container_size AS "containerSize",
|
${primaryContactUserJoin('company')}
|
||||||
bcu.vgm_tons AS "weightTons",
|
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
|
||||||
bcu.seal_number AS "sealNumber",
|
LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id
|
||||||
bc.id AS "bookingContainerId",
|
LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id
|
||||||
bc.container_type_id AS "containerTypeId",
|
LEFT JOIN LATERAL (
|
||||||
bcu.received_to_port AS received
|
SELECT string_agg(NULLIF(booking_container.container_number, ''), ', ' ORDER BY booking_container.container_number) AS container_numbers,
|
||||||
FROM freight.booking_container_units bcu
|
SUM(booking_container.quantity)::int AS container_quantity,
|
||||||
JOIN freight.booking_container bc
|
CASE
|
||||||
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
|
WHEN COUNT(booking_container.id) = 0 THEN NULL
|
||||||
WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL
|
WHEN bool_or(container_type.is_reefer) THEN 'REEFER_CONTAINER'
|
||||||
FOR UPDATE OF bcu`,
|
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],
|
[bookingId],
|
||||||
);
|
);
|
||||||
assertTruckLoad({
|
if (!booking) { skip('Booking not found'); continue; }
|
||||||
containers: selectedNumbers,
|
if (booking.paymentStatus !== 'PAID') { skip('Booking not PAID'); continue; }
|
||||||
bookingContainers: allUnits.map((unit) => unit.containerNumber),
|
// Direction is derived from the route (yard countries), not the stored field.
|
||||||
sizes: allUnits
|
const bookingDirection = deriveTradeDirection(
|
||||||
.filter((unit) => selectedNumbers.includes(unit.containerNumber))
|
{ country: booking.originCountry },
|
||||||
.map((unit) => unit.containerSize ?? ''),
|
{ country: booking.destinationCountry },
|
||||||
});
|
);
|
||||||
selectedUnits = allUnits.filter((unit) => selectedNumbers.includes(unit.containerNumber));
|
if (bookingDirection !== dto.direction) {
|
||||||
if (selectedUnits.some((unit) => unit.received)) {
|
skip(`Booking route is ${bookingDirection}, not ${dto.direction}`);
|
||||||
const repeated = selectedUnits.filter((unit) => unit.received).map((unit) => unit.containerNumber);
|
continue;
|
||||||
throw new BadRequestException(`Container(s) already received: ${repeated.join(', ')}`);
|
|
||||||
}
|
}
|
||||||
|
if (dto.direction === 'EXPORT' && booking.hasFirstMile) {
|
||||||
// If this is a customer-assigned truck, it may only deliver the boxes
|
if (!booking.firstMileRequestId) {
|
||||||
// assigned to that plate. Manual/unassigned arrivals retain the same
|
skip('First-mile request not created');
|
||||||
// physical capacity validation but have no assignment list to check.
|
continue;
|
||||||
if (truckEntrance?.truckPlateNumber) {
|
}
|
||||||
const assigned: Array<{ containerNumber: string }> = await manager.query(
|
if (booking.firstMileStatus !== 'RECEIVED_TO_PORT') {
|
||||||
`SELECT UPPER(ctc.container_number) AS "containerNumber"
|
skip('First-mile truck has not arrived');
|
||||||
FROM freight.customer_truck_assignments cta
|
continue;
|
||||||
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(
|
const containerQuantity = Number(booking.containerQuantity ?? 0);
|
||||||
`SELECT COUNT(DISTINCT inv.grn_number) AS batches
|
if (booking.freightType === 'CONTAINER' && containerQuantity <= 0) {
|
||||||
FROM freight.warehouse_inventory inv
|
skip('Container booking has no container quantity');
|
||||||
WHERE inv.booking_id = $1
|
|
||||||
AND inv.grn_number IS NOT NULL
|
|
||||||
AND inv.deleted_at IS NULL`,
|
|
||||||
[bookingId],
|
|
||||||
);
|
|
||||||
grnNumber = `${grnNumber}-${String(Number(batches ?? 0) + 1).padStart(2, '0')}`;
|
|
||||||
if (truckEntrance) {
|
|
||||||
truckEntrance.assignedEquipmentNumber = selectedNumbers.join(', ');
|
|
||||||
truckEntrance.unitCount = selectedNumbers.length;
|
|
||||||
truckEntrance.netWeightKg = selectedUnits.reduce(
|
|
||||||
(total, unit) => total + Number(unit.weightTons || 0),
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const existing = await manager
|
|
||||||
.getRepository(WarehouseInventory)
|
|
||||||
.findOne({ where: { bookingId } });
|
|
||||||
if (existing) {
|
|
||||||
skip('Already received');
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const receivedBefore =
|
const now = new Date();
|
||||||
booking.freightType === 'CONTAINER'
|
const truckEntrance = truckEntranceInput
|
||||||
? Number(
|
? this.mergeSystemTruckEntrance(truckEntranceInput, booking)
|
||||||
(
|
: undefined;
|
||||||
await manager.query(
|
// Multi-truck self-haul is selected explicitly at the gate. The booking
|
||||||
`SELECT COUNT(*) AS count
|
// source contains comma-joined legacy summary fields, which must never
|
||||||
FROM freight.booking_container_units bcu
|
// replace the one physical truck the receiver selected.
|
||||||
JOIN freight.booking_container bc
|
if (truckEntrance && !booking.hasFirstMile && truckEntranceInput) {
|
||||||
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
|
truckEntrance.truckPlateNumber = truckEntranceInput.truckPlateNumber;
|
||||||
WHERE bc.booking_id = $1
|
truckEntrance.driverName = truckEntranceInput.driverName;
|
||||||
AND bcu.received_to_port = true
|
truckEntrance.driverPhone = truckEntranceInput.driverPhone;
|
||||||
AND bcu.deleted_at IS NULL`,
|
truckEntrance.truckType = truckEntranceInput.truckType;
|
||||||
[bookingId],
|
}
|
||||||
)
|
if (dto.direction === 'EXPORT') {
|
||||||
)[0]?.count ?? 0,
|
this.assertTruckEntrance(truckEntrance);
|
||||||
)
|
}
|
||||||
: 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
|
type ReceiveContainerUnit = {
|
||||||
const weight =
|
containerNumber: string;
|
||||||
booking.freightType === 'CONTAINER'
|
containerSize: string | null;
|
||||||
? selectedUnits.reduce((total, unit) => total + Number(unit.weightTons || 0), 0)
|
weightTons: string | number;
|
||||||
: Number(booking.weight) || 0;
|
sealNumber: string | null;
|
||||||
const containerCount = booking.freightType === 'CONTAINER' ? selectedUnits.length : 0;
|
bookingContainerId: string;
|
||||||
this.assertCapacity('Warehouse', warehouse, weight, 0, containerCount);
|
containerTypeId: string | null;
|
||||||
this.assertCapacity('Yard', yard, weight, 0, containerCount);
|
received: boolean;
|
||||||
this.assertCapacity('Zone', zone, weight, 0, containerCount);
|
};
|
||||||
|
let selectedUnits: ReceiveContainerUnit[] = [];
|
||||||
|
let grnNumber = this.generateGrnNumber(dto.direction, bookingId, now, booking.customer);
|
||||||
|
|
||||||
const inventoryIds: string[] = [];
|
if (booking.freightType === 'CONTAINER') {
|
||||||
if (booking.freightType === 'CONTAINER') {
|
if (truckBookingIds.length !== 1) {
|
||||||
const containers = manager.getRepository(Container);
|
throw new BadRequestException(
|
||||||
for (const unit of selectedUnits) {
|
'Receive one container booking per arriving truck so its containers and documents stay separate',
|
||||||
let container = await containers.findOne({
|
);
|
||||||
where: { containerNumber: unit.containerNumber },
|
}
|
||||||
withDeleted: true,
|
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) {
|
selectedUnits = allUnits.filter((unit) => selectedNumbers.includes(unit.containerNumber));
|
||||||
throw new BadRequestException(
|
if (selectedUnits.some((unit) => unit.received)) {
|
||||||
`Container ${unit.containerNumber} has no container type and cannot be 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) {
|
} else {
|
||||||
container = await containers.save(
|
const existing = await manager
|
||||||
containers.create({
|
.getRepository(WarehouseInventory)
|
||||||
containerNumber: unit.containerNumber,
|
.findOne({ where: { bookingId } });
|
||||||
containerTypeId: unit.containerTypeId as string,
|
if (existing) {
|
||||||
bookingContainerId: unit.bookingContainerId,
|
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,
|
bookingId,
|
||||||
|
bookingContainerId: unit.bookingContainerId,
|
||||||
sealNumber: unit.sealNumber,
|
sealNumber: unit.sealNumber,
|
||||||
tareWeight: 0,
|
|
||||||
maxGrossWeight: Number(unit.weightTons || 0),
|
|
||||||
status: 'LOADED',
|
status: 'LOADED',
|
||||||
wagonId: null,
|
deletedAt: null,
|
||||||
position: null,
|
});
|
||||||
wagonBookingAllocationId: 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 {
|
inventoryIds.push(saved.id);
|
||||||
await containers.update(container.id, {
|
|
||||||
bookingId,
|
|
||||||
bookingContainerId: unit.bookingContainerId,
|
|
||||||
sealNumber: unit.sealNumber,
|
|
||||||
status: 'LOADED',
|
|
||||||
deletedAt: null,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
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(
|
const saved = await manager.getRepository(WarehouseInventory).save(
|
||||||
manager.getRepository(WarehouseInventory).create({
|
manager.getRepository(WarehouseInventory).create({
|
||||||
warehouseId: dto.warehouseId,
|
warehouseId: dto.warehouseId,
|
||||||
yardId: dto.yardId,
|
yardId: dto.yardId,
|
||||||
zoneId: dto.zoneId,
|
zoneId: dto.zoneId,
|
||||||
bookingId,
|
bookingId,
|
||||||
containerId: container.id,
|
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
weight: Number(unit.weightTons || 0),
|
weight,
|
||||||
grnNumber,
|
grnNumber,
|
||||||
status: 'RECEIVED',
|
status: 'RECEIVED',
|
||||||
arrivedAt: now,
|
arrivedAt: now,
|
||||||
@@ -1917,90 +1994,60 @@ export class WarehouseInventoryService {
|
|||||||
);
|
);
|
||||||
inventoryIds.push(saved.id);
|
inventoryIds.push(saved.id);
|
||||||
}
|
}
|
||||||
await manager.query(
|
|
||||||
`UPDATE freight.booking_container_units bcu
|
// Update warehouse/yard/zone capacity counters
|
||||||
SET received_to_port = true,
|
await this.applyCapacityDelta(manager, dto, weight, 0, containerCount);
|
||||||
received_at = COALESCE(bcu.received_at, NOW()),
|
|
||||||
grn_number = $3,
|
// Export self-haul: this receive IS the truck's arrival — see
|
||||||
updated_at = NOW()
|
// markCustomerTruckArrived / receive()'s single-booking mirror.
|
||||||
FROM freight.booking_container bc
|
if (dto.direction === 'EXPORT') {
|
||||||
WHERE bc.id = bcu.booking_container_id
|
await this.markCustomerTruckArrived(manager, bookingId, truckEntrance?.truckPlateNumber);
|
||||||
AND bc.booking_id = $1
|
}
|
||||||
AND UPPER(bcu.container_number) = ANY($2::varchar[])
|
|
||||||
AND bc.deleted_at IS NULL
|
await this.activityLog.record(
|
||||||
AND bcu.deleted_at IS NULL`,
|
{
|
||||||
[bookingId, selectedUnits.map((unit) => unit.containerNumber), grnNumber],
|
activityType: 'INVENTORY_RECEIVED',
|
||||||
);
|
inventoryId: inventoryIds[0],
|
||||||
} else {
|
|
||||||
const saved = await manager.getRepository(WarehouseInventory).save(
|
|
||||||
manager.getRepository(WarehouseInventory).create({
|
|
||||||
warehouseId: dto.warehouseId,
|
warehouseId: dto.warehouseId,
|
||||||
yardId: dto.yardId,
|
description: truckEntrance?.truckPlateNumber
|
||||||
zoneId: dto.zoneId,
|
? `GRN ${grnNumber}: bulk received ${dto.direction} booking via truck ${truckEntrance.truckPlateNumber}`
|
||||||
bookingId,
|
: `GRN ${grnNumber}: bulk received ${dto.direction} booking`,
|
||||||
quantity: 1,
|
performedBy: dto.performedBy,
|
||||||
weight,
|
},
|
||||||
grnNumber,
|
manager,
|
||||||
status: 'RECEIVED',
|
|
||||||
arrivedAt: now,
|
|
||||||
notes: receiveNote,
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
inventoryIds.push(saved.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update warehouse/yard/zone capacity counters
|
// Queued, not sent here: an SMS/email round-trip inside the transaction
|
||||||
await this.applyCapacityDelta(manager, dto, weight, 0, containerCount);
|
// holds capacity/location locks open for the whole gateway latency.
|
||||||
|
pendingNotifications.push({
|
||||||
// Export self-haul: this receive IS the truck's arrival — see
|
owner: {
|
||||||
// markCustomerTruckArrived / receive()'s single-booking mirror.
|
phone: truckEntrance?.customerPhone ?? booking.customerPhone,
|
||||||
if (dto.direction === 'EXPORT') {
|
ownerName: truckEntrance?.ownerName ?? booking.customer,
|
||||||
await this.markCustomerTruckArrived(manager, bookingId, truckEntrance?.truckPlateNumber);
|
bookingReference: truckEntrance?.edrDigitalBookingId ?? booking.reference,
|
||||||
}
|
grnNumber,
|
||||||
|
direction: dto.direction,
|
||||||
await this.activityLog.record(
|
warehouseId: dto.warehouseId,
|
||||||
{
|
bookingId,
|
||||||
activityType: 'INVENTORY_RECEIVED',
|
},
|
||||||
inventoryId: inventoryIds[0],
|
booking,
|
||||||
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,
|
|
||||||
bookingId,
|
bookingId,
|
||||||
},
|
});
|
||||||
booking,
|
|
||||||
bookingId,
|
|
||||||
});
|
|
||||||
|
|
||||||
result.receivedCount += 1;
|
result.receivedCount += 1;
|
||||||
result.results.push({
|
result.results.push({
|
||||||
bookingId,
|
bookingId,
|
||||||
status: 'RECEIVED',
|
status: 'RECEIVED',
|
||||||
inventoryId: inventoryIds[0],
|
inventoryId: inventoryIds[0],
|
||||||
inventoryIds,
|
inventoryIds,
|
||||||
grnNumber,
|
grnNumber,
|
||||||
...(booking.freightType === 'CONTAINER'
|
...(booking.freightType === 'CONTAINER'
|
||||||
? {
|
? {
|
||||||
receivedContainers: receivedAfter,
|
receivedContainers: receivedAfter,
|
||||||
remainingContainers: remainingAfter,
|
remainingContainers: remainingAfter,
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
});
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user