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