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)]
128#[allow(clippy::unwrap_used)]
129mod tests {
130    use super::*;
131
132    fn test_materials() -> Vec<(String, String)> {
133        (0..10)
134            .map(|i| (format!("MAT-{:03}", i), format!("Material {}", i)))
135            .collect()
136    }
137
138    #[test]
139    fn test_bom_generation() {
140        let mut gen = BomGenerator::new(42);
141        let materials = test_materials();
142        let bom = gen.generate("C001", &materials);
143
144        assert!(!bom.is_empty(), "Should generate BOM components");
145        for comp in &bom {
146            assert!(comp.quantity > Decimal::ZERO);
147            assert!(comp.id.is_some());
148            assert!(comp.entity_code.is_some());
149            assert!(comp.parent_material.is_some());
150            assert!(comp.component_description.is_some());
151            assert!(comp.level.is_some());
152            assert!(comp.position > 0);
153        }
154    }
155
156    #[test]
157    fn test_bom_has_phantoms() {
158        let mut gen = BomGenerator::new(77);
159        let materials: Vec<(String, String)> = (0..30)
160            .map(|i| (format!("MAT-{:03}", i), format!("Material {}", i)))
161            .collect();
162        let bom = gen.generate("C001", &materials);
163
164        // With 30 materials and ~10% phantom rate, expect at least one
165        let phantom_count = bom.iter().filter(|c| c.is_phantom).count();
166        assert!(
167            phantom_count > 0 || bom.len() < 10,
168            "Expected some phantom assemblies in a large BOM set"
169        );
170    }
171
172    #[test]
173    fn test_bom_deterministic() {
174        let materials = test_materials();
175        let mut gen1 = BomGenerator::new(12345);
176        let bom1 = gen1.generate("C001", &materials);
177        let mut gen2 = BomGenerator::new(12345);
178        let bom2 = gen2.generate("C001", &materials);
179
180        assert_eq!(bom1.len(), bom2.len());
181        for (a, b) in bom1.iter().zip(bom2.iter()) {
182            assert_eq!(a.component_material_id, b.component_material_id);
183            assert_eq!(a.quantity, b.quantity);
184        }
185    }
186
187    #[test]
188    fn test_bom_too_few_materials() {
189        let mut gen = BomGenerator::new(42);
190        let materials = vec![("MAT-001".to_string(), "M1".to_string())];
191        let bom = gen.generate("C001", &materials);
192        assert!(bom.is_empty(), "Should return empty for < 3 materials");
193    }
194}