datasynth_generators/sourcing/
qualification_generator.rs1use 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
13pub struct QualificationGenerator {
15 rng: ChaCha8Rng,
16 uuid_factory: DeterministicUuidFactory,
17 config: QualificationConfig,
18}
19
20impl QualificationGenerator {
21 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 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 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 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); 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 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 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 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 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}