-
…c-warehousing StockTransfer First cross-PBC reaction originating from pbc-quality. Records a REJECTED inspection with explicit source + quarantine location codes, publishes an api.v1 event inside the same transaction as the row insert, and pbc-warehousing's new subscriber atomically creates + confirms a StockTransfer that moves the rejected quantity to the quarantine bin. The whole chain — inspection insert + event publish + transfer create + confirm + two ledger rows — runs in a single transaction under the synchronous in-process bus with Propagation.MANDATORY. ## Why the auto-quarantine is opt-in per-inspection Not every inspection wants physical movement. A REJECTED batch that's already separated from good stock on the shop floor doesn't need the framework to move anything; the operator just wants the record. Forcing every rejection to create a ledger pair would collide with real-world QC workflows. The contract is simple: the `InspectionRecord` now carries two OPTIONAL columns (`source_location_code`, `quarantine_location_code`). When BOTH are set AND the decision is REJECTED AND the rejected quantity is positive, the subscriber reacts. Otherwise it logs at DEBUG and does nothing. The event is published either way, so audit/KPI subscribers see every inspection regardless. ## api.v1 additions New event class `org.vibeerp.api.v1.event.quality.InspectionRecordedEvent` with nine fields: inspectionCode, itemCode, sourceReference, decision, inspectedQuantity, rejectedQuantity, sourceLocationCode?, quarantineLocationCode?, inspector All required fields validated in `init { }` — blank strings, non-positive inspected quantity, negative rejected quantity, or an unknown decision string all throw at publish time so a malformed event never hits the outbox. `aggregateType = "quality.InspectionRecord"` matches the `<pbc>.<aggregate>` convention. `decision` is carried as a String (not the pbc-quality `InspectionDecision` enum) to keep guardrail #10 honest — api.v1 events MUST NOT leak internal PBC types. Consumers compare against the literal `"APPROVED"` / `"REJECTED"` strings. ## pbc-quality changes - `InspectionRecord` entity gains two nullable columns: `source_location_code` + `quarantine_location_code`. - Liquibase migration `002-quality-quarantine-locations.xml` adds the columns to `quality__inspection_record`. - `InspectionRecordService` now injects `EventBus` and publishes `InspectionRecordedEvent` inside the `@Transactional record()` method. The publish carries all nine fields including the optional locations. - `RecordInspectionCommand` + `RecordInspectionRequest` gain the two optional location fields; unchanged default-null means every existing caller keeps working unchanged. - `InspectionRecordResponse` exposes both new columns on the HTTP wire. ## pbc-warehousing changes - New `QualityRejectionQuarantineSubscriber` @Component. - Subscribes in `@PostConstruct` via the typed-class `EventBus.subscribe(InspectionRecordedEvent::class.java, ...)` overload — same pattern every other PBC subscriber uses (SalesOrderConfirmedSubscriber, WorkOrderRequestedSubscriber, the pbc-finance order subscribers). - `handle(event)` is `internal` so the unit test can drive it directly without going through the bus. - Activation contract (all must be true): decision=REJECTED, rejectedQuantity>0, sourceLocationCode non-blank, quarantineLocationCode non-blank. Any missing condition → no-op. - Idempotency: derived transfer code is `TR-QC-<inspectionCode>`. Before creating, the subscriber checks `stockTransfers.findByCode(derivedCode)` — if anything exists (DRAFT, CONFIRMED, or CANCELLED), the subscriber skips. A replay of the same event under at-least-once delivery is safe. - On success: creates a DRAFT StockTransfer with one line moving `rejectedQuantity` of `itemCode` from source to quarantine, then calls `confirm(id)` which writes the atomic TRANSFER_OUT + TRANSFER_IN ledger pair. ## Smoke test (fresh DB) ``` # seed POST /api/v1/catalog/items {code: WIDGET-1, baseUomCode: ea} POST /api/v1/inventory/locations {code: WH-MAIN, type: WAREHOUSE} POST /api/v1/inventory/locations {code: WH-QUARANTINE, type: WAREHOUSE} POST /api/v1/inventory/movements {itemCode: WIDGET-1, locationId: <WH-MAIN>, delta: 100, reason: RECEIPT} # the cross-PBC reaction POST /api/v1/quality/inspections {code: QC-R-001, itemCode: WIDGET-1, sourceReference: "WO:WO-001", decision: REJECTED, inspectedQuantity: 50, rejectedQuantity: 7, reason: "surface scratches", sourceLocationCode: "WH-MAIN", quarantineLocationCode: "WH-QUARANTINE"} → 201 {..., sourceLocationCode: "WH-MAIN", quarantineLocationCode: "WH-QUARANTINE"} # automatically created + confirmed GET /api/v1/warehousing/stock-transfers/by-code/TR-QC-QC-R-001 → 200 { "code": "TR-QC-QC-R-001", "fromLocationCode": "WH-MAIN", "toLocationCode": "WH-QUARANTINE", "status": "CONFIRMED", "note": "auto-quarantine from rejected inspection QC-R-001", "lines": [{"itemCode": "WIDGET-1", "quantity": 7.0}] } # ledger state (raw SQL) SELECT l.code, b.item_code, b.quantity FROM inventory__stock_balance b JOIN inventory__location l ON l.id = b.location_id WHERE b.item_code = 'WIDGET-1'; WH-MAIN | WIDGET-1 | 93.0000 ← was 100, now 93 WH-QUARANTINE | WIDGET-1 | 7.0000 ← 7 rejected units here SELECT item_code, location, reason, delta, reference FROM inventory__stock_movement m JOIN inventory__location l ON l.id=m.location_id WHERE m.reference = 'TR:TR-QC-QC-R-001'; WIDGET-1 | WH-MAIN | TRANSFER_OUT | -7 | TR:TR-QC-QC-R-001 WIDGET-1 | WH-QUARANTINE | TRANSFER_IN | 7 | TR:TR-QC-QC-R-001 # negatives POST /api/v1/quality/inspections {decision: APPROVED, ...+locations} → 201, but GET /TR-QC-QC-A-001 → 404 (no transfer, correct opt-out) POST /api/v1/quality/inspections {decision: REJECTED, rejected: 2, no locations} → 201, but GET /TR-QC-QC-R-002 → 404 (opt-in honored) # handler log [warehousing] auto-quarantining 7 units of 'WIDGET-1' from 'WH-MAIN' to 'WH-QUARANTINE' (inspection=QC-R-001, transfer=TR-QC-QC-R-001) ``` Everything happens in ONE transaction because EventBusImpl uses Propagation.MANDATORY with synchronous delivery: the inspection insert, the event publish, the StockTransfer create, the confirm, and the two ledger rows all commit or roll back together. ## Tests - Updated `InspectionRecordServiceTest`: the service now takes an `EventBus` constructor argument. Every existing test got a relaxed `EventBus` mock; the one new test `record publishes InspectionRecordedEvent on success` captures the published event and asserts every field including the location codes. - 6 new unit tests in `QualityRejectionQuarantineSubscriberTest`: * subscribe registers one listener for InspectionRecordedEvent * handle creates and confirms a quarantine transfer on a fully-populated REJECTED event (asserts derived code, locations, item code, quantity) * handle is a no-op when decision is APPROVED * handle is a no-op when sourceLocationCode is missing * handle is a no-op when quarantineLocationCode is missing * handle skips when a transfer with the derived code already exists (idempotent replay) - Total framework unit tests: 334 (was 327), all green. ## What this unblocks - **Quality KPI dashboards** — any PBC can now subscribe to `InspectionRecordedEvent` without coupling to pbc-quality. - **pbc-finance quality-cost tracking** — when GL growth lands, a finance subscriber can debit a "quality variance" account on every REJECTED inspection. - **REF.2 / customer plug-in workflows** — the printing-shop plug-in can emit an `InspectionRecordedEvent` of its own from a BPMN service task (via `context.eventBus.publish`) and drive the same quarantine chain without touching pbc-quality's HTTP surface. ## Non-goals (parking lot) - Partial-batch quarantine decisions (moving some units to quarantine, some back to general stock, some to scrap). v1 collapses the decision into a single "reject N units" action and assumes the operator splits batches manually before inspecting. A richer ResolutionPlan aggregate is a future chunk if real workflows need it. - Quality metrics storage. The event is audited by the existing wildcard event subscriber but no PBC rolls it up into a KPI table. Belongs to a future reporting feature. - Auto-approval chains. An APPROVED inspection could trigger a "release-from-hold" transfer (opposite direction) in a future-expanded subscriber, but v1 keeps the reaction REJECTED-only to match the "quarantine on fail" use case. -
…alidates locations at create Follow-up to the pbc-warehousing chunk. Plugs a real gap noticed in the smoke test: an unknown fromLocationCode or toLocationCode on a StockTransfer was silently accepted at create() and only surfaced as a confirm()-time rollback, which is a confusing UX — the operator types TR-001 wrong, hits "create", then hits "confirm" minutes later and sees "location GHOST-SRC is not in the inventory directory". ## api.v1 growth New cross-PBC method on `InventoryApi`: fun findLocationByCode(locationCode: String): LocationRef? Parallel shape to `CatalogApi.findItemByCode` — a lookup-by-code returning a lightweight ref or null, safe for any cross-PBC consumer to inject. The returned `LocationRef` data class carries id, code, name, type (as a String, not the inventory-internal LocationType enum — rationale in the KDoc), and active flag. Fields that are NOT part of the cross-PBC contract (audit columns, ext JSONB, the raw JPA entity) stay inside pbc-inventory. api.v1 additive change within the v1 line — no breaking rename, no signature churn on existing methods. The interface adds a new abstract method, which IS technically a source-breaking change for any in-tree implementation, but the only impl is pbc-inventory/InventoryApiAdapter which is updated in the same commit. No external plug-in implements InventoryApi (by design; plug-ins inject it, they don't provide it). ## Adapter implementation `InventoryApiAdapter.findLocationByCode` resolves the location via the existing `LocationJpaRepository.findByCode`, which is exactly what `recordMovement` already uses. A new private extension `Location.toRef()` builds the api.v1 DTO. Zero new SQL; zero new repository methods. ## pbc-warehousing wiring `StockTransferService.create` now calls the facade twice — once for the source location, once for the destination — BEFORE validating lines. The four-step ordering is: code uniqueness → from != to → non-empty lines → both locations exist and are active → per-line validation. Unknown locations produce a 400 with a clear message; deactivated locations produce a 400 distinguishing "doesn't exist" from "exists but can't be used": "from location code 'GHOST-SRC' is not in the inventory directory" "from location 'WH-CLOSED' is deactivated and cannot be transfer source" The confirm() path is unchanged. Locations may still vanish between create and confirm (though the likelihood is low for a normal workflow), and `recordMovement` will still raise its own error in that case — belt and suspenders. ## Smoke test ``` POST /api/v1/inventory/locations {code: WH-GOOD, type: WAREHOUSE} POST /api/v1/catalog/items {code: ITEM-1, baseUomCode: ea} POST /api/v1/warehousing/stock-transfers {code: TR-bad, fromLocationCode: GHOST-SRC, toLocationCode: WH-GOOD, lines: [{lineNo: 1, itemCode: ITEM-1, quantity: 1}]} → 400 "from location code 'GHOST-SRC' is not in the inventory directory" (before this commit: 201 DRAFT, then 400 at confirm) POST /api/v1/warehousing/stock-transfers {code: TR-bad2, fromLocationCode: WH-GOOD, toLocationCode: GHOST-DST, lines: [{lineNo: 1, itemCode: ITEM-1, quantity: 1}]} → 400 "to location code 'GHOST-DST' is not in the inventory directory" POST /api/v1/warehousing/stock-transfers {code: TR-ok, fromLocationCode: WH-GOOD, toLocationCode: WH-OTHER, lines: [{lineNo: 1, itemCode: ITEM-1, quantity: 1}]} → 201 DRAFT ← happy path still works ``` ## Tests - Updated the 3 existing `StockTransferServiceTest` tests that created real transfers to stub `inventory.findLocationByCode` for both WH-A and WH-B via a new `stubLocation()` helper. - 3 new tests: * `create rejects unknown from location via InventoryApi` * `create rejects unknown to location via InventoryApi` * `create rejects a deactivated from location` - Total framework unit tests: 300 (was 297), all green. ## Why this isn't a breaking api.v1 change InventoryApi is an interface consumed by other PBCs and by plug-ins, implemented ONLY by pbc-inventory. Adding a new method to an interface IS a source-breaking change for any implementer — but the framework's dependency rules mean no external code implements this interface. Plug-ins and other PBCs CONSUME it via dependency injection; the only production impl is InventoryApiAdapter, updated in the same commit. Binary compatibility for consumers is preserved: existing call sites compile and run unchanged because only the interface grew, not its existing methods. If/when a third party implements InventoryApi (e.g. a test double outside the framework, or a custom backend plug-in), this would be a semver-major-worthy addition. For the in-tree framework, it's additive-within-a-major. -
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.