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(|a, b| b.total_value.cmp(&a.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(|a, b| b.total_value.cmp(&a.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)]
511#[allow(clippy::unwrap_used)]
512mod tests {
513    use super::*;
514
515    #[test]
516    fn test_fifo_tracker() {
517        let mut tracker = FIFOTracker::new("MAT001".to_string());
518
519        // Receive 100 @ $10
520        tracker.receive(
521            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
522            "GR001".to_string(),
523            dec!(100),
524            dec!(10),
525        );
526
527        // Receive 100 @ $12
528        tracker.receive(
529            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
530            "GR002".to_string(),
531            dec!(100),
532            dec!(12),
533        );
534
535        assert_eq!(tracker.total_quantity, dec!(200));
536        assert_eq!(tracker.total_value, dec!(2200)); // 1000 + 1200
537
538        // Issue 150 (should use FIFO - 100 @ $10 + 50 @ $12)
539        let cost = tracker.issue(dec!(150)).unwrap();
540        assert_eq!(cost, dec!(1600)); // (100 * 10) + (50 * 12)
541        assert_eq!(tracker.total_quantity, dec!(50));
542    }
543
544    #[test]
545    fn test_abc_analysis() {
546        let valuations = vec![
547            MaterialValuation {
548                material_id: "A".to_string(),
549                description: "High value".to_string(),
550                plant: "P1".to_string(),
551                storage_location: "S1".to_string(),
552                quantity: dec!(10),
553                unit: "EA".to_string(),
554                unit_cost: dec!(100),
555                total_value: dec!(1000),
556                valuation_method: ValuationMethod::StandardCost,
557                standard_cost: dec!(100),
558                price_variance: Decimal::ZERO,
559            },
560            MaterialValuation {
561                material_id: "B".to_string(),
562                description: "Medium value".to_string(),
563                plant: "P1".to_string(),
564                storage_location: "S1".to_string(),
565                quantity: dec!(50),
566                unit: "EA".to_string(),
567                unit_cost: dec!(10),
568                total_value: dec!(500),
569                valuation_method: ValuationMethod::StandardCost,
570                standard_cost: dec!(10),
571                price_variance: Decimal::ZERO,
572            },
573            MaterialValuation {
574                material_id: "C".to_string(),
575                description: "Low value".to_string(),
576                plant: "P1".to_string(),
577                storage_location: "S1".to_string(),
578                quantity: dec!(100),
579                unit: "EA".to_string(),
580                unit_cost: dec!(1),
581                total_value: dec!(100),
582                valuation_method: ValuationMethod::StandardCost,
583                standard_cost: dec!(1),
584                price_variance: Decimal::ZERO,
585            },
586        ];
587
588        let total = dec!(1600);
589        let analysis = ABCAnalysis::from_valuations(&valuations, total);
590
591        // Material A (1000/1600 = 62.5%) should be A
592        assert!(!analysis.a_items.is_empty());
593    }
594
595    #[test]
596    fn test_inventory_turnover() {
597        let turnover = InventoryTurnover::calculate(
598            "1000".to_string(),
599            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
600            NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
601            dec!(100_000),
602            dec!(120_000),
603            dec!(1_000_000),
604        );
605
606        // Average inventory = 110,000
607        // Turnover = 1,000,000 / 110,000 = 9.09
608        assert!(turnover.turnover_ratio > dec!(9));
609
610        // DIO = 110,000 / 1,000,000 * 365 ≈ 40 days
611        assert!(turnover.dio_days > dec!(30) && turnover.dio_days < dec!(50));
612    }
613
614    #[test]
615    fn test_cost_variance() {
616        let variance = MaterialCostVariance::new(
617            "MAT001".to_string(),
618            "Test Material".to_string(),
619            dec!(10),  // Standard
620            dec!(11),  // Actual (higher)
621            dec!(100), // Quantity
622        );
623
624        // Variance = (10 - 11) * 100 = -100 (unfavorable)
625        assert_eq!(variance.price_variance, dec!(-100));
626        assert!(!variance.is_favorable);
627    }
628}