Commit 28616565370ef6c974efeb045bed1a2e997fe439

Authored by zichun
1 parent 809c0b70

feat(pbc): P5.6 — pbc-orders-purchase + receive-driven inventory increase

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.
CLAUDE.md
... ... @@ -95,10 +95,10 @@ plugins (incl. ref) depend on: api/api-v1 only
95 95  
96 96 **Foundation complete; first business surface in place.** As of the latest commit:
97 97  
98   -- **15 Gradle subprojects** built and tested. The dependency rule (PBCs never import each other; plug-ins only see `api.v1`) is enforced at configuration time by the root `build.gradle.kts`.
99   -- **175 unit tests across 15 modules**, all green. `./gradlew build` is the canonical full build.
  98 +- **16 Gradle subprojects** built and tested. The dependency rule (PBCs never import each other; plug-ins only see `api.v1`) is enforced at configuration time by the root `build.gradle.kts`.
  99 +- **186 unit tests across 16 modules**, all green. `./gradlew build` is the canonical full build.
100 100 - **All 9 cross-cutting platform services live and smoke-tested end-to-end** against real Postgres: auth (P4.1), authorization with `@RequirePermission` + JWT roles claim + AOP enforcement (P4.3), plug-in HTTP endpoints (P1.3), plug-in linter (P1.2), plug-in-owned Liquibase + typed SQL (P1.4), event bus + transactional outbox (P1.7), metadata loader + custom-field validation (P1.5 + P3.4), ICU4J translator with per-plug-in locale chain (P1.6), plus the audit/principal context bridge.
101   -- **5 of 10 core PBCs implemented end-to-end** (`pbc-identity`, `pbc-catalog`, `pbc-partners`, `pbc-inventory`, `pbc-orders-sales`). **pbc-inventory** has an append-only stock movement ledger; **pbc-orders-sales** can ship orders, atomically debiting stock via `InventoryApi.recordMovement` — the framework's first cross-PBC WRITE flow. The Gradle build still refuses any direct dependency between PBCs.
  101 +- **6 of 10 core PBCs implemented end-to-end** (`pbc-identity`, `pbc-catalog`, `pbc-partners`, `pbc-inventory`, `pbc-orders-sales`, `pbc-orders-purchase`). **The full buy-and-sell loop works**: a purchase order receives stock via `PURCHASE_RECEIPT` ledger rows, a sales order ships stock via `SALES_SHIPMENT` ledger rows. Both PBCs feed the same `inventory__stock_movement` ledger via the same `InventoryApi.recordMovement` facade.
102 102 - **Reference printing-shop plug-in** owns its own DB schema, CRUDs plates and ink recipes via REST through `context.jdbc`, ships its own metadata YAML, and registers 7 HTTP endpoints. It is the executable acceptance test for the framework.
103 103 - **Package root** is `org.vibeerp`.
104 104 - **Build commands:** `./gradlew build` (full), `./gradlew test` (unit tests), `./gradlew :distribution:bootRun` (run dev profile, stages plug-in JAR automatically), `docker compose up -d db` (Postgres). The bootstrap admin password is printed to the application logs on first boot.
... ...
PROGRESS.md
... ... @@ -10,27 +10,27 @@
10 10  
11 11 | | |
12 12 |---|---|
13   -| **Latest version** | v0.13 (post-ledger + ship) |
14   -| **Latest commit** | `e37c143 feat(inventory+orders): movement ledger + sales-order shipping (end-to-end demo)` |
  13 +| **Latest version** | v0.14 (post-P5.6) |
  14 +| **Latest commit** | `feat(pbc): P5.6 — pbc-orders-purchase + receive-driven inventory increase` |
15 15 | **Repo** | https://github.com/reporkey/vibe-erp |
16   -| **Modules** | 15 |
17   -| **Unit tests** | 175, all green |
18   -| **End-to-end smoke runs** | The killer demo works: create catalog item + partner + location, set stock to 1000, place an order for 50, confirm, **ship**, watch the balance drop to 950 and a `SALES_SHIPMENT` row appear in the ledger tagged `SO:SO-2026-0001`. Over-shipping rolls back atomically with a meaningful 400. |
19   -| **Real PBCs implemented** | 5 of 10 (`pbc-identity`, `pbc-catalog`, `pbc-partners`, `pbc-inventory`, `pbc-orders-sales`) |
  16 +| **Modules** | 16 |
  17 +| **Unit tests** | 186, all green |
  18 +| **End-to-end smoke runs** | The full buy-and-sell loop works: create supplier + customer + item + location, place a PO for 5000, confirm, **receive** (stock goes from 0 to 5000 via `PURCHASE_RECEIPT` ledger row tagged `PO:PO-2026-0001`), then place a SO for 50, confirm, **ship** (stock drops to 4950 via `SALES_SHIPMENT` tagged `SO:SO-2026-0001`). Both PBCs feed the same ledger. |
  19 +| **Real PBCs implemented** | 6 of 10 (`pbc-identity`, `pbc-catalog`, `pbc-partners`, `pbc-inventory`, `pbc-orders-sales`, `pbc-orders-purchase`) |
20 20 | **Plug-ins serving HTTP** | 1 (reference printing-shop, 7 endpoints + own DB schema + own metadata + own i18n bundles) |
21 21 | **Production-ready?** | **No.** Foundation is solid; many capabilities still deferred. |
22 22  
23 23 ## Current stage
24 24  
25   -**Foundation complete; Tier 1 customization live; authorization enforced; end-to-end order-to-shipment loop closed.** All eight cross-cutting platform services are live plus the authorization layer. The biggest leap in this version: the **inventory movement ledger** is live (`inventory__stock_movement` append-only table; the framework's first append-only ledger), and **sales orders can now ship** (`POST /sales-orders/{id}/ship`) via a cross-PBC write — pbc-orders-sales injects the new `InventoryApi.recordMovement` to atomically debit stock for every line and update the order status, all in one transaction. Either the whole shipment commits or none of it does. This is the framework's **first cross-PBC WRITE flow** (every earlier cross-PBC call was a read). The sales-order state machine grows: DRAFT → CONFIRMED → SHIPPED (terminal). Cancelling a SHIPPED order is rejected with a meaningful "use a return / refund flow" message.
  25 +**Foundation complete; Tier 1 customization live; authorization enforced; full buy-and-sell loop closed.** All eight cross-cutting platform services are live plus the authorization layer. **The framework now has six PBCs and both ends of the inventory loop work**: a purchase order receives stock via `PURCHASE_RECEIPT` ledger rows tagged `PO:<code>`, a sales order ships stock via `SALES_SHIPMENT` ledger rows tagged `SO:<code>`. Both PBCs feed the same `inventory__stock_movement` ledger via the same `InventoryApi.recordMovement` facade — the cross-PBC contract supports BOTH directions and BOTH consumers identically. The end-to-end demo "buy 5000 sheets, ship 50, see balance = 4950 with both ledger rows tagged correctly" runs in one smoke test.
26 26  
27   -The next phase continues **building business surface area**: pbc-orders-purchase, pbc-production, the workflow engine (Flowable), event-driven cross-PBC integration (the event bus has been wired since P1.7 but no flow uses it yet), and eventually the React SPA.
  27 +The next phase continues **building business surface area**: pbc-production (the framework's first non-order/non-master-data PBC), the workflow engine (Flowable), event-driven cross-PBC integration (the event bus has been wired since P1.7 but no flow uses it yet), and eventually the React SPA.
28 28  
29 29 ## Total scope (the v1.0 cut line)
30 30  
31 31 The framework reaches v1.0 when, on a fresh Postgres, an operator can `docker run` the image, log in, drop a customer plug-in JAR into `./plugins/`, restart, and walk a real workflow end-to-end without writing any code — and the **same** image, against a different Postgres pointed at a different company, also serves a different customer with a different plug-in. See the implementation plan for the full v1.0 acceptance bar.
32 32  
33   -That target breaks down into roughly 30 work units across 8 phases. About **21 are done** as of today. Below is the full list with status.
  33 +That target breaks down into roughly 30 work units across 8 phases. About **22 are done** as of today. Below is the full list with status.
34 34  
35 35 ### Phase 1 — Platform completion (foundation)
36 36  
... ... @@ -83,7 +83,7 @@ That target breaks down into roughly 30 work units across 8 phases. About **21 a
83 83 | P5.3 | `pbc-inventory` — locations + stock balances (movements deferred) | ✅ DONE — `f63a73d` |
84 84 | P5.4 | `pbc-warehousing` — receipts, picks, transfers, counts | 🔜 Pending |
85 85 | P5.5 | `pbc-orders-sales` — sales orders + lines (deliveries deferred) | ✅ DONE — `a8eb2a6` |
86   -| P5.6 | `pbc-orders-purchase` — RFQs, POs, receipts | 🔜 Pending |
  86 +| P5.6 | `pbc-orders-purchase` — POs + receive flow (RFQs deferred) | ✅ DONE — `<this commit>` |
87 87 | P5.7 | `pbc-production` — work orders, routings, operations | 🔜 Pending |
88 88 | P5.8 | `pbc-quality` — inspection plans, results, holds | 🔜 Pending |
89 89 | P5.9 | `pbc-finance` — GL, journal entries, AR/AP minimal | 🔜 Pending |
... ... @@ -129,7 +129,7 @@ These are the cross-cutting platform services already wired into the running fra
129 129 | **Metadata loader** (P1.5) | `platform-metadata` | Walks the host classpath and each plug-in JAR for `META-INF/vibe-erp/metadata/*.yml`, upserts entities/permissions/menus into `metadata__*` tables tagged by source. Idempotent (delete-by-source then insert). User-edited metadata (`source='user'`) is never touched. Public `GET /api/v1/_meta/metadata` returns the full set for SPA + AI-agent + OpenAPI introspection. |
130 130 | **i18n / Translator** (P1.6) | `platform-i18n` | ICU4J-backed `Translator` with named placeholders, plurals, gender, locale-aware number/date formatting. Per-plug-in instance scoped via a `(classLoader, baseName)` chain — plug-in's `META-INF/vibe-erp/i18n/messages_<locale>.properties` resolves before the host's `messages_<locale>.properties` for shared keys. `RequestLocaleProvider` reads `Accept-Language` from the active HTTP request via the servlet container, falling back to `vibeerp.i18n.defaultLocale` outside an HTTP context. JVM-default locale fallback explicitly disabled to prevent silent locale leaks. |
131 131 | **Custom field application** (P3.4) | `platform-metadata.customfield` | `CustomFieldRegistry` reads `metadata__custom_field` rows into an in-memory index keyed by entity name, refreshed at boot and after every plug-in load. `ExtJsonValidator` validates the JSONB `ext` map of any entity against the declared `FieldType`s — String maxLength, Integer/Decimal numeric coercion with precision/scale enforcement, Boolean, Date/DateTime ISO-8601 parsing, Enum allowed values, UUID format. Unknown keys are rejected; required missing fields are rejected; ALL violations are returned in a single 400 so a form submitter fixes everything in one round-trip. `Partner` is the first PBC entity to wire ext through `PartnerService.create/update`; the public `GET /api/v1/_meta/metadata/custom-fields/{entityName}` endpoint serves the api.v1 runtime view of declarations to the SPA / OpenAPI / AI agent. |
132   -| **PBC pattern** (P5.x recipe) | `pbc-identity`, `pbc-catalog`, `pbc-partners`, `pbc-inventory`, `pbc-orders-sales` | Five real PBCs prove the recipe across five aggregate shapes (single-entity user, two-entity catalog, parent-with-children partners, master-data+facts inventory, header+lines sales orders with state machine): domain entity extending `AuditedJpaEntity` → Spring Data JPA repository → application service → REST controller under `/api/v1/<pbc>/<resource>` → cross-PBC facade in `api.v1.ext.<pbc>` → adapter implementation. Architecture rule enforced by the Gradle build: PBCs never import each other, never import `platform-bootstrap`. **pbc-inventory** is the first PBC to *consume* one cross-PBC facade (`CatalogApi`); **pbc-orders-sales** is the first to consume *two* simultaneously (`PartnersApi` + `CatalogApi`) in a single transaction, proving the modular monolith works under realistic workload. |
  132 +| **PBC pattern** (P5.x recipe) | `pbc-identity`, `pbc-catalog`, `pbc-partners`, `pbc-inventory`, `pbc-orders-sales`, `pbc-orders-purchase` | Six real PBCs prove the recipe. **pbc-orders-purchase** is the buying-side mirror of pbc-orders-sales: same recipe, same three cross-PBC seams (PartnersApi + CatalogApi + InventoryApi), same line-with-recompute pattern, but the partner role check is inverted (must be SUPPLIER or BOTH), the state machine ends in RECEIVED instead of SHIPPED, and the cross-PBC inventory write uses positive deltas with `reason="PURCHASE_RECEIPT"`. The framework's `InventoryApi.recordMovement` facade now has TWO callers — the same primitive feeds the same ledger from both directions. |
133 133  
134 134 ## What the reference plug-in proves end-to-end
135 135  
... ... @@ -170,7 +170,7 @@ This is **real**: a JAR file dropped into a directory, loaded by the framework,
170 170 - **File store.** No abstraction; no S3 backend.
171 171 - **Job scheduler.** No Quartz. Periodic jobs don't have a home.
172 172 - **OIDC.** Built-in JWT only. OIDC client (Keycloak-compatible) is P4.2.
173   -- **More PBCs.** Identity, catalog, partners, inventory and orders-sales exist. Warehousing, orders-purchase, production, quality, finance are all pending.
  173 +- **More PBCs.** Identity, catalog, partners, inventory, orders-sales and orders-purchase exist. Warehousing, production, quality, finance are all pending.
174 174 - **Web SPA.** No React app. The framework is API-only today.
175 175 - **MCP server.** The architecture leaves room for it; the implementation is v1.1.
176 176 - **Mobile.** v2.
... ... @@ -217,7 +217,11 @@ pbc/pbc-partners Partner + Address + Contact entities + cross-PBC P
217 217 pbc/pbc-inventory Location + StockBalance + cross-PBC InventoryApi facade
218 218 (first PBC to CONSUME another PBC's facade — CatalogApi)
219 219 pbc/pbc-orders-sales SalesOrder + SalesOrderLine + cross-PBC SalesOrdersApi facade
220   - (first PBC to CONSUME TWO facades simultaneously — PartnersApi + CatalogApi)
  220 + (first PBC to CONSUME TWO facades simultaneously — PartnersApi + CatalogApi
  221 + and the first cross-PBC WRITE flow via InventoryApi.recordMovement)
  222 +pbc/pbc-orders-purchase PurchaseOrder + PurchaseOrderLine + cross-PBC PurchaseOrdersApi facade
  223 + (the buying-side mirror; receives via InventoryApi.recordMovement
  224 + with positive PURCHASE_RECEIPT deltas)
221 225  
222 226 reference-customer/plugin-printing-shop
223 227 Reference plug-in: own DB schema (plate, ink_recipe),
... ... @@ -226,7 +230,7 @@ reference-customer/plugin-printing-shop
226 230 distribution Bootable Spring Boot fat-jar assembly
227 231 ```
228 232  
229   -15 Gradle subprojects. Architectural dependency rule (PBCs never import each other; plug-ins only see api.v1) is enforced by the root `build.gradle.kts` at configuration time.
  233 +16 Gradle subprojects. Architectural dependency rule (PBCs never import each other; plug-ins only see api.v1) is enforced by the root `build.gradle.kts` at configuration time.
230 234  
231 235 ## Where to look next
232 236  
... ...
README.md
... ... @@ -77,7 +77,7 @@ vibe-erp/
77 77 ## Building
78 78  
79 79 ```bash
80   -# Build everything (compiles 15 modules, runs 175 unit tests)
  80 +# Build everything (compiles 16 modules, runs 186 unit tests)
81 81 ./gradlew build
82 82  
83 83 # Bring up Postgres + the reference plug-in JAR
... ... @@ -96,9 +96,9 @@ The bootstrap admin password is printed to the application logs on first boot. A
96 96  
97 97 | | |
98 98 |---|---|
99   -| Modules | 15 |
100   -| Unit tests | 175, all green |
101   -| Real PBCs | 5 of 10 |
  99 +| Modules | 16 |
  100 +| Unit tests | 186, all green |
  101 +| Real PBCs | 6 of 10 |
102 102 | Cross-cutting services live | 9 |
103 103 | Plug-ins serving HTTP | 1 (reference printing-shop) |
104 104 | Production-ready | **No** — workflow engine, forms, web SPA, more PBCs all still pending |
... ...
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/ext/orders/PurchaseOrdersApi.kt 0 → 100644
  1 +package org.vibeerp.api.v1.ext.orders
  2 +
  3 +import org.vibeerp.api.v1.core.Id
  4 +import java.math.BigDecimal
  5 +import java.time.LocalDate
  6 +
  7 +/**
  8 + * Cross-PBC facade for the purchase orders bounded context.
  9 + *
  10 + * The buying-side mirror of [SalesOrdersApi]. Sets up the same
  11 + * downstream consumers (production scheduling, finance/payables,
  12 + * receipt-driven inventory ramps) but on the buying side. Like every
  13 + * other `api.v1.ext.*` interface, it lives in this module so plug-ins
  14 + * and other PBCs can inject it without depending on `pbc-orders-purchase`.
  15 + *
  16 + * **What this facade exposes:** the order header (code, status,
  17 + * supplier reference, total) plus the lines as a flat list. Line
  18 + * shape is identical to the sales-order line ref — they're both
  19 + * "what was bought, how many, at what price" — and the existing
  20 + * [SalesOrderLineRef] type is reused so consumers don't have to
  21 + * write the same code twice.
  22 + *
  23 + * **What this facade does NOT expose** (deliberately, mirroring
  24 + * [SalesOrdersApi]): mutation operations, the `ext` JSONB, the
  25 + * audit history, list/search.
  26 + *
  27 + * **Why a separate API instead of folding into a generic OrdersApi:**
  28 + * the same reason there are two PBCs — buying and selling are
  29 + * genuinely different lifecycles. A generic interface would force
  30 + * every downstream consumer to look at a `direction` field and
  31 + * branch. Two interfaces, one per direction, keeps the call sites
  32 + * honest about what they're doing.
  33 + */
  34 +interface PurchaseOrdersApi {
  35 +
  36 + /**
  37 + * Look up a purchase order by its unique code (e.g. "PO-2026-0001").
  38 + * Returns `null` when no such order exists. Cancelled and
  39 + * received orders ARE returned (the facade does not hide them)
  40 + * because downstream consumers may legitimately need to react
  41 + * to either state.
  42 + */
  43 + fun findByCode(code: String): PurchaseOrderRef?
  44 +
  45 + /**
  46 + * Look up a purchase order by its primary-key id. Same semantics
  47 + * as [findByCode].
  48 + */
  49 + fun findById(id: Id<PurchaseOrderRef>): PurchaseOrderRef?
  50 +}
  51 +
  52 +/**
  53 + * Minimal, safe-to-publish view of a purchase order.
  54 + *
  55 + * Reuses [SalesOrderLineRef] for the line shape — buying and
  56 + * selling lines carry the same fields (line_no, item_code,
  57 + * quantity, unit_price, currency_code), so they share the api.v1
  58 + * type. The cost of duplication would only show up if the two ever
  59 + * needed different fields, at which point we split.
  60 + */
  61 +data class PurchaseOrderRef(
  62 + val id: Id<PurchaseOrderRef>,
  63 + val code: String,
  64 + val partnerCode: String,
  65 + val status: String, // DRAFT | CONFIRMED | RECEIVED | CANCELLED — string for plug-in compatibility
  66 + val orderDate: LocalDate,
  67 + val expectedDate: LocalDate?,
  68 + val currencyCode: String,
  69 + val totalAmount: BigDecimal,
  70 + val lines: List<SalesOrderLineRef>,
  71 +)
... ...
distribution/build.gradle.kts
... ... @@ -31,6 +31,7 @@ dependencies {
31 31 implementation(project(":pbc:pbc-partners"))
32 32 implementation(project(":pbc:pbc-inventory"))
33 33 implementation(project(":pbc:pbc-orders-sales"))
  34 + implementation(project(":pbc:pbc-orders-purchase"))
34 35  
35 36 implementation(libs.spring.boot.starter)
36 37 implementation(libs.spring.boot.starter.web)
... ...
distribution/src/main/resources/db/changelog/master.xml
... ... @@ -19,4 +19,5 @@
19 19 <include file="classpath:db/changelog/pbc-inventory/001-inventory-init.xml"/>
20 20 <include file="classpath:db/changelog/pbc-inventory/002-inventory-movement-ledger.xml"/>
21 21 <include file="classpath:db/changelog/pbc-orders-sales/001-orders-sales-init.xml"/>
  22 + <include file="classpath:db/changelog/pbc-orders-purchase/001-orders-purchase-init.xml"/>
22 23 </databaseChangeLog>
... ...
distribution/src/main/resources/db/changelog/pbc-orders-purchase/001-orders-purchase-init.xml 0 → 100644
  1 +<?xml version="1.0" encoding="UTF-8"?>
  2 +<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
  3 + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  4 + xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
  5 + https://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.27.xsd">
  6 +
  7 + <!--
  8 + pbc-orders-purchase initial schema (P5.6).
  9 +
  10 + Owns: orders_purchase__purchase_order, orders_purchase__purchase_order_line.
  11 +
  12 + Mirror of pbc-orders-sales — same shape, same conventions.
  13 +
  14 + NEITHER table has a foreign key to:
  15 + • partners__partner (cross-PBC reference enforced by PartnersApi)
  16 + • catalog__item (cross-PBC reference enforced by CatalogApi)
  17 + A database FK across PBCs would couple their schemas at the
  18 + storage level, defeating the bounded-context rule.
  19 + -->
  20 +
  21 + <changeSet id="orders-purchase-init-001" author="vibe_erp">
  22 + <comment>Create orders_purchase__purchase_order table (header)</comment>
  23 + <sql>
  24 + CREATE TABLE orders_purchase__purchase_order (
  25 + id uuid PRIMARY KEY,
  26 + code varchar(64) NOT NULL,
  27 + partner_code varchar(64) NOT NULL,
  28 + status varchar(16) NOT NULL,
  29 + order_date date NOT NULL,
  30 + expected_date date,
  31 + currency_code varchar(3) NOT NULL,
  32 + total_amount numeric(18,4) NOT NULL DEFAULT 0,
  33 + ext jsonb NOT NULL DEFAULT '{}'::jsonb,
  34 + created_at timestamptz NOT NULL,
  35 + created_by varchar(128) NOT NULL,
  36 + updated_at timestamptz NOT NULL,
  37 + updated_by varchar(128) NOT NULL,
  38 + version bigint NOT NULL DEFAULT 0
  39 + );
  40 + CREATE UNIQUE INDEX orders_purchase__purchase_order_code_uk
  41 + ON orders_purchase__purchase_order (code);
  42 + CREATE INDEX orders_purchase__purchase_order_partner_idx
  43 + ON orders_purchase__purchase_order (partner_code);
  44 + CREATE INDEX orders_purchase__purchase_order_status_idx
  45 + ON orders_purchase__purchase_order (status);
  46 + CREATE INDEX orders_purchase__purchase_order_date_idx
  47 + ON orders_purchase__purchase_order (order_date);
  48 + CREATE INDEX orders_purchase__purchase_order_ext_gin
  49 + ON orders_purchase__purchase_order USING GIN (ext jsonb_path_ops);
  50 + </sql>
  51 + <rollback>
  52 + DROP TABLE orders_purchase__purchase_order;
  53 + </rollback>
  54 + </changeSet>
  55 +
  56 + <changeSet id="orders-purchase-init-002" author="vibe_erp">
  57 + <comment>Create orders_purchase__purchase_order_line table (FK to header, no FK to catalog__item)</comment>
  58 + <sql>
  59 + CREATE TABLE orders_purchase__purchase_order_line (
  60 + id uuid PRIMARY KEY,
  61 + purchase_order_id uuid NOT NULL REFERENCES orders_purchase__purchase_order(id) ON DELETE CASCADE,
  62 + line_no integer NOT NULL,
  63 + item_code varchar(64) NOT NULL,
  64 + quantity numeric(18,4) NOT NULL,
  65 + unit_price numeric(18,4) NOT NULL,
  66 + currency_code varchar(3) NOT NULL,
  67 + created_at timestamptz NOT NULL,
  68 + created_by varchar(128) NOT NULL,
  69 + updated_at timestamptz NOT NULL,
  70 + updated_by varchar(128) NOT NULL,
  71 + version bigint NOT NULL DEFAULT 0,
  72 + CONSTRAINT orders_purchase__purchase_order_line_qty_pos CHECK (quantity &gt; 0),
  73 + CONSTRAINT orders_purchase__purchase_order_line_price_nonneg CHECK (unit_price &gt;= 0)
  74 + );
  75 + CREATE UNIQUE INDEX orders_purchase__purchase_order_line_order_lineno_uk
  76 + ON orders_purchase__purchase_order_line (purchase_order_id, line_no);
  77 + CREATE INDEX orders_purchase__purchase_order_line_item_idx
  78 + ON orders_purchase__purchase_order_line (item_code);
  79 + </sql>
  80 + <rollback>
  81 + DROP TABLE orders_purchase__purchase_order_line;
  82 + </rollback>
  83 + </changeSet>
  84 +
  85 +</databaseChangeLog>
... ...
pbc/pbc-orders-purchase/build.gradle.kts 0 → 100644
  1 +plugins {
  2 + alias(libs.plugins.kotlin.jvm)
  3 + alias(libs.plugins.kotlin.spring)
  4 + alias(libs.plugins.kotlin.jpa)
  5 + alias(libs.plugins.spring.dependency.management)
  6 +}
  7 +
  8 +description = "vibe_erp pbc-orders-purchase — purchase order header + lines + receipt-driven inventory increase. INTERNAL Packaged Business Capability."
  9 +
  10 +java {
  11 + toolchain {
  12 + languageVersion.set(JavaLanguageVersion.of(21))
  13 + }
  14 +}
  15 +
  16 +kotlin {
  17 + jvmToolchain(21)
  18 + compilerOptions {
  19 + freeCompilerArgs.add("-Xjsr305=strict")
  20 + }
  21 +}
  22 +
  23 +allOpen {
  24 + annotation("jakarta.persistence.Entity")
  25 + annotation("jakarta.persistence.MappedSuperclass")
  26 + annotation("jakarta.persistence.Embeddable")
  27 +}
  28 +
  29 +// Mirror of pbc-orders-sales — same dependency set, same enforcement
  30 +// rules. The Gradle build refuses any direct dependency on another
  31 +// pbc-* (the supplier/item/inventory references go through api.v1).
  32 +dependencies {
  33 + api(project(":api:api-v1"))
  34 + implementation(project(":platform:platform-persistence"))
  35 + implementation(project(":platform:platform-security"))
  36 + implementation(project(":platform:platform-metadata")) // ExtJsonValidator (P3.4)
  37 +
  38 + implementation(libs.kotlin.stdlib)
  39 + implementation(libs.kotlin.reflect)
  40 +
  41 + implementation(libs.spring.boot.starter)
  42 + implementation(libs.spring.boot.starter.web)
  43 + implementation(libs.spring.boot.starter.data.jpa)
  44 + implementation(libs.spring.boot.starter.validation)
  45 + implementation(libs.jackson.module.kotlin)
  46 +
  47 + testImplementation(libs.spring.boot.starter.test)
  48 + testImplementation(libs.junit.jupiter)
  49 + testImplementation(libs.assertk)
  50 + testImplementation(libs.mockk)
  51 +}
  52 +
  53 +tasks.test {
  54 + useJUnitPlatform()
  55 +}
... ...
pbc/pbc-orders-purchase/src/main/kotlin/org/vibeerp/pbc/orders/purchase/application/PurchaseOrderService.kt 0 → 100644
  1 +package org.vibeerp.pbc.orders.purchase.application
  2 +
  3 +import com.fasterxml.jackson.databind.ObjectMapper
  4 +import com.fasterxml.jackson.module.kotlin.registerKotlinModule
  5 +import org.springframework.stereotype.Service
  6 +import org.springframework.transaction.annotation.Transactional
  7 +import org.vibeerp.api.v1.ext.catalog.CatalogApi
  8 +import org.vibeerp.api.v1.ext.inventory.InventoryApi
  9 +import org.vibeerp.api.v1.ext.partners.PartnersApi
  10 +import org.vibeerp.pbc.orders.purchase.domain.PurchaseOrder
  11 +import org.vibeerp.pbc.orders.purchase.domain.PurchaseOrderLine
  12 +import org.vibeerp.pbc.orders.purchase.domain.PurchaseOrderStatus
  13 +import org.vibeerp.pbc.orders.purchase.infrastructure.PurchaseOrderJpaRepository
  14 +import org.vibeerp.platform.metadata.customfield.ExtJsonValidator
  15 +import java.math.BigDecimal
  16 +import java.time.LocalDate
  17 +import java.util.UUID
  18 +
  19 +/**
  20 + * Application service for purchase order CRUD and state transitions.
  21 + *
  22 + * **The buying-side mirror of [org.vibeerp.pbc.orders.sales.application.SalesOrderService].**
  23 + * Same shape — partner + line validation via three cross-PBC facades,
  24 + * recomputed total, state machine — but with the partner role
  25 + * inverted (must be SUPPLIER or BOTH, not CUSTOMER) and a `receive`
  26 + * operation that increments stock instead of debiting it.
  27 + *
  28 + * **State machine:**
  29 + * - DRAFT → CONFIRMED (confirm)
  30 + * - DRAFT → CANCELLED (cancel from draft)
  31 + * - CONFIRMED → CANCELLED (cancel a confirmed PO before goods arrive)
  32 + * - CONFIRMED → RECEIVED (receive — increments inventory)
  33 + * - RECEIVED → × (terminal; cancellation requires a return flow)
  34 + *
  35 + * **Why receiving is its own state and not just "shipped from
  36 + * supplier":** the same physical event (the goods arrive) is the
  37 + * trigger for OUR side to record the stock increment AND for the
  38 + * supplier's invoice to become payable. Other PBCs (finance,
  39 + * possibly production) react to RECEIVED specifically; mixing it
  40 + * with CONFIRMED would force every consumer to look at the receipt
  41 + * notes instead of the PO status.
  42 + */
  43 +@Service
  44 +@Transactional
  45 +class PurchaseOrderService(
  46 + private val orders: PurchaseOrderJpaRepository,
  47 + private val partnersApi: PartnersApi,
  48 + private val catalogApi: CatalogApi,
  49 + private val inventoryApi: InventoryApi,
  50 + private val extValidator: ExtJsonValidator,
  51 +) {
  52 +
  53 + private val jsonMapper: ObjectMapper = ObjectMapper().registerKotlinModule()
  54 +
  55 + @Transactional(readOnly = true)
  56 + fun list(): List<PurchaseOrder> = orders.findAll()
  57 +
  58 + @Transactional(readOnly = true)
  59 + fun findById(id: UUID): PurchaseOrder? = orders.findById(id).orElse(null)
  60 +
  61 + @Transactional(readOnly = true)
  62 + fun findByCode(code: String): PurchaseOrder? = orders.findByCode(code)
  63 +
  64 + fun create(command: CreatePurchaseOrderCommand): PurchaseOrder {
  65 + require(!orders.existsByCode(command.code)) {
  66 + "purchase order code '${command.code}' is already taken"
  67 + }
  68 +
  69 + // Cross-PBC validation #1: the supplier must exist, be
  70 + // active, and play a SUPPLIER role.
  71 + val partner = partnersApi.findPartnerByCode(command.partnerCode)
  72 + ?: throw IllegalArgumentException(
  73 + "partner code '${command.partnerCode}' is not in the partners directory (or is inactive)",
  74 + )
  75 + require(partner.type == "SUPPLIER" || partner.type == "BOTH") {
  76 + "partner '${command.partnerCode}' is type ${partner.type} and cannot be the supplier of a purchase order"
  77 + }
  78 +
  79 + require(command.lines.isNotEmpty()) {
  80 + "purchase order must have at least one line"
  81 + }
  82 + val duplicateLineNos = command.lines.groupBy { it.lineNo }.filter { it.value.size > 1 }.keys
  83 + require(duplicateLineNos.isEmpty()) {
  84 + "duplicate line numbers in purchase order: $duplicateLineNos"
  85 + }
  86 +
  87 + // Cross-PBC validation #2: every line's item must exist in
  88 + // the catalog and be active.
  89 + for (line in command.lines) {
  90 + require(line.quantity.signum() > 0) {
  91 + "line ${line.lineNo}: quantity must be positive (got ${line.quantity})"
  92 + }
  93 + require(line.unitPrice.signum() >= 0) {
  94 + "line ${line.lineNo}: unit price must be non-negative (got ${line.unitPrice})"
  95 + }
  96 + require(line.currencyCode == command.currencyCode) {
  97 + "line ${line.lineNo}: currency '${line.currencyCode}' does not match header currency '${command.currencyCode}'"
  98 + }
  99 + catalogApi.findItemByCode(line.itemCode)
  100 + ?: throw IllegalArgumentException(
  101 + "line ${line.lineNo}: item code '${line.itemCode}' is not in the catalog (or is inactive)",
  102 + )
  103 + }
  104 +
  105 + // Recompute total — caller's value ignored.
  106 + val total = command.lines.fold(BigDecimal.ZERO) { acc, line ->
  107 + acc + (line.quantity * line.unitPrice)
  108 + }
  109 +
  110 + val canonicalExt = extValidator.validate(ENTITY_NAME, command.ext)
  111 +
  112 + val order = PurchaseOrder(
  113 + code = command.code,
  114 + partnerCode = command.partnerCode,
  115 + status = PurchaseOrderStatus.DRAFT,
  116 + orderDate = command.orderDate,
  117 + expectedDate = command.expectedDate,
  118 + currencyCode = command.currencyCode,
  119 + totalAmount = total,
  120 + ).also {
  121 + it.ext = jsonMapper.writeValueAsString(canonicalExt)
  122 + }
  123 + for (line in command.lines) {
  124 + order.lines += PurchaseOrderLine(
  125 + purchaseOrder = order,
  126 + lineNo = line.lineNo,
  127 + itemCode = line.itemCode,
  128 + quantity = line.quantity,
  129 + unitPrice = line.unitPrice,
  130 + currencyCode = line.currencyCode,
  131 + )
  132 + }
  133 + return orders.save(order)
  134 + }
  135 +
  136 + fun update(id: UUID, command: UpdatePurchaseOrderCommand): PurchaseOrder {
  137 + val order = orders.findById(id).orElseThrow {
  138 + NoSuchElementException("purchase order not found: $id")
  139 + }
  140 + require(order.status == PurchaseOrderStatus.DRAFT) {
  141 + "cannot update purchase order ${order.code} in status ${order.status}; only DRAFT orders are mutable"
  142 + }
  143 +
  144 + command.partnerCode?.let { newPartnerCode ->
  145 + val partner = partnersApi.findPartnerByCode(newPartnerCode)
  146 + ?: throw IllegalArgumentException(
  147 + "partner code '$newPartnerCode' is not in the partners directory (or is inactive)",
  148 + )
  149 + require(partner.type == "SUPPLIER" || partner.type == "BOTH") {
  150 + "partner '$newPartnerCode' is type ${partner.type} and cannot be the supplier of a purchase order"
  151 + }
  152 + order.partnerCode = newPartnerCode
  153 + }
  154 + command.orderDate?.let { order.orderDate = it }
  155 + command.expectedDate?.let { order.expectedDate = it }
  156 + command.currencyCode?.let { order.currencyCode = it }
  157 +
  158 + if (command.ext != null) {
  159 + order.ext = jsonMapper.writeValueAsString(extValidator.validate(ENTITY_NAME, command.ext))
  160 + }
  161 +
  162 + if (command.lines != null) {
  163 + require(command.lines.isNotEmpty()) {
  164 + "purchase order must have at least one line"
  165 + }
  166 + val duplicateLineNos = command.lines.groupBy { it.lineNo }.filter { it.value.size > 1 }.keys
  167 + require(duplicateLineNos.isEmpty()) {
  168 + "duplicate line numbers in purchase order: $duplicateLineNos"
  169 + }
  170 + for (line in command.lines) {
  171 + require(line.quantity.signum() > 0) {
  172 + "line ${line.lineNo}: quantity must be positive (got ${line.quantity})"
  173 + }
  174 + require(line.unitPrice.signum() >= 0) {
  175 + "line ${line.lineNo}: unit price must be non-negative (got ${line.unitPrice})"
  176 + }
  177 + require(line.currencyCode == order.currencyCode) {
  178 + "line ${line.lineNo}: currency '${line.currencyCode}' does not match header currency '${order.currencyCode}'"
  179 + }
  180 + catalogApi.findItemByCode(line.itemCode)
  181 + ?: throw IllegalArgumentException(
  182 + "line ${line.lineNo}: item code '${line.itemCode}' is not in the catalog (or is inactive)",
  183 + )
  184 + }
  185 + order.lines.clear()
  186 + for (line in command.lines) {
  187 + order.lines += PurchaseOrderLine(
  188 + purchaseOrder = order,
  189 + lineNo = line.lineNo,
  190 + itemCode = line.itemCode,
  191 + quantity = line.quantity,
  192 + unitPrice = line.unitPrice,
  193 + currencyCode = line.currencyCode,
  194 + )
  195 + }
  196 + order.totalAmount = order.lines.fold(BigDecimal.ZERO) { acc, l -> acc + l.lineTotal }
  197 + }
  198 + return order
  199 + }
  200 +
  201 + fun confirm(id: UUID): PurchaseOrder {
  202 + val order = orders.findById(id).orElseThrow {
  203 + NoSuchElementException("purchase order not found: $id")
  204 + }
  205 + require(order.status == PurchaseOrderStatus.DRAFT) {
  206 + "cannot confirm purchase order ${order.code} in status ${order.status}; only DRAFT can be confirmed"
  207 + }
  208 + order.status = PurchaseOrderStatus.CONFIRMED
  209 + return order
  210 + }
  211 +
  212 + fun cancel(id: UUID): PurchaseOrder {
  213 + val order = orders.findById(id).orElseThrow {
  214 + NoSuchElementException("purchase order not found: $id")
  215 + }
  216 + require(order.status != PurchaseOrderStatus.CANCELLED) {
  217 + "purchase order ${order.code} is already cancelled"
  218 + }
  219 + // Receiving is terminal — once stock has come in the door
  220 + // the cancellation flow is "issue a return to the supplier",
  221 + // not a status flip.
  222 + require(order.status != PurchaseOrderStatus.RECEIVED) {
  223 + "cannot cancel purchase order ${order.code} in status RECEIVED; " +
  224 + "issue a return-to-supplier flow instead"
  225 + }
  226 + order.status = PurchaseOrderStatus.CANCELLED
  227 + return order
  228 + }
  229 +
  230 + /**
  231 + * Mark a CONFIRMED purchase order as RECEIVED, crediting stock
  232 + * for every line into [receivingLocationCode] in the same
  233 + * transaction.
  234 + *
  235 + * **The mirror of `SalesOrderService.ship`.** Where shipping
  236 + * debits stock with `SALES_SHIPMENT`, receipt credits stock
  237 + * with `PURCHASE_RECEIPT` and a positive delta. The cross-PBC
  238 + * write goes through the same `InventoryApi.recordMovement`
  239 + * facade — that's the whole point of the api.v1 contract: one
  240 + * primitive, two callers, both behaving consistently.
  241 + *
  242 + * The whole operation runs in ONE transaction. A failure on
  243 + * any line — bad item, bad location, bad reason sign (sign
  244 + * mismatch is impossible here because we hard-code the sign,
  245 + * but the validation is still active) — rolls back EVERY
  246 + * line's already-written movement AND the order status change.
  247 + * The customer cannot end up with "5 of 7 lines received,
  248 + * status still CONFIRMED, ledger half-written".
  249 + *
  250 + * @throws IllegalArgumentException if the order is not CONFIRMED,
  251 + * if the location code is unknown, or if any line cannot be
  252 + * recorded (catalog validation failure).
  253 + */
  254 + fun receive(id: UUID, receivingLocationCode: String): PurchaseOrder {
  255 + val order = orders.findById(id).orElseThrow {
  256 + NoSuchElementException("purchase order not found: $id")
  257 + }
  258 + require(order.status == PurchaseOrderStatus.CONFIRMED) {
  259 + "cannot receive purchase order ${order.code} in status ${order.status}; " +
  260 + "only CONFIRMED orders can be received"
  261 + }
  262 +
  263 + val reference = "PO:${order.code}"
  264 + for (line in order.lines) {
  265 + inventoryApi.recordMovement(
  266 + itemCode = line.itemCode,
  267 + locationCode = receivingLocationCode,
  268 + delta = line.quantity,
  269 + reason = "PURCHASE_RECEIPT",
  270 + reference = reference,
  271 + )
  272 + }
  273 +
  274 + order.status = PurchaseOrderStatus.RECEIVED
  275 + return order
  276 + }
  277 +
  278 + @Suppress("UNCHECKED_CAST")
  279 + fun parseExt(order: PurchaseOrder): Map<String, Any?> = try {
  280 + if (order.ext.isBlank()) emptyMap()
  281 + else jsonMapper.readValue(order.ext, Map::class.java) as Map<String, Any?>
  282 + } catch (ex: Throwable) {
  283 + emptyMap()
  284 + }
  285 +
  286 + companion object {
  287 + const val ENTITY_NAME: String = "PurchaseOrder"
  288 + }
  289 +}
  290 +
  291 +data class CreatePurchaseOrderCommand(
  292 + val code: String,
  293 + val partnerCode: String,
  294 + val orderDate: LocalDate,
  295 + val expectedDate: LocalDate? = null,
  296 + val currencyCode: String,
  297 + val lines: List<PurchaseOrderLineCommand>,
  298 + val ext: Map<String, Any?>? = null,
  299 +)
  300 +
  301 +data class UpdatePurchaseOrderCommand(
  302 + val partnerCode: String? = null,
  303 + val orderDate: LocalDate? = null,
  304 + val expectedDate: LocalDate? = null,
  305 + val currencyCode: String? = null,
  306 + val lines: List<PurchaseOrderLineCommand>? = null,
  307 + val ext: Map<String, Any?>? = null,
  308 +)
  309 +
  310 +data class PurchaseOrderLineCommand(
  311 + val lineNo: Int,
  312 + val itemCode: String,
  313 + val quantity: BigDecimal,
  314 + val unitPrice: BigDecimal,
  315 + val currencyCode: String,
  316 +)
... ...
pbc/pbc-orders-purchase/src/main/kotlin/org/vibeerp/pbc/orders/purchase/domain/PurchaseOrder.kt 0 → 100644
  1 +package org.vibeerp.pbc.orders.purchase.domain
  2 +
  3 +import jakarta.persistence.CascadeType
  4 +import jakarta.persistence.Column
  5 +import jakarta.persistence.Entity
  6 +import jakarta.persistence.EnumType
  7 +import jakarta.persistence.Enumerated
  8 +import jakarta.persistence.FetchType
  9 +import jakarta.persistence.OneToMany
  10 +import jakarta.persistence.OrderBy
  11 +import jakarta.persistence.Table
  12 +import org.hibernate.annotations.JdbcTypeCode
  13 +import org.hibernate.type.SqlTypes
  14 +import org.vibeerp.platform.persistence.audit.AuditedJpaEntity
  15 +import java.math.BigDecimal
  16 +import java.time.LocalDate
  17 +
  18 +/**
  19 + * The header of a purchase order.
  20 + *
  21 + * **The buying-side mirror of [org.vibeerp.pbc.orders.sales.domain.SalesOrder].**
  22 + * Where a sales order CONFIRMs that we will sell to a customer and
  23 + * SHIPs to debit our stock, a purchase order CONFIRMs that we will
  24 + * buy from a supplier and RECEIVEs to credit our stock. The two PBCs
  25 + * exist as separate aggregates because the lifecycles, the
  26 + * authorisation rules, and the partner roles are genuinely different
  27 + * — even though the data shape is similar.
  28 + *
  29 + * **Why a separate PBC instead of a `direction` enum on the existing
  30 + * sales order:** mixing buying and selling into one entity sounds
  31 + * tidy until the first time someone needs to ship a sales order from
  32 + * inside the same controller that's also receiving a purchase order.
  33 + * The Spring `@Transactional` boundaries, the permission keys, the
  34 + * downstream PBC integrations, and the metadata custom fields all
  35 + * end up needing per-direction conditions, which is exactly what a
  36 + * polymorphic table is bad at. Two PBCs, two adapters, two state
  37 + * machines, one shared `InventoryApi.recordMovement` for the
  38 + * stock side.
  39 + *
  40 + * **State machine:**
  41 + * - **DRAFT** — being prepared, lines may still be edited
  42 + * - **CONFIRMED** — committed, sent to the supplier
  43 + * - **RECEIVED** — terminal. Goods have arrived, ledger has been
  44 + * incremented via `InventoryApi.recordMovement`
  45 + * with `reason="PURCHASE_RECEIPT"`. The framework
  46 + * will NOT let you cancel a received PO (a return
  47 + * flow lands later as its own state).
  48 + * - **CANCELLED** — terminal. Reachable from DRAFT or CONFIRMED.
  49 + *
  50 + * Future expansion (PARTIALLY_RECEIVED, INVOICED, CLOSED) lands in
  51 + * its own focused chunk.
  52 + *
  53 + * **`expected_date`** captures the supplier's promised delivery
  54 + * date so the SPA can render an "expected vs received" view and
  55 + * the future production scheduler can plan around lead times. It is
  56 + * nullable because the date isn't always known at PO-creation time
  57 + * (some suppliers reply with a date several days later).
  58 + *
  59 + * Like sales orders, the `total_amount` is RECOMPUTED from the
  60 + * lines on every save and the caller's value is ignored — never
  61 + * trust a financial aggregate sent over the wire.
  62 + */
  63 +@Entity
  64 +@Table(name = "orders_purchase__purchase_order")
  65 +class PurchaseOrder(
  66 + code: String,
  67 + partnerCode: String,
  68 + status: PurchaseOrderStatus = PurchaseOrderStatus.DRAFT,
  69 + orderDate: LocalDate,
  70 + expectedDate: LocalDate? = null,
  71 + currencyCode: String,
  72 + totalAmount: BigDecimal = BigDecimal.ZERO,
  73 +) : AuditedJpaEntity() {
  74 +
  75 + @Column(name = "code", nullable = false, length = 64)
  76 + var code: String = code
  77 +
  78 + @Column(name = "partner_code", nullable = false, length = 64)
  79 + var partnerCode: String = partnerCode
  80 +
  81 + @Enumerated(EnumType.STRING)
  82 + @Column(name = "status", nullable = false, length = 16)
  83 + var status: PurchaseOrderStatus = status
  84 +
  85 + @Column(name = "order_date", nullable = false)
  86 + var orderDate: LocalDate = orderDate
  87 +
  88 + @Column(name = "expected_date", nullable = true)
  89 + var expectedDate: LocalDate? = expectedDate
  90 +
  91 + @Column(name = "currency_code", nullable = false, length = 3)
  92 + var currencyCode: String = currencyCode
  93 +
  94 + @Column(name = "total_amount", nullable = false, precision = 18, scale = 4)
  95 + var totalAmount: BigDecimal = totalAmount
  96 +
  97 + @Column(name = "ext", nullable = false, columnDefinition = "jsonb")
  98 + @JdbcTypeCode(SqlTypes.JSON)
  99 + var ext: String = "{}"
  100 +
  101 + @OneToMany(
  102 + mappedBy = "purchaseOrder",
  103 + cascade = [CascadeType.ALL],
  104 + orphanRemoval = true,
  105 + fetch = FetchType.EAGER,
  106 + )
  107 + @OrderBy("lineNo ASC")
  108 + var lines: MutableList<PurchaseOrderLine> = mutableListOf()
  109 +
  110 + override fun toString(): String =
  111 + "PurchaseOrder(id=$id, code='$code', supplier='$partnerCode', status=$status, total=$totalAmount $currencyCode)"
  112 +}
  113 +
  114 +/**
  115 + * Possible states a [PurchaseOrder] can be in. Mirrors
  116 + * [org.vibeerp.pbc.orders.sales.domain.SalesOrderStatus] with
  117 + * RECEIVED in place of SHIPPED.
  118 + */
  119 +enum class PurchaseOrderStatus {
  120 + DRAFT,
  121 + CONFIRMED,
  122 + RECEIVED,
  123 + CANCELLED,
  124 +}
... ...
pbc/pbc-orders-purchase/src/main/kotlin/org/vibeerp/pbc/orders/purchase/domain/PurchaseOrderLine.kt 0 → 100644
  1 +package org.vibeerp.pbc.orders.purchase.domain
  2 +
  3 +import jakarta.persistence.Column
  4 +import jakarta.persistence.Entity
  5 +import jakarta.persistence.JoinColumn
  6 +import jakarta.persistence.ManyToOne
  7 +import jakarta.persistence.Table
  8 +import org.vibeerp.platform.persistence.audit.AuditedJpaEntity
  9 +import java.math.BigDecimal
  10 +
  11 +/**
  12 + * One line item of a [PurchaseOrder]. Mirrors
  13 + * [org.vibeerp.pbc.orders.sales.domain.SalesOrderLine] — the same
  14 + * shape, the same rationale, just on the buying side.
  15 + *
  16 + * `item_code` is a varchar reference to the catalog (no FK across
  17 + * PBCs); `unit_price` and `currency_code` capture the price actually
  18 + * paid at the time the order was placed (a snapshot, not a price-book
  19 + * pointer); no `ext` jsonb on the line because lines are facts not
  20 + * master records.
  21 + *
  22 + * The line subtotal `lineTotal` is computed at read time. The
  23 + * service uses it to recompute the header `total_amount` on every
  24 + * save and ignore the caller's value, so the database total can
  25 + * never drift from the line sum.
  26 + */
  27 +@Entity
  28 +@Table(name = "orders_purchase__purchase_order_line")
  29 +class PurchaseOrderLine(
  30 + purchaseOrder: PurchaseOrder,
  31 + lineNo: Int,
  32 + itemCode: String,
  33 + quantity: BigDecimal,
  34 + unitPrice: BigDecimal,
  35 + currencyCode: String,
  36 +) : AuditedJpaEntity() {
  37 +
  38 + @ManyToOne
  39 + @JoinColumn(name = "purchase_order_id", nullable = false)
  40 + var purchaseOrder: PurchaseOrder = purchaseOrder
  41 +
  42 + @Column(name = "line_no", nullable = false)
  43 + var lineNo: Int = lineNo
  44 +
  45 + @Column(name = "item_code", nullable = false, length = 64)
  46 + var itemCode: String = itemCode
  47 +
  48 + @Column(name = "quantity", nullable = false, precision = 18, scale = 4)
  49 + var quantity: BigDecimal = quantity
  50 +
  51 + @Column(name = "unit_price", nullable = false, precision = 18, scale = 4)
  52 + var unitPrice: BigDecimal = unitPrice
  53 +
  54 + @Column(name = "currency_code", nullable = false, length = 3)
  55 + var currencyCode: String = currencyCode
  56 +
  57 + val lineTotal: BigDecimal
  58 + get() = quantity.multiply(unitPrice)
  59 +
  60 + override fun toString(): String =
  61 + "PurchaseOrderLine(id=$id, poId=${purchaseOrder.id}, line=$lineNo, item='$itemCode', qty=$quantity × $unitPrice = $lineTotal $currencyCode)"
  62 +}
... ...
pbc/pbc-orders-purchase/src/main/kotlin/org/vibeerp/pbc/orders/purchase/ext/PurchaseOrdersApiAdapter.kt 0 → 100644
  1 +package org.vibeerp.pbc.orders.purchase.ext
  2 +
  3 +import org.springframework.stereotype.Component
  4 +import org.springframework.transaction.annotation.Transactional
  5 +import org.vibeerp.api.v1.core.Id
  6 +import org.vibeerp.api.v1.ext.orders.PurchaseOrderRef
  7 +import org.vibeerp.api.v1.ext.orders.PurchaseOrdersApi
  8 +import org.vibeerp.api.v1.ext.orders.SalesOrderLineRef
  9 +import org.vibeerp.pbc.orders.purchase.domain.PurchaseOrder
  10 +import org.vibeerp.pbc.orders.purchase.domain.PurchaseOrderLine
  11 +import org.vibeerp.pbc.orders.purchase.infrastructure.PurchaseOrderJpaRepository
  12 +
  13 +/**
  14 + * Concrete [PurchaseOrdersApi] implementation. The sixth `*ApiAdapter`
  15 + * after Identity / Catalog / Partners / Inventory / SalesOrders.
  16 + *
  17 + * Mirror of [org.vibeerp.pbc.orders.sales.ext.SalesOrdersApiAdapter].
  18 + * Cancelled AND received orders ARE returned by the facade — the
  19 + * downstream consumers (production scheduling, invoicing) may
  20 + * legitimately need to react to either state.
  21 + *
  22 + * The adapter reuses [SalesOrderLineRef] for the line shape because
  23 + * the buying and selling lines carry the same fields. See the
  24 + * rationale on [PurchaseOrderRef].
  25 + */
  26 +@Component
  27 +@Transactional(readOnly = true)
  28 +class PurchaseOrdersApiAdapter(
  29 + private val orders: PurchaseOrderJpaRepository,
  30 +) : PurchaseOrdersApi {
  31 +
  32 + override fun findByCode(code: String): PurchaseOrderRef? =
  33 + orders.findByCode(code)?.toRef()
  34 +
  35 + override fun findById(id: Id<PurchaseOrderRef>): PurchaseOrderRef? =
  36 + orders.findById(id.value).orElse(null)?.toRef()
  37 +
  38 + private fun PurchaseOrder.toRef(): PurchaseOrderRef = PurchaseOrderRef(
  39 + id = Id<PurchaseOrderRef>(this.id),
  40 + code = this.code,
  41 + partnerCode = this.partnerCode,
  42 + status = this.status.name,
  43 + orderDate = this.orderDate,
  44 + expectedDate = this.expectedDate,
  45 + currencyCode = this.currencyCode,
  46 + totalAmount = this.totalAmount,
  47 + lines = this.lines.map { it.toRef() },
  48 + )
  49 +
  50 + private fun PurchaseOrderLine.toRef(): SalesOrderLineRef = SalesOrderLineRef(
  51 + lineNo = this.lineNo,
  52 + itemCode = this.itemCode,
  53 + quantity = this.quantity,
  54 + unitPrice = this.unitPrice,
  55 + currencyCode = this.currencyCode,
  56 + )
  57 +}
... ...
pbc/pbc-orders-purchase/src/main/kotlin/org/vibeerp/pbc/orders/purchase/http/PurchaseOrderController.kt 0 → 100644
  1 +package org.vibeerp.pbc.orders.purchase.http
  2 +
  3 +import com.fasterxml.jackson.annotation.JsonCreator
  4 +import com.fasterxml.jackson.annotation.JsonProperty
  5 +import jakarta.validation.Valid
  6 +import jakarta.validation.constraints.NotBlank
  7 +import jakarta.validation.constraints.NotEmpty
  8 +import jakarta.validation.constraints.NotNull
  9 +import jakarta.validation.constraints.Size
  10 +import org.springframework.http.HttpStatus
  11 +import org.springframework.http.ResponseEntity
  12 +import org.springframework.web.bind.annotation.GetMapping
  13 +import org.springframework.web.bind.annotation.PatchMapping
  14 +import org.springframework.web.bind.annotation.PathVariable
  15 +import org.springframework.web.bind.annotation.PostMapping
  16 +import org.springframework.web.bind.annotation.RequestBody
  17 +import org.springframework.web.bind.annotation.RequestMapping
  18 +import org.springframework.web.bind.annotation.ResponseStatus
  19 +import org.springframework.web.bind.annotation.RestController
  20 +import org.vibeerp.pbc.orders.purchase.application.CreatePurchaseOrderCommand
  21 +import org.vibeerp.pbc.orders.purchase.application.PurchaseOrderLineCommand
  22 +import org.vibeerp.pbc.orders.purchase.application.PurchaseOrderService
  23 +import org.vibeerp.pbc.orders.purchase.application.UpdatePurchaseOrderCommand
  24 +import org.vibeerp.pbc.orders.purchase.domain.PurchaseOrder
  25 +import org.vibeerp.pbc.orders.purchase.domain.PurchaseOrderLine
  26 +import org.vibeerp.pbc.orders.purchase.domain.PurchaseOrderStatus
  27 +import org.vibeerp.platform.security.authz.RequirePermission
  28 +import java.math.BigDecimal
  29 +import java.time.LocalDate
  30 +import java.util.UUID
  31 +
  32 +/**
  33 + * REST API for the purchase orders PBC.
  34 + *
  35 + * Mounted at `/api/v1/orders/purchase-orders`. Same shape as the
  36 + * sales-orders controller — CRUD plus state transitions on dedicated
  37 + * `/confirm`, `/cancel`, and `/receive` endpoints.
  38 + */
  39 +@RestController
  40 +@RequestMapping("/api/v1/orders/purchase-orders")
  41 +class PurchaseOrderController(
  42 + private val purchaseOrderService: PurchaseOrderService,
  43 +) {
  44 +
  45 + @GetMapping
  46 + fun list(): List<PurchaseOrderResponse> =
  47 + purchaseOrderService.list().map { it.toResponse(purchaseOrderService) }
  48 +
  49 + @GetMapping("/{id}")
  50 + fun get(@PathVariable id: UUID): ResponseEntity<PurchaseOrderResponse> {
  51 + val order = purchaseOrderService.findById(id) ?: return ResponseEntity.notFound().build()
  52 + return ResponseEntity.ok(order.toResponse(purchaseOrderService))
  53 + }
  54 +
  55 + @GetMapping("/by-code/{code}")
  56 + fun getByCode(@PathVariable code: String): ResponseEntity<PurchaseOrderResponse> {
  57 + val order = purchaseOrderService.findByCode(code) ?: return ResponseEntity.notFound().build()
  58 + return ResponseEntity.ok(order.toResponse(purchaseOrderService))
  59 + }
  60 +
  61 + @PostMapping
  62 + @ResponseStatus(HttpStatus.CREATED)
  63 + fun create(@RequestBody @Valid request: CreatePurchaseOrderRequest): PurchaseOrderResponse =
  64 + purchaseOrderService.create(
  65 + CreatePurchaseOrderCommand(
  66 + code = request.code,
  67 + partnerCode = request.partnerCode,
  68 + orderDate = request.orderDate,
  69 + expectedDate = request.expectedDate,
  70 + currencyCode = request.currencyCode,
  71 + lines = request.lines.map { it.toCommand() },
  72 + ext = request.ext,
  73 + ),
  74 + ).toResponse(purchaseOrderService)
  75 +
  76 + @PatchMapping("/{id}")
  77 + fun update(
  78 + @PathVariable id: UUID,
  79 + @RequestBody @Valid request: UpdatePurchaseOrderRequest,
  80 + ): PurchaseOrderResponse =
  81 + purchaseOrderService.update(
  82 + id,
  83 + UpdatePurchaseOrderCommand(
  84 + partnerCode = request.partnerCode,
  85 + orderDate = request.orderDate,
  86 + expectedDate = request.expectedDate,
  87 + currencyCode = request.currencyCode,
  88 + lines = request.lines?.map { it.toCommand() },
  89 + ext = request.ext,
  90 + ),
  91 + ).toResponse(purchaseOrderService)
  92 +
  93 + @PostMapping("/{id}/confirm")
  94 + @RequirePermission("orders.purchase.confirm")
  95 + fun confirm(@PathVariable id: UUID): PurchaseOrderResponse =
  96 + purchaseOrderService.confirm(id).toResponse(purchaseOrderService)
  97 +
  98 + @PostMapping("/{id}/cancel")
  99 + @RequirePermission("orders.purchase.cancel")
  100 + fun cancel(@PathVariable id: UUID): PurchaseOrderResponse =
  101 + purchaseOrderService.cancel(id).toResponse(purchaseOrderService)
  102 +
  103 + /**
  104 + * Receive a CONFIRMED purchase order. Atomically credits stock
  105 + * for every line into the location named in the request, then
  106 + * flips the order to RECEIVED. The whole operation runs in one
  107 + * transaction — see [PurchaseOrderService.receive] for the full
  108 + * rationale.
  109 + */
  110 + @PostMapping("/{id}/receive")
  111 + @RequirePermission("orders.purchase.receive")
  112 + fun receive(
  113 + @PathVariable id: UUID,
  114 + @RequestBody @Valid request: ReceivePurchaseOrderRequest,
  115 + ): PurchaseOrderResponse =
  116 + purchaseOrderService.receive(id, request.receivingLocationCode).toResponse(purchaseOrderService)
  117 +}
  118 +
  119 +// ─── DTOs ────────────────────────────────────────────────────────────
  120 +
  121 +data class CreatePurchaseOrderRequest(
  122 + @field:NotBlank @field:Size(max = 64) val code: String,
  123 + @field:NotBlank @field:Size(max = 64) val partnerCode: String,
  124 + @field:NotNull val orderDate: LocalDate,
  125 + val expectedDate: LocalDate? = null,
  126 + @field:NotBlank @field:Size(min = 3, max = 3) val currencyCode: String,
  127 + @field:NotEmpty @field:Valid val lines: List<PurchaseOrderLineRequest>,
  128 + val ext: Map<String, Any?>? = null,
  129 +)
  130 +
  131 +data class UpdatePurchaseOrderRequest(
  132 + @field:Size(max = 64) val partnerCode: String? = null,
  133 + val orderDate: LocalDate? = null,
  134 + val expectedDate: LocalDate? = null,
  135 + @field:Size(min = 3, max = 3) val currencyCode: String? = null,
  136 + @field:Valid val lines: List<PurchaseOrderLineRequest>? = null,
  137 + val ext: Map<String, Any?>? = null,
  138 +)
  139 +
  140 +data class PurchaseOrderLineRequest(
  141 + @field:NotNull val lineNo: Int,
  142 + @field:NotBlank @field:Size(max = 64) val itemCode: String,
  143 + @field:NotNull val quantity: BigDecimal,
  144 + @field:NotNull val unitPrice: BigDecimal,
  145 + @field:NotBlank @field:Size(min = 3, max = 3) val currencyCode: String,
  146 +) {
  147 + fun toCommand(): PurchaseOrderLineCommand = PurchaseOrderLineCommand(
  148 + lineNo = lineNo,
  149 + itemCode = itemCode,
  150 + quantity = quantity,
  151 + unitPrice = unitPrice,
  152 + currencyCode = currencyCode,
  153 + )
  154 +}
  155 +
  156 +/**
  157 + * Receiving request body.
  158 + *
  159 + * **Single-arg Kotlin data class — same Jackson trap as
  160 + * [org.vibeerp.pbc.orders.sales.http.ShipSalesOrderRequest].**
  161 + * jackson-module-kotlin treats a one-arg data class as a delegate-
  162 + * based creator and unwraps the body, which is wrong for HTTP
  163 + * payloads that always look like `{"receivingLocationCode": "..."}`.
  164 + * Fix: explicit `@JsonCreator(mode = PROPERTIES)` + `@param:JsonProperty`.
  165 + */
  166 +data class ReceivePurchaseOrderRequest @JsonCreator(mode = JsonCreator.Mode.PROPERTIES) constructor(
  167 + @param:JsonProperty("receivingLocationCode")
  168 + @field:NotBlank @field:Size(max = 64) val receivingLocationCode: String,
  169 +)
  170 +
  171 +data class PurchaseOrderResponse(
  172 + val id: UUID,
  173 + val code: String,
  174 + val partnerCode: String,
  175 + val status: PurchaseOrderStatus,
  176 + val orderDate: LocalDate,
  177 + val expectedDate: LocalDate?,
  178 + val currencyCode: String,
  179 + val totalAmount: BigDecimal,
  180 + val lines: List<PurchaseOrderLineResponse>,
  181 + val ext: Map<String, Any?>,
  182 +)
  183 +
  184 +data class PurchaseOrderLineResponse(
  185 + val id: UUID,
  186 + val lineNo: Int,
  187 + val itemCode: String,
  188 + val quantity: BigDecimal,
  189 + val unitPrice: BigDecimal,
  190 + val currencyCode: String,
  191 + val lineTotal: BigDecimal,
  192 +)
  193 +
  194 +private fun PurchaseOrder.toResponse(service: PurchaseOrderService) = PurchaseOrderResponse(
  195 + id = this.id,
  196 + code = this.code,
  197 + partnerCode = this.partnerCode,
  198 + status = this.status,
  199 + orderDate = this.orderDate,
  200 + expectedDate = this.expectedDate,
  201 + currencyCode = this.currencyCode,
  202 + totalAmount = this.totalAmount,
  203 + lines = this.lines.map { it.toResponse() },
  204 + ext = service.parseExt(this),
  205 +)
  206 +
  207 +private fun PurchaseOrderLine.toResponse() = PurchaseOrderLineResponse(
  208 + id = this.id,
  209 + lineNo = this.lineNo,
  210 + itemCode = this.itemCode,
  211 + quantity = this.quantity,
  212 + unitPrice = this.unitPrice,
  213 + currencyCode = this.currencyCode,
  214 + lineTotal = this.lineTotal,
  215 +)
... ...
pbc/pbc-orders-purchase/src/main/kotlin/org/vibeerp/pbc/orders/purchase/infrastructure/PurchaseOrderJpaRepository.kt 0 → 100644
  1 +package org.vibeerp.pbc.orders.purchase.infrastructure
  2 +
  3 +import org.springframework.data.jpa.repository.JpaRepository
  4 +import org.springframework.stereotype.Repository
  5 +import org.vibeerp.pbc.orders.purchase.domain.PurchaseOrder
  6 +import java.util.UUID
  7 +
  8 +/**
  9 + * Spring Data JPA repository for [PurchaseOrder]. Same shape as the
  10 + * sales-order repo: lookups by id and by code; no on-the-fly
  11 + * aggregation against the live OLTP table.
  12 + */
  13 +@Repository
  14 +interface PurchaseOrderJpaRepository : JpaRepository<PurchaseOrder, UUID> {
  15 +
  16 + fun findByCode(code: String): PurchaseOrder?
  17 +
  18 + fun existsByCode(code: String): Boolean
  19 +}
... ...
pbc/pbc-orders-purchase/src/main/resources/META-INF/vibe-erp/metadata/orders-purchase.yml 0 → 100644
  1 +# pbc-orders-purchase metadata.
  2 +#
  3 +# Loaded at boot by MetadataLoader, tagged source='core'.
  4 +
  5 +entities:
  6 + - name: PurchaseOrder
  7 + pbc: orders-purchase
  8 + table: orders_purchase__purchase_order
  9 + description: A purchase order header — supplier, currency, total, status
  10 +
  11 + - name: PurchaseOrderLine
  12 + pbc: orders-purchase
  13 + table: orders_purchase__purchase_order_line
  14 + description: One line item of a purchase order (item × quantity × unit price)
  15 +
  16 +permissions:
  17 + - key: orders.purchase.read
  18 + description: Read purchase orders
  19 + - key: orders.purchase.create
  20 + description: Create draft purchase orders
  21 + - key: orders.purchase.update
  22 + description: Update DRAFT purchase orders (lines, supplier, dates)
  23 + - key: orders.purchase.confirm
  24 + description: Confirm a draft purchase order (DRAFT → CONFIRMED)
  25 + - key: orders.purchase.cancel
  26 + description: Cancel a purchase order (DRAFT or CONFIRMED → CANCELLED)
  27 + - key: orders.purchase.receive
  28 + description: Mark a confirmed purchase order received (CONFIRMED → RECEIVED, credits inventory atomically)
  29 +
  30 +menus:
  31 + - path: /orders/purchase
  32 + label: Purchase orders
  33 + icon: shopping-cart
  34 + section: Purchasing
  35 + order: 600
... ...
pbc/pbc-orders-purchase/src/test/kotlin/org/vibeerp/pbc/orders/purchase/application/PurchaseOrderServiceTest.kt 0 → 100644
  1 +package org.vibeerp.pbc.orders.purchase.application
  2 +
  3 +import assertk.assertFailure
  4 +import assertk.assertThat
  5 +import assertk.assertions.hasMessage
  6 +import assertk.assertions.hasSize
  7 +import assertk.assertions.isEqualTo
  8 +import assertk.assertions.isInstanceOf
  9 +import assertk.assertions.messageContains
  10 +import io.mockk.every
  11 +import io.mockk.mockk
  12 +import io.mockk.slot
  13 +import io.mockk.verify
  14 +import org.junit.jupiter.api.BeforeEach
  15 +import org.junit.jupiter.api.Test
  16 +import org.vibeerp.api.v1.core.Id
  17 +import org.vibeerp.api.v1.ext.catalog.CatalogApi
  18 +import org.vibeerp.api.v1.ext.catalog.ItemRef
  19 +import org.vibeerp.api.v1.ext.inventory.InventoryApi
  20 +import org.vibeerp.api.v1.ext.inventory.StockBalanceRef
  21 +import org.vibeerp.api.v1.ext.partners.PartnerRef
  22 +import org.vibeerp.api.v1.ext.partners.PartnersApi
  23 +import org.vibeerp.pbc.orders.purchase.domain.PurchaseOrder
  24 +import org.vibeerp.pbc.orders.purchase.domain.PurchaseOrderLine
  25 +import org.vibeerp.pbc.orders.purchase.domain.PurchaseOrderStatus
  26 +import org.vibeerp.pbc.orders.purchase.infrastructure.PurchaseOrderJpaRepository
  27 +import org.vibeerp.platform.metadata.customfield.ExtJsonValidator
  28 +import java.math.BigDecimal
  29 +import java.time.LocalDate
  30 +import java.util.Optional
  31 +import java.util.UUID
  32 +
  33 +class PurchaseOrderServiceTest {
  34 +
  35 + private lateinit var orders: PurchaseOrderJpaRepository
  36 + private lateinit var partnersApi: PartnersApi
  37 + private lateinit var catalogApi: CatalogApi
  38 + private lateinit var inventoryApi: InventoryApi
  39 + private lateinit var extValidator: ExtJsonValidator
  40 + private lateinit var service: PurchaseOrderService
  41 +
  42 + @BeforeEach
  43 + fun setUp() {
  44 + orders = mockk()
  45 + partnersApi = mockk()
  46 + catalogApi = mockk()
  47 + inventoryApi = mockk()
  48 + extValidator = mockk()
  49 + every { extValidator.validate(any(), any()) } answers { secondArg() ?: emptyMap() }
  50 + every { orders.existsByCode(any()) } returns false
  51 + every { orders.save(any<PurchaseOrder>()) } answers { firstArg() }
  52 + service = PurchaseOrderService(orders, partnersApi, catalogApi, inventoryApi, extValidator)
  53 + }
  54 +
  55 + private fun stubSupplier(code: String, type: String = "SUPPLIER") {
  56 + every { partnersApi.findPartnerByCode(code) } returns PartnerRef(
  57 + id = Id(UUID.randomUUID()),
  58 + code = code,
  59 + name = "Stub partner",
  60 + type = type,
  61 + taxId = null,
  62 + active = true,
  63 + )
  64 + }
  65 +
  66 + private fun stubItem(code: String) {
  67 + every { catalogApi.findItemByCode(code) } returns ItemRef(
  68 + id = Id(UUID.randomUUID()),
  69 + code = code,
  70 + name = "Stub item",
  71 + itemType = "GOOD",
  72 + baseUomCode = "ea",
  73 + active = true,
  74 + )
  75 + }
  76 +
  77 + private fun line(no: Int = 1, item: String = "PAPER-A4", qty: String = "100", price: String = "0.04") =
  78 + PurchaseOrderLineCommand(
  79 + lineNo = no,
  80 + itemCode = item,
  81 + quantity = BigDecimal(qty),
  82 + unitPrice = BigDecimal(price),
  83 + currencyCode = "USD",
  84 + )
  85 +
  86 + private fun command(
  87 + code: String = "PO-1",
  88 + partnerCode: String = "SUP-1",
  89 + currency: String = "USD",
  90 + lines: List<PurchaseOrderLineCommand> = listOf(line()),
  91 + ) = CreatePurchaseOrderCommand(
  92 + code = code,
  93 + partnerCode = partnerCode,
  94 + orderDate = LocalDate.of(2026, 4, 8),
  95 + currencyCode = currency,
  96 + lines = lines,
  97 + )
  98 +
  99 + @Test
  100 + fun `create rejects unknown supplier via PartnersApi seam`() {
  101 + every { partnersApi.findPartnerByCode("FAKE") } returns null
  102 +
  103 + assertFailure { service.create(command(partnerCode = "FAKE")) }
  104 + .isInstanceOf(IllegalArgumentException::class)
  105 + .hasMessage("partner code 'FAKE' is not in the partners directory (or is inactive)")
  106 + }
  107 +
  108 + @Test
  109 + fun `create rejects CUSTOMER-only partner as supplier`() {
  110 + stubSupplier("CUST-1", type = "CUSTOMER")
  111 +
  112 + assertFailure { service.create(command(partnerCode = "CUST-1")) }
  113 + .isInstanceOf(IllegalArgumentException::class)
  114 + .messageContains("cannot be the supplier of a purchase order")
  115 + }
  116 +
  117 + @Test
  118 + fun `create accepts BOTH partner type as supplier`() {
  119 + stubSupplier("BOTH-1", type = "BOTH")
  120 + stubItem("PAPER-A4")
  121 +
  122 + val saved = service.create(command(partnerCode = "BOTH-1"))
  123 +
  124 + assertThat(saved.partnerCode).isEqualTo("BOTH-1")
  125 + }
  126 +
  127 + @Test
  128 + fun `create rejects unknown item via CatalogApi seam`() {
  129 + stubSupplier("SUP-1")
  130 + every { catalogApi.findItemByCode("FAKE-ITEM") } returns null
  131 +
  132 + assertFailure {
  133 + service.create(command(lines = listOf(line(item = "FAKE-ITEM"))))
  134 + }
  135 + .isInstanceOf(IllegalArgumentException::class)
  136 + .messageContains("item code 'FAKE-ITEM' is not in the catalog")
  137 + }
  138 +
  139 + @Test
  140 + fun `create rejects empty lines`() {
  141 + stubSupplier("SUP-1")
  142 +
  143 + assertFailure { service.create(command(lines = emptyList())) }
  144 + .isInstanceOf(IllegalArgumentException::class)
  145 + .hasMessage("purchase order must have at least one line")
  146 + }
  147 +
  148 + @Test
  149 + fun `create recomputes total from lines, ignoring caller`() {
  150 + stubSupplier("SUP-1")
  151 + stubItem("PAPER-A4")
  152 + stubItem("INK-CYAN")
  153 + val saved = slot<PurchaseOrder>()
  154 + every { orders.save(capture(saved)) } answers { saved.captured }
  155 +
  156 + val result = service.create(
  157 + command(
  158 + lines = listOf(
  159 + line(no = 1, item = "PAPER-A4", qty = "5000", price = "0.04"), // 200.00
  160 + line(no = 2, item = "INK-CYAN", qty = "10", price = "35.00"), // 350.00
  161 + ),
  162 + ),
  163 + )
  164 +
  165 + // 5000 × 0.04 + 10 × 35.00 = 200.00 + 350.00 = 550.00
  166 + assertThat(result.totalAmount).isEqualTo(BigDecimal("550.00"))
  167 + assertThat(result.lines).hasSize(2)
  168 + }
  169 +
  170 + @Test
  171 + fun `confirm transitions DRAFT to CONFIRMED`() {
  172 + val id = UUID.randomUUID()
  173 + val order = PurchaseOrder(
  174 + code = "PO-1",
  175 + partnerCode = "SUP-1",
  176 + status = PurchaseOrderStatus.DRAFT,
  177 + orderDate = LocalDate.of(2026, 4, 8),
  178 + currencyCode = "USD",
  179 + ).also { it.id = id }
  180 + every { orders.findById(id) } returns Optional.of(order)
  181 +
  182 + val confirmed = service.confirm(id)
  183 +
  184 + assertThat(confirmed.status).isEqualTo(PurchaseOrderStatus.CONFIRMED)
  185 + }
  186 +
  187 + // ─── receive() ───────────────────────────────────────────────────
  188 +
  189 + private fun confirmedPo(
  190 + id: UUID = UUID.randomUUID(),
  191 + lines: List<Pair<String, String>> = listOf("PAPER-A4" to "5000"),
  192 + ): PurchaseOrder {
  193 + val order = PurchaseOrder(
  194 + code = "PO-1",
  195 + partnerCode = "SUP-1",
  196 + status = PurchaseOrderStatus.CONFIRMED,
  197 + orderDate = LocalDate.of(2026, 4, 8),
  198 + currencyCode = "USD",
  199 + ).also { it.id = id }
  200 + var n = 1
  201 + for ((item, qty) in lines) {
  202 + order.lines += PurchaseOrderLine(
  203 + purchaseOrder = order,
  204 + lineNo = n++,
  205 + itemCode = item,
  206 + quantity = BigDecimal(qty),
  207 + unitPrice = BigDecimal("0.04"),
  208 + currencyCode = "USD",
  209 + )
  210 + }
  211 + return order
  212 + }
  213 +
  214 + private fun stubInventoryCredit(itemCode: String, locationCode: String, expectedDelta: BigDecimal) {
  215 + every {
  216 + inventoryApi.recordMovement(
  217 + itemCode = itemCode,
  218 + locationCode = locationCode,
  219 + delta = expectedDelta,
  220 + reason = "PURCHASE_RECEIPT",
  221 + reference = any(),
  222 + )
  223 + } returns StockBalanceRef(
  224 + id = Id(UUID.randomUUID()),
  225 + itemCode = itemCode,
  226 + locationCode = locationCode,
  227 + quantity = BigDecimal("1000"),
  228 + )
  229 + }
  230 +
  231 + @Test
  232 + fun `receive rejects a non-CONFIRMED order`() {
  233 + val id = UUID.randomUUID()
  234 + val draft = PurchaseOrder(
  235 + code = "PO-1",
  236 + partnerCode = "SUP-1",
  237 + status = PurchaseOrderStatus.DRAFT,
  238 + orderDate = LocalDate.of(2026, 4, 8),
  239 + currencyCode = "USD",
  240 + ).also { it.id = id }
  241 + every { orders.findById(id) } returns Optional.of(draft)
  242 +
  243 + assertFailure { service.receive(id, "WH-MAIN") }
  244 + .isInstanceOf(IllegalArgumentException::class)
  245 + .messageContains("only CONFIRMED orders can be received")
  246 + verify(exactly = 0) { inventoryApi.recordMovement(any(), any(), any(), any(), any()) }
  247 + }
  248 +
  249 + @Test
  250 + fun `receive walks every line and calls inventoryApi recordMovement with positive delta`() {
  251 + val id = UUID.randomUUID()
  252 + val po = confirmedPo(id, lines = listOf("PAPER-A4" to "5000", "INK-CYAN" to "10"))
  253 + every { orders.findById(id) } returns Optional.of(po)
  254 + stubInventoryCredit("PAPER-A4", "WH-MAIN", BigDecimal("5000"))
  255 + stubInventoryCredit("INK-CYAN", "WH-MAIN", BigDecimal("10"))
  256 +
  257 + val received = service.receive(id, "WH-MAIN")
  258 +
  259 + assertThat(received.status).isEqualTo(PurchaseOrderStatus.RECEIVED)
  260 + verify(exactly = 1) {
  261 + inventoryApi.recordMovement(
  262 + itemCode = "PAPER-A4",
  263 + locationCode = "WH-MAIN",
  264 + delta = BigDecimal("5000"),
  265 + reason = "PURCHASE_RECEIPT",
  266 + reference = "PO:PO-1",
  267 + )
  268 + }
  269 + verify(exactly = 1) {
  270 + inventoryApi.recordMovement(
  271 + itemCode = "INK-CYAN",
  272 + locationCode = "WH-MAIN",
  273 + delta = BigDecimal("10"),
  274 + reason = "PURCHASE_RECEIPT",
  275 + reference = "PO:PO-1",
  276 + )
  277 + }
  278 + }
  279 +
  280 + @Test
  281 + fun `cancel rejects a RECEIVED order`() {
  282 + val id = UUID.randomUUID()
  283 + val received = PurchaseOrder(
  284 + code = "PO-1",
  285 + partnerCode = "SUP-1",
  286 + status = PurchaseOrderStatus.RECEIVED,
  287 + orderDate = LocalDate.of(2026, 4, 8),
  288 + currencyCode = "USD",
  289 + ).also { it.id = id }
  290 + every { orders.findById(id) } returns Optional.of(received)
  291 +
  292 + assertFailure { service.cancel(id) }
  293 + .isInstanceOf(IllegalArgumentException::class)
  294 + .messageContains("RECEIVED")
  295 + }
  296 +
  297 + @Test
  298 + fun `cancel a CONFIRMED order is allowed (before receipt)`() {
  299 + val id = UUID.randomUUID()
  300 + val po = PurchaseOrder(
  301 + code = "PO-1",
  302 + partnerCode = "SUP-1",
  303 + status = PurchaseOrderStatus.CONFIRMED,
  304 + orderDate = LocalDate.of(2026, 4, 8),
  305 + currencyCode = "USD",
  306 + ).also { it.id = id }
  307 + every { orders.findById(id) } returns Optional.of(po)
  308 +
  309 + val cancelled = service.cancel(id)
  310 +
  311 + assertThat(cancelled.status).isEqualTo(PurchaseOrderStatus.CANCELLED)
  312 + }
  313 +}
... ...
settings.gradle.kts
... ... @@ -58,6 +58,9 @@ project(&quot;:pbc:pbc-inventory&quot;).projectDir = file(&quot;pbc/pbc-inventory&quot;)
58 58 include(":pbc:pbc-orders-sales")
59 59 project(":pbc:pbc-orders-sales").projectDir = file("pbc/pbc-orders-sales")
60 60  
  61 +include(":pbc:pbc-orders-purchase")
  62 +project(":pbc:pbc-orders-purchase").projectDir = file("pbc/pbc-orders-purchase")
  63 +
61 64 // ─── Reference customer plug-in (NOT loaded by default) ─────────────
62 65 include(":reference-customer:plugin-printing-shop")
63 66 project(":reference-customer:plugin-printing-shop").projectDir = file("reference-customer/plugin-printing-shop")
... ...