Skip to main content

datasynth_core/models/
material.rs

1//! Material master data model.
2//!
3//! Provides material/product master data for realistic inventory
4//! and procurement transaction generation.
5
6use rust_decimal::Decimal;
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9
10use super::graph_properties::{GraphPropertyValue, ToNodeProperties};
11
12/// Type of material in the system.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
14#[serde(rename_all = "snake_case")]
15pub enum MaterialType {
16    /// Raw materials used in production
17    #[default]
18    RawMaterial,
19    /// Semi-finished goods
20    SemiFinished,
21    /// Finished goods for sale
22    FinishedGood,
23    /// Trading goods (resale without transformation)
24    TradingGood,
25    /// Operating supplies (consumables)
26    OperatingSupplies,
27    /// Spare parts
28    SparePart,
29    /// Packaging material
30    Packaging,
31    /// Service (non-physical)
32    Service,
33}
34
35impl MaterialType {
36    /// Get the typical account category for this material type.
37    pub fn inventory_account_category(&self) -> &'static str {
38        match self {
39            Self::RawMaterial => "Raw Materials Inventory",
40            Self::SemiFinished => "Work in Progress",
41            Self::FinishedGood => "Finished Goods Inventory",
42            Self::TradingGood => "Trading Goods Inventory",
43            Self::OperatingSupplies => "Supplies Inventory",
44            Self::SparePart => "Spare Parts Inventory",
45            Self::Packaging => "Packaging Materials",
46            Self::Service => "N/A",
47        }
48    }
49
50    /// Check if this material type has physical inventory.
51    pub fn has_inventory(&self) -> bool {
52        !matches!(self, Self::Service)
53    }
54}
55
56/// Material group for categorization.
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
58#[serde(rename_all = "snake_case")]
59pub enum MaterialGroup {
60    /// Electronics and components
61    #[default]
62    Electronics,
63    /// Mechanical parts
64    Mechanical,
65    /// Chemicals and raw materials
66    Chemicals,
67    /// Chemical (alias for Chemicals)
68    Chemical,
69    /// Office supplies
70    OfficeSupplies,
71    /// IT equipment
72    ItEquipment,
73    /// Furniture
74    Furniture,
75    /// Packaging materials
76    PackagingMaterials,
77    /// Safety equipment
78    SafetyEquipment,
79    /// Tools
80    Tools,
81    /// Services
82    Services,
83    /// Consumables
84    Consumables,
85    /// Finished goods
86    FinishedGoods,
87}
88
89impl MaterialGroup {
90    /// Get typical unit of measure for this material group.
91    pub fn typical_uom(&self) -> &'static str {
92        match self {
93            Self::Electronics | Self::Mechanical | Self::ItEquipment => "EA",
94            Self::Chemicals | Self::Chemical => "KG",
95            Self::OfficeSupplies | Self::PackagingMaterials | Self::Consumables => "EA",
96            Self::Furniture | Self::FinishedGoods => "EA",
97            Self::SafetyEquipment | Self::Tools => "EA",
98            Self::Services => "HR",
99        }
100    }
101}
102
103/// Inventory valuation method.
104#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
105#[serde(rename_all = "snake_case")]
106pub enum ValuationMethod {
107    /// Standard cost valuation
108    #[default]
109    StandardCost,
110    /// Moving average price
111    MovingAverage,
112    /// First-in, first-out
113    Fifo,
114    /// Last-in, first-out (where permitted)
115    Lifo,
116    /// Specific identification
117    SpecificIdentification,
118}
119
120/// Unit of measure for materials.
121#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
122pub struct UnitOfMeasure {
123    /// Unit code (e.g., "EA", "KG", "L")
124    pub code: String,
125    /// Full name
126    pub name: String,
127    /// Conversion factor to base unit (1.0 for base unit)
128    pub conversion_factor: Decimal,
129}
130
131impl UnitOfMeasure {
132    /// Create each (piece) unit.
133    pub fn each() -> Self {
134        Self {
135            code: "EA".to_string(),
136            name: "Each".to_string(),
137            conversion_factor: Decimal::ONE,
138        }
139    }
140
141    /// Create kilogram unit.
142    pub fn kilogram() -> Self {
143        Self {
144            code: "KG".to_string(),
145            name: "Kilogram".to_string(),
146            conversion_factor: Decimal::ONE,
147        }
148    }
149
150    /// Create liter unit.
151    pub fn liter() -> Self {
152        Self {
153            code: "L".to_string(),
154            name: "Liter".to_string(),
155            conversion_factor: Decimal::ONE,
156        }
157    }
158
159    /// Create hour unit (for services).
160    pub fn hour() -> Self {
161        Self {
162            code: "HR".to_string(),
163            name: "Hour".to_string(),
164            conversion_factor: Decimal::ONE,
165        }
166    }
167}
168
169impl Default for UnitOfMeasure {
170    fn default() -> Self {
171        Self::each()
172    }
173}
174
175/// Component in a bill of materials.
176#[derive(Debug, Clone, Serialize, Deserialize)]
177pub struct BomComponent {
178    /// Component material ID
179    pub component_material_id: String,
180    /// Quantity required per parent unit
181    pub quantity: Decimal,
182    /// Unit of measure
183    pub uom: String,
184    /// Scrap percentage (waste factor)
185    pub scrap_percentage: Decimal,
186    /// Is this component optional?
187    pub is_optional: bool,
188    /// Position/sequence in BOM
189    pub position: u16,
190
191    // -- Graph export fields (DS-008) --
192    /// Unique BOM line identifier
193    #[serde(default, skip_serializing_if = "Option::is_none")]
194    pub id: Option<String>,
195    /// Company / entity code
196    #[serde(default, skip_serializing_if = "Option::is_none")]
197    pub entity_code: Option<String>,
198    /// Parent (finished/semi-finished) material ID
199    #[serde(default, skip_serializing_if = "Option::is_none")]
200    pub parent_material: Option<String>,
201    /// Component description
202    #[serde(default, skip_serializing_if = "Option::is_none")]
203    pub component_description: Option<String>,
204    /// BOM level (1 = direct component, 2+ = sub-assembly)
205    #[serde(default, skip_serializing_if = "Option::is_none")]
206    pub level: Option<u32>,
207    /// Whether this is a phantom assembly (exploded during MRP)
208    #[serde(default)]
209    pub is_phantom: bool,
210}
211
212impl BomComponent {
213    /// Create a new BOM component.
214    pub fn new(
215        component_material_id: impl Into<String>,
216        quantity: Decimal,
217        uom: impl Into<String>,
218    ) -> Self {
219        Self {
220            component_material_id: component_material_id.into(),
221            quantity,
222            uom: uom.into(),
223            scrap_percentage: Decimal::ZERO,
224            is_optional: false,
225            position: 0,
226            id: None,
227            entity_code: None,
228            parent_material: None,
229            component_description: None,
230            level: None,
231            is_phantom: false,
232        }
233    }
234
235    /// Set scrap percentage.
236    pub fn with_scrap(mut self, scrap_percentage: Decimal) -> Self {
237        self.scrap_percentage = scrap_percentage;
238        self
239    }
240
241    /// Calculate effective quantity including scrap.
242    pub fn effective_quantity(&self) -> Decimal {
243        self.quantity * (Decimal::ONE + self.scrap_percentage / Decimal::from(100))
244    }
245}
246
247impl ToNodeProperties for BomComponent {
248    fn node_type_name(&self) -> &'static str {
249        "bom_component"
250    }
251    fn node_type_code(&self) -> u16 {
252        343
253    }
254    fn to_node_properties(&self) -> HashMap<String, GraphPropertyValue> {
255        let mut p = HashMap::new();
256        if let Some(ref ec) = self.entity_code {
257            p.insert("entityCode".into(), GraphPropertyValue::String(ec.clone()));
258        }
259        if let Some(ref pm) = self.parent_material {
260            p.insert(
261                "parentMaterial".into(),
262                GraphPropertyValue::String(pm.clone()),
263            );
264        }
265        p.insert(
266            "componentMaterial".into(),
267            GraphPropertyValue::String(self.component_material_id.clone()),
268        );
269        if let Some(ref desc) = self.component_description {
270            p.insert(
271                "componentDescription".into(),
272                GraphPropertyValue::String(desc.clone()),
273            );
274        }
275        if let Some(lvl) = self.level {
276            p.insert("level".into(), GraphPropertyValue::Int(lvl as i64));
277        }
278        p.insert(
279            "quantityPer".into(),
280            GraphPropertyValue::Decimal(self.quantity),
281        );
282        p.insert("unit".into(), GraphPropertyValue::String(self.uom.clone()));
283        p.insert(
284            "scrapRate".into(),
285            GraphPropertyValue::Decimal(self.scrap_percentage),
286        );
287        p.insert(
288            "isPhantom".into(),
289            GraphPropertyValue::Bool(self.is_phantom),
290        );
291        p
292    }
293}
294
295/// Account determination rules for material transactions.
296#[derive(Debug, Clone, Serialize, Deserialize)]
297pub struct MaterialAccountDetermination {
298    /// Inventory account
299    pub inventory_account: String,
300    /// COGS account (for sales)
301    pub cogs_account: String,
302    /// Revenue account (for sales)
303    pub revenue_account: String,
304    /// Purchase expense account (for non-inventory items)
305    pub purchase_expense_account: String,
306    /// Price difference account
307    pub price_difference_account: String,
308    /// GR/IR clearing account
309    pub gr_ir_account: String,
310}
311
312impl Default for MaterialAccountDetermination {
313    fn default() -> Self {
314        Self {
315            inventory_account: "140000".to_string(),
316            cogs_account: "500000".to_string(),
317            revenue_account: "400000".to_string(),
318            purchase_expense_account: "600000".to_string(),
319            price_difference_account: "580000".to_string(),
320            gr_ir_account: "290000".to_string(),
321        }
322    }
323}
324
325/// Material master data.
326#[derive(Debug, Clone, Serialize, Deserialize)]
327pub struct Material {
328    /// Material ID (e.g., "MAT-001234")
329    pub material_id: String,
330
331    /// Material description
332    pub description: String,
333
334    /// Type of material
335    pub material_type: MaterialType,
336
337    /// Material group
338    pub material_group: MaterialGroup,
339
340    /// Base unit of measure
341    pub base_uom: UnitOfMeasure,
342
343    /// Valuation method
344    pub valuation_method: ValuationMethod,
345
346    /// Standard cost per base unit
347    pub standard_cost: Decimal,
348
349    /// List price (selling price) per base unit
350    pub list_price: Decimal,
351
352    /// Purchase price per base unit
353    pub purchase_price: Decimal,
354
355    /// Bill of materials components (if this is a produced item)
356    pub bom_components: Option<Vec<BomComponent>>,
357
358    /// Account determination rules
359    pub account_determination: MaterialAccountDetermination,
360
361    /// Weight per base unit (kg)
362    pub weight_kg: Option<Decimal>,
363
364    /// Volume per base unit (m3)
365    pub volume_m3: Option<Decimal>,
366
367    /// Shelf life in days (for perishables)
368    pub shelf_life_days: Option<u32>,
369
370    /// Is this material active?
371    pub is_active: bool,
372
373    /// Company code (if material is company-specific)
374    pub company_code: Option<String>,
375
376    /// Plant/location codes where material is available
377    pub plants: Vec<String>,
378
379    /// Minimum order quantity
380    pub min_order_quantity: Decimal,
381
382    /// Lead time in days for procurement
383    pub lead_time_days: u16,
384
385    /// Safety stock quantity
386    pub safety_stock: Decimal,
387
388    /// Reorder point
389    pub reorder_point: Decimal,
390
391    /// Preferred vendor ID
392    pub preferred_vendor_id: Option<String>,
393
394    /// ABC classification (A=high value, C=low value)
395    pub abc_classification: char,
396}
397
398impl Material {
399    /// Create a new material with minimal required fields.
400    pub fn new(
401        material_id: impl Into<String>,
402        description: impl Into<String>,
403        material_type: MaterialType,
404    ) -> Self {
405        Self {
406            material_id: material_id.into(),
407            description: description.into(),
408            material_type,
409            material_group: MaterialGroup::default(),
410            base_uom: UnitOfMeasure::default(),
411            valuation_method: ValuationMethod::default(),
412            standard_cost: Decimal::ZERO,
413            list_price: Decimal::ZERO,
414            purchase_price: Decimal::ZERO,
415            bom_components: None,
416            account_determination: MaterialAccountDetermination::default(),
417            weight_kg: None,
418            volume_m3: None,
419            shelf_life_days: None,
420            is_active: true,
421            company_code: None,
422            plants: vec!["1000".to_string()],
423            min_order_quantity: Decimal::ONE,
424            lead_time_days: 7,
425            safety_stock: Decimal::ZERO,
426            reorder_point: Decimal::ZERO,
427            preferred_vendor_id: None,
428            abc_classification: 'B',
429        }
430    }
431
432    /// Set material group.
433    pub fn with_group(mut self, group: MaterialGroup) -> Self {
434        self.material_group = group;
435        self
436    }
437
438    /// Set standard cost.
439    pub fn with_standard_cost(mut self, cost: Decimal) -> Self {
440        self.standard_cost = cost;
441        self
442    }
443
444    /// Set list price.
445    pub fn with_list_price(mut self, price: Decimal) -> Self {
446        self.list_price = price;
447        self
448    }
449
450    /// Set purchase price.
451    pub fn with_purchase_price(mut self, price: Decimal) -> Self {
452        self.purchase_price = price;
453        self
454    }
455
456    /// Set BOM components.
457    pub fn with_bom(mut self, components: Vec<BomComponent>) -> Self {
458        self.bom_components = Some(components);
459        self
460    }
461
462    /// Set company code.
463    pub fn with_company_code(mut self, code: impl Into<String>) -> Self {
464        self.company_code = Some(code.into());
465        self
466    }
467
468    /// Set preferred vendor.
469    pub fn with_preferred_vendor(mut self, vendor_id: impl Into<String>) -> Self {
470        self.preferred_vendor_id = Some(vendor_id.into());
471        self
472    }
473
474    /// Set ABC classification.
475    pub fn with_abc_classification(mut self, classification: char) -> Self {
476        self.abc_classification = classification;
477        self
478    }
479
480    /// Calculate the theoretical cost from BOM.
481    pub fn calculate_bom_cost(
482        &self,
483        component_costs: &std::collections::HashMap<String, Decimal>,
484    ) -> Option<Decimal> {
485        self.bom_components.as_ref().map(|components| {
486            components
487                .iter()
488                .map(|c| {
489                    let unit_cost = component_costs
490                        .get(&c.component_material_id)
491                        .copied()
492                        .unwrap_or(Decimal::ZERO);
493                    unit_cost * c.effective_quantity()
494                })
495                .sum()
496        })
497    }
498
499    /// Calculate gross margin percentage.
500    pub fn gross_margin_percent(&self) -> Decimal {
501        if self.list_price > Decimal::ZERO {
502            (self.list_price - self.standard_cost) / self.list_price * Decimal::from(100)
503        } else {
504            Decimal::ZERO
505        }
506    }
507
508    /// Check if reorder is needed based on current stock.
509    pub fn needs_reorder(&self, current_stock: Decimal) -> bool {
510        current_stock <= self.reorder_point
511    }
512
513    /// Calculate reorder quantity based on EOQ principles.
514    pub fn suggested_reorder_quantity(&self) -> Decimal {
515        // Simplified: order enough to cover lead time plus safety stock
516        self.reorder_point + self.safety_stock + self.min_order_quantity
517    }
518}
519
520/// Pool of materials for transaction generation.
521#[derive(Debug, Clone, Default, Serialize, Deserialize)]
522pub struct MaterialPool {
523    /// All materials
524    pub materials: Vec<Material>,
525    /// Index by material type
526    #[serde(skip)]
527    type_index: std::collections::HashMap<MaterialType, Vec<usize>>,
528    /// Index by material group
529    #[serde(skip)]
530    group_index: std::collections::HashMap<MaterialGroup, Vec<usize>>,
531    /// Index by ABC classification
532    #[serde(skip)]
533    abc_index: std::collections::HashMap<char, Vec<usize>>,
534}
535
536impl MaterialPool {
537    /// Create a new empty material pool.
538    pub fn new() -> Self {
539        Self::default()
540    }
541
542    /// Create a material pool from a vector of materials.
543    ///
544    /// This is the preferred way to create a pool from generated master data,
545    /// ensuring JEs reference real entities.
546    pub fn from_materials(materials: Vec<Material>) -> Self {
547        let mut pool = Self::new();
548        for material in materials {
549            pool.add_material(material);
550        }
551        pool
552    }
553
554    /// Add a material to the pool.
555    pub fn add_material(&mut self, material: Material) {
556        let idx = self.materials.len();
557        let material_type = material.material_type;
558        let material_group = material.material_group;
559        let abc = material.abc_classification;
560
561        self.materials.push(material);
562
563        self.type_index.entry(material_type).or_default().push(idx);
564        self.group_index
565            .entry(material_group)
566            .or_default()
567            .push(idx);
568        self.abc_index.entry(abc).or_default().push(idx);
569    }
570
571    /// Get a random material.
572    pub fn random_material(&self, rng: &mut impl rand::Rng) -> Option<&Material> {
573        use rand::seq::IndexedRandom;
574        self.materials.choose(rng)
575    }
576
577    /// Get a random material of a specific type.
578    pub fn random_material_of_type(
579        &self,
580        material_type: MaterialType,
581        rng: &mut impl rand::Rng,
582    ) -> Option<&Material> {
583        use rand::seq::IndexedRandom;
584        self.type_index
585            .get(&material_type)
586            .and_then(|indices| indices.choose(rng))
587            .map(|&idx| &self.materials[idx])
588    }
589
590    /// Get materials by ABC classification.
591    pub fn get_by_abc(&self, classification: char) -> Vec<&Material> {
592        self.abc_index
593            .get(&classification)
594            .map(|indices| indices.iter().map(|&i| &self.materials[i]).collect())
595            .unwrap_or_default()
596    }
597
598    /// Rebuild indices after deserialization.
599    pub fn rebuild_indices(&mut self) {
600        self.type_index.clear();
601        self.group_index.clear();
602        self.abc_index.clear();
603
604        for (idx, material) in self.materials.iter().enumerate() {
605            self.type_index
606                .entry(material.material_type)
607                .or_default()
608                .push(idx);
609            self.group_index
610                .entry(material.material_group)
611                .or_default()
612                .push(idx);
613            self.abc_index
614                .entry(material.abc_classification)
615                .or_default()
616                .push(idx);
617        }
618    }
619
620    /// Get material by ID.
621    pub fn get_by_id(&self, material_id: &str) -> Option<&Material> {
622        self.materials.iter().find(|m| m.material_id == material_id)
623    }
624
625    /// Get count of materials.
626    pub fn len(&self) -> usize {
627        self.materials.len()
628    }
629
630    /// Check if pool is empty.
631    pub fn is_empty(&self) -> bool {
632        self.materials.is_empty()
633    }
634}
635
636#[cfg(test)]
637#[allow(clippy::unwrap_used)]
638mod tests {
639    use super::*;
640
641    #[test]
642    fn test_material_creation() {
643        let material = Material::new("MAT-001", "Test Material", MaterialType::RawMaterial)
644            .with_standard_cost(Decimal::from(100))
645            .with_list_price(Decimal::from(150))
646            .with_abc_classification('A');
647
648        assert_eq!(material.material_id, "MAT-001");
649        assert_eq!(material.standard_cost, Decimal::from(100));
650        assert_eq!(material.abc_classification, 'A');
651    }
652
653    #[test]
654    fn test_gross_margin() {
655        let material = Material::new("MAT-001", "Test", MaterialType::FinishedGood)
656            .with_standard_cost(Decimal::from(60))
657            .with_list_price(Decimal::from(100));
658
659        let margin = material.gross_margin_percent();
660        assert_eq!(margin, Decimal::from(40));
661    }
662
663    #[test]
664    fn test_bom_cost_calculation() {
665        let mut component_costs = std::collections::HashMap::new();
666        component_costs.insert("COMP-001".to_string(), Decimal::from(10));
667        component_costs.insert("COMP-002".to_string(), Decimal::from(20));
668
669        let material = Material::new("FG-001", "Finished Good", MaterialType::FinishedGood)
670            .with_bom(vec![
671                BomComponent::new("COMP-001", Decimal::from(2), "EA"),
672                BomComponent::new("COMP-002", Decimal::from(3), "EA"),
673            ]);
674
675        let bom_cost = material.calculate_bom_cost(&component_costs).unwrap();
676        assert_eq!(bom_cost, Decimal::from(80)); // 2*10 + 3*20
677    }
678
679    #[test]
680    fn test_material_pool() {
681        let mut pool = MaterialPool::new();
682
683        pool.add_material(Material::new("MAT-001", "Raw 1", MaterialType::RawMaterial));
684        pool.add_material(Material::new(
685            "MAT-002",
686            "Finished 1",
687            MaterialType::FinishedGood,
688        ));
689        pool.add_material(Material::new("MAT-003", "Raw 2", MaterialType::RawMaterial));
690
691        assert_eq!(pool.len(), 3);
692        assert!(pool.get_by_id("MAT-001").is_some());
693        assert!(pool.get_by_id("MAT-999").is_none());
694    }
695
696    #[test]
697    fn test_bom_component_scrap() {
698        let component =
699            BomComponent::new("COMP-001", Decimal::from(100), "EA").with_scrap(Decimal::from(5)); // 5% scrap
700
701        let effective = component.effective_quantity();
702        assert_eq!(effective, Decimal::from(105)); // 100 * 1.05
703    }
704}