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 using the weighted-average cost formula.
244    ///
245    /// The `existing_qty` parameter is the quantity *before* this receipt is applied.
246    /// For moving-average valuation, the new unit cost is:
247    ///
248    /// ```text
249    /// new_avg_cost = (existing_qty × existing_unit_cost + receipt_qty × receipt_unit_cost)
250    ///                / (existing_qty + receipt_qty)
251    /// ```
252    pub fn update_on_receipt(&mut self, receipt_qty: Decimal, cost: Decimal) {
253        match self.method {
254            ValuationMethod::StandardCost => {
255                let actual_cost = cost;
256                let standard_cost = receipt_qty * self.standard_cost;
257                self.price_variance += actual_cost - standard_cost;
258                self.total_value += standard_cost;
259            }
260            ValuationMethod::MovingAverage => {
261                // cost is the total receipt value (receipt_qty × receipt_unit_cost).
262                // Reconstruct the existing quantity from total_value / unit_cost, then
263                // apply the weighted-average formula:
264                //   new_unit_cost = new_total_value / (existing_qty + receipt_qty)
265                let existing_qty = if self.unit_cost > Decimal::ZERO {
266                    self.total_value / self.unit_cost
267                } else {
268                    Decimal::ZERO
269                };
270                let new_qty = existing_qty + receipt_qty;
271                self.total_value += cost;
272                if new_qty > Decimal::ZERO {
273                    self.unit_cost = (self.total_value / new_qty).round_dp(4);
274                }
275            }
276            ValuationMethod::FIFO | ValuationMethod::LIFO => {
277                self.total_value += cost;
278            }
279        }
280    }
281
282    /// Calculates cost for issue.
283    pub fn calculate_issue_cost(&mut self, quantity: Decimal) -> Decimal {
284        let cost = quantity * self.unit_cost;
285        self.total_value = (self.total_value - cost).max(Decimal::ZERO);
286        cost
287    }
288}
289
290/// Valuation method.
291#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
292pub enum ValuationMethod {
293    /// Standard cost.
294    #[default]
295    StandardCost,
296    /// Moving average.
297    MovingAverage,
298    /// First-in, first-out.
299    FIFO,
300    /// Last-in, first-out.
301    LIFO,
302}
303
304/// Stock status.
305#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
306pub enum StockStatus {
307    /// Normal stock level.
308    #[default]
309    Normal,
310    /// Below reorder point.
311    BelowReorder,
312    /// Below safety stock.
313    BelowSafety,
314    /// Out of stock.
315    OutOfStock,
316    /// Over maximum.
317    OverMax,
318    /// Obsolete/slow moving.
319    Obsolete,
320}
321
322/// Batch/lot stock.
323#[derive(Debug, Clone, Serialize, Deserialize)]
324pub struct BatchStock {
325    /// Batch number.
326    pub batch_number: String,
327    /// Quantity in batch.
328    pub quantity: Decimal,
329    /// Manufacturing date.
330    pub manufacture_date: Option<NaiveDate>,
331    /// Expiration date.
332    pub expiration_date: Option<NaiveDate>,
333    /// Supplier batch.
334    pub supplier_batch: Option<String>,
335    /// Status.
336    pub status: BatchStatus,
337    /// Unit cost for this batch.
338    pub unit_cost: Decimal,
339}
340
341impl BatchStock {
342    /// Creates a new batch.
343    pub fn new(batch_number: String, quantity: Decimal, unit_cost: Decimal) -> Self {
344        Self {
345            batch_number,
346            quantity,
347            manufacture_date: None,
348            expiration_date: None,
349            supplier_batch: None,
350            status: BatchStatus::Unrestricted,
351            unit_cost,
352        }
353    }
354
355    /// Checks if batch is expired.
356    pub fn is_expired(&self, as_of_date: NaiveDate) -> bool {
357        self.expiration_date
358            .map(|exp| as_of_date > exp)
359            .unwrap_or(false)
360    }
361
362    /// Checks if batch is expiring soon (within days).
363    pub fn is_expiring_soon(&self, as_of_date: NaiveDate, days: i64) -> bool {
364        self.expiration_date
365            .map(|exp| {
366                let threshold = as_of_date + chrono::Duration::days(days);
367                as_of_date <= exp && exp <= threshold
368            })
369            .unwrap_or(false)
370    }
371}
372
373/// Batch status.
374#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
375pub enum BatchStatus {
376    /// Available for use.
377    #[default]
378    Unrestricted,
379    /// Quality inspection.
380    InInspection,
381    /// Blocked.
382    Blocked,
383    /// Expired.
384    Expired,
385    /// Reserved.
386    Reserved,
387}
388
389/// Serial number tracking.
390#[derive(Debug, Clone, Serialize, Deserialize)]
391pub struct SerialNumber {
392    /// Serial number.
393    pub serial_number: String,
394    /// Status.
395    pub status: SerialStatus,
396    /// Receipt date.
397    pub receipt_date: NaiveDate,
398    /// Issue date (if issued).
399    pub issue_date: Option<NaiveDate>,
400    /// Customer (if sold).
401    pub customer_id: Option<String>,
402    /// Warranty expiration.
403    pub warranty_expiration: Option<NaiveDate>,
404}
405
406/// Serial number status.
407#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
408pub enum SerialStatus {
409    /// In stock.
410    #[default]
411    InStock,
412    /// Reserved.
413    Reserved,
414    /// Issued/sold.
415    Issued,
416    /// In repair.
417    InRepair,
418    /// Scrapped.
419    Scrapped,
420}
421
422/// Inventory summary by plant.
423#[derive(Debug, Clone, Serialize, Deserialize)]
424pub struct InventorySummary {
425    /// Company code.
426    pub company_code: String,
427    /// As-of date.
428    pub as_of_date: NaiveDate,
429    /// Summary by plant.
430    pub by_plant: HashMap<String, PlantInventorySummary>,
431    /// Total inventory value.
432    pub total_value: Decimal,
433    /// Total SKU count.
434    pub total_sku_count: u32,
435    /// Items below reorder point.
436    pub below_reorder_count: u32,
437    /// Out of stock count.
438    pub out_of_stock_count: u32,
439}
440
441/// Plant-level inventory summary.
442#[derive(Debug, Clone, Serialize, Deserialize)]
443pub struct PlantInventorySummary {
444    /// Plant code.
445    pub plant: String,
446    /// Total value.
447    pub total_value: Decimal,
448    /// SKU count.
449    pub sku_count: u32,
450    /// Below reorder count.
451    pub below_reorder_count: u32,
452    /// Out of stock count.
453    pub out_of_stock_count: u32,
454    /// Total quantity.
455    pub total_quantity: Decimal,
456}
457
458impl InventorySummary {
459    /// Creates summary from positions.
460    pub fn from_positions(
461        company_code: String,
462        positions: &[InventoryPosition],
463        as_of_date: NaiveDate,
464    ) -> Self {
465        let mut by_plant: HashMap<String, PlantInventorySummary> = HashMap::new();
466        let mut total_value = Decimal::ZERO;
467        let mut total_sku_count = 0u32;
468        let mut below_reorder_count = 0u32;
469        let mut out_of_stock_count = 0u32;
470
471        for pos in positions.iter().filter(|p| p.company_code == company_code) {
472            let plant_summary =
473                by_plant
474                    .entry(pos.plant.clone())
475                    .or_insert_with(|| PlantInventorySummary {
476                        plant: pos.plant.clone(),
477                        total_value: Decimal::ZERO,
478                        sku_count: 0,
479                        below_reorder_count: 0,
480                        out_of_stock_count: 0,
481                        total_quantity: Decimal::ZERO,
482                    });
483
484            let value = pos.total_value();
485            plant_summary.total_value += value;
486            plant_summary.sku_count += 1;
487            plant_summary.total_quantity += pos.quantity_on_hand;
488
489            total_value += value;
490            total_sku_count += 1;
491
492            match pos.status {
493                StockStatus::BelowReorder | StockStatus::BelowSafety => {
494                    plant_summary.below_reorder_count += 1;
495                    below_reorder_count += 1;
496                }
497                StockStatus::OutOfStock => {
498                    plant_summary.out_of_stock_count += 1;
499                    out_of_stock_count += 1;
500                }
501                _ => {}
502            }
503        }
504
505        Self {
506            company_code,
507            as_of_date,
508            by_plant,
509            total_value,
510            total_sku_count,
511            below_reorder_count,
512            out_of_stock_count,
513        }
514    }
515}
516
517#[cfg(test)]
518#[allow(clippy::unwrap_used)]
519mod tests {
520    use super::*;
521    use rust_decimal_macros::dec;
522
523    fn create_test_position() -> InventoryPosition {
524        InventoryPosition::new(
525            "MAT001".to_string(),
526            "Test Material".to_string(),
527            "PLANT01".to_string(),
528            "SLOC01".to_string(),
529            "1000".to_string(),
530            "EA".to_string(),
531        )
532    }
533
534    #[test]
535    fn test_add_quantity() {
536        let mut pos = create_test_position();
537        pos.valuation.unit_cost = dec!(10);
538
539        pos.add_quantity(
540            dec!(100),
541            dec!(1000),
542            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
543        );
544
545        assert_eq!(pos.quantity_on_hand, dec!(100));
546        assert_eq!(pos.quantity_available, dec!(100));
547    }
548
549    #[test]
550    fn test_reserve_quantity() {
551        let mut pos = create_test_position();
552        pos.quantity_on_hand = dec!(100);
553        pos.calculate_available();
554
555        assert!(pos.reserve(dec!(30)));
556        assert_eq!(pos.quantity_reserved, dec!(30));
557        assert_eq!(pos.quantity_available, dec!(70));
558
559        // Try to reserve more than available
560        assert!(!pos.reserve(dec!(80)));
561    }
562
563    #[test]
564    fn test_stock_status() {
565        let mut pos =
566            create_test_position().with_stock_levels(dec!(10), dec!(200), dec!(50), dec!(20));
567
568        // Use add_quantity to properly update status
569        pos.add_quantity(
570            dec!(100),
571            dec!(1000),
572            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
573        );
574        assert_eq!(pos.status, StockStatus::Normal);
575
576        // Remove quantity to go below reorder point (50) but above safety (20)
577        let _ = pos.remove_quantity(dec!(70), NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
578        // Now quantity is 30, which is below reorder (50) but above safety (20)
579        assert_eq!(pos.status, StockStatus::BelowReorder);
580    }
581
582    #[test]
583    fn test_batch_expiration() {
584        let batch = BatchStock {
585            batch_number: "BATCH001".to_string(),
586            quantity: dec!(100),
587            manufacture_date: Some(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap()),
588            expiration_date: Some(NaiveDate::from_ymd_opt(2024, 6, 30).unwrap()),
589            supplier_batch: None,
590            status: BatchStatus::Unrestricted,
591            unit_cost: dec!(10),
592        };
593
594        let before = NaiveDate::from_ymd_opt(2024, 6, 1).unwrap();
595        let after = NaiveDate::from_ymd_opt(2024, 7, 1).unwrap();
596
597        assert!(!batch.is_expired(before));
598        assert!(batch.is_expired(after));
599        assert!(batch.is_expiring_soon(before, 30));
600    }
601}