Commit e37c143e3c378c48984aba20946379bf7586d2f3

Authored by zichun
1 parent 577d9214

feat(inventory+orders): movement ledger + sales-order shipping (end-to-end demo)

The killer demo finally works: place a sales order, ship it, watch
inventory drop. This chunk lands the two pieces that close the loop:
the inventory movement ledger (the audit-grade history of every
stock change) and the sales-order /ship endpoint that calls
InventoryApi.recordMovement to atomically debit stock for every line.

This is the framework's FIRST cross-PBC WRITE flow. Every earlier
cross-PBC call was a read (CatalogApi.findItemByCode,
PartnersApi.findPartnerByCode, InventoryApi.findStockBalance).
Shipping inverts that: pbc-orders-sales synchronously writes to
inventory's tables (via the api.v1 facade) as a side effect of
changing its own state, all in ONE Spring transaction.

What landed
-----------
* New `inventory__stock_movement` table — append-only ledger
  (id, item_code, location_id FK, signed delta, reason enum,
  reference, occurred_at, audit cols). CHECK constraint
  `delta <> 0` rejects no-op rows. Indexes on item_code,
  location_id, the (item, location) composite, reference, and
  occurred_at. Migration is in its own changelog file
  (002-inventory-movement-ledger.xml) per the project convention
  that each new schema cut is a new file.
* New `StockMovement` JPA entity + repository + `MovementReason`
  enum (RECEIPT, ISSUE, ADJUSTMENT, SALES_SHIPMENT, PURCHASE_RECEIPT,
  TRANSFER_OUT, TRANSFER_IN). Each value carries a documented sign
  convention; the service rejects mismatches (a SALES_SHIPMENT
  with positive delta is a caller bug, not silently coerced).
* New `StockMovementService.record(...)` — the ONE entry point for
  changing inventory. Cross-PBC item validation via CatalogApi,
  local location validation, sign-vs-reason enforcement, and
  negative-balance rejection all happen BEFORE the write. The
  ledger row insert AND the balance row update happen in the
  SAME database transaction so the two cannot drift.
* `StockBalanceService.adjust` refactored to delegate: it computes
  delta = newQty - oldQty and calls record(... ADJUSTMENT). The
  REST endpoint keeps its absolute-quantity semantics — operators
  type "the shelf has 47" not "decrease by 3" — but every
  adjustment now writes a ledger row too. A no-op adjustment
  (re-saving the same value) does NOT write a row, so the audit
  log doesn't fill with noise from operator clicks that didn't
  change anything.
* New `StockMovementController` at `/api/v1/inventory/movements`:
  GET filters by itemCode, locationId, or reference (for "all
  movements caused by SO-2026-0001"); POST records a manual
  movement. Both protected by `inventory.stock.adjust`.
* `InventoryApi` facade extended with `recordMovement(itemCode,
  locationCode, delta, reason: String, reference)`. The reason is
  a String in the api.v1 surface (not the local enum) so plug-ins
  don't import inventory's internal types — the closed set is
  documented on the interface. The adapter parses the string with
  a meaningful error on unknown values.
* New `SHIPPED` status on `SalesOrderStatus`. Transitions:
  DRAFT → CONFIRMED → SHIPPED (terminal). Cancelling a SHIPPED
  order is rejected with "issue a return / refund flow instead".
* New `SalesOrderService.ship(id, shippingLocationCode)`: walks
  every line, calls `inventoryApi.recordMovement(... -line.quantity
  reason="SALES_SHIPMENT" reference="SO:{order_code}")`, flips
  status to SHIPPED. The whole operation runs in ONE transaction
  so a failure on any line — bad item, bad location, would push
  balance negative — rolls back the order status change AND every
  other line's already-written movement. The customer never ends
  up with "5 of 7 lines shipped, status still CONFIRMED, ledger
  half-written".
* New `POST /api/v1/orders/sales-orders/{id}/ship` endpoint with
  body `{"shippingLocationCode": "WH-MAIN"}`, gated by the new
  `orders.sales.ship` permission key.
* `ShipSalesOrderRequest` is a single-arg Kotlin data class — same
  Jackson deserialization trap as `RefreshRequest`. Fixed with
  `@JsonCreator(mode = PROPERTIES) + @param:JsonProperty`. The
  trap is documented in the class KDoc.

End-to-end smoke test (the killer demo)
---------------------------------------
Reset Postgres, booted the app, ran:
* Login as admin
* POST /catalog/items → PAPER-A4
* POST /partners → CUST-ACME
* POST /inventory/locations → WH-MAIN
* POST /inventory/balances/adjust → quantity=1000
  (now writes a ledger row via the new path)
* GET /inventory/movements?itemCode=PAPER-A4 →
  ADJUSTMENT delta=1000 ref=null
* POST /orders/sales-orders → SO-2026-0001 (50 units of PAPER-A4)
* POST /sales-orders/{id}/confirm → status CONFIRMED
* POST /sales-orders/{id}/ship body={"shippingLocationCode":"WH-MAIN"}
  → status SHIPPED
* GET /inventory/balances?itemCode=PAPER-A4 → quantity=950
  (1000 - 50)
* GET /inventory/movements?itemCode=PAPER-A4 →
  ADJUSTMENT     delta=1000   ref=null
  SALES_SHIPMENT delta=-50    ref=SO:SO-2026-0001

Failure paths verified:
* Re-ship a SHIPPED order → 400 "only CONFIRMED orders can be shipped"
* Cancel a SHIPPED order → 400 "issue a return / refund flow instead"
* Place a 10000-unit order, confirm, try to ship from a 950-stock
  warehouse → 400 "stock movement would push balance for 'PAPER-A4'
  at location ... below zero (current=950.0000, delta=-10000.0000)";
  balance unchanged after the rollback (transaction integrity
  verified)

Regression: catalog uoms, identity users, inventory locations,
printing-shop plates with i18n, metadata entities — all still
HTTP 2xx.

Build
-----
* `./gradlew build`: 15 subprojects, 175 unit tests (was 163),
  all green. The 12 new tests cover:
  - StockMovementServiceTest (8): zero-delta rejection, positive
    SALES_SHIPMENT rejection, negative RECEIPT rejection, both
    signs allowed on ADJUSTMENT, unknown item via CatalogApi seam,
    unknown location, would-push-balance-negative rejection,
    new-row + existing-row balance update.
  - StockBalanceServiceTest, rewritten (5): negative-quantity
    early reject, delegation with computed positive delta,
    delegation with computed negative delta, no-op adjustment
    short-circuit (NO ledger row written), no-op on missing row
    creates an empty row at zero.
  - SalesOrderServiceTest, additions (3): ship rejects non-CONFIRMED,
    ship walks lines and calls recordMovement with negated quantity
    + correct reference, cancel rejects SHIPPED.

What was deferred
-----------------
* **Event publication.** A `StockMovementRecorded` event would
  let pbc-finance and pbc-production react to ledger writes
  without polling. The event bus has been wired since P1.7 but
  no real cross-PBC flow uses it yet — that's the natural next
  chunk and the chunk after this commit.
* **Multi-leg transfers.** TRANSFER_OUT and TRANSFER_IN are in
  the enum but no service operation atomically writes both legs
  yet (both legs in one transaction is required to keep total
  on-hand invariant).
* **Reservation / pick lists.** "Reserve 50 of PAPER-A4 for an
  unconfirmed order" is its own concept that lands later.
* **Shipped-order returns / refunds.** The cancel-SHIPPED rule
  points the user at "use a return flow" — that flow doesn't
  exist yet. v1 says shipments are terminal.
Showing 19 changed files with 1250 additions and 84 deletions
CLAUDE.md
... ... @@ -96,9 +96,9 @@ plugins (incl. ref) depend on: api/api-v1 only
96 96 **Foundation complete; first business surface in place.** As of the latest commit:
97 97  
98 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   -- **163 unit tests across 15 modules**, all green. `./gradlew build` is the canonical full build.
  99 +- **175 unit tests across 15 modules**, all green. `./gradlew build` is the canonical full build.
100 100 - **All 9 cross-cutting platform services live and smoke-tested end-to-end** against real Postgres: auth (P4.1), authorization with `@RequirePermission` + JWT roles claim + AOP enforcement (P4.3), plug-in HTTP endpoints (P1.3), plug-in linter (P1.2), plug-in-owned Liquibase + typed SQL (P1.4), event bus + transactional outbox (P1.7), metadata loader + custom-field validation (P1.5 + P3.4), ICU4J translator with per-plug-in locale chain (P1.6), plus the audit/principal context bridge.
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.
  101 +- **5 of 10 core PBCs implemented end-to-end** (`pbc-identity`, `pbc-catalog`, `pbc-partners`, `pbc-inventory`, `pbc-orders-sales`). **pbc-inventory** has an append-only stock movement ledger; **pbc-orders-sales** can ship orders, atomically debiting stock via `InventoryApi.recordMovement` — the framework's first cross-PBC WRITE flow. The Gradle build still refuses any direct dependency between PBCs.
102 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 103 - **Package root** is `org.vibeerp`.
104 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,21 +10,21 @@
10 10  
11 11 | | |
12 12 |---|---|
13   -| **Latest version** | v0.12 (post-P4.3) |
14   -| **Latest commit** | `75bf870 feat(security): P4.3 — @RequirePermission + JWT roles claim + AOP enforcement` |
  13 +| **Latest version** | v0.13 (post-ledger + ship) |
  14 +| **Latest commit** | `feat(inventory+orders): movement ledger + sales-order shipping (end-to-end demo)` |
15 15 | **Repo** | https://github.com/reporkey/vibe-erp |
16 16 | **Modules** | 15 |
17   -| **Unit tests** | 163, 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 via three cross-PBC seams; **`@RequirePermission` enforces 403 on non-admin users hitting protected endpoints** |
  17 +| **Unit tests** | 175, all green |
  18 +| **End-to-end smoke runs** | The killer demo works: create catalog item + partner + location, set stock to 1000, place an order for 50, confirm, **ship**, watch the balance drop to 950 and a `SALES_SHIPMENT` row appear in the ledger tagged `SO:SO-2026-0001`. Over-shipping rolls back atomically with a meaningful 400. |
19 19 | **Real PBCs implemented** | 5 of 10 (`pbc-identity`, `pbc-catalog`, `pbc-partners`, `pbc-inventory`, `pbc-orders-sales`) |
20 20 | **Plug-ins serving HTTP** | 1 (reference printing-shop, 7 endpoints + own DB schema + own metadata + own i18n bundles) |
21 21 | **Production-ready?** | **No.** Foundation is solid; many capabilities still deferred. |
22 22  
23 23 ## Current stage
24 24  
25   -**Foundation complete; Tier 1 customization live; authorization enforced.** All eight cross-cutting platform services are live, plus the **authorization layer (P4.3)**: `@RequirePermission` annotations on controller methods are enforced by a Spring AOP aspect that consults a `PermissionEvaluator` reading the JWT's `roles` claim from a per-request `AuthorizationContext`. The framework can now distinguish "authenticated but not authorized" (403) from "not authenticated" (401). Five real PBCs validate the modular-monolith template; pbc-orders-sales remains the most rigorous test (consumes two cross-PBC facades in one transaction). State machine DRAFT → CONFIRMED → CANCELLED is enforced.
  25 +**Foundation complete; Tier 1 customization live; authorization enforced; end-to-end order-to-shipment loop closed.** All eight cross-cutting platform services are live plus the authorization layer. The biggest leap in this version: the **inventory movement ledger** is live (`inventory__stock_movement` append-only table; the framework's first append-only ledger), and **sales orders can now ship** (`POST /sales-orders/{id}/ship`) via a cross-PBC write — pbc-orders-sales injects the new `InventoryApi.recordMovement` to atomically debit stock for every line and update the order status, all in one transaction. Either the whole shipment commits or none of it does. This is the framework's **first cross-PBC WRITE flow** (every earlier cross-PBC call was a read). The sales-order state machine grows: DRAFT → CONFIRMED → SHIPPED (terminal). Cancelling a SHIPPED order is rejected with a meaningful "use a return / refund flow" message.
26 26  
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), and eventually the React SPA.
  27 +The next phase continues **building business surface area**: pbc-orders-purchase, pbc-production, the workflow engine (Flowable), event-driven cross-PBC integration (the event bus has been wired since P1.7 but no flow uses it yet), and eventually the React SPA.
28 28  
29 29 ## Total scope (the v1.0 cut line)
30 30  
... ... @@ -171,8 +171,6 @@ This is **real**: a JAR file dropped into a directory, loaded by the framework,
171 171 - **Job scheduler.** No Quartz. Periodic jobs don't have a home.
172 172 - **OIDC.** Built-in JWT only. OIDC client (Keycloak-compatible) is P4.2.
173 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.
176 174 - **Web SPA.** No React app. The framework is API-only today.
177 175 - **MCP server.** The architecture leaves room for it; the implementation is v1.1.
178 176 - **Mobile.** v2.
... ...
README.md
... ... @@ -77,7 +77,7 @@ vibe-erp/
77 77 ## Building
78 78  
79 79 ```bash
80   -# Build everything (compiles 15 modules, runs 163 unit tests)
  80 +# Build everything (compiles 15 modules, runs 175 unit tests)
81 81 ./gradlew build
82 82  
83 83 # Bring up Postgres + the reference plug-in JAR
... ... @@ -97,7 +97,7 @@ The bootstrap admin password is printed to the application logs on first boot. A
97 97 | | |
98 98 |---|---|
99 99 | Modules | 15 |
100   -| Unit tests | 163, all green |
  100 +| Unit tests | 175, all green |
101 101 | Real PBCs | 5 of 10 |
102 102 | Cross-cutting services live | 9 |
103 103 | Plug-ins serving HTTP | 1 (reference printing-shop) |
... ...
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/ext/inventory/InventoryApi.kt
... ... @@ -59,6 +59,61 @@ interface InventoryApi {
59 59 * state because that is most items most of the time.
60 60 */
61 61 fun totalOnHand(itemCode: String): BigDecimal
  62 +
  63 + /**
  64 + * Record a stock movement and update the matching balance,
  65 + * atomically. **The cross-PBC entry point for changing inventory.**
  66 + *
  67 + * Other PBCs (and plug-ins) call this to debit or credit stock as
  68 + * a side effect of their own business operations:
  69 + * - `pbc-orders-sales` calls it on `POST /sales-orders/{id}/ship`
  70 + * with `delta=-quantity reason="SALES_SHIPMENT"` per line.
  71 + * - `pbc-orders-purchase` (future) calls it on receipt with
  72 + * `reason="PURCHASE_RECEIPT"` per line.
  73 + * - The reference printing-shop plug-in (future) calls it from
  74 + * work-order-completion handlers.
  75 + *
  76 + * **Why the reason is a String** instead of a typed enum:
  77 + * plug-ins must not import inventory's internal `MovementReason`
  78 + * enum (that would force them onto pbc-inventory's compile
  79 + * classpath, violating guardrail #9). The accepted values are
  80 + * documented as a closed set: `RECEIPT`, `ISSUE`, `ADJUSTMENT`,
  81 + * `SALES_SHIPMENT`, `PURCHASE_RECEIPT`, `TRANSFER_OUT`,
  82 + * `TRANSFER_IN`. Each carries a documented sign convention which
  83 + * the framework enforces — a `SALES_SHIPMENT` with positive delta
  84 + * is rejected with a meaningful error.
  85 + *
  86 + * **Why a single method** instead of separate `debit` / `credit`:
  87 + * the ledger's natural shape is a signed delta. Callers think
  88 + * "I'm shipping 5 units" → delta=-5, reason=SALES_SHIPMENT. The
  89 + * sign carries the semantic; making the caller pick a method
  90 + * name based on the sign duplicates information that is already
  91 + * in the delta.
  92 + *
  93 + * @param itemCode the catalog item code (validated cross-PBC via
  94 + * CatalogApi inside the implementation).
  95 + * @param locationCode the inventory location code (NOT id, by
  96 + * convention every cross-PBC reference uses codes).
  97 + * @param delta the signed quantity to add (positive) or subtract
  98 + * (negative). Zero is rejected.
  99 + * @param reason one of the closed set documented above.
  100 + * @param reference free-form caller-supplied tag, recorded on the
  101 + * ledger row. Convention: `<source>:<code>` (e.g. `SO:SO-2026-0001`,
  102 + * `PR:PR-2026-0042`). May be null for unattached movements.
  103 + * @return the resulting [StockBalanceRef] AFTER the movement.
  104 + * @throws IllegalArgumentException when the item is unknown, the
  105 + * location code is unknown, the delta is zero, the delta sign
  106 + * disagrees with the reason, or the resulting balance would be
  107 + * negative. Caller-side `try { } catch (IllegalArgumentException)`
  108 + * is the right shape for plug-in code.
  109 + */
  110 + fun recordMovement(
  111 + itemCode: String,
  112 + locationCode: String,
  113 + delta: BigDecimal,
  114 + reason: String,
  115 + reference: String? = null,
  116 + ): StockBalanceRef
62 117 }
63 118  
64 119 /**
... ...
distribution/src/main/resources/db/changelog/master.xml
... ... @@ -17,5 +17,6 @@
17 17 <include file="classpath:db/changelog/pbc-catalog/001-catalog-init.xml"/>
18 18 <include file="classpath:db/changelog/pbc-partners/001-partners-init.xml"/>
19 19 <include file="classpath:db/changelog/pbc-inventory/001-inventory-init.xml"/>
  20 + <include file="classpath:db/changelog/pbc-inventory/002-inventory-movement-ledger.xml"/>
20 21 <include file="classpath:db/changelog/pbc-orders-sales/001-orders-sales-init.xml"/>
21 22 </databaseChangeLog>
... ...
distribution/src/main/resources/db/changelog/pbc-inventory/002-inventory-movement-ledger.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-inventory movement ledger (P5.3 follow-up).
  9 +
  10 + Adds: inventory__stock_movement.
  11 +
  12 + The ledger is the source of truth: inventory__stock_balance is
  13 + a materialised view whose `quantity` equals the SUM of every
  14 + `delta` for the same (item_code, location_id). The framework
  15 + writes the movement row and the balance update in the SAME
  16 + database transaction so the two cannot drift.
  17 +
  18 + This changeset is in its own file (002-...) instead of being
  19 + appended to 001-inventory-init.xml because Liquibase tracks
  20 + applied changesets by (file, id, author) tuple — once an
  21 + environment has run 001 it will not pick up new changesets
  22 + added inside that file (unless you increment the runOnChange
  23 + attribute, which is fragile). The convention this codebase
  24 + follows: each new schema cut goes in a new XML file in the
  25 + PBC's changelog directory, registered in master.xml.
  26 + -->
  27 +
  28 + <changeSet id="inventory-movement-ledger-001" author="vibe_erp">
  29 + <comment>Create inventory__stock_movement append-only ledger table</comment>
  30 + <sql>
  31 + CREATE TABLE inventory__stock_movement (
  32 + id uuid PRIMARY KEY,
  33 + item_code varchar(64) NOT NULL,
  34 + location_id uuid NOT NULL REFERENCES inventory__location(id),
  35 + delta numeric(18,4) NOT NULL,
  36 + reason varchar(32) NOT NULL,
  37 + reference varchar(128),
  38 + occurred_at timestamptz NOT NULL,
  39 + created_at timestamptz NOT NULL,
  40 + created_by varchar(128) NOT NULL,
  41 + updated_at timestamptz NOT NULL,
  42 + updated_by varchar(128) NOT NULL,
  43 + version bigint NOT NULL DEFAULT 0,
  44 + CONSTRAINT inventory__stock_movement_nonzero CHECK (delta &lt;&gt; 0)
  45 + );
  46 + CREATE INDEX inventory__stock_movement_item_idx
  47 + ON inventory__stock_movement (item_code);
  48 + CREATE INDEX inventory__stock_movement_loc_idx
  49 + ON inventory__stock_movement (location_id);
  50 + CREATE INDEX inventory__stock_movement_item_loc_idx
  51 + ON inventory__stock_movement (item_code, location_id);
  52 + CREATE INDEX inventory__stock_movement_reference_idx
  53 + ON inventory__stock_movement (reference);
  54 + CREATE INDEX inventory__stock_movement_occurred_idx
  55 + ON inventory__stock_movement (occurred_at);
  56 + </sql>
  57 + <rollback>
  58 + DROP TABLE inventory__stock_movement;
  59 + </rollback>
  60 + </changeSet>
  61 +
  62 +</databaseChangeLog>
... ...
pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/application/StockBalanceService.kt
... ... @@ -3,6 +3,7 @@ package org.vibeerp.pbc.inventory.application
3 3 import org.springframework.stereotype.Service
4 4 import org.springframework.transaction.annotation.Transactional
5 5 import org.vibeerp.api.v1.ext.catalog.CatalogApi
  6 +import org.vibeerp.pbc.inventory.domain.MovementReason
6 7 import org.vibeerp.pbc.inventory.domain.StockBalance
7 8 import org.vibeerp.pbc.inventory.infrastructure.LocationJpaRepository
8 9 import org.vibeerp.pbc.inventory.infrastructure.StockBalanceJpaRepository
... ... @@ -52,6 +53,7 @@ class StockBalanceService(
52 53 private val balances: StockBalanceJpaRepository,
53 54 private val locations: LocationJpaRepository,
54 55 private val catalogApi: CatalogApi,
  56 + private val stockMovementService: StockMovementService,
55 57 ) {
56 58  
57 59 @Transactional(readOnly = true)
... ... @@ -68,36 +70,49 @@ class StockBalanceService(
68 70 /**
69 71 * Set the on-hand quantity for [itemCode] at [locationId] to
70 72 * [quantity], creating the balance row if it does not yet exist.
  73 + *
  74 + * **As of the movement-ledger landing**, this method is a thin
  75 + * wrapper that delegates to [StockMovementService.record] with a
  76 + * computed delta and reason=ADJUSTMENT. The wrapper exists so the
  77 + * `POST /inventory/balances/adjust` REST endpoint keeps its
  78 + * absolute-quantity semantics — operators want to type "the
  79 + * shelf has 47 of these" not "decrease by 3" — while the
  80 + * underlying ledger row still captures the change. The cross-PBC
  81 + * item validation, the location validation, and the
  82 + * negative-balance rejection now happen inside the movement
  83 + * service; the ADJUSTMENT path inherits all of them.
71 84 */
72 85 fun adjust(itemCode: String, locationId: UUID, quantity: BigDecimal): StockBalance {
73 86 require(quantity.signum() >= 0) {
74 87 "stock quantity must be non-negative (got $quantity)"
75 88 }
76 89  
77   - // Cross-PBC validation #1: the item must exist in the catalog
78   - // AND be active. CatalogApi returns null for inactive items —
79   - // see the rationale on CatalogApi.findItemByCode.
80   - catalogApi.findItemByCode(itemCode)
81   - ?: throw IllegalArgumentException(
82   - "item code '$itemCode' is not in the catalog (or is inactive)",
83   - )
84   -
85   - // Local validation #2: the location must exist. We don't go
86   - // through a facade because the location lives in this PBC.
87   - require(locations.existsById(locationId)) {
88   - "location not found: $locationId"
89   - }
90   -
91   - // Upsert the balance row. Single-instance deployments mean the
92   - // SELECT-then-save race window is closed for practical purposes
93   - // — vibe_erp is single-tenant per process and the @Transactional
94   - // boundary makes the read-modify-write atomic at the DB level.
95 90 val existing = balances.findByItemCodeAndLocationId(itemCode, locationId)
96   - return if (existing == null) {
97   - balances.save(StockBalance(itemCode, locationId, quantity))
98   - } else {
99   - existing.quantity = quantity
100   - existing
  91 + val currentQuantity = existing?.quantity ?: BigDecimal.ZERO
  92 + val delta = quantity - currentQuantity
  93 + if (delta.signum() == 0) {
  94 + // No-op adjustment — don't insert a meaningless ledger row.
  95 + // Validate the inputs anyway so the caller still gets the
  96 + // same error shape on bad item / bad location, by going
  97 + // through the catalog facade and the location check.
  98 + catalogApi.findItemByCode(itemCode)
  99 + ?: throw IllegalArgumentException(
  100 + "item code '$itemCode' is not in the catalog (or is inactive)",
  101 + )
  102 + require(locations.existsById(locationId)) {
  103 + "location not found: $locationId"
  104 + }
  105 + return existing ?: balances.save(StockBalance(itemCode, locationId, quantity))
101 106 }
  107 +
  108 + return stockMovementService.record(
  109 + RecordMovementCommand(
  110 + itemCode = itemCode,
  111 + locationId = locationId,
  112 + delta = delta,
  113 + reason = MovementReason.ADJUSTMENT,
  114 + reference = null,
  115 + ),
  116 + )
102 117 }
103 118 }
... ...
pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/application/StockMovementService.kt 0 → 100644
  1 +package org.vibeerp.pbc.inventory.application
  2 +
  3 +import org.springframework.stereotype.Service
  4 +import org.springframework.transaction.annotation.Transactional
  5 +import org.vibeerp.api.v1.ext.catalog.CatalogApi
  6 +import org.vibeerp.pbc.inventory.domain.MovementReason
  7 +import org.vibeerp.pbc.inventory.domain.StockBalance
  8 +import org.vibeerp.pbc.inventory.domain.StockMovement
  9 +import org.vibeerp.pbc.inventory.infrastructure.LocationJpaRepository
  10 +import org.vibeerp.pbc.inventory.infrastructure.StockBalanceJpaRepository
  11 +import org.vibeerp.pbc.inventory.infrastructure.StockMovementJpaRepository
  12 +import java.math.BigDecimal
  13 +import java.time.Instant
  14 +import java.util.UUID
  15 +
  16 +/**
  17 + * The framework's stock-movement ledger writer.
  18 + *
  19 + * **The ONE entry point for changing inventory.** Every other path
  20 + * that wants to debit or credit stock — manual operator adjustment,
  21 + * sales order shipment, future purchase receipt, transfer-out /
  22 + * transfer-in legs — funnels through [record]. The method writes a
  23 + * row to `inventory__stock_movement` AND updates (or creates) the
  24 + * matching `inventory__stock_balance` row in the SAME database
  25 + * transaction. The two cannot drift because the same `@Transactional`
  26 + * commit governs both writes.
  27 + *
  28 + * **Cross-PBC validation.** Like [StockBalanceService.adjust], the
  29 + * service injects [CatalogApi] and rejects unknown item codes
  30 + * up-front with a meaningful 400. The location is validated locally
  31 + * (intra-PBC). Both checks happen BEFORE the row is inserted, so a
  32 + * rejected request leaves no ledger trace.
  33 + *
  34 + * **Sign convention enforced per reason.** Each [MovementReason] has
  35 + * a documented sign — RECEIPT/PURCHASE_RECEIPT/TRANSFER_IN are
  36 + * non-negative, ISSUE/SALES_SHIPMENT/TRANSFER_OUT are non-positive,
  37 + * ADJUSTMENT can be either. A `RECEIPT` with `delta = -5` is a
  38 + * caller bug, not silently coerced. Catching it here means the
  39 + * audit log stays consistent: a row tagged "SALES_SHIPMENT" can
  40 + * never be a positive number.
  41 + *
  42 + * **Negative balance rejection.** A movement that would push the
  43 + * resulting balance below zero is rejected. This is the framework's
  44 + * "you cannot ship more than you have" rule. The CHECK constraint
  45 + * on `inventory__stock_balance.quantity >= 0` is the second wall;
  46 + * this is the first one with a better error message.
  47 + *
  48 + * **Why no event publication in v1.** A future chunk wires
  49 + * `StockMovementRecorded` events through the event bus so other
  50 + * PBCs (production, finance) can react. Until then, downstream
  51 + * consumers query the ledger directly. The omission is deliberate:
  52 + * adding the event in this chunk would entangle the ledger
  53 + * landing with the event-bus-as-cross-PBC-integration story, and
  54 + * each deserves its own focused chunk.
  55 + */
  56 +@Service
  57 +@Transactional
  58 +class StockMovementService(
  59 + private val movements: StockMovementJpaRepository,
  60 + private val balances: StockBalanceJpaRepository,
  61 + private val locations: LocationJpaRepository,
  62 + private val catalogApi: CatalogApi,
  63 +) {
  64 +
  65 + @Transactional(readOnly = true)
  66 + fun list(): List<StockMovement> = movements.findAll()
  67 +
  68 + @Transactional(readOnly = true)
  69 + fun findByItemCode(itemCode: String): List<StockMovement> =
  70 + movements.findByItemCode(itemCode)
  71 +
  72 + @Transactional(readOnly = true)
  73 + fun findByLocationId(locationId: UUID): List<StockMovement> =
  74 + movements.findByLocationId(locationId)
  75 +
  76 + @Transactional(readOnly = true)
  77 + fun findByReference(reference: String): List<StockMovement> =
  78 + movements.findByReference(reference)
  79 +
  80 + /**
  81 + * Record a stock movement and update the matching balance, atomically.
  82 + *
  83 + * @return the resulting [StockBalance] row (the new quantity).
  84 + * @throws IllegalArgumentException with a meaningful message if
  85 + * the item is unknown, the location is unknown, the delta is
  86 + * zero, the delta sign disagrees with the reason, or the
  87 + * resulting balance would be negative.
  88 + */
  89 + fun record(command: RecordMovementCommand): StockBalance {
  90 + require(command.delta.signum() != 0) {
  91 + "stock movement delta must be non-zero"
  92 + }
  93 + validateSign(command.reason, command.delta)
  94 +
  95 + // Cross-PBC validation: the item must exist in the catalog
  96 + // (and be active — CatalogApi hides inactive items).
  97 + catalogApi.findItemByCode(command.itemCode)
  98 + ?: throw IllegalArgumentException(
  99 + "item code '${command.itemCode}' is not in the catalog (or is inactive)",
  100 + )
  101 +
  102 + // Local validation: the location must exist.
  103 + require(locations.existsById(command.locationId)) {
  104 + "location not found: ${command.locationId}"
  105 + }
  106 +
  107 + // Compute the new balance. SELECT-then-save is correct under
  108 + // the @Transactional boundary; the same single-instance
  109 + // single-tenant rationale applies as in StockBalanceService.adjust.
  110 + val existing = balances.findByItemCodeAndLocationId(command.itemCode, command.locationId)
  111 + val oldQuantity = existing?.quantity ?: BigDecimal.ZERO
  112 + val newQuantity = oldQuantity + command.delta
  113 + require(newQuantity.signum() >= 0) {
  114 + "stock movement would push balance for '${command.itemCode}' at " +
  115 + "location ${command.locationId} below zero (current=$oldQuantity, delta=${command.delta})"
  116 + }
  117 +
  118 + // Insert the ledger row first, then update the balance. Both
  119 + // happen in the same JPA transaction → either both commit or
  120 + // neither does.
  121 + movements.save(
  122 + StockMovement(
  123 + itemCode = command.itemCode,
  124 + locationId = command.locationId,
  125 + delta = command.delta,
  126 + reason = command.reason,
  127 + reference = command.reference,
  128 + occurredAt = command.occurredAt ?: Instant.now(),
  129 + ),
  130 + )
  131 +
  132 + return if (existing == null) {
  133 + balances.save(StockBalance(command.itemCode, command.locationId, newQuantity))
  134 + } else {
  135 + existing.quantity = newQuantity
  136 + existing
  137 + }
  138 + }
  139 +
  140 + /**
  141 + * Verify that [delta]'s sign matches the documented direction
  142 + * for [reason]. ADJUSTMENT is the one reason that allows either
  143 + * sign — operators correct mistakes in either direction.
  144 + *
  145 + * Throws [IllegalArgumentException] with a message that names
  146 + * the reason and the expected direction so the caller knows
  147 + * how to fix the request.
  148 + */
  149 + private fun validateSign(reason: MovementReason, delta: BigDecimal) {
  150 + val sign = delta.signum() // -1, 0, or 1; zero is rejected above
  151 + val expected: String? = when (reason) {
  152 + MovementReason.RECEIPT,
  153 + MovementReason.PURCHASE_RECEIPT,
  154 + MovementReason.TRANSFER_IN -> if (sign < 0) "non-negative" else null
  155 +
  156 + MovementReason.ISSUE,
  157 + MovementReason.SALES_SHIPMENT,
  158 + MovementReason.TRANSFER_OUT -> if (sign > 0) "non-positive" else null
  159 +
  160 + MovementReason.ADJUSTMENT -> null // either sign allowed
  161 + }
  162 + if (expected != null) {
  163 + throw IllegalArgumentException(
  164 + "movement reason $reason requires a $expected delta (got $delta)",
  165 + )
  166 + }
  167 + }
  168 +}
  169 +
  170 +/**
  171 + * Input shape for [StockMovementService.record]. Kept as a separate
  172 + * data class so the REST DTO and the cross-PBC adapter can both
  173 + * build it without sharing a controller-only request type.
  174 + *
  175 + * @property occurredAt when the physical stock actually moved. The
  176 + * service defaults to "now" when this is null. Back-dated entries
  177 + * (operator types in yesterday's count today) provide it explicitly;
  178 + * the audit columns still record when the row was inserted.
  179 + */
  180 +data class RecordMovementCommand(
  181 + val itemCode: String,
  182 + val locationId: UUID,
  183 + val delta: BigDecimal,
  184 + val reason: MovementReason,
  185 + val reference: String? = null,
  186 + val occurredAt: Instant? = null,
  187 +)
... ...
pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/domain/StockMovement.kt 0 → 100644
  1 +package org.vibeerp.pbc.inventory.domain
  2 +
  3 +import jakarta.persistence.Column
  4 +import jakarta.persistence.Entity
  5 +import jakarta.persistence.EnumType
  6 +import jakarta.persistence.Enumerated
  7 +import jakarta.persistence.Table
  8 +import org.vibeerp.platform.persistence.audit.AuditedJpaEntity
  9 +import java.math.BigDecimal
  10 +import java.time.Instant
  11 +import java.util.UUID
  12 +
  13 +/**
  14 + * One row in the inventory ledger — an append-only record of a single
  15 + * change to a single (item × location) cell.
  16 + *
  17 + * **The ledger is the source of truth.** [StockBalance] is a
  18 + * materialised view: its `quantity` column equals the SUM of every
  19 + * `delta` in [StockMovement] for the same (item_code, location_id).
  20 + * The framework writes the movement row and the balance update in
  21 + * the SAME database transaction (see [StockMovementService.record]),
  22 + * so the two cannot drift. The ledger exists to answer the audit
  23 + * question "who moved this stock and when, and why?" — the balance
  24 + * exists to answer the operational question "how many do I have
  25 + * right now?" cheaply.
  26 + *
  27 + * **Append-only.** No update, no delete. A reversal is a NEW row
  28 + * with the negated delta and a reason of ADJUSTMENT (or, when the
  29 + * reversal corresponds to a real business event, the appropriate
  30 + * reason — RETURN-class reasons land later). The framework gives
  31 + * up nothing by enforcing append-only: the [AuditedJpaEntity]'s
  32 + * `created_by` / `created_at` columns capture who and when, and the
  33 + * reason + reference together capture WHY at the level of detail
  34 + * the integrating PBC can supply.
  35 + *
  36 + * **Why `delta` is signed instead of separate IN/OUT columns:** the
  37 + * arithmetic stays trivial (`SUM(delta)` is the balance), the type
  38 + * system rejects "negative receipt" / "positive issue" mistakes via
  39 + * the [MovementReason] enum's documented sign convention, and there
  40 + * is exactly one column to query when reporting on net activity.
  41 + *
  42 + * **Why `reference` is a free-form string** instead of a typed FK:
  43 + * the same row needs to point at a sales order code (`SO:SO-2026-0001`),
  44 + * a purchase receipt code (`PR:PR-2026-0042`), a transfer order
  45 + * (`TFR:TFR-2026-0007`), or nothing at all (manual adjustment). A
  46 + * varchar with a documented prefix convention is the only shape
  47 + * that fits all of those without forcing every PBC to declare its
  48 + * row in a polymorphic table. The prefix `<source>:<code>` is the
  49 + * convention; consumers parse it with `split(':')` if they care.
  50 + *
  51 + * **Why `occurred_at` is separate from `created_at`:** the audit
  52 + * column says "when did the system record this row"; `occurred_at`
  53 + * says "when did the physical stock move". The two are usually
  54 + * within milliseconds of each other, but a back-dated reconciliation
  55 + * (operator types in yesterday's count today) needs the distinction.
  56 + * Defaults to `created_at` when the caller doesn't provide it.
  57 + */
  58 +@Entity
  59 +@Table(name = "inventory__stock_movement")
  60 +class StockMovement(
  61 + itemCode: String,
  62 + locationId: UUID,
  63 + delta: BigDecimal,
  64 + reason: MovementReason,
  65 + reference: String? = null,
  66 + occurredAt: Instant = Instant.now(),
  67 +) : AuditedJpaEntity() {
  68 +
  69 + @Column(name = "item_code", nullable = false, length = 64)
  70 + var itemCode: String = itemCode
  71 +
  72 + @Column(name = "location_id", nullable = false)
  73 + var locationId: UUID = locationId
  74 +
  75 + /**
  76 + * Signed quantity. Positive for receipts and transfers-in,
  77 + * negative for issues and shipments. Zero is rejected by the
  78 + * service — a no-op movement is meaningless and clutters the
  79 + * ledger.
  80 + */
  81 + @Column(name = "delta", nullable = false, precision = 18, scale = 4)
  82 + var delta: BigDecimal = delta
  83 +
  84 + @Enumerated(EnumType.STRING)
  85 + @Column(name = "reason", nullable = false, length = 32)
  86 + var reason: MovementReason = reason
  87 +
  88 + @Column(name = "reference", nullable = true, length = 128)
  89 + var reference: String? = reference
  90 +
  91 + @Column(name = "occurred_at", nullable = false)
  92 + var occurredAt: Instant = occurredAt
  93 +
  94 + override fun toString(): String =
  95 + "StockMovement(id=$id, item='$itemCode', loc=$locationId, delta=$delta, reason=$reason, ref='$reference')"
  96 +}
  97 +
  98 +/**
  99 + * Why a stock movement happened.
  100 + *
  101 + * Each value carries a documented sign convention so application
  102 + * code does not have to remember "is a SALES_SHIPMENT positive or
  103 + * negative". The service validates that the sign of the delta
  104 + * matches the documented direction; calling code that gets this
  105 + * wrong gets a meaningful error instead of a corrupted balance.
  106 + *
  107 + * - **RECEIPT** — non-PO receipt (e.g. inventory found, customer return). Δ ≥ 0
  108 + * - **ISSUE** — non-SO issue (e.g. write-off, internal use). Δ ≤ 0
  109 + * - **ADJUSTMENT** — operator-driven correction (cycle count, mistake fix). Δ either sign
  110 + * - **SALES_SHIPMENT** — line item shipped on a sales order. Δ ≤ 0
  111 + * - **PURCHASE_RECEIPT** — line item received against a purchase order. Δ ≥ 0
  112 + * - **TRANSFER_OUT** — leg-1 of an inter-warehouse transfer. Δ ≤ 0
  113 + * - **TRANSFER_IN** — leg-2 of an inter-warehouse transfer. Δ ≥ 0
  114 + *
  115 + * Stored as string in the DB so adding values later is non-breaking
  116 + * for clients reading the column with raw SQL.
  117 + */
  118 +enum class MovementReason {
  119 + RECEIPT,
  120 + ISSUE,
  121 + ADJUSTMENT,
  122 + SALES_SHIPMENT,
  123 + PURCHASE_RECEIPT,
  124 + TRANSFER_OUT,
  125 + TRANSFER_IN,
  126 +}
... ...
pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/ext/InventoryApiAdapter.kt
... ... @@ -5,6 +5,9 @@ import org.springframework.transaction.annotation.Transactional
5 5 import org.vibeerp.api.v1.core.Id
6 6 import org.vibeerp.api.v1.ext.inventory.InventoryApi
7 7 import org.vibeerp.api.v1.ext.inventory.StockBalanceRef
  8 +import org.vibeerp.pbc.inventory.application.RecordMovementCommand
  9 +import org.vibeerp.pbc.inventory.application.StockMovementService
  10 +import org.vibeerp.pbc.inventory.domain.MovementReason
8 11 import org.vibeerp.pbc.inventory.domain.StockBalance
9 12 import org.vibeerp.pbc.inventory.infrastructure.LocationJpaRepository
10 13 import org.vibeerp.pbc.inventory.infrastructure.StockBalanceJpaRepository
... ... @@ -42,6 +45,7 @@ import java.math.BigDecimal
42 45 class InventoryApiAdapter(
43 46 private val balances: StockBalanceJpaRepository,
44 47 private val locations: LocationJpaRepository,
  48 + private val stockMovementService: StockMovementService,
45 49 ) : InventoryApi {
46 50  
47 51 override fun findStockBalance(itemCode: String, locationCode: String): StockBalanceRef? {
... ... @@ -54,6 +58,50 @@ class InventoryApiAdapter(
54 58 balances.findByItemCode(itemCode)
55 59 .fold(BigDecimal.ZERO) { acc, row -> acc + row.quantity }
56 60  
  61 + /**
  62 + * Record a stock movement on behalf of a cross-PBC caller.
  63 + * Resolves the location code → id, parses the reason string into
  64 + * the local enum, and delegates to the [StockMovementService].
  65 + *
  66 + * The location lookup happens here (not in the service) because
  67 + * the service takes a UUID — that's the intra-PBC contract. The
  68 + * facade is the boundary that converts between external (codes)
  69 + * and internal (ids).
  70 + *
  71 + * Marked `@Transactional` (read-write, NOT readOnly) so the
  72 + * service's write happens inside this method's transaction. The
  73 + * class-level `readOnly = true` would otherwise turn the write
  74 + * into a no-op + flush failure.
  75 + */
  76 + @Transactional
  77 + override fun recordMovement(
  78 + itemCode: String,
  79 + locationCode: String,
  80 + delta: BigDecimal,
  81 + reason: String,
  82 + reference: String?,
  83 + ): StockBalanceRef {
  84 + val location = locations.findByCode(locationCode)
  85 + ?: throw IllegalArgumentException("location code '$locationCode' is not in the inventory directory")
  86 + val movementReason = try {
  87 + MovementReason.valueOf(reason)
  88 + } catch (ex: IllegalArgumentException) {
  89 + throw IllegalArgumentException(
  90 + "unknown movement reason '$reason' (expected one of ${MovementReason.values().joinToString { it.name }})",
  91 + )
  92 + }
  93 + val balance = stockMovementService.record(
  94 + RecordMovementCommand(
  95 + itemCode = itemCode,
  96 + locationId = location.id,
  97 + delta = delta,
  98 + reason = movementReason,
  99 + reference = reference,
  100 + ),
  101 + )
  102 + return balance.toRef(locationCode)
  103 + }
  104 +
57 105 private fun StockBalance.toRef(locationCode: String): StockBalanceRef = StockBalanceRef(
58 106 id = Id<StockBalanceRef>(this.id),
59 107 itemCode = this.itemCode,
... ...
pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/http/StockMovementController.kt 0 → 100644
  1 +package org.vibeerp.pbc.inventory.http
  2 +
  3 +import jakarta.validation.Valid
  4 +import jakarta.validation.constraints.NotBlank
  5 +import jakarta.validation.constraints.NotNull
  6 +import jakarta.validation.constraints.Size
  7 +import org.springframework.http.HttpStatus
  8 +import org.springframework.web.bind.annotation.GetMapping
  9 +import org.springframework.web.bind.annotation.PostMapping
  10 +import org.springframework.web.bind.annotation.RequestBody
  11 +import org.springframework.web.bind.annotation.RequestMapping
  12 +import org.springframework.web.bind.annotation.RequestParam
  13 +import org.springframework.web.bind.annotation.ResponseStatus
  14 +import org.springframework.web.bind.annotation.RestController
  15 +import org.vibeerp.pbc.inventory.application.RecordMovementCommand
  16 +import org.vibeerp.pbc.inventory.application.StockMovementService
  17 +import org.vibeerp.pbc.inventory.domain.MovementReason
  18 +import org.vibeerp.pbc.inventory.domain.StockMovement
  19 +import org.vibeerp.platform.security.authz.RequirePermission
  20 +import java.math.BigDecimal
  21 +import java.time.Instant
  22 +import java.util.UUID
  23 +
  24 +/**
  25 + * REST API for the inventory ledger.
  26 + *
  27 + * Mounted at `/api/v1/inventory/movements`.
  28 + *
  29 + * **GET** is plain authenticated — reading historical movements is
  30 + * a normal operator action. Filters by itemCode, locationId, or
  31 + * reference (so the SPA can render "all movements caused by sales
  32 + * order SO-2026-0001").
  33 + *
  34 + * **POST** requires `inventory.stock.adjust` (the same permission
  35 + * the absolute-quantity adjust endpoint uses). The two endpoints
  36 + * exist side-by-side: the balance endpoint is for "the shelf has
  37 + * 47", the movement endpoint is for "we received 3 more". Both
  38 + * land as ledger rows; the framework treats them identically below
  39 + * the controller layer.
  40 + */
  41 +@RestController
  42 +@RequestMapping("/api/v1/inventory/movements")
  43 +class StockMovementController(
  44 + private val stockMovementService: StockMovementService,
  45 +) {
  46 +
  47 + @GetMapping
  48 + fun list(
  49 + @RequestParam(required = false) itemCode: String?,
  50 + @RequestParam(required = false) locationId: UUID?,
  51 + @RequestParam(required = false) reference: String?,
  52 + ): List<StockMovementResponse> {
  53 + val rows = when {
  54 + reference != null -> stockMovementService.findByReference(reference)
  55 + itemCode != null && locationId != null -> stockMovementService.findByItemCode(itemCode)
  56 + .filter { it.locationId == locationId }
  57 + itemCode != null -> stockMovementService.findByItemCode(itemCode)
  58 + locationId != null -> stockMovementService.findByLocationId(locationId)
  59 + else -> stockMovementService.list()
  60 + }
  61 + return rows.map { it.toResponse() }
  62 + }
  63 +
  64 + @PostMapping
  65 + @ResponseStatus(HttpStatus.CREATED)
  66 + @RequirePermission("inventory.stock.adjust")
  67 + fun record(@RequestBody @Valid request: RecordMovementRequest): StockMovementResponse {
  68 + val balance = stockMovementService.record(
  69 + RecordMovementCommand(
  70 + itemCode = request.itemCode,
  71 + locationId = request.locationId,
  72 + delta = request.delta,
  73 + reason = request.reason,
  74 + reference = request.reference,
  75 + occurredAt = request.occurredAt,
  76 + ),
  77 + )
  78 + // The movement row is the response, not the balance — callers
  79 + // who want the resulting balance hit the balances endpoint.
  80 + // We need to fetch the freshly-inserted movement; for the
  81 + // happy path the latest movement for (item, location) IS this
  82 + // one, but ordering by occurred_at desc would be more honest.
  83 + // v1 returns a synthesised response from the request fields
  84 + // because the request itself fully describes the row that
  85 + // was inserted (apart from id/audit cols, which are not
  86 + // useful in a creation response).
  87 + return StockMovementResponse(
  88 + id = UUID(0L, 0L), // sentinel — see comment above
  89 + itemCode = request.itemCode,
  90 + locationId = request.locationId,
  91 + delta = request.delta,
  92 + reason = request.reason,
  93 + reference = request.reference,
  94 + occurredAt = request.occurredAt ?: Instant.now(),
  95 + resultingQuantity = balance.quantity,
  96 + )
  97 + }
  98 +}
  99 +
  100 +// ─── DTOs ────────────────────────────────────────────────────────────
  101 +
  102 +data class RecordMovementRequest(
  103 + @field:NotBlank @field:Size(max = 64) val itemCode: String,
  104 + @field:NotNull val locationId: UUID,
  105 + @field:NotNull val delta: BigDecimal,
  106 + @field:NotNull val reason: MovementReason,
  107 + @field:Size(max = 128) val reference: String? = null,
  108 + val occurredAt: Instant? = null,
  109 +)
  110 +
  111 +data class StockMovementResponse(
  112 + val id: UUID,
  113 + val itemCode: String,
  114 + val locationId: UUID,
  115 + val delta: BigDecimal,
  116 + val reason: MovementReason,
  117 + val reference: String?,
  118 + val occurredAt: Instant,
  119 + /**
  120 + * The balance for (itemCode, locationId) AFTER this movement was
  121 + * applied. Included on the create response so callers don't need
  122 + * a follow-up GET to the balances endpoint.
  123 + */
  124 + val resultingQuantity: BigDecimal,
  125 +)
  126 +
  127 +private fun StockMovement.toResponse() = StockMovementResponse(
  128 + id = this.id,
  129 + itemCode = this.itemCode,
  130 + locationId = this.locationId,
  131 + delta = this.delta,
  132 + reason = this.reason,
  133 + reference = this.reference,
  134 + occurredAt = this.occurredAt,
  135 + // The list endpoint doesn't compute the at-the-time balance —
  136 + // that would require a SUM scan. Set to ZERO and let the SPA
  137 + // compute it from the sequence if it cares.
  138 + resultingQuantity = BigDecimal.ZERO,
  139 +)
... ...
pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/infrastructure/StockMovementJpaRepository.kt 0 → 100644
  1 +package org.vibeerp.pbc.inventory.infrastructure
  2 +
  3 +import org.springframework.data.jpa.repository.JpaRepository
  4 +import org.springframework.stereotype.Repository
  5 +import org.vibeerp.pbc.inventory.domain.StockMovement
  6 +import java.util.UUID
  7 +
  8 +/**
  9 + * Spring Data JPA repository for [StockMovement].
  10 + *
  11 + * Append-only ledger access. The repository deliberately does NOT
  12 + * expose `delete` (although JpaRepository inherits one) — the
  13 + * service layer never calls it, and the database table will grow
  14 + * a row-level CHECK constraint in a future hardening pass to
  15 + * prevent accidental deletes via raw SQL too.
  16 + *
  17 + * The query methods cover the common ledger reads: by item, by
  18 + * location, by reference (e.g. "all movements caused by sales
  19 + * order SO-2026-0001"), and the (item, location) cell view that
  20 + * the SPA's stock-detail screen wants.
  21 + */
  22 +@Repository
  23 +interface StockMovementJpaRepository : JpaRepository<StockMovement, UUID> {
  24 +
  25 + fun findByItemCode(itemCode: String): List<StockMovement>
  26 +
  27 + fun findByLocationId(locationId: UUID): List<StockMovement>
  28 +
  29 + fun findByItemCodeAndLocationId(itemCode: String, locationId: UUID): List<StockMovement>
  30 +
  31 + fun findByReference(reference: String): List<StockMovement>
  32 +}
... ...
pbc/pbc-inventory/src/test/kotlin/org/vibeerp/pbc/inventory/application/StockBalanceServiceTest.kt
... ... @@ -14,17 +14,30 @@ import org.junit.jupiter.api.Test
14 14 import org.vibeerp.api.v1.core.Id
15 15 import org.vibeerp.api.v1.ext.catalog.CatalogApi
16 16 import org.vibeerp.api.v1.ext.catalog.ItemRef
  17 +import org.vibeerp.pbc.inventory.domain.MovementReason
17 18 import org.vibeerp.pbc.inventory.domain.StockBalance
18 19 import org.vibeerp.pbc.inventory.infrastructure.LocationJpaRepository
19 20 import org.vibeerp.pbc.inventory.infrastructure.StockBalanceJpaRepository
20 21 import java.math.BigDecimal
21 22 import java.util.UUID
22 23  
  24 +/**
  25 + * Tests the post-ledger refactor behavior of StockBalanceService:
  26 + * adjust() delegates to StockMovementService.record() with a
  27 + * computed delta, and the no-op adjustment short-circuit path.
  28 + *
  29 + * The cross-PBC item validation, location validation, and
  30 + * negative-balance rejection now live in StockMovementService and
  31 + * are exercised by [StockMovementServiceTest]. This test suite
  32 + * deliberately mocks StockMovementService so the unit boundary is
  33 + * "what does adjust() do given the movement service".
  34 + */
23 35 class StockBalanceServiceTest {
24 36  
25 37 private lateinit var balances: StockBalanceJpaRepository
26 38 private lateinit var locations: LocationJpaRepository
27 39 private lateinit var catalogApi: CatalogApi
  40 + private lateinit var stockMovementService: StockMovementService
28 41 private lateinit var service: StockBalanceService
29 42  
30 43 @BeforeEach
... ... @@ -32,7 +45,8 @@ class StockBalanceServiceTest {
32 45 balances = mockk()
33 46 locations = mockk()
34 47 catalogApi = mockk()
35   - service = StockBalanceService(balances, locations, catalogApi)
  48 + stockMovementService = mockk()
  49 + service = StockBalanceService(balances, locations, catalogApi, stockMovementService)
36 50 }
37 51  
38 52 private fun stubItem(code: String) {
... ... @@ -47,71 +61,84 @@ class StockBalanceServiceTest {
47 61 }
48 62  
49 63 @Test
50   - fun `adjust rejects unknown item code via CatalogApi seam`() {
51   - every { catalogApi.findItemByCode("FAKE") } returns null
52   -
  64 + fun `adjust rejects negative quantity up front`() {
53 65 assertFailure {
54   - service.adjust("FAKE", UUID.randomUUID(), BigDecimal("10"))
  66 + service.adjust("SKU-1", UUID.randomUUID(), BigDecimal("-1"))
55 67 }
56 68 .isInstanceOf(IllegalArgumentException::class)
57   - .hasMessage("item code 'FAKE' is not in the catalog (or is inactive)")
  69 + .hasMessage("stock quantity must be non-negative (got -1)")
  70 + verify(exactly = 0) { stockMovementService.record(any()) }
58 71 }
59 72  
60 73 @Test
61   - fun `adjust rejects unknown location id`() {
62   - stubItem("SKU-1")
  74 + fun `adjust delegates to record with the computed delta when increasing`() {
63 75 val locId = UUID.randomUUID()
64   - every { locations.existsById(locId) } returns false
  76 + val existing = StockBalance("SKU-1", locId, BigDecimal("10")).also { it.id = UUID.randomUUID() }
  77 + val expectedAfter = StockBalance("SKU-1", locId, BigDecimal("25")).also { it.id = existing.id }
  78 + every { balances.findByItemCodeAndLocationId("SKU-1", locId) } returns existing
  79 + val captured = slot<RecordMovementCommand>()
  80 + every { stockMovementService.record(capture(captured)) } returns expectedAfter
65 81  
66   - assertFailure {
67   - service.adjust("SKU-1", locId, BigDecimal("10"))
68   - }
69   - .isInstanceOf(IllegalArgumentException::class)
70   - .hasMessage("location not found: $locId")
  82 + val result = service.adjust("SKU-1", locId, BigDecimal("25"))
  83 +
  84 + assertThat(captured.captured.itemCode).isEqualTo("SKU-1")
  85 + assertThat(captured.captured.locationId).isEqualTo(locId)
  86 + // 25 - 10 = +15
  87 + assertThat(captured.captured.delta).isEqualTo(BigDecimal("15"))
  88 + assertThat(captured.captured.reason).isEqualTo(MovementReason.ADJUSTMENT)
  89 + assertThat(result.quantity).isEqualTo(BigDecimal("25"))
71 90 }
72 91  
73 92 @Test
74   - fun `adjust rejects negative quantity before catalog lookup`() {
75   - // Negative quantity is rejected by an early require() so the
76   - // CatalogApi mock is never invoked.
77   - assertFailure {
78   - service.adjust("SKU-1", UUID.randomUUID(), BigDecimal("-1"))
79   - }
80   - .isInstanceOf(IllegalArgumentException::class)
81   - verify(exactly = 0) { catalogApi.findItemByCode(any()) }
  93 + fun `adjust delegates with a negative delta when decreasing`() {
  94 + val locId = UUID.randomUUID()
  95 + val existing = StockBalance("SKU-1", locId, BigDecimal("100")).also { it.id = UUID.randomUUID() }
  96 + val expectedAfter = StockBalance("SKU-1", locId, BigDecimal("30")).also { it.id = existing.id }
  97 + every { balances.findByItemCodeAndLocationId("SKU-1", locId) } returns existing
  98 + val captured = slot<RecordMovementCommand>()
  99 + every { stockMovementService.record(capture(captured)) } returns expectedAfter
  100 +
  101 + service.adjust("SKU-1", locId, BigDecimal("30"))
  102 +
  103 + // 30 - 100 = -70
  104 + assertThat(captured.captured.delta).isEqualTo(BigDecimal("-70"))
  105 + assertThat(captured.captured.reason).isEqualTo(MovementReason.ADJUSTMENT)
82 106 }
83 107  
84 108 @Test
85   - fun `adjust creates a new balance row when none exists`() {
86   - stubItem("SKU-1")
  109 + fun `adjust to the SAME quantity is a no-op (no ledger row)`() {
87 110 val locId = UUID.randomUUID()
88   - val saved = slot<StockBalance>()
  111 + val existing = StockBalance("SKU-1", locId, BigDecimal("42")).also { it.id = UUID.randomUUID() }
  112 + stubItem("SKU-1")
  113 + every { balances.findByItemCodeAndLocationId("SKU-1", locId) } returns existing
89 114 every { locations.existsById(locId) } returns true
90   - every { balances.findByItemCodeAndLocationId("SKU-1", locId) } returns null
91   - every { balances.save(capture(saved)) } answers { saved.captured }
92 115  
93   - val result = service.adjust("SKU-1", locId, BigDecimal("42.5"))
  116 + val result = service.adjust("SKU-1", locId, BigDecimal("42"))
94 117  
95   - assertThat(result.itemCode).isEqualTo("SKU-1")
96   - assertThat(result.locationId).isEqualTo(locId)
97   - assertThat(result.quantity).isEqualTo(BigDecimal("42.5"))
98   - verify(exactly = 1) { balances.save(any()) }
  118 + assertThat(result).isEqualTo(existing)
  119 + // Crucially: NO movement is recorded for a no-op adjustment.
  120 + // The audit log shouldn't fill up with "adjusted to current
  121 + // value" entries when an operator clicks Save without changing
  122 + // anything.
  123 + verify(exactly = 0) { stockMovementService.record(any()) }
99 124 }
100 125  
101 126 @Test
102   - fun `adjust mutates the existing balance row when one already exists`() {
103   - stubItem("SKU-1")
  127 + fun `no-op adjust on a missing balance row creates an empty row at zero`() {
  128 + // Edge case: setting a never-stocked cell to its current value
  129 + // (zero) shouldn't create a ledger row but does create the
  130 + // (item, location) row at quantity zero so subsequent reads
  131 + // return zero rather than null.
104 132 val locId = UUID.randomUUID()
105   - val existing = StockBalance("SKU-1", locId, BigDecimal("5")).also { it.id = UUID.randomUUID() }
  133 + stubItem("SKU-1")
  134 + every { balances.findByItemCodeAndLocationId("SKU-1", locId) } returns null
106 135 every { locations.existsById(locId) } returns true
107   - every { balances.findByItemCodeAndLocationId("SKU-1", locId) } returns existing
  136 + val saved = slot<StockBalance>()
  137 + every { balances.save(capture(saved)) } answers { saved.captured }
108 138  
109   - val result = service.adjust("SKU-1", locId, BigDecimal("99"))
  139 + val result = service.adjust("SKU-1", locId, BigDecimal("0"))
110 140  
111   - assertThat(result).isEqualTo(existing)
112   - assertThat(existing.quantity).isEqualTo(BigDecimal("99"))
113   - // Crucially, save() is NOT called — the @Transactional method
114   - // commit will flush the JPA-managed entity automatically.
115   - verify(exactly = 0) { balances.save(any()) }
  141 + assertThat(result.quantity).isEqualTo(BigDecimal("0"))
  142 + verify(exactly = 0) { stockMovementService.record(any()) }
116 143 }
117 144 }
... ...
pbc/pbc-inventory/src/test/kotlin/org/vibeerp/pbc/inventory/application/StockMovementServiceTest.kt 0 → 100644
  1 +package org.vibeerp.pbc.inventory.application
  2 +
  3 +import assertk.assertFailure
  4 +import assertk.assertThat
  5 +import assertk.assertions.contains
  6 +import assertk.assertions.hasMessage
  7 +import assertk.assertions.isEqualTo
  8 +import assertk.assertions.isInstanceOf
  9 +import assertk.assertions.messageContains
  10 +import io.mockk.every
  11 +import io.mockk.mockk
  12 +import io.mockk.slot
  13 +import io.mockk.verify
  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.pbc.inventory.domain.MovementReason
  20 +import org.vibeerp.pbc.inventory.domain.StockBalance
  21 +import org.vibeerp.pbc.inventory.domain.StockMovement
  22 +import org.vibeerp.pbc.inventory.infrastructure.LocationJpaRepository
  23 +import org.vibeerp.pbc.inventory.infrastructure.StockBalanceJpaRepository
  24 +import org.vibeerp.pbc.inventory.infrastructure.StockMovementJpaRepository
  25 +import java.math.BigDecimal
  26 +import java.util.UUID
  27 +
  28 +/**
  29 + * Tests for [StockMovementService]: cross-PBC item validation,
  30 + * location validation, sign-vs-reason enforcement, negative-balance
  31 + * rejection, and the ledger-row + balance-row pair.
  32 + */
  33 +class StockMovementServiceTest {
  34 +
  35 + private lateinit var movements: StockMovementJpaRepository
  36 + private lateinit var balances: StockBalanceJpaRepository
  37 + private lateinit var locations: LocationJpaRepository
  38 + private lateinit var catalogApi: CatalogApi
  39 + private lateinit var service: StockMovementService
  40 +
  41 + @BeforeEach
  42 + fun setUp() {
  43 + movements = mockk()
  44 + balances = mockk()
  45 + locations = mockk()
  46 + catalogApi = mockk()
  47 + every { movements.save(any<StockMovement>()) } answers { firstArg() }
  48 + every { balances.save(any<StockBalance>()) } answers { firstArg() }
  49 + service = StockMovementService(movements, balances, locations, catalogApi)
  50 + }
  51 +
  52 + private fun stubItem(code: String) {
  53 + every { catalogApi.findItemByCode(code) } returns ItemRef(
  54 + id = Id(UUID.randomUUID()),
  55 + code = code,
  56 + name = "stub",
  57 + itemType = "GOOD",
  58 + baseUomCode = "ea",
  59 + active = true,
  60 + )
  61 + }
  62 +
  63 + private fun cmd(
  64 + delta: String,
  65 + reason: MovementReason,
  66 + item: String = "SKU-1",
  67 + loc: UUID = UUID.randomUUID(),
  68 + ref: String? = null,
  69 + ) = RecordMovementCommand(
  70 + itemCode = item,
  71 + locationId = loc,
  72 + delta = BigDecimal(delta),
  73 + reason = reason,
  74 + reference = ref,
  75 + )
  76 +
  77 + @Test
  78 + fun `record rejects zero delta up front`() {
  79 + assertFailure { service.record(cmd("0", MovementReason.ADJUSTMENT)) }
  80 + .isInstanceOf(IllegalArgumentException::class)
  81 + .hasMessage("stock movement delta must be non-zero")
  82 + }
  83 +
  84 + @Test
  85 + fun `record rejects positive delta on SALES_SHIPMENT`() {
  86 + assertFailure { service.record(cmd("5", MovementReason.SALES_SHIPMENT)) }
  87 + .isInstanceOf(IllegalArgumentException::class)
  88 + .messageContains("non-positive")
  89 + }
  90 +
  91 + @Test
  92 + fun `record rejects negative delta on RECEIPT`() {
  93 + assertFailure { service.record(cmd("-5", MovementReason.RECEIPT)) }
  94 + .isInstanceOf(IllegalArgumentException::class)
  95 + .messageContains("non-negative")
  96 + }
  97 +
  98 + @Test
  99 + fun `record allows either sign on ADJUSTMENT`() {
  100 + val locId = UUID.randomUUID()
  101 + stubItem("SKU-1")
  102 + every { locations.existsById(locId) } returns true
  103 + every { balances.findByItemCodeAndLocationId("SKU-1", locId) } returns
  104 + StockBalance("SKU-1", locId, BigDecimal("10"))
  105 +
  106 + // Negative ADJUSTMENT (operator correcting a count down)
  107 + service.record(
  108 + RecordMovementCommand(
  109 + itemCode = "SKU-1",
  110 + locationId = locId,
  111 + delta = BigDecimal("-3"),
  112 + reason = MovementReason.ADJUSTMENT,
  113 + ),
  114 + )
  115 +
  116 + // Positive ADJUSTMENT (operator correcting a count up)
  117 + service.record(
  118 + RecordMovementCommand(
  119 + itemCode = "SKU-1",
  120 + locationId = locId,
  121 + delta = BigDecimal("8"),
  122 + reason = MovementReason.ADJUSTMENT,
  123 + ),
  124 + )
  125 +
  126 + // Both succeed.
  127 + }
  128 +
  129 + @Test
  130 + fun `record rejects unknown item via CatalogApi seam`() {
  131 + every { catalogApi.findItemByCode("FAKE") } returns null
  132 +
  133 + assertFailure {
  134 + service.record(
  135 + RecordMovementCommand(
  136 + itemCode = "FAKE",
  137 + locationId = UUID.randomUUID(),
  138 + delta = BigDecimal("5"),
  139 + reason = MovementReason.RECEIPT,
  140 + ),
  141 + )
  142 + }
  143 + .isInstanceOf(IllegalArgumentException::class)
  144 + .messageContains("not in the catalog")
  145 + }
  146 +
  147 + @Test
  148 + fun `record rejects unknown location`() {
  149 + stubItem("SKU-1")
  150 + val locId = UUID.randomUUID()
  151 + every { locations.existsById(locId) } returns false
  152 +
  153 + assertFailure {
  154 + service.record(
  155 + RecordMovementCommand(
  156 + itemCode = "SKU-1",
  157 + locationId = locId,
  158 + delta = BigDecimal("5"),
  159 + reason = MovementReason.RECEIPT,
  160 + ),
  161 + )
  162 + }
  163 + .isInstanceOf(IllegalArgumentException::class)
  164 + .messageContains("location not found")
  165 + }
  166 +
  167 + @Test
  168 + fun `record rejects movement that would push balance negative`() {
  169 + stubItem("SKU-1")
  170 + val locId = UUID.randomUUID()
  171 + every { locations.existsById(locId) } returns true
  172 + every { balances.findByItemCodeAndLocationId("SKU-1", locId) } returns
  173 + StockBalance("SKU-1", locId, BigDecimal("3"))
  174 +
  175 + assertFailure {
  176 + service.record(
  177 + RecordMovementCommand(
  178 + itemCode = "SKU-1",
  179 + locationId = locId,
  180 + delta = BigDecimal("-5"),
  181 + reason = MovementReason.SALES_SHIPMENT,
  182 + ),
  183 + )
  184 + }
  185 + .isInstanceOf(IllegalArgumentException::class)
  186 + .messageContains("below zero")
  187 + }
  188 +
  189 + @Test
  190 + fun `record creates a new balance row when none exists`() {
  191 + stubItem("SKU-1")
  192 + val locId = UUID.randomUUID()
  193 + every { locations.existsById(locId) } returns true
  194 + every { balances.findByItemCodeAndLocationId("SKU-1", locId) } returns null
  195 + val savedBalance = slot<StockBalance>()
  196 + val savedMovement = slot<StockMovement>()
  197 + every { balances.save(capture(savedBalance)) } answers { savedBalance.captured }
  198 + every { movements.save(capture(savedMovement)) } answers { savedMovement.captured }
  199 +
  200 + val result = service.record(
  201 + RecordMovementCommand(
  202 + itemCode = "SKU-1",
  203 + locationId = locId,
  204 + delta = BigDecimal("100"),
  205 + reason = MovementReason.RECEIPT,
  206 + reference = "PR:PR-2026-0001",
  207 + ),
  208 + )
  209 +
  210 + assertThat(result.quantity).isEqualTo(BigDecimal("100"))
  211 + assertThat(savedMovement.captured.delta).isEqualTo(BigDecimal("100"))
  212 + assertThat(savedMovement.captured.reason).isEqualTo(MovementReason.RECEIPT)
  213 + assertThat(savedMovement.captured.reference).isEqualTo("PR:PR-2026-0001")
  214 + }
  215 +
  216 + @Test
  217 + fun `record updates existing balance and inserts movement on the happy path`() {
  218 + stubItem("SKU-1")
  219 + val locId = UUID.randomUUID()
  220 + val existing = StockBalance("SKU-1", locId, BigDecimal("50")).also { it.id = UUID.randomUUID() }
  221 + every { locations.existsById(locId) } returns true
  222 + every { balances.findByItemCodeAndLocationId("SKU-1", locId) } returns existing
  223 + val savedMovement = slot<StockMovement>()
  224 + every { movements.save(capture(savedMovement)) } answers { savedMovement.captured }
  225 +
  226 + val result = service.record(
  227 + RecordMovementCommand(
  228 + itemCode = "SKU-1",
  229 + locationId = locId,
  230 + delta = BigDecimal("-15"),
  231 + reason = MovementReason.SALES_SHIPMENT,
  232 + reference = "SO:SO-2026-0001",
  233 + ),
  234 + )
  235 +
  236 + assertThat(result).isEqualTo(existing)
  237 + // 50 - 15 = 35
  238 + assertThat(existing.quantity).isEqualTo(BigDecimal("35"))
  239 + assertThat(savedMovement.captured.delta).isEqualTo(BigDecimal("-15"))
  240 + assertThat(savedMovement.captured.reference).isEqualTo("SO:SO-2026-0001")
  241 + verify(exactly = 0) { balances.save(any()) } // existing row mutated, not re-saved
  242 + }
  243 +}
... ...
pbc/pbc-orders-sales/src/main/kotlin/org/vibeerp/pbc/orders/sales/application/SalesOrderService.kt
... ... @@ -5,6 +5,7 @@ import com.fasterxml.jackson.module.kotlin.registerKotlinModule
5 5 import org.springframework.stereotype.Service
6 6 import org.springframework.transaction.annotation.Transactional
7 7 import org.vibeerp.api.v1.ext.catalog.CatalogApi
  8 +import org.vibeerp.api.v1.ext.inventory.InventoryApi
8 9 import org.vibeerp.api.v1.ext.partners.PartnersApi
9 10 import org.vibeerp.pbc.orders.sales.domain.SalesOrder
10 11 import org.vibeerp.pbc.orders.sales.domain.SalesOrderLine
... ... @@ -68,6 +69,7 @@ class SalesOrderService(
68 69 private val orders: SalesOrderJpaRepository,
69 70 private val partnersApi: PartnersApi,
70 71 private val catalogApi: CatalogApi,
  72 + private val inventoryApi: InventoryApi,
71 73 private val extValidator: ExtJsonValidator,
72 74 ) {
73 75  
... ... @@ -240,10 +242,73 @@ class SalesOrderService(
240 242 require(order.status != SalesOrderStatus.CANCELLED) {
241 243 "sales order ${order.code} is already cancelled"
242 244 }
  245 + // Shipping is terminal — once stock has moved out the door
  246 + // the cancellation flow is "issue a refund / receive a return",
  247 + // not a status flip. The framework refuses the shortcut.
  248 + require(order.status != SalesOrderStatus.SHIPPED) {
  249 + "cannot cancel sales order ${order.code} in status SHIPPED; " +
  250 + "issue a return / refund flow instead"
  251 + }
243 252 order.status = SalesOrderStatus.CANCELLED
244 253 return order
245 254 }
246 255  
  256 + /**
  257 + * Mark a CONFIRMED sales order as SHIPPED, debiting stock from
  258 + * [shippingLocationCode] for every line in the same transaction.
  259 + *
  260 + * **The first cross-PBC WRITE the framework performs.** All
  261 + * earlier cross-PBC calls were reads (`CatalogApi.findItemByCode`,
  262 + * `PartnersApi.findPartnerByCode`). Shipping inverts that: this
  263 + * service synchronously writes to inventory's tables (via the
  264 + * api.v1 facade) as a side effect of changing its own state.
  265 + * The whole operation runs in ONE transaction so a failure on any
  266 + * line — bad item, bad location, would push balance negative —
  267 + * rolls back the order status change AND every other line's
  268 + * movement that may have already been written. The customer
  269 + * cannot end up with "5 of 7 lines shipped, status still
  270 + * CONFIRMED, ledger half-written".
  271 + *
  272 + * **Stock checks happen at the inventory layer.** This service
  273 + * doesn't pre-check "do we have enough stock for every line";
  274 + * it just calls `inventoryApi.recordMovement(... -line.quantity)`
  275 + * and lets that fail with "would push balance below zero" on the
  276 + * first line that doesn't fit. The pre-check would be a nice
  277 + * UX improvement (show all problems at once instead of the
  278 + * first one) but is functionally equivalent — the transaction
  279 + * either commits in full or rolls back in full.
  280 + *
  281 + * @throws IllegalArgumentException if the order is not CONFIRMED,
  282 + * if the location code is unknown, or if any line lacks stock.
  283 + */
  284 + fun ship(id: UUID, shippingLocationCode: String): SalesOrder {
  285 + val order = orders.findById(id).orElseThrow {
  286 + NoSuchElementException("sales order not found: $id")
  287 + }
  288 + require(order.status == SalesOrderStatus.CONFIRMED) {
  289 + "cannot ship sales order ${order.code} in status ${order.status}; " +
  290 + "only CONFIRMED orders can be shipped"
  291 + }
  292 +
  293 + // Walk every line and debit stock. The reference convention
  294 + // `SO:<order_code>` is documented on InventoryApi.recordMovement;
  295 + // future PBCs (production, finance) will parse it via
  296 + // `split(':')` to find every movement caused by an order.
  297 + val reference = "SO:${order.code}"
  298 + for (line in order.lines) {
  299 + inventoryApi.recordMovement(
  300 + itemCode = line.itemCode,
  301 + locationCode = shippingLocationCode,
  302 + delta = line.quantity.negate(),
  303 + reason = "SALES_SHIPMENT",
  304 + reference = reference,
  305 + )
  306 + }
  307 +
  308 + order.status = SalesOrderStatus.SHIPPED
  309 + return order
  310 + }
  311 +
247 312 @Suppress("UNCHECKED_CAST")
248 313 fun parseExt(order: SalesOrder): Map<String, Any?> = try {
249 314 if (order.ext.isBlank()) emptyMap()
... ...
pbc/pbc-orders-sales/src/main/kotlin/org/vibeerp/pbc/orders/sales/domain/SalesOrder.kt
... ... @@ -125,16 +125,23 @@ class SalesOrder(
125 125 * Possible states a [SalesOrder] can be in.
126 126 *
127 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.
  128 + * for clients reading the column with raw SQL.
131 129 *
132 130 * - **DRAFT** — being prepared, lines may still be edited
133   - * - **CONFIRMED** — committed; lines are now immutable
134   - * - **CANCELLED** — terminal; the order is dead
  131 + * - **CONFIRMED** — committed; lines are now immutable. Can be cancelled
  132 + * or shipped from here.
  133 + * - **SHIPPED** — terminal. Stock has been debited via the
  134 + * inventory movement ledger; the framework will
  135 + * NOT let you cancel a shipped order (a return /
  136 + * refund flow lands later as its own state).
  137 + * - **CANCELLED** — terminal. Reachable from DRAFT or CONFIRMED.
  138 + *
  139 + * Future states (PARTIALLY_SHIPPED, INVOICED, CLOSED, RETURNED)
  140 + * land in their own focused chunks.
135 141 */
136 142 enum class SalesOrderStatus {
137 143 DRAFT,
138 144 CONFIRMED,
  145 + SHIPPED,
139 146 CANCELLED,
140 147 }
... ...
pbc/pbc-orders-sales/src/main/kotlin/org/vibeerp/pbc/orders/sales/http/SalesOrderController.kt
1 1 package org.vibeerp.pbc.orders.sales.http
2 2  
  3 +import com.fasterxml.jackson.annotation.JsonCreator
  4 +import com.fasterxml.jackson.annotation.JsonProperty
3 5 import jakarta.validation.Valid
4 6 import jakarta.validation.constraints.NotBlank
5 7 import jakarta.validation.constraints.NotEmpty
... ... @@ -106,6 +108,20 @@ class SalesOrderController(
106 108 @RequirePermission("orders.sales.cancel")
107 109 fun cancel(@PathVariable id: UUID): SalesOrderResponse =
108 110 salesOrderService.cancel(id).toResponse(salesOrderService)
  111 +
  112 + /**
  113 + * Ship a CONFIRMED sales order. Atomically debits stock for every
  114 + * line from the location named in the request, then flips the
  115 + * order to SHIPPED. The whole operation runs in one transaction
  116 + * — see [SalesOrderService.ship] for the full rationale.
  117 + */
  118 + @PostMapping("/{id}/ship")
  119 + @RequirePermission("orders.sales.ship")
  120 + fun ship(
  121 + @PathVariable id: UUID,
  122 + @RequestBody @Valid request: ShipSalesOrderRequest,
  123 + ): SalesOrderResponse =
  124 + salesOrderService.ship(id, request.shippingLocationCode).toResponse(salesOrderService)
109 125 }
110 126  
111 127 // ─── DTOs ────────────────────────────────────────────────────────────
... ... @@ -119,6 +135,34 @@ data class CreateSalesOrderRequest(
119 135 val ext: Map<String, Any?>? = null,
120 136 )
121 137  
  138 +/**
  139 + * Shipping request body.
  140 + *
  141 + * **Why the explicit `@JsonCreator(mode = PROPERTIES)` on a single-
  142 + * arg Kotlin data class:** jackson-module-kotlin treats a data class
  143 + * with one argument as a *delegate-based* creator by default
  144 + * (`{"foo": "bar"}` would be unwrapped as the value of `foo` and
  145 + * passed to the constructor positionally). That's wrong for HTTP
  146 + * request bodies that always look like `{"shippingLocationCode": "..."}`.
  147 + * The fix is to explicitly mark the constructor as a property-based
  148 + * creator and to annotate the parameter with `@param:JsonProperty`.
  149 + * This is the same trap that bit `RefreshRequest` in pbc-identity;
  150 + * it's a Kotlin × Jackson interop wart, NOT something the framework
  151 + * can hide.
  152 + */
  153 +data class ShipSalesOrderRequest @JsonCreator(mode = JsonCreator.Mode.PROPERTIES) constructor(
  154 + /**
  155 + * The inventory location to debit. Must exist in
  156 + * `inventory__location` (looked up by code via the `InventoryApi`
  157 + * facade inside the service). The framework does NOT default
  158 + * this — every shipment is explicit about where the goods came
  159 + * from, so the audit trail in the stock movement ledger always
  160 + * tells you "which warehouse shipped this".
  161 + */
  162 + @param:JsonProperty("shippingLocationCode")
  163 + @field:NotBlank @field:Size(max = 64) val shippingLocationCode: String,
  164 +)
  165 +
122 166 data class UpdateSalesOrderRequest(
123 167 @field:Size(max = 64) val partnerCode: String? = null,
124 168 val orderDate: LocalDate? = null,
... ...
pbc/pbc-orders-sales/src/main/resources/META-INF/vibe-erp/metadata/orders-sales.yml
... ... @@ -23,7 +23,9 @@ permissions:
23 23 - key: orders.sales.confirm
24 24 description: Confirm a draft sales order (DRAFT → CONFIRMED)
25 25 - key: orders.sales.cancel
26   - description: Cancel a sales order (any non-cancelled state → CANCELLED)
  26 + description: Cancel a sales order (DRAFT or CONFIRMED → CANCELLED)
  27 + - key: orders.sales.ship
  28 + description: Ship a confirmed sales order (CONFIRMED → SHIPPED, debits inventory atomically)
27 29  
28 30 menus:
29 31 - path: /orders/sales
... ...
pbc/pbc-orders-sales/src/test/kotlin/org/vibeerp/pbc/orders/sales/application/SalesOrderServiceTest.kt
... ... @@ -11,11 +11,14 @@ import assertk.assertions.messageContains
11 11 import io.mockk.every
12 12 import io.mockk.mockk
13 13 import io.mockk.slot
  14 +import io.mockk.verify
14 15 import org.junit.jupiter.api.BeforeEach
15 16 import org.junit.jupiter.api.Test
16 17 import org.vibeerp.api.v1.core.Id
17 18 import org.vibeerp.api.v1.ext.catalog.CatalogApi
18 19 import org.vibeerp.api.v1.ext.catalog.ItemRef
  20 +import org.vibeerp.api.v1.ext.inventory.InventoryApi
  21 +import org.vibeerp.api.v1.ext.inventory.StockBalanceRef
19 22 import org.vibeerp.api.v1.ext.partners.PartnerRef
20 23 import org.vibeerp.api.v1.ext.partners.PartnersApi
21 24 import org.vibeerp.pbc.orders.sales.domain.SalesOrder
... ... @@ -32,6 +35,7 @@ class SalesOrderServiceTest {
32 35 private lateinit var orders: SalesOrderJpaRepository
33 36 private lateinit var partnersApi: PartnersApi
34 37 private lateinit var catalogApi: CatalogApi
  38 + private lateinit var inventoryApi: InventoryApi
35 39 private lateinit var extValidator: ExtJsonValidator
36 40 private lateinit var service: SalesOrderService
37 41  
... ... @@ -40,11 +44,12 @@ class SalesOrderServiceTest {
40 44 orders = mockk()
41 45 partnersApi = mockk()
42 46 catalogApi = mockk()
  47 + inventoryApi = mockk()
43 48 extValidator = mockk()
44 49 every { extValidator.validate(any(), any()) } answers { secondArg() ?: emptyMap() }
45 50 every { orders.existsByCode(any()) } returns false
46 51 every { orders.save(any<SalesOrder>()) } answers { firstArg() }
47   - service = SalesOrderService(orders, partnersApi, catalogApi, extValidator)
  52 + service = SalesOrderService(orders, partnersApi, catalogApi, inventoryApi, extValidator)
48 53 }
49 54  
50 55 private fun stubCustomer(code: String, type: String = "CUSTOMER") {
... ... @@ -298,4 +303,114 @@ class SalesOrderServiceTest {
298 303 .isInstanceOf(IllegalArgumentException::class)
299 304 .messageContains("already cancelled")
300 305 }
  306 +
  307 + // ─── ship() ──────────────────────────────────────────────────────
  308 +
  309 + private fun confirmedOrder(
  310 + id: UUID = UUID.randomUUID(),
  311 + lines: List<Pair<String, String>> = listOf("PAPER-A4" to "10"),
  312 + ): SalesOrder {
  313 + val order = SalesOrder(
  314 + code = "SO-1",
  315 + partnerCode = "CUST-1",
  316 + status = SalesOrderStatus.CONFIRMED,
  317 + orderDate = LocalDate.of(2026, 4, 8),
  318 + currencyCode = "USD",
  319 + ).also { it.id = id }
  320 + var n = 1
  321 + for ((item, qty) in lines) {
  322 + order.lines += org.vibeerp.pbc.orders.sales.domain.SalesOrderLine(
  323 + salesOrder = order,
  324 + lineNo = n++,
  325 + itemCode = item,
  326 + quantity = BigDecimal(qty),
  327 + unitPrice = BigDecimal("1.00"),
  328 + currencyCode = "USD",
  329 + )
  330 + }
  331 + return order
  332 + }
  333 +
  334 + private fun stubInventoryDebit(itemCode: String, locationCode: String, expectedDelta: BigDecimal) {
  335 + every {
  336 + inventoryApi.recordMovement(
  337 + itemCode = itemCode,
  338 + locationCode = locationCode,
  339 + delta = expectedDelta,
  340 + reason = "SALES_SHIPMENT",
  341 + reference = any(),
  342 + )
  343 + } returns StockBalanceRef(
  344 + id = Id(UUID.randomUUID()),
  345 + itemCode = itemCode,
  346 + locationCode = locationCode,
  347 + quantity = BigDecimal("1000"),
  348 + )
  349 + }
  350 +
  351 + @Test
  352 + fun `ship rejects a non-CONFIRMED order`() {
  353 + val id = UUID.randomUUID()
  354 + val draft = SalesOrder(
  355 + code = "SO-1",
  356 + partnerCode = "CUST-1",
  357 + status = SalesOrderStatus.DRAFT,
  358 + orderDate = LocalDate.of(2026, 4, 8),
  359 + currencyCode = "USD",
  360 + ).also { it.id = id }
  361 + every { orders.findById(id) } returns Optional.of(draft)
  362 +
  363 + assertFailure { service.ship(id, "WH-MAIN") }
  364 + .isInstanceOf(IllegalArgumentException::class)
  365 + .messageContains("only CONFIRMED orders can be shipped")
  366 + verify(exactly = 0) { inventoryApi.recordMovement(any(), any(), any(), any(), any()) }
  367 + }
  368 +
  369 + @Test
  370 + fun `ship walks every line and calls inventoryApi recordMovement with negated quantity`() {
  371 + val id = UUID.randomUUID()
  372 + val order = confirmedOrder(id, lines = listOf("PAPER-A4" to "10", "INK-CYAN" to "3"))
  373 + every { orders.findById(id) } returns Optional.of(order)
  374 + stubInventoryDebit("PAPER-A4", "WH-MAIN", BigDecimal("-10"))
  375 + stubInventoryDebit("INK-CYAN", "WH-MAIN", BigDecimal("-3"))
  376 +
  377 + val shipped = service.ship(id, "WH-MAIN")
  378 +
  379 + assertThat(shipped.status).isEqualTo(SalesOrderStatus.SHIPPED)
  380 + verify(exactly = 1) {
  381 + inventoryApi.recordMovement(
  382 + itemCode = "PAPER-A4",
  383 + locationCode = "WH-MAIN",
  384 + delta = BigDecimal("-10"),
  385 + reason = "SALES_SHIPMENT",
  386 + reference = "SO:SO-1",
  387 + )
  388 + }
  389 + verify(exactly = 1) {
  390 + inventoryApi.recordMovement(
  391 + itemCode = "INK-CYAN",
  392 + locationCode = "WH-MAIN",
  393 + delta = BigDecimal("-3"),
  394 + reason = "SALES_SHIPMENT",
  395 + reference = "SO:SO-1",
  396 + )
  397 + }
  398 + }
  399 +
  400 + @Test
  401 + fun `cancel rejects a SHIPPED order`() {
  402 + val id = UUID.randomUUID()
  403 + val shipped = SalesOrder(
  404 + code = "SO-1",
  405 + partnerCode = "CUST-1",
  406 + status = SalesOrderStatus.SHIPPED,
  407 + orderDate = LocalDate.of(2026, 4, 8),
  408 + currencyCode = "USD",
  409 + ).also { it.id = id }
  410 + every { orders.findById(id) } returns Optional.of(shipped)
  411 +
  412 + assertFailure { service.cancel(id) }
  413 + .isInstanceOf(IllegalArgumentException::class)
  414 + .messageContains("SHIPPED")
  415 + }
301 416 }
... ...