Skip to main content

stateset_embedded/
warehouse.rs

1//! Warehouse and Location management operations
2//!
3//! Comprehensive warehouse management system supporting:
4//! - Warehouse definitions and configuration
5//! - Location hierarchy (zones, aisles, racks, bins)
6//! - Location inventory tracking
7//! - Inventory movements and transfers
8//!
9//! # Example
10//!
11//! ```ignore
12//! use stateset_embedded::{Commerce, CreateWarehouse, CreateLocation, WarehouseType, LocationType};
13//!
14//! let commerce = Commerce::new("./store.db")?;
15//!
16//! // Create a warehouse
17//! let warehouse = commerce.warehouse().create_warehouse(CreateWarehouse {
18//!     code: "WH-001".into(),
19//!     name: "Main Distribution Center".into(),
20//!     warehouse_type: WarehouseType::Distribution,
21//!     ..Default::default()
22//! })?;
23//!
24//! // Create a location within the warehouse
25//! let location = commerce.warehouse().create_location(CreateLocation {
26//!     warehouse_id: warehouse.id,
27//!     location_type: LocationType::Pick,
28//!     zone: Some("A".into()),
29//!     aisle: Some("01".into()),
30//!     rack: Some("02".into()),
31//!     bin: Some("03".into()),
32//!     ..Default::default()
33//! })?;
34//!
35//! println!("Created location {} in warehouse {}", location.code, warehouse.name);
36//! # Ok::<(), stateset_embedded::CommerceError>(())
37//! ```
38
39use rust_decimal::Decimal;
40use stateset_core::{
41    AdjustLocationInventory, BatchResult, CreateCycleCount, CreateLocation, CreateWarehouse,
42    CreateZone, CycleCount, CycleCountFilter, Location, LocationFilter, LocationInventory,
43    LocationInventoryFilter, LocationMovement, MoveInventory, MovementFilter, RecordCycleCountLine,
44    Result, UpdateLocation, UpdateWarehouse, UpdateZone, Warehouse, WarehouseFilter, Zone,
45};
46use stateset_db::Database;
47use std::sync::Arc;
48use uuid::Uuid;
49
50#[cfg(feature = "events")]
51use crate::events::EventSystem;
52#[cfg(feature = "events")]
53use chrono::Utc;
54#[cfg(feature = "events")]
55use stateset_core::CommerceEvent;
56
57/// Warehouse and Location management interface.
58pub struct WarehouseOps {
59    db: Arc<dyn Database>,
60    #[cfg(feature = "events")]
61    event_system: Arc<EventSystem>,
62}
63
64impl std::fmt::Debug for WarehouseOps {
65    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66        f.debug_struct("WarehouseOps").finish_non_exhaustive()
67    }
68}
69
70impl WarehouseOps {
71    #[cfg(feature = "events")]
72    pub(crate) fn new(db: Arc<dyn Database>, event_system: Arc<EventSystem>) -> Self {
73        Self { db, event_system }
74    }
75
76    #[cfg(not(feature = "events"))]
77    pub(crate) fn new(db: Arc<dyn Database>) -> Self {
78        Self { db }
79    }
80
81    #[cfg(feature = "events")]
82    fn emit(&self, event: CommerceEvent) {
83        self.event_system.emit(event);
84    }
85
86    // ========================================================================
87    // Warehouse Operations
88    // ========================================================================
89
90    /// Create a new warehouse.
91    ///
92    /// # Example
93    ///
94    /// ```rust,no_run
95    /// use stateset_embedded::{Commerce, CreateWarehouse, WarehouseType, WarehouseAddress};
96    ///
97    /// let commerce = Commerce::new(":memory:")?;
98    ///
99    /// let warehouse = commerce.warehouse().create_warehouse(CreateWarehouse {
100    ///     code: "DC-EAST".into(),
101    ///     name: "East Coast Distribution Center".into(),
102    ///     warehouse_type: WarehouseType::Distribution,
103    ///     address: WarehouseAddress {
104    ///         street1: "123 Logistics Way".into(),
105    ///         city: "Newark".into(),
106    ///         state: "NJ".into(),
107    ///         postal_code: "07102".into(),
108    ///         country: "US".into(),
109    ///         ..Default::default()
110    ///     },
111    ///     timezone: Some("America/New_York".into()),
112    /// })?;
113    /// # Ok::<(), stateset_embedded::CommerceError>(())
114    /// ```
115    pub fn create_warehouse(&self, input: CreateWarehouse) -> Result<Warehouse> {
116        self.db.warehouse().create_warehouse(input)
117    }
118
119    /// Get a warehouse by ID.
120    pub fn get_warehouse(&self, id: i32) -> Result<Option<Warehouse>> {
121        self.db.warehouse().get_warehouse(id)
122    }
123
124    /// Get a warehouse by code.
125    pub fn get_warehouse_by_code(&self, code: &str) -> Result<Option<Warehouse>> {
126        self.db.warehouse().get_warehouse_by_code(code)
127    }
128
129    /// Update a warehouse.
130    pub fn update_warehouse(&self, id: i32, input: UpdateWarehouse) -> Result<Warehouse> {
131        self.db.warehouse().update_warehouse(id, input)
132    }
133
134    /// List warehouses with optional filtering.
135    ///
136    /// # Example
137    ///
138    /// ```rust,no_run
139    /// use stateset_embedded::{Commerce, WarehouseFilter, WarehouseType};
140    ///
141    /// let commerce = Commerce::new(":memory:")?;
142    ///
143    /// // Get all active distribution centers
144    /// let warehouses = commerce.warehouse().list_warehouses(WarehouseFilter {
145    ///     warehouse_type: Some(WarehouseType::Distribution),
146    ///     is_active: Some(true),
147    ///     ..Default::default()
148    /// })?;
149    /// # Ok::<(), stateset_embedded::CommerceError>(())
150    /// ```
151    pub fn list_warehouses(&self, filter: WarehouseFilter) -> Result<Vec<Warehouse>> {
152        self.db.warehouse().list_warehouses(filter)
153    }
154
155    /// Delete a warehouse. Only warehouses without locations can be deleted.
156    pub fn delete_warehouse(&self, id: i32) -> Result<()> {
157        self.db.warehouse().delete_warehouse(id)
158    }
159
160    /// Count warehouses matching the filter.
161    pub fn count_warehouses(&self, filter: WarehouseFilter) -> Result<u64> {
162        self.db.warehouse().count_warehouses(filter)
163    }
164
165    // ========================================================================
166    // Zone Operations
167    // ========================================================================
168
169    /// Create a new zone within a warehouse.
170    ///
171    /// Zones are logical groupings of locations (e.g., "Bulk Storage", "Pick Area", "Returns").
172    pub fn create_zone(&self, input: CreateZone) -> Result<Zone> {
173        self.db.warehouse().create_zone(input)
174    }
175
176    /// Get a zone by ID.
177    pub fn get_zone(&self, id: i32) -> Result<Option<Zone>> {
178        self.db.warehouse().get_zone(id)
179    }
180
181    /// Get all zones in a warehouse.
182    pub fn get_zones(&self, warehouse_id: i32) -> Result<Vec<Zone>> {
183        self.db.warehouse().get_zones(warehouse_id)
184    }
185
186    /// Update a zone.
187    pub fn update_zone(&self, id: i32, input: UpdateZone) -> Result<Zone> {
188        self.db.warehouse().update_zone(id, input)
189    }
190
191    /// Delete a zone.
192    pub fn delete_zone(&self, id: i32) -> Result<()> {
193        self.db.warehouse().delete_zone(id)
194    }
195
196    // ========================================================================
197    // Location Operations
198    // ========================================================================
199
200    /// Create a new location within a warehouse.
201    ///
202    /// # Example
203    ///
204    /// ```rust,no_run
205    /// use stateset_embedded::{Commerce, CreateLocation, LocationType};
206    /// use rust_decimal_macros::dec;
207    ///
208    /// let commerce = Commerce::new(":memory:")?;
209    ///
210    /// // Create a pick location with capacity constraints
211    /// let location = commerce.warehouse().create_location(CreateLocation {
212    ///     warehouse_id: 1,
213    ///     location_type: LocationType::Pick,
214    ///     zone: Some("A".into()),
215    ///     aisle: Some("01".into()),
216    ///     rack: Some("05".into()),
217    ///     level: Some("2".into()),
218    ///     bin: Some("03".into()),
219    ///     max_weight_kg: Some(dec!(100)),
220    ///     max_volume_m3: Some(dec!(0.5)),
221    ///     is_pickable: Some(true),
222    ///     is_receivable: Some(false),
223    ///     ..Default::default()
224    /// })?;
225    ///
226    /// println!("Created location: {}", location.code); // e.g., "A-01-05-2-03"
227    /// # Ok::<(), stateset_embedded::CommerceError>(())
228    /// ```
229    pub fn create_location(&self, input: CreateLocation) -> Result<Location> {
230        self.db.warehouse().create_location(input)
231    }
232
233    /// Get a location by ID.
234    pub fn get_location(&self, id: i32) -> Result<Option<Location>> {
235        self.db.warehouse().get_location(id)
236    }
237
238    /// Get a location by code within a warehouse.
239    pub fn get_location_by_code(&self, warehouse_id: i32, code: &str) -> Result<Option<Location>> {
240        self.db.warehouse().get_location_by_code(warehouse_id, code)
241    }
242
243    /// Update a location.
244    pub fn update_location(&self, id: i32, input: UpdateLocation) -> Result<Location> {
245        self.db.warehouse().update_location(id, input)
246    }
247
248    /// List locations with optional filtering.
249    ///
250    /// # Example
251    ///
252    /// ```rust,no_run
253    /// use stateset_embedded::{Commerce, LocationFilter, LocationType};
254    ///
255    /// let commerce = Commerce::new(":memory:")?;
256    ///
257    /// // Get all pickable locations in zone A
258    /// let locations = commerce.warehouse().list_locations(LocationFilter {
259    ///     warehouse_id: Some(1),
260    ///     zone: Some("A".into()),
261    ///     is_pickable: Some(true),
262    ///     is_active: Some(true),
263    ///     ..Default::default()
264    /// })?;
265    /// # Ok::<(), stateset_embedded::CommerceError>(())
266    /// ```
267    pub fn list_locations(&self, filter: LocationFilter) -> Result<Vec<Location>> {
268        self.db.warehouse().list_locations(filter)
269    }
270
271    /// Delete a location. Only locations without inventory can be deleted.
272    pub fn delete_location(&self, id: i32) -> Result<()> {
273        self.db.warehouse().delete_location(id)
274    }
275
276    /// Count locations matching the filter.
277    pub fn count_locations(&self, filter: LocationFilter) -> Result<u64> {
278        self.db.warehouse().count_locations(filter)
279    }
280
281    /// Get all active locations for a warehouse.
282    pub fn get_locations_for_warehouse(&self, warehouse_id: i32) -> Result<Vec<Location>> {
283        self.db.warehouse().get_locations_for_warehouse(warehouse_id)
284    }
285
286    /// Get pickable locations with available inventory for a SKU.
287    ///
288    /// Returns locations that are marked as pickable and have available
289    /// (non-reserved) inventory for the specified SKU.
290    pub fn get_pickable_locations(&self, warehouse_id: i32, sku: &str) -> Result<Vec<Location>> {
291        self.db.warehouse().get_pickable_locations(warehouse_id, sku)
292    }
293
294    /// Get receivable locations for a warehouse.
295    ///
296    /// Returns locations where inventory can be received (e.g., receiving docks, staging areas).
297    pub fn get_receivable_locations(&self, warehouse_id: i32) -> Result<Vec<Location>> {
298        self.db.warehouse().get_receivable_locations(warehouse_id)
299    }
300
301    /// Create multiple locations in a batch.
302    ///
303    /// Returns a `BatchResult` with succeeded and failed operations.
304    pub fn create_locations_batch(
305        &self,
306        inputs: Vec<CreateLocation>,
307    ) -> Result<BatchResult<Location>> {
308        self.db.warehouse().create_locations_batch(inputs)
309    }
310
311    /// Get multiple locations by ID.
312    pub fn get_locations_batch(&self, ids: Vec<i32>) -> Result<Vec<Location>> {
313        self.db.warehouse().get_locations_batch(ids)
314    }
315
316    // ========================================================================
317    // Location Inventory Operations
318    // ========================================================================
319
320    /// Get all inventory at a specific location.
321    pub fn get_location_inventory(&self, location_id: i32) -> Result<Vec<LocationInventory>> {
322        self.db.warehouse().get_location_inventory(location_id)
323    }
324
325    /// Get all locations with inventory for a SKU within a warehouse.
326    pub fn get_inventory_for_sku(
327        &self,
328        warehouse_id: i32,
329        sku: &str,
330    ) -> Result<Vec<LocationInventory>> {
331        self.db.warehouse().get_inventory_for_sku(warehouse_id, sku)
332    }
333
334    /// Adjust inventory at a location.
335    ///
336    /// Use positive quantities to add inventory, negative to remove.
337    /// Creates a movement record for audit trail.
338    ///
339    /// # Example
340    ///
341    /// ```rust,no_run
342    /// use stateset_embedded::{Commerce, AdjustLocationInventory};
343    /// use rust_decimal_macros::dec;
344    ///
345    /// let commerce = Commerce::new(":memory:")?;
346    ///
347    /// // Add inventory from receiving
348    /// let inventory = commerce.warehouse().adjust_inventory(AdjustLocationInventory {
349    ///     location_id: 1,
350    ///     sku: "PROD-001".into(),
351    ///     lot_id: None,
352    ///     quantity: dec!(100),
353    ///     reason: "Receipt from PO-12345".into(),
354    ///     reference_type: Some("purchase_order".into()),
355    ///     reference_id: None,
356    ///     performed_by: Some("warehouse_user".into()),
357    /// })?;
358    ///
359    /// println!("New quantity on hand: {}", inventory.quantity_on_hand);
360    /// # Ok::<(), stateset_embedded::CommerceError>(())
361    /// ```
362    pub fn adjust_inventory(&self, input: AdjustLocationInventory) -> Result<LocationInventory> {
363        self.db.warehouse().adjust_inventory(input)
364    }
365
366    /// Move inventory between locations.
367    ///
368    /// Validates that sufficient available (non-reserved) quantity exists
369    /// at the source location. Creates movement records for both locations.
370    ///
371    /// # Example
372    ///
373    /// ```rust,no_run
374    /// use stateset_embedded::{Commerce, MoveInventory};
375    /// use rust_decimal_macros::dec;
376    ///
377    /// let commerce = Commerce::new(":memory:")?;
378    ///
379    /// // Move inventory from bulk to pick location
380    /// let movement = commerce.warehouse().move_inventory(MoveInventory {
381    ///     from_location_id: 1,  // Bulk storage
382    ///     to_location_id: 2,    // Pick location
383    ///     sku: "PROD-001".into(),
384    ///     lot_id: None,
385    ///     quantity: dec!(50),
386    ///     reason: Some("Replenish pick location".into()),
387    ///     performed_by: Some("forklift_operator".into()),
388    /// })?;
389    /// # Ok::<(), stateset_embedded::CommerceError>(())
390    /// ```
391    pub fn move_inventory(&self, input: MoveInventory) -> Result<LocationMovement> {
392        self.db.warehouse().move_inventory(input)
393    }
394
395    /// List location inventory with optional filtering.
396    pub fn list_location_inventory(
397        &self,
398        filter: LocationInventoryFilter,
399    ) -> Result<Vec<LocationInventory>> {
400        self.db.warehouse().list_location_inventory(filter)
401    }
402
403    /// Get total available quantity for a SKU across a warehouse.
404    ///
405    /// Sums available quantity (`on_hand` - reserved) across all locations.
406    pub fn get_total_available(&self, warehouse_id: i32, sku: &str) -> Result<Decimal> {
407        let inventory = self.db.warehouse().get_inventory_for_sku(warehouse_id, sku)?;
408        Ok(inventory.iter().map(|i| i.quantity_available).sum())
409    }
410
411    /// Get total on-hand quantity for a SKU across a warehouse.
412    pub fn get_total_on_hand(&self, warehouse_id: i32, sku: &str) -> Result<Decimal> {
413        let inventory = self.db.warehouse().get_inventory_for_sku(warehouse_id, sku)?;
414        Ok(inventory.iter().map(|i| i.quantity_on_hand).sum())
415    }
416
417    // ========================================================================
418    // Movement Operations
419    // ========================================================================
420
421    /// Get inventory movements with optional filtering.
422    ///
423    /// # Example
424    ///
425    /// ```rust,no_run
426    /// use stateset_embedded::{Commerce, MovementFilter, MovementType};
427    /// use chrono::{Utc, Duration};
428    ///
429    /// let commerce = Commerce::new(":memory:")?;
430    ///
431    /// // Get all transfers in the last 7 days
432    /// let movements = commerce.warehouse().get_movements(MovementFilter {
433    ///     warehouse_id: Some(1),
434    ///     movement_type: Some(MovementType::Transfer),
435    ///     from_date: Some(Utc::now() - Duration::days(7)),
436    ///     limit: Some(100),
437    ///     ..Default::default()
438    /// })?;
439    /// # Ok::<(), stateset_embedded::CommerceError>(())
440    /// ```
441    pub fn get_movements(&self, filter: MovementFilter) -> Result<Vec<LocationMovement>> {
442        self.db.warehouse().get_movements(filter)
443    }
444
445    /// Count movements matching the filter.
446    pub fn count_movements(&self, filter: MovementFilter) -> Result<u64> {
447        self.db.warehouse().count_movements(filter)
448    }
449
450    // ========================================================================
451    // Cycle Count Operations
452    // ========================================================================
453
454    /// Create a cycle count (draft) with its expected lines.
455    ///
456    /// # Example
457    ///
458    /// ```rust,no_run
459    /// use stateset_embedded::{Commerce, CreateCycleCount, CreateCycleCountLine};
460    /// use rust_decimal_macros::dec;
461    ///
462    /// let commerce = Commerce::new(":memory:")?;
463    ///
464    /// let count = commerce.warehouse().create_cycle_count(CreateCycleCount {
465    ///     warehouse_id: 1,
466    ///     location_id: Some(1),
467    ///     counted_by: Some("counter@example.com".into()),
468    ///     lines: vec![CreateCycleCountLine {
469    ///         sku: "PROD-001".into(),
470    ///         lot_id: None,
471    ///         expected_quantity: dec!(100),
472    ///     }],
473    ///     ..Default::default()
474    /// })?;
475    /// # Ok::<(), stateset_embedded::CommerceError>(())
476    /// ```
477    pub fn create_cycle_count(&self, input: CreateCycleCount) -> Result<CycleCount> {
478        self.db.warehouse().create_cycle_count(input)
479    }
480
481    /// Get a cycle count (with lines) by ID.
482    pub fn get_cycle_count(&self, id: Uuid) -> Result<Option<CycleCount>> {
483        self.db.warehouse().get_cycle_count(id)
484    }
485
486    /// List cycle counts matching the filter.
487    pub fn list_cycle_counts(&self, filter: CycleCountFilter) -> Result<Vec<CycleCount>> {
488        self.db.warehouse().list_cycle_counts(filter)
489    }
490
491    /// Start a draft cycle count (`draft` → `in_progress`).
492    pub fn start_cycle_count(&self, id: Uuid) -> Result<CycleCount> {
493        self.db.warehouse().start_cycle_count(id)
494    }
495
496    /// Record physical counts against an in-progress cycle count.
497    pub fn record_cycle_counts(
498        &self,
499        id: Uuid,
500        counts: Vec<RecordCycleCountLine>,
501    ) -> Result<CycleCount> {
502        self.db.warehouse().record_cycle_counts(id, counts)
503    }
504
505    /// Complete an in-progress cycle count.
506    ///
507    /// Computes each line's variance (counted - expected) and applies matching
508    /// inventory adjustments to `location_inventory`, recording `cycle_count`
509    /// movements for the audit trail.
510    pub fn complete_cycle_count(&self, id: Uuid) -> Result<CycleCount> {
511        let count = self.db.warehouse().complete_cycle_count(id)?;
512        #[cfg(feature = "events")]
513        self.emit(CommerceEvent::CycleCountCompleted {
514            cycle_count_id: count.id,
515            warehouse_id: count.warehouse_id,
516            line_count: count.lines.len(),
517            variance_line_count: count
518                .lines
519                .iter()
520                .filter(|l| l.variance.is_some_and(|v| !v.is_zero()))
521                .count(),
522            total_variance: count.lines.iter().filter_map(|l| l.variance).sum(),
523            timestamp: count.completed_at.unwrap_or_else(Utc::now),
524        });
525        Ok(count)
526    }
527
528    /// Cancel a draft or in-progress cycle count. No adjustments are applied.
529    pub fn cancel_cycle_count(&self, id: Uuid) -> Result<CycleCount> {
530        self.db.warehouse().cancel_cycle_count(id)
531    }
532}