1use chrono::NaiveDate;
4use datasynth_config::schema::ScorecardConfig;
5use datasynth_core::models::sourcing::{
6 ContractComplianceMetrics, ProcurementContract, ScoreboardTrend, ScorecardRecommendation,
7 SupplierScorecard,
8};
9use datasynth_core::uuid_factory::{DeterministicUuidFactory, GeneratorType};
10use rand::prelude::*;
11use rand_chacha::ChaCha8Rng;
12
13pub struct ScorecardGenerator {
15 rng: ChaCha8Rng,
16 uuid_factory: DeterministicUuidFactory,
17 config: ScorecardConfig,
18}
19
20impl ScorecardGenerator {
21 pub fn new(seed: u64) -> Self {
23 Self {
24 rng: ChaCha8Rng::seed_from_u64(seed),
25 uuid_factory: DeterministicUuidFactory::new(seed, GeneratorType::SourcingProject),
26 config: ScorecardConfig::default(),
27 }
28 }
29
30 pub fn with_config(seed: u64, config: ScorecardConfig) -> Self {
32 Self {
33 rng: ChaCha8Rng::seed_from_u64(seed),
34 uuid_factory: DeterministicUuidFactory::new(seed, GeneratorType::SourcingProject),
35 config,
36 }
37 }
38
39 pub fn generate(
41 &mut self,
42 company_code: &str,
43 vendor_contracts: &[(String, Vec<&ProcurementContract>)],
44 period_start: NaiveDate,
45 period_end: NaiveDate,
46 reviewer_id: &str,
47 ) -> Vec<SupplierScorecard> {
48 let mut scorecards = Vec::new();
49
50 for (vendor_id, contracts) in vendor_contracts {
51 let otd_rate = self.rng.gen_range(0.75..=0.99);
52 let quality_rate = self.rng.gen_range(0.85..=0.99);
53 let price_score = self.rng.gen_range(60.0..=100.0);
54 let responsiveness = self.rng.gen_range(50.0..=100.0);
55
56 let overall = otd_rate * 100.0 * self.config.on_time_delivery_weight
57 + quality_rate * 100.0 * self.config.quality_weight
58 + price_score * self.config.price_weight
59 + responsiveness * self.config.responsiveness_weight;
60
61 let grade = if overall >= self.config.grade_a_threshold {
62 "A"
63 } else if overall >= self.config.grade_b_threshold {
64 "B"
65 } else if overall >= self.config.grade_c_threshold {
66 "C"
67 } else {
68 "D"
69 };
70
71 let trend = if self.rng.gen_bool(0.5) {
72 ScoreboardTrend::Stable
73 } else if self.rng.gen_bool(0.6) {
74 ScoreboardTrend::Improving
75 } else {
76 ScoreboardTrend::Declining
77 };
78
79 let recommendation = match grade {
80 "A" => ScorecardRecommendation::Expand,
81 "B" => ScorecardRecommendation::Maintain,
82 "C" => ScorecardRecommendation::Probation,
83 _ => ScorecardRecommendation::Replace,
84 };
85
86 let contract_compliance: Vec<ContractComplianceMetrics> = contracts
87 .iter()
88 .map(|c| {
89 let consumed_f64: f64 = c.consumed_value.to_string().parse().unwrap_or(0.0);
90 let total_f64: f64 = c.total_value.to_string().parse().unwrap_or(1.0);
91 let utilization = if total_f64 > 0.0 {
92 consumed_f64 / total_f64
93 } else {
94 0.0
95 };
96
97 ContractComplianceMetrics {
98 contract_id: c.contract_id.clone(),
99 utilization_pct: utilization,
100 sla_breach_count: self.rng.gen_range(0..=3),
101 price_compliance_pct: self.rng.gen_range(0.85..=1.0),
102 amendment_count: c.amendment_count,
103 }
104 })
105 .collect();
106
107 scorecards.push(SupplierScorecard {
108 scorecard_id: self.uuid_factory.next().to_string(),
109 vendor_id: vendor_id.clone(),
110 company_code: company_code.to_string(),
111 period_start,
112 period_end,
113 on_time_delivery_rate: otd_rate,
114 quality_rate,
115 price_score,
116 responsiveness_score: responsiveness,
117 overall_score: overall,
118 grade: grade.to_string(),
119 trend,
120 contract_compliance,
121 recommendation,
122 reviewer_id: reviewer_id.to_string(),
123 comments: None,
124 });
125 }
126
127 scorecards
128 }
129}
130
131#[cfg(test)]
132#[allow(clippy::unwrap_used)]
133mod tests {
134 use super::*;
135 use datasynth_core::models::sourcing::{
136 ContractLineItem, ContractSla, ContractStatus, ContractTerms, ContractType,
137 };
138 use rust_decimal::Decimal;
139
140 fn test_contract() -> ProcurementContract {
141 ProcurementContract {
142 contract_id: "CTR-001".to_string(),
143 company_code: "C001".to_string(),
144 contract_type: ContractType::FixedPrice,
145 status: ContractStatus::Active,
146 vendor_id: "V001".to_string(),
147 title: "Test Contract".to_string(),
148 sourcing_project_id: None,
149 bid_id: None,
150 start_date: NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
151 end_date: NaiveDate::from_ymd_opt(2025, 12, 31).unwrap(),
152 total_value: Decimal::from(100_000),
153 consumed_value: Decimal::from(30_000),
154 terms: ContractTerms {
155 payment_terms: "NET30".to_string(),
156 delivery_terms: Some("FCA".to_string()),
157 warranty_months: None,
158 early_termination_penalty_pct: None,
159 auto_renewal: false,
160 termination_notice_days: 60,
161 price_adjustment_clause: false,
162 max_annual_price_increase_pct: None,
163 },
164 slas: vec![ContractSla {
165 metric_name: "on_time_delivery".to_string(),
166 target_value: 0.95,
167 minimum_value: 0.90,
168 breach_penalty_pct: 0.02,
169 measurement_frequency: "monthly".to_string(),
170 }],
171 line_items: vec![ContractLineItem {
172 line_number: 1,
173 material_id: None,
174 description: "Widget A".to_string(),
175 unit_price: Decimal::from(50),
176 uom: "EA".to_string(),
177 min_quantity: Some(Decimal::from(100)),
178 max_quantity: Some(Decimal::from(1200)),
179 quantity_released: Decimal::ZERO,
180 value_released: Decimal::ZERO,
181 }],
182 category_id: "CAT-001".to_string(),
183 owner_id: "BUYER-01".to_string(),
184 amendment_count: 1,
185 previous_contract_id: None,
186 }
187 }
188
189 fn test_vendor_contracts() -> Vec<(String, Vec<ProcurementContract>)> {
190 vec![
191 ("V001".to_string(), vec![test_contract()]),
192 ("V002".to_string(), vec![test_contract()]),
193 ]
194 }
195
196 #[test]
197 fn test_basic_generation() {
198 let mut gen = ScorecardGenerator::new(42);
199 let vc = test_vendor_contracts();
200 let vendor_refs: Vec<(String, Vec<&ProcurementContract>)> = vc
201 .iter()
202 .map(|(v, cs)| (v.clone(), cs.iter().collect()))
203 .collect();
204 let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
205 let end = NaiveDate::from_ymd_opt(2024, 3, 31).unwrap();
206
207 let scorecards = gen.generate("C001", &vendor_refs, start, end, "REV-01");
208
209 assert_eq!(scorecards.len(), 2);
210 for sc in &scorecards {
211 assert!(!sc.scorecard_id.is_empty());
212 assert_eq!(sc.company_code, "C001");
213 assert!(!sc.vendor_id.is_empty());
214 assert_eq!(sc.period_start, start);
215 assert_eq!(sc.period_end, end);
216 assert_eq!(sc.reviewer_id, "REV-01");
217 assert!(sc.overall_score > 0.0);
218 assert!(!sc.grade.is_empty());
219 assert!(!sc.contract_compliance.is_empty());
220 }
221 }
222
223 #[test]
224 fn test_deterministic() {
225 let vc = test_vendor_contracts();
226 let vendor_refs: Vec<(String, Vec<&ProcurementContract>)> = vc
227 .iter()
228 .map(|(v, cs)| (v.clone(), cs.iter().collect()))
229 .collect();
230 let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
231 let end = NaiveDate::from_ymd_opt(2024, 3, 31).unwrap();
232
233 let mut gen1 = ScorecardGenerator::new(42);
234 let mut gen2 = ScorecardGenerator::new(42);
235
236 let r1 = gen1.generate("C001", &vendor_refs, start, end, "REV-01");
237 let r2 = gen2.generate("C001", &vendor_refs, start, end, "REV-01");
238
239 assert_eq!(r1.len(), r2.len());
240 for (a, b) in r1.iter().zip(r2.iter()) {
241 assert_eq!(a.scorecard_id, b.scorecard_id);
242 assert_eq!(a.vendor_id, b.vendor_id);
243 assert_eq!(a.overall_score, b.overall_score);
244 assert_eq!(a.grade, b.grade);
245 assert_eq!(a.on_time_delivery_rate, b.on_time_delivery_rate);
246 }
247 }
248
249 #[test]
250 fn test_field_constraints() {
251 let mut gen = ScorecardGenerator::new(99);
252 let vc = test_vendor_contracts();
253 let vendor_refs: Vec<(String, Vec<&ProcurementContract>)> = vc
254 .iter()
255 .map(|(v, cs)| (v.clone(), cs.iter().collect()))
256 .collect();
257 let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
258 let end = NaiveDate::from_ymd_opt(2024, 3, 31).unwrap();
259
260 let scorecards = gen.generate("C001", &vendor_refs, start, end, "REV-01");
261
262 for sc in &scorecards {
263 assert!(sc.on_time_delivery_rate >= 0.75 && sc.on_time_delivery_rate <= 0.99);
265 assert!(sc.quality_rate >= 0.85 && sc.quality_rate <= 0.99);
266 assert!(sc.price_score >= 60.0 && sc.price_score <= 100.0);
267 assert!(sc.responsiveness_score >= 50.0 && sc.responsiveness_score <= 100.0);
268
269 assert!(["A", "B", "C", "D"].contains(&sc.grade.as_str()));
271
272 match sc.grade.as_str() {
274 "A" => assert!(matches!(sc.recommendation, ScorecardRecommendation::Expand)),
275 "B" => assert!(matches!(
276 sc.recommendation,
277 ScorecardRecommendation::Maintain
278 )),
279 "C" => assert!(matches!(
280 sc.recommendation,
281 ScorecardRecommendation::Probation
282 )),
283 "D" => assert!(matches!(
284 sc.recommendation,
285 ScorecardRecommendation::Replace
286 )),
287 _ => panic!("Unexpected grade"),
288 }
289
290 for cc in &sc.contract_compliance {
292 assert!(!cc.contract_id.is_empty());
293 assert!(cc.price_compliance_pct >= 0.85 && cc.price_compliance_pct <= 1.0);
294 assert!(cc.utilization_pct >= 0.0);
295 }
296 }
297 }
298}