Skip to main content

waremax_storage/
inventory.rs

1//! Inventory tracking
2
3use crate::rack::BinAddress;
4use std::collections::HashMap;
5use thiserror::Error;
6use waremax_core::SkuId;
7
8/// Error types for inventory operations
9#[derive(Error, Debug)]
10pub enum InventoryError {
11    #[error("Bin not found: {0}")]
12    BinNotFound(BinAddress),
13
14    #[error("Insufficient stock at {bin}: requested {requested}, available {available}")]
15    InsufficientStock {
16        bin: BinAddress,
17        requested: u32,
18        available: u32,
19    },
20}
21
22/// Inventory slot containing SKU and quantity
23#[derive(Clone, Debug)]
24pub struct InventorySlot {
25    pub sku_id: SkuId,
26    pub quantity: u32,
27}
28
29/// Inventory manager tracking stock in bins
30#[derive(Clone, Default)]
31pub struct Inventory {
32    bins: HashMap<BinAddress, InventorySlot>,
33    sku_locations: HashMap<SkuId, Vec<BinAddress>>,
34    /// All known bin addresses (including empty ones)
35    all_bins: Vec<BinAddress>,
36    /// Replenishment thresholds by SKU
37    replen_thresholds: HashMap<SkuId, u32>,
38}
39
40impl Inventory {
41    pub fn new() -> Self {
42        Self::default()
43    }
44
45    /// Register a bin address as available for inventory
46    pub fn register_bin(&mut self, address: BinAddress) {
47        if !self.all_bins.contains(&address) {
48            self.all_bins.push(address);
49        }
50    }
51
52    /// Set replenishment threshold for a SKU
53    pub fn set_replen_threshold(&mut self, sku_id: SkuId, threshold: u32) {
54        self.replen_thresholds.insert(sku_id, threshold);
55    }
56
57    /// Get replenishment threshold for a SKU
58    pub fn get_replen_threshold(&self, sku_id: SkuId) -> Option<u32> {
59        self.replen_thresholds.get(&sku_id).copied()
60    }
61
62    pub fn add_placement(&mut self, address: BinAddress, sku_id: SkuId, quantity: u32) {
63        self.bins
64            .insert(address.clone(), InventorySlot { sku_id, quantity });
65        self.sku_locations
66            .entry(sku_id)
67            .or_default()
68            .push(address.clone());
69        self.register_bin(address);
70    }
71
72    pub fn get_slot(&self, address: &BinAddress) -> Option<&InventorySlot> {
73        self.bins.get(address)
74    }
75
76    pub fn get_quantity(&self, address: &BinAddress) -> Option<u32> {
77        self.bins.get(address).map(|s| s.quantity)
78    }
79
80    pub fn find_sku(&self, sku_id: SkuId) -> impl Iterator<Item = &BinAddress> {
81        self.sku_locations
82            .get(&sku_id)
83            .into_iter()
84            .flat_map(|v| v.iter())
85    }
86
87    pub fn find_sku_with_stock(&self, sku_id: SkuId, min_qty: u32) -> Option<&BinAddress> {
88        self.sku_locations
89            .get(&sku_id)?
90            .iter()
91            .find(|addr| self.bins.get(*addr).is_some_and(|s| s.quantity >= min_qty))
92    }
93
94    pub fn decrement(&mut self, address: &BinAddress, qty: u32) -> Result<(), InventoryError> {
95        let slot = self
96            .bins
97            .get_mut(address)
98            .ok_or_else(|| InventoryError::BinNotFound(address.clone()))?;
99
100        if slot.quantity < qty {
101            return Err(InventoryError::InsufficientStock {
102                bin: address.clone(),
103                requested: qty,
104                available: slot.quantity,
105            });
106        }
107
108        slot.quantity -= qty;
109        Ok(())
110    }
111
112    pub fn increment(&mut self, address: &BinAddress, qty: u32) -> Result<(), InventoryError> {
113        let slot = self
114            .bins
115            .get_mut(address)
116            .ok_or_else(|| InventoryError::BinNotFound(address.clone()))?;
117        slot.quantity += qty;
118        Ok(())
119    }
120
121    pub fn total_quantity(&self, sku_id: SkuId) -> u32 {
122        self.sku_locations
123            .get(&sku_id)
124            .map(|addrs| {
125                addrs
126                    .iter()
127                    .filter_map(|addr| self.bins.get(addr))
128                    .map(|slot| slot.quantity)
129                    .sum()
130            })
131            .unwrap_or(0)
132    }
133
134    /// Get all bins that are empty (no inventory or zero quantity)
135    pub fn get_empty_bins(&self) -> Vec<&BinAddress> {
136        self.all_bins
137            .iter()
138            .filter(|addr| self.bins.get(*addr).is_none_or(|slot| slot.quantity == 0))
139            .collect()
140    }
141
142    /// Get all registered bins
143    pub fn all_bins(&self) -> &[BinAddress] {
144        &self.all_bins
145    }
146
147    /// Check if a SKU needs replenishment (below threshold)
148    pub fn needs_replenishment(&self, sku_id: SkuId) -> Option<(BinAddress, u32, u32)> {
149        let threshold = self.replen_thresholds.get(&sku_id)?;
150
151        // Find the first bin with this SKU that's below threshold
152        for addr in self.sku_locations.get(&sku_id)? {
153            if let Some(slot) = self.bins.get(addr) {
154                if slot.quantity < *threshold {
155                    return Some((addr.clone(), slot.quantity, *threshold));
156                }
157            }
158        }
159        None
160    }
161
162    /// Get all SKUs that need replenishment
163    pub fn get_replenishment_needed(&self) -> Vec<(SkuId, BinAddress, u32, u32)> {
164        let mut results = Vec::new();
165        for (&sku_id, &threshold) in &self.replen_thresholds {
166            if let Some(locations) = self.sku_locations.get(&sku_id) {
167                for addr in locations {
168                    if let Some(slot) = self.bins.get(addr) {
169                        if slot.quantity < threshold {
170                            results.push((sku_id, addr.clone(), slot.quantity, threshold));
171                        }
172                    }
173                }
174            }
175        }
176        results
177    }
178
179    /// Create a new empty bin slot (for putaway destination)
180    pub fn create_empty_slot(&mut self, address: BinAddress, sku_id: SkuId) {
181        self.bins.insert(
182            address.clone(),
183            InventorySlot {
184                sku_id,
185                quantity: 0,
186            },
187        );
188        self.sku_locations
189            .entry(sku_id)
190            .or_default()
191            .push(address.clone());
192        self.register_bin(address);
193    }
194}