use chrono::{DateTime, NaiveDate, Utc};
use rust_decimal::Decimal;
use rust_decimal_macros::dec;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, VecDeque};
use super::{InventoryPosition, ValuationMethod};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InventoryValuationReport {
pub company_code: String,
pub as_of_date: NaiveDate,
pub valuation_method: ValuationMethod,
pub materials: Vec<MaterialValuation>,
pub total_value: Decimal,
pub total_quantity: Decimal,
pub by_plant: HashMap<String, Decimal>,
pub by_material_group: HashMap<String, Decimal>,
#[serde(with = "crate::serde_timestamp::utc")]
pub generated_at: DateTime<Utc>,
}
impl InventoryValuationReport {
pub fn from_positions(
company_code: String,
positions: &[InventoryPosition],
as_of_date: NaiveDate,
) -> Self {
let mut materials = Vec::new();
let mut total_value = Decimal::ZERO;
let mut total_quantity = Decimal::ZERO;
let mut by_plant: HashMap<String, Decimal> = HashMap::new();
let by_material_group: HashMap<String, Decimal> = HashMap::new();
for pos in positions.iter().filter(|p| p.company_code == company_code) {
let value = pos.total_value();
total_value += value;
total_quantity += pos.quantity_on_hand;
*by_plant.entry(pos.plant.clone()).or_default() += value;
materials.push(MaterialValuation {
material_id: pos.material_id.clone(),
description: pos.description.clone(),
plant: pos.plant.clone(),
storage_location: pos.storage_location.clone(),
quantity: pos.quantity_on_hand,
unit: pos.unit.clone(),
unit_cost: pos.valuation.unit_cost,
total_value: value,
valuation_method: pos.valuation.method,
standard_cost: pos.valuation.standard_cost,
price_variance: pos.valuation.price_variance,
});
}
materials.sort_by(|a, b| b.total_value.cmp(&a.total_value));
Self {
company_code,
as_of_date,
valuation_method: ValuationMethod::StandardCost,
materials,
total_value,
total_quantity,
by_plant,
by_material_group,
generated_at: Utc::now(),
}
}
pub fn top_materials(&self, n: usize) -> Vec<&MaterialValuation> {
self.materials.iter().take(n).collect()
}
pub fn abc_analysis(&self) -> ABCAnalysis {
ABCAnalysis::from_valuations(&self.materials, self.total_value)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MaterialValuation {
pub material_id: String,
pub description: String,
pub plant: String,
pub storage_location: String,
pub quantity: Decimal,
pub unit: String,
pub unit_cost: Decimal,
pub total_value: Decimal,
pub valuation_method: ValuationMethod,
pub standard_cost: Decimal,
pub price_variance: Decimal,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ABCAnalysis {
pub a_items: Vec<ABCItem>,
pub b_items: Vec<ABCItem>,
pub c_items: Vec<ABCItem>,
pub a_threshold: Decimal,
pub b_threshold: Decimal,
pub summary: ABCSummary,
}
impl ABCAnalysis {
pub fn from_valuations(valuations: &[MaterialValuation], total_value: Decimal) -> Self {
let a_threshold = dec!(80);
let b_threshold = dec!(95);
let mut sorted: Vec<_> = valuations.iter().collect();
sorted.sort_by(|a, b| b.total_value.cmp(&a.total_value));
let mut a_items = Vec::new();
let mut b_items = Vec::new();
let mut c_items = Vec::new();
let mut cumulative_value = Decimal::ZERO;
for val in sorted {
cumulative_value += val.total_value;
let cumulative_percent = if total_value > Decimal::ZERO {
cumulative_value / total_value * dec!(100)
} else {
Decimal::ZERO
};
let item = ABCItem {
material_id: val.material_id.clone(),
description: val.description.clone(),
value: val.total_value,
cumulative_percent,
};
if cumulative_percent <= a_threshold {
a_items.push(item);
} else if cumulative_percent <= b_threshold {
b_items.push(item);
} else {
c_items.push(item);
}
}
let summary = ABCSummary {
a_count: a_items.len() as u32,
a_value: a_items.iter().map(|i| i.value).sum(),
a_percent: if total_value > Decimal::ZERO {
a_items.iter().map(|i| i.value).sum::<Decimal>() / total_value * dec!(100)
} else {
Decimal::ZERO
},
b_count: b_items.len() as u32,
b_value: b_items.iter().map(|i| i.value).sum(),
b_percent: if total_value > Decimal::ZERO {
b_items.iter().map(|i| i.value).sum::<Decimal>() / total_value * dec!(100)
} else {
Decimal::ZERO
},
c_count: c_items.len() as u32,
c_value: c_items.iter().map(|i| i.value).sum(),
c_percent: if total_value > Decimal::ZERO {
c_items.iter().map(|i| i.value).sum::<Decimal>() / total_value * dec!(100)
} else {
Decimal::ZERO
},
};
Self {
a_items,
b_items,
c_items,
a_threshold,
b_threshold,
summary,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ABCItem {
pub material_id: String,
pub description: String,
pub value: Decimal,
pub cumulative_percent: Decimal,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ABCSummary {
pub a_count: u32,
pub a_value: Decimal,
pub a_percent: Decimal,
pub b_count: u32,
pub b_value: Decimal,
pub b_percent: Decimal,
pub c_count: u32,
pub c_value: Decimal,
pub c_percent: Decimal,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FIFOLayer {
pub receipt_date: NaiveDate,
pub receipt_document: String,
pub quantity: Decimal,
pub unit_cost: Decimal,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FIFOTracker {
pub material_id: String,
pub layers: VecDeque<FIFOLayer>,
pub total_quantity: Decimal,
pub total_value: Decimal,
}
impl FIFOTracker {
pub fn new(material_id: String) -> Self {
Self {
material_id,
layers: VecDeque::new(),
total_quantity: Decimal::ZERO,
total_value: Decimal::ZERO,
}
}
pub fn receive(
&mut self,
date: NaiveDate,
document: String,
quantity: Decimal,
unit_cost: Decimal,
) {
self.layers.push_back(FIFOLayer {
receipt_date: date,
receipt_document: document,
quantity,
unit_cost,
});
self.total_quantity += quantity;
self.total_value += quantity * unit_cost;
}
pub fn issue(&mut self, quantity: Decimal) -> Option<Decimal> {
if quantity > self.total_quantity {
return None;
}
let mut remaining = quantity;
let mut total_cost = Decimal::ZERO;
while remaining > Decimal::ZERO && !self.layers.is_empty() {
let front = self
.layers
.front_mut()
.expect("FIFO layer exists when remaining > 0");
if front.quantity <= remaining {
total_cost += front.quantity * front.unit_cost;
remaining -= front.quantity;
self.layers.pop_front();
} else {
total_cost += remaining * front.unit_cost;
front.quantity -= remaining;
remaining = Decimal::ZERO;
}
}
self.total_quantity -= quantity;
self.total_value -= total_cost;
Some(total_cost)
}
pub fn weighted_average_cost(&self) -> Decimal {
if self.total_quantity > Decimal::ZERO {
self.total_value / self.total_quantity
} else {
Decimal::ZERO
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CostVarianceAnalysis {
pub company_code: String,
pub period: String,
pub variances: Vec<MaterialCostVariance>,
pub total_price_variance: Decimal,
pub total_quantity_variance: Decimal,
#[serde(with = "crate::serde_timestamp::utc")]
pub generated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MaterialCostVariance {
pub material_id: String,
pub description: String,
pub standard_cost: Decimal,
pub actual_cost: Decimal,
pub quantity: Decimal,
pub price_variance: Decimal,
pub variance_percent: Decimal,
pub is_favorable: bool,
}
impl MaterialCostVariance {
pub fn new(
material_id: String,
description: String,
standard_cost: Decimal,
actual_cost: Decimal,
quantity: Decimal,
) -> Self {
let price_variance = (standard_cost - actual_cost) * quantity;
let variance_percent = if actual_cost > Decimal::ZERO {
((standard_cost - actual_cost) / actual_cost * dec!(100)).round_dp(2)
} else {
Decimal::ZERO
};
let is_favorable = price_variance > Decimal::ZERO;
Self {
material_id,
description,
standard_cost,
actual_cost,
quantity,
price_variance,
variance_percent,
is_favorable,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InventoryTurnover {
pub company_code: String,
pub period_start: NaiveDate,
pub period_end: NaiveDate,
pub average_inventory: Decimal,
pub cogs: Decimal,
pub turnover_ratio: Decimal,
pub dio_days: Decimal,
pub by_material: Vec<MaterialTurnover>,
}
impl InventoryTurnover {
pub fn calculate(
company_code: String,
period_start: NaiveDate,
period_end: NaiveDate,
beginning_inventory: Decimal,
ending_inventory: Decimal,
cogs: Decimal,
) -> Self {
let average_inventory = (beginning_inventory + ending_inventory) / dec!(2);
let days_in_period = (period_end - period_start).num_days() as i32;
let turnover_ratio = if average_inventory > Decimal::ZERO {
(cogs / average_inventory).round_dp(2)
} else {
Decimal::ZERO
};
let dio_days = if cogs > Decimal::ZERO {
(average_inventory / cogs * Decimal::from(days_in_period)).round_dp(1)
} else {
Decimal::ZERO
};
Self {
company_code,
period_start,
period_end,
average_inventory,
cogs,
turnover_ratio,
dio_days,
by_material: Vec::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MaterialTurnover {
pub material_id: String,
pub description: String,
pub average_inventory: Decimal,
pub usage: Decimal,
pub turnover_ratio: Decimal,
pub days_of_supply: Decimal,
pub classification: TurnoverClassification,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum TurnoverClassification {
FastMoving,
Normal,
SlowMoving,
Dead,
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn test_fifo_tracker() {
let mut tracker = FIFOTracker::new("MAT001".to_string());
tracker.receive(
NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
"GR001".to_string(),
dec!(100),
dec!(10),
);
tracker.receive(
NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
"GR002".to_string(),
dec!(100),
dec!(12),
);
assert_eq!(tracker.total_quantity, dec!(200));
assert_eq!(tracker.total_value, dec!(2200));
let cost = tracker.issue(dec!(150)).unwrap();
assert_eq!(cost, dec!(1600)); assert_eq!(tracker.total_quantity, dec!(50));
}
#[test]
fn test_abc_analysis() {
let valuations = vec![
MaterialValuation {
material_id: "A".to_string(),
description: "High value".to_string(),
plant: "P1".to_string(),
storage_location: "S1".to_string(),
quantity: dec!(10),
unit: "EA".to_string(),
unit_cost: dec!(100),
total_value: dec!(1000),
valuation_method: ValuationMethod::StandardCost,
standard_cost: dec!(100),
price_variance: Decimal::ZERO,
},
MaterialValuation {
material_id: "B".to_string(),
description: "Medium value".to_string(),
plant: "P1".to_string(),
storage_location: "S1".to_string(),
quantity: dec!(50),
unit: "EA".to_string(),
unit_cost: dec!(10),
total_value: dec!(500),
valuation_method: ValuationMethod::StandardCost,
standard_cost: dec!(10),
price_variance: Decimal::ZERO,
},
MaterialValuation {
material_id: "C".to_string(),
description: "Low value".to_string(),
plant: "P1".to_string(),
storage_location: "S1".to_string(),
quantity: dec!(100),
unit: "EA".to_string(),
unit_cost: dec!(1),
total_value: dec!(100),
valuation_method: ValuationMethod::StandardCost,
standard_cost: dec!(1),
price_variance: Decimal::ZERO,
},
];
let total = dec!(1600);
let analysis = ABCAnalysis::from_valuations(&valuations, total);
assert!(!analysis.a_items.is_empty());
}
#[test]
fn test_inventory_turnover() {
let turnover = InventoryTurnover::calculate(
"1000".to_string(),
NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
dec!(100_000),
dec!(120_000),
dec!(1_000_000),
);
assert!(turnover.turnover_ratio > dec!(9));
assert!(turnover.dio_days > dec!(30) && turnover.dio_days < dec!(50));
}
#[test]
fn test_cost_variance() {
let variance = MaterialCostVariance::new(
"MAT001".to_string(),
"Test Material".to_string(),
dec!(10), dec!(11), dec!(100), );
assert_eq!(variance.price_variance, dec!(-100));
assert!(!variance.is_favorable);
}
}