An inspection report mirrored its outcome onto the inventory item but
never touched the item's status, and the loading paths gated on status
alone. Cargo that passed, reached READY_FOR_LOADING and was then
re-inspected as FAILED kept that status and loaded anyway.
Gate loading on the inspection outcome. load() is the single choke point
every loading path runs through, including loadItemsOntoTrain, so the
check sits there: nothing but PASSED travels, and the message names the
outcome so the operator knows what to fix.
A failed or under-review re-inspection also pulls the cargo back out of
the ready queue. load() refuses it either way, but leaving it READY_FOR_*
would keep it on the loading and pickup lists as though nothing had
happened.
Reversing a held inspection now needs a reason. Passing cargo whose
current inspection is FAILED or NEEDS_REVIEW is rejected without remarks,
so the record says why cargo that was deliberately held may now travel.
A first-time pass is unaffected.
Mark Selected as Inspected skips failed and under-review items instead of
clearing them. Overturning a failure is a deliberate, reasoned act, never
a side effect of ticking a row in a list; those items are reported back
with a reason telling the operator to re-inspect them individually.
Verified: freight-api type-check passes; 15 warehouse suites pass
(90 tests, 12 of them new).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Marking a received item inspected advanced only the rows the operator
ticked. A booking's cargo spans one inventory row per container, and a
multi-truck arrival files a GRN batch per truck, so a six-container
booking stayed half-inspected and never reached Ready To Load.
Widen the selection to every still-inspectable row of the same booking
before inspecting. Each row is then passed and advanced exactly as
before — EXPORT to READY_FOR_LOADING, IMPORT to READY_FOR_PICKUP — so
the whole booking moves together.
The rows the operator actually ticked lead the response, and are kept
even when ineligible so their skip reason still surfaces rather than
being dropped silently. Rows already PASSED, rows in a status that
cannot be inspected, and rows belonging to another booking are left
alone. Ad-hoc inventory with no booking expands to itself.
Also show yards by name in the receive and received queues. They
selected yards.code, so the route column read KALITY, the internal code
for the yard everyone calls GMP / Gelan Multipurpose Port.
Verified: freight-api type-check passes; both new SQL statements
EXPLAIN-validated against the dev database; 14 warehouse suites pass
(78 tests, 5 of them new).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ready to Load listed inventory and offered an auto-load picker, but the
warehouse floor works train by train: a train stands at the yard, its
bookings board wagon by wagon, it rolls. That view existed only on the
train schedule workspace, which the warehouse staff do not run.
Ready to Load now has an Items / By train switch. By train is a mirror
of the schedule's own column, placed where the loading actually happens.
Nothing in it has its own rules. Train position, the per-yard loading and
unloading windows, and the per-booking Load / Wagons / Unload actions all
come from the train-scheduling endpoints the schedule workspace already
calls, so a Load that would be refused there is disabled here with the
same reason and the two surfaces cannot disagree. No train-scheduling
code is touched.
What the warehouse adds is what the schedule cannot see: which of the
train's bookings are physically in the shed, with their GRN and
inspection state, laid out along the flow the staff follow -- receive and
GRN, inspect, ready, open the loading window, train at yard, load per
wagon, dispatch, unload at port.
Wagons
- The wagon modal calls the same per-wagon journey endpoints, so every
server gate (train at the yard, window started, PAID, GRN) is the
schedule's own.
- Wagons go one at a time in order: the server flips the booking to
IN_TRANSIT or ARRIVED on whichever call clears the last wagon, so
sequential is required, not merely tidy. A failure stops the run, the
wagons already sent stay done, and the toast says how many, so a retry
only resends the rest.
- Deliberately not mirrored: cancelling wagons that will not ride, and
the direct truck-to-train handover. Both are commercial decisions
(fees, credits, GRN waiver) that belong to the schedule workspace.
loadable-trains takes includeDispatched. Loading follows the train after
it rolls, since a mid-corridor warehouse boards its cargo when the train
stands at its yard, and the train-centric view needs the same set the
schedule offers Load on. The default stays pre-dispatch only, so the
existing auto-load picker is unchanged.
Also fixes the backoffice build: ReceiveInventoryModal used MultiSelect
without importing it, left behind by the self-haul assignment work.
Warehouse loading queues and the train schedule page now share one truth:
the schedule's per-yard stationWorkLogs. A booking's items aren't loadable
until "Start loading" has been clicked for their boarding yard, mirroring
the same assertStationWorkStarted check the train schedule page's own Load
button already enforces.
- LoadableTrainRow/LoadableTrain carry originStationId + stationWorkLogs.
- TrainLoadableItem carries originYardId/originYardLabel/loadingWindowStarted,
computed from station_work_logs in the same query.
- New YardLoadingWindows component surfaces the per-yard windows on the
loading panel; ReceiveInventoryModal and LoadToTrainPanel wire it in.
- Invalidate loadable-trains/train-loadable-items/warehouse-inventory
queries alongside train-scheduling ones, since they render off the same
data.
Fixes the StationWorkLogJson typo that broke the freight-api build.
The sheet printed Departure/Arrival Station as booking-level constants and
squeezed the footer figures into unrelated table columns (gross weight under
Cargo Name, full/empty under Wagon No.).
Stations now resolve per row from the slot's own board/alight yard, falling
back to the schedule's origin/destination and then the booking yards —
mirroring buildExportLoadListHtml's leg-slot handling.
The paper form's footer becomes its own tile block: In Total Wagon No., Tare
Weight, Load Capacity, Gross Weight, Equated Length, Full Wagon, Empty Wagon,
Total Amount. Rendered as .tile so buildTabularFallbackPdf still prints every
figure where Chromium is absent.
A booking is routinely loaded in parts, and nothing told the customer which
containers boarded and which stayed behind. The carriage acceptance sheet was
the only record, and it both totalled up cargo still sitting in the yard and
lived inside a Warehouse documents bundle that direct truck-to-train cargo
has no business in.
The sheet now marks each wagon Loaded or Not loaded and totals only the loaded
ones. On load, the customer gets an in-app, SMS and email notice carrying the
train number, route, departure time and both container lists — capped to a
summary on SMS and email, complete in the inbox. Anything left behind also
raises a warehouse-desk notice so somebody owns finding it space.
That desk is addressed by a new warehouse_inventory:get_notification
permission: a recipient selector, not a route guard, so ops can assign who
gets pinged without granting access to anything.
The GRN notice went out over SMS alone, to whatever phone number the gate
clerk typed. Where the receive carries a booking it now resolves the company
and delivers in-app, SMS and email, skipping the typed phone so the customer
is not texted twice; manual and backlog receives keep the old path.
Surfaces the physical stack/slot model in the staff app.
- ZoneLayoutModal: stacks drawn level by level with occupancy colours,
configured-vs-built-vs-occupied counts, and stack create/delete plus
block/reserve/free on empty levels
- SlotPicker in the store and move modals, offering only the next
fillable level of each stack so the form cannot suggest a position
the API will refuse
- move modal warns when a container is buried, lists the blockers, and
disables the action instead of firing a 409
- fix: move() now asserts accessibility server-side, matching release —
both are exits from a stack
Extends the warehouse hierarchy below zone with ground stacks and vertical
slots, so a container's exact position is recorded rather than only its zone.
- freight.warehouse_zone_stacks / warehouse_zone_slots, plus nullable
stack_id / slot_id on warehouse_inventory (existing rows stay valid)
- slot occupancy is derived from inventory status, guarded by a partial
unique index, so no exit path has to remember to free a slot
- placement service: hierarchy validation, bottom-up stacking rules,
accessibility/blocking-container reads, capacity vs slot summaries
- stack CRUD with auto-generated slots; reuses warehouse-zone permissions
- slot support folded into the existing move()/store() paths
- fix: validateLocation now rejects a mismatched warehouse/yard/zone triple
- seed:warehouse-layout builds the layout from a JSON config
Soft-delete route guarded by warehouses:delete, refused with 409 while
yards remain — zones and inventory hang off a yard, so cascading would
orphan stock. Backoffice list gets a delete action in both views,
omitted when the user lacks the permission.
dispatch() (LOADED -> DISPATCHED) routed through the shared transition()
helper, which only flipped status/timestamp and never called
applyCapacityDelta. deliver() (import pickup) already decrements
correctly, so export cargo leaving by train inflated currentWeight/
currentVolume/currentContainers forever instead of freeing capacity.
Add optional freeCapacity hook to transition(), wire dispatch() to it,
mirroring the negative-delta pattern already used in deliver().
New logo-settings module (mirrors stamp-settings): single uploaded logo,
stored via FilesService/MinIO, injected as a data URL into invoice/receipt,
contract, warehouse, train-scheduling, and payment-receipt PDFs. Adds a
matching backoffice settings page and settings:logo:view/manage permissions.
>
Direct truck-to-train export cargo skips the warehouse, so the existing
carriage acceptance sheet ready notice (fired on warehouse receive)
never reached these bookings. Their handover moment is the load itself.
Extract notifyCarriageAcceptanceReady into a shared notifications util
(was private to WarehouseInventoryService) and call it from
BookingJourneyService.loadBooking for EXPORT + DIRECT_TO_TRAIN bookings,
right after the GRN gate, before the load transaction proceeds.
Export cargo reaches a train two ways, but the platform only modelled
one. Direct truck-to-train cargo loads straight onto the wagon, never
enters a warehouse and so never has a GRN — yet assertExportReceivedWithGrn
required one before the carriage acceptance sheet could be issued or the
booking loaded from inside its schedule.
Adds export_handover_mode to freight.bookings (null = WAREHOUSE, so
existing bookings are unaffected) and teaches the shared gate to skip
DIRECT_TO_TRAIN. Both call sites are fixed by that single early return.
For direct bookings the carriage acceptance sheet builds its lines from
the booking's own containers, falling back to the declared bulk tonnage,
and is issuable as soon as the mode is chosen. Direct bookings are also
removed from the warehouse receive queue, since that cargo is never
coming to the shed.
Staff choose the mode from the booking detail page via a new endpoint
reusing bookings:operations. Switching to direct is refused once
warehouse inventory exists, so the two flows cannot cross.
Warehouse-then-train keeps every gate it had.
Import's customer_truck_assignments.arrived_at is set by a separate later
gate action (release()'s arrival branch — the truck returning to collect
already-warehoused goods). Export has no equivalent second step: the truck
delivering cargo to the warehouse arrives and is received in the same act,
so its arrival was never recorded anywhere.
Add markCustomerTruckArrived, mirroring release()'s existing self-haul
departure UPDATE (plate-matched, COALESCE(arrived_at, NOW())), and call it
from receive()/bulkReceive() for EXPORT bookings
Booking detail now has a Trucks tab displaying every EDR or customer truck
assigned to a booking's last mile. Each row shows warehouse-gate times
(arrival/departure) and destination-detention times (arrival/return), with
detention costs and inspection status. Detention rows only for EDR trucks;
customer self-haul shows —. Reuses existing TruckDetentionModal and
FeePreviewModal for edit/view actions.
Backend: arrivalTrucksForBooking() adds lastMileId per truck (direct chain to
detention preview), containerItems() adds inspection_status column to responses.
No new SQL, no migrations, no new endpoints.
Frontend: BookingTrucksPanel.tsx self-fetches all data via existing warehouse +
last-mile services, renders adaptive table with cargo-cost strip above.
Wired into BookingRequestDetailPage.tsx tab bar.
GRN-<DIR>-<DATE>-<REF8> carried no owner, so a note couldn't be
identified by who owns the cargo. Add an owner segment sourced from the
booking's company at every generation point (import, export, facility,
manual receive), keep REF8 for uniqueness, and label the GRN document
row Owner's Name.
Double handling billed every import with a matching rule. Add
bookings.double_handling (+ set_at/by), charge only when Yes, expose a
PATCH endpoint (import-only, locked after invoicing, audited) and Yes/No
items in the inventory row menu.
Assign was gated on any truck assigned, blocking multi-truck
deliveries. Gate on remaining containers (or bulk tonnage) instead, show
covered/total in the modal, cap a 40ft container to one truck with no
size mixing (mirrors assertTruckLoad), and lock arrived/departed rows.
Reject lease start/end and monthly payment on PURCHASE acquisitions (create and
update, validated against the resulting record). Add asset_acquisitions.item_name
column + migration. Enforce warehouse/yard/zone capacity on bulk receive and apply
capacity-counter deltas on save. Adds acquisition-guard spec.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- single-row Assign vehicle uses the full single-record flow (details + containers)
- release() rejects exit containers not assigned to the departing truck
- weighing modal offers only the selected truck's assigned containers
EDR truck gate-out now generates a per-truck handover (new
edr_assignment_id link) and notifies the customer to sign from the
portal. New sign endpoint delivers the signed truck's containers; last
signature auto-delivers remaining inventory, frees trucks and completes
the booking via import.handover.completed. Adds gate-in truck-arrival
notification and per-truck handover PDFs.
The page filtered on arrived_at IS NOT NULL, so a truck appeared only once the
warehouse receive flow stamped its arrival. Assigned trucks that had not yet
reached the yard were invisible, which left the page empty whenever nothing had
been received — every assigned truck was missing.
It now lists every truck assigned to a booking that has not departed, from both
haulage paths, tagged INBOUND (assigned, not yet arrived) or ON_SITE (arrived).
A scope toggle filters between them, dwell time shows only once a truck has
actually arrived, and the KPI count on the dashboard stays strict (arrived only).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three export rules that the flow left open.
An unpaid export booking could be received at the warehouse. Receiving is
what starts storage and mints a GRN, so it must not happen against cargo the
customer has not settled. receive() now rejects an unpaid EXPORT booking.
Import is untouched — it arrives OFF a train and its receive is the unload,
so gating that on payment would strand cargo already at the yard.
An allocated export booking could be marked loaded onto its train without
ever reaching the warehouse. An allocation is a plan; the GRN is the proof
the goods are in hand. Two loading paths skipped that check — the per-yard
loadBooking and the workspace confirmScheduleLoading — and both now require
every export booking to be received with a GRN first, however it arrived
(first-mile or the customer's own truck) and whatever it is allocated to.
The rule lives in one shared guard (assertExportReceivedWithGrn) so the two
paths cannot drift.
Export self-haul without a first-mile leg already worked and is unchanged:
assertSelfHaulPaid allows a customer truck when there is no EDR mile leg and
the booking is paid, and addTruck applies the same one-40ft-or-two-20ft rule
to containers and the tonnage drawdown to bulk, exactly as import does.
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>
Every facility raises a GRN — the goods changed hands, whether or not anyone
stores them. What differs is what happens next: Indode has a warehouse, so cargo
left there goes through the existing warehouse flow and accrues storage and
demurrage; Sebeta, Modjo, Adama and Dire Dawa only move cargo between train and
truck, so the handling event and its GRN are the whole record.
facility_handling_events carries that record because warehouse_inventory cannot:
its warehouse/yard/zone are NOT NULL, so a facility with equipment but no
warehouse could never have a row there. inventory_id links the storage record
when the facility does keep the cargo, which is what ties an Indode handover to
its demurrage.
generateGrnNumber moves to common/grn.util.ts so a GRN raised at a facility is
indistinguishable from one raised in a warehouse — the two live in different
tables, and a second generator would let the formats drift.
Recording is best-effort: the cargo moved regardless, so paperwork must never
fail the journey.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The previous commit made autoUnloadArrived and unloadBooking raise a GRN for any
direction, which changed import behaviour. Import keeps its own GRN handling
(autoUnloadArrivedBookings) and is left exactly as it was. Both paths now stamp a
GRN only when the booking is EXPORT — the direction whose cargo needs one to be
loaded onto a train. Import and domestic behave as before.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Requiring a GRN before loading only works if every path into the warehouse
issues one. Two did not: autoUnloadArrived and unloadBooking created RECEIVED
inventory with a null grn_number, so cargo that genuinely arrived — by first
mile or self haul — would have been stuck un-loadable behind the new gate.
Both now stamp a GRN, derived from the booking's trade direction, matching
receive/bulkReceive/autoUnloadArrivedBookings. unloadBooking keeps an
already-issued GRN when it re-unloads an existing row rather than reissuing one.
Every path that creates warehouse inventory now issues a GRN, so the chain is
seamless: booking arrives (first mile or self haul) -> received -> GRN -> loadable
onto its allocated wagon.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The export chain is: paid -> received into the warehouse -> GRN raised on arrival
-> loaded onto the allocated wagon. Receipt was already structural (the inventory
row only exists once receive() runs) and the wagon was already required, but the
GRN was merely displayed, never enforced — so cargo could be loaded and
dispatched without one.
- loadable now also requires a GRN, so the queue won't offer un-GRN'd cargo.
- loadItemsOntoTrain skips items with no GRN, so the rule holds server-side and
a hand-made API call can't bypass it.
- Read the GRN from inv.grn_number (what receive() stamps) and fall back to the
note only for legacy/seeded rows; it previously read the note alone, which the
real receive path merely mirrors.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
dispatchSchedule guarded status, Djibouti departure rules, locomotives and
wagons — but never checked the cargo. A train could be dispatched while the
bookings allocated to it sat received in the warehouse, silently leaving them
behind. Dispatch now refuses when an allocated booking has warehouse inventory
in RECEIVED/STORED/READY_FOR_LOADING, naming the bookings and pointing at the
two ways out: load them, or drop the wagon allocation so they ride a later
train. Bookings with no inventory at all are not blocked — allocating a wagon
before the goods arrive is normal planning.
Also drops RESERVED from the Load-to-Train filters: reserved stock is not
awaiting loading.
The sched_bookings CTE moves to common/schedule-bookings.sql so the warehouse
loading queue and this dispatch guard resolve a train's bookings identically —
if they drift, a train departs leaving cargo the warehouse still expects to load.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Load-to-Train queue was permanently empty for real traffic. loadableTrains()
and trainLoadableItems() gated on freight.train_schedule_bookings, but nothing in
the application writes that table — only the demo seeders do. Real wagon
allocation writes wagon_booking_allocations, reached via
train_schedules -> train_sets -> train_set_wagons, so an allocated export booking
never satisfied the EXISTS gate and no train ever appeared.
Both queries now resolve a schedule's bookings through a shared sched_bookings
CTE that unions the wagon-allocation chain with train_schedule_bookings, so real
allocations show up and the seeded demo scenarios keep working. The panel already
groups the returned rows by booking with their containers, so the queue now lists
the train, its bookings and their containers for selection.
Export flow this serves: booked -> paid -> received at the warehouse (first-mile
or self-haul) -> GRN -> loaded onto the wagons allocated to the booking.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>