diff --git a/distribution/build.gradle.kts b/distribution/build.gradle.kts
index e29adb8..6bc0553 100644
--- a/distribution/build.gradle.kts
+++ b/distribution/build.gradle.kts
@@ -31,6 +31,7 @@ dependencies {
implementation(project(":pbc:pbc-catalog"))
implementation(project(":pbc:pbc-partners"))
implementation(project(":pbc:pbc-inventory"))
+ implementation(project(":pbc:pbc-warehousing"))
implementation(project(":pbc:pbc-orders-sales"))
implementation(project(":pbc:pbc-orders-purchase"))
implementation(project(":pbc:pbc-finance"))
diff --git a/distribution/src/main/resources/db/changelog/master.xml b/distribution/src/main/resources/db/changelog/master.xml
index 8580a90..b13069b 100644
--- a/distribution/src/main/resources/db/changelog/master.xml
+++ b/distribution/src/main/resources/db/changelog/master.xml
@@ -18,6 +18,7 @@
+
diff --git a/distribution/src/main/resources/db/changelog/pbc-warehousing/001-warehousing-init.xml b/distribution/src/main/resources/db/changelog/pbc-warehousing/001-warehousing-init.xml
new file mode 100644
index 0000000..be10737
--- /dev/null
+++ b/distribution/src/main/resources/db/changelog/pbc-warehousing/001-warehousing-init.xml
@@ -0,0 +1,81 @@
+
+
+
+
+
+
+ Create warehousing__stock_transfer + warehousing__stock_transfer_line tables
+
+ CREATE TABLE warehousing__stock_transfer (
+ id uuid PRIMARY KEY,
+ code varchar(64) NOT NULL,
+ from_location_code varchar(64) NOT NULL,
+ to_location_code varchar(64) NOT NULL,
+ status varchar(16) NOT NULL,
+ transfer_date date,
+ note varchar(512),
+ created_at timestamptz NOT NULL,
+ created_by varchar(128) NOT NULL,
+ updated_at timestamptz NOT NULL,
+ updated_by varchar(128) NOT NULL,
+ version bigint NOT NULL DEFAULT 0,
+ CONSTRAINT warehousing__stock_transfer_status_check
+ CHECK (status IN ('DRAFT', 'CONFIRMED', 'CANCELLED')),
+ CONSTRAINT warehousing__stock_transfer_locations_distinct
+ CHECK (from_location_code <> to_location_code)
+ );
+ CREATE UNIQUE INDEX warehousing__stock_transfer_code_uk
+ ON warehousing__stock_transfer (code);
+ CREATE INDEX warehousing__stock_transfer_status_idx
+ ON warehousing__stock_transfer (status);
+ CREATE INDEX warehousing__stock_transfer_from_idx
+ ON warehousing__stock_transfer (from_location_code);
+ CREATE INDEX warehousing__stock_transfer_to_idx
+ ON warehousing__stock_transfer (to_location_code);
+
+ CREATE TABLE warehousing__stock_transfer_line (
+ id uuid PRIMARY KEY,
+ transfer_id uuid NOT NULL
+ REFERENCES warehousing__stock_transfer (id) ON DELETE CASCADE,
+ line_no integer NOT NULL,
+ item_code varchar(64) NOT NULL,
+ quantity numeric(18,4) NOT NULL,
+ created_at timestamptz NOT NULL,
+ created_by varchar(128) NOT NULL,
+ updated_at timestamptz NOT NULL,
+ updated_by varchar(128) NOT NULL,
+ version bigint NOT NULL DEFAULT 0,
+ CONSTRAINT warehousing__stock_transfer_line_qty_pos
+ CHECK (quantity > 0)
+ );
+ CREATE UNIQUE INDEX warehousing__stock_transfer_line_uk
+ ON warehousing__stock_transfer_line (transfer_id, line_no);
+ CREATE INDEX warehousing__stock_transfer_line_item_idx
+ ON warehousing__stock_transfer_line (item_code);
+
+
+ DROP TABLE warehousing__stock_transfer_line;
+ DROP TABLE warehousing__stock_transfer;
+
+
+
+
diff --git a/pbc/pbc-warehousing/build.gradle.kts b/pbc/pbc-warehousing/build.gradle.kts
new file mode 100644
index 0000000..170f0fd
--- /dev/null
+++ b/pbc/pbc-warehousing/build.gradle.kts
@@ -0,0 +1,58 @@
+plugins {
+ alias(libs.plugins.kotlin.jvm)
+ alias(libs.plugins.kotlin.spring)
+ alias(libs.plugins.kotlin.jpa)
+ alias(libs.plugins.spring.dependency.management)
+}
+
+description = "vibe_erp pbc-warehousing — first-class stock transfers across locations. " +
+ "Wraps InventoryApi.recordMovement with an orchestration aggregate (StockTransfer) that " +
+ "atomically writes TRANSFER_OUT + TRANSFER_IN ledger rows in one transaction. INTERNAL PBC."
+
+java {
+ toolchain {
+ languageVersion.set(JavaLanguageVersion.of(21))
+ }
+}
+
+kotlin {
+ jvmToolchain(21)
+ compilerOptions {
+ freeCompilerArgs.add("-Xjsr305=strict")
+ }
+}
+
+allOpen {
+ annotation("jakarta.persistence.Entity")
+ annotation("jakarta.persistence.MappedSuperclass")
+ annotation("jakarta.persistence.Embeddable")
+}
+
+// Ninth PBC. Same dependency rule — api/api-v1 + platform/* only, NEVER another pbc-*.
+// Cross-PBC reads go through CatalogApi (validate itemCode) + InventoryApi (validate
+// location codes implicitly via recordMovement; no dedicated "does this location exist?"
+// lookup on InventoryApi today, so we rely on recordMovement's own error path). The
+// cross-PBC WRITE happens via InventoryApi.recordMovement with TRANSFER_OUT + TRANSFER_IN.
+dependencies {
+ api(project(":api:api-v1"))
+ implementation(project(":platform:platform-persistence"))
+ implementation(project(":platform:platform-security"))
+
+ implementation(libs.kotlin.stdlib)
+ implementation(libs.kotlin.reflect)
+
+ implementation(libs.spring.boot.starter)
+ implementation(libs.spring.boot.starter.web)
+ implementation(libs.spring.boot.starter.data.jpa)
+ implementation(libs.spring.boot.starter.validation)
+ implementation(libs.jackson.module.kotlin)
+
+ testImplementation(libs.spring.boot.starter.test)
+ testImplementation(libs.junit.jupiter)
+ testImplementation(libs.assertk)
+ testImplementation(libs.mockk)
+}
+
+tasks.test {
+ useJUnitPlatform()
+}
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
new file mode 100644
index 0000000..fc88755
--- /dev/null
+++ b/pbc/pbc-warehousing/src/main/kotlin/org/vibeerp/pbc/warehousing/application/StockTransferService.kt
@@ -0,0 +1,201 @@
+package org.vibeerp.pbc.warehousing.application
+
+import org.slf4j.LoggerFactory
+import org.springframework.stereotype.Service
+import org.springframework.transaction.annotation.Transactional
+import org.vibeerp.api.v1.ext.catalog.CatalogApi
+import org.vibeerp.api.v1.ext.inventory.InventoryApi
+import org.vibeerp.pbc.warehousing.domain.StockTransfer
+import org.vibeerp.pbc.warehousing.domain.StockTransferLine
+import org.vibeerp.pbc.warehousing.domain.StockTransferStatus
+import org.vibeerp.pbc.warehousing.infrastructure.StockTransferJpaRepository
+import java.math.BigDecimal
+import java.time.LocalDate
+import java.util.UUID
+
+/**
+ * Application service for [StockTransfer] CRUD + state transitions.
+ *
+ * **Cross-PBC seams** (all via `api.v1.ext.*` interfaces, never via
+ * pbc-* internals):
+ * - [CatalogApi] — validates every line's [itemCode] at create time.
+ * Same rule pbc-production's `WorkOrderService.create` uses for
+ * its inputs.
+ * - [InventoryApi] — on [confirm] writes the atomic TRANSFER_OUT +
+ * TRANSFER_IN pair per line. This is the 5th caller of
+ * `recordMovement` (after pbc-orders-sales ship, pbc-orders-purchase
+ * receive, pbc-production work-order complete, and pbc-production
+ * work-order scrap). One ledger, one primitive, now five
+ * callers, all disciplined.
+ *
+ * **Atomicity of [confirm]:** the whole method is @Transactional, and
+ * every `recordMovement` call participates in the same transaction.
+ * A failure on any line — unknown item, unknown location, insufficient
+ * stock — rolls back EVERY prior TRANSFER_OUT AND TRANSFER_IN row.
+ * There is no half-confirmed transfer where some lines moved and
+ * others didn't.
+ *
+ * **No [update] verb yet** — transfers are immutable once created.
+ * A mistaken transfer is cancelled (if still DRAFT) and a new one
+ * created. This matches the pbc-production discipline and is the
+ * documented ergonomic: warehouse operators rarely edit an
+ * already-queued transfer; they cancel + recreate.
+ */
+@Service
+@Transactional
+class StockTransferService(
+ private val transfers: StockTransferJpaRepository,
+ private val catalogApi: CatalogApi,
+ private val inventoryApi: InventoryApi,
+) {
+
+ private val log = LoggerFactory.getLogger(StockTransferService::class.java)
+
+ @Transactional(readOnly = true)
+ fun list(): List = transfers.findAll()
+
+ @Transactional(readOnly = true)
+ fun findById(id: UUID): StockTransfer? = transfers.findById(id).orElse(null)
+
+ @Transactional(readOnly = true)
+ fun findByCode(code: String): StockTransfer? = transfers.findByCode(code)
+
+ /**
+ * Create a DRAFT transfer. Validates: code uniqueness, from and
+ * to locations distinct, at least one line, per-line positive
+ * quantity, unique line numbers, every itemCode present in the
+ * catalog. The locations themselves are NOT validated here
+ * because [InventoryApi] has no "does this location exist?"
+ * lookup in its facade; `confirm()` will raise an
+ * `IllegalArgumentException` from `recordMovement` if a location
+ * is bad, which aborts the confirmation cleanly.
+ */
+ fun create(command: CreateStockTransferCommand): StockTransfer {
+ require(!transfers.existsByCode(command.code)) {
+ "stock transfer code '${command.code}' is already taken"
+ }
+ require(command.fromLocationCode != command.toLocationCode) {
+ "from and to locations must differ (both are '${command.fromLocationCode}')"
+ }
+ require(command.lines.isNotEmpty()) {
+ "stock transfer '${command.code}' must have at least one line"
+ }
+
+ val seenLineNos = HashSet(command.lines.size)
+ for (line in command.lines) {
+ require(line.lineNo > 0) {
+ "stock transfer line_no must be positive (got ${line.lineNo})"
+ }
+ require(seenLineNos.add(line.lineNo)) {
+ "stock transfer line_no ${line.lineNo} is duplicated"
+ }
+ require(line.quantity.signum() > 0) {
+ "stock transfer line ${line.lineNo} quantity must be positive (got ${line.quantity})"
+ }
+ catalogApi.findItemByCode(line.itemCode)
+ ?: throw IllegalArgumentException(
+ "stock transfer line ${line.lineNo}: item code '${line.itemCode}' " +
+ "is not in the catalog (or is inactive)",
+ )
+ }
+
+ val transfer = StockTransfer(
+ code = command.code,
+ fromLocationCode = command.fromLocationCode,
+ toLocationCode = command.toLocationCode,
+ status = StockTransferStatus.DRAFT,
+ transferDate = command.transferDate,
+ note = command.note,
+ )
+ for (line in command.lines) {
+ transfer.lines.add(
+ StockTransferLine(
+ transfer = transfer,
+ lineNo = line.lineNo,
+ itemCode = line.itemCode,
+ quantity = line.quantity,
+ ),
+ )
+ }
+ return transfers.save(transfer)
+ }
+
+ /**
+ * Confirm a DRAFT transfer, writing the atomic TRANSFER_OUT +
+ * TRANSFER_IN pair per line via the inventory facade. The
+ * ledger reference string is `TR:` so a grep of
+ * the ledger attributes the pair to this transfer.
+ */
+ fun confirm(id: UUID): StockTransfer {
+ val transfer = transfers.findById(id).orElseThrow {
+ NoSuchElementException("stock transfer not found: $id")
+ }
+ require(transfer.status == StockTransferStatus.DRAFT) {
+ "cannot confirm stock transfer ${transfer.code} in status ${transfer.status}; " +
+ "only DRAFT can be confirmed"
+ }
+ log.info(
+ "[warehousing] confirming transfer {} ({} line(s)) from {} to {}",
+ transfer.code, transfer.lines.size, transfer.fromLocationCode, transfer.toLocationCode,
+ )
+
+ val reference = "TR:${transfer.code}"
+ for (line in transfer.lines) {
+ // Debit the source first so a balance-goes-negative error
+ // aborts the confirm before ANY TRANSFER_IN row has been
+ // written. recordMovement's own sign-vs-reason validation
+ // enforces that TRANSFER_OUT must be negative.
+ inventoryApi.recordMovement(
+ itemCode = line.itemCode,
+ locationCode = transfer.fromLocationCode,
+ delta = line.quantity.negate(),
+ reason = "TRANSFER_OUT",
+ reference = reference,
+ )
+ inventoryApi.recordMovement(
+ itemCode = line.itemCode,
+ locationCode = transfer.toLocationCode,
+ delta = line.quantity,
+ reason = "TRANSFER_IN",
+ reference = reference,
+ )
+ }
+
+ transfer.status = StockTransferStatus.CONFIRMED
+ return transfer
+ }
+
+ /**
+ * Cancel a DRAFT transfer. Once CONFIRMED, cancellation is
+ * refused — reversing a confirmed transfer means creating a new
+ * transfer in the opposite direction, matching the discipline
+ * every other PBC in the framework uses for ledger-posted
+ * documents.
+ */
+ fun cancel(id: UUID): StockTransfer {
+ val transfer = transfers.findById(id).orElseThrow {
+ NoSuchElementException("stock transfer not found: $id")
+ }
+ require(transfer.status == StockTransferStatus.DRAFT) {
+ "cannot cancel stock transfer ${transfer.code} in status ${transfer.status}; " +
+ "only DRAFT can be cancelled — reverse a confirmed transfer by creating a new one in the other direction"
+ }
+ transfer.status = StockTransferStatus.CANCELLED
+ return transfer
+ }
+}
+
+data class CreateStockTransferCommand(
+ val code: String,
+ val fromLocationCode: String,
+ val toLocationCode: String,
+ val transferDate: LocalDate? = null,
+ val note: String? = null,
+ val lines: List,
+)
+
+data class StockTransferLineCommand(
+ val lineNo: Int,
+ val itemCode: String,
+ val quantity: BigDecimal,
+)
diff --git a/pbc/pbc-warehousing/src/main/kotlin/org/vibeerp/pbc/warehousing/domain/StockTransfer.kt b/pbc/pbc-warehousing/src/main/kotlin/org/vibeerp/pbc/warehousing/domain/StockTransfer.kt
new file mode 100644
index 0000000..d578dd0
--- /dev/null
+++ b/pbc/pbc-warehousing/src/main/kotlin/org/vibeerp/pbc/warehousing/domain/StockTransfer.kt
@@ -0,0 +1,120 @@
+package org.vibeerp.pbc.warehousing.domain
+
+import jakarta.persistence.CascadeType
+import jakarta.persistence.Column
+import jakarta.persistence.Entity
+import jakarta.persistence.EnumType
+import jakarta.persistence.Enumerated
+import jakarta.persistence.FetchType
+import jakarta.persistence.OneToMany
+import jakarta.persistence.OrderBy
+import jakarta.persistence.Table
+import org.vibeerp.platform.persistence.audit.AuditedJpaEntity
+import java.time.LocalDate
+
+/**
+ * A stock-transfer document: an operator's intent to move stock from
+ * [fromLocationCode] to [toLocationCode]. When confirmed, the
+ * [org.vibeerp.pbc.warehousing.application.StockTransferService] writes
+ * one atomic pair of inventory movements per line via
+ * `InventoryApi.recordMovement`: a negative `TRANSFER_OUT` on the source
+ * and a positive `TRANSFER_IN` on the destination, in a single
+ * transaction. A failure anywhere rolls both halves back so the ledger
+ * never records a half-transfer.
+ *
+ * **Why transfer is a first-class aggregate and not just "two
+ * recordMovement calls":**
+ * - The operator records intent before the physical move happens —
+ * DRAFT transfers are a queue of "these moves are pending, pickers
+ * please execute". A flat movement ledger can't express this
+ * because movements are facts (already happened).
+ * - A transfer has a code, a date, a human audit trail, a reversible
+ * state machine — all the usual document-style ergonomics that the
+ * ledger alone doesn't give you.
+ * - Two movements that SHOULD be atomic can be audited as one
+ * transfer, even though on the ledger they're two separate rows
+ * with distinct (source location, destination location). Grep by
+ * `TR:` finds both halves.
+ *
+ * **State machine:**
+ * - **DRAFT** → **CONFIRMED** (confirm writes the two ledger rows per line)
+ * - **DRAFT** → **CANCELLED** (cancel — nothing has moved yet)
+ * - **CONFIRMED** is terminal (reversing a confirmed transfer means
+ * creating a NEW transfer in the opposite direction; pbc-warehousing
+ * refuses to "uncancel" a ledger-posted document, matching the
+ * discipline pbc-orders-sales + pbc-orders-purchase already use)
+ * - **CANCELLED** is terminal
+ *
+ * **Why source_location_code + dest_location_code are on the HEADER
+ * rather than per-line:** in real warehouse operations a single
+ * transfer document typically moves several items from the SAME source
+ * to the SAME destination (a full pallet's worth, or an aisle change).
+ * Hoisting the locations to the header keeps the common case terse.
+ * The rare split-destination case is modelled as two separate transfer
+ * documents. A future v2 may push locations onto the line.
+ *
+ * **Why `transfer_date` is a LocalDate, not an Instant:** transfers are
+ * warehouse-floor operations, and the operator records "when this is
+ * supposed to happen" in the local business day, not in UTC nanoseconds.
+ * The audit columns (`created_at`, `updated_at`) already carry the
+ * exact instant the row was written.
+ */
+@Entity
+@Table(name = "warehousing__stock_transfer")
+class StockTransfer(
+ code: String,
+ fromLocationCode: String,
+ toLocationCode: String,
+ status: StockTransferStatus = StockTransferStatus.DRAFT,
+ transferDate: LocalDate? = null,
+ note: String? = null,
+) : AuditedJpaEntity() {
+
+ @Column(name = "code", nullable = false, length = 64)
+ var code: String = code
+
+ @Column(name = "from_location_code", nullable = false, length = 64)
+ var fromLocationCode: String = fromLocationCode
+
+ @Column(name = "to_location_code", nullable = false, length = 64)
+ var toLocationCode: String = toLocationCode
+
+ @Enumerated(EnumType.STRING)
+ @Column(name = "status", nullable = false, length = 16)
+ var status: StockTransferStatus = status
+
+ @Column(name = "transfer_date", nullable = true)
+ var transferDate: LocalDate? = transferDate
+
+ @Column(name = "note", nullable = true, length = 512)
+ var note: String? = note
+
+ /**
+ * The per-item lines. Empty list is rejected at create time —
+ * a transfer with no items has nothing to move and is useless.
+ * Eagerly fetched because every read of a transfer header is
+ * followed in practice by a read of its lines.
+ */
+ @OneToMany(
+ mappedBy = "transfer",
+ cascade = [CascadeType.ALL],
+ orphanRemoval = true,
+ fetch = FetchType.EAGER,
+ )
+ @OrderBy("lineNo ASC")
+ var lines: MutableList = mutableListOf()
+
+ override fun toString(): String =
+ "StockTransfer(id=$id, code='$code', from='$fromLocationCode', to='$toLocationCode', " +
+ "status=$status, lines=${lines.size})"
+}
+
+/**
+ * State machine for [StockTransfer]. See the entity KDoc for the
+ * rationale behind each transition.
+ */
+enum class StockTransferStatus {
+ DRAFT,
+ CONFIRMED,
+ CANCELLED,
+}
diff --git a/pbc/pbc-warehousing/src/main/kotlin/org/vibeerp/pbc/warehousing/domain/StockTransferLine.kt b/pbc/pbc-warehousing/src/main/kotlin/org/vibeerp/pbc/warehousing/domain/StockTransferLine.kt
new file mode 100644
index 0000000..998ed65
--- /dev/null
+++ b/pbc/pbc-warehousing/src/main/kotlin/org/vibeerp/pbc/warehousing/domain/StockTransferLine.kt
@@ -0,0 +1,52 @@
+package org.vibeerp.pbc.warehousing.domain
+
+import jakarta.persistence.Column
+import jakarta.persistence.Entity
+import jakarta.persistence.JoinColumn
+import jakarta.persistence.ManyToOne
+import jakarta.persistence.Table
+import org.vibeerp.platform.persistence.audit.AuditedJpaEntity
+import java.math.BigDecimal
+
+/**
+ * One per-item line on a [StockTransfer].
+ *
+ * **Why `item_code` is a varchar, not a UUID FK:** same rule every
+ * other pbc-* uses — cross-PBC references go by the item's stable
+ * human code, not its storage id. The application layer validates
+ * existence via `CatalogApi.findItemByCode` at create time, and
+ * `InventoryApi.recordMovement` re-validates on confirm.
+ *
+ * **No ext JSONB on the line** — lines are facts, not master records;
+ * custom fields go on the header if needed.
+ *
+ * **`line_no` is unique per transfer** — the application enforces
+ * this; the schema adds a unique index `(transfer_id, line_no)` to
+ * back it up.
+ */
+@Entity
+@Table(name = "warehousing__stock_transfer_line")
+class StockTransferLine(
+ transfer: StockTransfer,
+ lineNo: Int,
+ itemCode: String,
+ quantity: BigDecimal,
+) : AuditedJpaEntity() {
+
+ @ManyToOne
+ @JoinColumn(name = "transfer_id", nullable = false)
+ var transfer: StockTransfer = transfer
+
+ @Column(name = "line_no", nullable = false)
+ var lineNo: Int = lineNo
+
+ @Column(name = "item_code", nullable = false, length = 64)
+ var itemCode: String = itemCode
+
+ @Column(name = "quantity", nullable = false, precision = 18, scale = 4)
+ var quantity: BigDecimal = quantity
+
+ override fun toString(): String =
+ "StockTransferLine(id=$id, transferId=${transfer.id}, line=$lineNo, " +
+ "item='$itemCode', qty=$quantity)"
+}
diff --git a/pbc/pbc-warehousing/src/main/kotlin/org/vibeerp/pbc/warehousing/http/StockTransferController.kt b/pbc/pbc-warehousing/src/main/kotlin/org/vibeerp/pbc/warehousing/http/StockTransferController.kt
new file mode 100644
index 0000000..cc9d63b
--- /dev/null
+++ b/pbc/pbc-warehousing/src/main/kotlin/org/vibeerp/pbc/warehousing/http/StockTransferController.kt
@@ -0,0 +1,143 @@
+package org.vibeerp.pbc.warehousing.http
+
+import jakarta.validation.Valid
+import jakarta.validation.constraints.NotBlank
+import jakarta.validation.constraints.NotNull
+import jakarta.validation.constraints.Size
+import org.springframework.http.HttpStatus
+import org.springframework.http.ResponseEntity
+import org.springframework.web.bind.annotation.GetMapping
+import org.springframework.web.bind.annotation.PathVariable
+import org.springframework.web.bind.annotation.PostMapping
+import org.springframework.web.bind.annotation.RequestBody
+import org.springframework.web.bind.annotation.RequestMapping
+import org.springframework.web.bind.annotation.ResponseStatus
+import org.springframework.web.bind.annotation.RestController
+import org.vibeerp.pbc.warehousing.application.CreateStockTransferCommand
+import org.vibeerp.pbc.warehousing.application.StockTransferLineCommand
+import org.vibeerp.pbc.warehousing.application.StockTransferService
+import org.vibeerp.pbc.warehousing.domain.StockTransfer
+import org.vibeerp.pbc.warehousing.domain.StockTransferLine
+import org.vibeerp.pbc.warehousing.domain.StockTransferStatus
+import org.vibeerp.platform.security.authz.RequirePermission
+import java.math.BigDecimal
+import java.time.LocalDate
+import java.util.UUID
+
+/**
+ * REST API for the warehousing PBC. Mounted at
+ * `/api/v1/warehousing/stock-transfers`. State transitions use
+ * dedicated `/confirm` and `/cancel` endpoints — same shape as the
+ * other core PBCs.
+ */
+@RestController
+@RequestMapping("/api/v1/warehousing/stock-transfers")
+class StockTransferController(
+ private val service: StockTransferService,
+) {
+
+ @GetMapping
+ @RequirePermission("warehousing.stock-transfer.read")
+ fun list(): List =
+ service.list().map { it.toResponse() }
+
+ @GetMapping("/{id}")
+ @RequirePermission("warehousing.stock-transfer.read")
+ fun get(@PathVariable id: UUID): ResponseEntity {
+ val transfer = service.findById(id) ?: return ResponseEntity.notFound().build()
+ return ResponseEntity.ok(transfer.toResponse())
+ }
+
+ @GetMapping("/by-code/{code}")
+ @RequirePermission("warehousing.stock-transfer.read")
+ fun getByCode(@PathVariable code: String): ResponseEntity {
+ val transfer = service.findByCode(code) ?: return ResponseEntity.notFound().build()
+ return ResponseEntity.ok(transfer.toResponse())
+ }
+
+ @PostMapping
+ @ResponseStatus(HttpStatus.CREATED)
+ @RequirePermission("warehousing.stock-transfer.create")
+ fun create(@RequestBody @Valid request: CreateStockTransferRequest): StockTransferResponse =
+ service.create(request.toCommand()).toResponse()
+
+ @PostMapping("/{id}/confirm")
+ @RequirePermission("warehousing.stock-transfer.confirm")
+ fun confirm(@PathVariable id: UUID): StockTransferResponse =
+ service.confirm(id).toResponse()
+
+ @PostMapping("/{id}/cancel")
+ @RequirePermission("warehousing.stock-transfer.cancel")
+ fun cancel(@PathVariable id: UUID): StockTransferResponse =
+ service.cancel(id).toResponse()
+}
+
+// ─── DTOs ────────────────────────────────────────────────────────────
+
+data class CreateStockTransferRequest(
+ @field:NotBlank @field:Size(max = 64) val code: String,
+ @field:NotBlank @field:Size(max = 64) val fromLocationCode: String,
+ @field:NotBlank @field:Size(max = 64) val toLocationCode: String,
+ val transferDate: LocalDate? = null,
+ @field:Size(max = 512) val note: String? = null,
+ @field:Valid val lines: List,
+) {
+ fun toCommand(): CreateStockTransferCommand = CreateStockTransferCommand(
+ code = code,
+ fromLocationCode = fromLocationCode,
+ toLocationCode = toLocationCode,
+ transferDate = transferDate,
+ note = note,
+ lines = lines.map { it.toCommand() },
+ )
+}
+
+data class StockTransferLineRequest(
+ @field:NotNull val lineNo: Int,
+ @field:NotBlank @field:Size(max = 64) val itemCode: String,
+ @field:NotNull val quantity: BigDecimal,
+) {
+ fun toCommand(): StockTransferLineCommand = StockTransferLineCommand(
+ lineNo = lineNo,
+ itemCode = itemCode,
+ quantity = quantity,
+ )
+}
+
+data class StockTransferResponse(
+ val id: UUID,
+ val code: String,
+ val fromLocationCode: String,
+ val toLocationCode: String,
+ val status: StockTransferStatus,
+ val transferDate: LocalDate?,
+ val note: String?,
+ val lines: List,
+)
+
+data class StockTransferLineResponse(
+ val id: UUID,
+ val lineNo: Int,
+ val itemCode: String,
+ val quantity: BigDecimal,
+)
+
+private fun StockTransfer.toResponse(): StockTransferResponse =
+ StockTransferResponse(
+ id = id,
+ code = code,
+ fromLocationCode = fromLocationCode,
+ toLocationCode = toLocationCode,
+ status = status,
+ transferDate = transferDate,
+ note = note,
+ lines = lines.map { it.toResponse() },
+ )
+
+private fun StockTransferLine.toResponse(): StockTransferLineResponse =
+ StockTransferLineResponse(
+ id = id,
+ lineNo = lineNo,
+ itemCode = itemCode,
+ quantity = quantity,
+ )
diff --git a/pbc/pbc-warehousing/src/main/kotlin/org/vibeerp/pbc/warehousing/infrastructure/StockTransferJpaRepository.kt b/pbc/pbc-warehousing/src/main/kotlin/org/vibeerp/pbc/warehousing/infrastructure/StockTransferJpaRepository.kt
new file mode 100644
index 0000000..5481f1b
--- /dev/null
+++ b/pbc/pbc-warehousing/src/main/kotlin/org/vibeerp/pbc/warehousing/infrastructure/StockTransferJpaRepository.kt
@@ -0,0 +1,10 @@
+package org.vibeerp.pbc.warehousing.infrastructure
+
+import org.springframework.data.jpa.repository.JpaRepository
+import org.vibeerp.pbc.warehousing.domain.StockTransfer
+import java.util.UUID
+
+interface StockTransferJpaRepository : JpaRepository {
+ fun existsByCode(code: String): Boolean
+ fun findByCode(code: String): StockTransfer?
+}
diff --git a/pbc/pbc-warehousing/src/main/resources/META-INF/vibe-erp/metadata/warehousing.yml b/pbc/pbc-warehousing/src/main/resources/META-INF/vibe-erp/metadata/warehousing.yml
new file mode 100644
index 0000000..529a115
--- /dev/null
+++ b/pbc/pbc-warehousing/src/main/resources/META-INF/vibe-erp/metadata/warehousing.yml
@@ -0,0 +1,26 @@
+# pbc-warehousing metadata.
+#
+# Loaded at boot by MetadataLoader, tagged source='core'.
+
+entities:
+ - name: StockTransfer
+ pbc: warehousing
+ table: warehousing__stock_transfer
+ description: A multi-line stock transfer document from one location to another (DRAFT then CONFIRMED writes atomic TRANSFER_OUT and TRANSFER_IN ledger rows)
+
+permissions:
+ - key: warehousing.stock-transfer.read
+ description: Read stock transfers
+ - key: warehousing.stock-transfer.create
+ description: Create stock transfers (in DRAFT status)
+ - key: warehousing.stock-transfer.confirm
+ description: Confirm a DRAFT stock transfer, posting the atomic TRANSFER_OUT and TRANSFER_IN ledger pair
+ - key: warehousing.stock-transfer.cancel
+ description: Cancel a DRAFT stock transfer
+
+menus:
+ - path: /warehousing/stock-transfers
+ label: Stock transfers
+ icon: truck
+ section: Warehousing
+ order: 500
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
new file mode 100644
index 0000000..a167f66
--- /dev/null
+++ b/pbc/pbc-warehousing/src/test/kotlin/org/vibeerp/pbc/warehousing/application/StockTransferServiceTest.kt
@@ -0,0 +1,230 @@
+package org.vibeerp.pbc.warehousing.application
+
+import assertk.assertFailure
+import assertk.assertThat
+import assertk.assertions.contains
+import assertk.assertions.hasMessage
+import assertk.assertions.isEqualTo
+import assertk.assertions.isInstanceOf
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.slot
+import io.mockk.verify
+import io.mockk.verifyOrder
+import org.junit.jupiter.api.Test
+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.StockBalanceRef
+import org.vibeerp.pbc.warehousing.domain.StockTransfer
+import org.vibeerp.pbc.warehousing.domain.StockTransferStatus
+import org.vibeerp.pbc.warehousing.infrastructure.StockTransferJpaRepository
+import java.math.BigDecimal
+import java.util.Optional
+import java.util.UUID
+
+class StockTransferServiceTest {
+
+ private val transfers: StockTransferJpaRepository = mockk(relaxed = true)
+ private val catalog: CatalogApi = mockk()
+ private val inventory: InventoryApi = mockk()
+ private val service = StockTransferService(transfers, catalog, inventory)
+
+ private fun stubItemExists(code: String) {
+ every { catalog.findItemByCode(code) } returns ItemRef(
+ id = Id(UUID.randomUUID()),
+ code = code,
+ name = "fake",
+ itemType = "GOOD",
+ baseUomCode = "ea",
+ active = true,
+ )
+ }
+
+ private fun cmd(
+ code: String = "TR-001",
+ from: String = "WH-A",
+ to: String = "WH-B",
+ lines: List = listOf(
+ StockTransferLineCommand(lineNo = 1, itemCode = "ITEM-1", quantity = BigDecimal("5")),
+ StockTransferLineCommand(lineNo = 2, itemCode = "ITEM-2", quantity = BigDecimal("3")),
+ ),
+ ) = CreateStockTransferCommand(
+ code = code,
+ fromLocationCode = from,
+ toLocationCode = to,
+ lines = lines,
+ )
+
+ @Test
+ fun `create persists a DRAFT transfer when everything validates`() {
+ every { transfers.existsByCode("TR-001") } returns false
+ stubItemExists("ITEM-1")
+ stubItemExists("ITEM-2")
+ every { transfers.save(any()) } answers { firstArg() }
+
+ val saved = service.create(cmd())
+
+ assertThat(saved.code).isEqualTo("TR-001")
+ assertThat(saved.fromLocationCode).isEqualTo("WH-A")
+ assertThat(saved.toLocationCode).isEqualTo("WH-B")
+ assertThat(saved.status).isEqualTo(StockTransferStatus.DRAFT)
+ assertThat(saved.lines.size).isEqualTo(2)
+ assertThat(saved.lines[0].itemCode).isEqualTo("ITEM-1")
+ assertThat(saved.lines[1].itemCode).isEqualTo("ITEM-2")
+ }
+
+ @Test
+ fun `create rejects duplicate code`() {
+ every { transfers.existsByCode("TR-dup") } returns true
+
+ assertFailure { service.create(cmd(code = "TR-dup")) }
+ .isInstanceOf(IllegalArgumentException::class)
+ .hasMessage("stock transfer code 'TR-dup' is already taken")
+ }
+
+ @Test
+ fun `create rejects same from and to location`() {
+ every { transfers.existsByCode(any()) } returns false
+
+ assertFailure { service.create(cmd(from = "WH-A", to = "WH-A")) }
+ .isInstanceOf(IllegalArgumentException::class)
+ .hasMessage("from and to locations must differ (both are 'WH-A')")
+ }
+
+ @Test
+ fun `create rejects an empty line list`() {
+ every { transfers.existsByCode(any()) } returns false
+
+ assertFailure { service.create(cmd(lines = emptyList())) }
+ .isInstanceOf(IllegalArgumentException::class)
+ .hasMessage("stock transfer 'TR-001' must have at least one line")
+ }
+
+ @Test
+ fun `create rejects duplicate line numbers`() {
+ every { transfers.existsByCode(any()) } returns false
+ stubItemExists("X")
+
+ assertFailure {
+ service.create(
+ cmd(
+ lines = listOf(
+ StockTransferLineCommand(1, "X", BigDecimal("1")),
+ StockTransferLineCommand(1, "X", BigDecimal("2")),
+ ),
+ ),
+ )
+ }
+ .isInstanceOf(IllegalArgumentException::class)
+ .hasMessage("stock transfer line_no 1 is duplicated")
+ }
+
+ @Test
+ fun `create rejects non-positive quantities`() {
+ every { transfers.existsByCode(any()) } returns false
+ stubItemExists("X")
+
+ assertFailure {
+ service.create(
+ cmd(lines = listOf(StockTransferLineCommand(1, "X", BigDecimal.ZERO))),
+ )
+ }
+ .isInstanceOf(IllegalArgumentException::class)
+ }
+
+ @Test
+ fun `create rejects unknown items via CatalogApi`() {
+ every { transfers.existsByCode(any()) } returns false
+ every { catalog.findItemByCode("GHOST") } returns null
+
+ assertFailure {
+ service.create(
+ cmd(lines = listOf(StockTransferLineCommand(1, "GHOST", BigDecimal("1")))),
+ )
+ }
+ .isInstanceOf(IllegalArgumentException::class)
+ }
+
+ @Test
+ fun `confirm writes an atomic TRANSFER_OUT + TRANSFER_IN pair per line`() {
+ val id = UUID.randomUUID()
+ val transfer = StockTransfer(
+ code = "TR-002",
+ fromLocationCode = "WH-A",
+ toLocationCode = "WH-B",
+ ).also { it.id = id }
+ transfer.lines.add(
+ org.vibeerp.pbc.warehousing.domain.StockTransferLine(
+ transfer = transfer, lineNo = 1, itemCode = "ITEM-1", quantity = BigDecimal("5"),
+ ),
+ )
+ transfer.lines.add(
+ org.vibeerp.pbc.warehousing.domain.StockTransferLine(
+ transfer = transfer, lineNo = 2, itemCode = "ITEM-2", quantity = BigDecimal("3"),
+ ),
+ )
+ every { transfers.findById(id) } returns Optional.of(transfer)
+ every { inventory.recordMovement(any(), any(), any(), any(), any()) } returns
+ StockBalanceRef(Id(UUID.randomUUID()), "x", "x", BigDecimal.ZERO)
+
+ val result = service.confirm(id)
+
+ assertThat(result.status).isEqualTo(StockTransferStatus.CONFIRMED)
+
+ // Four movements total: (line 1 out + in), (line 2 out + in).
+ // The contract is OUT-first-per-line so a ledger failure on the
+ // source side aborts before touching the destination.
+ verifyOrder {
+ inventory.recordMovement("ITEM-1", "WH-A", BigDecimal("-5"), "TRANSFER_OUT", "TR:TR-002")
+ inventory.recordMovement("ITEM-1", "WH-B", BigDecimal("5"), "TRANSFER_IN", "TR:TR-002")
+ inventory.recordMovement("ITEM-2", "WH-A", BigDecimal("-3"), "TRANSFER_OUT", "TR:TR-002")
+ inventory.recordMovement("ITEM-2", "WH-B", BigDecimal("3"), "TRANSFER_IN", "TR:TR-002")
+ }
+ }
+
+ @Test
+ fun `confirm refuses a non-DRAFT transfer`() {
+ val id = UUID.randomUUID()
+ val transfer = StockTransfer(
+ code = "TR-003",
+ fromLocationCode = "A",
+ toLocationCode = "B",
+ status = StockTransferStatus.CONFIRMED,
+ ).also { it.id = id }
+ every { transfers.findById(id) } returns Optional.of(transfer)
+
+ assertFailure { service.confirm(id) }
+ .isInstanceOf(IllegalArgumentException::class)
+ }
+
+ @Test
+ fun `cancel refuses a CONFIRMED transfer`() {
+ val id = UUID.randomUUID()
+ val transfer = StockTransfer(
+ code = "TR-004",
+ fromLocationCode = "A",
+ toLocationCode = "B",
+ status = StockTransferStatus.CONFIRMED,
+ ).also { it.id = id }
+ every { transfers.findById(id) } returns Optional.of(transfer)
+
+ assertFailure { service.cancel(id) }
+ .isInstanceOf(IllegalArgumentException::class)
+ }
+
+ @Test
+ fun `cancel flips a DRAFT transfer to CANCELLED`() {
+ val id = UUID.randomUUID()
+ val transfer = StockTransfer(
+ code = "TR-005",
+ fromLocationCode = "A",
+ toLocationCode = "B",
+ ).also { it.id = id }
+ every { transfers.findById(id) } returns Optional.of(transfer)
+
+ val result = service.cancel(id)
+ assertThat(result.status).isEqualTo(StockTransferStatus.CANCELLED)
+ }
+}
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 7024489..2b9e07c 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -58,6 +58,9 @@ project(":pbc:pbc-partners").projectDir = file("pbc/pbc-partners")
include(":pbc:pbc-inventory")
project(":pbc:pbc-inventory").projectDir = file("pbc/pbc-inventory")
+include(":pbc:pbc-warehousing")
+project(":pbc:pbc-warehousing").projectDir = file("pbc/pbc-warehousing")
+
include(":pbc:pbc-orders-sales")
project(":pbc:pbc-orders-sales").projectDir = file("pbc/pbc-orders-sales")