Skip to main content

datasynth_generators/manufacturing/
quality_inspection_generator.rs

1//! Quality inspection generator for manufacturing processes.
2//!
3//! Generates realistic quality inspections linked to production orders,
4//! with multiple inspection characteristics, defect tracking, and
5//! disposition assignment.
6
7use chrono::NaiveDate;
8use datasynth_core::models::{
9    InspectionCharacteristic, InspectionResult, InspectionType, QualityInspection,
10};
11use datasynth_core::utils::seeded_rng;
12use datasynth_core::uuid_factory::{DeterministicUuidFactory, GeneratorType};
13use rand::prelude::*;
14use rand_chacha::ChaCha8Rng;
15use rust_decimal::Decimal;
16use smallvec::SmallVec;
17use tracing::debug;
18
19/// Characteristic names used in quality inspections.
20const CHARACTERISTIC_NAMES: &[&str] = &[
21    "Dimension A",
22    "Weight",
23    "Surface Finish",
24    "Tensile Strength",
25    "Hardness",
26    "Thickness",
27    "Diameter",
28    "Flatness",
29    "Concentricity",
30    "Color Consistency",
31];
32
33/// Disposition actions for inspection results.
34const DISPOSITIONS_ACCEPTED: &[&str] = &["use_as_is", "stock"];
35const DISPOSITIONS_CONDITIONAL: &[&str] = &["use_as_is", "rework", "downgrade"];
36const DISPOSITIONS_REJECTED: &[&str] = &["return_to_vendor", "scrap", "rework"];
37
38/// Generates [`QualityInspection`] instances linked to production orders
39/// with realistic inspection characteristics and defect rates.
40pub struct QualityInspectionGenerator {
41    rng: ChaCha8Rng,
42    uuid_factory: DeterministicUuidFactory,
43}
44
45impl QualityInspectionGenerator {
46    /// Create a new quality inspection generator with the given seed.
47    pub fn new(seed: u64) -> Self {
48        Self {
49            rng: seeded_rng(seed, 0),
50            uuid_factory: DeterministicUuidFactory::new(seed, GeneratorType::QualityInspection),
51        }
52    }
53
54    /// Generate quality inspections for a set of production orders.
55    ///
56    /// # Arguments
57    ///
58    /// * `company_code` - Company code for all generated inspections.
59    /// * `production_orders` - Tuples of `(order_id, material_id, material_description)`.
60    /// * `inspection_date` - Date for all generated inspections.
61    pub fn generate(
62        &mut self,
63        company_code: &str,
64        production_orders: &[(String, String, String)],
65        inspection_date: NaiveDate,
66    ) -> Vec<QualityInspection> {
67        debug!(company_code, order_count = production_orders.len(), %inspection_date, "Generating quality inspections");
68        production_orders
69            .iter()
70            .map(|(order_id, material_id, material_desc)| {
71                self.generate_one(
72                    company_code,
73                    order_id,
74                    material_id,
75                    material_desc,
76                    inspection_date,
77                )
78            })
79            .collect()
80    }
81
82    /// Generate a single quality inspection for a production order.
83    fn generate_one(
84        &mut self,
85        company_code: &str,
86        order_id: &str,
87        material_id: &str,
88        material_description: &str,
89        inspection_date: NaiveDate,
90    ) -> QualityInspection {
91        let inspection_id = self.uuid_factory.next().to_string();
92
93        // Inspection type distribution: 40% Final, 25% InProcess, 20% Incoming, 10% Random, 5% Periodic
94        let inspection_type = self.pick_inspection_type();
95
96        // Lot size based on typical production order quantity
97        let lot_size_f64: f64 = self.rng.random_range(50.0..=1000.0);
98        let lot_size = Decimal::from_f64_retain(lot_size_f64.round()).unwrap_or(Decimal::from(100));
99
100        // Sample size: 10-30% of lot
101        let sample_pct: f64 = self.rng.random_range(0.10..=0.30);
102        let sample_size_f64 = (lot_size_f64 * sample_pct).round().max(1.0);
103        let sample_size = Decimal::from_f64_retain(sample_size_f64).unwrap_or(Decimal::from(10));
104
105        // Generate 2-5 inspection characteristics
106        let num_characteristics: usize = self.rng.random_range(2..=5);
107        let characteristics = self.generate_characteristics(num_characteristics);
108
109        // Count defects (failed characteristics)
110        let defect_count = characteristics.iter().filter(|c| !c.passed).count() as u32;
111        let defect_rate = if sample_size_f64 > 0.0 {
112            defect_count as f64 / sample_size_f64
113        } else {
114            0.0
115        };
116
117        // Inspection result: 80% Accepted, 10% Conditionally, 7% Rejected, 3% Pending
118        let result = self.pick_result();
119
120        // Inspector
121        let inspector_id = Some(format!("QC-{:02}", self.rng.random_range(1..=20)));
122
123        // Disposition based on result
124        let disposition = match result {
125            InspectionResult::Accepted => DISPOSITIONS_ACCEPTED
126                .choose(&mut self.rng)
127                .map(std::string::ToString::to_string),
128            InspectionResult::Conditionally => DISPOSITIONS_CONDITIONAL
129                .choose(&mut self.rng)
130                .map(std::string::ToString::to_string),
131            InspectionResult::Rejected => DISPOSITIONS_REJECTED
132                .choose(&mut self.rng)
133                .map(std::string::ToString::to_string),
134            InspectionResult::Pending => None,
135        };
136
137        // Notes for non-accepted results
138        let notes = match result {
139            InspectionResult::Rejected => Some(format!(
140                "{defect_count} defects found in {num_characteristics} characteristics. Material held for disposition."
141            )),
142            InspectionResult::Conditionally => Some(format!(
143                "Minor deviations noted. {defect_count} characteristic(s) marginally out of spec."
144            )),
145            _ => None,
146        };
147
148        QualityInspection {
149            inspection_id,
150            company_code: company_code.to_string(),
151            reference_type: "production_order".to_string(),
152            reference_id: order_id.to_string(),
153            material_id: material_id.to_string(),
154            material_description: material_description.to_string(),
155            inspection_type,
156            inspection_date,
157            inspector_id,
158            lot_size,
159            sample_size,
160            defect_count,
161            defect_rate,
162            result,
163            characteristics,
164            disposition,
165            notes,
166        }
167    }
168
169    /// Pick an inspection type based on distribution.
170    fn pick_inspection_type(&mut self) -> InspectionType {
171        let roll: f64 = self.rng.random();
172        if roll < 0.40 {
173            InspectionType::Final
174        } else if roll < 0.65 {
175            InspectionType::InProcess
176        } else if roll < 0.85 {
177            InspectionType::Incoming
178        } else if roll < 0.95 {
179            InspectionType::Random
180        } else {
181            InspectionType::Periodic
182        }
183    }
184
185    /// Pick an inspection result based on distribution.
186    fn pick_result(&mut self) -> InspectionResult {
187        let roll: f64 = self.rng.random();
188        if roll < 0.80 {
189            InspectionResult::Accepted
190        } else if roll < 0.90 {
191            InspectionResult::Conditionally
192        } else if roll < 0.97 {
193            InspectionResult::Rejected
194        } else {
195            InspectionResult::Pending
196        }
197    }
198
199    /// Generate inspection characteristics with target/actual values and limits.
200    fn generate_characteristics(
201        &mut self,
202        count: usize,
203    ) -> SmallVec<[InspectionCharacteristic; 4]> {
204        // Shuffle and pick `count` characteristic names
205        let mut indices: Vec<usize> = (0..CHARACTERISTIC_NAMES.len()).collect();
206        indices.shuffle(&mut self.rng);
207        let selected_count = count.min(indices.len());
208
209        indices[..selected_count]
210            .iter()
211            .map(|&idx| {
212                let name = CHARACTERISTIC_NAMES[idx].to_string();
213
214                // Target value: random 10.0 - 100.0
215                let target_value: f64 = self.rng.random_range(10.0..=100.0);
216
217                // Limits: ± 5-15% of target
218                let tolerance_pct: f64 = self.rng.random_range(0.05..=0.15);
219                let lower_limit = target_value * (1.0 - tolerance_pct);
220                let upper_limit = target_value * (1.0 + tolerance_pct);
221
222                // Actual value: target * random(0.95 - 1.05)
223                let actual_factor: f64 = self.rng.random_range(0.95..=1.05);
224                let actual_value = target_value * actual_factor;
225
226                let passed = actual_value >= lower_limit && actual_value <= upper_limit;
227
228                InspectionCharacteristic {
229                    name,
230                    target_value,
231                    actual_value,
232                    lower_limit,
233                    upper_limit,
234                    passed,
235                }
236            })
237            .collect()
238    }
239}
240
241// ---------------------------------------------------------------------------
242// Tests
243// ---------------------------------------------------------------------------
244
245#[cfg(test)]
246#[allow(clippy::unwrap_used)]
247mod tests {
248    use super::*;
249
250    fn sample_orders() -> Vec<(String, String, String)> {
251        vec![
252            (
253                "PO-001".to_string(),
254                "MAT-001".to_string(),
255                "Widget Alpha".to_string(),
256            ),
257            (
258                "PO-002".to_string(),
259                "MAT-002".to_string(),
260                "Widget Beta".to_string(),
261            ),
262            (
263                "PO-003".to_string(),
264                "MAT-003".to_string(),
265                "Widget Gamma".to_string(),
266            ),
267        ]
268    }
269
270    #[test]
271    fn test_basic_generation() {
272        let mut gen = QualityInspectionGenerator::new(42);
273        let orders = sample_orders();
274        let date = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
275
276        let inspections = gen.generate("C001", &orders, date);
277
278        assert_eq!(inspections.len(), orders.len());
279        for insp in &inspections {
280            assert_eq!(insp.company_code, "C001");
281            assert_eq!(insp.inspection_date, date);
282            assert!(!insp.inspection_id.is_empty());
283            assert_eq!(insp.reference_type, "production_order");
284            assert!(insp.lot_size > Decimal::ZERO);
285            assert!(insp.sample_size > Decimal::ZERO);
286            assert!(insp.sample_size <= insp.lot_size);
287            assert!(!insp.characteristics.is_empty());
288            assert!(insp.characteristics.len() >= 2 && insp.characteristics.len() <= 5);
289            assert!(insp.inspector_id.is_some());
290        }
291    }
292
293    #[test]
294    fn test_deterministic() {
295        let orders = sample_orders();
296        let date = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
297
298        let mut gen1 = QualityInspectionGenerator::new(12345);
299        let insp1 = gen1.generate("C001", &orders, date);
300
301        let mut gen2 = QualityInspectionGenerator::new(12345);
302        let insp2 = gen2.generate("C001", &orders, date);
303
304        assert_eq!(insp1.len(), insp2.len());
305        for (i1, i2) in insp1.iter().zip(insp2.iter()) {
306            assert_eq!(i1.inspection_id, i2.inspection_id);
307            assert_eq!(i1.lot_size, i2.lot_size);
308            assert_eq!(i1.sample_size, i2.sample_size);
309            assert_eq!(i1.defect_count, i2.defect_count);
310            assert_eq!(i1.characteristics.len(), i2.characteristics.len());
311        }
312    }
313
314    #[test]
315    fn test_characteristics_limits() {
316        let mut gen = QualityInspectionGenerator::new(99);
317        let orders = sample_orders();
318        let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
319
320        let inspections = gen.generate("C001", &orders, date);
321
322        for insp in &inspections {
323            for char in &insp.characteristics {
324                // Lower limit must be below target
325                assert!(
326                    char.lower_limit < char.target_value,
327                    "Lower limit {} should be below target {}",
328                    char.lower_limit,
329                    char.target_value,
330                );
331                // Upper limit must be above target
332                assert!(
333                    char.upper_limit > char.target_value,
334                    "Upper limit {} should be above target {}",
335                    char.upper_limit,
336                    char.target_value,
337                );
338                // Passed should be consistent with limits
339                let within_limits =
340                    char.actual_value >= char.lower_limit && char.actual_value <= char.upper_limit;
341                assert_eq!(
342                    char.passed, within_limits,
343                    "Passed flag ({}) inconsistent with limits for {}",
344                    char.passed, char.name,
345                );
346            }
347        }
348    }
349}