-
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.