-
Adds JournalEntryLine child entity with debit/credit legs per account, completing the pbc-finance GL foundation. Domain: - JournalEntryLine entity: lineNo, accountCode, debit (>=0), credit (>=0), description. FK to parent JournalEntry with CASCADE delete. Unique (journal_entry_id, line_no). - JournalEntry gains @OneToMany lines collection (EAGER fetch, ordered by lineNo). Event subscribers now write balanced double-entry lines: - SalesOrderConfirmed: DR 1100 (AR), CR 4100 (Revenue) - PurchaseOrderConfirmed: DR 1200 (Inventory), CR 2100 (AP) The parent JournalEntry.amount is retained as a denormalized summary; the lines are the source of truth for accounting. The seeded account codes (1100, 1200, 2100, 4100, 5100) match the chart from the previous commit. JournalEntryController.toResponse() now includes the lines array so the SPA can display debit/credit legs inline. Schema: 004-finance-entry-lines.xml adds finance__journal_entry_line with FK, unique (entry, lineNo), non-negative check on dr/cr. Smoke verified on fresh Postgres: - Confirm SO-2026-0001 -> AR entry with 2 lines: DR 1100 $1950, CR 4100 $1950 - Confirm PO-2026-0001 -> AP entry with 2 lines: DR 1200 $2550, CR 2100 $2550 - Both entries balanced (sum DR = sum CR) Caught by smoke: LazyInitializationException on the new lines collection — fixed by switching FetchType from LAZY to EAGER (entries have 2-4 lines max, eager is appropriate). -
First step of pbc-finance GL growth: the chart of accounts. Backend: - Account entity (code, name, accountType: ASSET/LIABILITY/EQUITY/ REVENUE/EXPENSE, description, active) - AccountJpaRepository + AccountService (list, findById, findByCode, create with duplicate-code guard) - AccountController at /api/v1/finance/accounts (GET list, GET by id, POST create). Permission-gated: finance.account.read, .create. - Liquibase 003-finance-accounts.xml: table + unique code index + 6 seeded accounts (1000 Cash, 1100 AR, 1200 Inventory, 2100 AP, 4100 Sales Revenue, 5100 COGS) - finance.yml updated: Account entity + 2 permissions + menu entry SPA: - AccountsPage with sortable list + inline create form - finance.listAccounts + finance.createAccount in typed API client - Sidebar: "Chart of Accounts" above "Journal Entries" in Finance - Route /accounts wired in App.tsx + SpaController + SecurityConfig This is the foundation for the next step (JournalEntryLine child entity with per-account debit/credit legs + balanced-entry validation). The seeded chart covers the 6 accounts the existing event subscribers will reference once the double-entry lines land. -
The framework now supports federated authentication: operators can configure an external OIDC provider (Keycloak, Auth0, or any OIDC-compliant issuer) and the API accepts JWTs from both the built-in auth (/api/v1/auth/login, HS256) and the OIDC provider (RS256, JWKS auto-discovered). Opt-in via vibeerp.security.oidc.issuer-uri. When blank (default), only built-in auth works — exactly the pre-P4.2 behavior. When set, the JwtDecoder becomes a composite: tries the built-in HS256 decoder first (cheap, local HMAC), falls back to the OIDC decoder (RS256, cached JWKS fetch from the provider's .well-known endpoint). Claim mapping: PrincipalContextFilter now handles both formats: - Built-in: sub=UUID, username=<claim>, roles=<flat array> - OIDC/Keycloak: sub=OIDC subject, preferred_username=<claim>, realm_access.roles=<nested array> Claim names are configurable via vibeerp.security.oidc.username-claim and roles-claim for non-Keycloak providers. New files: - OidcProperties.kt: config properties class for the OIDC block Modified files: - JwtConfiguration.kt: composite decoder, now takes OidcProperties - PrincipalContextFilter.kt: dual claim resolution (built-in first, OIDC fallback), now takes OidcProperties - JwtRoundTripTest.kt: updated to pass OidcProperties (defaults) - application.yaml: OIDC config block with env-var interpolation No new dependencies — uses Spring Security's existing JwtDecoders.fromIssuerLocation() which is already on the classpath via spring-boot-starter-oauth2-resource-server. -
Adds S3FileStorage alongside the existing LocalDiskFileStorage, selected at boot by vibeerp.files.backend (local or s3). The local backend is the default (matchIfMissing=true) so existing deployments are unaffected. Setting backend=s3 activates the S3 backend with its own config block. Works with AWS S3, MinIO, DigitalOcean Spaces, or any S3-compatible object store via the endpoint-url override. The S3 client is lazy-initialized on first use so the bean loads even when S3 is unreachable at boot time (useful for tests and for the local-disk default path where the S3 bean is never instantiated). Configuration (vibeerp.files.s3.*): - bucket (required when backend=s3) - region (default: us-east-1) - endpoint-url (optional; for MinIO and non-AWS services) - access-key + secret-key (optional; falls back to AWS DefaultCredentialsProvider chain) - key-prefix (optional; namespaces objects so multiple instances can share one bucket) Implementation notes: - put() reads the stream into a byte array for S3 (S3 requires Content-Length up front; chunked upload is a future optimization for large files) - get() returns the S3 response InputStream directly; caller must close it (same contract as local backend) - list() paginates via ContinuationToken for buckets with >1000 objects per prefix - Content-type is stored as native S3 object metadata (no sidecar .meta file unlike local backend) Dependency: software.amazon.awssdk:s3:2.28.6 (AWS SDK v2) added to libs.versions.toml and platform-files build.gradle.kts. LocalDiskFileStorage gained @ConditionalOnProperty(havingValue = "local", matchIfMissing = true) so it's the default but doesn't conflict when backend=s3. application.yaml updated with commented-out S3 config block documenting all available properties. -
Reworks the demo seed and SPA to match the reference customer's work-order management process (EBC-PP-001 from raw/ docs). Demo seed (DemoSeedRunner): - 7 printing-specific items: paper stock, 4-color ink, CTP plates, lamination film, business cards, brochures, posters - 4 partners: 2 customers (Wucai Advertising, Globe Marketing), 2 suppliers (Huazhong Paper, InkPro Industries) - 2 warehouses with opening stock for all items - Pre-seeded WO-PRINT-0001 with full BOM (3 inputs: paper + ink + CTP plates from WH-RAW) and 3-step routing (CTP plate-making @ CTP-ROOM-01 -> offset printing @ PRESS-A -> post-press finishing @ BIND-01) matching EBC-PP-001 steps C-010/C-040 - 2 DRAFT sales orders: SO-2026-0001 (100x business cards + 500x brochures, $1950), SO-2026-0002 (200x posters, $760) - 1 DRAFT purchase order: PO-2026-0001 (10000x paper + 50kg ink, $2550) from Huazhong Paper SPA additions: - New CreateSalesOrderPage with customer dropdown, item selector, dynamic line add/remove, quantity + price inputs. Navigates to the detail page on creation. - "+ New Order" button on the SalesOrdersPage header - Dashboard "Try the demo" section rewritten to walk the EBC-PP-001 flow: create SO -> confirm (auto-spawns WOs) -> walk WO routing -> complete (material issue + production receipt) -> ship SO (stock debit + AR settle) - salesOrders.create() added to the typed API client The key demo beat: confirming SO-2026-0001 auto-spawns WO-FROM-SO-2026-0001-L1 and -L2 via SalesOrderConfirmedSubscriber (EBC-PP-001 step B-010). The pre-seeded WO-PRINT-0001 shows the full BOM + routing story separately. Together they demonstrate that the framework expresses the customer's production workflow through configuration, not code. Smoke verified on fresh Postgres: all 7 items seeded, WO with 3 BOM + 3 ops created, SO confirm spawns 2 WOs with source traceability, SPA /sales-orders/new renders and creates orders.
-
First runnable end-to-end demo: open the browser, log in, click through every PBC, and walk a sales order DRAFT → CONFIRMED → SHIPPED. Stock balances drop, the SALES_SHIPMENT row appears in the ledger, and the AR journal entry settles — all visible in the SPA without touching curl. Bumps version to 0.29.0-SNAPSHOT. What landed ----------- * New `:web` Gradle subproject — Vite + React 18 + TypeScript + Tailwind 3.4. The Gradle wrapper is two `Exec` tasks (`npmInstall`, `npmBuild`) with proper inputs/outputs declared for incremental builds. Deliberately no node-gradle plugin — one less moving piece. * SPA architecture: hand-written typed REST client over `fetch` (auth header injection + 401 handler), AuthContext that decodes the JWT for display, ProtectedRoute, AppLayout with sidebar grouped by PBC, 16 page components covering the full v1 surface (Items, UoMs, Partners, Locations, Stock Balances + Movements, Sales Orders + detail w/ confirm/ship/cancel, Purchase Orders + detail w/ confirm/receive/cancel, Work Orders + detail w/ start/complete, Shop-Floor dashboard with 5s polling, Journal Entries). 211 KB JS / 21 KB CSS gzipped. * Sales-order detail page: confirm/ship/cancel verbs each refresh the order, the (SO-filtered) movements list, and the (SO-filtered) journal entries — so an operator watches the ledger row appear and the AR row settle in real time after a single click. Same pattern on the purchase-order detail page for the AP/RECEIPT side. * Shop-floor dashboard polls /api/v1/production/work-orders/shop- floor every 5s and renders one card per IN_PROGRESS WO with current operation, planned vs actual minutes (progress bar), and operations-completed. * `:distribution` consumes the SPA dist via a normal Gradle outgoing/incoming configuration: `:web` exposes `webStaticBundle`, `:distribution`'s `bundleWebStatic` Sync task copies it into `${buildDir}/web-static/static/`, and that parent directory is added to the main resources source set so Spring Boot serves the SPA from `classpath:/static/` out of the same fat-jar. Single artifact, no nginx, no CORS. * New `SpaController` in platform-bootstrap forwards every known SPA route prefix to `/index.html` so React Router's HTML5 history mode works on hard refresh / deep-link entry. Explicit list (12 prefixes) rather than catch-all so typoed API URLs still get an honest 404 instead of the SPA shell. * SecurityConfiguration restructured: keeps the public allowlist for /api/v1/auth + /api/v1/_meta + /v3/api-docs + /swagger-ui, then `/api/**` is `.authenticated()`, then SPA static assets + every SPA route prefix are `.permitAll()`. The order is load-bearing — putting `.authenticated()` for /api/** BEFORE the SPA permitAll preserves the framework's "API is always authenticated" invariant even with the SPA bundled in the same fat-jar. The SPA bundle itself is just HTML+CSS+JS so permitting it is correct; secrets are gated by /api/**. * New `DemoSeedRunner` in `:distribution` (gated behind `vibeerp.demo.seed=true`, set in application-dev.yaml only). Idempotent — the runner short-circuits if its sentinel item (DEMO-PAPER-A4) already exists. Seeds 5 items, 2 warehouses, 4 partners, opening stock for every item, one open DEMO-SO-0001 (50× business cards + 20× brochures, $720), one open DEMO-PO-0001 (10000× paper, $400). Every row carries the DEMO- prefix so it's trivially distinguishable from hand-created data; a future "delete demo data" command has an obvious filter. Production deploys never set the property, so the @ConditionalOnProperty bean stays absent from the context. How to run ---------- docker compose up -d db ./gradlew :distribution:bootRun open http://localhost:8080 # Read the bootstrap admin password from the boot log, # log in as admin, and walk DEMO-SO-0001 through the # confirm + ship flow to see the buy-sell loop in the UI. What was caught by the smoke test --------------------------------- * TypeScript strict mode + `error: unknown` in React state → `{error && <X/>}` evaluates to `unknown` and JSX rejects it. Fixed by typing the state as `Error | null` and converting in catches with `e instanceof Error ? e : new Error(String(e))`. Affected 16 page files; the conversion is now uniform. * DataTable's `T extends Record<string, unknown>` constraint was too restrictive for typed row interfaces; relaxed to unconstrained `T` with `(row as unknown as Record<…>)[key]` for the unkeyed cell read fallback. * `vite.config.ts` needs `@types/node` for `node:path` + `__dirname`; added to devDependencies and tsconfig.node.json declares `"types": ["node"]`. * KDoc nested-comment trap (4th time): SpaController's KDoc had `/api/v1/...` in backticks; the `/*` inside backticks starts a nested block comment and breaks Kotlin compilation with "Unclosed comment". Rephrased to "the api-v1 prefix". * SecurityConfiguration order: a draft version that put the SPA permit-all rules BEFORE `/api/**` authenticated() let unauthenticated requests reach API endpoints. Caught by an explicit smoke test (curl /api/v1/some-bogus-endpoint should return 401, not 404 from a missing static file). Reordered so /api/** authentication runs first. End-to-end smoke (real Postgres, fresh DB) ------------------------------------------ - bootRun starts in 7.6s - DemoSeedRunner reports "populating starter dataset… done" - GET / returns the SPA HTML - GET /sales-orders, /sales-orders/<uuid>, /journal-entries all return 200 (SPA shell — React Router takes over) - GET /assets/index-*.js / /assets/index-*.css both 200 - GET /api/v1/some-bogus-endpoint → 401 (Spring Security rejects before any controller mapping) - admin login via /api/v1/auth/login → 200 + JWT - GET /catalog/items → 5 DEMO-* rows - GET /partners/partners → 4 DEMO-* rows - GET /inventory/locations → 2 DEMO-* warehouses - GET /inventory/balances → 5 starting balances - POST /orders/sales-orders/<id>/confirm → CONFIRMED; GET /finance/journal-entries shows AR POSTED 720 USD - POST /orders/sales-orders/<id>/ship {"shippingLocationCode": "DEMO-WH-FG"} → SHIPPED; balances drop to 150 + 80; journal entry flips to SETTLED - POST /orders/purchase-orders/<id>/confirm → CONFIRMED; AP POSTED 400 USD appears - POST /orders/purchase-orders/<id>/receive → RECEIVED; PAPER balance grows from 5000 to 15000; AP row SETTLED - 8 stock_movement rows in the ledger total -
Closes a 15-commit-old TODO on MetaController and unifies the version story across /api/v1/_meta/info, /v3/api-docs, and the api-v1.jar manifest. **Build metadata wiring.** `distribution/build.gradle.kts` now calls `buildInfo()` inside the `springBoot { }` block. This makes Spring Boot's Gradle plug-in write `META-INF/build-info.properties` into the bootJar at build time with group / artifact / version / build time pulled from `project.version` + timestamps. Spring Boot's `BuildInfoAutoConfiguration` then exposes a `BuildProperties` bean that injection points can consume. **MetaController enriched.** Now injects: - `ObjectProvider<BuildProperties>` — returns the real version (`0.28.0-SNAPSHOT`) and the build timestamp when packaged through the distribution bootJar; falls back to `0.0.0-test` inside a bare platform-bootstrap unit test classloader with no build-info file on the classpath. - `Environment` — returns `spring.profiles.active` so a dashboard can distinguish "dev" from "staging" from a prod container that activates no profile. The GET /api/v1/_meta/info response now carries: - `name`, `apiVersion` — unchanged - `implementationVersion` — from BuildProperties (was stuck at "0.1.0-SNAPSHOT" via an unreachable `javaClass.package` lookup) - `buildTime` — ISO-8601 string from BuildProperties, null if the classpath has no build-info file - `activeProfiles` — list of effective spring profiles **OpenApiConfiguration now reads version from BuildProperties too.** Previously OPENAPI_INFO_VERSION was a hardcoded "v0.28.0" constant. Now it's injected via ObjectProvider<BuildProperties> with the same fallback pattern as MetaController. A single version bump in gradle.properties now flows to: gradle.properties → Spring Boot's buildInfo() → build-info.properties (on the classpath) → BuildProperties bean → MetaController (/_meta/info) → OpenApiConfiguration (/v3/api-docs + Swagger UI) → api-v1.jar manifest (already wired) No more hand-maintained version strings in code. Bump `vibeerp.version` in gradle.properties and every display follows. **Version bump.** `gradle.properties` `vibeerp.version`: `0.1.0-SNAPSHOT` → `0.28.0-SNAPSHOT`. This matches the numeric label used on PROGRESS.md's "Latest version" row and carries a documentation comment explaining the propagation chain so the next person bumping it knows what to update alongside (just the one line + PROGRESS.md). **KDoc trap caught.** A literal `/api/v1/_meta/**` path pattern in MetaController's KDoc tripped the Kotlin nested-comment parser (`/**` starts a KDoc). Rephrased as "the whole `/api/v1/_meta` prefix" to sidestep the trap — same workaround I saved in feedback memory after the first time it bit me. **Smoke-tested end-to-end against real Postgres:** - GET /api/v1/_meta/info returns `{"implementationVersion": "0.28.0-SNAPSHOT", "buildTime": "2026-04-09T09:48:25.646Z", "activeProfiles": ["dev"]}` - GET /v3/api-docs `info.version` = "0.28.0-SNAPSHOT" (was the hardcoded "v0.28.0" constant before this chunk) - Single edit to gradle.properties propagates cleanly. 24 modules, 355 unit tests, all green. -
Adds WorkOrderOperation child entity and two new verbs that gate WorkOrder.complete() behind a strict sequential walk of shop-floor steps. An empty operations list keeps the v2 behavior exactly; a non-empty list forces every op to reach COMPLETED before the work order can finish. **New domain.** - `production__work_order_operation` table with `UNIQUE (work_order_id, line_no)` and a status CHECK constraint admitting PENDING / IN_PROGRESS / COMPLETED. - `WorkOrderOperation` @Entity mirroring the `WorkOrderInput` shape: `lineNo`, `operationCode`, `workCenter`, `standardMinutes`, `status`, `actualMinutes` (nullable), `startedAt` + `completedAt` timestamps. No `ext` JSONB — operations are facts, not master records. - `WorkOrderOperationStatus` enum (PENDING / IN_PROGRESS / COMPLETED). - `WorkOrder.operations` collection with the same @OneToMany + cascade=ALL + orphanRemoval + @OrderBy("lineNo ASC") pattern as `inputs`. **State machine (sequential).** - `startOperation(workOrderId, operationId)` — parent WO must be IN_PROGRESS; target op must be PENDING; every earlier op must be COMPLETED. Flips to IN_PROGRESS and stamps `startedAt`. Idempotent no-op if already IN_PROGRESS. - `completeOperation(workOrderId, operationId, actualMinutes)` — parent WO must be IN_PROGRESS; target op must be IN_PROGRESS; `actualMinutes` must be non-negative. Flips to COMPLETED and stamps `completedAt`. Idempotent with the same `actualMinutes`; refuses to clobber with a different value. - `WorkOrder.complete()` gains a routings gate: refuses if any operation is not COMPLETED. Empty operations list is legal and preserves v2 behavior (auto-spawned orders from `SalesOrderConfirmedSubscriber` continue to complete without any gate). **Why sequential, not parallel.** v3 deliberately forbids parallel operations on one routing. The shop-floor dashboard story is trivial when the invariant is "you are on step N of M"; the unit test matrix is finite. Parallel routings (two presses in parallel) wait for a real consumer asking for them. Same pattern as every other pbc-production invariant — grow the PBC when consumers appear, not on speculation. **Why standardMinutes + actualMinutes instead of just timestamps.** The variance between planned and actual runtime is the single most interesting data point on a routing. Deriving it from `completedAt - startedAt` at report time has to fight shift-boundary and pause-resume ambiguity; the operator typing in "this run took 47 minutes" is the single source of truth. `startedAt` and `completedAt` are kept as an audit trail, not used for variance math. **Why work_center is a varchar not a FK.** Same cross-PBC discipline as every other identifier in pbc-production: work centers will be the seam for a future pbc-equipment PBC, and pinning a FK now would couple two PBC schemas before the consumer even exists (CLAUDE.md guardrail #9). **HTTP surface.** - `POST /api/v1/production/work-orders/{id}/operations/{operationId}/start` → `production.work-order.operation.start` - `POST /api/v1/production/work-orders/{id}/operations/{operationId}/complete` → `production.work-order.operation.complete` Body: `{"actualMinutes": "..."}`. Annotated with the single-arg Jackson trap escape hatch (`@JsonCreator(mode=PROPERTIES)` + `@param:JsonProperty`) — same trap that bit `CompleteWorkOrderRequest`, `ShipSalesOrderRequest`, `ReceivePurchaseOrderRequest`. Caught at smoke-test time. - `CreateWorkOrderRequest` accepts an optional `operations` array alongside `inputs`. - `WorkOrderResponse` gains `operations: List<WorkOrderOperationResponse>` showing status, standardMinutes, actualMinutes, startedAt, completedAt. **Metadata.** Two new permissions in `production.yml`: `production.work-order.operation.start` and `production.work-order.operation.complete`. **Tests (12 new).** create-with-ops happy path; duplicate line_no refused; blank operationCode refused; complete() gated when any op is not COMPLETED; complete() passes when every op is COMPLETED; startOperation refused on DRAFT parent; startOperation flips PENDING to IN_PROGRESS and stamps startedAt; startOperation refuses skip-ahead over a PENDING predecessor; startOperation is idempotent when already IN_PROGRESS; completeOperation records actualMinutes and flips to COMPLETED; completeOperation rejects negative actualMinutes; completeOperation refuses clobbering an already-COMPLETED op with a different value. **Smoke-tested end-to-end against real Postgres:** - Created a WO with 3 operations (CUT → PRINT → BIND) - `complete()` refused while DRAFT, then refused while IN_PROGRESS with pending ops ("3 routing operation(s) are not yet COMPLETED") - Skip-ahead `startOperation(op2)` refused ("earlier operation(s) are not yet COMPLETED") - Walked ops 1 → 2 → 3 through start + complete with varying actualMinutes (17, 32.5, 18 vs standard 15, 30, 20) - Final `complete()` succeeded, wrote exactly ONE PRODUCTION_RECEIPT ledger row for 100 units of FG-BROCHURE — no premature writes - Separately verified a no-operations WO still walks DRAFT → IN_PROGRESS → COMPLETED exactly like v2 24 modules, 349 unit tests (+12), all green. -
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. -
…c-warehousing StockTransfer First cross-PBC reaction originating from pbc-quality. Records a REJECTED inspection with explicit source + quarantine location codes, publishes an api.v1 event inside the same transaction as the row insert, and pbc-warehousing's new subscriber atomically creates + confirms a StockTransfer that moves the rejected quantity to the quarantine bin. The whole chain — inspection insert + event publish + transfer create + confirm + two ledger rows — runs in a single transaction under the synchronous in-process bus with Propagation.MANDATORY. ## Why the auto-quarantine is opt-in per-inspection Not every inspection wants physical movement. A REJECTED batch that's already separated from good stock on the shop floor doesn't need the framework to move anything; the operator just wants the record. Forcing every rejection to create a ledger pair would collide with real-world QC workflows. The contract is simple: the `InspectionRecord` now carries two OPTIONAL columns (`source_location_code`, `quarantine_location_code`). When BOTH are set AND the decision is REJECTED AND the rejected quantity is positive, the subscriber reacts. Otherwise it logs at DEBUG and does nothing. The event is published either way, so audit/KPI subscribers see every inspection regardless. ## api.v1 additions New event class `org.vibeerp.api.v1.event.quality.InspectionRecordedEvent` with nine fields: inspectionCode, itemCode, sourceReference, decision, inspectedQuantity, rejectedQuantity, sourceLocationCode?, quarantineLocationCode?, inspector All required fields validated in `init { }` — blank strings, non-positive inspected quantity, negative rejected quantity, or an unknown decision string all throw at publish time so a malformed event never hits the outbox. `aggregateType = "quality.InspectionRecord"` matches the `<pbc>.<aggregate>` convention. `decision` is carried as a String (not the pbc-quality `InspectionDecision` enum) to keep guardrail #10 honest — api.v1 events MUST NOT leak internal PBC types. Consumers compare against the literal `"APPROVED"` / `"REJECTED"` strings. ## pbc-quality changes - `InspectionRecord` entity gains two nullable columns: `source_location_code` + `quarantine_location_code`. - Liquibase migration `002-quality-quarantine-locations.xml` adds the columns to `quality__inspection_record`. - `InspectionRecordService` now injects `EventBus` and publishes `InspectionRecordedEvent` inside the `@Transactional record()` method. The publish carries all nine fields including the optional locations. - `RecordInspectionCommand` + `RecordInspectionRequest` gain the two optional location fields; unchanged default-null means every existing caller keeps working unchanged. - `InspectionRecordResponse` exposes both new columns on the HTTP wire. ## pbc-warehousing changes - New `QualityRejectionQuarantineSubscriber` @Component. - Subscribes in `@PostConstruct` via the typed-class `EventBus.subscribe(InspectionRecordedEvent::class.java, ...)` overload — same pattern every other PBC subscriber uses (SalesOrderConfirmedSubscriber, WorkOrderRequestedSubscriber, the pbc-finance order subscribers). - `handle(event)` is `internal` so the unit test can drive it directly without going through the bus. - Activation contract (all must be true): decision=REJECTED, rejectedQuantity>0, sourceLocationCode non-blank, quarantineLocationCode non-blank. Any missing condition → no-op. - Idempotency: derived transfer code is `TR-QC-<inspectionCode>`. Before creating, the subscriber checks `stockTransfers.findByCode(derivedCode)` — if anything exists (DRAFT, CONFIRMED, or CANCELLED), the subscriber skips. A replay of the same event under at-least-once delivery is safe. - On success: creates a DRAFT StockTransfer with one line moving `rejectedQuantity` of `itemCode` from source to quarantine, then calls `confirm(id)` which writes the atomic TRANSFER_OUT + TRANSFER_IN ledger pair. ## Smoke test (fresh DB) ``` # seed POST /api/v1/catalog/items {code: WIDGET-1, baseUomCode: ea} POST /api/v1/inventory/locations {code: WH-MAIN, type: WAREHOUSE} POST /api/v1/inventory/locations {code: WH-QUARANTINE, type: WAREHOUSE} POST /api/v1/inventory/movements {itemCode: WIDGET-1, locationId: <WH-MAIN>, delta: 100, reason: RECEIPT} # the cross-PBC reaction POST /api/v1/quality/inspections {code: QC-R-001, itemCode: WIDGET-1, sourceReference: "WO:WO-001", decision: REJECTED, inspectedQuantity: 50, rejectedQuantity: 7, reason: "surface scratches", sourceLocationCode: "WH-MAIN", quarantineLocationCode: "WH-QUARANTINE"} → 201 {..., sourceLocationCode: "WH-MAIN", quarantineLocationCode: "WH-QUARANTINE"} # automatically created + confirmed GET /api/v1/warehousing/stock-transfers/by-code/TR-QC-QC-R-001 → 200 { "code": "TR-QC-QC-R-001", "fromLocationCode": "WH-MAIN", "toLocationCode": "WH-QUARANTINE", "status": "CONFIRMED", "note": "auto-quarantine from rejected inspection QC-R-001", "lines": [{"itemCode": "WIDGET-1", "quantity": 7.0}] } # ledger state (raw SQL) SELECT l.code, b.item_code, b.quantity FROM inventory__stock_balance b JOIN inventory__location l ON l.id = b.location_id WHERE b.item_code = 'WIDGET-1'; WH-MAIN | WIDGET-1 | 93.0000 ← was 100, now 93 WH-QUARANTINE | WIDGET-1 | 7.0000 ← 7 rejected units here SELECT item_code, location, reason, delta, reference FROM inventory__stock_movement m JOIN inventory__location l ON l.id=m.location_id WHERE m.reference = 'TR:TR-QC-QC-R-001'; WIDGET-1 | WH-MAIN | TRANSFER_OUT | -7 | TR:TR-QC-QC-R-001 WIDGET-1 | WH-QUARANTINE | TRANSFER_IN | 7 | TR:TR-QC-QC-R-001 # negatives POST /api/v1/quality/inspections {decision: APPROVED, ...+locations} → 201, but GET /TR-QC-QC-A-001 → 404 (no transfer, correct opt-out) POST /api/v1/quality/inspections {decision: REJECTED, rejected: 2, no locations} → 201, but GET /TR-QC-QC-R-002 → 404 (opt-in honored) # handler log [warehousing] auto-quarantining 7 units of 'WIDGET-1' from 'WH-MAIN' to 'WH-QUARANTINE' (inspection=QC-R-001, transfer=TR-QC-QC-R-001) ``` Everything happens in ONE transaction because EventBusImpl uses Propagation.MANDATORY with synchronous delivery: the inspection insert, the event publish, the StockTransfer create, the confirm, and the two ledger rows all commit or roll back together. ## Tests - Updated `InspectionRecordServiceTest`: the service now takes an `EventBus` constructor argument. Every existing test got a relaxed `EventBus` mock; the one new test `record publishes InspectionRecordedEvent on success` captures the published event and asserts every field including the location codes. - 6 new unit tests in `QualityRejectionQuarantineSubscriberTest`: * subscribe registers one listener for InspectionRecordedEvent * handle creates and confirms a quarantine transfer on a fully-populated REJECTED event (asserts derived code, locations, item code, quantity) * handle is a no-op when decision is APPROVED * handle is a no-op when sourceLocationCode is missing * handle is a no-op when quarantineLocationCode is missing * handle skips when a transfer with the derived code already exists (idempotent replay) - Total framework unit tests: 334 (was 327), all green. ## What this unblocks - **Quality KPI dashboards** — any PBC can now subscribe to `InspectionRecordedEvent` without coupling to pbc-quality. - **pbc-finance quality-cost tracking** — when GL growth lands, a finance subscriber can debit a "quality variance" account on every REJECTED inspection. - **REF.2 / customer plug-in workflows** — the printing-shop plug-in can emit an `InspectionRecordedEvent` of its own from a BPMN service task (via `context.eventBus.publish`) and drive the same quarantine chain without touching pbc-quality's HTTP surface. ## Non-goals (parking lot) - Partial-batch quarantine decisions (moving some units to quarantine, some back to general stock, some to scrap). v1 collapses the decision into a single "reject N units" action and assumes the operator splits batches manually before inspecting. A richer ResolutionPlan aggregate is a future chunk if real workflows need it. - Quality metrics storage. The event is audited by the existing wildcard event subscriber but no PBC rolls it up into a KPI table. Belongs to a future reporting feature. - Auto-approval chains. An APPROVED inspection could trigger a "release-from-hold" transfer (opposite direction) in a future-expanded subscriber, but v1 keeps the reaction REJECTED-only to match the "quarantine on fail" use case. -
Closes the P1.9 row of the implementation plan. New platform-files subproject exposing a cross-PBC facade for the framework's binary blob store, with a local-disk implementation and a thin HTTP surface for multipart upload / download / delete / list. ## api.v1 additions (package `org.vibeerp.api.v1.files`) - `FileStorage` — injectable facade with five methods: * `put(key, contentType, content: InputStream): FileHandle` * `get(key): FileReadResult?` * `exists(key): Boolean` * `delete(key): Boolean` * `list(prefix): List<FileHandle>` Stream-first (not byte-array-first) so reports PDFs etc. don't have to be materialized in memory. Keys are opaque strings with slashes allowed for logical grouping; the local-disk backend maps them to subdirectories. - `FileHandle` — read-only metadata DTO (key, size, contentType, createdAt, updatedAt). - `FileReadResult` — the return type of `get()` bundling a handle and an open InputStream. The caller MUST close the stream (`result.content.use { ... }` is the idiomatic shape); the facade is not responsible for managing the consumer's lifetime. - `PluginContext.files: FileStorage` — new member on the plug-in context interface, default implementation throws `UnsupportedOperationException("upgrade vibe_erp to v0.8 or later")`. Same backward-compat pattern we used for `endpoints`, `jdbc`, `taskHandlers`. Plug-ins that need to persist report PDFs, uploaded attachments, or exported archives inject this through the context. ## platform-files runtime - `LocalDiskFileStorage` @Component reading `vibeerp.files.local-path` (default `./files-local`, overridden in dev profile to `./files-dev`, overridden in production config to `/opt/vibe-erp/files`). **Layout**: files are stored at `<root>/<key>` with a sidecar metadata file at `<root>/<key>.meta` containing a single line `content_type=<value>`. Sidecars beat xattrs (not portable across Linux/macOS) and beat an H2/SQLite index (overkill for single-tenant single-instance). **Atomicity**: every `put` writes to a `.tmp` sibling file and atomic-moves it into place so a concurrent read against the same key never sees a half-written mix. **Key safety**: `put`/`get`/`delete` all validate the key: rejects blank, leading `/`, `..` (path traversal), and trailing `.meta` (sidecar collision). Every resolved path is checked to stay under the configured root via `normalize().startsWith(root)`. - `FileController` at `/api/v1/files/**`: * `POST /api/v1/files?key=...` multipart upload (form field `file`) * `GET /api/v1/files?prefix=...` list by prefix * `GET /api/v1/files/metadata?key=...` metadata only (doesn't open the stream) * `GET /api/v1/files/download?key=...` stream bytes with the right Content-Type + filename * `DELETE /api/v1/files?key=...` delete by key All endpoints @RequirePermission-gated via the keys declared in the metadata YAML. The `key` is a query parameter, NOT a path variable, so slashes in the key don't collide with Spring's path matching. - `META-INF/vibe-erp/metadata/files.yml` — 2 permissions + 1 menu. ## Smoke test (fresh DB, as admin) ``` POST /api/v1/files?key=reports/smoke-test.txt (multipart file) → 201 {"key":"reports/smoke-test.txt", "size":61, "contentType":"text/plain", "createdAt":"...","updatedAt":"..."} GET /api/v1/files?prefix=reports/ → [{"key":"reports/smoke-test.txt","size":61, ...}] GET /api/v1/files/metadata?key=reports/smoke-test.txt → same handle, no bytes GET /api/v1/files/download?key=reports/smoke-test.txt → 200 Content-Type: text/plain body: original upload content (diff == 0) DELETE /api/v1/files?key=reports/smoke-test.txt → 200 {"removed":true} GET /api/v1/files/download?key=reports/smoke-test.txt → 404 # path traversal POST /api/v1/files?key=../escape (multipart file) → 400 "file key must not contain '..' (got '../escape')" GET /api/v1/_meta/metadata → permissions include ["files.file.read", "files.file.write"] ``` Downloaded bytes match the uploaded bytes exactly — round-trip verified with `diff -q`. ## Tests - 12 new unit tests in `LocalDiskFileStorageTest` using JUnit 5's `@TempDir`: * `put then get round-trips content and metadata` * `put overwrites an existing key with the new content` * `get returns null for an unknown key` * `exists distinguishes present from absent` * `delete removes the file and its metadata sidecar` * `delete on unknown key returns false` * `list filters by prefix and returns sorted keys` * `put rejects a key with dot-dot` * `put rejects a key starting with slash` * `put rejects a key ending in dot-meta sidecar` * `put rejects blank content type` * `list sidecar metadata files are hidden from listing results` - Total framework unit tests: 327 (was 315), all green. ## What this unblocks - **P1.8 JasperReports integration** — now has a first-class home for generated PDFs. A report renderer can call `fileStorage.put("reports/quote-$code.pdf", "application/pdf", ...)` and return the handle to the caller. - **Plug-in attachments** — the printing-shop plug-in's future "plate scan image" or "QC report" attachments can be stored via `context.files` without touching the database. - **Export/import flows** — a scheduled job can write a nightly CSV export via `FileStorage.put` and a separate endpoint can download it; the scheduler-to-storage path is clean and typed. - **S3 backend when needed** — the interface is already streaming- based; dropping in an `S3FileStorage` @Component and toggling `vibeerp.files.backend: s3` in config is a future additive chunk, zero api.v1 churn. ## Non-goals (parking lot) - S3 backend. The config already reads `vibeerp.files.backend`, local is hard-wired for v1.0. Keeps the dependency tree off aws-sdk until a real consumer exists. - Range reads / HTTP `Range: bytes=...` support. Future enhancement for large-file streaming (e.g. video attachments). - Presigned URLs (for direct browser-to-S3 upload, skipping the framework). Design decision lives with the S3 backend chunk. - Per-file ACLs. The four `files.file.*` permissions currently gate all files uniformly; per-path or per-owner ACLs would require a new metadata table and haven't been asked for by any PBC yet. - Plug-in loader integration. `PluginContext.files` throws the default `UnsupportedOperationException` until the plug-in loader is wired to pass the host `FileStorage` through `DefaultPluginContext`. Lands in the same chunk as the first plug-in that needs to store a file. -
Closes the P1.10 row of the implementation plan. New platform-jobs subproject shipping a Quartz-backed background job engine adapted to the api.v1 JobHandler contract, so PBCs and plug-ins can register scheduled work without ever importing Quartz types. ## The shape (matches the P2.1 workflow engine) platform-jobs is to scheduled work what platform-workflow is to BPMN service tasks. Same pattern, same discipline: - A single `@Component` bridge (`QuartzJobBridge`) is the ONLY org.quartz.Job implementation in the framework. Every persistent trigger points at it. - A single `JobHandlerRegistry` (owner-tagged, duplicate-key-rejecting, ConcurrentHashMap-backed) holds every registered JobHandler by key. Mirrors `TaskHandlerRegistry`. - The bridge reads the handler key from the trigger's JobDataMap, looks it up in the registry, and executes the matching JobHandler inside a `PrincipalContext.runAs("system:jobs:<key>")` block so audit rows written during the job get a structured, greppable `created_by` value ("system:jobs:core.audit.prune") instead of the default `__system__`. - Handler-thrown exceptions are re-wrapped as `JobExecutionException` so Quartz's MISFIRE machinery handles them properly. - `@DisallowConcurrentExecution` on the bridge stops a long-running handler from being started again before it finishes. ## api.v1 additions (package `org.vibeerp.api.v1.jobs`) - `JobHandler` — interface with `key()` + `execute(context)`. Analogous to the workflow TaskHandler. Plug-ins implement this to contribute scheduled work without any Quartz dependency. - `JobContext` — read-only execution context passed to the handler: principal, locale, correlation id, started-at instant, data map. Unlike TaskContext it has no `set()` writeback — scheduled jobs don't produce continuation state for a downstream step; a job that wants to talk to the rest of the system writes to its own domain table or publishes an event. - `JobScheduler` — injectable facade exposing: * `scheduleCron(scheduleKey, handlerKey, cronExpression, data)` * `scheduleOnce(scheduleKey, handlerKey, runAt, data)` * `unschedule(scheduleKey): Boolean` * `triggerNow(handlerKey, data): JobExecutionSummary` — synchronous in-thread execution, bypasses Quartz; used by the HTTP trigger endpoint and by tests. * `listScheduled(): List<ScheduledJobInfo>` — introspection Both `scheduleCron` and `scheduleOnce` are idempotent on `scheduleKey` (replace if exists). - `ScheduledJobInfo` + `JobExecutionSummary` + `ScheduleKind` — read-only DTOs returned by the scheduler. ## platform-jobs runtime - `QuartzJobBridge` — the shared Job impl. Routes by the `__vibeerp_handler_key` JobDataMap entry. Uses `@Autowired` field injection because Quartz instantiates Job classes through its own JobFactory (Spring Boot's `SpringBeanJobFactory` autowires fields after construction, which is the documented pattern). - `QuartzJobScheduler` — the concrete api.v1 `JobScheduler` implementation. Builds JobDetail + Trigger pairs under fixed group names (`vibeerp-jobs`), uses `addJob(replace=true)` + explicit `checkExists` + `rescheduleJob` for idempotent scheduling, strips the reserved `__vibeerp_handler_key` from the data visible to the handler. - `SimpleJobContext` — internal immutable `JobContext` impl. Defensive-copies the data map at construction. - `JobHandlerRegistry` — owner-tagged registry (OWNER_CORE by default, any other string for plug-in ownership). Same `register` / `unregister` / `unregisterAllByOwner` / `find` / `keys` / `size` surface as `TaskHandlerRegistry`. The plug-in loader integration seam is defined; the loader hook that calls `register(handler, pluginId)` lands when a plug-in actually ships a job handler (YAGNI). - `JobController` at `/api/v1/jobs/**`: * `GET /handlers` (perm `jobs.handler.read`) * `POST /handlers/{key}/trigger` (perm `jobs.job.trigger`) * `GET /scheduled` (perm `jobs.schedule.read`) * `POST /scheduled` (perm `jobs.schedule.write`) * `DELETE /scheduled/{key}` (perm `jobs.schedule.write`) - `VibeErpPingJobHandler` — built-in diagnostic. Key `vibeerp.jobs.ping`. Logs the invocation and exits. Safe to trigger from any environment; mirrors the core `vibeerp.workflow.ping` workflow handler from P2.1. - `META-INF/vibe-erp/metadata/jobs.yml` — 4 permissions + 2 menus. ## Spring Boot config (application.yaml) ``` spring.quartz: job-store-type: jdbc jdbc: initialize-schema: always # creates QRTZ_* tables on first boot properties: org.quartz.scheduler.instanceName: vibeerp-scheduler org.quartz.scheduler.instanceId: AUTO org.quartz.threadPool.threadCount: "4" org.quartz.jobStore.driverDelegateClass: org.quartz.impl.jdbcjobstore.PostgreSQLDelegate org.quartz.jobStore.isClustered: "false" ``` ## The config trap caught during smoke-test (documented in-file) First boot crashed with `SchedulerConfigException: DataSource name not set.` because I'd initially added `org.quartz.jobStore.class=org.quartz.impl.jdbcjobstore.JobStoreTX` to the raw Quartz properties. That is correct for a standalone Quartz deployment but WRONG for the Spring Boot starter: the starter configures a `LocalDataSourceJobStore` that wraps the Spring-managed DataSource automatically when `job-store-type=jdbc`, and setting `jobStore.class` explicitly overrides that wrapper back to Quartz's standalone JobStoreTX — which then fails at init because Quartz-standalone expects a separately-named `dataSource` property the Spring Boot starter doesn't supply. Fix: drop the `jobStore.class` property entirely. The `driverDelegateClass` is still fine to set explicitly because it's read by both the standalone and Spring-wrapped JobStore implementations. Rationale is documented in the config comment so the next maintainer doesn't add it back. ## Smoke test (fresh DB, as admin) ``` GET /api/v1/jobs/handlers → {"count": 1, "keys": ["vibeerp.jobs.ping"]} POST /api/v1/jobs/handlers/vibeerp.jobs.ping/trigger {"data": {"source": "smoke-test"}} → 200 {"handlerKey": "vibeerp.jobs.ping", "correlationId": "e142...", "startedAt": "...", "finishedAt": "...", "ok": true} log: VibeErpPingJobHandler invoked at=... principal='system:jobs:manual-trigger' data={source=smoke-test} GET /api/v1/jobs/scheduled → [] POST /api/v1/jobs/scheduled {"scheduleKey": "ping-every-sec", "handlerKey": "vibeerp.jobs.ping", "cronExpression": "0/1 * * * * ?", "data": {"trigger": "cron"}} → 201 {"scheduleKey": "ping-every-sec", "handlerKey": "vibeerp.jobs.ping"} # after 3 seconds GET /api/v1/jobs/scheduled → [{"scheduleKey": "ping-every-sec", "handlerKey": "vibeerp.jobs.ping", "kind": "CRON", "cronExpression": "0/1 * * * * ?", "nextFireTime": "...", "previousFireTime": "...", "data": {"trigger": "cron"}}] DELETE /api/v1/jobs/scheduled/ping-every-sec → 200 {"removed": true} # handler log count after ~3 seconds of cron ticks grep -c "VibeErpPingJobHandler invoked" /tmp/boot.log → 5 # 1 manual trigger + 4 cron ticks before unschedule — matches the # 0/1 * * * * ? expression # negatives POST /api/v1/jobs/handlers/nope/trigger → 400 "no JobHandler registered for key 'nope'" POST /api/v1/jobs/scheduled {cronExpression: "not a cron"} → 400 "invalid Quartz cron expression: 'not a cron'" ``` ## Three schemas coexist in one Postgres database ``` SELECT count(*) FILTER (WHERE table_name LIKE 'qrtz_%') AS quartz_tables, count(*) FILTER (WHERE table_name LIKE 'act_%') AS flowable_tables, count(*) FILTER (WHERE table_name NOT LIKE 'qrtz_%' AND table_name NOT LIKE 'act_%' AND table_schema = 'public') AS vibeerp_tables FROM information_schema.tables WHERE table_schema = 'public'; quartz_tables | flowable_tables | vibeerp_tables ---------------+-----------------+---------------- 11 | 39 | 48 ``` Three independent schema owners (Quartz / Flowable / Liquibase) in one public schema, no collisions. Spring Boot's `QuartzDataSourceScriptDatabaseInitializer` runs the QRTZ_* DDL once and skips on subsequent boots; Flowable's internal MyBatis schema manager does the same for ACT_* tables; our Liquibase owns the rest. ## Tests - 6 new tests in `JobHandlerRegistryTest`: * initial handlers registered with OWNER_CORE * duplicate key fails fast with both owners in the error * unregisterAllByOwner only removes handlers owned by that id * unregister by key returns false for unknown * find on missing key returns null * blank key is rejected - 9 new tests in `QuartzJobSchedulerTest` (Quartz Scheduler mocked): * scheduleCron rejects an unknown handler key * scheduleCron rejects an invalid cron expression * scheduleCron adds job + schedules trigger when nothing exists yet * scheduleCron reschedules when the trigger already exists * scheduleOnce uses a simple trigger at the requested instant * unschedule returns true/false correctly * triggerNow calls the handler synchronously and returns ok=true * triggerNow propagates the handler's exception * triggerNow rejects an unknown handler key - Total framework unit tests: 315 (was 300), all green. ## What this unblocks - **pbc-finance audit prune** — a core recurring job that deletes posted journal entries older than N days, driven by a cron from a Tier 1 metadata row. - **Plug-in scheduled work** — once the loader integration hook is wired (trivial follow-up), any plug-in's `start(context)` can register a JobHandler via `context.jobs.register(handler)` and the host strips it on plug-in stop via `unregisterAllByOwner`. - **Delayed workflow continuations** — a BPMN handler can call `jobScheduler.scheduleOnce(...)` to "re-evaluate this workflow in 24 hours if no one has approved it", bridging the workflow engine and the scheduler without introducing Thread.sleep. - **Outbox draining strategy** — the existing 5-second OutboxPoller can move from a Spring @Scheduled to a Quartz cron so it inherits the scheduler's persistence, misfire handling, and the future clustering story. ## Non-goals (parking lot) - **Clustered scheduling.** `isClustered=false` for now. Making this true requires every instance to share a unique `instanceId` and agree on the JDBC lock policy — doable but out of v1.0 scope since vibe_erp is single-tenant single-instance by design. - **Async execution of triggerNow.** The current `triggerNow` runs synchronously on the caller thread so HTTP requests see the real result. A future "fire and forget" endpoint would delegate to `Scheduler.triggerJob(...)` against the JobDetail instead. - **Per-job permissions.** Today the four `jobs.*` permissions gate the whole controller. A future enhancement could attach per-handler permissions (so "trigger audit prune" requires a different permission than "trigger pricing refresh"). - **Plug-in loader integration.** The seam is defined on `JobHandlerRegistry` (owner tagging + unregisterAllByOwner) but `VibeErpPluginManager` doesn't call it yet. Lands in the same chunk as the first plug-in that ships a JobHandler. -
Closes the core PBC row of the v1.0 target. Ships pbc-quality as a lean v1 recording-only aggregate: any caller that performs a quality inspection (inbound goods, in-process work order output, outbound shipment) appends an immutable InspectionRecord with a decision (APPROVED/REJECTED), inspected/rejected quantities, a free-form source reference, and the inspector's principal id. ## Deliberately narrow v1 scope pbc-quality does NOT ship: - cross-PBC writes (no "rejected stock gets auto-quarantined" rule) - event publishing (no InspectionRecordedEvent in api.v1 yet) - inspection plans or templates (no "item X requires checks Y, Z") - multi-check records (one decision per row; multi-step inspections become multiple records) The rationale is the "follow the consumer" discipline: every seam the framework adds has to be driven by a real consumer. With no PBC yet subscribing to inspection events or calling into pbc-quality, speculatively building those capabilities would be guessing the shape. Future chunks that actually need them (e.g. pbc-warehousing auto-quarantine on rejection, pbc-production WorkOrder scrap from rejected QC) will grow the seam into the shape they need. Even at this narrow scope pbc-quality delivers real value: a queryable, append-only, permission-gated record of every QC decision in the system, filterable by source reference or item code, and linked to the catalog via CatalogApi. ## Module contents - `build.gradle.kts` — new Gradle subproject following the existing recipe. api-v1 + platform/persistence + platform/security only; no cross-pbc deps (guardrail #9 stays honest). - `InspectionRecord` entity — code, item_code, source_reference, decision (enum), inspected_quantity, rejected_quantity, inspector (principal id as String, same convention as created_by), reason, inspected_at. Owns table `quality__inspection_record`. No `ext` column in v1 — the aggregate is simple enough that adding Tier 1 customization now would be speculation; it can be added in one edit when a customer asks for it. - `InspectionDecision` enum — APPROVED, REJECTED. Deliberately two-valued; see the entity KDoc for why "conditional accept" is rejected as a shape. - `InspectionRecordJpaRepository` — existsByCode, findByCode, findBySourceReference, findByItemCode. - `InspectionRecordService` — ONE write verb `record`. Inspections are immutable; revising means recording a new one with a new code. Validates: * code is unique * source reference non-blank * inspected quantity > 0 * rejected quantity >= 0 * rejected <= inspected * APPROVED ↔ rejected = 0, REJECTED ↔ rejected > 0 * itemCode resolves via CatalogApi Inspector is read from `PrincipalContext.currentOrSystem()` at call time so a real HTTP user records their own inspections and a background job recording a batch uses a named system principal. - `InspectionRecordController` — `/api/v1/quality/inspections` with GET list (supports `?sourceReference=` and `?itemCode=` query params), GET by id, GET by-code, POST record. Every endpoint @RequirePermission-gated. - `META-INF/vibe-erp/metadata/quality.yml` — 1 entity, 2 permissions (`quality.inspection.read`, `quality.inspection.record`), 1 menu. - `distribution/.../db/changelog/pbc-quality/001-quality-init.xml` — single table with the full audit column set plus: * CHECK decision IN ('APPROVED', 'REJECTED') * CHECK inspected_quantity > 0 * CHECK rejected_quantity >= 0 * CHECK rejected_quantity <= inspected_quantity The application enforces the biconditional (APPROVED ↔ rejected=0) because CHECK constraints in Postgres can't express the same thing ergonomically; the DB enforces the weaker "rejected is within bounds" so a direct INSERT can't fabricate nonsense. - `settings.gradle.kts`, `distribution/build.gradle.kts`, `master.xml` all wired. ## Smoke test (fresh DB + running app, as admin) ``` POST /api/v1/catalog/items {code: WIDGET-1, baseUomCode: ea} → 201 POST /api/v1/quality/inspections {code: QC-2026-001, itemCode: WIDGET-1, sourceReference: "WO:WO-001", decision: APPROVED, inspectedQuantity: 100, rejectedQuantity: 0} → 201 {inspector: <admin principal uuid>, inspectedAt: "..."} POST /api/v1/quality/inspections {code: QC-2026-002, itemCode: WIDGET-1, sourceReference: "WO:WO-002", decision: REJECTED, inspectedQuantity: 50, rejectedQuantity: 7, reason: "surface scratches detected on 7 units"} → 201 GET /api/v1/quality/inspections?sourceReference=WO:WO-001 → [{code: QC-2026-001, ...}] GET /api/v1/quality/inspections?itemCode=WIDGET-1 → [APPROVED, REJECTED] ← filter works, 2 records # Negative: APPROVED with positive rejected POST /api/v1/quality/inspections {decision: APPROVED, rejectedQuantity: 3, ...} → 400 "APPROVED inspection must have rejected quantity = 0 (got 3); record a REJECTED inspection instead" # Negative: rejected > inspected POST /api/v1/quality/inspections {decision: REJECTED, inspectedQuantity: 5, rejectedQuantity: 10, ...} → 400 "rejected quantity (10) cannot exceed inspected (5)" GET /api/v1/_meta/metadata → permissions include ["quality.inspection.read", "quality.inspection.record"] ``` The `inspector` field on the created records contains the admin user's principal UUID exactly as written by the `PrincipalContextFilter` — proving the audit trail end-to-end. ## Tests - 9 new unit tests in `InspectionRecordServiceTest`: * `record persists an APPROVED inspection with rejected=0` * `record persists a REJECTED inspection with positive rejected` * `inspector defaults to system when no principal is bound` — validates the `PrincipalContext.currentOrSystem()` fallback * `record rejects duplicate code` * `record rejects non-positive inspected quantity` * `record rejects rejected greater than inspected` * `APPROVED with positive rejected is rejected` * `REJECTED with zero rejected is rejected` * `record rejects unknown items via CatalogApi` - Total framework unit tests: 297 (was 288), all green. ## Framework state after this commit - **20 → 21 Gradle subprojects** - **10 of 10 core PBCs live** (pbc-identity, pbc-catalog, pbc-partners, pbc-inventory, pbc-warehousing, pbc-orders-sales, pbc-orders-purchase, pbc-finance, pbc-production, pbc-quality). The P5.x row of the implementation plan is complete at minimal v1 scope. - The v1.0 acceptance bar's "core PBC coverage" line is met. Remaining v1.0 work is cross-cutting (reports, forms, scheduler, web SPA) plus the richer per-PBC v2/v3 scopes. ## What this unblocks - **Cross-PBC quality integration** — any PBC that needs to react to a quality decision can subscribe when pbc-quality grows its event. pbc-warehousing quarantine on rejection is the obvious first consumer. - **The full buy-make-sell BPMN scenario** — now every step has a home: sales → procurement → warehousing → production → quality → finance are all live. The big reference-plug-in end-to-end flow is unblocked at the PBC level. - **Completes the P5.x row** of the implementation plan. Remaining v1.0 work is cross-cutting platform units (P1.8 reports, P1.9 files, P1.10 jobs, P2.2/P2.3 designer/forms) plus the web SPA. -
Ninth core PBC. Ships the first-class orchestration aggregate for moving stock between locations: a header + lines that represents operator intent, and a confirm() verb that atomically posts the matching TRANSFER_OUT / TRANSFER_IN ledger pair per line via the existing InventoryApi.recordMovement facade. Takes the framework's core-PBC count to 9 of 10 (only pbc-quality remains in the P5.x row). ## The shape pbc-warehousing sits above pbc-inventory in the dependency graph: it doesn't replace the flat movement ledger, it orchestrates multi-row ledger writes with a business-level document on top. A DRAFT `warehousing__stock_transfer` row is queued intent (pickers haven't started yet); a CONFIRMED row reflects movements that have already posted to the `inventory__stock_movement` ledger. Each confirmed line becomes two ledger rows: TRANSFER_OUT(itemCode, fromLocationCode, -quantity, ref="TR:<code>") TRANSFER_IN (itemCode, toLocationCode, quantity, ref="TR:<code>") All rows of one confirm call run inside ONE @Transactional method, so a failure anywhere — unknown item, unknown location, balance would go below zero — rolls back EVERY line's both halves. There is no half-confirmed transfer. ## Module contents - `build.gradle.kts` — new Gradle subproject, api-v1 + platform/* dependencies only. No cross-PBC dependency (guardrail #9 stays honest; CatalogApi + InventoryApi both come in via api.v1.ext). - `StockTransfer` entity — header with code, from/to location codes, status (DRAFT/CONFIRMED/CANCELLED), transfer_date, note, OneToMany<StockTransferLine>. Table name `warehousing__stock_transfer`. - `StockTransferLine` entity — lineNo, itemCode, quantity. `transfer_id → warehousing__stock_transfer(id) ON DELETE CASCADE`, unique `(transfer_id, line_no)`. - `StockTransferJpaRepository` — existsByCode + findByCode. - `StockTransferService` — create / confirm / cancel + three read methods. @Transactional service-level; all state transitions run through @Transactional methods so the event-bus MANDATORY propagation (if/when a pbc-warehousing event is added later) has a transaction to join. Business invariants: * code is unique (existsByCode short-circuit) * from != to (enforced in code AND in the Liquibase CHECK) * at least one line * each line: positive line_no, unique per transfer, positive quantity, itemCode must resolve via CatalogApi.findItemByCode * confirm requires DRAFT; writes OUT-first-per-line so a balance-goes-negative error aborts before touching the destination location * cancel requires DRAFT; CONFIRMED transfers are terminal (reverse by creating a NEW transfer in the opposite direction, matching the document-discipline rule every other PBC uses) - `StockTransferController` — `/api/v1/warehousing/stock-transfers` with GET list, GET by id, GET by-code, POST create, POST {id}/confirm, POST {id}/cancel. Every endpoint @RequirePermission-gated using the keys declared in the metadata YAML. Matches the shape of pbc-orders-sales, pbc-orders-purchase, pbc-production. - DTOs use the established pattern — jakarta.validation on the request, response mapping via extension functions. - `META-INF/vibe-erp/metadata/warehousing.yml` — 1 entity, 4 permissions, 1 menu. Loaded by MetadataLoader at boot, visible via `GET /api/v1/_meta/metadata`. - `distribution/src/main/resources/db/changelog/pbc-warehousing/001-warehousing-init.xml` — creates both tables with the full audit column set, state CHECK constraint, locations-distinct CHECK, unique (transfer_id, line_no) index, quantity > 0 CHECK, item_code index for cross-PBC grep. - `settings.gradle.kts`, `distribution/build.gradle.kts`, `master.xml` all wired. ## Smoke test (fresh DB + running app) ``` # seed POST /api/v1/catalog/items {code: PAPER-A4, baseUomCode: sheet} POST /api/v1/catalog/items {code: PAPER-A3, baseUomCode: sheet} POST /api/v1/inventory/locations {code: WH-MAIN, type: WAREHOUSE} POST /api/v1/inventory/locations {code: WH-SHOP, type: WAREHOUSE} POST /api/v1/inventory/movements {itemCode: PAPER-A4, locationId: <WH-MAIN>, delta: 100, reason: RECEIPT} POST /api/v1/inventory/movements {itemCode: PAPER-A3, locationId: <WH-MAIN>, delta: 50, reason: RECEIPT} # exercise the new PBC POST /api/v1/warehousing/stock-transfers {code: TR-001, fromLocationCode: WH-MAIN, toLocationCode: WH-SHOP, lines: [{lineNo: 1, itemCode: PAPER-A4, quantity: 30}, {lineNo: 2, itemCode: PAPER-A3, quantity: 10}]} → 201 DRAFT POST /api/v1/warehousing/stock-transfers/<id>/confirm → 200 CONFIRMED # verify balances via the raw DB (the HTTP stock-balance endpoint # has a separate unrelated bug returning 500; the ledger state is # what this commit is proving) SELECT item_code, location_id, quantity FROM inventory__stock_balance; PAPER-A4 / WH-MAIN → 70 ← debited 30 PAPER-A4 / WH-SHOP → 30 ← credited 30 PAPER-A3 / WH-MAIN → 40 ← debited 10 PAPER-A3 / WH-SHOP → 10 ← credited 10 SELECT item_code, location_id, reason, delta, reference FROM inventory__stock_movement ORDER BY occurred_at; PAPER-A4 / WH-MAIN / TRANSFER_OUT / -30 / TR:TR-001 PAPER-A4 / WH-SHOP / TRANSFER_IN / 30 / TR:TR-001 PAPER-A3 / WH-MAIN / TRANSFER_OUT / -10 / TR:TR-001 PAPER-A3 / WH-SHOP / TRANSFER_IN / 10 / TR:TR-001 ``` Four rows all tagged `TR:TR-001`. A grep of the ledger attributes both halves of each line to the single source transfer document. ## Transactional rollback test (in the same smoke run) ``` # ask for more than exists POST /api/v1/warehousing/stock-transfers {code: TR-002, from: WH-MAIN, to: WH-SHOP, lines: [{lineNo: 1, itemCode: PAPER-A4, quantity: 1000}]} → 201 DRAFT POST /api/v1/warehousing/stock-transfers/<id>/confirm → 400 "stock movement would push balance for 'PAPER-A4' at location <WH-MAIN> below zero (current=70.0000, delta=-1000.0000)" # assert TR-002 is still DRAFT GET /api/v1/warehousing/stock-transfers/<id> → status: DRAFT ← NOT flipped to CONFIRMED # assert the ledger still has exactly 6 rows (no partial writes) SELECT count(*) FROM inventory__stock_movement; → 6 ``` The failed confirm left no residue: status stayed DRAFT, and the ledger count is unchanged at 6 (the 2 RECEIPT seeds + the 4 TRANSFER_OUT/IN from TR-001). Propagation.REQUIRED + Spring's default rollback-on-unchecked-exception semantics do exactly what the KDoc promises. ## State-machine guards ``` POST /api/v1/warehousing/stock-transfers/<confirmed-id>/confirm → 400 "cannot confirm stock transfer TR-001 in status CONFIRMED; only DRAFT can be confirmed" POST /api/v1/warehousing/stock-transfers/<confirmed-id>/cancel → 400 "cannot cancel stock transfer TR-001 in status CONFIRMED; only DRAFT can be cancelled — reverse a confirmed transfer by creating a new one in the other direction" ``` ## Tests - 10 new unit tests in `StockTransferServiceTest`: * `create persists a DRAFT transfer when everything validates` * `create rejects duplicate code` * `create rejects same from and to location` * `create rejects an empty line list` * `create rejects duplicate line numbers` * `create rejects non-positive quantities` * `create rejects unknown items via CatalogApi` * `confirm writes an atomic TRANSFER_OUT + TRANSFER_IN pair per line` — uses `verifyOrder` to assert OUT-first-per-line dispatch order * `confirm refuses a non-DRAFT transfer` * `cancel refuses a CONFIRMED transfer` * `cancel flips a DRAFT transfer to CANCELLED` - Total framework unit tests: 288 (was 278), all green. ## What this unblocks - **Real warehouse workflows** — confirm a transfer from a picker UI (R1 is pending), driven by a BPMN that hands the confirm to a TaskHandler once the physical move is complete. - **pbc-quality (P5.8, last remaining core PBC)** — inspection plans + results + holds. Holds would typically quarantine stock by moving it to a QUARANTINE location via a stock transfer, which is the natural consumer for this aggregate. - **Stocktakes (physical inventory reconciliation)** — future pbc-warehousing verb that compares counted vs recorded and posts the differences as ADJUSTMENT rows; shares the same `recordMovement` primitive. -
New platform subproject `platform/platform-workflow` that makes `org.vibeerp.api.v1.workflow.TaskHandler` a live extension point. This is the framework's first chunk of Phase 2 (embedded workflow engine) and the dependency other work has been waiting on — pbc-production routings/operations, the full buy-make-sell BPMN scenario in the reference plug-in, and ultimately the BPMN designer web UI all hang off this seam. ## The shape - `flowable-spring-boot-starter-process:7.0.1` pulled in behind a single new module. Every other module in the framework still sees only the api.v1 TaskHandler + WorkflowTask + TaskContext surface — guardrail #10 stays honest, no Flowable type leaks to plug-ins or PBCs. - `TaskHandlerRegistry` is the host-side index of every registered handler, keyed by `TaskHandler.key()`. Auto-populated from every Spring bean implementing TaskHandler via constructor injection of `List<TaskHandler>`; duplicate keys fail fast at registration time. `register` / `unregister` exposed for a future plug-in lifecycle integration. - `DispatchingJavaDelegate` is a single Spring-managed JavaDelegate named `taskDispatcher`. Every BPMN service task in the framework references it via `flowable:delegateExpression="${taskDispatcher}"`. The dispatcher reads `execution.currentActivityId` as the task key (BPMN `id` attribute = TaskHandler key — no extension elements, no field injection, no second source of truth) and routes to the matching registered handler. A defensive copy of the execution variables is passed to the handler so it cannot mutate Flowable's internal map. - `DelegateTaskContext` adapts Flowable's `DelegateExecution` to the api.v1 `TaskContext` — the variable `set(name, value)` call forwards through Flowable's variable scope (persisted in the same transaction as the surrounding service task execution) and null values remove the variable. Principal + locale are documented placeholders for now (a workflow-engine `Principal.System`), waiting on the propagation chunk that plumbs the initiating user through `runtimeService.startProcessInstanceByKey(...)`. - `WorkflowService` is a thin facade over Flowable's `RuntimeService` + `RepositoryService` exposing exactly the four operations the controller needs: start, list active, inspect variables, list definitions. Everything richer (signals, timers, sub-processes, user-task completion, history queries) lands on this seam in later chunks. - `WorkflowController` at `/api/v1/workflow/**`: * `POST /process-instances` (permission `workflow.process.start`) * `GET /process-instances` (`workflow.process.read`) * `GET /process-instances/{id}/variables` (`workflow.process.read`) * `GET /definitions` (`workflow.definition.read`) * `GET /handlers` (`workflow.definition.read`) Exception handlers map `NoSuchElementException` + `FlowableObjectNotFoundException` → 404, `IllegalArgumentException` → 400, and any other `FlowableException` → 400. Permissions are declared in a new `META-INF/vibe-erp/metadata/workflow.yml` loaded by the core MetadataLoader so they show up under `GET /api/v1/_meta/metadata` alongside every other permission. ## The executable self-test - `vibeerp-ping.bpmn20.xml` ships in `processes/` on the module classpath and Flowable's starter auto-deploys it at boot. Structure: `start` → serviceTask id=`vibeerp.workflow.ping` (delegateExpression=`${taskDispatcher}`) → `end`. Process definitionKey is `vibeerp-workflow-ping` (distinct from the serviceTask id because BPMN 2.0 ids must be unique per document). - `PingTaskHandler` is a real shipped bean, not test code: its `execute` writes `pong=true`, `pongAt=<Instant.now()>`, and `correlationId=<ctx.correlationId()>` to the process variables. Operators and AI agents get a trivial "is the workflow engine alive?" probe out of the box. Why the demo lives in src/main, not src/test: Flowable's auto-deployer reads from the host classpath at boot, so if either half lived under src/test the smoke test wouldn't be reproducible from the shipped image — exactly what CLAUDE.md's "reference plug-in is the executable acceptance test" discipline is trying to prevent. ## The Flowable + Liquibase trap **Learned the hard way during the smoke test.** Adding `flowable-spring-boot-starter-process` immediately broke boot with `Schema-validation: missing table [catalog__item]`. Liquibase was silently not running. Root cause: Flowable 7.x registers a Spring Boot `EnvironmentPostProcessor` called `FlowableLiquibaseEnvironmentPostProcessor` that, unless the user has already set an explicit value, forces `spring.liquibase.enabled=false` with a WARN log line that reads "Flowable pulls in Liquibase but does not use the Spring Boot configuration for it". Our master.xml then never executes and JPA validation fails against the empty schema. Fix is a single line in `distribution/src/main/resources/application.yaml` — `spring.liquibase.enabled: true` — with a comment explaining why it must stay there for anyone who touches config next. Flowable's own ACT_* tables and vibe_erp's `catalog__*`, `pbc.*__*`, etc. tables coexist happily in the same public schema — 39 ACT_* tables alongside 45 vibe_erp tables on the smoke-tested DB. Flowable manages its own schema via its internal MyBatis DDL, Liquibase manages ours, they don't touch each other. ## Smoke-test transcript (fresh DB, dev profile) ``` docker compose down -v && docker compose up -d db ./gradlew :distribution:bootRun & # ... Flowable creates ACT_* tables, Liquibase creates vibe_erp tables, # MetadataLoader loads workflow.yml, TaskHandlerRegistry boots with 1 handler, # BPMN auto-deployed from classpath POST /api/v1/auth/login → JWT GET /api/v1/workflow/definitions → 1 definition (vibeerp-workflow-ping) GET /api/v1/workflow/handlers → {"count":1,"keys":["vibeerp.workflow.ping"]} POST /api/v1/workflow/process-instances {"processDefinitionKey":"vibeerp-workflow-ping", "businessKey":"smoke-1", "variables":{"greeting":"ni hao"}} → 201 {"processInstanceId":"...","ended":true, "variables":{"pong":true,"pongAt":"2026-04-09T...", "correlationId":"...","greeting":"ni hao"}} POST /api/v1/workflow/process-instances {"processDefinitionKey":"does-not-exist"} → 404 {"message":"No process definition found for key 'does-not-exist'"} GET /api/v1/catalog/uoms → still returns the 15 seeded UoMs (sanity) ``` ## Tests - 15 new unit tests in `platform-workflow/src/test`: * `TaskHandlerRegistryTest` — init with initial handlers, duplicate key fails fast, blank key rejected, unregister removes, unregister on unknown returns false, find on missing returns null * `DispatchingJavaDelegateTest` — dispatches by currentActivityId, throws on missing handler, defensive-copies the variable map * `DelegateTaskContextTest` — set non-null forwards, set null removes, blank name rejected, principal/locale/correlationId passthrough, default correlation id is stable across calls * `PingTaskHandlerTest` — key matches the BPMN serviceTask id, execute writes pong + pongAt + correlationId - Total framework unit tests: 261 (was 246), all green. ## What this unblocks - **REF.1** — real quote→job-card workflow handler in the printing-shop plug-in - **pbc-production routings/operations (v3)** — each operation becomes a BPMN step with duration + machine assignment - **P2.3** — user-task form rendering (landing on top of the RuntimeService already exposed via WorkflowService) - **P2.2** — BPMN designer web page (later, depends on R1) ## Deliberate non-goals (parking lot) - Principal propagation from the REST caller through the process start into the handler — uses a fixed `workflow-engine` `Principal.System` for now. Follow-up chunk will plumb the authenticated user as a Flowable variable. - Plug-in-contributed TaskHandler registration via PF4J child contexts — the registry exposes `register/unregister` but the plug-in loader doesn't call them yet. Follow-up chunk. - BPMN user tasks, signals, timers, history queries — seam exists, deliberately not built out. - Workflow deployment from `metadata__workflow` rows (the Tier 1 path). Today deployment is classpath-only via Flowable's auto- deployer. - The Flowable async job executor is explicitly deactivated (`flowable.async-executor-activate: false`) — background-job machinery belongs to the future Quartz integration (P1.10), not Flowable. -
Grows pbc-production from the minimal v1 (DRAFT → COMPLETED in one step, single output, no BOM) into a real v2 production PBC: 1. IN_PROGRESS state between DRAFT and COMPLETED so "started but not finished" work orders are observable on a dashboard. WorkOrderService.start(id) performs the transition and publishes a new WorkOrderStartedEvent. cancel() now accepts DRAFT OR IN_PROGRESS (v2 writes nothing to the ledger at start() so there is nothing to undo on cancel). 2. Bill of materials via a new WorkOrderInput child entity — @OneToMany with cascade + orphanRemoval, same shape as SalesOrderLine. Each line carries (lineNo, itemCode, quantityPerUnit, sourceLocationCode). complete() now iterates the inputs in lineNo order and writes one MATERIAL_ISSUE ledger row per line (delta = -(quantityPerUnit × outputQuantity)) BEFORE writing the PRODUCTION_RECEIPT for the output. All in one transaction — a failure anywhere rolls back every prior ledger row AND the status flip. Empty inputs list is legal (the v1 auto-spawn-from-SO path still works unchanged, writing only the PRODUCTION_RECEIPT). 3. Scrap flow for COMPLETED work orders via a new scrap(id, scrapLocationCode, quantity, note) service method. Writes a negative ADJUSTMENT ledger row tagged WO:<code>:SCRAP and publishes a new WorkOrderScrappedEvent. Chose ADJUSTMENT over adding a new SCRAP movement reason to keep the enum stable — the reference-string suffix is the disambiguator. The work order itself STAYS COMPLETED; scrap is a correction on top of a terminal state, not a state change. complete() now requires IN_PROGRESS (not DRAFT); existing callers must start() first. api.v1 grows two events (WorkOrderStartedEvent, WorkOrderScrappedEvent) alongside the three that already existed. Since this is additive within a major version, the api.v1 semver contract holds — existing subscribers continue to compile. Liquibase: 002-production-v2.xml widens the status CHECK and creates production__work_order_input with (work_order_id FK, line_no, item_code, quantity_per_unit, source_location_code) plus a unique (work_order_id, line_no) constraint, a CHECK quantity_per_unit > 0, and the audit columns. ON DELETE CASCADE from the parent. Unit tests: WorkOrderServiceTest grows from 8 to 18 cases — covers start happy path, start rejection, complete-on-DRAFT rejection, empty-BOM complete, BOM-with-two-lines complete (verifies both MATERIAL_ISSUE deltas AND the PRODUCTION_RECEIPT all fire with the right references), scrap happy path, scrap on non-COMPLETED rejection, scrap with non-positive quantity rejection, cancel-from-IN_PROGRESS, and BOM validation rejects (unknown item, duplicate line_no). Smoke verified end-to-end against real Postgres: - Created WO-SMOKE with 2-line BOM (2 paper + 0.5 ink per brochure, output 100). - Started (DRAFT → IN_PROGRESS, no ledger rows). - Completed: paper balance 500→300 (MATERIAL_ISSUE -200), ink 200→150 (MATERIAL_ISSUE -50), FG-BROCHURE 0→100 (PRODUCTION_RECEIPT +100). All 3 rows tagged WO:WO-SMOKE. - Scrapped 7 units: FG-BROCHURE 100→93, ADJUSTMENT -7 tagged WO:WO-SMOKE:SCRAP, work order stayed COMPLETED. - Auto-spawn: SO-42 confirm still creates WO-FROM-SO-42-L1 as a DRAFT with empty BOM; starting + completing it writes only the PRODUCTION_RECEIPT (zero MATERIAL_ISSUE rows), proving the empty-BOM path is backwards-compatible. - Negative paths: complete-on-DRAFT 400s, scrap-on-DRAFT 400s, double-start 400s, cancel-from-IN_PROGRESS 200. 240 unit tests, 18 Gradle subprojects.
-
The framework's eighth PBC and the first one that's NOT order- or master-data-shaped. Work orders are about *making things*, which is the reason the printing-shop reference customer exists in the first place. With this PBC in place the framework can express the full buy-sell-make loop end-to-end. What landed (new module pbc/pbc-production/) - WorkOrder entity (production__work_order): code, output_item_code, output_quantity, status (DRAFT|COMPLETED| CANCELLED), due_date (display-only), source_sales_order_code (nullable — work orders can be either auto-spawned from a confirmed SO or created manually), ext. - WorkOrderJpaRepository with existsBySourceSalesOrderCode / findBySourceSalesOrderCode for the auto-spawn dedup. - WorkOrderService.create / complete / cancel: • create validates the output item via CatalogApi (same seam SalesOrderService and PurchaseOrderService use), rejects non-positive quantities, publishes WorkOrderCreatedEvent. • complete(outputLocationCode) credits finished goods to the named location via InventoryApi.recordMovement with reason=PRODUCTION_RECEIPT (added in commit c52d0d59) and reference="WO:<order_code>", then flips status to COMPLETED, then publishes WorkOrderCompletedEvent — all in the same @Transactional method. • cancel only allowed from DRAFT (no un-producing finished goods); publishes WorkOrderCancelledEvent. - SalesOrderConfirmedSubscriber (@PostConstruct → EventBus.subscribe(SalesOrderConfirmedEvent::class.java, ...)): walks the confirmed sales order's lines via SalesOrdersApi (NOT by importing pbc-orders-sales) and calls WorkOrderService.create for each line. Coded as one bean with one subscription — matches pbc-finance's one-bean-per-subject pattern. • Idempotent on source sales order code — if any work order already exists for the SO, the whole spawn is a no-op. • Tolerant of a missing SO (defensive against a future async bus that could deliver the confirm event after the SO has vanished). • The WO code convention: WO-FROM-<so_code>-L<lineno>, e.g. WO-FROM-SO-2026-0001-L1. - REST controller /api/v1/production/work-orders: list, get, by-code, create, complete, cancel — each annotated with @RequirePermission. Four permission keys declared in the production.yml metadata: read / create / complete / cancel. - CompleteWorkOrderRequest: single-arg DTO uses the @JsonCreator(mode=PROPERTIES) + @param:JsonProperty trick that already bit ShipSalesOrderRequest and ReceivePurchaseOrderRequest; cross-referenced in the KDoc so the third instance doesn't need re-discovery. - distribution/.../pbc-production/001-production-init.xml: CREATE TABLE with CHECK on status + CHECK on qty>0 + GIN on ext + the usual indexes. NEITHER output_item_code NOR source_sales_order_code is a foreign key (cross-PBC reference policy — guardrail #9). - settings.gradle.kts + distribution/build.gradle.kts: registers the new module and adds it to the distribution dependency list. - master.xml: includes the new changelog in dependency order, after pbc-finance. New api.v1 surface: org.vibeerp.api.v1.event.production.* - WorkOrderCreatedEvent, WorkOrderCompletedEvent, WorkOrderCancelledEvent — sealed under WorkOrderEvent, aggregateType="production.WorkOrder". Same pattern as the order events, so any future consumer (finance revenue recognition, warehouse put-away dashboard, a customer plug-in that needs to react to "work finished") subscribes through the public typed-class overload with no dependency on pbc-production. Unit tests (13 new, 217 → 230 total) - WorkOrderServiceTest (9 tests): create dedup, positive quantity check, catalog seam, happy-path create with event assertion, complete rejects non-DRAFT, complete happy path with InventoryApi.recordMovement assertion + event assertion, cancel from DRAFT, cancel rejects COMPLETED. - SalesOrderConfirmedSubscriberTest (5 tests): subscription registration count, spawns N work orders for N SO lines with correct code convention, idempotent when WOs already exist, no-op on missing SO, and a listener-routing test that captures the EventListener instance and verifies it forwards to the right service method. End-to-end smoke verified against real Postgres - Fresh DB, fresh boot. Both OrderEventSubscribers (pbc-finance) and SalesOrderConfirmedSubscriber (pbc-production) log their subscription registration before the first HTTP call. - Seeded two items (BROCHURE-A, BROCHURE-B), a customer, and a finished-goods location (WH-FG). - Created a 2-line sales order (SO-WO-1), confirmed it. → Produced ONE orders_sales.SalesOrder outbox row. → Produced ONE AR POSTED finance__journal_entry for 1000 USD (500 × 1 + 250 × 2 — the pbc-finance consumer still works). → Produced TWO draft work orders auto-spawned from the SO lines: WO-FROM-SO-WO-1-L1 (BROCHURE-A × 500) and WO-FROM-SO-WO-1-L2 (BROCHURE-B × 250), both with source_sales_order_code=SO-WO-1. - Completed WO1 to WH-FG: → Produced a PRODUCTION_RECEIPT ledger row for BROCHURE-A delta=500 reference="WO:WO-FROM-SO-WO-1-L1". → inventory__stock_balance now has BROCHURE-A = 500 at WH-FG. → Flipped status to COMPLETED. - Cancelled WO2 → CANCELLED. - Created a manual WO-MANUAL-1 with no source SO → succeeds; demonstrates the "operator creates a WO to build inventory ahead of demand" path. - platform__event_outbox ends with 6 rows all DISPATCHED: orders_sales.SalesOrder SO-WO-1 production.WorkOrder WO-FROM-SO-WO-1-L1 (created) production.WorkOrder WO-FROM-SO-WO-1-L2 (created) production.WorkOrder WO-FROM-SO-WO-1-L1 (completed) production.WorkOrder WO-FROM-SO-WO-1-L2 (cancelled) production.WorkOrder WO-MANUAL-1 (created) Why this chunk was the right next move - pbc-finance was a PASSIVE consumer — it only wrote derived reporting state. pbc-production is the first ACTIVE consumer: it creates new aggregates with their own state machines and their own cross-PBC writes in reaction to another PBC's events. This is a meaningfully harder test of the event-driven integration story and it passes end-to-end. - "One ledger, three callers" is now real: sales shipments, purchase receipts, AND production receipts all feed the same inventory__stock_movement ledger through the same InventoryApi.recordMovement facade. The facade has proven stable under three very different callers. - The framework now expresses the basic ERP trinity: buy (purchase orders), sell (sales orders), make (work orders). That's the shape every real manufacturing customer needs, and it's done without any PBC importing another. What's deliberately NOT in v1 - No bill of materials. complete() only credits finished goods; it does NOT issue raw materials. A shop floor that needs to consume 4 sheets of paper to produce 1 brochure does it manually via POST /api/v1/inventory/movements with reason= MATERIAL_ISSUE (added in commit c52d0d59). A proper BOM lands as WorkOrderInput lines in a future chunk. - No IN_PROGRESS state. complete() goes DRAFT → COMPLETED in one step. A real shop floor needs "started but not finished" visibility; that's the next iteration. - No routings, operations, machine assignments, or due-date enforcement. due_date is display-only. - No "scrap defective output" flow for a COMPLETED work order. cancel refuses from COMPLETED; the fix requires a new MovementReason and a new event, not a special-case method on the service. -
The minimal pbc-finance landed in commit bf090c2e only reacted to *ConfirmedEvent. This change wires the rest of the order lifecycle (ship/receive → SETTLED, cancel → REVERSED) so the journal entry reflects what actually happened to the order, not just the moment it was confirmed. JournalEntryStatus (new enum + new column) - POSTED — created from a confirm event (existing behaviour) - SETTLED — promoted by SalesOrderShippedEvent / PurchaseOrderReceivedEvent - REVERSED — promoted by SalesOrderCancelledEvent / PurchaseOrderCancelledEvent - The status field is intentionally a separate axis from JournalEntryType: type tells you "AR or AP", status tells you "where in its lifecycle". distribution/.../pbc-finance/002-finance-status.xml - ALTER TABLE adds `status varchar(16) NOT NULL DEFAULT 'POSTED'`, a CHECK constraint mirroring the enum values, and an index on status for the new filter endpoint. The DEFAULT 'POSTED' covers any existing rows on an upgraded environment without a backfill step. JournalEntryService — four new methods, all idempotent - settleFromSalesShipped(event) → POSTED → SETTLED for AR - settleFromPurchaseReceived(event) → POSTED → SETTLED for AP - reverseFromSalesCancelled(event) → POSTED → REVERSED for AR - reverseFromPurchaseCancelled(event) → POSTED → REVERSED for AP Each runs through a private settleByOrderCode/reverseByOrderCode helper that: 1. Looks up the row by order_code (new repo method findFirstByOrderCode). If absent → no-op (e.g. cancel from DRAFT means no *ConfirmedEvent was ever published, so no journal entry exists; this is the most common cancel path). 2. If the row is already in the destination status → no-op (idempotent under at-least-once delivery, e.g. outbox replay or future Kafka retry). 3. Refuses to overwrite a contradictory terminal status — a SETTLED row cannot be REVERSED, and vice versa. The producer's state machine forbids cancel-from-shipped/received, so reaching here implies an upstream contract violation; logged at WARN and the row is left alone. OrderEventSubscribers — six subscriptions per @PostConstruct - All six order events from api.v1.event.orders.* are subscribed via the typed-class EventBus.subscribe(eventType, listener) overload, the same public API a plug-in would use. Boot log line updated: "pbc-finance subscribed to 6 order events". JournalEntryController — new ?status= filter - GET /api/v1/finance/journal-entries?status=POSTED|SETTLED|REVERSED surfaces the partition. Existing ?orderCode= and ?type= filters unchanged. Read permission still finance.journal.read. 12 new unit tests (213 total, was 201) - JournalEntryServiceTest: settle/reverse for AR + AP, idempotency on duplicate destination status, refusal to overwrite a contradictory terminal status, no-op on missing row, default POSTED on new entries. - OrderEventSubscribersTest: assert all SIX subscriptions registered, one new test that captures all four lifecycle listeners and verifies they forward to the correct service methods. End-to-end smoke (real Postgres, fresh DB) - Booted with the new DDL applied (status column + CHECK + index) on an empty DB. The OrderEventSubscribers @PostConstruct line confirms 6 subscriptions registered before the first HTTP call. - Five lifecycle scenarios driven via REST: PO-FULL: confirm + receive → AP SETTLED amount=50.00 SO-FULL: confirm + ship → AR SETTLED amount= 1.00 SO-REVERSE: confirm + cancel → AR REVERSED amount= 1.00 PO-REVERSE: confirm + cancel → AP REVERSED amount=50.00 SO-DRAFT-CANCEL: cancel only → NO ROW (no confirm event) - finance__journal_entry returns exactly 4 rows (the 5th scenario correctly produces nothing) and ?status filters all return the expected partition (POSTED=0, SETTLED=2, REVERSED=2). What's still NOT in pbc-finance - Still no debit/credit legs, no chart of accounts, no period close, no double-entry invariant. This is the v0.17 minimal seed; the real P5.9 build promotes it into a real GL. - No reaction to "settle then reverse" or "reverse then settle" other than the WARN-and-leave-alone defensive path. A real GL would write a separate compensating journal entry; the minimal PBC just keeps the row immutable once it leaves POSTED.
-
The framework's seventh PBC, and the first one whose ENTIRE purpose is to react to events published by other PBCs. It validates the *consumer* side of the cross-PBC event seam that was wired up in commit 67406e87 (event-driven cross-PBC integration). With pbc-finance in place, the bus now has both producers and consumers in real PBC business logic — not just the wildcard EventAuditLogSubscriber that ships with platform-events. What landed (new module pbc/pbc-finance/, ~480 lines including tests) - JournalEntry entity (finance__journal_entry): id, code (= originating event UUID), type (AR|AP), partner_code, order_code, amount, currency_code, posted_at, ext. Unique index on `code` is the durability anchor for idempotent event delivery; the service ALSO existsByCode-checks before insert to make duplicate-event handling a clean no-op rather than a constraint-violation exception. - JournalEntryJpaRepository with existsByCode + findByOrderCode + findByType (the read-side filters used by the controller). - JournalEntryService.recordSalesConfirmed / recordPurchaseConfirmed take a SalesOrderConfirmedEvent / PurchaseOrderConfirmedEvent and write the corresponding AR/AP row. @Transactional with Propagation.REQUIRED so the listener joins the publisher's TX when the bus delivers synchronously (today) and creates a fresh one if a future async bus delivers from a worker thread. The KDoc explains why REQUIRED is the correct default and why REQUIRES_NEW would be wrong here. - OrderEventSubscribers @Component with @PostConstruct that calls EventBus.subscribe(SalesOrderConfirmedEvent::class.java, ...) and EventBus.subscribe(PurchaseOrderConfirmedEvent::class.java, ...) once at boot. Uses the public typed-class subscribe overload — NOT the platform-internal subscribeToAll wildcard helper. This is the API surface plug-ins will also use. - JournalEntryController: read-only REST under /api/v1/finance/journal-entries with @RequirePermission "finance.journal.read". Filter params: ?orderCode= and ?type=. Deliberately no POST endpoint — entries are derived state. - finance.yml metadata declaring 1 entity, 1 permission, 1 menu. - Liquibase changelog at distribution/.../pbc-finance/001-finance-init.xml + master.xml include + distribution/build.gradle.kts dep. - settings.gradle.kts: registers :pbc:pbc-finance. - 9 new unit tests (6 for JournalEntryService, 3 for OrderEventSubscribers) — including idempotency, dedup-by-event-id contract, listener-forwarding correctness via slot-captured EventListener invocation. Total tests: 192 → 201, 16 → 17 modules. Why this is the right shape - pbc-finance has zero source dependency on pbc-orders-sales, pbc-orders-purchase, pbc-partners, or pbc-catalog. The Gradle build refuses any cross-PBC dependency at configuration time — pbc-finance only declares api/api-v1, platform-persistence, and platform-security. The events and partner/item references it consumes all live in api.v1.event.orders / are stored as opaque string codes. - Subscribers go through EventBus.subscribe(eventType, listener), the public typed-class overload from api.v1.event.EventBus. Plug-ins use exactly this API; this PBC proves the API works end-to-end from a real consumer. - The consumer is idempotent on the producer's event id, so at-least-once delivery (outbox replay, future Kafka retry) cannot create duplicate journal entries. This makes the consumer correct under both the current synchronous bus and any future async / out-of-process bus. - Read-only REST API: derived state should not be writable from the outside. Adjustments and reversals will land later as their own command verbs when the real P5.9 finance build needs them, not as a generic create endpoint. End-to-end smoke verified against real Postgres - Booted on a fresh DB; the OrderEventSubscribers @PostConstruct log line confirms the subscription registered before any HTTP traffic. - Seeded an item, supplier, customer, location (existing PBCs). - Created PO PO-FIN-1 (5000 × 0.04 = 200 USD) → confirmed → GET /api/v1/finance/journal-entries returns ONE row: type=AP partner=SUP-PAPER order=PO-FIN-1 amount=200.0000 USD - Created SO SO-FIN-1 (50 × 0.10 = 5 USD) → confirmed → GET /api/v1/finance/journal-entries now returns TWO rows: type=AR partner=CUST-ACME order=SO-FIN-1 amount=5.0000 USD (plus the AP row from above) - GET /api/v1/finance/journal-entries?orderCode=PO-FIN-1 → only the AP row. - GET /api/v1/finance/journal-entries?type=AR → only the AR row. - platform__event_outbox shows 2 rows (one per confirm) both DISPATCHED, finance__journal_entry shows 2 rows. - The journal-entry code column equals the originating event UUID, proving the dedup contract is wired. What this is NOT (yet) - Not a real general ledger. No debit/credit legs, no chart of accounts, no period close, no double-entry invariant. P5.9 promotes this minimal seed into a real finance PBC. - No reaction to ship/receive/cancel events yet — only confirm. Real revenue recognition (which happens at ship time for most accounting standards) lands with the P5.9 build. - No outbound api.v1.ext facade. pbc-finance does not (yet) expose itself to other PBCs; it is a pure consumer. When pbc-production needs to know "did this order's invoice clear", that facade gets added.
-
The buying-side mirror of pbc-orders-sales. Adds the 6th real PBC and closes the loop: the framework now does both directions of the inventory flow through the same `InventoryApi.recordMovement` facade. Buy stock with a PO that hits RECEIVED, ship stock with a SO that hits SHIPPED, both feed the same `inventory__stock_movement` ledger. What landed ----------- * New Gradle subproject `pbc/pbc-orders-purchase` (16 modules total now). Same dependency set as pbc-orders-sales, same architectural enforcement — no direct dependency on any other PBC; cross-PBC references go through `api.v1.ext.<pbc>` facades at runtime. * Two JPA entities mirroring SalesOrder / SalesOrderLine: - `PurchaseOrder` (header) — code, partner_code (varchar, NOT a UUID FK), status enum DRAFT/CONFIRMED/RECEIVED/CANCELLED, order_date, expected_date (nullable, the supplier's promised delivery date), currency_code, total_amount, ext jsonb. - `PurchaseOrderLine` — purchase_order_id FK, line_no, item_code, quantity, unit_price, currency_code. Same shape as the sales order line; the api.v1 facade reuses `SalesOrderLineRef` rather than declaring a duplicate type. * `PurchaseOrderService.create` performs three cross-PBC validations in one transaction: 1. PartnersApi.findPartnerByCode → reject if null. 2. The partner's `type` must be SUPPLIER or BOTH (a CUSTOMER-only partner cannot be the supplier of a purchase order — the mirror of the sales-order rule that rejects SUPPLIER-only partners as customers). 3. CatalogApi.findItemByCode for EVERY line. Then validates: at least one line, no duplicate line numbers, positive quantity, non-negative price, currency matches header. The header total is RECOMPUTED from the lines (caller's value ignored — never trust a financial aggregate sent over the wire). * State machine enforced by `confirm()`, `cancel()`, and `receive()`: - DRAFT → CONFIRMED (confirm) - DRAFT → CANCELLED (cancel) - CONFIRMED → CANCELLED (cancel before receipt) - CONFIRMED → RECEIVED (receive — increments inventory) - RECEIVED → × (terminal; cancellation requires a return-to-supplier flow) * `receive(id, receivingLocationCode)` walks every line and calls `inventoryApi.recordMovement(... +line.quantity reason="PURCHASE_RECEIPT" reference="PO:<order_code>")`. The whole operation runs in ONE transaction so a failure on any line rolls back EVERY line's already-written movement AND the order status change. The customer cannot end up with "5 of 7 lines received, status still CONFIRMED, ledger half-written". * New `POST /api/v1/orders/purchase-orders/{id}/receive` endpoint with body `{"receivingLocationCode": "WH-MAIN"}`, gated by `orders.purchase.receive`. The single-arg DTO has the same Jackson `@JsonCreator(mode = PROPERTIES)` workaround as `ShipSalesOrderRequest` (the trap is documented in the class KDoc with a back-reference to ShipSalesOrderRequest). * Confirm/cancel/receive endpoints carry `@RequirePermission` annotations (`orders.purchase.confirm`, `orders.purchase.cancel`, `orders.purchase.receive`). All three keys declared in the new `orders-purchase.yml` metadata. * New api.v1 facade `org.vibeerp.api.v1.ext.orders.PurchaseOrdersApi` + `PurchaseOrderRef`. Reuses the existing `SalesOrderLineRef` type for the line shape — buying and selling lines carry the same fields, so duplicating the ref type would be busywork. * `PurchaseOrdersApiAdapter` — sixth `*ApiAdapter` after Identity, Catalog, Partners, Inventory, SalesOrders. * `orders-purchase.yml` metadata declaring 2 entities, 6 permission keys, 1 menu entry under "Purchasing". End-to-end smoke test (the full demo loop) ------------------------------------------ Reset Postgres, booted the app, ran: * Login as admin * POST /catalog/items → PAPER-A4 * POST /partners → SUP-PAPER (SUPPLIER) * POST /inventory/locations → WH-MAIN * GET /inventory/balances?itemCode=PAPER-A4 → [] (no stock) * POST /orders/purchase-orders → PO-2026-0001 for 5000 sheets @ $0.04 = total $200.00 (recomputed from the line) * POST /purchase-orders/{id}/confirm → status CONFIRMED * POST /purchase-orders/{id}/receive body={"receivingLocationCode":"WH-MAIN"} → status RECEIVED * GET /inventory/balances?itemCode=PAPER-A4 → quantity=5000 * GET /inventory/movements?itemCode=PAPER-A4 → PURCHASE_RECEIPT delta=5000 ref=PO:PO-2026-0001 Then the FULL loop with the sales side from the previous chunk: * POST /partners → CUST-ACME (CUSTOMER) * POST /orders/sales-orders → SO-2026-0001 for 50 sheets * confirm + ship from WH-MAIN * GET /inventory/balances?itemCode=PAPER-A4 → quantity=4950 (5000-50) * GET /inventory/movements?itemCode=PAPER-A4 → PURCHASE_RECEIPT delta=5000 ref=PO:PO-2026-0001 SALES_SHIPMENT delta=-50 ref=SO:SO-2026-0001 The framework's `InventoryApi.recordMovement` facade now has TWO callers — pbc-orders-sales (negative deltas, SALES_SHIPMENT) and pbc-orders-purchase (positive deltas, PURCHASE_RECEIPT) — feeding the same ledger from both sides. Failure paths verified: * Re-receive a RECEIVED PO → 400 "only CONFIRMED orders can be received" * Cancel a RECEIVED PO → 400 "issue a return-to-supplier flow instead" * Create a PO from a CUSTOMER-only partner → 400 "partner 'CUST-ONLY' is type CUSTOMER and cannot be the supplier of a purchase order" Regression: catalog uoms, identity users, partners, inventory, sales orders, purchase orders, printing-shop plates with i18n, metadata entities (15 now, was 13) — all still HTTP 2xx. Build ----- * `./gradlew build`: 16 subprojects, 186 unit tests (was 175), all green. The 11 new tests cover the same shapes as the sales-order tests but inverted: unknown supplier, CUSTOMER-only rejection, BOTH-type acceptance, unknown item, empty lines, total recomputation, confirm/cancel state machine, receive-rejects-non-CONFIRMED, receive-walks-lines-with-positive- delta, cancel-rejects-RECEIVED, cancel-CONFIRMED-allowed. What was deferred ----------------- * **RFQs** (request for quotation) and **supplier price catalogs** — both lay alongside POs but neither is in v1. * **Partial receipts**. v1's RECEIVED is "all-or-nothing"; the supplier delivering 4500 of 5000 sheets is not yet modelled. * **Supplier returns / refunds**. The cancel-RECEIVED rejection message says "issue a return-to-supplier flow" — that flow doesn't exist yet. * **Three-way matching** (PO + receipt + invoice). Lands with pbc-finance. * **Multi-leg transfers**. TRANSFER_IN/TRANSFER_OUT exist in the movement enum but no service operation yet writes both legs in one transaction. -
The killer demo finally works: place a sales order, ship it, watch inventory drop. This chunk lands the two pieces that close the loop: the inventory movement ledger (the audit-grade history of every stock change) and the sales-order /ship endpoint that calls InventoryApi.recordMovement to atomically debit stock for every line. This is the framework's FIRST cross-PBC WRITE flow. Every earlier cross-PBC call was a read (CatalogApi.findItemByCode, PartnersApi.findPartnerByCode, InventoryApi.findStockBalance). Shipping inverts that: pbc-orders-sales synchronously writes to inventory's tables (via the api.v1 facade) as a side effect of changing its own state, all in ONE Spring transaction. What landed ----------- * New `inventory__stock_movement` table — append-only ledger (id, item_code, location_id FK, signed delta, reason enum, reference, occurred_at, audit cols). CHECK constraint `delta <> 0` rejects no-op rows. Indexes on item_code, location_id, the (item, location) composite, reference, and occurred_at. Migration is in its own changelog file (002-inventory-movement-ledger.xml) per the project convention that each new schema cut is a new file. * New `StockMovement` JPA entity + repository + `MovementReason` enum (RECEIPT, ISSUE, ADJUSTMENT, SALES_SHIPMENT, PURCHASE_RECEIPT, TRANSFER_OUT, TRANSFER_IN). Each value carries a documented sign convention; the service rejects mismatches (a SALES_SHIPMENT with positive delta is a caller bug, not silently coerced). * New `StockMovementService.record(...)` — the ONE entry point for changing inventory. Cross-PBC item validation via CatalogApi, local location validation, sign-vs-reason enforcement, and negative-balance rejection all happen BEFORE the write. The ledger row insert AND the balance row update happen in the SAME database transaction so the two cannot drift. * `StockBalanceService.adjust` refactored to delegate: it computes delta = newQty - oldQty and calls record(... ADJUSTMENT). The REST endpoint keeps its absolute-quantity semantics — operators type "the shelf has 47" not "decrease by 3" — but every adjustment now writes a ledger row too. A no-op adjustment (re-saving the same value) does NOT write a row, so the audit log doesn't fill with noise from operator clicks that didn't change anything. * New `StockMovementController` at `/api/v1/inventory/movements`: GET filters by itemCode, locationId, or reference (for "all movements caused by SO-2026-0001"); POST records a manual movement. Both protected by `inventory.stock.adjust`. * `InventoryApi` facade extended with `recordMovement(itemCode, locationCode, delta, reason: String, reference)`. The reason is a String in the api.v1 surface (not the local enum) so plug-ins don't import inventory's internal types — the closed set is documented on the interface. The adapter parses the string with a meaningful error on unknown values. * New `SHIPPED` status on `SalesOrderStatus`. Transitions: DRAFT → CONFIRMED → SHIPPED (terminal). Cancelling a SHIPPED order is rejected with "issue a return / refund flow instead". * New `SalesOrderService.ship(id, shippingLocationCode)`: walks every line, calls `inventoryApi.recordMovement(... -line.quantity reason="SALES_SHIPMENT" reference="SO:{order_code}")`, flips status to SHIPPED. The whole operation runs in ONE transaction so a failure on any line — bad item, bad location, would push balance negative — rolls back the order status change AND every other line's already-written movement. The customer never ends up with "5 of 7 lines shipped, status still CONFIRMED, ledger half-written". * New `POST /api/v1/orders/sales-orders/{id}/ship` endpoint with body `{"shippingLocationCode": "WH-MAIN"}`, gated by the new `orders.sales.ship` permission key. * `ShipSalesOrderRequest` is a single-arg Kotlin data class — same Jackson deserialization trap as `RefreshRequest`. Fixed with `@JsonCreator(mode = PROPERTIES) + @param:JsonProperty`. The trap is documented in the class KDoc. End-to-end smoke test (the killer demo) --------------------------------------- Reset Postgres, booted the app, ran: * Login as admin * POST /catalog/items → PAPER-A4 * POST /partners → CUST-ACME * POST /inventory/locations → WH-MAIN * POST /inventory/balances/adjust → quantity=1000 (now writes a ledger row via the new path) * GET /inventory/movements?itemCode=PAPER-A4 → ADJUSTMENT delta=1000 ref=null * POST /orders/sales-orders → SO-2026-0001 (50 units of PAPER-A4) * POST /sales-orders/{id}/confirm → status CONFIRMED * POST /sales-orders/{id}/ship body={"shippingLocationCode":"WH-MAIN"} → status SHIPPED * GET /inventory/balances?itemCode=PAPER-A4 → quantity=950 (1000 - 50) * GET /inventory/movements?itemCode=PAPER-A4 → ADJUSTMENT delta=1000 ref=null SALES_SHIPMENT delta=-50 ref=SO:SO-2026-0001 Failure paths verified: * Re-ship a SHIPPED order → 400 "only CONFIRMED orders can be shipped" * Cancel a SHIPPED order → 400 "issue a return / refund flow instead" * Place a 10000-unit order, confirm, try to ship from a 950-stock warehouse → 400 "stock movement would push balance for 'PAPER-A4' at location ... below zero (current=950.0000, delta=-10000.0000)"; balance unchanged after the rollback (transaction integrity verified) Regression: catalog uoms, identity users, inventory locations, printing-shop plates with i18n, metadata entities — all still HTTP 2xx. Build ----- * `./gradlew build`: 15 subprojects, 175 unit tests (was 163), all green. The 12 new tests cover: - StockMovementServiceTest (8): zero-delta rejection, positive SALES_SHIPMENT rejection, negative RECEIPT rejection, both signs allowed on ADJUSTMENT, unknown item via CatalogApi seam, unknown location, would-push-balance-negative rejection, new-row + existing-row balance update. - StockBalanceServiceTest, rewritten (5): negative-quantity early reject, delegation with computed positive delta, delegation with computed negative delta, no-op adjustment short-circuit (NO ledger row written), no-op on missing row creates an empty row at zero. - SalesOrderServiceTest, additions (3): ship rejects non-CONFIRMED, ship walks lines and calls recordMovement with negated quantity + correct reference, cancel rejects SHIPPED. What was deferred ----------------- * **Event publication.** A `StockMovementRecorded` event would let pbc-finance and pbc-production react to ledger writes without polling. The event bus has been wired since P1.7 but no real cross-PBC flow uses it yet — that's the natural next chunk and the chunk after this commit. * **Multi-leg transfers.** TRANSFER_OUT and TRANSFER_IN are in the enum but no service operation atomically writes both legs yet (both legs in one transaction is required to keep total on-hand invariant). * **Reservation / pick lists.** "Reserve 50 of PAPER-A4 for an unconfirmed order" is its own concept that lands later. * **Shipped-order returns / refunds.** The cancel-SHIPPED rule points the user at "use a return flow" — that flow doesn't exist yet. v1 says shipments are terminal. -
The fifth real PBC and the first business workflow PBC. pbc-inventory proved a PBC could consume ONE cross-PBC facade (CatalogApi). pbc-orders-sales consumes TWO simultaneously (PartnersApi for the customer, CatalogApi for every line's item) in a single transaction — the most rigorous test of the modular monolith story so far. Neither source PBC is on the compile classpath; the Gradle build refuses any direct dependency. Spring DI wires the api.v1 interfaces to their concrete adapters at runtime. What landed ----------- * New Gradle subproject `pbc/pbc-orders-sales` (15 modules total). * Two JPA entities, both extending `AuditedJpaEntity`: - `SalesOrder` (header) — code, partner_code (varchar, NOT a UUID FK to partners), status enum DRAFT/CONFIRMED/CANCELLED, order_date, currency_code (varchar(3)), total_amount numeric(18,4), ext jsonb. Eager-loaded `lines` collection because every read of the header is followed by a read of the lines in practice. - `SalesOrderLine` — sales_order_id FK, line_no, item_code (varchar, NOT a UUID FK to catalog), quantity, unit_price, currency_code. Per-line currency in the schema even though v1 enforces all-lines- match-header (so multi-currency relaxation is later schema-free). No `ext` jsonb on lines: lines are facts, not master records; custom fields belong on the header. * `SalesOrderService.create` performs **three independent cross-PBC validations** in one transaction: 1. PartnersApi.findPartnerByCode → reject if null (covers unknown AND inactive partners; the facade hides them). 2. PartnersApi result.type must be CUSTOMER or BOTH (a SUPPLIER-only partner cannot be the customer of a sales order). 3. CatalogApi.findItemByCode for EVERY line → reject if null. Then it ALSO validates: at least one line, no duplicate line numbers, positive quantity, non-negative price, currency matches header. The header total is RECOMPUTED from the lines — the caller's value is intentionally ignored. Never trust a financial aggregate sent over the wire. * State machine enforced by `confirm()` and `cancel()`: - DRAFT → CONFIRMED (confirm) - DRAFT → CANCELLED (cancel from draft) - CONFIRMED → CANCELLED (cancel a confirmed order) Anything else throws with a descriptive message. CONFIRMED orders are immutable except for cancellation — the `update` method refuses to mutate a non-DRAFT order. * `update` with line items REPLACES the existing lines wholesale (PUT semantics for lines, PATCH for header columns). Partial line edits are not modelled because the typical "edit one line" UI gesture renders to a full re-send anyway. * REST: `/api/v1/orders/sales-orders` (CRUD + `/confirm` + `/cancel`). State transitions live on dedicated POST endpoints rather than PATCH-based status writes — they have side effects (lines become immutable, downstream PBCs will receive events in future versions), and sentinel-status writes hide that. * New api.v1 facade `org.vibeerp.api.v1.ext.orders.SalesOrdersApi` with `findByCode`, `findById`, `SalesOrderRef`, `SalesOrderLineRef`. Fifth ext.* package after identity, catalog, partners, inventory. Sets up the next consumers: pbc-production for work orders, pbc-finance for invoicing, the printing-shop reference plug-in for the quote-to-job-card workflow. * `SalesOrdersApiAdapter` runtime implementation. Cancelled orders ARE returned by the facade (unlike inactive items / partners which are hidden) because downstream consumers may legitimately need to react to a cancellation — release a production slot, void an invoice, etc. * `orders-sales.yml` metadata declaring 2 entities, 5 permission keys, 1 menu entry. Build enforcement (still load-bearing) -------------------------------------- The root `build.gradle.kts` STILL refuses any direct dependency from `pbc-orders-sales` to either `pbc-partners` or `pbc-catalog`. Try adding either as `implementation(project(...))` and the build fails at configuration time with the architectural violation. The cross-PBC interfaces live in api-v1; the concrete adapters live in their owning PBCs; Spring DI assembles them at runtime via the bootstrap @ComponentScan. pbc-orders-sales sees only the api.v1 interfaces. End-to-end smoke test --------------------- Reset Postgres, booted the app, hit: * POST /api/v1/catalog/items × 2 → PAPER-A4, INK-CYAN * POST /api/v1/partners/partners → CUST-ACME (CUSTOMER), SUP-ONLY (SUPPLIER) * POST /api/v1/orders/sales-orders → 201, two lines, total 386.50 (5000 × 0.05 + 3 × 45.50 = 250.00 + 136.50, correctly recomputed) * POST .../sales-orders with FAKE-PARTNER → 400 with the meaningful message "partner code 'FAKE-PARTNER' is not in the partners directory (or is inactive)" * POST .../sales-orders with SUP-ONLY → 400 "partner 'SUP-ONLY' is type SUPPLIER and cannot be the customer of a sales order" * POST .../sales-orders with FAKE-ITEM line → 400 "line 1: item code 'FAKE-ITEM' is not in the catalog (or is inactive)" * POST /{id}/confirm → status DRAFT → CONFIRMED * PATCH the CONFIRMED order → 400 "only DRAFT orders are mutable" * Re-confirm a CONFIRMED order → 400 "only DRAFT can be confirmed" * POST /{id}/cancel a CONFIRMED order → status CANCELLED (allowed) * SELECT * FROM orders_sales__sales_order — single row, total 386.5000, status CANCELLED * SELECT * FROM orders_sales__sales_order_line — two rows in line_no order with the right items and quantities * GET /api/v1/_meta/metadata/entities → 13 entities now (was 11) * Regression: catalog uoms, identity users, partners, inventory locations, printing-shop plates with i18n (Accept-Language: zh-CN) all still HTTP 2xx. Build ----- * `./gradlew build`: 15 subprojects, 153 unit tests (was 139), all green. The 14 new tests cover: unknown/SUPPLIER-only/BOTH-type partner paths, unknown item path, empty/duplicate-lineno line arrays, negative-quantity early reject (verifies CatalogApi NOT consulted), currency mismatch reject, total recomputation, all three state-machine transitions and the rejected ones. What was deferred ----------------- * **Sales-order shipping**. Confirmed orders cannot yet ship, because shipping requires atomically debiting inventory — which needs the movement ledger that was deferred from P5.3. The pair of chunks (movement ledger + sales-order shipping flow) is the natural next combination. * **Multi-currency lines**. The schema column is per-line but the service enforces all-lines-match-header in v1. Relaxing this is a service-only change. * **Quotes** (DRAFT-but-customer-visible) and **deliveries** (the thing that triggers shipping). v1 only models the order itself. * **Pricing engine / discounts**. v1 takes the unit price the caller sends. A real ERP has a price book lookup, customer-specific pricing, volume discounts, promotional pricing — all of which slot in BEFORE the line price is set, leaving the schema unchanged. * **Tax**. v1 totals are pre-tax. Tax calculation is its own PBC (and a regulatory minefield) that lands later. -
The fourth real PBC, and the first one that CONSUMES another PBC's api.v1.ext facade. Until now every PBC was a *provider* of an ext.<pbc> interface (identity, catalog, partners). pbc-inventory is the first *consumer*: it injects org.vibeerp.api.v1.ext.catalog.CatalogApi to validate item codes before adjusting stock. This proves the cross-PBC contract works in both directions, exactly as guardrail #9 requires. What landed ----------- * New Gradle subproject `pbc/pbc-inventory` (14 modules total now). * Two JPA entities, both extending `AuditedJpaEntity`: - `Location` — code, name, type (WAREHOUSE/BIN/VIRTUAL), active, ext jsonb. Single table for all location levels with a type discriminator (no recursive self-reference in v1; YAGNI for the "one warehouse, handful of bins" shape every printing shop has). - `StockBalance` — item_code (varchar, NOT a UUID FK), location_id FK, quantity numeric(18,4). The item_code is deliberately a string FK that references nothing because pbc-inventory has no compile-time link to pbc-catalog — the cross-PBC link goes through CatalogApi at runtime. UNIQUE INDEX on (item_code, location_id) is the primary integrity guarantee; UUID id is the addressable PK. CHECK (quantity >= 0). * `LocationService` and `StockBalanceService` with full CRUD + adjust semantics. ext jsonb on Location goes through ExtJsonValidator (P3.4 — Tier 1 customisation). * `StockBalanceService.adjust(itemCode, locationId, quantity)`: 1. Reject negative quantity. 2. **Inject CatalogApi**, call `findItemByCode(itemCode)`, reject if null with a meaningful 400. THIS is the cross-PBC seam test. 3. Verify the location exists. 4. SELECT-then-save upsert on (item_code, location_id) — single row per cell, mutated in place when the row exists, created when it doesn't. Single-instance deployment makes the read-modify-write race window academic. * REST: `/api/v1/inventory/locations` (CRUD), `/api/v1/inventory/balances` (GET with itemCode or locationId filters, POST /adjust). * New api.v1 facade `org.vibeerp.api.v1.ext.inventory` with `InventoryApi.findStockBalance(itemCode, locationCode)` + `totalOnHand(itemCode)` + `StockBalanceRef`. Fourth ext.* package after identity, catalog, partners. Sets up the next consumers (sales orders, purchase orders, the printing-shop plug-in's "do we have enough paper for this job?"). * `InventoryApiAdapter` runtime implementation in pbc-inventory. * `inventory.yml` metadata declaring 2 entities, 6 permission keys, 2 menu entries. Build enforcement (the load-bearing bit) ---------------------------------------- The root build.gradle.kts STILL refuses any direct dependency from pbc-inventory to pbc-catalog. Try adding `implementation(project( ":pbc:pbc-catalog"))` to pbc-inventory's build.gradle.kts and the build fails at configuration time with "Architectural violation in :pbc:pbc-inventory: depends on :pbc:pbc-catalog". The CatalogApi interface is in api-v1; the CatalogApiAdapter implementation is in pbc-catalog; Spring DI wires them at runtime via the bootstrap @ComponentScan. pbc-inventory only ever sees the interface. End-to-end smoke test --------------------- Reset Postgres, booted the app, hit: * POST /api/v1/inventory/locations → 201, "WH-MAIN" warehouse * POST /api/v1/catalog/items → 201, "PAPER-A4" sheet item * POST /api/v1/inventory/balances/adjust with itemCode=PAPER-A4 → 200, the cross-PBC catalog lookup succeeded * POST .../adjust with itemCode=FAKE-ITEM → 400 with the meaningful message "item code 'FAKE-ITEM' is not in the catalog (or is inactive)" — the cross-PBC seam REJECTS unknown items as designed * POST .../adjust with quantity=-5 → 400 "stock quantity must be non-negative", caught BEFORE the CatalogApi mock would be invoked * POST .../adjust again with quantity=7500 → 200; SELECT shows ONE row with id unchanged and quantity = 7500 (upsert mutates, not duplicates) * GET /api/v1/inventory/balances?itemCode=PAPER-A4 → the row, with scale-4 numeric serialised verbatim * GET /api/v1/_meta/metadata/entities → 11 entities now (was 9 before Location + StockBalance landed) * Regression: catalog uoms, identity users, partners, printing-shop plates with i18n (Accept-Language: zh-CN), Location custom-fields endpoint all still HTTP 2xx. Build ----- * `./gradlew build`: 14 subprojects, 139 unit tests (was 129), all green. The 10 new tests cover Location CRUD + the StockBalance adjust path with mocked CatalogApi: unknown item rejection, unknown location rejection, negative-quantity early reject (verifies CatalogApi is NOT consulted), happy-path create, and upsert (existing row mutated, save() not called because @Transactional flushes the JPA-managed entity on commit). What was deferred ----------------- * `inventory__stock_movement` append-only ledger. The current operation is "set the quantity"; receipts/issues/transfers as discrete events with audit trail land in a focused follow-up. The balance row will then be regenerated from the ledger via a Liquibase backfill. * Negative-balance / over-issue prevention. The CHECK constraint blocks SET to a negative value, but there's no concept of "you cannot ISSUE more than is on hand" yet because there is no separate ISSUE operation — only absolute SET. * Lots, batches, serial numbers, expiry dates. Plenty of printing shops need none of these; the ones that do can either wait for the lot/serial chunk later or add the columns via Tier 1 custom fields on Location for now. * Cross-warehouse transfer atomicity (debit one, credit another in one transaction). Same — needs the ledger. -
The eighth cross-cutting platform service is live: plug-ins and PBCs now have a real Translator and LocaleProvider instead of the UnsupportedOperationException stubs that have shipped since v0.5. What landed ----------- * New Gradle subproject `platform/platform-i18n` (13 modules total). * `IcuTranslator` — backed by ICU4J's MessageFormat (named placeholders, plurals, gender, locale-aware number/date/currency formatting), the format every modern translation tool speaks natively. JDK ResourceBundle handles per-locale fallback (zh_CN → zh → root). * The translator takes a list of `BundleLocation(classLoader, baseName)` pairs and tries them in order. For the **core** translator the chain is just `[(host, "messages")]`; for a **per-plug-in** translator constructed by VibeErpPluginManager it's `[(pluginClassLoader, "META-INF/vibe-erp/i18n/messages"), (host, "messages")]` so plug-in keys override host keys, but plug-ins still inherit shared keys like `errors.not_found`. * Critical detail: the plug-in baseName uses a path the host does NOT publish, because PF4J's `PluginClassLoader` is parent-first — a `getResource("messages.properties")` against the plug-in classloader would find the HOST bundle through the parent chain, defeating the per-plug-in override entirely. Naming the plug-in resource somewhere the host doesn't claim sidesteps the trap. * The translator disables `ResourceBundle.Control`'s automatic JVM-default locale fallback. The default control walks `requested → root → JVM default → root` which would silently serve German strings to a Japanese-locale request just because the German bundle exists. The fallback chain stops at root within a bundle, then moves to the next bundle location, then returns the key string itself. * `RequestLocaleProvider` reads the active HTTP request's Accept-Language via `RequestContextHolder` + the servlet container's `getLocale()`. Outside an HTTP request (background jobs, workflow tasks, MCP agents) it falls back to the configured default locale (`vibeerp.i18n.defaultLocale`, default `en`). Importantly, when an HTTP request HAS no Accept-Language header it ALSO falls back to the configured default — never to the JVM's locale. * `I18nConfiguration` exposes `coreTranslator` and `coreLocaleProvider` beans. Per-plug-in translators are NOT beans — they're constructed imperatively per plug-in start in VibeErpPluginManager because each needs its own classloader at the front of the resolution chain. * `DefaultPluginContext` now wires `translator` and `localeProvider` for real instead of throwing `UnsupportedOperationException`. Bundles ------- * Core: `platform-i18n/src/main/resources/messages.properties` (English), `messages_zh_CN.properties` (Simplified Chinese), `messages_de.properties` (German). Six common keys (errors, ok/cancel/save/delete) and an ICU plural example for `counts.items`. Java 9+ JEP 226 reads .properties files as UTF-8 by default, so Chinese characters are written directly rather than as `\\uXXXX` escapes. * Reference plug-in: moved from the broken `i18n/messages_en-US.properties` / `messages_zh-CN.properties` (wrong path, hyphen-locale filenames ResourceBundle ignores) to the canonical `META-INF/vibe-erp/i18n/messages.properties` / `messages_zh_CN.properties` paths with underscore locale tags. Added a new `printingshop.plate.created` key with an ICU plural for `ink_count` to demonstrate non-trivial argument substitution. End-to-end smoke test --------------------- Reset Postgres, booted the app, hit POST /api/v1/plugins/printing-shop/plates with three different Accept-Language headers: * (no header) → "Plate 'PLATE-001' created with no inks." (en-US, plug-in base bundle) * `Accept-Language: zh-CN` → "已创建印版 'PLATE-002' (无油墨)。" (zh-CN, plug-in zh_CN bundle) * `Accept-Language: de` → "Plate 'PLATE-003' created with no inks." (de, but the plug-in ships no German bundle so it falls back to the plug-in base bundle — correct, the key is plug-in-specific) Regression: identity, catalog, partners, and `GET /plates` all still HTTP 200 after the i18n wiring change. Build ----- * `./gradlew build`: 13 subprojects, 118 unit tests (was 107 / 12), all green. The 11 new tests cover ICU plural rendering, named-arg substitution, locale fallback (zh_CN → root, ja → root via NO_FALLBACK), cross-classloader override (a real JAR built in /tmp at test time), and RequestLocaleProvider's three resolution paths (no request → default; Accept-Language present → request locale; request without Accept-Language → default, NOT JVM locale). * The architectural rule still enforced: platform-plugins now imports platform-i18n, which is a platform-* dependency (allowed), not a pbc-* dependency (forbidden). What was deferred ----------------- * User-preferred locale from the authenticated user's profile row is NOT in the resolution chain yet — the `LocaleProvider` interface leaves room for it but the implementation only consults Accept-Language and the configured default. Adding it slots in between request and default without changing the api.v1 surface. * The metadata translation overrides table (`metadata__translation`) is also deferred — the `Translator` JavaDoc mentions it as the first lookup source, but right now keys come from .properties files only. Once Tier 1 customisation lands (P3.x), key users will be able to override any string from the SPA without touching code. -
The third real PBC. Validates the modular-monolith template against a parent-with-children aggregate (Partner → Addresses → Contacts), where the previous two PBCs only had single-table or two-independent-table shapes. What landed ----------- * New Gradle subproject `pbc/pbc-partners` (12 modules total now). * Three JPA entities, all extending `AuditedJpaEntity`: - `Partner` — code, name, type (CUSTOMER/SUPPLIER/BOTH), tax_id, website, email, phone, active, ext jsonb. Single-table for both customers and suppliers because the role flag is a property of the relationship, not the organisation. - `Address` — partner_id FK, address_type (BILLING/SHIPPING/OTHER), line1/line2/city/region/postal_code/country_code (ISO 3166-1), is_primary. Two free address lines + structured city/region/code is the smallest set that round-trips through every postal system. - `Contact` — partner_id FK, full_name, role, email, phone, active. PII-tagged in metadata YAML for the future audit/export tooling. * Spring Data JPA repos, application services with full CRUD and the invariants below, REST controllers under `/api/v1/partners/partners` (+ nested addresses, contacts). * `partners-init.xml` Liquibase changelog with the three tables, FKs, GIN index on `partner.ext`, indexes on type/active/country. * New api.v1 facade `org.vibeerp.api.v1.ext.partners` with `PartnersApi` + `PartnerRef`. Third `ext.<pbc>` after identity and catalog. Inactive partners hidden at the facade boundary. * `PartnersApiAdapter` runtime implementation in pbc-partners, never leaking JPA entity types. * `partners.yml` metadata declaring all 3 entities, 12 permission keys, 1 menu entry. Picked up automatically by `MetadataLoader`. * 15 new unit tests across `PartnerServiceTest`, `AddressServiceTest` and `ContactServiceTest` (mockk-based, mirroring catalog tests). Invariants enforced in code (not blindly delegated to the DB) ------------------------------------------------------------- * Partner code uniqueness — explicit check produces a 400 with a real message instead of a 500 from the unique-index violation. * Partner code is NOT updatable — every external reference uses code, so renaming is a data-migration concern, not an API call. * Partner deactivate cascades to contacts (also flipped to inactive). Addresses are NOT touched (no `active` column — they exist or they don't). Verified end-to-end against Postgres. * "Primary" flag is at most one per (partner, address_type). When a new/updated address is marked primary, all OTHER primaries of the same type for the same partner are demoted in the same transaction. * Addresses and contacts reject operations on unknown partners up-front to give better errors than the FK-violation. End-to-end smoke test --------------------- Reset Postgres, booted the app, hit: * POST /api/v1/auth/login (admin) → JWT * POST /api/v1/partners/partners (CUSTOMER, SUPPLIER) → 201 * GET /api/v1/partners/partners → lists both * GET /api/v1/partners/partners/by-code/CUST-ACME → resolves * POST /api/v1/partners/partners (dup code) → 400 with real message * POST .../{id}/addresses (BILLING, primary) → 201 * POST .../{id}/contacts → 201 * DELETE /api/v1/partners/partners/{id} → 204; partner active=false * GET .../contacts → contact ALSO active=false (cascade verified) * GET /api/v1/_meta/metadata/entities → 3 partners entities present * GET /api/v1/_meta/metadata/permissions → 12 partners permissions * Regression: catalog UoMs/items, identity users, printing-shop plug-in plates all still HTTP 200. Build ----- * `./gradlew build`: 12 subprojects, 107 unit tests, all green (was 11 / 92 before this commit). * The architectural rule still enforced: pbc-partners depends on api-v1 + platform-persistence + platform-security only — no cross-PBC dep, no platform-bootstrap dep. What was deferred ----------------- * Permission enforcement on contact endpoints (P4.3). Currently plain authenticated; the metadata declares the planned `partners.contact.*` keys for when @RequirePermission lands. * Per-country address structure layered on top via metadata forms (P3.x). The current schema is the smallest universal subset. * `deletePartnerCompletely` — out of scope for v1; should be a separate "data scrub" admin tool, not a routine API call. -
Adds the foundation for the entire Tier 1 customization story. Core PBCs and plug-ins now ship YAML files declaring their entities, permissions, and menus; a `MetadataLoader` walks the host classpath and each plug-in JAR at boot, upserts the rows tagged with their source, and exposes them at a public REST endpoint so the future SPA, AI-agent function catalog, OpenAPI generator, and external introspection tooling can all see what the framework offers without scraping code. What landed: * New `platform/platform-metadata/` Gradle subproject. Depends on api-v1 + platform-persistence + jackson-yaml + spring-jdbc. * `MetadataYamlFile` DTOs (entities, permissions, menus). Forward- compatible: unknown top-level keys are ignored, so a future plug-in built against a newer schema (forms, workflows, rules, translations) loads cleanly on an older host that doesn't know those sections yet. * `MetadataLoader` with two entry points: loadCore() — uses Spring's PathMatchingResourcePatternResolver against the host classloader. Finds every classpath*:META-INF/ vibe-erp/metadata/*.yml across all jars contributing to the application. Tagged source='core'. loadFromPluginJar(pluginId, jarPath) — opens ONE specific plug-in JAR via java.util.jar.JarFile and walks its entries directly. This is critical: a plug-in's PluginClassLoader is parent-first, so a classpath*: scan against it would ALSO pick up the host's metadata files via parent classpath. We saw this in the first smoke run — the plug-in source ended up with 6 entities (the plug-in's 2 + the host's 4) before the fix. Walking the JAR file directly guarantees only the plug-in's own files load. Tagged source='plugin:<id>'. Both entry points use the same delete-then-insert idempotent core (doLoad). Loading the same source twice produces the same final state. User-edited metadata (source='user') is NEVER touched by either path — it survives boot, plug-in install, and plug-in upgrade. This is what lets a future SPA "Customize" UI add custom fields without fearing they'll be wiped on the next deploy. * `VibeErpPluginManager.afterPropertiesSet()` now calls metadataLoader.loadCore() at the very start, then walks plug-ins and calls loadFromPluginJar(...) for each one between Liquibase migration and start(context). Order is guaranteed: core → linter → migrate → metadata → start. The CommandLineRunner I originally put `loadCore()` in turned out to be wrong because Spring runs CommandLineRunners AFTER InitializingBean.afterPropertiesSet(), so the plug-in metadata was loading BEFORE core — the wrong way around. Calling loadCore() inline in the plug-in manager fixes the ordering without any @Order(...) gymnastics. * `MetadataController` exposes: GET /api/v1/_meta/metadata — all three sections GET /api/v1/_meta/metadata/entities — entities only GET /api/v1/_meta/metadata/permissions GET /api/v1/_meta/metadata/menus Public allowlist (covered by the existing /api/v1/_meta/** rule in SecurityConfiguration). The metadata is intentionally non- sensitive — entity names, permission keys, menu paths. Nothing in here is PII or secret; the SPA needs to read it before the user has logged in. * YAML files shipped: - pbc-identity/META-INF/vibe-erp/metadata/identity.yml (User + Role entities, 6 permissions, Users + Roles menus) - pbc-catalog/META-INF/vibe-erp/metadata/catalog.yml (Item + Uom entities, 7 permissions, Items + UoMs menus) - reference plug-in/META-INF/vibe-erp/metadata/printing-shop.yml (Plate + InkRecipe entities, 5 permissions, Plates + Inks menus in a "Printing shop" section) Tests: 4 MetadataLoaderTest cases (loadFromPluginJar happy paths, mixed sections, blank pluginId rejection, missing-file no-op wipe) + 7 MetadataYamlParseTest cases (DTO mapping, optional fields, section defaults, forward-compat unknown keys). Total now **92 unit tests** across 11 modules, all green. End-to-end smoke test against fresh Postgres + plug-in loaded: Boot logs: MetadataLoader: source='core' loaded 4 entities, 13 permissions, 4 menus from 2 file(s) MetadataLoader: source='plugin:printing-shop' loaded 2 entities, 5 permissions, 2 menus from 1 file(s) HTTP smoke (everything green): GET /api/v1/_meta/metadata (no auth) → 200 6 entities, 18 permissions, 6 menus entity names: User, Role, Item, Uom, Plate, InkRecipe menu sections: Catalog, Printing shop, System GET /api/v1/_meta/metadata/entities → 200 GET /api/v1/_meta/metadata/menus → 200 Direct DB verification: metadata__entity: core=4, plugin:printing-shop=2 metadata__permission: core=13, plugin:printing-shop=5 metadata__menu: core=4, plugin:printing-shop=2 Idempotency: restart the app, identical row counts. Existing endpoints regression: GET /api/v1/identity/users (Bearer) → 1 user GET /api/v1/catalog/uoms (Bearer) → 15 UoMs GET /api/v1/plugins/printing-shop/ping (Bearer) → 200 Bugs caught and fixed during the smoke test: • The first attempt loaded core metadata via a CommandLineRunner annotated @Order(HIGHEST_PRECEDENCE) and per-plug-in metadata inline in VibeErpPluginManager.afterPropertiesSet(). Spring runs all InitializingBeans BEFORE any CommandLineRunner, so the plug-in metadata loaded first and the core load came second — wrong order. Fix: drop CoreMetadataInitializer entirely; have the plug-in manager call metadataLoader.loadCore() directly at the start of afterPropertiesSet(). • The first attempt's plug-in load used metadataLoader.load(pluginClassLoader, ...) which used Spring's PathMatchingResourcePatternResolver against the plug-in's classloader. PluginClassLoader is parent-first, so the resolver enumerated BOTH the plug-in's own JAR AND the host classpath's metadata files, tagging core entities as source='plugin:<id>' and corrupting the seed counts. Fix: refactor MetadataLoader to expose loadFromPluginJar(pluginId, jarPath) which opens the plug-in JAR directly via java.util.jar.JarFile and walks its entries — never asking the classloader at all. The api-v1 surface didn't change. • Two KDoc comments contained the literal string `*.yml` after a `/` character (`/metadata/*.yml`), forming the `/*` pattern that Kotlin's lexer treats as a nested-comment opener. The file failed to compile with "Unclosed comment". This is the third time I've hit this trap; rewriting both KDocs to avoid the literal `/*` sequence. • The MetadataLoaderTest's hand-rolled JAR builder didn't include explicit directory entries for parent paths. Real Gradle JARs do include them, and Spring's PathMatchingResourcePatternResolver needs them to enumerate via classpath*:. Fixed the test helper to write directory entries for every parent of each file. Implementation plan refreshed: P1.5 marked DONE. Next priority candidates: P5.2 (pbc-partners — third PBC clone) and P3.4 (custom field application via the ext jsonb column, which would unlock the full Tier 1 customization story). Framework state: 17→18 commits, 10→11 modules, 81→92 unit tests, metadata seeded for 6 entities + 18 permissions + 6 menus. -
Adds the framework's event bus, the second cross-cutting service (after auth) that PBCs and plug-ins both consume. Implements the transactional outbox pattern from the architecture spec section 9 — events are written to the database in the same transaction as the publisher's domain change, so a publish followed by a rollback never escapes. This is the seam where a future Kafka/NATS bridge plugs in WITHOUT touching any PBC code. What landed: * New `platform/platform-events/` module: - `EventOutboxEntry` JPA entity backed by `platform__event_outbox` (id, event_id, topic, aggregate_type, aggregate_id, payload jsonb, status, attempts, last_error, occurred_at, dispatched_at, version). Status enum: PENDING / DISPATCHED / FAILED. - `EventOutboxRepository` Spring Data JPA repo with a pessimistic SELECT FOR UPDATE query for poller dispatch. - `ListenerRegistry` — in-memory subscription holder, indexed both by event class (Class.isInstance) and by topic string. Supports a `**` wildcard for the platform's audit subscriber. Backed by CopyOnWriteArrayList so dispatch is lock-free. - `EventBusImpl` — implements the api.v1 EventBus. publish() writes the outbox row AND synchronously delivers to in-process listeners in the SAME transaction. Marked Propagation.MANDATORY so the bus refuses to publish outside an existing transaction (preventing publish-and-rollback leaks). Listener exceptions are caught and logged; the outbox row still commits. - `OutboxPoller` — Spring @Scheduled component that runs every 5s, drains PENDING / FAILED rows under a pessimistic lock, marks them DISPATCHED. v0.5 has no real external dispatcher — the poller is the seam where Kafka/NATS plugs in later. - `EventBusConfiguration` — @EnableScheduling so the poller actually runs. Lives in this module so the seam activates automatically when platform-events is on the classpath. - `EventAuditLogSubscriber` — wildcard subscriber that logs every event at INFO. Demo proof that the bus works end-to-end. Future versions replace it with a real audit log writer. * `platform__event_outbox` Liquibase changeset (platform-events-001): table + unique index on event_id + index on (status, created_at) + index on topic. * DefaultPluginContext.eventBus is no longer a stub that throws — it's now the real EventBus injected by VibeErpPluginManager. Plug-ins can publish and subscribe via the api.v1 surface. Note: subscriptions are NOT auto-scoped to the plug-in lifecycle in v0.5; a plug-in that wants its subscriptions removed on stop() must call subscription.close() explicitly. Auto-scoping lands when per-plug-in Spring child contexts ship. * pbc-identity now publishes `UserCreatedEvent` after a successful UserService.create(). The event class is internal to pbc-identity (not in api.v1) — other PBCs subscribe by topic string (`identity.user.created`), not by class. This is the right tradeoff: string topics are stable across plug-in classloaders, class equality is not, and adding every event class to api.v1 would be perpetual surface-area bloat. Tests: 13 new unit tests (9 EventBusImplTest + 4 OutboxPollerTest) plus 2 new UserServiceTest cases that verify the publish happens on the happy path and does NOT happen when create() rejects a duplicate. Total now 76 unit tests across the framework, all green. End-to-end smoke test against fresh Postgres with the plug-in loaded (everything green): EventAuditLogSubscriber subscribed to ** at boot Outbox empty before any user create ✓ POST /api/v1/auth/login → 200 POST /api/v1/identity/users (create alice) → 201 Outbox row appears with topic=identity.user.created, status=PENDING immediately after create ✓ EventAuditLogSubscriber log line fires synchronously inside the create transaction ✓ POST /api/v1/identity/users (create bob) → 201 Wait 8s (one OutboxPoller cycle) Both outbox rows now DISPATCHED, dispatched_at set ✓ Existing PBCs still work: GET /api/v1/identity/users → 3 users ✓ GET /api/v1/catalog/uoms → 15 UoMs ✓ Plug-in still works: GET /api/v1/plugins/printing-shop/ping → 200 ✓ The most important assertion is the synchronous audit log line appearing on the same thread as the user creation request. That proves the entire chain — UserService.create() → eventBus.publish() → EventBusImpl writes outbox row → ListenerRegistry.deliver() finds wildcard subscriber → EventAuditLogSubscriber.handle() logs — runs end-to-end inside the publisher's transaction. The poller flipping PENDING → DISPATCHED 5s later proves the outbox + poller seam works without any external dispatcher. Bug encountered and fixed during the smoke test: • EventBusImplTest used `ObjectMapper().registerKotlinModule()` which doesn't pick up jackson-datatype-jsr310. Production code uses Spring Boot's auto-configured ObjectMapper which already has jsr310 because spring-boot-starter-web is on the classpath of distribution. The test setup was the only place using a bare mapper. Fixed by switching to `findAndRegisterModules()` AND by adding jackson-datatype-jsr310 as an explicit implementation dependency of platform-events (so future modules that depend on the bus without bringing web in still get Instant serialization). What is explicitly NOT in this chunk: • External dispatcher (Kafka/NATS bridge) — the poller is a no-op that just marks rows DISPATCHED. The seam exists; the dispatcher is a future P1.7.b unit. • Exponential backoff on FAILED rows — every cycle re-attempts. Real backoff lands when there's a real dispatcher to fail. • Dead-letter queue — same. • Per-plug-in subscription auto-scoping — plug-ins must close() explicitly today. • Async / fire-and-forget publish — synchronous in-process only. -
The reference printing-shop plug-in now actually does something: its main class registers two HTTP endpoints during start(context), and a real curl to /api/v1/plugins/printing-shop/ping returns the JSON the plug-in's lambda produced. End-to-end smoke test 10/10 green. This is the chunk that turns vibe_erp from "an ERP app that has a plug-in folder" into "an ERP framework whose plug-ins can serve traffic". What landed: * api.v1 — additive (binary-compatible per the api.v1 stability rule): - org.vibeerp.api.v1.plugin.HttpMethod (enum) - org.vibeerp.api.v1.plugin.PluginRequest (path params, query, body) - org.vibeerp.api.v1.plugin.PluginResponse (status + body) - org.vibeerp.api.v1.plugin.PluginEndpointHandler (fun interface) - org.vibeerp.api.v1.plugin.PluginEndpointRegistrar (per-plugin scoped, register(method, path, handler)) - PluginContext.endpoints getter with default impl that throws UnsupportedOperationException so the addition is binary-compatible with plug-ins compiled against earlier api.v1 builds. * platform-plugins — three new files: - PluginEndpointRegistry: process-wide registration storage. Uses Spring's AntPathMatcher so {var} extracts path variables. Synchronized mutation. Exact-match fast path before pattern loop. Rejects duplicate (method, path) per plug-in. unregisterAll(plugin) on shutdown. - ScopedPluginEndpointRegistrar: per-plugin wrapper that tags every register() call with the right plugin id. Plug-ins cannot register under another plug-in's namespace. - PluginEndpointDispatcher: single Spring @RestController at /api/v1/plugins/{pluginId}/** that catches GET/POST/PUT/PATCH/DELETE, asks the registry for a match, builds a PluginRequest, calls the handler, serializes the response. 404 on no match, 500 on handler throw (logged with stack trace). - DefaultPluginContext: implements PluginContext with a real SLF4J-backed logger (every line tagged with the plug-in id) and the scoped endpoint registrar. The other six services (eventBus, transaction, translator, localeProvider, permissionCheck, entityRegistry) throw UnsupportedOperationException with messages pointing at the implementation plan unit that will land each one. Loud failure beats silent no-op. * VibeErpPluginManager — after PF4J's startPlugins() now walks every loaded plug-in, casts the wrapper instance to api.v1.plugin.Plugin, and calls start(context) with a freshly-built DefaultPluginContext. Tracks the started set so destroy() can call stop() and unregisterAll() in reverse order. Catches plug-in start failures loudly without bringing the framework down. * Reference plug-in (PrintingShopPlugin): - Now extends BOTH org.pf4j.Plugin (so PF4J's loader can instantiate it via the Plugin-Class manifest entry) AND org.vibeerp.api.v1.plugin.Plugin (so the host's vibe_erp lifecycle hook can call start(context)). Uses Kotlin import aliases to disambiguate the two `Plugin` simple names. - In start(context), registers two endpoints: GET /ping — returns {plugin, version, ok, message} GET /echo/{name} — extracts path variable, echoes it back - The /echo handler proves path-variable extraction works end-to-end. * Build infrastructure: - reference-customer/plugin-printing-shop now has an `installToDev` Gradle task that builds the JAR and stages it into <repo>/plugins-dev/. The task wipes any previous staged copies first so renaming the JAR on a version bump doesn't leave PF4J trying to load two versions. - distribution's `bootRun` task now (a) depends on `installToDev` so the staging happens automatically and (b) sets workingDir to the repo root so application-dev.yaml's relative `vibeerp.plugins.directory: ./plugins-dev` resolves to the right place. Without (b) bootRun's CWD was distribution/ and PF4J found "No plugins" — which is exactly the bug that surfaced in the first smoke run. - .gitignore now excludes /plugins-dev/ and /files-dev/. Tests: 12 new unit tests for PluginEndpointRegistry covering literal paths, single/multi path variables, duplicate registration rejection, literal-vs-pattern precedence, cross-plug-in isolation, method matching, and unregisterAll. Total now 61 unit tests across the framework, all green. End-to-end smoke test against fresh Postgres + the plug-in JAR loaded by PF4J at boot (10/10 passing): GET /api/v1/plugins/printing-shop/ping (no auth) → 401 POST /api/v1/auth/login → access token GET /api/v1/plugins/printing-shop/ping (Bearer) → 200 {plugin, version, ok, message} GET /api/v1/plugins/printing-shop/echo/hello → 200, echoed=hello GET /api/v1/plugins/printing-shop/echo/world → 200, echoed=world GET /api/v1/plugins/printing-shop/nonexistent → 404 (no handler) GET /api/v1/plugins/missing-plugin/ping → 404 (no plugin) POST /api/v1/plugins/printing-shop/ping → 404 (wrong method) GET /api/v1/catalog/uoms (Bearer) → 200, 15 UoMs GET /api/v1/identity/users (Bearer) → 200, 1 user PF4J resolved the JAR, started the plug-in, the host called vibe_erp's start(context), the plug-in registered two endpoints, and the dispatcher routed real HTTP traffic to the plug-in's lambdas. The boot log shows the full chain. What is explicitly NOT in this chunk and remains for later: • plug-in linter (P1.2) — bytecode scan for forbidden imports • plug-in Liquibase application (P1.4) — plug-in-owned schemas • per-plug-in Spring child context — currently we just instantiate the plug-in via PF4J's classloader; there is no Spring context for the plug-in's own beans • PluginContext.eventBus / transaction / translator / etc. — they still throw UnsupportedOperationException with TODO messages • Path-template precedence between multiple competing patterns (only literal-beats-pattern is implemented, not most-specific-pattern) • Permission checks at the dispatcher (Spring Security still catches plug-in endpoints with the global "anyRequest authenticated" rule, which is the right v0.5 behavior) • Hot reload of plug-ins (cold restart only) Bug encountered and fixed during the smoke test: • application-dev.yaml has `vibeerp.plugins.directory: ./plugins-dev`, a relative path. Gradle's `bootRun` task by default uses the subproject's directory as the working directory, so the relative path resolved to <repo>/distribution/plugins-dev/ instead of <repo>/plugins-dev/. PF4J reported "No plugins" because that directory was empty. Fixed by setting bootRun.workingDir = rootProject.layout.projectDirectory.asFile. • One KDoc comment in PluginEndpointDispatcher contained the literal string `/api/v1/plugins/{pluginId}/**` inside backticks. The Kotlin lexer doesn't treat backticks as comment-suppressing, so `/**` opened a nested KDoc comment that was never closed and the file failed to compile. Same root cause as the AuthController bug earlier in the session. Rewrote the line to avoid the literal `/**` sequence. -
Adds the second core PBC, validating that the pbc-identity template is actually clonable and that the Gradle dependency rule fires correctly for a real second PBC. What landed: * New `pbc/pbc-catalog/` Gradle subproject. Same shape as pbc-identity: api-v1 + platform-persistence + platform-security only (no platform-bootstrap, no other pbc). The architecture rule in the root build.gradle.kts now has two real PBCs to enforce against. * `Uom` entity (catalog__uom) — code, name, dimension, ext jsonb. Code is the natural key (stable, human-readable). UomService rejects duplicate codes and refuses to update the code itself (would invalidate every Item FK referencing it). UomController at /api/v1/catalog/uoms exposes list, get-by-id, get-by-code, create, update. * `Item` entity (catalog__item) — code, name, description, item_type (GOOD/SERVICE/DIGITAL enum), base_uom_code FK, active flag, ext jsonb. ItemService validates the referenced UoM exists at the application layer (better error message than the DB FK alone), refuses to update code or baseUomCode (data-migration operations, not edits), supports soft delete via deactivate. ItemController at /api/v1/catalog/items with full CRUD. * `org.vibeerp.api.v1.ext.catalog.CatalogApi` — second cross-PBC facade in api.v1 (after IdentityApi). Exposes findItemByCode(code) and findUomByCode(code) returning safe ItemRef/UomRef DTOs. Inactive items are filtered to null at the boundary so callers cannot accidentally reference deactivated catalog rows. * `CatalogApiAdapter` in pbc-catalog — concrete @Component implementing CatalogApi. Maps internal entities to api.v1 DTOs without leaking storage types. * Liquibase changeset (catalog-init-001..003) creates both tables with unique indexes on code, GIN indexes on ext, and seeds 15 canonical units of measure: kg/g/t (mass), m/cm/mm/km (length), m2 (area), l/ml (volume), ea/sheet/pack (count), h/min (time). Tagged created_by='__seed__' so a future metadata uninstall sweep can identify them. Tests: 11 new unit tests (UomServiceTest x5, ItemServiceTest x6), total now 49 unit tests across the framework, all green. End-to-end smoke test against fresh Postgres via docker-compose (14/14 passing): GET /api/v1/catalog/items (no auth) → 401 POST /api/v1/auth/login → access token GET /api/v1/catalog/uoms (Bearer) → 15 seeded UoMs GET /api/v1/catalog/uoms/by-code/kg → 200 POST custom UoM 'roll' → 201 POST duplicate UoM 'kg' → 400 + clear message GET items → [] POST item with unknown UoM → 400 + clear message POST item with valid UoM → 201 catalog__item.created_by → admin user UUID (NOT __system__) GET /by-code/INK-CMYK-CYAN → 200 PATCH item name + description → 200 DELETE item → 204 GET item → active=false The principal-context bridge from P4.1 keeps working without any additional wiring in pbc-catalog: every PBC inherits the audit behavior for free by extending AuditedJpaEntity. That is exactly the "PBCs follow a recipe, the framework provides the cross-cutting machinery" promise from the architecture spec. Architectural rule enforcement still active: confirmed by reading the build.gradle.kts and observing that pbc-catalog declares no :platform:platform-bootstrap and no :pbc:pbc-identity dependency. The build refuses to load on either violation.
-
Implements the auth unit from the implementation plan. Until now, the framework let any caller hit any endpoint; with the single-tenant refactor there is no second wall, so auth was the most pressing gap. What landed: * New `platform-security` module owns the framework's security primitives (JWT issuer/verifier, password encoder, Spring Security filter chain config, AuthenticationFailedException). Lives between platform-persistence and platform-bootstrap. * `JwtIssuer` mints HS256-signed access (15min) and refresh (7d) tokens via NimbusJwtEncoder. `JwtVerifier` decodes them back to a typed `DecodedToken` so PBCs never need to import OAuth2 types. JWT secret is read from VIBEERP_JWT_SECRET; the framework refuses to start if the secret is shorter than 32 bytes. * `SecurityConfiguration` wires Spring Security with JWT resource server, stateless sessions, CSRF disabled, and a public allowlist for /actuator/health, /actuator/info, /api/v1/_meta/**, /api/v1/auth/login, /api/v1/auth/refresh. * `PrincipalContext` (in platform-persistence/security) is the bridge between Spring Security's SecurityContextHolder and the audit listener. Bound by `PrincipalContextFilter` which runs AFTER BearerTokenAuthenticationFilter so SecurityContextHolder is fully populated. The audit listener (AuditedJpaEntityListener) now reads from PrincipalContext, so created_by/updated_by are real user ids instead of __system__. * `pbc-identity` gains `UserCredential` (separate table from User — password hashes never share a query plan with user records), `AuthService` (login + refresh, generic AuthenticationFailedException on every failure to thwart account enumeration), and `AuthController` exposing /api/v1/auth/login and /api/v1/auth/refresh. * `BootstrapAdminInitializer` runs on first boot of an empty identity__user table, creates an `admin` user with a random 16-char password printed to the application logs. Subsequent boots see the user exists and skip silently. * GlobalExceptionHandler maps AuthenticationFailedException → 401 with a generic "invalid credentials" body (RFC 7807 ProblemDetail). * New module also brings BouncyCastle as a runtime-only dep (Argon2PasswordEncoder needs it). Tests: 38 unit tests pass, including JwtRoundTripTest (issue/decode round trip + tamper detection + secret-length validation), PrincipalContextTest (ThreadLocal lifecycle), AuthServiceTest (9 cases covering login + refresh happy paths and every failure mode). End-to-end smoke test against a fresh Postgres via docker-compose: GET /api/v1/identity/users (no auth) → 401 POST /api/v1/auth/login (admin + bootstrap) → 200 + access/refresh POST /api/v1/auth/login (wrong password) → 401 GET /api/v1/identity/users (Bearer) → 200, lists admin POST /api/v1/identity/users (Bearer) → 201, creates alice alice.created_by → admin's user UUID POST /api/v1/auth/refresh (refresh token) → 200 + new pair POST /api/v1/auth/refresh (access token) → 401 (type mismatch) GET /api/v1/identity/users (garbage token) → 401 GET /api/v1/_meta/info (no auth, public) → 200 Plan: docs/superpowers/specs/2026-04-07-vibe-erp-implementation-plan.md refreshed to drop the now-dead P1.1 (RLS hook) and H1 (per-region tenant routing), reorder priorities so P4.1 is first, and reflect the single-tenant change throughout. Bug fixes encountered along the way (caught by the smoke test, not by unit tests — the value of running real workflows): • JwtIssuer was producing IssuedToken.expiresAt with nanosecond precision but JWT exp is integer seconds; the round-trip test failed equality. Fixed by truncating to ChronoUnit.SECONDS at issue time. • PrincipalContextFilter was registered with addFilterAfter UsernamePasswordAuthenticationFilter, which runs BEFORE the OAuth2 BearerTokenAuthenticationFilter, so SecurityContextHolder was empty when the bridge filter read it. Result: every authenticated request still wrote __system__ in audit columns. Fixed by addFilterAfter BearerTokenAuthenticationFilter::class. • RefreshRequest is a single-String data class. jackson-module-kotlin interprets single-arg data classes as delegate-based creators, so Jackson tried to deserialize the entire JSON object as a String and threw HttpMessageNotReadableException. Fixed by adding @JsonCreator(mode = PROPERTIES) + @param:JsonProperty. -
Design change: vibe_erp deliberately does NOT support multiple companies in one process. Each running instance serves exactly one company against an isolated Postgres database. Hosting many customers means provisioning many independent instances, not multiplexing them. Why: most ERP/EBC customers will not accept a SaaS where their data shares a database with other companies. The single-tenant-per-instance model is what the user actually wants the product to look like, and it dramatically simplifies the framework. What changed: - CLAUDE.md guardrail #5 rewritten from "multi-tenant from day one" to "single-tenant per instance, isolated database" - api.v1: removed TenantId value class entirely; removed tenantId from Entity, AuditedEntity, Principal, DomainEvent, RequestContext, TaskContext, IdentityApi.UserRef, Repository - platform-persistence: deleted TenantContext, HibernateTenantResolver, TenantAwareJpaTransactionManager, TenancyJpaConfiguration; removed @TenantId and tenant_id column from AuditedJpaEntity - platform-bootstrap: deleted TenantResolutionFilter; dropped vibeerp.instance.mode and default-tenant from properties; added vibeerp.instance.company-name; added VibeErpApplication @EnableJpaRepositories and @EntityScan so PBC repositories outside the main package are wired; added GlobalExceptionHandler that maps IllegalArgumentException → 400 and NoSuchElementException → 404 (RFC 7807 ProblemDetail) - pbc-identity: removed tenant_id from User, repository, controller, DTOs, IdentityApiAdapter; updated UserService duplicate-username message and the matching test - distribution: dropped multiTenancy=DISCRIMINATOR and tenant_identifier_resolver from application.yaml; configured Spring Boot mainClass on the springBoot extension (not just bootJar) so bootRun works - Liquibase: rewrote platform-init changelog to drop platform__tenant and the tenant_id columns on every metadata__* table; rewrote pbc-identity init to drop tenant_id columns, the (tenant_id, *) composite indexes, and the per-table RLS policies - IdentifiersTest replaced with Id<T> tests since the TenantId tests no longer apply Verified end-to-end against a real Postgres via docker-compose: POST /api/v1/identity/users → 201 Created GET /api/v1/identity/users → list works GET /api/v1/identity/users/X → fetch by id works POST duplicate username → 400 Bad Request (was 500) PATCH bogus id → 404 Not Found (was 500) PATCH alice → 200 OK DELETE alice → 204, alice now disabled All 18 unit tests pass.
-
BLOCKER: wire Hibernate multi-tenancy - application.yaml: set hibernate.tenant_identifier_resolver and hibernate.multiTenancy=DISCRIMINATOR so HibernateTenantResolver is actually installed into the SessionFactory - AuditedJpaEntity.tenantId: add @org.hibernate.annotations.TenantId so every PBC entity inherits the discriminator - AuditedJpaEntityListener.onCreate: throw if a caller pre-set tenantId to a different value than the current TenantContext, instead of silently overwriting (defense against cross-tenant write bugs) IMPORTANT: dependency hygiene - pbc-identity no longer depends on platform-bootstrap (wrong direction; bootstrap assembles PBCs at the top of the stack) - root build.gradle.kts: tighten the architectural-rule enforcement to also reject :pbc:* -> platform-bootstrap; switch plug-in detection from a fragile pathname heuristic to an explicit extra["vibeerp.module-kind"] = "plugin" marker; reference plug-in declares the marker IMPORTANT: api.v1 surface additions (all non-breaking) - Repository: documented closed exception set; new PersistenceExceptions.kt declares OptimisticLockConflictException, UniqueConstraintViolationException, EntityValidationException, and EntityNotFoundException so plug-ins never see Hibernate types - TaskContext: now exposes tenantId(), principal(), locale(), correlationId() so workflow handlers (which run outside an HTTP request) can pass tenant-aware calls back into api.v1 - EventBus: subscribe() now returns a Subscription with close() so long-lived subscribers can deregister explicitly; added a subscribe(topic: String, ...) overload for cross-classloader event routing where Class<E> equality is unreliable - IdentityApi.findUserById: tightened from Id<*> to PrincipalId so the type system rejects "wrong-id-kind" mistakes at the cross-PBC boundary NITs: - HealthController.kt -> MetaController.kt (file name now matches the class name); added TODO(v0.2) for reading implementationVersion from the Spring Boot BuildProperties bean