Every revenue report excluded invoices whose booking carries
contract_kind = 'GENERAL', on the premise that such a booking is an
umbrella contract row paid once and drawn down by many orders, so
counting it alongside those orders would double-count.
That premise does not hold. On the dev database all 169 GENERAL
bookings are real shipments with origin and destination yards, warehouse
receipts and their own invoices; there is no umbrella invoice to
double-count, and no invoice source of that kind exists at all. The
predicate simply deleted 119 invoices and ETB 164.6M of billed revenue
from every Finance report, which is why revenue-by-customer reported
ETB 39.4M collected while /billing/invoices/summary reported ETB 140.1M
- a gap of exactly ETB 100,741,976, the paid total of the 80 PAID
invoices the predicate hid.
Removing it from the shared revenue and invoice ledgers brings the
reports back in line with billing (ETB 210.6M billed, ETB 140.1M paid,
163 invoices), and removing it from the overview repository restores the
same bookings to the operational KPIs.
Also fixes what that exposed in the reports that build their own query:
- GATEWAY_PAID read the booking's whole gateway total onto every invoice
sharing that booking. With 25 booking ids backing 58 invoices, the
reconciliation report claimed ETB 113.2M of receipts against ETB 43.6M
of settlement and showed ~ETB 89.4M of variance that does not exist.
Receipts are now apportioned across an invoice's siblings by settled
share, so the gateway column sums to the payments table and total
variance is the real ETB 13.1M of manual settlements.
- Aging Receivables joined companies with an INNER JOIN, dropping
shipping-line-billed arrears, and summed both currencies under a
hardcoded ETB label. Both payer joins are now LEFT and the report
takes a currency filter.
- Invoicing Pipeline summed ETB and USD invoices into one ETB total and
applied no trade-direction scope, unlike every other Finance report.
Both are now applied.
Verified against the shared dev database: every new statement passes
EXPLAIN, and the report totals reconcile with billing and with
freight.payments. Type-check and the reports and overview suites pass;
the five failures in billing.service.spec.ts are pre-existing on this
branch and untouched by this change.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Marshalling 2 rendered only the final consist after a mid-corridor
adjustScheduleConsist call, with no signal that anything changed.
Reuses the existing ScheduleWagonAdjustmentLog (already recording
every ADD/REMOVE/SWITCH) filtered to the printed stop's yard and to
ADD/SWITCH, and prints a Consist changed at this stop box. Origin
import/export docs are untouched — the option is only passed from
intercityMarshallingDocument.
Origin marshalling (import + export) now prints leg slots as
TO BE LOADED AT <board yard> with board/alight stations and keeps
their containers out of the loaded tallies. Intercity Marshalling 2 no
longer hides whole-route cargo whose allocation never left PLANNED
(import flow confirms loading at schedule level), so the on-board view
matches the physical consist before and after mid-corridor coupling.
- 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.
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.