Four changes the business asked for on Charged and Actual Volumes:
- Split the Leg column into From and To, both sortable.
- Add 20ft and 40ft container counts beside TEU, off the marshalling
record's container items.
- Classify cargo into the revenue vocabulary rather than the operational
one, so a corridor's tonnage and its revenue read in the same buckets.
Charge-only buckets (incidental, first/last mile, customs) cannot be
emitted — no physical wagon is one.
- Carry the empty wagons as rows of their own, the way the marshalling
document lists them. `allocationLedgerQb` gains `includeEmptyWagons`,
which starts the ledger from the wagon instead of the allocation; the
empty and total wagon counts become plain group aggregates, so a
departure's wagons now add up down its rows instead of every row
repeating the train's total. Vehicle-Km lands on the empty rows and
sums across legs.
`LOADED_WAGONS_EXPR` gains a FILTER on the allocation being present — a
no-op for every allocation-grain report, and the fix at the one place
all of them route through. `SCHEDULE_EMPTY_WAGONS` had no callers left
and is deleted.
Verified: type-check clean, 50 report specs pass (incl. a new one
asserting the cargo expression only emits keys the revenue vocabulary
offers), and the report SQL EXPLAINs and runs against edr_dev — 60 empty
wagons over 4 rows, 53,539 Vehicle-Km, 20ft/40ft counts populating.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Grouped by Train, a row was a period average over the train's stops, which
threw away the one thing that grain is for: when the work actually happened.
The row is now the stop itself — logged arrival, departure, unloading and
loading times, and that stop's own durations. Grouped by Station it still
averages over every train that called there.
The two shapes need different columns, so `ReportColumn.hideWhen` names the
filter values that hide a column, and the runner resolves sort against the
visible set — sorting by an average and then switching to Train falls back to
the default sort instead of a 42703 on a column the query no longer selects.
`ReportFilterDef.defaultValue` pins the unset grain to `train`, so "no value"
is never a third shape. The table, the export field list and the chart toggle
all follow the visible set; the chart is station-grain only, since per-stop
rows have nothing to bar-chart.
Both grains EXPLAIN-validated and run against the dev database.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
IAM lets an employee hold several positions, but the vendored JwtGuard
collapses employee.positions[] down to a single employee.position and
drops the rest. Non-delegate secondary positions vanished entirely, so
staff on two posts resolved to one post's permissions and every check
on the other rejected them.
FreightJwtGuard re-attaches the full list from the same session
snapshot the parent guard already read, so nothing extra is fetched
per request beyond a cached session lookup. employee.position is left
untouched, keeping audit logging and delegation unaffected.
collectPermissionKeys and collectPositionTypeKeys now union across
every position, and /me returns them all.
Verified against a real two-position user (djibouti-gl-director +
djibouti-gl-chief) on the local dev database:
/me positions 1 -> 2
/me permissionKeys 17 -> 28
GET /api/interchange-documents 403 -> 200
GET /api/trains 403 -> 200
11 permissions recovered, none lost. Six single-position users return
byte-identical payloads before and after.
- 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.
The OCC report publishes total loading and unloading time and the other
activity left over from a station stay, but nothing recorded when handling
started or ended — the July 2026 seed had to write the figure into a
checkpoint note. Four nullable stamps now ride the stop's arrival row, which
is the row the staying-time report builds a stay from (a turnaround's
departure belongs to a different schedule).
Handling is unloading start to loading end, so a container stop reads as one
window and a bulk station that only loads or only unloads still reports its
half; other activity is the rest of the stay. Both stay NULL where nothing
was logged rather than collapsing to zero.
- station-staying-time: + loading/unloading and other activity per stop
- turnaround-cycle: + the same, summed over the cycle's stops
- loading-unloading (new): per train per station per period, so a week or
month view is that train's average over its stops
- the stop/stay query moves to operations-classification, shared by both
Reproduces the monthly count sheet a port warehouse publishes: trains,
containers by size and laden state, wagons, TEU and bulk wagons per cargo
type, each split into export and import beside an overall total.
Two departures from the spreadsheet it replaces:
- Wagons are counted distinctly from the marshalling record rather than
derived as 20ft/2 + 40ft + bulk wagons, which overstates whenever a wagon
ran part-loaded.
- Total is counted over everything rather than summed across the direction
columns — a train carrying both an import and an export booking belongs to
both and would otherwise count twice.
Demurrage is billed on invoice lines and is left to Revenue by Category.
Adds a station filter matching either end of the corridor, so one warehouse
can report the trains it worked in both directions.
These reports resolved ctx.directions and never read it, so a user restricted
to one trade direction saw every row, and a user with no trade access — where
directions is [] and the rule is show nothing — saw all of them.
first-last-mile-bookings scopes on the booking's own direction; the other five
scope on ts.direction. global-logistics-wagons uses the fragment form so a log
row whose schedule is gone stays visible, which is the rule the other ledgers
apply to rows carrying no direction.
Also adds the missing soft-delete guards: b.deleted_at on the first/last mile
booking join, ts.deleted_at on the wagon-teu-utilization and
global-logistics-wagons schedule joins.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The cargo filter was applied only to the operated and attainment subqueries.
The plan side selected targets by metric and dimension alone, and the FULL
OUTER JOIN put every filtered-out key back as a row of zeros —
?categories=FERTILIZER returned all ten planned categories.
planKeyFilter restricts targets to the selected categories (or container
classes), reading ot.cargo_category for a station plan and ot.dimension_key
otherwise. Values are whitelisted against the vocabulary and inlined, because
the fragment is assembled into raw CTE text and the runner does not validate
multiselect values.
Two related grain leaks close with it:
- planCountryFilter narrows a station plan to the chosen country. All seven
station targets are Ethiopian, so the Djibouti view was listing 33 Ethiopian
targets as stations that moved nothing.
- planGrainFilter drops the plan entirely when origin, destination, train
number or direction is set. No target carries a route, so the plan there was
the whole corridor's target sitting beside one slice of its work, and the
implement rate read as a miss that never happened.
Fixes cargo-volume-performance, cargo-volume-by-station, trainset-performance
and teu-performance together.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Report descriptions run to a paragraph. ReportDescription wraps Mantine's
Spoiler to clamp them to two lines, and the toggle only renders when the
text actually overflows.
PageHeader kept its subtitle on a hard truncate class, which would have
pinned the spoiler to one line, so a ReactNode subtitle now renders as-is
and owns its own layout. A string subtitle still truncates as before.
Whole-file requote to double quotes plus wrapped column literals. No
behaviour change.
Kept as its own commit because it is out of step with the rest of
reports/definitions, which is single-quoted: bare prettier ignores
@edr/prettier-config, so running it on one file requotes that file alone.
Drop this commit if the directory should stay consistent.
Loaded and empty wagons were both shown but never summed, so the train's
actual consist had to be added up by hand on every row. MAX() on the empty
count for the same reason the distance uses it: the value is constant within
a group that includes ts.id and the leg.
The report priced every departure against its planned origin-to-destination
corridor, so a train that worked several station-to-station moves showed one
row and one distance. Ton/Km and Vehicle-Km were then computed against that
single corridor and understated the work actually done.
The row grain is now the leg: consecutive checkpoint events at different
yards, read with lead() over each schedule. DISTINCT because a train that
works the same pair twice in one departure is still one leg — without it the
join fans the cargo out and doubles every SUM in the group. Both ends fall
back to the schedule's own corridor, so a departure with no checkpoints
logged keeps exactly the single row it had before.
Only query() joins the legs. The KPIs stay corridor-level and would count the
same cargo once per leg if they had that join.
The bare UUID column sat next to the booking reference it duplicates, and a
reference is what anyone reading or exporting this report actually quotes. The
report is the audit trail for an export, so a column nobody can act on is
weight in every downloaded file.
Eleven report columns bucketed their timestamp to a bare day with to_char,
which is wrong for anything a user reads as an event rather than a period:
two departures on the same date, or a wagon request fulfilled hours after it
was raised, were indistinguishable in the output.
The renderer only shows the time when the value actually has one, keyed off
the string rather than a per-column flag — a genuine day bucket would
otherwise render as 12:00 AM, which reads as data rather than as absence.
Both are numbers a customer or a provider support desk quotes back, so they
belong in the free-text box rather than behind a filter pill.
The transaction id and merchant order id are plain ORs — the payment alias is
already joined by every caller of applyInvoiceFilters. The PNR folds into the
existing booking EXISTS block instead of adding a second subquery, so it
inherits that block's correlation and also matches warehouse-, first-mile-
and last-mile-sourced invoices, not just booking-sourced ones.
bk is promoted to alwaysJoin now that the export's scope() references it.
The PNR is the CBE_BILL reference the customer actually pays against, but it
is stamped onto the booking at payment-initiation time — it is a column on
neither the invoice nor the payment. Both surfaces read it back by source id,
the same lookup the sealed invoice PDF already did, so screen, export and
document now agree.
The export's join casts bk.id::text rather than i.source_id::uuid: source_id
is a bare varchar pointer that is not always a UUID (EIMS self-test rows
carry a slug), and casting that direction throws on those rows.
The settled method is split across two stores: a gateway settlement records
the real provider on the linked freight.payments row (cbe-bill, telebirr)
while the invoice's own payments ledger only writes a flat "GATEWAY"; a
manual settlement has no payments row at all and the ledger is the only
source (BANK_TRANSFER, OFFLINE, or whatever PayInvoiceDto.method carried).
invoicePaymentMethodExpr folds both into one UPPER_SNAKE vocabulary —
provider first, newest ledger entry as the fallback — and the list filter,
the export field and the export filter all use that same expression, so the
screen and the file can never disagree.
The paymentMethods param is deliberately not validated against a fixed list:
the manual pay endpoint takes a free-form method, so an IsIn would silently
drop real values.
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>
Carves cargo load/unload confirmation out of the coarse
trainScheduling.update permission into its own guard
(TrainSchedulingLoad/TrainSchedulingUnload), covering import, export,
and intercity — the generic per-booking route already serves all
directions, and the intercity-specific route gets the same two keys.
Adds the catalog entries and grants them to operationsOfficer/director
alongside the existing .update grant so current access is unchanged.