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