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.