Skip to main content

datasynth_core/models/subledger/inventory/
position.rs

1//! Inventory position model.
2
3use chrono::{DateTime, NaiveDate, Utc};
4use rust_decimal::Decimal;
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7
8/// Inventory position (stock on hand).
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct InventoryPosition {
11    /// Material ID.
12    pub material_id: String,
13    /// Material description.
14    pub description: String,
15    /// Plant/warehouse.
16    pub plant: String,
17    /// Storage location.
18    pub storage_location: String,
19    /// Company code.
20    pub company_code: String,
21    /// Quantity on hand.
22    pub quantity_on_hand: Decimal,
23    /// Unit of measure.
24    pub unit: String,
25    /// Reserved quantity.
26    pub quantity_reserved: Decimal,
27    /// Available quantity (on hand - reserved).
28    pub quantity_available: Decimal,
29    /// Quality inspection quantity.
30    pub quantity_in_inspection: Decimal,
31    /// Blocked quantity.
32    pub quantity_blocked: Decimal,
33    /// In-transit quantity.
34    pub quantity_in_transit: Decimal,
35    /// Valuation data.
36    pub valuation: PositionValuation,
37    /// Last movement date.
38    pub last_movement_date: Option<NaiveDate>,
39    /// Last count date.
40    pub last_count_date: Option<NaiveDate>,
41    /// Minimum stock level.
42    pub min_stock: Option<Decimal>,
43    /// Maximum stock level.
44    pub max_stock: Option<Decimal>,
45    /// Reorder point.
46    pub reorder_point: Option<Decimal>,
47    /// Safety stock.
48    pub safety_stock: Option<Decimal>,
49    /// Stock status.
50    pub status: StockStatus,
51    /// Batch/lot tracking.
52    pub batches: Vec<BatchStock>,
53    /// Serial numbers (if serialized).
54    pub serial_numbers: Vec<SerialNumber>,
55    /// Last updated.
56    pub updated_at: DateTime<Utc>,
57}
58
59impl InventoryPosition {
60    /// Creates a new inventory position.
61    pub fn new(
62        material_id: String,
63        description: String,
64        plant: String,
65        storage_location: String,
66        company_code: String,
67        unit: String,
68    ) -> Self {
69        Self {
70            material_id,
71            description,
72            plant,
73            storage_location,
74            company_code,
75            quantity_on_hand: Decimal::ZERO,
76            unit,
77            quantity_reserved: Decimal::ZERO,
78            quantity_available: Decimal::ZERO,
79            quantity_in_inspection: Decimal::ZERO,
80            quantity_blocked: Decimal::ZERO,
81            quantity_in_transit: Decimal::ZERO,
82            valuation: PositionValuation::default(),
83            last_movement_date: None,
84            last_count_date: None,
85            min_stock: None,
86            max_stock: None,
87            reorder_point: None,
88            safety_stock: None,
89            status: StockStatus::Normal,
90            batches: Vec::new(),
91            serial_numbers: Vec::new(),
92            updated_at: Utc::now(),
93        }
94    }
95
96    /// Calculates available quantity.
97    pub fn calculate_available(&mut self) {
98        self.quantity_available = self.quantity_on_hand
99            - self.quantity_reserved
100            - self.quantity_in_inspection
101            - self.quantity_blocked;
102    }
103
104    /// Adds quantity to position.
105    pub fn add_quantity(&mut self, quantity: Decimal, cost: Decimal, date: NaiveDate) {
106        self.quantity_on_hand += quantity;
107        self.valuation.update_on_receipt(quantity, cost);
108        self.last_movement_date = Some(date);
109        self.calculate_available();
110        self.update_status();
111        self.updated_at = Utc::now();
112    }
113
114    /// Removes quantity from position.
115    pub fn remove_quantity(&mut self, quantity: Decimal, date: NaiveDate) -> Option<Decimal> {
116        if quantity > self.quantity_available {
117            return None;
118        }
119
120        let cost = self.valuation.calculate_issue_cost(quantity);
121        self.quantity_on_hand -= quantity;
122        self.last_movement_date = Some(date);
123        self.calculate_available();
124        self.update_status();
125        self.updated_at = Utc::now();
126
127        Some(cost)
128    }
129
130    /// Reserves quantity.
131    pub fn reserve(&mut self, quantity: Decimal) -> bool {
132        if quantity > self.quantity_available {
133            return false;
134        }
135        self.quantity_reserved += quantity;
136        self.calculate_available();
137        self.updated_at = Utc::now();
138        true
139    }
140
141    /// Releases reservation.
142    pub fn release_reservation(&mut self, quantity: Decimal) {
143        self.quantity_reserved = (self.quantity_reserved - quantity).max(Decimal::ZERO);
144        self.calculate_available();
145        self.updated_at = Utc::now();
146    }
147
148    /// Blocks quantity.
149    pub fn block(&mut self, quantity: Decimal) {
150        self.quantity_blocked += quantity;
151        self.calculate_available();
152        self.updated_at = Utc::now();
153    }
154
155    /// Unblocks quantity.
156    pub fn unblock(&mut self, quantity: Decimal) {
157        self.quantity_blocked = (self.quantity_blocked - quantity).max(Decimal::ZERO);
158        self.calculate_available();
159        self.updated_at = Utc::now();
160    }
161
162    /// Updates stock status based on levels.
163    fn update_status(&mut self) {
164        if self.quantity_on_hand <= Decimal::ZERO {
165            self.status = StockStatus::OutOfStock;
166        } else if let Some(safety) = self.safety_stock {
167            if self.quantity_on_hand <= safety {
168                self.status = StockStatus::BelowSafety;
169            } else if let Some(reorder) = self.reorder_point {
170                if self.quantity_on_hand <= reorder {
171                    self.status = StockStatus::BelowReorder;
172                } else {
173                    self.status = StockStatus::Normal;
174                }
175            } else {
176                self.status = StockStatus::Normal;
177            }
178        } else {
179            self.status = if self.quantity_on_hand > Decimal::ZERO {
180                StockStatus::Normal
181            } else {
182                StockStatus::OutOfStock
183            };
184        }
185    }
186
187    /// Sets stock level parameters.
188    pub fn with_stock_levels(
189        mut self,
190        min: Decimal,
191        max: Decimal,
192        reorder: Decimal,
193        safety: Decimal,
194    ) -> Self {
195        self.min_stock = Some(min);
196        self.max_stock = Some(max);
197        self.reorder_point = Some(reorder);
198        self.safety_stock = Some(safety);
199        self.update_status();
200        self
201    }
202
203    /// Gets total inventory value.
204    pub fn total_value(&self) -> Decimal {
205        self.quantity_on_hand * self.valuation.unit_cost
206    }
207
208    /// Checks if reorder is needed.
209    pub fn needs_reorder(&self) -> bool {
210        self.reorder_point
211            .map(|rp| self.quantity_available <= rp)
212            .unwrap_or(false)
213    }
214
215    /// Gets days of supply based on average usage.
216    pub fn days_of_supply(&self, average_daily_usage: Decimal) -> Option<Decimal> {
217        if average_daily_usage > Decimal::ZERO {
218            Some((self.quantity_available / average_daily_usage).round_dp(1))
219        } else {
220            None
221        }
222    }
223}
224
225/// Valuation data for inventory position.
226#[derive(Debug, Clone, Default, Serialize, Deserialize)]
227pub struct PositionValuation {
228    /// Valuation method.
229    pub method: ValuationMethod,
230    /// Standard cost (if standard costing).
231    pub standard_cost: Decimal,
232    /// Moving average unit cost.
233    pub unit_cost: Decimal,
234    /// Total value.
235    pub total_value: Decimal,
236    /// Price variance (standard vs actual).
237    pub price_variance: Decimal,
238    /// Last price change date.
239    pub last_price_change: Option<NaiveDate>,
240}
241
242impl PositionValuation {
243    /// Updates valuation on receipt.
244    pub fn update_on_receipt(&mut self, quantity: Decimal, cost: Decimal) {
245        match self.method {
246            ValuationMethod::StandardCost => {
247                let actual_cost = cost;
248                let standard_cost = quantity * self.standard_cost;
249                self.price_variance += actual_cost - standard_cost;
250                self.total_value += standard_cost;
251            }
252            ValuationMethod::MovingAverage => {
253                let new_total = self.total_value + cost;
254                // Note: quantity_on_hand should be passed in; simplified here
255                self.total_value = new_total;
256            }
257            ValuationMethod::FIFO | ValuationMethod::LIFO => {
258                self.total_value += cost;
259            }
260        }
261    }
262
263    /// Calculates cost for issue.
264    pub fn calculate_issue_cost(&mut self, quantity: Decimal) -> Decimal {
265        let cost = quantity * self.unit_cost;
266        self.total_value = (self.total_value - cost).max(Decimal::ZERO);
267        cost
268    }
269}
270
271/// Valuation method.
272#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
273pub enum ValuationMethod {
274    /// Standard cost.
275    #[default]
276    StandardCost,
277    /// Moving average.
278    MovingAverage,
279    /// First-in, first-out.
280    FIFO,
281    /// Last-in, first-out.
282    LIFO,
283}
284
285/// Stock status.
286#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
287pub enum StockStatus {
288    /// Normal stock level.
289    #[default]
290    Normal,
291    /// Below reorder point.
292    BelowReorder,
293    /// Below safety stock.
294    BelowSafety,
295    /// Out of stock.
296    OutOfStock,
297    /// Over maximum.
298    OverMax,
299    /// Obsolete/slow moving.
300    Obsolete,
301}
302
303/// Batch/lot stock.
304#[derive(Debug, Clone, Serialize, Deserialize)]
305pub struct BatchStock {
306    /// Batch number.
307    pub batch_number: String,
308    /// Quantity in batch.
309    pub quantity: Decimal,
310    /// Manufacturing date.
311    pub manufacture_date: Option<NaiveDate>,
312    /// Expiration date.
313    pub expiration_date: Option<NaiveDate>,
314    /// Supplier batch.
315    pub supplier_batch: Option<String>,
316    /// Status.
317    pub status: BatchStatus,
318    /// Unit cost for this batch.
319    pub unit_cost: Decimal,
320}
321
322impl BatchStock {
323    /// Creates a new batch.
324    pub fn new(batch_number: String, quantity: Decimal, unit_cost: Decimal) -> Self {
325        Self {
326            batch_number,
327            quantity,
328            manufacture_date: None,
329            expiration_date: None,
330            supplier_batch: None,
331            status: BatchStatus::Unrestricted,
332            unit_cost,
333        }
334    }
335
336    /// Checks if batch is expired.
337    pub fn is_expired(&self, as_of_date: NaiveDate) -> bool {
338        self.expiration_date
339            .map(|exp| as_of_date > exp)
340            .unwrap_or(false)
341    }
342
343    /// Checks if batch is expiring soon (within days).
344    pub fn is_expiring_soon(&self, as_of_date: NaiveDate, days: i64) -> bool {
345        self.expiration_date
346            .map(|exp| {
347                let threshold = as_of_date + chrono::Duration::days(days);
348                as_of_date <= exp && exp <= threshold
349            })
350            .unwrap_or(false)
351    }
352}
353
354/// Batch status.
355#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
356pub enum BatchStatus {
357    /// Available for use.
358    #[default]
359    Unrestricted,
360    /// Quality inspection.
361    InInspection,
362    /// Blocked.
363    Blocked,
364    /// Expired.
365    Expired,
366    /// Reserved.
367    Reserved,
368}
369
370/// Serial number tracking.
371#[derive(Debug, Clone, Serialize, Deserialize)]
372pub struct SerialNumber {
373    /// Serial number.
374    pub serial_number: String,
375    /// Status.
376    pub status: SerialStatus,
377    /// Receipt date.
378    pub receipt_date: NaiveDate,
379    /// Issue date (if issued).
380    pub issue_date: Option<NaiveDate>,
381    /// Customer (if sold).
382    pub customer_id: Option<String>,
383    /// Warranty expiration.
384    pub warranty_expiration: Option<NaiveDate>,
385}
386
387/// Serial number status.
388#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
389pub enum SerialStatus {
390    /// In stock.
391    #[default]
392    InStock,
393    /// Reserved.
394    Reserved,
395    /// Issued/sold.
396    Issued,
397    /// In repair.
398    InRepair,
399    /// Scrapped.
400    Scrapped,
401}
402
403/// Inventory summary by plant.
404#[derive(Debug, Clone, Serialize, Deserialize)]
405pub struct InventorySummary {
406    /// Company code.
407    pub company_code: String,
408    /// As-of date.
409    pub as_of_date: NaiveDate,
410    /// Summary by plant.
411    pub by_plant: HashMap<String, PlantInventorySummary>,
412    /// Total inventory value.
413    pub total_value: Decimal,
414    /// Total SKU count.
415    pub total_sku_count: u32,
416    /// Items below reorder point.
417    pub below_reorder_count: u32,
418    /// Out of stock count.
419    pub out_of_stock_count: u32,
420}
421
422/// Plant-level inventory summary.
423#[derive(Debug, Clone, Serialize, Deserialize)]
424pub struct PlantInventorySummary {
425    /// Plant code.
426    pub plant: String,
427    /// Total value.
428    pub total_value: Decimal,
429    /// SKU count.
430    pub sku_count: u32,
431    /// Below reorder count.
432    pub below_reorder_count: u32,
433    /// Out of stock count.
434    pub out_of_stock_count: u32,
435    /// Total quantity.
436    pub total_quantity: Decimal,
437}
438
439impl InventorySummary {
440    /// Creates summary from positions.
441    pub fn from_positions(
442        company_code: String,
443        positions: &[InventoryPosition],
444        as_of_date: NaiveDate,
445    ) -> Self {
446        let mut by_plant: HashMap<String, PlantInventorySummary> = HashMap::new();
447        let mut total_value = Decimal::ZERO;
448        let mut total_sku_count = 0u32;
449        let mut below_reorder_count = 0u32;
450        let mut out_of_stock_count = 0u32;
451
452        for pos in positions.iter().filter(|p| p.company_code == company_code) {
453            let plant_summary =
454                by_plant
455                    .entry(pos.plant.clone())
456                    .or_insert_with(|| PlantInventorySummary {
457                        plant: pos.plant.clone(),
458                        total_value: Decimal::ZERO,
459                        sku_count: 0,
460                        below_reorder_count: 0,
461                        out_of_stock_count: 0,
462                        total_quantity: Decimal::ZERO,
463                    });
464
465            let value = pos.total_value();
466            plant_summary.total_value += value;
467            plant_summary.sku_count += 1;
468            plant_summary.total_quantity += pos.quantity_on_hand;
469
470            total_value += value;
471            total_sku_count += 1;
472
473            match pos.status {
474                StockStatus::BelowReorder | StockStatus::BelowSafety => {
475                    plant_summary.below_reorder_count += 1;
476                    below_reorder_count += 1;
477                }
478                StockStatus::OutOfStock => {
479                    plant_summary.out_of_stock_count += 1;
480                    out_of_stock_count += 1;
481                }
482                _ => {}
483            }
484        }
485
486        Self {
487            company_code,
488            as_of_date,
489            by_plant,
490            total_value,
491            total_sku_count,
492            below_reorder_count,
493            out_of_stock_count,
494        }
495    }
496}
497
498#[cfg(test)]
499mod tests {
500    use super::*;
501    use rust_decimal_macros::dec;
502
503    fn create_test_position() -> InventoryPosition {
504        InventoryPosition::new(
505            "MAT001".to_string(),
506            "Test Material".to_string(),
507            "PLANT01".to_string(),
508            "SLOC01".to_string(),
509            "1000".to_string(),
510            "EA".to_string(),
511        )
512    }
513
514    #[test]
515    fn test_add_quantity() {
516        let mut pos = create_test_position();
517        pos.valuation.unit_cost = dec!(10);
518
519        pos.add_quantity(
520            dec!(100),
521            dec!(1000),
522            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
523        );
524
525        assert_eq!(pos.quantity_on_hand, dec!(100));
526        assert_eq!(pos.quantity_available, dec!(100));
527    }
528
529    #[test]
530    fn test_reserve_quantity() {
531        let mut pos = create_test_position();
532        pos.quantity_on_hand = dec!(100);
533        pos.calculate_available();
534
535        assert!(pos.reserve(dec!(30)));
536        assert_eq!(pos.quantity_reserved, dec!(30));
537        assert_eq!(pos.quantity_available, dec!(70));
538
539        // Try to reserve more than available
540        assert!(!pos.reserve(dec!(80)));
541    }
542
543    #[test]
544    fn test_stock_status() {
545        let mut pos =
546            create_test_position().with_stock_levels(dec!(10), dec!(200), dec!(50), dec!(20));
547
548        // Use add_quantity to properly update status
549        pos.add_quantity(
550            dec!(100),
551            dec!(1000),
552            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
553        );
554        assert_eq!(pos.status, StockStatus::Normal);
555
556        // Remove quantity to go below reorder point (50) but above safety (20)
557        let _ = pos.remove_quantity(dec!(70), NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
558        // Now quantity is 30, which is below reorder (50) but above safety (20)
559        assert_eq!(pos.status, StockStatus::BelowReorder);
560    }
561
562    #[test]
563    fn test_batch_expiration() {
564        let batch = BatchStock {
565            batch_number: "BATCH001".to_string(),
566            quantity: dec!(100),
567            manufacture_date: Some(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap()),
568            expiration_date: Some(NaiveDate::from_ymd_opt(2024, 6, 30).unwrap()),
569            supplier_batch: None,
570            status: BatchStatus::Unrestricted,
571            unit_cost: dec!(10),
572        };
573
574        let before = NaiveDate::from_ymd_opt(2024, 6, 1).unwrap();
575        let after = NaiveDate::from_ymd_opt(2024, 7, 1).unwrap();
576
577        assert!(!batch.is_expired(before));
578        assert!(batch.is_expired(after));
579        assert!(batch.is_expiring_soon(before, 30));
580    }
581}