1use 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
21pub 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 #[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 #[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 pub fn get_item(&self, id: i64) -> Result<Option<InventoryItem>> {
148 self.db.inventory().get_item(id)
149 }
150
151 pub fn get_item_by_sku(&self, sku: &str) -> Result<Option<InventoryItem>> {
153 self.db.inventory().get_item_by_sku(sku)
154 }
155
156 pub fn get_stock(&self, sku: &str) -> Result<Option<StockLevel>> {
172 self.db.inventory().get_stock(sku)
173 }
174
175 #[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 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 #[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 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 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 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 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 pub fn list(&self, filter: InventoryFilter) -> Result<Vec<InventoryItem>> {
358 self.db.inventory().list(filter)
359 }
360
361 pub fn get_reorder_needed(&self) -> Result<Vec<StockLevel>> {
363 self.db.inventory().get_reorder_needed()
364 }
365
366 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 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}