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