From 28616565370ef6c974efeb045bed1a2e997fe439 Mon Sep 17 00:00:00 2001 From: zichun Date: Wed, 8 Apr 2026 18:08:16 +0800 Subject: [PATCH] feat(pbc): P5.6 — pbc-orders-purchase + receive-driven inventory increase --- CLAUDE.md | 6 +++--- PROGRESS.md | 32 ++++++++++++++++++-------------- README.md | 8 ++++---- api/api-v1/src/main/kotlin/org/vibeerp/api/v1/ext/orders/PurchaseOrdersApi.kt | 71 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ distribution/build.gradle.kts | 1 + distribution/src/main/resources/db/changelog/master.xml | 1 + distribution/src/main/resources/db/changelog/pbc-orders-purchase/001-orders-purchase-init.xml | 85 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ pbc/pbc-orders-purchase/build.gradle.kts | 55 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ pbc/pbc-orders-purchase/src/main/kotlin/org/vibeerp/pbc/orders/purchase/application/PurchaseOrderService.kt | 316 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ pbc/pbc-orders-purchase/src/main/kotlin/org/vibeerp/pbc/orders/purchase/domain/PurchaseOrder.kt | 124 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ pbc/pbc-orders-purchase/src/main/kotlin/org/vibeerp/pbc/orders/purchase/domain/PurchaseOrderLine.kt | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ pbc/pbc-orders-purchase/src/main/kotlin/org/vibeerp/pbc/orders/purchase/ext/PurchaseOrdersApiAdapter.kt | 57 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ pbc/pbc-orders-purchase/src/main/kotlin/org/vibeerp/pbc/orders/purchase/http/PurchaseOrderController.kt | 215 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ pbc/pbc-orders-purchase/src/main/kotlin/org/vibeerp/pbc/orders/purchase/infrastructure/PurchaseOrderJpaRepository.kt | 19 +++++++++++++++++++ pbc/pbc-orders-purchase/src/main/resources/META-INF/vibe-erp/metadata/orders-purchase.yml | 35 +++++++++++++++++++++++++++++++++++ pbc/pbc-orders-purchase/src/test/kotlin/org/vibeerp/pbc/orders/purchase/application/PurchaseOrderServiceTest.kt | 313 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ settings.gradle.kts | 3 +++ 17 files changed, 1382 insertions(+), 21 deletions(-) create mode 100644 api/api-v1/src/main/kotlin/org/vibeerp/api/v1/ext/orders/PurchaseOrdersApi.kt create mode 100644 distribution/src/main/resources/db/changelog/pbc-orders-purchase/001-orders-purchase-init.xml create mode 100644 pbc/pbc-orders-purchase/build.gradle.kts create mode 100644 pbc/pbc-orders-purchase/src/main/kotlin/org/vibeerp/pbc/orders/purchase/application/PurchaseOrderService.kt create mode 100644 pbc/pbc-orders-purchase/src/main/kotlin/org/vibeerp/pbc/orders/purchase/domain/PurchaseOrder.kt create mode 100644 pbc/pbc-orders-purchase/src/main/kotlin/org/vibeerp/pbc/orders/purchase/domain/PurchaseOrderLine.kt create mode 100644 pbc/pbc-orders-purchase/src/main/kotlin/org/vibeerp/pbc/orders/purchase/ext/PurchaseOrdersApiAdapter.kt create mode 100644 pbc/pbc-orders-purchase/src/main/kotlin/org/vibeerp/pbc/orders/purchase/http/PurchaseOrderController.kt create mode 100644 pbc/pbc-orders-purchase/src/main/kotlin/org/vibeerp/pbc/orders/purchase/infrastructure/PurchaseOrderJpaRepository.kt create mode 100644 pbc/pbc-orders-purchase/src/main/resources/META-INF/vibe-erp/metadata/orders-purchase.yml create mode 100644 pbc/pbc-orders-purchase/src/test/kotlin/org/vibeerp/pbc/orders/purchase/application/PurchaseOrderServiceTest.kt diff --git a/CLAUDE.md b/CLAUDE.md index 8554d15..b6f6d12 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -95,10 +95,10 @@ plugins (incl. ref) depend on: api/api-v1 only **Foundation complete; first business surface in place.** As of the latest commit: -- **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`. -- **175 unit tests across 15 modules**, all green. `./gradlew build` is the canonical full build. +- **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`. +- **186 unit tests across 16 modules**, all green. `./gradlew build` is the canonical full build. - **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. -- **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. +- **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. - **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. - **Package root** is `org.vibeerp`. - **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. diff --git a/PROGRESS.md b/PROGRESS.md index 00b7f5f..00f9d25 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -10,27 +10,27 @@ | | | |---|---| -| **Latest version** | v0.13 (post-ledger + ship) | -| **Latest commit** | `e37c143 feat(inventory+orders): movement ledger + sales-order shipping (end-to-end demo)` | +| **Latest version** | v0.14 (post-P5.6) | +| **Latest commit** | `feat(pbc): P5.6 — pbc-orders-purchase + receive-driven inventory increase` | | **Repo** | https://github.com/reporkey/vibe-erp | -| **Modules** | 15 | -| **Unit tests** | 175, all green | -| **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. | -| **Real PBCs implemented** | 5 of 10 (`pbc-identity`, `pbc-catalog`, `pbc-partners`, `pbc-inventory`, `pbc-orders-sales`) | +| **Modules** | 16 | +| **Unit tests** | 186, all green | +| **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. | +| **Real PBCs implemented** | 6 of 10 (`pbc-identity`, `pbc-catalog`, `pbc-partners`, `pbc-inventory`, `pbc-orders-sales`, `pbc-orders-purchase`) | | **Plug-ins serving HTTP** | 1 (reference printing-shop, 7 endpoints + own DB schema + own metadata + own i18n bundles) | | **Production-ready?** | **No.** Foundation is solid; many capabilities still deferred. | ## Current stage -**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. +**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:`, a sales order ships stock via `SALES_SHIPMENT` ledger rows tagged `SO:`. 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. -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. +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. ## Total scope (the v1.0 cut line) 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. -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. +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. ### Phase 1 — Platform completion (foundation) @@ -83,7 +83,7 @@ That target breaks down into roughly 30 work units across 8 phases. About **21 a | P5.3 | `pbc-inventory` — locations + stock balances (movements deferred) | ✅ DONE — `f63a73d` | | P5.4 | `pbc-warehousing` — receipts, picks, transfers, counts | 🔜 Pending | | P5.5 | `pbc-orders-sales` — sales orders + lines (deliveries deferred) | ✅ DONE — `a8eb2a6` | -| P5.6 | `pbc-orders-purchase` — RFQs, POs, receipts | 🔜 Pending | +| P5.6 | `pbc-orders-purchase` — POs + receive flow (RFQs deferred) | ✅ DONE — `` | | P5.7 | `pbc-production` — work orders, routings, operations | 🔜 Pending | | P5.8 | `pbc-quality` — inspection plans, results, holds | 🔜 Pending | | 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 | **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. | | **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_.properties` resolves before the host's `messages_.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. | | **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. | -| **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//` → cross-PBC facade in `api.v1.ext.` → 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. | +| **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. | ## What the reference plug-in proves end-to-end @@ -170,7 +170,7 @@ This is **real**: a JAR file dropped into a directory, loaded by the framework, - **File store.** No abstraction; no S3 backend. - **Job scheduler.** No Quartz. Periodic jobs don't have a home. - **OIDC.** Built-in JWT only. OIDC client (Keycloak-compatible) is P4.2. -- **More PBCs.** Identity, catalog, partners, inventory and orders-sales exist. Warehousing, orders-purchase, production, quality, finance are all pending. +- **More PBCs.** Identity, catalog, partners, inventory, orders-sales and orders-purchase exist. Warehousing, production, quality, finance are all pending. - **Web SPA.** No React app. The framework is API-only today. - **MCP server.** The architecture leaves room for it; the implementation is v1.1. - **Mobile.** v2. @@ -217,7 +217,11 @@ pbc/pbc-partners Partner + Address + Contact entities + cross-PBC P pbc/pbc-inventory Location + StockBalance + cross-PBC InventoryApi facade (first PBC to CONSUME another PBC's facade — CatalogApi) pbc/pbc-orders-sales SalesOrder + SalesOrderLine + cross-PBC SalesOrdersApi facade - (first PBC to CONSUME TWO facades simultaneously — PartnersApi + CatalogApi) + (first PBC to CONSUME TWO facades simultaneously — PartnersApi + CatalogApi + and the first cross-PBC WRITE flow via InventoryApi.recordMovement) +pbc/pbc-orders-purchase PurchaseOrder + PurchaseOrderLine + cross-PBC PurchaseOrdersApi facade + (the buying-side mirror; receives via InventoryApi.recordMovement + with positive PURCHASE_RECEIPT deltas) reference-customer/plugin-printing-shop Reference plug-in: own DB schema (plate, ink_recipe), @@ -226,7 +230,7 @@ reference-customer/plugin-printing-shop distribution Bootable Spring Boot fat-jar assembly ``` -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. +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. ## Where to look next diff --git a/README.md b/README.md index 24f9b65..45674a2 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ vibe-erp/ ## Building ```bash -# Build everything (compiles 15 modules, runs 175 unit tests) +# Build everything (compiles 16 modules, runs 186 unit tests) ./gradlew build # 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 | | | |---|---| -| Modules | 15 | -| Unit tests | 175, all green | -| Real PBCs | 5 of 10 | +| Modules | 16 | +| Unit tests | 186, all green | +| Real PBCs | 6 of 10 | | Cross-cutting services live | 9 | | Plug-ins serving HTTP | 1 (reference printing-shop) | | Production-ready | **No** — workflow engine, forms, web SPA, more PBCs all still pending | diff --git a/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/ext/orders/PurchaseOrdersApi.kt b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/ext/orders/PurchaseOrdersApi.kt new file mode 100644 index 0000000..156012d --- /dev/null +++ b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/ext/orders/PurchaseOrdersApi.kt @@ -0,0 +1,71 @@ +package org.vibeerp.api.v1.ext.orders + +import org.vibeerp.api.v1.core.Id +import java.math.BigDecimal +import java.time.LocalDate + +/** + * Cross-PBC facade for the purchase orders bounded context. + * + * The buying-side mirror of [SalesOrdersApi]. Sets up the same + * downstream consumers (production scheduling, finance/payables, + * receipt-driven inventory ramps) but on the buying side. Like every + * other `api.v1.ext.*` interface, it lives in this module so plug-ins + * and other PBCs can inject it without depending on `pbc-orders-purchase`. + * + * **What this facade exposes:** the order header (code, status, + * supplier reference, total) plus the lines as a flat list. Line + * shape is identical to the sales-order line ref — they're both + * "what was bought, how many, at what price" — and the existing + * [SalesOrderLineRef] type is reused so consumers don't have to + * write the same code twice. + * + * **What this facade does NOT expose** (deliberately, mirroring + * [SalesOrdersApi]): mutation operations, the `ext` JSONB, the + * audit history, list/search. + * + * **Why a separate API instead of folding into a generic OrdersApi:** + * the same reason there are two PBCs — buying and selling are + * genuinely different lifecycles. A generic interface would force + * every downstream consumer to look at a `direction` field and + * branch. Two interfaces, one per direction, keeps the call sites + * honest about what they're doing. + */ +interface PurchaseOrdersApi { + + /** + * Look up a purchase order by its unique code (e.g. "PO-2026-0001"). + * Returns `null` when no such order exists. Cancelled and + * received orders ARE returned (the facade does not hide them) + * because downstream consumers may legitimately need to react + * to either state. + */ + fun findByCode(code: String): PurchaseOrderRef? + + /** + * Look up a purchase order by its primary-key id. Same semantics + * as [findByCode]. + */ + fun findById(id: Id): PurchaseOrderRef? +} + +/** + * Minimal, safe-to-publish view of a purchase order. + * + * Reuses [SalesOrderLineRef] for the line shape — buying and + * selling lines carry the same fields (line_no, item_code, + * quantity, unit_price, currency_code), so they share the api.v1 + * type. The cost of duplication would only show up if the two ever + * needed different fields, at which point we split. + */ +data class PurchaseOrderRef( + val id: Id, + val code: String, + val partnerCode: String, + val status: String, // DRAFT | CONFIRMED | RECEIVED | CANCELLED — string for plug-in compatibility + val orderDate: LocalDate, + val expectedDate: LocalDate?, + val currencyCode: String, + val totalAmount: BigDecimal, + val lines: List, +) diff --git a/distribution/build.gradle.kts b/distribution/build.gradle.kts index 27e7034..a2b721a 100644 --- a/distribution/build.gradle.kts +++ b/distribution/build.gradle.kts @@ -31,6 +31,7 @@ dependencies { implementation(project(":pbc:pbc-partners")) implementation(project(":pbc:pbc-inventory")) implementation(project(":pbc:pbc-orders-sales")) + implementation(project(":pbc:pbc-orders-purchase")) implementation(libs.spring.boot.starter) implementation(libs.spring.boot.starter.web) diff --git a/distribution/src/main/resources/db/changelog/master.xml b/distribution/src/main/resources/db/changelog/master.xml index 9000dd6..cea7e1b 100644 --- a/distribution/src/main/resources/db/changelog/master.xml +++ b/distribution/src/main/resources/db/changelog/master.xml @@ -19,4 +19,5 @@ + diff --git a/distribution/src/main/resources/db/changelog/pbc-orders-purchase/001-orders-purchase-init.xml b/distribution/src/main/resources/db/changelog/pbc-orders-purchase/001-orders-purchase-init.xml new file mode 100644 index 0000000..9f5f223 --- /dev/null +++ b/distribution/src/main/resources/db/changelog/pbc-orders-purchase/001-orders-purchase-init.xml @@ -0,0 +1,85 @@ + + + + + + + Create orders_purchase__purchase_order table (header) + + CREATE TABLE orders_purchase__purchase_order ( + id uuid PRIMARY KEY, + code varchar(64) NOT NULL, + partner_code varchar(64) NOT NULL, + status varchar(16) NOT NULL, + order_date date NOT NULL, + expected_date date, + currency_code varchar(3) NOT NULL, + total_amount numeric(18,4) NOT NULL DEFAULT 0, + ext jsonb NOT NULL DEFAULT '{}'::jsonb, + created_at timestamptz NOT NULL, + created_by varchar(128) NOT NULL, + updated_at timestamptz NOT NULL, + updated_by varchar(128) NOT NULL, + version bigint NOT NULL DEFAULT 0 + ); + CREATE UNIQUE INDEX orders_purchase__purchase_order_code_uk + ON orders_purchase__purchase_order (code); + CREATE INDEX orders_purchase__purchase_order_partner_idx + ON orders_purchase__purchase_order (partner_code); + CREATE INDEX orders_purchase__purchase_order_status_idx + ON orders_purchase__purchase_order (status); + CREATE INDEX orders_purchase__purchase_order_date_idx + ON orders_purchase__purchase_order (order_date); + CREATE INDEX orders_purchase__purchase_order_ext_gin + ON orders_purchase__purchase_order USING GIN (ext jsonb_path_ops); + + + DROP TABLE orders_purchase__purchase_order; + + + + + Create orders_purchase__purchase_order_line table (FK to header, no FK to catalog__item) + + CREATE TABLE orders_purchase__purchase_order_line ( + id uuid PRIMARY KEY, + purchase_order_id uuid NOT NULL REFERENCES orders_purchase__purchase_order(id) ON DELETE CASCADE, + line_no integer NOT NULL, + item_code varchar(64) NOT NULL, + quantity numeric(18,4) NOT NULL, + unit_price numeric(18,4) NOT NULL, + currency_code varchar(3) NOT NULL, + created_at timestamptz NOT NULL, + created_by varchar(128) NOT NULL, + updated_at timestamptz NOT NULL, + updated_by varchar(128) NOT NULL, + version bigint NOT NULL DEFAULT 0, + CONSTRAINT orders_purchase__purchase_order_line_qty_pos CHECK (quantity > 0), + CONSTRAINT orders_purchase__purchase_order_line_price_nonneg CHECK (unit_price >= 0) + ); + CREATE UNIQUE INDEX orders_purchase__purchase_order_line_order_lineno_uk + ON orders_purchase__purchase_order_line (purchase_order_id, line_no); + CREATE INDEX orders_purchase__purchase_order_line_item_idx + ON orders_purchase__purchase_order_line (item_code); + + + DROP TABLE orders_purchase__purchase_order_line; + + + + diff --git a/pbc/pbc-orders-purchase/build.gradle.kts b/pbc/pbc-orders-purchase/build.gradle.kts new file mode 100644 index 0000000..d2037be --- /dev/null +++ b/pbc/pbc-orders-purchase/build.gradle.kts @@ -0,0 +1,55 @@ +plugins { + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.kotlin.spring) + alias(libs.plugins.kotlin.jpa) + alias(libs.plugins.spring.dependency.management) +} + +description = "vibe_erp pbc-orders-purchase — purchase order header + lines + receipt-driven inventory increase. INTERNAL Packaged Business Capability." + +java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(21)) + } +} + +kotlin { + jvmToolchain(21) + compilerOptions { + freeCompilerArgs.add("-Xjsr305=strict") + } +} + +allOpen { + annotation("jakarta.persistence.Entity") + annotation("jakarta.persistence.MappedSuperclass") + annotation("jakarta.persistence.Embeddable") +} + +// Mirror of pbc-orders-sales — same dependency set, same enforcement +// rules. The Gradle build refuses any direct dependency on another +// pbc-* (the supplier/item/inventory references go through api.v1). +dependencies { + api(project(":api:api-v1")) + implementation(project(":platform:platform-persistence")) + implementation(project(":platform:platform-security")) + implementation(project(":platform:platform-metadata")) // ExtJsonValidator (P3.4) + + implementation(libs.kotlin.stdlib) + implementation(libs.kotlin.reflect) + + implementation(libs.spring.boot.starter) + implementation(libs.spring.boot.starter.web) + implementation(libs.spring.boot.starter.data.jpa) + implementation(libs.spring.boot.starter.validation) + implementation(libs.jackson.module.kotlin) + + testImplementation(libs.spring.boot.starter.test) + testImplementation(libs.junit.jupiter) + testImplementation(libs.assertk) + testImplementation(libs.mockk) +} + +tasks.test { + useJUnitPlatform() +} diff --git a/pbc/pbc-orders-purchase/src/main/kotlin/org/vibeerp/pbc/orders/purchase/application/PurchaseOrderService.kt b/pbc/pbc-orders-purchase/src/main/kotlin/org/vibeerp/pbc/orders/purchase/application/PurchaseOrderService.kt new file mode 100644 index 0000000..2aa7097 --- /dev/null +++ b/pbc/pbc-orders-purchase/src/main/kotlin/org/vibeerp/pbc/orders/purchase/application/PurchaseOrderService.kt @@ -0,0 +1,316 @@ +package org.vibeerp.pbc.orders.purchase.application + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.registerKotlinModule +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import org.vibeerp.api.v1.ext.catalog.CatalogApi +import org.vibeerp.api.v1.ext.inventory.InventoryApi +import org.vibeerp.api.v1.ext.partners.PartnersApi +import org.vibeerp.pbc.orders.purchase.domain.PurchaseOrder +import org.vibeerp.pbc.orders.purchase.domain.PurchaseOrderLine +import org.vibeerp.pbc.orders.purchase.domain.PurchaseOrderStatus +import org.vibeerp.pbc.orders.purchase.infrastructure.PurchaseOrderJpaRepository +import org.vibeerp.platform.metadata.customfield.ExtJsonValidator +import java.math.BigDecimal +import java.time.LocalDate +import java.util.UUID + +/** + * Application service for purchase order CRUD and state transitions. + * + * **The buying-side mirror of [org.vibeerp.pbc.orders.sales.application.SalesOrderService].** + * Same shape — partner + line validation via three cross-PBC facades, + * recomputed total, state machine — but with the partner role + * inverted (must be SUPPLIER or BOTH, not CUSTOMER) and a `receive` + * operation that increments stock instead of debiting it. + * + * **State machine:** + * - DRAFT → CONFIRMED (confirm) + * - DRAFT → CANCELLED (cancel from draft) + * - CONFIRMED → CANCELLED (cancel a confirmed PO before goods arrive) + * - CONFIRMED → RECEIVED (receive — increments inventory) + * - RECEIVED → × (terminal; cancellation requires a return flow) + * + * **Why receiving is its own state and not just "shipped from + * supplier":** the same physical event (the goods arrive) is the + * trigger for OUR side to record the stock increment AND for the + * supplier's invoice to become payable. Other PBCs (finance, + * possibly production) react to RECEIVED specifically; mixing it + * with CONFIRMED would force every consumer to look at the receipt + * notes instead of the PO status. + */ +@Service +@Transactional +class PurchaseOrderService( + private val orders: PurchaseOrderJpaRepository, + private val partnersApi: PartnersApi, + private val catalogApi: CatalogApi, + private val inventoryApi: InventoryApi, + private val extValidator: ExtJsonValidator, +) { + + private val jsonMapper: ObjectMapper = ObjectMapper().registerKotlinModule() + + @Transactional(readOnly = true) + fun list(): List = orders.findAll() + + @Transactional(readOnly = true) + fun findById(id: UUID): PurchaseOrder? = orders.findById(id).orElse(null) + + @Transactional(readOnly = true) + fun findByCode(code: String): PurchaseOrder? = orders.findByCode(code) + + fun create(command: CreatePurchaseOrderCommand): PurchaseOrder { + require(!orders.existsByCode(command.code)) { + "purchase order code '${command.code}' is already taken" + } + + // Cross-PBC validation #1: the supplier must exist, be + // active, and play a SUPPLIER role. + val partner = partnersApi.findPartnerByCode(command.partnerCode) + ?: throw IllegalArgumentException( + "partner code '${command.partnerCode}' is not in the partners directory (or is inactive)", + ) + require(partner.type == "SUPPLIER" || partner.type == "BOTH") { + "partner '${command.partnerCode}' is type ${partner.type} and cannot be the supplier of a purchase order" + } + + require(command.lines.isNotEmpty()) { + "purchase order must have at least one line" + } + val duplicateLineNos = command.lines.groupBy { it.lineNo }.filter { it.value.size > 1 }.keys + require(duplicateLineNos.isEmpty()) { + "duplicate line numbers in purchase order: $duplicateLineNos" + } + + // Cross-PBC validation #2: every line's item must exist in + // the catalog and be active. + for (line in command.lines) { + require(line.quantity.signum() > 0) { + "line ${line.lineNo}: quantity must be positive (got ${line.quantity})" + } + require(line.unitPrice.signum() >= 0) { + "line ${line.lineNo}: unit price must be non-negative (got ${line.unitPrice})" + } + require(line.currencyCode == command.currencyCode) { + "line ${line.lineNo}: currency '${line.currencyCode}' does not match header currency '${command.currencyCode}'" + } + catalogApi.findItemByCode(line.itemCode) + ?: throw IllegalArgumentException( + "line ${line.lineNo}: item code '${line.itemCode}' is not in the catalog (or is inactive)", + ) + } + + // Recompute total — caller's value ignored. + val total = command.lines.fold(BigDecimal.ZERO) { acc, line -> + acc + (line.quantity * line.unitPrice) + } + + val canonicalExt = extValidator.validate(ENTITY_NAME, command.ext) + + val order = PurchaseOrder( + code = command.code, + partnerCode = command.partnerCode, + status = PurchaseOrderStatus.DRAFT, + orderDate = command.orderDate, + expectedDate = command.expectedDate, + currencyCode = command.currencyCode, + totalAmount = total, + ).also { + it.ext = jsonMapper.writeValueAsString(canonicalExt) + } + for (line in command.lines) { + order.lines += PurchaseOrderLine( + purchaseOrder = order, + lineNo = line.lineNo, + itemCode = line.itemCode, + quantity = line.quantity, + unitPrice = line.unitPrice, + currencyCode = line.currencyCode, + ) + } + return orders.save(order) + } + + fun update(id: UUID, command: UpdatePurchaseOrderCommand): PurchaseOrder { + val order = orders.findById(id).orElseThrow { + NoSuchElementException("purchase order not found: $id") + } + require(order.status == PurchaseOrderStatus.DRAFT) { + "cannot update purchase order ${order.code} in status ${order.status}; only DRAFT orders are mutable" + } + + command.partnerCode?.let { newPartnerCode -> + val partner = partnersApi.findPartnerByCode(newPartnerCode) + ?: throw IllegalArgumentException( + "partner code '$newPartnerCode' is not in the partners directory (or is inactive)", + ) + require(partner.type == "SUPPLIER" || partner.type == "BOTH") { + "partner '$newPartnerCode' is type ${partner.type} and cannot be the supplier of a purchase order" + } + order.partnerCode = newPartnerCode + } + command.orderDate?.let { order.orderDate = it } + command.expectedDate?.let { order.expectedDate = it } + command.currencyCode?.let { order.currencyCode = it } + + if (command.ext != null) { + order.ext = jsonMapper.writeValueAsString(extValidator.validate(ENTITY_NAME, command.ext)) + } + + if (command.lines != null) { + require(command.lines.isNotEmpty()) { + "purchase order must have at least one line" + } + val duplicateLineNos = command.lines.groupBy { it.lineNo }.filter { it.value.size > 1 }.keys + require(duplicateLineNos.isEmpty()) { + "duplicate line numbers in purchase order: $duplicateLineNos" + } + for (line in command.lines) { + require(line.quantity.signum() > 0) { + "line ${line.lineNo}: quantity must be positive (got ${line.quantity})" + } + require(line.unitPrice.signum() >= 0) { + "line ${line.lineNo}: unit price must be non-negative (got ${line.unitPrice})" + } + require(line.currencyCode == order.currencyCode) { + "line ${line.lineNo}: currency '${line.currencyCode}' does not match header currency '${order.currencyCode}'" + } + catalogApi.findItemByCode(line.itemCode) + ?: throw IllegalArgumentException( + "line ${line.lineNo}: item code '${line.itemCode}' is not in the catalog (or is inactive)", + ) + } + order.lines.clear() + for (line in command.lines) { + order.lines += PurchaseOrderLine( + purchaseOrder = order, + lineNo = line.lineNo, + itemCode = line.itemCode, + quantity = line.quantity, + unitPrice = line.unitPrice, + currencyCode = line.currencyCode, + ) + } + order.totalAmount = order.lines.fold(BigDecimal.ZERO) { acc, l -> acc + l.lineTotal } + } + return order + } + + fun confirm(id: UUID): PurchaseOrder { + val order = orders.findById(id).orElseThrow { + NoSuchElementException("purchase order not found: $id") + } + require(order.status == PurchaseOrderStatus.DRAFT) { + "cannot confirm purchase order ${order.code} in status ${order.status}; only DRAFT can be confirmed" + } + order.status = PurchaseOrderStatus.CONFIRMED + return order + } + + fun cancel(id: UUID): PurchaseOrder { + val order = orders.findById(id).orElseThrow { + NoSuchElementException("purchase order not found: $id") + } + require(order.status != PurchaseOrderStatus.CANCELLED) { + "purchase order ${order.code} is already cancelled" + } + // Receiving is terminal — once stock has come in the door + // the cancellation flow is "issue a return to the supplier", + // not a status flip. + require(order.status != PurchaseOrderStatus.RECEIVED) { + "cannot cancel purchase order ${order.code} in status RECEIVED; " + + "issue a return-to-supplier flow instead" + } + order.status = PurchaseOrderStatus.CANCELLED + return order + } + + /** + * Mark a CONFIRMED purchase order as RECEIVED, crediting stock + * for every line into [receivingLocationCode] in the same + * transaction. + * + * **The mirror of `SalesOrderService.ship`.** Where shipping + * debits stock with `SALES_SHIPMENT`, receipt credits stock + * with `PURCHASE_RECEIPT` and a positive delta. The cross-PBC + * write goes through the same `InventoryApi.recordMovement` + * facade — that's the whole point of the api.v1 contract: one + * primitive, two callers, both behaving consistently. + * + * The whole operation runs in ONE transaction. A failure on + * any line — bad item, bad location, bad reason sign (sign + * mismatch is impossible here because we hard-code the sign, + * but the validation is still active) — 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". + * + * @throws IllegalArgumentException if the order is not CONFIRMED, + * if the location code is unknown, or if any line cannot be + * recorded (catalog validation failure). + */ + fun receive(id: UUID, receivingLocationCode: String): PurchaseOrder { + val order = orders.findById(id).orElseThrow { + NoSuchElementException("purchase order not found: $id") + } + require(order.status == PurchaseOrderStatus.CONFIRMED) { + "cannot receive purchase order ${order.code} in status ${order.status}; " + + "only CONFIRMED orders can be received" + } + + val reference = "PO:${order.code}" + for (line in order.lines) { + inventoryApi.recordMovement( + itemCode = line.itemCode, + locationCode = receivingLocationCode, + delta = line.quantity, + reason = "PURCHASE_RECEIPT", + reference = reference, + ) + } + + order.status = PurchaseOrderStatus.RECEIVED + return order + } + + @Suppress("UNCHECKED_CAST") + fun parseExt(order: PurchaseOrder): Map = try { + if (order.ext.isBlank()) emptyMap() + else jsonMapper.readValue(order.ext, Map::class.java) as Map + } catch (ex: Throwable) { + emptyMap() + } + + companion object { + const val ENTITY_NAME: String = "PurchaseOrder" + } +} + +data class CreatePurchaseOrderCommand( + val code: String, + val partnerCode: String, + val orderDate: LocalDate, + val expectedDate: LocalDate? = null, + val currencyCode: String, + val lines: List, + val ext: Map? = null, +) + +data class UpdatePurchaseOrderCommand( + val partnerCode: String? = null, + val orderDate: LocalDate? = null, + val expectedDate: LocalDate? = null, + val currencyCode: String? = null, + val lines: List? = null, + val ext: Map? = null, +) + +data class PurchaseOrderLineCommand( + val lineNo: Int, + val itemCode: String, + val quantity: BigDecimal, + val unitPrice: BigDecimal, + val currencyCode: String, +) diff --git a/pbc/pbc-orders-purchase/src/main/kotlin/org/vibeerp/pbc/orders/purchase/domain/PurchaseOrder.kt b/pbc/pbc-orders-purchase/src/main/kotlin/org/vibeerp/pbc/orders/purchase/domain/PurchaseOrder.kt new file mode 100644 index 0000000..79e66ab --- /dev/null +++ b/pbc/pbc-orders-purchase/src/main/kotlin/org/vibeerp/pbc/orders/purchase/domain/PurchaseOrder.kt @@ -0,0 +1,124 @@ +package org.vibeerp.pbc.orders.purchase.domain + +import jakarta.persistence.CascadeType +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.FetchType +import jakarta.persistence.OneToMany +import jakarta.persistence.OrderBy +import jakarta.persistence.Table +import org.hibernate.annotations.JdbcTypeCode +import org.hibernate.type.SqlTypes +import org.vibeerp.platform.persistence.audit.AuditedJpaEntity +import java.math.BigDecimal +import java.time.LocalDate + +/** + * The header of a purchase order. + * + * **The buying-side mirror of [org.vibeerp.pbc.orders.sales.domain.SalesOrder].** + * Where a sales order CONFIRMs that we will sell to a customer and + * SHIPs to debit our stock, a purchase order CONFIRMs that we will + * buy from a supplier and RECEIVEs to credit our stock. The two PBCs + * exist as separate aggregates because the lifecycles, the + * authorisation rules, and the partner roles are genuinely different + * — even though the data shape is similar. + * + * **Why a separate PBC instead of a `direction` enum on the existing + * sales order:** mixing buying and selling into one entity sounds + * tidy until the first time someone needs to ship a sales order from + * inside the same controller that's also receiving a purchase order. + * The Spring `@Transactional` boundaries, the permission keys, the + * downstream PBC integrations, and the metadata custom fields all + * end up needing per-direction conditions, which is exactly what a + * polymorphic table is bad at. Two PBCs, two adapters, two state + * machines, one shared `InventoryApi.recordMovement` for the + * stock side. + * + * **State machine:** + * - **DRAFT** — being prepared, lines may still be edited + * - **CONFIRMED** — committed, sent to the supplier + * - **RECEIVED** — terminal. Goods have arrived, ledger has been + * incremented via `InventoryApi.recordMovement` + * with `reason="PURCHASE_RECEIPT"`. The framework + * will NOT let you cancel a received PO (a return + * flow lands later as its own state). + * - **CANCELLED** — terminal. Reachable from DRAFT or CONFIRMED. + * + * Future expansion (PARTIALLY_RECEIVED, INVOICED, CLOSED) lands in + * its own focused chunk. + * + * **`expected_date`** captures the supplier's promised delivery + * date so the SPA can render an "expected vs received" view and + * the future production scheduler can plan around lead times. It is + * nullable because the date isn't always known at PO-creation time + * (some suppliers reply with a date several days later). + * + * Like sales orders, the `total_amount` is RECOMPUTED from the + * lines on every save and the caller's value is ignored — never + * trust a financial aggregate sent over the wire. + */ +@Entity +@Table(name = "orders_purchase__purchase_order") +class PurchaseOrder( + code: String, + partnerCode: String, + status: PurchaseOrderStatus = PurchaseOrderStatus.DRAFT, + orderDate: LocalDate, + expectedDate: LocalDate? = null, + currencyCode: String, + totalAmount: BigDecimal = BigDecimal.ZERO, +) : AuditedJpaEntity() { + + @Column(name = "code", nullable = false, length = 64) + var code: String = code + + @Column(name = "partner_code", nullable = false, length = 64) + var partnerCode: String = partnerCode + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 16) + var status: PurchaseOrderStatus = status + + @Column(name = "order_date", nullable = false) + var orderDate: LocalDate = orderDate + + @Column(name = "expected_date", nullable = true) + var expectedDate: LocalDate? = expectedDate + + @Column(name = "currency_code", nullable = false, length = 3) + var currencyCode: String = currencyCode + + @Column(name = "total_amount", nullable = false, precision = 18, scale = 4) + var totalAmount: BigDecimal = totalAmount + + @Column(name = "ext", nullable = false, columnDefinition = "jsonb") + @JdbcTypeCode(SqlTypes.JSON) + var ext: String = "{}" + + @OneToMany( + mappedBy = "purchaseOrder", + cascade = [CascadeType.ALL], + orphanRemoval = true, + fetch = FetchType.EAGER, + ) + @OrderBy("lineNo ASC") + var lines: MutableList = mutableListOf() + + override fun toString(): String = + "PurchaseOrder(id=$id, code='$code', supplier='$partnerCode', status=$status, total=$totalAmount $currencyCode)" +} + +/** + * Possible states a [PurchaseOrder] can be in. Mirrors + * [org.vibeerp.pbc.orders.sales.domain.SalesOrderStatus] with + * RECEIVED in place of SHIPPED. + */ +enum class PurchaseOrderStatus { + DRAFT, + CONFIRMED, + RECEIVED, + CANCELLED, +} diff --git a/pbc/pbc-orders-purchase/src/main/kotlin/org/vibeerp/pbc/orders/purchase/domain/PurchaseOrderLine.kt b/pbc/pbc-orders-purchase/src/main/kotlin/org/vibeerp/pbc/orders/purchase/domain/PurchaseOrderLine.kt new file mode 100644 index 0000000..b1583b6 --- /dev/null +++ b/pbc/pbc-orders-purchase/src/main/kotlin/org/vibeerp/pbc/orders/purchase/domain/PurchaseOrderLine.kt @@ -0,0 +1,62 @@ +package org.vibeerp.pbc.orders.purchase.domain + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.JoinColumn +import jakarta.persistence.ManyToOne +import jakarta.persistence.Table +import org.vibeerp.platform.persistence.audit.AuditedJpaEntity +import java.math.BigDecimal + +/** + * One line item of a [PurchaseOrder]. Mirrors + * [org.vibeerp.pbc.orders.sales.domain.SalesOrderLine] — the same + * shape, the same rationale, just on the buying side. + * + * `item_code` is a varchar reference to the catalog (no FK across + * PBCs); `unit_price` and `currency_code` capture the price actually + * paid at the time the order was placed (a snapshot, not a price-book + * pointer); no `ext` jsonb on the line because lines are facts not + * master records. + * + * The line subtotal `lineTotal` is computed at read time. The + * service uses it to recompute the header `total_amount` on every + * save and ignore the caller's value, so the database total can + * never drift from the line sum. + */ +@Entity +@Table(name = "orders_purchase__purchase_order_line") +class PurchaseOrderLine( + purchaseOrder: PurchaseOrder, + lineNo: Int, + itemCode: String, + quantity: BigDecimal, + unitPrice: BigDecimal, + currencyCode: String, +) : AuditedJpaEntity() { + + @ManyToOne + @JoinColumn(name = "purchase_order_id", nullable = false) + var purchaseOrder: PurchaseOrder = purchaseOrder + + @Column(name = "line_no", nullable = false) + var lineNo: Int = lineNo + + @Column(name = "item_code", nullable = false, length = 64) + var itemCode: String = itemCode + + @Column(name = "quantity", nullable = false, precision = 18, scale = 4) + var quantity: BigDecimal = quantity + + @Column(name = "unit_price", nullable = false, precision = 18, scale = 4) + var unitPrice: BigDecimal = unitPrice + + @Column(name = "currency_code", nullable = false, length = 3) + var currencyCode: String = currencyCode + + val lineTotal: BigDecimal + get() = quantity.multiply(unitPrice) + + override fun toString(): String = + "PurchaseOrderLine(id=$id, poId=${purchaseOrder.id}, line=$lineNo, item='$itemCode', qty=$quantity × $unitPrice = $lineTotal $currencyCode)" +} diff --git a/pbc/pbc-orders-purchase/src/main/kotlin/org/vibeerp/pbc/orders/purchase/ext/PurchaseOrdersApiAdapter.kt b/pbc/pbc-orders-purchase/src/main/kotlin/org/vibeerp/pbc/orders/purchase/ext/PurchaseOrdersApiAdapter.kt new file mode 100644 index 0000000..e71849c --- /dev/null +++ b/pbc/pbc-orders-purchase/src/main/kotlin/org/vibeerp/pbc/orders/purchase/ext/PurchaseOrdersApiAdapter.kt @@ -0,0 +1,57 @@ +package org.vibeerp.pbc.orders.purchase.ext + +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional +import org.vibeerp.api.v1.core.Id +import org.vibeerp.api.v1.ext.orders.PurchaseOrderRef +import org.vibeerp.api.v1.ext.orders.PurchaseOrdersApi +import org.vibeerp.api.v1.ext.orders.SalesOrderLineRef +import org.vibeerp.pbc.orders.purchase.domain.PurchaseOrder +import org.vibeerp.pbc.orders.purchase.domain.PurchaseOrderLine +import org.vibeerp.pbc.orders.purchase.infrastructure.PurchaseOrderJpaRepository + +/** + * Concrete [PurchaseOrdersApi] implementation. The sixth `*ApiAdapter` + * after Identity / Catalog / Partners / Inventory / SalesOrders. + * + * Mirror of [org.vibeerp.pbc.orders.sales.ext.SalesOrdersApiAdapter]. + * Cancelled AND received orders ARE returned by the facade — the + * downstream consumers (production scheduling, invoicing) may + * legitimately need to react to either state. + * + * The adapter reuses [SalesOrderLineRef] for the line shape because + * the buying and selling lines carry the same fields. See the + * rationale on [PurchaseOrderRef]. + */ +@Component +@Transactional(readOnly = true) +class PurchaseOrdersApiAdapter( + private val orders: PurchaseOrderJpaRepository, +) : PurchaseOrdersApi { + + override fun findByCode(code: String): PurchaseOrderRef? = + orders.findByCode(code)?.toRef() + + override fun findById(id: Id): PurchaseOrderRef? = + orders.findById(id.value).orElse(null)?.toRef() + + private fun PurchaseOrder.toRef(): PurchaseOrderRef = PurchaseOrderRef( + id = Id(this.id), + code = this.code, + partnerCode = this.partnerCode, + status = this.status.name, + orderDate = this.orderDate, + expectedDate = this.expectedDate, + currencyCode = this.currencyCode, + totalAmount = this.totalAmount, + lines = this.lines.map { it.toRef() }, + ) + + private fun PurchaseOrderLine.toRef(): SalesOrderLineRef = SalesOrderLineRef( + lineNo = this.lineNo, + itemCode = this.itemCode, + quantity = this.quantity, + unitPrice = this.unitPrice, + currencyCode = this.currencyCode, + ) +} diff --git a/pbc/pbc-orders-purchase/src/main/kotlin/org/vibeerp/pbc/orders/purchase/http/PurchaseOrderController.kt b/pbc/pbc-orders-purchase/src/main/kotlin/org/vibeerp/pbc/orders/purchase/http/PurchaseOrderController.kt new file mode 100644 index 0000000..b5fe9d9 --- /dev/null +++ b/pbc/pbc-orders-purchase/src/main/kotlin/org/vibeerp/pbc/orders/purchase/http/PurchaseOrderController.kt @@ -0,0 +1,215 @@ +package org.vibeerp.pbc.orders.purchase.http + +import com.fasterxml.jackson.annotation.JsonCreator +import com.fasterxml.jackson.annotation.JsonProperty +import jakarta.validation.Valid +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.NotEmpty +import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Size +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController +import org.vibeerp.pbc.orders.purchase.application.CreatePurchaseOrderCommand +import org.vibeerp.pbc.orders.purchase.application.PurchaseOrderLineCommand +import org.vibeerp.pbc.orders.purchase.application.PurchaseOrderService +import org.vibeerp.pbc.orders.purchase.application.UpdatePurchaseOrderCommand +import org.vibeerp.pbc.orders.purchase.domain.PurchaseOrder +import org.vibeerp.pbc.orders.purchase.domain.PurchaseOrderLine +import org.vibeerp.pbc.orders.purchase.domain.PurchaseOrderStatus +import org.vibeerp.platform.security.authz.RequirePermission +import java.math.BigDecimal +import java.time.LocalDate +import java.util.UUID + +/** + * REST API for the purchase orders PBC. + * + * Mounted at `/api/v1/orders/purchase-orders`. Same shape as the + * sales-orders controller — CRUD plus state transitions on dedicated + * `/confirm`, `/cancel`, and `/receive` endpoints. + */ +@RestController +@RequestMapping("/api/v1/orders/purchase-orders") +class PurchaseOrderController( + private val purchaseOrderService: PurchaseOrderService, +) { + + @GetMapping + fun list(): List = + purchaseOrderService.list().map { it.toResponse(purchaseOrderService) } + + @GetMapping("/{id}") + fun get(@PathVariable id: UUID): ResponseEntity { + val order = purchaseOrderService.findById(id) ?: return ResponseEntity.notFound().build() + return ResponseEntity.ok(order.toResponse(purchaseOrderService)) + } + + @GetMapping("/by-code/{code}") + fun getByCode(@PathVariable code: String): ResponseEntity { + val order = purchaseOrderService.findByCode(code) ?: return ResponseEntity.notFound().build() + return ResponseEntity.ok(order.toResponse(purchaseOrderService)) + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + fun create(@RequestBody @Valid request: CreatePurchaseOrderRequest): PurchaseOrderResponse = + purchaseOrderService.create( + CreatePurchaseOrderCommand( + code = request.code, + partnerCode = request.partnerCode, + orderDate = request.orderDate, + expectedDate = request.expectedDate, + currencyCode = request.currencyCode, + lines = request.lines.map { it.toCommand() }, + ext = request.ext, + ), + ).toResponse(purchaseOrderService) + + @PatchMapping("/{id}") + fun update( + @PathVariable id: UUID, + @RequestBody @Valid request: UpdatePurchaseOrderRequest, + ): PurchaseOrderResponse = + purchaseOrderService.update( + id, + UpdatePurchaseOrderCommand( + partnerCode = request.partnerCode, + orderDate = request.orderDate, + expectedDate = request.expectedDate, + currencyCode = request.currencyCode, + lines = request.lines?.map { it.toCommand() }, + ext = request.ext, + ), + ).toResponse(purchaseOrderService) + + @PostMapping("/{id}/confirm") + @RequirePermission("orders.purchase.confirm") + fun confirm(@PathVariable id: UUID): PurchaseOrderResponse = + purchaseOrderService.confirm(id).toResponse(purchaseOrderService) + + @PostMapping("/{id}/cancel") + @RequirePermission("orders.purchase.cancel") + fun cancel(@PathVariable id: UUID): PurchaseOrderResponse = + purchaseOrderService.cancel(id).toResponse(purchaseOrderService) + + /** + * Receive a CONFIRMED purchase order. Atomically credits stock + * for every line into the location named in the request, then + * flips the order to RECEIVED. The whole operation runs in one + * transaction — see [PurchaseOrderService.receive] for the full + * rationale. + */ + @PostMapping("/{id}/receive") + @RequirePermission("orders.purchase.receive") + fun receive( + @PathVariable id: UUID, + @RequestBody @Valid request: ReceivePurchaseOrderRequest, + ): PurchaseOrderResponse = + purchaseOrderService.receive(id, request.receivingLocationCode).toResponse(purchaseOrderService) +} + +// ─── DTOs ──────────────────────────────────────────────────────────── + +data class CreatePurchaseOrderRequest( + @field:NotBlank @field:Size(max = 64) val code: String, + @field:NotBlank @field:Size(max = 64) val partnerCode: String, + @field:NotNull val orderDate: LocalDate, + val expectedDate: LocalDate? = null, + @field:NotBlank @field:Size(min = 3, max = 3) val currencyCode: String, + @field:NotEmpty @field:Valid val lines: List, + val ext: Map? = null, +) + +data class UpdatePurchaseOrderRequest( + @field:Size(max = 64) val partnerCode: String? = null, + val orderDate: LocalDate? = null, + val expectedDate: LocalDate? = null, + @field:Size(min = 3, max = 3) val currencyCode: String? = null, + @field:Valid val lines: List? = null, + val ext: Map? = null, +) + +data class PurchaseOrderLineRequest( + @field:NotNull val lineNo: Int, + @field:NotBlank @field:Size(max = 64) val itemCode: String, + @field:NotNull val quantity: BigDecimal, + @field:NotNull val unitPrice: BigDecimal, + @field:NotBlank @field:Size(min = 3, max = 3) val currencyCode: String, +) { + fun toCommand(): PurchaseOrderLineCommand = PurchaseOrderLineCommand( + lineNo = lineNo, + itemCode = itemCode, + quantity = quantity, + unitPrice = unitPrice, + currencyCode = currencyCode, + ) +} + +/** + * Receiving request body. + * + * **Single-arg Kotlin data class — same Jackson trap as + * [org.vibeerp.pbc.orders.sales.http.ShipSalesOrderRequest].** + * jackson-module-kotlin treats a one-arg data class as a delegate- + * based creator and unwraps the body, which is wrong for HTTP + * payloads that always look like `{"receivingLocationCode": "..."}`. + * Fix: explicit `@JsonCreator(mode = PROPERTIES)` + `@param:JsonProperty`. + */ +data class ReceivePurchaseOrderRequest @JsonCreator(mode = JsonCreator.Mode.PROPERTIES) constructor( + @param:JsonProperty("receivingLocationCode") + @field:NotBlank @field:Size(max = 64) val receivingLocationCode: String, +) + +data class PurchaseOrderResponse( + val id: UUID, + val code: String, + val partnerCode: String, + val status: PurchaseOrderStatus, + val orderDate: LocalDate, + val expectedDate: LocalDate?, + val currencyCode: String, + val totalAmount: BigDecimal, + val lines: List, + val ext: Map, +) + +data class PurchaseOrderLineResponse( + val id: UUID, + val lineNo: Int, + val itemCode: String, + val quantity: BigDecimal, + val unitPrice: BigDecimal, + val currencyCode: String, + val lineTotal: BigDecimal, +) + +private fun PurchaseOrder.toResponse(service: PurchaseOrderService) = PurchaseOrderResponse( + id = this.id, + code = this.code, + partnerCode = this.partnerCode, + status = this.status, + orderDate = this.orderDate, + expectedDate = this.expectedDate, + currencyCode = this.currencyCode, + totalAmount = this.totalAmount, + lines = this.lines.map { it.toResponse() }, + ext = service.parseExt(this), +) + +private fun PurchaseOrderLine.toResponse() = PurchaseOrderLineResponse( + id = this.id, + lineNo = this.lineNo, + itemCode = this.itemCode, + quantity = this.quantity, + unitPrice = this.unitPrice, + currencyCode = this.currencyCode, + lineTotal = this.lineTotal, +) diff --git a/pbc/pbc-orders-purchase/src/main/kotlin/org/vibeerp/pbc/orders/purchase/infrastructure/PurchaseOrderJpaRepository.kt b/pbc/pbc-orders-purchase/src/main/kotlin/org/vibeerp/pbc/orders/purchase/infrastructure/PurchaseOrderJpaRepository.kt new file mode 100644 index 0000000..9440861 --- /dev/null +++ b/pbc/pbc-orders-purchase/src/main/kotlin/org/vibeerp/pbc/orders/purchase/infrastructure/PurchaseOrderJpaRepository.kt @@ -0,0 +1,19 @@ +package org.vibeerp.pbc.orders.purchase.infrastructure + +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository +import org.vibeerp.pbc.orders.purchase.domain.PurchaseOrder +import java.util.UUID + +/** + * Spring Data JPA repository for [PurchaseOrder]. Same shape as the + * sales-order repo: lookups by id and by code; no on-the-fly + * aggregation against the live OLTP table. + */ +@Repository +interface PurchaseOrderJpaRepository : JpaRepository { + + fun findByCode(code: String): PurchaseOrder? + + fun existsByCode(code: String): Boolean +} diff --git a/pbc/pbc-orders-purchase/src/main/resources/META-INF/vibe-erp/metadata/orders-purchase.yml b/pbc/pbc-orders-purchase/src/main/resources/META-INF/vibe-erp/metadata/orders-purchase.yml new file mode 100644 index 0000000..ba1955c --- /dev/null +++ b/pbc/pbc-orders-purchase/src/main/resources/META-INF/vibe-erp/metadata/orders-purchase.yml @@ -0,0 +1,35 @@ +# pbc-orders-purchase metadata. +# +# Loaded at boot by MetadataLoader, tagged source='core'. + +entities: + - name: PurchaseOrder + pbc: orders-purchase + table: orders_purchase__purchase_order + description: A purchase order header — supplier, currency, total, status + + - name: PurchaseOrderLine + pbc: orders-purchase + table: orders_purchase__purchase_order_line + description: One line item of a purchase order (item × quantity × unit price) + +permissions: + - key: orders.purchase.read + description: Read purchase orders + - key: orders.purchase.create + description: Create draft purchase orders + - key: orders.purchase.update + description: Update DRAFT purchase orders (lines, supplier, dates) + - key: orders.purchase.confirm + description: Confirm a draft purchase order (DRAFT → CONFIRMED) + - key: orders.purchase.cancel + description: Cancel a purchase order (DRAFT or CONFIRMED → CANCELLED) + - key: orders.purchase.receive + description: Mark a confirmed purchase order received (CONFIRMED → RECEIVED, credits inventory atomically) + +menus: + - path: /orders/purchase + label: Purchase orders + icon: shopping-cart + section: Purchasing + order: 600 diff --git a/pbc/pbc-orders-purchase/src/test/kotlin/org/vibeerp/pbc/orders/purchase/application/PurchaseOrderServiceTest.kt b/pbc/pbc-orders-purchase/src/test/kotlin/org/vibeerp/pbc/orders/purchase/application/PurchaseOrderServiceTest.kt new file mode 100644 index 0000000..d126d52 --- /dev/null +++ b/pbc/pbc-orders-purchase/src/test/kotlin/org/vibeerp/pbc/orders/purchase/application/PurchaseOrderServiceTest.kt @@ -0,0 +1,313 @@ +package org.vibeerp.pbc.orders.purchase.application + +import assertk.assertFailure +import assertk.assertThat +import assertk.assertions.hasMessage +import assertk.assertions.hasSize +import assertk.assertions.isEqualTo +import assertk.assertions.isInstanceOf +import assertk.assertions.messageContains +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.vibeerp.api.v1.core.Id +import org.vibeerp.api.v1.ext.catalog.CatalogApi +import org.vibeerp.api.v1.ext.catalog.ItemRef +import org.vibeerp.api.v1.ext.inventory.InventoryApi +import org.vibeerp.api.v1.ext.inventory.StockBalanceRef +import org.vibeerp.api.v1.ext.partners.PartnerRef +import org.vibeerp.api.v1.ext.partners.PartnersApi +import org.vibeerp.pbc.orders.purchase.domain.PurchaseOrder +import org.vibeerp.pbc.orders.purchase.domain.PurchaseOrderLine +import org.vibeerp.pbc.orders.purchase.domain.PurchaseOrderStatus +import org.vibeerp.pbc.orders.purchase.infrastructure.PurchaseOrderJpaRepository +import org.vibeerp.platform.metadata.customfield.ExtJsonValidator +import java.math.BigDecimal +import java.time.LocalDate +import java.util.Optional +import java.util.UUID + +class PurchaseOrderServiceTest { + + private lateinit var orders: PurchaseOrderJpaRepository + private lateinit var partnersApi: PartnersApi + private lateinit var catalogApi: CatalogApi + private lateinit var inventoryApi: InventoryApi + private lateinit var extValidator: ExtJsonValidator + private lateinit var service: PurchaseOrderService + + @BeforeEach + fun setUp() { + orders = mockk() + partnersApi = mockk() + catalogApi = mockk() + inventoryApi = mockk() + extValidator = mockk() + every { extValidator.validate(any(), any()) } answers { secondArg() ?: emptyMap() } + every { orders.existsByCode(any()) } returns false + every { orders.save(any()) } answers { firstArg() } + service = PurchaseOrderService(orders, partnersApi, catalogApi, inventoryApi, extValidator) + } + + private fun stubSupplier(code: String, type: String = "SUPPLIER") { + every { partnersApi.findPartnerByCode(code) } returns PartnerRef( + id = Id(UUID.randomUUID()), + code = code, + name = "Stub partner", + type = type, + taxId = null, + active = true, + ) + } + + private fun stubItem(code: String) { + every { catalogApi.findItemByCode(code) } returns ItemRef( + id = Id(UUID.randomUUID()), + code = code, + name = "Stub item", + itemType = "GOOD", + baseUomCode = "ea", + active = true, + ) + } + + private fun line(no: Int = 1, item: String = "PAPER-A4", qty: String = "100", price: String = "0.04") = + PurchaseOrderLineCommand( + lineNo = no, + itemCode = item, + quantity = BigDecimal(qty), + unitPrice = BigDecimal(price), + currencyCode = "USD", + ) + + private fun command( + code: String = "PO-1", + partnerCode: String = "SUP-1", + currency: String = "USD", + lines: List = listOf(line()), + ) = CreatePurchaseOrderCommand( + code = code, + partnerCode = partnerCode, + orderDate = LocalDate.of(2026, 4, 8), + currencyCode = currency, + lines = lines, + ) + + @Test + fun `create rejects unknown supplier via PartnersApi seam`() { + every { partnersApi.findPartnerByCode("FAKE") } returns null + + assertFailure { service.create(command(partnerCode = "FAKE")) } + .isInstanceOf(IllegalArgumentException::class) + .hasMessage("partner code 'FAKE' is not in the partners directory (or is inactive)") + } + + @Test + fun `create rejects CUSTOMER-only partner as supplier`() { + stubSupplier("CUST-1", type = "CUSTOMER") + + assertFailure { service.create(command(partnerCode = "CUST-1")) } + .isInstanceOf(IllegalArgumentException::class) + .messageContains("cannot be the supplier of a purchase order") + } + + @Test + fun `create accepts BOTH partner type as supplier`() { + stubSupplier("BOTH-1", type = "BOTH") + stubItem("PAPER-A4") + + val saved = service.create(command(partnerCode = "BOTH-1")) + + assertThat(saved.partnerCode).isEqualTo("BOTH-1") + } + + @Test + fun `create rejects unknown item via CatalogApi seam`() { + stubSupplier("SUP-1") + every { catalogApi.findItemByCode("FAKE-ITEM") } returns null + + assertFailure { + service.create(command(lines = listOf(line(item = "FAKE-ITEM")))) + } + .isInstanceOf(IllegalArgumentException::class) + .messageContains("item code 'FAKE-ITEM' is not in the catalog") + } + + @Test + fun `create rejects empty lines`() { + stubSupplier("SUP-1") + + assertFailure { service.create(command(lines = emptyList())) } + .isInstanceOf(IllegalArgumentException::class) + .hasMessage("purchase order must have at least one line") + } + + @Test + fun `create recomputes total from lines, ignoring caller`() { + stubSupplier("SUP-1") + stubItem("PAPER-A4") + stubItem("INK-CYAN") + val saved = slot() + every { orders.save(capture(saved)) } answers { saved.captured } + + val result = service.create( + command( + lines = listOf( + line(no = 1, item = "PAPER-A4", qty = "5000", price = "0.04"), // 200.00 + line(no = 2, item = "INK-CYAN", qty = "10", price = "35.00"), // 350.00 + ), + ), + ) + + // 5000 × 0.04 + 10 × 35.00 = 200.00 + 350.00 = 550.00 + assertThat(result.totalAmount).isEqualTo(BigDecimal("550.00")) + assertThat(result.lines).hasSize(2) + } + + @Test + fun `confirm transitions DRAFT to CONFIRMED`() { + val id = UUID.randomUUID() + val order = PurchaseOrder( + code = "PO-1", + partnerCode = "SUP-1", + status = PurchaseOrderStatus.DRAFT, + orderDate = LocalDate.of(2026, 4, 8), + currencyCode = "USD", + ).also { it.id = id } + every { orders.findById(id) } returns Optional.of(order) + + val confirmed = service.confirm(id) + + assertThat(confirmed.status).isEqualTo(PurchaseOrderStatus.CONFIRMED) + } + + // ─── receive() ─────────────────────────────────────────────────── + + private fun confirmedPo( + id: UUID = UUID.randomUUID(), + lines: List> = listOf("PAPER-A4" to "5000"), + ): PurchaseOrder { + val order = PurchaseOrder( + code = "PO-1", + partnerCode = "SUP-1", + status = PurchaseOrderStatus.CONFIRMED, + orderDate = LocalDate.of(2026, 4, 8), + currencyCode = "USD", + ).also { it.id = id } + var n = 1 + for ((item, qty) in lines) { + order.lines += PurchaseOrderLine( + purchaseOrder = order, + lineNo = n++, + itemCode = item, + quantity = BigDecimal(qty), + unitPrice = BigDecimal("0.04"), + currencyCode = "USD", + ) + } + return order + } + + private fun stubInventoryCredit(itemCode: String, locationCode: String, expectedDelta: BigDecimal) { + every { + inventoryApi.recordMovement( + itemCode = itemCode, + locationCode = locationCode, + delta = expectedDelta, + reason = "PURCHASE_RECEIPT", + reference = any(), + ) + } returns StockBalanceRef( + id = Id(UUID.randomUUID()), + itemCode = itemCode, + locationCode = locationCode, + quantity = BigDecimal("1000"), + ) + } + + @Test + fun `receive rejects a non-CONFIRMED order`() { + val id = UUID.randomUUID() + val draft = PurchaseOrder( + code = "PO-1", + partnerCode = "SUP-1", + status = PurchaseOrderStatus.DRAFT, + orderDate = LocalDate.of(2026, 4, 8), + currencyCode = "USD", + ).also { it.id = id } + every { orders.findById(id) } returns Optional.of(draft) + + assertFailure { service.receive(id, "WH-MAIN") } + .isInstanceOf(IllegalArgumentException::class) + .messageContains("only CONFIRMED orders can be received") + verify(exactly = 0) { inventoryApi.recordMovement(any(), any(), any(), any(), any()) } + } + + @Test + fun `receive walks every line and calls inventoryApi recordMovement with positive delta`() { + val id = UUID.randomUUID() + val po = confirmedPo(id, lines = listOf("PAPER-A4" to "5000", "INK-CYAN" to "10")) + every { orders.findById(id) } returns Optional.of(po) + stubInventoryCredit("PAPER-A4", "WH-MAIN", BigDecimal("5000")) + stubInventoryCredit("INK-CYAN", "WH-MAIN", BigDecimal("10")) + + val received = service.receive(id, "WH-MAIN") + + assertThat(received.status).isEqualTo(PurchaseOrderStatus.RECEIVED) + verify(exactly = 1) { + inventoryApi.recordMovement( + itemCode = "PAPER-A4", + locationCode = "WH-MAIN", + delta = BigDecimal("5000"), + reason = "PURCHASE_RECEIPT", + reference = "PO:PO-1", + ) + } + verify(exactly = 1) { + inventoryApi.recordMovement( + itemCode = "INK-CYAN", + locationCode = "WH-MAIN", + delta = BigDecimal("10"), + reason = "PURCHASE_RECEIPT", + reference = "PO:PO-1", + ) + } + } + + @Test + fun `cancel rejects a RECEIVED order`() { + val id = UUID.randomUUID() + val received = PurchaseOrder( + code = "PO-1", + partnerCode = "SUP-1", + status = PurchaseOrderStatus.RECEIVED, + orderDate = LocalDate.of(2026, 4, 8), + currencyCode = "USD", + ).also { it.id = id } + every { orders.findById(id) } returns Optional.of(received) + + assertFailure { service.cancel(id) } + .isInstanceOf(IllegalArgumentException::class) + .messageContains("RECEIVED") + } + + @Test + fun `cancel a CONFIRMED order is allowed (before receipt)`() { + val id = UUID.randomUUID() + val po = PurchaseOrder( + code = "PO-1", + partnerCode = "SUP-1", + status = PurchaseOrderStatus.CONFIRMED, + orderDate = LocalDate.of(2026, 4, 8), + currencyCode = "USD", + ).also { it.id = id } + every { orders.findById(id) } returns Optional.of(po) + + val cancelled = service.cancel(id) + + assertThat(cancelled.status).isEqualTo(PurchaseOrderStatus.CANCELLED) + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 1c0deb1..984ff23 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -58,6 +58,9 @@ project(":pbc:pbc-inventory").projectDir = file("pbc/pbc-inventory") include(":pbc:pbc-orders-sales") project(":pbc:pbc-orders-sales").projectDir = file("pbc/pbc-orders-sales") +include(":pbc:pbc-orders-purchase") +project(":pbc:pbc-orders-purchase").projectDir = file("pbc/pbc-orders-purchase") + // ─── Reference customer plug-in (NOT loaded by default) ───────────── include(":reference-customer:plugin-printing-shop") project(":reference-customer:plugin-printing-shop").projectDir = file("reference-customer/plugin-printing-shop") -- libgit2 0.22.2