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