Skip to main content

backbone_inventory/infrastructure/persistence/
quant_repository.rs

1//! Repository for Quant 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 quant SQL: the reservation triangle's apex (spec stock-business-logic
6//! §3/§5.1). `stock_quants.reserved_quantity` is the AUTHORITATIVE reservation counter — these
7//! methods are its single writer family; the move-line quantity is a mirror and the move state an
8//! aggregate, both driven by the engine, never by a second counter here.
9//!
10//! 4-layer rule: services orchestrate, repositories hold SQL. Every write method takes the CALLER'S
11//! connection (the movement's transaction), and the caller has already relayed the ambient org
12//! scope onto it.
13
14use rust_decimal::Decimal;
15use sqlx::{PgPool, Row};
16use uuid::Uuid;
17
18use crate::domain::entity::Quant;
19
20/// Table name for Quant entities
21pub const TABLE_NAME: &str = "inventory.stock_quants";
22
23/// Repository for Quant entities.
24///
25/// All standard CRUD, soft-delete, pagination, and bulk methods are
26/// provided automatically via `Deref` to `backbone_orm::GenericCrudRepository`.
27pub struct QuantRepository(
28    backbone_orm::GenericCrudRepository<Quant, backbone_orm::SoftDelete>,
29);
30
31impl std::ops::Deref for QuantRepository {
32    type Target = backbone_orm::GenericCrudRepository<Quant, backbone_orm::SoftDelete>;
33    fn deref(&self) -> &Self::Target { &self.0 }
34}
35
36impl QuantRepository {
37    /// Create a new repository instance.
38    pub fn new(pool: PgPool) -> Self {
39        Self(backbone_orm::GenericCrudRepository::new(pool, TABLE_NAME))
40    }
41}
42
43/// A quant row as the reservation engine sees it.
44pub struct QuantRow {
45    pub id: Uuid,
46    pub item_id: Uuid,
47    pub location_id: Uuid,
48    pub lot_id: Option<Uuid>,
49    pub package_id: Option<Uuid>,
50    pub owner_id: Option<Uuid>,
51    pub quantity: Decimal,
52    pub reserved_quantity: Decimal,
53    pub in_date: Option<chrono::DateTime<chrono::Utc>>,
54}
55
56impl QuantRow {
57    /// `available = quantity - reserved` — always a READ off the authoritative pair, never a
58    /// third writer (spec T2 / stock.hook.yaml rule T2-avail).
59    pub fn available(&self) -> Decimal {
60        self.quantity - self.reserved_quantity
61    }
62}
63
64/// The dimension tuple that identifies exactly ONE quant row: item x location x lot x package x
65/// owner (spec §3). NULL dimensions are matched with `IS NOT DISTINCT FROM` — the table's unique
66/// index treats NULLs as distinct, so NULL-dimension dedup is the ENGINE's responsibility
67/// (stock.hook.yaml R12), carried by the advisory lock + insert-guard below.
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub struct QuantDims {
70    pub item_id: Uuid,
71    pub location_id: Uuid,
72    pub lot_id: Option<Uuid>,
73    pub package_id: Option<Uuid>,
74    pub owner_id: Option<Uuid>,
75}
76
77impl QuantDims {
78    /// A stable text key for the transaction-scoped advisory lock that serializes creation of the
79    /// quant row for this dimension tuple (two competing first-writers would otherwise both pass
80    /// the NOT EXISTS guard — NULLs are distinct in the partial unique index).
81    fn lock_key(&self) -> String {
82        let opt = |v: Option<Uuid>| v.map(|u| u.to_string()).unwrap_or_else(|| "NULL".into());
83        format!(
84            "quant:{}:{}:{}:{}:{}",
85            self.item_id, self.location_id, opt(self.lot_id), opt(self.package_id), opt(self.owner_id)
86        )
87    }
88}
89
90/// Hand-written quant SQL — the reservation apex. Lives here (not in the move engine) per the
91/// module's 4-layer rule: services orchestrate, repositories hold SQL.
92impl QuantRepository {
93    /// Lock the quant row for a dimension tuple `FOR UPDATE`, creating a zeroed row if absent —
94    /// the reservation-engine analogue of `BinRepository::lock_or_init`.
95    ///
96    /// **This row lock is what serializes competing reservations**: two `_action_assign` calls
97    /// racing for the same stock queue on this row; the second reads the first's committed
98    /// reservation once the lock is granted (READ COMMITTED re-evaluates the row under lock), so
99    /// `reserved_quantity` can never exceed what the on-hand actually allowed (R22).
100    ///
101    /// Creation is serialized by a transaction-scoped advisory lock on the dimension key because
102    /// the partial unique index `(item, location, lot, package, owner) WHERE deleted_at IS NULL`
103    /// does NOT dedup NULL dimensions — without the advisory lock two first-writers could both
104    /// insert. The lock auto-releases at commit/rollback of the caller's transaction.
105    pub async fn lock_or_init(
106        &self,
107        conn: &mut sqlx::PgConnection,
108        dims: QuantDims,
109    ) -> Result<QuantRow, sqlx::Error> {
110        Self::lock_or_init_on(conn, dims).await
111    }
112
113    /// [`Self::lock_or_init`] for a caller that holds no repository: every path that may create
114    /// a quant goes through this one, so they all take the same creation lock.
115    pub async fn lock_or_init_on(
116        conn: &mut sqlx::PgConnection,
117        dims: QuantDims,
118    ) -> Result<QuantRow, sqlx::Error> {
119        sqlx::query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))")
120            .bind(dims.lock_key())
121            .execute(&mut *conn)
122            .await?;
123        if let Some(row) = Self::fetch_for_update(conn, dims).await? {
124            return Ok(row);
125        }
126        sqlx::query(
127            r#"INSERT INTO inventory.stock_quants
128                 (id, item_id, location_id, lot_id, package_id, owner_id, quantity,
129                  reserved_quantity, available_quantity)
130               SELECT $1,$2,$3,$4,$5,$6,0,0,0
131               WHERE NOT EXISTS (
132                 SELECT 1 FROM inventory.stock_quants q
133                 WHERE q.item_id=$2 AND q.location_id=$3
134                   AND q.lot_id IS NOT DISTINCT FROM $4
135                   AND q.package_id IS NOT DISTINCT FROM $5
136                   AND q.owner_id IS NOT DISTINCT FROM $6
137                   AND (q.metadata->>'deleted_at') IS NULL
138               )"#,
139        )
140        .bind(Uuid::new_v4()).bind(dims.item_id).bind(dims.location_id)
141        .bind(dims.lot_id).bind(dims.package_id).bind(dims.owner_id)
142        .execute(&mut *conn)
143        .await?;
144        // Either this call won the insert race, or the advisory lock means it never races: the
145        // re-select under FOR UPDATE returns exactly one row either way.
146        Self::fetch_for_update(conn, dims)
147            .await?
148            .ok_or_else(|| sqlx::Error::RowNotFound)
149    }
150
151    /// The `FOR UPDATE` re-select used by [`Self::lock_or_init`]. NULL-safe on every dimension.
152    async fn fetch_for_update(
153        conn: &mut sqlx::PgConnection,
154        dims: QuantDims,
155    ) -> Result<Option<QuantRow>, sqlx::Error> {
156        let row = sqlx::query(
157            r#"SELECT id, item_id, location_id, lot_id, package_id, owner_id, quantity,
158                      reserved_quantity, in_date
159               FROM inventory.stock_quants
160               WHERE item_id=$1 AND location_id=$2
161                 AND lot_id IS NOT DISTINCT FROM $3
162                 AND package_id IS NOT DISTINCT FROM $4
163                 AND owner_id IS NOT DISTINCT FROM $5
164                 AND (metadata->>'deleted_at') IS NULL
165               FOR UPDATE"#,
166        )
167        .bind(dims.item_id).bind(dims.location_id)
168        .bind(dims.lot_id).bind(dims.package_id).bind(dims.owner_id)
169        .fetch_optional(&mut *conn)
170        .await?;
171        Ok(row.map(|r| quant_row_of(r)))
172    }
173
174    /// The RESERVED step of `_synchronize_quant` (spec §3): move `delta` onto
175    /// `reserved_quantity` — the authoritative counter. `available_quantity` (the stored T2
176    /// compute) is rewritten in the SAME statement as the derived shadow `quantity - reserved`,
177    /// so it can never drift. The caller holds the row's `FOR UPDATE` from [`Self::lock_or_init`].
178    ///
179    /// `delta > 0` reserves; `delta < 0` releases. The table CHECK (`reserved_quantity >= 0`)
180    /// backstops release-over-reserve (a raw-SQL writer cannot underflow it — ADR-0015).
181    pub async fn adjust_reserved(
182        &self,
183        conn: &mut sqlx::PgConnection,
184        quant_id: Uuid,
185        delta: Decimal,
186    ) -> Result<QuantRow, sqlx::Error> {
187        let row = sqlx::query(
188            r#"UPDATE inventory.stock_quants
189               SET reserved_quantity = reserved_quantity + $2,
190                   available_quantity = quantity - (reserved_quantity + $2)
191               WHERE id = $1 AND (metadata->>'deleted_at') IS NULL
192               RETURNING id, item_id, location_id, lot_id, package_id, owner_id, quantity,
193                         reserved_quantity, in_date"#,
194        )
195        .bind(quant_id).bind(delta)
196        .fetch_one(&mut *conn)
197        .await?;
198        Ok(quant_row_of(row))
199    }
200
201    /// The AVAILABLE step of `_synchronize_quant` (spec §3): physically move stock —
202    /// `qty_delta` onto `quantity` (negative = stock leaves this quant), plus a simultaneous
203    /// `reserved_delta` (normally the release of the reservation this move consumed). The stored
204    /// T2 compute is rewritten in lockstep. Stamp `in_date` on first arrival (FIFO ordering
205    /// within the lot) when the quant transitions to a positive on-hand from non-positive.
206    pub async fn apply_qty(
207        &self,
208        conn: &mut sqlx::PgConnection,
209        quant_id: Uuid,
210        qty_delta: Decimal,
211        reserved_delta: Decimal,
212    ) -> Result<QuantRow, sqlx::Error> {
213        let row = sqlx::query(
214            r#"UPDATE inventory.stock_quants
215               SET quantity = quantity + $2,
216                   reserved_quantity = reserved_quantity + $3,
217                   available_quantity = (quantity + $2) - (reserved_quantity + $3),
218                   in_date = CASE
219                       WHEN in_date IS NULL AND (quantity + $2) > 0 THEN NOW()
220                       ELSE in_date END
221               WHERE id = $1 AND (metadata->>'deleted_at') IS NULL
222               RETURNING id, item_id, location_id, lot_id, package_id, owner_id, quantity,
223                         reserved_quantity, in_date"#,
224        )
225        .bind(quant_id).bind(qty_delta).bind(reserved_delta)
226        .fetch_one(&mut *conn)
227        .await?;
228        Ok(quant_row_of(row))
229    }
230
231    /// Reservation candidates at one (item, location): the quants with free availability,
232    /// oldest arrival first (`in_date` FIFO ordering — the default removal strategy; explicit
233    /// fifo/lifo/fefo strategies layer on top when the tracking surface lands). Takes the
234    /// caller's transaction: `_action_assign` locks each candidate as it reserves it.
235    pub async fn fetch_reservation_candidates(
236        &self,
237        conn: &mut sqlx::PgConnection,
238        item_id: Uuid,
239        location_id: Uuid,
240    ) -> Result<Vec<QuantRow>, sqlx::Error> {
241        let rows = sqlx::query(
242            r#"SELECT id, item_id, location_id, lot_id, package_id, owner_id, quantity,
243                      reserved_quantity, in_date
244               FROM inventory.stock_quants
245               WHERE item_id=$1 AND location_id=$2
246                 AND quantity - reserved_quantity > 0
247                 AND (metadata->>'deleted_at') IS NULL
248               ORDER BY in_date NULLS LAST, id"#,
249        )
250        .bind(item_id).bind(location_id)
251        .fetch_all(&mut *conn)
252        .await?;
253        Ok(rows.into_iter().map(quant_row_of).collect())
254    }
255
256    /// Re-derive one quant's authoritative `reserved_quantity` from the MIRROR arm: the sum of
257    /// the live move-line quantities matching this quant's dimension tuple (state not
258    /// done/cancel). This is the triangle's self-heal — the reserved step of
259    /// `_synchronize_quant` after a move's lines flip out of the live set (done or cancel),
260    /// including the partial-validate case where an operator shrinks a line below what it had
261    /// reserved (the residual would otherwise stay held by no move). The mirror can only ever
262    /// reflect reservations the engine minted, so the SUM is the truth by construction.
263    ///
264    /// Takes the caller's transaction; the caller holds the quant's `FOR UPDATE` from
265    /// [`Self::lock_or_init`]. Returns the recomputed row.
266    pub async fn recompute_reserved_from_mirror(
267        &self,
268        conn: &mut sqlx::PgConnection,
269        quant_id: Uuid,
270        dims: QuantDims,
271    ) -> Result<QuantRow, sqlx::Error> {
272        let row = sqlx::query(
273            r#"UPDATE inventory.stock_quants q
274               SET reserved_quantity = m.mirror_qty,
275                   available_quantity = q.quantity - m.mirror_qty
276               FROM (
277                   SELECT COALESCE(SUM(l.quantity), 0) AS mirror_qty
278                   FROM inventory.stock_move_lines l
279                   WHERE l.item_id = $2
280                     AND l.location_id = $3
281                     AND l.lot_id IS NOT DISTINCT FROM $4
282                     AND l.package_id IS NOT DISTINCT FROM $5
283                     AND l.owner_id IS NOT DISTINCT FROM $6
284                     AND l.state::text NOT IN ('done', 'cancel')
285                     AND (l.metadata->>'deleted_at') IS NULL
286               ) m
287               WHERE q.id = $1 AND (q.metadata->>'deleted_at') IS NULL
288               RETURNING q.id, q.item_id, q.location_id, q.lot_id, q.package_id, q.owner_id,
289                         q.quantity, q.reserved_quantity, q.in_date"#,
290        )
291        .bind(quant_id)
292        .bind(dims.item_id)
293        .bind(dims.location_id)
294        .bind(dims.lot_id)
295        .bind(dims.package_id)
296        .bind(dims.owner_id)
297        .fetch_one(&mut *conn)
298        .await?;
299        Ok(quant_row_of(row))
300    }
301}
302
303/// On-hand aggregation at one location — the `available = quantity - reserved` READ (T2), summed
304/// over the location's quant grain. Pool-based (each read its own unit of work), riding the
305/// ambient org scope like every read in the suite.
306pub struct QuantOnHandRow {
307    pub on_hand_qty: Decimal,
308    pub reserved_qty: Decimal,
309    pub quant_count: i64,
310}
311
312impl QuantRepository {
313    /// SUM(quantity) / SUM(reserved_quantity) over one (item, location). Zeroed row when
314    /// no quant exists (an unreceived item is unavailable, not an error).
315    pub async fn fetch_on_hand(
316        &self,
317        pool: &PgPool,
318        item_id: Uuid,
319        location_id: Uuid,
320    ) -> Result<QuantOnHandRow, sqlx::Error> {
321        let row = backbone_orm::org_scope::fetch_one_row_scoped(
322            pool,
323            sqlx::query(
324                r#"SELECT
325                       COALESCE(SUM(quantity), 0) AS on_hand_qty,
326                       COALESCE(SUM(reserved_quantity), 0) AS reserved_qty,
327                       COUNT(*) AS quant_count
328                   FROM inventory.stock_quants
329                   WHERE item_id=$1 AND location_id=$2
330                     AND (metadata->>'deleted_at') IS NULL"#,
331            )
332            .bind(item_id).bind(location_id),
333        )
334        .await?;
335        Ok(QuantOnHandRow {
336            on_hand_qty: row.get("on_hand_qty"),
337            reserved_qty: row.get("reserved_qty"),
338            quant_count: row.get("quant_count"),
339        })
340    }
341
342    /// One warehouse-scope availability row (the shop-pivot read): SUM(quantity) /
343    /// SUM(reserved_quantity) over every quant whose location belongs to the pivot warehouse and
344    /// holds stock (usage `internal`). `available` is NOT read off the stored
345    /// `available_quantity` column — the caller computes `on_hand − reserved` per call, so the
346    /// number is fresh off the reservation triangle regardless of any stored-compute staleness.
347    ///
348    /// Batch over `item_ids = ANY($1)`; items with no quant rows in the warehouse are simply
349    /// absent from the result (the caller projects them as zeroed, unavailable rows — an
350    /// unreceived item is unavailable, not an error). Pool-based and org-scoped: the read rides
351    /// the ambient org scope the composing service set per request (ADR-0029).
352    pub async fn fetch_warehouse_on_hand(
353        &self,
354        pool: &PgPool,
355        item_ids: &[Uuid],
356        warehouse_id: Uuid,
357    ) -> Result<Vec<QuantWarehouseRow>, sqlx::Error> {
358        let rows = backbone_orm::org_scope::fetch_all_rows_scoped(
359            pool,
360            sqlx::query(
361                r#"SELECT
362                       q.item_id AS item_id,
363                       COALESCE(SUM(q.quantity), 0) AS on_hand_qty,
364                       COALESCE(SUM(q.reserved_quantity), 0) AS reserved_qty
365                   FROM inventory.stock_quants q
366                   JOIN inventory.locations l ON l.id = q.location_id
367                   WHERE q.item_id = ANY($1)
368                     AND l.warehouse_id = $2
369                     AND l.usage = 'internal'::location_usage
370                     AND (q.metadata->>'deleted_at') IS NULL
371                     AND (l.metadata->>'deleted_at') IS NULL
372                   GROUP BY q.item_id"#,
373            )
374            .bind(item_ids)
375            .bind(warehouse_id),
376        )
377        .await?;
378        Ok(rows
379            .into_iter()
380            .map(|r| QuantWarehouseRow {
381                item_id: r.get("item_id"),
382                on_hand_qty: r.get("on_hand_qty"),
383                reserved_qty: r.get("reserved_qty"),
384            })
385            .collect())
386    }
387}
388
389/// One item's warehouse-scope on-hand pair (the shop-pivot availability read).
390pub struct QuantWarehouseRow {
391    pub item_id: Uuid,
392    pub on_hand_qty: Decimal,
393    pub reserved_qty: Decimal,
394}
395
396fn quant_row_of(r: sqlx::postgres::PgRow) -> QuantRow {
397    QuantRow {
398        id: r.get("id"),
399        item_id: r.get("item_id"),
400        location_id: r.get("location_id"),
401        lot_id: r.get("lot_id"),
402        package_id: r.get("package_id"),
403        owner_id: r.get("owner_id"),
404        quantity: r.get("quantity"),
405        reserved_quantity: r.get("reserved_quantity"),
406        in_date: r.get("in_date"),
407    }
408}
409
410backbone_core::impl_crud_repository!(QuantRepository, Quant, soft_delete);