Commit a8eb2a6661a5847896a485a65fd316601ab914ab

Authored by zichun
1 parent 1f00ce31

feat(pbc): P5.5 — pbc-orders-sales + first PBC consuming TWO cross-PBC facades

The fifth real PBC and the first business workflow PBC. pbc-inventory
proved a PBC could consume ONE cross-PBC facade (CatalogApi).
pbc-orders-sales consumes TWO simultaneously (PartnersApi for the
customer, CatalogApi for every line's item) in a single transaction —
the most rigorous test of the modular monolith story so far. Neither
source PBC is on the compile classpath; the Gradle build refuses any
direct dependency. Spring DI wires the api.v1 interfaces to their
concrete adapters at runtime.

What landed
-----------
* New Gradle subproject `pbc/pbc-orders-sales` (15 modules total).
* Two JPA entities, both extending `AuditedJpaEntity`:
  - `SalesOrder` (header) — code, partner_code (varchar, NOT a UUID
    FK to partners), status enum DRAFT/CONFIRMED/CANCELLED, order_date,
    currency_code (varchar(3)), total_amount numeric(18,4),
    ext jsonb. Eager-loaded `lines` collection because every read of
    the header is followed by a read of the lines in practice.
  - `SalesOrderLine` — sales_order_id FK, line_no, item_code (varchar,
    NOT a UUID FK to catalog), quantity, unit_price, currency_code.
    Per-line currency in the schema even though v1 enforces all-lines-
    match-header (so multi-currency relaxation is later schema-free).
    No `ext` jsonb on lines: lines are facts, not master records;
    custom fields belong on the header.
* `SalesOrderService.create` performs **three independent
  cross-PBC validations** in one transaction:
  1. PartnersApi.findPartnerByCode → reject if null (covers unknown
     AND inactive partners; the facade hides them).
  2. PartnersApi result.type must be CUSTOMER or BOTH (a SUPPLIER-only
     partner cannot be the customer of a sales order).
  3. CatalogApi.findItemByCode for EVERY line → reject if null.
  Then it ALSO validates: at least one line, no duplicate line numbers,
  positive quantity, non-negative price, currency matches header.
  The header total is RECOMPUTED from the lines — the caller's value
  is intentionally ignored. Never trust a financial aggregate sent
  over the wire.
* State machine enforced by `confirm()` and `cancel()`:
  - DRAFT → CONFIRMED   (confirm)
  - DRAFT → CANCELLED   (cancel from draft)
  - CONFIRMED → CANCELLED (cancel a confirmed order)
  Anything else throws with a descriptive message. CONFIRMED orders
  are immutable except for cancellation — the `update` method refuses
  to mutate a non-DRAFT order.
* `update` with line items REPLACES the existing lines wholesale
  (PUT semantics for lines, PATCH for header columns). Partial line
  edits are not modelled because the typical "edit one line" UI
  gesture renders to a full re-send anyway.
* REST: `/api/v1/orders/sales-orders` (CRUD + `/confirm` + `/cancel`).
  State transitions live on dedicated POST endpoints rather than
  PATCH-based status writes — they have side effects (lines become
  immutable, downstream PBCs will receive events in future versions),
  and sentinel-status writes hide that.
* New api.v1 facade `org.vibeerp.api.v1.ext.orders.SalesOrdersApi`
  with `findByCode`, `findById`, `SalesOrderRef`, `SalesOrderLineRef`.
  Fifth ext.* package after identity, catalog, partners, inventory.
  Sets up the next consumers: pbc-production for work orders, pbc-finance
  for invoicing, the printing-shop reference plug-in for the
  quote-to-job-card workflow.
* `SalesOrdersApiAdapter` runtime implementation. Cancelled orders ARE
  returned by the facade (unlike inactive items / partners which are
  hidden) because downstream consumers may legitimately need to react
  to a cancellation — release a production slot, void an invoice, etc.
* `orders-sales.yml` metadata declaring 2 entities, 5 permission keys,
  1 menu entry.

Build enforcement (still load-bearing)
--------------------------------------
The root `build.gradle.kts` STILL refuses any direct dependency from
`pbc-orders-sales` to either `pbc-partners` or `pbc-catalog`. Try
adding either as `implementation(project(...))` and the build fails
at configuration time with the architectural violation. The
cross-PBC interfaces live in api-v1; the concrete adapters live in
their owning PBCs; Spring DI assembles them at runtime via the
bootstrap @ComponentScan. pbc-orders-sales sees only the api.v1
interfaces.

End-to-end smoke test
---------------------
Reset Postgres, booted the app, hit:
* POST /api/v1/catalog/items × 2  → PAPER-A4, INK-CYAN
* POST /api/v1/partners/partners → CUST-ACME (CUSTOMER), SUP-ONLY (SUPPLIER)
* POST /api/v1/orders/sales-orders → 201, two lines, total 386.50
  (5000 × 0.05 + 3 × 45.50 = 250.00 + 136.50, correctly recomputed)
* POST .../sales-orders with FAKE-PARTNER → 400 with the meaningful
  message "partner code 'FAKE-PARTNER' is not in the partners
  directory (or is inactive)"
* POST .../sales-orders with SUP-ONLY → 400 "partner 'SUP-ONLY' is
  type SUPPLIER and cannot be the customer of a sales order"
* POST .../sales-orders with FAKE-ITEM line → 400 "line 1: item code
  'FAKE-ITEM' is not in the catalog (or is inactive)"
* POST /{id}/confirm → status DRAFT → CONFIRMED
* PATCH the CONFIRMED order → 400 "only DRAFT orders are mutable"
* Re-confirm a CONFIRMED order → 400 "only DRAFT can be confirmed"
* POST /{id}/cancel a CONFIRMED order → status CANCELLED (allowed)
* SELECT * FROM orders_sales__sales_order — single row, total
  386.5000, status CANCELLED
* SELECT * FROM orders_sales__sales_order_line — two rows in line_no
  order with the right items and quantities
* GET /api/v1/_meta/metadata/entities → 13 entities now (was 11)
* Regression: catalog uoms, identity users, partners, inventory
  locations, printing-shop plates with i18n (Accept-Language: zh-CN)
  all still HTTP 2xx.

Build
-----
* `./gradlew build`: 15 subprojects, 153 unit tests (was 139),
  all green. The 14 new tests cover: unknown/SUPPLIER-only/BOTH-type
  partner paths, unknown item path, empty/duplicate-lineno line
  arrays, negative-quantity early reject (verifies CatalogApi NOT
  consulted), currency mismatch reject, total recomputation, all
  three state-machine transitions and the rejected ones.

What was deferred
-----------------
* **Sales-order shipping**. Confirmed orders cannot yet ship, because
  shipping requires atomically debiting inventory — which needs the
  movement ledger that was deferred from P5.3. The pair of chunks
  (movement ledger + sales-order shipping flow) is the natural next
  combination.
* **Multi-currency lines**. The schema column is per-line but the
  service enforces all-lines-match-header in v1. Relaxing this is a
  service-only change.
* **Quotes** (DRAFT-but-customer-visible) and **deliveries** (the
  thing that triggers shipping). v1 only models the order itself.
* **Pricing engine / discounts**. v1 takes the unit price the caller
  sends. A real ERP has a price book lookup, customer-specific
  pricing, volume discounts, promotional pricing — all of which slot
  in BEFORE the line price is set, leaving the schema unchanged.
* **Tax**. v1 totals are pre-tax. Tax calculation is its own PBC
  (and a regulatory minefield) that lands later.
CLAUDE.md
@@ -95,10 +95,10 @@ plugins (incl. ref) depend on: api/api-v1 only @@ -95,10 +95,10 @@ plugins (incl. ref) depend on: api/api-v1 only
95 95
96 **Foundation complete; first business surface in place.** As of the latest commit: 96 **Foundation complete; first business surface in place.** As of the latest commit:
97 97
98 -- **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`.  
99 -- **139 unit tests across 14 modules**, all green. `./gradlew build` is the canonical full build. 98 +- **15 Gradle subprojects** built and tested. The dependency rule (PBCs never import each other; plug-ins only see `api.v1`) is enforced at configuration time by the root `build.gradle.kts`.
  99 +- **153 unit tests across 15 modules**, all green. `./gradlew build` is the canonical full build.
100 - **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. 100 - **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.
101 -- **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. 101 +- **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.
102 - **Reference printing-shop plug-in** owns its own DB schema, CRUDs plates and ink recipes via REST through `context.jdbc`, ships its own metadata YAML, and registers 7 HTTP endpoints. It is the executable acceptance test for the framework. 102 - **Reference printing-shop plug-in** owns its own DB schema, CRUDs plates and ink recipes via REST through `context.jdbc`, ships its own metadata YAML, and registers 7 HTTP endpoints. It is the executable acceptance test for the framework.
103 - **Package root** is `org.vibeerp`. 103 - **Package root** is `org.vibeerp`.
104 - **Build commands:** `./gradlew build` (full), `./gradlew test` (unit tests), `./gradlew :distribution:bootRun` (run dev profile, stages plug-in JAR automatically), `docker compose up -d db` (Postgres). The bootstrap admin password is printed to the application logs on first boot. 104 - **Build commands:** `./gradlew build` (full), `./gradlew test` (unit tests), `./gradlew :distribution:bootRun` (run dev profile, stages plug-in JAR automatically), `docker compose up -d db` (Postgres). The bootstrap admin password is printed to the application logs on first boot.
PROGRESS.md
@@ -10,27 +10,27 @@ @@ -10,27 +10,27 @@
10 10
11 | | | 11 | | |
12 |---|---| 12 |---|---|
13 -| **Latest version** | v0.10 (post-P5.3) |  
14 -| **Latest commit** | `f63a73d feat(pbc): P5.3 — pbc-inventory + first cross-PBC facade caller` | 13 +| **Latest version** | v0.11 (post-P5.5) |
  14 +| **Latest commit** | `feat(pbc): P5.5 — pbc-orders-sales + first PBC consuming TWO cross-PBC facades` |
15 | **Repo** | https://github.com/reporkey/vibe-erp | 15 | **Repo** | https://github.com/reporkey/vibe-erp |
16 -| **Modules** | 14 |  
17 -| **Unit tests** | 139, all green |  
18 -| **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 |  
19 -| **Real PBCs implemented** | 4 of 10 (`pbc-identity`, `pbc-catalog`, `pbc-partners`, `pbc-inventory`) | 16 +| **Modules** | 15 |
  17 +| **Unit tests** | 153, all green |
  18 +| **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 |
  19 +| **Real PBCs implemented** | 5 of 10 (`pbc-identity`, `pbc-catalog`, `pbc-partners`, `pbc-inventory`, `pbc-orders-sales`) |
20 | **Plug-ins serving HTTP** | 1 (reference printing-shop, 7 endpoints + own DB schema + own metadata + own i18n bundles) | 20 | **Plug-ins serving HTTP** | 1 (reference printing-shop, 7 endpoints + own DB schema + own metadata + own i18n bundles) |
21 | **Production-ready?** | **No.** Foundation is solid; many capabilities still deferred. | 21 | **Production-ready?** | **No.** Foundation is solid; many capabilities still deferred. |
22 22
23 ## Current stage 23 ## Current stage
24 24
25 -**Foundation complete; Tier 1 customization live; 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. 25 +**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.
26 26
27 -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. 27 +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.
28 28
29 ## Total scope (the v1.0 cut line) 29 ## Total scope (the v1.0 cut line)
30 30
31 The framework reaches v1.0 when, on a fresh Postgres, an operator can `docker run` the image, log in, drop a customer plug-in JAR into `./plugins/`, restart, and walk a real workflow end-to-end without writing any code — and the **same** image, against a different Postgres pointed at a different company, also serves a different customer with a different plug-in. See the implementation plan for the full v1.0 acceptance bar. 31 The framework reaches v1.0 when, on a fresh Postgres, an operator can `docker run` the image, log in, drop a customer plug-in JAR into `./plugins/`, restart, and walk a real workflow end-to-end without writing any code — and the **same** image, against a different Postgres pointed at a different company, also serves a different customer with a different plug-in. See the implementation plan for the full v1.0 acceptance bar.
32 32
33 -That target breaks down into roughly 30 work units across 8 phases. About **19 are done** as of today. Below is the full list with status. 33 +That target breaks down into roughly 30 work units across 8 phases. About **20 are done** as of today. Below is the full list with status.
34 34
35 ### Phase 1 — Platform completion (foundation) 35 ### Phase 1 — Platform completion (foundation)
36 36
@@ -82,7 +82,7 @@ That target breaks down into roughly 30 work units across 8 phases. About **19 a @@ -82,7 +82,7 @@ That target breaks down into roughly 30 work units across 8 phases. About **19 a
82 | P5.2 | `pbc-partners` — customers, suppliers, contacts, addresses | ✅ DONE — `3e40cae` | 82 | P5.2 | `pbc-partners` — customers, suppliers, contacts, addresses | ✅ DONE — `3e40cae` |
83 | P5.3 | `pbc-inventory` — locations + stock balances (movements deferred) | ✅ DONE — `f63a73d` | 83 | P5.3 | `pbc-inventory` — locations + stock balances (movements deferred) | ✅ DONE — `f63a73d` |
84 | P5.4 | `pbc-warehousing` — receipts, picks, transfers, counts | 🔜 Pending | 84 | P5.4 | `pbc-warehousing` — receipts, picks, transfers, counts | 🔜 Pending |
85 -| P5.5 | `pbc-orders-sales` — quotes, sales orders, deliveries | 🔜 Pending | 85 +| P5.5 | `pbc-orders-sales` — sales orders + lines (deliveries deferred) | ✅ DONE — `<this commit>` |
86 | P5.6 | `pbc-orders-purchase` — RFQs, POs, receipts | 🔜 Pending | 86 | P5.6 | `pbc-orders-purchase` — RFQs, POs, receipts | 🔜 Pending |
87 | P5.7 | `pbc-production` — work orders, routings, operations | 🔜 Pending | 87 | P5.7 | `pbc-production` — work orders, routings, operations | 🔜 Pending |
88 | P5.8 | `pbc-quality` — inspection plans, results, holds | 🔜 Pending | 88 | P5.8 | `pbc-quality` — inspection plans, results, holds | 🔜 Pending |
@@ -128,7 +128,7 @@ These are the cross-cutting platform services already wired into the running fra @@ -128,7 +128,7 @@ These are the cross-cutting platform services already wired into the running fra
128 | **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. | 128 | **Metadata loader** (P1.5) | `platform-metadata` | Walks the host classpath and each plug-in JAR for `META-INF/vibe-erp/metadata/*.yml`, upserts entities/permissions/menus into `metadata__*` tables tagged by source. Idempotent (delete-by-source then insert). User-edited metadata (`source='user'`) is never touched. Public `GET /api/v1/_meta/metadata` returns the full set for SPA + AI-agent + OpenAPI introspection. |
129 | **i18n / Translator** (P1.6) | `platform-i18n` | ICU4J-backed `Translator` with named placeholders, plurals, gender, locale-aware number/date formatting. Per-plug-in instance scoped via a `(classLoader, baseName)` chain — plug-in's `META-INF/vibe-erp/i18n/messages_<locale>.properties` resolves before the host's `messages_<locale>.properties` for shared keys. `RequestLocaleProvider` reads `Accept-Language` from the active HTTP request via the servlet container, falling back to `vibeerp.i18n.defaultLocale` outside an HTTP context. JVM-default locale fallback explicitly disabled to prevent silent locale leaks. | 129 | **i18n / Translator** (P1.6) | `platform-i18n` | ICU4J-backed `Translator` with named placeholders, plurals, gender, locale-aware number/date formatting. Per-plug-in instance scoped via a `(classLoader, baseName)` chain — plug-in's `META-INF/vibe-erp/i18n/messages_<locale>.properties` resolves before the host's `messages_<locale>.properties` for shared keys. `RequestLocaleProvider` reads `Accept-Language` from the active HTTP request via the servlet container, falling back to `vibeerp.i18n.defaultLocale` outside an HTTP context. JVM-default locale fallback explicitly disabled to prevent silent locale leaks. |
130 | **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. | 130 | **Custom field application** (P3.4) | `platform-metadata.customfield` | `CustomFieldRegistry` reads `metadata__custom_field` rows into an in-memory index keyed by entity name, refreshed at boot and after every plug-in load. `ExtJsonValidator` validates the JSONB `ext` map of any entity against the declared `FieldType`s — String maxLength, Integer/Decimal numeric coercion with precision/scale enforcement, Boolean, Date/DateTime ISO-8601 parsing, Enum allowed values, UUID format. Unknown keys are rejected; required missing fields are rejected; ALL violations are returned in a single 400 so a form submitter fixes everything in one round-trip. `Partner` is the first PBC entity to wire ext through `PartnerService.create/update`; the public `GET /api/v1/_meta/metadata/custom-fields/{entityName}` endpoint serves the api.v1 runtime view of declarations to the SPA / OpenAPI / AI agent. |
131 -| **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/<pbc>/<resource>` → cross-PBC facade in `api.v1.ext.<pbc>` → adapter implementation. Architecture rule enforced by the Gradle build: PBCs never import each other, never import `platform-bootstrap`. **pbc-inventory** is 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. | 131 +| **PBC pattern** (P5.x recipe) | `pbc-identity`, `pbc-catalog`, `pbc-partners`, `pbc-inventory`, `pbc-orders-sales` | Five real PBCs prove the recipe across five aggregate shapes (single-entity user, two-entity catalog, parent-with-children partners, master-data+facts inventory, header+lines sales orders with state machine): domain entity extending `AuditedJpaEntity` → Spring Data JPA repository → application service → REST controller under `/api/v1/<pbc>/<resource>` → cross-PBC facade in `api.v1.ext.<pbc>` → adapter implementation. Architecture rule enforced by the Gradle build: PBCs never import each other, never import `platform-bootstrap`. **pbc-inventory** is the first PBC to *consume* one cross-PBC facade (`CatalogApi`); **pbc-orders-sales** is the first to consume *two* simultaneously (`PartnersApi` + `CatalogApi`) in a single transaction, proving the modular monolith works under realistic workload. |
132 132
133 ## What the reference plug-in proves end-to-end 133 ## What the reference plug-in proves end-to-end
134 134
@@ -170,8 +170,9 @@ This is **real**: a JAR file dropped into a directory, loaded by the framework, @@ -170,8 +170,9 @@ This is **real**: a JAR file dropped into a directory, loaded by the framework,
170 - **Job scheduler.** No Quartz. Periodic jobs don't have a home. 170 - **Job scheduler.** No Quartz. Periodic jobs don't have a home.
171 - **OIDC.** Built-in JWT only. OIDC client (Keycloak-compatible) is P4.2. 171 - **OIDC.** Built-in JWT only. OIDC client (Keycloak-compatible) is P4.2.
172 - **Permission checks.** `Principal` is bound; `metadata__permission` is seeded; the actual `@RequirePermission` enforcement layer is P4.3. 172 - **Permission checks.** `Principal` is bound; `metadata__permission` is seeded; the actual `@RequirePermission` enforcement layer is P4.3.
173 -- **More PBCs.** Identity, catalog, partners and inventory exist. Warehousing, orders, production, quality, finance are all pending.  
174 -- **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. 173 +- **More PBCs.** Identity, catalog, partners, inventory and orders-sales exist. Warehousing, orders-purchase, production, quality, finance are all pending.
  174 +- **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.
  175 +- **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.
175 - **Web SPA.** No React app. The framework is API-only today. 176 - **Web SPA.** No React app. The framework is API-only today.
176 - **MCP server.** The architecture leaves room for it; the implementation is v1.1. 177 - **MCP server.** The architecture leaves room for it; the implementation is v1.1.
177 - **Mobile.** v2. 178 - **Mobile.** v2.
@@ -216,7 +217,9 @@ pbc/pbc-identity User entity end-to-end + auth + bootstrap admin @@ -216,7 +217,9 @@ pbc/pbc-identity User entity end-to-end + auth + bootstrap admin
216 pbc/pbc-catalog Item + Uom entities + cross-PBC CatalogApi facade 217 pbc/pbc-catalog Item + Uom entities + cross-PBC CatalogApi facade
217 pbc/pbc-partners Partner + Address + Contact entities + cross-PBC PartnersApi facade 218 pbc/pbc-partners Partner + Address + Contact entities + cross-PBC PartnersApi facade
218 pbc/pbc-inventory Location + StockBalance + cross-PBC InventoryApi facade 219 pbc/pbc-inventory Location + StockBalance + cross-PBC InventoryApi facade
219 - (FIRST PBC to also CONSUME another PBC's facade — CatalogApi) 220 + (first PBC to CONSUME another PBC's facade — CatalogApi)
  221 +pbc/pbc-orders-sales SalesOrder + SalesOrderLine + cross-PBC SalesOrdersApi facade
  222 + (first PBC to CONSUME TWO facades simultaneously — PartnersApi + CatalogApi)
220 223
221 reference-customer/plugin-printing-shop 224 reference-customer/plugin-printing-shop
222 Reference plug-in: own DB schema (plate, ink_recipe), 225 Reference plug-in: own DB schema (plate, ink_recipe),
@@ -225,7 +228,7 @@ reference-customer/plugin-printing-shop @@ -225,7 +228,7 @@ reference-customer/plugin-printing-shop
225 distribution Bootable Spring Boot fat-jar assembly 228 distribution Bootable Spring Boot fat-jar assembly
226 ``` 229 ```
227 230
228 -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. 231 +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.
229 232
230 ## Where to look next 233 ## Where to look next
231 234
README.md
@@ -77,7 +77,7 @@ vibe-erp/ @@ -77,7 +77,7 @@ vibe-erp/
77 ## Building 77 ## Building
78 78
79 ```bash 79 ```bash
80 -# Build everything (compiles 14 modules, runs 139 unit tests) 80 +# Build everything (compiles 15 modules, runs 153 unit tests)
81 ./gradlew build 81 ./gradlew build
82 82
83 # Bring up Postgres + the reference plug-in JAR 83 # Bring up Postgres + the reference plug-in JAR
@@ -96,9 +96,9 @@ The bootstrap admin password is printed to the application logs on first boot. A @@ -96,9 +96,9 @@ The bootstrap admin password is printed to the application logs on first boot. A
96 96
97 | | | 97 | | |
98 |---|---| 98 |---|---|
99 -| Modules | 14 |  
100 -| Unit tests | 139, all green |  
101 -| Real PBCs | 4 of 10 | 99 +| Modules | 15 |
  100 +| Unit tests | 153, all green |
  101 +| Real PBCs | 5 of 10 |
102 | Cross-cutting services live | 8 | 102 | Cross-cutting services live | 8 |
103 | Plug-ins serving HTTP | 1 (reference printing-shop) | 103 | Plug-ins serving HTTP | 1 (reference printing-shop) |
104 | Production-ready | **No** — workflow engine, forms, web SPA, more PBCs all still pending | 104 | Production-ready | **No** — workflow engine, forms, web SPA, more PBCs all still pending |
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/ext/orders/SalesOrdersApi.kt 0 → 100644
  1 +package org.vibeerp.api.v1.ext.orders
  2 +
  3 +import org.vibeerp.api.v1.core.Id
  4 +import java.math.BigDecimal
  5 +import java.time.LocalDate
  6 +
  7 +/**
  8 + * Cross-PBC facade for the sales orders bounded context.
  9 + *
  10 + * The fifth `api.v1.ext.*` package after identity, catalog, partners,
  11 + * and inventory. Sets up the next generation of consumers:
  12 + *
  13 + * - **pbc-production** will inject [SalesOrdersApi] to find the
  14 + * sales order a printing-shop work order is fulfilling.
  15 + * - **pbc-finance** (future) will inject it to generate invoices
  16 + * when an order is shipped.
  17 + * - **The reference printing-shop plug-in** will inject it to
  18 + * demonstrate the end-to-end "quote → confirmed order → job card"
  19 + * workflow that lives in the reference docs.
  20 + *
  21 + * **What this facade exposes:** the order header (code, status,
  22 + * partner reference, total) plus the lines as a flat list. Lines
  23 + * carry just enough for downstream PBCs to compute their own work
  24 + * (which item, how many, at what price).
  25 + *
  26 + * **What this facade does NOT expose** (deliberately):
  27 + * - Update / cancel / confirm operations. Other PBCs must NOT
  28 + * mutate sales orders directly — they react to events
  29 + * (`SalesOrderConfirmed`, `SalesOrderCancelled`) emitted via the
  30 + * event bus. Centralising the state machine in pbc-orders-sales
  31 + * keeps the order's invariants in one place.
  32 + * - The full audit/version history.
  33 + * - The `ext` JSONB. Custom-field reads cross the metadata system,
  34 + * not the cross-PBC facade.
  35 + * - List / search. Reporting against orders is its own concern;
  36 + * cross-PBC consumers ask "is THIS specific order real?", not
  37 + * "give me all orders matching X".
  38 + */
  39 +interface SalesOrdersApi {
  40 +
  41 + /**
  42 + * Look up a sales order by its unique code (e.g. "SO-2026-0001").
  43 + * Returns `null` when no such order exists. Cancelled orders ARE
  44 + * returned (the facade does not hide them) because downstream
  45 + * consumers may legitimately need to react to a cancellation —
  46 + * for example, to release a previously-reserved production slot.
  47 + */
  48 + fun findByCode(code: String): SalesOrderRef?
  49 +
  50 + /**
  51 + * Look up a sales order by its primary-key id. Same semantics as
  52 + * [findByCode] — cancelled orders are visible.
  53 + */
  54 + fun findById(id: Id<SalesOrderRef>): SalesOrderRef?
  55 +}
  56 +
  57 +/**
  58 + * Minimal, safe-to-publish view of a sales order.
  59 + */
  60 +data class SalesOrderRef(
  61 + val id: Id<SalesOrderRef>,
  62 + val code: String,
  63 + val partnerCode: String,
  64 + val status: String, // DRAFT | CONFIRMED | CANCELLED — string for plug-in compatibility
  65 + val orderDate: LocalDate,
  66 + val currencyCode: String,
  67 + val totalAmount: BigDecimal,
  68 + val lines: List<SalesOrderLineRef>,
  69 +)
  70 +
  71 +/**
  72 + * Minimal view of one sales order line.
  73 + *
  74 + * No `id` field — lines are addressed via their parent order. The
  75 + * line number is the natural identifier within an order.
  76 + */
  77 +data class SalesOrderLineRef(
  78 + val lineNo: Int,
  79 + val itemCode: String,
  80 + val quantity: BigDecimal,
  81 + val unitPrice: BigDecimal,
  82 + val currencyCode: String,
  83 +)
distribution/build.gradle.kts
@@ -30,6 +30,7 @@ dependencies { @@ -30,6 +30,7 @@ dependencies {
30 implementation(project(":pbc:pbc-catalog")) 30 implementation(project(":pbc:pbc-catalog"))
31 implementation(project(":pbc:pbc-partners")) 31 implementation(project(":pbc:pbc-partners"))
32 implementation(project(":pbc:pbc-inventory")) 32 implementation(project(":pbc:pbc-inventory"))
  33 + implementation(project(":pbc:pbc-orders-sales"))
33 34
34 implementation(libs.spring.boot.starter) 35 implementation(libs.spring.boot.starter)
35 implementation(libs.spring.boot.starter.web) 36 implementation(libs.spring.boot.starter.web)
distribution/src/main/resources/db/changelog/master.xml
@@ -17,4 +17,5 @@ @@ -17,4 +17,5 @@
17 <include file="classpath:db/changelog/pbc-catalog/001-catalog-init.xml"/> 17 <include file="classpath:db/changelog/pbc-catalog/001-catalog-init.xml"/>
18 <include file="classpath:db/changelog/pbc-partners/001-partners-init.xml"/> 18 <include file="classpath:db/changelog/pbc-partners/001-partners-init.xml"/>
19 <include file="classpath:db/changelog/pbc-inventory/001-inventory-init.xml"/> 19 <include file="classpath:db/changelog/pbc-inventory/001-inventory-init.xml"/>
  20 + <include file="classpath:db/changelog/pbc-orders-sales/001-orders-sales-init.xml"/>
20 </databaseChangeLog> 21 </databaseChangeLog>
distribution/src/main/resources/db/changelog/pbc-orders-sales/001-orders-sales-init.xml 0 → 100644
  1 +<?xml version="1.0" encoding="UTF-8"?>
  2 +<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
  3 + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  4 + xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
  5 + https://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.27.xsd">
  6 +
  7 + <!--
  8 + pbc-orders-sales initial schema (P5.5).
  9 +
  10 + Owns: orders_sales__sales_order, orders_sales__sales_order_line.
  11 +
  12 + vibe_erp is single-tenant per instance — no tenant_id columns,
  13 + no Row-Level Security policies.
  14 +
  15 + NEITHER table has a foreign key to:
  16 + • partners__partner (cross-PBC reference enforced by PartnersApi)
  17 + • catalog__item (cross-PBC reference enforced by CatalogApi)
  18 + A database FK across PBCs would couple their schemas at the
  19 + storage level, defeating the bounded-context rule.
  20 +
  21 + The line table HAS a foreign key to its parent order because
  22 + the relationship is intra-PBC and the framework's "PBCs own
  23 + their own tables" guarantee makes the FK safe.
  24 +
  25 + Conventions enforced for every business table in vibe_erp:
  26 + • UUID primary key
  27 + • Audit columns: created_at, created_by, updated_at, updated_by
  28 + • Optimistic-locking version column
  29 + • ext jsonb NOT NULL DEFAULT '{}' on aggregate root only
  30 + (sales order has it; lines do not — see SalesOrderLine doc)
  31 + -->
  32 +
  33 + <changeSet id="orders-sales-init-001" author="vibe_erp">
  34 + <comment>Create orders_sales__sales_order table (header)</comment>
  35 + <sql>
  36 + CREATE TABLE orders_sales__sales_order (
  37 + id uuid PRIMARY KEY,
  38 + code varchar(64) NOT NULL,
  39 + partner_code varchar(64) NOT NULL,
  40 + status varchar(16) NOT NULL,
  41 + order_date date NOT NULL,
  42 + currency_code varchar(3) NOT NULL,
  43 + total_amount numeric(18,4) NOT NULL DEFAULT 0,
  44 + ext jsonb NOT NULL DEFAULT '{}'::jsonb,
  45 + created_at timestamptz NOT NULL,
  46 + created_by varchar(128) NOT NULL,
  47 + updated_at timestamptz NOT NULL,
  48 + updated_by varchar(128) NOT NULL,
  49 + version bigint NOT NULL DEFAULT 0
  50 + );
  51 + CREATE UNIQUE INDEX orders_sales__sales_order_code_uk
  52 + ON orders_sales__sales_order (code);
  53 + CREATE INDEX orders_sales__sales_order_partner_idx
  54 + ON orders_sales__sales_order (partner_code);
  55 + CREATE INDEX orders_sales__sales_order_status_idx
  56 + ON orders_sales__sales_order (status);
  57 + CREATE INDEX orders_sales__sales_order_date_idx
  58 + ON orders_sales__sales_order (order_date);
  59 + CREATE INDEX orders_sales__sales_order_ext_gin
  60 + ON orders_sales__sales_order USING GIN (ext jsonb_path_ops);
  61 + </sql>
  62 + <rollback>
  63 + DROP TABLE orders_sales__sales_order;
  64 + </rollback>
  65 + </changeSet>
  66 +
  67 + <changeSet id="orders-sales-init-002" author="vibe_erp">
  68 + <comment>Create orders_sales__sales_order_line table (FK to header, no FK to catalog__item)</comment>
  69 + <sql>
  70 + CREATE TABLE orders_sales__sales_order_line (
  71 + id uuid PRIMARY KEY,
  72 + sales_order_id uuid NOT NULL REFERENCES orders_sales__sales_order(id) ON DELETE CASCADE,
  73 + line_no integer NOT NULL,
  74 + item_code varchar(64) NOT NULL,
  75 + quantity numeric(18,4) NOT NULL,
  76 + unit_price numeric(18,4) NOT NULL,
  77 + currency_code varchar(3) NOT NULL,
  78 + created_at timestamptz NOT NULL,
  79 + created_by varchar(128) NOT NULL,
  80 + updated_at timestamptz NOT NULL,
  81 + updated_by varchar(128) NOT NULL,
  82 + version bigint NOT NULL DEFAULT 0,
  83 + CONSTRAINT orders_sales__sales_order_line_qty_pos CHECK (quantity &gt; 0),
  84 + CONSTRAINT orders_sales__sales_order_line_price_nonneg CHECK (unit_price &gt;= 0)
  85 + );
  86 + CREATE UNIQUE INDEX orders_sales__sales_order_line_order_lineno_uk
  87 + ON orders_sales__sales_order_line (sales_order_id, line_no);
  88 + CREATE INDEX orders_sales__sales_order_line_item_idx
  89 + ON orders_sales__sales_order_line (item_code);
  90 + </sql>
  91 + <rollback>
  92 + DROP TABLE orders_sales__sales_order_line;
  93 + </rollback>
  94 + </changeSet>
  95 +
  96 +</databaseChangeLog>
pbc/pbc-orders-sales/build.gradle.kts 0 → 100644
  1 +plugins {
  2 + alias(libs.plugins.kotlin.jvm)
  3 + alias(libs.plugins.kotlin.spring)
  4 + alias(libs.plugins.kotlin.jpa)
  5 + alias(libs.plugins.spring.dependency.management)
  6 +}
  7 +
  8 +description = "vibe_erp pbc-orders-sales — sales order header + lines, three-way cross-PBC validation. INTERNAL Packaged Business Capability."
  9 +
  10 +java {
  11 + toolchain {
  12 + languageVersion.set(JavaLanguageVersion.of(21))
  13 + }
  14 +}
  15 +
  16 +kotlin {
  17 + jvmToolchain(21)
  18 + compilerOptions {
  19 + freeCompilerArgs.add("-Xjsr305=strict")
  20 + }
  21 +}
  22 +
  23 +allOpen {
  24 + annotation("jakarta.persistence.Entity")
  25 + annotation("jakarta.persistence.MappedSuperclass")
  26 + annotation("jakarta.persistence.Embeddable")
  27 +}
  28 +
  29 +// CRITICAL: pbc-orders-sales may depend on api-v1 (which exposes the
  30 +// cross-PBC PartnersApi, CatalogApi, and InventoryApi interfaces),
  31 +// platform-persistence, platform-security, and platform-metadata —
  32 +// but NEVER on platform-bootstrap, NEVER on another pbc-* (NOT on
  33 +// pbc-partners, pbc-catalog, OR pbc-inventory, even though we INJECT
  34 +// all three of their *Api interfaces at runtime).
  35 +//
  36 +// The cross-PBC concrete adapters live in their respective PBCs and
  37 +// are wired into the Spring context at runtime by the bootstrap
  38 +// @ComponentScan; this PBC sees only the api.v1 interfaces, exactly
  39 +// as guardrail #9 demands.
  40 +//
  41 +// pbc-orders-sales is the FIRST PBC to consume THREE cross-PBC
  42 +// facades simultaneously — the most rigorous test of the modular
  43 +// monolith story so far. The root build.gradle.kts enforces the
  44 +// dependency rule at configuration time.
  45 +dependencies {
  46 + api(project(":api:api-v1"))
  47 + implementation(project(":platform:platform-persistence"))
  48 + implementation(project(":platform:platform-security"))
  49 + implementation(project(":platform:platform-metadata")) // ExtJsonValidator (P3.4)
  50 +
  51 + implementation(libs.kotlin.stdlib)
  52 + implementation(libs.kotlin.reflect)
  53 +
  54 + implementation(libs.spring.boot.starter)
  55 + implementation(libs.spring.boot.starter.web)
  56 + implementation(libs.spring.boot.starter.data.jpa)
  57 + implementation(libs.spring.boot.starter.validation)
  58 + implementation(libs.jackson.module.kotlin)
  59 +
  60 + testImplementation(libs.spring.boot.starter.test)
  61 + testImplementation(libs.junit.jupiter)
  62 + testImplementation(libs.assertk)
  63 + testImplementation(libs.mockk)
  64 +}
  65 +
  66 +tasks.test {
  67 + useJUnitPlatform()
  68 +}
pbc/pbc-orders-sales/src/main/kotlin/org/vibeerp/pbc/orders/sales/application/SalesOrderService.kt 0 → 100644
  1 +package org.vibeerp.pbc.orders.sales.application
  2 +
  3 +import com.fasterxml.jackson.databind.ObjectMapper
  4 +import com.fasterxml.jackson.module.kotlin.registerKotlinModule
  5 +import org.springframework.stereotype.Service
  6 +import org.springframework.transaction.annotation.Transactional
  7 +import org.vibeerp.api.v1.ext.catalog.CatalogApi
  8 +import org.vibeerp.api.v1.ext.partners.PartnersApi
  9 +import org.vibeerp.pbc.orders.sales.domain.SalesOrder
  10 +import org.vibeerp.pbc.orders.sales.domain.SalesOrderLine
  11 +import org.vibeerp.pbc.orders.sales.domain.SalesOrderStatus
  12 +import org.vibeerp.pbc.orders.sales.infrastructure.SalesOrderJpaRepository
  13 +import org.vibeerp.platform.metadata.customfield.ExtJsonValidator
  14 +import java.math.BigDecimal
  15 +import java.time.LocalDate
  16 +import java.util.UUID
  17 +
  18 +/**
  19 + * Application service for sales order CRUD and state transitions.
  20 + *
  21 + * **The framework's first PBC that consumes TWO cross-PBC facades
  22 + * simultaneously**: it injects [PartnersApi] (to validate the
  23 + * customer) AND [CatalogApi] (to validate every line's item). This
  24 + * is the most rigorous test of the modular monolith story so far —
  25 + * pbc-orders-sales has no compile-time dependency on either
  26 + * pbc-partners or pbc-catalog (the Gradle build refuses such
  27 + * dependencies, see CLAUDE.md guardrail #9), but Spring DI wires the
  28 + * two interfaces to their concrete adapters at runtime.
  29 + *
  30 + * **What `create` validates** (in this order):
  31 + * 1. The order code is unique (existing rule).
  32 + * 2. The partner code resolves via [PartnersApi.findPartnerByCode].
  33 + * The facade hides inactive partners, so an inactive partner
  34 + * looks identical to a non-existent one — which is what we want
  35 + * for new orders. The partner's `type` must be CUSTOMER or BOTH;
  36 + * a SUPPLIER-only partner cannot be the customer of a sales
  37 + * order. (The reverse rule will guard pbc-orders-purchase later.)
  38 + * 3. Every line has at least one item, every line's `item_code`
  39 + * resolves via [CatalogApi.findItemByCode] (also hides inactive),
  40 + * every line has a positive quantity and a non-negative price.
  41 + * 4. Every line's currency matches the header currency. v1 enforces
  42 + * all-lines-match-header; the per-line currency column is in the
  43 + * schema for future relaxation without a migration.
  44 + * 5. The header total is RECOMPUTED from the lines (the caller's
  45 + * value, if any, is ignored). This is the cardinal rule of
  46 + * financial data: never trust an aggregate the caller sent you.
  47 + *
  48 + * **State machine** (enforced by [confirm] and [cancel]):
  49 + * - DRAFT → CONFIRMED (confirm)
  50 + * - DRAFT → CANCELLED (cancel from draft)
  51 + * - CONFIRMED → CANCELLED (cancel a confirmed order)
  52 + * - Anything else throws.
  53 + *
  54 + * **What `update` does**:
  55 + * - Allowed only when status is DRAFT. Once confirmed, the order is
  56 + * immutable except for cancellation. This is a hard rule because
  57 + * confirmed orders are typically printed, sent to the customer,
  58 + * or have downstream side effects (production scheduling, stock
  59 + * reservation) that an in-place edit would silently invalidate.
  60 + * - When line items are provided, they REPLACE the existing lines
  61 + * entirely (PUT semantics for lines, PATCH for header columns).
  62 + * Partial line edits are not modelled because the typical "edit
  63 + * this one line" UI gesture renders to a full re-send anyway.
  64 + */
  65 +@Service
  66 +@Transactional
  67 +class SalesOrderService(
  68 + private val orders: SalesOrderJpaRepository,
  69 + private val partnersApi: PartnersApi,
  70 + private val catalogApi: CatalogApi,
  71 + private val extValidator: ExtJsonValidator,
  72 +) {
  73 +
  74 + private val jsonMapper: ObjectMapper = ObjectMapper().registerKotlinModule()
  75 +
  76 + @Transactional(readOnly = true)
  77 + fun list(): List<SalesOrder> = orders.findAll()
  78 +
  79 + @Transactional(readOnly = true)
  80 + fun findById(id: UUID): SalesOrder? = orders.findById(id).orElse(null)
  81 +
  82 + @Transactional(readOnly = true)
  83 + fun findByCode(code: String): SalesOrder? = orders.findByCode(code)
  84 +
  85 + fun create(command: CreateSalesOrderCommand): SalesOrder {
  86 + require(!orders.existsByCode(command.code)) {
  87 + "sales order code '${command.code}' is already taken"
  88 + }
  89 +
  90 + // Cross-PBC validation #1: the customer must exist, be
  91 + // active, and play a CUSTOMER role.
  92 + val partner = partnersApi.findPartnerByCode(command.partnerCode)
  93 + ?: throw IllegalArgumentException(
  94 + "partner code '${command.partnerCode}' is not in the partners directory (or is inactive)",
  95 + )
  96 + require(partner.type == "CUSTOMER" || partner.type == "BOTH") {
  97 + "partner '${command.partnerCode}' is type ${partner.type} and cannot be the customer of a sales order"
  98 + }
  99 +
  100 + require(command.lines.isNotEmpty()) {
  101 + "sales order must have at least one line"
  102 + }
  103 + // Sanity-check line numbers are unique within the order.
  104 + val duplicateLineNos = command.lines.groupBy { it.lineNo }.filter { it.value.size > 1 }.keys
  105 + require(duplicateLineNos.isEmpty()) {
  106 + "duplicate line numbers in sales order: $duplicateLineNos"
  107 + }
  108 +
  109 + // Cross-PBC validation #2: every line's item must exist in
  110 + // the catalog and be active.
  111 + for (line in command.lines) {
  112 + require(line.quantity.signum() > 0) {
  113 + "line ${line.lineNo}: quantity must be positive (got ${line.quantity})"
  114 + }
  115 + require(line.unitPrice.signum() >= 0) {
  116 + "line ${line.lineNo}: unit price must be non-negative (got ${line.unitPrice})"
  117 + }
  118 + require(line.currencyCode == command.currencyCode) {
  119 + "line ${line.lineNo}: currency '${line.currencyCode}' does not match header currency '${command.currencyCode}'"
  120 + }
  121 + catalogApi.findItemByCode(line.itemCode)
  122 + ?: throw IllegalArgumentException(
  123 + "line ${line.lineNo}: item code '${line.itemCode}' is not in the catalog (or is inactive)",
  124 + )
  125 + }
  126 +
  127 + // Recompute the header total — the caller's value (if any)
  128 + // is intentionally ignored. Never trust a financial aggregate
  129 + // sent over the wire.
  130 + val total = command.lines.fold(BigDecimal.ZERO) { acc, line ->
  131 + acc + (line.quantity * line.unitPrice)
  132 + }
  133 +
  134 + val canonicalExt = extValidator.validate(ENTITY_NAME, command.ext)
  135 +
  136 + val order = SalesOrder(
  137 + code = command.code,
  138 + partnerCode = command.partnerCode,
  139 + status = SalesOrderStatus.DRAFT,
  140 + orderDate = command.orderDate,
  141 + currencyCode = command.currencyCode,
  142 + totalAmount = total,
  143 + ).also {
  144 + it.ext = jsonMapper.writeValueAsString(canonicalExt)
  145 + }
  146 + for (line in command.lines) {
  147 + order.lines += SalesOrderLine(
  148 + salesOrder = order,
  149 + lineNo = line.lineNo,
  150 + itemCode = line.itemCode,
  151 + quantity = line.quantity,
  152 + unitPrice = line.unitPrice,
  153 + currencyCode = line.currencyCode,
  154 + )
  155 + }
  156 + return orders.save(order)
  157 + }
  158 +
  159 + fun update(id: UUID, command: UpdateSalesOrderCommand): SalesOrder {
  160 + val order = orders.findById(id).orElseThrow {
  161 + NoSuchElementException("sales order not found: $id")
  162 + }
  163 + require(order.status == SalesOrderStatus.DRAFT) {
  164 + "cannot update sales order ${order.code} in status ${order.status}; only DRAFT orders are mutable"
  165 + }
  166 +
  167 + command.partnerCode?.let { newPartnerCode ->
  168 + val partner = partnersApi.findPartnerByCode(newPartnerCode)
  169 + ?: throw IllegalArgumentException(
  170 + "partner code '$newPartnerCode' is not in the partners directory (or is inactive)",
  171 + )
  172 + require(partner.type == "CUSTOMER" || partner.type == "BOTH") {
  173 + "partner '$newPartnerCode' is type ${partner.type} and cannot be the customer of a sales order"
  174 + }
  175 + order.partnerCode = newPartnerCode
  176 + }
  177 + command.orderDate?.let { order.orderDate = it }
  178 + command.currencyCode?.let { order.currencyCode = it }
  179 +
  180 + if (command.ext != null) {
  181 + order.ext = jsonMapper.writeValueAsString(extValidator.validate(ENTITY_NAME, command.ext))
  182 + }
  183 +
  184 + if (command.lines != null) {
  185 + require(command.lines.isNotEmpty()) {
  186 + "sales order must have at least one line"
  187 + }
  188 + val duplicateLineNos = command.lines.groupBy { it.lineNo }.filter { it.value.size > 1 }.keys
  189 + require(duplicateLineNos.isEmpty()) {
  190 + "duplicate line numbers in sales order: $duplicateLineNos"
  191 + }
  192 + for (line in command.lines) {
  193 + require(line.quantity.signum() > 0) {
  194 + "line ${line.lineNo}: quantity must be positive (got ${line.quantity})"
  195 + }
  196 + require(line.unitPrice.signum() >= 0) {
  197 + "line ${line.lineNo}: unit price must be non-negative (got ${line.unitPrice})"
  198 + }
  199 + require(line.currencyCode == order.currencyCode) {
  200 + "line ${line.lineNo}: currency '${line.currencyCode}' does not match header currency '${order.currencyCode}'"
  201 + }
  202 + catalogApi.findItemByCode(line.itemCode)
  203 + ?: throw IllegalArgumentException(
  204 + "line ${line.lineNo}: item code '${line.itemCode}' is not in the catalog (or is inactive)",
  205 + )
  206 + }
  207 + // Replace lines wholesale: clear (orphanRemoval will
  208 + // delete the old rows), add the new ones, recompute total.
  209 + order.lines.clear()
  210 + for (line in command.lines) {
  211 + order.lines += SalesOrderLine(
  212 + salesOrder = order,
  213 + lineNo = line.lineNo,
  214 + itemCode = line.itemCode,
  215 + quantity = line.quantity,
  216 + unitPrice = line.unitPrice,
  217 + currencyCode = line.currencyCode,
  218 + )
  219 + }
  220 + order.totalAmount = order.lines.fold(BigDecimal.ZERO) { acc, l -> acc + l.lineTotal }
  221 + }
  222 + return order
  223 + }
  224 +
  225 + fun confirm(id: UUID): SalesOrder {
  226 + val order = orders.findById(id).orElseThrow {
  227 + NoSuchElementException("sales order not found: $id")
  228 + }
  229 + require(order.status == SalesOrderStatus.DRAFT) {
  230 + "cannot confirm sales order ${order.code} in status ${order.status}; only DRAFT can be confirmed"
  231 + }
  232 + order.status = SalesOrderStatus.CONFIRMED
  233 + return order
  234 + }
  235 +
  236 + fun cancel(id: UUID): SalesOrder {
  237 + val order = orders.findById(id).orElseThrow {
  238 + NoSuchElementException("sales order not found: $id")
  239 + }
  240 + require(order.status != SalesOrderStatus.CANCELLED) {
  241 + "sales order ${order.code} is already cancelled"
  242 + }
  243 + order.status = SalesOrderStatus.CANCELLED
  244 + return order
  245 + }
  246 +
  247 + @Suppress("UNCHECKED_CAST")
  248 + fun parseExt(order: SalesOrder): Map<String, Any?> = try {
  249 + if (order.ext.isBlank()) emptyMap()
  250 + else jsonMapper.readValue(order.ext, Map::class.java) as Map<String, Any?>
  251 + } catch (ex: Throwable) {
  252 + emptyMap()
  253 + }
  254 +
  255 + companion object {
  256 + const val ENTITY_NAME: String = "SalesOrder"
  257 + }
  258 +}
  259 +
  260 +data class CreateSalesOrderCommand(
  261 + val code: String,
  262 + val partnerCode: String,
  263 + val orderDate: LocalDate,
  264 + val currencyCode: String,
  265 + val lines: List<SalesOrderLineCommand>,
  266 + val ext: Map<String, Any?>? = null,
  267 +)
  268 +
  269 +data class UpdateSalesOrderCommand(
  270 + val partnerCode: String? = null,
  271 + val orderDate: LocalDate? = null,
  272 + val currencyCode: String? = null,
  273 + val lines: List<SalesOrderLineCommand>? = null,
  274 + val ext: Map<String, Any?>? = null,
  275 +)
  276 +
  277 +data class SalesOrderLineCommand(
  278 + val lineNo: Int,
  279 + val itemCode: String,
  280 + val quantity: BigDecimal,
  281 + val unitPrice: BigDecimal,
  282 + val currencyCode: String,
  283 +)
pbc/pbc-orders-sales/src/main/kotlin/org/vibeerp/pbc/orders/sales/domain/SalesOrder.kt 0 → 100644
  1 +package org.vibeerp.pbc.orders.sales.domain
  2 +
  3 +import jakarta.persistence.CascadeType
  4 +import jakarta.persistence.Column
  5 +import jakarta.persistence.Entity
  6 +import jakarta.persistence.EnumType
  7 +import jakarta.persistence.Enumerated
  8 +import jakarta.persistence.FetchType
  9 +import jakarta.persistence.JoinColumn
  10 +import jakarta.persistence.OneToMany
  11 +import jakarta.persistence.OrderBy
  12 +import jakarta.persistence.Table
  13 +import org.hibernate.annotations.JdbcTypeCode
  14 +import org.hibernate.type.SqlTypes
  15 +import org.vibeerp.platform.persistence.audit.AuditedJpaEntity
  16 +import java.math.BigDecimal
  17 +import java.time.LocalDate
  18 +
  19 +/**
  20 + * The header of a sales order.
  21 + *
  22 + * pbc-orders-sales is the framework's first **business workflow PBC**:
  23 + * unlike identity, catalog, partners and inventory (which are master
  24 + * data + facts), this PBC carries a state machine and references
  25 + * across THREE other PBCs at create time. Every cross-PBC reference
  26 + * goes through `api.v1.ext.<pbc>` interfaces — see
  27 + * [SalesOrderService] for the resolution chain.
  28 + *
  29 + * **Why a string `partner_code` and a string `currency_code`** instead
  30 + * of UUID FKs:
  31 + * - The partner reference is **cross-PBC**. There can be no foreign
  32 + * key to `partners__partner` because pbc-orders-sales has no
  33 + * compile-time dependency on pbc-partners (CLAUDE.md guardrail #9).
  34 + * The link to "this is a real customer" is enforced at the
  35 + * application layer through `PartnersApi.findPartnerByCode`.
  36 + * - The currency code is the natural key (USD, EUR, CNY, JPY, GBP).
  37 + * Currencies are stable identifiers that survive every database
  38 + * re-import; modelling them as a varchar makes seed data trivial
  39 + * and the rows readable.
  40 + *
  41 + * **Why a denormalised `total_amount` column** instead of always
  42 + * computing from the lines:
  43 + * - Sales orders are read at scale by reporting, by the SPA's list
  44 + * view, and by future commission/incentive jobs. Recomputing the
  45 + * total on every read is wasteful when it changes only on
  46 + * create/update.
  47 + * - The total IS recomputed by the service on every save (the
  48 + * caller's claimed total is ignored), so it cannot drift from the
  49 + * line sum.
  50 + * - The total is stored as `numeric(18, 4)` so the typical 2-digit
  51 + * currency precision rounds correctly. Currency-aware rounding is
  52 + * delegated to the `Money` value object in api.v1 at the boundary.
  53 + *
  54 + * **Why the status set is small** (DRAFT / CONFIRMED / CANCELLED):
  55 + * - These three are universal. Every shop has a "saving but not yet
  56 + * sent to the customer" state, a "this is committed" state, and a
  57 + * "we're not doing this" state. Anything more granular
  58 + * (PARTIALLY_SHIPPED, BACKORDERED, RETURNED, …) requires the
  59 + * movement ledger and shipping flow that are deferred from v1.
  60 + * - The transition graph is enforced by `SalesOrderService`:
  61 + * DRAFT → CONFIRMED, DRAFT → CANCELLED, CONFIRMED → CANCELLED.
  62 + * Anything else throws.
  63 + *
  64 + * The `lines` collection is loaded eagerly because every read of an
  65 + * order header is followed in practice by a read of the lines (the
  66 + * SPA shows them, the reporting jobs sum them, the cancel-confirm
  67 + * flow uses them). Lazy loading would force a JOIN-N+1 every time
  68 + * for no benefit at this scale.
  69 + */
  70 +@Entity
  71 +@Table(name = "orders_sales__sales_order")
  72 +class SalesOrder(
  73 + code: String,
  74 + partnerCode: String,
  75 + status: SalesOrderStatus = SalesOrderStatus.DRAFT,
  76 + orderDate: LocalDate,
  77 + currencyCode: String,
  78 + totalAmount: BigDecimal = BigDecimal.ZERO,
  79 +) : AuditedJpaEntity() {
  80 +
  81 + @Column(name = "code", nullable = false, length = 64)
  82 + var code: String = code
  83 +
  84 + @Column(name = "partner_code", nullable = false, length = 64)
  85 + var partnerCode: String = partnerCode
  86 +
  87 + @Enumerated(EnumType.STRING)
  88 + @Column(name = "status", nullable = false, length = 16)
  89 + var status: SalesOrderStatus = status
  90 +
  91 + @Column(name = "order_date", nullable = false)
  92 + var orderDate: LocalDate = orderDate
  93 +
  94 + @Column(name = "currency_code", nullable = false, length = 3)
  95 + var currencyCode: String = currencyCode
  96 +
  97 + @Column(name = "total_amount", nullable = false, precision = 18, scale = 4)
  98 + var totalAmount: BigDecimal = totalAmount
  99 +
  100 + @Column(name = "ext", nullable = false, columnDefinition = "jsonb")
  101 + @JdbcTypeCode(SqlTypes.JSON)
  102 + var ext: String = "{}"
  103 +
  104 + /**
  105 + * The order's line items, eagerly loaded. Cascade ALL because
  106 + * lines have no independent existence — they belong to exactly
  107 + * one order, are created with the order, and are deleted with the
  108 + * order. orphanRemoval keeps the collection consistent when a
  109 + * caller drops a line by removing it from this list.
  110 + */
  111 + @OneToMany(
  112 + mappedBy = "salesOrder",
  113 + cascade = [CascadeType.ALL],
  114 + orphanRemoval = true,
  115 + fetch = FetchType.EAGER,
  116 + )
  117 + @OrderBy("lineNo ASC")
  118 + var lines: MutableList<SalesOrderLine> = mutableListOf()
  119 +
  120 + override fun toString(): String =
  121 + "SalesOrder(id=$id, code='$code', partner='$partnerCode', status=$status, total=$totalAmount $currencyCode)"
  122 +}
  123 +
  124 +/**
  125 + * Possible states a [SalesOrder] can be in.
  126 + *
  127 + * Stored as a string in the DB so adding values later is non-breaking
  128 + * for clients reading the column with raw SQL. Future expansion
  129 + * (PARTIALLY_SHIPPED, SHIPPED, INVOICED, CLOSED) requires the
  130 + * movement ledger and shipping flow which are deferred from v1.
  131 + *
  132 + * - **DRAFT** — being prepared, lines may still be edited
  133 + * - **CONFIRMED** — committed; lines are now immutable
  134 + * - **CANCELLED** — terminal; the order is dead
  135 + */
  136 +enum class SalesOrderStatus {
  137 + DRAFT,
  138 + CONFIRMED,
  139 + CANCELLED,
  140 +}
pbc/pbc-orders-sales/src/main/kotlin/org/vibeerp/pbc/orders/sales/domain/SalesOrderLine.kt 0 → 100644
  1 +package org.vibeerp.pbc.orders.sales.domain
  2 +
  3 +import jakarta.persistence.Column
  4 +import jakarta.persistence.Entity
  5 +import jakarta.persistence.JoinColumn
  6 +import jakarta.persistence.ManyToOne
  7 +import jakarta.persistence.Table
  8 +import org.vibeerp.platform.persistence.audit.AuditedJpaEntity
  9 +import java.math.BigDecimal
  10 +
  11 +/**
  12 + * One line item of a [SalesOrder].
  13 + *
  14 + * **Why `item_code` is a varchar and not a UUID FK to catalog__item.id:**
  15 + * Same reason `partner_code` on the header is a string — pbc-orders-sales
  16 + * has NO compile-time dependency on pbc-catalog (CLAUDE.md guardrail #9).
  17 + * The link to "this is a real catalog item" is enforced at the
  18 + * application layer through `org.vibeerp.api.v1.ext.catalog.CatalogApi`
  19 + * at create time. Storing the code instead of the UUID makes the row
  20 + * legible without a join and survives the catalog being re-imported
  21 + * from scratch.
  22 + *
  23 + * **Why `unit_price` and `currency_code` live ON the line** instead
  24 + * of being normalised to a separate price book:
  25 + * - At order time, the price you actually charged the customer is
  26 + * a fact that survives every future price book change. The line
  27 + * is a snapshot of "we sold X units at Y per unit on this day",
  28 + * and the line's price NEVER changes after the order is
  29 + * confirmed.
  30 + * - The `currency_code` exists per-line (not just per-header) so
  31 + * a future multi-currency order can quote some lines in USD and
  32 + * some in CNY. v1 enforces all-lines-match-header-currency, but
  33 + * the column is in the schema so the rule can relax later
  34 + * without a migration.
  35 + *
  36 + * **Why no `ext` JSONB on the line:** lines are facts, not master
  37 + * records. Tier 1 customisation belongs on the order header (where
  38 + * the customer-specific fields live) and on the catalog item itself
  39 + * (which is the natural place for "what extra attributes does this
  40 + * SKU have?"). Adding ext on the line would be the kind of
  41 + * customisability foot-gun the framework refuses by design — see
  42 + * the StockBalance rationale.
  43 + *
  44 + * The line itself extends [AuditedJpaEntity] for the audit columns
  45 + * but the values matter less than they do on the header — most lines
  46 + * are created and updated as part of one save on the parent order.
  47 + */
  48 +@Entity
  49 +@Table(name = "orders_sales__sales_order_line")
  50 +class SalesOrderLine(
  51 + salesOrder: SalesOrder,
  52 + lineNo: Int,
  53 + itemCode: String,
  54 + quantity: BigDecimal,
  55 + unitPrice: BigDecimal,
  56 + currencyCode: String,
  57 +) : AuditedJpaEntity() {
  58 +
  59 + @ManyToOne
  60 + @JoinColumn(name = "sales_order_id", nullable = false)
  61 + var salesOrder: SalesOrder = salesOrder
  62 +
  63 + @Column(name = "line_no", nullable = false)
  64 + var lineNo: Int = lineNo
  65 +
  66 + @Column(name = "item_code", nullable = false, length = 64)
  67 + var itemCode: String = itemCode
  68 +
  69 + @Column(name = "quantity", nullable = false, precision = 18, scale = 4)
  70 + var quantity: BigDecimal = quantity
  71 +
  72 + @Column(name = "unit_price", nullable = false, precision = 18, scale = 4)
  73 + var unitPrice: BigDecimal = unitPrice
  74 +
  75 + @Column(name = "currency_code", nullable = false, length = 3)
  76 + var currencyCode: String = currencyCode
  77 +
  78 + /**
  79 + * Line subtotal: quantity × unit_price. Computed at read time
  80 + * because the inputs are both stored and the result is just a
  81 + * convenience for callers — keeping it OUT of the database means
  82 + * the storage cannot get out of sync with the inputs.
  83 + */
  84 + val lineTotal: BigDecimal
  85 + get() = quantity.multiply(unitPrice)
  86 +
  87 + override fun toString(): String =
  88 + "SalesOrderLine(id=$id, orderId=${salesOrder.id}, line=$lineNo, item='$itemCode', qty=$quantity × $unitPrice = $lineTotal $currencyCode)"
  89 +}
pbc/pbc-orders-sales/src/main/kotlin/org/vibeerp/pbc/orders/sales/ext/SalesOrdersApiAdapter.kt 0 → 100644
  1 +package org.vibeerp.pbc.orders.sales.ext
  2 +
  3 +import org.springframework.stereotype.Component
  4 +import org.springframework.transaction.annotation.Transactional
  5 +import org.vibeerp.api.v1.core.Id
  6 +import org.vibeerp.api.v1.ext.orders.SalesOrderLineRef
  7 +import org.vibeerp.api.v1.ext.orders.SalesOrderRef
  8 +import org.vibeerp.api.v1.ext.orders.SalesOrdersApi
  9 +import org.vibeerp.pbc.orders.sales.domain.SalesOrder
  10 +import org.vibeerp.pbc.orders.sales.domain.SalesOrderLine
  11 +import org.vibeerp.pbc.orders.sales.infrastructure.SalesOrderJpaRepository
  12 +
  13 +/**
  14 + * Concrete [SalesOrdersApi] implementation. The fifth `*ApiAdapter`
  15 + * after IdentityApiAdapter, CatalogApiAdapter, PartnersApiAdapter,
  16 + * and InventoryApiAdapter.
  17 + *
  18 + * Like the other adapters, this NEVER returns its own JPA entity
  19 + * types and NEVER reaches across PBC boundaries. The two methods
  20 + * walk the order header + lines once each and convert to the
  21 + * api.v1 [SalesOrderRef] / [SalesOrderLineRef] DTOs.
  22 + *
  23 + * Cancelled orders ARE returned by the facade (unlike the
  24 + * "inactive item / inactive partner = null" rule on those facades),
  25 + * because downstream consumers — production scheduling, invoicing,
  26 + * commission rollups — may legitimately need to react to a
  27 + * cancellation. The contract is documented on [SalesOrdersApi].
  28 + */
  29 +@Component
  30 +@Transactional(readOnly = true)
  31 +class SalesOrdersApiAdapter(
  32 + private val orders: SalesOrderJpaRepository,
  33 +) : SalesOrdersApi {
  34 +
  35 + override fun findByCode(code: String): SalesOrderRef? =
  36 + orders.findByCode(code)?.toRef()
  37 +
  38 + override fun findById(id: Id<SalesOrderRef>): SalesOrderRef? =
  39 + orders.findById(id.value).orElse(null)?.toRef()
  40 +
  41 + private fun SalesOrder.toRef(): SalesOrderRef = SalesOrderRef(
  42 + id = Id<SalesOrderRef>(this.id),
  43 + code = this.code,
  44 + partnerCode = this.partnerCode,
  45 + status = this.status.name,
  46 + orderDate = this.orderDate,
  47 + currencyCode = this.currencyCode,
  48 + totalAmount = this.totalAmount,
  49 + lines = this.lines.map { it.toRef() },
  50 + )
  51 +
  52 + private fun SalesOrderLine.toRef(): SalesOrderLineRef = SalesOrderLineRef(
  53 + lineNo = this.lineNo,
  54 + itemCode = this.itemCode,
  55 + quantity = this.quantity,
  56 + unitPrice = this.unitPrice,
  57 + currencyCode = this.currencyCode,
  58 + )
  59 +}
pbc/pbc-orders-sales/src/main/kotlin/org/vibeerp/pbc/orders/sales/http/SalesOrderController.kt 0 → 100644
  1 +package org.vibeerp.pbc.orders.sales.http
  2 +
  3 +import jakarta.validation.Valid
  4 +import jakarta.validation.constraints.NotBlank
  5 +import jakarta.validation.constraints.NotEmpty
  6 +import jakarta.validation.constraints.NotNull
  7 +import jakarta.validation.constraints.Size
  8 +import org.springframework.http.HttpStatus
  9 +import org.springframework.http.ResponseEntity
  10 +import org.springframework.web.bind.annotation.GetMapping
  11 +import org.springframework.web.bind.annotation.PatchMapping
  12 +import org.springframework.web.bind.annotation.PathVariable
  13 +import org.springframework.web.bind.annotation.PostMapping
  14 +import org.springframework.web.bind.annotation.RequestBody
  15 +import org.springframework.web.bind.annotation.RequestMapping
  16 +import org.springframework.web.bind.annotation.ResponseStatus
  17 +import org.springframework.web.bind.annotation.RestController
  18 +import org.vibeerp.pbc.orders.sales.application.CreateSalesOrderCommand
  19 +import org.vibeerp.pbc.orders.sales.application.SalesOrderLineCommand
  20 +import org.vibeerp.pbc.orders.sales.application.SalesOrderService
  21 +import org.vibeerp.pbc.orders.sales.application.UpdateSalesOrderCommand
  22 +import org.vibeerp.pbc.orders.sales.domain.SalesOrder
  23 +import org.vibeerp.pbc.orders.sales.domain.SalesOrderLine
  24 +import org.vibeerp.pbc.orders.sales.domain.SalesOrderStatus
  25 +import java.math.BigDecimal
  26 +import java.time.LocalDate
  27 +import java.util.UUID
  28 +
  29 +/**
  30 + * REST API for the sales orders PBC.
  31 + *
  32 + * Mounted at `/api/v1/orders/sales-orders`. Authenticated.
  33 + *
  34 + * The shape of POST is intentionally close to a "shopping cart"
  35 + * payload — header fields plus a line array — because that is what
  36 + * every SPA, every external integration, and every AI agent will
  37 + * naturally generate. Wire-format-friendly is the rule.
  38 + *
  39 + * State transitions live on dedicated POST endpoints rather than as
  40 + * status updates via PATCH:
  41 + * - `POST /{id}/confirm` is more honest than
  42 + * `PATCH /{id} {"status": "CONFIRMED"}` because the action has
  43 + * side effects (lines become immutable, downstream PBCs receive
  44 + * events in future versions). Sentinel-status writes hide that.
  45 + * - `POST /{id}/cancel` similarly.
  46 + */
  47 +@RestController
  48 +@RequestMapping("/api/v1/orders/sales-orders")
  49 +class SalesOrderController(
  50 + private val salesOrderService: SalesOrderService,
  51 +) {
  52 +
  53 + @GetMapping
  54 + fun list(): List<SalesOrderResponse> =
  55 + salesOrderService.list().map { it.toResponse(salesOrderService) }
  56 +
  57 + @GetMapping("/{id}")
  58 + fun get(@PathVariable id: UUID): ResponseEntity<SalesOrderResponse> {
  59 + val order = salesOrderService.findById(id) ?: return ResponseEntity.notFound().build()
  60 + return ResponseEntity.ok(order.toResponse(salesOrderService))
  61 + }
  62 +
  63 + @GetMapping("/by-code/{code}")
  64 + fun getByCode(@PathVariable code: String): ResponseEntity<SalesOrderResponse> {
  65 + val order = salesOrderService.findByCode(code) ?: return ResponseEntity.notFound().build()
  66 + return ResponseEntity.ok(order.toResponse(salesOrderService))
  67 + }
  68 +
  69 + @PostMapping
  70 + @ResponseStatus(HttpStatus.CREATED)
  71 + fun create(@RequestBody @Valid request: CreateSalesOrderRequest): SalesOrderResponse =
  72 + salesOrderService.create(
  73 + CreateSalesOrderCommand(
  74 + code = request.code,
  75 + partnerCode = request.partnerCode,
  76 + orderDate = request.orderDate,
  77 + currencyCode = request.currencyCode,
  78 + lines = request.lines.map { it.toCommand() },
  79 + ext = request.ext,
  80 + ),
  81 + ).toResponse(salesOrderService)
  82 +
  83 + @PatchMapping("/{id}")
  84 + fun update(
  85 + @PathVariable id: UUID,
  86 + @RequestBody @Valid request: UpdateSalesOrderRequest,
  87 + ): SalesOrderResponse =
  88 + salesOrderService.update(
  89 + id,
  90 + UpdateSalesOrderCommand(
  91 + partnerCode = request.partnerCode,
  92 + orderDate = request.orderDate,
  93 + currencyCode = request.currencyCode,
  94 + lines = request.lines?.map { it.toCommand() },
  95 + ext = request.ext,
  96 + ),
  97 + ).toResponse(salesOrderService)
  98 +
  99 + @PostMapping("/{id}/confirm")
  100 + fun confirm(@PathVariable id: UUID): SalesOrderResponse =
  101 + salesOrderService.confirm(id).toResponse(salesOrderService)
  102 +
  103 + @PostMapping("/{id}/cancel")
  104 + fun cancel(@PathVariable id: UUID): SalesOrderResponse =
  105 + salesOrderService.cancel(id).toResponse(salesOrderService)
  106 +}
  107 +
  108 +// ─── DTOs ────────────────────────────────────────────────────────────
  109 +
  110 +data class CreateSalesOrderRequest(
  111 + @field:NotBlank @field:Size(max = 64) val code: String,
  112 + @field:NotBlank @field:Size(max = 64) val partnerCode: String,
  113 + @field:NotNull val orderDate: LocalDate,
  114 + @field:NotBlank @field:Size(min = 3, max = 3) val currencyCode: String,
  115 + @field:NotEmpty @field:Valid val lines: List<SalesOrderLineRequest>,
  116 + val ext: Map<String, Any?>? = null,
  117 +)
  118 +
  119 +data class UpdateSalesOrderRequest(
  120 + @field:Size(max = 64) val partnerCode: String? = null,
  121 + val orderDate: LocalDate? = null,
  122 + @field:Size(min = 3, max = 3) val currencyCode: String? = null,
  123 + @field:Valid val lines: List<SalesOrderLineRequest>? = null,
  124 + val ext: Map<String, Any?>? = null,
  125 +)
  126 +
  127 +data class SalesOrderLineRequest(
  128 + @field:NotNull val lineNo: Int,
  129 + @field:NotBlank @field:Size(max = 64) val itemCode: String,
  130 + @field:NotNull val quantity: BigDecimal,
  131 + @field:NotNull val unitPrice: BigDecimal,
  132 + @field:NotBlank @field:Size(min = 3, max = 3) val currencyCode: String,
  133 +) {
  134 + fun toCommand(): SalesOrderLineCommand = SalesOrderLineCommand(
  135 + lineNo = lineNo,
  136 + itemCode = itemCode,
  137 + quantity = quantity,
  138 + unitPrice = unitPrice,
  139 + currencyCode = currencyCode,
  140 + )
  141 +}
  142 +
  143 +data class SalesOrderResponse(
  144 + val id: UUID,
  145 + val code: String,
  146 + val partnerCode: String,
  147 + val status: SalesOrderStatus,
  148 + val orderDate: LocalDate,
  149 + val currencyCode: String,
  150 + val totalAmount: BigDecimal,
  151 + val lines: List<SalesOrderLineResponse>,
  152 + val ext: Map<String, Any?>,
  153 +)
  154 +
  155 +data class SalesOrderLineResponse(
  156 + val id: UUID,
  157 + val lineNo: Int,
  158 + val itemCode: String,
  159 + val quantity: BigDecimal,
  160 + val unitPrice: BigDecimal,
  161 + val currencyCode: String,
  162 + val lineTotal: BigDecimal,
  163 +)
  164 +
  165 +private fun SalesOrder.toResponse(service: SalesOrderService) = SalesOrderResponse(
  166 + id = this.id,
  167 + code = this.code,
  168 + partnerCode = this.partnerCode,
  169 + status = this.status,
  170 + orderDate = this.orderDate,
  171 + currencyCode = this.currencyCode,
  172 + totalAmount = this.totalAmount,
  173 + lines = this.lines.map { it.toResponse() },
  174 + ext = service.parseExt(this),
  175 +)
  176 +
  177 +private fun SalesOrderLine.toResponse() = SalesOrderLineResponse(
  178 + id = this.id,
  179 + lineNo = this.lineNo,
  180 + itemCode = this.itemCode,
  181 + quantity = this.quantity,
  182 + unitPrice = this.unitPrice,
  183 + currencyCode = this.currencyCode,
  184 + lineTotal = this.lineTotal,
  185 +)
pbc/pbc-orders-sales/src/main/kotlin/org/vibeerp/pbc/orders/sales/infrastructure/SalesOrderJpaRepository.kt 0 → 100644
  1 +package org.vibeerp.pbc.orders.sales.infrastructure
  2 +
  3 +import org.springframework.data.jpa.repository.JpaRepository
  4 +import org.springframework.stereotype.Repository
  5 +import org.vibeerp.pbc.orders.sales.domain.SalesOrder
  6 +import java.util.UUID
  7 +
  8 +/**
  9 + * Spring Data JPA repository for [SalesOrder].
  10 + *
  11 + * Note: lookups by partner code or item code (the typical reporting
  12 + * shape) are NOT exposed here in v1. Reporting against sales orders
  13 + * is its own concern that needs read replicas, materialised views,
  14 + * or a separate analytics path. Adding query methods here would
  15 + * encourage on-the-fly aggregation against the live OLTP table,
  16 + * which is the path to pain.
  17 + */
  18 +@Repository
  19 +interface SalesOrderJpaRepository : JpaRepository<SalesOrder, UUID> {
  20 +
  21 + fun findByCode(code: String): SalesOrder?
  22 +
  23 + fun existsByCode(code: String): Boolean
  24 +}
pbc/pbc-orders-sales/src/main/resources/META-INF/vibe-erp/metadata/orders-sales.yml 0 → 100644
  1 +# pbc-orders-sales metadata.
  2 +#
  3 +# Loaded at boot by MetadataLoader, tagged source='core'.
  4 +
  5 +entities:
  6 + - name: SalesOrder
  7 + pbc: orders-sales
  8 + table: orders_sales__sales_order
  9 + description: A sales order header — customer, currency, total, status
  10 +
  11 + - name: SalesOrderLine
  12 + pbc: orders-sales
  13 + table: orders_sales__sales_order_line
  14 + description: One line item of a sales order (item × quantity × unit price)
  15 +
  16 +permissions:
  17 + - key: orders.sales.read
  18 + description: Read sales orders
  19 + - key: orders.sales.create
  20 + description: Create draft sales orders
  21 + - key: orders.sales.update
  22 + description: Update DRAFT sales orders (lines, partner, dates)
  23 + - key: orders.sales.confirm
  24 + description: Confirm a draft sales order (DRAFT → CONFIRMED)
  25 + - key: orders.sales.cancel
  26 + description: Cancel a sales order (any non-cancelled state → CANCELLED)
  27 +
  28 +menus:
  29 + - path: /orders/sales
  30 + label: Sales orders
  31 + icon: receipt
  32 + section: Sales
  33 + order: 500
pbc/pbc-orders-sales/src/test/kotlin/org/vibeerp/pbc/orders/sales/application/SalesOrderServiceTest.kt 0 → 100644
  1 +package org.vibeerp.pbc.orders.sales.application
  2 +
  3 +import assertk.assertFailure
  4 +import assertk.assertThat
  5 +import assertk.assertions.contains
  6 +import assertk.assertions.hasMessage
  7 +import assertk.assertions.hasSize
  8 +import assertk.assertions.isEqualTo
  9 +import assertk.assertions.isInstanceOf
  10 +import assertk.assertions.messageContains
  11 +import io.mockk.every
  12 +import io.mockk.mockk
  13 +import io.mockk.slot
  14 +import org.junit.jupiter.api.BeforeEach
  15 +import org.junit.jupiter.api.Test
  16 +import org.vibeerp.api.v1.core.Id
  17 +import org.vibeerp.api.v1.ext.catalog.CatalogApi
  18 +import org.vibeerp.api.v1.ext.catalog.ItemRef
  19 +import org.vibeerp.api.v1.ext.partners.PartnerRef
  20 +import org.vibeerp.api.v1.ext.partners.PartnersApi
  21 +import org.vibeerp.pbc.orders.sales.domain.SalesOrder
  22 +import org.vibeerp.pbc.orders.sales.domain.SalesOrderStatus
  23 +import org.vibeerp.pbc.orders.sales.infrastructure.SalesOrderJpaRepository
  24 +import org.vibeerp.platform.metadata.customfield.ExtJsonValidator
  25 +import java.math.BigDecimal
  26 +import java.time.LocalDate
  27 +import java.util.Optional
  28 +import java.util.UUID
  29 +
  30 +class SalesOrderServiceTest {
  31 +
  32 + private lateinit var orders: SalesOrderJpaRepository
  33 + private lateinit var partnersApi: PartnersApi
  34 + private lateinit var catalogApi: CatalogApi
  35 + private lateinit var extValidator: ExtJsonValidator
  36 + private lateinit var service: SalesOrderService
  37 +
  38 + @BeforeEach
  39 + fun setUp() {
  40 + orders = mockk()
  41 + partnersApi = mockk()
  42 + catalogApi = mockk()
  43 + extValidator = mockk()
  44 + every { extValidator.validate(any(), any()) } answers { secondArg() ?: emptyMap() }
  45 + every { orders.existsByCode(any()) } returns false
  46 + every { orders.save(any<SalesOrder>()) } answers { firstArg() }
  47 + service = SalesOrderService(orders, partnersApi, catalogApi, extValidator)
  48 + }
  49 +
  50 + private fun stubCustomer(code: String, type: String = "CUSTOMER") {
  51 + every { partnersApi.findPartnerByCode(code) } returns PartnerRef(
  52 + id = Id(UUID.randomUUID()),
  53 + code = code,
  54 + name = "Stub partner",
  55 + type = type,
  56 + taxId = null,
  57 + active = true,
  58 + )
  59 + }
  60 +
  61 + private fun stubItem(code: String) {
  62 + every { catalogApi.findItemByCode(code) } returns ItemRef(
  63 + id = Id(UUID.randomUUID()),
  64 + code = code,
  65 + name = "Stub item",
  66 + itemType = "GOOD",
  67 + baseUomCode = "ea",
  68 + active = true,
  69 + )
  70 + }
  71 +
  72 + private fun line(no: Int = 1, item: String = "PAPER-A4", qty: String = "10", price: String = "5.00") =
  73 + SalesOrderLineCommand(
  74 + lineNo = no,
  75 + itemCode = item,
  76 + quantity = BigDecimal(qty),
  77 + unitPrice = BigDecimal(price),
  78 + currencyCode = "USD",
  79 + )
  80 +
  81 + private fun command(
  82 + code: String = "SO-1",
  83 + partnerCode: String = "CUST-1",
  84 + currency: String = "USD",
  85 + lines: List<SalesOrderLineCommand> = listOf(line()),
  86 + ) = CreateSalesOrderCommand(
  87 + code = code,
  88 + partnerCode = partnerCode,
  89 + orderDate = LocalDate.of(2026, 4, 8),
  90 + currencyCode = currency,
  91 + lines = lines,
  92 + )
  93 +
  94 + @Test
  95 + fun `create rejects unknown partner via PartnersApi seam`() {
  96 + every { partnersApi.findPartnerByCode("FAKE") } returns null
  97 +
  98 + assertFailure { service.create(command(partnerCode = "FAKE")) }
  99 + .isInstanceOf(IllegalArgumentException::class)
  100 + .hasMessage("partner code 'FAKE' is not in the partners directory (or is inactive)")
  101 + }
  102 +
  103 + @Test
  104 + fun `create rejects SUPPLIER-only partner`() {
  105 + stubCustomer("SUP-1", type = "SUPPLIER")
  106 +
  107 + assertFailure { service.create(command(partnerCode = "SUP-1")) }
  108 + .isInstanceOf(IllegalArgumentException::class)
  109 + .messageContains("cannot be the customer of a sales order")
  110 + }
  111 +
  112 + @Test
  113 + fun `create accepts BOTH partner type`() {
  114 + stubCustomer("BOTH-1", type = "BOTH")
  115 + stubItem("PAPER-A4")
  116 +
  117 + val saved = service.create(command(partnerCode = "BOTH-1"))
  118 +
  119 + assertThat(saved.partnerCode).isEqualTo("BOTH-1")
  120 + }
  121 +
  122 + @Test
  123 + fun `create rejects unknown item via CatalogApi seam`() {
  124 + stubCustomer("CUST-1")
  125 + every { catalogApi.findItemByCode("FAKE-ITEM") } returns null
  126 +
  127 + assertFailure {
  128 + service.create(command(lines = listOf(line(item = "FAKE-ITEM"))))
  129 + }
  130 + .isInstanceOf(IllegalArgumentException::class)
  131 + .messageContains("item code 'FAKE-ITEM' is not in the catalog")
  132 + }
  133 +
  134 + @Test
  135 + fun `create rejects empty lines`() {
  136 + stubCustomer("CUST-1")
  137 +
  138 + assertFailure { service.create(command(lines = emptyList())) }
  139 + .isInstanceOf(IllegalArgumentException::class)
  140 + .hasMessage("sales order must have at least one line")
  141 + }
  142 +
  143 + @Test
  144 + fun `create rejects duplicate line numbers`() {
  145 + stubCustomer("CUST-1")
  146 + stubItem("PAPER-A4")
  147 +
  148 + assertFailure {
  149 + service.create(
  150 + command(lines = listOf(line(no = 1), line(no = 1, item = "PAPER-A4"))),
  151 + )
  152 + }
  153 + .isInstanceOf(IllegalArgumentException::class)
  154 + .messageContains("duplicate line numbers")
  155 + }
  156 +
  157 + @Test
  158 + fun `create rejects negative quantity before catalog lookup`() {
  159 + stubCustomer("CUST-1")
  160 +
  161 + assertFailure {
  162 + service.create(command(lines = listOf(line(qty = "-1"))))
  163 + }
  164 + .isInstanceOf(IllegalArgumentException::class)
  165 + .messageContains("quantity must be positive")
  166 + }
  167 +
  168 + @Test
  169 + fun `create rejects line currency mismatch`() {
  170 + stubCustomer("CUST-1")
  171 + stubItem("PAPER-A4")
  172 +
  173 + assertFailure {
  174 + service.create(
  175 + command(
  176 + currency = "USD",
  177 + lines = listOf(
  178 + SalesOrderLineCommand(
  179 + lineNo = 1,
  180 + itemCode = "PAPER-A4",
  181 + quantity = BigDecimal("1"),
  182 + unitPrice = BigDecimal("1"),
  183 + currencyCode = "EUR",
  184 + ),
  185 + ),
  186 + ),
  187 + )
  188 + }
  189 + .isInstanceOf(IllegalArgumentException::class)
  190 + .messageContains("does not match header currency")
  191 + }
  192 +
  193 + @Test
  194 + fun `create recomputes total from lines, ignoring caller`() {
  195 + stubCustomer("CUST-1")
  196 + stubItem("PAPER-A4")
  197 + stubItem("PAPER-A3")
  198 + val saved = slot<SalesOrder>()
  199 + every { orders.save(capture(saved)) } answers { saved.captured }
  200 +
  201 + val result = service.create(
  202 + command(
  203 + lines = listOf(
  204 + line(no = 1, item = "PAPER-A4", qty = "10", price = "5.00"), // 50.00
  205 + line(no = 2, item = "PAPER-A3", qty = "3", price = "8.50"), // 25.50
  206 + ),
  207 + ),
  208 + )
  209 +
  210 + // 10 × 5.00 + 3 × 8.50 = 50.00 + 25.50 = 75.50
  211 + assertThat(result.totalAmount).isEqualTo(BigDecimal("75.50"))
  212 + assertThat(result.lines).hasSize(2)
  213 + }
  214 +
  215 + @Test
  216 + fun `confirm transitions DRAFT to CONFIRMED`() {
  217 + val id = UUID.randomUUID()
  218 + val order = SalesOrder(
  219 + code = "SO-1",
  220 + partnerCode = "CUST-1",
  221 + status = SalesOrderStatus.DRAFT,
  222 + orderDate = LocalDate.of(2026, 4, 8),
  223 + currencyCode = "USD",
  224 + ).also { it.id = id }
  225 + every { orders.findById(id) } returns Optional.of(order)
  226 +
  227 + val confirmed = service.confirm(id)
  228 +
  229 + assertThat(confirmed.status).isEqualTo(SalesOrderStatus.CONFIRMED)
  230 + }
  231 +
  232 + @Test
  233 + fun `confirm rejects an already-confirmed order`() {
  234 + val id = UUID.randomUUID()
  235 + val order = SalesOrder(
  236 + code = "SO-1",
  237 + partnerCode = "CUST-1",
  238 + status = SalesOrderStatus.CONFIRMED,
  239 + orderDate = LocalDate.of(2026, 4, 8),
  240 + currencyCode = "USD",
  241 + ).also { it.id = id }
  242 + every { orders.findById(id) } returns Optional.of(order)
  243 +
  244 + assertFailure { service.confirm(id) }
  245 + .isInstanceOf(IllegalArgumentException::class)
  246 + .messageContains("only DRAFT can be confirmed")
  247 + }
  248 +
  249 + @Test
  250 + fun `update rejects mutation of a CONFIRMED order`() {
  251 + val id = UUID.randomUUID()
  252 + val order = SalesOrder(
  253 + code = "SO-1",
  254 + partnerCode = "CUST-1",
  255 + status = SalesOrderStatus.CONFIRMED,
  256 + orderDate = LocalDate.of(2026, 4, 8),
  257 + currencyCode = "USD",
  258 + ).also { it.id = id }
  259 + every { orders.findById(id) } returns Optional.of(order)
  260 +
  261 + assertFailure {
  262 + service.update(id, UpdateSalesOrderCommand(orderDate = LocalDate.of(2026, 4, 9)))
  263 + }
  264 + .isInstanceOf(IllegalArgumentException::class)
  265 + .messageContains("only DRAFT orders are mutable")
  266 + }
  267 +
  268 + @Test
  269 + fun `cancel a confirmed order is allowed`() {
  270 + val id = UUID.randomUUID()
  271 + val order = SalesOrder(
  272 + code = "SO-1",
  273 + partnerCode = "CUST-1",
  274 + status = SalesOrderStatus.CONFIRMED,
  275 + orderDate = LocalDate.of(2026, 4, 8),
  276 + currencyCode = "USD",
  277 + ).also { it.id = id }
  278 + every { orders.findById(id) } returns Optional.of(order)
  279 +
  280 + val cancelled = service.cancel(id)
  281 +
  282 + assertThat(cancelled.status).isEqualTo(SalesOrderStatus.CANCELLED)
  283 + }
  284 +
  285 + @Test
  286 + fun `cancel rejects an already-cancelled order`() {
  287 + val id = UUID.randomUUID()
  288 + val order = SalesOrder(
  289 + code = "SO-1",
  290 + partnerCode = "CUST-1",
  291 + status = SalesOrderStatus.CANCELLED,
  292 + orderDate = LocalDate.of(2026, 4, 8),
  293 + currencyCode = "USD",
  294 + ).also { it.id = id }
  295 + every { orders.findById(id) } returns Optional.of(order)
  296 +
  297 + assertFailure { service.cancel(id) }
  298 + .isInstanceOf(IllegalArgumentException::class)
  299 + .messageContains("already cancelled")
  300 + }
  301 +}
settings.gradle.kts
@@ -55,6 +55,9 @@ project(&quot;:pbc:pbc-partners&quot;).projectDir = file(&quot;pbc/pbc-partners&quot;) @@ -55,6 +55,9 @@ project(&quot;:pbc:pbc-partners&quot;).projectDir = file(&quot;pbc/pbc-partners&quot;)
55 include(":pbc:pbc-inventory") 55 include(":pbc:pbc-inventory")
56 project(":pbc:pbc-inventory").projectDir = file("pbc/pbc-inventory") 56 project(":pbc:pbc-inventory").projectDir = file("pbc/pbc-inventory")
57 57
  58 +include(":pbc:pbc-orders-sales")
  59 +project(":pbc:pbc-orders-sales").projectDir = file("pbc/pbc-orders-sales")
  60 +
58 // ─── Reference customer plug-in (NOT loaded by default) ───────────── 61 // ─── Reference customer plug-in (NOT loaded by default) ─────────────
59 include(":reference-customer:plugin-printing-shop") 62 include(":reference-customer:plugin-printing-shop")
60 project(":reference-customer:plugin-printing-shop").projectDir = file("reference-customer/plugin-printing-shop") 63 project(":reference-customer:plugin-printing-shop").projectDir = file("reference-customer/plugin-printing-shop")