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.
A target is a quota, not a flat allowance. The Plan column spread it evenly
and kept asking for the same twelfth of a yearly figure no matter how far
behind the year had fallen, so the one number operations actually needs —
what must move per month for the rest of the year — was nowhere on the
page.
Plan now keeps its meaning and a Required column sits beside it. Plan is
the committed spread and never moves, which is the whole reason it stays:
Implement Rate is measured against it, so a month that missed still reads
as a month that missed. Required is the same target read as a quota — at
each bucket, whatever is still outstanding spread across the time still
left. A 1,200 t year 20% met by June asks 140 t of June and 960 t of
December, which is 1,200 less the 240 delivered. Over-delivery clamps to
zero rather than going negative.
Attainment is deliberately measured with the user's date bounds stripped
(attainmentCtx) and every other filter left in place. Reusing the report's
own filtered aggregate would make a July-only view read year-to-date as
nothing delivered and demand the entire year's tonnage from one month —
the failure would look like a plausible number, not an error.
Granularity gains half-year, nine-month and 90-day. Postgres has no
date_trunc for any of them, so PERIOD_UNITS entries became builders rather
than fragments to interpolate, and all eight blocks anchor to the calendar
year. Nine does not divide twelve and 90 does not divide 365: a nine-month
year is Jan-Sep plus a short Oct-Dec, and the fourth 90-day block absorbs
the remainder at 95 days. That last one is a choice — uncapped floor
division opens a five-day stub bucket every December, which is noise
rather than a period.
Two consequences of the shared unit table, both handled here:
- plannedRowsSql now generates a day at a time and groups, instead of
stepping by the bucket width. The ragged blocks restart each January, so
stepping 90 days from January 1st walks off the anchor in the second
year. Day grain also gets partial-bucket overlap for free, at the same
sub-day precision the old clipping had.
- nextPeriodOrdinalExpr asks the unit for its next block start rather than
adding its own step. revenue-by-period evaluates a regression there, and
a ragged unit's final block is shorter than its nominal width, so + step
would land past the next block and forecast at the wrong x.
Verified against Postgres 16 with the entities synchronised into it: all
272 report/granularity combinations in the registry EXPLAIN clean, and the
1,200 t drill-down sums back to 1,200 at every one of the eight grains.
A station target's plan is per station AND per cargo type; the other two
dimensions already carry the category inside dimension_key. update() kept
whatever was stored whenever the payload omitted the key, and the admin
form builds its payload from visible fields only — so switching "Plan by"
away from Station left the old category behind on a row that no longer has
any use for one.
That row is not merely untidy. It survives the COALESCE(cargo_category,'')
unique index alongside the legitimate null-category row for the same key,
plannedRowsSql groups by the column, and the two plan rows then join the
same operated row through the FULL OUTER JOIN: the category lists twice,
each line carrying the full operated tonnage, while the summary tiles are
computed separately and stay correct — so the table disagrees with its own
totals and nothing says why.
create() and update() now resolve the slot through one place, which is the
point: the two paths cannot drift again. cargoCategory is derived from the
effective dimension rather than carried over, and a station target without
one is rejected instead of stored as a plan the report can never match.
dimensionKey is checked against the vocabulary the reports actually emit —
CARGO_CATEGORIES / CONTAINER_CLASSES, or live yards.code for stations. An
unknown key used to store fine, list fine and fall back to showing the raw
key, while being a plan no report would ever find.
Also drops @Global from the module. Nothing outside it injects either
service; the reports read both tables in raw SQL, so the docstring's stated
reason for being global was not true.