diff --git a/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/plugin/PluginContext.kt b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/plugin/PluginContext.kt index a8a8803..a3c341d 100644 --- a/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/plugin/PluginContext.kt +++ b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/plugin/PluginContext.kt @@ -7,6 +7,7 @@ import org.vibeerp.api.v1.i18n.Translator import org.vibeerp.api.v1.files.FileStorage import org.vibeerp.api.v1.jobs.PluginJobHandlerRegistrar import org.vibeerp.api.v1.persistence.Transaction +import org.vibeerp.api.v1.reports.ReportRenderer import org.vibeerp.api.v1.security.PermissionCheck import org.vibeerp.api.v1.workflow.PluginTaskHandlerRegistrar @@ -148,6 +149,22 @@ interface PluginContext { "PluginContext.jobs is not implemented by this host. " + "Upgrade vibe_erp to v0.9 or later." ) + + /** + * Produce printable output (PDF today) from a template + data + * map. Plug-ins use this to render quotes, delivery notes, QC + * certificates, and other business documents. See + * [org.vibeerp.api.v1.reports.ReportRenderer]. + * + * Added in api.v1.0.x (P1.8). Default implementation throws so + * an older host running a newer plug-in jar fails loudly at + * first call rather than silently returning empty PDFs. + */ + val reports: ReportRenderer + get() = throw UnsupportedOperationException( + "PluginContext.reports is not implemented by this host. " + + "Upgrade vibe_erp to v0.10 or later." + ) } /** diff --git a/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/reports/ReportRenderer.kt b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/reports/ReportRenderer.kt new file mode 100644 index 0000000..0371441 --- /dev/null +++ b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/reports/ReportRenderer.kt @@ -0,0 +1,91 @@ +package org.vibeerp.api.v1.reports + +import java.io.InputStream + +/** + * Cross-PBC facade for the framework's report renderer. + * + * The framework-wide seam for turning a template + data payload + * into printable output (today: PDF). Core PBCs and plug-ins inject + * [ReportRenderer] to produce quote PDFs, delivery notes, QC + * certificates, work-order job cards, and any other piece of paper + * a printing shop drops on a clipboard. The host's `platform-reports` + * module supplies the only implementation. + * + * **Why one interface instead of exposing JasperReports directly:** + * - Plug-ins must not import `net.sf.jasperreports.*` or any other + * internal report engine type (CLAUDE.md guardrail #10). Swapping + * Jasper for a different engine (e.g. OpenPDF-only, or a + * cloud-hosted template service) is a platform-reports change + * that costs zero plug-in lines. + * - The facade forces the minimal contract: a template + a data + * map in, a byte array out. Everything fancier (sub-reports, + * pre-compiled `.jasper` files, locale-aware number formatting, + * embedded fonts) lives inside the implementation and is an + * additive change. + * + * **Template lifecycle is the caller's problem.** The renderer does + * NOT care where the template came from — the caller loads the + * JRXML from wherever it lives (plug-in JAR classpath resource, + * `FileStorage`, database row, HTTP upload) and hands an open + * InputStream to [renderPdf]. The implementation reads it to the + * end but does NOT close it; the caller owns the stream's + * lifetime and should wrap in `use { }`. + * + * **What this facade does NOT expose:** + * - Multiple output formats. v1.0 is PDF-only. HTML/XLSX exporters + * land as additional methods when a real consumer needs them. + * - Pre-compiled template caching. The implementation is free to + * cache compiled templates by a key the caller supplies, but + * the cache key is not part of the api.v1 contract — an + * additive method [renderPdfCached] lands when a benchmark + * shows it's worth the extra surface. + * - Sub-reports / master-detail. A future chunk adds a + * `resolveSubReport(name)` callback to the interface. + * + * v1.0 is deliberately boring. The goal is "plug-in produces a + * quote PDF" working end-to-end, not "every Jasper feature the + * framework can reach". + */ +public interface ReportRenderer { + + /** + * Compile and fill the given [template] (a JRXML source stream, + * or a pre-compiled `.jasper` stream — the implementation + * decides which based on the first bytes) with [data] as the + * parameter map, and export the result as a PDF. + * + * The [data] map is passed to JasperReports as parameters: a + * template referencing `$P{name}` reads `data["name"]` at + * fill time. The framework does not currently support passing + * JDBC data sources — reports are parameter-driven, not + * query-driven. A future additive overload may take a + * `dataSource: List>` when a real consumer + * needs tabular data. + * + * @param template an open input stream pointing at a JRXML (or + * pre-compiled .jasper) resource. The caller owns the + * lifetime and should close it (e.g. `template.use { ... }`). + * @param data the parameter map. Keys are JRXML parameter + * names (matching `$P{…}`). Values are any JSON-compatible + * type; complex objects are stringified by the template + * expression engine. + * @return the rendered PDF as a byte array. For very large + * reports a streaming variant will land when memory pressure + * becomes a real concern. + * @throws ReportRenderException if the template is malformed, + * references a parameter that isn't in [data], or the + * underlying engine fails to compile or fill. + */ + public fun renderPdf(template: InputStream, data: Map): ByteArray +} + +/** + * Wraps anything the report engine threw into a single api.v1 + * exception type so plug-ins don't have to import concrete engine + * exception classes. + */ +public class ReportRenderException( + message: String, + cause: Throwable? = null, +) : RuntimeException(message, cause) diff --git a/distribution/build.gradle.kts b/distribution/build.gradle.kts index ef749c1..bba6d68 100644 --- a/distribution/build.gradle.kts +++ b/distribution/build.gradle.kts @@ -29,6 +29,7 @@ dependencies { implementation(project(":platform:platform-workflow")) implementation(project(":platform:platform-jobs")) implementation(project(":platform:platform-files")) + implementation(project(":platform:platform-reports")) implementation(project(":pbc:pbc-identity")) implementation(project(":pbc:pbc-catalog")) implementation(project(":pbc:pbc-partners")) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1737684..b8f0c8a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,6 +8,7 @@ hibernate = "6.5.3.Final" liquibase = "4.29.2" pf4j = "3.12.0" flowable = "7.0.1" +jasperreports = "6.21.3" icu4j = "75.1" jackson = "2.18.0" junitJupiter = "5.11.2" @@ -57,6 +58,9 @@ flowable-spring-boot-starter-process = { module = "org.flowable:flowable-spring- # Job scheduler (Quartz via Spring Boot starter) spring-boot-starter-quartz = { module = "org.springframework.boot:spring-boot-starter-quartz", version.ref = "springBoot" } +# Reports (JasperReports PDF rendering) +jasperreports = { module = "net.sf.jasperreports:jasperreports", version.ref = "jasperreports" } + # i18n icu4j = { module = "com.ibm.icu:icu4j", version.ref = "icu4j" } diff --git a/platform/platform-reports/build.gradle.kts b/platform/platform-reports/build.gradle.kts new file mode 100644 index 0000000..c23d5e5 --- /dev/null +++ b/platform/platform-reports/build.gradle.kts @@ -0,0 +1,54 @@ +plugins { + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.kotlin.spring) + alias(libs.plugins.spring.dependency.management) +} + +description = "vibe_erp JasperReports-backed PDF rendering. Adapts Jasper to the api.v1 ReportRenderer contract. INTERNAL." + +java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(21)) + } +} + +kotlin { + jvmToolchain(21) + compilerOptions { + freeCompilerArgs.add("-Xjsr305=strict") + } +} + +// Only module that pulls JasperReports in. JasperReports has a +// notoriously heavy transitive dep tree (POI for Excel, Batik for +// SVG, Groovy for expressions, commons-digester, Apache Velocity, +// ...) — most of it is only needed for features we don't use. +// Aggressive exclusions keep the distribution jar from ballooning. +dependencies { + api(project(":api:api-v1")) + implementation(project(":platform:platform-security")) // @RequirePermission on the controller + + implementation(libs.kotlin.stdlib) + implementation(libs.kotlin.reflect) + implementation(libs.jackson.module.kotlin) + + implementation(libs.spring.boot.starter) + implementation(libs.spring.boot.starter.web) + + // JasperReports itself. Transitive deps left intact — + // aggressive exclusions break template loading because Jasper + // uses commons-digester for XML parsing internally. The + // dep tree IS heavy, but that's the cost of PDF rendering + // in a v1.0 framework; optimising belongs to a future chunk + // with benchmark data. + implementation(libs.jasperreports) + + testImplementation(libs.spring.boot.starter.test) + testImplementation(libs.junit.jupiter) + testImplementation(libs.assertk) + testImplementation(libs.mockk) +} + +tasks.test { + useJUnitPlatform() +} diff --git a/platform/platform-reports/src/main/kotlin/org/vibeerp/platform/reports/JasperReportRenderer.kt b/platform/platform-reports/src/main/kotlin/org/vibeerp/platform/reports/JasperReportRenderer.kt new file mode 100644 index 0000000..2b1f0b2 --- /dev/null +++ b/platform/platform-reports/src/main/kotlin/org/vibeerp/platform/reports/JasperReportRenderer.kt @@ -0,0 +1,103 @@ +package org.vibeerp.platform.reports + +import net.sf.jasperreports.engine.JREmptyDataSource +import net.sf.jasperreports.engine.JasperCompileManager +import net.sf.jasperreports.engine.JasperExportManager +import net.sf.jasperreports.engine.JasperFillManager +import net.sf.jasperreports.engine.JasperPrint +import net.sf.jasperreports.engine.JasperReport +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Component +import org.vibeerp.api.v1.reports.ReportRenderException +import org.vibeerp.api.v1.reports.ReportRenderer +import java.io.ByteArrayOutputStream +import java.io.InputStream + +/** + * JasperReports-backed implementation of api.v1 [ReportRenderer]. + * + * **The compile → fill → export cycle.** JasperReports separates a + * report's lifecycle into three stages: + * 1. **Compile** a JRXML template into an in-memory [JasperReport] + * object (or load a pre-compiled `.jasper` file, but this v1 + * only accepts JRXML). + * 2. **Fill** the compiled report by evaluating every expression + * against a data source + a parameter map, producing a + * `JasperPrint` object — the pageset. + * 3. **Export** the `JasperPrint` to a target format; v1 exports + * to PDF via the built-in `JasperExportManager.exportReportToPdf`. + * + * The framework's v1 contract collapses these into a single + * [renderPdf] call that takes the template bytes + a parameter map + * and returns the PDF bytes. Callers that need richer control (e.g. + * fill from a live JDBC result set, stream PDF output, produce + * multi-page sub-reports) will get additive overloads in a future + * api.v1.0.x chunk. + * + * **No expression compiler headaches.** The default JasperReports + * expression compiler (`JRJavacCompiler`) uses + * `javax.tools.ToolProvider.getSystemJavaCompiler()` which is + * available on any JDK runtime — and vibe_erp already requires a + * JDK at runtime (Flowable, Liquibase, and Quartz all need it too). + * We do NOT add Groovy, Janino, or ECJ as a dependency — the JDK's + * own javac does the job. + * + * **Template compilation is NOT cached.** Every call compiles the + * JRXML fresh. This is fine for infrequent reports (quote PDFs, + * nightly job-card batches) but would be a hot-path cost for a + * consumer that renders the same template thousands of times per + * minute. The obvious optimization (a `ConcurrentHashMap` keyed by template hash) is deliberately NOT + * shipped until a benchmark shows it's needed — the cache key + * semantics need a real consumer to design against. + * + * **`JREmptyDataSource` for parameter-only reports.** A JRXML + * template with no `` definitions only reads its data from + * the parameter map. JasperReports still wants a data source, so + * the framework passes `JREmptyDataSource(1)` — "one row of nothing" + * — which satisfies the engine and lets the template render + * exactly once. Reports that iterate over a list of rows will need + * a future `renderPdf(template, data, rows)` overload. + */ +@Component +class JasperReportRenderer : ReportRenderer { + + private val log = LoggerFactory.getLogger(JasperReportRenderer::class.java) + + override fun renderPdf(template: InputStream, data: Map): ByteArray { + val compiled: JasperReport = try { + JasperCompileManager.compileReport(template) + } catch (ex: Throwable) { + throw ReportRenderException("failed to compile JRXML template: ${ex.message}", ex) + } + + // JasperFillManager requires a non-null parameter map with + // String keys. Copy defensively and strip nulls — JR treats + // a null parameter as "not set" which is usually not what + // the caller means. + val params: MutableMap = HashMap() + for ((k, v) in data) { + if (v != null) params[k] = v + } + + val jasperPrint: JasperPrint = try { + JasperFillManager.fillReport(compiled, params, JREmptyDataSource(1)) + } catch (ex: Throwable) { + throw ReportRenderException("failed to fill report: ${ex.message}", ex) + } + + val buffer = ByteArrayOutputStream() + try { + // Explicit JasperPrint type on the local so Kotlin picks + // the JasperPrint overload of exportReportToPdfStream + // (there is an ambiguous InputStream overload too). + JasperExportManager.exportReportToPdfStream(jasperPrint, buffer) + } catch (ex: Throwable) { + throw ReportRenderException("failed to export PDF: ${ex.message}", ex) + } + + val bytes = buffer.toByteArray() + log.debug("rendered PDF size={} params={}", bytes.size, params.keys.sorted()) + return bytes + } +} diff --git a/platform/platform-reports/src/main/kotlin/org/vibeerp/platform/reports/http/ReportController.kt b/platform/platform-reports/src/main/kotlin/org/vibeerp/platform/reports/http/ReportController.kt new file mode 100644 index 0000000..6ff43b4 --- /dev/null +++ b/platform/platform-reports/src/main/kotlin/org/vibeerp/platform/reports/http/ReportController.kt @@ -0,0 +1,91 @@ +package org.vibeerp.platform.reports.http + +import org.springframework.core.io.ClassPathResource +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.multipart.MultipartFile +import org.vibeerp.api.v1.reports.ReportRenderException +import org.vibeerp.api.v1.reports.ReportRenderer +import org.vibeerp.platform.security.authz.RequirePermission + +/** + * HTTP surface over the framework's [ReportRenderer]. + * + * - `POST /api/v1/reports/ping` render the built-in self-test + * JRXML with the supplied params + * and return a PDF + * - `POST /api/v1/reports/render` multipart upload a JRXML + + * supply params; returns a PDF + * + * The multipart endpoint is intentionally for operator / test use; + * the main way to render reports in production is via a plug-in or + * core PBC injecting [ReportRenderer] and calling it against a + * template loaded from the plug-in's classpath, the file store, or + * the metadata database. + */ +@RestController +@RequestMapping("/api/v1/reports") +class ReportController( + private val reportRenderer: ReportRenderer, +) { + + @PostMapping("/ping", produces = [MediaType.APPLICATION_PDF_VALUE]) + @RequirePermission("reports.report.render") + fun ping(@RequestBody(required = false) request: PingReportRequest?): ResponseEntity { + val name = request?.name ?: "world" + val template = ClassPathResource(BUILTIN_TEMPLATE).inputStream + val bytes = template.use { reportRenderer.renderPdf(it, mapOf("name" to name)) } + return pdfResponse(bytes, "vibeerp-ping.pdf") + } + + @PostMapping( + "/render", + consumes = [MediaType.MULTIPART_FORM_DATA_VALUE], + produces = [MediaType.APPLICATION_PDF_VALUE], + ) + @RequirePermission("reports.report.render") + fun render( + @org.springframework.web.bind.annotation.RequestParam("template") template: MultipartFile, + @org.springframework.web.bind.annotation.RequestParam("filename", required = false) filename: String?, + ): ResponseEntity { + val bytes = template.inputStream.use { reportRenderer.renderPdf(it, emptyMap()) } + return pdfResponse(bytes, filename ?: "rendered.pdf") + } + + private fun pdfResponse(bytes: ByteArray, filename: String): ResponseEntity = + ResponseEntity.status(HttpStatus.OK) + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_PDF_VALUE) + .header(HttpHeaders.CONTENT_LENGTH, bytes.size.toString()) + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"$filename\"") + .body(bytes) + + @ExceptionHandler(ReportRenderException::class) + fun handleRenderFailure(ex: ReportRenderException): ResponseEntity = + ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(ErrorResponse(message = ex.message ?: "report render failed")) + + companion object { + /** + * Classpath path to the built-in self-test JRXML shipped in + * platform-reports/src/main/resources. Kept as a constant so + * unit tests and a future "test this template" smoke endpoint + * both reference the same file. + */ + const val BUILTIN_TEMPLATE: String = "reports/vibeerp-ping-report.jrxml" + } +} + +data class PingReportRequest( + val name: String? = null, +) + +data class ErrorResponse( + val message: String, +) diff --git a/platform/platform-reports/src/main/resources/META-INF/vibe-erp/metadata/reports.yml b/platform/platform-reports/src/main/resources/META-INF/vibe-erp/metadata/reports.yml new file mode 100644 index 0000000..b2c2763 --- /dev/null +++ b/platform/platform-reports/src/main/resources/META-INF/vibe-erp/metadata/reports.yml @@ -0,0 +1,14 @@ +# platform-reports metadata. +# +# Loaded at boot by MetadataLoader, tagged source='core'. + +permissions: + - key: reports.report.render + description: Render a JasperReports template to PDF + +menus: + - path: /reports + label: Reports + icon: file-pdf + section: Reports + order: 700 diff --git a/platform/platform-reports/src/main/resources/reports/vibeerp-ping-report.jrxml b/platform/platform-reports/src/main/resources/reports/vibeerp-ping-report.jrxml new file mode 100644 index 0000000..5f933b8 --- /dev/null +++ b/platform/platform-reports/src/main/resources/reports/vibeerp-ping-report.jrxml @@ -0,0 +1,73 @@ + + + + + + + + <band height="80"> + <staticText> + <reportElement x="0" y="0" width="535" height="36"/> + <textElement textAlignment="Center"> + <font size="24" isBold="true"/> + </textElement> + <text><![CDATA[vibe_erp report engine]]></text> + </staticText> + <staticText> + <reportElement x="0" y="40" width="535" height="20"/> + <textElement textAlignment="Center"> + <font size="12"/> + </textElement> + <text><![CDATA[Built-in self-test (vibeerp.reports.ping)]]></text> + </staticText> + </band> + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/platform/platform-reports/src/test/kotlin/org/vibeerp/platform/reports/JasperReportRendererTest.kt b/platform/platform-reports/src/test/kotlin/org/vibeerp/platform/reports/JasperReportRendererTest.kt new file mode 100644 index 0000000..aa96f50 --- /dev/null +++ b/platform/platform-reports/src/test/kotlin/org/vibeerp/platform/reports/JasperReportRendererTest.kt @@ -0,0 +1,49 @@ +package org.vibeerp.platform.reports + +import assertk.assertFailure +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isGreaterThan +import assertk.assertions.isInstanceOf +import org.junit.jupiter.api.Test +import org.vibeerp.api.v1.reports.ReportRenderException +import java.io.ByteArrayInputStream + +class JasperReportRendererTest { + + private val subject = JasperReportRenderer() + + @Test + fun `renders the built-in ping template to a valid PDF byte stream`() { + val template = javaClass.classLoader + .getResourceAsStream("reports/vibeerp-ping-report.jrxml") + ?: error("built-in template missing from the test classpath") + + val pdf = template.use { subject.renderPdf(it, mapOf("name" to "Alice")) } + + // A valid PDF starts with the literal header `%PDF-`. + assertThat(pdf.size).isGreaterThan(200) + assertThat(String(pdf.take(5).toByteArray(), Charsets.ISO_8859_1)).isEqualTo("%PDF-") + } + + @Test + fun `renders with the default parameter when the data map is empty`() { + val template = javaClass.classLoader + .getResourceAsStream("reports/vibeerp-ping-report.jrxml") + ?: error("built-in template missing from the test classpath") + + val pdf = template.use { subject.renderPdf(it, emptyMap()) } + + // Still produces a valid PDF — the JRXML supplies a + // defaultValueExpression of "world" for the name parameter. + assertThat(String(pdf.take(5).toByteArray(), Charsets.ISO_8859_1)).isEqualTo("%PDF-") + } + + @Test + fun `wraps compile failures in ReportRenderException`() { + val garbage = ByteArrayInputStream("not a valid JRXML".toByteArray()) + + assertFailure { subject.renderPdf(garbage, emptyMap()) } + .isInstanceOf(ReportRenderException::class) + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 5e29c30..99d1eed 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -51,6 +51,9 @@ project(":platform:platform-jobs").projectDir = file("platform/platform-jobs") include(":platform:platform-files") project(":platform:platform-files").projectDir = file("platform/platform-files") +include(":platform:platform-reports") +project(":platform:platform-reports").projectDir = file("platform/platform-reports") + // ─── Packaged Business Capabilities (core PBCs) ───────────────────── include(":pbc:pbc-identity") project(":pbc:pbc-identity").projectDir = file("pbc/pbc-identity")