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,6 +31,7 @@ dependencies { | ||
| 31 | implementation(project(":pbc:pbc-catalog")) | 31 | implementation(project(":pbc:pbc-catalog")) |
| 32 | implementation(project(":pbc:pbc-partners")) | 32 | implementation(project(":pbc:pbc-partners")) |
| 33 | implementation(project(":pbc:pbc-inventory")) | 33 | implementation(project(":pbc:pbc-inventory")) |
| 34 | + implementation(project(":pbc:pbc-warehousing")) | ||
| 34 | implementation(project(":pbc:pbc-orders-sales")) | 35 | implementation(project(":pbc:pbc-orders-sales")) |
| 35 | implementation(project(":pbc:pbc-orders-purchase")) | 36 | implementation(project(":pbc:pbc-orders-purchase")) |
| 36 | implementation(project(":pbc:pbc-finance")) | 37 | implementation(project(":pbc:pbc-finance")) |
distribution/src/main/resources/db/changelog/master.xml
| @@ -18,6 +18,7 @@ | @@ -18,6 +18,7 @@ | ||
| 18 | <include file="classpath:db/changelog/pbc-partners/001-partners-init.xml"/> | 18 | <include file="classpath:db/changelog/pbc-partners/001-partners-init.xml"/> |
| 19 | <include file="classpath:db/changelog/pbc-inventory/001-inventory-init.xml"/> | 19 | <include file="classpath:db/changelog/pbc-inventory/001-inventory-init.xml"/> |
| 20 | <include file="classpath:db/changelog/pbc-inventory/002-inventory-movement-ledger.xml"/> | 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 | <include file="classpath:db/changelog/pbc-orders-sales/001-orders-sales-init.xml"/> | 22 | <include file="classpath:db/changelog/pbc-orders-sales/001-orders-sales-init.xml"/> |
| 22 | <include file="classpath:db/changelog/pbc-orders-purchase/001-orders-purchase-init.xml"/> | 23 | <include file="classpath:db/changelog/pbc-orders-purchase/001-orders-purchase-init.xml"/> |
| 23 | <include file="classpath:db/changelog/pbc-finance/001-finance-init.xml"/> | 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,6 +58,9 @@ project(":pbc:pbc-partners").projectDir = file("pbc/pbc-partners") | ||
| 58 | include(":pbc:pbc-inventory") | 58 | include(":pbc:pbc-inventory") |
| 59 | project(":pbc:pbc-inventory").projectDir = file("pbc/pbc-inventory") | 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 | include(":pbc:pbc-orders-sales") | 64 | include(":pbc:pbc-orders-sales") |
| 62 | project(":pbc:pbc-orders-sales").projectDir = file("pbc/pbc-orders-sales") | 65 | project(":pbc:pbc-orders-sales").projectDir = file("pbc/pbc-orders-sales") |
| 63 | 66 |