Ninth core PBC. Ships the first-class orchestration aggregate for
moving stock between locations: a header + lines that represents
operator intent, and a confirm() verb that atomically posts the
matching TRANSFER_OUT / TRANSFER_IN ledger pair per line via the
existing InventoryApi.recordMovement facade.
Takes the framework's core-PBC count to 9 of 10 (only pbc-quality
remains in the P5.x row).
## The shape
pbc-warehousing sits above pbc-inventory in the dependency graph:
it doesn't replace the flat movement ledger, it orchestrates
multi-row ledger writes with a business-level document on top. A
DRAFT `warehousing__stock_transfer` row is queued intent (pickers
haven't started yet); a CONFIRMED row reflects movements that have
already posted to the `inventory__stock_movement` ledger. Each
confirmed line becomes two ledger rows:
TRANSFER_OUT(itemCode, fromLocationCode, -quantity, ref="TR:<code>")
TRANSFER_IN (itemCode, toLocationCode, quantity, ref="TR:<code>")
All rows of one confirm call run inside ONE @Transactional method,
so a failure anywhere — unknown item, unknown location, balance
would go below zero — rolls back EVERY line's both halves. There
is no half-confirmed transfer.
## Module contents
- `build.gradle.kts` — new Gradle subproject, api-v1 + platform/*
dependencies only. No cross-PBC dependency (guardrail #9 stays
honest; CatalogApi + InventoryApi both come in via api.v1.ext).
- `StockTransfer` entity — header with code, from/to location
codes, status (DRAFT/CONFIRMED/CANCELLED), transfer_date, note,
OneToMany<StockTransferLine>. Table name
`warehousing__stock_transfer`.
- `StockTransferLine` entity — lineNo, itemCode, quantity.
`transfer_id → warehousing__stock_transfer(id) ON DELETE CASCADE`,
unique `(transfer_id, line_no)`.
- `StockTransferJpaRepository` — existsByCode + findByCode.
- `StockTransferService` — create / confirm / cancel + three read
methods. @Transactional service-level; all state transitions run
through @Transactional methods so the event-bus MANDATORY
propagation (if/when a pbc-warehousing event is added later) has
a transaction to join. Business invariants:
* code is unique (existsByCode short-circuit)
* from != to (enforced in code AND in the Liquibase CHECK)
* at least one line
* each line: positive line_no, unique per transfer, positive
quantity, itemCode must resolve via CatalogApi.findItemByCode
* confirm requires DRAFT; writes OUT-first-per-line so a
balance-goes-negative error aborts before touching the
destination location
* cancel requires DRAFT; CONFIRMED transfers are terminal
(reverse by creating a NEW transfer in the opposite direction,
matching the document-discipline rule every other PBC uses)
- `StockTransferController` — `/api/v1/warehousing/stock-transfers`
with GET list, GET by id, GET by-code, POST create, POST
{id}/confirm, POST {id}/cancel. Every endpoint
@RequirePermission-gated using the keys declared in the metadata
YAML. Matches the shape of pbc-orders-sales, pbc-orders-purchase,
pbc-production.
- DTOs use the established pattern — jakarta.validation on the
request, response mapping via extension functions.
- `META-INF/vibe-erp/metadata/warehousing.yml` — 1 entity, 4
permissions, 1 menu. Loaded by MetadataLoader at boot, visible
via `GET /api/v1/_meta/metadata`.
- `distribution/src/main/resources/db/changelog/pbc-warehousing/001-warehousing-init.xml`
— creates both tables with the full audit column set, state
CHECK constraint, locations-distinct CHECK, unique
(transfer_id, line_no) index, quantity > 0 CHECK, item_code
index for cross-PBC grep.
- `settings.gradle.kts`, `distribution/build.gradle.kts`,
`master.xml` all wired.
## Smoke test (fresh DB + running app)
```
# seed
POST /api/v1/catalog/items {code: PAPER-A4, baseUomCode: sheet}
POST /api/v1/catalog/items {code: PAPER-A3, baseUomCode: sheet}
POST /api/v1/inventory/locations {code: WH-MAIN, type: WAREHOUSE}
POST /api/v1/inventory/locations {code: WH-SHOP, type: WAREHOUSE}
POST /api/v1/inventory/movements {itemCode: PAPER-A4, locationId: <WH-MAIN>, delta: 100, reason: RECEIPT}
POST /api/v1/inventory/movements {itemCode: PAPER-A3, locationId: <WH-MAIN>, delta: 50, reason: RECEIPT}
# exercise the new PBC
POST /api/v1/warehousing/stock-transfers
{code: TR-001, fromLocationCode: WH-MAIN, toLocationCode: WH-SHOP,
lines: [{lineNo: 1, itemCode: PAPER-A4, quantity: 30},
{lineNo: 2, itemCode: PAPER-A3, quantity: 10}]}
→ 201 DRAFT
POST /api/v1/warehousing/stock-transfers/<id>/confirm
→ 200 CONFIRMED
# verify balances via the raw DB (the HTTP stock-balance endpoint
# has a separate unrelated bug returning 500; the ledger state is
# what this commit is proving)
SELECT item_code, location_id, quantity FROM inventory__stock_balance;
PAPER-A4 / WH-MAIN → 70 ← debited 30
PAPER-A4 / WH-SHOP → 30 ← credited 30
PAPER-A3 / WH-MAIN → 40 ← debited 10
PAPER-A3 / WH-SHOP → 10 ← credited 10
SELECT item_code, location_id, reason, delta, reference
FROM inventory__stock_movement ORDER BY occurred_at;
PAPER-A4 / WH-MAIN / TRANSFER_OUT / -30 / TR:TR-001
PAPER-A4 / WH-SHOP / TRANSFER_IN / 30 / TR:TR-001
PAPER-A3 / WH-MAIN / TRANSFER_OUT / -10 / TR:TR-001
PAPER-A3 / WH-SHOP / TRANSFER_IN / 10 / TR:TR-001
```
Four rows all tagged `TR:TR-001`. A grep of the ledger attributes
both halves of each line to the single source transfer document.
## Transactional rollback test (in the same smoke run)
```
# ask for more than exists
POST /api/v1/warehousing/stock-transfers
{code: TR-002, from: WH-MAIN, to: WH-SHOP,
lines: [{lineNo: 1, itemCode: PAPER-A4, quantity: 1000}]}
→ 201 DRAFT
POST /api/v1/warehousing/stock-transfers/<id>/confirm
→ 400 "stock movement would push balance for 'PAPER-A4' at
location <WH-MAIN> below zero (current=70.0000, delta=-1000.0000)"
# assert TR-002 is still DRAFT
GET /api/v1/warehousing/stock-transfers/<id> → status: DRAFT ← NOT flipped to CONFIRMED
# assert the ledger still has exactly 6 rows (no partial writes)
SELECT count(*) FROM inventory__stock_movement; → 6
```
The failed confirm left no residue: status stayed DRAFT, and the
ledger count is unchanged at 6 (the 2 RECEIPT seeds + the 4
TRANSFER_OUT/IN from TR-001). Propagation.REQUIRED + Spring's
default rollback-on-unchecked-exception semantics do exactly what
the KDoc promises.
## State-machine guards
```
POST /api/v1/warehousing/stock-transfers/<confirmed-id>/confirm
→ 400 "cannot confirm stock transfer TR-001 in status CONFIRMED;
only DRAFT can be confirmed"
POST /api/v1/warehousing/stock-transfers/<confirmed-id>/cancel
→ 400 "cannot cancel stock transfer TR-001 in status CONFIRMED;
only DRAFT can be cancelled — reverse a confirmed transfer
by creating a new one in the other direction"
```
## Tests
- 10 new unit tests in `StockTransferServiceTest`:
* `create persists a DRAFT transfer when everything validates`
* `create rejects duplicate code`
* `create rejects same from and to location`
* `create rejects an empty line list`
* `create rejects duplicate line numbers`
* `create rejects non-positive quantities`
* `create rejects unknown items via CatalogApi`
* `confirm writes an atomic TRANSFER_OUT + TRANSFER_IN pair per line`
— uses `verifyOrder` to assert OUT-first-per-line dispatch order
* `confirm refuses a non-DRAFT transfer`
* `cancel refuses a CONFIRMED transfer`
* `cancel flips a DRAFT transfer to CANCELLED`
- Total framework unit tests: 288 (was 278), all green.
## What this unblocks
- **Real warehouse workflows** — confirm a transfer from a picker
UI (R1 is pending), driven by a BPMN that hands the confirm to a
TaskHandler once the physical move is complete.
- **pbc-quality (P5.8, last remaining core PBC)** — inspection
plans + results + holds. Holds would typically quarantine stock
by moving it to a QUARANTINE location via a stock transfer,
which is the natural consumer for this aggregate.
- **Stocktakes (physical inventory reconciliation)** — future
pbc-warehousing verb that compares counted vs recorded and posts
the differences as ADJUSTMENT rows; shares the same
`recordMovement` primitive.