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 fn rpool(&self) -> sqlx::PgPool {
80 crate::request_pool::current().unwrap_or_else(|| self.pool.clone())
81 }
82
83 async fn scoped_tx(
84 &self,
85 ) -> Result<sqlx::Transaction<'_, sqlx::Postgres>, sqlx::Error> {
86 let mut tx = self.rpool().begin().await?;
87 if let Some(scope) = backbone_orm::org_scope::current_org_scope() {
88 backbone_orm::org_scope::bind_org_scope_on(&mut tx, &scope).await?;
89 }
90 Ok(tx)
91 }
92
93 pub async fn hold(
98 &self,
99 ref_type: &str,
100 ref_id: Uuid,
101 warehouse_id: Uuid,
102 lines: &[HoldLine],
103 expires_at: DateTime<Utc>,
104 ) -> Result<Vec<HoldAckLine>, ReservationError> {
105 if lines.is_empty() || lines.iter().any(|l| l.qty <= Decimal::ZERO) {
106 return Err(ReservationError::EmptyHold);
107 }
108 let availability = self
111 .availability_after_holds(warehouse_id)
112 .await?;
113 let mut tx = self.scoped_tx().await?;
114 Repo::release_for_ref(&mut tx, ref_type, ref_id, Utc::now()).await?;
117 let mut acks = Vec::with_capacity(lines.len());
118 for line in lines {
119 let available = availability
120 .iter()
121 .find(|(item, _)| *item == line.item_id)
122 .map(|(_, qty)| *qty)
123 .unwrap_or(Decimal::ZERO);
124 if line.qty > available {
125 return Err(ReservationError::InsufficientForHold {
126 item: line.item_id,
127 asked: line.qty,
128 available,
129 });
130 }
131 let id: Uuid = sqlx::query_scalar(
133 r#"INSERT INTO inventory.stock_reservations
134 (ref_type, ref_id, warehouse_id, item_id, qty, status, expires_at)
135 VALUES ($1, $2, $3, $4, $5, 'held', $6)
136 RETURNING id"#,
137 )
138 .bind(ref_type)
139 .bind(ref_id)
140 .bind(warehouse_id)
141 .bind(line.item_id)
142 .bind(line.qty)
143 .bind(expires_at)
144 .fetch_one(&mut *tx)
145 .await?;
146 acks.push(HoldAckLine {
147 item_id: line.item_id,
148 reservation_id: id,
149 remaining_after_hold: available - line.qty,
150 });
151 }
152 tx.commit().await?;
153 Ok(acks)
154 }
155
156 pub async fn release(&self, ref_type: &str, ref_id: Uuid) -> Result<usize, ReservationError> {
158 let mut tx = self.scoped_tx().await?;
159 let released = Repo::release_for_ref(&mut tx, ref_type, ref_id, Utc::now()).await?;
160 tx.commit().await?;
161 Ok(released.len())
162 }
163
164 pub async fn consume(&self, ref_type: &str, ref_id: Uuid) -> Result<usize, ReservationError> {
167 let mut tx = self.scoped_tx().await?;
168 let consumed = Repo::consume_for_ref(&mut tx, ref_type, ref_id, Utc::now()).await?;
169 tx.commit().await?;
170 Ok(consumed.len())
171 }
172
173 pub async fn held_for_ref(
175 &self,
176 ref_type: &str,
177 ref_id: Uuid,
178 ) -> Result<
179 Vec<crate::infrastructure::persistence::stock_reservation_repository::HeldReservation>,
180 ReservationError,
181 > {
182 let mut conn = self.rpool().acquire().await?;
183 if let Some(scope) = backbone_orm::org_scope::current_org_scope() {
184 let _ = backbone_orm::org_scope::bind_org_scope_on(&mut conn, &scope).await;
187 }
188 Ok(Repo::held_for_ref(&mut conn, ref_type, ref_id).await?)
189 }
190
191 pub async fn sweep_expired(&self, limit: i64) -> Result<usize, ReservationError> {
194 let mut tx = self.scoped_tx().await?;
195 let expired = Repo::expire_stale(&mut tx, Utc::now(), limit).await?;
196 tx.commit().await?;
197 Ok(expired.len())
198 }
199
200 async fn availability_after_holds(
206 &self,
207 warehouse_id: Uuid,
208 ) -> Result<Vec<(Uuid, Decimal)>, ReservationError> {
209 let mut conn = self.rpool().acquire().await?;
210 if let Some(scope) = backbone_orm::org_scope::current_org_scope() {
211 let _ = backbone_orm::org_scope::bind_org_scope_on(&mut conn, &scope).await;
212 }
213 let rows: Vec<(Uuid, Decimal)> = sqlx::query_as(
214 r#"WITH triangle AS (
215 SELECT q.item_id,
216 COALESCE(SUM(q.quantity - q.reserved_quantity), 0) AS avail
217 FROM inventory.stock_quants q
218 JOIN inventory.locations l ON l.id = q.location_id
219 WHERE l.warehouse_id = $1
220 AND l.usage::text = 'internal'
221 AND (q.metadata->>'deleted_at') IS NULL
222 AND (l.metadata->>'deleted_at') IS NULL
223 GROUP BY q.item_id
224 ),
225 held AS (
226 SELECT item_id, COALESCE(SUM(qty), 0) AS held
227 FROM inventory.stock_reservations
228 WHERE warehouse_id = $1 AND status = 'held' AND expires_at > now()
229 GROUP BY item_id
230 )
231 SELECT t.item_id, t.avail - COALESCE(h.held, 0)
232 FROM triangle t LEFT JOIN held h ON h.item_id = t.item_id"#,
233 )
234 .bind(warehouse_id)
235 .fetch_all(&mut *conn)
236 .await?;
237 Ok(rows)
238 }
239}
240
241impl From<ReservationRepoError> for ReservationError {
242 fn from(e: ReservationRepoError) -> Self {
243 match e {
244 ReservationRepoError::Db(db) => ReservationError::Db(db),
245 }
246 }
247}