Skip to main content

datasynth_generators/sourcing/
qualification_generator.rs

1//! Supplier qualification generator.
2
3use chrono::NaiveDate;
4use datasynth_config::schema::QualificationConfig;
5use datasynth_core::models::sourcing::{
6    QualificationScore, QualificationStatus, SupplierCertification, SupplierQualification,
7};
8use datasynth_core::utils::seeded_rng;
9use datasynth_core::uuid_factory::{DeterministicUuidFactory, GeneratorType};
10use rand::prelude::*;
11use rand_chacha::ChaCha8Rng;
12
13/// Generates supplier qualification records.
14pub struct QualificationGenerator {
15    rng: ChaCha8Rng,
16    uuid_factory: DeterministicUuidFactory,
17    config: QualificationConfig,
18}
19
20impl QualificationGenerator {
21    /// Create a new qualification generator.
22    pub fn new(seed: u64) -> Self {
23        Self {
24            rng: seeded_rng(seed, 0),
25            uuid_factory: DeterministicUuidFactory::new(seed, GeneratorType::SupplierQualification),
26            config: QualificationConfig::default(),
27        }
28    }
29
30    /// Create with custom configuration.
31    pub fn with_config(seed: u64, config: QualificationConfig) -> Self {
32        Self {
33            rng: seeded_rng(seed, 0),
34            uuid_factory: DeterministicUuidFactory::new(seed, GeneratorType::SupplierQualification),
35            config,
36        }
37    }
38
39    /// Generate qualifications for vendors in a sourcing project.
40    pub fn generate(
41        &mut self,
42        company_code: &str,
43        vendor_ids: &[String],
44        sourcing_project_id: Option<&str>,
45        evaluator_id: &str,
46        qualification_date: NaiveDate,
47    ) -> Vec<SupplierQualification> {
48        let mut qualifications = Vec::new();
49
50        for vendor_id in vendor_ids {
51            let criteria = vec![
52                ("Financial Stability", self.config.financial_weight, 60.0),
53                ("Quality Management", self.config.quality_weight, 65.0),
54                ("Delivery Performance", self.config.delivery_weight, 60.0),
55                ("Compliance", self.config.compliance_weight, 70.0),
56            ];
57
58            let mut scores = Vec::new();
59            let mut weighted_total = 0.0;
60            let mut all_mandatory_passed = true;
61
62            for (name, weight, min_score) in &criteria {
63                let score = self.rng.random_range(40.0..=100.0);
64                let passed = score >= *min_score;
65                if !passed {
66                    all_mandatory_passed = false;
67                }
68                weighted_total += score * weight;
69                scores.push(QualificationScore {
70                    criterion_name: name.to_string(),
71                    score,
72                    passed,
73                    comments: None,
74                });
75            }
76
77            let status = if !all_mandatory_passed {
78                QualificationStatus::Disqualified
79            } else if weighted_total >= 75.0 {
80                QualificationStatus::Qualified
81            } else if weighted_total >= 60.0 {
82                QualificationStatus::ConditionallyQualified
83            } else {
84                QualificationStatus::Disqualified
85            };
86
87            let valid_until = if matches!(
88                status,
89                QualificationStatus::Qualified | QualificationStatus::ConditionallyQualified
90            ) {
91                Some(qualification_date + chrono::Duration::days(self.config.validity_days as i64))
92            } else {
93                None
94            };
95
96            qualifications.push(SupplierQualification {
97                qualification_id: self.uuid_factory.next().to_string(),
98                vendor_id: vendor_id.clone(),
99                sourcing_project_id: sourcing_project_id.map(|s| s.to_string()),
100                company_code: company_code.to_string(),
101                status,
102                start_date: qualification_date - chrono::Duration::days(14),
103                completion_date: Some(qualification_date),
104                valid_until,
105                scores,
106                overall_score: weighted_total,
107                evaluator_id: evaluator_id.to_string(),
108                certifications: Vec::new(),
109                conditions: if matches!(status, QualificationStatus::ConditionallyQualified) {
110                    Some("Improvement plan required within 90 days".to_string())
111                } else {
112                    None
113                },
114            });
115        }
116
117        qualifications
118    }
119
120    /// Generate certifications for a vendor.
121    pub fn generate_certifications(
122        &mut self,
123        vendor_id: &str,
124        base_date: NaiveDate,
125    ) -> Vec<SupplierCertification> {
126        let cert_types = [
127            ("ISO 9001", "TUV Rheinland"),
128            ("ISO 14001", "Bureau Veritas"),
129            ("SOC 2 Type II", "Deloitte"),
130            ("ISO 27001", "BSI Group"),
131        ];
132
133        let count = self.rng.random_range(0..=3);
134        let mut certs = Vec::new();
135
136        for &(cert_type, issuer) in cert_types.iter().take(count) {
137            let issue_date = base_date - chrono::Duration::days(self.rng.random_range(30..=730));
138            let expiry_date = issue_date + chrono::Duration::days(365 * 3);
139
140            certs.push(SupplierCertification {
141                certification_id: self.uuid_factory.next().to_string(),
142                vendor_id: vendor_id.to_string(),
143                certification_type: cert_type.to_string(),
144                issuing_body: issuer.to_string(),
145                issue_date,
146                expiry_date,
147                is_valid: expiry_date >= base_date,
148            });
149        }
150
151        certs
152    }
153}
154
155#[cfg(test)]
156#[allow(clippy::unwrap_used)]
157mod tests {
158    use super::*;
159
160    fn test_vendor_ids() -> Vec<String> {
161        vec!["V001".to_string(), "V002".to_string(), "V003".to_string()]
162    }
163
164    #[test]
165    fn test_basic_generation() {
166        let mut gen = QualificationGenerator::new(42);
167        let date = NaiveDate::from_ymd_opt(2024, 6, 1).unwrap();
168        let results = gen.generate("C001", &test_vendor_ids(), Some("SP-001"), "EVAL-01", date);
169
170        assert_eq!(results.len(), 3);
171        for qual in &results {
172            assert_eq!(qual.company_code, "C001");
173            assert!(!qual.qualification_id.is_empty());
174            assert!(!qual.vendor_id.is_empty());
175            assert_eq!(qual.evaluator_id, "EVAL-01");
176            assert_eq!(qual.sourcing_project_id.as_deref(), Some("SP-001"));
177            assert!(!qual.scores.is_empty());
178            assert_eq!(qual.scores.len(), 4); // 4 criteria
179            assert!(qual.overall_score > 0.0);
180        }
181    }
182
183    #[test]
184    fn test_deterministic() {
185        let date = NaiveDate::from_ymd_opt(2024, 6, 1).unwrap();
186        let vendors = test_vendor_ids();
187
188        let mut gen1 = QualificationGenerator::new(42);
189        let mut gen2 = QualificationGenerator::new(42);
190
191        let r1 = gen1.generate("C001", &vendors, Some("SP-001"), "EVAL-01", date);
192        let r2 = gen2.generate("C001", &vendors, Some("SP-001"), "EVAL-01", date);
193
194        assert_eq!(r1.len(), r2.len());
195        for (a, b) in r1.iter().zip(r2.iter()) {
196            assert_eq!(a.qualification_id, b.qualification_id);
197            assert_eq!(a.vendor_id, b.vendor_id);
198            assert_eq!(a.overall_score, b.overall_score);
199            assert_eq!(a.status, b.status);
200        }
201    }
202
203    #[test]
204    fn test_field_constraints() {
205        let mut gen = QualificationGenerator::new(99);
206        let date = NaiveDate::from_ymd_opt(2024, 6, 1).unwrap();
207        let results = gen.generate("C001", &test_vendor_ids(), None, "EVAL-01", date);
208
209        for qual in &results {
210            // All scores should be between 40 and 100
211            for score in &qual.scores {
212                assert!(score.score >= 40.0 && score.score <= 100.0);
213                assert!(!score.criterion_name.is_empty());
214            }
215
216            // Qualified or conditionally qualified should have valid_until
217            match qual.status {
218                QualificationStatus::Qualified | QualificationStatus::ConditionallyQualified => {
219                    assert!(qual.valid_until.is_some());
220                }
221                QualificationStatus::Disqualified => {
222                    assert!(qual.valid_until.is_none());
223                }
224                _ => {}
225            }
226
227            // Start date should be 14 days before completion
228            assert_eq!(qual.start_date, date - chrono::Duration::days(14));
229            assert_eq!(qual.completion_date, Some(date));
230        }
231    }
232
233    #[test]
234    fn test_generate_certifications() {
235        let mut gen = QualificationGenerator::new(42);
236        let date = NaiveDate::from_ymd_opt(2024, 6, 1).unwrap();
237        let certs = gen.generate_certifications("V001", date);
238
239        // Could be 0-3 certs
240        assert!(certs.len() <= 3);
241        for cert in &certs {
242            assert_eq!(cert.vendor_id, "V001");
243            assert!(!cert.certification_id.is_empty());
244            assert!(!cert.certification_type.is_empty());
245            assert!(!cert.issuing_body.is_empty());
246            assert!(cert.expiry_date > cert.issue_date);
247        }
248    }
249}