• …alidates locations at create
    
    Follow-up to the pbc-warehousing chunk. Plugs a real gap noticed in
    the smoke test: an unknown fromLocationCode or toLocationCode on a
    StockTransfer was silently accepted at create() and only surfaced
    as a confirm()-time rollback, which is a confusing UX — the operator
    types TR-001 wrong, hits "create", then hits "confirm" minutes later
    and sees "location GHOST-SRC is not in the inventory directory".
    
    ## api.v1 growth
    
    New cross-PBC method on `InventoryApi`:
    
        fun findLocationByCode(locationCode: String): LocationRef?
    
    Parallel shape to `CatalogApi.findItemByCode` — a lookup-by-code
    returning a lightweight ref or null, safe for any cross-PBC consumer
    to inject. The returned `LocationRef` data class carries id, code,
    name, type (as a String, not the inventory-internal LocationType
    enum — rationale in the KDoc), and active flag. Fields that are
    NOT part of the cross-PBC contract (audit columns, ext JSONB, the
    raw JPA entity) stay inside pbc-inventory.
    
    api.v1 additive change within the v1 line — no breaking rename, no
    signature churn on existing methods. The interface adds a new
    abstract method, which IS technically a source-breaking change for
    any in-tree implementation, but the only impl is
    pbc-inventory/InventoryApiAdapter which is updated in the same
    commit. No external plug-in implements InventoryApi (by design;
    plug-ins inject it, they don't provide it).
    
    ## Adapter implementation
    
    `InventoryApiAdapter.findLocationByCode` resolves the location via
    the existing `LocationJpaRepository.findByCode`, which is exactly
    what `recordMovement` already uses. A new private extension
    `Location.toRef()` builds the api.v1 DTO. Zero new SQL; zero new
    repository methods.
    
    ## pbc-warehousing wiring
    
    `StockTransferService.create` now calls the facade twice — once for
    the source location, once for the destination — BEFORE validating
    lines. The four-step ordering is: code uniqueness → from != to →
    non-empty lines → both locations exist and are active → per-line
    validation. Unknown locations produce a 400 with a clear message;
    deactivated locations produce a 400 distinguishing "doesn't exist"
    from "exists but can't be used":
    
        "from location code 'GHOST-SRC' is not in the inventory directory"
        "from location 'WH-CLOSED' is deactivated and cannot be transfer source"
    
    The confirm() path is unchanged. Locations may still vanish between
    create and confirm (though the likelihood is low for a normal
    workflow), and `recordMovement` will still raise its own error in
    that case — belt and suspenders.
    
    ## Smoke test
    
    ```
    POST /api/v1/inventory/locations {code: WH-GOOD, type: WAREHOUSE}
    POST /api/v1/catalog/items       {code: ITEM-1, baseUomCode: ea}
    
    POST /api/v1/warehousing/stock-transfers
         {code: TR-bad, fromLocationCode: GHOST-SRC, toLocationCode: WH-GOOD,
          lines: [{lineNo: 1, itemCode: ITEM-1, quantity: 1}]}
      → 400 "from location code 'GHOST-SRC' is not in the inventory directory"
         (before this commit: 201 DRAFT, then 400 at confirm)
    
    POST /api/v1/warehousing/stock-transfers
         {code: TR-bad2, fromLocationCode: WH-GOOD, toLocationCode: GHOST-DST,
          lines: [{lineNo: 1, itemCode: ITEM-1, quantity: 1}]}
      → 400 "to location code 'GHOST-DST' is not in the inventory directory"
    
    POST /api/v1/warehousing/stock-transfers
         {code: TR-ok, fromLocationCode: WH-GOOD, toLocationCode: WH-OTHER,
          lines: [{lineNo: 1, itemCode: ITEM-1, quantity: 1}]}
      → 201 DRAFT   ← happy path still works
    ```
    
    ## Tests
    
    - Updated the 3 existing `StockTransferServiceTest` tests that
      created real transfers to stub `inventory.findLocationByCode` for
      both WH-A and WH-B via a new `stubLocation()` helper.
    - 3 new tests:
      * `create rejects unknown from location via InventoryApi`
      * `create rejects unknown to location via InventoryApi`
      * `create rejects a deactivated from location`
    - Total framework unit tests: 300 (was 297), all green.
    
    ## Why this isn't a breaking api.v1 change
    
    InventoryApi is an interface consumed by other PBCs and by plug-ins,
    implemented ONLY by pbc-inventory. Adding a new method to an
    interface IS a source-breaking change for any implementer — but
    the framework's dependency rules mean no external code implements
    this interface. Plug-ins and other PBCs CONSUME it via dependency
    injection; the only production impl is InventoryApiAdapter, updated
    in the same commit. Binary compatibility for consumers is
    preserved: existing call sites compile and run unchanged because
    only the interface grew, not its existing methods.
    
    If/when a third party implements InventoryApi (e.g. a test double
    outside the framework, or a custom backend plug-in), this would be
    a semver-major-worthy addition. For the in-tree framework, it's
    additive-within-a-major.
    zichun authored
     
    Browse Code »
  • 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.
    zichun authored
     
    Browse Code »