Intercity load unload
Data model — yards.has_facility, yard_facilities, facility_handling_events, resolver, seeder
The gate — intercity load/unload refused where there's no facility
GRN raised on every facility load/unload; storage only at Indode
Yards config toggle — adding a facility is now a toggle, not a code change
Which yards can handle cargo is data, not a constant — the set grows. The Yards
config screen now carries the flag, so adding a facility is a toggle rather than
a code change. Config-driven, so this is a column plus a form field.
Co-Authored-By: Claude Opus 4.8 (1M context) <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>
Intercity cargo rides a passing train and is handled at the booking's own yards,
so those yards need the equipment to do it — a train stopping somewhere is not
the same as somewhere being able to load it. Loading at an origin or unloading at
a destination without has_facility is now refused, naming the yard.
The check sits inside loadBooking/unloadBooking rather than the intercity
controller wrapper, because recording a checkpoint auto-unloads every booking
destined at that yard (autoUnloadAtYard) and would otherwise route around it.
Import/export are untouched: their cargo is handled at the route's terminal
ports, not at an arbitrary mid-corridor yard, so the gate returns early for
anything that isn't DOMESTIC.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Intercity cargo is loaded at its origin yard and unloaded at its destination, but
only some yards have the equipment. EDR's facilities are Indode, Sebeta, Modjo,
Adama and Dire Dawa — and the set grows, so it has to be data.
- yards.has_facility marks a yard as a load/unload point; the new yard_facilities
record says what it can do. Only Indode stores cargo (has_warehouse), so only it
accrues storage/demurrage — the rest just move cargo on and off the train.
- facility_handling_events records each load/unload and carries its GRN.
warehouse_inventory cannot: its warehouse/yard/zone are NOT NULL, so a facility
without a warehouse could never have a row. inventory_id links to the storage
record when there is one.
- YardFacilitiesService.facilityForYard is the single resolver the handling flows
share, so they cannot drift on what a facility is.
- The seeder flags EXISTING yards and creates none. The codes are historical and
do not read like the facility names — Indode is KALITY ("Gelan Multi Purpose
Port (Indode)") and Sebeta is LEGACY_DEST — so it maps by code. Creating fresh
INDODE/SEBETA yards would have split data that routes and bookings already
reference.
Negad is deliberately absent: NAGAD ("DCT/SGDT") is in Djibouti while
NEGAD_FY_BCC is in Ethiopia and inactive, and which one is the intercity facility
is unsettled.
No behaviour change yet — nothing reads has_facility until the gate lands.
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>