From a8eb2a6661a5847896a485a65fd316601ab914ab Mon Sep 17 00:00:00 2001 From: zichun Date: Wed, 8 Apr 2026 15:41:14 +0800 Subject: [PATCH] feat(pbc): P5.5 — pbc-orders-sales + first PBC consuming TWO cross-PBC facades --- CLAUDE.md | 6 +++--- PROGRESS.md | 33 ++++++++++++++++++--------------- README.md | 8 ++++---- api/api-v1/src/main/kotlin/org/vibeerp/api/v1/ext/orders/SalesOrdersApi.kt | 83 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ distribution/build.gradle.kts | 1 + distribution/src/main/resources/db/changelog/master.xml | 1 + distribution/src/main/resources/db/changelog/pbc-orders-sales/001-orders-sales-init.xml | 96 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ pbc/pbc-orders-sales/build.gradle.kts | 68 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ pbc/pbc-orders-sales/src/main/kotlin/org/vibeerp/pbc/orders/sales/application/SalesOrderService.kt | 283 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ pbc/pbc-orders-sales/src/main/kotlin/org/vibeerp/pbc/orders/sales/domain/SalesOrder.kt | 140 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ pbc/pbc-orders-sales/src/main/kotlin/org/vibeerp/pbc/orders/sales/domain/SalesOrderLine.kt | 89 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ pbc/pbc-orders-sales/src/main/kotlin/org/vibeerp/pbc/orders/sales/ext/SalesOrdersApiAdapter.kt | 59 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ pbc/pbc-orders-sales/src/main/kotlin/org/vibeerp/pbc/orders/sales/http/SalesOrderController.kt | 185 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ pbc/pbc-orders-sales/src/main/kotlin/org/vibeerp/pbc/orders/sales/infrastructure/SalesOrderJpaRepository.kt | 24 ++++++++++++++++++++++++ pbc/pbc-orders-sales/src/main/resources/META-INF/vibe-erp/metadata/orders-sales.yml | 33 +++++++++++++++++++++++++++++++++ pbc/pbc-orders-sales/src/test/kotlin/org/vibeerp/pbc/orders/sales/application/SalesOrderServiceTest.kt | 301 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ settings.gradle.kts | 3 +++ 17 files changed, 1391 insertions(+), 22 deletions(-) create mode 100644 api/api-v1/src/main/kotlin/org/vibeerp/api/v1/ext/orders/SalesOrdersApi.kt create mode 100644 distribution/src/main/resources/db/changelog/pbc-orders-sales/001-orders-sales-init.xml create mode 100644 pbc/pbc-orders-sales/build.gradle.kts create mode 100644 pbc/pbc-orders-sales/src/main/kotlin/org/vibeerp/pbc/orders/sales/application/SalesOrderService.kt create mode 100644 pbc/pbc-orders-sales/src/main/kotlin/org/vibeerp/pbc/orders/sales/domain/SalesOrder.kt create mode 100644 pbc/pbc-orders-sales/src/main/kotlin/org/vibeerp/pbc/orders/sales/domain/SalesOrderLine.kt create mode 100644 pbc/pbc-orders-sales/src/main/kotlin/org/vibeerp/pbc/orders/sales/ext/SalesOrdersApiAdapter.kt create mode 100644 pbc/pbc-orders-sales/src/main/kotlin/org/vibeerp/pbc/orders/sales/http/SalesOrderController.kt create mode 100644 pbc/pbc-orders-sales/src/main/kotlin/org/vibeerp/pbc/orders/sales/infrastructure/SalesOrderJpaRepository.kt create mode 100644 pbc/pbc-orders-sales/src/main/resources/META-INF/vibe-erp/metadata/orders-sales.yml create mode 100644 pbc/pbc-orders-sales/src/test/kotlin/org/vibeerp/pbc/orders/sales/application/SalesOrderServiceTest.kt diff --git a/CLAUDE.md b/CLAUDE.md index 114e055..61cdf03 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: -- **14 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`. -- **139 unit tests across 14 modules**, all green. `./gradlew build` is the canonical full build. +- **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`. +- **153 unit tests across 15 modules**, all green. `./gradlew build` is the canonical full build. - **All 8 cross-cutting platform services live and smoke-tested end-to-end** against real Postgres: auth (P4.1), 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. -- **4 of 10 core PBCs implemented end-to-end** (`pbc-identity`, `pbc-catalog`, `pbc-partners`, `pbc-inventory`) — they validate the recipe every future PBC clones across four different aggregate shapes. **pbc-inventory is the first PBC to also CONSUME another PBC's facade** (`CatalogApi`), proving the cross-PBC contract works in both directions. +- **5 of 10 core PBCs implemented end-to-end** (`pbc-identity`, `pbc-catalog`, `pbc-partners`, `pbc-inventory`, `pbc-orders-sales`). **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 one transaction. The Gradle build still refuses any direct dependency between PBCs — Spring DI wires the interfaces to their concrete adapters at runtime. - **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 088b6e7..b9b7805 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -10,27 +10,27 @@ | | | |---|---| -| **Latest version** | v0.10 (post-P5.3) | -| **Latest commit** | `f63a73d feat(pbc): P5.3 — pbc-inventory + first cross-PBC facade caller` | +| **Latest version** | v0.11 (post-P5.5) | +| **Latest commit** | `feat(pbc): P5.5 — pbc-orders-sales + first PBC consuming TWO cross-PBC facades` | | **Repo** | https://github.com/reporkey/vibe-erp | -| **Modules** | 14 | -| **Unit tests** | 139, all green | -| **End-to-end smoke runs** | All cross-cutting services + all 4 PBCs verified against real Postgres; reference plug-in returns localised strings in 3 locales; Partner + Location accept custom-field `ext`; pbc-inventory rejects unknown items via the cross-PBC catalog seam | -| **Real PBCs implemented** | 4 of 10 (`pbc-identity`, `pbc-catalog`, `pbc-partners`, `pbc-inventory`) | +| **Modules** | 15 | +| **Unit tests** | 153, all green | +| **End-to-end smoke runs** | All cross-cutting services + all 5 PBCs verified against real Postgres; reference plug-in returns localised strings in 3 locales; Partner + Location accept custom-field `ext`; sales orders validate customer + items via the cross-PBC seams in one transaction; state machine DRAFT → CONFIRMED → CANCELLED enforced | +| **Real PBCs implemented** | 5 of 10 (`pbc-identity`, `pbc-catalog`, `pbc-partners`, `pbc-inventory`, `pbc-orders-sales`) | | **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; cross-PBC seam proven in both directions.** All eight cross-cutting platform services that PBCs and plug-ins depend on are live (auth, plug-in lifecycle, plug-in HTTP, plug-in linter, plug-in DB schemas, event bus + outbox, metadata loader + custom-field validator, ICU4J translator). Four real PBCs (identity, catalog, partners, inventory) validate the modular-monolith template across four different aggregate shapes. **pbc-inventory is the first cross-PBC facade *caller*** — it injects `CatalogApi` to validate item codes before adjusting stock, proving the api.v1.ext.* contract works in both directions (every earlier PBC was a provider; this is the first consumer). The Gradle build still refuses any direct dependency between PBCs. +**Foundation complete; Tier 1 customization live; first business workflow PBC online.** All eight cross-cutting platform services that PBCs and plug-ins depend on are live (auth, plug-in lifecycle, plug-in HTTP, plug-in linter, plug-in DB schemas, event bus + outbox, metadata loader + custom-field validator, ICU4J translator). Five real PBCs (identity, catalog, partners, inventory, orders-sales) validate the modular-monolith template across five different aggregate shapes. **pbc-orders-sales is the first PBC to consume TWO cross-PBC facades simultaneously**: it injects `PartnersApi` to resolve the customer AND `CatalogApi` to resolve every line's item, in the same transaction, while still having no compile-time dependency on either source PBC. The Gradle build refuses any direct dependency between PBCs; Spring DI wires the interfaces to their concrete adapters at runtime. State machine DRAFT → CONFIRMED → CANCELLED is enforced; CONFIRMED orders are immutable except for cancellation. -The next phase continues **building business surface area**: more PBCs (sales orders, purchase orders, production), the workflow engine (Flowable), permission enforcement against `metadata__role_permission`, and eventually the React SPA. +The next phase continues **building business surface area**: pbc-orders-purchase, pbc-production, the stock movement ledger that pairs with sales-order shipping, the workflow engine (Flowable), permission enforcement against `metadata__role_permission`, 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 **19 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 **20 are done** as of today. Below is the full list with status. ### Phase 1 — Platform completion (foundation) @@ -82,7 +82,7 @@ That target breaks down into roughly 30 work units across 8 phases. About **19 a | P5.2 | `pbc-partners` — customers, suppliers, contacts, addresses | ✅ DONE — `3e40cae` | | 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` — quotes, sales orders, deliveries | 🔜 Pending | +| P5.5 | `pbc-orders-sales` — sales orders + lines (deliveries deferred) | ✅ DONE — `` | | P5.6 | `pbc-orders-purchase` — RFQs, POs, receipts | 🔜 Pending | | P5.7 | `pbc-production` — work orders, routings, operations | 🔜 Pending | | P5.8 | `pbc-quality` — inspection plans, results, holds | 🔜 Pending | @@ -128,7 +128,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` | Four real PBCs prove the recipe across four aggregate shapes (single-entity user, two-entity catalog, parent-with-children partners+addresses+contacts, master-data + facts inventory locations+balances): 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 also the first PBC to *consume* another PBC's facade — it injects `CatalogApi` from api.v1 to validate item codes before adjusting stock, proving the cross-PBC contract works in both directions. | +| **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. | ## What the reference plug-in proves end-to-end @@ -170,8 +170,9 @@ This is **real**: a JAR file dropped into a directory, loaded by the framework, - **Job scheduler.** No Quartz. Periodic jobs don't have a home. - **OIDC.** Built-in JWT only. OIDC client (Keycloak-compatible) is P4.2. - **Permission checks.** `Principal` is bound; `metadata__permission` is seeded; the actual `@RequirePermission` enforcement layer is P4.3. -- **More PBCs.** Identity, catalog, partners and inventory exist. Warehousing, orders, production, quality, finance are all pending. -- **Stock movement ledger.** Inventory has balances but not the append-only `inventory__stock_movement` history yet — the v1 cut is "set the quantity"; the audit-grade ledger lands in a follow-up. +- **More PBCs.** Identity, catalog, partners, inventory and orders-sales exist. Warehousing, orders-purchase, production, quality, finance are all pending. +- **Sales-order shipping flow.** Sales orders can be created and confirmed but cannot yet *ship* — that requires the inventory movement ledger (deferred from P5.3) so the framework can atomically debit stock when an order ships. +- **Stock movement ledger.** Inventory has balances but not the append-only `inventory__stock_movement` history yet — the v1 cut is "set the quantity"; the audit-grade ledger lands in a follow-up that will also unlock sales-order shipping. - **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. @@ -216,7 +217,9 @@ pbc/pbc-identity User entity end-to-end + auth + bootstrap admin pbc/pbc-catalog Item + Uom entities + cross-PBC CatalogApi facade pbc/pbc-partners Partner + Address + Contact entities + cross-PBC PartnersApi facade pbc/pbc-inventory Location + StockBalance + cross-PBC InventoryApi facade - (FIRST PBC to also CONSUME another PBC's facade — CatalogApi) + (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) reference-customer/plugin-printing-shop Reference plug-in: own DB schema (plate, ink_recipe), @@ -225,7 +228,7 @@ reference-customer/plugin-printing-shop distribution Bootable Spring Boot fat-jar assembly ``` -14 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. +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. ## Where to look next diff --git a/README.md b/README.md index 952935a..8821cac 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ vibe-erp/ ## Building ```bash -# Build everything (compiles 14 modules, runs 139 unit tests) +# Build everything (compiles 15 modules, runs 153 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 | 14 | -| Unit tests | 139, all green | -| Real PBCs | 4 of 10 | +| Modules | 15 | +| Unit tests | 153, all green | +| Real PBCs | 5 of 10 | | Cross-cutting services live | 8 | | 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/SalesOrdersApi.kt b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/ext/orders/SalesOrdersApi.kt new file mode 100644 index 0000000..b278652 --- /dev/null +++ b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/ext/orders/SalesOrdersApi.kt @@ -0,0 +1,83 @@ +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 sales orders bounded context. + * + * The fifth `api.v1.ext.*` package after identity, catalog, partners, + * and inventory. Sets up the next generation of consumers: + * + * - **pbc-production** will inject [SalesOrdersApi] to find the + * sales order a printing-shop work order is fulfilling. + * - **pbc-finance** (future) will inject it to generate invoices + * when an order is shipped. + * - **The reference printing-shop plug-in** will inject it to + * demonstrate the end-to-end "quote → confirmed order → job card" + * workflow that lives in the reference docs. + * + * **What this facade exposes:** the order header (code, status, + * partner reference, total) plus the lines as a flat list. Lines + * carry just enough for downstream PBCs to compute their own work + * (which item, how many, at what price). + * + * **What this facade does NOT expose** (deliberately): + * - Update / cancel / confirm operations. Other PBCs must NOT + * mutate sales orders directly — they react to events + * (`SalesOrderConfirmed`, `SalesOrderCancelled`) emitted via the + * event bus. Centralising the state machine in pbc-orders-sales + * keeps the order's invariants in one place. + * - The full audit/version history. + * - The `ext` JSONB. Custom-field reads cross the metadata system, + * not the cross-PBC facade. + * - List / search. Reporting against orders is its own concern; + * cross-PBC consumers ask "is THIS specific order real?", not + * "give me all orders matching X". + */ +interface SalesOrdersApi { + + /** + * Look up a sales order by its unique code (e.g. "SO-2026-0001"). + * Returns `null` when no such order exists. Cancelled orders ARE + * returned (the facade does not hide them) because downstream + * consumers may legitimately need to react to a cancellation — + * for example, to release a previously-reserved production slot. + */ + fun findByCode(code: String): SalesOrderRef? + + /** + * Look up a sales order by its primary-key id. Same semantics as + * [findByCode] — cancelled orders are visible. + */ + fun findById(id: Id): SalesOrderRef? +} + +/** + * Minimal, safe-to-publish view of a sales order. + */ +data class SalesOrderRef( + val id: Id, + val code: String, + val partnerCode: String, + val status: String, // DRAFT | CONFIRMED | CANCELLED — string for plug-in compatibility + val orderDate: LocalDate, + val currencyCode: String, + val totalAmount: BigDecimal, + val lines: List, +) + +/** + * Minimal view of one sales order line. + * + * No `id` field — lines are addressed via their parent order. The + * line number is the natural identifier within an order. + */ +data class SalesOrderLineRef( + val lineNo: Int, + val itemCode: String, + val quantity: BigDecimal, + val unitPrice: BigDecimal, + val currencyCode: String, +) diff --git a/distribution/build.gradle.kts b/distribution/build.gradle.kts index 7d31b7c..27e7034 100644 --- a/distribution/build.gradle.kts +++ b/distribution/build.gradle.kts @@ -30,6 +30,7 @@ dependencies { implementation(project(":pbc:pbc-catalog")) implementation(project(":pbc:pbc-partners")) implementation(project(":pbc:pbc-inventory")) + implementation(project(":pbc:pbc-orders-sales")) 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 2c99f3e..b1c4cea 100644 --- a/distribution/src/main/resources/db/changelog/master.xml +++ b/distribution/src/main/resources/db/changelog/master.xml @@ -17,4 +17,5 @@ + diff --git a/distribution/src/main/resources/db/changelog/pbc-orders-sales/001-orders-sales-init.xml b/distribution/src/main/resources/db/changelog/pbc-orders-sales/001-orders-sales-init.xml new file mode 100644 index 0000000..bb89d6f --- /dev/null +++ b/distribution/src/main/resources/db/changelog/pbc-orders-sales/001-orders-sales-init.xml @@ -0,0 +1,96 @@ + + + + + + + Create orders_sales__sales_order table (header) + + CREATE TABLE orders_sales__sales_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, + 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_sales__sales_order_code_uk + ON orders_sales__sales_order (code); + CREATE INDEX orders_sales__sales_order_partner_idx + ON orders_sales__sales_order (partner_code); + CREATE INDEX orders_sales__sales_order_status_idx + ON orders_sales__sales_order (status); + CREATE INDEX orders_sales__sales_order_date_idx + ON orders_sales__sales_order (order_date); + CREATE INDEX orders_sales__sales_order_ext_gin + ON orders_sales__sales_order USING GIN (ext jsonb_path_ops); + + + DROP TABLE orders_sales__sales_order; + + + + + Create orders_sales__sales_order_line table (FK to header, no FK to catalog__item) + + CREATE TABLE orders_sales__sales_order_line ( + id uuid PRIMARY KEY, + sales_order_id uuid NOT NULL REFERENCES orders_sales__sales_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_sales__sales_order_line_qty_pos CHECK (quantity > 0), + CONSTRAINT orders_sales__sales_order_line_price_nonneg CHECK (unit_price >= 0) + ); + CREATE UNIQUE INDEX orders_sales__sales_order_line_order_lineno_uk + ON orders_sales__sales_order_line (sales_order_id, line_no); + CREATE INDEX orders_sales__sales_order_line_item_idx + ON orders_sales__sales_order_line (item_code); + + + DROP TABLE orders_sales__sales_order_line; + + + + diff --git a/pbc/pbc-orders-sales/build.gradle.kts b/pbc/pbc-orders-sales/build.gradle.kts new file mode 100644 index 0000000..4f04a0f --- /dev/null +++ b/pbc/pbc-orders-sales/build.gradle.kts @@ -0,0 +1,68 @@ +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-sales — sales order header + lines, three-way cross-PBC validation. 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") +} + +// CRITICAL: pbc-orders-sales may depend on api-v1 (which exposes the +// cross-PBC PartnersApi, CatalogApi, and InventoryApi interfaces), +// platform-persistence, platform-security, and platform-metadata — +// but NEVER on platform-bootstrap, NEVER on another pbc-* (NOT on +// pbc-partners, pbc-catalog, OR pbc-inventory, even though we INJECT +// all three of their *Api interfaces at runtime). +// +// The cross-PBC concrete adapters live in their respective PBCs and +// are wired into the Spring context at runtime by the bootstrap +// @ComponentScan; this PBC sees only the api.v1 interfaces, exactly +// as guardrail #9 demands. +// +// pbc-orders-sales is the FIRST PBC to consume THREE cross-PBC +// facades simultaneously — the most rigorous test of the modular +// monolith story so far. The root build.gradle.kts enforces the +// dependency rule at configuration time. +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-sales/src/main/kotlin/org/vibeerp/pbc/orders/sales/application/SalesOrderService.kt b/pbc/pbc-orders-sales/src/main/kotlin/org/vibeerp/pbc/orders/sales/application/SalesOrderService.kt new file mode 100644 index 0000000..376fa0e --- /dev/null +++ b/pbc/pbc-orders-sales/src/main/kotlin/org/vibeerp/pbc/orders/sales/application/SalesOrderService.kt @@ -0,0 +1,283 @@ +package org.vibeerp.pbc.orders.sales.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.partners.PartnersApi +import org.vibeerp.pbc.orders.sales.domain.SalesOrder +import org.vibeerp.pbc.orders.sales.domain.SalesOrderLine +import org.vibeerp.pbc.orders.sales.domain.SalesOrderStatus +import org.vibeerp.pbc.orders.sales.infrastructure.SalesOrderJpaRepository +import org.vibeerp.platform.metadata.customfield.ExtJsonValidator +import java.math.BigDecimal +import java.time.LocalDate +import java.util.UUID + +/** + * Application service for sales order CRUD and state transitions. + * + * **The framework's first PBC that consumes TWO cross-PBC facades + * simultaneously**: it injects [PartnersApi] (to validate the + * customer) AND [CatalogApi] (to validate every line's item). This + * is the most rigorous test of the modular monolith story so far — + * pbc-orders-sales has no compile-time dependency on either + * pbc-partners or pbc-catalog (the Gradle build refuses such + * dependencies, see CLAUDE.md guardrail #9), but Spring DI wires the + * two interfaces to their concrete adapters at runtime. + * + * **What `create` validates** (in this order): + * 1. The order code is unique (existing rule). + * 2. The partner code resolves via [PartnersApi.findPartnerByCode]. + * The facade hides inactive partners, so an inactive partner + * looks identical to a non-existent one — which is what we want + * for new orders. The partner's `type` must be CUSTOMER or BOTH; + * a SUPPLIER-only partner cannot be the customer of a sales + * order. (The reverse rule will guard pbc-orders-purchase later.) + * 3. Every line has at least one item, every line's `item_code` + * resolves via [CatalogApi.findItemByCode] (also hides inactive), + * every line has a positive quantity and a non-negative price. + * 4. Every line's currency matches the header currency. v1 enforces + * all-lines-match-header; the per-line currency column is in the + * schema for future relaxation without a migration. + * 5. The header total is RECOMPUTED from the lines (the caller's + * value, if any, is ignored). This is the cardinal rule of + * financial data: never trust an aggregate the caller sent you. + * + * **State machine** (enforced by [confirm] and [cancel]): + * - DRAFT → CONFIRMED (confirm) + * - DRAFT → CANCELLED (cancel from draft) + * - CONFIRMED → CANCELLED (cancel a confirmed order) + * - Anything else throws. + * + * **What `update` does**: + * - Allowed only when status is DRAFT. Once confirmed, the order is + * immutable except for cancellation. This is a hard rule because + * confirmed orders are typically printed, sent to the customer, + * or have downstream side effects (production scheduling, stock + * reservation) that an in-place edit would silently invalidate. + * - When line items are provided, they REPLACE the existing lines + * entirely (PUT semantics for lines, PATCH for header columns). + * Partial line edits are not modelled because the typical "edit + * this one line" UI gesture renders to a full re-send anyway. + */ +@Service +@Transactional +class SalesOrderService( + private val orders: SalesOrderJpaRepository, + private val partnersApi: PartnersApi, + private val catalogApi: CatalogApi, + 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): SalesOrder? = orders.findById(id).orElse(null) + + @Transactional(readOnly = true) + fun findByCode(code: String): SalesOrder? = orders.findByCode(code) + + fun create(command: CreateSalesOrderCommand): SalesOrder { + require(!orders.existsByCode(command.code)) { + "sales order code '${command.code}' is already taken" + } + + // Cross-PBC validation #1: the customer must exist, be + // active, and play a CUSTOMER 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 == "CUSTOMER" || partner.type == "BOTH") { + "partner '${command.partnerCode}' is type ${partner.type} and cannot be the customer of a sales order" + } + + require(command.lines.isNotEmpty()) { + "sales order must have at least one line" + } + // Sanity-check line numbers are unique within the order. + val duplicateLineNos = command.lines.groupBy { it.lineNo }.filter { it.value.size > 1 }.keys + require(duplicateLineNos.isEmpty()) { + "duplicate line numbers in sales 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 the header total — the caller's value (if any) + // is intentionally ignored. Never trust a financial aggregate + // sent over the wire. + val total = command.lines.fold(BigDecimal.ZERO) { acc, line -> + acc + (line.quantity * line.unitPrice) + } + + val canonicalExt = extValidator.validate(ENTITY_NAME, command.ext) + + val order = SalesOrder( + code = command.code, + partnerCode = command.partnerCode, + status = SalesOrderStatus.DRAFT, + orderDate = command.orderDate, + currencyCode = command.currencyCode, + totalAmount = total, + ).also { + it.ext = jsonMapper.writeValueAsString(canonicalExt) + } + for (line in command.lines) { + order.lines += SalesOrderLine( + salesOrder = 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: UpdateSalesOrderCommand): SalesOrder { + val order = orders.findById(id).orElseThrow { + NoSuchElementException("sales order not found: $id") + } + require(order.status == SalesOrderStatus.DRAFT) { + "cannot update sales 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 == "CUSTOMER" || partner.type == "BOTH") { + "partner '$newPartnerCode' is type ${partner.type} and cannot be the customer of a sales order" + } + order.partnerCode = newPartnerCode + } + command.orderDate?.let { order.orderDate = 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()) { + "sales 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 sales 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)", + ) + } + // Replace lines wholesale: clear (orphanRemoval will + // delete the old rows), add the new ones, recompute total. + order.lines.clear() + for (line in command.lines) { + order.lines += SalesOrderLine( + salesOrder = 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): SalesOrder { + val order = orders.findById(id).orElseThrow { + NoSuchElementException("sales order not found: $id") + } + require(order.status == SalesOrderStatus.DRAFT) { + "cannot confirm sales order ${order.code} in status ${order.status}; only DRAFT can be confirmed" + } + order.status = SalesOrderStatus.CONFIRMED + return order + } + + fun cancel(id: UUID): SalesOrder { + val order = orders.findById(id).orElseThrow { + NoSuchElementException("sales order not found: $id") + } + require(order.status != SalesOrderStatus.CANCELLED) { + "sales order ${order.code} is already cancelled" + } + order.status = SalesOrderStatus.CANCELLED + return order + } + + @Suppress("UNCHECKED_CAST") + fun parseExt(order: SalesOrder): 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 = "SalesOrder" + } +} + +data class CreateSalesOrderCommand( + val code: String, + val partnerCode: String, + val orderDate: LocalDate, + val currencyCode: String, + val lines: List, + val ext: Map? = null, +) + +data class UpdateSalesOrderCommand( + val partnerCode: String? = null, + val orderDate: LocalDate? = null, + val currencyCode: String? = null, + val lines: List? = null, + val ext: Map? = null, +) + +data class SalesOrderLineCommand( + val lineNo: Int, + val itemCode: String, + val quantity: BigDecimal, + val unitPrice: BigDecimal, + val currencyCode: String, +) diff --git a/pbc/pbc-orders-sales/src/main/kotlin/org/vibeerp/pbc/orders/sales/domain/SalesOrder.kt b/pbc/pbc-orders-sales/src/main/kotlin/org/vibeerp/pbc/orders/sales/domain/SalesOrder.kt new file mode 100644 index 0000000..bc15a79 --- /dev/null +++ b/pbc/pbc-orders-sales/src/main/kotlin/org/vibeerp/pbc/orders/sales/domain/SalesOrder.kt @@ -0,0 +1,140 @@ +package org.vibeerp.pbc.orders.sales.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.JoinColumn +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 sales order. + * + * pbc-orders-sales is the framework's first **business workflow PBC**: + * unlike identity, catalog, partners and inventory (which are master + * data + facts), this PBC carries a state machine and references + * across THREE other PBCs at create time. Every cross-PBC reference + * goes through `api.v1.ext.` interfaces — see + * [SalesOrderService] for the resolution chain. + * + * **Why a string `partner_code` and a string `currency_code`** instead + * of UUID FKs: + * - The partner reference is **cross-PBC**. There can be no foreign + * key to `partners__partner` because pbc-orders-sales has no + * compile-time dependency on pbc-partners (CLAUDE.md guardrail #9). + * The link to "this is a real customer" is enforced at the + * application layer through `PartnersApi.findPartnerByCode`. + * - The currency code is the natural key (USD, EUR, CNY, JPY, GBP). + * Currencies are stable identifiers that survive every database + * re-import; modelling them as a varchar makes seed data trivial + * and the rows readable. + * + * **Why a denormalised `total_amount` column** instead of always + * computing from the lines: + * - Sales orders are read at scale by reporting, by the SPA's list + * view, and by future commission/incentive jobs. Recomputing the + * total on every read is wasteful when it changes only on + * create/update. + * - The total IS recomputed by the service on every save (the + * caller's claimed total is ignored), so it cannot drift from the + * line sum. + * - The total is stored as `numeric(18, 4)` so the typical 2-digit + * currency precision rounds correctly. Currency-aware rounding is + * delegated to the `Money` value object in api.v1 at the boundary. + * + * **Why the status set is small** (DRAFT / CONFIRMED / CANCELLED): + * - These three are universal. Every shop has a "saving but not yet + * sent to the customer" state, a "this is committed" state, and a + * "we're not doing this" state. Anything more granular + * (PARTIALLY_SHIPPED, BACKORDERED, RETURNED, …) requires the + * movement ledger and shipping flow that are deferred from v1. + * - The transition graph is enforced by `SalesOrderService`: + * DRAFT → CONFIRMED, DRAFT → CANCELLED, CONFIRMED → CANCELLED. + * Anything else throws. + * + * The `lines` collection is loaded eagerly because every read of an + * order header is followed in practice by a read of the lines (the + * SPA shows them, the reporting jobs sum them, the cancel-confirm + * flow uses them). Lazy loading would force a JOIN-N+1 every time + * for no benefit at this scale. + */ +@Entity +@Table(name = "orders_sales__sales_order") +class SalesOrder( + code: String, + partnerCode: String, + status: SalesOrderStatus = SalesOrderStatus.DRAFT, + orderDate: LocalDate, + 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: SalesOrderStatus = status + + @Column(name = "order_date", nullable = false) + var orderDate: LocalDate = orderDate + + @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 = "{}" + + /** + * The order's line items, eagerly loaded. Cascade ALL because + * lines have no independent existence — they belong to exactly + * one order, are created with the order, and are deleted with the + * order. orphanRemoval keeps the collection consistent when a + * caller drops a line by removing it from this list. + */ + @OneToMany( + mappedBy = "salesOrder", + cascade = [CascadeType.ALL], + orphanRemoval = true, + fetch = FetchType.EAGER, + ) + @OrderBy("lineNo ASC") + var lines: MutableList = mutableListOf() + + override fun toString(): String = + "SalesOrder(id=$id, code='$code', partner='$partnerCode', status=$status, total=$totalAmount $currencyCode)" +} + +/** + * Possible states a [SalesOrder] can be in. + * + * Stored as a string in the DB so adding values later is non-breaking + * for clients reading the column with raw SQL. Future expansion + * (PARTIALLY_SHIPPED, SHIPPED, INVOICED, CLOSED) requires the + * movement ledger and shipping flow which are deferred from v1. + * + * - **DRAFT** — being prepared, lines may still be edited + * - **CONFIRMED** — committed; lines are now immutable + * - **CANCELLED** — terminal; the order is dead + */ +enum class SalesOrderStatus { + DRAFT, + CONFIRMED, + CANCELLED, +} diff --git a/pbc/pbc-orders-sales/src/main/kotlin/org/vibeerp/pbc/orders/sales/domain/SalesOrderLine.kt b/pbc/pbc-orders-sales/src/main/kotlin/org/vibeerp/pbc/orders/sales/domain/SalesOrderLine.kt new file mode 100644 index 0000000..918f46a --- /dev/null +++ b/pbc/pbc-orders-sales/src/main/kotlin/org/vibeerp/pbc/orders/sales/domain/SalesOrderLine.kt @@ -0,0 +1,89 @@ +package org.vibeerp.pbc.orders.sales.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 [SalesOrder]. + * + * **Why `item_code` is a varchar and not a UUID FK to catalog__item.id:** + * Same reason `partner_code` on the header is a string — pbc-orders-sales + * has NO compile-time dependency on pbc-catalog (CLAUDE.md guardrail #9). + * The link to "this is a real catalog item" is enforced at the + * application layer through `org.vibeerp.api.v1.ext.catalog.CatalogApi` + * at create time. Storing the code instead of the UUID makes the row + * legible without a join and survives the catalog being re-imported + * from scratch. + * + * **Why `unit_price` and `currency_code` live ON the line** instead + * of being normalised to a separate price book: + * - At order time, the price you actually charged the customer is + * a fact that survives every future price book change. The line + * is a snapshot of "we sold X units at Y per unit on this day", + * and the line's price NEVER changes after the order is + * confirmed. + * - The `currency_code` exists per-line (not just per-header) so + * a future multi-currency order can quote some lines in USD and + * some in CNY. v1 enforces all-lines-match-header-currency, but + * the column is in the schema so the rule can relax later + * without a migration. + * + * **Why no `ext` JSONB on the line:** lines are facts, not master + * records. Tier 1 customisation belongs on the order header (where + * the customer-specific fields live) and on the catalog item itself + * (which is the natural place for "what extra attributes does this + * SKU have?"). Adding ext on the line would be the kind of + * customisability foot-gun the framework refuses by design — see + * the StockBalance rationale. + * + * The line itself extends [AuditedJpaEntity] for the audit columns + * but the values matter less than they do on the header — most lines + * are created and updated as part of one save on the parent order. + */ +@Entity +@Table(name = "orders_sales__sales_order_line") +class SalesOrderLine( + salesOrder: SalesOrder, + lineNo: Int, + itemCode: String, + quantity: BigDecimal, + unitPrice: BigDecimal, + currencyCode: String, +) : AuditedJpaEntity() { + + @ManyToOne + @JoinColumn(name = "sales_order_id", nullable = false) + var salesOrder: SalesOrder = salesOrder + + @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 + + /** + * Line subtotal: quantity × unit_price. Computed at read time + * because the inputs are both stored and the result is just a + * convenience for callers — keeping it OUT of the database means + * the storage cannot get out of sync with the inputs. + */ + val lineTotal: BigDecimal + get() = quantity.multiply(unitPrice) + + override fun toString(): String = + "SalesOrderLine(id=$id, orderId=${salesOrder.id}, line=$lineNo, item='$itemCode', qty=$quantity × $unitPrice = $lineTotal $currencyCode)" +} diff --git a/pbc/pbc-orders-sales/src/main/kotlin/org/vibeerp/pbc/orders/sales/ext/SalesOrdersApiAdapter.kt b/pbc/pbc-orders-sales/src/main/kotlin/org/vibeerp/pbc/orders/sales/ext/SalesOrdersApiAdapter.kt new file mode 100644 index 0000000..e4c2eaf --- /dev/null +++ b/pbc/pbc-orders-sales/src/main/kotlin/org/vibeerp/pbc/orders/sales/ext/SalesOrdersApiAdapter.kt @@ -0,0 +1,59 @@ +package org.vibeerp.pbc.orders.sales.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.SalesOrderLineRef +import org.vibeerp.api.v1.ext.orders.SalesOrderRef +import org.vibeerp.api.v1.ext.orders.SalesOrdersApi +import org.vibeerp.pbc.orders.sales.domain.SalesOrder +import org.vibeerp.pbc.orders.sales.domain.SalesOrderLine +import org.vibeerp.pbc.orders.sales.infrastructure.SalesOrderJpaRepository + +/** + * Concrete [SalesOrdersApi] implementation. The fifth `*ApiAdapter` + * after IdentityApiAdapter, CatalogApiAdapter, PartnersApiAdapter, + * and InventoryApiAdapter. + * + * Like the other adapters, this NEVER returns its own JPA entity + * types and NEVER reaches across PBC boundaries. The two methods + * walk the order header + lines once each and convert to the + * api.v1 [SalesOrderRef] / [SalesOrderLineRef] DTOs. + * + * Cancelled orders ARE returned by the facade (unlike the + * "inactive item / inactive partner = null" rule on those facades), + * because downstream consumers — production scheduling, invoicing, + * commission rollups — may legitimately need to react to a + * cancellation. The contract is documented on [SalesOrdersApi]. + */ +@Component +@Transactional(readOnly = true) +class SalesOrdersApiAdapter( + private val orders: SalesOrderJpaRepository, +) : SalesOrdersApi { + + override fun findByCode(code: String): SalesOrderRef? = + orders.findByCode(code)?.toRef() + + override fun findById(id: Id): SalesOrderRef? = + orders.findById(id.value).orElse(null)?.toRef() + + private fun SalesOrder.toRef(): SalesOrderRef = SalesOrderRef( + id = Id(this.id), + code = this.code, + partnerCode = this.partnerCode, + status = this.status.name, + orderDate = this.orderDate, + currencyCode = this.currencyCode, + totalAmount = this.totalAmount, + lines = this.lines.map { it.toRef() }, + ) + + private fun SalesOrderLine.toRef(): SalesOrderLineRef = SalesOrderLineRef( + lineNo = this.lineNo, + itemCode = this.itemCode, + quantity = this.quantity, + unitPrice = this.unitPrice, + currencyCode = this.currencyCode, + ) +} diff --git a/pbc/pbc-orders-sales/src/main/kotlin/org/vibeerp/pbc/orders/sales/http/SalesOrderController.kt b/pbc/pbc-orders-sales/src/main/kotlin/org/vibeerp/pbc/orders/sales/http/SalesOrderController.kt new file mode 100644 index 0000000..554c754 --- /dev/null +++ b/pbc/pbc-orders-sales/src/main/kotlin/org/vibeerp/pbc/orders/sales/http/SalesOrderController.kt @@ -0,0 +1,185 @@ +package org.vibeerp.pbc.orders.sales.http + +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.sales.application.CreateSalesOrderCommand +import org.vibeerp.pbc.orders.sales.application.SalesOrderLineCommand +import org.vibeerp.pbc.orders.sales.application.SalesOrderService +import org.vibeerp.pbc.orders.sales.application.UpdateSalesOrderCommand +import org.vibeerp.pbc.orders.sales.domain.SalesOrder +import org.vibeerp.pbc.orders.sales.domain.SalesOrderLine +import org.vibeerp.pbc.orders.sales.domain.SalesOrderStatus +import java.math.BigDecimal +import java.time.LocalDate +import java.util.UUID + +/** + * REST API for the sales orders PBC. + * + * Mounted at `/api/v1/orders/sales-orders`. Authenticated. + * + * The shape of POST is intentionally close to a "shopping cart" + * payload — header fields plus a line array — because that is what + * every SPA, every external integration, and every AI agent will + * naturally generate. Wire-format-friendly is the rule. + * + * State transitions live on dedicated POST endpoints rather than as + * status updates via PATCH: + * - `POST /{id}/confirm` is more honest than + * `PATCH /{id} {"status": "CONFIRMED"}` because the action has + * side effects (lines become immutable, downstream PBCs receive + * events in future versions). Sentinel-status writes hide that. + * - `POST /{id}/cancel` similarly. + */ +@RestController +@RequestMapping("/api/v1/orders/sales-orders") +class SalesOrderController( + private val salesOrderService: SalesOrderService, +) { + + @GetMapping + fun list(): List = + salesOrderService.list().map { it.toResponse(salesOrderService) } + + @GetMapping("/{id}") + fun get(@PathVariable id: UUID): ResponseEntity { + val order = salesOrderService.findById(id) ?: return ResponseEntity.notFound().build() + return ResponseEntity.ok(order.toResponse(salesOrderService)) + } + + @GetMapping("/by-code/{code}") + fun getByCode(@PathVariable code: String): ResponseEntity { + val order = salesOrderService.findByCode(code) ?: return ResponseEntity.notFound().build() + return ResponseEntity.ok(order.toResponse(salesOrderService)) + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + fun create(@RequestBody @Valid request: CreateSalesOrderRequest): SalesOrderResponse = + salesOrderService.create( + CreateSalesOrderCommand( + code = request.code, + partnerCode = request.partnerCode, + orderDate = request.orderDate, + currencyCode = request.currencyCode, + lines = request.lines.map { it.toCommand() }, + ext = request.ext, + ), + ).toResponse(salesOrderService) + + @PatchMapping("/{id}") + fun update( + @PathVariable id: UUID, + @RequestBody @Valid request: UpdateSalesOrderRequest, + ): SalesOrderResponse = + salesOrderService.update( + id, + UpdateSalesOrderCommand( + partnerCode = request.partnerCode, + orderDate = request.orderDate, + currencyCode = request.currencyCode, + lines = request.lines?.map { it.toCommand() }, + ext = request.ext, + ), + ).toResponse(salesOrderService) + + @PostMapping("/{id}/confirm") + fun confirm(@PathVariable id: UUID): SalesOrderResponse = + salesOrderService.confirm(id).toResponse(salesOrderService) + + @PostMapping("/{id}/cancel") + fun cancel(@PathVariable id: UUID): SalesOrderResponse = + salesOrderService.cancel(id).toResponse(salesOrderService) +} + +// ─── DTOs ──────────────────────────────────────────────────────────── + +data class CreateSalesOrderRequest( + @field:NotBlank @field:Size(max = 64) val code: String, + @field:NotBlank @field:Size(max = 64) val partnerCode: String, + @field:NotNull val orderDate: LocalDate, + @field:NotBlank @field:Size(min = 3, max = 3) val currencyCode: String, + @field:NotEmpty @field:Valid val lines: List, + val ext: Map? = null, +) + +data class UpdateSalesOrderRequest( + @field:Size(max = 64) val partnerCode: String? = null, + val orderDate: LocalDate? = null, + @field:Size(min = 3, max = 3) val currencyCode: String? = null, + @field:Valid val lines: List? = null, + val ext: Map? = null, +) + +data class SalesOrderLineRequest( + @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(): SalesOrderLineCommand = SalesOrderLineCommand( + lineNo = lineNo, + itemCode = itemCode, + quantity = quantity, + unitPrice = unitPrice, + currencyCode = currencyCode, + ) +} + +data class SalesOrderResponse( + val id: UUID, + val code: String, + val partnerCode: String, + val status: SalesOrderStatus, + val orderDate: LocalDate, + val currencyCode: String, + val totalAmount: BigDecimal, + val lines: List, + val ext: Map, +) + +data class SalesOrderLineResponse( + val id: UUID, + val lineNo: Int, + val itemCode: String, + val quantity: BigDecimal, + val unitPrice: BigDecimal, + val currencyCode: String, + val lineTotal: BigDecimal, +) + +private fun SalesOrder.toResponse(service: SalesOrderService) = SalesOrderResponse( + id = this.id, + code = this.code, + partnerCode = this.partnerCode, + status = this.status, + orderDate = this.orderDate, + currencyCode = this.currencyCode, + totalAmount = this.totalAmount, + lines = this.lines.map { it.toResponse() }, + ext = service.parseExt(this), +) + +private fun SalesOrderLine.toResponse() = SalesOrderLineResponse( + 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-sales/src/main/kotlin/org/vibeerp/pbc/orders/sales/infrastructure/SalesOrderJpaRepository.kt b/pbc/pbc-orders-sales/src/main/kotlin/org/vibeerp/pbc/orders/sales/infrastructure/SalesOrderJpaRepository.kt new file mode 100644 index 0000000..fc05ba9 --- /dev/null +++ b/pbc/pbc-orders-sales/src/main/kotlin/org/vibeerp/pbc/orders/sales/infrastructure/SalesOrderJpaRepository.kt @@ -0,0 +1,24 @@ +package org.vibeerp.pbc.orders.sales.infrastructure + +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository +import org.vibeerp.pbc.orders.sales.domain.SalesOrder +import java.util.UUID + +/** + * Spring Data JPA repository for [SalesOrder]. + * + * Note: lookups by partner code or item code (the typical reporting + * shape) are NOT exposed here in v1. Reporting against sales orders + * is its own concern that needs read replicas, materialised views, + * or a separate analytics path. Adding query methods here would + * encourage on-the-fly aggregation against the live OLTP table, + * which is the path to pain. + */ +@Repository +interface SalesOrderJpaRepository : JpaRepository { + + fun findByCode(code: String): SalesOrder? + + fun existsByCode(code: String): Boolean +} diff --git a/pbc/pbc-orders-sales/src/main/resources/META-INF/vibe-erp/metadata/orders-sales.yml b/pbc/pbc-orders-sales/src/main/resources/META-INF/vibe-erp/metadata/orders-sales.yml new file mode 100644 index 0000000..2c5711c --- /dev/null +++ b/pbc/pbc-orders-sales/src/main/resources/META-INF/vibe-erp/metadata/orders-sales.yml @@ -0,0 +1,33 @@ +# pbc-orders-sales metadata. +# +# Loaded at boot by MetadataLoader, tagged source='core'. + +entities: + - name: SalesOrder + pbc: orders-sales + table: orders_sales__sales_order + description: A sales order header — customer, currency, total, status + + - name: SalesOrderLine + pbc: orders-sales + table: orders_sales__sales_order_line + description: One line item of a sales order (item × quantity × unit price) + +permissions: + - key: orders.sales.read + description: Read sales orders + - key: orders.sales.create + description: Create draft sales orders + - key: orders.sales.update + description: Update DRAFT sales orders (lines, partner, dates) + - key: orders.sales.confirm + description: Confirm a draft sales order (DRAFT → CONFIRMED) + - key: orders.sales.cancel + description: Cancel a sales order (any non-cancelled state → CANCELLED) + +menus: + - path: /orders/sales + label: Sales orders + icon: receipt + section: Sales + order: 500 diff --git a/pbc/pbc-orders-sales/src/test/kotlin/org/vibeerp/pbc/orders/sales/application/SalesOrderServiceTest.kt b/pbc/pbc-orders-sales/src/test/kotlin/org/vibeerp/pbc/orders/sales/application/SalesOrderServiceTest.kt new file mode 100644 index 0000000..0b7015b --- /dev/null +++ b/pbc/pbc-orders-sales/src/test/kotlin/org/vibeerp/pbc/orders/sales/application/SalesOrderServiceTest.kt @@ -0,0 +1,301 @@ +package org.vibeerp.pbc.orders.sales.application + +import assertk.assertFailure +import assertk.assertThat +import assertk.assertions.contains +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 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.partners.PartnerRef +import org.vibeerp.api.v1.ext.partners.PartnersApi +import org.vibeerp.pbc.orders.sales.domain.SalesOrder +import org.vibeerp.pbc.orders.sales.domain.SalesOrderStatus +import org.vibeerp.pbc.orders.sales.infrastructure.SalesOrderJpaRepository +import org.vibeerp.platform.metadata.customfield.ExtJsonValidator +import java.math.BigDecimal +import java.time.LocalDate +import java.util.Optional +import java.util.UUID + +class SalesOrderServiceTest { + + private lateinit var orders: SalesOrderJpaRepository + private lateinit var partnersApi: PartnersApi + private lateinit var catalogApi: CatalogApi + private lateinit var extValidator: ExtJsonValidator + private lateinit var service: SalesOrderService + + @BeforeEach + fun setUp() { + orders = mockk() + partnersApi = mockk() + catalogApi = mockk() + extValidator = mockk() + every { extValidator.validate(any(), any()) } answers { secondArg() ?: emptyMap() } + every { orders.existsByCode(any()) } returns false + every { orders.save(any()) } answers { firstArg() } + service = SalesOrderService(orders, partnersApi, catalogApi, extValidator) + } + + private fun stubCustomer(code: String, type: String = "CUSTOMER") { + 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 = "10", price: String = "5.00") = + SalesOrderLineCommand( + lineNo = no, + itemCode = item, + quantity = BigDecimal(qty), + unitPrice = BigDecimal(price), + currencyCode = "USD", + ) + + private fun command( + code: String = "SO-1", + partnerCode: String = "CUST-1", + currency: String = "USD", + lines: List = listOf(line()), + ) = CreateSalesOrderCommand( + code = code, + partnerCode = partnerCode, + orderDate = LocalDate.of(2026, 4, 8), + currencyCode = currency, + lines = lines, + ) + + @Test + fun `create rejects unknown partner 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 SUPPLIER-only partner`() { + stubCustomer("SUP-1", type = "SUPPLIER") + + assertFailure { service.create(command(partnerCode = "SUP-1")) } + .isInstanceOf(IllegalArgumentException::class) + .messageContains("cannot be the customer of a sales order") + } + + @Test + fun `create accepts BOTH partner type`() { + stubCustomer("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`() { + stubCustomer("CUST-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`() { + stubCustomer("CUST-1") + + assertFailure { service.create(command(lines = emptyList())) } + .isInstanceOf(IllegalArgumentException::class) + .hasMessage("sales order must have at least one line") + } + + @Test + fun `create rejects duplicate line numbers`() { + stubCustomer("CUST-1") + stubItem("PAPER-A4") + + assertFailure { + service.create( + command(lines = listOf(line(no = 1), line(no = 1, item = "PAPER-A4"))), + ) + } + .isInstanceOf(IllegalArgumentException::class) + .messageContains("duplicate line numbers") + } + + @Test + fun `create rejects negative quantity before catalog lookup`() { + stubCustomer("CUST-1") + + assertFailure { + service.create(command(lines = listOf(line(qty = "-1")))) + } + .isInstanceOf(IllegalArgumentException::class) + .messageContains("quantity must be positive") + } + + @Test + fun `create rejects line currency mismatch`() { + stubCustomer("CUST-1") + stubItem("PAPER-A4") + + assertFailure { + service.create( + command( + currency = "USD", + lines = listOf( + SalesOrderLineCommand( + lineNo = 1, + itemCode = "PAPER-A4", + quantity = BigDecimal("1"), + unitPrice = BigDecimal("1"), + currencyCode = "EUR", + ), + ), + ), + ) + } + .isInstanceOf(IllegalArgumentException::class) + .messageContains("does not match header currency") + } + + @Test + fun `create recomputes total from lines, ignoring caller`() { + stubCustomer("CUST-1") + stubItem("PAPER-A4") + stubItem("PAPER-A3") + 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 = "10", price = "5.00"), // 50.00 + line(no = 2, item = "PAPER-A3", qty = "3", price = "8.50"), // 25.50 + ), + ), + ) + + // 10 × 5.00 + 3 × 8.50 = 50.00 + 25.50 = 75.50 + assertThat(result.totalAmount).isEqualTo(BigDecimal("75.50")) + assertThat(result.lines).hasSize(2) + } + + @Test + fun `confirm transitions DRAFT to CONFIRMED`() { + val id = UUID.randomUUID() + val order = SalesOrder( + code = "SO-1", + partnerCode = "CUST-1", + status = SalesOrderStatus.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(SalesOrderStatus.CONFIRMED) + } + + @Test + fun `confirm rejects an already-confirmed order`() { + val id = UUID.randomUUID() + val order = SalesOrder( + code = "SO-1", + partnerCode = "CUST-1", + status = SalesOrderStatus.CONFIRMED, + orderDate = LocalDate.of(2026, 4, 8), + currencyCode = "USD", + ).also { it.id = id } + every { orders.findById(id) } returns Optional.of(order) + + assertFailure { service.confirm(id) } + .isInstanceOf(IllegalArgumentException::class) + .messageContains("only DRAFT can be confirmed") + } + + @Test + fun `update rejects mutation of a CONFIRMED order`() { + val id = UUID.randomUUID() + val order = SalesOrder( + code = "SO-1", + partnerCode = "CUST-1", + status = SalesOrderStatus.CONFIRMED, + orderDate = LocalDate.of(2026, 4, 8), + currencyCode = "USD", + ).also { it.id = id } + every { orders.findById(id) } returns Optional.of(order) + + assertFailure { + service.update(id, UpdateSalesOrderCommand(orderDate = LocalDate.of(2026, 4, 9))) + } + .isInstanceOf(IllegalArgumentException::class) + .messageContains("only DRAFT orders are mutable") + } + + @Test + fun `cancel a confirmed order is allowed`() { + val id = UUID.randomUUID() + val order = SalesOrder( + code = "SO-1", + partnerCode = "CUST-1", + status = SalesOrderStatus.CONFIRMED, + orderDate = LocalDate.of(2026, 4, 8), + currencyCode = "USD", + ).also { it.id = id } + every { orders.findById(id) } returns Optional.of(order) + + val cancelled = service.cancel(id) + + assertThat(cancelled.status).isEqualTo(SalesOrderStatus.CANCELLED) + } + + @Test + fun `cancel rejects an already-cancelled order`() { + val id = UUID.randomUUID() + val order = SalesOrder( + code = "SO-1", + partnerCode = "CUST-1", + status = SalesOrderStatus.CANCELLED, + orderDate = LocalDate.of(2026, 4, 8), + currencyCode = "USD", + ).also { it.id = id } + every { orders.findById(id) } returns Optional.of(order) + + assertFailure { service.cancel(id) } + .isInstanceOf(IllegalArgumentException::class) + .messageContains("already cancelled") + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index f699691..1c0deb1 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -55,6 +55,9 @@ project(":pbc:pbc-partners").projectDir = file("pbc/pbc-partners") include(":pbc:pbc-inventory") 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") + // ─── 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