Closes the P1.8 row of the implementation plan — **every Phase 1
platform unit is now ✅**. New platform-reports subproject wrapping
JasperReports 6.21.3 with a minimal api.v1 ReportRenderer facade,
a built-in self-test JRXML template, and a thin HTTP surface.
## api.v1 additions (package `org.vibeerp.api.v1.reports`)
- `ReportRenderer` — injectable facade with ONE method for v1:
`renderPdf(template: InputStream, data: Map<String, Any?>): ByteArray`
Caller loads the JRXML (or pre-compiled .jasper) from wherever
(plug-in JAR classpath, FileStorage, DB metadata row, HTTP
upload) and hands an open stream to the renderer. The framework
reads the bytes, compiles/fills/exports, and returns the PDF.
- `ReportRenderException` — wraps any engine exception so plug-ins
don't have to import concrete Jasper exception types.
- `PluginContext.reports: ReportRenderer` — new optional member
with the default-throw backward-compat pattern used for every
other addition. Plug-ins that ship quote PDFs, job cards,
delivery notes, etc. inject this through the context.
## platform-reports runtime
- `JasperReportRenderer` @Component — wraps JasperReports' compile
→ fill → export cycle into one method.
* `JasperCompileManager.compileReport(template)` turns the
JRXML stream into an in-memory `JasperReport`.
* `JasperFillManager.fillReport(compiled, params, JREmptyDataSource(1))`
evaluates expressions against the parameter map. The empty
data source satisfies Jasper's requirement for a non-null
data source when the template has no `<field>` definitions.
* `JasperExportManager.exportReportToPdfStream(jasperPrint, buffer)`
produces the PDF bytes. The `JasperPrint` type annotation on
the local is deliberate — Jasper has an ambiguous
`exportReportToPdfStream(InputStream, OutputStream)` overload
and Kotlin needs the explicit type to pick the right one.
* Every stage catches `Throwable` and re-throws as
`ReportRenderException` with a useful message, keeping the
api.v1 surface clean of Jasper's exception hierarchy.
- `ReportController` at `/api/v1/reports/**`:
* `POST /ping` render the built-in self-test JRXML with
the supplied `{name: "..."}` (optional, defaults
to "world") and return the PDF bytes with
`application/pdf` Content-Type
* `POST /render` multipart upload a JRXML template + return
the PDF. Operator / test use, not the main
production path.
Both endpoints @RequirePermission-gated via `reports.report.render`.
- `reports/vibeerp-ping-report.jrxml` — a single-page JRXML with
a title, centred "Hello, $P{name}!" text, and a footer. Zero
fields, one string parameter with a default value. Ships on the
platform-reports classpath and is loaded by the `/ping` endpoint
via `ClassPathResource`.
- `META-INF/vibe-erp/metadata/reports.yml` — 1 permission + 1 menu.
## Design decisions captured in-file
- **No template compilation cache.** Every call compiles the JRXML
fresh. Fine for infrequent reports (quotes, job cards); a hot
path that renders thousands of the same report per minute would
want a `ConcurrentHashMap<String, JasperReport>` keyed by
template hash. Deliberately NOT shipped until a benchmark shows
it's needed — the cache key semantics need a real consumer.
- **No multiple output formats.** v1 is PDF-only. Additive
overloads for HTML/XLSX land when a real consumer needs them.
- **No data-source argument.** v1 is parameter-driven, not
query-driven. A future `renderPdf(template, data, rows)`
overload will take tabular data for `<field>`-based templates.
- **No Groovy / Janino / ECJ.** The default `JRJavacCompiler` uses
`javax.tools.ToolProvider.getSystemJavaCompiler()` which is
available on any JDK runtime. vibe_erp already requires a JDK
(not JRE) for Liquibase + Flowable + Quartz, so we inherit this
for free. Zero extra compiler dependencies.
## Config trap caught during first build (documented in build.gradle.kts)
My first attempt added aggressive JasperReports exclusions to
shrink the transitive dep tree (POI, Batik, Velocity, Castor,
Groovy, commons-digester, ...). The build compiled fine but
`JasperCompileManager.compileReport(...)` threw
`ClassNotFoundException: org.apache.commons.digester.Digester`
at runtime — Jasper uses Digester internally to parse the JRXML
structure, and excluding the transitive dep silently breaks
template loading.
Fix: remove ALL exclusions. JasperReports' dep tree IS heavy,
but each transitive is load-bearing for a use case that's only
obvious once you exercise the engine end-to-end. A benchmark-
driven optimization chunk can revisit this later if the JAR size
becomes a concern; for v1.0 the "just pull it all in" approach is
correct. Documented in the build.gradle.kts so the next person
who thinks about trimming the dep tree reads the warning first.
## Smoke test (fresh DB, as admin)
```
POST /api/v1/reports/ping {"name": "Alice"}
→ 200
Content-Type: application/pdf
Content-Length: 1436
body: %PDF-1.5 ... (valid 1-page PDF)
$ file /tmp/ping-report.pdf
/tmp/ping-report.pdf: PDF document, version 1.5, 1 pages (zip deflate encoded)
POST /api/v1/reports/ping (no body)
→ 200, 1435 bytes, renders with default name="world" from JRXML
defaultValueExpression
# negative
POST /api/v1/reports/render (multipart with garbage bytes)
→ 400 {"message": "failed to compile JRXML template:
org.xml.sax.SAXParseException; lineNumber: 1; columnNumber: 1;
Content is not allowed in prolog."}
GET /api/v1/_meta/metadata
→ permissions includes "reports.report.render"
```
The `%PDF-` magic header is present and the `file` command on
macOS identifies the bytes as a valid PDF 1.5 single-page document.
JasperReports compile + fill + export are all running against
the live JDK 21 javac inside the Spring Boot app on first boot.
## Tests
- 3 new unit tests in `JasperReportRendererTest`:
* `renders the built-in ping template to a valid PDF byte stream`
— checks for the `%PDF-` magic header and a reasonable size
* `renders with the default parameter when the data map is empty`
— proves the JRXML's defaultValueExpression fires
* `wraps compile failures in ReportRenderException` — feeds
garbage bytes and asserts the exception type
- Total framework unit tests: 337 (was 334), all green.
## What this unblocks
- **Printing-shop quote PDFs.** The reference plug-in can now ship
a `reports/quote.jrxml` in its JAR, load it in an HTTP handler
via classloader, render via `context.reports.renderPdf(...)`,
and either return the PDF bytes directly or persist it via
`context.files.put("reports/quote-$code.pdf", "application/pdf", ...)`
for later download. The P1.8 → P1.9 chain is ready.
- **Job cards, delivery notes, pick lists, QC certificates.**
Every business document in a printing shop is a report
template + a data payload. The facade handles them all through
the same `renderPdf` call.
- **A future reports PBC.** When a PBC actually needs report
metadata persisted (template versioning, report scheduling), a
new pbc-reports can layer on top without changing api.v1 —
the renderer stays the lowest-level primitive, the PBC becomes
the management surface.
## Phase 1 completion
With P1.8 landed:
| Unit | Status |
|------|--------|
| P1.2 Plug-in linter | ✅ |
| P1.3 Plug-in HTTP + lifecycle | ✅ |
| P1.4 Plug-in Liquibase + PluginJdbc | ✅ |
| P1.5 Metadata store + loader | ✅ |
| P1.6 ICU4J translator | ✅ |
| P1.7 Event bus + outbox | ✅ |
| P1.8 JasperReports integration | ✅ |
| P1.9 File store | ✅ |
| P1.10 Quartz scheduler | ✅ |
**All nine Phase 1 platform units are now done.** (P1.1 Postgres RLS
was removed by the early single-tenant refactor, per CLAUDE.md
guardrail #5.) Remaining v1.0 work is cross-cutting: pbc-finance
GL growth, the web SPA (R1–R4), OIDC (P4.2), the MCP server (A1),
and richer per-PBC v2/v3 scopes.
## Non-goals (parking lot)
- Template caching keyed by hash.
- HTML/XLSX exporters.
- Pre-compiled `.jasper` support via a Gradle build task.
- Sub-reports (master-detail).
- Dependency-tree optimisation via selective exclusions — needs a
benchmark-driven chunk to prove each exclusion is safe.
- Plug-in loader integration for custom font embedding. Jasper's
default fonts work; custom fonts land when a real customer
plug-in needs them.