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                id: None,
289                entity_code: None,
290                parent_material: None,
291                component_description: None,
292                level: None,
293                is_phantom: false,
294            });
295        }
296
297        // Generate the finished good with BOM
298        let mut material = self.generate_material_of_type(
299            MaterialType::FinishedGood,
300            company_code,
301            effective_date,
302        );
303
304        material.bom_components = Some(components);
305
306        material
307    }
308
309    /// Generate a material pool with specified count.
310    pub fn generate_material_pool(
311        &mut self,
312        count: usize,
313        company_code: &str,
314        effective_date: NaiveDate,
315    ) -> MaterialPool {
316        debug!(count, company_code, %effective_date, "Generating material pool");
317        let mut pool = MaterialPool::new();
318
319        for _ in 0..count {
320            let material = self.generate_material(company_code, effective_date);
321            pool.add_material(material);
322        }
323
324        pool
325    }
326
327    /// Generate a material pool with BOMs.
328    pub fn generate_material_pool_with_bom(
329        &mut self,
330        count: usize,
331        bom_rate: f64,
332        company_code: &str,
333        effective_date: NaiveDate,
334    ) -> MaterialPool {
335        let mut pool = MaterialPool::new();
336
337        // First generate raw materials and semi-finished goods
338        let raw_count = (count as f64 * 0.4) as usize;
339        for _ in 0..raw_count {
340            let material = self.generate_material_of_type(
341                MaterialType::RawMaterial,
342                company_code,
343                effective_date,
344            );
345            pool.add_material(material);
346        }
347
348        let semi_count = (count as f64 * 0.2) as usize;
349        for _ in 0..semi_count {
350            let material = self.generate_material_of_type(
351                MaterialType::SemiFinished,
352                company_code,
353                effective_date,
354            );
355            pool.add_material(material);
356        }
357
358        // Generate finished goods, some with BOM
359        let finished_count = count - raw_count - semi_count;
360        for _ in 0..finished_count {
361            let material =
362                if self.rng.random::<f64>() < bom_rate && !self.created_materials.is_empty() {
363                    self.generate_material_with_bom_from_existing(company_code, effective_date)
364                } else {
365                    self.generate_material_of_type(
366                        MaterialType::FinishedGood,
367                        company_code,
368                        effective_date,
369                    )
370                };
371            pool.add_material(material);
372        }
373
374        pool
375    }
376
377    /// Generate a material with BOM using existing materials.
378    fn generate_material_with_bom_from_existing(
379        &mut self,
380        company_code: &str,
381        effective_date: NaiveDate,
382    ) -> Material {
383        let mut material = self.generate_material_of_type(
384            MaterialType::FinishedGood,
385            company_code,
386            effective_date,
387        );
388
389        // Select some existing materials as components
390        let component_count = self
391            .rng
392            .random_range(2..=5)
393            .min(self.created_materials.len());
394        let mut components = Vec::new();
395
396        for i in 0..component_count {
397            if let Some(component_material_id) = self.created_materials.get(i) {
398                components.push(BomComponent {
399                    component_material_id: component_material_id.clone(),
400                    quantity: Decimal::from(self.rng.random_range(1..5)),
401                    uom: "EA".to_string(),
402                    position: (i + 1) as u16 * 10,
403                    scrap_percentage: Decimal::ZERO,
404                    is_optional: false,
405                    id: None,
406                    entity_code: None,
407                    parent_material: None,
408                    component_description: None,
409                    level: None,
410                    is_phantom: false,
411                });
412            }
413        }
414
415        if !components.is_empty() {
416            material.bom_components = Some(components);
417        }
418
419        material
420    }
421
422    /// Select material type based on distribution.
423    fn select_material_type(&mut self) -> MaterialType {
424        let roll: f64 = self.rng.random();
425        let mut cumulative = 0.0;
426
427        for (mat_type, prob) in &self.config.material_type_distribution {
428            cumulative += prob;
429            if roll < cumulative {
430                return *mat_type;
431            }
432        }
433
434        MaterialType::FinishedGood
435    }
436
437    /// Select valuation method based on distribution.
438    fn select_valuation_method(&mut self) -> ValuationMethod {
439        let roll: f64 = self.rng.random();
440        let mut cumulative = 0.0;
441
442        for (method, prob) in &self.config.valuation_method_distribution {
443            cumulative += prob;
444            if roll < cumulative {
445                return *method;
446            }
447        }
448
449        ValuationMethod::StandardCost
450    }
451
452    /// Select description for material type.
453    fn select_description(&mut self, material_type: &MaterialType) -> &'static str {
454        for (mat_type, descriptions) in MATERIAL_DESCRIPTIONS {
455            if mat_type == material_type {
456                let idx = self.rng.random_range(0..descriptions.len());
457                return descriptions[idx];
458            }
459        }
460        "Generic Material"
461    }
462
463    /// Select material group for type.
464    fn select_material_group(&mut self, material_type: &MaterialType) -> MaterialGroup {
465        match material_type {
466            MaterialType::FinishedGood => {
467                let options = [
468                    MaterialGroup::Electronics,
469                    MaterialGroup::Mechanical,
470                    MaterialGroup::FinishedGoods,
471                ];
472                options[self.rng.random_range(0..options.len())]
473            }
474            MaterialType::RawMaterial => {
475                let options = [
476                    MaterialGroup::Chemicals,
477                    MaterialGroup::Chemical,
478                    MaterialGroup::Mechanical,
479                ];
480                options[self.rng.random_range(0..options.len())]
481            }
482            MaterialType::SemiFinished => {
483                let options = [MaterialGroup::Electronics, MaterialGroup::Mechanical];
484                options[self.rng.random_range(0..options.len())]
485            }
486            MaterialType::TradingGood => MaterialGroup::FinishedGoods,
487            MaterialType::OperatingSupplies => MaterialGroup::Services,
488            MaterialType::Packaging | MaterialType::SparePart => MaterialGroup::Consumables,
489            _ => MaterialGroup::Consumables,
490        }
491    }
492
493    /// Generate standard cost.
494    fn generate_standard_cost(&mut self) -> Decimal {
495        let min = self.config.standard_cost_range.0;
496        let max = self.config.standard_cost_range.1;
497        let range = (max - min).to_string().parse::<f64>().unwrap_or(0.0);
498        let offset =
499            Decimal::from_f64_retain(self.rng.random::<f64>() * range).unwrap_or(Decimal::ZERO);
500        (min + offset).round_dp(2)
501    }
502
503    /// Generate list price from standard cost.
504    fn generate_list_price(&mut self, standard_cost: Decimal) -> Decimal {
505        let (min_margin, max_margin) = self.config.gross_margin_range;
506        let margin = min_margin + self.rng.random::<f64>() * (max_margin - min_margin);
507        let markup = Decimal::from_f64_retain(1.0 / (1.0 - margin)).unwrap_or(Decimal::from(2));
508        (standard_cost * markup).round_dp(2)
509    }
510
511    /// Generate safety stock.
512    fn generate_safety_stock(&mut self) -> Decimal {
513        Decimal::from(self.rng.random_range(10..500))
514    }
515
516    /// Generate account determination.
517    fn generate_account_determination(
518        &mut self,
519        material_type: &MaterialType,
520    ) -> MaterialAccountDetermination {
521        match material_type {
522            MaterialType::FinishedGood | MaterialType::TradingGood => {
523                MaterialAccountDetermination {
524                    inventory_account: "140000".to_string(),
525                    cogs_account: "500000".to_string(),
526                    revenue_account: "400000".to_string(),
527                    purchase_expense_account: "500000".to_string(),
528                    price_difference_account: "590000".to_string(),
529                    gr_ir_account: "290000".to_string(),
530                }
531            }
532            MaterialType::RawMaterial | MaterialType::SemiFinished => {
533                MaterialAccountDetermination {
534                    inventory_account: "141000".to_string(),
535                    cogs_account: "510000".to_string(),
536                    revenue_account: "400000".to_string(),
537                    purchase_expense_account: "510000".to_string(),
538                    price_difference_account: "591000".to_string(),
539                    gr_ir_account: "290000".to_string(),
540                }
541            }
542            MaterialType::OperatingSupplies => MaterialAccountDetermination {
543                inventory_account: "".to_string(),
544                cogs_account: "520000".to_string(),
545                revenue_account: "410000".to_string(),
546                purchase_expense_account: "520000".to_string(),
547                price_difference_account: "".to_string(),
548                gr_ir_account: "290000".to_string(),
549            },
550            _ => MaterialAccountDetermination {
551                inventory_account: "145000".to_string(),
552                cogs_account: "530000".to_string(),
553                revenue_account: "400000".to_string(),
554                purchase_expense_account: "530000".to_string(),
555                price_difference_account: "595000".to_string(),
556                gr_ir_account: "290000".to_string(),
557            },
558        }
559    }
560
561    /// Reset the generator.
562    pub fn reset(&mut self) {
563        self.rng = seeded_rng(self.seed, 0);
564        self.material_counter = 0;
565        self.created_materials.clear();
566    }
567}
568
569#[cfg(test)]
570#[allow(clippy::unwrap_used)]
571mod tests {
572    use super::*;
573
574    #[test]
575    fn test_material_generation() {
576        let mut gen = MaterialGenerator::new(42);
577        let material = gen.generate_material("1000", NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
578
579        assert!(!material.material_id.is_empty());
580        assert!(!material.description.is_empty());
581        assert!(material.standard_cost > Decimal::ZERO);
582        assert!(material.list_price >= material.standard_cost);
583    }
584
585    #[test]
586    fn test_material_pool_generation() {
587        let mut gen = MaterialGenerator::new(42);
588        let pool =
589            gen.generate_material_pool(50, "1000", NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
590
591        assert_eq!(pool.materials.len(), 50);
592
593        // Should have various material types
594        let raw_count = pool
595            .materials
596            .iter()
597            .filter(|m| m.material_type == MaterialType::RawMaterial)
598            .count();
599        let finished_count = pool
600            .materials
601            .iter()
602            .filter(|m| m.material_type == MaterialType::FinishedGood)
603            .count();
604
605        assert!(raw_count > 0);
606        assert!(finished_count > 0);
607    }
608
609    #[test]
610    fn test_material_with_bom() {
611        let mut gen = MaterialGenerator::new(42);
612        let material =
613            gen.generate_material_with_bom("1000", NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(), 3);
614
615        assert_eq!(material.material_type, MaterialType::FinishedGood);
616        assert!(material.bom_components.is_some());
617        assert_eq!(material.bom_components.as_ref().unwrap().len(), 3);
618    }
619
620    #[test]
621    fn test_material_pool_with_bom() {
622        let mut gen = MaterialGenerator::new(42);
623        let pool = gen.generate_material_pool_with_bom(
624            100,
625            0.5,
626            "1000",
627            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
628        );
629
630        assert_eq!(pool.materials.len(), 100);
631
632        // Should have some materials with BOMs
633        let bom_count = pool
634            .materials
635            .iter()
636            .filter(|m| m.bom_components.is_some())
637            .count();
638
639        assert!(bom_count > 0);
640    }
641
642    #[test]
643    fn test_deterministic_generation() {
644        let mut gen1 = MaterialGenerator::new(42);
645        let mut gen2 = MaterialGenerator::new(42);
646
647        let material1 =
648            gen1.generate_material("1000", NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
649        let material2 =
650            gen2.generate_material("1000", NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
651
652        assert_eq!(material1.material_id, material2.material_id);
653        assert_eq!(material1.description, material2.description);
654        assert_eq!(material1.standard_cost, material2.standard_cost);
655    }
656
657    #[test]
658    fn test_material_margin() {
659        let mut gen = MaterialGenerator::new(42);
660
661        for _ in 0..10 {
662            let material =
663                gen.generate_material("1000", NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
664
665            // List price should be higher than standard cost (gross margin)
666            assert!(
667                material.list_price >= material.standard_cost,
668                "List price {} should be >= standard cost {}",
669                material.list_price,
670                material.standard_cost
671            );
672
673            // Check margin is within configured range
674            let margin = material.gross_margin_percent();
675            assert!(
676                margin >= Decimal::from(15) && margin <= Decimal::from(55),
677                "Margin {} should be within expected range",
678                margin
679            );
680        }
681    }
682}