- Add ShippingLineBookingsPage for listing and managing shipping line bookings.
- Create ShippingLineDocumentsModal for document uploads related to bookings.
- Introduce ShippingLineInitiateModal for initiating new shipping line bookings.
- Implement booking document state management with booking-doc-state utility.
- Add shipping line bookings service for API interactions.
- Update index to export new components and services.
- Enhance types for freight to include shipping line credits.
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
Nest runs class and method guards together, so a class gate naming only
the view key silently required view AND action. Staff granted just an
action were denied before their key was checked. Each class gate now
names every key its routes use, and FleetView accepts an array so the
fleet controllers keep their coarse fallback.
Drops the one-off grant mapping SQL with it: already applied to dev, and
this fix removes the companion-view rule that was its recurring part.
Nest runs class and method guards together, so a class-level view key
ANDs with every action key below it. Staff granted only an action were
denied before their key was ever checked: OCC could not fulfil wagon
transfers, dispatchers could not create a yard, and track staff could not
assign first/last-mile vehicles. Reads now carry the view key themselves,
and the warehouses baseline lists every key its routes use.
Gates the previously open support-agent, procurement, compliance,
facilities, list-users and trade-access controllers, separates customer
from staff routes across bookings, contracts, companies, billing,
warehouses, files and train scheduling, and moves billing, overview,
reports and the settings controllers onto their own keys instead of the
blanket admin key. Drops the demo-permissions module and the untested
notification test route.
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.
Join truck_types via vehicles.truck_type_id (normalized legacy
vehicle_type only as fallback) so type renames can't unmatch detention
rules and FK-less vehicles keep billing.
Store the signer's saved-signature URL on booking_handovers (new
signature_image_url column) when a handover is signed — per-truck or
booking-level — so signed handover documents can render the actual
signature, matching the contract-signing flow.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
Yard create/update now rejects capacities that overflow the parent warehouse,
and zone create/update rejects capacities that overflow the parent yard, via
BadRequestException. Dispatcher permission preset expanded to full CRUD on
warehouse and fleet management; allocation and fee rules remain view-only.
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>