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);