Commit d04eb7f42c0a896810a12ed3a9319f92d696c066
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.
Showing
9 changed files
with
491 additions
and
2 deletions
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 | +} |