Commit 28616565370ef6c974efeb045bed1a2e997fe439
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.
Showing
17 changed files
with
1382 additions
and
21 deletions
CLAUDE.md
| @@ -95,10 +95,10 @@ plugins (incl. ref) depend on: api/api-v1 only | @@ -95,10 +95,10 @@ plugins (incl. ref) depend on: api/api-v1 only | ||
| 95 | 95 | ||
| 96 | **Foundation complete; first business surface in place.** As of the latest commit: | 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 | - **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. | 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 | - **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. | 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 | - **Package root** is `org.vibeerp`. | 103 | - **Package root** is `org.vibeerp`. |
| 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. | 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,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 | | **Repo** | https://github.com/reporkey/vibe-erp | | 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 | | **Plug-ins serving HTTP** | 1 (reference printing-shop, 7 endpoints + own DB schema + own metadata + own i18n bundles) | | 20 | | **Plug-ins serving HTTP** | 1 (reference printing-shop, 7 endpoints + own DB schema + own metadata + own i18n bundles) | |
| 21 | | **Production-ready?** | **No.** Foundation is solid; many capabilities still deferred. | | 21 | | **Production-ready?** | **No.** Foundation is solid; many capabilities still deferred. | |
| 22 | 22 | ||
| 23 | ## Current stage | 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 | ## Total scope (the v1.0 cut line) | 29 | ## Total scope (the v1.0 cut line) |
| 30 | 30 | ||
| 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. | 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 | ### Phase 1 — Platform completion (foundation) | 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,7 +83,7 @@ That target breaks down into roughly 30 work units across 8 phases. About **21 a | ||
| 83 | | P5.3 | `pbc-inventory` — locations + stock balances (movements deferred) | ✅ DONE — `f63a73d` | | 83 | | P5.3 | `pbc-inventory` — locations + stock balances (movements deferred) | ✅ DONE — `f63a73d` | |
| 84 | | P5.4 | `pbc-warehousing` — receipts, picks, transfers, counts | 🔜 Pending | | 84 | | P5.4 | `pbc-warehousing` — receipts, picks, transfers, counts | 🔜 Pending | |
| 85 | | P5.5 | `pbc-orders-sales` — sales orders + lines (deliveries deferred) | ✅ DONE — `a8eb2a6` | | 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 | | P5.7 | `pbc-production` — work orders, routings, operations | 🔜 Pending | | 87 | | P5.7 | `pbc-production` — work orders, routings, operations | 🔜 Pending | |
| 88 | | P5.8 | `pbc-quality` — inspection plans, results, holds | 🔜 Pending | | 88 | | P5.8 | `pbc-quality` — inspection plans, results, holds | 🔜 Pending | |
| 89 | | P5.9 | `pbc-finance` — GL, journal entries, AR/AP minimal | 🔜 Pending | | 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,7 +129,7 @@ These are the cross-cutting platform services already wired into the running fra | ||
| 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. | | 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 | | **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. | | 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 | | **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. | | 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 | ## What the reference plug-in proves end-to-end | 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,7 +170,7 @@ This is **real**: a JAR file dropped into a directory, loaded by the framework, | ||
| 170 | - **File store.** No abstraction; no S3 backend. | 170 | - **File store.** No abstraction; no S3 backend. |
| 171 | - **Job scheduler.** No Quartz. Periodic jobs don't have a home. | 171 | - **Job scheduler.** No Quartz. Periodic jobs don't have a home. |
| 172 | - **OIDC.** Built-in JWT only. OIDC client (Keycloak-compatible) is P4.2. | 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 | - **Web SPA.** No React app. The framework is API-only today. | 174 | - **Web SPA.** No React app. The framework is API-only today. |
| 175 | - **MCP server.** The architecture leaves room for it; the implementation is v1.1. | 175 | - **MCP server.** The architecture leaves room for it; the implementation is v1.1. |
| 176 | - **Mobile.** v2. | 176 | - **Mobile.** v2. |
| @@ -217,7 +217,11 @@ pbc/pbc-partners Partner + Address + Contact entities + cross-PBC P | @@ -217,7 +217,11 @@ pbc/pbc-partners Partner + Address + Contact entities + cross-PBC P | ||
| 217 | pbc/pbc-inventory Location + StockBalance + cross-PBC InventoryApi facade | 217 | pbc/pbc-inventory Location + StockBalance + cross-PBC InventoryApi facade |
| 218 | (first PBC to CONSUME another PBC's facade — CatalogApi) | 218 | (first PBC to CONSUME another PBC's facade — CatalogApi) |
| 219 | pbc/pbc-orders-sales SalesOrder + SalesOrderLine + cross-PBC SalesOrdersApi facade | 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 | reference-customer/plugin-printing-shop | 226 | reference-customer/plugin-printing-shop |
| 223 | Reference plug-in: own DB schema (plate, ink_recipe), | 227 | Reference plug-in: own DB schema (plate, ink_recipe), |
| @@ -226,7 +230,7 @@ reference-customer/plugin-printing-shop | @@ -226,7 +230,7 @@ reference-customer/plugin-printing-shop | ||
| 226 | distribution Bootable Spring Boot fat-jar assembly | 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 | ## Where to look next | 235 | ## Where to look next |
| 232 | 236 |
README.md
| @@ -77,7 +77,7 @@ vibe-erp/ | @@ -77,7 +77,7 @@ vibe-erp/ | ||
| 77 | ## Building | 77 | ## Building |
| 78 | 78 | ||
| 79 | ```bash | 79 | ```bash |
| 80 | -# Build everything (compiles 15 modules, runs 175 unit tests) | 80 | +# Build everything (compiles 16 modules, runs 186 unit tests) |
| 81 | ./gradlew build | 81 | ./gradlew build |
| 82 | 82 | ||
| 83 | # Bring up Postgres + the reference plug-in JAR | 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,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 | | Cross-cutting services live | 9 | | 102 | | Cross-cutting services live | 9 | |
| 103 | | Plug-ins serving HTTP | 1 (reference printing-shop) | | 103 | | Plug-ins serving HTTP | 1 (reference printing-shop) | |
| 104 | | Production-ready | **No** — workflow engine, forms, web SPA, more PBCs all still pending | | 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,6 +31,7 @@ dependencies { | ||
| 31 | implementation(project(":pbc:pbc-partners")) | 31 | implementation(project(":pbc:pbc-partners")) |
| 32 | implementation(project(":pbc:pbc-inventory")) | 32 | implementation(project(":pbc:pbc-inventory")) |
| 33 | implementation(project(":pbc:pbc-orders-sales")) | 33 | implementation(project(":pbc:pbc-orders-sales")) |
| 34 | + implementation(project(":pbc:pbc-orders-purchase")) | ||
| 34 | 35 | ||
| 35 | implementation(libs.spring.boot.starter) | 36 | implementation(libs.spring.boot.starter) |
| 36 | implementation(libs.spring.boot.starter.web) | 37 | implementation(libs.spring.boot.starter.web) |
distribution/src/main/resources/db/changelog/master.xml
| @@ -19,4 +19,5 @@ | @@ -19,4 +19,5 @@ | ||
| 19 | <include file="classpath:db/changelog/pbc-inventory/001-inventory-init.xml"/> | 19 | <include file="classpath:db/changelog/pbc-inventory/001-inventory-init.xml"/> |
| 20 | <include file="classpath:db/changelog/pbc-inventory/002-inventory-movement-ledger.xml"/> | 20 | <include file="classpath:db/changelog/pbc-inventory/002-inventory-movement-ledger.xml"/> |
| 21 | <include file="classpath:db/changelog/pbc-orders-sales/001-orders-sales-init.xml"/> | 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 | </databaseChangeLog> | 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 > 0), | ||
| 73 | + CONSTRAINT orders_purchase__purchase_order_line_price_nonneg CHECK (unit_price >= 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(":pbc:pbc-inventory").projectDir = file("pbc/pbc-inventory") | @@ -58,6 +58,9 @@ project(":pbc:pbc-inventory").projectDir = file("pbc/pbc-inventory") | ||
| 58 | include(":pbc:pbc-orders-sales") | 58 | include(":pbc:pbc-orders-sales") |
| 59 | project(":pbc:pbc-orders-sales").projectDir = file("pbc/pbc-orders-sales") | 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 | // ─── Reference customer plug-in (NOT loaded by default) ───────────── | 64 | // ─── Reference customer plug-in (NOT loaded by default) ───────────── |
| 62 | include(":reference-customer:plugin-printing-shop") | 65 | include(":reference-customer:plugin-printing-shop") |
| 63 | project(":reference-customer:plugin-printing-shop").projectDir = file("reference-customer/plugin-printing-shop") | 66 | project(":reference-customer:plugin-printing-shop").projectDir = file("reference-customer/plugin-printing-shop") |