From e37c143e3c378c48984aba20946379bf7586d2f3 Mon Sep 17 00:00:00 2001 From: zichun Date: Wed, 8 Apr 2026 17:38:46 +0800 Subject: [PATCH] feat(inventory+orders): movement ledger + sales-order shipping (end-to-end demo) --- CLAUDE.md | 4 ++-- PROGRESS.md | 14 ++++++-------- README.md | 4 ++-- api/api-v1/src/main/kotlin/org/vibeerp/api/v1/ext/inventory/InventoryApi.kt | 55 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ distribution/src/main/resources/db/changelog/master.xml | 1 + distribution/src/main/resources/db/changelog/pbc-inventory/002-inventory-movement-ledger.xml | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/application/StockBalanceService.kt | 61 ++++++++++++++++++++++++++++++++++++++----------------------- pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/application/StockMovementService.kt | 187 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/domain/StockMovement.kt | 126 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/ext/InventoryApiAdapter.kt | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/http/StockMovementController.kt | 139 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/infrastructure/StockMovementJpaRepository.kt | 32 ++++++++++++++++++++++++++++++++ pbc/pbc-inventory/src/test/kotlin/org/vibeerp/pbc/inventory/application/StockBalanceServiceTest.kt | 111 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------------------------ pbc/pbc-inventory/src/test/kotlin/org/vibeerp/pbc/inventory/application/StockMovementServiceTest.kt | 243 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ pbc/pbc-orders-sales/src/main/kotlin/org/vibeerp/pbc/orders/sales/application/SalesOrderService.kt | 65 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ pbc/pbc-orders-sales/src/main/kotlin/org/vibeerp/pbc/orders/sales/domain/SalesOrder.kt | 17 ++++++++++++----- pbc/pbc-orders-sales/src/main/kotlin/org/vibeerp/pbc/orders/sales/http/SalesOrderController.kt | 44 ++++++++++++++++++++++++++++++++++++++++++++ pbc/pbc-orders-sales/src/main/resources/META-INF/vibe-erp/metadata/orders-sales.yml | 4 +++- pbc/pbc-orders-sales/src/test/kotlin/org/vibeerp/pbc/orders/sales/application/SalesOrderServiceTest.kt | 117 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++- 19 files changed, 1250 insertions(+), 84 deletions(-) create mode 100644 distribution/src/main/resources/db/changelog/pbc-inventory/002-inventory-movement-ledger.xml create mode 100644 pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/application/StockMovementService.kt create mode 100644 pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/domain/StockMovement.kt create mode 100644 pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/http/StockMovementController.kt create mode 100644 pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/infrastructure/StockMovementJpaRepository.kt create mode 100644 pbc/pbc-inventory/src/test/kotlin/org/vibeerp/pbc/inventory/application/StockMovementServiceTest.kt diff --git a/CLAUDE.md b/CLAUDE.md index d8b1c0b..8554d15 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -96,9 +96,9 @@ plugins (incl. ref) depend on: api/api-v1 only **Foundation complete; first business surface in place.** As of the latest commit: - **15 Gradle subprojects** built and tested. The dependency rule (PBCs never import each other; plug-ins only see `api.v1`) is enforced at configuration time by the root `build.gradle.kts`. -- **163 unit tests across 15 modules**, all green. `./gradlew build` is the canonical full build. +- **175 unit tests across 15 modules**, all green. `./gradlew build` is the canonical full build. - **All 9 cross-cutting platform services live and smoke-tested end-to-end** against real Postgres: auth (P4.1), authorization with `@RequirePermission` + JWT roles claim + AOP enforcement (P4.3), plug-in HTTP endpoints (P1.3), plug-in linter (P1.2), plug-in-owned Liquibase + typed SQL (P1.4), event bus + transactional outbox (P1.7), metadata loader + custom-field validation (P1.5 + P3.4), ICU4J translator with per-plug-in locale chain (P1.6), plus the audit/principal context bridge. -- **5 of 10 core PBCs implemented end-to-end** (`pbc-identity`, `pbc-catalog`, `pbc-partners`, `pbc-inventory`, `pbc-orders-sales`). **pbc-inventory** is the first PBC to consume one cross-PBC facade (`CatalogApi`); **pbc-orders-sales** is the first to consume *two simultaneously* (`PartnersApi` + `CatalogApi`) in one transaction. The Gradle build still refuses any direct dependency between PBCs — Spring DI wires the interfaces to their concrete adapters at runtime. +- **5 of 10 core PBCs implemented end-to-end** (`pbc-identity`, `pbc-catalog`, `pbc-partners`, `pbc-inventory`, `pbc-orders-sales`). **pbc-inventory** has an append-only stock movement ledger; **pbc-orders-sales** can ship orders, atomically debiting stock via `InventoryApi.recordMovement` — the framework's first cross-PBC WRITE flow. The Gradle build still refuses any direct dependency between PBCs. - **Reference printing-shop plug-in** owns its own DB schema, CRUDs plates and ink recipes via REST through `context.jdbc`, ships its own metadata YAML, and registers 7 HTTP endpoints. It is the executable acceptance test for the framework. - **Package root** is `org.vibeerp`. - **Build commands:** `./gradlew build` (full), `./gradlew test` (unit tests), `./gradlew :distribution:bootRun` (run dev profile, stages plug-in JAR automatically), `docker compose up -d db` (Postgres). The bootstrap admin password is printed to the application logs on first boot. diff --git a/PROGRESS.md b/PROGRESS.md index e64799a..8cbcd63 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -10,21 +10,21 @@ | | | |---|---| -| **Latest version** | v0.12 (post-P4.3) | -| **Latest commit** | `75bf870 feat(security): P4.3 — @RequirePermission + JWT roles claim + AOP enforcement` | +| **Latest version** | v0.13 (post-ledger + ship) | +| **Latest commit** | `feat(inventory+orders): movement ledger + sales-order shipping (end-to-end demo)` | | **Repo** | https://github.com/reporkey/vibe-erp | | **Modules** | 15 | -| **Unit tests** | 163, all green | -| **End-to-end smoke runs** | All cross-cutting services + all 5 PBCs verified against real Postgres; reference plug-in returns localised strings in 3 locales; Partner + Location accept custom-field `ext`; sales orders validate via three cross-PBC seams; **`@RequirePermission` enforces 403 on non-admin users hitting protected endpoints** | +| **Unit tests** | 175, all green | +| **End-to-end smoke runs** | The killer demo works: create catalog item + partner + location, set stock to 1000, place an order for 50, confirm, **ship**, watch the balance drop to 950 and a `SALES_SHIPMENT` row appear in the ledger tagged `SO:SO-2026-0001`. Over-shipping rolls back atomically with a meaningful 400. | | **Real PBCs implemented** | 5 of 10 (`pbc-identity`, `pbc-catalog`, `pbc-partners`, `pbc-inventory`, `pbc-orders-sales`) | | **Plug-ins serving HTTP** | 1 (reference printing-shop, 7 endpoints + own DB schema + own metadata + own i18n bundles) | | **Production-ready?** | **No.** Foundation is solid; many capabilities still deferred. | ## Current stage -**Foundation complete; Tier 1 customization live; authorization enforced.** All eight cross-cutting platform services are live, plus the **authorization layer (P4.3)**: `@RequirePermission` annotations on controller methods are enforced by a Spring AOP aspect that consults a `PermissionEvaluator` reading the JWT's `roles` claim from a per-request `AuthorizationContext`. The framework can now distinguish "authenticated but not authorized" (403) from "not authenticated" (401). Five real PBCs validate the modular-monolith template; pbc-orders-sales remains the most rigorous test (consumes two cross-PBC facades in one transaction). State machine DRAFT → CONFIRMED → CANCELLED is enforced. +**Foundation complete; Tier 1 customization live; authorization enforced; end-to-end order-to-shipment loop closed.** All eight cross-cutting platform services are live plus the authorization layer. The biggest leap in this version: the **inventory movement ledger** is live (`inventory__stock_movement` append-only table; the framework's first append-only ledger), and **sales orders can now ship** (`POST /sales-orders/{id}/ship`) via a cross-PBC write — pbc-orders-sales injects the new `InventoryApi.recordMovement` to atomically debit stock for every line and update the order status, all in one transaction. Either the whole shipment commits or none of it does. This is the framework's **first cross-PBC WRITE flow** (every earlier cross-PBC call was a read). The sales-order state machine grows: DRAFT → CONFIRMED → SHIPPED (terminal). Cancelling a SHIPPED order is rejected with a meaningful "use a return / refund flow" message. -The next phase continues **building business surface area**: pbc-orders-purchase, pbc-production, the stock movement ledger that pairs with sales-order shipping, the workflow engine (Flowable), and eventually the React SPA. +The next phase continues **building business surface area**: pbc-orders-purchase, pbc-production, the workflow engine (Flowable), event-driven cross-PBC integration (the event bus has been wired since P1.7 but no flow uses it yet), and eventually the React SPA. ## Total scope (the v1.0 cut line) @@ -171,8 +171,6 @@ This is **real**: a JAR file dropped into a directory, loaded by the framework, - **Job scheduler.** No Quartz. Periodic jobs don't have a home. - **OIDC.** Built-in JWT only. OIDC client (Keycloak-compatible) is P4.2. - **More PBCs.** Identity, catalog, partners, inventory and orders-sales exist. Warehousing, orders-purchase, production, quality, finance are all pending. -- **Sales-order shipping flow.** Sales orders can be created and confirmed but cannot yet *ship* — that requires the inventory movement ledger (deferred from P5.3) so the framework can atomically debit stock when an order ships. -- **Stock movement ledger.** Inventory has balances but not the append-only `inventory__stock_movement` history yet — the v1 cut is "set the quantity"; the audit-grade ledger lands in a follow-up that will also unlock sales-order shipping. - **Web SPA.** No React app. The framework is API-only today. - **MCP server.** The architecture leaves room for it; the implementation is v1.1. - **Mobile.** v2. diff --git a/README.md b/README.md index fab5db0..24f9b65 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ vibe-erp/ ## Building ```bash -# Build everything (compiles 15 modules, runs 163 unit tests) +# Build everything (compiles 15 modules, runs 175 unit tests) ./gradlew build # Bring up Postgres + the reference plug-in JAR @@ -97,7 +97,7 @@ The bootstrap admin password is printed to the application logs on first boot. A | | | |---|---| | Modules | 15 | -| Unit tests | 163, all green | +| Unit tests | 175, all green | | Real PBCs | 5 of 10 | | Cross-cutting services live | 9 | | Plug-ins serving HTTP | 1 (reference printing-shop) | diff --git a/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/ext/inventory/InventoryApi.kt b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/ext/inventory/InventoryApi.kt index a38f08d..823033a 100644 --- a/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/ext/inventory/InventoryApi.kt +++ b/api/api-v1/src/main/kotlin/org/vibeerp/api/v1/ext/inventory/InventoryApi.kt @@ -59,6 +59,61 @@ interface InventoryApi { * state because that is most items most of the time. */ fun totalOnHand(itemCode: String): BigDecimal + + /** + * Record a stock movement and update the matching balance, + * atomically. **The cross-PBC entry point for changing inventory.** + * + * Other PBCs (and plug-ins) call this to debit or credit stock as + * a side effect of their own business operations: + * - `pbc-orders-sales` calls it on `POST /sales-orders/{id}/ship` + * with `delta=-quantity reason="SALES_SHIPMENT"` per line. + * - `pbc-orders-purchase` (future) calls it on receipt with + * `reason="PURCHASE_RECEIPT"` per line. + * - The reference printing-shop plug-in (future) calls it from + * work-order-completion handlers. + * + * **Why the reason is a String** instead of a typed enum: + * plug-ins must not import inventory's internal `MovementReason` + * enum (that would force them onto pbc-inventory's compile + * classpath, violating guardrail #9). The accepted values are + * documented as a closed set: `RECEIPT`, `ISSUE`, `ADJUSTMENT`, + * `SALES_SHIPMENT`, `PURCHASE_RECEIPT`, `TRANSFER_OUT`, + * `TRANSFER_IN`. Each carries a documented sign convention which + * the framework enforces — a `SALES_SHIPMENT` with positive delta + * is rejected with a meaningful error. + * + * **Why a single method** instead of separate `debit` / `credit`: + * the ledger's natural shape is a signed delta. Callers think + * "I'm shipping 5 units" → delta=-5, reason=SALES_SHIPMENT. The + * sign carries the semantic; making the caller pick a method + * name based on the sign duplicates information that is already + * in the delta. + * + * @param itemCode the catalog item code (validated cross-PBC via + * CatalogApi inside the implementation). + * @param locationCode the inventory location code (NOT id, by + * convention every cross-PBC reference uses codes). + * @param delta the signed quantity to add (positive) or subtract + * (negative). Zero is rejected. + * @param reason one of the closed set documented above. + * @param reference free-form caller-supplied tag, recorded on the + * ledger row. Convention: `:` (e.g. `SO:SO-2026-0001`, + * `PR:PR-2026-0042`). May be null for unattached movements. + * @return the resulting [StockBalanceRef] AFTER the movement. + * @throws IllegalArgumentException when the item is unknown, the + * location code is unknown, the delta is zero, the delta sign + * disagrees with the reason, or the resulting balance would be + * negative. Caller-side `try { } catch (IllegalArgumentException)` + * is the right shape for plug-in code. + */ + fun recordMovement( + itemCode: String, + locationCode: String, + delta: BigDecimal, + reason: String, + reference: String? = null, + ): StockBalanceRef } /** diff --git a/distribution/src/main/resources/db/changelog/master.xml b/distribution/src/main/resources/db/changelog/master.xml index b1c4cea..9000dd6 100644 --- a/distribution/src/main/resources/db/changelog/master.xml +++ b/distribution/src/main/resources/db/changelog/master.xml @@ -17,5 +17,6 @@ + diff --git a/distribution/src/main/resources/db/changelog/pbc-inventory/002-inventory-movement-ledger.xml b/distribution/src/main/resources/db/changelog/pbc-inventory/002-inventory-movement-ledger.xml new file mode 100644 index 0000000..97868fc --- /dev/null +++ b/distribution/src/main/resources/db/changelog/pbc-inventory/002-inventory-movement-ledger.xml @@ -0,0 +1,62 @@ + + + + + + + Create inventory__stock_movement append-only ledger table + + CREATE TABLE inventory__stock_movement ( + id uuid PRIMARY KEY, + item_code varchar(64) NOT NULL, + location_id uuid NOT NULL REFERENCES inventory__location(id), + delta numeric(18,4) NOT NULL, + reason varchar(32) NOT NULL, + reference varchar(128), + occurred_at timestamptz NOT NULL, + created_at timestamptz NOT NULL, + created_by varchar(128) NOT NULL, + updated_at timestamptz NOT NULL, + updated_by varchar(128) NOT NULL, + version bigint NOT NULL DEFAULT 0, + CONSTRAINT inventory__stock_movement_nonzero CHECK (delta <> 0) + ); + CREATE INDEX inventory__stock_movement_item_idx + ON inventory__stock_movement (item_code); + CREATE INDEX inventory__stock_movement_loc_idx + ON inventory__stock_movement (location_id); + CREATE INDEX inventory__stock_movement_item_loc_idx + ON inventory__stock_movement (item_code, location_id); + CREATE INDEX inventory__stock_movement_reference_idx + ON inventory__stock_movement (reference); + CREATE INDEX inventory__stock_movement_occurred_idx + ON inventory__stock_movement (occurred_at); + + + DROP TABLE inventory__stock_movement; + + + + diff --git a/pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/application/StockBalanceService.kt b/pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/application/StockBalanceService.kt index be9d9c4..a4d7b01 100644 --- a/pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/application/StockBalanceService.kt +++ b/pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/application/StockBalanceService.kt @@ -3,6 +3,7 @@ package org.vibeerp.pbc.inventory.application import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import org.vibeerp.api.v1.ext.catalog.CatalogApi +import org.vibeerp.pbc.inventory.domain.MovementReason import org.vibeerp.pbc.inventory.domain.StockBalance import org.vibeerp.pbc.inventory.infrastructure.LocationJpaRepository import org.vibeerp.pbc.inventory.infrastructure.StockBalanceJpaRepository @@ -52,6 +53,7 @@ class StockBalanceService( private val balances: StockBalanceJpaRepository, private val locations: LocationJpaRepository, private val catalogApi: CatalogApi, + private val stockMovementService: StockMovementService, ) { @Transactional(readOnly = true) @@ -68,36 +70,49 @@ class StockBalanceService( /** * Set the on-hand quantity for [itemCode] at [locationId] to * [quantity], creating the balance row if it does not yet exist. + * + * **As of the movement-ledger landing**, this method is a thin + * wrapper that delegates to [StockMovementService.record] with a + * computed delta and reason=ADJUSTMENT. The wrapper exists so the + * `POST /inventory/balances/adjust` REST endpoint keeps its + * absolute-quantity semantics — operators want to type "the + * shelf has 47 of these" not "decrease by 3" — while the + * underlying ledger row still captures the change. The cross-PBC + * item validation, the location validation, and the + * negative-balance rejection now happen inside the movement + * service; the ADJUSTMENT path inherits all of them. */ fun adjust(itemCode: String, locationId: UUID, quantity: BigDecimal): StockBalance { require(quantity.signum() >= 0) { "stock quantity must be non-negative (got $quantity)" } - // Cross-PBC validation #1: the item must exist in the catalog - // AND be active. CatalogApi returns null for inactive items — - // see the rationale on CatalogApi.findItemByCode. - catalogApi.findItemByCode(itemCode) - ?: throw IllegalArgumentException( - "item code '$itemCode' is not in the catalog (or is inactive)", - ) - - // Local validation #2: the location must exist. We don't go - // through a facade because the location lives in this PBC. - require(locations.existsById(locationId)) { - "location not found: $locationId" - } - - // Upsert the balance row. Single-instance deployments mean the - // SELECT-then-save race window is closed for practical purposes - // — vibe_erp is single-tenant per process and the @Transactional - // boundary makes the read-modify-write atomic at the DB level. val existing = balances.findByItemCodeAndLocationId(itemCode, locationId) - return if (existing == null) { - balances.save(StockBalance(itemCode, locationId, quantity)) - } else { - existing.quantity = quantity - existing + val currentQuantity = existing?.quantity ?: BigDecimal.ZERO + val delta = quantity - currentQuantity + if (delta.signum() == 0) { + // No-op adjustment — don't insert a meaningless ledger row. + // Validate the inputs anyway so the caller still gets the + // same error shape on bad item / bad location, by going + // through the catalog facade and the location check. + catalogApi.findItemByCode(itemCode) + ?: throw IllegalArgumentException( + "item code '$itemCode' is not in the catalog (or is inactive)", + ) + require(locations.existsById(locationId)) { + "location not found: $locationId" + } + return existing ?: balances.save(StockBalance(itemCode, locationId, quantity)) } + + return stockMovementService.record( + RecordMovementCommand( + itemCode = itemCode, + locationId = locationId, + delta = delta, + reason = MovementReason.ADJUSTMENT, + reference = null, + ), + ) } } diff --git a/pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/application/StockMovementService.kt b/pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/application/StockMovementService.kt new file mode 100644 index 0000000..256ac92 --- /dev/null +++ b/pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/application/StockMovementService.kt @@ -0,0 +1,187 @@ +package org.vibeerp.pbc.inventory.application + +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import org.vibeerp.api.v1.ext.catalog.CatalogApi +import org.vibeerp.pbc.inventory.domain.MovementReason +import org.vibeerp.pbc.inventory.domain.StockBalance +import org.vibeerp.pbc.inventory.domain.StockMovement +import org.vibeerp.pbc.inventory.infrastructure.LocationJpaRepository +import org.vibeerp.pbc.inventory.infrastructure.StockBalanceJpaRepository +import org.vibeerp.pbc.inventory.infrastructure.StockMovementJpaRepository +import java.math.BigDecimal +import java.time.Instant +import java.util.UUID + +/** + * The framework's stock-movement ledger writer. + * + * **The ONE entry point for changing inventory.** Every other path + * that wants to debit or credit stock — manual operator adjustment, + * sales order shipment, future purchase receipt, transfer-out / + * transfer-in legs — funnels through [record]. The method writes a + * row to `inventory__stock_movement` AND updates (or creates) the + * matching `inventory__stock_balance` row in the SAME database + * transaction. The two cannot drift because the same `@Transactional` + * commit governs both writes. + * + * **Cross-PBC validation.** Like [StockBalanceService.adjust], the + * service injects [CatalogApi] and rejects unknown item codes + * up-front with a meaningful 400. The location is validated locally + * (intra-PBC). Both checks happen BEFORE the row is inserted, so a + * rejected request leaves no ledger trace. + * + * **Sign convention enforced per reason.** Each [MovementReason] has + * a documented sign — RECEIPT/PURCHASE_RECEIPT/TRANSFER_IN are + * non-negative, ISSUE/SALES_SHIPMENT/TRANSFER_OUT are non-positive, + * ADJUSTMENT can be either. A `RECEIPT` with `delta = -5` is a + * caller bug, not silently coerced. Catching it here means the + * audit log stays consistent: a row tagged "SALES_SHIPMENT" can + * never be a positive number. + * + * **Negative balance rejection.** A movement that would push the + * resulting balance below zero is rejected. This is the framework's + * "you cannot ship more than you have" rule. The CHECK constraint + * on `inventory__stock_balance.quantity >= 0` is the second wall; + * this is the first one with a better error message. + * + * **Why no event publication in v1.** A future chunk wires + * `StockMovementRecorded` events through the event bus so other + * PBCs (production, finance) can react. Until then, downstream + * consumers query the ledger directly. The omission is deliberate: + * adding the event in this chunk would entangle the ledger + * landing with the event-bus-as-cross-PBC-integration story, and + * each deserves its own focused chunk. + */ +@Service +@Transactional +class StockMovementService( + private val movements: StockMovementJpaRepository, + private val balances: StockBalanceJpaRepository, + private val locations: LocationJpaRepository, + private val catalogApi: CatalogApi, +) { + + @Transactional(readOnly = true) + fun list(): List = movements.findAll() + + @Transactional(readOnly = true) + fun findByItemCode(itemCode: String): List = + movements.findByItemCode(itemCode) + + @Transactional(readOnly = true) + fun findByLocationId(locationId: UUID): List = + movements.findByLocationId(locationId) + + @Transactional(readOnly = true) + fun findByReference(reference: String): List = + movements.findByReference(reference) + + /** + * Record a stock movement and update the matching balance, atomically. + * + * @return the resulting [StockBalance] row (the new quantity). + * @throws IllegalArgumentException with a meaningful message if + * the item is unknown, the location is unknown, the delta is + * zero, the delta sign disagrees with the reason, or the + * resulting balance would be negative. + */ + fun record(command: RecordMovementCommand): StockBalance { + require(command.delta.signum() != 0) { + "stock movement delta must be non-zero" + } + validateSign(command.reason, command.delta) + + // Cross-PBC validation: the item must exist in the catalog + // (and be active — CatalogApi hides inactive items). + catalogApi.findItemByCode(command.itemCode) + ?: throw IllegalArgumentException( + "item code '${command.itemCode}' is not in the catalog (or is inactive)", + ) + + // Local validation: the location must exist. + require(locations.existsById(command.locationId)) { + "location not found: ${command.locationId}" + } + + // Compute the new balance. SELECT-then-save is correct under + // the @Transactional boundary; the same single-instance + // single-tenant rationale applies as in StockBalanceService.adjust. + val existing = balances.findByItemCodeAndLocationId(command.itemCode, command.locationId) + val oldQuantity = existing?.quantity ?: BigDecimal.ZERO + val newQuantity = oldQuantity + command.delta + require(newQuantity.signum() >= 0) { + "stock movement would push balance for '${command.itemCode}' at " + + "location ${command.locationId} below zero (current=$oldQuantity, delta=${command.delta})" + } + + // Insert the ledger row first, then update the balance. Both + // happen in the same JPA transaction → either both commit or + // neither does. + movements.save( + StockMovement( + itemCode = command.itemCode, + locationId = command.locationId, + delta = command.delta, + reason = command.reason, + reference = command.reference, + occurredAt = command.occurredAt ?: Instant.now(), + ), + ) + + return if (existing == null) { + balances.save(StockBalance(command.itemCode, command.locationId, newQuantity)) + } else { + existing.quantity = newQuantity + existing + } + } + + /** + * Verify that [delta]'s sign matches the documented direction + * for [reason]. ADJUSTMENT is the one reason that allows either + * sign — operators correct mistakes in either direction. + * + * Throws [IllegalArgumentException] with a message that names + * the reason and the expected direction so the caller knows + * how to fix the request. + */ + private fun validateSign(reason: MovementReason, delta: BigDecimal) { + val sign = delta.signum() // -1, 0, or 1; zero is rejected above + val expected: String? = when (reason) { + MovementReason.RECEIPT, + MovementReason.PURCHASE_RECEIPT, + MovementReason.TRANSFER_IN -> if (sign < 0) "non-negative" else null + + MovementReason.ISSUE, + MovementReason.SALES_SHIPMENT, + MovementReason.TRANSFER_OUT -> if (sign > 0) "non-positive" else null + + MovementReason.ADJUSTMENT -> null // either sign allowed + } + if (expected != null) { + throw IllegalArgumentException( + "movement reason $reason requires a $expected delta (got $delta)", + ) + } + } +} + +/** + * Input shape for [StockMovementService.record]. Kept as a separate + * data class so the REST DTO and the cross-PBC adapter can both + * build it without sharing a controller-only request type. + * + * @property occurredAt when the physical stock actually moved. The + * service defaults to "now" when this is null. Back-dated entries + * (operator types in yesterday's count today) provide it explicitly; + * the audit columns still record when the row was inserted. + */ +data class RecordMovementCommand( + val itemCode: String, + val locationId: UUID, + val delta: BigDecimal, + val reason: MovementReason, + val reference: String? = null, + val occurredAt: Instant? = null, +) diff --git a/pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/domain/StockMovement.kt b/pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/domain/StockMovement.kt new file mode 100644 index 0000000..f8459fc --- /dev/null +++ b/pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/domain/StockMovement.kt @@ -0,0 +1,126 @@ +package org.vibeerp.pbc.inventory.domain + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.Table +import org.vibeerp.platform.persistence.audit.AuditedJpaEntity +import java.math.BigDecimal +import java.time.Instant +import java.util.UUID + +/** + * One row in the inventory ledger — an append-only record of a single + * change to a single (item × location) cell. + * + * **The ledger is the source of truth.** [StockBalance] is a + * materialised view: its `quantity` column equals the SUM of every + * `delta` in [StockMovement] for the same (item_code, location_id). + * The framework writes the movement row and the balance update in + * the SAME database transaction (see [StockMovementService.record]), + * so the two cannot drift. The ledger exists to answer the audit + * question "who moved this stock and when, and why?" — the balance + * exists to answer the operational question "how many do I have + * right now?" cheaply. + * + * **Append-only.** No update, no delete. A reversal is a NEW row + * with the negated delta and a reason of ADJUSTMENT (or, when the + * reversal corresponds to a real business event, the appropriate + * reason — RETURN-class reasons land later). The framework gives + * up nothing by enforcing append-only: the [AuditedJpaEntity]'s + * `created_by` / `created_at` columns capture who and when, and the + * reason + reference together capture WHY at the level of detail + * the integrating PBC can supply. + * + * **Why `delta` is signed instead of separate IN/OUT columns:** the + * arithmetic stays trivial (`SUM(delta)` is the balance), the type + * system rejects "negative receipt" / "positive issue" mistakes via + * the [MovementReason] enum's documented sign convention, and there + * is exactly one column to query when reporting on net activity. + * + * **Why `reference` is a free-form string** instead of a typed FK: + * the same row needs to point at a sales order code (`SO:SO-2026-0001`), + * a purchase receipt code (`PR:PR-2026-0042`), a transfer order + * (`TFR:TFR-2026-0007`), or nothing at all (manual adjustment). A + * varchar with a documented prefix convention is the only shape + * that fits all of those without forcing every PBC to declare its + * row in a polymorphic table. The prefix `:` is the + * convention; consumers parse it with `split(':')` if they care. + * + * **Why `occurred_at` is separate from `created_at`:** the audit + * column says "when did the system record this row"; `occurred_at` + * says "when did the physical stock move". The two are usually + * within milliseconds of each other, but a back-dated reconciliation + * (operator types in yesterday's count today) needs the distinction. + * Defaults to `created_at` when the caller doesn't provide it. + */ +@Entity +@Table(name = "inventory__stock_movement") +class StockMovement( + itemCode: String, + locationId: UUID, + delta: BigDecimal, + reason: MovementReason, + reference: String? = null, + occurredAt: Instant = Instant.now(), +) : AuditedJpaEntity() { + + @Column(name = "item_code", nullable = false, length = 64) + var itemCode: String = itemCode + + @Column(name = "location_id", nullable = false) + var locationId: UUID = locationId + + /** + * Signed quantity. Positive for receipts and transfers-in, + * negative for issues and shipments. Zero is rejected by the + * service — a no-op movement is meaningless and clutters the + * ledger. + */ + @Column(name = "delta", nullable = false, precision = 18, scale = 4) + var delta: BigDecimal = delta + + @Enumerated(EnumType.STRING) + @Column(name = "reason", nullable = false, length = 32) + var reason: MovementReason = reason + + @Column(name = "reference", nullable = true, length = 128) + var reference: String? = reference + + @Column(name = "occurred_at", nullable = false) + var occurredAt: Instant = occurredAt + + override fun toString(): String = + "StockMovement(id=$id, item='$itemCode', loc=$locationId, delta=$delta, reason=$reason, ref='$reference')" +} + +/** + * Why a stock movement happened. + * + * Each value carries a documented sign convention so application + * code does not have to remember "is a SALES_SHIPMENT positive or + * negative". The service validates that the sign of the delta + * matches the documented direction; calling code that gets this + * wrong gets a meaningful error instead of a corrupted balance. + * + * - **RECEIPT** — non-PO receipt (e.g. inventory found, customer return). Δ ≥ 0 + * - **ISSUE** — non-SO issue (e.g. write-off, internal use). Δ ≤ 0 + * - **ADJUSTMENT** — operator-driven correction (cycle count, mistake fix). Δ either sign + * - **SALES_SHIPMENT** — line item shipped on a sales order. Δ ≤ 0 + * - **PURCHASE_RECEIPT** — line item received against a purchase order. Δ ≥ 0 + * - **TRANSFER_OUT** — leg-1 of an inter-warehouse transfer. Δ ≤ 0 + * - **TRANSFER_IN** — leg-2 of an inter-warehouse transfer. Δ ≥ 0 + * + * Stored as string in the DB so adding values later is non-breaking + * for clients reading the column with raw SQL. + */ +enum class MovementReason { + RECEIPT, + ISSUE, + ADJUSTMENT, + SALES_SHIPMENT, + PURCHASE_RECEIPT, + TRANSFER_OUT, + TRANSFER_IN, +} diff --git a/pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/ext/InventoryApiAdapter.kt b/pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/ext/InventoryApiAdapter.kt index 448a277..c22ab33 100644 --- a/pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/ext/InventoryApiAdapter.kt +++ b/pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/ext/InventoryApiAdapter.kt @@ -5,6 +5,9 @@ import org.springframework.transaction.annotation.Transactional import org.vibeerp.api.v1.core.Id import org.vibeerp.api.v1.ext.inventory.InventoryApi import org.vibeerp.api.v1.ext.inventory.StockBalanceRef +import org.vibeerp.pbc.inventory.application.RecordMovementCommand +import org.vibeerp.pbc.inventory.application.StockMovementService +import org.vibeerp.pbc.inventory.domain.MovementReason import org.vibeerp.pbc.inventory.domain.StockBalance import org.vibeerp.pbc.inventory.infrastructure.LocationJpaRepository import org.vibeerp.pbc.inventory.infrastructure.StockBalanceJpaRepository @@ -42,6 +45,7 @@ import java.math.BigDecimal class InventoryApiAdapter( private val balances: StockBalanceJpaRepository, private val locations: LocationJpaRepository, + private val stockMovementService: StockMovementService, ) : InventoryApi { override fun findStockBalance(itemCode: String, locationCode: String): StockBalanceRef? { @@ -54,6 +58,50 @@ class InventoryApiAdapter( balances.findByItemCode(itemCode) .fold(BigDecimal.ZERO) { acc, row -> acc + row.quantity } + /** + * Record a stock movement on behalf of a cross-PBC caller. + * Resolves the location code → id, parses the reason string into + * the local enum, and delegates to the [StockMovementService]. + * + * The location lookup happens here (not in the service) because + * the service takes a UUID — that's the intra-PBC contract. The + * facade is the boundary that converts between external (codes) + * and internal (ids). + * + * Marked `@Transactional` (read-write, NOT readOnly) so the + * service's write happens inside this method's transaction. The + * class-level `readOnly = true` would otherwise turn the write + * into a no-op + flush failure. + */ + @Transactional + override fun recordMovement( + itemCode: String, + locationCode: String, + delta: BigDecimal, + reason: String, + reference: String?, + ): StockBalanceRef { + val location = locations.findByCode(locationCode) + ?: throw IllegalArgumentException("location code '$locationCode' is not in the inventory directory") + val movementReason = try { + MovementReason.valueOf(reason) + } catch (ex: IllegalArgumentException) { + throw IllegalArgumentException( + "unknown movement reason '$reason' (expected one of ${MovementReason.values().joinToString { it.name }})", + ) + } + val balance = stockMovementService.record( + RecordMovementCommand( + itemCode = itemCode, + locationId = location.id, + delta = delta, + reason = movementReason, + reference = reference, + ), + ) + return balance.toRef(locationCode) + } + private fun StockBalance.toRef(locationCode: String): StockBalanceRef = StockBalanceRef( id = Id(this.id), itemCode = this.itemCode, diff --git a/pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/http/StockMovementController.kt b/pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/http/StockMovementController.kt new file mode 100644 index 0000000..a8cf0a9 --- /dev/null +++ b/pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/http/StockMovementController.kt @@ -0,0 +1,139 @@ +package org.vibeerp.pbc.inventory.http + +import jakarta.validation.Valid +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Size +import org.springframework.http.HttpStatus +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController +import org.vibeerp.pbc.inventory.application.RecordMovementCommand +import org.vibeerp.pbc.inventory.application.StockMovementService +import org.vibeerp.pbc.inventory.domain.MovementReason +import org.vibeerp.pbc.inventory.domain.StockMovement +import org.vibeerp.platform.security.authz.RequirePermission +import java.math.BigDecimal +import java.time.Instant +import java.util.UUID + +/** + * REST API for the inventory ledger. + * + * Mounted at `/api/v1/inventory/movements`. + * + * **GET** is plain authenticated — reading historical movements is + * a normal operator action. Filters by itemCode, locationId, or + * reference (so the SPA can render "all movements caused by sales + * order SO-2026-0001"). + * + * **POST** requires `inventory.stock.adjust` (the same permission + * the absolute-quantity adjust endpoint uses). The two endpoints + * exist side-by-side: the balance endpoint is for "the shelf has + * 47", the movement endpoint is for "we received 3 more". Both + * land as ledger rows; the framework treats them identically below + * the controller layer. + */ +@RestController +@RequestMapping("/api/v1/inventory/movements") +class StockMovementController( + private val stockMovementService: StockMovementService, +) { + + @GetMapping + fun list( + @RequestParam(required = false) itemCode: String?, + @RequestParam(required = false) locationId: UUID?, + @RequestParam(required = false) reference: String?, + ): List { + val rows = when { + reference != null -> stockMovementService.findByReference(reference) + itemCode != null && locationId != null -> stockMovementService.findByItemCode(itemCode) + .filter { it.locationId == locationId } + itemCode != null -> stockMovementService.findByItemCode(itemCode) + locationId != null -> stockMovementService.findByLocationId(locationId) + else -> stockMovementService.list() + } + return rows.map { it.toResponse() } + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + @RequirePermission("inventory.stock.adjust") + fun record(@RequestBody @Valid request: RecordMovementRequest): StockMovementResponse { + val balance = stockMovementService.record( + RecordMovementCommand( + itemCode = request.itemCode, + locationId = request.locationId, + delta = request.delta, + reason = request.reason, + reference = request.reference, + occurredAt = request.occurredAt, + ), + ) + // The movement row is the response, not the balance — callers + // who want the resulting balance hit the balances endpoint. + // We need to fetch the freshly-inserted movement; for the + // happy path the latest movement for (item, location) IS this + // one, but ordering by occurred_at desc would be more honest. + // v1 returns a synthesised response from the request fields + // because the request itself fully describes the row that + // was inserted (apart from id/audit cols, which are not + // useful in a creation response). + return StockMovementResponse( + id = UUID(0L, 0L), // sentinel — see comment above + itemCode = request.itemCode, + locationId = request.locationId, + delta = request.delta, + reason = request.reason, + reference = request.reference, + occurredAt = request.occurredAt ?: Instant.now(), + resultingQuantity = balance.quantity, + ) + } +} + +// ─── DTOs ──────────────────────────────────────────────────────────── + +data class RecordMovementRequest( + @field:NotBlank @field:Size(max = 64) val itemCode: String, + @field:NotNull val locationId: UUID, + @field:NotNull val delta: BigDecimal, + @field:NotNull val reason: MovementReason, + @field:Size(max = 128) val reference: String? = null, + val occurredAt: Instant? = null, +) + +data class StockMovementResponse( + val id: UUID, + val itemCode: String, + val locationId: UUID, + val delta: BigDecimal, + val reason: MovementReason, + val reference: String?, + val occurredAt: Instant, + /** + * The balance for (itemCode, locationId) AFTER this movement was + * applied. Included on the create response so callers don't need + * a follow-up GET to the balances endpoint. + */ + val resultingQuantity: BigDecimal, +) + +private fun StockMovement.toResponse() = StockMovementResponse( + id = this.id, + itemCode = this.itemCode, + locationId = this.locationId, + delta = this.delta, + reason = this.reason, + reference = this.reference, + occurredAt = this.occurredAt, + // The list endpoint doesn't compute the at-the-time balance — + // that would require a SUM scan. Set to ZERO and let the SPA + // compute it from the sequence if it cares. + resultingQuantity = BigDecimal.ZERO, +) diff --git a/pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/infrastructure/StockMovementJpaRepository.kt b/pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/infrastructure/StockMovementJpaRepository.kt new file mode 100644 index 0000000..a4438ac --- /dev/null +++ b/pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/infrastructure/StockMovementJpaRepository.kt @@ -0,0 +1,32 @@ +package org.vibeerp.pbc.inventory.infrastructure + +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository +import org.vibeerp.pbc.inventory.domain.StockMovement +import java.util.UUID + +/** + * Spring Data JPA repository for [StockMovement]. + * + * Append-only ledger access. The repository deliberately does NOT + * expose `delete` (although JpaRepository inherits one) — the + * service layer never calls it, and the database table will grow + * a row-level CHECK constraint in a future hardening pass to + * prevent accidental deletes via raw SQL too. + * + * The query methods cover the common ledger reads: by item, by + * location, by reference (e.g. "all movements caused by sales + * order SO-2026-0001"), and the (item, location) cell view that + * the SPA's stock-detail screen wants. + */ +@Repository +interface StockMovementJpaRepository : JpaRepository { + + fun findByItemCode(itemCode: String): List + + fun findByLocationId(locationId: UUID): List + + fun findByItemCodeAndLocationId(itemCode: String, locationId: UUID): List + + fun findByReference(reference: String): List +} diff --git a/pbc/pbc-inventory/src/test/kotlin/org/vibeerp/pbc/inventory/application/StockBalanceServiceTest.kt b/pbc/pbc-inventory/src/test/kotlin/org/vibeerp/pbc/inventory/application/StockBalanceServiceTest.kt index a845a74..f78edab 100644 --- a/pbc/pbc-inventory/src/test/kotlin/org/vibeerp/pbc/inventory/application/StockBalanceServiceTest.kt +++ b/pbc/pbc-inventory/src/test/kotlin/org/vibeerp/pbc/inventory/application/StockBalanceServiceTest.kt @@ -14,17 +14,30 @@ import org.junit.jupiter.api.Test import org.vibeerp.api.v1.core.Id import org.vibeerp.api.v1.ext.catalog.CatalogApi import org.vibeerp.api.v1.ext.catalog.ItemRef +import org.vibeerp.pbc.inventory.domain.MovementReason import org.vibeerp.pbc.inventory.domain.StockBalance import org.vibeerp.pbc.inventory.infrastructure.LocationJpaRepository import org.vibeerp.pbc.inventory.infrastructure.StockBalanceJpaRepository import java.math.BigDecimal import java.util.UUID +/** + * Tests the post-ledger refactor behavior of StockBalanceService: + * adjust() delegates to StockMovementService.record() with a + * computed delta, and the no-op adjustment short-circuit path. + * + * The cross-PBC item validation, location validation, and + * negative-balance rejection now live in StockMovementService and + * are exercised by [StockMovementServiceTest]. This test suite + * deliberately mocks StockMovementService so the unit boundary is + * "what does adjust() do given the movement service". + */ class StockBalanceServiceTest { private lateinit var balances: StockBalanceJpaRepository private lateinit var locations: LocationJpaRepository private lateinit var catalogApi: CatalogApi + private lateinit var stockMovementService: StockMovementService private lateinit var service: StockBalanceService @BeforeEach @@ -32,7 +45,8 @@ class StockBalanceServiceTest { balances = mockk() locations = mockk() catalogApi = mockk() - service = StockBalanceService(balances, locations, catalogApi) + stockMovementService = mockk() + service = StockBalanceService(balances, locations, catalogApi, stockMovementService) } private fun stubItem(code: String) { @@ -47,71 +61,84 @@ class StockBalanceServiceTest { } @Test - fun `adjust rejects unknown item code via CatalogApi seam`() { - every { catalogApi.findItemByCode("FAKE") } returns null - + fun `adjust rejects negative quantity up front`() { assertFailure { - service.adjust("FAKE", UUID.randomUUID(), BigDecimal("10")) + service.adjust("SKU-1", UUID.randomUUID(), BigDecimal("-1")) } .isInstanceOf(IllegalArgumentException::class) - .hasMessage("item code 'FAKE' is not in the catalog (or is inactive)") + .hasMessage("stock quantity must be non-negative (got -1)") + verify(exactly = 0) { stockMovementService.record(any()) } } @Test - fun `adjust rejects unknown location id`() { - stubItem("SKU-1") + fun `adjust delegates to record with the computed delta when increasing`() { val locId = UUID.randomUUID() - every { locations.existsById(locId) } returns false + val existing = StockBalance("SKU-1", locId, BigDecimal("10")).also { it.id = UUID.randomUUID() } + val expectedAfter = StockBalance("SKU-1", locId, BigDecimal("25")).also { it.id = existing.id } + every { balances.findByItemCodeAndLocationId("SKU-1", locId) } returns existing + val captured = slot() + every { stockMovementService.record(capture(captured)) } returns expectedAfter - assertFailure { - service.adjust("SKU-1", locId, BigDecimal("10")) - } - .isInstanceOf(IllegalArgumentException::class) - .hasMessage("location not found: $locId") + val result = service.adjust("SKU-1", locId, BigDecimal("25")) + + assertThat(captured.captured.itemCode).isEqualTo("SKU-1") + assertThat(captured.captured.locationId).isEqualTo(locId) + // 25 - 10 = +15 + assertThat(captured.captured.delta).isEqualTo(BigDecimal("15")) + assertThat(captured.captured.reason).isEqualTo(MovementReason.ADJUSTMENT) + assertThat(result.quantity).isEqualTo(BigDecimal("25")) } @Test - fun `adjust rejects negative quantity before catalog lookup`() { - // Negative quantity is rejected by an early require() so the - // CatalogApi mock is never invoked. - assertFailure { - service.adjust("SKU-1", UUID.randomUUID(), BigDecimal("-1")) - } - .isInstanceOf(IllegalArgumentException::class) - verify(exactly = 0) { catalogApi.findItemByCode(any()) } + fun `adjust delegates with a negative delta when decreasing`() { + val locId = UUID.randomUUID() + val existing = StockBalance("SKU-1", locId, BigDecimal("100")).also { it.id = UUID.randomUUID() } + val expectedAfter = StockBalance("SKU-1", locId, BigDecimal("30")).also { it.id = existing.id } + every { balances.findByItemCodeAndLocationId("SKU-1", locId) } returns existing + val captured = slot() + every { stockMovementService.record(capture(captured)) } returns expectedAfter + + service.adjust("SKU-1", locId, BigDecimal("30")) + + // 30 - 100 = -70 + assertThat(captured.captured.delta).isEqualTo(BigDecimal("-70")) + assertThat(captured.captured.reason).isEqualTo(MovementReason.ADJUSTMENT) } @Test - fun `adjust creates a new balance row when none exists`() { - stubItem("SKU-1") + fun `adjust to the SAME quantity is a no-op (no ledger row)`() { val locId = UUID.randomUUID() - val saved = slot() + val existing = StockBalance("SKU-1", locId, BigDecimal("42")).also { it.id = UUID.randomUUID() } + stubItem("SKU-1") + every { balances.findByItemCodeAndLocationId("SKU-1", locId) } returns existing every { locations.existsById(locId) } returns true - every { balances.findByItemCodeAndLocationId("SKU-1", locId) } returns null - every { balances.save(capture(saved)) } answers { saved.captured } - val result = service.adjust("SKU-1", locId, BigDecimal("42.5")) + val result = service.adjust("SKU-1", locId, BigDecimal("42")) - assertThat(result.itemCode).isEqualTo("SKU-1") - assertThat(result.locationId).isEqualTo(locId) - assertThat(result.quantity).isEqualTo(BigDecimal("42.5")) - verify(exactly = 1) { balances.save(any()) } + assertThat(result).isEqualTo(existing) + // Crucially: NO movement is recorded for a no-op adjustment. + // The audit log shouldn't fill up with "adjusted to current + // value" entries when an operator clicks Save without changing + // anything. + verify(exactly = 0) { stockMovementService.record(any()) } } @Test - fun `adjust mutates the existing balance row when one already exists`() { - stubItem("SKU-1") + fun `no-op adjust on a missing balance row creates an empty row at zero`() { + // Edge case: setting a never-stocked cell to its current value + // (zero) shouldn't create a ledger row but does create the + // (item, location) row at quantity zero so subsequent reads + // return zero rather than null. val locId = UUID.randomUUID() - val existing = StockBalance("SKU-1", locId, BigDecimal("5")).also { it.id = UUID.randomUUID() } + stubItem("SKU-1") + every { balances.findByItemCodeAndLocationId("SKU-1", locId) } returns null every { locations.existsById(locId) } returns true - every { balances.findByItemCodeAndLocationId("SKU-1", locId) } returns existing + val saved = slot() + every { balances.save(capture(saved)) } answers { saved.captured } - val result = service.adjust("SKU-1", locId, BigDecimal("99")) + val result = service.adjust("SKU-1", locId, BigDecimal("0")) - assertThat(result).isEqualTo(existing) - assertThat(existing.quantity).isEqualTo(BigDecimal("99")) - // Crucially, save() is NOT called — the @Transactional method - // commit will flush the JPA-managed entity automatically. - verify(exactly = 0) { balances.save(any()) } + assertThat(result.quantity).isEqualTo(BigDecimal("0")) + verify(exactly = 0) { stockMovementService.record(any()) } } } diff --git a/pbc/pbc-inventory/src/test/kotlin/org/vibeerp/pbc/inventory/application/StockMovementServiceTest.kt b/pbc/pbc-inventory/src/test/kotlin/org/vibeerp/pbc/inventory/application/StockMovementServiceTest.kt new file mode 100644 index 0000000..5566730 --- /dev/null +++ b/pbc/pbc-inventory/src/test/kotlin/org/vibeerp/pbc/inventory/application/StockMovementServiceTest.kt @@ -0,0 +1,243 @@ +package org.vibeerp.pbc.inventory.application + +import assertk.assertFailure +import assertk.assertThat +import assertk.assertions.contains +import assertk.assertions.hasMessage +import assertk.assertions.isEqualTo +import assertk.assertions.isInstanceOf +import assertk.assertions.messageContains +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.vibeerp.api.v1.core.Id +import org.vibeerp.api.v1.ext.catalog.CatalogApi +import org.vibeerp.api.v1.ext.catalog.ItemRef +import org.vibeerp.pbc.inventory.domain.MovementReason +import org.vibeerp.pbc.inventory.domain.StockBalance +import org.vibeerp.pbc.inventory.domain.StockMovement +import org.vibeerp.pbc.inventory.infrastructure.LocationJpaRepository +import org.vibeerp.pbc.inventory.infrastructure.StockBalanceJpaRepository +import org.vibeerp.pbc.inventory.infrastructure.StockMovementJpaRepository +import java.math.BigDecimal +import java.util.UUID + +/** + * Tests for [StockMovementService]: cross-PBC item validation, + * location validation, sign-vs-reason enforcement, negative-balance + * rejection, and the ledger-row + balance-row pair. + */ +class StockMovementServiceTest { + + private lateinit var movements: StockMovementJpaRepository + private lateinit var balances: StockBalanceJpaRepository + private lateinit var locations: LocationJpaRepository + private lateinit var catalogApi: CatalogApi + private lateinit var service: StockMovementService + + @BeforeEach + fun setUp() { + movements = mockk() + balances = mockk() + locations = mockk() + catalogApi = mockk() + every { movements.save(any()) } answers { firstArg() } + every { balances.save(any()) } answers { firstArg() } + service = StockMovementService(movements, balances, locations, catalogApi) + } + + private fun stubItem(code: String) { + every { catalogApi.findItemByCode(code) } returns ItemRef( + id = Id(UUID.randomUUID()), + code = code, + name = "stub", + itemType = "GOOD", + baseUomCode = "ea", + active = true, + ) + } + + private fun cmd( + delta: String, + reason: MovementReason, + item: String = "SKU-1", + loc: UUID = UUID.randomUUID(), + ref: String? = null, + ) = RecordMovementCommand( + itemCode = item, + locationId = loc, + delta = BigDecimal(delta), + reason = reason, + reference = ref, + ) + + @Test + fun `record rejects zero delta up front`() { + assertFailure { service.record(cmd("0", MovementReason.ADJUSTMENT)) } + .isInstanceOf(IllegalArgumentException::class) + .hasMessage("stock movement delta must be non-zero") + } + + @Test + fun `record rejects positive delta on SALES_SHIPMENT`() { + assertFailure { service.record(cmd("5", MovementReason.SALES_SHIPMENT)) } + .isInstanceOf(IllegalArgumentException::class) + .messageContains("non-positive") + } + + @Test + fun `record rejects negative delta on RECEIPT`() { + assertFailure { service.record(cmd("-5", MovementReason.RECEIPT)) } + .isInstanceOf(IllegalArgumentException::class) + .messageContains("non-negative") + } + + @Test + fun `record allows either sign on ADJUSTMENT`() { + val locId = UUID.randomUUID() + stubItem("SKU-1") + every { locations.existsById(locId) } returns true + every { balances.findByItemCodeAndLocationId("SKU-1", locId) } returns + StockBalance("SKU-1", locId, BigDecimal("10")) + + // Negative ADJUSTMENT (operator correcting a count down) + service.record( + RecordMovementCommand( + itemCode = "SKU-1", + locationId = locId, + delta = BigDecimal("-3"), + reason = MovementReason.ADJUSTMENT, + ), + ) + + // Positive ADJUSTMENT (operator correcting a count up) + service.record( + RecordMovementCommand( + itemCode = "SKU-1", + locationId = locId, + delta = BigDecimal("8"), + reason = MovementReason.ADJUSTMENT, + ), + ) + + // Both succeed. + } + + @Test + fun `record rejects unknown item via CatalogApi seam`() { + every { catalogApi.findItemByCode("FAKE") } returns null + + assertFailure { + service.record( + RecordMovementCommand( + itemCode = "FAKE", + locationId = UUID.randomUUID(), + delta = BigDecimal("5"), + reason = MovementReason.RECEIPT, + ), + ) + } + .isInstanceOf(IllegalArgumentException::class) + .messageContains("not in the catalog") + } + + @Test + fun `record rejects unknown location`() { + stubItem("SKU-1") + val locId = UUID.randomUUID() + every { locations.existsById(locId) } returns false + + assertFailure { + service.record( + RecordMovementCommand( + itemCode = "SKU-1", + locationId = locId, + delta = BigDecimal("5"), + reason = MovementReason.RECEIPT, + ), + ) + } + .isInstanceOf(IllegalArgumentException::class) + .messageContains("location not found") + } + + @Test + fun `record rejects movement that would push balance negative`() { + stubItem("SKU-1") + val locId = UUID.randomUUID() + every { locations.existsById(locId) } returns true + every { balances.findByItemCodeAndLocationId("SKU-1", locId) } returns + StockBalance("SKU-1", locId, BigDecimal("3")) + + assertFailure { + service.record( + RecordMovementCommand( + itemCode = "SKU-1", + locationId = locId, + delta = BigDecimal("-5"), + reason = MovementReason.SALES_SHIPMENT, + ), + ) + } + .isInstanceOf(IllegalArgumentException::class) + .messageContains("below zero") + } + + @Test + fun `record creates a new balance row when none exists`() { + stubItem("SKU-1") + val locId = UUID.randomUUID() + every { locations.existsById(locId) } returns true + every { balances.findByItemCodeAndLocationId("SKU-1", locId) } returns null + val savedBalance = slot() + val savedMovement = slot() + every { balances.save(capture(savedBalance)) } answers { savedBalance.captured } + every { movements.save(capture(savedMovement)) } answers { savedMovement.captured } + + val result = service.record( + RecordMovementCommand( + itemCode = "SKU-1", + locationId = locId, + delta = BigDecimal("100"), + reason = MovementReason.RECEIPT, + reference = "PR:PR-2026-0001", + ), + ) + + assertThat(result.quantity).isEqualTo(BigDecimal("100")) + assertThat(savedMovement.captured.delta).isEqualTo(BigDecimal("100")) + assertThat(savedMovement.captured.reason).isEqualTo(MovementReason.RECEIPT) + assertThat(savedMovement.captured.reference).isEqualTo("PR:PR-2026-0001") + } + + @Test + fun `record updates existing balance and inserts movement on the happy path`() { + stubItem("SKU-1") + val locId = UUID.randomUUID() + val existing = StockBalance("SKU-1", locId, BigDecimal("50")).also { it.id = UUID.randomUUID() } + every { locations.existsById(locId) } returns true + every { balances.findByItemCodeAndLocationId("SKU-1", locId) } returns existing + val savedMovement = slot() + every { movements.save(capture(savedMovement)) } answers { savedMovement.captured } + + val result = service.record( + RecordMovementCommand( + itemCode = "SKU-1", + locationId = locId, + delta = BigDecimal("-15"), + reason = MovementReason.SALES_SHIPMENT, + reference = "SO:SO-2026-0001", + ), + ) + + assertThat(result).isEqualTo(existing) + // 50 - 15 = 35 + assertThat(existing.quantity).isEqualTo(BigDecimal("35")) + assertThat(savedMovement.captured.delta).isEqualTo(BigDecimal("-15")) + assertThat(savedMovement.captured.reference).isEqualTo("SO:SO-2026-0001") + verify(exactly = 0) { balances.save(any()) } // existing row mutated, not re-saved + } +} diff --git a/pbc/pbc-orders-sales/src/main/kotlin/org/vibeerp/pbc/orders/sales/application/SalesOrderService.kt b/pbc/pbc-orders-sales/src/main/kotlin/org/vibeerp/pbc/orders/sales/application/SalesOrderService.kt index 376fa0e..73ad3d6 100644 --- a/pbc/pbc-orders-sales/src/main/kotlin/org/vibeerp/pbc/orders/sales/application/SalesOrderService.kt +++ b/pbc/pbc-orders-sales/src/main/kotlin/org/vibeerp/pbc/orders/sales/application/SalesOrderService.kt @@ -5,6 +5,7 @@ import com.fasterxml.jackson.module.kotlin.registerKotlinModule import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import org.vibeerp.api.v1.ext.catalog.CatalogApi +import org.vibeerp.api.v1.ext.inventory.InventoryApi import org.vibeerp.api.v1.ext.partners.PartnersApi import org.vibeerp.pbc.orders.sales.domain.SalesOrder import org.vibeerp.pbc.orders.sales.domain.SalesOrderLine @@ -68,6 +69,7 @@ class SalesOrderService( private val orders: SalesOrderJpaRepository, private val partnersApi: PartnersApi, private val catalogApi: CatalogApi, + private val inventoryApi: InventoryApi, private val extValidator: ExtJsonValidator, ) { @@ -240,10 +242,73 @@ class SalesOrderService( require(order.status != SalesOrderStatus.CANCELLED) { "sales order ${order.code} is already cancelled" } + // Shipping is terminal — once stock has moved out the door + // the cancellation flow is "issue a refund / receive a return", + // not a status flip. The framework refuses the shortcut. + require(order.status != SalesOrderStatus.SHIPPED) { + "cannot cancel sales order ${order.code} in status SHIPPED; " + + "issue a return / refund flow instead" + } order.status = SalesOrderStatus.CANCELLED return order } + /** + * Mark a CONFIRMED sales order as SHIPPED, debiting stock from + * [shippingLocationCode] for every line in the same transaction. + * + * **The first cross-PBC WRITE the framework performs.** All + * earlier cross-PBC calls were reads (`CatalogApi.findItemByCode`, + * `PartnersApi.findPartnerByCode`). Shipping inverts that: this + * service synchronously writes to inventory's tables (via the + * api.v1 facade) as a side effect of changing its own state. + * The whole operation runs in ONE transaction so a failure on any + * line — bad item, bad location, would push balance negative — + * rolls back the order status change AND every other line's + * movement that may have already been written. The customer + * cannot end up with "5 of 7 lines shipped, status still + * CONFIRMED, ledger half-written". + * + * **Stock checks happen at the inventory layer.** This service + * doesn't pre-check "do we have enough stock for every line"; + * it just calls `inventoryApi.recordMovement(... -line.quantity)` + * and lets that fail with "would push balance below zero" on the + * first line that doesn't fit. The pre-check would be a nice + * UX improvement (show all problems at once instead of the + * first one) but is functionally equivalent — the transaction + * either commits in full or rolls back in full. + * + * @throws IllegalArgumentException if the order is not CONFIRMED, + * if the location code is unknown, or if any line lacks stock. + */ + fun ship(id: UUID, shippingLocationCode: String): SalesOrder { + val order = orders.findById(id).orElseThrow { + NoSuchElementException("sales order not found: $id") + } + require(order.status == SalesOrderStatus.CONFIRMED) { + "cannot ship sales order ${order.code} in status ${order.status}; " + + "only CONFIRMED orders can be shipped" + } + + // Walk every line and debit stock. The reference convention + // `SO:` is documented on InventoryApi.recordMovement; + // future PBCs (production, finance) will parse it via + // `split(':')` to find every movement caused by an order. + val reference = "SO:${order.code}" + for (line in order.lines) { + inventoryApi.recordMovement( + itemCode = line.itemCode, + locationCode = shippingLocationCode, + delta = line.quantity.negate(), + reason = "SALES_SHIPMENT", + reference = reference, + ) + } + + order.status = SalesOrderStatus.SHIPPED + return order + } + @Suppress("UNCHECKED_CAST") fun parseExt(order: SalesOrder): Map = try { if (order.ext.isBlank()) emptyMap() diff --git a/pbc/pbc-orders-sales/src/main/kotlin/org/vibeerp/pbc/orders/sales/domain/SalesOrder.kt b/pbc/pbc-orders-sales/src/main/kotlin/org/vibeerp/pbc/orders/sales/domain/SalesOrder.kt index bc15a79..005078c 100644 --- a/pbc/pbc-orders-sales/src/main/kotlin/org/vibeerp/pbc/orders/sales/domain/SalesOrder.kt +++ b/pbc/pbc-orders-sales/src/main/kotlin/org/vibeerp/pbc/orders/sales/domain/SalesOrder.kt @@ -125,16 +125,23 @@ class SalesOrder( * Possible states a [SalesOrder] can be in. * * Stored as a string in the DB so adding values later is non-breaking - * for clients reading the column with raw SQL. Future expansion - * (PARTIALLY_SHIPPED, SHIPPED, INVOICED, CLOSED) requires the - * movement ledger and shipping flow which are deferred from v1. + * for clients reading the column with raw SQL. * * - **DRAFT** — being prepared, lines may still be edited - * - **CONFIRMED** — committed; lines are now immutable - * - **CANCELLED** — terminal; the order is dead + * - **CONFIRMED** — committed; lines are now immutable. Can be cancelled + * or shipped from here. + * - **SHIPPED** — terminal. Stock has been debited via the + * inventory movement ledger; the framework will + * NOT let you cancel a shipped order (a return / + * refund flow lands later as its own state). + * - **CANCELLED** — terminal. Reachable from DRAFT or CONFIRMED. + * + * Future states (PARTIALLY_SHIPPED, INVOICED, CLOSED, RETURNED) + * land in their own focused chunks. */ enum class SalesOrderStatus { DRAFT, CONFIRMED, + SHIPPED, CANCELLED, } diff --git a/pbc/pbc-orders-sales/src/main/kotlin/org/vibeerp/pbc/orders/sales/http/SalesOrderController.kt b/pbc/pbc-orders-sales/src/main/kotlin/org/vibeerp/pbc/orders/sales/http/SalesOrderController.kt index b159f8d..9df9669 100644 --- a/pbc/pbc-orders-sales/src/main/kotlin/org/vibeerp/pbc/orders/sales/http/SalesOrderController.kt +++ b/pbc/pbc-orders-sales/src/main/kotlin/org/vibeerp/pbc/orders/sales/http/SalesOrderController.kt @@ -1,5 +1,7 @@ package org.vibeerp.pbc.orders.sales.http +import com.fasterxml.jackson.annotation.JsonCreator +import com.fasterxml.jackson.annotation.JsonProperty import jakarta.validation.Valid import jakarta.validation.constraints.NotBlank import jakarta.validation.constraints.NotEmpty @@ -106,6 +108,20 @@ class SalesOrderController( @RequirePermission("orders.sales.cancel") fun cancel(@PathVariable id: UUID): SalesOrderResponse = salesOrderService.cancel(id).toResponse(salesOrderService) + + /** + * Ship a CONFIRMED sales order. Atomically debits stock for every + * line from the location named in the request, then flips the + * order to SHIPPED. The whole operation runs in one transaction + * — see [SalesOrderService.ship] for the full rationale. + */ + @PostMapping("/{id}/ship") + @RequirePermission("orders.sales.ship") + fun ship( + @PathVariable id: UUID, + @RequestBody @Valid request: ShipSalesOrderRequest, + ): SalesOrderResponse = + salesOrderService.ship(id, request.shippingLocationCode).toResponse(salesOrderService) } // ─── DTOs ──────────────────────────────────────────────────────────── @@ -119,6 +135,34 @@ data class CreateSalesOrderRequest( val ext: Map? = null, ) +/** + * Shipping request body. + * + * **Why the explicit `@JsonCreator(mode = PROPERTIES)` on a single- + * arg Kotlin data class:** jackson-module-kotlin treats a data class + * with one argument as a *delegate-based* creator by default + * (`{"foo": "bar"}` would be unwrapped as the value of `foo` and + * passed to the constructor positionally). That's wrong for HTTP + * request bodies that always look like `{"shippingLocationCode": "..."}`. + * The fix is to explicitly mark the constructor as a property-based + * creator and to annotate the parameter with `@param:JsonProperty`. + * This is the same trap that bit `RefreshRequest` in pbc-identity; + * it's a Kotlin × Jackson interop wart, NOT something the framework + * can hide. + */ +data class ShipSalesOrderRequest @JsonCreator(mode = JsonCreator.Mode.PROPERTIES) constructor( + /** + * The inventory location to debit. Must exist in + * `inventory__location` (looked up by code via the `InventoryApi` + * facade inside the service). The framework does NOT default + * this — every shipment is explicit about where the goods came + * from, so the audit trail in the stock movement ledger always + * tells you "which warehouse shipped this". + */ + @param:JsonProperty("shippingLocationCode") + @field:NotBlank @field:Size(max = 64) val shippingLocationCode: String, +) + data class UpdateSalesOrderRequest( @field:Size(max = 64) val partnerCode: String? = null, val orderDate: LocalDate? = null, diff --git a/pbc/pbc-orders-sales/src/main/resources/META-INF/vibe-erp/metadata/orders-sales.yml b/pbc/pbc-orders-sales/src/main/resources/META-INF/vibe-erp/metadata/orders-sales.yml index 2c5711c..672afb6 100644 --- a/pbc/pbc-orders-sales/src/main/resources/META-INF/vibe-erp/metadata/orders-sales.yml +++ b/pbc/pbc-orders-sales/src/main/resources/META-INF/vibe-erp/metadata/orders-sales.yml @@ -23,7 +23,9 @@ permissions: - key: orders.sales.confirm description: Confirm a draft sales order (DRAFT → CONFIRMED) - key: orders.sales.cancel - description: Cancel a sales order (any non-cancelled state → CANCELLED) + description: Cancel a sales order (DRAFT or CONFIRMED → CANCELLED) + - key: orders.sales.ship + description: Ship a confirmed sales order (CONFIRMED → SHIPPED, debits inventory atomically) menus: - path: /orders/sales diff --git a/pbc/pbc-orders-sales/src/test/kotlin/org/vibeerp/pbc/orders/sales/application/SalesOrderServiceTest.kt b/pbc/pbc-orders-sales/src/test/kotlin/org/vibeerp/pbc/orders/sales/application/SalesOrderServiceTest.kt index 0b7015b..44f74a2 100644 --- a/pbc/pbc-orders-sales/src/test/kotlin/org/vibeerp/pbc/orders/sales/application/SalesOrderServiceTest.kt +++ b/pbc/pbc-orders-sales/src/test/kotlin/org/vibeerp/pbc/orders/sales/application/SalesOrderServiceTest.kt @@ -11,11 +11,14 @@ import assertk.assertions.messageContains import io.mockk.every import io.mockk.mockk import io.mockk.slot +import io.mockk.verify import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.vibeerp.api.v1.core.Id import org.vibeerp.api.v1.ext.catalog.CatalogApi import org.vibeerp.api.v1.ext.catalog.ItemRef +import org.vibeerp.api.v1.ext.inventory.InventoryApi +import org.vibeerp.api.v1.ext.inventory.StockBalanceRef import org.vibeerp.api.v1.ext.partners.PartnerRef import org.vibeerp.api.v1.ext.partners.PartnersApi import org.vibeerp.pbc.orders.sales.domain.SalesOrder @@ -32,6 +35,7 @@ class SalesOrderServiceTest { private lateinit var orders: SalesOrderJpaRepository private lateinit var partnersApi: PartnersApi private lateinit var catalogApi: CatalogApi + private lateinit var inventoryApi: InventoryApi private lateinit var extValidator: ExtJsonValidator private lateinit var service: SalesOrderService @@ -40,11 +44,12 @@ class SalesOrderServiceTest { orders = mockk() partnersApi = mockk() catalogApi = mockk() + inventoryApi = mockk() extValidator = mockk() every { extValidator.validate(any(), any()) } answers { secondArg() ?: emptyMap() } every { orders.existsByCode(any()) } returns false every { orders.save(any()) } answers { firstArg() } - service = SalesOrderService(orders, partnersApi, catalogApi, extValidator) + service = SalesOrderService(orders, partnersApi, catalogApi, inventoryApi, extValidator) } private fun stubCustomer(code: String, type: String = "CUSTOMER") { @@ -298,4 +303,114 @@ class SalesOrderServiceTest { .isInstanceOf(IllegalArgumentException::class) .messageContains("already cancelled") } + + // ─── ship() ────────────────────────────────────────────────────── + + private fun confirmedOrder( + id: UUID = UUID.randomUUID(), + lines: List> = listOf("PAPER-A4" to "10"), + ): SalesOrder { + val order = SalesOrder( + code = "SO-1", + partnerCode = "CUST-1", + status = SalesOrderStatus.CONFIRMED, + orderDate = LocalDate.of(2026, 4, 8), + currencyCode = "USD", + ).also { it.id = id } + var n = 1 + for ((item, qty) in lines) { + order.lines += org.vibeerp.pbc.orders.sales.domain.SalesOrderLine( + salesOrder = order, + lineNo = n++, + itemCode = item, + quantity = BigDecimal(qty), + unitPrice = BigDecimal("1.00"), + currencyCode = "USD", + ) + } + return order + } + + private fun stubInventoryDebit(itemCode: String, locationCode: String, expectedDelta: BigDecimal) { + every { + inventoryApi.recordMovement( + itemCode = itemCode, + locationCode = locationCode, + delta = expectedDelta, + reason = "SALES_SHIPMENT", + reference = any(), + ) + } returns StockBalanceRef( + id = Id(UUID.randomUUID()), + itemCode = itemCode, + locationCode = locationCode, + quantity = BigDecimal("1000"), + ) + } + + @Test + fun `ship rejects a non-CONFIRMED order`() { + val id = UUID.randomUUID() + val draft = SalesOrder( + code = "SO-1", + partnerCode = "CUST-1", + status = SalesOrderStatus.DRAFT, + orderDate = LocalDate.of(2026, 4, 8), + currencyCode = "USD", + ).also { it.id = id } + every { orders.findById(id) } returns Optional.of(draft) + + assertFailure { service.ship(id, "WH-MAIN") } + .isInstanceOf(IllegalArgumentException::class) + .messageContains("only CONFIRMED orders can be shipped") + verify(exactly = 0) { inventoryApi.recordMovement(any(), any(), any(), any(), any()) } + } + + @Test + fun `ship walks every line and calls inventoryApi recordMovement with negated quantity`() { + val id = UUID.randomUUID() + val order = confirmedOrder(id, lines = listOf("PAPER-A4" to "10", "INK-CYAN" to "3")) + every { orders.findById(id) } returns Optional.of(order) + stubInventoryDebit("PAPER-A4", "WH-MAIN", BigDecimal("-10")) + stubInventoryDebit("INK-CYAN", "WH-MAIN", BigDecimal("-3")) + + val shipped = service.ship(id, "WH-MAIN") + + assertThat(shipped.status).isEqualTo(SalesOrderStatus.SHIPPED) + verify(exactly = 1) { + inventoryApi.recordMovement( + itemCode = "PAPER-A4", + locationCode = "WH-MAIN", + delta = BigDecimal("-10"), + reason = "SALES_SHIPMENT", + reference = "SO:SO-1", + ) + } + verify(exactly = 1) { + inventoryApi.recordMovement( + itemCode = "INK-CYAN", + locationCode = "WH-MAIN", + delta = BigDecimal("-3"), + reason = "SALES_SHIPMENT", + reference = "SO:SO-1", + ) + } + } + + @Test + fun `cancel rejects a SHIPPED order`() { + val id = UUID.randomUUID() + val shipped = SalesOrder( + code = "SO-1", + partnerCode = "CUST-1", + status = SalesOrderStatus.SHIPPED, + orderDate = LocalDate.of(2026, 4, 8), + currencyCode = "USD", + ).also { it.id = id } + every { orders.findById(id) } returns Optional.of(shipped) + + assertFailure { service.cancel(id) } + .isInstanceOf(IllegalArgumentException::class) + .messageContains("SHIPPED") + } } -- libgit2 0.22.2