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")