A statutory statement - a balance sheet, an income statement - is a fixed
line structure where every line is a formula over the chart of accounts.
`kind: balance` supplies the numbers underneath it but cannot express the
form: its output is one row per dimension value, with no way to say "this
line is accounts 20*+21* netted to the debit side" or "this line is the sum
of those two".
`kind: statement` declares both. It takes the same ledger inputs as a
balance report plus `account:` - the field holding the account code - and
`lines:`, each line either a leaf (an `accounts:` selector and a `measure:`)
or computed (`sum:` / `less:` over other lines' codes).
It is emitted as ONE query in the ordinary `.report` shape - Code / Label /
Amount, the balance report's own fromDate/toDate parameters - so the whole
report pipeline is reused unchanged: no new artefact, no new runtime, no
synchronizer.
Four decisions shape the emitted SQL:
- The ledger is reduced to one balance per account FIRST, in a CTE, because
a Net measure nets an account's two sides before the line sums it.
Netting after the sum reports gross turnover; netting per account is what
puts a both-type settlement account on the asset side when it is in debit
and the liability side when it is in credit.
- Computed lines are flattened at generation time into their leaves' signed
terms, so every line is one aggregate over that CTE and no line waits for
another - which is why nested subtotals need no recursive SQL, and why a
reference cycle has to be a parse error.
- The lines are ordered by a selected-but-not-projected ordinal: a
statement's rows are a structure, and statutory codes sort
lexicographically wrong (A.II before A.X).
- No builder-owned joins/conditions are emitted. A statement's joins live
inside its own subquery and the report editor cannot rebuild a WITH, so it
opens free-style - the safe half of the #6675 round-trip guard.
A range selector compares equally long code prefixes (`60-69` takes 601 and
6999); a plain BETWEEN over whole codes would drop both. The account-code
charset is closed, so a quote or a LIKE wildcard never reaches the literal.
StatementSupport holds the measure vocabulary and the selector grammar and
is shared by the parser and the generator, so what validates and what is
emitted cannot drift. StatementReportSqlTest RUNS the emitted query against
a real H2 ledger and reads the figures off - a statement is arithmetic, and
a wrong selector or a mis-ordered netting yields well-formed SQL and a
plausible wrong number that no string assertion catches. The same query was
verified by hand on PostgreSQL.
Boundary, consistent with #6721: the statement's numbers are the platform's;
the legally mandated print layout stays a hand-authored `.print`.
Closes #6909.
What
A statutory statement — a balance sheet, an income statement — is a fixed line structure where every line is a formula over the chart of accounts.
kind: balance(#6248) supplies the numbers underneath one but cannot express the form: its output is one row per dimension value, so there is no way to say this line is accounts20*+21*netted to the debit side, or this line is the sum of those two.kind: statementdeclares both:A line is either a leaf (
accounts:selects,measure:says which balance to take) or computed (sum:/less:over other lines' codes) — never both.The report is emitted as one query in the ordinary
.reportshape —Code/Label/Amount, with the balance report's ownfromDate/toDateparameters — so the entire report pipeline (repository, controller, Harmonia page, security, i18n, dashboard) is reused unchanged. No new artefact, no new runtime, no synchronizer.Why the SQL looks the way it does
Four decisions, each load-bearing:
WITH "ACCOUNT_BALANCES"CTE. ANetmeasure nets an account's two sides before the line sums it; netting after the sum reports gross turnover. This is what puts a both-type settlement account on the asset side when it is in debit and the liability side when it is in credit —closingNetDebiton the asset line andclosingNetCrediton the liability line file each account where it belongs without the author knowing which way it went.Ordinal. A statement's rows are a structure, and statutory codes sort lexicographically wrong (A.IIbeforeA.X).joins/conditionsare emitted. A statement's joins live inside its own subquery and the report editor's visual builder cannot rebuild aWITH, so a statement deliberately opens free-style — the safe half of the Report Editor silently rewrites and corrupts the query of generated .report files #6675 round-trip guard, not an oversight.A range selector compares equally long code prefixes (
60-69takes601and6999); a plainBETWEEN '60' AND '69'over whole codes would drop both. The account-code charset is closed (letters, digits, dot, underscore), so a quote or aLIKEwildcard can never reach the literal.Testing
StatementReportSqlTestruns the emitted query against a real H2 ledger and reads the figures off — prefix, range, per-account netting of a both-type account, thefilter, cross-line addition and subtraction, a negative line, the opening/period windows, plus theCOUNT(*)wrap and the appendedLIMITthe generated repository uses. A statement is arithmetic: a wrong selector or a mis-ordered netting produces well-formed SQL and a plausible wrong number, which no string assertion catches.ReportIntentGeneratorTestcovers the emission and every rejection (dimensions/measures on a statement, duplicate codes, leaf+computed, neither, unknown measure, unresolvable reference, cycle, malformed range, a quote in a code, inputs without the kind).IntentEngineITcarries a statement in its intent — asserting the emitted.reportand the generated page — 58/58 green.formatter:validateand the release-profile javadoc are clean.Design note
The issue asked whether
lines:should live inline or in a seeded, tenant-editable definition entity. This PR takes the inline form: the account-to-line mapping is authored structure, reviewable and versioned with the model, and it fits the existing report pipeline without inventing a runtime. A tenant-editable variant can be layered over the same semantics later without invalidating anything here — recorded in the spec proposal.Boundary
Consistent with what #6721 records: the statement's numbers are the platform's; the legally mandated print layout stays a hand-authored
.printover the result.Docs
components/engine/engine-intent/CLAUDE.mdandintent-assistant-guide.md(the catalog the intent AI reads) are updated in this PR.Note on CI
The
apishard is currently red on master (since #6906) on an unrelatedModelGenerationITassertion — see the comment below.