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