Skip to main content

datasynth_core/models/subledger/inventory/
valuation.rs

1//! Inventory valuation models.
2
3use chrono::{DateTime, NaiveDate, Utc};
4use rust_decimal::Decimal;
5use rust_decimal_macros::dec;
6use serde::{Deserialize, Serialize};
7use std::collections::{HashMap, VecDeque};
8
9use super::{InventoryPosition, ValuationMethod};
10
11/// Inventory valuation report.
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct InventoryValuationReport {
14    /// Company code.
15    pub company_code: String,
16    /// As-of date.
17    pub as_of_date: NaiveDate,
18    /// Valuation method.
19    pub valuation_method: ValuationMethod,
20    /// Material valuations.
21    pub materials: Vec<MaterialValuation>,
22    /// Total inventory value.
23    pub total_value: Decimal,
24    /// Total quantity.
25    pub total_quantity: Decimal,
26    /// Value by plant.
27    pub by_plant: HashMap<String, Decimal>,
28    /// Value by material group.
29    pub by_material_group: HashMap<String, Decimal>,
30    /// Generated at.
31    pub generated_at: DateTime<Utc>,
32}
33
34impl InventoryValuationReport {
35    /// Creates a valuation report from positions.
36    pub fn from_positions(
37        company_code: String,
38        positions: &[InventoryPosition],
39        as_of_date: NaiveDate,
40    ) -> Self {
41        let mut materials = Vec::new();
42        let mut total_value = Decimal::ZERO;
43        let mut total_quantity = Decimal::ZERO;
44        let mut by_plant: HashMap<String, Decimal> = HashMap::new();
45        let by_material_group: HashMap<String, Decimal> = HashMap::new();
46
47        for pos in positions.iter().filter(|p| p.company_code == company_code) {
48            let value = pos.total_value();
49            total_value += value;
50            total_quantity += pos.quantity_on_hand;
51
52            *by_plant.entry(pos.plant.clone()).or_default() += value;
53
54            materials.push(MaterialValuation {
55                material_id: pos.material_id.clone(),
56                description: pos.description.clone(),
57                plant: pos.plant.clone(),
58                storage_location: pos.storage_location.clone(),
59                quantity: pos.quantity_on_hand,
60                unit: pos.unit.clone(),
61                unit_cost: pos.valuation.unit_cost,
62                total_value: value,
63                valuation_method: pos.valuation.method,
64                standard_cost: pos.valuation.standard_cost,
65                price_variance: pos.valuation.price_variance,
66            });
67        }
68
69        // Sort by value descending
70        materials.sort_by(|a, b| b.total_value.cmp(&a.total_value));
71
72        Self {
73            company_code,
74            as_of_date,
75            valuation_method: ValuationMethod::StandardCost,
76            materials,
77            total_value,
78            total_quantity,
79            by_plant,
80            by_material_group,
81            generated_at: Utc::now(),
82        }
83    }
84
85    /// Gets top N materials by value.
86    pub fn top_materials(&self, n: usize) -> Vec<&MaterialValuation> {
87        self.materials.iter().take(n).collect()
88    }
89
90    /// Gets ABC classification.
91    pub fn abc_analysis(&self) -> ABCAnalysis {
92        ABCAnalysis::from_valuations(&self.materials, self.total_value)
93    }
94}
95
96/// Material valuation detail.
97#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct MaterialValuation {
99    /// Material ID.
100    pub material_id: String,
101    /// Description.
102    pub description: String,
103    /// Plant.
104    pub plant: String,
105    /// Storage location.
106    pub storage_location: String,
107    /// Quantity.
108    pub quantity: Decimal,
109    /// Unit.
110    pub unit: String,
111    /// Unit cost.
112    pub unit_cost: Decimal,
113    /// Total value.
114    pub total_value: Decimal,
115    /// Valuation method.
116    pub valuation_method: ValuationMethod,
117    /// Standard cost.
118    pub standard_cost: Decimal,
119    /// Price variance.
120    pub price_variance: Decimal,
121}
122
123/// ABC Analysis result.
124#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct ABCAnalysis {
126    /// A items (typically 80% of value, 20% of items).
127    pub a_items: Vec<ABCItem>,
128    /// B items (typically 15% of value, 30% of items).
129    pub b_items: Vec<ABCItem>,
130    /// C items (typically 5% of value, 50% of items).
131    pub c_items: Vec<ABCItem>,
132    /// A threshold percentage.
133    pub a_threshold: Decimal,
134    /// B threshold percentage.
135    pub b_threshold: Decimal,
136    /// Summary statistics.
137    pub summary: ABCSummary,
138}
139
140impl ABCAnalysis {
141    /// Creates ABC analysis from valuations.
142    pub fn from_valuations(valuations: &[MaterialValuation], total_value: Decimal) -> Self {
143        let a_threshold = dec!(80);
144        let b_threshold = dec!(95);
145
146        let mut sorted: Vec<_> = valuations.iter().collect();
147        sorted.sort_by(|a, b| b.total_value.cmp(&a.total_value));
148
149        let mut a_items = Vec::new();
150        let mut b_items = Vec::new();
151        let mut c_items = Vec::new();
152
153        let mut cumulative_value = Decimal::ZERO;
154
155        for val in sorted {
156            cumulative_value += val.total_value;
157            let cumulative_percent = if total_value > Decimal::ZERO {
158                cumulative_value / total_value * dec!(100)
159            } else {
160                Decimal::ZERO
161            };
162
163            let item = ABCItem {
164                material_id: val.material_id.clone(),
165                description: val.description.clone(),
166                value: val.total_value,
167                cumulative_percent,
168            };
169
170            if cumulative_percent <= a_threshold {
171                a_items.push(item);
172            } else if cumulative_percent <= b_threshold {
173                b_items.push(item);
174            } else {
175                c_items.push(item);
176            }
177        }
178
179        let summary = ABCSummary {
180            a_count: a_items.len() as u32,
181            a_value: a_items.iter().map(|i| i.value).sum(),
182            a_percent: if total_value > Decimal::ZERO {
183                a_items.iter().map(|i| i.value).sum::<Decimal>() / total_value * dec!(100)
184            } else {
185                Decimal::ZERO
186            },
187            b_count: b_items.len() as u32,
188            b_value: b_items.iter().map(|i| i.value).sum(),
189            b_percent: if total_value > Decimal::ZERO {
190                b_items.iter().map(|i| i.value).sum::<Decimal>() / total_value * dec!(100)
191            } else {
192                Decimal::ZERO
193            },
194            c_count: c_items.len() as u32,
195            c_value: c_items.iter().map(|i| i.value).sum(),
196            c_percent: if total_value > Decimal::ZERO {
197                c_items.iter().map(|i| i.value).sum::<Decimal>() / total_value * dec!(100)
198            } else {
199                Decimal::ZERO
200            },
201        };
202
203        Self {
204            a_items,
205            b_items,
206            c_items,
207            a_threshold,
208            b_threshold,
209            summary,
210        }
211    }
212}
213
214/// Item in ABC classification.
215#[derive(Debug, Clone, Serialize, Deserialize)]
216pub struct ABCItem {
217    /// Material ID.
218    pub material_id: String,
219    /// Description.
220    pub description: String,
221    /// Value.
222    pub value: Decimal,
223    /// Cumulative percentage.
224    pub cumulative_percent: Decimal,
225}
226
227/// ABC analysis summary.
228#[derive(Debug, Clone, Serialize, Deserialize)]
229pub struct ABCSummary {
230    /// A item count.
231    pub a_count: u32,
232    /// A total value.
233    pub a_value: Decimal,
234    /// A percentage.
235    pub a_percent: Decimal,
236    /// B item count.
237    pub b_count: u32,
238    /// B total value.
239    pub b_value: Decimal,
240    /// B percentage.
241    pub b_percent: Decimal,
242    /// C item count.
243    pub c_count: u32,
244    /// C total value.
245    pub c_value: Decimal,
246    /// C percentage.
247    pub c_percent: Decimal,
248}
249
250/// FIFO valuation layer.
251#[derive(Debug, Clone, Serialize, Deserialize)]
252pub struct FIFOLayer {
253    /// Receipt date.
254    pub receipt_date: NaiveDate,
255    /// Receipt document.
256    pub receipt_document: String,
257    /// Quantity remaining.
258    pub quantity: Decimal,
259    /// Unit cost.
260    pub unit_cost: Decimal,
261}
262
263/// FIFO inventory tracker.
264#[derive(Debug, Clone, Serialize, Deserialize)]
265pub struct FIFOTracker {
266    /// Material ID.
267    pub material_id: String,
268    /// Layers (oldest first).
269    pub layers: VecDeque<FIFOLayer>,
270    /// Total quantity.
271    pub total_quantity: Decimal,
272    /// Total value.
273    pub total_value: Decimal,
274}
275
276impl FIFOTracker {
277    /// Creates a new FIFO tracker.
278    pub fn new(material_id: String) -> Self {
279        Self {
280            material_id,
281            layers: VecDeque::new(),
282            total_quantity: Decimal::ZERO,
283            total_value: Decimal::ZERO,
284        }
285    }
286
287    /// Adds a receipt.
288    pub fn receive(
289        &mut self,
290        date: NaiveDate,
291        document: String,
292        quantity: Decimal,
293        unit_cost: Decimal,
294    ) {
295        self.layers.push_back(FIFOLayer {
296            receipt_date: date,
297            receipt_document: document,
298            quantity,
299            unit_cost,
300        });
301        self.total_quantity += quantity;
302        self.total_value += quantity * unit_cost;
303    }
304
305    /// Issues quantity using FIFO.
306    pub fn issue(&mut self, quantity: Decimal) -> Option<Decimal> {
307        if quantity > self.total_quantity {
308            return None;
309        }
310
311        let mut remaining = quantity;
312        let mut total_cost = Decimal::ZERO;
313
314        while remaining > Decimal::ZERO && !self.layers.is_empty() {
315            let front = self.layers.front_mut().unwrap();
316
317            if front.quantity <= remaining {
318                // Consume entire layer
319                total_cost += front.quantity * front.unit_cost;
320                remaining -= front.quantity;
321                self.layers.pop_front();
322            } else {
323                // Partial consumption
324                total_cost += remaining * front.unit_cost;
325                front.quantity -= remaining;
326                remaining = Decimal::ZERO;
327            }
328        }
329
330        self.total_quantity -= quantity;
331        self.total_value -= total_cost;
332
333        Some(total_cost)
334    }
335
336    /// Gets weighted average cost.
337    pub fn weighted_average_cost(&self) -> Decimal {
338        if self.total_quantity > Decimal::ZERO {
339            self.total_value / self.total_quantity
340        } else {
341            Decimal::ZERO
342        }
343    }
344}
345
346/// Standard cost variance analysis.
347#[derive(Debug, Clone, Serialize, Deserialize)]
348pub struct CostVarianceAnalysis {
349    /// Company code.
350    pub company_code: String,
351    /// Period.
352    pub period: String,
353    /// Material variances.
354    pub variances: Vec<MaterialCostVariance>,
355    /// Total price variance.
356    pub total_price_variance: Decimal,
357    /// Total quantity variance.
358    pub total_quantity_variance: Decimal,
359    /// Generated at.
360    pub generated_at: DateTime<Utc>,
361}
362
363/// Material cost variance.
364#[derive(Debug, Clone, Serialize, Deserialize)]
365pub struct MaterialCostVariance {
366    /// Material ID.
367    pub material_id: String,
368    /// Description.
369    pub description: String,
370    /// Standard cost.
371    pub standard_cost: Decimal,
372    /// Actual cost.
373    pub actual_cost: Decimal,
374    /// Quantity.
375    pub quantity: Decimal,
376    /// Price variance.
377    pub price_variance: Decimal,
378    /// Variance percentage.
379    pub variance_percent: Decimal,
380    /// Is favorable.
381    pub is_favorable: bool,
382}
383
384impl MaterialCostVariance {
385    /// Creates a new variance record.
386    pub fn new(
387        material_id: String,
388        description: String,
389        standard_cost: Decimal,
390        actual_cost: Decimal,
391        quantity: Decimal,
392    ) -> Self {
393        let price_variance = (standard_cost - actual_cost) * quantity;
394        let variance_percent = if actual_cost > Decimal::ZERO {
395            ((standard_cost - actual_cost) / actual_cost * dec!(100)).round_dp(2)
396        } else {
397            Decimal::ZERO
398        };
399        let is_favorable = price_variance > Decimal::ZERO;
400
401        Self {
402            material_id,
403            description,
404            standard_cost,
405            actual_cost,
406            quantity,
407            price_variance,
408            variance_percent,
409            is_favorable,
410        }
411    }
412}
413
414/// Inventory turnover analysis.
415#[derive(Debug, Clone, Serialize, Deserialize)]
416pub struct InventoryTurnover {
417    /// Company code.
418    pub company_code: String,
419    /// Period start.
420    pub period_start: NaiveDate,
421    /// Period end.
422    pub period_end: NaiveDate,
423    /// Average inventory.
424    pub average_inventory: Decimal,
425    /// Cost of goods sold.
426    pub cogs: Decimal,
427    /// Turnover ratio.
428    pub turnover_ratio: Decimal,
429    /// Days inventory outstanding.
430    pub dio_days: Decimal,
431    /// By material.
432    pub by_material: Vec<MaterialTurnover>,
433}
434
435impl InventoryTurnover {
436    /// Calculates turnover from COGS and inventory levels.
437    pub fn calculate(
438        company_code: String,
439        period_start: NaiveDate,
440        period_end: NaiveDate,
441        beginning_inventory: Decimal,
442        ending_inventory: Decimal,
443        cogs: Decimal,
444    ) -> Self {
445        let average_inventory = (beginning_inventory + ending_inventory) / dec!(2);
446        let days_in_period = (period_end - period_start).num_days() as i32;
447
448        let turnover_ratio = if average_inventory > Decimal::ZERO {
449            (cogs / average_inventory).round_dp(2)
450        } else {
451            Decimal::ZERO
452        };
453
454        let dio_days = if cogs > Decimal::ZERO {
455            (average_inventory / cogs * Decimal::from(days_in_period)).round_dp(1)
456        } else {
457            Decimal::ZERO
458        };
459
460        Self {
461            company_code,
462            period_start,
463            period_end,
464            average_inventory,
465            cogs,
466            turnover_ratio,
467            dio_days,
468            by_material: Vec::new(),
469        }
470    }
471}
472
473/// Material-level turnover.
474#[derive(Debug, Clone, Serialize, Deserialize)]
475pub struct MaterialTurnover {
476    /// Material ID.
477    pub material_id: String,
478    /// Description.
479    pub description: String,
480    /// Average inventory.
481    pub average_inventory: Decimal,
482    /// Usage/COGS.
483    pub usage: Decimal,
484    /// Turnover ratio.
485    pub turnover_ratio: Decimal,
486    /// Days of supply.
487    pub days_of_supply: Decimal,
488    /// Classification (fast/slow/dead).
489    pub classification: TurnoverClassification,
490}
491
492/// Turnover classification.
493#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
494pub enum TurnoverClassification {
495    /// Fast moving.
496    FastMoving,
497    /// Normal.
498    Normal,
499    /// Slow moving.
500    SlowMoving,
501    /// Dead/obsolete stock.
502    Dead,
503}
504
505#[cfg(test)]
506mod tests {
507    use super::*;
508
509    #[test]
510    fn test_fifo_tracker() {
511        let mut tracker = FIFOTracker::new("MAT001".to_string());
512
513        // Receive 100 @ $10
514        tracker.receive(
515            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
516            "GR001".to_string(),
517            dec!(100),
518            dec!(10),
519        );
520
521        // Receive 100 @ $12
522        tracker.receive(
523            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
524            "GR002".to_string(),
525            dec!(100),
526            dec!(12),
527        );
528
529        assert_eq!(tracker.total_quantity, dec!(200));
530        assert_eq!(tracker.total_value, dec!(2200)); // 1000 + 1200
531
532        // Issue 150 (should use FIFO - 100 @ $10 + 50 @ $12)
533        let cost = tracker.issue(dec!(150)).unwrap();
534        assert_eq!(cost, dec!(1600)); // (100 * 10) + (50 * 12)
535        assert_eq!(tracker.total_quantity, dec!(50));
536    }
537
538    #[test]
539    fn test_abc_analysis() {
540        let valuations = vec![
541            MaterialValuation {
542                material_id: "A".to_string(),
543                description: "High value".to_string(),
544                plant: "P1".to_string(),
545                storage_location: "S1".to_string(),
546                quantity: dec!(10),
547                unit: "EA".to_string(),
548                unit_cost: dec!(100),
549                total_value: dec!(1000),
550                valuation_method: ValuationMethod::StandardCost,
551                standard_cost: dec!(100),
552                price_variance: Decimal::ZERO,
553            },
554            MaterialValuation {
555                material_id: "B".to_string(),
556                description: "Medium value".to_string(),
557                plant: "P1".to_string(),
558                storage_location: "S1".to_string(),
559                quantity: dec!(50),
560                unit: "EA".to_string(),
561                unit_cost: dec!(10),
562                total_value: dec!(500),
563                valuation_method: ValuationMethod::StandardCost,
564                standard_cost: dec!(10),
565                price_variance: Decimal::ZERO,
566            },
567            MaterialValuation {
568                material_id: "C".to_string(),
569                description: "Low value".to_string(),
570                plant: "P1".to_string(),
571                storage_location: "S1".to_string(),
572                quantity: dec!(100),
573                unit: "EA".to_string(),
574                unit_cost: dec!(1),
575                total_value: dec!(100),
576                valuation_method: ValuationMethod::StandardCost,
577                standard_cost: dec!(1),
578                price_variance: Decimal::ZERO,
579            },
580        ];
581
582        let total = dec!(1600);
583        let analysis = ABCAnalysis::from_valuations(&valuations, total);
584
585        // Material A (1000/1600 = 62.5%) should be A
586        assert!(!analysis.a_items.is_empty());
587    }
588
589    #[test]
590    fn test_inventory_turnover() {
591        let turnover = InventoryTurnover::calculate(
592            "1000".to_string(),
593            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
594            NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
595            dec!(100_000),
596            dec!(120_000),
597            dec!(1_000_000),
598        );
599
600        // Average inventory = 110,000
601        // Turnover = 1,000,000 / 110,000 = 9.09
602        assert!(turnover.turnover_ratio > dec!(9));
603
604        // DIO = 110,000 / 1,000,000 * 365 ≈ 40 days
605        assert!(turnover.dio_days > dec!(30) && turnover.dio_days < dec!(50));
606    }
607
608    #[test]
609    fn test_cost_variance() {
610        let variance = MaterialCostVariance::new(
611            "MAT001".to_string(),
612            "Test Material".to_string(),
613            dec!(10),  // Standard
614            dec!(11),  // Actual (higher)
615            dec!(100), // Quantity
616        );
617
618        // Variance = (10 - 11) * 100 = -100 (unfavorable)
619        assert_eq!(variance.price_variance, dec!(-100));
620        assert!(!variance.is_favorable);
621    }
622}