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)]
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 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}