feat(warehouse): show the trucks in the yard, not just a count of them

Multi-truck self-haul had no list anywhere on the warehouse side. The ops
dashboard counted trucks on site and offered no way to open the list, and
the inventory table showed a blank plate on exactly the bookings that have
several trucks: it read booking.customer_truck_plate_number, which
multi-truck self-haul leaves null because plates live in
customer_truck_assignments. Booking BK-2026-000033 has a truck and a driver
on file and displayed neither.

Adds a Trucks on Site page listing every truck that has arrived and not yet
departed, across bookings, with plate, driver, booking, customer, containers
and dwell time. It covers both haulage paths because the gate does — a
customer's own truck and an EDR last-mile truck reach the same barrier — and
flags anything sitting over four hours. It lives under Warehouse Management
rather than Imports or Exports, since the yard is not per-direction.

The inventory queries now read plates and drivers from the assignments and
keep the booking columns as the fallback for single-truck bookings written
before that table existed.

Both new statements were EXPLAIN-validated against the live schema; the
plate fix returns the data that was previously null.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hagernesh
2026-07-20 12:35:51 +00:00
parent cd80a29602
commit 0c0de284b0
8 changed files with 329 additions and 6 deletions

View File

@@ -69,6 +69,15 @@ export class WarehouseInventoryController {
return this.inventoryService.opsStats();
}
@Get('trucks-on-site')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({
summary: 'Trucks currently in the yard (customer self-haul + EDR last-mile)',
})
trucksOnSite() {
return this.inventoryService.trucksOnSite();
}
@Get('zone-occupancy')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'Live occupancy per zone (rated capacity vs held) — heatmap data' })

View File

@@ -410,6 +410,77 @@ export class WarehouseInventoryService {
* - trucksOnSite: customer trucks arrived but not departed
* - itemsAging: in-warehouse items older than 7 days (demurrage risk)
*/
/**
* Every truck currently inside the yard, across all bookings — the list behind
* the `trucksOnSite` figure on the ops dashboard, which until now could only
* be counted and never opened.
*
* Covers both haulage paths because the gate does: a customer's own truck and
* an EDR last-mile truck arrive at the same barrier and need the same paper.
* "On site" means arrived and not yet departed.
*/
async trucksOnSite(): Promise<
Array<{
source: 'CUSTOMER' | 'EDR';
assignmentId: string;
plateNumber: string | null;
driverName: string | null;
truckType: string | null;
arrivedAt: string | null;
bookingId: string;
bookingReference: string | null;
customerName: string | null;
containers: string | null;
}>
> {
return this.dataSource.query(
`SELECT 'CUSTOMER' AS "source",
a.id AS "assignmentId",
a.plate_number AS "plateNumber",
a.driver_name AS "driverName",
a.truck_type AS "truckType",
a.arrived_at AS "arrivedAt",
b.id AS "bookingId",
b.reference AS "bookingReference",
company.name AS "customerName",
(SELECT string_agg(c.container_number, ', ' ORDER BY c.container_number)
FROM freight.customer_truck_containers c
WHERE c.assignment_id = a.id AND c.deleted_at IS NULL) AS "containers"
FROM freight.customer_truck_assignments a
JOIN freight.bookings b ON b.id = a.booking_id AND b.deleted_at IS NULL
LEFT JOIN freight.companies company ON company.id = b.company_id
WHERE a.deleted_at IS NULL
AND a.arrived_at IS NOT NULL
AND a.departed_at IS NULL
UNION ALL
SELECT 'EDR' AS "source",
va.id AS "assignmentId",
COALESCE(v.plate_number, v.power_plate_no) AS "plateNumber",
NULLIF(TRIM(CONCAT_WS(' ', d.first_name, d.last_name)), '') AS "driverName",
v.vehicle_type AS "truckType",
va.arrived_at AS "arrivedAt",
b.id AS "bookingId",
b.reference AS "bookingReference",
company.name AS "customerName",
(SELECT string_agg(lvc.container_number, ', ' ORDER BY lvc.container_number)
FROM freight.last_mile_vehicle_containers lvc
WHERE lvc.assignment_id = va.id AND lvc.deleted_at IS NULL) AS "containers"
FROM freight.last_mile_vehicle_assignments va
JOIN freight.last_mile lm ON lm.id = va.last_mile_id AND lm.deleted_at IS NULL
JOIN freight.bookings b ON b.id = lm.booking_id AND b.deleted_at IS NULL
LEFT JOIN freight.vehicles v ON v.id = va.vehicle_id
LEFT JOIN freight.drivers d ON d.id = v.assigned_driver_id
LEFT JOIN freight.companies company ON company.id = b.company_id
WHERE va.deleted_at IS NULL
AND va.arrived_at IS NOT NULL
AND va.departed_at IS NULL
ORDER BY "arrivedAt" ASC`,
);
}
async opsStats(): Promise<{
receivedToday: number;
receivedYesterday: number;
@@ -1202,8 +1273,18 @@ export class WarehouseInventoryService {
driver.phone_number AS "firstMileDriverPhone",
driver.license_number AS "firstMileDriverLicenseNumber",
v.vehicle_type AS "firstMileTruckType",
b.customer_truck_plate_number AS "customerTruckPlateNumber",
b.customer_truck_driver_name AS "customerTruckDriverName",
-- Multi-truck self-haul writes plates/drivers to
-- customer_truck_assignments and leaves the booking columns null,
-- so read the assignments first and keep the legacy column as the
-- fallback for single-truck bookings written before that table.
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"
@@ -1334,8 +1415,14 @@ export class WarehouseInventoryService {
driver.phone_number AS "firstMileDriverPhone",
driver.license_number AS "firstMileDriverLicenseNumber",
v.vehicle_type AS "firstMileTruckType",
b.customer_truck_plate_number AS "customerTruckPlateNumber",
b.customer_truck_driver_name AS "customerTruckDriverName",
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",
@@ -1794,8 +1881,18 @@ export class WarehouseInventoryService {
THEN 'DOOR_DELIVERY' ELSE 'TERMINAL_PICKUP' END AS "pickupOption",
(NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL
OR COALESCE(st.includes_last_mile, false)) AS "lastMileRequested",
b.customer_truck_plate_number AS "customerTruckPlateNumber",
b.customer_truck_driver_name AS "customerTruckDriverName",
-- Multi-truck self-haul writes plates/drivers to
-- customer_truck_assignments and leaves the booking columns null,
-- so read the assignments first and keep the legacy column as the
-- fallback for single-truck bookings written before that table.
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",