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.