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