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
316                .layers
317                .front_mut()
318                .expect("FIFO layer exists when remaining > 0");
319
320            if front.quantity <= remaining {
321                // Consume entire layer
322                total_cost += front.quantity * front.unit_cost;
323                remaining -= front.quantity;
324                self.layers.pop_front();
325            } else {
326                // Partial consumption
327                total_cost += remaining * front.unit_cost;
328                front.quantity -= remaining;
329                remaining = Decimal::ZERO;
330            }
331        }
332
333        self.total_quantity -= quantity;
334        self.total_value -= total_cost;
335
336        Some(total_cost)
337    }
338
339    /// Gets weighted average cost.
340    pub fn weighted_average_cost(&self) -> Decimal {
341        if self.total_quantity > Decimal::ZERO {
342            self.total_value / self.total_quantity
343        } else {
344            Decimal::ZERO
345        }
346    }
347}
348
349/// Standard cost variance analysis.
350#[derive(Debug, Clone, Serialize, Deserialize)]
351pub struct CostVarianceAnalysis {
352    /// Company code.
353    pub company_code: String,
354    /// Period.
355    pub period: String,
356    /// Material variances.
357    pub variances: Vec<MaterialCostVariance>,
358    /// Total price variance.
359    pub total_price_variance: Decimal,
360    /// Total quantity variance.
361    pub total_quantity_variance: Decimal,
362    /// Generated at.
363    pub generated_at: DateTime<Utc>,
364}
365
366/// Material cost variance.
367#[derive(Debug, Clone, Serialize, Deserialize)]
368pub struct MaterialCostVariance {
369    /// Material ID.
370    pub material_id: String,
371    /// Description.
372    pub description: String,
373    /// Standard cost.
374    pub standard_cost: Decimal,
375    /// Actual cost.
376    pub actual_cost: Decimal,
377    /// Quantity.
378    pub quantity: Decimal,
379    /// Price variance.
380    pub price_variance: Decimal,
381    /// Variance percentage.
382    pub variance_percent: Decimal,
383    /// Is favorable.
384    pub is_favorable: bool,
385}
386
387impl MaterialCostVariance {
388    /// Creates a new variance record.
389    pub fn new(
390        material_id: String,
391        description: String,
392        standard_cost: Decimal,
393        actual_cost: Decimal,
394        quantity: Decimal,
395    ) -> Self {
396        let price_variance = (standard_cost - actual_cost) * quantity;
397        let variance_percent = if actual_cost > Decimal::ZERO {
398            ((standard_cost - actual_cost) / actual_cost * dec!(100)).round_dp(2)
399        } else {
400            Decimal::ZERO
401        };
402        let is_favorable = price_variance > Decimal::ZERO;
403
404        Self {
405            material_id,
406            description,
407            standard_cost,
408            actual_cost,
409            quantity,
410            price_variance,
411            variance_percent,
412            is_favorable,
413        }
414    }
415}
416
417/// Inventory turnover analysis.
418#[derive(Debug, Clone, Serialize, Deserialize)]
419pub struct InventoryTurnover {
420    /// Company code.
421    pub company_code: String,
422    /// Period start.
423    pub period_start: NaiveDate,
424    /// Period end.
425    pub period_end: NaiveDate,
426    /// Average inventory.
427    pub average_inventory: Decimal,
428    /// Cost of goods sold.
429    pub cogs: Decimal,
430    /// Turnover ratio.
431    pub turnover_ratio: Decimal,
432    /// Days inventory outstanding.
433    pub dio_days: Decimal,
434    /// By material.
435    pub by_material: Vec<MaterialTurnover>,
436}
437
438impl InventoryTurnover {
439    /// Calculates turnover from COGS and inventory levels.
440    pub fn calculate(
441        company_code: String,
442        period_start: NaiveDate,
443        period_end: NaiveDate,
444        beginning_inventory: Decimal,
445        ending_inventory: Decimal,
446        cogs: Decimal,
447    ) -> Self {
448        let average_inventory = (beginning_inventory + ending_inventory) / dec!(2);
449        let days_in_period = (period_end - period_start).num_days() as i32;
450
451        let turnover_ratio = if average_inventory > Decimal::ZERO {
452            (cogs / average_inventory).round_dp(2)
453        } else {
454            Decimal::ZERO
455        };
456
457        let dio_days = if cogs > Decimal::ZERO {
458            (average_inventory / cogs * Decimal::from(days_in_period)).round_dp(1)
459        } else {
460            Decimal::ZERO
461        };
462
463        Self {
464            company_code,
465            period_start,
466            period_end,
467            average_inventory,
468            cogs,
469            turnover_ratio,
470            dio_days,
471            by_material: Vec::new(),
472        }
473    }
474}
475
476/// Material-level turnover.
477#[derive(Debug, Clone, Serialize, Deserialize)]
478pub struct MaterialTurnover {
479    /// Material ID.
480    pub material_id: String,
481    /// Description.
482    pub description: String,
483    /// Average inventory.
484    pub average_inventory: Decimal,
485    /// Usage/COGS.
486    pub usage: Decimal,
487    /// Turnover ratio.
488    pub turnover_ratio: Decimal,
489    /// Days of supply.
490    pub days_of_supply: Decimal,
491    /// Classification (fast/slow/dead).
492    pub classification: TurnoverClassification,
493}
494
495/// Turnover classification.
496#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
497pub enum TurnoverClassification {
498    /// Fast moving.
499    FastMoving,
500    /// Normal.
501    Normal,
502    /// Slow moving.
503    SlowMoving,
504    /// Dead/obsolete stock.
505    Dead,
506}
507
508#[cfg(test)]
509#[allow(clippy::unwrap_used)]
510mod tests {
511    use super::*;
512
513    #[test]
514    fn test_fifo_tracker() {
515        let mut tracker = FIFOTracker::new("MAT001".to_string());
516
517        // Receive 100 @ $10
518        tracker.receive(
519            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
520            "GR001".to_string(),
521            dec!(100),
522            dec!(10),
523        );
524
525        // Receive 100 @ $12
526        tracker.receive(
527            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
528            "GR002".to_string(),
529            dec!(100),
530            dec!(12),
531        );
532
533        assert_eq!(tracker.total_quantity, dec!(200));
534        assert_eq!(tracker.total_value, dec!(2200)); // 1000 + 1200
535
536        // Issue 150 (should use FIFO - 100 @ $10 + 50 @ $12)
537        let cost = tracker.issue(dec!(150)).unwrap();
538        assert_eq!(cost, dec!(1600)); // (100 * 10) + (50 * 12)
539        assert_eq!(tracker.total_quantity, dec!(50));
540    }
541
542    #[test]
543    fn test_abc_analysis() {
544        let valuations = vec![
545            MaterialValuation {
546                material_id: "A".to_string(),
547                description: "High value".to_string(),
548                plant: "P1".to_string(),
549                storage_location: "S1".to_string(),
550                quantity: dec!(10),
551                unit: "EA".to_string(),
552                unit_cost: dec!(100),
553                total_value: dec!(1000),
554                valuation_method: ValuationMethod::StandardCost,
555                standard_cost: dec!(100),
556                price_variance: Decimal::ZERO,
557            },
558            MaterialValuation {
559                material_id: "B".to_string(),
560                description: "Medium value".to_string(),
561                plant: "P1".to_string(),
562                storage_location: "S1".to_string(),
563                quantity: dec!(50),
564                unit: "EA".to_string(),
565                unit_cost: dec!(10),
566                total_value: dec!(500),
567                valuation_method: ValuationMethod::StandardCost,
568                standard_cost: dec!(10),
569                price_variance: Decimal::ZERO,
570            },
571            MaterialValuation {
572                material_id: "C".to_string(),
573                description: "Low value".to_string(),
574                plant: "P1".to_string(),
575                storage_location: "S1".to_string(),
576                quantity: dec!(100),
577                unit: "EA".to_string(),
578                unit_cost: dec!(1),
579                total_value: dec!(100),
580                valuation_method: ValuationMethod::StandardCost,
581                standard_cost: dec!(1),
582                price_variance: Decimal::ZERO,
583            },
584        ];
585
586        let total = dec!(1600);
587        let analysis = ABCAnalysis::from_valuations(&valuations, total);
588
589        // Material A (1000/1600 = 62.5%) should be A
590        assert!(!analysis.a_items.is_empty());
591    }
592
593    #[test]
594    fn test_inventory_turnover() {
595        let turnover = InventoryTurnover::calculate(
596            "1000".to_string(),
597            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
598            NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
599            dec!(100_000),
600            dec!(120_000),
601            dec!(1_000_000),
602        );
603
604        // Average inventory = 110,000
605        // Turnover = 1,000,000 / 110,000 = 9.09
606        assert!(turnover.turnover_ratio > dec!(9));
607
608        // DIO = 110,000 / 1,000,000 * 365 ≈ 40 days
609        assert!(turnover.dio_days > dec!(30) && turnover.dio_days < dec!(50));
610    }
611
612    #[test]
613    fn test_cost_variance() {
614        let variance = MaterialCostVariance::new(
615            "MAT001".to_string(),
616            "Test Material".to_string(),
617            dec!(10),  // Standard
618            dec!(11),  // Actual (higher)
619            dec!(100), // Quantity
620        );
621
622        // Variance = (10 - 11) * 100 = -100 (unfavorable)
623        assert_eq!(variance.price_variance, dec!(-100));
624        assert!(!variance.is_favorable);
625    }
626}