Commit Graph

6 Commits

Author SHA1 Message Date
ghost2023
3734ca3897 feat(freight-api): add DJF as a supported currency
Adds DJF to freight.payments_currency_enum (migration 3860000000000,
alone in its own migration per Postgres's ADD VALUE-in-a-transaction
restriction) and to manual_payment_settings (djf_enabled column,
migration 3870000000000, default true).

Replaces the binary ETB/USD assumptions that would have silently
mispriced or discarded a DJF booking:
- booking-pricing / contract-pricing: usdToEtb scalar -> a rate table
  keyed by source currency (ExchangeService.getRateTable), so a
  contract-frozen rate converts into whatever currency the booking is
  paid in instead of being dropped when neither leg is ETB or USD.
- warehouse-fee / booking-wagon-cancellation: normalizeCurrency no
  longer coerces anything non-ETB to USD.
- additional-charge: convertAmount no longer bails out for a currency
  that isn't literally ETB or USD.
- manual-payment-settings: isEnabled/enabledCurrencies cover DJF.

Widens the three @IsIn(['ETB','USD']) DTO validators, and adds DJF to
the export/report currency filter option lists.

Claude-Session: https://claude.ai/code/session_01CZy77vCWhka3pnmVF9NDkL
2026-09-04 11:52:37 +03:00
Nathnael
3a7e16a746 fix(reports): stop finance reports dropping general-contract revenue
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>
2026-08-27 05:22:37 +00:00
ghost2023
260d5a1590 feat(reports): cascade an unmet plan onto the periods that remain
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.
2026-08-21 17:40:35 +03:00
ghost2023
3ce58d4c57 refactor(reports): label revenue categories from a key column
CATEGORY_LABEL_EXPR wraps the classifying CASE, so it only works where the
classification happens in the same SELECT. A report that classifies in a
subquery and labels in the wrapper has a plain key column to label instead.

CATEGORY_LABEL_OF takes that key expression; CATEGORY_LABEL_EXPR is now
defined through it, so its three existing callers are unchanged. Mirrors
CATEGORY_LABEL_OF in operations-classification.ts.

Claude-Session: https://claude.ai/code/session_01LoY3hNWqcaAC1pYmGPN7jr
2026-08-21 15:51:05 +03:00
Nathnael
fb21ad1541 feat(WIP): filtering, exporting and more reports 2026-08-19 13:51:18 +00:00
Nathnael
e964a9b8f4 chore: more reporting 2026-08-19 10:14:46 +00:00