backbone-inventory 0.17.2

Inventory: stock ledger of record (SLE) + moving-average valuation; emits COGS/asset AccountingPost (Delivery/Receipt -> GL)
Documentation
//! Repository for Bin entities
//!
//! Originally generated by metaphor-schema; now **user-owned** — this exact path is declared under
//! `user_owned` in `metaphor.codegen.yaml`, so the generator skips it wholesale. The custom methods
//! below hold the hand-written Bin SQL — the `FOR UPDATE` balance lock that serializes every stock move (4-layer rule: services orchestrate, repos hold SQL).
//!
//! Thin newtype over `backbone_orm::GenericCrudRepository<Bin, backbone_orm::SoftDelete>`.
//! All standard CRUD methods are available via `Deref`.

use anyhow::Result;
use backbone_orm::org_scope;
use rust_decimal::Decimal;
use sqlx::{PgPool, Row};
use uuid::Uuid;

use crate::domain::entity::Bin;


/// Table name for Bin entities
pub const TABLE_NAME: &str = "inventory.bins";

/// Repository for Bin entities.
///
/// All standard CRUD, soft-delete, pagination, and bulk methods are
/// provided automatically via `Deref` to `backbone_orm::GenericCrudRepository`.
pub struct BinRepository(
    backbone_orm::GenericCrudRepository<Bin, backbone_orm::SoftDelete>,
);

impl std::ops::Deref for BinRepository {
    type Target = backbone_orm::GenericCrudRepository<Bin, backbone_orm::SoftDelete>;
    fn deref(&self) -> &Self::Target { &self.0 }
}

impl BinRepository {
    /// Create a new repository instance.
    pub fn new(pool: PgPool) -> Self {
        Self(backbone_orm::GenericCrudRepository::new(pool, TABLE_NAME))
    }
}

/// A Bin's running balance — the moving-average state the valuation engine reads and rewrites.
pub struct BinBalanceRow {
    pub actual_qty: Decimal,
    pub valuation_rate: Decimal,
    pub stock_value: Decimal,
}

/// Hand-written Bin SQL. Lives here (not in the write service) per the module's 4-layer rule:
/// services orchestrate and own the unit of work, repositories hold the SQL.
impl BinRepository {
    /// Lock a bin `FOR UPDATE`, creating a zeroed one if absent, and return its current balance.
    ///
    /// **This lock is what serializes stock moves** — every valuation-changing movement takes it
    /// before reading a quantity, so two concurrent moves against one (item, warehouse) queue here
    /// instead of both blending against a stale average. Callers must preserve the order they take
    /// these locks in.
    ///
    /// Takes the CALLER'S connection: the lock must be held for the whole movement, and the SLE +
    /// Bin write must commit as one unit with it. **The caller MUST have already relayed the
    /// ambient org scope onto that transaction before calling** — the composed decorator's fence
    /// (ADR-0029) is what bounds the bin lookup. Don't re-bind here; the caller's relay covers it.
    pub async fn lock_or_init(
        &self,
        conn: &mut sqlx::PgConnection,
        item_id: Uuid,
        warehouse_id: Uuid,
    ) -> Result<BinBalanceRow, sqlx::Error> {
        let row = sqlx::query(
            r#"SELECT actual_qty, valuation_rate, stock_value FROM inventory.bins
               WHERE item_id=$1 AND warehouse_id=$2 AND (metadata->>'deleted_at') IS NULL
               FOR UPDATE"#,
        )
        .bind(item_id).bind(warehouse_id)
        .fetch_optional(&mut *conn)
        .await?;
        if let Some(r) = row {
            return Ok(BinBalanceRow {
                actual_qty: r.get("actual_qty"),
                valuation_rate: r.get("valuation_rate"),
                stock_value: r.get("stock_value"),
            });
        }
        sqlx::query(
            r#"INSERT INTO inventory.bins (id, item_id, warehouse_id, actual_qty, reserved_qty, valuation_rate, stock_value)
               VALUES ($1,$2,$3,0,0,0,0)"#,
        )
        .bind(Uuid::new_v4()).bind(item_id).bind(warehouse_id)
        .execute(&mut *conn)
        .await?;
        Ok(BinBalanceRow {
            actual_qty: Decimal::ZERO, valuation_rate: Decimal::ZERO, stock_value: Decimal::ZERO,
        })
    }

    /// Write a bin's recomputed balance back. Must ride the same transaction holding the row's
    /// `FOR UPDATE` from [`Self::lock_or_init`].
    pub async fn update_balance(
        &self,
        conn: &mut sqlx::PgConnection,
        item_id: Uuid,
        warehouse_id: Uuid,
        actual_qty: Decimal,
        valuation_rate: Decimal,
        stock_value: Decimal,
    ) -> Result<(), sqlx::Error> {
        sqlx::query(
            r#"UPDATE inventory.bins SET actual_qty=$3, valuation_rate=$4, stock_value=$5
               WHERE item_id=$1 AND warehouse_id=$2 AND (metadata->>'deleted_at') IS NULL"#,
        )
        .bind(item_id).bind(warehouse_id)
        .bind(actual_qty).bind(valuation_rate).bind(stock_value)
        .execute(conn)
        .await?;
        Ok(())
    }
}

/// A Bin's available-to-commit columns.
pub struct BinAvailabilityRow {
    pub actual_qty: Decimal,
    pub reserved_qty: Decimal,
}

/// A Bin's available-to-commit columns, carrying the warehouse the row came from.
pub struct BinWarehouseAvailabilityRow {
    pub warehouse_id: Uuid,
    pub actual_qty: Decimal,
    pub reserved_qty: Decimal,
}

/// Hand-written Bin read SQL backing the consumer-facing read port
/// (`InventoryReadService`). Pool-based: each read is its own unit of work.
///
/// These run through the scoped read helpers so they ride the ambient org scope's
/// request-dedicated connection — the composed decorator's fence (ADR-0029) bounds what the
/// caller can see, like every other read in the suite.
impl BinRepository {
    /// One bin's availability columns. `Ok(None)` = no bin row (the caller decides what that
    /// means; the read service projects it as a zeroed, unavailable view).
    pub async fn fetch_availability(
        &self,
        pool: &PgPool,
        item_id: Uuid,
        warehouse_id: Uuid,
    ) -> Result<Option<BinAvailabilityRow>, sqlx::Error> {
        let row = org_scope::fetch_optional_row_scoped(
            pool,
            sqlx::query(
                r#"SELECT actual_qty, reserved_qty FROM inventory.bins
                   WHERE item_id=$1 AND warehouse_id=$2 AND (metadata->>'deleted_at') IS NULL"#,
            )
            .bind(item_id).bind(warehouse_id),
        )
        .await?;
        Ok(row.map(|r| BinAvailabilityRow {
            actual_qty: r.get("actual_qty"),
            reserved_qty: r.get("reserved_qty"),
        }))
    }

    /// Every bin holding one item, ordered by warehouse.
    pub async fn fetch_availability_across_warehouses(
        &self,
        pool: &PgPool,
        item_id: Uuid,
    ) -> Result<Vec<BinWarehouseAvailabilityRow>, sqlx::Error> {
        let rows = org_scope::fetch_all_rows_scoped(
            pool,
            sqlx::query(
                r#"SELECT warehouse_id, actual_qty, reserved_qty FROM inventory.bins
                   WHERE item_id=$1 AND (metadata->>'deleted_at') IS NULL
                   ORDER BY warehouse_id"#,
            )
            .bind(item_id),
        )
        .await?;
        Ok(rows
            .into_iter()
            .map(|r| BinWarehouseAvailabilityRow {
                warehouse_id: r.get("warehouse_id"),
                actual_qty: r.get("actual_qty"),
                reserved_qty: r.get("reserved_qty"),
            })
            .collect())
    }

    /// One bin's valuation columns. `Ok(None)` = no bin row.
    pub async fn fetch_balance(
        &self,
        pool: &PgPool,
        item_id: Uuid,
        warehouse_id: Uuid,
    ) -> Result<Option<BinBalanceRow>, sqlx::Error> {
        let row = org_scope::fetch_optional_row_scoped(
            pool,
            sqlx::query(
                r#"SELECT actual_qty, valuation_rate, stock_value FROM inventory.bins
                   WHERE item_id=$1 AND warehouse_id=$2 AND (metadata->>'deleted_at') IS NULL"#,
            )
            .bind(item_id).bind(warehouse_id),
        )
        .await?;
        Ok(row.map(|r| BinBalanceRow {
            actual_qty: r.get("actual_qty"),
            valuation_rate: r.get("valuation_rate"),
            stock_value: r.get("stock_value"),
        }))
    }
}

backbone_core::impl_crud_repository!(BinRepository, Bin, soft_delete);