Skip to main content

backbone_inventory/infrastructure/persistence/
bin_repository.rs

1//! Repository for Bin entities
2//!
3//! Originally generated by metaphor-schema; now **user-owned** — this exact path is declared under
4//! `user_owned` in `metaphor.codegen.yaml`, so the generator skips it wholesale. The custom methods
5//! 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).
6//!
7//! Thin newtype over `backbone_orm::GenericCrudRepository<Bin, backbone_orm::SoftDelete>`.
8//! All standard CRUD methods are available via `Deref`.
9
10use anyhow::Result;
11use backbone_orm::org_scope;
12use rust_decimal::Decimal;
13use sqlx::{PgPool, Row};
14use uuid::Uuid;
15
16use crate::domain::entity::Bin;
17
18
19/// Table name for Bin entities
20pub const TABLE_NAME: &str = "inventory.bins";
21
22/// Repository for Bin entities.
23///
24/// All standard CRUD, soft-delete, pagination, and bulk methods are
25/// provided automatically via `Deref` to `backbone_orm::GenericCrudRepository`.
26pub struct BinRepository(
27    backbone_orm::GenericCrudRepository<Bin, backbone_orm::SoftDelete>,
28);
29
30impl std::ops::Deref for BinRepository {
31    type Target = backbone_orm::GenericCrudRepository<Bin, backbone_orm::SoftDelete>;
32    fn deref(&self) -> &Self::Target { &self.0 }
33}
34
35impl BinRepository {
36    /// Create a new repository instance.
37    pub fn new(pool: PgPool) -> Self {
38        Self(backbone_orm::GenericCrudRepository::new(pool, TABLE_NAME))
39    }
40}
41
42/// A Bin's running balance — the moving-average state the valuation engine reads and rewrites.
43pub struct BinBalanceRow {
44    pub actual_qty: Decimal,
45    pub valuation_rate: Decimal,
46    pub stock_value: Decimal,
47}
48
49/// Hand-written Bin SQL. Lives here (not in the write service) per the module's 4-layer rule:
50/// services orchestrate and own the unit of work, repositories hold the SQL.
51impl BinRepository {
52    /// Lock a bin `FOR UPDATE`, creating a zeroed one if absent, and return its current balance.
53    ///
54    /// **This lock is what serializes stock moves** — every valuation-changing movement takes it
55    /// before reading a quantity, so two concurrent moves against one (item, warehouse) queue here
56    /// instead of both blending against a stale average. Callers must preserve the order they take
57    /// these locks in.
58    ///
59    /// Takes the CALLER'S connection: the lock must be held for the whole movement, and the SLE +
60    /// Bin write must commit as one unit with it. **The caller MUST have already relayed the
61    /// ambient org scope onto that transaction before calling** — the composed decorator's fence
62    /// (ADR-0029) is what bounds the bin lookup. Don't re-bind here; the caller's relay covers it.
63    pub async fn lock_or_init(
64        &self,
65        conn: &mut sqlx::PgConnection,
66        item_id: Uuid,
67        warehouse_id: Uuid,
68    ) -> Result<BinBalanceRow, sqlx::Error> {
69        let row = sqlx::query(
70            r#"SELECT actual_qty, valuation_rate, stock_value FROM inventory.bins
71               WHERE item_id=$1 AND warehouse_id=$2 AND (metadata->>'deleted_at') IS NULL
72               FOR UPDATE"#,
73        )
74        .bind(item_id).bind(warehouse_id)
75        .fetch_optional(&mut *conn)
76        .await?;
77        if let Some(r) = row {
78            return Ok(BinBalanceRow {
79                actual_qty: r.get("actual_qty"),
80                valuation_rate: r.get("valuation_rate"),
81                stock_value: r.get("stock_value"),
82            });
83        }
84        sqlx::query(
85            r#"INSERT INTO inventory.bins (id, item_id, warehouse_id, actual_qty, reserved_qty, valuation_rate, stock_value)
86               VALUES ($1,$2,$3,0,0,0,0)"#,
87        )
88        .bind(Uuid::new_v4()).bind(item_id).bind(warehouse_id)
89        .execute(&mut *conn)
90        .await?;
91        Ok(BinBalanceRow {
92            actual_qty: Decimal::ZERO, valuation_rate: Decimal::ZERO, stock_value: Decimal::ZERO,
93        })
94    }
95
96    /// Write a bin's recomputed balance back. Must ride the same transaction holding the row's
97    /// `FOR UPDATE` from [`Self::lock_or_init`].
98    pub async fn update_balance(
99        &self,
100        conn: &mut sqlx::PgConnection,
101        item_id: Uuid,
102        warehouse_id: Uuid,
103        actual_qty: Decimal,
104        valuation_rate: Decimal,
105        stock_value: Decimal,
106    ) -> Result<(), sqlx::Error> {
107        sqlx::query(
108            r#"UPDATE inventory.bins SET actual_qty=$3, valuation_rate=$4, stock_value=$5
109               WHERE item_id=$1 AND warehouse_id=$2 AND (metadata->>'deleted_at') IS NULL"#,
110        )
111        .bind(item_id).bind(warehouse_id)
112        .bind(actual_qty).bind(valuation_rate).bind(stock_value)
113        .execute(conn)
114        .await?;
115        Ok(())
116    }
117}
118
119/// A Bin's available-to-commit columns.
120pub struct BinAvailabilityRow {
121    pub actual_qty: Decimal,
122    pub reserved_qty: Decimal,
123}
124
125/// A Bin's available-to-commit columns, carrying the warehouse the row came from.
126pub struct BinWarehouseAvailabilityRow {
127    pub warehouse_id: Uuid,
128    pub actual_qty: Decimal,
129    pub reserved_qty: Decimal,
130}
131
132/// Hand-written Bin read SQL backing the consumer-facing read port
133/// (`InventoryReadService`). Pool-based: each read is its own unit of work.
134///
135/// These run through the scoped read helpers so they ride the ambient org scope's
136/// request-dedicated connection — the composed decorator's fence (ADR-0029) bounds what the
137/// caller can see, like every other read in the suite.
138impl BinRepository {
139    /// One bin's availability columns. `Ok(None)` = no bin row (the caller decides what that
140    /// means; the read service projects it as a zeroed, unavailable view).
141    pub async fn fetch_availability(
142        &self,
143        pool: &PgPool,
144        item_id: Uuid,
145        warehouse_id: Uuid,
146    ) -> Result<Option<BinAvailabilityRow>, sqlx::Error> {
147        let row = org_scope::fetch_optional_row_scoped(
148            pool,
149            sqlx::query(
150                r#"SELECT actual_qty, reserved_qty FROM inventory.bins
151                   WHERE item_id=$1 AND warehouse_id=$2 AND (metadata->>'deleted_at') IS NULL"#,
152            )
153            .bind(item_id).bind(warehouse_id),
154        )
155        .await?;
156        Ok(row.map(|r| BinAvailabilityRow {
157            actual_qty: r.get("actual_qty"),
158            reserved_qty: r.get("reserved_qty"),
159        }))
160    }
161
162    /// Every bin holding one item, ordered by warehouse.
163    pub async fn fetch_availability_across_warehouses(
164        &self,
165        pool: &PgPool,
166        item_id: Uuid,
167    ) -> Result<Vec<BinWarehouseAvailabilityRow>, sqlx::Error> {
168        let rows = org_scope::fetch_all_rows_scoped(
169            pool,
170            sqlx::query(
171                r#"SELECT warehouse_id, actual_qty, reserved_qty FROM inventory.bins
172                   WHERE item_id=$1 AND (metadata->>'deleted_at') IS NULL
173                   ORDER BY warehouse_id"#,
174            )
175            .bind(item_id),
176        )
177        .await?;
178        Ok(rows
179            .into_iter()
180            .map(|r| BinWarehouseAvailabilityRow {
181                warehouse_id: r.get("warehouse_id"),
182                actual_qty: r.get("actual_qty"),
183                reserved_qty: r.get("reserved_qty"),
184            })
185            .collect())
186    }
187
188    /// One bin's valuation columns. `Ok(None)` = no bin row.
189    pub async fn fetch_balance(
190        &self,
191        pool: &PgPool,
192        item_id: Uuid,
193        warehouse_id: Uuid,
194    ) -> Result<Option<BinBalanceRow>, sqlx::Error> {
195        let row = org_scope::fetch_optional_row_scoped(
196            pool,
197            sqlx::query(
198                r#"SELECT actual_qty, valuation_rate, stock_value FROM inventory.bins
199                   WHERE item_id=$1 AND warehouse_id=$2 AND (metadata->>'deleted_at') IS NULL"#,
200            )
201            .bind(item_id).bind(warehouse_id),
202        )
203        .await?;
204        Ok(row.map(|r| BinBalanceRow {
205            actual_qty: r.get("actual_qty"),
206            valuation_rate: r.get("valuation_rate"),
207            stock_value: r.get("stock_value"),
208        }))
209    }
210}
211
212backbone_core::impl_crud_repository!(BinRepository, Bin, soft_delete);