Commit a8eb2a6661a5847896a485a65fd316601ab914ab
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.
Showing
17 changed files
with
1391 additions
and
22 deletions
CLAUDE.md
| @@ -95,10 +95,10 @@ plugins (incl. ref) depend on: api/api-v1 only | @@ -95,10 +95,10 @@ plugins (incl. ref) depend on: api/api-v1 only | ||
| 95 | 95 | ||
| 96 | **Foundation complete; first business surface in place.** As of the latest commit: | 96 | **Foundation complete; first business surface in place.** As of the latest commit: |
| 97 | 97 | ||
| 98 | -- **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 > 0), | ||
| 84 | + CONSTRAINT orders_sales__sales_order_line_price_nonneg CHECK (unit_price >= 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(":pbc:pbc-partners").projectDir = file("pbc/pbc-partners") | @@ -55,6 +55,9 @@ project(":pbc:pbc-partners").projectDir = file("pbc/pbc-partners") | ||
| 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") |