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.