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