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 sqlx::query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))")
111 .bind(dims.lock_key())
112 .execute(&mut *conn)
113 .await?;
114 if let Some(row) = Self::fetch_for_update(conn, dims).await? {
115 return Ok(row);
116 }
117 sqlx::query(
118 r#"INSERT INTO inventory.stock_quants
119 (id, item_id, location_id, lot_id, package_id, owner_id, quantity,
120 reserved_quantity, available_quantity)
121 SELECT $1,$2,$3,$4,$5,$6,0,0,0
122 WHERE NOT EXISTS (
123 SELECT 1 FROM inventory.stock_quants q
124 WHERE q.item_id=$2 AND q.location_id=$3
125 AND q.lot_id IS NOT DISTINCT FROM $4
126 AND q.package_id IS NOT DISTINCT FROM $5
127 AND q.owner_id IS NOT DISTINCT FROM $6
128 AND (q.metadata->>'deleted_at') IS NULL
129 )"#,
130 )
131 .bind(Uuid::new_v4()).bind(dims.item_id).bind(dims.location_id)
132 .bind(dims.lot_id).bind(dims.package_id).bind(dims.owner_id)
133 .execute(&mut *conn)
134 .await?;
135 // Either this call won the insert race, or the advisory lock means it never races: the
136 // re-select under FOR UPDATE returns exactly one row either way.
137 Self::fetch_for_update(conn, dims)
138 .await?
139 .ok_or_else(|| sqlx::Error::RowNotFound)
140 }
141
142 /// The `FOR UPDATE` re-select used by [`Self::lock_or_init`]. NULL-safe on every dimension.
143 async fn fetch_for_update(
144 conn: &mut sqlx::PgConnection,
145 dims: QuantDims,
146 ) -> Result<Option<QuantRow>, sqlx::Error> {
147 let row = sqlx::query(
148 r#"SELECT id, item_id, location_id, lot_id, package_id, owner_id, quantity,
149 reserved_quantity, in_date
150 FROM inventory.stock_quants
151 WHERE item_id=$1 AND location_id=$2
152 AND lot_id IS NOT DISTINCT FROM $3
153 AND package_id IS NOT DISTINCT FROM $4
154 AND owner_id IS NOT DISTINCT FROM $5
155 AND (metadata->>'deleted_at') IS NULL
156 FOR UPDATE"#,
157 )
158 .bind(dims.item_id).bind(dims.location_id)
159 .bind(dims.lot_id).bind(dims.package_id).bind(dims.owner_id)
160 .fetch_optional(&mut *conn)
161 .await?;
162 Ok(row.map(|r| quant_row_of(r)))
163 }
164
165 /// The RESERVED step of `_synchronize_quant` (spec §3): move `delta` onto
166 /// `reserved_quantity` — the authoritative counter. `available_quantity` (the stored T2
167 /// compute) is rewritten in the SAME statement as the derived shadow `quantity - reserved`,
168 /// so it can never drift. The caller holds the row's `FOR UPDATE` from [`Self::lock_or_init`].
169 ///
170 /// `delta > 0` reserves; `delta < 0` releases. The table CHECK (`reserved_quantity >= 0`)
171 /// backstops release-over-reserve (a raw-SQL writer cannot underflow it — ADR-0015).
172 pub async fn adjust_reserved(
173 &self,
174 conn: &mut sqlx::PgConnection,
175 quant_id: Uuid,
176 delta: Decimal,
177 ) -> Result<QuantRow, sqlx::Error> {
178 let row = sqlx::query(
179 r#"UPDATE inventory.stock_quants
180 SET reserved_quantity = reserved_quantity + $2,
181 available_quantity = quantity - (reserved_quantity + $2)
182 WHERE id = $1 AND (metadata->>'deleted_at') IS NULL
183 RETURNING id, item_id, location_id, lot_id, package_id, owner_id, quantity,
184 reserved_quantity, in_date"#,
185 )
186 .bind(quant_id).bind(delta)
187 .fetch_one(&mut *conn)
188 .await?;
189 Ok(quant_row_of(row))
190 }
191
192 /// The AVAILABLE step of `_synchronize_quant` (spec §3): physically move stock —
193 /// `qty_delta` onto `quantity` (negative = stock leaves this quant), plus a simultaneous
194 /// `reserved_delta` (normally the release of the reservation this move consumed). The stored
195 /// T2 compute is rewritten in lockstep. Stamp `in_date` on first arrival (FIFO ordering
196 /// within the lot) when the quant transitions to a positive on-hand from non-positive.
197 pub async fn apply_qty(
198 &self,
199 conn: &mut sqlx::PgConnection,
200 quant_id: Uuid,
201 qty_delta: Decimal,
202 reserved_delta: Decimal,
203 ) -> Result<QuantRow, sqlx::Error> {
204 let row = sqlx::query(
205 r#"UPDATE inventory.stock_quants
206 SET quantity = quantity + $2,
207 reserved_quantity = reserved_quantity + $3,
208 available_quantity = (quantity + $2) - (reserved_quantity + $3),
209 in_date = CASE
210 WHEN in_date IS NULL AND (quantity + $2) > 0 THEN NOW()
211 ELSE in_date END
212 WHERE id = $1 AND (metadata->>'deleted_at') IS NULL
213 RETURNING id, item_id, location_id, lot_id, package_id, owner_id, quantity,
214 reserved_quantity, in_date"#,
215 )
216 .bind(quant_id).bind(qty_delta).bind(reserved_delta)
217 .fetch_one(&mut *conn)
218 .await?;
219 Ok(quant_row_of(row))
220 }
221
222 /// Reservation candidates at one (item, location): the quants with free availability,
223 /// oldest arrival first (`in_date` FIFO ordering — the default removal strategy; explicit
224 /// fifo/lifo/fefo strategies layer on top when the tracking surface lands). Takes the
225 /// caller's transaction: `_action_assign` locks each candidate as it reserves it.
226 pub async fn fetch_reservation_candidates(
227 &self,
228 conn: &mut sqlx::PgConnection,
229 item_id: Uuid,
230 location_id: Uuid,
231 ) -> Result<Vec<QuantRow>, sqlx::Error> {
232 let rows = sqlx::query(
233 r#"SELECT id, item_id, location_id, lot_id, package_id, owner_id, quantity,
234 reserved_quantity, in_date
235 FROM inventory.stock_quants
236 WHERE item_id=$1 AND location_id=$2
237 AND quantity - reserved_quantity > 0
238 AND (metadata->>'deleted_at') IS NULL
239 ORDER BY in_date NULLS LAST, id"#,
240 )
241 .bind(item_id).bind(location_id)
242 .fetch_all(&mut *conn)
243 .await?;
244 Ok(rows.into_iter().map(quant_row_of).collect())
245 }
246
247 /// Re-derive one quant's authoritative `reserved_quantity` from the MIRROR arm: the sum of
248 /// the live move-line quantities matching this quant's dimension tuple (state not
249 /// done/cancel). This is the triangle's self-heal — the reserved step of
250 /// `_synchronize_quant` after a move's lines flip out of the live set (done or cancel),
251 /// including the partial-validate case where an operator shrinks a line below what it had
252 /// reserved (the residual would otherwise stay held by no move). The mirror can only ever
253 /// reflect reservations the engine minted, so the SUM is the truth by construction.
254 ///
255 /// Takes the caller's transaction; the caller holds the quant's `FOR UPDATE` from
256 /// [`Self::lock_or_init`]. Returns the recomputed row.
257 pub async fn recompute_reserved_from_mirror(
258 &self,
259 conn: &mut sqlx::PgConnection,
260 quant_id: Uuid,
261 dims: QuantDims,
262 ) -> Result<QuantRow, sqlx::Error> {
263 let row = sqlx::query(
264 r#"UPDATE inventory.stock_quants q
265 SET reserved_quantity = m.mirror_qty,
266 available_quantity = q.quantity - m.mirror_qty
267 FROM (
268 SELECT COALESCE(SUM(l.quantity), 0) AS mirror_qty
269 FROM inventory.stock_move_lines l
270 WHERE l.item_id = $2
271 AND l.location_id = $3
272 AND l.lot_id IS NOT DISTINCT FROM $4
273 AND l.package_id IS NOT DISTINCT FROM $5
274 AND l.owner_id IS NOT DISTINCT FROM $6
275 AND l.state::text NOT IN ('done', 'cancel')
276 AND (l.metadata->>'deleted_at') IS NULL
277 ) m
278 WHERE q.id = $1 AND (q.metadata->>'deleted_at') IS NULL
279 RETURNING q.id, q.item_id, q.location_id, q.lot_id, q.package_id, q.owner_id,
280 q.quantity, q.reserved_quantity, q.in_date"#,
281 )
282 .bind(quant_id)
283 .bind(dims.item_id)
284 .bind(dims.location_id)
285 .bind(dims.lot_id)
286 .bind(dims.package_id)
287 .bind(dims.owner_id)
288 .fetch_one(&mut *conn)
289 .await?;
290 Ok(quant_row_of(row))
291 }
292}
293
294/// On-hand aggregation at one location — the `available = quantity - reserved` READ (T2), summed
295/// over the location's quant grain. Pool-based (each read its own unit of work), riding the
296/// ambient org scope like every read in the suite.
297pub struct QuantOnHandRow {
298 pub on_hand_qty: Decimal,
299 pub reserved_qty: Decimal,
300 pub quant_count: i64,
301}
302
303impl QuantRepository {
304 /// SUM(quantity) / SUM(reserved_quantity) over one (item, location). Zeroed row when
305 /// no quant exists (an unreceived item is unavailable, not an error).
306 pub async fn fetch_on_hand(
307 &self,
308 pool: &PgPool,
309 item_id: Uuid,
310 location_id: Uuid,
311 ) -> Result<QuantOnHandRow, sqlx::Error> {
312 let row = backbone_orm::company_scope::fetch_one_row_scoped(
313 pool,
314 sqlx::query(
315 r#"SELECT
316 COALESCE(SUM(quantity), 0) AS on_hand_qty,
317 COALESCE(SUM(reserved_quantity), 0) AS reserved_qty,
318 COUNT(*) AS quant_count
319 FROM inventory.stock_quants
320 WHERE item_id=$1 AND location_id=$2
321 AND (metadata->>'deleted_at') IS NULL"#,
322 )
323 .bind(item_id).bind(location_id),
324 )
325 .await?;
326 Ok(QuantOnHandRow {
327 on_hand_qty: row.get("on_hand_qty"),
328 reserved_qty: row.get("reserved_qty"),
329 quant_count: row.get("quant_count"),
330 })
331 }
332
333 /// One warehouse-scope availability row (the shop-pivot read): SUM(quantity) /
334 /// SUM(reserved_quantity) over every quant whose location belongs to the pivot warehouse and
335 /// holds stock (usage `internal`). `available` is NOT read off the stored
336 /// `available_quantity` column — the caller computes `on_hand − reserved` per call, so the
337 /// number is fresh off the reservation triangle regardless of any stored-compute staleness.
338 ///
339 /// Batch over `item_ids = ANY($1)`; items with no quant rows in the warehouse are simply
340 /// absent from the result (the caller projects them as zeroed, unavailable rows — an
341 /// unreceived item is unavailable, not an error). Pool-based and org-scoped: the read rides
342 /// the ambient org scope the composing service set per request (ADR-0029).
343 pub async fn fetch_warehouse_on_hand(
344 &self,
345 pool: &PgPool,
346 item_ids: &[Uuid],
347 warehouse_id: Uuid,
348 ) -> Result<Vec<QuantWarehouseRow>, sqlx::Error> {
349 let rows = backbone_orm::company_scope::fetch_all_rows_scoped(
350 pool,
351 sqlx::query(
352 r#"SELECT
353 q.item_id AS item_id,
354 COALESCE(SUM(q.quantity), 0) AS on_hand_qty,
355 COALESCE(SUM(q.reserved_quantity), 0) AS reserved_qty
356 FROM inventory.stock_quants q
357 JOIN inventory.locations l ON l.id = q.location_id
358 WHERE q.item_id = ANY($1)
359 AND l.warehouse_id = $2
360 AND l.usage = 'internal'::location_usage
361 AND (q.metadata->>'deleted_at') IS NULL
362 AND (l.metadata->>'deleted_at') IS NULL
363 GROUP BY q.item_id"#,
364 )
365 .bind(item_ids)
366 .bind(warehouse_id),
367 )
368 .await?;
369 Ok(rows
370 .into_iter()
371 .map(|r| QuantWarehouseRow {
372 item_id: r.get("item_id"),
373 on_hand_qty: r.get("on_hand_qty"),
374 reserved_qty: r.get("reserved_qty"),
375 })
376 .collect())
377 }
378}
379
380/// One item's warehouse-scope on-hand pair (the shop-pivot availability read).
381pub struct QuantWarehouseRow {
382 pub item_id: Uuid,
383 pub on_hand_qty: Decimal,
384 pub reserved_qty: Decimal,
385}
386
387fn quant_row_of(r: sqlx::postgres::PgRow) -> QuantRow {
388 QuantRow {
389 id: r.get("id"),
390 item_id: r.get("item_id"),
391 location_id: r.get("location_id"),
392 lot_id: r.get("lot_id"),
393 package_id: r.get("package_id"),
394 owner_id: r.get("owner_id"),
395 quantity: r.get("quantity"),
396 reserved_quantity: r.get("reserved_quantity"),
397 in_date: r.get("in_date"),
398 }
399}
400
401backbone_core::impl_crud_repository!(QuantRepository, Quant, soft_delete);