From d04eb7f42c0a896810a12ed3a9319f92d696c066 Mon Sep 17 00:00:00 2001 From: zichun Date: Thu, 9 Apr 2026 15:18:05 +0800 Subject: [PATCH] feat(quality+warehousing): cross-PBC auto-quarantine — pbc-quality REJECTED → pbc-warehousing StockTransfer --- api/api-v1/src/main/kotlin/org/vibeerp/api/v1/event/quality/InspectionEvents.kt | 91 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ distribution/src/main/resources/db/changelog/master.xml | 1 + distribution/src/main/resources/db/changelog/pbc-quality/002-quality-quarantine-locations.xml | 29 +++++++++++++++++++++++++++++ pbc/pbc-quality/src/main/kotlin/org/vibeerp/pbc/quality/application/InspectionRecordService.kt | 32 +++++++++++++++++++++++++++++++- pbc/pbc-quality/src/main/kotlin/org/vibeerp/pbc/quality/domain/InspectionRecord.kt | 23 +++++++++++++++++++++++ pbc/pbc-quality/src/main/kotlin/org/vibeerp/pbc/quality/http/InspectionRecordController.kt | 17 +++++++++++++++++ pbc/pbc-quality/src/test/kotlin/org/vibeerp/pbc/quality/application/InspectionRecordServiceTest.kt | 40 +++++++++++++++++++++++++++++++++++++++- pbc/pbc-warehousing/src/main/kotlin/org/vibeerp/pbc/warehousing/event/QualityRejectionQuarantineSubscriber.kt | 137 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ pbc/pbc-warehousing/src/test/kotlin/org/vibeerp/pbc/warehousing/event/QualityRejectionQuarantineSubscriberTest.kt | 123 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 9 files changed, 491 insertions(+), 2 deletions(-) create mode 100644 api/api-v1/src/main/kotlin/org/vibeerp/api/v1/event/quality/InspectionEvents.kt create mode 100644 distribution/src/main/resources/db/changelog/pbc-quality/002-quality-quarantine-locations.xml create mode 100644 pbc/pbc-warehousing/src/main/kotlin/org/vibeerp/pbc/warehousing/event/QualityRejectionQuarantineSubscriber.kt create mode 100644 pbc/pbc-warehousing/src/test/kotlin/org/vibeerp/pbc/warehousing/event/QualityRejectionQuarantineSubscriberTest.kt diff --git a/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/event/quality/InspectionEvents.kt b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/event/quality/InspectionEvents.kt new file mode 100644 index 0000000..03676f4 --- /dev/null +++ b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/event/quality/InspectionEvents.kt @@ -0,0 +1,91 @@ +package org.vibeerp.api.v1.event.quality + +import org.vibeerp.api.v1.core.Id +import org.vibeerp.api.v1.event.DomainEvent +import java.math.BigDecimal +import java.time.Instant + +/** + * Domain events emitted by pbc-quality. + * + * The first api.v1 event in the quality namespace. Published by + * `InspectionRecordService.record()` every time a new inspection is + * recorded, regardless of decision. Downstream subscribers can filter + * on [decision] and react only to REJECTED inspections (e.g. the + * pbc-warehousing auto-quarantine subscriber). + * + * **Why a single event type for both APPROVED and REJECTED** + * (rather than `InspectionApprovedEvent` + `InspectionRejectedEvent`): + * most consumers want both shapes — a quality KPI dashboard, an + * audit log, a PBC that needs any-inspection side effects. Forcing + * those consumers to subscribe to two events is ergonomic friction. + * Consumers that only care about rejections check the [decision] + * field; the event-bus filter is trivial. + * + * **`aggregateType` convention:** `quality.InspectionRecord`, matching + * the `.` rule the rest of the framework uses. + * + * **What the event carries:** + * - [inspectionCode] — the stable business code of the inspection + * record, for downstream idempotency keys + * - [itemCode] — the catalog item inspected + * - [sourceReference] — the free-form link into the source aggregate + * (`WO:`, `PO::L`, etc.), same convention the + * ledger uses + * - [decision] — APPROVED or REJECTED as a String (not the pbc-quality + * internal enum type, to keep guardrail #10 honest) + * - [inspectedQuantity] / [rejectedQuantity] — so a downstream + * subscriber can size its reaction (e.g. move N rejected units + * to the quarantine bin, not the whole batch) + * - [sourceLocationCode] / [quarantineLocationCode] — OPTIONAL + * inventory locations. When both are set AND decision is + * REJECTED, the pbc-warehousing subscriber creates a + * quarantine StockTransfer; when either is null, the subscriber + * is a no-op. This keeps the auto-quarantine behavior OPT-IN + * per-inspection rather than forcing every quality record to + * carry locations. + * - [inspector] — the principal id (UUID string) who recorded the + * inspection, same as the stored column + * + * **Idempotency:** the event is published inside + * `InspectionRecordService.record()`'s @Transactional method using + * the event bus's Propagation.MANDATORY contract, so the publish + * and the row insert are the same transaction. A subscriber MUST + * still be idempotent under at-least-once delivery; the recommended + * key is [inspectionCode]. + */ +public data class InspectionRecordedEvent( + public val inspectionCode: String, + public val itemCode: String, + public val sourceReference: String, + public val decision: String, + public val inspectedQuantity: BigDecimal, + public val rejectedQuantity: BigDecimal, + public val sourceLocationCode: String?, + public val quarantineLocationCode: String?, + public val inspector: String, + override val eventId: Id = Id.random(), + override val occurredAt: Instant = Instant.now(), +) : DomainEvent { + override val aggregateType: String get() = INSPECTION_RECORD_AGGREGATE_TYPE + override val aggregateId: String get() = inspectionCode + + init { + require(inspectionCode.isNotBlank()) { "InspectionRecordedEvent.inspectionCode must not be blank" } + require(itemCode.isNotBlank()) { "InspectionRecordedEvent.itemCode must not be blank" } + require(sourceReference.isNotBlank()) { "InspectionRecordedEvent.sourceReference must not be blank" } + require(decision == "APPROVED" || decision == "REJECTED") { + "InspectionRecordedEvent.decision must be APPROVED or REJECTED (got '$decision')" + } + require(inspectedQuantity.signum() > 0) { + "InspectionRecordedEvent.inspectedQuantity must be positive" + } + require(rejectedQuantity.signum() >= 0) { + "InspectionRecordedEvent.rejectedQuantity must not be negative" + } + require(inspector.isNotBlank()) { "InspectionRecordedEvent.inspector must not be blank" } + } +} + +/** Topic string for wildcard / topic-based subscriptions to inspection events. */ +public const val INSPECTION_RECORD_AGGREGATE_TYPE: String = "quality.InspectionRecord" diff --git a/distribution/src/main/resources/db/changelog/master.xml b/distribution/src/main/resources/db/changelog/master.xml index db7f413..0de47bf 100644 --- a/distribution/src/main/resources/db/changelog/master.xml +++ b/distribution/src/main/resources/db/changelog/master.xml @@ -26,4 +26,5 @@ + diff --git a/distribution/src/main/resources/db/changelog/pbc-quality/002-quality-quarantine-locations.xml b/distribution/src/main/resources/db/changelog/pbc-quality/002-quality-quarantine-locations.xml new file mode 100644 index 0000000..5c82c48 --- /dev/null +++ b/distribution/src/main/resources/db/changelog/pbc-quality/002-quality-quarantine-locations.xml @@ -0,0 +1,29 @@ + + + + + + + Add nullable source_location_code and quarantine_location_code columns + + ALTER TABLE quality__inspection_record + ADD COLUMN source_location_code varchar(64), + ADD COLUMN quarantine_location_code varchar(64); + + + ALTER TABLE quality__inspection_record + DROP COLUMN source_location_code, + DROP COLUMN quarantine_location_code; + + + + diff --git a/pbc/pbc-quality/src/main/kotlin/org/vibeerp/pbc/quality/application/InspectionRecordService.kt b/pbc/pbc-quality/src/main/kotlin/org/vibeerp/pbc/quality/application/InspectionRecordService.kt index 699cd4e..205dfa2 100644 --- a/pbc/pbc-quality/src/main/kotlin/org/vibeerp/pbc/quality/application/InspectionRecordService.kt +++ b/pbc/pbc-quality/src/main/kotlin/org/vibeerp/pbc/quality/application/InspectionRecordService.kt @@ -3,6 +3,8 @@ package org.vibeerp.pbc.quality.application import org.slf4j.LoggerFactory import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional +import org.vibeerp.api.v1.event.EventBus +import org.vibeerp.api.v1.event.quality.InspectionRecordedEvent import org.vibeerp.api.v1.ext.catalog.CatalogApi import org.vibeerp.pbc.quality.domain.InspectionDecision import org.vibeerp.pbc.quality.domain.InspectionRecord @@ -41,6 +43,7 @@ import java.util.UUID class InspectionRecordService( private val inspections: InspectionRecordJpaRepository, private val catalogApi: CatalogApi, + private val eventBus: EventBus, ) { private val log = LoggerFactory.getLogger(InspectionRecordService::class.java) @@ -112,15 +115,40 @@ class InspectionRecordService( inspector = PrincipalContext.currentOrSystem(), reason = command.reason, inspectedAt = Instant.now(), + sourceLocationCode = command.sourceLocationCode, + quarantineLocationCode = command.quarantineLocationCode, ) val saved = inspections.save(record) log.info( "[quality] recorded inspection {} decision={} item={} source={} " + - "inspected={} rejected={} inspector={}", + "inspected={} rejected={} inspector={} srcLoc={} quaLoc={}", saved.code, saved.decision, saved.itemCode, saved.sourceReference, saved.inspectedQuantity, saved.rejectedQuantity, saved.inspector, + saved.sourceLocationCode, saved.quarantineLocationCode, ) + + // Publish the cross-PBC event inside the same transaction as + // the row insert. Propagation.MANDATORY on EventBusImpl means + // the publish joins this method's transaction and the outbox + // row commits atomically with the inspection row. + // Downstream subscribers (e.g. pbc-warehousing's auto-quarantine + // subscriber) MUST be idempotent — see the event's KDoc for + // the recommended idempotency key (inspection code). + eventBus.publish( + InspectionRecordedEvent( + inspectionCode = saved.code, + itemCode = saved.itemCode, + sourceReference = saved.sourceReference, + decision = saved.decision.name, + inspectedQuantity = saved.inspectedQuantity, + rejectedQuantity = saved.rejectedQuantity, + sourceLocationCode = saved.sourceLocationCode, + quarantineLocationCode = saved.quarantineLocationCode, + inspector = saved.inspector, + ), + ) + return saved } } @@ -133,4 +161,6 @@ data class RecordInspectionCommand( val inspectedQuantity: BigDecimal, val rejectedQuantity: BigDecimal, val reason: String? = null, + val sourceLocationCode: String? = null, + val quarantineLocationCode: String? = null, ) diff --git a/pbc/pbc-quality/src/main/kotlin/org/vibeerp/pbc/quality/domain/InspectionRecord.kt b/pbc/pbc-quality/src/main/kotlin/org/vibeerp/pbc/quality/domain/InspectionRecord.kt index f4d3137..ef277b5 100644 --- a/pbc/pbc-quality/src/main/kotlin/org/vibeerp/pbc/quality/domain/InspectionRecord.kt +++ b/pbc/pbc-quality/src/main/kotlin/org/vibeerp/pbc/quality/domain/InspectionRecord.kt @@ -71,6 +71,8 @@ class InspectionRecord( inspector: String, reason: String? = null, inspectedAt: Instant = Instant.now(), + sourceLocationCode: String? = null, + quarantineLocationCode: String? = null, ) : AuditedJpaEntity() { @Column(name = "code", nullable = false, length = 64) @@ -101,6 +103,27 @@ class InspectionRecord( @Column(name = "inspected_at", nullable = false) var inspectedAt: Instant = inspectedAt + /** + * OPTIONAL inventory location the inspected stock currently + * sits in. When set together with [quarantineLocationCode] AND + * [decision] is REJECTED, pbc-warehousing's subscriber to + * `InspectionRecordedEvent` will create + confirm a + * [StockTransfer] moving [rejectedQuantity] of [itemCode] from + * this location to the quarantine bin. Leaving either nullable + * column unset means "no auto-quarantine" — the inspection is + * still recorded and the event is still published, it just + * doesn't trigger any side effect. + */ + @Column(name = "source_location_code", nullable = true, length = 64) + var sourceLocationCode: String? = sourceLocationCode + + /** + * OPTIONAL destination for auto-quarantine on rejection. See + * [sourceLocationCode] for the full contract. + */ + @Column(name = "quarantine_location_code", nullable = true, length = 64) + var quarantineLocationCode: String? = quarantineLocationCode + override fun toString(): String = "InspectionRecord(id=$id, code='$code', item='$itemCode', decision=$decision, " + "inspected=$inspectedQuantity, rejected=$rejectedQuantity, ref='$sourceReference')" diff --git a/pbc/pbc-quality/src/main/kotlin/org/vibeerp/pbc/quality/http/InspectionRecordController.kt b/pbc/pbc-quality/src/main/kotlin/org/vibeerp/pbc/quality/http/InspectionRecordController.kt index e6deffc..b9321f6 100644 --- a/pbc/pbc-quality/src/main/kotlin/org/vibeerp/pbc/quality/http/InspectionRecordController.kt +++ b/pbc/pbc-quality/src/main/kotlin/org/vibeerp/pbc/quality/http/InspectionRecordController.kt @@ -77,6 +77,17 @@ data class RecordInspectionRequest( @field:NotNull val inspectedQuantity: BigDecimal, @field:NotNull val rejectedQuantity: BigDecimal, @field:Size(max = 512) val reason: String? = null, + /** + * Optional inventory location the inspected stock sits in today. + * When set together with [quarantineLocationCode] AND [decision] + * is REJECTED, pbc-warehousing's subscriber auto-creates a + * quarantine StockTransfer on the framework event bus. + */ + @field:Size(max = 64) val sourceLocationCode: String? = null, + /** + * Optional destination location for auto-quarantine on rejection. + */ + @field:Size(max = 64) val quarantineLocationCode: String? = null, ) { fun toCommand(): RecordInspectionCommand = RecordInspectionCommand( code = code, @@ -86,6 +97,8 @@ data class RecordInspectionRequest( inspectedQuantity = inspectedQuantity, rejectedQuantity = rejectedQuantity, reason = reason, + sourceLocationCode = sourceLocationCode, + quarantineLocationCode = quarantineLocationCode, ) } @@ -100,6 +113,8 @@ data class InspectionRecordResponse( val inspector: String, val reason: String?, val inspectedAt: Instant, + val sourceLocationCode: String?, + val quarantineLocationCode: String?, ) private fun InspectionRecord.toResponse(): InspectionRecordResponse = @@ -114,4 +129,6 @@ private fun InspectionRecord.toResponse(): InspectionRecordResponse = inspector = inspector, reason = reason, inspectedAt = inspectedAt, + sourceLocationCode = sourceLocationCode, + quarantineLocationCode = quarantineLocationCode, ) diff --git a/pbc/pbc-quality/src/test/kotlin/org/vibeerp/pbc/quality/application/InspectionRecordServiceTest.kt b/pbc/pbc-quality/src/test/kotlin/org/vibeerp/pbc/quality/application/InspectionRecordServiceTest.kt index 1741e72..09a1304 100644 --- a/pbc/pbc-quality/src/test/kotlin/org/vibeerp/pbc/quality/application/InspectionRecordServiceTest.kt +++ b/pbc/pbc-quality/src/test/kotlin/org/vibeerp/pbc/quality/application/InspectionRecordServiceTest.kt @@ -5,12 +5,18 @@ import assertk.assertThat import assertk.assertions.hasMessage import assertk.assertions.isEqualTo import assertk.assertions.isInstanceOf +import io.mockk.Runs import io.mockk.every +import io.mockk.just import io.mockk.mockk import io.mockk.slot +import io.mockk.verify import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Test import org.vibeerp.api.v1.core.Id +import org.vibeerp.api.v1.event.DomainEvent +import org.vibeerp.api.v1.event.EventBus +import org.vibeerp.api.v1.event.quality.InspectionRecordedEvent import org.vibeerp.api.v1.ext.catalog.CatalogApi import org.vibeerp.api.v1.ext.catalog.ItemRef import org.vibeerp.pbc.quality.domain.InspectionDecision @@ -24,7 +30,10 @@ class InspectionRecordServiceTest { private val repo: InspectionRecordJpaRepository = mockk(relaxed = true) private val catalog: CatalogApi = mockk() - private val service = InspectionRecordService(repo, catalog) + private val eventBus: EventBus = mockk().also { + every { it.publish(any()) } just Runs + } + private val service = InspectionRecordService(repo, catalog, eventBus) @AfterEach fun clearContext() { @@ -163,4 +172,33 @@ class InspectionRecordServiceTest { assertFailure { service.record(cmd()) } .isInstanceOf(IllegalArgumentException::class) } + + @Test + fun `record publishes InspectionRecordedEvent on success`() { + every { repo.existsByCode(any()) } returns false + stubItemExists("ITEM-1") + every { repo.save(any()) } answers { firstArg() } + + val captured = slot() + every { eventBus.publish(capture(captured)) } just Runs + + service.record( + cmd( + code = "QC-evt", + decision = InspectionDecision.REJECTED, + inspected = "10", + rejected = "3", + ).copy( + sourceLocationCode = "WH-MAIN", + quarantineLocationCode = "QUARANTINE", + ), + ) + + verify(exactly = 1) { eventBus.publish(any()) } + assertThat(captured.captured.inspectionCode).isEqualTo("QC-evt") + assertThat(captured.captured.decision).isEqualTo("REJECTED") + assertThat(captured.captured.sourceLocationCode).isEqualTo("WH-MAIN") + assertThat(captured.captured.quarantineLocationCode).isEqualTo("QUARANTINE") + assertThat(captured.captured.rejectedQuantity).isEqualTo(BigDecimal("3")) + } } diff --git a/pbc/pbc-warehousing/src/main/kotlin/org/vibeerp/pbc/warehousing/event/QualityRejectionQuarantineSubscriber.kt b/pbc/pbc-warehousing/src/main/kotlin/org/vibeerp/pbc/warehousing/event/QualityRejectionQuarantineSubscriber.kt new file mode 100644 index 0000000..c58f33e --- /dev/null +++ b/pbc/pbc-warehousing/src/main/kotlin/org/vibeerp/pbc/warehousing/event/QualityRejectionQuarantineSubscriber.kt @@ -0,0 +1,137 @@ +package org.vibeerp.pbc.warehousing.event + +import jakarta.annotation.PostConstruct +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Component +import org.vibeerp.api.v1.event.EventBus +import org.vibeerp.api.v1.event.EventListener +import org.vibeerp.api.v1.event.quality.InspectionRecordedEvent +import org.vibeerp.pbc.warehousing.application.CreateStockTransferCommand +import org.vibeerp.pbc.warehousing.application.StockTransferLineCommand +import org.vibeerp.pbc.warehousing.application.StockTransferService + +/** + * Reacts to REJECTED inspections from pbc-quality by automatically + * creating + confirming a [StockTransfer] that moves the rejected + * quantity from the source location to the quarantine location. + * + * **Activation contract.** The subscriber is a no-op unless ALL of: + * - `event.decision == "REJECTED"` + * - `event.rejectedQuantity > 0` + * - `event.sourceLocationCode` is non-null and non-blank + * - `event.quarantineLocationCode` is non-null and non-blank + * + * When any of those is missing, the rejection is still recorded in + * pbc-quality and the event is still emitted (so audit + KPI + * subscribers see it), but no stock moves. This keeps auto-quarantine + * OPT-IN per-inspection — the caller who records the inspection + * decides whether to ask for a physical move by supplying the two + * location codes. + * + * **Idempotency.** The subscriber short-circuits if a stock transfer + * with the derived code `TR-QC-` already exists. + * At-least-once delivery replays are safe; a second dispatch of the + * same event does nothing. + * + * **Transactional boundary.** `StockTransferService.create` and + * `.confirm` are both `@Transactional`. Inside the synchronous + * in-process bus (EventBusImpl with `Propagation.MANDATORY`), the + * subscriber runs in the publisher's transaction — the entire + * "record inspection → publish event → create transfer → confirm + * transfer → write two ledger rows" chain is atomic. A failure on + * any step rolls back the inspection row too. Under a future async + * bus (e.g. outbox-replay-on-a-worker-thread), each step would + * land in its own transaction and the subscriber's idempotency + * check prevents duplicate work on re-delivery. + * + * **Why pbc-warehousing, not pbc-quality.** Guardrail #9: PBCs + * don't import each other. The subscriber lives here because the + * framework already allows a PBC to subscribe to api.v1 events it + * doesn't own (see [org.vibeerp.pbc.finance] reacting to order + * events, [org.vibeerp.pbc.production] reacting to sales-order + * confirmation events). This is the 8th such subscriber and the + * first cross-PBC reaction originating from pbc-quality. + */ +@Component +class QualityRejectionQuarantineSubscriber( + private val eventBus: EventBus, + private val stockTransfers: StockTransferService, +) { + private val log = LoggerFactory.getLogger(QualityRejectionQuarantineSubscriber::class.java) + + @PostConstruct + fun subscribe() { + eventBus.subscribe( + InspectionRecordedEvent::class.java, + EventListener { event -> handle(event) }, + ) + log.info( + "pbc-warehousing subscribed to InspectionRecordedEvent via EventBus.subscribe (typed-class overload)", + ) + } + + /** + * Visible for testing — handles one inbound event without going + * through the bus. + */ + internal fun handle(event: InspectionRecordedEvent) { + if (event.decision != "REJECTED") { + log.debug( + "[warehousing] InspectionRecordedEvent {} is APPROVED; auto-quarantine is a no-op", + event.inspectionCode, + ) + return + } + if (event.rejectedQuantity.signum() <= 0) { + log.debug( + "[warehousing] InspectionRecordedEvent {} has rejected=0; nothing to quarantine", + event.inspectionCode, + ) + return + } + val src = event.sourceLocationCode + val dst = event.quarantineLocationCode + if (src.isNullOrBlank() || dst.isNullOrBlank()) { + log.debug( + "[warehousing] InspectionRecordedEvent {} is REJECTED but no sourceLocationCode/" + + "quarantineLocationCode set; auto-quarantine is opt-in, skipping", + event.inspectionCode, + ) + return + } + + val transferCode = "TR-QC-${event.inspectionCode}" + val existing = stockTransfers.findByCode(transferCode) + if (existing != null) { + log.debug( + "[warehousing] quarantine transfer '{}' for inspection {} already exists (status={}); skipping", + transferCode, event.inspectionCode, existing.status, + ) + return + } + + log.info( + "[warehousing] auto-quarantining {} units of '{}' from '{}' to '{}' " + + "(inspection={}, transfer={})", + event.rejectedQuantity, event.itemCode, src, dst, + event.inspectionCode, transferCode, + ) + + val transfer = stockTransfers.create( + CreateStockTransferCommand( + code = transferCode, + fromLocationCode = src, + toLocationCode = dst, + note = "auto-quarantine from rejected inspection ${event.inspectionCode}", + lines = listOf( + StockTransferLineCommand( + lineNo = 1, + itemCode = event.itemCode, + quantity = event.rejectedQuantity, + ), + ), + ), + ) + stockTransfers.confirm(transfer.id) + } +} diff --git a/pbc/pbc-warehousing/src/test/kotlin/org/vibeerp/pbc/warehousing/event/QualityRejectionQuarantineSubscriberTest.kt b/pbc/pbc-warehousing/src/test/kotlin/org/vibeerp/pbc/warehousing/event/QualityRejectionQuarantineSubscriberTest.kt new file mode 100644 index 0000000..5877cec --- /dev/null +++ b/pbc/pbc-warehousing/src/test/kotlin/org/vibeerp/pbc/warehousing/event/QualityRejectionQuarantineSubscriberTest.kt @@ -0,0 +1,123 @@ +package org.vibeerp.pbc.warehousing.event + +import assertk.assertThat +import assertk.assertions.isEqualTo +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import org.junit.jupiter.api.Test +import org.vibeerp.api.v1.event.EventBus +import org.vibeerp.api.v1.event.EventListener +import org.vibeerp.api.v1.event.quality.InspectionRecordedEvent +import org.vibeerp.pbc.warehousing.application.CreateStockTransferCommand +import org.vibeerp.pbc.warehousing.application.StockTransferService +import org.vibeerp.pbc.warehousing.domain.StockTransfer +import org.vibeerp.pbc.warehousing.domain.StockTransferStatus +import java.math.BigDecimal +import java.util.UUID + +class QualityRejectionQuarantineSubscriberTest { + + private fun event( + decision: String = "REJECTED", + rejected: String = "3", + src: String? = "WH-MAIN", + dst: String? = "WH-QUARANTINE", + ): InspectionRecordedEvent = InspectionRecordedEvent( + inspectionCode = "QC-001", + itemCode = "WIDGET-1", + sourceReference = "WO:WO-001", + decision = decision, + inspectedQuantity = BigDecimal("10"), + rejectedQuantity = BigDecimal(rejected), + sourceLocationCode = src, + quarantineLocationCode = dst, + inspector = "user-42", + ) + + private fun fakeTransfer(code: String): StockTransfer { + val t = StockTransfer( + code = code, + fromLocationCode = "WH-MAIN", + toLocationCode = "WH-QUARANTINE", + ) + t.id = UUID.randomUUID() + return t + } + + @Test + fun `subscribe registers one listener for InspectionRecordedEvent`() { + val bus = mockk(relaxed = true) + val service = mockk() + QualityRejectionQuarantineSubscriber(bus, service).subscribe() + verify(exactly = 1) { + bus.subscribe(InspectionRecordedEvent::class.java, any>()) + } + } + + @Test + fun `handle creates and confirms a quarantine transfer on a fully-populated REJECTED event`() { + val bus = mockk(relaxed = true) + val service = mockk() + every { service.findByCode("TR-QC-QC-001") } returns null + val createdSlot = slot() + val fake = fakeTransfer("TR-QC-QC-001") + every { service.create(capture(createdSlot)) } returns fake + every { service.confirm(fake.id) } returns fake + + QualityRejectionQuarantineSubscriber(bus, service).handle(event()) + + verify(exactly = 1) { service.create(any()) } + verify(exactly = 1) { service.confirm(fake.id) } + assertThat(createdSlot.captured.code).isEqualTo("TR-QC-QC-001") + assertThat(createdSlot.captured.fromLocationCode).isEqualTo("WH-MAIN") + assertThat(createdSlot.captured.toLocationCode).isEqualTo("WH-QUARANTINE") + assertThat(createdSlot.captured.lines.size).isEqualTo(1) + assertThat(createdSlot.captured.lines[0].itemCode).isEqualTo("WIDGET-1") + assertThat(createdSlot.captured.lines[0].quantity).isEqualTo(BigDecimal("3")) + } + + @Test + fun `handle is a no-op when decision is APPROVED`() { + val bus = mockk(relaxed = true) + val service = mockk(relaxed = true) + QualityRejectionQuarantineSubscriber(bus, service).handle( + event(decision = "APPROVED", rejected = "0"), + ) + verify(exactly = 0) { service.create(any()) } + verify(exactly = 0) { service.confirm(any()) } + } + + @Test + fun `handle is a no-op when sourceLocationCode is missing`() { + val bus = mockk(relaxed = true) + val service = mockk(relaxed = true) + QualityRejectionQuarantineSubscriber(bus, service).handle(event(src = null)) + verify(exactly = 0) { service.create(any()) } + } + + @Test + fun `handle is a no-op when quarantineLocationCode is missing`() { + val bus = mockk(relaxed = true) + val service = mockk(relaxed = true) + QualityRejectionQuarantineSubscriber(bus, service).handle(event(dst = null)) + verify(exactly = 0) { service.create(any()) } + } + + @Test + fun `handle skips when a transfer with the derived code already exists idempotent replay`() { + val bus = mockk(relaxed = true) + val service = mockk() + val existing = fakeTransfer("TR-QC-QC-001") + existing.status = StockTransferStatus.CONFIRMED + every { service.findByCode("TR-QC-QC-001") } returns existing + + QualityRejectionQuarantineSubscriber(bus, service).handle(event()) + + verify(exactly = 0) { service.create(any()) } + verify(exactly = 0) { service.confirm(any()) } + } +} -- libgit2 0.22.2