use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use super::graph_properties::{GraphPropertyValue, ToNodeProperties};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum MaterialType {
#[default]
RawMaterial,
SemiFinished,
FinishedGood,
TradingGood,
OperatingSupplies,
SparePart,
Packaging,
Service,
}
impl MaterialType {
pub fn inventory_account_category(&self) -> &'static str {
match self {
Self::RawMaterial => "Raw Materials Inventory",
Self::SemiFinished => "Work in Progress",
Self::FinishedGood => "Finished Goods Inventory",
Self::TradingGood => "Trading Goods Inventory",
Self::OperatingSupplies => "Supplies Inventory",
Self::SparePart => "Spare Parts Inventory",
Self::Packaging => "Packaging Materials",
Self::Service => "N/A",
}
}
pub fn has_inventory(&self) -> bool {
!matches!(self, Self::Service)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum MaterialGroup {
#[default]
Electronics,
Mechanical,
Chemicals,
Chemical,
OfficeSupplies,
ItEquipment,
Furniture,
PackagingMaterials,
SafetyEquipment,
Tools,
Services,
Consumables,
FinishedGoods,
}
impl MaterialGroup {
pub fn typical_uom(&self) -> &'static str {
match self {
Self::Electronics | Self::Mechanical | Self::ItEquipment => "EA",
Self::Chemicals | Self::Chemical => "KG",
Self::OfficeSupplies | Self::PackagingMaterials | Self::Consumables => "EA",
Self::Furniture | Self::FinishedGoods => "EA",
Self::SafetyEquipment | Self::Tools => "EA",
Self::Services => "HR",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum ValuationMethod {
#[default]
StandardCost,
MovingAverage,
Fifo,
Lifo,
SpecificIdentification,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct UnitOfMeasure {
pub code: String,
pub name: String,
pub conversion_factor: Decimal,
}
impl UnitOfMeasure {
pub fn each() -> Self {
Self {
code: "EA".to_string(),
name: "Each".to_string(),
conversion_factor: Decimal::ONE,
}
}
pub fn kilogram() -> Self {
Self {
code: "KG".to_string(),
name: "Kilogram".to_string(),
conversion_factor: Decimal::ONE,
}
}
pub fn liter() -> Self {
Self {
code: "L".to_string(),
name: "Liter".to_string(),
conversion_factor: Decimal::ONE,
}
}
pub fn hour() -> Self {
Self {
code: "HR".to_string(),
name: "Hour".to_string(),
conversion_factor: Decimal::ONE,
}
}
}
impl Default for UnitOfMeasure {
fn default() -> Self {
Self::each()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BomComponent {
pub component_material_id: String,
pub quantity: Decimal,
pub uom: String,
pub scrap_percentage: Decimal,
pub is_optional: bool,
pub position: u16,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub entity_code: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_material: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub component_description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub level: Option<u32>,
#[serde(default)]
pub is_phantom: bool,
}
impl BomComponent {
pub fn new(
component_material_id: impl Into<String>,
quantity: Decimal,
uom: impl Into<String>,
) -> Self {
Self {
component_material_id: component_material_id.into(),
quantity,
uom: uom.into(),
scrap_percentage: Decimal::ZERO,
is_optional: false,
position: 0,
id: None,
entity_code: None,
parent_material: None,
component_description: None,
level: None,
is_phantom: false,
}
}
pub fn with_scrap(mut self, scrap_percentage: Decimal) -> Self {
self.scrap_percentage = scrap_percentage;
self
}
pub fn effective_quantity(&self) -> Decimal {
self.quantity * (Decimal::ONE + self.scrap_percentage / Decimal::from(100))
}
}
impl ToNodeProperties for BomComponent {
fn node_type_name(&self) -> &'static str {
"bom_component"
}
fn node_type_code(&self) -> u16 {
343
}
fn to_node_properties(&self) -> HashMap<String, GraphPropertyValue> {
let mut p = HashMap::new();
if let Some(ref ec) = self.entity_code {
p.insert("entityCode".into(), GraphPropertyValue::String(ec.clone()));
}
if let Some(ref pm) = self.parent_material {
p.insert(
"parentMaterial".into(),
GraphPropertyValue::String(pm.clone()),
);
}
p.insert(
"componentMaterial".into(),
GraphPropertyValue::String(self.component_material_id.clone()),
);
if let Some(ref desc) = self.component_description {
p.insert(
"componentDescription".into(),
GraphPropertyValue::String(desc.clone()),
);
}
if let Some(lvl) = self.level {
p.insert("level".into(), GraphPropertyValue::Int(lvl as i64));
}
p.insert(
"quantityPer".into(),
GraphPropertyValue::Decimal(self.quantity),
);
p.insert("unit".into(), GraphPropertyValue::String(self.uom.clone()));
p.insert(
"scrapRate".into(),
GraphPropertyValue::Decimal(self.scrap_percentage),
);
p.insert(
"isPhantom".into(),
GraphPropertyValue::Bool(self.is_phantom),
);
p
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MaterialAccountDetermination {
pub inventory_account: String,
pub cogs_account: String,
pub revenue_account: String,
pub purchase_expense_account: String,
pub price_difference_account: String,
pub gr_ir_account: String,
}
impl Default for MaterialAccountDetermination {
fn default() -> Self {
Self {
inventory_account: "140000".to_string(),
cogs_account: "500000".to_string(),
revenue_account: "400000".to_string(),
purchase_expense_account: "600000".to_string(),
price_difference_account: "580000".to_string(),
gr_ir_account: "290000".to_string(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Material {
pub material_id: String,
pub description: String,
pub material_type: MaterialType,
pub material_group: MaterialGroup,
pub base_uom: UnitOfMeasure,
pub valuation_method: ValuationMethod,
pub standard_cost: Decimal,
pub list_price: Decimal,
pub purchase_price: Decimal,
pub bom_components: Option<Vec<BomComponent>>,
pub account_determination: MaterialAccountDetermination,
pub weight_kg: Option<Decimal>,
pub volume_m3: Option<Decimal>,
pub shelf_life_days: Option<u32>,
pub is_active: bool,
pub company_code: Option<String>,
pub plants: Vec<String>,
pub min_order_quantity: Decimal,
pub lead_time_days: u16,
pub safety_stock: Decimal,
pub reorder_point: Decimal,
pub preferred_vendor_id: Option<String>,
pub abc_classification: char,
}
impl Material {
pub fn new(
material_id: impl Into<String>,
description: impl Into<String>,
material_type: MaterialType,
) -> Self {
Self {
material_id: material_id.into(),
description: description.into(),
material_type,
material_group: MaterialGroup::default(),
base_uom: UnitOfMeasure::default(),
valuation_method: ValuationMethod::default(),
standard_cost: Decimal::ZERO,
list_price: Decimal::ZERO,
purchase_price: Decimal::ZERO,
bom_components: None,
account_determination: MaterialAccountDetermination::default(),
weight_kg: None,
volume_m3: None,
shelf_life_days: None,
is_active: true,
company_code: None,
plants: vec!["1000".to_string()],
min_order_quantity: Decimal::ONE,
lead_time_days: 7,
safety_stock: Decimal::ZERO,
reorder_point: Decimal::ZERO,
preferred_vendor_id: None,
abc_classification: 'B',
}
}
pub fn with_group(mut self, group: MaterialGroup) -> Self {
self.material_group = group;
self
}
pub fn with_standard_cost(mut self, cost: Decimal) -> Self {
self.standard_cost = cost;
self
}
pub fn with_list_price(mut self, price: Decimal) -> Self {
self.list_price = price;
self
}
pub fn with_purchase_price(mut self, price: Decimal) -> Self {
self.purchase_price = price;
self
}
pub fn with_bom(mut self, components: Vec<BomComponent>) -> Self {
self.bom_components = Some(components);
self
}
pub fn with_company_code(mut self, code: impl Into<String>) -> Self {
self.company_code = Some(code.into());
self
}
pub fn with_preferred_vendor(mut self, vendor_id: impl Into<String>) -> Self {
self.preferred_vendor_id = Some(vendor_id.into());
self
}
pub fn with_abc_classification(mut self, classification: char) -> Self {
self.abc_classification = classification;
self
}
pub fn calculate_bom_cost(
&self,
component_costs: &std::collections::HashMap<String, Decimal>,
) -> Option<Decimal> {
self.bom_components.as_ref().map(|components| {
components
.iter()
.map(|c| {
let unit_cost = component_costs
.get(&c.component_material_id)
.copied()
.unwrap_or(Decimal::ZERO);
unit_cost * c.effective_quantity()
})
.sum()
})
}
pub fn gross_margin_percent(&self) -> Decimal {
if self.list_price > Decimal::ZERO {
(self.list_price - self.standard_cost) / self.list_price * Decimal::from(100)
} else {
Decimal::ZERO
}
}
pub fn needs_reorder(&self, current_stock: Decimal) -> bool {
current_stock <= self.reorder_point
}
pub fn suggested_reorder_quantity(&self) -> Decimal {
self.reorder_point + self.safety_stock + self.min_order_quantity
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct MaterialPool {
pub materials: Vec<Material>,
#[serde(skip)]
type_index: std::collections::HashMap<MaterialType, Vec<usize>>,
#[serde(skip)]
group_index: std::collections::HashMap<MaterialGroup, Vec<usize>>,
#[serde(skip)]
abc_index: std::collections::HashMap<char, Vec<usize>>,
}
impl MaterialPool {
pub fn new() -> Self {
Self::default()
}
pub fn from_materials(materials: Vec<Material>) -> Self {
let mut pool = Self::new();
for material in materials {
pool.add_material(material);
}
pool
}
pub fn add_material(&mut self, material: Material) {
let idx = self.materials.len();
let material_type = material.material_type;
let material_group = material.material_group;
let abc = material.abc_classification;
self.materials.push(material);
self.type_index.entry(material_type).or_default().push(idx);
self.group_index
.entry(material_group)
.or_default()
.push(idx);
self.abc_index.entry(abc).or_default().push(idx);
}
pub fn random_material(&self, rng: &mut impl rand::Rng) -> Option<&Material> {
use rand::seq::IndexedRandom;
self.materials.choose(rng)
}
pub fn random_material_of_type(
&self,
material_type: MaterialType,
rng: &mut impl rand::Rng,
) -> Option<&Material> {
use rand::seq::IndexedRandom;
self.type_index
.get(&material_type)
.and_then(|indices| indices.choose(rng))
.map(|&idx| &self.materials[idx])
}
pub fn get_by_abc(&self, classification: char) -> Vec<&Material> {
self.abc_index
.get(&classification)
.map(|indices| indices.iter().map(|&i| &self.materials[i]).collect())
.unwrap_or_default()
}
pub fn rebuild_indices(&mut self) {
self.type_index.clear();
self.group_index.clear();
self.abc_index.clear();
for (idx, material) in self.materials.iter().enumerate() {
self.type_index
.entry(material.material_type)
.or_default()
.push(idx);
self.group_index
.entry(material.material_group)
.or_default()
.push(idx);
self.abc_index
.entry(material.abc_classification)
.or_default()
.push(idx);
}
}
pub fn get_by_id(&self, material_id: &str) -> Option<&Material> {
self.materials.iter().find(|m| m.material_id == material_id)
}
pub fn len(&self) -> usize {
self.materials.len()
}
pub fn is_empty(&self) -> bool {
self.materials.is_empty()
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn test_material_creation() {
let material = Material::new("MAT-001", "Test Material", MaterialType::RawMaterial)
.with_standard_cost(Decimal::from(100))
.with_list_price(Decimal::from(150))
.with_abc_classification('A');
assert_eq!(material.material_id, "MAT-001");
assert_eq!(material.standard_cost, Decimal::from(100));
assert_eq!(material.abc_classification, 'A');
}
#[test]
fn test_gross_margin() {
let material = Material::new("MAT-001", "Test", MaterialType::FinishedGood)
.with_standard_cost(Decimal::from(60))
.with_list_price(Decimal::from(100));
let margin = material.gross_margin_percent();
assert_eq!(margin, Decimal::from(40));
}
#[test]
fn test_bom_cost_calculation() {
let mut component_costs = std::collections::HashMap::new();
component_costs.insert("COMP-001".to_string(), Decimal::from(10));
component_costs.insert("COMP-002".to_string(), Decimal::from(20));
let material = Material::new("FG-001", "Finished Good", MaterialType::FinishedGood)
.with_bom(vec![
BomComponent::new("COMP-001", Decimal::from(2), "EA"),
BomComponent::new("COMP-002", Decimal::from(3), "EA"),
]);
let bom_cost = material.calculate_bom_cost(&component_costs).unwrap();
assert_eq!(bom_cost, Decimal::from(80)); }
#[test]
fn test_material_pool() {
let mut pool = MaterialPool::new();
pool.add_material(Material::new("MAT-001", "Raw 1", MaterialType::RawMaterial));
pool.add_material(Material::new(
"MAT-002",
"Finished 1",
MaterialType::FinishedGood,
));
pool.add_material(Material::new("MAT-003", "Raw 2", MaterialType::RawMaterial));
assert_eq!(pool.len(), 3);
assert!(pool.get_by_id("MAT-001").is_some());
assert!(pool.get_by_id("MAT-999").is_none());
}
#[test]
fn test_bom_component_scrap() {
let component =
BomComponent::new("COMP-001", Decimal::from(100), "EA").with_scrap(Decimal::from(5));
let effective = component.effective_quantity();
assert_eq!(effective, Decimal::from(105)); }
}