The inventory table has one row per inventory item, so on a self-haul booking
with several trucks there was no way to tell which container sat on which
truck without opening a document. The plate column now lists every plate, but
not the mapping.
Each row with a customer truck gets a chevron that opens a per-truck
breakdown: plate, driver, type, the containers it carries, and whether it has
arrived. Bulk trucks show "Bulk" — they haul loose tonnage, not containers.
The breakdown fetches only when opened, so a table nobody expands costs no
extra requests, and it reuses the existing per-booking trucks endpoint rather
than widening the inventory query.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
A truck holds one 40ft or two 20ft, a container booking takes no more trucks
than it has containers, and a bulk booking takes trucks until its tonnage is
hauled away. The same physics whoever drives, but the rule was written out
four times — addTruck, updateTruck, departTruck and LastMileService — beside
a byte-identical container-size query. Copies drift: that is how the
self-haul guard ended up enforced on one side only.
The bulk cap was the real gap. EDR summed net_weight_tons of departed trucks
and refused another once the booking was drawn down. The customer side had
no cap at all: for bulk it skipped straight past every check, so a self-haul
bulk booking could take unlimited trucks.
It could not simply reuse the EDR sum. customer_truck_assignments had no net
and no tare, only a gross_weight_kg that holds tonnes despite its name and
that nothing in the live flow ever wrote — release() recorded exit weights
against the EDR table alone, which is why all five customer trucks on dev
have neither weight nor departure. Any drawdown keyed on it would have
summed zero forever and never fired.
So the customer table now carries tare_weight_tons and net_weight_tons to
match the EDR one, release() records the customer truck's exit as it already
did for EDR, and the drawdown counts both sources — a booking hauls by one
path or the other and "until no tonnage is left" means the same either way.
Also locks a load once its truck has arrived on the EDR side, which the
customer side has always done, and fills the arrival form from the customer
truck on file: the prefill read booking.customer_truck_*, which multi-truck
self-haul leaves null, so a booking with a truck assigned opened blank.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The rule that a customer's own truck and an EDR road leg are alternatives
existed on the truck side only (CustomerTruckService.assertSelfHaulPaid).
LastMileService had no counterpart: create checked payment and nothing else,
so any paid booking could be accepted into the queue. A booking took a
customer truck at 06:42 and an EDR last-mile leg with a real EDR truck at
06:47, neither side aware of the other, on a contract that had chosen no
road legs at all.
The road legs are chosen on the contract and copied onto the booking, and
the pickup/delivery address is the only per-booking record of that choice.
service_types cannot serve: every type ships with includes_first_mile and
includes_last_mile set to true, so reading them would mean no booking could
ever self-haul. That same always-true flag had already killed the first-mile
guard, whose `address || serviceType.includesFirstMile` admitted every paid
export booking.
One shared rule now answers it for both sides, so the two halves cannot
drift apart again: last-mile create rejects a booking that chose no road
legs and one already carrying a customer truck; first-mile no longer honours
the service-type flag; the customer-truck guard reads the same helper.
Existing legs are untouched — the guards are on creation, so the one booking
already carrying both needs a human to reconcile it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The IsNotBackdated validator was covered in isolation, but not on the DTO
that actually carries it. Asserts a backdated occurredAt is rejected, that
"now" passes, and that omitting the field still validates so the service can
stamp it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>