Skip to main content

stateset_embedded/
inventory.rs

1//! Inventory operations
2
3use rust_decimal::Decimal;
4use rust_decimal::prelude::ToPrimitive;
5use stateset_core::{
6    CreateInventoryItem, InventoryFilter, InventoryItem, InventoryReservation,
7    InventoryTransaction, Result, StockLevel,
8};
9use stateset_db::Database;
10use stateset_observability::Metrics;
11use std::sync::Arc;
12use uuid::Uuid;
13
14#[cfg(feature = "events")]
15use crate::events::EventSystem;
16#[cfg(feature = "events")]
17use chrono::Utc;
18#[cfg(feature = "events")]
19use stateset_core::CommerceEvent;
20
21/// Inventory operations interface.
22pub struct Inventory {
23    db: Arc<dyn Database>,
24    metrics: Metrics,
25    #[cfg(feature = "events")]
26    event_system: Arc<EventSystem>,
27}
28
29impl std::fmt::Debug for Inventory {
30    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
31        f.debug_struct("Inventory").finish_non_exhaustive()
32    }
33}
34
35impl Inventory {
36    #[cfg(feature = "events")]
37    pub(crate) fn new(
38        db: Arc<dyn Database>,
39        event_system: Arc<EventSystem>,
40        metrics: Metrics,
41    ) -> Self {
42        Self { db, metrics, event_system }
43    }
44
45    #[cfg(not(feature = "events"))]
46    pub(crate) fn new(db: Arc<dyn Database>, metrics: Metrics) -> Self {
47        Self { db, metrics }
48    }
49
50    #[cfg(feature = "events")]
51    fn emit(&self, event: CommerceEvent) {
52        self.event_system.emit(event);
53    }
54
55    #[cfg(feature = "events")]
56    fn emit_adjustment_events(&self, transaction: &InventoryTransaction, sku: &str, reason: &str) {
57        if let Ok(Some(balance)) =
58            self.db.inventory().get_balance(transaction.item_id, transaction.location_id)
59        {
60            self.emit(CommerceEvent::InventoryAdjusted {
61                item_id: transaction.item_id,
62                sku: sku.to_string(),
63                location_id: transaction.location_id,
64                quantity_change: transaction.quantity,
65                new_quantity: balance.quantity_on_hand,
66                reason: reason.to_string(),
67                timestamp: transaction.created_at,
68            });
69
70            if let Some(reorder_point) = balance.reorder_point {
71                if balance.quantity_available < reorder_point {
72                    self.emit(CommerceEvent::LowStockAlert {
73                        sku: sku.to_string(),
74                        location_id: transaction.location_id,
75                        current_quantity: balance.quantity_available,
76                        reorder_point,
77                        timestamp: transaction.created_at,
78                    });
79                }
80            }
81        }
82    }
83
84    /// Best-effort: called after the reservation write has committed, so a
85    /// transient read failure here must not surface as an error for a
86    /// mutation that already succeeded (callers could otherwise retry the
87    /// committed write and corrupt allocation accounting).
88    #[cfg(feature = "events")]
89    fn emit_reservation_event(
90        &self,
91        reservation_id: Uuid,
92        event: fn(InventoryReservation, String) -> CommerceEvent,
93    ) {
94        let reservation = match self.db.inventory().get_reservation(reservation_id) {
95            Ok(Some(reservation)) => reservation,
96            Ok(None) => return,
97            Err(e) => {
98                tracing::warn!(error = %e, %reservation_id, "reservation event lookup failed after commit");
99                return;
100            }
101        };
102        let sku = match self.db.inventory().get_item(reservation.item_id) {
103            Ok(Some(item)) => item.sku,
104            Ok(None) => return,
105            Err(e) => {
106                tracing::warn!(error = %e, %reservation_id, "reservation event item lookup failed after commit");
107                return;
108            }
109        };
110        self.emit(event(reservation, sku));
111    }
112
113    /// Create a new inventory item (SKU).
114    ///
115    /// # Example
116    ///
117    /// ```rust,no_run
118    /// # use stateset_embedded::*;
119    /// # use rust_decimal_macros::dec;
120    /// # let commerce = Commerce::new(":memory:")?;
121    /// commerce.inventory().create_item(CreateInventoryItem {
122    ///     sku: "SKU-001".into(),
123    ///     name: "Widget".into(),
124    ///     initial_quantity: Some(dec!(100)),
125    ///     reorder_point: Some(dec!(10)),
126    ///     ..Default::default()
127    /// })?;
128    /// # Ok::<(), CommerceError>(())
129    /// ```
130    #[tracing::instrument(skip(self, input), fields(sku = %input.sku, name = %input.name))]
131    pub fn create_item(&self, input: CreateInventoryItem) -> Result<InventoryItem> {
132        tracing::info!("creating inventory item");
133        let item = self.db.inventory().create_item(input)?;
134        #[cfg(feature = "events")]
135        {
136            self.emit(CommerceEvent::InventoryItemCreated {
137                item_id: item.id,
138                sku: item.sku.clone(),
139                name: item.name.clone(),
140                timestamp: item.created_at,
141            });
142        }
143        Ok(item)
144    }
145
146    /// Get an inventory item by ID.
147    pub fn get_item(&self, id: i64) -> Result<Option<InventoryItem>> {
148        self.db.inventory().get_item(id)
149    }
150
151    /// Get an inventory item by SKU.
152    pub fn get_item_by_sku(&self, sku: &str) -> Result<Option<InventoryItem>> {
153        self.db.inventory().get_item_by_sku(sku)
154    }
155
156    /// Get stock level for a SKU (aggregated across all locations).
157    ///
158    /// # Example
159    ///
160    /// ```rust,no_run
161    /// # use stateset_embedded::*;
162    /// # let commerce = Commerce::new(":memory:")?;
163    /// if let Some(stock) = commerce.inventory().get_stock("SKU-001")? {
164    ///     println!("Available: {}", stock.total_available);
165    ///     for loc in stock.locations {
166    ///         println!("  Location {}: {}", loc.location_id, loc.available);
167    ///     }
168    /// }
169    /// # Ok::<(), CommerceError>(())
170    /// ```
171    pub fn get_stock(&self, sku: &str) -> Result<Option<StockLevel>> {
172        self.db.inventory().get_stock(sku)
173    }
174
175    /// Adjust inventory quantity.
176    ///
177    /// Use positive numbers to add stock, negative to remove.
178    ///
179    /// # Example
180    ///
181    /// ```rust,no_run
182    /// # use stateset_embedded::*;
183    /// # use rust_decimal_macros::dec;
184    /// # let commerce = Commerce::new(":memory:")?;
185    /// // Add 50 units
186    /// commerce.inventory().adjust("SKU-001", dec!(50), "Restocked from supplier")?;
187    ///
188    /// // Remove 5 units
189    /// commerce.inventory().adjust("SKU-001", dec!(-5), "Damaged items")?;
190    /// # Ok::<(), CommerceError>(())
191    /// ```
192    #[tracing::instrument(skip(self), fields(sku = %sku, quantity = %quantity))]
193    pub fn adjust(
194        &self,
195        sku: &str,
196        quantity: Decimal,
197        reason: &str,
198    ) -> Result<InventoryTransaction> {
199        tracing::info!("adjusting inventory");
200        let transaction = self.db.inventory().adjust(stateset_core::AdjustInventory {
201            sku: sku.to_string(),
202            location_id: None,
203            quantity,
204            reason: reason.to_string(),
205            reference_type: None,
206            reference_id: None,
207        })?;
208        self.metrics.record_inventory_adjusted(sku, quantity.to_f64().unwrap_or(0.0));
209        #[cfg(feature = "events")]
210        {
211            self.emit_adjustment_events(&transaction, sku, reason);
212        }
213        Ok(transaction)
214    }
215
216    /// Adjust inventory at a specific location.
217    pub fn adjust_at_location(
218        &self,
219        sku: &str,
220        location_id: i32,
221        quantity: Decimal,
222        reason: &str,
223    ) -> Result<InventoryTransaction> {
224        let transaction = self.db.inventory().adjust(stateset_core::AdjustInventory {
225            sku: sku.to_string(),
226            location_id: Some(location_id),
227            quantity,
228            reason: reason.to_string(),
229            reference_type: None,
230            reference_id: None,
231        })?;
232        self.metrics.record_inventory_adjusted(sku, quantity.to_f64().unwrap_or(0.0));
233        #[cfg(feature = "events")]
234        {
235            self.emit_adjustment_events(&transaction, sku, reason);
236        }
237        Ok(transaction)
238    }
239
240    /// Reserve inventory for an order or other reference.
241    ///
242    /// # Example
243    ///
244    /// ```rust,no_run
245    /// # use stateset_embedded::*;
246    /// # use rust_decimal_macros::dec;
247    /// # let commerce = Commerce::new(":memory:")?;
248    /// let reservation = commerce.inventory().reserve(
249    ///     "SKU-001",
250    ///     dec!(5),
251    ///     "order",
252    ///     "ord_12345",
253    ///     Some(3600), // Expires in 1 hour
254    /// )?;
255    /// # Ok::<(), CommerceError>(())
256    /// ```
257    #[tracing::instrument(skip(self), fields(sku = %sku, quantity = %quantity, reference_type = %reference_type))]
258    pub fn reserve(
259        &self,
260        sku: &str,
261        quantity: Decimal,
262        reference_type: &str,
263        reference_id: &str,
264        expires_in_seconds: Option<i64>,
265    ) -> Result<InventoryReservation> {
266        tracing::info!("reserving inventory");
267        let reservation = self.db.inventory().reserve(stateset_core::ReserveInventory {
268            sku: sku.to_string(),
269            location_id: None,
270            quantity,
271            reference_type: reference_type.to_string(),
272            reference_id: reference_id.to_string(),
273            expires_in_seconds,
274        })?;
275        #[cfg(feature = "events")]
276        {
277            self.emit(CommerceEvent::InventoryReserved {
278                reservation_id: reservation.id,
279                sku: sku.to_string(),
280                quantity: reservation.quantity,
281                reference_type: reservation.reference_type.clone(),
282                reference_id: reservation.reference_id.clone(),
283                timestamp: reservation.created_at,
284            });
285
286            // The reservation is already committed; this balance read only feeds
287            // the best-effort low-stock alert. A transient read failure here must
288            // not surface as a reservation error (the caller would see Err for a
289            // reservation that is durably allocated).
290            match self.db.inventory().get_balance(reservation.item_id, reservation.location_id) {
291                Ok(Some(balance)) => {
292                    if let Some(reorder_point) = balance.reorder_point {
293                        if balance.quantity_available < reorder_point {
294                            self.emit(CommerceEvent::LowStockAlert {
295                                sku: sku.to_string(),
296                                location_id: reservation.location_id,
297                                current_quantity: balance.quantity_available,
298                                reorder_point,
299                                timestamp: reservation.created_at,
300                            });
301                        }
302                    }
303                }
304                Ok(None) => {}
305                Err(e) => {
306                    tracing::warn!(error = %e, sku, "low-stock check failed after reservation commit");
307                }
308            }
309        }
310        Ok(reservation)
311    }
312
313    /// Release a reservation.
314    pub fn release_reservation(&self, reservation_id: Uuid) -> Result<()> {
315        self.db.inventory().release_reservation(reservation_id)?;
316        #[cfg(feature = "events")]
317        {
318            self.emit_reservation_event(reservation_id, |reservation, sku| {
319                CommerceEvent::InventoryReservationReleased {
320                    reservation_id: reservation.id,
321                    sku,
322                    quantity: reservation.quantity,
323                    timestamp: Utc::now(),
324                }
325            });
326        }
327        Ok(())
328    }
329
330    /// Confirm a reservation (marks as allocated).
331    pub fn confirm_reservation(&self, reservation_id: Uuid) -> Result<()> {
332        self.db.inventory().confirm_reservation(reservation_id)?;
333        #[cfg(feature = "events")]
334        {
335            self.emit_reservation_event(reservation_id, |reservation, sku| {
336                CommerceEvent::InventoryReservationConfirmed {
337                    reservation_id: reservation.id,
338                    sku,
339                    quantity: reservation.quantity,
340                    timestamp: Utc::now(),
341                }
342            });
343        }
344        Ok(())
345    }
346
347    /// List reservations by reference (e.g., order id).
348    pub fn list_reservations_by_reference(
349        &self,
350        reference_type: &str,
351        reference_id: &str,
352    ) -> Result<Vec<InventoryReservation>> {
353        self.db.inventory().list_reservations_by_reference(reference_type, reference_id)
354    }
355
356    /// List inventory items with optional filtering.
357    pub fn list(&self, filter: InventoryFilter) -> Result<Vec<InventoryItem>> {
358        self.db.inventory().list(filter)
359    }
360
361    /// Get items that need reordering (below reorder point).
362    pub fn get_reorder_needed(&self) -> Result<Vec<StockLevel>> {
363        self.db.inventory().get_reorder_needed()
364    }
365
366    /// Get transaction history for an item.
367    pub fn get_transactions(&self, item_id: i64, limit: u32) -> Result<Vec<InventoryTransaction>> {
368        self.db.inventory().get_transactions(item_id, limit)
369    }
370
371    /// Check if a SKU has sufficient available quantity.
372    pub fn has_stock(&self, sku: &str, quantity: Decimal) -> Result<bool> {
373        if quantity < Decimal::ZERO {
374            return Err(stateset_core::CommerceError::ValidationError(
375                "Quantity to check must be non-negative".to_string(),
376            ));
377        }
378
379        if let Some(stock) = self.get_stock(sku)? {
380            Ok(stock.total_available >= quantity)
381        } else {
382            Err(stateset_core::CommerceError::NotFound)
383        }
384    }
385}