-
Removes the ext-handling copy/paste that had grown across four PBCs (partners, inventory, orders-sales, orders-purchase). Every service that wrote the JSONB `ext` column was manually doing the same four-step sequence: validate, null-check, serialize with a local ObjectMapper, assign to the entity. And every response mapper was doing the inverse: check-if-blank, parse, cast, swallow errors. Net: ~15 lines saved per PBC, one place to change the ext contract later (e.g. PII redaction, audit tagging, field-level events), and a stable plug-in opt-in mechanism — any plug-in entity that implements `HasExt` automatically participates. New api.v1 surface: interface HasExt { val extEntityName: String // key into metadata__custom_field var ext: String // the serialized JSONB column } Lives in `org.vibeerp.api.v1.entity` so plug-ins can opt their own entities into the same validation path. Zero Spring/Jackson dependencies — api.v1 stays clean. Extended `ExtJsonValidator` (platform-metadata) with two helpers: fun applyTo(entity: HasExt, ext: Map<String, Any?>?) — null-safe; validates; writes canonical JSON to entity.ext. Replaces the validate + writeValueAsString + assign triplet in every service's create() and update(). fun parseExt(entity: HasExt): Map<String, Any?> — returns empty map on blank/corrupt column; response mappers never 500 on bad data. Replaces the four identical parseExt local functions. ExtJsonValidator now takes an ObjectMapper via constructor injection (Spring Boot's auto-configured bean). Entities that now implement HasExt (override val extEntityName; override var ext; companion object const val ENTITY_NAME): - Partner (`partners.Partner` → "Partner") - Location (`inventory.Location` → "Location") - SalesOrder (`orders_sales.SalesOrder` → "SalesOrder") - PurchaseOrder (`orders_purchase.PurchaseOrder` → "PurchaseOrder") Deliberately NOT converted this chunk: - WorkOrder (pbc-production) — its ext column has no declared fields yet; a follow-up that adds declarations AND the HasExt implementation is cleaner than splitting the two. - JournalEntry (pbc-finance) — derived state, no ext column. Services lose: - The `jsonMapper: ObjectMapper = ObjectMapper().registerKotlinModule()` field (four copies eliminated) - The `parseExt(entity): Map` helper function (four copies) - The `companion object { const val ENTITY_NAME = ... }` constant (moved onto the entity where it belongs) - The `val canonicalExt = extValidator.validate(...)` + `.also { it.ext = jsonMapper.writeValueAsString(canonicalExt) }` create pattern (replaced with one applyTo call) - The `if (command.ext != null) { ... }` update pattern (applyTo is null-safe) Unit tests: 6 new cases on ExtJsonValidatorTest cover applyTo and parseExt (null-safe path, happy path, failure path, blank column, round-trip, malformed JSON). Existing service tests just swap the mock setup from stubbing `validate` to stubbing `applyTo` and `parseExt` with no-ops. Smoke verified end-to-end against real Postgres: - POST /partners with valid ext (partners_credit_limit, partners_industry) → 201, canonical form persisted. - GET /partners/by-code/X → 200, ext round-trips. - POST with invalid enum value → 400 "value 'x' is not in allowed set [printing, publishing, packaging, other]". - POST with undeclared key → 400 "ext contains undeclared key(s) for 'Partner': [rogue_field]". - PATCH with new ext → 200, ext updated. - PATCH WITHOUT ext field → 200, prior ext preserved (null-safe applyTo). - POST /orders/sales-orders with no ext → 201, the create path via the shared helper still works. 246 unit tests (+6 over 240), 18 Gradle subprojects. -
Completes the @RequirePermission rollout that started in commit b174cf60. Every non-state-transition endpoint in pbc-inventory (Location CRUD), pbc-orders-sales, and pbc-orders-purchase is now guarded by the pre-declared permission keys from their respective metadata YAMLs. State-transition verbs (confirm/cancel/ship/receive) were annotated in the original P4.3 demo chunk; this one fills in the list/get/create/update gap. Inventory - LocationController: list/get/getByCode → inventory.location.read; create → inventory.location.create; update → inventory.location.update; deactivate → inventory.location.deactivate. - (StockBalanceController.adjust + StockMovementController.record were already annotated with inventory.stock.adjust.) Orders-sales - SalesOrderController: list/get/getByCode → orders.sales.read; create → orders.sales.create; update → orders.sales.update. (confirm/cancel/ship were already annotated.) Orders-purchase - PurchaseOrderController: list/get/getByCode → orders.purchase.read; create → orders.purchase.create; update → orders.purchase.update. (confirm/cancel/receive were already annotated.) No new permission keys. Every key this chunk consumes was already declared in the relevant metadata YAML since the respective PBC was first built — catalog + partners already shipped in this state, and the inventory/orders YAMLs declared their read/create/update keys from day one but the controllers hadn't started using them. Admin happy path still works (bootstrap admin has the wildcard `admin` role, same as after commit b174cf60). 230 unit tests still green — annotations are purely additive, no existing test hits the @RequirePermission path since service-level tests bypass the controller entirely. Combined with b174cf60, the framework now has full @RequirePermission coverage on every PBC controller except pbc-identity's user admin (which is a separate permission surface — user/role administration has its own security story). A minimum-privilege role like "sales-clerk" can now be granted exactly `orders.sales.read` + `orders.sales.create` + `partners.partner.read` and NOT accidentally see catalog admin, inventory movements, finance journals, or contact PII.
-
The event bus and transactional outbox have existed since P1.7 but no real PBC business logic was publishing through them. This change closes that loop end-to-end: api.v1.event.orders (new public surface) - SalesOrderConfirmedEvent / SalesOrderShippedEvent / SalesOrderCancelledEvent — sealed under SalesOrderEvent, aggregateType = "orders_sales.SalesOrder" - PurchaseOrderConfirmedEvent / PurchaseOrderReceivedEvent / PurchaseOrderCancelledEvent — sealed under PurchaseOrderEvent, aggregateType = "orders_purchase.PurchaseOrder" - Events live in api.v1 (not inside the PBCs) so other PBCs and customer plug-ins can subscribe without importing the producing PBC — that would violate guardrail #9. pbc-orders-sales / pbc-orders-purchase - SalesOrderService and PurchaseOrderService now inject EventBus and publish a typed event from each state-changing method (confirm, ship/receive, cancel). The publish runs INSIDE the same @Transactional method as the JPA mutation and the InventoryApi.recordMovement ledger writes — EventBusImpl uses Propagation.MANDATORY, so a publish outside a transaction fails loudly. A failure in any line rolls back the status change AND every ledger row AND the would-have-been outbox row. - 6 new unit tests (3 per service) mockk the EventBus and verify each transition publishes exactly one matching event with the expected fields. Total tests: 186 → 192. End-to-end smoke verified against real Postgres - Created supplier, customer, item PAPER-A4, location WH-MAIN. - Drove a PO and an SO through the full state machine plus a cancel of each. 6 events fired: orders_purchase.PurchaseOrder × 3 (confirm + receive + cancel) orders_sales.SalesOrder × 3 (confirm + ship + cancel) - The wildcard EventAuditLogSubscriber logged each one at INFO level to /tmp/vibe-erp-boot.log with the [event-audit] tag. - platform__event_outbox shows 6 rows, all flipped from PENDING to DISPATCHED by the OutboxPoller within seconds. - The publish-inside-the-ledger-transaction guarantee means a subscriber that reads inventory__stock_movement on event receipt is guaranteed to see the matching SALES_SHIPMENT or PURCHASE_RECEIPT rows. This is what the architecture spec section 9 promised and now delivers. Why this is the right shape - Other PBCs (production, finance) and customer plug-ins can now react to "an order was confirmed/shipped/received/cancelled" without ever importing pbc-orders-* internals. The event class objects live in api.v1, the only stable contract surface. - The aggregateType strings ("orders_sales.SalesOrder", "orders_purchase.PurchaseOrder") match the <pbc>.<aggregate> convention documented on DomainEvent.aggregateType, so a cross-classloader subscriber can use the topic-string subscribe overload without holding the concrete Class<E>. - The bus's outbox row is the durability anchor for the future Kafka/NATS bridge: switching from in-process delivery to cross-process delivery will require zero changes to either PBC's publish call. -
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.