Commit ba507803de38d3825bfa1741d957f0f1b44a34cb

Authored by zichun
1 parent ab9cc192

feat(pbc-warehousing): P5.4 — StockTransfer aggregate with atomic TRANSFER_OUT/IN ledger pair

Ninth core PBC. Ships the first-class orchestration aggregate for
moving stock between locations: a header + lines that represents
operator intent, and a confirm() verb that atomically posts the
matching TRANSFER_OUT / TRANSFER_IN ledger pair per line via the
existing InventoryApi.recordMovement facade.

Takes the framework's core-PBC count to 9 of 10 (only pbc-quality
remains in the P5.x row).

## The shape

pbc-warehousing sits above pbc-inventory in the dependency graph:
it doesn't replace the flat movement ledger, it orchestrates
multi-row ledger writes with a business-level document on top. A
DRAFT `warehousing__stock_transfer` row is queued intent (pickers
haven't started yet); a CONFIRMED row reflects movements that have
already posted to the `inventory__stock_movement` ledger. Each
confirmed line becomes two ledger rows:

  TRANSFER_OUT(itemCode, fromLocationCode, -quantity, ref="TR:<code>")
  TRANSFER_IN (itemCode, toLocationCode,    quantity, ref="TR:<code>")

All rows of one confirm call run inside ONE @Transactional method,
so a failure anywhere — unknown item, unknown location, balance
would go below zero — rolls back EVERY line's both halves. There
is no half-confirmed transfer.

## Module contents

- `build.gradle.kts` — new Gradle subproject, api-v1 + platform/*
  dependencies only. No cross-PBC dependency (guardrail #9 stays
  honest; CatalogApi + InventoryApi both come in via api.v1.ext).
- `StockTransfer` entity — header with code, from/to location
  codes, status (DRAFT/CONFIRMED/CANCELLED), transfer_date, note,
  OneToMany<StockTransferLine>. Table name
  `warehousing__stock_transfer`.
- `StockTransferLine` entity — lineNo, itemCode, quantity.
  `transfer_id → warehousing__stock_transfer(id) ON DELETE CASCADE`,
  unique `(transfer_id, line_no)`.
- `StockTransferJpaRepository` — existsByCode + findByCode.
- `StockTransferService` — create / confirm / cancel + three read
  methods. @Transactional service-level; all state transitions run
  through @Transactional methods so the event-bus MANDATORY
  propagation (if/when a pbc-warehousing event is added later) has
  a transaction to join. Business invariants:
    * code is unique (existsByCode short-circuit)
    * from != to (enforced in code AND in the Liquibase CHECK)
    * at least one line
    * each line: positive line_no, unique per transfer, positive
      quantity, itemCode must resolve via CatalogApi.findItemByCode
    * confirm requires DRAFT; writes OUT-first-per-line so a
      balance-goes-negative error aborts before touching the
      destination location
    * cancel requires DRAFT; CONFIRMED transfers are terminal
      (reverse by creating a NEW transfer in the opposite direction,
      matching the document-discipline rule every other PBC uses)
- `StockTransferController` — `/api/v1/warehousing/stock-transfers`
  with GET list, GET by id, GET by-code, POST create, POST
  {id}/confirm, POST {id}/cancel. Every endpoint
  @RequirePermission-gated using the keys declared in the metadata
  YAML. Matches the shape of pbc-orders-sales, pbc-orders-purchase,
  pbc-production.
- DTOs use the established pattern — jakarta.validation on the
  request, response mapping via extension functions.
- `META-INF/vibe-erp/metadata/warehousing.yml` — 1 entity, 4
  permissions, 1 menu. Loaded by MetadataLoader at boot, visible
  via `GET /api/v1/_meta/metadata`.
- `distribution/src/main/resources/db/changelog/pbc-warehousing/001-warehousing-init.xml`
  — creates both tables with the full audit column set, state
  CHECK constraint, locations-distinct CHECK, unique
  (transfer_id, line_no) index, quantity > 0 CHECK, item_code
  index for cross-PBC grep.
- `settings.gradle.kts`, `distribution/build.gradle.kts`,
  `master.xml` all wired.

## Smoke test (fresh DB + running app)

```
# seed
POST /api/v1/catalog/items   {code: PAPER-A4, baseUomCode: sheet}
POST /api/v1/catalog/items   {code: PAPER-A3, baseUomCode: sheet}
POST /api/v1/inventory/locations {code: WH-MAIN, type: WAREHOUSE}
POST /api/v1/inventory/locations {code: WH-SHOP, type: WAREHOUSE}
POST /api/v1/inventory/movements {itemCode: PAPER-A4, locationId: <WH-MAIN>, delta: 100, reason: RECEIPT}
POST /api/v1/inventory/movements {itemCode: PAPER-A3, locationId: <WH-MAIN>, delta: 50,  reason: RECEIPT}

# exercise the new PBC
POST /api/v1/warehousing/stock-transfers
     {code: TR-001, fromLocationCode: WH-MAIN, toLocationCode: WH-SHOP,
      lines: [{lineNo: 1, itemCode: PAPER-A4, quantity: 30},
              {lineNo: 2, itemCode: PAPER-A3, quantity: 10}]}
  → 201 DRAFT
POST /api/v1/warehousing/stock-transfers/<id>/confirm
  → 200 CONFIRMED

# verify balances via the raw DB (the HTTP stock-balance endpoint
# has a separate unrelated bug returning 500; the ledger state is
# what this commit is proving)
SELECT item_code, location_id, quantity FROM inventory__stock_balance;
  PAPER-A4 / WH-MAIN →  70   ← debited 30
  PAPER-A4 / WH-SHOP →  30   ← credited 30
  PAPER-A3 / WH-MAIN →  40   ← debited 10
  PAPER-A3 / WH-SHOP →  10   ← credited 10

SELECT item_code, location_id, reason, delta, reference
  FROM inventory__stock_movement ORDER BY occurred_at;
  PAPER-A4 / WH-MAIN / TRANSFER_OUT / -30 / TR:TR-001
  PAPER-A4 / WH-SHOP / TRANSFER_IN  /  30 / TR:TR-001
  PAPER-A3 / WH-MAIN / TRANSFER_OUT / -10 / TR:TR-001
  PAPER-A3 / WH-SHOP / TRANSFER_IN  /  10 / TR:TR-001
```

Four rows all tagged `TR:TR-001`. A grep of the ledger attributes
both halves of each line to the single source transfer document.

## Transactional rollback test (in the same smoke run)

```
# ask for more than exists
POST /api/v1/warehousing/stock-transfers
     {code: TR-002, from: WH-MAIN, to: WH-SHOP,
      lines: [{lineNo: 1, itemCode: PAPER-A4, quantity: 1000}]}
  → 201 DRAFT
POST /api/v1/warehousing/stock-transfers/<id>/confirm
  → 400 "stock movement would push balance for 'PAPER-A4' at
         location <WH-MAIN> below zero (current=70.0000, delta=-1000.0000)"

# assert TR-002 is still DRAFT
GET /api/v1/warehousing/stock-transfers/<id> → status: DRAFT  ← NOT flipped to CONFIRMED

# assert the ledger still has exactly 6 rows (no partial writes)
SELECT count(*) FROM inventory__stock_movement; → 6
```

The failed confirm left no residue: status stayed DRAFT, and the
ledger count is unchanged at 6 (the 2 RECEIPT seeds + the 4
TRANSFER_OUT/IN from TR-001). Propagation.REQUIRED + Spring's
default rollback-on-unchecked-exception semantics do exactly what
the KDoc promises.

## State-machine guards

```
POST /api/v1/warehousing/stock-transfers/<confirmed-id>/confirm
  → 400 "cannot confirm stock transfer TR-001 in status CONFIRMED;
         only DRAFT can be confirmed"

POST /api/v1/warehousing/stock-transfers/<confirmed-id>/cancel
  → 400 "cannot cancel stock transfer TR-001 in status CONFIRMED;
         only DRAFT can be cancelled — reverse a confirmed transfer
         by creating a new one in the other direction"
```

## Tests

- 10 new unit tests in `StockTransferServiceTest`:
  * `create persists a DRAFT transfer when everything validates`
  * `create rejects duplicate code`
  * `create rejects same from and to location`
  * `create rejects an empty line list`
  * `create rejects duplicate line numbers`
  * `create rejects non-positive quantities`
  * `create rejects unknown items via CatalogApi`
  * `confirm writes an atomic TRANSFER_OUT + TRANSFER_IN pair per line`
    — uses `verifyOrder` to assert OUT-first-per-line dispatch order
  * `confirm refuses a non-DRAFT transfer`
  * `cancel refuses a CONFIRMED transfer`
  * `cancel flips a DRAFT transfer to CANCELLED`
- Total framework unit tests: 288 (was 278), all green.

## What this unblocks

- **Real warehouse workflows** — confirm a transfer from a picker
  UI (R1 is pending), driven by a BPMN that hands the confirm to a
  TaskHandler once the physical move is complete.
- **pbc-quality (P5.8, last remaining core PBC)** — inspection
  plans + results + holds. Holds would typically quarantine stock
  by moving it to a QUARANTINE location via a stock transfer,
  which is the natural consumer for this aggregate.
- **Stocktakes (physical inventory reconciliation)** — future
  pbc-warehousing verb that compares counted vs recorded and posts
  the differences as ADJUSTMENT rows; shares the same
  `recordMovement` primitive.
distribution/build.gradle.kts
... ... @@ -31,6 +31,7 @@ dependencies {
31 31 implementation(project(":pbc:pbc-catalog"))
32 32 implementation(project(":pbc:pbc-partners"))
33 33 implementation(project(":pbc:pbc-inventory"))
  34 + implementation(project(":pbc:pbc-warehousing"))
34 35 implementation(project(":pbc:pbc-orders-sales"))
35 36 implementation(project(":pbc:pbc-orders-purchase"))
36 37 implementation(project(":pbc:pbc-finance"))
... ...
distribution/src/main/resources/db/changelog/master.xml
... ... @@ -18,6 +18,7 @@
18 18 <include file="classpath:db/changelog/pbc-partners/001-partners-init.xml"/>
19 19 <include file="classpath:db/changelog/pbc-inventory/001-inventory-init.xml"/>
20 20 <include file="classpath:db/changelog/pbc-inventory/002-inventory-movement-ledger.xml"/>
  21 + <include file="classpath:db/changelog/pbc-warehousing/001-warehousing-init.xml"/>
21 22 <include file="classpath:db/changelog/pbc-orders-sales/001-orders-sales-init.xml"/>
22 23 <include file="classpath:db/changelog/pbc-orders-purchase/001-orders-purchase-init.xml"/>
23 24 <include file="classpath:db/changelog/pbc-finance/001-finance-init.xml"/>
... ...
distribution/src/main/resources/db/changelog/pbc-warehousing/001-warehousing-init.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-warehousing initial schema (P5.4).
  9 +
  10 + Owns: warehousing__stock_transfer + warehousing__stock_transfer_line.
  11 +
  12 + StockTransfer is a first-class orchestration aggregate on top of
  13 + the flat InventoryApi.recordMovement primitive. Confirming a DRAFT
  14 + transfer writes one TRANSFER_OUT + TRANSFER_IN pair per line
  15 + atomically in a single transaction, referenced TR:<code>.
  16 +
  17 + Neither item_code nor the location codes are foreign keys — they
  18 + are cross-PBC references. DB-level FKs across PBCs would couple
  19 + pbc-warehousing's schema with pbc-catalog and pbc-inventory at
  20 + the storage level, defeating the bounded-context rule. Existence
  21 + is enforced at the application layer via CatalogApi at create time
  22 + and InventoryApi.recordMovement at confirm time.
  23 + -->
  24 +
  25 + <changeSet id="warehousing-init-001" author="vibe_erp">
  26 + <comment>Create warehousing__stock_transfer + warehousing__stock_transfer_line tables</comment>
  27 + <sql>
  28 + CREATE TABLE warehousing__stock_transfer (
  29 + id uuid PRIMARY KEY,
  30 + code varchar(64) NOT NULL,
  31 + from_location_code varchar(64) NOT NULL,
  32 + to_location_code varchar(64) NOT NULL,
  33 + status varchar(16) NOT NULL,
  34 + transfer_date date,
  35 + note varchar(512),
  36 + created_at timestamptz NOT NULL,
  37 + created_by varchar(128) NOT NULL,
  38 + updated_at timestamptz NOT NULL,
  39 + updated_by varchar(128) NOT NULL,
  40 + version bigint NOT NULL DEFAULT 0,
  41 + CONSTRAINT warehousing__stock_transfer_status_check
  42 + CHECK (status IN ('DRAFT', 'CONFIRMED', 'CANCELLED')),
  43 + CONSTRAINT warehousing__stock_transfer_locations_distinct
  44 + CHECK (from_location_code &lt;&gt; to_location_code)
  45 + );
  46 + CREATE UNIQUE INDEX warehousing__stock_transfer_code_uk
  47 + ON warehousing__stock_transfer (code);
  48 + CREATE INDEX warehousing__stock_transfer_status_idx
  49 + ON warehousing__stock_transfer (status);
  50 + CREATE INDEX warehousing__stock_transfer_from_idx
  51 + ON warehousing__stock_transfer (from_location_code);
  52 + CREATE INDEX warehousing__stock_transfer_to_idx
  53 + ON warehousing__stock_transfer (to_location_code);
  54 +
  55 + CREATE TABLE warehousing__stock_transfer_line (
  56 + id uuid PRIMARY KEY,
  57 + transfer_id uuid NOT NULL
  58 + REFERENCES warehousing__stock_transfer (id) ON DELETE CASCADE,
  59 + line_no integer NOT NULL,
  60 + item_code varchar(64) NOT NULL,
  61 + quantity numeric(18,4) NOT NULL,
  62 + created_at timestamptz NOT NULL,
  63 + created_by varchar(128) NOT NULL,
  64 + updated_at timestamptz NOT NULL,
  65 + updated_by varchar(128) NOT NULL,
  66 + version bigint NOT NULL DEFAULT 0,
  67 + CONSTRAINT warehousing__stock_transfer_line_qty_pos
  68 + CHECK (quantity &gt; 0)
  69 + );
  70 + CREATE UNIQUE INDEX warehousing__stock_transfer_line_uk
  71 + ON warehousing__stock_transfer_line (transfer_id, line_no);
  72 + CREATE INDEX warehousing__stock_transfer_line_item_idx
  73 + ON warehousing__stock_transfer_line (item_code);
  74 + </sql>
  75 + <rollback>
  76 + DROP TABLE warehousing__stock_transfer_line;
  77 + DROP TABLE warehousing__stock_transfer;
  78 + </rollback>
  79 + </changeSet>
  80 +
  81 +</databaseChangeLog>
... ...
pbc/pbc-warehousing/build.gradle.kts 0 → 100644
  1 +plugins {
  2 + alias(libs.plugins.kotlin.jvm)
  3 + alias(libs.plugins.kotlin.spring)
  4 + alias(libs.plugins.kotlin.jpa)
  5 + alias(libs.plugins.spring.dependency.management)
  6 +}
  7 +
  8 +description = "vibe_erp pbc-warehousing — first-class stock transfers across locations. " +
  9 + "Wraps InventoryApi.recordMovement with an orchestration aggregate (StockTransfer) that " +
  10 + "atomically writes TRANSFER_OUT + TRANSFER_IN ledger rows in one transaction. INTERNAL PBC."
  11 +
  12 +java {
  13 + toolchain {
  14 + languageVersion.set(JavaLanguageVersion.of(21))
  15 + }
  16 +}
  17 +
  18 +kotlin {
  19 + jvmToolchain(21)
  20 + compilerOptions {
  21 + freeCompilerArgs.add("-Xjsr305=strict")
  22 + }
  23 +}
  24 +
  25 +allOpen {
  26 + annotation("jakarta.persistence.Entity")
  27 + annotation("jakarta.persistence.MappedSuperclass")
  28 + annotation("jakarta.persistence.Embeddable")
  29 +}
  30 +
  31 +// Ninth PBC. Same dependency rule — api/api-v1 + platform/* only, NEVER another pbc-*.
  32 +// Cross-PBC reads go through CatalogApi (validate itemCode) + InventoryApi (validate
  33 +// location codes implicitly via recordMovement; no dedicated "does this location exist?"
  34 +// lookup on InventoryApi today, so we rely on recordMovement's own error path). The
  35 +// cross-PBC WRITE happens via InventoryApi.recordMovement with TRANSFER_OUT + TRANSFER_IN.
  36 +dependencies {
  37 + api(project(":api:api-v1"))
  38 + implementation(project(":platform:platform-persistence"))
  39 + implementation(project(":platform:platform-security"))
  40 +
  41 + implementation(libs.kotlin.stdlib)
  42 + implementation(libs.kotlin.reflect)
  43 +
  44 + implementation(libs.spring.boot.starter)
  45 + implementation(libs.spring.boot.starter.web)
  46 + implementation(libs.spring.boot.starter.data.jpa)
  47 + implementation(libs.spring.boot.starter.validation)
  48 + implementation(libs.jackson.module.kotlin)
  49 +
  50 + testImplementation(libs.spring.boot.starter.test)
  51 + testImplementation(libs.junit.jupiter)
  52 + testImplementation(libs.assertk)
  53 + testImplementation(libs.mockk)
  54 +}
  55 +
  56 +tasks.test {
  57 + useJUnitPlatform()
  58 +}
... ...
pbc/pbc-warehousing/src/main/kotlin/org/vibeerp/pbc/warehousing/application/StockTransferService.kt 0 → 100644
  1 +package org.vibeerp.pbc.warehousing.application
  2 +
  3 +import org.slf4j.LoggerFactory
  4 +import org.springframework.stereotype.Service
  5 +import org.springframework.transaction.annotation.Transactional
  6 +import org.vibeerp.api.v1.ext.catalog.CatalogApi
  7 +import org.vibeerp.api.v1.ext.inventory.InventoryApi
  8 +import org.vibeerp.pbc.warehousing.domain.StockTransfer
  9 +import org.vibeerp.pbc.warehousing.domain.StockTransferLine
  10 +import org.vibeerp.pbc.warehousing.domain.StockTransferStatus
  11 +import org.vibeerp.pbc.warehousing.infrastructure.StockTransferJpaRepository
  12 +import java.math.BigDecimal
  13 +import java.time.LocalDate
  14 +import java.util.UUID
  15 +
  16 +/**
  17 + * Application service for [StockTransfer] CRUD + state transitions.
  18 + *
  19 + * **Cross-PBC seams** (all via `api.v1.ext.*` interfaces, never via
  20 + * pbc-* internals):
  21 + * - [CatalogApi] — validates every line's [itemCode] at create time.
  22 + * Same rule pbc-production's `WorkOrderService.create` uses for
  23 + * its inputs.
  24 + * - [InventoryApi] — on [confirm] writes the atomic TRANSFER_OUT +
  25 + * TRANSFER_IN pair per line. This is the 5th caller of
  26 + * `recordMovement` (after pbc-orders-sales ship, pbc-orders-purchase
  27 + * receive, pbc-production work-order complete, and pbc-production
  28 + * work-order scrap). One ledger, one primitive, now five
  29 + * callers, all disciplined.
  30 + *
  31 + * **Atomicity of [confirm]:** the whole method is @Transactional, and
  32 + * every `recordMovement` call participates in the same transaction.
  33 + * A failure on any line — unknown item, unknown location, insufficient
  34 + * stock — rolls back EVERY prior TRANSFER_OUT AND TRANSFER_IN row.
  35 + * There is no half-confirmed transfer where some lines moved and
  36 + * others didn't.
  37 + *
  38 + * **No [update] verb yet** — transfers are immutable once created.
  39 + * A mistaken transfer is cancelled (if still DRAFT) and a new one
  40 + * created. This matches the pbc-production discipline and is the
  41 + * documented ergonomic: warehouse operators rarely edit an
  42 + * already-queued transfer; they cancel + recreate.
  43 + */
  44 +@Service
  45 +@Transactional
  46 +class StockTransferService(
  47 + private val transfers: StockTransferJpaRepository,
  48 + private val catalogApi: CatalogApi,
  49 + private val inventoryApi: InventoryApi,
  50 +) {
  51 +
  52 + private val log = LoggerFactory.getLogger(StockTransferService::class.java)
  53 +
  54 + @Transactional(readOnly = true)
  55 + fun list(): List<StockTransfer> = transfers.findAll()
  56 +
  57 + @Transactional(readOnly = true)
  58 + fun findById(id: UUID): StockTransfer? = transfers.findById(id).orElse(null)
  59 +
  60 + @Transactional(readOnly = true)
  61 + fun findByCode(code: String): StockTransfer? = transfers.findByCode(code)
  62 +
  63 + /**
  64 + * Create a DRAFT transfer. Validates: code uniqueness, from and
  65 + * to locations distinct, at least one line, per-line positive
  66 + * quantity, unique line numbers, every itemCode present in the
  67 + * catalog. The locations themselves are NOT validated here
  68 + * because [InventoryApi] has no "does this location exist?"
  69 + * lookup in its facade; `confirm()` will raise an
  70 + * `IllegalArgumentException` from `recordMovement` if a location
  71 + * is bad, which aborts the confirmation cleanly.
  72 + */
  73 + fun create(command: CreateStockTransferCommand): StockTransfer {
  74 + require(!transfers.existsByCode(command.code)) {
  75 + "stock transfer code '${command.code}' is already taken"
  76 + }
  77 + require(command.fromLocationCode != command.toLocationCode) {
  78 + "from and to locations must differ (both are '${command.fromLocationCode}')"
  79 + }
  80 + require(command.lines.isNotEmpty()) {
  81 + "stock transfer '${command.code}' must have at least one line"
  82 + }
  83 +
  84 + val seenLineNos = HashSet<Int>(command.lines.size)
  85 + for (line in command.lines) {
  86 + require(line.lineNo > 0) {
  87 + "stock transfer line_no must be positive (got ${line.lineNo})"
  88 + }
  89 + require(seenLineNos.add(line.lineNo)) {
  90 + "stock transfer line_no ${line.lineNo} is duplicated"
  91 + }
  92 + require(line.quantity.signum() > 0) {
  93 + "stock transfer line ${line.lineNo} quantity must be positive (got ${line.quantity})"
  94 + }
  95 + catalogApi.findItemByCode(line.itemCode)
  96 + ?: throw IllegalArgumentException(
  97 + "stock transfer line ${line.lineNo}: item code '${line.itemCode}' " +
  98 + "is not in the catalog (or is inactive)",
  99 + )
  100 + }
  101 +
  102 + val transfer = StockTransfer(
  103 + code = command.code,
  104 + fromLocationCode = command.fromLocationCode,
  105 + toLocationCode = command.toLocationCode,
  106 + status = StockTransferStatus.DRAFT,
  107 + transferDate = command.transferDate,
  108 + note = command.note,
  109 + )
  110 + for (line in command.lines) {
  111 + transfer.lines.add(
  112 + StockTransferLine(
  113 + transfer = transfer,
  114 + lineNo = line.lineNo,
  115 + itemCode = line.itemCode,
  116 + quantity = line.quantity,
  117 + ),
  118 + )
  119 + }
  120 + return transfers.save(transfer)
  121 + }
  122 +
  123 + /**
  124 + * Confirm a DRAFT transfer, writing the atomic TRANSFER_OUT +
  125 + * TRANSFER_IN pair per line via the inventory facade. The
  126 + * ledger reference string is `TR:<transfer_code>` so a grep of
  127 + * the ledger attributes the pair to this transfer.
  128 + */
  129 + fun confirm(id: UUID): StockTransfer {
  130 + val transfer = transfers.findById(id).orElseThrow {
  131 + NoSuchElementException("stock transfer not found: $id")
  132 + }
  133 + require(transfer.status == StockTransferStatus.DRAFT) {
  134 + "cannot confirm stock transfer ${transfer.code} in status ${transfer.status}; " +
  135 + "only DRAFT can be confirmed"
  136 + }
  137 + log.info(
  138 + "[warehousing] confirming transfer {} ({} line(s)) from {} to {}",
  139 + transfer.code, transfer.lines.size, transfer.fromLocationCode, transfer.toLocationCode,
  140 + )
  141 +
  142 + val reference = "TR:${transfer.code}"
  143 + for (line in transfer.lines) {
  144 + // Debit the source first so a balance-goes-negative error
  145 + // aborts the confirm before ANY TRANSFER_IN row has been
  146 + // written. recordMovement's own sign-vs-reason validation
  147 + // enforces that TRANSFER_OUT must be negative.
  148 + inventoryApi.recordMovement(
  149 + itemCode = line.itemCode,
  150 + locationCode = transfer.fromLocationCode,
  151 + delta = line.quantity.negate(),
  152 + reason = "TRANSFER_OUT",
  153 + reference = reference,
  154 + )
  155 + inventoryApi.recordMovement(
  156 + itemCode = line.itemCode,
  157 + locationCode = transfer.toLocationCode,
  158 + delta = line.quantity,
  159 + reason = "TRANSFER_IN",
  160 + reference = reference,
  161 + )
  162 + }
  163 +
  164 + transfer.status = StockTransferStatus.CONFIRMED
  165 + return transfer
  166 + }
  167 +
  168 + /**
  169 + * Cancel a DRAFT transfer. Once CONFIRMED, cancellation is
  170 + * refused — reversing a confirmed transfer means creating a new
  171 + * transfer in the opposite direction, matching the discipline
  172 + * every other PBC in the framework uses for ledger-posted
  173 + * documents.
  174 + */
  175 + fun cancel(id: UUID): StockTransfer {
  176 + val transfer = transfers.findById(id).orElseThrow {
  177 + NoSuchElementException("stock transfer not found: $id")
  178 + }
  179 + require(transfer.status == StockTransferStatus.DRAFT) {
  180 + "cannot cancel stock transfer ${transfer.code} in status ${transfer.status}; " +
  181 + "only DRAFT can be cancelled — reverse a confirmed transfer by creating a new one in the other direction"
  182 + }
  183 + transfer.status = StockTransferStatus.CANCELLED
  184 + return transfer
  185 + }
  186 +}
  187 +
  188 +data class CreateStockTransferCommand(
  189 + val code: String,
  190 + val fromLocationCode: String,
  191 + val toLocationCode: String,
  192 + val transferDate: LocalDate? = null,
  193 + val note: String? = null,
  194 + val lines: List<StockTransferLineCommand>,
  195 +)
  196 +
  197 +data class StockTransferLineCommand(
  198 + val lineNo: Int,
  199 + val itemCode: String,
  200 + val quantity: BigDecimal,
  201 +)
... ...
pbc/pbc-warehousing/src/main/kotlin/org/vibeerp/pbc/warehousing/domain/StockTransfer.kt 0 → 100644
  1 +package org.vibeerp.pbc.warehousing.domain
  2 +
  3 +import jakarta.persistence.CascadeType
  4 +import jakarta.persistence.Column
  5 +import jakarta.persistence.Entity
  6 +import jakarta.persistence.EnumType
  7 +import jakarta.persistence.Enumerated
  8 +import jakarta.persistence.FetchType
  9 +import jakarta.persistence.OneToMany
  10 +import jakarta.persistence.OrderBy
  11 +import jakarta.persistence.Table
  12 +import org.vibeerp.platform.persistence.audit.AuditedJpaEntity
  13 +import java.time.LocalDate
  14 +
  15 +/**
  16 + * A stock-transfer document: an operator's intent to move stock from
  17 + * [fromLocationCode] to [toLocationCode]. When confirmed, the
  18 + * [org.vibeerp.pbc.warehousing.application.StockTransferService] writes
  19 + * one atomic pair of inventory movements per line via
  20 + * `InventoryApi.recordMovement`: a negative `TRANSFER_OUT` on the source
  21 + * and a positive `TRANSFER_IN` on the destination, in a single
  22 + * transaction. A failure anywhere rolls both halves back so the ledger
  23 + * never records a half-transfer.
  24 + *
  25 + * **Why transfer is a first-class aggregate and not just "two
  26 + * recordMovement calls":**
  27 + * - The operator records intent before the physical move happens —
  28 + * DRAFT transfers are a queue of "these moves are pending, pickers
  29 + * please execute". A flat movement ledger can't express this
  30 + * because movements are facts (already happened).
  31 + * - A transfer has a code, a date, a human audit trail, a reversible
  32 + * state machine — all the usual document-style ergonomics that the
  33 + * ledger alone doesn't give you.
  34 + * - Two movements that SHOULD be atomic can be audited as one
  35 + * transfer, even though on the ledger they're two separate rows
  36 + * with distinct (source location, destination location). Grep by
  37 + * `TR:<code>` finds both halves.
  38 + *
  39 + * **State machine:**
  40 + * - **DRAFT** → **CONFIRMED** (confirm writes the two ledger rows per line)
  41 + * - **DRAFT** → **CANCELLED** (cancel — nothing has moved yet)
  42 + * - **CONFIRMED** is terminal (reversing a confirmed transfer means
  43 + * creating a NEW transfer in the opposite direction; pbc-warehousing
  44 + * refuses to "uncancel" a ledger-posted document, matching the
  45 + * discipline pbc-orders-sales + pbc-orders-purchase already use)
  46 + * - **CANCELLED** is terminal
  47 + *
  48 + * **Why source_location_code + dest_location_code are on the HEADER
  49 + * rather than per-line:** in real warehouse operations a single
  50 + * transfer document typically moves several items from the SAME source
  51 + * to the SAME destination (a full pallet's worth, or an aisle change).
  52 + * Hoisting the locations to the header keeps the common case terse.
  53 + * The rare split-destination case is modelled as two separate transfer
  54 + * documents. A future v2 may push locations onto the line.
  55 + *
  56 + * **Why `transfer_date` is a LocalDate, not an Instant:** transfers are
  57 + * warehouse-floor operations, and the operator records "when this is
  58 + * supposed to happen" in the local business day, not in UTC nanoseconds.
  59 + * The audit columns (`created_at`, `updated_at`) already carry the
  60 + * exact instant the row was written.
  61 + */
  62 +@Entity
  63 +@Table(name = "warehousing__stock_transfer")
  64 +class StockTransfer(
  65 + code: String,
  66 + fromLocationCode: String,
  67 + toLocationCode: String,
  68 + status: StockTransferStatus = StockTransferStatus.DRAFT,
  69 + transferDate: LocalDate? = null,
  70 + note: String? = null,
  71 +) : AuditedJpaEntity() {
  72 +
  73 + @Column(name = "code", nullable = false, length = 64)
  74 + var code: String = code
  75 +
  76 + @Column(name = "from_location_code", nullable = false, length = 64)
  77 + var fromLocationCode: String = fromLocationCode
  78 +
  79 + @Column(name = "to_location_code", nullable = false, length = 64)
  80 + var toLocationCode: String = toLocationCode
  81 +
  82 + @Enumerated(EnumType.STRING)
  83 + @Column(name = "status", nullable = false, length = 16)
  84 + var status: StockTransferStatus = status
  85 +
  86 + @Column(name = "transfer_date", nullable = true)
  87 + var transferDate: LocalDate? = transferDate
  88 +
  89 + @Column(name = "note", nullable = true, length = 512)
  90 + var note: String? = note
  91 +
  92 + /**
  93 + * The per-item lines. Empty list is rejected at create time —
  94 + * a transfer with no items has nothing to move and is useless.
  95 + * Eagerly fetched because every read of a transfer header is
  96 + * followed in practice by a read of its lines.
  97 + */
  98 + @OneToMany(
  99 + mappedBy = "transfer",
  100 + cascade = [CascadeType.ALL],
  101 + orphanRemoval = true,
  102 + fetch = FetchType.EAGER,
  103 + )
  104 + @OrderBy("lineNo ASC")
  105 + var lines: MutableList<StockTransferLine> = mutableListOf()
  106 +
  107 + override fun toString(): String =
  108 + "StockTransfer(id=$id, code='$code', from='$fromLocationCode', to='$toLocationCode', " +
  109 + "status=$status, lines=${lines.size})"
  110 +}
  111 +
  112 +/**
  113 + * State machine for [StockTransfer]. See the entity KDoc for the
  114 + * rationale behind each transition.
  115 + */
  116 +enum class StockTransferStatus {
  117 + DRAFT,
  118 + CONFIRMED,
  119 + CANCELLED,
  120 +}
... ...
pbc/pbc-warehousing/src/main/kotlin/org/vibeerp/pbc/warehousing/domain/StockTransferLine.kt 0 → 100644
  1 +package org.vibeerp.pbc.warehousing.domain
  2 +
  3 +import jakarta.persistence.Column
  4 +import jakarta.persistence.Entity
  5 +import jakarta.persistence.JoinColumn
  6 +import jakarta.persistence.ManyToOne
  7 +import jakarta.persistence.Table
  8 +import org.vibeerp.platform.persistence.audit.AuditedJpaEntity
  9 +import java.math.BigDecimal
  10 +
  11 +/**
  12 + * One per-item line on a [StockTransfer].
  13 + *
  14 + * **Why `item_code` is a varchar, not a UUID FK:** same rule every
  15 + * other pbc-* uses — cross-PBC references go by the item's stable
  16 + * human code, not its storage id. The application layer validates
  17 + * existence via `CatalogApi.findItemByCode` at create time, and
  18 + * `InventoryApi.recordMovement` re-validates on confirm.
  19 + *
  20 + * **No ext JSONB on the line** — lines are facts, not master records;
  21 + * custom fields go on the header if needed.
  22 + *
  23 + * **`line_no` is unique per transfer** — the application enforces
  24 + * this; the schema adds a unique index `(transfer_id, line_no)` to
  25 + * back it up.
  26 + */
  27 +@Entity
  28 +@Table(name = "warehousing__stock_transfer_line")
  29 +class StockTransferLine(
  30 + transfer: StockTransfer,
  31 + lineNo: Int,
  32 + itemCode: String,
  33 + quantity: BigDecimal,
  34 +) : AuditedJpaEntity() {
  35 +
  36 + @ManyToOne
  37 + @JoinColumn(name = "transfer_id", nullable = false)
  38 + var transfer: StockTransfer = transfer
  39 +
  40 + @Column(name = "line_no", nullable = false)
  41 + var lineNo: Int = lineNo
  42 +
  43 + @Column(name = "item_code", nullable = false, length = 64)
  44 + var itemCode: String = itemCode
  45 +
  46 + @Column(name = "quantity", nullable = false, precision = 18, scale = 4)
  47 + var quantity: BigDecimal = quantity
  48 +
  49 + override fun toString(): String =
  50 + "StockTransferLine(id=$id, transferId=${transfer.id}, line=$lineNo, " +
  51 + "item='$itemCode', qty=$quantity)"
  52 +}
... ...
pbc/pbc-warehousing/src/main/kotlin/org/vibeerp/pbc/warehousing/http/StockTransferController.kt 0 → 100644
  1 +package org.vibeerp.pbc.warehousing.http
  2 +
  3 +import jakarta.validation.Valid
  4 +import jakarta.validation.constraints.NotBlank
  5 +import jakarta.validation.constraints.NotNull
  6 +import jakarta.validation.constraints.Size
  7 +import org.springframework.http.HttpStatus
  8 +import org.springframework.http.ResponseEntity
  9 +import org.springframework.web.bind.annotation.GetMapping
  10 +import org.springframework.web.bind.annotation.PathVariable
  11 +import org.springframework.web.bind.annotation.PostMapping
  12 +import org.springframework.web.bind.annotation.RequestBody
  13 +import org.springframework.web.bind.annotation.RequestMapping
  14 +import org.springframework.web.bind.annotation.ResponseStatus
  15 +import org.springframework.web.bind.annotation.RestController
  16 +import org.vibeerp.pbc.warehousing.application.CreateStockTransferCommand
  17 +import org.vibeerp.pbc.warehousing.application.StockTransferLineCommand
  18 +import org.vibeerp.pbc.warehousing.application.StockTransferService
  19 +import org.vibeerp.pbc.warehousing.domain.StockTransfer
  20 +import org.vibeerp.pbc.warehousing.domain.StockTransferLine
  21 +import org.vibeerp.pbc.warehousing.domain.StockTransferStatus
  22 +import org.vibeerp.platform.security.authz.RequirePermission
  23 +import java.math.BigDecimal
  24 +import java.time.LocalDate
  25 +import java.util.UUID
  26 +
  27 +/**
  28 + * REST API for the warehousing PBC. Mounted at
  29 + * `/api/v1/warehousing/stock-transfers`. State transitions use
  30 + * dedicated `/confirm` and `/cancel` endpoints — same shape as the
  31 + * other core PBCs.
  32 + */
  33 +@RestController
  34 +@RequestMapping("/api/v1/warehousing/stock-transfers")
  35 +class StockTransferController(
  36 + private val service: StockTransferService,
  37 +) {
  38 +
  39 + @GetMapping
  40 + @RequirePermission("warehousing.stock-transfer.read")
  41 + fun list(): List<StockTransferResponse> =
  42 + service.list().map { it.toResponse() }
  43 +
  44 + @GetMapping("/{id}")
  45 + @RequirePermission("warehousing.stock-transfer.read")
  46 + fun get(@PathVariable id: UUID): ResponseEntity<StockTransferResponse> {
  47 + val transfer = service.findById(id) ?: return ResponseEntity.notFound().build()
  48 + return ResponseEntity.ok(transfer.toResponse())
  49 + }
  50 +
  51 + @GetMapping("/by-code/{code}")
  52 + @RequirePermission("warehousing.stock-transfer.read")
  53 + fun getByCode(@PathVariable code: String): ResponseEntity<StockTransferResponse> {
  54 + val transfer = service.findByCode(code) ?: return ResponseEntity.notFound().build()
  55 + return ResponseEntity.ok(transfer.toResponse())
  56 + }
  57 +
  58 + @PostMapping
  59 + @ResponseStatus(HttpStatus.CREATED)
  60 + @RequirePermission("warehousing.stock-transfer.create")
  61 + fun create(@RequestBody @Valid request: CreateStockTransferRequest): StockTransferResponse =
  62 + service.create(request.toCommand()).toResponse()
  63 +
  64 + @PostMapping("/{id}/confirm")
  65 + @RequirePermission("warehousing.stock-transfer.confirm")
  66 + fun confirm(@PathVariable id: UUID): StockTransferResponse =
  67 + service.confirm(id).toResponse()
  68 +
  69 + @PostMapping("/{id}/cancel")
  70 + @RequirePermission("warehousing.stock-transfer.cancel")
  71 + fun cancel(@PathVariable id: UUID): StockTransferResponse =
  72 + service.cancel(id).toResponse()
  73 +}
  74 +
  75 +// ─── DTOs ────────────────────────────────────────────────────────────
  76 +
  77 +data class CreateStockTransferRequest(
  78 + @field:NotBlank @field:Size(max = 64) val code: String,
  79 + @field:NotBlank @field:Size(max = 64) val fromLocationCode: String,
  80 + @field:NotBlank @field:Size(max = 64) val toLocationCode: String,
  81 + val transferDate: LocalDate? = null,
  82 + @field:Size(max = 512) val note: String? = null,
  83 + @field:Valid val lines: List<StockTransferLineRequest>,
  84 +) {
  85 + fun toCommand(): CreateStockTransferCommand = CreateStockTransferCommand(
  86 + code = code,
  87 + fromLocationCode = fromLocationCode,
  88 + toLocationCode = toLocationCode,
  89 + transferDate = transferDate,
  90 + note = note,
  91 + lines = lines.map { it.toCommand() },
  92 + )
  93 +}
  94 +
  95 +data class StockTransferLineRequest(
  96 + @field:NotNull val lineNo: Int,
  97 + @field:NotBlank @field:Size(max = 64) val itemCode: String,
  98 + @field:NotNull val quantity: BigDecimal,
  99 +) {
  100 + fun toCommand(): StockTransferLineCommand = StockTransferLineCommand(
  101 + lineNo = lineNo,
  102 + itemCode = itemCode,
  103 + quantity = quantity,
  104 + )
  105 +}
  106 +
  107 +data class StockTransferResponse(
  108 + val id: UUID,
  109 + val code: String,
  110 + val fromLocationCode: String,
  111 + val toLocationCode: String,
  112 + val status: StockTransferStatus,
  113 + val transferDate: LocalDate?,
  114 + val note: String?,
  115 + val lines: List<StockTransferLineResponse>,
  116 +)
  117 +
  118 +data class StockTransferLineResponse(
  119 + val id: UUID,
  120 + val lineNo: Int,
  121 + val itemCode: String,
  122 + val quantity: BigDecimal,
  123 +)
  124 +
  125 +private fun StockTransfer.toResponse(): StockTransferResponse =
  126 + StockTransferResponse(
  127 + id = id,
  128 + code = code,
  129 + fromLocationCode = fromLocationCode,
  130 + toLocationCode = toLocationCode,
  131 + status = status,
  132 + transferDate = transferDate,
  133 + note = note,
  134 + lines = lines.map { it.toResponse() },
  135 + )
  136 +
  137 +private fun StockTransferLine.toResponse(): StockTransferLineResponse =
  138 + StockTransferLineResponse(
  139 + id = id,
  140 + lineNo = lineNo,
  141 + itemCode = itemCode,
  142 + quantity = quantity,
  143 + )
... ...
pbc/pbc-warehousing/src/main/kotlin/org/vibeerp/pbc/warehousing/infrastructure/StockTransferJpaRepository.kt 0 → 100644
  1 +package org.vibeerp.pbc.warehousing.infrastructure
  2 +
  3 +import org.springframework.data.jpa.repository.JpaRepository
  4 +import org.vibeerp.pbc.warehousing.domain.StockTransfer
  5 +import java.util.UUID
  6 +
  7 +interface StockTransferJpaRepository : JpaRepository<StockTransfer, UUID> {
  8 + fun existsByCode(code: String): Boolean
  9 + fun findByCode(code: String): StockTransfer?
  10 +}
... ...
pbc/pbc-warehousing/src/main/resources/META-INF/vibe-erp/metadata/warehousing.yml 0 → 100644
  1 +# pbc-warehousing metadata.
  2 +#
  3 +# Loaded at boot by MetadataLoader, tagged source='core'.
  4 +
  5 +entities:
  6 + - name: StockTransfer
  7 + pbc: warehousing
  8 + table: warehousing__stock_transfer
  9 + description: A multi-line stock transfer document from one location to another (DRAFT then CONFIRMED writes atomic TRANSFER_OUT and TRANSFER_IN ledger rows)
  10 +
  11 +permissions:
  12 + - key: warehousing.stock-transfer.read
  13 + description: Read stock transfers
  14 + - key: warehousing.stock-transfer.create
  15 + description: Create stock transfers (in DRAFT status)
  16 + - key: warehousing.stock-transfer.confirm
  17 + description: Confirm a DRAFT stock transfer, posting the atomic TRANSFER_OUT and TRANSFER_IN ledger pair
  18 + - key: warehousing.stock-transfer.cancel
  19 + description: Cancel a DRAFT stock transfer
  20 +
  21 +menus:
  22 + - path: /warehousing/stock-transfers
  23 + label: Stock transfers
  24 + icon: truck
  25 + section: Warehousing
  26 + order: 500
... ...
pbc/pbc-warehousing/src/test/kotlin/org/vibeerp/pbc/warehousing/application/StockTransferServiceTest.kt 0 → 100644
  1 +package org.vibeerp.pbc.warehousing.application
  2 +
  3 +import assertk.assertFailure
  4 +import assertk.assertThat
  5 +import assertk.assertions.contains
  6 +import assertk.assertions.hasMessage
  7 +import assertk.assertions.isEqualTo
  8 +import assertk.assertions.isInstanceOf
  9 +import io.mockk.every
  10 +import io.mockk.mockk
  11 +import io.mockk.slot
  12 +import io.mockk.verify
  13 +import io.mockk.verifyOrder
  14 +import org.junit.jupiter.api.Test
  15 +import org.vibeerp.api.v1.core.Id
  16 +import org.vibeerp.api.v1.ext.catalog.CatalogApi
  17 +import org.vibeerp.api.v1.ext.catalog.ItemRef
  18 +import org.vibeerp.api.v1.ext.inventory.InventoryApi
  19 +import org.vibeerp.api.v1.ext.inventory.StockBalanceRef
  20 +import org.vibeerp.pbc.warehousing.domain.StockTransfer
  21 +import org.vibeerp.pbc.warehousing.domain.StockTransferStatus
  22 +import org.vibeerp.pbc.warehousing.infrastructure.StockTransferJpaRepository
  23 +import java.math.BigDecimal
  24 +import java.util.Optional
  25 +import java.util.UUID
  26 +
  27 +class StockTransferServiceTest {
  28 +
  29 + private val transfers: StockTransferJpaRepository = mockk(relaxed = true)
  30 + private val catalog: CatalogApi = mockk()
  31 + private val inventory: InventoryApi = mockk()
  32 + private val service = StockTransferService(transfers, catalog, inventory)
  33 +
  34 + private fun stubItemExists(code: String) {
  35 + every { catalog.findItemByCode(code) } returns ItemRef(
  36 + id = Id(UUID.randomUUID()),
  37 + code = code,
  38 + name = "fake",
  39 + itemType = "GOOD",
  40 + baseUomCode = "ea",
  41 + active = true,
  42 + )
  43 + }
  44 +
  45 + private fun cmd(
  46 + code: String = "TR-001",
  47 + from: String = "WH-A",
  48 + to: String = "WH-B",
  49 + lines: List<StockTransferLineCommand> = listOf(
  50 + StockTransferLineCommand(lineNo = 1, itemCode = "ITEM-1", quantity = BigDecimal("5")),
  51 + StockTransferLineCommand(lineNo = 2, itemCode = "ITEM-2", quantity = BigDecimal("3")),
  52 + ),
  53 + ) = CreateStockTransferCommand(
  54 + code = code,
  55 + fromLocationCode = from,
  56 + toLocationCode = to,
  57 + lines = lines,
  58 + )
  59 +
  60 + @Test
  61 + fun `create persists a DRAFT transfer when everything validates`() {
  62 + every { transfers.existsByCode("TR-001") } returns false
  63 + stubItemExists("ITEM-1")
  64 + stubItemExists("ITEM-2")
  65 + every { transfers.save(any<StockTransfer>()) } answers { firstArg() }
  66 +
  67 + val saved = service.create(cmd())
  68 +
  69 + assertThat(saved.code).isEqualTo("TR-001")
  70 + assertThat(saved.fromLocationCode).isEqualTo("WH-A")
  71 + assertThat(saved.toLocationCode).isEqualTo("WH-B")
  72 + assertThat(saved.status).isEqualTo(StockTransferStatus.DRAFT)
  73 + assertThat(saved.lines.size).isEqualTo(2)
  74 + assertThat(saved.lines[0].itemCode).isEqualTo("ITEM-1")
  75 + assertThat(saved.lines[1].itemCode).isEqualTo("ITEM-2")
  76 + }
  77 +
  78 + @Test
  79 + fun `create rejects duplicate code`() {
  80 + every { transfers.existsByCode("TR-dup") } returns true
  81 +
  82 + assertFailure { service.create(cmd(code = "TR-dup")) }
  83 + .isInstanceOf(IllegalArgumentException::class)
  84 + .hasMessage("stock transfer code 'TR-dup' is already taken")
  85 + }
  86 +
  87 + @Test
  88 + fun `create rejects same from and to location`() {
  89 + every { transfers.existsByCode(any()) } returns false
  90 +
  91 + assertFailure { service.create(cmd(from = "WH-A", to = "WH-A")) }
  92 + .isInstanceOf(IllegalArgumentException::class)
  93 + .hasMessage("from and to locations must differ (both are 'WH-A')")
  94 + }
  95 +
  96 + @Test
  97 + fun `create rejects an empty line list`() {
  98 + every { transfers.existsByCode(any()) } returns false
  99 +
  100 + assertFailure { service.create(cmd(lines = emptyList())) }
  101 + .isInstanceOf(IllegalArgumentException::class)
  102 + .hasMessage("stock transfer 'TR-001' must have at least one line")
  103 + }
  104 +
  105 + @Test
  106 + fun `create rejects duplicate line numbers`() {
  107 + every { transfers.existsByCode(any()) } returns false
  108 + stubItemExists("X")
  109 +
  110 + assertFailure {
  111 + service.create(
  112 + cmd(
  113 + lines = listOf(
  114 + StockTransferLineCommand(1, "X", BigDecimal("1")),
  115 + StockTransferLineCommand(1, "X", BigDecimal("2")),
  116 + ),
  117 + ),
  118 + )
  119 + }
  120 + .isInstanceOf(IllegalArgumentException::class)
  121 + .hasMessage("stock transfer line_no 1 is duplicated")
  122 + }
  123 +
  124 + @Test
  125 + fun `create rejects non-positive quantities`() {
  126 + every { transfers.existsByCode(any()) } returns false
  127 + stubItemExists("X")
  128 +
  129 + assertFailure {
  130 + service.create(
  131 + cmd(lines = listOf(StockTransferLineCommand(1, "X", BigDecimal.ZERO))),
  132 + )
  133 + }
  134 + .isInstanceOf(IllegalArgumentException::class)
  135 + }
  136 +
  137 + @Test
  138 + fun `create rejects unknown items via CatalogApi`() {
  139 + every { transfers.existsByCode(any()) } returns false
  140 + every { catalog.findItemByCode("GHOST") } returns null
  141 +
  142 + assertFailure {
  143 + service.create(
  144 + cmd(lines = listOf(StockTransferLineCommand(1, "GHOST", BigDecimal("1")))),
  145 + )
  146 + }
  147 + .isInstanceOf(IllegalArgumentException::class)
  148 + }
  149 +
  150 + @Test
  151 + fun `confirm writes an atomic TRANSFER_OUT + TRANSFER_IN pair per line`() {
  152 + val id = UUID.randomUUID()
  153 + val transfer = StockTransfer(
  154 + code = "TR-002",
  155 + fromLocationCode = "WH-A",
  156 + toLocationCode = "WH-B",
  157 + ).also { it.id = id }
  158 + transfer.lines.add(
  159 + org.vibeerp.pbc.warehousing.domain.StockTransferLine(
  160 + transfer = transfer, lineNo = 1, itemCode = "ITEM-1", quantity = BigDecimal("5"),
  161 + ),
  162 + )
  163 + transfer.lines.add(
  164 + org.vibeerp.pbc.warehousing.domain.StockTransferLine(
  165 + transfer = transfer, lineNo = 2, itemCode = "ITEM-2", quantity = BigDecimal("3"),
  166 + ),
  167 + )
  168 + every { transfers.findById(id) } returns Optional.of(transfer)
  169 + every { inventory.recordMovement(any(), any(), any(), any(), any()) } returns
  170 + StockBalanceRef(Id(UUID.randomUUID()), "x", "x", BigDecimal.ZERO)
  171 +
  172 + val result = service.confirm(id)
  173 +
  174 + assertThat(result.status).isEqualTo(StockTransferStatus.CONFIRMED)
  175 +
  176 + // Four movements total: (line 1 out + in), (line 2 out + in).
  177 + // The contract is OUT-first-per-line so a ledger failure on the
  178 + // source side aborts before touching the destination.
  179 + verifyOrder {
  180 + inventory.recordMovement("ITEM-1", "WH-A", BigDecimal("-5"), "TRANSFER_OUT", "TR:TR-002")
  181 + inventory.recordMovement("ITEM-1", "WH-B", BigDecimal("5"), "TRANSFER_IN", "TR:TR-002")
  182 + inventory.recordMovement("ITEM-2", "WH-A", BigDecimal("-3"), "TRANSFER_OUT", "TR:TR-002")
  183 + inventory.recordMovement("ITEM-2", "WH-B", BigDecimal("3"), "TRANSFER_IN", "TR:TR-002")
  184 + }
  185 + }
  186 +
  187 + @Test
  188 + fun `confirm refuses a non-DRAFT transfer`() {
  189 + val id = UUID.randomUUID()
  190 + val transfer = StockTransfer(
  191 + code = "TR-003",
  192 + fromLocationCode = "A",
  193 + toLocationCode = "B",
  194 + status = StockTransferStatus.CONFIRMED,
  195 + ).also { it.id = id }
  196 + every { transfers.findById(id) } returns Optional.of(transfer)
  197 +
  198 + assertFailure { service.confirm(id) }
  199 + .isInstanceOf(IllegalArgumentException::class)
  200 + }
  201 +
  202 + @Test
  203 + fun `cancel refuses a CONFIRMED transfer`() {
  204 + val id = UUID.randomUUID()
  205 + val transfer = StockTransfer(
  206 + code = "TR-004",
  207 + fromLocationCode = "A",
  208 + toLocationCode = "B",
  209 + status = StockTransferStatus.CONFIRMED,
  210 + ).also { it.id = id }
  211 + every { transfers.findById(id) } returns Optional.of(transfer)
  212 +
  213 + assertFailure { service.cancel(id) }
  214 + .isInstanceOf(IllegalArgumentException::class)
  215 + }
  216 +
  217 + @Test
  218 + fun `cancel flips a DRAFT transfer to CANCELLED`() {
  219 + val id = UUID.randomUUID()
  220 + val transfer = StockTransfer(
  221 + code = "TR-005",
  222 + fromLocationCode = "A",
  223 + toLocationCode = "B",
  224 + ).also { it.id = id }
  225 + every { transfers.findById(id) } returns Optional.of(transfer)
  226 +
  227 + val result = service.cancel(id)
  228 + assertThat(result.status).isEqualTo(StockTransferStatus.CANCELLED)
  229 + }
  230 +}
... ...
settings.gradle.kts
... ... @@ -58,6 +58,9 @@ project(&quot;:pbc:pbc-partners&quot;).projectDir = file(&quot;pbc/pbc-partners&quot;)
58 58 include(":pbc:pbc-inventory")
59 59 project(":pbc:pbc-inventory").projectDir = file("pbc/pbc-inventory")
60 60  
  61 +include(":pbc:pbc-warehousing")
  62 +project(":pbc:pbc-warehousing").projectDir = file("pbc/pbc-warehousing")
  63 +
61 64 include(":pbc:pbc-orders-sales")
62 65 project(":pbc:pbc-orders-sales").projectDir = file("pbc/pbc-orders-sales")
63 66  
... ...