-
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.