Commit d04eb7f42c0a896810a12ed3a9319f92d696c066

Authored by zichun
1 parent f6bbb180

feat(quality+warehousing): cross-PBC auto-quarantine — pbc-quality REJECTED → pb…

…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.
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/event/quality/InspectionEvents.kt 0 → 100644
  1 +package org.vibeerp.api.v1.event.quality
  2 +
  3 +import org.vibeerp.api.v1.core.Id
  4 +import org.vibeerp.api.v1.event.DomainEvent
  5 +import java.math.BigDecimal
  6 +import java.time.Instant
  7 +
  8 +/**
  9 + * Domain events emitted by pbc-quality.
  10 + *
  11 + * The first api.v1 event in the quality namespace. Published by
  12 + * `InspectionRecordService.record()` every time a new inspection is
  13 + * recorded, regardless of decision. Downstream subscribers can filter
  14 + * on [decision] and react only to REJECTED inspections (e.g. the
  15 + * pbc-warehousing auto-quarantine subscriber).
  16 + *
  17 + * **Why a single event type for both APPROVED and REJECTED**
  18 + * (rather than `InspectionApprovedEvent` + `InspectionRejectedEvent`):
  19 + * most consumers want both shapes — a quality KPI dashboard, an
  20 + * audit log, a PBC that needs any-inspection side effects. Forcing
  21 + * those consumers to subscribe to two events is ergonomic friction.
  22 + * Consumers that only care about rejections check the [decision]
  23 + * field; the event-bus filter is trivial.
  24 + *
  25 + * **`aggregateType` convention:** `quality.InspectionRecord`, matching
  26 + * the `<pbc>.<aggregate>` rule the rest of the framework uses.
  27 + *
  28 + * **What the event carries:**
  29 + * - [inspectionCode] — the stable business code of the inspection
  30 + * record, for downstream idempotency keys
  31 + * - [itemCode] — the catalog item inspected
  32 + * - [sourceReference] — the free-form link into the source aggregate
  33 + * (`WO:<code>`, `PO:<code>:L<n>`, etc.), same convention the
  34 + * ledger uses
  35 + * - [decision] — APPROVED or REJECTED as a String (not the pbc-quality
  36 + * internal enum type, to keep guardrail #10 honest)
  37 + * - [inspectedQuantity] / [rejectedQuantity] — so a downstream
  38 + * subscriber can size its reaction (e.g. move N rejected units
  39 + * to the quarantine bin, not the whole batch)
  40 + * - [sourceLocationCode] / [quarantineLocationCode] — OPTIONAL
  41 + * inventory locations. When both are set AND decision is
  42 + * REJECTED, the pbc-warehousing subscriber creates a
  43 + * quarantine StockTransfer; when either is null, the subscriber
  44 + * is a no-op. This keeps the auto-quarantine behavior OPT-IN
  45 + * per-inspection rather than forcing every quality record to
  46 + * carry locations.
  47 + * - [inspector] — the principal id (UUID string) who recorded the
  48 + * inspection, same as the stored column
  49 + *
  50 + * **Idempotency:** the event is published inside
  51 + * `InspectionRecordService.record()`'s @Transactional method using
  52 + * the event bus's Propagation.MANDATORY contract, so the publish
  53 + * and the row insert are the same transaction. A subscriber MUST
  54 + * still be idempotent under at-least-once delivery; the recommended
  55 + * key is [inspectionCode].
  56 + */
  57 +public data class InspectionRecordedEvent(
  58 + public val inspectionCode: String,
  59 + public val itemCode: String,
  60 + public val sourceReference: String,
  61 + public val decision: String,
  62 + public val inspectedQuantity: BigDecimal,
  63 + public val rejectedQuantity: BigDecimal,
  64 + public val sourceLocationCode: String?,
  65 + public val quarantineLocationCode: String?,
  66 + public val inspector: String,
  67 + override val eventId: Id<DomainEvent> = Id.random(),
  68 + override val occurredAt: Instant = Instant.now(),
  69 +) : DomainEvent {
  70 + override val aggregateType: String get() = INSPECTION_RECORD_AGGREGATE_TYPE
  71 + override val aggregateId: String get() = inspectionCode
  72 +
  73 + init {
  74 + require(inspectionCode.isNotBlank()) { "InspectionRecordedEvent.inspectionCode must not be blank" }
  75 + require(itemCode.isNotBlank()) { "InspectionRecordedEvent.itemCode must not be blank" }
  76 + require(sourceReference.isNotBlank()) { "InspectionRecordedEvent.sourceReference must not be blank" }
  77 + require(decision == "APPROVED" || decision == "REJECTED") {
  78 + "InspectionRecordedEvent.decision must be APPROVED or REJECTED (got '$decision')"
  79 + }
  80 + require(inspectedQuantity.signum() > 0) {
  81 + "InspectionRecordedEvent.inspectedQuantity must be positive"
  82 + }
  83 + require(rejectedQuantity.signum() >= 0) {
  84 + "InspectionRecordedEvent.rejectedQuantity must not be negative"
  85 + }
  86 + require(inspector.isNotBlank()) { "InspectionRecordedEvent.inspector must not be blank" }
  87 + }
  88 +}
  89 +
  90 +/** Topic string for wildcard / topic-based subscriptions to inspection events. */
  91 +public const val INSPECTION_RECORD_AGGREGATE_TYPE: String = "quality.InspectionRecord"
distribution/src/main/resources/db/changelog/master.xml
@@ -26,4 +26,5 @@ @@ -26,4 +26,5 @@
26 <include file="classpath:db/changelog/pbc-production/001-production-init.xml"/> 26 <include file="classpath:db/changelog/pbc-production/001-production-init.xml"/>
27 <include file="classpath:db/changelog/pbc-production/002-production-v2.xml"/> 27 <include file="classpath:db/changelog/pbc-production/002-production-v2.xml"/>
28 <include file="classpath:db/changelog/pbc-quality/001-quality-init.xml"/> 28 <include file="classpath:db/changelog/pbc-quality/001-quality-init.xml"/>
  29 + <include file="classpath:db/changelog/pbc-quality/002-quality-quarantine-locations.xml"/>
29 </databaseChangeLog> 30 </databaseChangeLog>
distribution/src/main/resources/db/changelog/pbc-quality/002-quality-quarantine-locations.xml 0 → 100644
  1 +<?xml version="1.0" encoding="UTF-8"?>
  2 +<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
  3 + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  4 + xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
  5 + https://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.27.xsd">
  6 +
  7 + <!--
  8 + pbc-quality v1.1: add OPTIONAL source / quarantine location
  9 + columns so REJECTED inspections can trigger pbc-warehousing's
  10 + auto-quarantine subscriber. Both columns are nullable — when
  11 + either is unset the subscriber is a no-op and the inspection
  12 + is still recorded + the event is still published.
  13 + -->
  14 +
  15 + <changeSet id="quality-add-quarantine-locations" author="vibe_erp">
  16 + <comment>Add nullable source_location_code and quarantine_location_code columns</comment>
  17 + <sql>
  18 + ALTER TABLE quality__inspection_record
  19 + ADD COLUMN source_location_code varchar(64),
  20 + ADD COLUMN quarantine_location_code varchar(64);
  21 + </sql>
  22 + <rollback>
  23 + ALTER TABLE quality__inspection_record
  24 + DROP COLUMN source_location_code,
  25 + DROP COLUMN quarantine_location_code;
  26 + </rollback>
  27 + </changeSet>
  28 +
  29 +</databaseChangeLog>
pbc/pbc-quality/src/main/kotlin/org/vibeerp/pbc/quality/application/InspectionRecordService.kt
@@ -3,6 +3,8 @@ package org.vibeerp.pbc.quality.application @@ -3,6 +3,8 @@ package org.vibeerp.pbc.quality.application
3 import org.slf4j.LoggerFactory 3 import org.slf4j.LoggerFactory
4 import org.springframework.stereotype.Service 4 import org.springframework.stereotype.Service
5 import org.springframework.transaction.annotation.Transactional 5 import org.springframework.transaction.annotation.Transactional
  6 +import org.vibeerp.api.v1.event.EventBus
  7 +import org.vibeerp.api.v1.event.quality.InspectionRecordedEvent
6 import org.vibeerp.api.v1.ext.catalog.CatalogApi 8 import org.vibeerp.api.v1.ext.catalog.CatalogApi
7 import org.vibeerp.pbc.quality.domain.InspectionDecision 9 import org.vibeerp.pbc.quality.domain.InspectionDecision
8 import org.vibeerp.pbc.quality.domain.InspectionRecord 10 import org.vibeerp.pbc.quality.domain.InspectionRecord
@@ -41,6 +43,7 @@ import java.util.UUID @@ -41,6 +43,7 @@ import java.util.UUID
41 class InspectionRecordService( 43 class InspectionRecordService(
42 private val inspections: InspectionRecordJpaRepository, 44 private val inspections: InspectionRecordJpaRepository,
43 private val catalogApi: CatalogApi, 45 private val catalogApi: CatalogApi,
  46 + private val eventBus: EventBus,
44 ) { 47 ) {
45 private val log = LoggerFactory.getLogger(InspectionRecordService::class.java) 48 private val log = LoggerFactory.getLogger(InspectionRecordService::class.java)
46 49
@@ -112,15 +115,40 @@ class InspectionRecordService( @@ -112,15 +115,40 @@ class InspectionRecordService(
112 inspector = PrincipalContext.currentOrSystem(), 115 inspector = PrincipalContext.currentOrSystem(),
113 reason = command.reason, 116 reason = command.reason,
114 inspectedAt = Instant.now(), 117 inspectedAt = Instant.now(),
  118 + sourceLocationCode = command.sourceLocationCode,
  119 + quarantineLocationCode = command.quarantineLocationCode,
115 ) 120 )
116 val saved = inspections.save(record) 121 val saved = inspections.save(record)
117 122
118 log.info( 123 log.info(
119 "[quality] recorded inspection {} decision={} item={} source={} " + 124 "[quality] recorded inspection {} decision={} item={} source={} " +
120 - "inspected={} rejected={} inspector={}", 125 + "inspected={} rejected={} inspector={} srcLoc={} quaLoc={}",
121 saved.code, saved.decision, saved.itemCode, saved.sourceReference, 126 saved.code, saved.decision, saved.itemCode, saved.sourceReference,
122 saved.inspectedQuantity, saved.rejectedQuantity, saved.inspector, 127 saved.inspectedQuantity, saved.rejectedQuantity, saved.inspector,
  128 + saved.sourceLocationCode, saved.quarantineLocationCode,
123 ) 129 )
  130 +
  131 + // Publish the cross-PBC event inside the same transaction as
  132 + // the row insert. Propagation.MANDATORY on EventBusImpl means
  133 + // the publish joins this method's transaction and the outbox
  134 + // row commits atomically with the inspection row.
  135 + // Downstream subscribers (e.g. pbc-warehousing's auto-quarantine
  136 + // subscriber) MUST be idempotent — see the event's KDoc for
  137 + // the recommended idempotency key (inspection code).
  138 + eventBus.publish(
  139 + InspectionRecordedEvent(
  140 + inspectionCode = saved.code,
  141 + itemCode = saved.itemCode,
  142 + sourceReference = saved.sourceReference,
  143 + decision = saved.decision.name,
  144 + inspectedQuantity = saved.inspectedQuantity,
  145 + rejectedQuantity = saved.rejectedQuantity,
  146 + sourceLocationCode = saved.sourceLocationCode,
  147 + quarantineLocationCode = saved.quarantineLocationCode,
  148 + inspector = saved.inspector,
  149 + ),
  150 + )
  151 +
124 return saved 152 return saved
125 } 153 }
126 } 154 }
@@ -133,4 +161,6 @@ data class RecordInspectionCommand( @@ -133,4 +161,6 @@ data class RecordInspectionCommand(
133 val inspectedQuantity: BigDecimal, 161 val inspectedQuantity: BigDecimal,
134 val rejectedQuantity: BigDecimal, 162 val rejectedQuantity: BigDecimal,
135 val reason: String? = null, 163 val reason: String? = null,
  164 + val sourceLocationCode: String? = null,
  165 + val quarantineLocationCode: String? = null,
136 ) 166 )
pbc/pbc-quality/src/main/kotlin/org/vibeerp/pbc/quality/domain/InspectionRecord.kt
@@ -71,6 +71,8 @@ class InspectionRecord( @@ -71,6 +71,8 @@ class InspectionRecord(
71 inspector: String, 71 inspector: String,
72 reason: String? = null, 72 reason: String? = null,
73 inspectedAt: Instant = Instant.now(), 73 inspectedAt: Instant = Instant.now(),
  74 + sourceLocationCode: String? = null,
  75 + quarantineLocationCode: String? = null,
74 ) : AuditedJpaEntity() { 76 ) : AuditedJpaEntity() {
75 77
76 @Column(name = "code", nullable = false, length = 64) 78 @Column(name = "code", nullable = false, length = 64)
@@ -101,6 +103,27 @@ class InspectionRecord( @@ -101,6 +103,27 @@ class InspectionRecord(
101 @Column(name = "inspected_at", nullable = false) 103 @Column(name = "inspected_at", nullable = false)
102 var inspectedAt: Instant = inspectedAt 104 var inspectedAt: Instant = inspectedAt
103 105
  106 + /**
  107 + * OPTIONAL inventory location the inspected stock currently
  108 + * sits in. When set together with [quarantineLocationCode] AND
  109 + * [decision] is REJECTED, pbc-warehousing's subscriber to
  110 + * `InspectionRecordedEvent` will create + confirm a
  111 + * [StockTransfer] moving [rejectedQuantity] of [itemCode] from
  112 + * this location to the quarantine bin. Leaving either nullable
  113 + * column unset means "no auto-quarantine" — the inspection is
  114 + * still recorded and the event is still published, it just
  115 + * doesn't trigger any side effect.
  116 + */
  117 + @Column(name = "source_location_code", nullable = true, length = 64)
  118 + var sourceLocationCode: String? = sourceLocationCode
  119 +
  120 + /**
  121 + * OPTIONAL destination for auto-quarantine on rejection. See
  122 + * [sourceLocationCode] for the full contract.
  123 + */
  124 + @Column(name = "quarantine_location_code", nullable = true, length = 64)
  125 + var quarantineLocationCode: String? = quarantineLocationCode
  126 +
104 override fun toString(): String = 127 override fun toString(): String =
105 "InspectionRecord(id=$id, code='$code', item='$itemCode', decision=$decision, " + 128 "InspectionRecord(id=$id, code='$code', item='$itemCode', decision=$decision, " +
106 "inspected=$inspectedQuantity, rejected=$rejectedQuantity, ref='$sourceReference')" 129 "inspected=$inspectedQuantity, rejected=$rejectedQuantity, ref='$sourceReference')"
pbc/pbc-quality/src/main/kotlin/org/vibeerp/pbc/quality/http/InspectionRecordController.kt
@@ -77,6 +77,17 @@ data class RecordInspectionRequest( @@ -77,6 +77,17 @@ data class RecordInspectionRequest(
77 @field:NotNull val inspectedQuantity: BigDecimal, 77 @field:NotNull val inspectedQuantity: BigDecimal,
78 @field:NotNull val rejectedQuantity: BigDecimal, 78 @field:NotNull val rejectedQuantity: BigDecimal,
79 @field:Size(max = 512) val reason: String? = null, 79 @field:Size(max = 512) val reason: String? = null,
  80 + /**
  81 + * Optional inventory location the inspected stock sits in today.
  82 + * When set together with [quarantineLocationCode] AND [decision]
  83 + * is REJECTED, pbc-warehousing's subscriber auto-creates a
  84 + * quarantine StockTransfer on the framework event bus.
  85 + */
  86 + @field:Size(max = 64) val sourceLocationCode: String? = null,
  87 + /**
  88 + * Optional destination location for auto-quarantine on rejection.
  89 + */
  90 + @field:Size(max = 64) val quarantineLocationCode: String? = null,
80 ) { 91 ) {
81 fun toCommand(): RecordInspectionCommand = RecordInspectionCommand( 92 fun toCommand(): RecordInspectionCommand = RecordInspectionCommand(
82 code = code, 93 code = code,
@@ -86,6 +97,8 @@ data class RecordInspectionRequest( @@ -86,6 +97,8 @@ data class RecordInspectionRequest(
86 inspectedQuantity = inspectedQuantity, 97 inspectedQuantity = inspectedQuantity,
87 rejectedQuantity = rejectedQuantity, 98 rejectedQuantity = rejectedQuantity,
88 reason = reason, 99 reason = reason,
  100 + sourceLocationCode = sourceLocationCode,
  101 + quarantineLocationCode = quarantineLocationCode,
89 ) 102 )
90 } 103 }
91 104
@@ -100,6 +113,8 @@ data class InspectionRecordResponse( @@ -100,6 +113,8 @@ data class InspectionRecordResponse(
100 val inspector: String, 113 val inspector: String,
101 val reason: String?, 114 val reason: String?,
102 val inspectedAt: Instant, 115 val inspectedAt: Instant,
  116 + val sourceLocationCode: String?,
  117 + val quarantineLocationCode: String?,
103 ) 118 )
104 119
105 private fun InspectionRecord.toResponse(): InspectionRecordResponse = 120 private fun InspectionRecord.toResponse(): InspectionRecordResponse =
@@ -114,4 +129,6 @@ private fun InspectionRecord.toResponse(): InspectionRecordResponse = @@ -114,4 +129,6 @@ private fun InspectionRecord.toResponse(): InspectionRecordResponse =
114 inspector = inspector, 129 inspector = inspector,
115 reason = reason, 130 reason = reason,
116 inspectedAt = inspectedAt, 131 inspectedAt = inspectedAt,
  132 + sourceLocationCode = sourceLocationCode,
  133 + quarantineLocationCode = quarantineLocationCode,
117 ) 134 )
pbc/pbc-quality/src/test/kotlin/org/vibeerp/pbc/quality/application/InspectionRecordServiceTest.kt
@@ -5,12 +5,18 @@ import assertk.assertThat @@ -5,12 +5,18 @@ import assertk.assertThat
5 import assertk.assertions.hasMessage 5 import assertk.assertions.hasMessage
6 import assertk.assertions.isEqualTo 6 import assertk.assertions.isEqualTo
7 import assertk.assertions.isInstanceOf 7 import assertk.assertions.isInstanceOf
  8 +import io.mockk.Runs
8 import io.mockk.every 9 import io.mockk.every
  10 +import io.mockk.just
9 import io.mockk.mockk 11 import io.mockk.mockk
10 import io.mockk.slot 12 import io.mockk.slot
  13 +import io.mockk.verify
11 import org.junit.jupiter.api.AfterEach 14 import org.junit.jupiter.api.AfterEach
12 import org.junit.jupiter.api.Test 15 import org.junit.jupiter.api.Test
13 import org.vibeerp.api.v1.core.Id 16 import org.vibeerp.api.v1.core.Id
  17 +import org.vibeerp.api.v1.event.DomainEvent
  18 +import org.vibeerp.api.v1.event.EventBus
  19 +import org.vibeerp.api.v1.event.quality.InspectionRecordedEvent
14 import org.vibeerp.api.v1.ext.catalog.CatalogApi 20 import org.vibeerp.api.v1.ext.catalog.CatalogApi
15 import org.vibeerp.api.v1.ext.catalog.ItemRef 21 import org.vibeerp.api.v1.ext.catalog.ItemRef
16 import org.vibeerp.pbc.quality.domain.InspectionDecision 22 import org.vibeerp.pbc.quality.domain.InspectionDecision
@@ -24,7 +30,10 @@ class InspectionRecordServiceTest { @@ -24,7 +30,10 @@ class InspectionRecordServiceTest {
24 30
25 private val repo: InspectionRecordJpaRepository = mockk(relaxed = true) 31 private val repo: InspectionRecordJpaRepository = mockk(relaxed = true)
26 private val catalog: CatalogApi = mockk() 32 private val catalog: CatalogApi = mockk()
27 - private val service = InspectionRecordService(repo, catalog) 33 + private val eventBus: EventBus = mockk<EventBus>().also {
  34 + every { it.publish(any<DomainEvent>()) } just Runs
  35 + }
  36 + private val service = InspectionRecordService(repo, catalog, eventBus)
28 37
29 @AfterEach 38 @AfterEach
30 fun clearContext() { 39 fun clearContext() {
@@ -163,4 +172,33 @@ class InspectionRecordServiceTest { @@ -163,4 +172,33 @@ class InspectionRecordServiceTest {
163 assertFailure { service.record(cmd()) } 172 assertFailure { service.record(cmd()) }
164 .isInstanceOf(IllegalArgumentException::class) 173 .isInstanceOf(IllegalArgumentException::class)
165 } 174 }
  175 +
  176 + @Test
  177 + fun `record publishes InspectionRecordedEvent on success`() {
  178 + every { repo.existsByCode(any()) } returns false
  179 + stubItemExists("ITEM-1")
  180 + every { repo.save(any<InspectionRecord>()) } answers { firstArg() }
  181 +
  182 + val captured = slot<InspectionRecordedEvent>()
  183 + every { eventBus.publish(capture(captured)) } just Runs
  184 +
  185 + service.record(
  186 + cmd(
  187 + code = "QC-evt",
  188 + decision = InspectionDecision.REJECTED,
  189 + inspected = "10",
  190 + rejected = "3",
  191 + ).copy(
  192 + sourceLocationCode = "WH-MAIN",
  193 + quarantineLocationCode = "QUARANTINE",
  194 + ),
  195 + )
  196 +
  197 + verify(exactly = 1) { eventBus.publish(any<InspectionRecordedEvent>()) }
  198 + assertThat(captured.captured.inspectionCode).isEqualTo("QC-evt")
  199 + assertThat(captured.captured.decision).isEqualTo("REJECTED")
  200 + assertThat(captured.captured.sourceLocationCode).isEqualTo("WH-MAIN")
  201 + assertThat(captured.captured.quarantineLocationCode).isEqualTo("QUARANTINE")
  202 + assertThat(captured.captured.rejectedQuantity).isEqualTo(BigDecimal("3"))
  203 + }
166 } 204 }
pbc/pbc-warehousing/src/main/kotlin/org/vibeerp/pbc/warehousing/event/QualityRejectionQuarantineSubscriber.kt 0 → 100644
  1 +package org.vibeerp.pbc.warehousing.event
  2 +
  3 +import jakarta.annotation.PostConstruct
  4 +import org.slf4j.LoggerFactory
  5 +import org.springframework.stereotype.Component
  6 +import org.vibeerp.api.v1.event.EventBus
  7 +import org.vibeerp.api.v1.event.EventListener
  8 +import org.vibeerp.api.v1.event.quality.InspectionRecordedEvent
  9 +import org.vibeerp.pbc.warehousing.application.CreateStockTransferCommand
  10 +import org.vibeerp.pbc.warehousing.application.StockTransferLineCommand
  11 +import org.vibeerp.pbc.warehousing.application.StockTransferService
  12 +
  13 +/**
  14 + * Reacts to REJECTED inspections from pbc-quality by automatically
  15 + * creating + confirming a [StockTransfer] that moves the rejected
  16 + * quantity from the source location to the quarantine location.
  17 + *
  18 + * **Activation contract.** The subscriber is a no-op unless ALL of:
  19 + * - `event.decision == "REJECTED"`
  20 + * - `event.rejectedQuantity > 0`
  21 + * - `event.sourceLocationCode` is non-null and non-blank
  22 + * - `event.quarantineLocationCode` is non-null and non-blank
  23 + *
  24 + * When any of those is missing, the rejection is still recorded in
  25 + * pbc-quality and the event is still emitted (so audit + KPI
  26 + * subscribers see it), but no stock moves. This keeps auto-quarantine
  27 + * OPT-IN per-inspection — the caller who records the inspection
  28 + * decides whether to ask for a physical move by supplying the two
  29 + * location codes.
  30 + *
  31 + * **Idempotency.** The subscriber short-circuits if a stock transfer
  32 + * with the derived code `TR-QC-<inspection-code>` already exists.
  33 + * At-least-once delivery replays are safe; a second dispatch of the
  34 + * same event does nothing.
  35 + *
  36 + * **Transactional boundary.** `StockTransferService.create` and
  37 + * `.confirm` are both `@Transactional`. Inside the synchronous
  38 + * in-process bus (EventBusImpl with `Propagation.MANDATORY`), the
  39 + * subscriber runs in the publisher's transaction — the entire
  40 + * "record inspection → publish event → create transfer → confirm
  41 + * transfer → write two ledger rows" chain is atomic. A failure on
  42 + * any step rolls back the inspection row too. Under a future async
  43 + * bus (e.g. outbox-replay-on-a-worker-thread), each step would
  44 + * land in its own transaction and the subscriber's idempotency
  45 + * check prevents duplicate work on re-delivery.
  46 + *
  47 + * **Why pbc-warehousing, not pbc-quality.** Guardrail #9: PBCs
  48 + * don't import each other. The subscriber lives here because the
  49 + * framework already allows a PBC to subscribe to api.v1 events it
  50 + * doesn't own (see [org.vibeerp.pbc.finance] reacting to order
  51 + * events, [org.vibeerp.pbc.production] reacting to sales-order
  52 + * confirmation events). This is the 8th such subscriber and the
  53 + * first cross-PBC reaction originating from pbc-quality.
  54 + */
  55 +@Component
  56 +class QualityRejectionQuarantineSubscriber(
  57 + private val eventBus: EventBus,
  58 + private val stockTransfers: StockTransferService,
  59 +) {
  60 + private val log = LoggerFactory.getLogger(QualityRejectionQuarantineSubscriber::class.java)
  61 +
  62 + @PostConstruct
  63 + fun subscribe() {
  64 + eventBus.subscribe(
  65 + InspectionRecordedEvent::class.java,
  66 + EventListener { event -> handle(event) },
  67 + )
  68 + log.info(
  69 + "pbc-warehousing subscribed to InspectionRecordedEvent via EventBus.subscribe (typed-class overload)",
  70 + )
  71 + }
  72 +
  73 + /**
  74 + * Visible for testing — handles one inbound event without going
  75 + * through the bus.
  76 + */
  77 + internal fun handle(event: InspectionRecordedEvent) {
  78 + if (event.decision != "REJECTED") {
  79 + log.debug(
  80 + "[warehousing] InspectionRecordedEvent {} is APPROVED; auto-quarantine is a no-op",
  81 + event.inspectionCode,
  82 + )
  83 + return
  84 + }
  85 + if (event.rejectedQuantity.signum() <= 0) {
  86 + log.debug(
  87 + "[warehousing] InspectionRecordedEvent {} has rejected=0; nothing to quarantine",
  88 + event.inspectionCode,
  89 + )
  90 + return
  91 + }
  92 + val src = event.sourceLocationCode
  93 + val dst = event.quarantineLocationCode
  94 + if (src.isNullOrBlank() || dst.isNullOrBlank()) {
  95 + log.debug(
  96 + "[warehousing] InspectionRecordedEvent {} is REJECTED but no sourceLocationCode/" +
  97 + "quarantineLocationCode set; auto-quarantine is opt-in, skipping",
  98 + event.inspectionCode,
  99 + )
  100 + return
  101 + }
  102 +
  103 + val transferCode = "TR-QC-${event.inspectionCode}"
  104 + val existing = stockTransfers.findByCode(transferCode)
  105 + if (existing != null) {
  106 + log.debug(
  107 + "[warehousing] quarantine transfer '{}' for inspection {} already exists (status={}); skipping",
  108 + transferCode, event.inspectionCode, existing.status,
  109 + )
  110 + return
  111 + }
  112 +
  113 + log.info(
  114 + "[warehousing] auto-quarantining {} units of '{}' from '{}' to '{}' " +
  115 + "(inspection={}, transfer={})",
  116 + event.rejectedQuantity, event.itemCode, src, dst,
  117 + event.inspectionCode, transferCode,
  118 + )
  119 +
  120 + val transfer = stockTransfers.create(
  121 + CreateStockTransferCommand(
  122 + code = transferCode,
  123 + fromLocationCode = src,
  124 + toLocationCode = dst,
  125 + note = "auto-quarantine from rejected inspection ${event.inspectionCode}",
  126 + lines = listOf(
  127 + StockTransferLineCommand(
  128 + lineNo = 1,
  129 + itemCode = event.itemCode,
  130 + quantity = event.rejectedQuantity,
  131 + ),
  132 + ),
  133 + ),
  134 + )
  135 + stockTransfers.confirm(transfer.id)
  136 + }
  137 +}
pbc/pbc-warehousing/src/test/kotlin/org/vibeerp/pbc/warehousing/event/QualityRejectionQuarantineSubscriberTest.kt 0 → 100644
  1 +package org.vibeerp.pbc.warehousing.event
  2 +
  3 +import assertk.assertThat
  4 +import assertk.assertions.isEqualTo
  5 +import io.mockk.Runs
  6 +import io.mockk.every
  7 +import io.mockk.just
  8 +import io.mockk.mockk
  9 +import io.mockk.slot
  10 +import io.mockk.verify
  11 +import org.junit.jupiter.api.Test
  12 +import org.vibeerp.api.v1.event.EventBus
  13 +import org.vibeerp.api.v1.event.EventListener
  14 +import org.vibeerp.api.v1.event.quality.InspectionRecordedEvent
  15 +import org.vibeerp.pbc.warehousing.application.CreateStockTransferCommand
  16 +import org.vibeerp.pbc.warehousing.application.StockTransferService
  17 +import org.vibeerp.pbc.warehousing.domain.StockTransfer
  18 +import org.vibeerp.pbc.warehousing.domain.StockTransferStatus
  19 +import java.math.BigDecimal
  20 +import java.util.UUID
  21 +
  22 +class QualityRejectionQuarantineSubscriberTest {
  23 +
  24 + private fun event(
  25 + decision: String = "REJECTED",
  26 + rejected: String = "3",
  27 + src: String? = "WH-MAIN",
  28 + dst: String? = "WH-QUARANTINE",
  29 + ): InspectionRecordedEvent = InspectionRecordedEvent(
  30 + inspectionCode = "QC-001",
  31 + itemCode = "WIDGET-1",
  32 + sourceReference = "WO:WO-001",
  33 + decision = decision,
  34 + inspectedQuantity = BigDecimal("10"),
  35 + rejectedQuantity = BigDecimal(rejected),
  36 + sourceLocationCode = src,
  37 + quarantineLocationCode = dst,
  38 + inspector = "user-42",
  39 + )
  40 +
  41 + private fun fakeTransfer(code: String): StockTransfer {
  42 + val t = StockTransfer(
  43 + code = code,
  44 + fromLocationCode = "WH-MAIN",
  45 + toLocationCode = "WH-QUARANTINE",
  46 + )
  47 + t.id = UUID.randomUUID()
  48 + return t
  49 + }
  50 +
  51 + @Test
  52 + fun `subscribe registers one listener for InspectionRecordedEvent`() {
  53 + val bus = mockk<EventBus>(relaxed = true)
  54 + val service = mockk<StockTransferService>()
  55 + QualityRejectionQuarantineSubscriber(bus, service).subscribe()
  56 + verify(exactly = 1) {
  57 + bus.subscribe(InspectionRecordedEvent::class.java, any<EventListener<InspectionRecordedEvent>>())
  58 + }
  59 + }
  60 +
  61 + @Test
  62 + fun `handle creates and confirms a quarantine transfer on a fully-populated REJECTED event`() {
  63 + val bus = mockk<EventBus>(relaxed = true)
  64 + val service = mockk<StockTransferService>()
  65 + every { service.findByCode("TR-QC-QC-001") } returns null
  66 + val createdSlot = slot<CreateStockTransferCommand>()
  67 + val fake = fakeTransfer("TR-QC-QC-001")
  68 + every { service.create(capture(createdSlot)) } returns fake
  69 + every { service.confirm(fake.id) } returns fake
  70 +
  71 + QualityRejectionQuarantineSubscriber(bus, service).handle(event())
  72 +
  73 + verify(exactly = 1) { service.create(any()) }
  74 + verify(exactly = 1) { service.confirm(fake.id) }
  75 + assertThat(createdSlot.captured.code).isEqualTo("TR-QC-QC-001")
  76 + assertThat(createdSlot.captured.fromLocationCode).isEqualTo("WH-MAIN")
  77 + assertThat(createdSlot.captured.toLocationCode).isEqualTo("WH-QUARANTINE")
  78 + assertThat(createdSlot.captured.lines.size).isEqualTo(1)
  79 + assertThat(createdSlot.captured.lines[0].itemCode).isEqualTo("WIDGET-1")
  80 + assertThat(createdSlot.captured.lines[0].quantity).isEqualTo(BigDecimal("3"))
  81 + }
  82 +
  83 + @Test
  84 + fun `handle is a no-op when decision is APPROVED`() {
  85 + val bus = mockk<EventBus>(relaxed = true)
  86 + val service = mockk<StockTransferService>(relaxed = true)
  87 + QualityRejectionQuarantineSubscriber(bus, service).handle(
  88 + event(decision = "APPROVED", rejected = "0"),
  89 + )
  90 + verify(exactly = 0) { service.create(any()) }
  91 + verify(exactly = 0) { service.confirm(any()) }
  92 + }
  93 +
  94 + @Test
  95 + fun `handle is a no-op when sourceLocationCode is missing`() {
  96 + val bus = mockk<EventBus>(relaxed = true)
  97 + val service = mockk<StockTransferService>(relaxed = true)
  98 + QualityRejectionQuarantineSubscriber(bus, service).handle(event(src = null))
  99 + verify(exactly = 0) { service.create(any()) }
  100 + }
  101 +
  102 + @Test
  103 + fun `handle is a no-op when quarantineLocationCode is missing`() {
  104 + val bus = mockk<EventBus>(relaxed = true)
  105 + val service = mockk<StockTransferService>(relaxed = true)
  106 + QualityRejectionQuarantineSubscriber(bus, service).handle(event(dst = null))
  107 + verify(exactly = 0) { service.create(any()) }
  108 + }
  109 +
  110 + @Test
  111 + fun `handle skips when a transfer with the derived code already exists idempotent replay`() {
  112 + val bus = mockk<EventBus>(relaxed = true)
  113 + val service = mockk<StockTransferService>()
  114 + val existing = fakeTransfer("TR-QC-QC-001")
  115 + existing.status = StockTransferStatus.CONFIRMED
  116 + every { service.findByCode("TR-QC-QC-001") } returns existing
  117 +
  118 + QualityRejectionQuarantineSubscriber(bus, service).handle(event())
  119 +
  120 + verify(exactly = 0) { service.create(any()) }
  121 + verify(exactly = 0) { service.confirm(any()) }
  122 + }
  123 +}