diff --git a/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/ext/inventory/InventoryApi.kt b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/ext/inventory/InventoryApi.kt index 823033a..41168a2 100644 --- a/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/ext/inventory/InventoryApi.kt +++ b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/ext/inventory/InventoryApi.kt @@ -40,6 +40,25 @@ import java.math.BigDecimal interface InventoryApi { /** + * Look up an inventory location by its stable business code. + * Returns `null` when no location with that code exists. + * + * Cross-PBC callers use this as a pre-flight validation before + * committing to an operation that needs a location: for example, + * pbc-warehousing's `StockTransferService.create` uses it to + * reject an unknown `fromLocationCode` at document-creation time + * rather than waiting until `confirm()` rolls back a partial + * ledger write. Same shape as + * [org.vibeerp.api.v1.ext.catalog.CatalogApi.findItemByCode] — a + * lookup-by-code returning a lightweight ref or null. + * + * Only the code is returned (plus a few common descriptive + * fields); callers that need the full location record should go + * through the inventory REST API with their own token. + */ + fun findLocationByCode(locationCode: String): LocationRef? + + /** * Look up the on-hand stock balance for [itemCode] at * [locationCode]. Returns `null` when no balance row exists for * that combination — the framework treats "no row" as "no stock", @@ -131,3 +150,27 @@ data class StockBalanceRef( val locationCode: String, val quantity: BigDecimal, ) + +/** + * Minimal, safe-to-publish view of an inventory location. + * + * Returned by [InventoryApi.findLocationByCode]. Carries the stable + * business code, the display name, and the active flag. Fields that + * are not here are deliberately not part of the cross-PBC contract: + * the underlying id, the `ext` JSONB, the audit columns. A cross-PBC + * caller that needs richer location data should hit the inventory + * REST API directly with their own token. + * + * The `type` field is a free-form String, not an enum, because + * pbc-inventory has historically grown new location types + * (WAREHOUSE, SHOP, VIRTUAL, IN_TRANSIT, ...) at roughly one per + * quarter; a String here decouples the cross-PBC surface from that + * churn. Callers that want to switch on it do so at their own risk. + */ +data class LocationRef( + val id: Id, + val code: String, + val name: String, + val type: String, + val active: Boolean, +) diff --git a/pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/ext/InventoryApiAdapter.kt b/pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/ext/InventoryApiAdapter.kt index c22ab33..0c09374 100644 --- a/pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/ext/InventoryApiAdapter.kt +++ b/pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/ext/InventoryApiAdapter.kt @@ -4,9 +4,11 @@ import org.springframework.stereotype.Component import org.springframework.transaction.annotation.Transactional import org.vibeerp.api.v1.core.Id import org.vibeerp.api.v1.ext.inventory.InventoryApi +import org.vibeerp.api.v1.ext.inventory.LocationRef import org.vibeerp.api.v1.ext.inventory.StockBalanceRef import org.vibeerp.pbc.inventory.application.RecordMovementCommand import org.vibeerp.pbc.inventory.application.StockMovementService +import org.vibeerp.pbc.inventory.domain.Location import org.vibeerp.pbc.inventory.domain.MovementReason import org.vibeerp.pbc.inventory.domain.StockBalance import org.vibeerp.pbc.inventory.infrastructure.LocationJpaRepository @@ -48,6 +50,11 @@ class InventoryApiAdapter( private val stockMovementService: StockMovementService, ) : InventoryApi { + override fun findLocationByCode(locationCode: String): LocationRef? { + val location = locations.findByCode(locationCode) ?: return null + return location.toRef() + } + override fun findStockBalance(itemCode: String, locationCode: String): StockBalanceRef? { val location = locations.findByCode(locationCode) ?: return null val balance = balances.findByItemCodeAndLocationId(itemCode, location.id) ?: return null @@ -108,4 +115,12 @@ class InventoryApiAdapter( locationCode = locationCode, quantity = this.quantity, ) + + private fun Location.toRef(): LocationRef = LocationRef( + id = Id(this.id), + code = this.code, + name = this.name, + type = this.type.name, + active = this.active, + ) } diff --git a/pbc/pbc-warehousing/src/main/kotlin/org/vibeerp/pbc/warehousing/application/StockTransferService.kt b/pbc/pbc-warehousing/src/main/kotlin/org/vibeerp/pbc/warehousing/application/StockTransferService.kt index fc88755..045d782 100644 --- a/pbc/pbc-warehousing/src/main/kotlin/org/vibeerp/pbc/warehousing/application/StockTransferService.kt +++ b/pbc/pbc-warehousing/src/main/kotlin/org/vibeerp/pbc/warehousing/application/StockTransferService.kt @@ -81,6 +81,26 @@ class StockTransferService( "stock transfer '${command.code}' must have at least one line" } + // Validate both locations exist via the inventory facade. Doing + // this at create time (not just at confirm time) means the + // operator gets immediate feedback on a typo instead of + // discovering it only when the confirm rolls back. Same + // pattern: ItemCode is validated at create via CatalogApi too. + val fromLocation = inventoryApi.findLocationByCode(command.fromLocationCode) + ?: throw IllegalArgumentException( + "from location code '${command.fromLocationCode}' is not in the inventory directory", + ) + val toLocation = inventoryApi.findLocationByCode(command.toLocationCode) + ?: throw IllegalArgumentException( + "to location code '${command.toLocationCode}' is not in the inventory directory", + ) + require(fromLocation.active) { + "from location '${command.fromLocationCode}' is deactivated and cannot be transfer source" + } + require(toLocation.active) { + "to location '${command.toLocationCode}' is deactivated and cannot be transfer destination" + } + val seenLineNos = HashSet(command.lines.size) for (line in command.lines) { require(line.lineNo > 0) { diff --git a/pbc/pbc-warehousing/src/test/kotlin/org/vibeerp/pbc/warehousing/application/StockTransferServiceTest.kt b/pbc/pbc-warehousing/src/test/kotlin/org/vibeerp/pbc/warehousing/application/StockTransferServiceTest.kt index a167f66..b9f5190 100644 --- a/pbc/pbc-warehousing/src/test/kotlin/org/vibeerp/pbc/warehousing/application/StockTransferServiceTest.kt +++ b/pbc/pbc-warehousing/src/test/kotlin/org/vibeerp/pbc/warehousing/application/StockTransferServiceTest.kt @@ -16,6 +16,7 @@ import org.vibeerp.api.v1.core.Id import org.vibeerp.api.v1.ext.catalog.CatalogApi import org.vibeerp.api.v1.ext.catalog.ItemRef import org.vibeerp.api.v1.ext.inventory.InventoryApi +import org.vibeerp.api.v1.ext.inventory.LocationRef import org.vibeerp.api.v1.ext.inventory.StockBalanceRef import org.vibeerp.pbc.warehousing.domain.StockTransfer import org.vibeerp.pbc.warehousing.domain.StockTransferStatus @@ -42,6 +43,16 @@ class StockTransferServiceTest { ) } + private fun stubLocation(code: String, active: Boolean = true) { + every { inventory.findLocationByCode(code) } returns LocationRef( + id = Id(UUID.randomUUID()), + code = code, + name = "$code name", + type = "WAREHOUSE", + active = active, + ) + } + private fun cmd( code: String = "TR-001", from: String = "WH-A", @@ -60,6 +71,8 @@ class StockTransferServiceTest { @Test fun `create persists a DRAFT transfer when everything validates`() { every { transfers.existsByCode("TR-001") } returns false + stubLocation("WH-A") + stubLocation("WH-B") stubItemExists("ITEM-1") stubItemExists("ITEM-2") every { transfers.save(any()) } answers { firstArg() } @@ -105,6 +118,8 @@ class StockTransferServiceTest { @Test fun `create rejects duplicate line numbers`() { every { transfers.existsByCode(any()) } returns false + stubLocation("WH-A") + stubLocation("WH-B") stubItemExists("X") assertFailure { @@ -124,6 +139,8 @@ class StockTransferServiceTest { @Test fun `create rejects non-positive quantities`() { every { transfers.existsByCode(any()) } returns false + stubLocation("WH-A") + stubLocation("WH-B") stubItemExists("X") assertFailure { @@ -137,6 +154,8 @@ class StockTransferServiceTest { @Test fun `create rejects unknown items via CatalogApi`() { every { transfers.existsByCode(any()) } returns false + stubLocation("WH-A") + stubLocation("WH-B") every { catalog.findItemByCode("GHOST") } returns null assertFailure { @@ -148,6 +167,37 @@ class StockTransferServiceTest { } @Test + fun `create rejects unknown from location via InventoryApi`() { + every { transfers.existsByCode(any()) } returns false + every { inventory.findLocationByCode("WH-A") } returns null + + assertFailure { service.create(cmd(from = "WH-A", to = "WH-B")) } + .isInstanceOf(IllegalArgumentException::class) + .hasMessage("from location code 'WH-A' is not in the inventory directory") + } + + @Test + fun `create rejects unknown to location via InventoryApi`() { + every { transfers.existsByCode(any()) } returns false + stubLocation("WH-A") + every { inventory.findLocationByCode("WH-B") } returns null + + assertFailure { service.create(cmd(from = "WH-A", to = "WH-B")) } + .isInstanceOf(IllegalArgumentException::class) + .hasMessage("to location code 'WH-B' is not in the inventory directory") + } + + @Test + fun `create rejects a deactivated from location`() { + every { transfers.existsByCode(any()) } returns false + stubLocation("WH-A", active = false) + stubLocation("WH-B") + + assertFailure { service.create(cmd(from = "WH-A", to = "WH-B")) } + .isInstanceOf(IllegalArgumentException::class) + } + + @Test fun `confirm writes an atomic TRANSFER_OUT + TRANSFER_IN pair per line`() { val id = UUID.randomUUID() val transfer = StockTransfer(