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