Truck assiggnment and per truc

This commit is contained in:
hagiye
2026-09-02 01:41:08 +03:00
parent 6d6b32feae
commit 031efce92b
12 changed files with 769 additions and 97 deletions

View File

@@ -45,6 +45,16 @@ describe('assertTruckLoad', () => {
).toThrow(BadRequestException);
});
it('allows two containers only when both are explicitly 20ft', () => {
expect(() =>
assertTruckLoad({
containers: ['ABCD1234567', 'ABCD7654321'],
bookingContainers: booking,
sizes: ['20ft', '45ft'],
}),
).toThrow(BadRequestException);
});
it('rejects more than two containers', () => {
expect(() =>
assertTruckLoad({

View File

@@ -54,10 +54,11 @@ export function assertTruckLoad({
}
}
// A 40ft fills the bed, so it travels alone.
if (containers.length > 1 && sizes.some((size) => size.includes('40'))) {
// A truck may pair containers only when BOTH are explicitly 20ft. A 40ft
// (and any legacy/unknown larger size) fills the bed and travels alone.
if (containers.length > 1 && sizes.some((size) => !size.includes('20'))) {
throw new BadRequestException(
'A 40ft container fills the truck — assign only 1 container to this truck',
'Truck capacity is either 1 x 40ft container or up to 2 x 20ft containers',
);
}
}

View File

@@ -312,12 +312,27 @@ export class BookingsService {
LEFT JOIN freight.yards ay ON ay.id = tsw.alight_yard_id
LEFT JOIN freight.wagon_allocation_container_items ci
ON ci.wagon_booking_allocation_id = a.id AND ci.deleted_at IS NULL
AND (
$2 <> 'EXPORT' OR $3 <> 'CONTAINER' OR EXISTS (
SELECT 1
FROM freight.booking_container_units received_unit
JOIN freight.booking_container received_line
ON received_line.id = received_unit.booking_container_id
AND received_line.deleted_at IS NULL
WHERE received_line.booking_id = a.booking_id
AND received_unit.container_number = ci.container_number
AND received_unit.received_to_port = true
AND NULLIF(TRIM(received_unit.grn_number), '') IS NOT NULL
AND received_unit.deleted_at IS NULL
)
)
WHERE a.booking_id = $1 AND a.deleted_at IS NULL
GROUP BY tsw.id, a.id, a.status, wt.code, wt.name, w.wagon_number, wt.tare_weight_tons,
s.train_number, s.scheduled_departure_date, so.label, sd.label,
by_.label, ay.label
HAVING $2 <> 'EXPORT' OR $3 <> 'CONTAINER' OR COUNT(ci.id) > 0
ORDER BY tsw.sequence_no`,
[bookingId],
[bookingId, booking.tradeDirection, booking.freightType],
);
// Export acceptance happens at the warehouse gate, not at marshalling: EDR
// takes custody of the cargo when it receives it, and the customer is handed
@@ -352,17 +367,17 @@ export class BookingsService {
)
: booking.tradeDirection === 'EXPORT'
? await this.dataSource.query(
`SELECT inv.weight AS "allocatedWeightTons",
c.container_number AS "containerNumbers"
FROM freight.warehouse_inventory inv
LEFT JOIN freight.containers c
ON c.id = inv.container_id AND c.deleted_at IS NULL
WHERE inv.booking_id = $1 AND inv.deleted_at IS NULL
AND COALESCE(
NULLIF(TRIM(inv.grn_number), ''),
substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')
) IS NOT NULL
ORDER BY inv.created_at`,
`SELECT unit.vgm_tons AS "allocatedWeightTons",
unit.container_number AS "containerNumbers",
unit.seal_number AS "sealNumbers"
FROM freight.booking_container_units unit
JOIN freight.booking_container line
ON line.id = unit.booking_container_id AND line.deleted_at IS NULL
WHERE line.booking_id = $1
AND unit.deleted_at IS NULL
AND unit.received_to_port = true
AND NULLIF(TRIM(unit.grn_number), '') IS NOT NULL
ORDER BY unit.received_at, unit.container_number`,
[bookingId],
)
: [];

View File

@@ -35,6 +35,7 @@ interface BookingGuardRow {
lastMile: string | null;
paymentStatus: string | null;
status: string | null;
trainScheduleStatus: string | null;
}
/**
@@ -294,19 +295,16 @@ export class CustomerTruckService {
}
const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase());
if (booking.freightType === 'CONTAINER' && !requested.length) {
throw new BadRequestException('Select the containers loaded on this truck');
}
if (requested.length) {
const bookingNumbers = await this.bookingContainerNumbers(bookingId);
for (const n of requested) {
if (!bookingNumbers.includes(n)) {
throw new BadRequestException(`Container ${n} is not one of this booking's containers`);
}
}
const elsewhere = await this.assignedContainerNumbersExcept(bookingId, assignmentId);
for (const n of requested) {
if (elsewhere.includes(n)) {
throw new ConflictException(`Container ${n} is already loaded onto another truck`);
}
}
assertTruckLoad({
containers: requested,
bookingContainers: await this.bookingContainerNumbers(bookingId),
sizes: await bookingContainerSizes(this.dataSource, bookingId, requested),
assignedElsewhere: await this.assignedContainerNumbersExcept(bookingId, assignmentId),
});
}
await this.dataSource.transaction(async (manager) => {
@@ -542,9 +540,16 @@ export class CustomerTruckService {
first_mile_pickup_address AS "firstMile",
last_mile_delivery_address AS "lastMile",
payment_status AS "paymentStatus",
status
FROM freight.bookings
WHERE id = $1 AND deleted_at IS NULL`,
b.status,
(SELECT ts.status
FROM freight.train_schedule_bookings tsb
JOIN freight.train_schedules ts
ON ts.id = tsb.train_schedule_id AND ts.deleted_at IS NULL
WHERE tsb.booking_id = b.id AND tsb.deleted_at IS NULL
ORDER BY ts.updated_at DESC
LIMIT 1) AS "trainScheduleStatus"
FROM freight.bookings b
WHERE b.id = $1 AND b.deleted_at IS NULL`,
[bookingId],
);
if (!row) throw new NotFoundException(`Booking ${bookingId} not found`);
@@ -575,7 +580,7 @@ export class CustomerTruckService {
private assertAssignmentWindow(booking: BookingGuardRow): void {
const status = booking.status ?? '';
if (booking.tradeDirection === 'IMPORT') {
if (status !== 'ARRIVED') {
if (status !== 'ARRIVED' && booking.trainScheduleStatus !== 'ARRIVED') {
throw new BadRequestException(
'Import pickup trucks can only be assigned after the train has arrived',
);

View File

@@ -1,6 +1,19 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { ArrayNotEmpty, IsArray, IsBoolean, IsIn, IsNumber, IsOptional, IsString, IsUUID, Min } from 'class-validator';
import {
ArrayMaxSize,
ArrayNotEmpty,
ArrayUnique,
IsArray,
IsBoolean,
IsIn,
IsNumber,
IsOptional,
IsString,
IsUUID,
Matches,
Min,
} from 'class-validator';
import { ValidateNested } from 'class-validator';
export class TruckEntranceDto {
@@ -187,6 +200,22 @@ export class BulkReceiveDto {
@IsUUID('all', { each: true })
bookingIds!: string[];
/**
* The physical containers delivered by this truck. Container exports are
* received one truck at a time: either one 40ft box or up to two 20ft boxes.
*/
@ApiPropertyOptional({ type: [String] })
@IsOptional()
@IsArray()
@ArrayNotEmpty()
@ArrayMaxSize(2)
@ArrayUnique()
@Matches(/^[A-Z]{4}\d{7}$/, {
each: true,
message: 'each container number must match ISO container format, e.g. ABCD1234567',
})
containerNumbers?: string[];
@ApiPropertyOptional({ type: TruckEntranceDto })
@IsOptional()
@ValidateNested()

View File

@@ -17,6 +17,7 @@ import {
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
import { generateGrnNumber } from '../../common/grn.util';
import { assertTruckLoad } from '../../common/truck-load.util';
import { SCHEDULE_BOOKINGS_CTE } from '../../common/schedule-bookings.sql';
import { Booking } from '../bookings/entities/booking.entity';
import type { StationWorkLog } from '../train-schedules/entities/train-schedule.entity';
@@ -275,12 +276,30 @@ export interface EligibleBookingRow {
customerTruckType: string | null;
customerTruckContainerNumber: string | null;
customerTruckAssignedAt: string | null;
containerUnits: Array<{
containerNumber: string;
containerSize: string | null;
weightTons: number;
received: boolean;
grnNumber: string | null;
}>;
receivedContainerCount: number;
remainingContainerCount: number;
}
export interface BulkReceiveResult {
receivedCount: number;
skippedCount: number;
results: { bookingId: string; status: string; inventoryId?: string; grnNumber?: string; reason?: string }[];
results: {
bookingId: string;
status: string;
inventoryId?: string;
inventoryIds?: string[];
grnNumber?: string;
receivedContainers?: number;
remainingContainers?: number;
reason?: string;
}[];
}
@@ -1406,6 +1425,9 @@ export class WarehouseInventoryService {
${companyNotifyPhoneExpr('company')} AS "customerPhone",
COALESCE(bcu.unit_numbers, bc.container_numbers) AS "containerNumber",
bcu.seal_numbers AS "sealNumbers",
COALESCE(bcu.container_units, '[]'::json) AS "containerUnits",
COALESCE(bcu.received_count, 0)::int AS "receivedContainerCount",
COALESCE(bcu.remaining_count, 0)::int AS "remainingContainerCount",
bc.container_quantity AS "containerQuantity",
bc.container_packaging_type AS "containerPackagingType",
-- service_types.includes_last_mile/first_mile are NOT read here: every
@@ -1457,7 +1479,6 @@ export class WarehouseInventoryService {
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id
LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id
LEFT JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL
LEFT JOIN LATERAL (
SELECT string_agg(NULLIF(booking_container.container_number, ''), ', ' ORDER BY booking_container.container_number) AS container_numbers,
SUM(booking_container.quantity)::int AS container_quantity,
@@ -1476,7 +1497,18 @@ export class WarehouseInventoryService {
) bc ON true
LEFT JOIN LATERAL (
SELECT string_agg(NULLIF(unit.container_number, ''), ', ' ORDER BY unit.container_number) AS unit_numbers,
string_agg(DISTINCT NULLIF(unit.seal_number, ''), ', ') AS seal_numbers
string_agg(DISTINCT NULLIF(unit.seal_number, ''), ', ') AS seal_numbers,
COUNT(*) FILTER (WHERE unit.received_to_port)::int AS received_count,
COUNT(*) FILTER (WHERE NOT unit.received_to_port)::int AS remaining_count,
json_agg(
json_build_object(
'containerNumber', unit.container_number,
'containerSize', line.container_size,
'weightTons', unit.vgm_tons,
'received', unit.received_to_port,
'grnNumber', unit.grn_number
) ORDER BY unit.container_number
) AS container_units
FROM freight.booking_container_units unit
JOIN freight.booking_container line
ON line.id = unit.booking_container_id AND line.deleted_at IS NULL
@@ -1493,7 +1525,14 @@ export class WarehouseInventoryService {
LEFT JOIN freight.drivers driver ON driver.id = v.assigned_driver_id
WHERE b.deleted_at IS NULL
AND b.payment_status = 'PAID'
AND inv.id IS NULL
AND (
(b.freight_type = 'CONTAINER' AND COALESCE(bcu.remaining_count, 0) > 0)
OR
(b.freight_type <> 'CONTAINER' AND NOT EXISTS (
SELECT 1 FROM freight.warehouse_inventory inv
WHERE inv.booking_id = b.id AND inv.deleted_at IS NULL
))
)
-- Direct truck-to-train cargo never comes to the warehouse, so never
-- offer it for receipt.
AND COALESCE(b.export_handover_mode, 'WAREHOUSE') <> 'DIRECT_TO_TRAIN'
@@ -1650,9 +1689,6 @@ export class WarehouseInventoryService {
}
}
const existing = await manager.getRepository(WarehouseInventory).findOne({ where: { bookingId } });
if (existing) { skip('Already received'); continue; }
const containerQuantity = Number(booking.containerQuantity ?? 0);
if (booking.freightType === 'CONTAINER' && containerQuantity <= 0) {
skip('Container booking has no container quantity');
@@ -1660,62 +1696,256 @@ export class WarehouseInventoryService {
}
const now = new Date();
const grnNumber = this.generateGrnNumber(dto.direction, bookingId, now, booking.customer);
const truckEntrance = dto.truckEntrance
? this.mergeSystemTruckEntrance(dto.truckEntrance, booking)
: undefined;
// Multi-truck self-haul is selected explicitly at the gate. The booking
// source contains comma-joined legacy summary fields, which must never
// replace the one physical truck the receiver selected.
if (truckEntrance && !booking.hasFirstMile && dto.truckEntrance) {
truckEntrance.truckPlateNumber = dto.truckEntrance.truckPlateNumber;
truckEntrance.driverName = dto.truckEntrance.driverName;
truckEntrance.driverPhone = dto.truckEntrance.driverPhone;
truckEntrance.truckType = dto.truckEntrance.truckType;
}
if (dto.direction === 'EXPORT') {
this.assertTruckEntrance(truckEntrance);
}
type ReceiveContainerUnit = {
containerNumber: string;
containerSize: string | null;
weightTons: string | number;
sealNumber: string | null;
bookingContainerId: string;
containerTypeId: string | null;
received: boolean;
};
let selectedUnits: ReceiveContainerUnit[] = [];
let grnNumber = this.generateGrnNumber(dto.direction, bookingId, now, booking.customer);
if (booking.freightType === 'CONTAINER') {
if (dto.bookingIds.length !== 1) {
throw new BadRequestException(
'Receive one container booking per arriving truck so its containers and documents stay separate',
);
}
const selectedNumbers = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase());
if (!selectedNumbers.length) {
throw new BadRequestException('Select the containers arriving on this truck');
}
const allUnits: ReceiveContainerUnit[] = await manager.query(
`SELECT UPPER(bcu.container_number) AS "containerNumber",
bc.container_size AS "containerSize",
bcu.vgm_tons AS "weightTons",
bcu.seal_number AS "sealNumber",
bc.id AS "bookingContainerId",
bc.container_type_id AS "containerTypeId",
bcu.received_to_port AS received
FROM freight.booking_container_units bcu
JOIN freight.booking_container bc
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL
FOR UPDATE OF bcu`,
[bookingId],
);
assertTruckLoad({
containers: selectedNumbers,
bookingContainers: allUnits.map((unit) => unit.containerNumber),
sizes: allUnits
.filter((unit) => selectedNumbers.includes(unit.containerNumber))
.map((unit) => unit.containerSize ?? ''),
});
selectedUnits = allUnits.filter((unit) => selectedNumbers.includes(unit.containerNumber));
if (selectedUnits.some((unit) => unit.received)) {
const repeated = selectedUnits.filter((unit) => unit.received).map((unit) => unit.containerNumber);
throw new BadRequestException(`Container(s) already received: ${repeated.join(', ')}`);
}
// If this is a customer-assigned truck, it may only deliver the boxes
// assigned to that plate. Manual/unassigned arrivals retain the same
// physical capacity validation but have no assignment list to check.
if (truckEntrance?.truckPlateNumber) {
const assigned: Array<{ containerNumber: string }> = await manager.query(
`SELECT UPPER(ctc.container_number) AS "containerNumber"
FROM freight.customer_truck_assignments cta
JOIN freight.customer_truck_containers ctc
ON ctc.assignment_id = cta.id AND ctc.deleted_at IS NULL
WHERE cta.booking_id = $1
AND UPPER(cta.plate_number) = UPPER($2)
AND cta.deleted_at IS NULL`,
[bookingId, truckEntrance.truckPlateNumber],
);
if (
assigned.length > 0 &&
selectedNumbers.some(
(number) => !assigned.some((container) => container.containerNumber === number),
)
) {
throw new BadRequestException(
`Selected containers are not assigned to truck ${truckEntrance.truckPlateNumber}`,
);
}
}
const [{ batches }]: Array<{ batches: string }> = await manager.query(
`SELECT COUNT(DISTINCT inv.grn_number) AS batches
FROM freight.warehouse_inventory inv
WHERE inv.booking_id = $1
AND inv.grn_number IS NOT NULL
AND inv.deleted_at IS NULL`,
[bookingId],
);
grnNumber = `${grnNumber}-${String(Number(batches ?? 0) + 1).padStart(2, '0')}`;
if (truckEntrance) {
truckEntrance.assignedEquipmentNumber = selectedNumbers.join(', ');
truckEntrance.unitCount = selectedNumbers.length;
truckEntrance.netWeightKg = selectedUnits.reduce(
(total, unit) => total + Number(unit.weightTons || 0),
0,
);
}
} else {
const existing = await manager
.getRepository(WarehouseInventory)
.findOne({ where: { bookingId } });
if (existing) {
skip('Already received');
continue;
}
}
const receivedBefore =
booking.freightType === 'CONTAINER'
? Number(
(
await manager.query(
`SELECT COUNT(*) AS count
FROM freight.booking_container_units bcu
JOIN freight.booking_container bc
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
WHERE bc.booking_id = $1
AND bcu.received_to_port = true
AND bcu.deleted_at IS NULL`,
[bookingId],
)
)[0]?.count ?? 0,
)
: 0;
const receivedAfter = receivedBefore + selectedUnits.length;
const remainingAfter = Math.max(0, containerQuantity - receivedAfter);
const receiveNote = this.buildReceiveNote({
grnNumber,
direction: dto.direction,
notes: `Bulk received (${dto.direction})`,
notes:
booking.freightType === 'CONTAINER'
? `${selectedUnits.length} container(s) arrived: ${selectedUnits
.map((unit) => unit.containerNumber)
.join(', ')}. ${remainingAfter} container(s) left.`
: `Bulk received (${dto.direction})`,
truckEntrance,
});
// Validate capacity before saving
const weight = Number(booking.weight) || 0;
const containerCount = booking.freightType === 'CONTAINER' ? containerQuantity : 0;
const weight =
booking.freightType === 'CONTAINER'
? selectedUnits.reduce((total, unit) => total + Number(unit.weightTons || 0), 0)
: Number(booking.weight) || 0;
const containerCount = booking.freightType === 'CONTAINER' ? selectedUnits.length : 0;
this.assertCapacity('Warehouse', warehouse, weight, 0, containerCount);
this.assertCapacity('Yard', yard, weight, 0, containerCount);
this.assertCapacity('Zone', zone, weight, 0, containerCount);
const saved = await manager.getRepository(WarehouseInventory).save(
manager.getRepository(WarehouseInventory).create({
warehouseId: dto.warehouseId,
yardId: dto.yardId,
zoneId: dto.zoneId,
bookingId,
quantity: booking.freightType === 'CONTAINER' ? containerQuantity : 1,
weight,
grnNumber,
status: 'RECEIVED',
arrivedAt: now,
notes: receiveNote,
}),
);
const inventoryIds: string[] = [];
if (booking.freightType === 'CONTAINER') {
const containers = manager.getRepository(Container);
for (const unit of selectedUnits) {
let container = await containers.findOne({
where: { containerNumber: unit.containerNumber },
withDeleted: true,
});
if (!container && !unit.containerTypeId) {
throw new BadRequestException(
`Container ${unit.containerNumber} has no container type and cannot be received`,
);
}
if (!container) {
container = await containers.save(
containers.create({
containerNumber: unit.containerNumber,
containerTypeId: unit.containerTypeId as string,
bookingContainerId: unit.bookingContainerId,
bookingId,
sealNumber: unit.sealNumber,
tareWeight: 0,
maxGrossWeight: Number(unit.weightTons || 0),
status: 'LOADED',
wagonId: null,
position: null,
wagonBookingAllocationId: null,
}),
);
} else {
await containers.update(container.id, {
bookingId,
bookingContainerId: unit.bookingContainerId,
sealNumber: unit.sealNumber,
status: 'LOADED',
deletedAt: null,
});
}
const saved = await manager.getRepository(WarehouseInventory).save(
manager.getRepository(WarehouseInventory).create({
warehouseId: dto.warehouseId,
yardId: dto.yardId,
zoneId: dto.zoneId,
bookingId,
containerId: container.id,
quantity: 1,
weight: Number(unit.weightTons || 0),
grnNumber,
status: 'RECEIVED',
arrivedAt: now,
notes: receiveNote,
}),
);
inventoryIds.push(saved.id);
}
await manager.query(
`UPDATE freight.booking_container_units bcu
SET received_to_port = true,
received_at = COALESCE(bcu.received_at, NOW()),
grn_number = $3,
updated_at = NOW()
FROM freight.booking_container bc
WHERE bc.id = bcu.booking_container_id
AND bc.booking_id = $1
AND UPPER(bcu.container_number) = ANY($2::varchar[])
AND bc.deleted_at IS NULL
AND bcu.deleted_at IS NULL`,
[bookingId, selectedUnits.map((unit) => unit.containerNumber), grnNumber],
);
} else {
const saved = await manager.getRepository(WarehouseInventory).save(
manager.getRepository(WarehouseInventory).create({
warehouseId: dto.warehouseId,
yardId: dto.yardId,
zoneId: dto.zoneId,
bookingId,
quantity: 1,
weight,
grnNumber,
status: 'RECEIVED',
arrivedAt: now,
notes: receiveNote,
}),
);
inventoryIds.push(saved.id);
}
// Update warehouse/yard/zone capacity counters
await this.applyCapacityDelta(manager, dto, weight, 0, containerCount);
// Receiving the booking flags every container unit as received into the
// port (self-haul export: the delivering truck's goods are now in) so
// staff can raise the per-container GRN over what's received.
await manager.query(
`UPDATE freight.booking_container_units bcu
SET received_to_port = true,
received_at = COALESCE(bcu.received_at, NOW()),
updated_at = NOW()
FROM freight.booking_container bc
WHERE bc.id = bcu.booking_container_id
AND bc.booking_id = $1
AND bc.deleted_at IS NULL
AND bcu.deleted_at IS NULL
AND bcu.received_to_port = false`,
[bookingId],
);
// Export self-haul: this receive IS the truck's arrival — see
// markCustomerTruckArrived / receive()'s single-booking mirror.
if (dto.direction === 'EXPORT') {
@@ -1725,7 +1955,7 @@ export class WarehouseInventoryService {
await this.activityLog.record(
{
activityType: 'INVENTORY_RECEIVED',
inventoryId: saved.id,
inventoryId: inventoryIds[0],
warehouseId: dto.warehouseId,
description: truckEntrance?.truckPlateNumber
? `GRN ${grnNumber}: bulk received ${dto.direction} booking via truck ${truckEntrance.truckPlateNumber}`
@@ -1752,7 +1982,19 @@ export class WarehouseInventoryService {
});
result.receivedCount += 1;
result.results.push({ bookingId, status: 'RECEIVED', inventoryId: saved.id, grnNumber });
result.results.push({
bookingId,
status: 'RECEIVED',
inventoryId: inventoryIds[0],
inventoryIds,
grnNumber,
...(booking.freightType === 'CONTAINER'
? {
receivedContainers: receivedAfter,
remainingContainers: remainingAfter,
}
: {}),
});
}
});
@@ -4026,7 +4268,7 @@ export class WarehouseInventoryService {
`SELECT inv.id,
inv.release_date AS "releaseDate",
inv.release_order_reference AS "releaseOrderReference",
inv.quantity,
COALESCE(receipt_batch.quantity, inv.quantity) AS quantity,
inv.weight,
inv.status,
inv.notes,
@@ -4355,11 +4597,12 @@ export class WarehouseInventoryService {
*/
async bookingContainerWeights(
bookingId: string,
): Promise<Array<{ containerNumber: string; weightTons: number }>> {
const rows: Array<{ containerNumber: string; weightTons: string }> =
): Promise<Array<{ containerNumber: string; weightTons: number; containerSize: string | null }>> {
const rows: Array<{ containerNumber: string; weightTons: string; containerSize: string | null }> =
await this.dataSource.query(
`SELECT bcu.container_number AS "containerNumber",
MAX(COALESCE(bcu.vgm_tons, 0)) AS "weightTons"
MAX(COALESCE(bcu.vgm_tons, 0)) AS "weightTons",
MAX(bc.container_size) AS "containerSize"
FROM freight.booking_container_units bcu
JOIN freight.booking_container bc
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
@@ -4371,6 +4614,7 @@ export class WarehouseInventoryService {
return rows.map((r) => ({
containerNumber: r.containerNumber,
weightTons: Number(r.weightTons) || 0,
containerSize: r.containerSize ?? null,
}));
}
@@ -4572,7 +4816,7 @@ export class WarehouseInventoryService {
-- An unweighed item still reports the cargo weight it holds: fall
-- back to the item's container VGM, then the booking's declared
-- weight, so a GRN never prints "0 t" for goods that are present.
COALESCE(NULLIF(inv.weight, 0), item_vgm.tons, b.cargo_total_weight_vgm, 0) AS weight,
COALESCE(NULLIF(receipt_batch.weight, 0), NULLIF(inv.weight, 0), item_vgm.tons, b.cargo_total_weight_vgm, 0) AS weight,
inv.volume,
inv.status,
inv.notes,
@@ -4589,8 +4833,8 @@ export class WarehouseInventoryService {
origin_yard.code AS "originYardCode",
destination_yard.label AS "destinationYardLabel",
destination_yard.code AS "destinationYardCode",
COALESCE(container.container_number, booking_container.container_number) AS "containerNumber",
booking_container."containerSummary" AS "bookingContainerSummary",
COALESCE(receipt_batch.container_numbers, container.container_number, booking_container.container_number) AS "containerNumber",
COALESCE(receipt_batch.container_summary, booking_container."containerSummary") AS "bookingContainerSummary",
COALESCE(cargo_type.cargo_type_name, b.cargo_free_text, cargo.description) AS "cargoDescription",
wh.name AS "warehouseName",
wh.code AS "warehouseCode",
@@ -4620,6 +4864,40 @@ export class WarehouseInventoryService {
WHERE bc.booking_id = b.id
AND bc.deleted_at IS NULL
) booking_container ON true
LEFT JOIN LATERAL (
SELECT COUNT(*)::int AS quantity,
SUM(batch.weight) AS weight,
string_agg(batch.container_number, ', ' ORDER BY batch.container_number)
FILTER (WHERE batch.container_number IS NOT NULL) AS container_numbers,
string_agg(
CONCAT(batch.container_number, ' (', COALESCE(batch.container_size, 'size unknown'), ')'),
', ' ORDER BY batch.container_number
) FILTER (WHERE batch.container_number IS NOT NULL) AS container_summary
FROM (
SELECT inv2.id,
inv2.weight,
c2.container_number,
bc2.container_size
FROM freight.warehouse_inventory inv2
LEFT JOIN freight.containers c2
ON c2.id = inv2.container_id AND c2.deleted_at IS NULL
LEFT JOIN freight.booking_container_units bcu2
ON bcu2.container_number = c2.container_number AND bcu2.deleted_at IS NULL
LEFT JOIN freight.booking_container bc2
ON bc2.id = bcu2.booking_container_id
AND bc2.booking_id = inv2.booking_id
AND bc2.deleted_at IS NULL
WHERE inv2.booking_id = inv.booking_id
AND inv2.deleted_at IS NULL
AND COALESCE(
NULLIF(TRIM(inv2.grn_number), ''),
substring(inv2.notes FROM 'GRN Number: ([^\\n\\r]+)')
) = COALESCE(
NULLIF(TRIM(inv.grn_number), ''),
substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')
)
) batch
) receipt_batch ON true
LEFT JOIN LATERAL (
SELECT SUM(bcu.vgm_tons) AS tons
FROM freight.booking_container_units bcu