backbone_inventory/application/service/
inventory_reservation.rs1use chrono::{DateTime, Utc};
27use rust_decimal::Decimal;
28use uuid::Uuid;
29
30use crate::infrastructure::persistence::stock_reservation_repository::{
31 ReservationRepoError, StockReservationRepository as Repo,
32};
33
34#[derive(Debug, thiserror::Error)]
35pub enum ReservationError {
36 #[error("db: {0}")]
37 Db(#[from] sqlx::Error),
38 #[error("cannot hold {asked} of item {item}: availability after existing holds is {available}")]
39 InsufficientForHold {
40 item: Uuid,
41 asked: Decimal,
42 available: Decimal,
43 },
44 #[error("hold lines carry no quantities")]
45 EmptyHold,
46}
47
48#[derive(Debug, Clone)]
50pub struct HoldLine {
51 pub item_id: Uuid,
52 pub qty: Decimal,
53}
54
55#[derive(Debug, Clone)]
58pub struct HoldAckLine {
59 pub item_id: Uuid,
60 pub reservation_id: Uuid,
61 pub remaining_after_hold: Decimal,
62}
63
64#[derive(Clone)]
67pub struct ReservationService {
68 pool: sqlx::PgPool,
69}
70
71impl ReservationService {
72 pub fn new(pool: sqlx::PgPool) -> Self {
73 Self { pool }
74 }
75
76 async fn scoped_tx(
77 &self,
78 ) -> Result<sqlx::Transaction<'_, sqlx::Postgres>, sqlx::Error> {
79 let mut tx = self.pool.begin().await?;
80 if let Some(scope) = backbone_orm::org_scope::current_org_scope() {
81 backbone_orm::org_scope::bind_org_scope_on(&mut tx, &scope).await?;
82 }
83 Ok(tx)
84 }
85
86 pub async fn hold(
91 &self,
92 ref_type: &str,
93 ref_id: Uuid,
94 warehouse_id: Uuid,
95 lines: &[HoldLine],
96 expires_at: DateTime<Utc>,
97 ) -> Result<Vec<HoldAckLine>, ReservationError> {
98 if lines.is_empty() || lines.iter().any(|l| l.qty <= Decimal::ZERO) {
99 return Err(ReservationError::EmptyHold);
100 }
101 let availability = self
104 .availability_after_holds(warehouse_id)
105 .await?;
106 let mut tx = self.scoped_tx().await?;
107 Repo::release_for_ref(&mut tx, ref_type, ref_id, Utc::now()).await?;
110 let mut acks = Vec::with_capacity(lines.len());
111 for line in lines {
112 let available = availability
113 .iter()
114 .find(|(item, _)| *item == line.item_id)
115 .map(|(_, qty)| *qty)
116 .unwrap_or(Decimal::ZERO);
117 if line.qty > available {
118 return Err(ReservationError::InsufficientForHold {
119 item: line.item_id,
120 asked: line.qty,
121 available,
122 });
123 }
124 let id: Uuid = sqlx::query_scalar(
126 r#"INSERT INTO inventory.stock_reservations
127 (ref_type, ref_id, warehouse_id, item_id, qty, status, expires_at)
128 VALUES ($1, $2, $3, $4, $5, 'held', $6)
129 RETURNING id"#,
130 )
131 .bind(ref_type)
132 .bind(ref_id)
133 .bind(warehouse_id)
134 .bind(line.item_id)
135 .bind(line.qty)
136 .bind(expires_at)
137 .fetch_one(&mut *tx)
138 .await?;
139 acks.push(HoldAckLine {
140 item_id: line.item_id,
141 reservation_id: id,
142 remaining_after_hold: available - line.qty,
143 });
144 }
145 tx.commit().await?;
146 Ok(acks)
147 }
148
149 pub async fn release(&self, ref_type: &str, ref_id: Uuid) -> Result<usize, ReservationError> {
151 let mut tx = self.scoped_tx().await?;
152 let released = Repo::release_for_ref(&mut tx, ref_type, ref_id, Utc::now()).await?;
153 tx.commit().await?;
154 Ok(released.len())
155 }
156
157 pub async fn consume(&self, ref_type: &str, ref_id: Uuid) -> Result<usize, ReservationError> {
160 let mut tx = self.scoped_tx().await?;
161 let consumed = Repo::consume_for_ref(&mut tx, ref_type, ref_id, Utc::now()).await?;
162 tx.commit().await?;
163 Ok(consumed.len())
164 }
165
166 pub async fn held_for_ref(
168 &self,
169 ref_type: &str,
170 ref_id: Uuid,
171 ) -> Result<
172 Vec<crate::infrastructure::persistence::stock_reservation_repository::HeldReservation>,
173 ReservationError,
174 > {
175 let mut conn = self.pool.acquire().await?;
176 if let Some(scope) = backbone_orm::org_scope::current_org_scope() {
177 let _ = backbone_orm::org_scope::bind_org_scope_on(&mut conn, &scope).await;
180 }
181 Ok(Repo::held_for_ref(&mut conn, ref_type, ref_id).await?)
182 }
183
184 pub async fn sweep_expired(&self, limit: i64) -> Result<usize, ReservationError> {
187 let mut tx = self.scoped_tx().await?;
188 let expired = Repo::expire_stale(&mut tx, Utc::now(), limit).await?;
189 tx.commit().await?;
190 Ok(expired.len())
191 }
192
193 async fn availability_after_holds(
199 &self,
200 warehouse_id: Uuid,
201 ) -> Result<Vec<(Uuid, Decimal)>, ReservationError> {
202 let mut conn = self.pool.acquire().await?;
203 if let Some(scope) = backbone_orm::org_scope::current_org_scope() {
204 let _ = backbone_orm::org_scope::bind_org_scope_on(&mut conn, &scope).await;
205 }
206 let rows: Vec<(Uuid, Decimal)> = sqlx::query_as(
207 r#"WITH triangle AS (
208 SELECT q.item_id,
209 COALESCE(SUM(q.quantity - q.reserved_quantity), 0) AS avail
210 FROM inventory.stock_quants q
211 JOIN inventory.locations l ON l.id = q.location_id
212 WHERE l.warehouse_id = $1
213 AND l.usage::text = 'internal'
214 AND (q.metadata->>'deleted_at') IS NULL
215 AND (l.metadata->>'deleted_at') IS NULL
216 GROUP BY q.item_id
217 ),
218 held AS (
219 SELECT item_id, COALESCE(SUM(qty), 0) AS held
220 FROM inventory.stock_reservations
221 WHERE warehouse_id = $1 AND status = 'held' AND expires_at > now()
222 GROUP BY item_id
223 )
224 SELECT t.item_id, t.avail - COALESCE(h.held, 0)
225 FROM triangle t LEFT JOIN held h ON h.item_id = t.item_id"#,
226 )
227 .bind(warehouse_id)
228 .fetch_all(&mut *conn)
229 .await?;
230 Ok(rows)
231 }
232}
233
234impl From<ReservationRepoError> for ReservationError {
235 fn from(e: ReservationRepoError) -> Self {
236 match e {
237 ReservationRepoError::Db(db) => ReservationError::Db(db),
238 }
239 }
240}