• 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.
    zichun authored
     
    Browse Code »
  • 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.
    zichun authored
     
    Browse Code »