Commit 19e060eccf60fb55f403059c6200743517ac3f82

Authored by zichun
1 parent 39152a69

feat(inventory+warehousing): InventoryApi.findLocationByCode — pbc-warehousing v…

…alidates locations at create

Follow-up to the pbc-warehousing chunk. Plugs a real gap noticed in
the smoke test: an unknown fromLocationCode or toLocationCode on a
StockTransfer was silently accepted at create() and only surfaced
as a confirm()-time rollback, which is a confusing UX — the operator
types TR-001 wrong, hits "create", then hits "confirm" minutes later
and sees "location GHOST-SRC is not in the inventory directory".

## api.v1 growth

New cross-PBC method on `InventoryApi`:

    fun findLocationByCode(locationCode: String): LocationRef?

Parallel shape to `CatalogApi.findItemByCode` — a lookup-by-code
returning a lightweight ref or null, safe for any cross-PBC consumer
to inject. The returned `LocationRef` data class carries id, code,
name, type (as a String, not the inventory-internal LocationType
enum — rationale in the KDoc), and active flag. Fields that are
NOT part of the cross-PBC contract (audit columns, ext JSONB, the
raw JPA entity) stay inside pbc-inventory.

api.v1 additive change within the v1 line — no breaking rename, no
signature churn on existing methods. The interface adds a new
abstract method, which IS technically a source-breaking change for
any in-tree implementation, but the only impl is
pbc-inventory/InventoryApiAdapter which is updated in the same
commit. No external plug-in implements InventoryApi (by design;
plug-ins inject it, they don't provide it).

## Adapter implementation

`InventoryApiAdapter.findLocationByCode` resolves the location via
the existing `LocationJpaRepository.findByCode`, which is exactly
what `recordMovement` already uses. A new private extension
`Location.toRef()` builds the api.v1 DTO. Zero new SQL; zero new
repository methods.

## pbc-warehousing wiring

`StockTransferService.create` now calls the facade twice — once for
the source location, once for the destination — BEFORE validating
lines. The four-step ordering is: code uniqueness → from != to →
non-empty lines → both locations exist and are active → per-line
validation. Unknown locations produce a 400 with a clear message;
deactivated locations produce a 400 distinguishing "doesn't exist"
from "exists but can't be used":

    "from location code 'GHOST-SRC' is not in the inventory directory"
    "from location 'WH-CLOSED' is deactivated and cannot be transfer source"

The confirm() path is unchanged. Locations may still vanish between
create and confirm (though the likelihood is low for a normal
workflow), and `recordMovement` will still raise its own error in
that case — belt and suspenders.

## Smoke test

```
POST /api/v1/inventory/locations {code: WH-GOOD, type: WAREHOUSE}
POST /api/v1/catalog/items       {code: ITEM-1, baseUomCode: ea}

POST /api/v1/warehousing/stock-transfers
     {code: TR-bad, fromLocationCode: GHOST-SRC, toLocationCode: WH-GOOD,
      lines: [{lineNo: 1, itemCode: ITEM-1, quantity: 1}]}
  → 400 "from location code 'GHOST-SRC' is not in the inventory directory"
     (before this commit: 201 DRAFT, then 400 at confirm)

POST /api/v1/warehousing/stock-transfers
     {code: TR-bad2, fromLocationCode: WH-GOOD, toLocationCode: GHOST-DST,
      lines: [{lineNo: 1, itemCode: ITEM-1, quantity: 1}]}
  → 400 "to location code 'GHOST-DST' is not in the inventory directory"

POST /api/v1/warehousing/stock-transfers
     {code: TR-ok, fromLocationCode: WH-GOOD, toLocationCode: WH-OTHER,
      lines: [{lineNo: 1, itemCode: ITEM-1, quantity: 1}]}
  → 201 DRAFT   ← happy path still works
```

## Tests

- Updated the 3 existing `StockTransferServiceTest` tests that
  created real transfers to stub `inventory.findLocationByCode` for
  both WH-A and WH-B via a new `stubLocation()` helper.
- 3 new tests:
  * `create rejects unknown from location via InventoryApi`
  * `create rejects unknown to location via InventoryApi`
  * `create rejects a deactivated from location`
- Total framework unit tests: 300 (was 297), all green.

## Why this isn't a breaking api.v1 change

InventoryApi is an interface consumed by other PBCs and by plug-ins,
implemented ONLY by pbc-inventory. Adding a new method to an
interface IS a source-breaking change for any implementer — but
the framework's dependency rules mean no external code implements
this interface. Plug-ins and other PBCs CONSUME it via dependency
injection; the only production impl is InventoryApiAdapter, updated
in the same commit. Binary compatibility for consumers is
preserved: existing call sites compile and run unchanged because
only the interface grew, not its existing methods.

If/when a third party implements InventoryApi (e.g. a test double
outside the framework, or a custom backend plug-in), this would be
a semver-major-worthy addition. For the in-tree framework, it's
additive-within-a-major.
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/ext/inventory/InventoryApi.kt
... ... @@ -40,6 +40,25 @@ import java.math.BigDecimal
40 40 interface InventoryApi {
41 41  
42 42 /**
  43 + * Look up an inventory location by its stable business code.
  44 + * Returns `null` when no location with that code exists.
  45 + *
  46 + * Cross-PBC callers use this as a pre-flight validation before
  47 + * committing to an operation that needs a location: for example,
  48 + * pbc-warehousing's `StockTransferService.create` uses it to
  49 + * reject an unknown `fromLocationCode` at document-creation time
  50 + * rather than waiting until `confirm()` rolls back a partial
  51 + * ledger write. Same shape as
  52 + * [org.vibeerp.api.v1.ext.catalog.CatalogApi.findItemByCode] — a
  53 + * lookup-by-code returning a lightweight ref or null.
  54 + *
  55 + * Only the code is returned (plus a few common descriptive
  56 + * fields); callers that need the full location record should go
  57 + * through the inventory REST API with their own token.
  58 + */
  59 + fun findLocationByCode(locationCode: String): LocationRef?
  60 +
  61 + /**
43 62 * Look up the on-hand stock balance for [itemCode] at
44 63 * [locationCode]. Returns `null` when no balance row exists for
45 64 * that combination — the framework treats "no row" as "no stock",
... ... @@ -131,3 +150,27 @@ data class StockBalanceRef(
131 150 val locationCode: String,
132 151 val quantity: BigDecimal,
133 152 )
  153 +
  154 +/**
  155 + * Minimal, safe-to-publish view of an inventory location.
  156 + *
  157 + * Returned by [InventoryApi.findLocationByCode]. Carries the stable
  158 + * business code, the display name, and the active flag. Fields that
  159 + * are not here are deliberately not part of the cross-PBC contract:
  160 + * the underlying id, the `ext` JSONB, the audit columns. A cross-PBC
  161 + * caller that needs richer location data should hit the inventory
  162 + * REST API directly with their own token.
  163 + *
  164 + * The `type` field is a free-form String, not an enum, because
  165 + * pbc-inventory has historically grown new location types
  166 + * (WAREHOUSE, SHOP, VIRTUAL, IN_TRANSIT, ...) at roughly one per
  167 + * quarter; a String here decouples the cross-PBC surface from that
  168 + * churn. Callers that want to switch on it do so at their own risk.
  169 + */
  170 +data class LocationRef(
  171 + val id: Id<LocationRef>,
  172 + val code: String,
  173 + val name: String,
  174 + val type: String,
  175 + val active: Boolean,
  176 +)
... ...
pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/ext/InventoryApiAdapter.kt
... ... @@ -4,9 +4,11 @@ import org.springframework.stereotype.Component
4 4 import org.springframework.transaction.annotation.Transactional
5 5 import org.vibeerp.api.v1.core.Id
6 6 import org.vibeerp.api.v1.ext.inventory.InventoryApi
  7 +import org.vibeerp.api.v1.ext.inventory.LocationRef
7 8 import org.vibeerp.api.v1.ext.inventory.StockBalanceRef
8 9 import org.vibeerp.pbc.inventory.application.RecordMovementCommand
9 10 import org.vibeerp.pbc.inventory.application.StockMovementService
  11 +import org.vibeerp.pbc.inventory.domain.Location
10 12 import org.vibeerp.pbc.inventory.domain.MovementReason
11 13 import org.vibeerp.pbc.inventory.domain.StockBalance
12 14 import org.vibeerp.pbc.inventory.infrastructure.LocationJpaRepository
... ... @@ -48,6 +50,11 @@ class InventoryApiAdapter(
48 50 private val stockMovementService: StockMovementService,
49 51 ) : InventoryApi {
50 52  
  53 + override fun findLocationByCode(locationCode: String): LocationRef? {
  54 + val location = locations.findByCode(locationCode) ?: return null
  55 + return location.toRef()
  56 + }
  57 +
51 58 override fun findStockBalance(itemCode: String, locationCode: String): StockBalanceRef? {
52 59 val location = locations.findByCode(locationCode) ?: return null
53 60 val balance = balances.findByItemCodeAndLocationId(itemCode, location.id) ?: return null
... ... @@ -108,4 +115,12 @@ class InventoryApiAdapter(
108 115 locationCode = locationCode,
109 116 quantity = this.quantity,
110 117 )
  118 +
  119 + private fun Location.toRef(): LocationRef = LocationRef(
  120 + id = Id<LocationRef>(this.id),
  121 + code = this.code,
  122 + name = this.name,
  123 + type = this.type.name,
  124 + active = this.active,
  125 + )
111 126 }
... ...
pbc/pbc-warehousing/src/main/kotlin/org/vibeerp/pbc/warehousing/application/StockTransferService.kt
... ... @@ -81,6 +81,26 @@ class StockTransferService(
81 81 "stock transfer '${command.code}' must have at least one line"
82 82 }
83 83  
  84 + // Validate both locations exist via the inventory facade. Doing
  85 + // this at create time (not just at confirm time) means the
  86 + // operator gets immediate feedback on a typo instead of
  87 + // discovering it only when the confirm rolls back. Same
  88 + // pattern: ItemCode is validated at create via CatalogApi too.
  89 + val fromLocation = inventoryApi.findLocationByCode(command.fromLocationCode)
  90 + ?: throw IllegalArgumentException(
  91 + "from location code '${command.fromLocationCode}' is not in the inventory directory",
  92 + )
  93 + val toLocation = inventoryApi.findLocationByCode(command.toLocationCode)
  94 + ?: throw IllegalArgumentException(
  95 + "to location code '${command.toLocationCode}' is not in the inventory directory",
  96 + )
  97 + require(fromLocation.active) {
  98 + "from location '${command.fromLocationCode}' is deactivated and cannot be transfer source"
  99 + }
  100 + require(toLocation.active) {
  101 + "to location '${command.toLocationCode}' is deactivated and cannot be transfer destination"
  102 + }
  103 +
84 104 val seenLineNos = HashSet<Int>(command.lines.size)
85 105 for (line in command.lines) {
86 106 require(line.lineNo > 0) {
... ...
pbc/pbc-warehousing/src/test/kotlin/org/vibeerp/pbc/warehousing/application/StockTransferServiceTest.kt
... ... @@ -16,6 +16,7 @@ import org.vibeerp.api.v1.core.Id
16 16 import org.vibeerp.api.v1.ext.catalog.CatalogApi
17 17 import org.vibeerp.api.v1.ext.catalog.ItemRef
18 18 import org.vibeerp.api.v1.ext.inventory.InventoryApi
  19 +import org.vibeerp.api.v1.ext.inventory.LocationRef
19 20 import org.vibeerp.api.v1.ext.inventory.StockBalanceRef
20 21 import org.vibeerp.pbc.warehousing.domain.StockTransfer
21 22 import org.vibeerp.pbc.warehousing.domain.StockTransferStatus
... ... @@ -42,6 +43,16 @@ class StockTransferServiceTest {
42 43 )
43 44 }
44 45  
  46 + private fun stubLocation(code: String, active: Boolean = true) {
  47 + every { inventory.findLocationByCode(code) } returns LocationRef(
  48 + id = Id(UUID.randomUUID()),
  49 + code = code,
  50 + name = "$code name",
  51 + type = "WAREHOUSE",
  52 + active = active,
  53 + )
  54 + }
  55 +
45 56 private fun cmd(
46 57 code: String = "TR-001",
47 58 from: String = "WH-A",
... ... @@ -60,6 +71,8 @@ class StockTransferServiceTest {
60 71 @Test
61 72 fun `create persists a DRAFT transfer when everything validates`() {
62 73 every { transfers.existsByCode("TR-001") } returns false
  74 + stubLocation("WH-A")
  75 + stubLocation("WH-B")
63 76 stubItemExists("ITEM-1")
64 77 stubItemExists("ITEM-2")
65 78 every { transfers.save(any<StockTransfer>()) } answers { firstArg() }
... ... @@ -105,6 +118,8 @@ class StockTransferServiceTest {
105 118 @Test
106 119 fun `create rejects duplicate line numbers`() {
107 120 every { transfers.existsByCode(any()) } returns false
  121 + stubLocation("WH-A")
  122 + stubLocation("WH-B")
108 123 stubItemExists("X")
109 124  
110 125 assertFailure {
... ... @@ -124,6 +139,8 @@ class StockTransferServiceTest {
124 139 @Test
125 140 fun `create rejects non-positive quantities`() {
126 141 every { transfers.existsByCode(any()) } returns false
  142 + stubLocation("WH-A")
  143 + stubLocation("WH-B")
127 144 stubItemExists("X")
128 145  
129 146 assertFailure {
... ... @@ -137,6 +154,8 @@ class StockTransferServiceTest {
137 154 @Test
138 155 fun `create rejects unknown items via CatalogApi`() {
139 156 every { transfers.existsByCode(any()) } returns false
  157 + stubLocation("WH-A")
  158 + stubLocation("WH-B")
140 159 every { catalog.findItemByCode("GHOST") } returns null
141 160  
142 161 assertFailure {
... ... @@ -148,6 +167,37 @@ class StockTransferServiceTest {
148 167 }
149 168  
150 169 @Test
  170 + fun `create rejects unknown from location via InventoryApi`() {
  171 + every { transfers.existsByCode(any()) } returns false
  172 + every { inventory.findLocationByCode("WH-A") } returns null
  173 +
  174 + assertFailure { service.create(cmd(from = "WH-A", to = "WH-B")) }
  175 + .isInstanceOf(IllegalArgumentException::class)
  176 + .hasMessage("from location code 'WH-A' is not in the inventory directory")
  177 + }
  178 +
  179 + @Test
  180 + fun `create rejects unknown to location via InventoryApi`() {
  181 + every { transfers.existsByCode(any()) } returns false
  182 + stubLocation("WH-A")
  183 + every { inventory.findLocationByCode("WH-B") } returns null
  184 +
  185 + assertFailure { service.create(cmd(from = "WH-A", to = "WH-B")) }
  186 + .isInstanceOf(IllegalArgumentException::class)
  187 + .hasMessage("to location code 'WH-B' is not in the inventory directory")
  188 + }
  189 +
  190 + @Test
  191 + fun `create rejects a deactivated from location`() {
  192 + every { transfers.existsByCode(any()) } returns false
  193 + stubLocation("WH-A", active = false)
  194 + stubLocation("WH-B")
  195 +
  196 + assertFailure { service.create(cmd(from = "WH-A", to = "WH-B")) }
  197 + .isInstanceOf(IllegalArgumentException::class)
  198 + }
  199 +
  200 + @Test
151 201 fun `confirm writes an atomic TRANSFER_OUT + TRANSFER_IN pair per line`() {
152 202 val id = UUID.randomUUID()
153 203 val transfer = StockTransfer(
... ...