From 0b2978dc3b64b1effd53548731277841cc2a60ae Mon Sep 17 00:00:00 2001 From: delchev Date: Mon, 24 Aug 2026 11:05:24 +0300 Subject: [PATCH] feat(intent): declarative financial statement definitions (#6909) 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`. --- components/engine/engine-intent/CLAUDE.md | 3 +- .../intent/generator/StatementSupport.java | 284 +++++++++++++++++ .../report/ReportIntentGenerator.java | 291 +++++++++++++++++- .../components/intent/model/ReportIntent.java | 48 ++- .../intent/model/StatementLineIntent.java | 110 +++++++ .../intent/parser/IntentParser.java | 227 +++++++++++++- .../main/resources/intent-assistant-guide.md | 72 ++++- .../report/ReportIntentGeneratorTest.java | 190 ++++++++++++ .../report/StatementReportSqlTest.java | 196 ++++++++++++ .../report-file/report.js.template | 7 + .../integration/tests/api/IntentEngineIT.java | 57 +++- 11 files changed, 1460 insertions(+), 25 deletions(-) create mode 100644 components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/StatementSupport.java create mode 100644 components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/StatementLineIntent.java create mode 100644 components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/report/StatementReportSqlTest.java diff --git a/components/engine/engine-intent/CLAUDE.md b/components/engine/engine-intent/CLAUDE.md index 507168cc3da..9b34163a1c6 100644 --- a/components/engine/engine-intent/CLAUDE.md +++ b/components/engine/engine-intent/CLAUDE.md @@ -421,8 +421,9 @@ Semantics worth knowing: - **`identity` / `personal` / `sensitive` = the personal (my) surface.** `identity: ` on the entity representing the person (conventionally the unique e-mail) declares how the logged-in username maps to a record; `personal: true` on a record-owning to-one relation (at most one per entity; target must declare identity - same-model parse-checked, cross-model generation-checked via `TargetInfo.identityProperty`) makes the entity get an ADDITIONAL generated `MyController` (rest-java `EntityMyController.java.template`, `personalModels` collection): reads filtered to the mapped identity record (`Criteria.eq(identityProperty, User.getName())`), owner FK forced server-side on writes, foreign/missing records the same 404, `sensitive: true` fields (never the PK/identity/owner FK) stripped from responses AND ignored on writes - the allow-list is server-side, UI hiding alone would be cosmetic security. Composition children inherit the scope through their DIRECT parent (one hop - `requireMyParent` ancestor guard; deeper chains get no personal surface, documented). The power controller is untouched. Emitted as entity `identityProperty` + FK `relationshipPersonal`/`relationshipIdentityProperty` + field `sensitiveProperty`; ModelParameterProcessor derives `personalProperty`/`personalParent`/`sensitiveProperties`. Design/status: repo-root `PERSONALIZATION_PLAN.md` (phase A; personal UI, Personal Shell, per-user task assignee and collection-driven generation are the later phases). - **`visibleTo: [Role, ...]` on a field = role-scoped visibility, enforced where the data leaves the server (#6550).** `sensitive:` hides a field on the PERSONAL surface; nothing scoped one on the main surface, so a salary / rate / margin was visible to every user who could read the entity, and hiding the control would have been cosmetic - the REST response still carried the value. `visibleTo:` is an **allow-list** (never the inverse `hiddenFor:` - a role added to the application later must see nothing until it is listed, and a misspelled role must hide the value, not expose it): the field is stripped from every response and ignored on every write unless the caller holds ONE of the listed roles. Emitted as the model's own per-property **`roleRead` + `roleWrite`** (the same comma-separated pair a hand-modeled `.edm` may carry, so the whole enforcement is the rest-java template's existing `redactRead` / `mergeWritable` / `applyOnCreate` machinery, generalized from one role to any-of); read and write get the SAME list on purpose - a caller who may not see the value must not be able to set it. Enforced on **all three** generated surfaces: the power controller, and the personal / partner ones, where owning the record (or being the partner it belongs to) is not the same as holding the role. Two further exits are closed with it: the **change trail** (`history: true`) drops the entries of a withheld property - it records the before/after of every write - and a derived total fed by a restricted field **inherits its allow-list** (the rollup / `aggregate: true` / `aggregates:` shapes, `propagateRestrictedDerivations`), because a sum of hidden figures is that same figure one entity out. Parser: every listed role must be declared in `permissions:` (an undeclared one is a typo that would hide the field from everybody with nothing anywhere to say so), an empty `visibleTo: []` is refused on the raw tree (the typed mapping cannot tell it from an absent key), and it is refused on the primary key, the `identity` field and the document title - hiding those does not produce a restricted field, it produces a broken page. A **report** over a restricted field is a generation WARNING, not a refusal: a report carries no field-level scoping, so it re-serves the figure to everyone who may open it - legitimate to author (a payroll report over payroll data), never silent. **The UI half is server-driven**: each generated controller exposes `GET .../restricted` answering which properties IT withholds from the caller in front of it (the `/{id}/mutable` precedent), and the generated pages ask it once and leave those columns / inputs / totals / filter + export columns out - the browser never learns a role name, and the redaction on the wire stays authoritative. A page only asks when its entity has such a field (`hasRestrictedFields`), so an application using none of this issues no extra request. Covered by `RoleScopedFieldControllerTemplateIT` (the three controllers rendered through Velocity) and the `visibleTo` assertions in `IntentEmissionCoverageIT`; the runtime redaction itself is not IT-assertable because local basic auth answers `isInRole` true for the test user. - **`reports[].kind: balance` = the accounting balance report (opening / period / closing).** `kind: balance` + `date:` (the window-driving `date` field — own or one-hop `relation.field`, e.g. `journalEntry.entryDate` on the ledger items) + `debit:`/`credit:` (numeric source amount fields) + `dimensions:` replaces `measures` with six generated totals per dimension row: Opening Debit/Credit (`< :fromDate`), Debit/Credit (the inclusive period), Closing Debit/Credit (`<= :toDate`) — `SUM(CASE WHEN ...)` columns, so opening + period = closing. The window bounds are **declared `.report` `parameters`** (`{name, type: DATE, initial}` — the report editor's existing shape) with all-time defaults (`1900-01-01`/`9999-12-31`), bound by the generated repository's existing `baseParameters` and passed through the controller's GET query params / POST body untouched — `kind: balance` introduced no new backend plumbing, only the first generator that emits `parameters`. The Harmonia report page renders every declared parameter as a first-class input above the filters (date → picker, sent by name in every `/search`/`/count`/`/export` body; empty → the server default) and, for balance, a totals `tfoot` shown only when the whole result fits on one page. Parser (`validateBalanceReport`): date must be `date`-typed (timestamp rejected — a midnight `toDate` would silently drop that day's intra-day entries), debit/credit numeric fields of the source, ≥1 dimension, `measures` forbidden, and date/debit/credit without `kind: balance` is rejected. Only POSTED entries counting is the author's job via `filter:` (e.g. `journalEntry.status == 2`), composable like any report filter. AngularJS report UI ignores the parameters (Harmonia-only pickers, like `where:`). +- **`reports[].kind: statement` = the statutory financial statement (#6909).** A balance sheet or an income statement is a FIXED LINE STRUCTURE where every line is a formula over the chart of accounts - which `kind: balance` cannot express: its output is one row per dimension value, and there is no way to say "this line is accounts 20*+21* netted to the debit side" or "this line is the sum of those two". A statement declares the same ledger inputs (`source` / `date` / `debit` / `credit` / `filter` / `scope`) plus **`account:`** - the string field holding the account CODE, own or one-hop - and **`lines:`**, each line either a LEAF (`accounts:` selector + `measure:`) or COMPUTED (`sum:` / `less:` over other lines' `code`s). 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 (repository, controller, Harmonia page, security, i18n, dashboard) is reused unchanged and no new artefact, runtime or synchronizer exists. Four things decide the shape and are worth keeping: (a) **the ledger is reduced to one balance per account FIRST**, in a `WITH "ACCOUNT_BALANCES"` CTE, because a `Net` measure (`closingNetDebit` = what is left on the debit side once the account's two sides are netted, per account) has to net BEFORE the line sums - netting after the sum reports gross turnover, and it is exactly 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; (b) **computed lines are FLATTENED at generation time** into their leaves' own signed terms, so every emitted line is one aggregate over that CTE and no line waits for another - which is why nesting subtotals needs no recursive SQL and why the parser must reject a reference cycle (it would otherwise recurse until the stack ran out); (c) **the lines are ordered by a selected-but-not-projected `Ordinal`**, since a statement's rows are a structure and its codes sort lexicographically wrong (`A.II` before `A.X`); (d) **no `joins` / `conditions` are emitted on the document** - a statement's joins live inside its own subquery and the report editor's visual builder cannot rebuild a `WITH`, so it deliberately opens **free-style**, which is the safe half of the #6675 round-trip guard rather than an oversight. Selector grammar: `20*` prefix, `4110` exact, `60-69` an inclusive range over equally long code prefixes (`SUBSTRING(code FROM 1 FOR n)` - a plain `BETWEEN '60' AND '69'` would drop `601`, which is the bug this shape exists to avoid); the code charset is closed (letters, digits, dot, underscore) so a quote or a `LIKE` wildcard can never reach the literal. Parser: `dimensions`/`measures` forbidden, unique codes, known measure, resolvable references, no cycle, and a `date`/`debit`/`credit` or `account`/`lines` without the matching kind is refused. `StatementSupport` (measures + selectors) is shared by the parser and the generator, so what validates and what is emitted are one grammar. `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 produces well-formed SQL and a plausible wrong number, which 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` over the result. - **`ageing(, [30, 60, 90])` dimension = the receivables-ageing bucket column (#6357).** Buckets rows by how long ago a date fell, so the standard ageing family (`0-30` / `31-60` / `61-90` / `90+`) is a report definition instead of hand SQL; the field may be an own `date`/`timestamp` or a one-hop `relation.field`. **Emitted as a `CASE` over DATE BOUNDARIES** (`field > CURRENT_DATE - INTERVAL 'n' DAY`), deliberately NOT as the day-count arithmetic the issue sketched: `CURRENT_DATE - field` yields an **integer on PostgreSQL but an INTERVAL on H2** (verified), so comparing it to a number is not portable - and the `.report` `query` is a static string baked at generate time with no dialect to switch on. The interval form is standard SQL and was executed against H2 to confirm the emitted shape (including `GROUP BY` over the CASE) runs and buckets correctly. A **null date buckets as `n/a`**, never into the oldest bucket, which would misreport it as maximally overdue. Parser: thresholds must be ascending positive day counts and the field must be temporal (a non-temporal column would otherwise fail at query time instead of authoring time). **Caveat:** the bucket is a text label, and the query builder emits no `ORDER BY`, so buckets sort lexicographically - fine for equal-width thresholds (`[30,60,90]`), wrong for mixed widths (`[7,30,120]` sorts `0-7`, `120+`, `31-120`, `8-30`). Prefer equal-digit thresholds until an explicit bucket ordering lands. -- **`reports[].parameters` = the report's user-set inputs, on ANY report (#6357).** `- { name: fromDate, target: issuedOn, op: ge }` renders an input above the report and binds it into the query's `WHERE`; `target:` is a field of the source or a one-hop `relation.field` (joining exactly like a dimension, so a parameter may filter by a column the report does not display), `op:` is `ge`/`le`/`eq`/`like`, and `initial:` is the value bound when the input is empty. **No new backend plumbing** - `kind: balance` already emitted `parameters`, and the repository (`baseParameters`), the controller (a `@QueryParam` per declared parameter) and the Harmonia page's parameter strip were generic over them all along; this is the authoring half. Every term is emitted as a **plain binary comparison against a named marker**, i.e. a structured `conditions` row, so declaring a parameter can never park the report in the editor's free-style mode - which is also why there is no `(:p IS NULL OR ...)` optionality: a parameter is bound on EVERY call, and the neutral case is carried by `initial` instead. Hence `initial` is **required** unless the comparison has a neutral "any value" default (a date `ge`/`le` bound → all-time; `like` → the empty pattern) - an `eq` selector and a numeric bound have none, and defaulting them silently would open the report empty or arbitrarily narrowed. Three shapes worth knowing: a **nullable** target is read through its empty value (`COALESCE(col, 0)` / `''` / the far end of the window) because otherwise declaring a parameter drops every row holding no value in that column *before the user touches anything* - the opposite of what the neutral default promises (a `required` target keeps the plain, index-friendly comparison); a **`timestamp`** target compares as `CAST(col AS DATE)`, since the input is a date picker and a raw-instant `le` bound would drop the chosen day's own rows; and `like` is `col LIKE '%' || :p || '%'` (contains, which is also what makes the empty default match everything). `rawWhere` now parenthesises the authored `filter:` whenever ANYTHING is appended - an `OR`-carrying filter plus a parameter is the case where `AND` binding tighter silently answers a different question (caught by the test, not in review). Parser (`validateReportParameters`): the name must be a plain non-keyword identifier that is neither platform-bound (`language`) nor an identifier the generated controller declares (`filter`/`limit`/`offset`/`repository`) - it becomes a Java method parameter there, so a collision is a `javac` error in generated code; the target must be a FIELD of a parameterizable family (date/timestamp/number/string - a relation, `boolean` and `text` are refused, the relation with the alternative named); an authored `type:` is a declaration checked against the target, never a conversion; and `kind: balance` may add parameters but not redeclare `fromDate`/`toDate`. v1 gaps, deliberate: no relation picker (the value would be a raw FK and the page has no dropdown for it), no `gt`/`lt`/`ne` (`col > col` is false, so they have no neutral form), and the parameter label is humanized from the name rather than translated - as `kind: balance` already did. Tests: `ReportParametersTest` (emission + every parse rule) and `IntentEmissionCoverageIT`, where the report that is actually EXECUTED declares two parameters over nullable, unset columns - the pre-existing "both claims counted" assertion is what proves an untouched parameter narrows nothing, and `?note=mine` / `?minTotal=1000` prove the emitted SQL (the `||` concatenation, the bound comparison) runs. +- **`reports[].parameters` = the report's user-set inputs, on ANY report (#6357).** `- { name: fromDate, target: issuedOn, op: ge }` renders an input above the report and binds it into the query's `WHERE`; `target:` is a field of the source or a one-hop `relation.field` (joining exactly like a dimension, so a parameter may filter by a column the report does not display), `op:` is `ge`/`le`/`eq`/`like`, and `initial:` is the value bound when the input is empty. **No new backend plumbing** - `kind: balance` already emitted `parameters`, and the repository (`baseParameters`), the controller (a `@QueryParam` per declared parameter) and the Harmonia page's parameter strip were generic over them all along; this is the authoring half. Every term is emitted as a **plain binary comparison against a named marker**, i.e. a structured `conditions` row, so declaring a parameter can never park the report in the editor's free-style mode - which is also why there is no `(:p IS NULL OR ...)` optionality: a parameter is bound on EVERY call, and the neutral case is carried by `initial` instead. Hence `initial` is **required** unless the comparison has a neutral "any value" default (a date `ge`/`le` bound → all-time; `like` → the empty pattern) - an `eq` selector and a numeric bound have none, and defaulting them silently would open the report empty or arbitrarily narrowed. Three shapes worth knowing: a **nullable** target is read through its empty value (`COALESCE(col, 0)` / `''` / the far end of the window) because otherwise declaring a parameter drops every row holding no value in that column *before the user touches anything* - the opposite of what the neutral default promises (a `required` target keeps the plain, index-friendly comparison); a **`timestamp`** target compares as `CAST(col AS DATE)`, since the input is a date picker and a raw-instant `le` bound would drop the chosen day's own rows; and `like` is `col LIKE '%' || :p || '%'` (contains, which is also what makes the empty default match everything). `rawWhere` now parenthesises the authored `filter:` whenever ANYTHING is appended - an `OR`-carrying filter plus a parameter is the case where `AND` binding tighter silently answers a different question (caught by the test, not in review). Parser (`validateReportParameters`): the name must be a plain non-keyword identifier that is neither platform-bound (`language`) nor an identifier the generated controller declares (`filter`/`limit`/`offset`/`repository`) - it becomes a Java method parameter there, so a collision is a `javac` error in generated code; the target must be a FIELD of a parameterizable family (date/timestamp/number/string - a relation, `boolean` and `text` are refused, the relation with the alternative named); an authored `type:` is a declaration checked against the target, never a conversion; and a ledger kind (`kind: balance`, `kind: statement`) may add parameters but not redeclare the `fromDate`/`toDate` it declares itself. v1 gaps, deliberate: no relation picker (the value would be a raw FK and the page has no dropdown for it), no `gt`/`lt`/`ne` (`col > col` is false, so they have no neutral form), and the parameter label is humanized from the name rather than translated - as `kind: balance` already did. Tests: `ReportParametersTest` (emission + every parse rule) and `IntentEmissionCoverageIT`, where the report that is actually EXECUTED declares two parameters over nullable, unset columns - the pre-existing "both claims counted" assertion is what proves an untouched parameter narrows nothing, and `?note=mine` / `?minTotal=1000` prove the emitted SQL (the `||` concatenation, the bound comparison) runs. - **`reports[].widget` = a dashboard KPI tile backed by the report.** The report supplies the data (source/dimensions/measures/filter → the generated SQL + controller); the widget only says which number the tile shows: `kind: count` (default — the report's record count via the controller's count endpoint), `kind: value` (`value:` names a declared measure; `at: { : now | }` pins dimension columns as typed EQ conditions over the report output — the `now` token stays symbolic in the `.report` and is resolved client-side, type-aware: `month(x)` → current YYYYMM, `year(x)` → current year, date → today), or `kind: list` (`limit:` rows, default 5, rendered as a mini table from the report's own column metadata). `IntentParser.validateReportWidget` checks kind/value-measure/at-dimensions; `ReportIntentGenerator.widget(...)` resolves authored expressions to column aliases and emits the `widget` block on the `.report` (no SQL, no URLs — path-agnostic rule intact). At runtime the shared reports store (`application-core/shell/js/stores/reports.js`) reads the block off the `.report`, derives the report controller URL from the discovered page path (a `sanitizeJavaIdentifier` mirror — keep it in sync with `ModelParameterProcessor`), resolves the pins and fetches count/value/rows; a 403 hides the tile (role-guarded report) instead of erroring. A widget-bearing report shows the KPI tile INSTEAD of its iframe preview tile; `dashboard: false` hides both. - **Top-level `widgets:` = custom dashboard widgets (the dashboard's escape hatch).** `kind: kpi` (default) is a number tile fed by a developer REST endpoint (`url` returns `{value, description?}` — typically a client-Java `@Controller` under `custom/`); `kind: page` embeds the developer's HTML page like a report preview tile. The kind implies how the URL is consumed — there is deliberately no separate source-type field. The parser (`validateWidgets`) checks name/kind and that `url` is a same-origin path (no scheme/host); `EdmIntentGenerator.buildCustomWidgets` bakes them onto the `.model` root (`widgets` array with defaults + `tId`), the model's translate action emits their labels into the catalog, and the Harmonia `dashboardPage.js.template` bakes and renders them (kpi tiles fetch via the shared client with `{ baseUrl: '' }`; page tiles iframe). Prefer `reports[].widget` when a report can supply the number; the value the endpoint returns may be a string (`"99.9%"`), rendered as-is. The `.report` widget block is also **authorable by hand in the Web IDE's Report Editor** (`editor-report`: a "Dashboard Widget" panel — enable, kind, label/icon, value-measure picker over the aggregate columns, `at` pins over the grouping columns, list limit — plus Description + "Show on the home dashboard" in General), so classic non-intent projects get KPI tiles too. - **Every `view:` = an ADDITIONAL page, never a replacement (#6547) — and on a document's line-items child a calendar is the items PANE (#6482).** The calendar used to be emitted as `layoutType: MANAGE_CALENDAR`, which *replaced* whatever layout the entity had resolved: a `function: Document` master browsed on a calendar silently lost its whole document surface (line items, Print, inline process tasks — approvals fell back to the Inbox), so authors had to choose between the two. `EdmIntentGenerator` now emits the entity attribute **`calendarView="true"`** and leaves `layoutType` at the natural MANAGE / MANAGE_MASTER / MANAGE_DOCUMENT, so every page that layout generates still exists; the calendar rides alongside as an extra page. Routing keeps the landing route where it always was (no behaviour change for existing calendar apps) and moves the layout's own browse page down one segment: `/` = the calendar, **`//list`** = the layout's list / master / document list, `/create` + `/:id/edit` + `/:id/preview` = the layout's own editor — which is the whole fix, since those now resolve to the document page for a document master. Both browse pages carry a switch to the other (`goCalendar()` / `goList()`); same on the personal surface (`/my/` calendar, `/my//list` list, so `personalListModels` no longer excludes calendar roots). `MANAGE_CALENDAR` is gone from every consumer (`uiCalendarModels` / `personalCalendarModels` key on `calendarView`; the shell template, the AppTest manifest's layout token and `calendar.js` follow) — it was intent-only, never offered by the entity editor, so nothing hand-authored can still carry it. `calendar.js` no longer emits the shared manage form either: the layout owns it. **`view: slots` got the same treatment in the same PR** (`slotsView="true"`, `uiSlotsModels` keyed on it, `slots.js` no longer emitting the shared form, a `Slots`/`List` toggle pair): the picker is how a booking is CREATED — slot-click opens the LAYOUT's create route prefilled with the datetime, so a booking document is created as a document — and the list/document page is how it is worked with afterwards. An author needs both, so nothing about a view replaces a layout any more. **One consequence to keep in mind:** because a calendar/slots entity is now also a member of a LAYOUT collection, `navigation.js`'s `PERSPECTIVE_COLLECTIONS` must NOT list `uiCalendarModels`/`uiSlotsModels` — it did at first and emitted that entity's perspective twice (same rename path generated twice, duplicate `application-perspectives` contribution). **The line-items case:** a document's items child declaring `view: calendar` used to emit its panel markup and then be filtered out of `secondaryDetails` by name (the items child has its own section), so the declaration produced nothing at all — the authored-but-unconsumed failure mode, green at every step. The master now carries **`documentItemsLayout: "calendar"`** (derived from the child, never authored on the master — the `calendar:` config belongs to the child) and the items pane renders as an `x-h-calendar` on all three document surfaces (power / personal / partner): the same rows and the same line dialog, event-click edits, empty-day click adds with the date preset, and Delete moves into the dialog (a calendar has no per-row menu). It is mutually exclusive with `documentItemsLayout: chat` (both claim that pane) — parser-rejected. The event mapping is the shared `application-core/shell/js/services/calendarEvents.js` (`window.HarmoniaCalendar`), and the calendar's configuration is read at RUNTIME from the child's detail registration, so the document page still never enumerates the child at generation time. diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/StatementSupport.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/StatementSupport.java new file mode 100644 index 00000000000..f5cdf1641e1 --- /dev/null +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/StatementSupport.java @@ -0,0 +1,284 @@ +/* + * Copyright (c) 2010-2026 Eclipse Dirigible contributors + * + * All rights reserved. This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.dirigible.components.intent.generator; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +/** + * The two vocabularies a {@code kind: statement} report is authored in - the per-line + * {@code measure} and the {@code accounts} selector - and the SQL each lowers to. + * + *

+ * Shared by the parser and the generator on purpose: what the parser accepts and what the generator + * emits are the same grammar, and the one failure this must never have is a selector that validates + * and then silently selects nothing. Everything here reads the per-account balance columns of the + * statement query's own subquery (see {@code ReportIntentGenerator}); it never touches the + * application's tables. + */ +public final class StatementSupport { + + /** The three windows a statement reads, in the order the subquery exposes them. */ + private static final List WINDOWS = List.of("opening", "period", "closing"); + + /** + * One per-account balance the statement subquery exposes: the column it lands in, whether it sums + * the debit or the credit side, and the window it covers. + * + * @param column the quoted subquery column + * @param debit whether this is the debit side ({@code false} = the credit side) + * @param window the window it covers - {@code opening}, {@code period} or {@code closing} + */ + public record Balance(String column, boolean debit, String window) { + } + + /** + * The six per-account balances of the statement subquery, in a fixed order. The generator emits + * exactly these columns and every measure reads them, so the subquery's shape is stated once. + */ + private static final List BALANCES = balances(); + + /** The six balances: each window in its debit and its credit form. */ + private static List balances() { + List balances = new ArrayList<>(); + for (String window : WINDOWS) { + balances.add(new Balance(column(window, "DEBIT"), true, window)); + balances.add(new Balance(column(window, "CREDIT"), false, window)); + } + return List.copyOf(balances); + } + + /** The six per-account balances the statement subquery exposes. */ + public static List balanceColumns() { + return BALANCES; + } + + /** The quoted subquery column of one window and side, e.g. {@code "OPENING_DEBIT"}. */ + private static String column(String window, String side) { + return "\"" + window.toUpperCase(Locale.ROOT) + "_" + side + "\""; + } + + /** + * A line's balance: which of the per-account sums it takes, and how. + * + *

+ * The four plain measures take a side raw - a turnover. The four {@code Net} ones net an account's + * two sides before the line sums it and keep only what is left on the named side. That is + * what puts a both-type account on the statement side its actual balance puts it on - a settlement + * account in debit is a receivable, the same account in credit is a payable - and it is why the + * netting cannot happen after the line's sum: a line summing raw debits and raw credits reports + * gross turnover, not a balance. + * + * @param authored the name this measure is authored under + * @param sql what one account contributes to a line taking it + */ + public record Measure(String authored, String sql) { + } + + /** + * The twelve measures by their lower-cased authored name - the full cross product of the three + * windows, the two sides and the netted/raw choice, generated rather than listed so a name and the + * balance it reads cannot disagree. + */ + private static final Map MEASURES = measures(); + + private static Map measures() { + Map measures = new LinkedHashMap<>(); + for (String window : WINDOWS) { + String debit = column(window, "DEBIT"); + String credit = column(window, "CREDIT"); + add(measures, window + "Debit", debit); + add(measures, window + "Credit", credit); + add(measures, window + "NetDebit", net(debit, credit)); + add(measures, window + "NetCredit", net(credit, debit)); + } + return Collections.unmodifiableMap(measures); + } + + private static void add(Map measures, String authored, String sql) { + measures.put(authored.toLowerCase(Locale.ROOT), new Measure(authored, sql)); + } + + /** {@code kept - other}, floored at zero: what is left on the kept side once the two are netted. */ + private static String net(String kept, String other) { + String difference = kept + " - " + other; + return "CASE WHEN " + difference + " > 0 THEN " + difference + " ELSE 0 END"; + } + + private StatementSupport() {} + + /** + * The measure authored under the given name. + * + * @param authored the authored name (case-insensitive, may be null or blank) + * @return the measure, or {@code null} when the name is not one + */ + public static Measure measure(String authored) { + return authored == null ? null + : MEASURES.get(authored.trim() + .toLowerCase(Locale.ROOT)); + } + + /** The authored measure names, for an author-facing error message. */ + public static List measureNames() { + return MEASURES.values() + .stream() + .map(Measure::authored) + .toList(); + } + + /** + * An {@code accounts} selector: the comma-separated terms over the account code, and the SQL they + * match with. + * + * @param terms the parsed terms, in the authored order + */ + public record Selector(List terms) { + + /** + * The predicate selecting an account, over the given account-code column. + * + * @param accountColumn the quoted account-code column of the statement subquery + * @return the predicate - parenthesised, so it composes inside a CASE + */ + public String sql(String accountColumn) { + List predicates = new ArrayList<>(); + for (Term term : terms) { + predicates.add(term.sql(accountColumn)); + } + return predicates.size() == 1 ? predicates.get(0) : "(" + String.join(" OR ", predicates) + ")"; + } + } + + /** One term of an {@code accounts} selector. */ + public sealed interface Term { + + /** + * The predicate this term matches an account with. + * + * @param accountColumn the quoted account-code column + * @return the predicate + */ + String sql(String accountColumn); + } + + /** {@code 20*} - every account whose code starts with the prefix. */ + public record Prefix(String value) implements Term { + + @Override + public String sql(String accountColumn) { + return accountColumn + " LIKE '" + value + "%'"; + } + } + + /** {@code 4110} - exactly this account. */ + public record Exact(String value) implements Term { + + @Override + public String sql(String accountColumn) { + return accountColumn + " = '" + value + "'"; + } + } + + /** + * {@code 60-69} - every account whose code starts inside the range. The bounds are equally long + * prefixes and the comparison is over exactly that many leading characters, so {@code 60-69} + * selects {@code 601} and {@code 6999} as an accountant expects - a plain + * {@code BETWEEN '60' AND '69'} would drop both, since {@code '601' > '69'} lexicographically. + */ + public record Range(String from, String to) implements Term { + + @Override + public String sql(String accountColumn) { + String prefix = "SUBSTRING(" + accountColumn + " FROM 1 FOR " + from.length() + ")"; + return "(" + prefix + " >= '" + from + "' AND " + prefix + " <= '" + to + "')"; + } + } + + /** + * Parse an {@code accounts} selector. + * + * @param authored the authored selector (may be null or blank) + * @param issues the issues found, appended to - each one names what is wrong with which term + * @param prefix the message prefix identifying the line + * @return the parsed selector, or {@code null} when it did not parse + */ + public static Selector selector(String authored, List issues, String prefix) { + if (authored == null || authored.isBlank()) { + return null; + } + List terms = new ArrayList<>(); + for (String raw : authored.split(",")) { + String token = raw.trim(); + if (token.isEmpty()) { + issues.add(prefix + " accounts [" + authored.trim() + "] has an empty term"); + continue; + } + Term term = term(token, issues, prefix); + if (term != null) { + terms.add(term); + } + } + return terms.isEmpty() ? null : new Selector(terms); + } + + /** One selector term: a range when it carries the separator, else a prefix or an exact code. */ + private static Term term(String token, List issues, String prefix) { + int separator = token.indexOf('-'); + if (separator >= 0) { + String from = token.substring(0, separator); + String to = token.substring(separator + 1); + if (!code(from, prefix, token, issues) || !code(to, prefix, token, issues)) { + return null; + } + if (from.length() != to.length()) { + issues.add(prefix + " accounts term [" + token + "] is a range whose bounds are of different length" + + " - a range compares equally long code prefixes"); + return null; + } + if (from.compareTo(to) > 0) { + issues.add(prefix + " accounts term [" + token + "] is a range that ends before it starts"); + return null; + } + return new Range(from, to); + } + if (token.endsWith("*")) { + String value = token.substring(0, token.length() - 1); + return code(value, prefix, token, issues) ? new Prefix(value) : null; + } + return code(token, prefix, token, issues) ? new Exact(token) : null; + } + + /** + * An account code an author may write into a selector: letters, digits, dot and underscore. The + * charset is closed deliberately - the code goes into the query as a literal, so a quote or a + * {@code LIKE} wildcard must never reach it, and the hyphen is the range separator. + */ + private static boolean code(String value, String prefix, String token, List issues) { + if (value.isEmpty()) { + issues.add(prefix + " accounts term [" + token + "] has an empty account code"); + return false; + } + for (int i = 0; i < value.length(); i++) { + char character = value.charAt(i); + if (!Character.isLetterOrDigit(character) && character != '.' && character != '_') { + issues.add(prefix + " accounts term [" + token + "] contains [" + character + + "] - an account code may hold letters, digits, dot and underscore;" + + " a hyphen separates the bounds of a range and a trailing asterisk makes a prefix"); + return false; + } + } + return true; + } +} diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/report/ReportIntentGenerator.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/report/ReportIntentGenerator.java index 1f473052c0a..c48a9f7b2f6 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/report/ReportIntentGenerator.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/report/ReportIntentGenerator.java @@ -12,6 +12,7 @@ import java.util.ArrayList; import java.util.HashSet; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; import java.util.Map; @@ -23,6 +24,7 @@ import com.google.gson.Gson; import com.google.gson.GsonBuilder; import org.eclipse.dirigible.components.intent.generator.IntentGenerationContext; +import org.eclipse.dirigible.components.intent.generator.StatementSupport; import org.eclipse.dirigible.components.intent.generator.edm.CrossModelSupport; import org.eclipse.dirigible.components.intent.generator.IntentNaming; import org.eclipse.dirigible.components.intent.generator.IntentTargetGenerator; @@ -34,6 +36,7 @@ import org.eclipse.dirigible.components.intent.model.UsesIntent; import org.eclipse.dirigible.components.intent.model.ReportIntent; import org.eclipse.dirigible.components.intent.model.ReportParameterIntent; +import org.eclipse.dirigible.components.intent.model.StatementLineIntent; import org.eclipse.dirigible.components.intent.model.WidgetIntent; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -215,9 +218,10 @@ private static Map build(IntentGenerationContext context, Report String baseTable = report.getSource() == null ? "" : IntentNaming.tableName(context, report.getSource()); boolean balance = report.isBalance(); - boolean aggregated = balance || report.getMeasures() - .stream() - .anyMatch(m -> m != null && !m.isBlank()); + boolean statement = report.isStatement(); + boolean aggregated = balance || statement || report.getMeasures() + .stream() + .anyMatch(m -> m != null && !m.isBlank()); Map joins = new LinkedHashMap<>(); List> columns = new ArrayList<>(); @@ -282,8 +286,14 @@ private static Map build(IntentGenerationContext context, Report columns.add(dimensionColumn); dimensionColumns.put(expressionKey(dimension), new WidgetDimension(dimensionColumn, null)); } + StatementQuery statementQuery = null; if (balance) { addBalanceMeasures(context, model, source, baseAlias, report, joins, columns); + } else if (statement) { + // The ledger references are resolved (and their joins registered) BEFORE the filter's, so + // the emitted FROM introduces the statement's own tables first - the order the balance + // report already establishes. + statementQuery = prepareStatement(context, model, source, baseAlias, report, joins, columns); } else { for (String measure : report.getMeasures()) { if (measure == null || measure.isBlank()) { @@ -300,7 +310,9 @@ private static Map build(IntentGenerationContext context, Report warnOnRestrictedColumns(context, model, source, report); String filter = buildWhere(context, model, source, baseAlias, joins, report.getFilter()); Map scope = scopeCondition(context, model, source, baseAlias, report, aggregated); - List> parameters = balance ? balanceParameters() : new ArrayList<>(); + // A statement takes the balance window too: its ledger reduction is the balance report's, + // and the authored `parameters:` are appended to the same list below. + List> parameters = report.isLedgerKind() ? balanceParameters() : new ArrayList<>(); List> parameterConditions = parameterConditions(context, model, source, baseAlias, report, joins, parameters); List> conditions = conditions(filter, scope); String where; @@ -311,7 +323,8 @@ private static Map build(IntentGenerationContext context, Report where = predicate(conditions); } List> joinRows = joinRows(joins); - String query = buildQuery(baseTable, baseAlias, joinRows, columns, where); + String query = statementQuery == null ? buildQuery(baseTable, baseAlias, joinRows, columns, where) + : statementQuery.sql(baseTable, baseAlias, joinRows, where); Map document = new LinkedHashMap<>(); document.put("name", report.getName()); @@ -343,9 +356,17 @@ private static Map build(IntentGenerationContext context, Report // The report kind rides on the .report so the generated page knows to render the // balance affordances (window pickers, totals row). document.put("kind", "balance"); + } else if (statement) { + document.put("kind", "statement"); } document.put("columns", columns); - if (!joinRows.isEmpty()) { + // A statement's joins and filter live inside its own subquery, not in a SELECT the report + // editor's visual builder could rebuild - so they are deliberately NOT emitted as the + // builder-owned model. The builder's round-trip check then fails to reproduce the query and + // the report opens free-style, where the query string is the source of truth: the honest + // outcome, and the one that keeps the editor from rewriting the statement into a flat SELECT + // on save (dirigible #6675). + if (!statement && !joinRows.isEmpty()) { document.put("joins", joinRows); } document.put("query", query); @@ -354,7 +375,7 @@ private static Map build(IntentGenerationContext context, Report } // Only when the predicate round-trips: an empty `conditions` used to make the editor emit a // bare `WHERE`, and a partial one would have silently dropped the rest of the filter. - if (conditions != null && !conditions.isEmpty()) { + if (!statement && conditions != null && !conditions.isEmpty()) { document.put("conditions", conditions); } document.put("security", security(context, report.getName())); @@ -597,6 +618,259 @@ private static Map reportParameter(String name, String type, Str return parameter; } + /** The alias of the per-account balance subquery a statement's lines read. */ + private static final String ACCOUNT_BALANCES = "\"ACCOUNT_BALANCES\""; + + /** The alias of the derived table holding the statement's lines. */ + private static final String STATEMENT_LINES_ALIAS = "STATEMENT_LINES"; + + /** The same alias, quoted, as the query refers to it. */ + private static final String STATEMENT_LINES = "\"" + STATEMENT_LINES_ALIAS + "\""; + + /** The account-code column the per-account balance subquery exposes to the line selectors. */ + private static final String ACCOUNT_CODE = "\"ACCOUNT_CODE\""; + + /** + * Resolve a statement's ledger references, register their joins and emit its three output columns. + * + * @param context the generation context + * @param model the intent model + * @param source the report's source entity + * @param baseAlias the base-table alias + * @param report the statement report + * @param joins the joins collected so far, added to + * @param columns the emitted columns, appended to + * @return everything the query assembly needs afterwards + */ + private static StatementQuery prepareStatement(IntentGenerationContext context, IntentModel model, EntityIntent source, + String baseAlias, ReportIntent report, Map joins, List> columns) { + ColumnRef date = resolve(context, model, source, baseAlias, report.getDate() + .trim()); + registerJoin(joins, date); + ColumnRef debit = resolve(context, model, source, baseAlias, report.getDebit() + .trim()); + registerJoin(joins, debit); + ColumnRef credit = resolve(context, model, source, baseAlias, report.getCredit() + .trim()); + registerJoin(joins, credit); + ColumnRef account = resolve(context, model, source, baseAlias, report.getAccount() + .trim()); + // Only the entity join, never the language overlay: the account CODE is an identifier the line + // selectors match on, and matching a translated value would make a statement's lines depend on + // the reader's language. + if (account.join != null) { + joins.putIfAbsent(account.join.alias, account.join); + } + columns.add(column(STATEMENT_LINES_ALIAS, "Code", "Code", "CHARACTER VARYING", "NONE", false)); + columns.add(column(STATEMENT_LINES_ALIAS, "Label", "Label", "CHARACTER VARYING", "NONE", false)); + columns.add(column(STATEMENT_LINES_ALIAS, "Amount", "Amount", "DECIMAL", "NONE", false)); + return new StatementQuery(account.qualified(), balanceSums(date, debit, credit), statementLines(report)); + } + + /** + * The six per-account windowed sums of the statement subquery, in the {@code as ""} + * form. The windows are the balance report's, to the token: opening strictly before + * {@code :fromDate}, the period inclusive of both bounds, closing everything up to {@code :toDate} + * - so the two kinds cannot disagree about what a period is. + * + * @param date the window-driving date column + * @param debit the debit amount column + * @param credit the credit amount column + * @return the select terms + */ + private static List balanceSums(ColumnRef date, ColumnRef debit, ColumnRef credit) { + Map windows = Map.of("opening", date.qualified() + " < :fromDate", "period", + date.qualified() + " >= :fromDate AND " + date.qualified() + " <= :toDate", "closing", date.qualified() + " <= :toDate"); + List sums = new ArrayList<>(); + for (StatementSupport.Balance balance : StatementSupport.balanceColumns()) { + ColumnRef amount = balance.debit() ? debit : credit; + sums.add("SUM(CASE WHEN " + windows.get(balance.window()) + " THEN COALESCE(" + amount.qualified() + ", 0) ELSE 0 END) as " + + balance.column()); + } + return sums; + } + + /** + * The statement's lines, each resolved to the amount expression it selects from the per-account + * balances. Computed lines are FLATTENED here - a line summing other lines is replaced by their own + * account terms, recursively - so every emitted line is one aggregate over the same subquery and no + * line has to be evaluated before another. The parser has already rejected a cycle and an unknown + * reference; the walk still carries its own path guard, because a cycle reaching the generator + * would recurse until the stack ran out rather than report anything. + * + * @param report the statement report + * @return the lines, in the authored order + */ + private static List statementLines(ReportIntent report) { + Map byCode = new LinkedHashMap<>(); + for (StatementLineIntent line : report.getLines()) { + if (line.getCode() != null && !line.getCode() + .isBlank()) { + byCode.putIfAbsent(line.getCode() + .trim(), + line); + } + } + List lines = new ArrayList<>(); + for (StatementLineIntent line : report.getLines()) { + List terms = new ArrayList<>(); + collectStatementTerms(line, 1, byCode, terms, new LinkedHashSet<>()); + String amount = terms.isEmpty() ? "0" : String.join(" ", terms); + lines.add(new StatementLine(line.getCode() + .trim(), + line.getLabel() + .trim(), + amount)); + } + return lines; + } + + /** + * Append the signed account terms a line contributes, following its {@code sum}/{@code less} + * references down to the leaves. + * + * @param line the line to flatten + * @param sign {@code 1} when the line is added, {@code -1} when it is subtracted + * @param byCode the statement's lines by code + * @param terms the emitted terms, appended to - each carrying its own leading sign + * @param path the codes on the current walk, guarding against a cycle + */ + private static void collectStatementTerms(StatementLineIntent line, int sign, Map byCode, + List terms, Set path) { + if (line == null) { + return; + } + String code = line.getCode() == null ? null + : line.getCode() + .trim(); + if (code != null && !path.add(code)) { + LOGGER.warn("Statement line [{}] takes part in a cycle - dropping the reference that closes it", code); + return; + } + if (line.isLeaf()) { + // The parser has already reported anything wrong with the selector, so the issues it + // collects here are a duplicate of what the author has been told and are discarded. + List reported = new ArrayList<>(); + StatementSupport.Selector selector = StatementSupport.selector(line.getAccounts(), reported, ""); + StatementSupport.Measure measure = StatementSupport.measure(line.getMeasure()); + if (selector != null && measure != null) { + terms.add((terms.isEmpty() && sign > 0 ? "" : (sign > 0 ? "+ " : "- ")) + "COALESCE(SUM(CASE WHEN " + + selector.sql(ACCOUNT_CODE) + " THEN " + measure.sql() + " ELSE 0 END), 0)"); + } + } else { + for (String reference : line.getSum()) { + collectStatementTerms(byCode.get(trimmed(reference)), sign, byCode, terms, path); + } + for (String reference : line.getLess()) { + collectStatementTerms(byCode.get(trimmed(reference)), -sign, byCode, terms, path); + } + } + if (code != null) { + path.remove(code); + } + } + + private static String trimmed(String value) { + return value == null ? null : value.trim(); + } + + /** One emitted statement line: its code, its caption and the amount it selects. */ + private record StatementLine(String code, String label, String amount) { + } + + /** + * A statement's query: the per-account balances it aggregates and the fixed lines it renders from + * them. + * + * @param accountColumn the qualified account-code column of the source + * @param balanceSums the six windowed per-account sums, as select terms + * @param lines the statement's lines, already flattened + */ + private record StatementQuery(String accountColumn, List balanceSums, List lines) { + + /** + * The statement SQL: one subquery reducing the ledger to a balance per account, then one row per + * declared line reading it. + * + *

+ * The per-account level is not an optimisation, it is the semantics: a {@code Net} measure nets an + * account's two sides before the line sums it, so the reduction has to happen per account and + * exactly once. It is a common table expression for that reason - repeating the subquery per line + * would re-scan the ledger once per statement line. + * + * @param baseTable the physical source table + * @param baseAlias the source alias + * @param joins the resolved joins + * @param where the WHERE predicate restricting which ledger rows count, or null + * @return the query + */ + String sql(String baseTable, String baseAlias, List> joins, String where) { + StringBuilder sql = new StringBuilder("WITH ").append(ACCOUNT_BALANCES) + .append(" as (\nSELECT ") + .append(accountColumn) + .append(" as ") + .append(ACCOUNT_CODE); + for (String balance : balanceSums) { + sql.append(", ") + .append(balance); + } + sql.append("\nFROM ") + .append(quote(baseTable)) + .append(" as ") + .append(baseAlias); + for (Map join : joins) { + sql.append('\n') + .append(join.get("type")) + .append(" JOIN ") + .append(quote((String) join.get("name"))) + .append(" as ") + .append(join.get("alias")) + .append(" ON ") + .append(join.get("condition")); + } + if (where != null && !where.isBlank()) { + sql.append("\nWHERE ") + .append(where); + } + sql.append("\nGROUP BY ") + .append(accountColumn) + .append("\n)\nSELECT ") + .append(STATEMENT_LINES) + .append(".\"Code\" as \"Code\", ") + .append(STATEMENT_LINES) + .append(".\"Label\" as \"Label\", ") + .append(STATEMENT_LINES) + .append(".\"Amount\" as \"Amount\"\nFROM ("); + for (int ordinal = 0; ordinal < lines.size(); ordinal++) { + StatementLine line = lines.get(ordinal); + if (ordinal > 0) { + sql.append("\nUNION ALL"); + } + // The line's ordinal is what ORDERs the statement: a statement's rows are a structure, + // and its codes sort lexicographically wrong (A.II before A.X). It is selected but not + // projected - the reader gets Code / Label / Amount. + // Every literal is CAST, so the union's own column types are the declared ones rather + // than whatever the first branch's literal happened to be long enough for. + sql.append("\nSELECT ") + .append(ordinal + 1) + .append(" as \"Ordinal\", CAST('") + .append(line.code()) + .append("' AS VARCHAR(255)) as \"Code\", CAST('") + .append(line.label()) + .append("' AS VARCHAR(4000)) as \"Label\", ") + .append(line.amount()) + .append(" as \"Amount\"\nFROM ") + .append(ACCOUNT_BALANCES); + } + return sql.append("\n) as ") + .append(STATEMENT_LINES) + .append("\nORDER BY ") + .append(STATEMENT_LINES) + .append(".\"Ordinal\"") + .toString(); + } + } + /** * A dimension's emitted column plus its date-bucket function ({@code month}/{@code year}), if any. */ @@ -1077,7 +1351,7 @@ private static boolean referencesStatus(ReportIntent report, String relationName * *

* Own fields and one-hop {@code relation.field} paths are both scanned, over the dimensions, the - * measures, the filter and the {@code kind: balance} amount fields. + * measures, the filter and the ledger kinds' amount / account fields. */ private static void warnOnRestrictedColumns(IntentGenerationContext context, IntentModel model, EntityIntent source, ReportIntent report) { @@ -1089,6 +1363,7 @@ private static void warnOnRestrictedColumns(IntentGenerationContext context, Int expressions.add(report.getFilter()); expressions.add(report.getDebit()); expressions.add(report.getCredit()); + expressions.add(report.getAccount()); for (FieldIntent field : source.getFields()) { if (!field.getVisibleTo() .isEmpty() diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/ReportIntent.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/ReportIntent.java index 756d1c76c5d..7294eb50d51 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/ReportIntent.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/ReportIntent.java @@ -26,8 +26,11 @@ public class ReportIntent { * Optional report kind. {@code balance} is the accounting balance shape: opening / period / closing * debit and credit totals per dimension over the runtime {@code fromDate}/{@code toDate} window - * {@link #date} drives the window, {@link #debit}/{@link #credit} are the summed amount fields, and - * the report declares the two date parameters on the generated {@code .report}. Absent (the - * default) -> a plain aggregation report from {@link #measures}. + * the report declares the two date parameters on the generated {@code .report}. {@code statement} + * is the statutory statement shape over the same signed ledger: the same window and amount fields, + * but the output is the fixed {@link #lines} of a balance sheet or an income statement rather than + * one row per dimension value. Absent (the default) -> a plain aggregation report from + * {@link #measures}. */ private String kind; /** @@ -40,6 +43,18 @@ public class ReportIntent { private String debit; /** {@code kind: balance}: the numeric source field holding the credit amount. */ private String credit; + /** + * {@code kind: statement}: the account-code field the statement groups the ledger by - a + * {@code string} field of the source or a one-hop {@code relation.field} path to it (e.g. + * {@code account.code}). It is the code the {@link StatementLineIntent#getAccounts() line + * selectors} match against, so it is the chart-of-accounts code and never the display name. + */ + private String account; + /** + * {@code kind: statement}: the statement's fixed lines, in the order they are rendered - each one + * either reading the ledger through an account selector or computed from other lines. + */ + private List lines = new ArrayList<>(); private List dimensions = new ArrayList<>(); private List measures = new ArrayList<>(); /** @@ -99,6 +114,19 @@ public boolean isBalance() { return kind != null && "balance".equalsIgnoreCase(kind.trim()); } + /** Whether this is a financial statement report ({@code kind: statement}). */ + public boolean isStatement() { + return kind != null && "statement".equalsIgnoreCase(kind.trim()); + } + + /** + * Whether this report reads the signed ledger - the two kinds sharing {@link #date} / + * {@link #debit} / {@link #credit}. + */ + public boolean isLedgerKind() { + return isBalance() || isStatement(); + } + public String getKind() { return kind; } @@ -131,6 +159,22 @@ public void setCredit(String credit) { this.credit = credit; } + public String getAccount() { + return account; + } + + public void setAccount(String account) { + this.account = account; + } + + public List getLines() { + return lines; + } + + public void setLines(List lines) { + this.lines = lines == null ? new ArrayList<>() : lines; + } + public List getDimensions() { return dimensions; } diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/StatementLineIntent.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/StatementLineIntent.java new file mode 100644 index 00000000000..efd5e9043ef --- /dev/null +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/StatementLineIntent.java @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2010-2026 Eclipse Dirigible contributors + * + * All rights reserved. This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.dirigible.components.intent.model; + +import java.util.ArrayList; +import java.util.List; + +/** + * One line of a {@code kind: statement} report - a fixed row of a balance sheet or an income + * statement. + * + *

+ * A line is either a leaf, which reads the ledger ({@link #accounts} selects the accounts, + * {@link #measure} says which of their balances to take), or computed, which is arithmetic + * over other lines referenced by their {@link #code} ({@link #sum} adds them, {@link #less} + * subtracts). The two shapes are exclusive: a line that both reads accounts and adds other lines + * would double-count silently. + */ +public class StatementLineIntent { + + /** + * The line's reference, unique within the statement - the statutory line code ({@code A.I}) other + * lines reference and the first output column. + */ + private String code; + /** The line's caption, rendered verbatim as the statement's second output column. */ + private String label; + /** + * A leaf line's account selector: comma-separated terms over the account code - a prefix + * ({@code 20*}), an inclusive range of equally long prefixes ({@code 60-69}), or an exact code + * ({@code 4110}). A row matching any term contributes. + */ + private String accounts; + /** + * A leaf line's balance: {@code opening}/{@code period}/{@code closing} x + * {@code Debit}/{@code Credit}, plus the {@code Net} variants that net an account's two sides + * before taking it ({@code closingNetDebit} - so a both-type account lands on the statement side + * its actual balance puts it on). + */ + private String measure; + /** A computed line's addends: the codes of other lines of this statement. */ + private List sum = new ArrayList<>(); + /** A computed line's subtrahends: the codes of other lines of this statement. */ + private List less = new ArrayList<>(); + + /** Whether this line reads the ledger rather than other lines. */ + public boolean isLeaf() { + return accounts != null && !accounts.isBlank(); + } + + /** Whether this line is arithmetic over other lines. */ + public boolean isComputed() { + return !sum.isEmpty() || !less.isEmpty(); + } + + public String getCode() { + return code; + } + + public void setCode(String code) { + this.code = code; + } + + public String getLabel() { + return label; + } + + public void setLabel(String label) { + this.label = label; + } + + public String getAccounts() { + return accounts; + } + + public void setAccounts(String accounts) { + this.accounts = accounts; + } + + public String getMeasure() { + return measure; + } + + public void setMeasure(String measure) { + this.measure = measure; + } + + public List getSum() { + return sum; + } + + public void setSum(List sum) { + this.sum = sum == null ? new ArrayList<>() : sum; + } + + public List getLess() { + return less; + } + + public void setLess(List less) { + this.less = less == null ? new ArrayList<>() : less; + } +} diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/IntentParser.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/IntentParser.java index 7368e11ed3d..1e964f6c904 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/IntentParser.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/IntentParser.java @@ -33,6 +33,7 @@ import org.eclipse.dirigible.components.intent.generator.ProcessResilienceSupport; import org.eclipse.dirigible.components.intent.generator.ProcessWaitSupport; import org.eclipse.dirigible.components.intent.generator.ScheduleSupport; +import org.eclipse.dirigible.components.intent.generator.StatementSupport; import org.eclipse.dirigible.components.intent.generator.StepEventSupport; import org.eclipse.dirigible.components.intent.generator.TriggerSupport; import org.eclipse.dirigible.components.intent.model.ActionIntent; @@ -72,6 +73,7 @@ import org.eclipse.dirigible.components.intent.model.SlotsIntent; import org.eclipse.dirigible.components.intent.model.ReportIntent; import org.eclipse.dirigible.components.intent.model.ReportParameterIntent; +import org.eclipse.dirigible.components.intent.model.StatementLineIntent; import org.eclipse.dirigible.components.intent.model.ExpansionIntent; import org.eclipse.dirigible.components.intent.model.RollupIntent; import org.eclipse.dirigible.components.intent.model.ScheduleConditionIntent; @@ -7344,7 +7346,8 @@ private static void validateReportParameters(IntentModel model, ReportIntent rep issues.add(subject + " uses the reserved name [" + name + "] - the platform binds it itself or the generated report controller declares it"); } - if (report.isBalance() && BALANCE_REPORT_PARAMETERS.contains(name)) { + if (report.isLedgerKind() && BALANCE_REPORT_PARAMETERS.contains(name)) { + // A statement declares the same window on its own behalf as a balance report does. issues.add(subject + " collides with the balance window parameter of the same name"); } String op = parameter.getNormalizedOp(); @@ -7417,26 +7420,45 @@ private static String validateReportParameterTarget(IntentModel model, EntityInt */ private static void validateBalanceReport(IntentModel model, ReportIntent report, List issues) { boolean balanceInputs = report.getDate() != null || report.getDebit() != null || report.getCredit() != null; + boolean statementInputs = report.getAccount() != null || !report.getLines() + .isEmpty(); if (report.getKind() == null || report.getKind() .isBlank()) { if (balanceInputs) { - issues.add("report [" + report.getName() + "] declares date/debit/credit but is not kind: balance"); + issues.add("report [" + report.getName() + "] declares date/debit/credit but is not kind: balance or kind: statement"); + } + if (statementInputs) { + issues.add("report [" + report.getName() + "] declares account/lines but is not kind: statement"); } return; } - if (!report.isBalance()) { - issues.add("report [" + report.getName() + "] has unknown kind [" + report.getKind() + "] - expected balance"); + if (!report.isLedgerKind()) { + issues.add("report [" + report.getName() + "] has unknown kind [" + report.getKind() + "] - expected balance or statement"); return; } - String prefix = "balance report [" + report.getName() + "]"; + String prefix = (report.isStatement() ? "statement" : "balance") + " report [" + report.getName() + "]"; if (!report.getMeasures() .isEmpty()) { issues.add(prefix + " must not declare measures - it computes the opening/period/closing debit and credit totals"); } - if (report.getDimensions() - .stream() - .noneMatch(d -> d != null && !d.isBlank())) { - issues.add(prefix + " needs at least one dimension to balance by"); + if (report.isStatement()) { + // A statement's output rows are its lines; a dimension would multiply every line by the + // dimension's values and the line codes would stop being unique - which is the one thing a + // statement guarantees. + if (report.getDimensions() + .stream() + .anyMatch(d -> d != null && !d.isBlank())) { + issues.add(prefix + " must not declare dimensions - its rows are the declared lines"); + } + } else { + if (report.getDimensions() + .stream() + .noneMatch(d -> d != null && !d.isBlank())) { + issues.add(prefix + " needs at least one dimension to balance by"); + } + if (statementInputs) { + issues.add(prefix + " declares account/lines - those belong to kind: statement"); + } } EntityIntent source = null; for (EntityIntent entity : model.getEntities()) { @@ -7451,6 +7473,193 @@ private static void validateBalanceReport(IntentModel model, ReportIntent report validateBalanceDate(model, source, report, issues, prefix); requireNumericBalanceField(source, report.getDebit(), "debit", issues, prefix); requireNumericBalanceField(source, report.getCredit(), "credit", issues, prefix); + if (report.isStatement()) { + validateStatementAccount(model, source, report, issues, prefix); + validateStatementLines(report, issues, prefix); + } + } + + /** + * A statement's {@code account} must resolve to a {@code string} field - directly on the source or + * through a one-hop to-one {@code relation.field} path, exactly like the balance {@code date}. It + * is the code the line selectors match with, so a numeric or date field cannot carry it, and a + * cross-model target is checked at generation like every cross-model reference. + */ + private static void validateStatementAccount(IntentModel model, EntityIntent source, ReportIntent report, List issues, + String prefix) { + String reference = report.getAccount(); + if (reference == null || reference.isBlank()) { + issues.add(prefix + " needs account: the account-code field the lines select on"); + return; + } + reference = reference.trim(); + FieldIntent field; + int dot = reference.indexOf('.'); + if (dot > 0) { + RelationIntent relation = toOneRelation(source, reference.substring(0, dot)); + if (relation == null) { + issues.add(prefix + " account [" + reference + "] does not start with a to-one relation of [" + source.getName() + "]"); + return; + } + if (relation.isCrossModel()) { + return; + } + EntityIntent target = null; + for (EntityIntent entity : model.getEntities()) { + if (entity.getName() != null && entity.getName() + .equals(relation.getTo())) { + target = entity; + } + } + field = target == null ? null : fieldByName(target, reference.substring(dot + 1)); + } else { + field = fieldByName(source, reference); + } + if (field == null) { + issues.add(prefix + " account [" + reference + "] does not resolve to a field"); + } else if (!"string".equalsIgnoreCase(field.getType() == null ? "" : field.getType())) { + issues.add(prefix + " account [" + reference + "] must be a string field holding the account code (found [" + field.getType() + + "])"); + } + } + + /** + * The statement's lines: every line is either a leaf reading the ledger ({@code accounts} + + * {@code measure}) or arithmetic over other lines ({@code sum} / {@code less}), never both and + * never neither. Line codes are unique, every referenced code exists, and the reference graph is + * acyclic - a cycle would flatten forever in the generator, and a code that resolves to nothing + * would render a line reading zero with nothing to say why. + */ + private static void validateStatementLines(ReportIntent report, List issues, String prefix) { + List lines = report.getLines(); + if (lines.isEmpty()) { + issues.add(prefix + " needs lines: the statement's fixed line structure"); + return; + } + Map byCode = new LinkedHashMap<>(); + for (StatementLineIntent line : lines) { + String code = line.getCode() == null ? null + : line.getCode() + .trim(); + if (code == null || code.isEmpty()) { + issues.add(prefix + " has a line without a code"); + continue; + } + String linePrefix = prefix + " line [" + code + "]"; + if (byCode.put(code, line) != null) { + issues.add(prefix + " declares the line code [" + code + "] twice"); + } + if (!statementLiteral(code)) { + issues.add(linePrefix + " has a code carrying a quote or a control character - a line code is rendered" + + " into the statement query as a literal"); + } + if (line.getLabel() == null || line.getLabel() + .isBlank()) { + issues.add(linePrefix + " has no label"); + } else if (!statementLiteral(line.getLabel())) { + issues.add(linePrefix + " has a label carrying a control character"); + } + if (line.isLeaf() && line.isComputed()) { + issues.add(linePrefix + " both selects accounts and sums other lines - a line does one or the other," + + " else the same amount is counted twice"); + continue; + } + if (line.isLeaf()) { + StatementSupport.selector(line.getAccounts(), issues, linePrefix); + if (line.getMeasure() == null || line.getMeasure() + .isBlank()) { + issues.add(linePrefix + " needs measure: which balance of the selected accounts the line takes - one of " + + StatementSupport.measureNames()); + } else if (StatementSupport.measure(line.getMeasure()) == null) { + issues.add(linePrefix + " has unknown measure [" + line.getMeasure() + .trim() + + "] - expected one of " + StatementSupport.measureNames()); + } + } else if (line.isComputed()) { + if (line.getMeasure() != null && !line.getMeasure() + .isBlank()) { + issues.add(linePrefix + " is computed from other lines and cannot declare a measure -" + + " each referenced line carries its own"); + } + } else { + issues.add(linePrefix + " neither selects accounts (accounts + measure) nor sums other lines (sum / less)"); + } + } + validateStatementReferences(byCode, issues, prefix); + } + + /** + * Every {@code sum}/{@code less} code names a declared line, and the graph they form is acyclic. + */ + private static void validateStatementReferences(Map byCode, List issues, String prefix) { + for (Map.Entry entry : byCode.entrySet()) { + String linePrefix = prefix + " line [" + entry.getKey() + "]"; + for (String reference : statementReferences(entry.getValue())) { + if (reference.equals(entry.getKey())) { + issues.add(linePrefix + " references itself"); + } else if (!byCode.containsKey(reference)) { + issues.add(linePrefix + " references the line [" + reference + "], which the statement does not declare"); + } + } + } + for (String code : byCode.keySet()) { + List path = new ArrayList<>(); + if (statementCycle(code, byCode, new HashSet<>(), path)) { + issues.add(prefix + " has a cycle in its line arithmetic: " + String.join(" -> ", path)); + return; // one cycle report is enough - every line on it would repeat the same message + } + } + } + + /** The codes a line references, in the authored order, ignoring blanks. */ + private static List statementReferences(StatementLineIntent line) { + List references = new ArrayList<>(); + for (String reference : line.getSum()) { + if (!isBlank(reference)) { + references.add(reference.trim()); + } + } + for (String reference : line.getLess()) { + if (!isBlank(reference)) { + references.add(reference.trim()); + } + } + return references; + } + + /** Depth-first cycle search over the line references, recording the offending path. */ + private static boolean statementCycle(String code, Map byCode, Set onPath, List path) { + if (!onPath.add(code)) { + path.add(code); + return true; + } + path.add(code); + StatementLineIntent line = byCode.get(code); + if (line != null) { + for (String reference : statementReferences(line)) { + if (byCode.containsKey(reference) && statementCycle(reference, byCode, onPath, path)) { + return true; + } + } + } + onPath.remove(code); + path.remove(path.size() - 1); + return false; + } + + /** + * Whether a value may be rendered into the statement query as a SQL string literal. Quotes and + * control characters are refused rather than escaped: a line code and a label are authored + * captions, and refusing them here keeps the generator's literal rendering trivially correct. + */ + private static boolean statementLiteral(String value) { + for (int i = 0; i < value.length(); i++) { + char character = value.charAt(i); + if (character == '\'' || character == '\\' || Character.isISOControl(character)) { + return false; + } + } + return true; } /** diff --git a/components/engine/engine-intent/src/main/resources/intent-assistant-guide.md b/components/engine/engine-intent/src/main/resources/intent-assistant-guide.md index 98633742fe6..5e412891655 100644 --- a/components/engine/engine-intent/src/main/resources/intent-assistant-guide.md +++ b/components/engine/engine-intent/src/main/resources/intent-assistant-guide.md @@ -1815,8 +1815,8 @@ target is compared as a date, so a `le` bound includes the chosen day. The targe - a relation itself is not one (name a field of it: `Customer.name`), `boolean` and `text` fields are not parameterizable, and the name must be a plain, non-keyword identifier that is not one the platform already binds (`language`) or the generated controller declares (`filter`, `limit`, -`offset`, `repository`). `kind: balance` declares its own `fromDate`/`toDate`, -so a balance report may add further parameters but not redeclare those two. +`offset`, `repository`). `kind: balance` and `kind: statement` declare their own +`fromDate`/`toDate`, so either may add further parameters but not redeclare those two. #### reports[].scope - which lifecycle rows an aggregate counts @@ -1918,6 +1918,74 @@ must be numeric fields of the source; at least one dimension; `measures` must be posted entries with a `filter` on the source's (or its master's) status FK — the report itself does not filter. +#### reports[].kind: statement - the statutory financial statement + +**Use when:** the user needs a balance sheet, an income statement, or any other fixed line structure +over the same signed ledger — a form where every line is a formula over the chart of accounts and +some lines are subtotals of others. A `kind: balance` report gives one row per dimension value; a +statement gives the *lines of the form*. + +```yaml +reports: + - name: BalanceSheet + kind: statement + source: JournalEntryItem # the ledger line items (same as a balance report) + date: journalEntry.entryDate # the date driving the window (field or one-hop relation.field) + debit: debit # the numeric debit amount field of the source + credit: credit # the numeric credit amount field of the source + account: account.code # the account CODE the lines select on (a string field) + filter: "journalEntry.status == 2" # only POSTED entries count + lines: + - { code: A.I, label: Fixed assets, accounts: "20*,21*", measure: closingNetDebit } + - { code: A.II, label: Receivables, accounts: "41*", measure: closingNetDebit } + - { code: A, label: Total assets, sum: [A.I, A.II] } + - { code: B.I, label: Payables, accounts: "40-49", measure: closingNetCredit } + - { code: B, label: Net assets, sum: [A], less: [B.I] } +``` + +The report's rows are the declared `lines`, in the authored order, as three columns — **Code**, +**Label**, **Amount** — and the window is the same pair of runtime From/To date parameters a balance +report declares. + +**A line is either a leaf or computed, never both.** A leaf reads the ledger: `accounts` selects the +accounts and `measure` says which of their balances to take. A computed line is arithmetic over +other lines of the same statement, referenced by their `code` — `sum:` adds them, `less:` subtracts +them; both may appear on one line. + +**`accounts` — the selector**, comma-separated, matching the account code: + +| term | means | +| --------- | ---------------------------------------------------------------------- | +| `20*` | every account whose code starts with `20` | +| `4110` | exactly that account | +| `60-69` | every account starting inside the range — the bounds are equally long prefixes, so this takes `601` and `6999` too | + +A code may hold letters, digits, dot and underscore; the hyphen is the range separator and a +trailing asterisk makes a prefix. + +**`measure` — which balance the line takes**, one of the twelve: +`openingDebit`, `openingCredit`, `openingNetDebit`, `openingNetCredit`, +`periodDebit`, `periodCredit`, `periodNetDebit`, `periodNetCredit`, +`closingDebit`, `closingCredit`, `closingNetDebit`, `closingNetCredit`. + +The plain ones sum the raw side (turnover). **The `Net` ones net an account's two sides before the +line sums it and keep only what is left on the named side** — that is what puts a both-type account +on the side its actual balance puts it on, and it is what a balance sheet line almost always wants: +a settlement account in debit is a receivable, the same account in credit is a payable, and +`closingNetDebit` on the asset line together with `closingNetCredit` on the liability line files each +one where it belongs without the author having to know which way it went. Use a plain measure only +when the line really is a turnover (an income statement's gross movements). + +**Rules:** `date` must be a `date` field (a `timestamp` is rejected — the window bounds are dates); +`debit`/`credit` must be numeric fields of the source; `account` must be a `string` field (own or +one-hop) holding the code; `dimensions` and `measures` must be empty (the lines ARE the rows); line +codes are unique, every `sum`/`less` code must be a declared line, and the references must not form +a cycle. Restrict to posted entries with a `filter`, exactly as for a balance report. + +**Boundary:** a statement report computes the statement's *numbers*. The legally mandated print +layout stays a hand-authored `.print` template over that result — the platform's standing contract +for statutory form. + #### reports[].chart - render as a chart Add `chart:` to render the report page as a chart instead of a table (the page keeps a Table/Chart diff --git a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/report/ReportIntentGeneratorTest.java b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/report/ReportIntentGeneratorTest.java index 6d5ffbac4df..7890be20d61 100644 --- a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/report/ReportIntentGeneratorTest.java +++ b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/report/ReportIntentGeneratorTest.java @@ -368,6 +368,196 @@ void balanceInputsWithoutTheKindAndAnUnknownKindAreRejected() { assertTrue(message.contains("unknown kind [pivot]"), message); } + private static final String STATEMENT_INTENT = """ + name: ledger + entities: + - name: Account + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: code, type: string } + - { name: name, type: string } + - name: JournalEntry + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: entryDate, type: date } + relations: + - { name: items, kind: oneToMany, to: JournalEntryItem } + - name: JournalEntryItem + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: debit, type: decimal } + - { name: credit, type: decimal } + relations: + - { name: journalEntry, kind: manyToOne, to: JournalEntry, composition: true } + - { name: account, kind: manyToOne, to: Account, required: true } + reports: + - name: BalanceSheet + kind: statement + source: JournalEntryItem + date: journalEntry.entryDate + debit: debit + credit: credit + account: account.code + lines: + - { code: A.I, label: Fixed assets, accounts: "20*,21*", measure: closingNetDebit } + - { code: A.II, label: Receivables, accounts: "41*", measure: closingNetDebit } + - { code: A, label: Total assets, sum: [A.I, A.II] } + """; + + @Test + @SuppressWarnings("unchecked") + void statementReportEmitsOneRowPerLineOverThePerAccountBalances() { + IntentModel model = IntentParser.parse(STATEMENT_INTENT); + Map document = ReportIntentGenerator.buildForTest(TestContexts.context(model), model.getReports() + .get(0)); + + assertEquals("statement", document.get("kind")); + + String query = (String) document.get("query"); + // The ledger is reduced to one balance per account FIRST: a net measure nets an account's two + // sides before a line sums it, so the reduction cannot happen per line. + assertTrue(query.contains("WITH \"ACCOUNT_BALANCES\" as (\nSELECT Account.\"ACCOUNT_CODE\" as \"ACCOUNT_CODE\""), query); + assertTrue(query.contains("GROUP BY Account.\"ACCOUNT_CODE\""), query); + // The windows are the balance report's, to the token. + assertTrue(query.contains( + "SUM(CASE WHEN JournalEntry.\"JOURNAL_ENTRY_ENTRY_DATE\" <= :toDate THEN COALESCE(JournalEntryItem.\"JOURNAL_ENTRY_ITEM_DEBIT\", 0) ELSE 0 END) as \"CLOSING_DEBIT\""), + query); + // A comma-separated selector is an OR of prefixes; a net measure floors the account at zero. + assertTrue(query.contains( + "COALESCE(SUM(CASE WHEN (\"ACCOUNT_CODE\" LIKE '20%' OR \"ACCOUNT_CODE\" LIKE '21%') THEN CASE WHEN \"CLOSING_DEBIT\" - \"CLOSING_CREDIT\" > 0 THEN \"CLOSING_DEBIT\" - \"CLOSING_CREDIT\" ELSE 0 END ELSE 0 END), 0)"), + query); + // A computed line is FLATTENED into its leaves' own terms, so no line waits for another. + assertTrue(query.contains( + "CAST('A' AS VARCHAR(255)) as \"Code\", CAST('Total assets' AS VARCHAR(4000)) as \"Label\", COALESCE(SUM(CASE WHEN (\"ACCOUNT_CODE\" LIKE '20%'"), + query); + assertTrue(query.contains("ELSE 0 END), 0) + COALESCE(SUM(CASE WHEN \"ACCOUNT_CODE\" LIKE '41%'"), query); + assertTrue(query.endsWith("ORDER BY \"STATEMENT_LINES\".\"Ordinal\""), query); + + // Code / Label / Amount - the amount right-aligned and money-formatted like every decimal. + List> columns = (List>) document.get("columns"); + assertEquals(3, columns.size()); + assertEquals(List.of("Code", "Label", "Amount"), columns.stream() + .map(column -> column.get("alias")) + .toList()); + assertEquals("DECIMAL", columns.get(2) + .get("type")); + assertEquals("### ### ### ##0.00", columns.get(2) + .get("pattern")); + + // The window parameters are the balance report's, so a statement is queried the same way. + List> parameters = (List>) document.get("parameters"); + assertEquals(2, parameters.size()); + assertEquals("fromDate", parameters.get(0) + .get("name")); + assertEquals("toDate", parameters.get(1) + .get("name")); + + // A statement's joins live inside its own subquery, so the builder-owned model is deliberately + // absent and the report editor opens it free-style rather than rewriting it into a flat SELECT. + assertFalse(document.containsKey("joins"), "a statement must not claim builder-owned joins"); + assertFalse(document.containsKey("conditions"), "a statement must not claim builder-owned conditions"); + } + + @Test + void statementLinesMustBeWellFormed() { + IntentValidationException error = assertThrows(IntentValidationException.class, () -> IntentParser.parse(""" + name: ledger + entities: + - name: Account + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: code, type: integer } + - name: JournalEntryItem + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: debit, type: decimal } + - { name: credit, type: decimal } + - { name: entryDate, type: date } + relations: + - { name: account, kind: manyToOne, to: Account, required: true } + reports: + - name: BalanceSheet + kind: statement + source: JournalEntryItem + date: entryDate + debit: debit + credit: credit + account: account.code + dimensions: [debit] + lines: + - { code: A, label: Assets, accounts: "20*", measure: closingBalance } + - { code: A, label: Repeat, accounts: "21*", measure: closingNetDebit } + - { code: B, label: Both, accounts: "22*", measure: closingNetDebit, sum: [A] } + - { code: C, label: Neither } + - { code: D, label: Missing, sum: [Nope] } + - { code: E, label: Bad range, accounts: "60-699", measure: closingNetDebit } + - { code: F, label: Injected, accounts: "20';DROP", measure: closingNetDebit } + """)); + String message = error.getMessage(); + assertTrue(message.contains("must not declare dimensions"), message); + assertTrue(message.contains("account [account.code] must be a string field"), message); + assertTrue(message.contains("unknown measure [closingBalance]"), message); + assertTrue(message.contains("declares the line code [A] twice"), message); + assertTrue(message.contains("both selects accounts and sums other lines"), message); + assertTrue(message.contains("line [C] neither selects accounts"), message); + assertTrue(message.contains("references the line [Nope], which the statement does not declare"), message); + assertTrue(message.contains("bounds are of different length"), message); + assertTrue(message.contains("contains [']"), message); + } + + @Test + void statementLineArithmeticMustNotFormACycle() { + IntentValidationException error = assertThrows(IntentValidationException.class, () -> IntentParser.parse(""" + name: ledger + entities: + - name: Account + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: code, type: string } + - name: JournalEntryItem + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: debit, type: decimal } + - { name: credit, type: decimal } + - { name: entryDate, type: date } + relations: + - { name: account, kind: manyToOne, to: Account, required: true } + reports: + - name: BalanceSheet + kind: statement + source: JournalEntryItem + date: entryDate + debit: debit + credit: credit + account: account.code + lines: + - { code: A, label: A, sum: [B] } + - { code: B, label: B, sum: [A] } + """)); + assertTrue(error.getMessage() + .contains("cycle in its line arithmetic"), + error.getMessage()); + } + + @Test + void statementInputsWithoutTheKindAreRejected() { + IntentValidationException error = assertThrows(IntentValidationException.class, () -> IntentParser.parse(""" + name: ledger + entities: + - name: JournalEntryItem + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: code, type: string } + reports: + - name: Totals + source: JournalEntryItem + account: code + """)); + assertTrue(error.getMessage() + .contains("declares account/lines but is not kind: statement"), + error.getMessage()); + } + private static final String AGEING_INTENT = """ name: billing entities: diff --git a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/report/StatementReportSqlTest.java b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/report/StatementReportSqlTest.java new file mode 100644 index 00000000000..683ebf5538d --- /dev/null +++ b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/report/StatementReportSqlTest.java @@ -0,0 +1,196 @@ +/* + * Copyright (c) 2010-2026 Eclipse Dirigible contributors + * + * All rights reserved. This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.dirigible.components.intent.generator.report; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.math.BigDecimal; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.eclipse.dirigible.components.intent.generator.TestContexts; +import org.eclipse.dirigible.components.intent.model.IntentModel; +import org.eclipse.dirigible.components.intent.parser.IntentParser; +import org.junit.jupiter.api.Test; + +/** + * The generated {@code kind: statement} query, RUN - against a real database holding a real little + * ledger, not asserted as a string. + * + *

+ * A statement is arithmetic: a selector that matches nothing, a netting that happens after the sum + * instead of before it, or a subtotal that double-counts all produce well-formed SQL and a + * plausible-looking wrong number. Only executing it and reading the figures off proves the + * semantics, so this test builds the tables the generator names, posts entries, and checks each + * line's amount. It also runs the two shapes the generated report repository wraps the query in - + * the {@code COUNT(*)} wrap and the appended {@code LIMIT} - because a statement query is a + * {@code WITH} and those wraps are where a common table expression would break if the database + * refused it there. + */ +class StatementReportSqlTest { + + private static final String INTENT = """ + name: ledger + entities: + - name: Account + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: code, type: string } + - { name: name, type: string } + - name: JournalEntry + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: entryDate, type: date } + - { name: posted, type: integer } + relations: + - { name: items, kind: oneToMany, to: JournalEntryItem } + - name: JournalEntryItem + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: debit, type: decimal } + - { name: credit, type: decimal } + relations: + - { name: journalEntry, kind: manyToOne, to: JournalEntry, composition: true } + - { name: account, kind: manyToOne, to: Account, required: true } + reports: + - name: BalanceSheet + kind: statement + source: JournalEntryItem + date: journalEntry.entryDate + debit: debit + credit: credit + account: account.code + filter: "journalEntry.posted == 1" + lines: + - { code: A.I, label: Fixed assets, accounts: "20*,21*", measure: closingNetDebit } + - { code: A.II, label: Receivables, accounts: "41*", measure: closingNetDebit } + - { code: A, label: Total assets, sum: [A.I, A.II] } + - { code: B.I, label: Payables, accounts: "40-49", measure: closingNetCredit } + - { code: C, label: Net assets, sum: [A], less: [B.I] } + - { code: D, label: Opening assets, accounts: "2*", measure: openingNetDebit } + - { code: E, label: Period additions, accounts: "2*", measure: periodNetDebit } + """; + + /** The ledger the assertions below are read off. */ + private static final String[] SCHEMA = { + "CREATE TABLE \"LEDGER_ACCOUNT\" (\"ACCOUNT_ID\" INT PRIMARY KEY, \"ACCOUNT_CODE\" VARCHAR(20), \"ACCOUNT_NAME\" VARCHAR(100))", + "CREATE TABLE \"LEDGER_JOURNAL_ENTRY\" (\"JOURNAL_ENTRY_ID\" INT PRIMARY KEY, \"JOURNAL_ENTRY_ENTRY_DATE\" DATE," + + " \"JOURNAL_ENTRY_POSTED\" INT)", + "CREATE TABLE \"LEDGER_JOURNAL_ENTRY_ITEM\" (\"JOURNAL_ENTRY_ITEM_ID\" INT PRIMARY KEY," + + " \"JOURNAL_ENTRY_ITEM_DEBIT\" DECIMAL(19,2), \"JOURNAL_ENTRY_ITEM_CREDIT\" DECIMAL(19,2)," + + " \"JOURNAL_ENTRY_ITEM_JOURNAL_ENTRY\" INT, \"JOURNAL_ENTRY_ITEM_ACCOUNT\" INT)", + // 2010 / 2110 fixed assets, 4110 receivable, 4010 payable, 4510 a both-type account. + "INSERT INTO \"LEDGER_ACCOUNT\" VALUES (1,'2010','Land'), (2,'2110','Equipment'), (3,'4110','Trade receivables')," + + " (4,'4010','Trade payables'), (5,'4510','VAT settlement'), (6,'5010','Cash')", + // One entry before the window, one inside it, one unposted - which the filter must drop. + "INSERT INTO \"LEDGER_JOURNAL_ENTRY\" VALUES (1, DATE '2025-12-31', 1), (2, DATE '2026-03-15', 1), (3, DATE '2026-06-01', 0)", + "INSERT INTO \"LEDGER_JOURNAL_ENTRY_ITEM\" VALUES (1, 1000, NULL, 1, 1), (2, NULL, 1000, 1, 4), (3, 500, NULL, 2, 3)," + + " (4, NULL, 200, 2, 3), (5, 250, NULL, 2, 2), (6, NULL, 700, 2, 5), (7, 100, NULL, 2, 5)," + + " (8, 9999, NULL, 3, 1)"}; + + @Test + void theStatementQueryComputesEveryLineOverARealLedger() throws SQLException { + Map amounts = run(bound(query())); + + // 2010 (1000 debit) + 2110 (250 debit) - the unposted 9999 is filtered out. + assertEquals(new BigDecimal("1250.00"), amounts.get("A.I"), "fixed assets should net each account, posted entries only"); + // 4110 holds 500 debit and 200 credit: netting BEFORE the sum leaves 300 on the debit side. + assertEquals(new BigDecimal("300.00"), amounts.get("A.II"), "a receivable with a credit note should report its net balance"); + assertEquals(new BigDecimal("1550.00"), amounts.get("A"), "a sum line should add exactly its referenced lines"); + // 4010 (1000 credit) + 4510 (700 credit against 100 debit = 600) + 4110, whose net is on the + // debit side and so contributes nothing to a net-credit line. + assertEquals(new BigDecimal("1600.00"), amounts.get("B.I"), + "a range selector should take the whole 40-49 block, netted per account"); + assertEquals(new BigDecimal("-50.00"), amounts.get("C"), + "a line may be negative - the arithmetic is not floored, only each account's netting is"); + // The window: only the 2025-12-31 entry is opening, only the 2026-03-15 one is in the period. + assertEquals(new BigDecimal("1000.00"), amounts.get("D"), "the opening measure should see only entries before :fromDate"); + assertEquals(new BigDecimal("250.00"), amounts.get("E"), "the period measure should see only entries inside the window"); + } + + /** The lines are the statement's structure, so they come back in the authored order. */ + @Test + void theStatementRendersItsLinesInTheAuthoredOrder() throws SQLException { + assertEquals(List.of("A.I", "A.II", "A", "B.I", "C", "D", "E"), new ArrayList<>(run(bound(query())).keySet()), + "the statement should render its lines in the order they are declared"); + } + + /** + * The generated report repository counts through {@code SELECT COUNT(*) FROM ()} and pages + * by appending {@code LIMIT}/{@code OFFSET}. A statement query is a {@code WITH}, so both wraps are + * worth proving rather than assuming. + */ + @Test + void theQuerySurvivesTheWrapsTheGeneratedRepositoryPutsItIn() throws SQLException { + String query = bound(query()); + try (Connection connection = database(); Statement statement = connection.createStatement()) { + try (ResultSet rows = statement.executeQuery("SELECT COUNT(*) FROM (" + query + ") AS \"REPORT_TOTAL\"")) { + rows.next(); + assertEquals(7, rows.getInt(1), "the count wrap should see every line"); + } + try (ResultSet rows = statement.executeQuery(query + " LIMIT 3 OFFSET 1")) { + List codes = new ArrayList<>(); + while (rows.next()) { + codes.add(rows.getString("Code")); + } + assertEquals(List.of("A.II", "A", "B.I"), codes, "paging should walk the ordered lines"); + } + } + } + + /** The emitted statement query. */ + private static String query() { + IntentModel model = IntentParser.parse(INTENT); + return (String) ReportIntentGenerator.buildForTest(TestContexts.context(model), model.getReports() + .get(0)) + .get("query"); + } + + /** + * The window parameters the generated repository binds, as literals - the report declares them as + * {@code .report} parameters and this test has no repository to bind them. + */ + private static String bound(String query) { + return query.replace(":fromDate", "DATE '2026-01-01'") + .replace(":toDate", "DATE '2026-12-31'"); + } + + /** Run the statement and read its lines back, in the order the query returns them. */ + private static Map run(String query) throws SQLException { + Map amounts = new LinkedHashMap<>(); + try (Connection connection = database(); + Statement statement = connection.createStatement(); + ResultSet rows = statement.executeQuery(query)) { + while (rows.next()) { + amounts.put(rows.getString("Code"), rows.getBigDecimal("Amount")); + } + } + return amounts; + } + + /** A private in-memory ledger, created fresh for each connection. */ + private static Connection database() throws SQLException { + Connection connection = DriverManager.getConnection("jdbc:h2:mem:statement;DB_CLOSE_DELAY=-1;INIT=SET SCHEMA PUBLIC"); + try (Statement statement = connection.createStatement()) { + statement.execute("DROP ALL OBJECTS"); + for (String ddl : SCHEMA) { + statement.execute(ddl); + } + } + return connection; + } +} diff --git a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/report-file/report.js.template b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/report-file/report.js.template index 079c196cf9c..88d9b87c9e7 100644 --- a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/report-file/report.js.template +++ b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/report-file/report.js.template @@ -60,7 +60,14 @@ document.addEventListener('alpine:init', () => { state: 'loading', // loading | error | empty | default error: null, page: 1, +#if($kind == 'statement') + // A statement's rows ARE its structure - a balance sheet split across pages stops being one, and + // its line count is bounded by the definition rather than by the data. So the whole statement is + // fetched at once; the pagination controls stay for the (unexpected) longer definition. + limit: 500, +#else limit: 20, +#end count: 0, // Preview mode (dashboard tile): only the first few rows, no toolbar / pagination. Driven by // the ?preview=1 query param the dashboard tile passes when it embeds this page in an iframe. diff --git a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEngineIT.java b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEngineIT.java index 31ebc6867bc..ae049fe55b0 100644 --- a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEngineIT.java +++ b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEngineIT.java @@ -220,6 +220,23 @@ class IntentEngineIT extends IntegrationTest { debit: total credit: creditSnapshot dimensions: [customer] + # kind: statement - the statutory shape over the SAME signed ledger: instead of one row + # per dimension value, a fixed line structure where each line is a formula over the + # account codes, plus arithmetic over other lines. (The ledger here is the balance + # report's; the country code stands in for the chart-of-accounts code.) + - name: OrderStatement + kind: statement + source: Order + date: orderDate + debit: total + credit: creditSnapshot + account: country.code2 + lines: + - { code: A.I, label: Alpine markets, accounts: "AL,AT", measure: closingNetDebit } + - { code: A.II, label: Other markets, accounts: "B-Z", measure: closingNetDebit } + - { code: A, label: Total markets, sum: [A.I, A.II] } + - { code: B, label: Owed to markets, accounts: "A-Z", measure: closingNetCredit } + - { code: C, label: Net position, sum: [A], less: [B] } # Custom dashboard widgets - developer-supplied content: a REST KPI (the url returns # {value, description?}) and an embedded page tile. @@ -359,7 +376,7 @@ void parse_returns_the_full_model() { .body("processes", hasSize(1)) .body("processes[0].steps", hasSize(6)) .body("forms", hasSize(1)) - .body("reports", hasSize(4)) + .body("reports", hasSize(5)) .body("permissions", hasSize(2)) .body("seeds[0].rows", hasSize(2))); } @@ -603,8 +620,8 @@ void generate_writes_all_model_files_into_the_workspace_project() { .body("project", equalTo(PROJECT)) .body("written", hasItems("orders.edm", "orders.model", "OrderApproval.bpmn", "ApproveOrder.form", - "OrdersByCustomer.report", "OrderBalance.report", "orders.roles", - "orders.glue", "countries.csvim", "countries.csv", + "OrdersByCustomer.report", "OrderBalance.report", "OrderStatement.report", + "orders.roles", "orders.glue", "countries.csvim", "countries.csv", "doc/Templates/Order/Print/en/standard.print", "orders.test")) .body("scrubbed", hasSize(0)) // The model-to-code plan the editor replays: one entry per generated model with a @@ -3149,6 +3166,21 @@ void report_file_stack_generates_typed_column_filters() { "the report table should align and format cells from the column metadata"); assertTrue(page.contains("align: 'right'"), "decimal measures should be right-aligned"); assertTrue(page.contains("pattern: '### ### ### ##0.00'"), "the page metadata should carry the money pattern for decimal columns"); + assertTrue(page.contains("limit: 20"), "an ordinary report should page in twenties"); + + // A statement's rows ARE its structure, so its page fetches the whole statement rather than + // splitting a balance sheet across pages. + restAssuredExecutor.execute(() -> given().contentType("application/json") + .body(payload) + .when() + .post("/services/ide/generate/model/" + WORKSPACE + "/" + PROJECT + + "?path=OrderStatement.report") + .then() + .statusCode(201)); + String statementPage = contentOf("gen/OrderStatement/reports/OrderStatement/report.js"); + assertTrue(statementPage.contains("limit: 500"), "a statement page should fetch the whole line structure at once"); + assertTrue(statementPage.contains("{ key: 'Code', kind: 'text'") && statementPage.contains("{ key: 'Amount', kind: 'number'"), + "the statement page should carry the Code / Label / Amount column metadata"); } @Test @@ -4140,6 +4172,25 @@ private void assertReport() { "the balance report should declare the two window parameters"); assertTrue(balance.contains("\"initial\": \"1900-01-01\"") && balance.contains("\"initial\": \"9999-12-31\""), "the window parameters should default to the all-time balance"); + + // kind: statement - the same window, but the rows are the declared lines: one subquery + // reducing the ledger to a balance per account code, then one row per line reading it. + String statement = contentOf("OrderStatement.report"); + assertTrue(statement.contains("\"kind\": \"statement\""), "the statement report should carry its kind"); + assertTrue(statement.contains("WITH \\\"ACCOUNT_BALANCES\\\" as (") && statement.contains("GROUP BY Country.\\\"COUNTRY_CODE2\\\""), + "the statement should reduce the ledger to one balance per account code before its lines read it"); + assertTrue(statement.contains("CAST('A.I' AS VARCHAR(255))") && statement.contains("CAST('Alpine markets' AS VARCHAR(4000))"), + "each line should render its code and label as the statement's first two columns"); + assertTrue(statement.contains("(\\\"ACCOUNT_CODE\\\" = 'AL' OR \\\"ACCOUNT_CODE\\\" = 'AT')"), + "a comma-separated selector of exact codes should be an OR of equalities"); + assertTrue(statement.contains("SUBSTRING(\\\"ACCOUNT_CODE\\\" FROM 1 FOR 1) >= 'B'"), + "a range selector should compare equally long code prefixes, not the whole code"); + assertTrue(statement.contains("ORDER BY \\\"STATEMENT_LINES\\\".\\\"Ordinal\\\""), + "the statement should render its lines in the authored order"); + assertTrue(statement.contains("\"alias\": \"Code\"") && statement.contains("\"alias\": \"Label\"") + && statement.contains("\"alias\": \"Amount\""), "a statement's columns are Code / Label / Amount"); + assertTrue(statement.contains("\"name\": \"fromDate\"") && statement.contains("\"name\": \"toDate\""), + "a statement should declare the same window parameters as a balance report"); } private void assertRoles() {