Skip to main content

datasynth_generators/manufacturing/
bom_generator.rs

1//! Bill of Materials (BOM) generator for manufacturing processes.
2//!
3//! Generates multi-level BOM structures for finished and semi-finished
4//! materials, creating parent-child component relationships with realistic
5//! quantities, scrap rates, and phantom assembly flags.
6
7use datasynth_core::models::BomComponent;
8use datasynth_core::utils::seeded_rng;
9use datasynth_core::uuid_factory::{DeterministicUuidFactory, GeneratorType};
10use rand::prelude::*;
11use rand_chacha::ChaCha8Rng;
12use rust_decimal::Decimal;
13use tracing::debug;
14
15/// Component descriptions for generated BOM items.
16const COMPONENT_DESCRIPTIONS: &[&str] = &[
17    "Steel plate",
18    "Aluminum extrusion",
19    "Bearing assembly",
20    "Electronic module",
21    "Fastener set",
22    "Gasket kit",
23    "Wire harness",
24    "Plastic housing",
25    "Rubber seal",
26    "Circuit board",
27    "Motor unit",
28    "Sensor module",
29    "Filter element",
30    "Bracket assembly",
31    "Spring set",
32];
33
34/// Generates [`BomComponent`] records linking parent materials to their components.
35pub struct BomGenerator {
36    rng: ChaCha8Rng,
37    uuid_factory: DeterministicUuidFactory,
38}
39
40impl BomGenerator {
41    /// Create a new BOM generator with the given seed.
42    pub fn new(seed: u64) -> Self {
43        Self {
44            rng: seeded_rng(seed, 0),
45            uuid_factory: DeterministicUuidFactory::new(seed, GeneratorType::BomComponent),
46        }
47    }
48
49    /// Generate BOM components for the given materials.
50    ///
51    /// For each finished or semi-finished material (those with an even index
52    /// in the provided list, as a simple heuristic), generates 2-8 BOM
53    /// components drawn from the remaining materials.
54    ///
55    /// # Arguments
56    ///
57    /// * `company_code` - Company / entity code.
58    /// * `material_ids` - Available materials as `(material_id, description)` tuples.
59    pub fn generate(
60        &mut self,
61        company_code: &str,
62        material_ids: &[(String, String)],
63    ) -> Vec<BomComponent> {
64        debug!(
65            company_code,
66            material_count = material_ids.len(),
67            "Generating BOM components"
68        );
69        if material_ids.len() < 3 {
70            return Vec::new();
71        }
72
73        let mut components = Vec::new();
74
75        // Treat ~40% of materials as finished goods with BOMs
76        let parent_count = (material_ids.len() * 2 / 5).max(1);
77
78        for parent_idx in 0..parent_count {
79            let (parent_id, _parent_desc) = &material_ids[parent_idx];
80            let comp_count = self.rng.random_range(2..=8).min(material_ids.len() - 1);
81
82            // Select component materials (skip the parent itself)
83            let mut candidate_indices: Vec<usize> = (0..material_ids.len())
84                .filter(|&i| i != parent_idx)
85                .collect();
86            candidate_indices.shuffle(&mut self.rng);
87            let selected = &candidate_indices[..comp_count.min(candidate_indices.len())];
88
89            for (pos, &comp_idx) in selected.iter().enumerate() {
90                let (comp_id, comp_desc) = &material_ids[comp_idx];
91                let bom_id = self.uuid_factory.next().to_string();
92
93                let quantity_per = Decimal::from(self.rng.random_range(1..=100));
94                let scrap_pct: f64 = self.rng.random_range(0.01..=0.10);
95                let scrap_percentage =
96                    Decimal::from_f64_retain(scrap_pct).unwrap_or(Decimal::new(2, 2));
97                let is_phantom = self.rng.random_bool(0.10);
98                let level = if pos < 2 { 1 } else { 2 };
99
100                let mut comp =
101                    BomComponent::new(comp_id, quantity_per, "EA").with_scrap(scrap_percentage);
102                comp.position = (pos + 1) as u16 * 10;
103                comp.id = Some(bom_id);
104                comp.entity_code = Some(company_code.to_string());
105                comp.parent_material = Some(parent_id.clone());
106                comp.component_description = Some(if comp_desc.is_empty() {
107                    COMPONENT_DESCRIPTIONS[self.rng.random_range(0..COMPONENT_DESCRIPTIONS.len())]
108                        .to_string()
109                } else {
110                    comp_desc.clone()
111                });
112                comp.level = Some(level);
113                comp.is_phantom = is_phantom;
114
115                components.push(comp);
116            }
117        }
118
119        components
120    }
121}
122
123// ---------------------------------------------------------------------------
124// Tests
125// ---------------------------------------------------------------------------
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    fn test_materials() -> Vec<(String, String)> {
132        (0..10)
133            .map(|i| (format!("MAT-{:03}", i), format!("Material {}", i)))
134            .collect()
135    }
136
137    #[test]
138    fn test_bom_generation() {
139        let mut gen = BomGenerator::new(42);
140        let materials = test_materials();
141        let bom = gen.generate("C001", &materials);
142
143        assert!(!bom.is_empty(), "Should generate BOM components");
144        for comp in &bom {
145            assert!(comp.quantity > Decimal::ZERO);
146            assert!(comp.id.is_some());
147            assert!(comp.entity_code.is_some());
148            assert!(comp.parent_material.is_some());
149            assert!(comp.component_description.is_some());
150            assert!(comp.level.is_some());
151            assert!(comp.position > 0);
152        }
153    }
154
155    #[test]
156    fn test_bom_has_phantoms() {
157        let mut gen = BomGenerator::new(77);
158        let materials: Vec<(String, String)> = (0..30)
159            .map(|i| (format!("MAT-{:03}", i), format!("Material {}", i)))
160            .collect();
161        let bom = gen.generate("C001", &materials);
162
163        // With 30 materials and ~10% phantom rate, expect at least one
164        let phantom_count = bom.iter().filter(|c| c.is_phantom).count();
165        assert!(
166            phantom_count > 0 || bom.len() < 10,
167            "Expected some phantom assemblies in a large BOM set"
168        );
169    }
170
171    #[test]
172    fn test_bom_deterministic() {
173        let materials = test_materials();
174        let mut gen1 = BomGenerator::new(12345);
175        let bom1 = gen1.generate("C001", &materials);
176        let mut gen2 = BomGenerator::new(12345);
177        let bom2 = gen2.generate("C001", &materials);
178
179        assert_eq!(bom1.len(), bom2.len());
180        for (a, b) in bom1.iter().zip(bom2.iter()) {
181            assert_eq!(a.component_material_id, b.component_material_id);
182            assert_eq!(a.quantity, b.quantity);
183        }
184    }
185
186    #[test]
187    fn test_bom_too_few_materials() {
188        let mut gen = BomGenerator::new(42);
189        let materials = vec![("MAT-001".to_string(), "M1".to_string())];
190        let bom = gen.generate("C001", &materials);
191        assert!(bom.is_empty(), "Should return empty for < 3 materials");
192    }
193}