Skip to content

intent: reports - union: reads several entities as one report source, so aging can span invoices, notes and opening items (#7946) - #8543

Open
delchev wants to merge 1 commit into
masterfrom
issue-7946-report-union
Open

delchev wants to merge 1 commit into
masterfrom
issue-7946-report-union

Conversation

@delchev

@delchev delchev commented Nov 8, 2026

Copy link
Copy Markdown
Contributor

Cause

A report had exactly one source. An aged receivables / payables report by counterparty must add up to the receivable (payable) balance, whose open items live in several entities: invoices, debit notes, credit notes (negative) and migrated opening items. A revenue report must net credit notes against invoices. Neither could be expressed, and #7888's aging: explicitly named the missing union as a limit.

Change

- name: AgedReceivables
  union:
    - { source: SalesInvoice, counterparty: customer, date: date, due: due, amount: "payable - paid" }
    - { source: CreditNote,   counterparty: customer, date: date, due: date, amount: "allocated - total" }
    - { source: OpeningBalance, counterparty: customer, date: documentDate, due: dueDate,
        amount: "debit - credit", filter: "customer IS NOT NULL" }
  dimensions: [counterparty]
  aging: { buckets: [30, 60, 90] }
  • union: replaces source:. Each member maps its own fields onto the logical columns source (the member's entity name), counterparty (a to-one relation), date, due and a signed amount. amount uses the aging.amount grammar, which now also accepts one leading sign ("-total"), so a credit note can be negated.
  • Not aging-only, as the issue comment asks: dimensions: [source], measures: ["sum(amount)"] over invoice total and credit note -total gives net revenue.
  • ReportUnion builds one logical entity from those columns. It is shared by the parser (dimensions, measures, parameters and period are validated against it) and the generator (they are resolved against it). So a union report is an ordinary report over that entity, and the rest of the report path does not know about unions.
  • The entity's table is a generated, parameter-free view <TABLE>_UNION (<Report>Union.view), following the existing _LINES / _CORRESPONDENCE pattern. It has one UNION ALL arm per member. Each arm carries the member's filter and its own lifecycle scope, so a draft of any document type is excluded. Dates are cast to DATE, so a timestamp member and a date member share a column. Amount terms are COALESCEd.
  • With aging:, the members supply the due date, amount and document date, and the block carries only buckets / asOf. The as-of date stays on the .report.
  • A member from another model (model:) is read from its owner's table. Its fields, counterparty target and status stages are checked against the owner's resolved .model at generation.
  • Refused at parse:
    • source together with union;
    • a ledger kind;
    • counterparty, date or due mapped by some members but not all;
    • members whose counterparties point at different entities;
    • non-temporal dates or non-numeric amounts;
    • aging naming due / amount / date over a union, or a union whose members don't map due.
  • No drill on a union report, since a row counts several entities.
  • Docs updated: assistant guide, module CLAUDE.md (the reports: aging buckets - open items bucketed by days past due as of a report date (not yet due / 0-30 / 31-60 / 61-90 / 90+) #7888 entry's "NOT done: union" now points here), intent-dsl-features.md.

Verification

  • UnionReportTest runs the generated view and report on H2 over invoices, credit notes and opening items. It checks:
    • per counterparty, the buckets add up to the open amount;
    • a document due tomorrow is Not Yet Due;
    • a partially paid invoice shows its remainder;
    • an unallocated credit note reduces its bucket;
    • a draft invoice is excluded by its member's scope;
    • a supplier opening item is dropped by the member filter;
    • a migrated opening item is aged by its own timestamp due date.
      It also checks netted revenue by source, the cross-model arm's SQL, and every parse refusal.
  • The engine-intent unit suite (2109 tests) is green, including AgingReportTest and ReportEditorRoundTripTest unchanged.
  • IntentEmissionCoverageIT is green. It publishes EntryAgingUnion (debit and negated credit as two members of one union), which synchronizes the generated view, compiles the controller and reads it over REST with and without ?asOf=.
  • formatter:validate passes with the cache wiped, and the release-profile javadoc build of engine-intent passes.
  • Not verified:
    • PostgreSQL: the view SQL uses only CAST(... AS DATE), CAST('x' AS VARCHAR(255)), COALESCE and UNION ALL, but I did not run it on PG locally. The CI PG leg covers the IT.
    • A cross-model member against a real owner .model: the unit test covers the convention fallback only.
    • Ledger reconciliation against a posted journal: that is downstream (BusinessIntents/base-journal#78).

Fixes #7946

🤖 Generated with Claude Code

… so aging can span invoices, notes and opening items (#7946)

Cause: a report had exactly one source. An aged receivables / payables
report by counterparty must add up to the receivable balance, whose open
items live in several entities (invoices, debit notes, credit notes,
migrated opening items), and a revenue report must net credit notes
against invoices - neither was expressible, and #7888's aging: named the
missing union as a limit.

Change: union: [{ source, model?, counterparty?, date?, due?, amount,
filter? }] replaces source:. Each member maps its own fields onto the
logical columns source / counterparty / date / due / amount (signed, the
aging.amount grammar, which now also takes one leading sign: "-total").
ReportUnion builds a logical entity of those columns, shared by the
parser (dimensions, measures, parameters validated against it) and the
generator (resolved against it), so a union report is an ordinary report
over that entity. Its table is a generated parameter-free
<TABLE>_UNION view (<Report>Union.view): one UNION ALL arm per member,
carrying the member's filter and its own lifecycle scope, dates cast to
DATE, amount terms COALESCEd. With aging: the members supply due, amount
and date. A model: member is read from its owner's table and checked
against its resolved .model at generation. counterparty/date/due are
mapped by every member or none; source + union, a ledger kind, and
aging naming due/amount/date over a union are refused at parse.

Verified: UnionReportTest runs the view and the report on H2 over
invoices + credit notes + opening items (buckets per counterparty add up
to the open amount, draft excluded per member, member filter, opening
item aged by its own timestamp due date, netted revenue by source,
cross-model arm SQL, parse refusals); engine-intent unit suite (2109)
green; IntentEmissionCoverageIT green with a published EntryAgingUnion
read over REST with and without ?asOf=; formatter:validate with the
cache wiped; release-profile javadoc on engine-intent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

reports: aging over several open-item sources (union) - aged receivables / payables across invoices, notes and migrated opening items

1 participant