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