Skip to main content

backbone_inventory/infrastructure/persistence/
stock_ledger_entry_repository.rs

1//! Repository for StockLedgerEntry 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 append-only Stock Ledger SQL
6//! (4-layer rule: services orchestrate, repositories hold the SQL).
7//!
8//! Thin newtype over `backbone_orm::GenericCrudRepository<StockLedgerEntry, backbone_orm::SoftDelete>`.
9//! All standard CRUD methods are available via `Deref`.
10
11use anyhow::Result;
12use rust_decimal::Decimal;
13use sqlx::{PgPool, Row};
14use uuid::Uuid;
15
16use crate::domain::entity::StockLedgerEntry;
17
18/// Table name for StockLedgerEntry entities
19pub const TABLE_NAME: &str = "inventory.stock_ledger_entries";
20
21/// Repository for StockLedgerEntry entities.
22///
23/// All standard CRUD, soft-delete, pagination, and bulk methods are
24/// provided automatically via `Deref` to `backbone_orm::GenericCrudRepository`.
25pub struct StockLedgerEntryRepository(
26    backbone_orm::GenericCrudRepository<StockLedgerEntry, backbone_orm::SoftDelete>,
27);
28
29impl std::ops::Deref for StockLedgerEntryRepository {
30    type Target = backbone_orm::GenericCrudRepository<StockLedgerEntry, backbone_orm::SoftDelete>;
31    fn deref(&self) -> &Self::Target { &self.0 }
32}
33
34impl StockLedgerEntryRepository {
35    /// Create a new repository instance.
36    pub fn new(pool: PgPool) -> Self {
37        Self(backbone_orm::GenericCrudRepository::new(pool, TABLE_NAME))
38    }
39}
40
41/// The exact row an immutable Stock Ledger Entry insert writes.
42///
43/// Mirrors the raw column shape rather than the `StockLedgerEntry` entity: `voucher_type` is
44/// supplied as a free string and cast at the DB (`$12::voucher_type`) so a bad value fails as a DB
45/// error, and `status` is a literal 'active' (the SLE is append-only — a correction is a new
46/// entry, never an edit).
47pub struct NewSleRow<'a> {
48    pub item_id: Uuid,
49    pub warehouse_id: Uuid,
50    pub posting_date: chrono::NaiveDate,
51    /// Signed: positive on an inflow, negative on an outflow.
52    pub actual_qty: Decimal,
53    pub qty_after_txn: Decimal,
54    pub incoming_rate: Decimal,
55    pub valuation_rate: Decimal,
56    pub stock_value: Decimal,
57    /// Signed value delta this entry contributes — what the GL post mirrors.
58    pub stock_value_difference: Decimal,
59    /// "purchase_receipt" | "delivery_note" | "stock_entry" | "stock_reconciliation".
60    pub voucher_type: &'a str,
61    pub voucher_id: Uuid,
62    pub voucher_no: &'a str,
63    pub sle_no: i32,
64}
65
66/// Hand-written StockLedgerEntry SQL. Lives here (not in the write service) per the module's 4-layer
67/// rule: services orchestrate and own the unit of work, repositories hold the SQL.
68impl StockLedgerEntryRepository {
69    /// Append one immutable SLE.
70    ///
71    /// Takes the CALLER'S connection: the SLE and the Bin balance it describes must commit as one
72    /// unit, on the transaction holding the bin's `FOR UPDATE`. The caller has already relayed the
73    /// ambient org scope onto it — don't re-bind here.
74    pub async fn insert_sle(
75        &self,
76        conn: &mut sqlx::PgConnection,
77        e: &NewSleRow<'_>,
78    ) -> Result<(), sqlx::Error> {
79        sqlx::query(
80            r#"INSERT INTO inventory.stock_ledger_entries
81                (id, item_id, warehouse_id, posting_date, actual_qty, qty_after_txn,
82                 incoming_rate, valuation_rate, stock_value, stock_value_difference, voucher_type,
83                 voucher_id, voucher_no, sle_no, status)
84               VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11::voucher_type,$12,$13,$14,'active')"#,
85        )
86        .bind(Uuid::new_v4()).bind(e.item_id).bind(e.warehouse_id).bind(e.posting_date)
87        .bind(e.actual_qty).bind(e.qty_after_txn).bind(e.incoming_rate).bind(e.valuation_rate)
88        .bind(e.stock_value).bind(e.stock_value_difference).bind(e.voucher_type).bind(e.voucher_id)
89        .bind(e.voucher_no).bind(e.sle_no)
90        .execute(conn)
91        .await?;
92        Ok(())
93    }
94
95    /// The highest `sle_no` a voucher has already used — so a cancellation can continue the sequence
96    /// with its compensating entries instead of colliding on the
97    /// `(voucher_type, voucher_id, sle_no)` unique index. Takes the caller's connection (rides the
98    /// movement's transaction). 0 when the voucher has no entries yet.
99    pub async fn fetch_max_sle_no(
100        &self,
101        conn: &mut sqlx::PgConnection,
102        voucher_type: &str,
103        voucher_id: Uuid,
104    ) -> Result<i32, sqlx::Error> {
105        let max: Option<i32> = sqlx::query_scalar(
106            r#"SELECT MAX(sle_no) FROM inventory.stock_ledger_entries
107               WHERE voucher_type=$1::voucher_type AND voucher_id=$2 AND (metadata->>'deleted_at') IS NULL"#,
108        )
109        .bind(voucher_type).bind(voucher_id)
110        .fetch_one(conn)
111        .await?;
112        Ok(max.unwrap_or(0))
113    }
114
115    /// The total value one move carried off its source bin: the NEGATED sum of the move's
116    /// signed SLE value deltas (a move's rows ride `voucher_type='stock_entry'` with
117    /// `voucher_no` = the move name; an outflow's delta is negative, so negating yields the
118    /// positive value the move took out — the COGS of a delivery-shaped move). A voucher door
119    /// resuming a crashed submit uses this to recover an already-landed line's value without
120    /// re-reading the bins. 0 when the move has no rows.
121    pub async fn sum_move_value(
122        &self,
123        pool: &PgPool,
124        move_name: &str,
125    ) -> Result<Decimal, sqlx::Error> {
126        let row = backbone_orm::org_scope::fetch_one_row_scoped(
127            pool,
128            sqlx::query(
129                r#"SELECT COALESCE(-SUM(stock_value_difference), 0) AS carried
130                   FROM inventory.stock_ledger_entries
131                   WHERE voucher_no=$1 AND voucher_type='stock_entry'
132                     AND (metadata->>'deleted_at') IS NULL"#,
133            )
134            .bind(move_name),
135        )
136        .await?;
137        Ok(row.get("carried"))
138    }
139
140    /// The value legs ONE move minted, split by direction: `(out_value, in_value)` — the
141    /// negated sum of the move's negative `stock_value_difference` rows (what the OUT leg
142    /// carried off the source bin) and the sum of its positive rows (what the IN leg blended
143    /// into the destination). Keyed on `voucher_id` = the move id (the exact grain the mint
144    /// wrote; move names are descriptive, ids are unique). A GL repost uses this to rebuild the
145    /// move's envelope from the committed ledger without re-reading the bins. `(0, 0)` when the
146    /// move minted no rows.
147    pub async fn move_leg_values(
148        &self,
149        pool: &PgPool,
150        move_id: Uuid,
151    ) -> Result<(Decimal, Decimal), sqlx::Error> {
152        let row = backbone_orm::org_scope::fetch_one_row_scoped(
153            pool,
154            sqlx::query(
155                r#"SELECT
156                       COALESCE(-SUM(stock_value_difference) FILTER (WHERE stock_value_difference < 0), 0) AS out_value,
157                       COALESCE(SUM(stock_value_difference) FILTER (WHERE stock_value_difference > 0), 0) AS in_value
158                   FROM inventory.stock_ledger_entries
159                   WHERE voucher_type='stock_entry' AND voucher_id=$1
160                     AND (metadata->>'deleted_at') IS NULL"#,
161            )
162            .bind(move_id),
163        )
164        .await?;
165        Ok((row.get("out_value"), row.get("in_value")))
166    }
167
168    /// The value ONE move blended into its destination bin: the sum of the move's positive
169    /// signed SLE value deltas, on the caller's connection (a landed-cost validation reads
170    /// every target move's carried value inside its read phase). This is the `value` basis of
171    /// a landed-cost split. 0 when the move minted no rows.
172    pub async fn sum_move_in_value(
173        &self,
174        conn: &mut sqlx::PgConnection,
175        move_id: Uuid,
176    ) -> Result<Decimal, sqlx::Error> {
177        let row: Option<Decimal> = sqlx::query_scalar(
178            r#"SELECT COALESCE(SUM(stock_value_difference), 0)
179               FROM inventory.stock_ledger_entries
180               WHERE voucher_type='stock_entry' AND voucher_id=$1
181                 AND stock_value_difference > 0 AND (metadata->>'deleted_at') IS NULL"#,
182        )
183        .bind(move_id)
184        .fetch_one(&mut *conn)
185        .await?;
186        Ok(row.unwrap_or(Decimal::ZERO))
187    }
188
189    /// The landed-cost revaluation rows one landed cost minted (`voucher_type='landed_cost'`,
190    /// one value-only row per target move line): the voucher name and the signed value delta
191    /// each row carries. A landed-cost GL repost rebuilds its debit legs from exactly these
192    /// rows — the minted ledger, never a re-computation. Takes the caller's connection.
193    pub async fn fetch_lc_revaluations(
194        &self,
195        conn: &mut sqlx::PgConnection,
196        lc_id: Uuid,
197    ) -> Result<Vec<(String, Decimal)>, sqlx::Error> {
198        let rows = sqlx::query(
199            r#"SELECT voucher_no, stock_value_difference FROM inventory.stock_ledger_entries
200               WHERE voucher_type='landed_cost' AND voucher_id=$1
201                 AND (metadata->>'deleted_at') IS NULL
202               ORDER BY sle_no"#,
203        )
204        .bind(lc_id)
205        .fetch_all(&mut *conn)
206        .await?;
207        Ok(rows
208            .into_iter()
209            .map(|r| (r.get::<String, _>("voucher_no"), r.get::<Decimal, _>("stock_value_difference")))
210            .collect())
211    }
212
213    /// Whether a voucher already minted an SLE row under an exact `voucher_no` — the
214    /// crash-resume probe for deterministic-name inserts (a landed-cost revaluation row
215    /// `{lc_number}/{move_name}/{idx}` that already landed in a prior crashed attempt must be
216    /// skipped, never re-minted). Takes the caller's connection (rides the caller's unit of
217    /// work).
218    pub async fn has_sle_named(
219        &self,
220        conn: &mut sqlx::PgConnection,
221        voucher_type: &str,
222        voucher_id: Uuid,
223        voucher_no: &str,
224    ) -> Result<bool, sqlx::Error> {
225        let n: i64 = sqlx::query_scalar(
226            r#"SELECT COUNT(*) FROM inventory.stock_ledger_entries
227               WHERE voucher_type=$1::voucher_type AND voucher_id=$2 AND voucher_no=$3
228                 AND (metadata->>'deleted_at') IS NULL"#,
229        )
230        .bind(voucher_type)
231        .bind(voucher_id)
232        .bind(voucher_no)
233        .fetch_one(&mut *conn)
234        .await?;
235        Ok(n > 0)
236    }
237
238    /// Read-only FIFO ATTRIBUTION of a bin's current on-hand onto its inbound moves: for each
239    /// move, how many of the units it blended in are still on hand. This answers "how much of
240    /// this receipt is left to revalue" for landed costs.
241    ///
242    /// The arithmetic (per (item, warehouse)): order the IN rows FIFO by
243    /// `(posting_date, created_at, id)`; the units still on hand are the NEWEST ones, so a
244    /// move's remaining share is `min(qty_m, max(0, on_hand − qty of IN rows strictly before
245    /// it))`. OUT rows never enter the formula — their effect is already carried by the bin's
246    /// current `actual_qty`. Value-only rows (qty 0, e.g. landed-cost adjustments) never move
247    /// the attribution.
248    ///
249    /// **This is attribution, NOT FIFO costing** — the moving-average SLE engine stays the
250    /// single valuation writer; nothing here feeds a rate or a value.
251    pub async fn remaining_qty_for_moves(
252        &self,
253        pool: &PgPool,
254        move_ids: &[Uuid],
255    ) -> Result<std::collections::HashMap<Uuid, Decimal>, sqlx::Error> {
256        if move_ids.is_empty() { return Ok(std::collections::HashMap::new()); }
257        let rows = backbone_orm::org_scope::fetch_all_rows_scoped(
258            pool,
259            sqlx::query(
260                r#"WITH ins AS (
261                       SELECT s.voucher_id AS move_id, s.item_id, s.warehouse_id,
262                              s.actual_qty AS qty,
263                              COALESCE(SUM(s.actual_qty) OVER (
264                                  PARTITION BY s.item_id, s.warehouse_id
265                                  ORDER BY s.posting_date, (s.metadata->>'created_at'), s.id
266                                  ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING
267                              ), 0) AS prior_in
268                       FROM inventory.stock_ledger_entries s
269                       WHERE s.voucher_type='stock_entry'
270                         AND s.actual_qty > 0 AND (s.metadata->>'deleted_at') IS NULL
271                   )
272                   SELECT ins.move_id,
273                          LEAST(ins.qty, GREATEST(COALESCE(b.actual_qty, 0) - ins.prior_in, 0)) AS remaining
274                   FROM ins
275                   LEFT JOIN inventory.bins b
276                          ON b.item_id=ins.item_id
277                         AND b.warehouse_id=ins.warehouse_id
278                         AND (b.metadata->>'deleted_at') IS NULL
279                   WHERE ins.move_id = ANY($1)"#,
280            )
281            .bind(move_ids),
282        )
283        .await?;
284        Ok(rows
285            .into_iter()
286            .map(|r| (r.get::<Uuid, _>("move_id"), r.get::<Decimal, _>("remaining")))
287            .collect())
288    }
289}
290
291backbone_core::impl_crud_repository!(StockLedgerEntryRepository, StockLedgerEntry, soft_delete);