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>
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.
Guards the two facts the report exists to get right: the cancellation
fee is never a payable, and exactly one wagon-cancellation status
(CREDIT_AVAILABLE) is a live liability. Adding a status to
WAGON_CANCELLATION_STATUSES now fails here until someone decides which
side of the ledger it lands on.
Also pins the sort expressions to the union wrapper alias — a branch
alias would resolve at build time and 42P01 at runtime, since the runner
appends ORDER BY outside the subquery.
The receivable/payable split contradicted how money actually moves, in
three ways that each changed a headline number:
- The wagon-cancellation FEE was booked as a payable. It is money the
customer owes EDR (raised ISSUED and unpaid at request time), so it
belongs on the receivable side while open. The sign was inverted.
- A whole-booking wagon cancellation was booked at the source invoice's
full paid_amount, and never cleared: the booking stays CANCELLED and
the invoice stays PAID even after the credit is rebooked. The real
liability is the ledger row's credit_amount, and only while it sits in
CREDIT_AVAILABLE — cancellation refunds no cash, it hands back
bookable credit redeemed by creating another booking.
- Shipping-line debt in UNBILLED has no invoice row at all, so an
invoice-only fact table could not see it. That is the un-batched half
of the debt, in the report whose stated purpose is shipping-line
credit.
The report is now a UNION of the three tables that hold the answer:
invoices with a balance (plus prepayments against dead bookings),
UNBILLED shipping_line_credits, and CREDIT_AVAILABLE
booking_wagon_cancellations. A booking already carried by the
cancellation ledger is excluded from the invoice branch so its money is
counted once. Fully settled invoices are dropped — zero exposure is
neither a receivable nor a payable.
Branches are re-projected through an explicit column list before being
unioned: UNION matches by position and TypeORM does not preserve
addSelect order, which silently reordered one branch into
"gross, exposure, side_key, ..." and failed with "UNION types text and
numeric cannot be matched".
Verified against Postgres with a rollback-only fixture covering every
side, plus EXPLAIN over each filter combination and every sortable
column.
Targets could only be committed weekly, monthly, quarterly or yearly, so a
figure the business quotes per half-year or per 90 days had to be split by
hand into buckets it was never expressed in. The reports already re-gather
a target into whatever grain the viewer asks for; this just lets the plan
be entered at the grain it was agreed in.
Adds day, half-year, nine-month and 90-day, matching the report units added
alongside. normalisePeriodStart snaps each to its block start with the same
calendar-year anchoring the SQL uses — Jan/Jul for half-years, Jan/Oct for
nine-months, days 1/91/181/271 for 90-day blocks, including the same cap on
the fourth block so late December does not snap into a stub of its own.
That agreement is the load-bearing part. The unique index is keyed on
period_start, and a target snapped to a boundary the report does not bucket
on is a plan measured against a period that does not exist. The two halves
live in different files and different languages, so the spec pins the
boundaries rather than trusting them to stay in step.
Adds operations-targets.service.spec.ts, which the module had none of:
every period type, idempotency, the leap year, and the block-four cap.