Commit ba507803de38d3825bfa1741d957f0f1b44a34cb
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.
Showing
12 changed files
with
926 additions
and
0 deletions
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 <> 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 > 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(":pbc:pbc-partners").projectDir = file("pbc/pbc-partners") |
| 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 | ... | ... |