datasynth_generators/manufacturing/
bom_generator.rs1use 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
15const 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
34pub struct BomGenerator {
36 rng: ChaCha8Rng,
37 uuid_factory: DeterministicUuidFactory,
38}
39
40impl BomGenerator {
41 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 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 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 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#[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 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}