feat(intent): a notify block can attach a parameterized report render (#6931) - #6934
Merged
Conversation
…#6931) `attach: print` is deliberately document-only, so the standard AR **customer statement** - a period's rows per customer, mailed monthly or on demand - had no way out of the system, while its little brother the per-invoice dunning reminder already had one. With report `parameters:` generalized to any report (#6911) the statement is expressible as a report definition; only the delivery was missing. reports: - name: CustomerStatement source: SalesInvoice dimensions: [issuedOn] measures: ["sum(total)"] parameters: - { name: fromDate, target: issuedOn, op: ge } - { name: toDate, target: issuedOn, op: le } - { name: customer, target: Customer.name, op: eq, initial: "-" } schedules: - name: monthly-statements cron: "0 0 7 1 * ?" entity: Customer where: [{ field: openBalance, op: gt, value: 0 }] notify: to: email subject: "Your statement" body: "Please find attached your account statement." attach: report: CustomerStatement bind: { customer: name, fromDate: periodStart, toDate: periodEnd } `bind:` maps a report parameter to a field or a one-hop `relation.field` of the record the message is about (the ROW inside a `forEach`), resolved through the same resolver a `{placeholder}` uses - so a relation the bindings read shares the local the message text already declares. Available at all four notify call sites; `language:` / `languageFrom:` / `fileName:` apply as they do to a document, and absent a pattern the name is `<Report> <record>.pdf`. Four decisions, each of them a way the mail is quietly WRONG rather than broken: - **`attach` is now polymorphic**, so `NotificationIntent.attach` is typed `Object` (a `String` field makes plain Gson throw on the map) and `getAttach()` reports only the KIND. Every existing reader compares kinds, so nothing else moved. The map's vocabulary is closed through `UnknownKeyValidator.MAP_KEYS`, with `bind:` staying opaque - its keys are the named report's parameters, which the notify validator checks. - **A parameter that declares an `initial` must be bound.** A parameter is bound on every call (#6911), so an unbound one rides its `initial`: one FIXED slice, identical for every recipient - the "whole ledger to one customer" shape, where the mail goes out, the attachment IS a report, and nothing about it says whose. A parameter WITHOUT an `initial` is precisely one whose comparison has a neutral any-value default (a date window bound, a `like` search), so omitting it legitimately means the whole range. That is why requiredness is tested on `initial` and not on the op: no re-resolution of the target, and no issue reported twice. A balance report's own `fromDate`/`toDate` are bindable and optional. - **An unresolvable report attachment drops the mail** rather than degrading to plain text: the bindings ARE the scoping, so sending without them would carry the wrong rows, which is worse than not sending. - **The render reuses the whole print path.** `Print.render(<name>, ...)` resolves a CMS template BY NAME, so a report name works exactly as an entity name does. `ReportPrintTemplate` seeds `doc/Templates/<Report>/Print/en/standard.print` - written from the columns `ReportIntentGenerator` JUST resolved rather than re-derived, because a column alias is what the query actually SELECTs and a second derivation would drift into placeholders that render empty. Written once and developer-owned afterwards (a mailed statement is a formatted artifact), and only for reports something actually mails. The bound values double as the rendered header, since a table of rows never states which slice it is. One run-time detail: each bound value passes through a generated `reportValue(Object)` that stringifies it, because the report repository JSON-encodes its parameters and the named-parameter binder accepts only a JSON primitive - a raw `LocalDate` would arrive as an object and be rejected. Null stays null, which is what makes a nullable source column fall back to the parameter's `initial` instead of failing the send. Tests: `NotifyAttachReportTest` covers the glue at two call sites, the merged relation load and every parse rule; `ReportPrintTemplateTest` pins the scaffold to the report's own aliases; the `orders.glue` fixture renders the branch in `ModelGenerationIT`; and `IntentEmissionCoverageIT` attaches `ClaimsByUnit` to the `BillFlow` sending step, so the client-Java compile of that project proves the generated code - repository package included - actually compiles. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #6931.
attach: printis deliberately document-only, so the standard AR customer statement - a period's rows per customer, mailed monthly or on demand - had no way out of the system, while its little brother the per-invoice dunning reminder already had one. With reportparameters:generalized to any report (#6911) the statement is expressible as a report definition; only the delivery was missing.bind:maps a report parameter to a field or a one-hoprelation.fieldof the record the message is about (the ROW inside aforEach), resolved through the same resolver a{placeholder}uses - so a relation the bindings read shares the local the message text already declares. Available at all four notify call sites;language:/languageFrom:/fileName:apply as they do to a document, and absent a pattern the name is<Report> <record>.pdf.Four decisions, each of them a way the mail is quietly WRONG rather than broken
attachis now polymorphic, soNotificationIntent.attachis typedObject(aStringfield makes plain Gson throw on the map) andgetAttach()reports only the KIND. Every existing reader compares kinds, so nothing else moved. The map's vocabulary is closed throughUnknownKeyValidator.MAP_KEYS, withbind:staying opaque - its keys are the named report's parameters, which the notify validator checks.A parameter that declares an
initialmust be bound. A parameter is bound on every call (feat(intent): user-set report parameters on any report (#6357) #6911), so an unbound one rides itsinitial: one FIXED slice, identical for every recipient - the "whole ledger to one customer" shape, where the mail goes out, the attachment IS a report, and nothing about it says whose. A parameter without aninitialis precisely one whose comparison has a neutral any-value default (a date window bound, alikesearch), so omitting it legitimately means the whole range. That is why requiredness is tested oninitialand not on the op: no re-resolution of the target, and no issue reported twice. A balance report's ownfromDate/toDateare bindable and optional.An unresolvable report attachment drops the mail rather than degrading to plain text: the bindings ARE the scoping, so sending without them would carry the wrong rows, which is worse than not sending.
The render reuses the whole print path.
Print.render(<name>, ...)resolves a CMS template BY NAME, so a report name works exactly as an entity name does.ReportPrintTemplateseedsdoc/Templates/<Report>/Print/en/standard.print- written from the columnsReportIntentGeneratorjust resolved rather than re-derived, because a column alias is what the query actually SELECTs and a second derivation would drift into placeholders that render empty. Written once and developer-owned afterwards (a mailed statement is a formatted artifact), and only for reports something actually mails. The bound values double as the rendered header, since a table of rows never states which slice it is.One run-time detail: each bound value passes through a generated
reportValue(Object)that stringifies it, because the report repository JSON-encodes its parameters and the named-parameter binder accepts only a JSON primitive - a rawLocalDatewould arrive as an object and be rejected. Null stays null, which is what makes a nullable source column fall back to the parameter'sinitialinstead of failing the send.Scope notes
record.scope) is rejected with a message of its own, and a report attachment has norecordPrint-style anchor variant - a report is always scoped by the record the message is about, because that is what makes the rows the recipient's.Tests
NotifyAttachReportTest- the glue at two call sites, the merged relation load, and every parse rule (unbound selector, unknown report, unknown parameter with a case suggestion, an unresolvable bind source, therecord.scope, a malformed shape, a stray key, a parameterless report).ReportPrintTemplateTest- the scaffold binds the report's own resolved aliases, a balance report's window is part of the header, and the seeded path is the onePrint.renderresolves by name.ModelGenerationIT- theorders.gluefixture gained a report-attaching schedule, so the new template branch is actually rendered and shape-checked.IntentEmissionCoverageIT-ClaimsByUnitis attached to theBillFlowsending step, so the client-Java compile of that project proves the generated code (repository package included) compiles, and the seeded report print template is asserted.Docs: the engine-intent guide gained a mail a REPORT section;
CLAUDE.mdand the moduleCLAUDE.mdrecord the decisions. A docs-site PR follows.🤖 Generated with Claude Code