A booking sold WITHOUT the return service had no way to send its empties
back: the containers were the customer's problem and nothing in the
system priced, billed or planned the movement.
Adds the request flow end to end. The customer opens the booking, says
how many containers are coming back and types each number; the request
lands in a new backoffice queue (Empty Return Requests). Approval prices
it off the same live WITH_RETURN route rate the rule engine bills when
the service IS bought up front — per container, converted to birr, and
overridable by the reviewer — and issues the invoice there and then.
Payment settles through the normal invoice path, whose
`empty_return_request.invoice.paid` event moves the request to PAID; the
customer then books the return date and the truck.
Scheduled requests surface on Container Returns as Planned Empty
Returns, where confirming the arrival records the containers through the
existing empty-container-return flow — which in turn closes the request
once its last container is in.
Container freight only, never a booking that already ships WITH_RETURN,
and only from IN_TRANSIT onward: the empty comes back after delivery, so
the option has to outlive ARRIVED.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Container Returns only ever offered the last-mile path: a booking reached
the list once it had a truck assigned and warehouse inventory flagged as
returning. Bookings that ship WITH equipment return had no way in, so the
empties they owe were invisible until that path happened to fire.
Adds GET /import-operations/empty-return-bookings — the containers a
booking flagged is_return, carrying whichever of them already has an
empty return recorded, grouped one row per booking and dropped from the
list once nothing is pending. Covers both spellings of the booking's
equipment_return (WITH_RETURN and the older RETURN) and skips bookings
that never ship.
Backoffice grows a "Bookings With Empty Container Return" card above the
existing sections: pick the booking, tick the containers coming back,
say where they landed, and each tick becomes an empty container return
on that booking — which is what the Returned Containers table then
advances. The existing last-mile, standalone and bulk flows are
untouched.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.
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
Yard and zone soft-delete, refused with 409 while a yard still has
zones or a zone still holds inventory. warehouse_zones:delete was
missing from the catalog — the role presets spread every zone key, so
its absence crashes FreightPositionsSeeder at boot; a migration seeds
it everywhere.
Clicking a zone opens its contents as a datatable. Container identity
comes from booking_container_units for booked cargo and from
containers for backlog registrations; bulk cargo keeps its row with no
container number rather than disappearing from the zone.
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.
Backend now generates a separate marshalling document per corridor stop
where the consist actually coupled/uncoupled/switched something
(marshallingStops/marshallingDocumentAt), instead of one 'current
position' doc. Wires that into the backoffice:
- TrainScheduleV2DetailPage: the single Intercity Marshalling menu item
becomes one item per stop with a logged change, falling back to the
old single item when nothing has happened yet.
- TrainScheduleTrackPage: same fallback/menu treatment on its own
marshalling button.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Empties already sitting in an EDR yard but never entered in the system had
to be typed one at a time. Adds a bulk path: parse the sheet in the browser
(all-or-nothing, row-numbered errors), preview it, then POST one batch.
The server rejects the batch if any container already has a non-COMPLETED
return, so re-uploading the same sheet cannot duplicate boxes. No interchange
notification fires — these are historical rows, not a live handover.
Company is an Autocomplete over registered customers that also accepts a
typed name, since a backfilled box may belong to a company that is not a
customer yet. Exact name match sets customer_id; the name always lands in the
new empty_container_returns.company_name.
Also fixes the single Record Return modal, which collected Yard and Zone and
then dropped them before the API call, and did not invalidate the returns
list after a standalone return.
A GENERAL + customs contract does not let the customer book directly: they
submit a shipment request, and initiateForShipmentRequest opens a BARE booking
from it — "the request itself carries the quantities; the instance carries
none". Between initiation and completeUnderContract the booking legitimately
holds no cargo, so the export reported 0 containers for a customer who had
declared, say, 2 x 20FT. 23 bookings on dev data are in that state.
Adds two columns and one filter reading booking_requests.requested_lines:
- "Requested cargo" — the declared lines as text ("2 x 20FT"), handling the
bulk shape too (tons / item count), not only containers.
- "Requested containers" — the declared box count, with a matching min/max
filter on the list and the export.
Deliberately a separate column rather than a fallback inside the real container
count: a declared 2 x 20FT is a request, not two boxes on a booking, and
merging them would overstate operational totals. The two compose instead —
Containers = 0 AND Requested containers >= 1 is exactly the set awaiting
completion after clearance.
requested_lines is free-form jsonb, so the container array is guarded by
jsonb_typeof before jsonb_array_elements; one malformed row would otherwise
500 the whole list.
Container filters on the booking-requests list:
- "Container type" — bookings carrying that type.
- "Containers" — a count of BOXES (booking_container is one row per line with
a quantity, so this sums quantity rather than counting rows), as an exact
value or a range. It reads the container-type filter when one is set, so the
one control answers both "10 containers in total" and "10 forty-footers".
Export gains a column per container type ("20FT containers", "40FT
containers"), plus the total "Containers" column and the two filters. Container
types are reference rows, not a constant, so `ExportDataset` gains an optional
`dynamicFields` resolver — DB-driven columns appended to the static list and
cached for the process, mirroring the existing `ExportFilterDef.optionsQuery`.
Adding a 45ft container type adds its column with no code change. The type id
is interpolated into raw SQL (ExportField.select has no parameter bag), so the
resolver drops any id that is not a uuid.
Also repoints the export's "Container VGM" column at the per-line sum. It was
projecting bookings.cargo_total_weight_vgm, which the portal wizard leaves at 0
for container freight — the same trap the tonnage fix addressed — so the column
read 0 for every portal-created container booking. Non-zero on dev data goes
from 54 to 170 of 208 container bookings.
The booking-requests list could filter by freight type but not by what is
actually in the booking, and the export's only cargo column showed the
commodity name — blank for every container booking, which stores no
commodity at all.
Adds one resolver, `bookingContentSql`, that answers "what did the customer
say is in this booking" per freight type: the container lines they entered
("2 × 40FT, 1 × 20FT") for container freight, since the wizard asks them for
no description; the commodity they picked from the cargo tree for bulk,
falling back to their free-text description.
List filters:
- "Content" — a single select flattening the cargo tree the same way the
booking wizard presents it (group, then each commodity as "Bulk → Wheat").
Picking a GROUP matches its whole subtree via a recursive walk, so "Bulk"
returns all 44 bulk bookings rather than the 0 that carry the group id
itself. This makes the existing, previously unexposed `cargoTypeId` param
group-aware.
- "Content contains" — a contains-search over the description, the commodity
name and the container types, so container bookings are reachable by "40FT"
even though they carry no words of the customer's own.
Both apply through `applyListFilters`, so the list, its summary tiles and its
facets agree, and both are declared on the bookings export dataset — the
export button already forwards the page's filters verbatim.
Export fields: "Content" (default), plus "Cargo description" as its own
column. The old `cargo` column is unchanged and still selectable, relabelled
"Cargo (commodity)"; it loses only its default tick, so saved presets that
name it keep working.
- Added a reason field to train history entries for detach/maintenance actions.
- Updated TrainHistoryPanel to display the reason for wagon detachments.
- Introduced per-wagon load/unload functionality in ScheduleWorkspacePanel with a modal for managing individual wagons.
- Implemented API endpoints for loading and unloading specific wagons, including the ability to cancel remaining wagons with a reason.
- Refactored detach request handling in TrainBuilderDetailPage to streamline the process and remove the approval flow, requiring a reason for detachments.
- Updated types and services to support new wagon loading/unloading features and booking wagon retrieval.
The detail page showed the company's business contact details but not the
credentials anyone actually signs in with, and the two drift apart
routinely — so "the customer says they can't log in" was unanswerable
from this screen.
Adds `GET /backoffice/customers/:companyId/accounts`, joining each
external profile to its IAM account, primary contact first. Deliberately
not filtered to active accounts: a suspended or never-activated login is
exactly the case being looked into. The user query selects columns
explicitly — the entity's relations include credentials and sessions, and
this response reaches a browser.
Rendered as cards rather than a table: it is a handful of rows of
mostly-optional fields, which a table renders as a field of dashes.
"Password never set" is called out on its own, being the usual answer to
"they never got in", and a profile whose IAM user is gone reads as a red
fault rather than an inactive status.
- Added StationWorkControls to manage loading/unloading phases in TrainScheduleV2DetailPage.
- Implemented API endpoints for recording station work and managing wagon detach requests.
- Updated contract templates to include Ethiopian customs handling options.
- Enhanced shipment forms to collect customs clearing agent details for without-customs bookings.
- Introduced NUMBER_OF_WAGONS as a unit of measure for bulk cargo, allowing customers to specify wagon counts.
- Improved validation for customs clearing agent information in shipment forms.
- Updated various components and services to accommodate new features and ensure data integrity.
- Implemented read-only locking for customer-requested container sizes and billing currency in the GlCreateBookingForm component.
- Added functionality to lock partner quantities based on shipment requests in the ConsolidationPartnerPanel.
- Introduced a new Leave action in the LogPassYardWorkModal to unassign bookings from trains.
- Enhanced the AuditLogsPage to support filtering by action and added a Go button for direct navigation to entity detail pages.
- Updated WagonCancellationsPage to handle odd-20ft credits requiring partner selection during rebooking.
- Improved TrainScheduleV2DetailPage to allow manual loading of cargo and display warnings for unassigned bookings.
- Added a new reference field to the audit logs for better searchability and tracking of actions.
- Created a migration to add the reference column to the audit logs table and established an index for efficient querying.
- Defined a registry for audit reference sources to streamline the retrieval of human identifiers for various entities.
BuyerDetails Country/Region/City/Wereda now resolve from the Ministry's own
EIMS_COUNTRY_REGION_VW master instead of the EIMS_BUYER_*_CODES env maps and
the ethiopia-geo-codes table. Both invented their codes and looked names up
globally, so KERSA/GORO/BABILE/BURE — each present in several zones with
different LOCALITY_NOs — could be filed against the wrong jurisdiction.
Resolution is hierarchical and refuses to guess: an unknown or ambiguous
address raises a local validation error naming the level that failed, and
never selects the first matching row. Spelling differences between EDR and
MoR live in a reviewed, parent-scoped alias layer; the dataset itself stays
verbatim so it remains traceable to the Ministry sheet.
Resolution now runs before the counter reservation in both the single and
bulk paths, so a bad company address no longer burns an EIMS sequence number.
Adds eims:import-locations to regenerate the dataset from a future workbook,
reporting duplicate rows and same-hierarchy code conflicts.
- Implemented pagination in ScheduleHistoryPanel to manage large history entries.
- Updated API to support pagination parameters for schedule history.
- Enhanced ConsolidationApprovalsPage with tabbed navigation and pagination for approval rows.
- Introduced new types for paginated responses in bookings and train scheduling services.
- Added a database migration to create an index on wagon_booking_allocations for performance improvements.
- Implemented pagination in ScheduleHistoryPanel to manage large history entries.
- Updated API to support pagination parameters for schedule history.
- Enhanced ConsolidationApprovalsPage with tabbed navigation and pagination for approval rows.
- Introduced new types for paginated responses in bookings and train scheduling services.
- Added a database migration to create an index on wagon_booking_allocations for performance improvements.
- planned couples: loose wagons join the train at a route stop, added
from the schedule yards tab; capacity credits them per corridor edge
and coupling validates locomotive weight/length caps per leg
- real-cut toggle: a cut wagon permanently leaves the train build at
its cut yard (soft cut still sits out one trip only)
- fix heaviest-leg display counting a shared slot's full cargo on
every spanned edge (phantom pull-weight overload on S-2026-00045)
- confirmation dialogs for workspace add/load/unload/remove actions
- train-builder History and Detached-wagons tabs, backed by paginated
endpoints; builder detaches now always write adjustment-log rows
Migrations 3660 (planned_wagon_couples, planned_wagon_real_cuts) and
3670 (adjustment log train_schedule_id nullable) — both applied to the
dev DB by hand; watch mode does not run migrations.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds maintenanceFrom/maintenanceTo to WagonListFilters and wires a
wagons-only "Last maintenance" date-range filter into FleetResourcePage,
alongside the existing Registered filter. Server-paged, so the range is
resolved by the API (see wagons.service.ts).
role-dashboards.config.ts's ROLE_LAYOUTS was a hand-typed table of ~40
raw role/position keys (including edr_freight_app/xxx ad-hoc
sub-positions), matched against the user's held keys client-side.
Replaced with resolveOverviewLayout(), which just picks the
highest-priority key out of whatever GET /overview/layouts returns —
the same 'server filters by permission, frontend renders what comes
back' shape Reports already uses, so the frontend no longer needs to
know any individual permission key.
Hard cutover: no fallback to the old key table. Positions not covered
by the previous commit's grants (the ad-hoc department sub-positions)
will render the executive layout until granted a layout permission.
Empty container returns now generate a downloadable Equipment Interchange Receipt: container number & size, exact return timestamp, depot/location, condition, and the carrier/booking reference. Customers can download it from their booking's Documents tab (own-booking returns only); staff can pull any via the same endpoint.