-
Add three write controllers behind @RequirePermission("admin.metadata.write"): - FormDefinitionController (PUT/DELETE /api/v1/_meta/metadata/forms/{slug}) - ListViewDefinitionController (PUT/DELETE /api/v1/_meta/metadata/list-views/{slug}) - CustomFieldWriteController (POST/PUT/DELETE /api/v1/_meta/metadata/custom-fields) All three enforce source='user' immutability: rows seeded by core or plug-in YAMLs cannot be modified or deleted through the REST surface. CustomFieldWriteController calls CustomFieldRegistry.refresh() after every successful write so the in-memory index stays current. MetadataController gains GET endpoints for forms, list views (including by-slug lookup), and the aggregate /metadata response now includes forms and listViews sections. Ships platform-metadata.yml declaring admin.metadata.read/write permissions and a Metadata Admin menu entry. 22 unit tests cover the full CRUD + source-enforcement matrix. -
9 tasks: backend YAML/loader extension, CRUD endpoints with source enforcement, @rjsf form renderer + custom widgets, form designer, list view designer, metadata admin tabbed UI, smoke test + version bump.
-
Sub-project A of remaining v1.0 work. Key decisions: - Hybrid: core forms stay handcrafted, renderer for user-task forms - @rjsf/core with custom ERP widget registry - Structured property editor (not drag-and-drop) for form designer - Custom entities deferred to v1.1
-
CLAUDE.md "Repository state" was stale (18→25 subprojects, 246→356 tests, 8→10 PBCs, 9→12 platform services). PROGRESS.md "What's not yet live" still listed Flowable, JasperReports, file store, job scheduler, OIDC, and web SPA as missing — all are live. "Current stage" paragraph updated to reflect 10 PBCs and 12 services. "How to run" section updated to remove DemoSeedRunner reference (moved to demo branch).
-
Tier 1 customization comes alive in the SPA: custom fields declared in YAML metadata now render automatically in create forms without any compile-time knowledge of the field. New component: DynamicExtFields - Fetches custom field declarations from the existing /api/v1/_meta/metadata/custom-fields/{entityName} endpoint - Renders one input per declared field, type-matched: string → text, integer → number (step=1), decimal/money/ quantity → number (step=0.01), boolean → checkbox, date → date picker, dateTime → datetime-local, enum → select dropdown, uuid → text - Labels resolve from labelTranslations using the active locale (i18n integration) - Required fields show a red asterisk - Values are collected in the ext map and sent with the create request Wired into: CreateItemPage (entityName="Item"), CreatePartnerPage (entityName="Partner"). Both now show a "Custom fields" section below the static fields when the entity has custom field declarations in metadata. No new backend code — the existing /api/v1/_meta/metadata/ custom-fields endpoint already returns exactly the shape the component needs. This is P3.1: the runtime form renderer for Tier 1 customization. -
Adds edit pages for items and partners — the two entities operators update most often. Each form loads the existing record, pre-fills all editable fields, and PATCHes on save. Code and baseUomCode are read-only after creation (by design). New pages: - EditItemPage: name, type, description, active toggle - EditPartnerPage: name, type, email, phone API client: catalog.updateItem, partners.update (PATCH). List pages: item/partner codes are now clickable links to the edit page instead of plain text. Routes wired at /items/:id/edit and /partners/:id/edit.
-
Adds client-side i18n infrastructure to the SPA (CLAUDE.md guardrail #6: global/i18n from day one). New files: - i18n/messages.ts: flat key-value message bundles for en-US and zh-CN. Keys use dot-notation (nav.*, action.*, status.*, label.*). ~80 keys per locale covering navigation, actions, status badges, and common labels. - i18n/LocaleContext.tsx: LocaleProvider + useT() hook + useLocale() hook. Active locale stored in localStorage, defaults to the browser's navigator.language. Auto-detects zh-* → zh-CN. Wired into the SPA: - main.tsx wraps the app in <LocaleProvider> - AppLayout sidebar uses t(key) for every heading and item - Top bar has a locale dropdown (English / 中文) that switches the entire sidebar + status labels instantly - StatusBadge uses t('status.DRAFT') etc. so statuses render as '草稿' / '已确认' / '已发货' in Chinese The i18n system is intentionally simple: plain strings, no ICU MessageFormat patterns (those live on the backend via ICU4J). A future chunk can adopt @formatjs/intl-messageformat if the SPA needs plural/gender/number formatting client-side. Not yet translated: page-level titles and form labels (they still use hard-coded English). The infrastructure is in place; translating individual pages is incremental. -
Removes demo-specific code from the main (framework) branch so main stays a clean, generic foundation. The demo branch retains these files from before this commit. Removed from main: - DemoSeedRunner.kt (printing-company seed data) - vibeerp.demo.seed config in application-dev.yaml - EBC-PP-001 demo walkthrough in DashboardPage The dashboard now shows a generic "Getting started" guide that walks operators through setting up master data, creating orders, and walking the buy-make-sell loop — without referencing any specific customer or seed data. To run the printing-company demo, use the demo worktree: cd ~/Desktop/vibe_erp_demo ./gradlew :distribution:bootRun
-
JournalEntriesPage now renders expandable debit/credit lines for each entry. Click a row to toggle the line detail view showing account code, DR/CR amounts, and description. Types updated: JournalEntry now includes lines array with JournalEntryLine (lineNo, accountCode, debit, credit, description). This makes the GL growth visible in the demo — confirming an SO shows the AR entry with DR 1100 / CR 4100 balanced lines inline.
-
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. -
Pins today's 5 feature commits: - 25353240 SPA CRUD forms (item, partner, PO, WO) - 82c5267d R2 identity screens (users, roles, assignment) - c2fab13b S3 file backend (P1.9 complete) - 6ad72c7c OIDC federation (P4.2 complete) - 17771894 SPA fill (create location, adjust stock) P1.9 promoted from Partial to DONE. P4.2 promoted from Pending to DONE. R2 promoted from Pending to DONE. R4 updated to reflect create forms for every manageable entity. Version bumped 0.29.0 -> 0.30.0-SNAPSHOT.
-
Two more operator-facing forms: - CreateLocationPage: code, name, type (WAREHOUSE/BIN/VIRTUAL) - AdjustStockPage: item dropdown, location dropdown, absolute quantity. Creates the balance row if absent; sets it to the given value if present. Shows the resulting balance inline. API client: inventory.createLocation, inventory.adjustBalance. Locations list gets "+ New Location"; Balances list gets "Adjust Stock". Routes wired at /locations/new and /balances/adjust. With this commit, every PBC entity that operators need to create or manage has a SPA form: items, partners, locations, stock balances, sales orders, purchase orders, work orders (with BOM + routing), users, and roles. The only create-less entities are journal entries (read-only, event-driven) and stock movements (append-only ledger). -
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. -
Closes the R2 gap: an admin can now manage users and roles entirely from the SPA without touching curl or Swagger UI. Backend (pbc-identity): - New RoleService with createRole, assignRole, revokeRole, findUserRoleCodes, listRoles. Each method validates existence + idempotency (duplicate assignment rejected, missing role rejected). - New RoleController at /api/v1/identity/roles (CRUD) + /api/v1/identity/users/{userId}/roles/{roleCode} (POST assign, DELETE revoke). All permission-gated: identity.role.read, identity.role.create, identity.role.assign. - identity.yml updated: added identity.role.create permission. SPA (web/): - UsersPage — list with username link to detail, "+ New User" - CreateUserPage — username, display name, email form - UserDetailPage — shows user info + role toggle list. Each role has an Assign/Revoke button that takes effect on the user's next login (JWT carries roles from login time). - RolesPage — list with inline create form (code + name) - Sidebar gains "System" section with Users + Roles links - API client + types: identity.listUsers, getUser, createUser, listRoles, createRole, getUserRoles, assignRole, revokeRole Infrastructure: - SpaController: added /users/** and /roles/** forwarding - SecurityConfiguration: added /users/** and /roles/** to the SPA permitAll block -
Extends the R1 SPA with create forms for the four entities operators interact with most. Each page follows the same pattern proven by CreateSalesOrderPage: a card-scoped form with dropdowns populated from the API, inline validation, and a redirect to the detail or list page on success. New pages: - CreateItemPage — code, name, type (GOOD/SERVICE/DIGITAL), UoM dropdown populated from /api/v1/catalog/uoms - CreatePartnerPage — code, name, type (CUSTOMER/SUPPLIER/BOTH), optional email + phone - CreatePurchaseOrderPage — symmetric to CreateSalesOrderPage; supplier dropdown filtered to SUPPLIER/BOTH partners, optional expected date, dynamic line items - CreateWorkOrderPage — output item + quantity + optional due date, dynamic BOM inputs (item + qty/unit + source location dropdown), dynamic routing operations (op code + work center + std minutes). The most complex form in the SPA — matches the EBC-PP-001 work order creation flow API client additions: catalog.createItem, partners.create, purchaseOrders.create, production.createWorkOrder — each a typed wrapper around POST to the corresponding endpoint. List pages updated: Items, Partners, Purchase Orders, Work Orders all now show a "+ New" button in the PageHeader that links to the create form. Routes wired: /items/new, /partners/new, /purchase-orders/new, /work-orders/new — all covered by the existing SpaController wildcard patterns and SecurityConfiguration permitAll rules. -
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.
-
Updates the "at a glance" row to v0.29.0-SNAPSHOT + fc62d6d7, bumps the Phase 6 R1 row to DONE with the commit ref and an overview of what landed (Gradle wrapper, SpaController, security reordering, bundled fat-jar, 16 pages), and rewrites the "How to run" section to walk the click-through demo instead of just curl. README's status table updated to reflect 10/10 PBCs + 356 tests + SPA status; building section now mentions that `./gradlew build` compiles the SPA too. No code changes.
-
The R1 SPA chunk added a :web Gradle subproject whose npmBuild Exec task runs Vite during :distribution:bootJar. The Dockerfile's build stage uses eclipse-temurin:21-jdk-alpine which has no node/npm, so the docker image CI job fails with: process "/bin/sh -c chmod +x ./gradlew && ./gradlew :distribution:bootJar --no-daemon" did not complete successfully: exit code: 1 Fix: apk add --no-cache nodejs npm before the Gradle build. Alpine 3.21 ships node v22 + npm 10 which Vite 5 + React 18 handle fine. The runtime stage stays a pure JRE image — node is only needed at build time and never makes it into the shipping container.
-
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 the deferred TODO from the OpenAPI commit (11bef932): every endpoint a plug-in registers via `PluginContext.endpoints.register` now shows up in the OpenAPI spec alongside the host's @RestController operations. Downstream OpenAPI clients (R1 web SPA codegen, A1 MCP server tool catalog, operator-side Swagger UI browsing) can finally see the customer-specific HTTP surface. **Problem.** springdoc's default scan walks `@RestController` beans on the host classpath. Plug-in endpoints are NOT registered that way — they live as lambdas on a single `PluginEndpointDispatcher` catch-all controller, so the default scan saw ONE dispatcher path and zero per-plug-in detail. The printing-shop plug-in's 8 endpoints were entirely invisible to the spec. **Solution: an OpenApiCustomizer bean that queries the registry at spec-build time.** 1. `PluginEndpointRegistry.snapshot()` — new public read-only view. Returns a list of `(pluginId, method, path)` tuples without exposing the handler lambdas. Taken under the registry's intrinsic lock and copied out so callers can iterate without racing plug-in (un)registration. Ordered by registration order for determinism. 2. `PluginEndpointSummary` — new public data class in platform-plugins. `pluginId` + `method` + `path` plus a `fullPath()` helper that prepends `/api/v1/plugins/<pluginId>`. 3. `PluginEndpointsOpenApiCustomizer @Component` — new class in `platform-plugins/openapi/`. Implements `org.springdoc.core.customizers.OpenApiCustomizer`. On every `/v3/api-docs` request, iterates `registry.snapshot()`, groups by full path, and attaches a `PathItem` with one `Operation` per registered HTTP verb. Each operation gets: - A tag `"Plug-in: <pluginId>"` so Swagger UI groups every plug-in's surface under a header - A `summary` + `description` naming the plug-in - Path parameters auto-extracted from `{name}` segments - A generic JSON request body for POST/PUT/PATCH - A generic 200 response + 401/403/404 error responses - The global bearerAuth security scheme (inherited from OpenApiConfiguration, no per-op annotation) 4. `compileOnly(libs.springdoc.openapi.starter.webmvc.ui)` in platform-plugins so `OpenApiCustomizer` is visible at compile time without dragging the full webmvc-ui bundle into platform-plugins' runtime classpath (distribution already pulls it in via platform-bootstrap's `implementation`). 5. `implementation(project(":platform:platform-plugins"))` added to platform-bootstrap so `OpenApiConfiguration` can inject the customizer by type and explicitly wire it to the `pluginEndpointsGroup()` `GroupedOpenApi` builder via `.addOpenApiCustomizer(...)`. **This is load-bearing** — springdoc's grouped specs run their own customizer pipeline and do NOT inherit top-level @Component OpenApiCustomizer beans. Caught at smoke-test time: initially the customizer populated the default /v3/api-docs but the /v3/api-docs/plugins group still showed only the dispatcher. Fix was making the customizer a constructor-injected dep of OpenApiConfiguration and calling `addOpenApiCustomizer` on the group builder. **What a future chunk might add** (not in this one): - Richer per-endpoint JSON Schema — v1 ships unconstrained `ObjectSchema` request/response bodies because the framework has no per-endpoint shape info at the registrar layer. A future `PluginEndpointRegistrar` overload accepting an explicit schema would let plug-ins document their payloads. - Per-endpoint `@RequirePermission` surface — the dispatcher enforces permissions at runtime but doesn't record them on the registration, so the OpenAPI spec doesn't list them. **KDoc `/**` trap caught.** A literal plug-in URL pattern in the customizer's KDoc (`/api/v1/plugins/{pluginId}/**`) tripped the Kotlin nested-comment parser again. Rephrased as "under the `/api/v1/plugins/{pluginId}` prefix" to sidestep. Third time this trap has bitten me — the workaround is in feedback memory. **HttpMethod enum caught.** Initial `when` branch on the customizer covered HEAD/OPTIONS which don't exist in the api.v1 `HttpMethod` enum (only GET/POST/PUT/PATCH/DELETE). Dropped those branches. **Smoke-tested end-to-end against real Postgres:** - GET /v3/api-docs/plugins returns 7 paths: - /api/v1/plugins/printing-shop/echo/{name} GET - /api/v1/plugins/printing-shop/inks GET, POST - /api/v1/plugins/printing-shop/ping GET - /api/v1/plugins/printing-shop/plates GET, POST - /api/v1/plugins/printing-shop/plates/{id} GET - /api/v1/plugins/printing-shop/plates/{id}/generate-quote-pdf POST - /api/v1/plugins/{pluginId}/** (dispatcher fallback) Before this chunk: only the dispatcher fallback (1 path). - Top-level /v3/api-docs now also includes the 6 printing-shop paths it previously didn't. - All 7 printing-shop endpoints remain functional at the real dispatcher (no behavior change — this is a documentation-only enhancement). 24 modules, 355 unit tests, all green.
-
Adds 15 GroupedOpenApi beans that split the single giant OpenAPI spec into per-PBC + per-platform-module focused specs selectable from Swagger UI's top-right "Select a definition" dropdown. No @RestController changes — all groups are defined by URL prefix in platform-bootstrap, so adding a new PBC means touching exactly this file (plus the controller itself). Each group stays additive alongside the default /v3/api-docs. **Groups shipped:** Platform platform-core — /api/v1/auth/**, /api/v1/_meta/** platform-workflow — /api/v1/workflow/** platform-jobs — /api/v1/jobs/** platform-files — /api/v1/files/** platform-reports — /api/v1/reports/** Core PBCs pbc-identity — /api/v1/identity/** pbc-catalog — /api/v1/catalog/** pbc-partners — /api/v1/partners/** pbc-inventory — /api/v1/inventory/** pbc-warehousing — /api/v1/warehousing/** pbc-orders — /api/v1/orders/** (sales + purchase together) pbc-production — /api/v1/production/** pbc-quality — /api/v1/quality/** pbc-finance — /api/v1/finance/** Plug-in dispatcher plugins — /api/v1/plugins/** **Why path-prefix grouping, not package-scan grouping.** Package-scan grouping would force OpenApiConfiguration to know every PBC's Kotlin package name and drift every time a PBC ships or a controller moves. Path-prefix grouping only shifts when `@RequestMapping` changes — which is already a breaking API change that would need review anyway. This keeps the control plane for grouping in one file while the routing stays in each controller. **Why pbc-orders is one group, not split sales/purchase.** Both controllers share the `/api/v1/orders/` prefix, and sales / purchase are the same shape in practice — splitting them into two groups would just duplicate the dropdown entries. A future chunk can split if a real consumer asks for it. **Primary group unchanged.** The default /v3/api-docs continues to return the full merged spec (every operation in one document). The grouped specs are additive at /v3/api-docs/<group-name> and clients can pick whichever they need. Swagger UI defaults to showing the first group in the dropdown. **Smoke-tested end-to-end against real Postgres:** - GET /v3/api-docs/swagger-config returns 15 groups with human-readable display names - Per-group path counts (confirming each group is focused): pbc-production: 10 paths pbc-catalog: 6 paths pbc-orders: 12 paths platform-core: 9 paths platform-files: 3 paths plugins: 1 path (dispatcher) - Default /v3/api-docs continues to return the full spec. 24 modules, 355 unit tests, all green. -
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 self-introspection of the framework's REST surface via springdoc-openapi. Every @RestController method in the host application is now documented in a machine-readable OpenAPI 3 spec at /v3/api-docs and rendered for humans at /swagger-ui/index.html. This is the first step toward: - R1 (web SPA): OpenAPI codegen feeds a typed TypeScript client - A1 (MCP server): discoverable tool catalog - Operator debugging: browsable "what can this instance do" page **Dependency.** New `springdoc-openapi-starter-webmvc-ui` 2.6.0 added to platform-bootstrap (not distribution) because it ships @Configuration classes that need to run inside a full Spring Boot application context AND brings a Swagger UI WebJar. platform-bootstrap is the only module with a @SpringBootApplication anyway; pbc modules never depend on it, so plug-in classloaders stay clean and the OpenAPI scanner only sees host controllers. **Configuration.** New `OpenApiConfiguration` @Configuration in platform-bootstrap provides a single @Bean OpenAPI: - Title "vibe_erp", version v0.28.0 (hardcoded; moves to a build property when a real version header ships) - Description with a framework-level intro explaining the bearer-JWT auth model, the permission whitelist, and the fact that plug-in endpoints under /api/v1/plugins/{id}/** are NOT scanned (they are dynamically registered via PluginContext.endpoints on a single dispatcher controller; a future chunk may extend the spec at runtime). - One relative server entry ("/") so the spec works behind a reverse proxy without baking localhost into it. - bearerAuth security scheme (HTTP/bearer/JWT) applied globally via addSecurityItem, so every operation in the rendered UI shows a lock icon and the "Authorize" button accepts a raw JWT (Swagger adds the "Bearer " prefix itself). **Security whitelist.** SecurityConfiguration now permits three additional path patterns without authentication: - /v3/api-docs/** — the generated JSON spec - /swagger-ui/** — the Swagger UI static assets + index - /swagger-ui.html — the legacy path (redirects to the above) The data still requires a valid JWT: an unauthenticated "Try it out" call from the Swagger UI against a pbc endpoint returns 401 exactly like a curl would. **Why not wire this into every PBC controller with @Operation / @Parameter annotations in this chunk:** springdoc already auto-generates the full path + request body + response schema from reflection. Adding hand-written annotations is scope creep — a future chunk can tag per-operation @Operation(security = ...) to surface the @RequirePermission keys once a consumer actually needs them. **Smoke-tested end-to-end against real Postgres:** - GET /v3/api-docs returns 200 with 64680 bytes of OpenAPI JSON - 76 total paths listed across every PBC controller - All v3 production paths present: /work-orders/shop-floor, /work-orders/{id}/operations/{operationId}/start + /complete, /work-orders/{id}/{start,complete,cancel,scrap} - components.securitySchemes includes bearerAuth (type=http, format=JWT) - GET /swagger-ui/index.html returns 200 with the Swagger HTML bundle (5 swagger markers found in the HTML) - GET /swagger-ui.html (legacy path) returns 200 after redirect 25 modules (unchanged count — new config lives inside platform-bootstrap), 355 unit tests, all green. -
Adds GET /api/v1/production/work-orders/shop-floor — a pure read that returns every IN_PROGRESS work order with its current operation and planned/actual time totals. Designed to feed a future shop-floor dashboard (web SPA, mobile, or an external reporting tool) without any follow-up round trips. **Service method.** `WorkOrderService.shopFloorSnapshot()` is a @Transactional(readOnly = true) query that: 1. Pulls every IN_PROGRESS work order via the existing `WorkOrderJpaRepository.findByStatus`. 2. Sorts by WO code ascending so a dashboard poll gets a stable row order. 3. For each WO picks the "current operation" = first op in IN_PROGRESS status, or, if none, first PENDING op. This captures both live states: "operator is running step N right now" and "operator just finished step N and hasn't picked up step N+1 yet". 4. Computes `totalStandardMinutes` (sum across every op) + `totalActualMinutes` (sum of completed ops' `actualMinutes` only, treating null as zero). 5. Counts completed vs total operations for a "step 2 of 5" badge. 6. Returns a list of `ShopFloorEntry` DTOs — flat structure, one row per WO, nullable `current*` fields when a WO has no routing at all (v2-compat path). **HTTP surface.** - `GET /api/v1/production/work-orders/shop-floor` - New permission `production.shop-floor.read` - Response is `List<ShopFloorEntryResponse>` — flat so a SPA can render a table without joining across nested JSON. Fields are 1:1 with the service-side `ShopFloorEntry`. **Design choices.** - Mounted under `/work-orders/shop-floor` rather than a top-level `/production/shop-floor` so every production read stays under the same permission/audit/OpenAPI root. - Read-only, zero events published, zero ledger writes. Pure projection over existing state. - Returns empty list when no WO is in-progress — the dashboard renders "no jobs running" without a special case. - Sorted by code so polling is deterministic. A future chunk might add sort-by-work-center if a dashboard needs a by-station view. **Why not a top-level "shop-floor" PBC.** A shop-floor dashboard doesn't own any state — every field it displays is projected from pbc-production. A new PBC would duplicate the data model and create a reaction loop on work order events. Keeping the read in pbc-production matches the CLAUDE.md guardrail "grow the PBC when real consumers appear, not on speculation". **Nullable `current*` fields.** A WO with an empty operations list (the v2-compat path — auto-spawned from SalesOrderConfirmedSubscriber before v3 routings) has all four `current*` fields set to null. The dashboard UI renders "no routing" or similar without any downstream round trip. **Tests (5 new).** empty snapshot when no IN_PROGRESS WOs; one entry per IN_PROGRESS WO with stable sort; current-op picks IN_PROGRESS over PENDING; current-op picks first PENDING when no op is IN_PROGRESS (between-operations state); v2-compat WO with no operations shows null current-op fields and zero time sums. **Smoke-tested end-to-end against real Postgres:** 1. Empty shop-floor initially (no IN_PROGRESS WOs) 2. Started plugin-printing-shop-quote-to-work-order BPMN with quoteCode=Q-DASH-1, quantity=500 3. Started the resulting WO — shop-floor showed currentOperationLineNo=1 (CUT @ PRINTING-CUT-01) status=PENDING, 0/4 completed, totalStandardMinutes=75, totalActualMinutes=0 4. Started op 1 — currentOperationStatus flipped to IN_PROGRESS 5. Completed op 1 with actualMinutes=17 — current op rolled forward to line 2 (PRINT @ PRINTING-PRESS-A) status=PENDING, operationsCompleted=1/4, totalActualMinutes=17 24 modules, 355 unit tests (+5), all green. -
Extends WorkOrderRequestedEvent with an optional routing so a producer — core PBC or customer plug-in — can attach shop-floor operations to a requested work order without importing any pbc-production internals. The reference printing-shop plug-in's quote-to-work-order BPMN now ships a 4-step default routing (CUT → PRINT → FOLD → BIND) end-to-end through the public api.v1 surface. **api.v1 surface additions (additive, defaulted).** - New public data class `RoutingOperationSpec(lineNo, operationCode, workCenter, standardMinutes)` in `api.v1.event.production.WorkOrderEvents` with init-block invariants matching pbc-production v3's internal validation (positive lineNo, non-blank operationCode + workCenter, non-negative standardMinutes). - `WorkOrderRequestedEvent` gains an `operations: List<RoutingOperationSpec>` field, defaulted to `emptyList()`. Existing callers compile without changes; the event's init block now also validates that every operation has a unique lineNo. Convention matches the other v1 events that already carry defaulted `eventId` and `occurredAt` — additive within a major version. **pbc-production subscriber wiring.** - `WorkOrderRequestedSubscriber.handle` now maps `event.operations` → `WorkOrderOperationCommand` 1:1 and passes them to `CreateWorkOrderCommand`. Empty list keeps the v2 behavior exactly (auto-spawned orders from the SO path still get no routing and walk DRAFT → IN_PROGRESS → COMPLETED without any gate); a non-empty list feeds the new v3 WorkOrderOperation children and forces a sequential walk on the shop floor. The log line now includes `ops=<size>` so operators can see at a glance whether a WO came with a routing. **Reference plug-in.** - `CreateWorkOrderFromQuoteTaskHandler` now attaches `DEFAULT_PRINTING_SHOP_ROUTING`: a 4-step sequence modeled on the reference business doc's brochure production flow. Each step gets its own work center (PRINTING-CUT-01, PRINTING-PRESS-A, PRINTING-FOLD-01, PRINTING-BIND-01) so a future shop-floor dashboard can show which station is running which job. Standard times are round-number placeholders (15/30/10/20 minutes) — a real customer tunes them from historical data. Deliberately hard-coded in v1: a real shop with a dozen different flows would either ship a richer plug-in that picks routing per item type, or wait for a future Tier 1 "routing template" metadata entity. v1 just proves the event-driven seam carries v3 operations end-to-end. **Why this is the right shape.** - Zero new compile-time coupling. The plug-in imports only `api.v1.event.production.RoutingOperationSpec`; the plug-in linter would refuse any reach into `pbc.production.*`. - Core pbc-production stays ignorant of the plug-in: the subscriber doesn't know where the event came from. - The same `WorkOrderRequestedEvent` path now works for ANY producer — the next customer plug-in that spawns routed work orders gets zero core changes. **Tests.** New `WorkOrderRequestedSubscriberTest.handle passes event operations through as WorkOrderOperationCommand` asserts the 1:1 mapping of RoutingOperationSpec → WorkOrderOperationCommand. The existing test gains one assertion that an empty `operations` list on the event produces an empty `operations` list on the command (backwards-compat lock-in). **Smoke-tested end-to-end against real Postgres:** 1. POST /api/v1/workflow/process-instances with processDefinitionKey `plugin-printing-shop-quote-to-work-order` and variables `{quoteCode: "Q-ROUTING-001", itemCode: "FG-BROCHURE", quantity: 250}` 2. BPMN runs through CreateWorkOrderFromQuoteTaskHandler, publishes WorkOrderRequestedEvent with 4 operations 3. pbc-production subscriber creates WO `WO-FROM-PRINTINGSHOP-Q-ROUTING-001` 4. GET /api/v1/production/work-orders/by-code/... returns the WO with status=DRAFT and 4 operations (CUT/PRINT/FOLD/BIND) all PENDING, each with its own work_center and standard_minutes. This is the framework's first business flow where a customer plug-in provides a routing to a core PBC end-to-end through api.v1 alone. Closes the loop between the v3 routings feature (commit fa867189) and the executable acceptance test in the reference plug-in. 24 modules, 350 unit tests (+1), 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.