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    /// Optional template provider for user-supplied material descriptions (v3.2.1+)
148    template_provider: Option<datasynth_core::templates::SharedTemplateProvider>,
149}
150
151impl MaterialGenerator {
152    /// Create a new material generator.
153    pub fn new(seed: u64) -> Self {
154        Self::with_config(seed, MaterialGeneratorConfig::default())
155    }
156
157    /// Create a new material generator with custom configuration.
158    pub fn with_config(seed: u64, config: MaterialGeneratorConfig) -> Self {
159        Self {
160            rng: seeded_rng(seed, 0),
161            seed,
162            config,
163            material_counter: 0,
164            created_materials: Vec::new(),
165            country_pack: None,
166            template_provider: None,
167        }
168    }
169
170    /// Set the country pack for locale-aware generation.
171    pub fn set_country_pack(&mut self, pack: datasynth_core::CountryPack) {
172        self.country_pack = Some(pack);
173    }
174
175    /// Set a template provider so user-supplied material descriptions
176    /// override the embedded pool. (v3.2.1+)
177    ///
178    /// When `None` (default), the embedded `MATERIAL_DESCRIPTIONS` pool
179    /// is used — byte-identical to pre-v3.2.1 output.
180    pub fn set_template_provider(
181        &mut self,
182        provider: datasynth_core::templates::SharedTemplateProvider,
183    ) {
184        self.template_provider = Some(provider);
185    }
186
187    /// Set a counter offset so that generated IDs start after a given value.
188    ///
189    /// This is used when generating materials for multiple companies in parallel
190    /// to ensure globally unique IDs. For example, if company 0 generates 100
191    /// materials (MAT-000001..MAT-000100), company 1 should set offset=100 so its
192    /// materials start at MAT-000101.
193    pub fn set_counter_offset(&mut self, offset: usize) {
194        self.material_counter = offset;
195    }
196
197    /// Generate a single material.
198    pub fn generate_material(
199        &mut self,
200        _company_code: &str,
201        _effective_date: NaiveDate,
202    ) -> Material {
203        self.material_counter += 1;
204
205        let material_id = format!("MAT-{:06}", self.material_counter);
206        let material_type = self.select_material_type();
207        let description = self.select_description(&material_type);
208
209        let mut material =
210            Material::new(material_id.clone(), description.to_string(), material_type);
211
212        // Set material group
213        material.material_group = self.select_material_group(&material_type);
214
215        // Set valuation method
216        material.valuation_method = self.select_valuation_method();
217
218        // Set costs and prices
219        let standard_cost = self.generate_standard_cost();
220        material.standard_cost = standard_cost;
221        material.purchase_price = standard_cost;
222        material.list_price = self.generate_list_price(standard_cost);
223
224        // Set unit of measure
225        material.base_uom = if material_type == MaterialType::OperatingSupplies {
226            UnitOfMeasure::hour()
227        } else {
228            UnitOfMeasure::each()
229        };
230
231        // Set account determination
232        material.account_determination = self.generate_account_determination(&material_type);
233
234        // Set stock and reorder info
235        if material_type != MaterialType::OperatingSupplies {
236            material.safety_stock = self.generate_safety_stock();
237            material.reorder_point = material.safety_stock * Decimal::from(2);
238        }
239
240        // Add to created materials for BOM references
241        self.created_materials.push(material_id);
242
243        material
244    }
245
246    /// Generate a material with specific type.
247    pub fn generate_material_of_type(
248        &mut self,
249        material_type: MaterialType,
250        _company_code: &str,
251        _effective_date: NaiveDate,
252    ) -> Material {
253        self.material_counter += 1;
254
255        let material_id = format!("MAT-{:06}", self.material_counter);
256        let description = self.select_description(&material_type);
257
258        let mut material =
259            Material::new(material_id.clone(), description.to_string(), material_type);
260
261        material.material_group = self.select_material_group(&material_type);
262        material.valuation_method = self.select_valuation_method();
263
264        let standard_cost = self.generate_standard_cost();
265        material.standard_cost = standard_cost;
266        material.purchase_price = standard_cost;
267        material.list_price = self.generate_list_price(standard_cost);
268
269        material.base_uom = if material_type == MaterialType::OperatingSupplies {
270            UnitOfMeasure::hour()
271        } else {
272            UnitOfMeasure::each()
273        };
274
275        material.account_determination = self.generate_account_determination(&material_type);
276
277        if material_type != MaterialType::OperatingSupplies {
278            material.safety_stock = self.generate_safety_stock();
279            material.reorder_point = material.safety_stock * Decimal::from(2);
280        }
281
282        self.created_materials.push(material_id);
283
284        material
285    }
286
287    /// Generate a material with BOM.
288    pub fn generate_material_with_bom(
289        &mut self,
290        company_code: &str,
291        effective_date: NaiveDate,
292        component_count: usize,
293    ) -> Material {
294        // Generate component materials first
295        let mut components = Vec::new();
296        for i in 0..component_count {
297            let component_type = if i % 2 == 0 {
298                MaterialType::RawMaterial
299            } else {
300                MaterialType::SemiFinished
301            };
302            let component =
303                self.generate_material_of_type(component_type, company_code, effective_date);
304
305            let quantity = Decimal::from(self.rng.random_range(1..10));
306            components.push(BomComponent {
307                component_material_id: component.material_id.clone(),
308                quantity,
309                uom: component.base_uom.code.clone(),
310                position: (i + 1) as u16 * 10,
311                scrap_percentage: Decimal::ZERO,
312                is_optional: false,
313                id: None,
314                entity_code: None,
315                parent_material: None,
316                component_description: None,
317                level: None,
318                is_phantom: false,
319            });
320        }
321
322        // Generate the finished good with BOM
323        let mut material = self.generate_material_of_type(
324            MaterialType::FinishedGood,
325            company_code,
326            effective_date,
327        );
328
329        material.bom_components = Some(components);
330
331        material
332    }
333
334    /// Generate a material pool with specified count.
335    pub fn generate_material_pool(
336        &mut self,
337        count: usize,
338        company_code: &str,
339        effective_date: NaiveDate,
340    ) -> MaterialPool {
341        debug!(count, company_code, %effective_date, "Generating material pool");
342        let mut pool = MaterialPool::new();
343
344        for _ in 0..count {
345            let material = self.generate_material(company_code, effective_date);
346            pool.add_material(material);
347        }
348
349        pool
350    }
351
352    /// Generate a material pool with BOMs.
353    pub fn generate_material_pool_with_bom(
354        &mut self,
355        count: usize,
356        bom_rate: f64,
357        company_code: &str,
358        effective_date: NaiveDate,
359    ) -> MaterialPool {
360        let mut pool = MaterialPool::new();
361
362        // First generate raw materials and semi-finished goods
363        let raw_count = (count as f64 * 0.4) as usize;
364        for _ in 0..raw_count {
365            let material = self.generate_material_of_type(
366                MaterialType::RawMaterial,
367                company_code,
368                effective_date,
369            );
370            pool.add_material(material);
371        }
372
373        let semi_count = (count as f64 * 0.2) as usize;
374        for _ in 0..semi_count {
375            let material = self.generate_material_of_type(
376                MaterialType::SemiFinished,
377                company_code,
378                effective_date,
379            );
380            pool.add_material(material);
381        }
382
383        // Generate finished goods, some with BOM
384        let finished_count = count - raw_count - semi_count;
385        for _ in 0..finished_count {
386            let material =
387                if self.rng.random::<f64>() < bom_rate && !self.created_materials.is_empty() {
388                    self.generate_material_with_bom_from_existing(company_code, effective_date)
389                } else {
390                    self.generate_material_of_type(
391                        MaterialType::FinishedGood,
392                        company_code,
393                        effective_date,
394                    )
395                };
396            pool.add_material(material);
397        }
398
399        pool
400    }
401
402    /// Generate a material with BOM using existing materials.
403    fn generate_material_with_bom_from_existing(
404        &mut self,
405        company_code: &str,
406        effective_date: NaiveDate,
407    ) -> Material {
408        let mut material = self.generate_material_of_type(
409            MaterialType::FinishedGood,
410            company_code,
411            effective_date,
412        );
413
414        // Select some existing materials as components
415        let component_count = self
416            .rng
417            .random_range(2..=5)
418            .min(self.created_materials.len());
419        let mut components = Vec::new();
420
421        for i in 0..component_count {
422            if let Some(component_material_id) = self.created_materials.get(i) {
423                components.push(BomComponent {
424                    component_material_id: component_material_id.clone(),
425                    quantity: Decimal::from(self.rng.random_range(1..5)),
426                    uom: "EA".to_string(),
427                    position: (i + 1) as u16 * 10,
428                    scrap_percentage: Decimal::ZERO,
429                    is_optional: false,
430                    id: None,
431                    entity_code: None,
432                    parent_material: None,
433                    component_description: None,
434                    level: None,
435                    is_phantom: false,
436                });
437            }
438        }
439
440        if !components.is_empty() {
441            material.bom_components = Some(components);
442        }
443
444        material
445    }
446
447    /// Select material type based on distribution.
448    fn select_material_type(&mut self) -> MaterialType {
449        let roll: f64 = self.rng.random();
450        let mut cumulative = 0.0;
451
452        for (mat_type, prob) in &self.config.material_type_distribution {
453            cumulative += prob;
454            if roll < cumulative {
455                return *mat_type;
456            }
457        }
458
459        MaterialType::FinishedGood
460    }
461
462    /// Select valuation method based on distribution.
463    fn select_valuation_method(&mut self) -> ValuationMethod {
464        let roll: f64 = self.rng.random();
465        let mut cumulative = 0.0;
466
467        for (method, prob) in &self.config.valuation_method_distribution {
468            cumulative += prob;
469            if roll < cumulative {
470                return *method;
471            }
472        }
473
474        ValuationMethod::StandardCost
475    }
476
477    /// Select description for material type.
478    ///
479    /// v3.2.1+: the user's [`TemplateProvider`] sees the material-type
480    /// key and gets to supply a custom description. If its pool is
481    /// empty for this type, the `get_material_description` trait
482    /// method's fallback (`"{type} material"`) is detected and the
483    /// embedded `MATERIAL_DESCRIPTIONS` pool is used instead — this
484    /// preserves byte-identical output when no user pack is configured.
485    fn select_description(&mut self, material_type: &MaterialType) -> String {
486        let type_key = Self::material_type_to_key(material_type);
487
488        // Provider first — but only if it returns something that isn't
489        // the generic fallback string. The default provider returns
490        // "{type} material" when no file pool exists; we reject that
491        // and use the embedded pool instead so behavior is unchanged
492        // when `templates.path` is not set.
493        if let Some(ref provider) = self.template_provider {
494            let candidate = provider.get_material_description(type_key, &mut self.rng);
495            let generic_fallback = format!("{type_key} material");
496            if candidate != generic_fallback {
497                return candidate;
498            }
499        }
500
501        for (mat_type, descriptions) in MATERIAL_DESCRIPTIONS {
502            if mat_type == material_type {
503                let idx = self.rng.random_range(0..descriptions.len());
504                return descriptions[idx].to_string();
505            }
506        }
507        "Generic Material".to_string()
508    }
509
510    /// Map a `MaterialType` enum variant onto the lowercase/snake_case
511    /// key the template YAML uses (e.g. `MaterialType::FinishedGood` →
512    /// `"finished_good"`).
513    fn material_type_to_key(material_type: &MaterialType) -> &'static str {
514        match material_type {
515            MaterialType::FinishedGood => "finished_good",
516            MaterialType::RawMaterial => "raw_material",
517            MaterialType::SemiFinished => "semi_finished",
518            MaterialType::TradingGood => "trading_good",
519            MaterialType::OperatingSupplies => "operating_supplies",
520            MaterialType::Packaging => "packaging",
521            MaterialType::Service => "service",
522            MaterialType::SparePart => "spare_part",
523        }
524    }
525
526    /// Select material group for type.
527    fn select_material_group(&mut self, material_type: &MaterialType) -> MaterialGroup {
528        match material_type {
529            MaterialType::FinishedGood => {
530                let options = [
531                    MaterialGroup::Electronics,
532                    MaterialGroup::Mechanical,
533                    MaterialGroup::FinishedGoods,
534                ];
535                options[self.rng.random_range(0..options.len())]
536            }
537            MaterialType::RawMaterial => {
538                let options = [
539                    MaterialGroup::Chemicals,
540                    MaterialGroup::Chemical,
541                    MaterialGroup::Mechanical,
542                ];
543                options[self.rng.random_range(0..options.len())]
544            }
545            MaterialType::SemiFinished => {
546                let options = [MaterialGroup::Electronics, MaterialGroup::Mechanical];
547                options[self.rng.random_range(0..options.len())]
548            }
549            MaterialType::TradingGood => MaterialGroup::FinishedGoods,
550            MaterialType::OperatingSupplies => MaterialGroup::Services,
551            MaterialType::Packaging | MaterialType::SparePart => MaterialGroup::Consumables,
552            _ => MaterialGroup::Consumables,
553        }
554    }
555
556    /// Generate standard cost.
557    fn generate_standard_cost(&mut self) -> Decimal {
558        let min = self.config.standard_cost_range.0;
559        let max = self.config.standard_cost_range.1;
560        let range = (max - min).to_string().parse::<f64>().unwrap_or(0.0);
561        let offset =
562            Decimal::from_f64_retain(self.rng.random::<f64>() * range).unwrap_or(Decimal::ZERO);
563        (min + offset).round_dp(2)
564    }
565
566    /// Generate list price from standard cost.
567    fn generate_list_price(&mut self, standard_cost: Decimal) -> Decimal {
568        let (min_margin, max_margin) = self.config.gross_margin_range;
569        let margin = min_margin + self.rng.random::<f64>() * (max_margin - min_margin);
570        let markup = Decimal::from_f64_retain(1.0 / (1.0 - margin)).unwrap_or(Decimal::from(2));
571        (standard_cost * markup).round_dp(2)
572    }
573
574    /// Generate safety stock.
575    fn generate_safety_stock(&mut self) -> Decimal {
576        Decimal::from(self.rng.random_range(10..500))
577    }
578
579    /// Generate account determination.
580    fn generate_account_determination(
581        &mut self,
582        material_type: &MaterialType,
583    ) -> MaterialAccountDetermination {
584        match material_type {
585            MaterialType::FinishedGood | MaterialType::TradingGood => {
586                MaterialAccountDetermination {
587                    inventory_account: "140000".to_string(),
588                    cogs_account: "500000".to_string(),
589                    revenue_account: "400000".to_string(),
590                    purchase_expense_account: "500000".to_string(),
591                    price_difference_account: "590000".to_string(),
592                    gr_ir_account: "290000".to_string(),
593                }
594            }
595            MaterialType::RawMaterial | MaterialType::SemiFinished => {
596                MaterialAccountDetermination {
597                    inventory_account: "141000".to_string(),
598                    cogs_account: "510000".to_string(),
599                    revenue_account: "400000".to_string(),
600                    purchase_expense_account: "510000".to_string(),
601                    price_difference_account: "591000".to_string(),
602                    gr_ir_account: "290000".to_string(),
603                }
604            }
605            MaterialType::OperatingSupplies => MaterialAccountDetermination {
606                inventory_account: "".to_string(),
607                cogs_account: "520000".to_string(),
608                revenue_account: "410000".to_string(),
609                purchase_expense_account: "520000".to_string(),
610                price_difference_account: "".to_string(),
611                gr_ir_account: "290000".to_string(),
612            },
613            _ => MaterialAccountDetermination {
614                inventory_account: "145000".to_string(),
615                cogs_account: "530000".to_string(),
616                revenue_account: "400000".to_string(),
617                purchase_expense_account: "530000".to_string(),
618                price_difference_account: "595000".to_string(),
619                gr_ir_account: "290000".to_string(),
620            },
621        }
622    }
623
624    /// Reset the generator.
625    pub fn reset(&mut self) {
626        self.rng = seeded_rng(self.seed, 0);
627        self.material_counter = 0;
628        self.created_materials.clear();
629    }
630}
631
632#[cfg(test)]
633#[allow(clippy::unwrap_used)]
634mod tests {
635    use super::*;
636
637    #[test]
638    fn test_material_generation() {
639        let mut gen = MaterialGenerator::new(42);
640        let material = gen.generate_material("1000", NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
641
642        assert!(!material.material_id.is_empty());
643        assert!(!material.description.is_empty());
644        assert!(material.standard_cost > Decimal::ZERO);
645        assert!(material.list_price >= material.standard_cost);
646    }
647
648    #[test]
649    fn test_material_pool_generation() {
650        let mut gen = MaterialGenerator::new(42);
651        let pool =
652            gen.generate_material_pool(50, "1000", NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
653
654        assert_eq!(pool.materials.len(), 50);
655
656        // Should have various material types
657        let raw_count = pool
658            .materials
659            .iter()
660            .filter(|m| m.material_type == MaterialType::RawMaterial)
661            .count();
662        let finished_count = pool
663            .materials
664            .iter()
665            .filter(|m| m.material_type == MaterialType::FinishedGood)
666            .count();
667
668        assert!(raw_count > 0);
669        assert!(finished_count > 0);
670    }
671
672    #[test]
673    fn test_material_with_bom() {
674        let mut gen = MaterialGenerator::new(42);
675        let material =
676            gen.generate_material_with_bom("1000", NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(), 3);
677
678        assert_eq!(material.material_type, MaterialType::FinishedGood);
679        assert!(material.bom_components.is_some());
680        assert_eq!(material.bom_components.as_ref().unwrap().len(), 3);
681    }
682
683    #[test]
684    fn test_material_pool_with_bom() {
685        let mut gen = MaterialGenerator::new(42);
686        let pool = gen.generate_material_pool_with_bom(
687            100,
688            0.5,
689            "1000",
690            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
691        );
692
693        assert_eq!(pool.materials.len(), 100);
694
695        // Should have some materials with BOMs
696        let bom_count = pool
697            .materials
698            .iter()
699            .filter(|m| m.bom_components.is_some())
700            .count();
701
702        assert!(bom_count > 0);
703    }
704
705    #[test]
706    fn test_deterministic_generation() {
707        let mut gen1 = MaterialGenerator::new(42);
708        let mut gen2 = MaterialGenerator::new(42);
709
710        let material1 =
711            gen1.generate_material("1000", NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
712        let material2 =
713            gen2.generate_material("1000", NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
714
715        assert_eq!(material1.material_id, material2.material_id);
716        assert_eq!(material1.description, material2.description);
717        assert_eq!(material1.standard_cost, material2.standard_cost);
718    }
719
720    #[test]
721    fn test_material_margin() {
722        let mut gen = MaterialGenerator::new(42);
723
724        for _ in 0..10 {
725            let material =
726                gen.generate_material("1000", NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
727
728            // List price should be higher than standard cost (gross margin)
729            assert!(
730                material.list_price >= material.standard_cost,
731                "List price {} should be >= standard cost {}",
732                material.list_price,
733                material.standard_cost
734            );
735
736            // Check margin is within configured range
737            let margin = material.gross_margin_percent();
738            assert!(
739                margin >= Decimal::from(15) && margin <= Decimal::from(55),
740                "Margin {} should be within expected range",
741                margin
742            );
743        }
744    }
745}