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