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.gen_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 = if self.rng.gen::<f64>() < bom_rate && !self.created_materials.is_empty()
356            {
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.rng.gen_range(2..=5).min(self.created_materials.len());
385        let mut components = Vec::new();
386
387        for i in 0..component_count {
388            if let Some(component_material_id) = self.created_materials.get(i) {
389                components.push(BomComponent {
390                    component_material_id: component_material_id.clone(),
391                    quantity: Decimal::from(self.rng.gen_range(1..5)),
392                    uom: "EA".to_string(),
393                    position: (i + 1) as u16 * 10,
394                    scrap_percentage: Decimal::ZERO,
395                    is_optional: false,
396                });
397            }
398        }
399
400        if !components.is_empty() {
401            material.bom_components = Some(components);
402        }
403
404        material
405    }
406
407    /// Select material type based on distribution.
408    fn select_material_type(&mut self) -> MaterialType {
409        let roll: f64 = self.rng.gen();
410        let mut cumulative = 0.0;
411
412        for (mat_type, prob) in &self.config.material_type_distribution {
413            cumulative += prob;
414            if roll < cumulative {
415                return *mat_type;
416            }
417        }
418
419        MaterialType::FinishedGood
420    }
421
422    /// Select valuation method based on distribution.
423    fn select_valuation_method(&mut self) -> ValuationMethod {
424        let roll: f64 = self.rng.gen();
425        let mut cumulative = 0.0;
426
427        for (method, prob) in &self.config.valuation_method_distribution {
428            cumulative += prob;
429            if roll < cumulative {
430                return *method;
431            }
432        }
433
434        ValuationMethod::StandardCost
435    }
436
437    /// Select description for material type.
438    fn select_description(&mut self, material_type: &MaterialType) -> &'static str {
439        for (mat_type, descriptions) in MATERIAL_DESCRIPTIONS {
440            if mat_type == material_type {
441                let idx = self.rng.gen_range(0..descriptions.len());
442                return descriptions[idx];
443            }
444        }
445        "Generic Material"
446    }
447
448    /// Select material group for type.
449    fn select_material_group(&mut self, material_type: &MaterialType) -> MaterialGroup {
450        match material_type {
451            MaterialType::FinishedGood => {
452                let options = [
453                    MaterialGroup::Electronics,
454                    MaterialGroup::Mechanical,
455                    MaterialGroup::FinishedGoods,
456                ];
457                options[self.rng.gen_range(0..options.len())]
458            }
459            MaterialType::RawMaterial => {
460                let options = [
461                    MaterialGroup::Chemicals,
462                    MaterialGroup::Chemical,
463                    MaterialGroup::Mechanical,
464                ];
465                options[self.rng.gen_range(0..options.len())]
466            }
467            MaterialType::SemiFinished => {
468                let options = [MaterialGroup::Electronics, MaterialGroup::Mechanical];
469                options[self.rng.gen_range(0..options.len())]
470            }
471            MaterialType::TradingGood => MaterialGroup::FinishedGoods,
472            MaterialType::OperatingSupplies => MaterialGroup::Services,
473            MaterialType::Packaging | MaterialType::SparePart => MaterialGroup::Consumables,
474            _ => MaterialGroup::Consumables,
475        }
476    }
477
478    /// Generate standard cost.
479    fn generate_standard_cost(&mut self) -> Decimal {
480        let min = self.config.standard_cost_range.0;
481        let max = self.config.standard_cost_range.1;
482        let range = (max - min).to_string().parse::<f64>().unwrap_or(0.0);
483        let offset =
484            Decimal::from_f64_retain(self.rng.gen::<f64>() * range).unwrap_or(Decimal::ZERO);
485        (min + offset).round_dp(2)
486    }
487
488    /// Generate list price from standard cost.
489    fn generate_list_price(&mut self, standard_cost: Decimal) -> Decimal {
490        let (min_margin, max_margin) = self.config.gross_margin_range;
491        let margin = min_margin + self.rng.gen::<f64>() * (max_margin - min_margin);
492        let markup = Decimal::from_f64_retain(1.0 / (1.0 - margin)).unwrap_or(Decimal::from(2));
493        (standard_cost * markup).round_dp(2)
494    }
495
496    /// Generate safety stock.
497    fn generate_safety_stock(&mut self) -> Decimal {
498        Decimal::from(self.rng.gen_range(10..500))
499    }
500
501    /// Generate account determination.
502    fn generate_account_determination(
503        &mut self,
504        material_type: &MaterialType,
505    ) -> MaterialAccountDetermination {
506        match material_type {
507            MaterialType::FinishedGood | MaterialType::TradingGood => {
508                MaterialAccountDetermination {
509                    inventory_account: "140000".to_string(),
510                    cogs_account: "500000".to_string(),
511                    revenue_account: "400000".to_string(),
512                    purchase_expense_account: "500000".to_string(),
513                    price_difference_account: "590000".to_string(),
514                    gr_ir_account: "290000".to_string(),
515                }
516            }
517            MaterialType::RawMaterial | MaterialType::SemiFinished => {
518                MaterialAccountDetermination {
519                    inventory_account: "141000".to_string(),
520                    cogs_account: "510000".to_string(),
521                    revenue_account: "400000".to_string(),
522                    purchase_expense_account: "510000".to_string(),
523                    price_difference_account: "591000".to_string(),
524                    gr_ir_account: "290000".to_string(),
525                }
526            }
527            MaterialType::OperatingSupplies => MaterialAccountDetermination {
528                inventory_account: "".to_string(),
529                cogs_account: "520000".to_string(),
530                revenue_account: "410000".to_string(),
531                purchase_expense_account: "520000".to_string(),
532                price_difference_account: "".to_string(),
533                gr_ir_account: "290000".to_string(),
534            },
535            _ => MaterialAccountDetermination {
536                inventory_account: "145000".to_string(),
537                cogs_account: "530000".to_string(),
538                revenue_account: "400000".to_string(),
539                purchase_expense_account: "530000".to_string(),
540                price_difference_account: "595000".to_string(),
541                gr_ir_account: "290000".to_string(),
542            },
543        }
544    }
545
546    /// Reset the generator.
547    pub fn reset(&mut self) {
548        self.rng = seeded_rng(self.seed, 0);
549        self.material_counter = 0;
550        self.created_materials.clear();
551    }
552}
553
554#[cfg(test)]
555#[allow(clippy::unwrap_used)]
556mod tests {
557    use super::*;
558
559    #[test]
560    fn test_material_generation() {
561        let mut gen = MaterialGenerator::new(42);
562        let material = gen.generate_material("1000", NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
563
564        assert!(!material.material_id.is_empty());
565        assert!(!material.description.is_empty());
566        assert!(material.standard_cost > Decimal::ZERO);
567        assert!(material.list_price >= material.standard_cost);
568    }
569
570    #[test]
571    fn test_material_pool_generation() {
572        let mut gen = MaterialGenerator::new(42);
573        let pool =
574            gen.generate_material_pool(50, "1000", NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
575
576        assert_eq!(pool.materials.len(), 50);
577
578        // Should have various material types
579        let raw_count = pool
580            .materials
581            .iter()
582            .filter(|m| m.material_type == MaterialType::RawMaterial)
583            .count();
584        let finished_count = pool
585            .materials
586            .iter()
587            .filter(|m| m.material_type == MaterialType::FinishedGood)
588            .count();
589
590        assert!(raw_count > 0);
591        assert!(finished_count > 0);
592    }
593
594    #[test]
595    fn test_material_with_bom() {
596        let mut gen = MaterialGenerator::new(42);
597        let material =
598            gen.generate_material_with_bom("1000", NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(), 3);
599
600        assert_eq!(material.material_type, MaterialType::FinishedGood);
601        assert!(material.bom_components.is_some());
602        assert_eq!(material.bom_components.as_ref().unwrap().len(), 3);
603    }
604
605    #[test]
606    fn test_material_pool_with_bom() {
607        let mut gen = MaterialGenerator::new(42);
608        let pool = gen.generate_material_pool_with_bom(
609            100,
610            0.5,
611            "1000",
612            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
613        );
614
615        assert_eq!(pool.materials.len(), 100);
616
617        // Should have some materials with BOMs
618        let bom_count = pool
619            .materials
620            .iter()
621            .filter(|m| m.bom_components.is_some())
622            .count();
623
624        assert!(bom_count > 0);
625    }
626
627    #[test]
628    fn test_deterministic_generation() {
629        let mut gen1 = MaterialGenerator::new(42);
630        let mut gen2 = MaterialGenerator::new(42);
631
632        let material1 =
633            gen1.generate_material("1000", NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
634        let material2 =
635            gen2.generate_material("1000", NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
636
637        assert_eq!(material1.material_id, material2.material_id);
638        assert_eq!(material1.description, material2.description);
639        assert_eq!(material1.standard_cost, material2.standard_cost);
640    }
641
642    #[test]
643    fn test_material_margin() {
644        let mut gen = MaterialGenerator::new(42);
645
646        for _ in 0..10 {
647            let material =
648                gen.generate_material("1000", NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
649
650            // List price should be higher than standard cost (gross margin)
651            assert!(
652                material.list_price >= material.standard_cost,
653                "List price {} should be >= standard cost {}",
654                material.list_price,
655                material.standard_cost
656            );
657
658            // Check margin is within configured range
659            let margin = material.gross_margin_percent();
660            assert!(
661                margin >= Decimal::from(15) && margin <= Decimal::from(55),
662                "Margin {} should be within expected range",
663                margin
664            );
665        }
666    }
667}