Skip to main content

datasynth_generators/master_data/
material_generator.rs

1//! Material generator for inventory and product master data.
2
3use chrono::NaiveDate;
4use datasynth_core::models::{
5    BomComponent, Material, MaterialAccountDetermination, MaterialGroup, MaterialPool,
6    MaterialType, UnitOfMeasure, ValuationMethod,
7};
8use rand::prelude::*;
9use rand_chacha::ChaCha8Rng;
10use rust_decimal::Decimal;
11
12/// Configuration for material generation.
13#[derive(Debug, Clone)]
14pub struct MaterialGeneratorConfig {
15    /// Distribution of material types (type, probability)
16    pub material_type_distribution: Vec<(MaterialType, f64)>,
17    /// Distribution of valuation methods (method, probability)
18    pub valuation_method_distribution: Vec<(ValuationMethod, f64)>,
19    /// Probability of material having BOM
20    pub bom_rate: f64,
21    /// Default base unit of measure
22    pub default_uom: String,
23    /// Gross margin range (min, max) as percentages
24    pub gross_margin_range: (f64, f64),
25    /// Standard cost range (min, max)
26    pub standard_cost_range: (Decimal, Decimal),
27}
28
29impl Default for MaterialGeneratorConfig {
30    fn default() -> Self {
31        Self {
32            material_type_distribution: vec![
33                (MaterialType::FinishedGood, 0.30),
34                (MaterialType::RawMaterial, 0.35),
35                (MaterialType::SemiFinished, 0.15),
36                (MaterialType::TradingGood, 0.10),
37                (MaterialType::OperatingSupplies, 0.05),
38                (MaterialType::Packaging, 0.05),
39            ],
40            valuation_method_distribution: vec![
41                (ValuationMethod::StandardCost, 0.60),
42                (ValuationMethod::MovingAverage, 0.30),
43                (ValuationMethod::Fifo, 0.08),
44                (ValuationMethod::Lifo, 0.02),
45            ],
46            bom_rate: 0.25,
47            default_uom: "EA".to_string(),
48            gross_margin_range: (0.20, 0.50),
49            standard_cost_range: (Decimal::from(10), Decimal::from(10_000)),
50        }
51    }
52}
53
54/// Material description templates by type.
55const MATERIAL_DESCRIPTIONS: &[(MaterialType, &[&str])] = &[
56    (
57        MaterialType::FinishedGood,
58        &[
59            "Assembled Unit A",
60            "Complete Product B",
61            "Final Assembly C",
62            "Packaged Item D",
63            "Ready Product E",
64            "Finished Component F",
65            "Complete Module G",
66            "Final Product H",
67        ],
68    ),
69    (
70        MaterialType::RawMaterial,
71        &[
72            "Steel Plate Grade A",
73            "Aluminum Sheet 6061",
74            "Copper Wire AWG 12",
75            "Plastic Resin ABS",
76            "Raw Polymer Mix",
77            "Chemical Compound X",
78            "Base Material Y",
79            "Raw Stock Z",
80        ],
81    ),
82    (
83        MaterialType::SemiFinished,
84        &[
85            "Sub-Assembly Part A",
86            "Machined Component B",
87            "Intermediate Product C",
88            "Partial Assembly D",
89            "Semi-Complete Unit E",
90            "Work in Progress F",
91            "Partially Processed G",
92            "Intermediate Module H",
93        ],
94    ),
95    (
96        MaterialType::TradingGood,
97        &[
98            "Resale Item A",
99            "Trading Good B",
100            "Merchandise C",
101            "Distribution Item D",
102            "Wholesale Product E",
103            "Retail Item F",
104            "Trade Good G",
105            "Commercial Product H",
106        ],
107    ),
108    (
109        MaterialType::OperatingSupplies,
110        &[
111            "Cleaning Supplies",
112            "Office Supplies",
113            "Maintenance Supplies",
114            "Workshop Consumables",
115            "Safety Supplies",
116            "Facility Supplies",
117            "General Supplies",
118            "Operating Materials",
119        ],
120    ),
121    (
122        MaterialType::Packaging,
123        &[
124            "Cardboard Box Large",
125            "Plastic Container",
126            "Shipping Carton",
127            "Protective Wrap",
128            "Pallet Unit",
129            "Foam Insert",
130            "Label Roll",
131            "Tape Industrial",
132        ],
133    ),
134];
135
136/// Material group templates.
137const MATERIAL_GROUPS: &[(MaterialGroup, &str)] = &[
138    (MaterialGroup::Electronics, "ELEC"),
139    (MaterialGroup::Mechanical, "MECH"),
140    (MaterialGroup::Chemical, "CHEM"),
141    (MaterialGroup::PackagingMaterials, "PACK"),
142    (MaterialGroup::Chemicals, "RAWM"),
143    (MaterialGroup::FinishedGoods, "FING"),
144    (MaterialGroup::Tools, "TOOL"),
145    (MaterialGroup::Consumables, "CONS"),
146    (MaterialGroup::Services, "SERV"),
147];
148
149/// Generator for material master data.
150pub struct MaterialGenerator {
151    rng: ChaCha8Rng,
152    seed: u64,
153    config: MaterialGeneratorConfig,
154    material_counter: usize,
155    created_materials: Vec<String>, // Track for BOM references
156}
157
158impl MaterialGenerator {
159    /// Create a new material generator.
160    pub fn new(seed: u64) -> Self {
161        Self::with_config(seed, MaterialGeneratorConfig::default())
162    }
163
164    /// Create a new material generator with custom configuration.
165    pub fn with_config(seed: u64, config: MaterialGeneratorConfig) -> Self {
166        Self {
167            rng: ChaCha8Rng::seed_from_u64(seed),
168            seed,
169            config,
170            material_counter: 0,
171            created_materials: Vec::new(),
172        }
173    }
174
175    /// Generate a single material.
176    pub fn generate_material(
177        &mut self,
178        _company_code: &str,
179        _effective_date: NaiveDate,
180    ) -> Material {
181        self.material_counter += 1;
182
183        let material_id = format!("MAT-{:06}", self.material_counter);
184        let material_type = self.select_material_type();
185        let description = self.select_description(&material_type);
186
187        let mut material =
188            Material::new(material_id.clone(), description.to_string(), material_type);
189
190        // Set material group
191        material.material_group = self.select_material_group(&material_type);
192
193        // Set valuation method
194        material.valuation_method = self.select_valuation_method();
195
196        // Set costs and prices
197        let standard_cost = self.generate_standard_cost();
198        material.standard_cost = standard_cost;
199        material.purchase_price = standard_cost;
200        material.list_price = self.generate_list_price(standard_cost);
201
202        // Set unit of measure
203        material.base_uom = if material_type == MaterialType::OperatingSupplies {
204            UnitOfMeasure::hour()
205        } else {
206            UnitOfMeasure::each()
207        };
208
209        // Set account determination
210        material.account_determination = self.generate_account_determination(&material_type);
211
212        // Set stock and reorder info
213        if material_type != MaterialType::OperatingSupplies {
214            material.safety_stock = self.generate_safety_stock();
215            material.reorder_point = material.safety_stock * Decimal::from(2);
216        }
217
218        // Add to created materials for BOM references
219        self.created_materials.push(material_id);
220
221        material
222    }
223
224    /// Generate a material with specific type.
225    pub fn generate_material_of_type(
226        &mut self,
227        material_type: MaterialType,
228        _company_code: &str,
229        _effective_date: NaiveDate,
230    ) -> Material {
231        self.material_counter += 1;
232
233        let material_id = format!("MAT-{:06}", self.material_counter);
234        let description = self.select_description(&material_type);
235
236        let mut material =
237            Material::new(material_id.clone(), description.to_string(), material_type);
238
239        material.material_group = self.select_material_group(&material_type);
240        material.valuation_method = self.select_valuation_method();
241
242        let standard_cost = self.generate_standard_cost();
243        material.standard_cost = standard_cost;
244        material.purchase_price = standard_cost;
245        material.list_price = self.generate_list_price(standard_cost);
246
247        material.base_uom = if material_type == MaterialType::OperatingSupplies {
248            UnitOfMeasure::hour()
249        } else {
250            UnitOfMeasure::each()
251        };
252
253        material.account_determination = self.generate_account_determination(&material_type);
254
255        if material_type != MaterialType::OperatingSupplies {
256            material.safety_stock = self.generate_safety_stock();
257            material.reorder_point = material.safety_stock * Decimal::from(2);
258        }
259
260        self.created_materials.push(material_id);
261
262        material
263    }
264
265    /// Generate a material with BOM.
266    pub fn generate_material_with_bom(
267        &mut self,
268        company_code: &str,
269        effective_date: NaiveDate,
270        component_count: usize,
271    ) -> Material {
272        // Generate component materials first
273        let mut components = Vec::new();
274        for i in 0..component_count {
275            let component_type = if i % 2 == 0 {
276                MaterialType::RawMaterial
277            } else {
278                MaterialType::SemiFinished
279            };
280            let component =
281                self.generate_material_of_type(component_type, company_code, effective_date);
282
283            let quantity = Decimal::from(self.rng.gen_range(1..10));
284            components.push(BomComponent {
285                component_material_id: component.material_id.clone(),
286                quantity,
287                uom: component.base_uom.code.clone(),
288                position: (i + 1) as u16 * 10,
289                scrap_percentage: Decimal::ZERO,
290                is_optional: false,
291            });
292        }
293
294        // Generate the finished good with BOM
295        let mut material = self.generate_material_of_type(
296            MaterialType::FinishedGood,
297            company_code,
298            effective_date,
299        );
300
301        material.bom_components = Some(components);
302
303        material
304    }
305
306    /// Generate a material pool with specified count.
307    pub fn generate_material_pool(
308        &mut self,
309        count: usize,
310        company_code: &str,
311        effective_date: NaiveDate,
312    ) -> MaterialPool {
313        let mut pool = MaterialPool::new();
314
315        for _ in 0..count {
316            let material = self.generate_material(company_code, effective_date);
317            pool.add_material(material);
318        }
319
320        pool
321    }
322
323    /// Generate a material pool with BOMs.
324    pub fn generate_material_pool_with_bom(
325        &mut self,
326        count: usize,
327        bom_rate: f64,
328        company_code: &str,
329        effective_date: NaiveDate,
330    ) -> MaterialPool {
331        let mut pool = MaterialPool::new();
332
333        // First generate raw materials and semi-finished goods
334        let raw_count = (count as f64 * 0.4) as usize;
335        for _ in 0..raw_count {
336            let material = self.generate_material_of_type(
337                MaterialType::RawMaterial,
338                company_code,
339                effective_date,
340            );
341            pool.add_material(material);
342        }
343
344        let semi_count = (count as f64 * 0.2) as usize;
345        for _ in 0..semi_count {
346            let material = self.generate_material_of_type(
347                MaterialType::SemiFinished,
348                company_code,
349                effective_date,
350            );
351            pool.add_material(material);
352        }
353
354        // Generate finished goods, some with BOM
355        let finished_count = count - raw_count - semi_count;
356        for _ in 0..finished_count {
357            let material = if self.rng.gen::<f64>() < bom_rate && !self.created_materials.is_empty()
358            {
359                self.generate_material_with_bom_from_existing(company_code, effective_date)
360            } else {
361                self.generate_material_of_type(
362                    MaterialType::FinishedGood,
363                    company_code,
364                    effective_date,
365                )
366            };
367            pool.add_material(material);
368        }
369
370        pool
371    }
372
373    /// Generate a material with BOM using existing materials.
374    fn generate_material_with_bom_from_existing(
375        &mut self,
376        company_code: &str,
377        effective_date: NaiveDate,
378    ) -> Material {
379        let mut material = self.generate_material_of_type(
380            MaterialType::FinishedGood,
381            company_code,
382            effective_date,
383        );
384
385        // Select some existing materials as components
386        let component_count = self.rng.gen_range(2..=5).min(self.created_materials.len());
387        let mut components = Vec::new();
388
389        for i in 0..component_count {
390            if let Some(component_material_id) = self.created_materials.get(i) {
391                components.push(BomComponent {
392                    component_material_id: component_material_id.clone(),
393                    quantity: Decimal::from(self.rng.gen_range(1..5)),
394                    uom: "EA".to_string(),
395                    position: (i + 1) as u16 * 10,
396                    scrap_percentage: Decimal::ZERO,
397                    is_optional: false,
398                });
399            }
400        }
401
402        if !components.is_empty() {
403            material.bom_components = Some(components);
404        }
405
406        material
407    }
408
409    /// Select material type based on distribution.
410    fn select_material_type(&mut self) -> MaterialType {
411        let roll: f64 = self.rng.gen();
412        let mut cumulative = 0.0;
413
414        for (mat_type, prob) in &self.config.material_type_distribution {
415            cumulative += prob;
416            if roll < cumulative {
417                return *mat_type;
418            }
419        }
420
421        MaterialType::FinishedGood
422    }
423
424    /// Select valuation method based on distribution.
425    fn select_valuation_method(&mut self) -> ValuationMethod {
426        let roll: f64 = self.rng.gen();
427        let mut cumulative = 0.0;
428
429        for (method, prob) in &self.config.valuation_method_distribution {
430            cumulative += prob;
431            if roll < cumulative {
432                return *method;
433            }
434        }
435
436        ValuationMethod::StandardCost
437    }
438
439    /// Select description for material type.
440    fn select_description(&mut self, material_type: &MaterialType) -> &'static str {
441        for (mat_type, descriptions) in MATERIAL_DESCRIPTIONS {
442            if mat_type == material_type {
443                let idx = self.rng.gen_range(0..descriptions.len());
444                return descriptions[idx];
445            }
446        }
447        "Generic Material"
448    }
449
450    /// Select material group for type.
451    fn select_material_group(&mut self, material_type: &MaterialType) -> MaterialGroup {
452        match material_type {
453            MaterialType::FinishedGood => {
454                let options = [
455                    MaterialGroup::Electronics,
456                    MaterialGroup::Mechanical,
457                    MaterialGroup::FinishedGoods,
458                ];
459                options[self.rng.gen_range(0..options.len())]
460            }
461            MaterialType::RawMaterial => {
462                let options = [
463                    MaterialGroup::Chemicals,
464                    MaterialGroup::Chemical,
465                    MaterialGroup::Mechanical,
466                ];
467                options[self.rng.gen_range(0..options.len())]
468            }
469            MaterialType::SemiFinished => {
470                let options = [MaterialGroup::Electronics, MaterialGroup::Mechanical];
471                options[self.rng.gen_range(0..options.len())]
472            }
473            MaterialType::TradingGood => MaterialGroup::FinishedGoods,
474            MaterialType::OperatingSupplies => MaterialGroup::Services,
475            MaterialType::Packaging | MaterialType::SparePart => MaterialGroup::Consumables,
476            _ => MaterialGroup::Consumables,
477        }
478    }
479
480    /// Generate standard cost.
481    fn generate_standard_cost(&mut self) -> Decimal {
482        let min = self.config.standard_cost_range.0;
483        let max = self.config.standard_cost_range.1;
484        let range = (max - min).to_string().parse::<f64>().unwrap_or(0.0);
485        let offset =
486            Decimal::from_f64_retain(self.rng.gen::<f64>() * range).unwrap_or(Decimal::ZERO);
487        (min + offset).round_dp(2)
488    }
489
490    /// Generate list price from standard cost.
491    fn generate_list_price(&mut self, standard_cost: Decimal) -> Decimal {
492        let (min_margin, max_margin) = self.config.gross_margin_range;
493        let margin = min_margin + self.rng.gen::<f64>() * (max_margin - min_margin);
494        let markup = Decimal::from_f64_retain(1.0 / (1.0 - margin)).unwrap_or(Decimal::from(2));
495        (standard_cost * markup).round_dp(2)
496    }
497
498    /// Generate safety stock.
499    fn generate_safety_stock(&mut self) -> Decimal {
500        Decimal::from(self.rng.gen_range(10..500))
501    }
502
503    /// Generate account determination.
504    fn generate_account_determination(
505        &mut self,
506        material_type: &MaterialType,
507    ) -> MaterialAccountDetermination {
508        match material_type {
509            MaterialType::FinishedGood | MaterialType::TradingGood => {
510                MaterialAccountDetermination {
511                    inventory_account: "140000".to_string(),
512                    cogs_account: "500000".to_string(),
513                    revenue_account: "400000".to_string(),
514                    purchase_expense_account: "500000".to_string(),
515                    price_difference_account: "590000".to_string(),
516                    gr_ir_account: "290000".to_string(),
517                }
518            }
519            MaterialType::RawMaterial | MaterialType::SemiFinished => {
520                MaterialAccountDetermination {
521                    inventory_account: "141000".to_string(),
522                    cogs_account: "510000".to_string(),
523                    revenue_account: "400000".to_string(),
524                    purchase_expense_account: "510000".to_string(),
525                    price_difference_account: "591000".to_string(),
526                    gr_ir_account: "290000".to_string(),
527                }
528            }
529            MaterialType::OperatingSupplies => MaterialAccountDetermination {
530                inventory_account: "".to_string(),
531                cogs_account: "520000".to_string(),
532                revenue_account: "410000".to_string(),
533                purchase_expense_account: "520000".to_string(),
534                price_difference_account: "".to_string(),
535                gr_ir_account: "290000".to_string(),
536            },
537            _ => MaterialAccountDetermination {
538                inventory_account: "145000".to_string(),
539                cogs_account: "530000".to_string(),
540                revenue_account: "400000".to_string(),
541                purchase_expense_account: "530000".to_string(),
542                price_difference_account: "595000".to_string(),
543                gr_ir_account: "290000".to_string(),
544            },
545        }
546    }
547
548    /// Reset the generator.
549    pub fn reset(&mut self) {
550        self.rng = ChaCha8Rng::seed_from_u64(self.seed);
551        self.material_counter = 0;
552        self.created_materials.clear();
553    }
554}
555
556#[cfg(test)]
557mod tests {
558    use super::*;
559
560    #[test]
561    fn test_material_generation() {
562        let mut gen = MaterialGenerator::new(42);
563        let material = gen.generate_material("1000", NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
564
565        assert!(!material.material_id.is_empty());
566        assert!(!material.description.is_empty());
567        assert!(material.standard_cost > Decimal::ZERO);
568        assert!(material.list_price >= material.standard_cost);
569    }
570
571    #[test]
572    fn test_material_pool_generation() {
573        let mut gen = MaterialGenerator::new(42);
574        let pool =
575            gen.generate_material_pool(50, "1000", NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
576
577        assert_eq!(pool.materials.len(), 50);
578
579        // Should have various material types
580        let raw_count = pool
581            .materials
582            .iter()
583            .filter(|m| m.material_type == MaterialType::RawMaterial)
584            .count();
585        let finished_count = pool
586            .materials
587            .iter()
588            .filter(|m| m.material_type == MaterialType::FinishedGood)
589            .count();
590
591        assert!(raw_count > 0);
592        assert!(finished_count > 0);
593    }
594
595    #[test]
596    fn test_material_with_bom() {
597        let mut gen = MaterialGenerator::new(42);
598        let material =
599            gen.generate_material_with_bom("1000", NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(), 3);
600
601        assert_eq!(material.material_type, MaterialType::FinishedGood);
602        assert!(material.bom_components.is_some());
603        assert_eq!(material.bom_components.as_ref().unwrap().len(), 3);
604    }
605
606    #[test]
607    fn test_material_pool_with_bom() {
608        let mut gen = MaterialGenerator::new(42);
609        let pool = gen.generate_material_pool_with_bom(
610            100,
611            0.5,
612            "1000",
613            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
614        );
615
616        assert_eq!(pool.materials.len(), 100);
617
618        // Should have some materials with BOMs
619        let bom_count = pool
620            .materials
621            .iter()
622            .filter(|m| m.bom_components.is_some())
623            .count();
624
625        assert!(bom_count > 0);
626    }
627
628    #[test]
629    fn test_deterministic_generation() {
630        let mut gen1 = MaterialGenerator::new(42);
631        let mut gen2 = MaterialGenerator::new(42);
632
633        let material1 =
634            gen1.generate_material("1000", NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
635        let material2 =
636            gen2.generate_material("1000", NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
637
638        assert_eq!(material1.material_id, material2.material_id);
639        assert_eq!(material1.description, material2.description);
640        assert_eq!(material1.standard_cost, material2.standard_cost);
641    }
642
643    #[test]
644    fn test_material_margin() {
645        let mut gen = MaterialGenerator::new(42);
646
647        for _ in 0..10 {
648            let material =
649                gen.generate_material("1000", NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
650
651            // List price should be higher than standard cost (gross margin)
652            assert!(
653                material.list_price >= material.standard_cost,
654                "List price {} should be >= standard cost {}",
655                material.list_price,
656                material.standard_cost
657            );
658
659            // Check margin is within configured range
660            let margin = material.gross_margin_percent();
661            assert!(
662                margin >= Decimal::from(15) && margin <= Decimal::from(55),
663                "Margin {} should be within expected range",
664                margin
665            );
666        }
667    }
668}