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 purchase_order_ids: Vec::new(),
188 }
189 }
190
191 fn test_vendor_contracts() -> Vec<(String, Vec<ProcurementContract>)> {
192 vec![
193 ("V001".to_string(), vec![test_contract()]),
194 ("V002".to_string(), vec![test_contract()]),
195 ]
196 }
197
198 #[test]
199 fn test_basic_generation() {
200 let mut gen = ScorecardGenerator::new(42);
201 let vc = test_vendor_contracts();
202 let vendor_refs: Vec<(String, Vec<&ProcurementContract>)> = vc
203 .iter()
204 .map(|(v, cs)| (v.clone(), cs.iter().collect()))
205 .collect();
206 let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
207 let end = NaiveDate::from_ymd_opt(2024, 3, 31).unwrap();
208
209 let scorecards = gen.generate("C001", &vendor_refs, start, end, "REV-01");
210
211 assert_eq!(scorecards.len(), 2);
212 for sc in &scorecards {
213 assert!(!sc.scorecard_id.is_empty());
214 assert_eq!(sc.company_code, "C001");
215 assert!(!sc.vendor_id.is_empty());
216 assert_eq!(sc.period_start, start);
217 assert_eq!(sc.period_end, end);
218 assert_eq!(sc.reviewer_id, "REV-01");
219 assert!(sc.overall_score > 0.0);
220 assert!(!sc.grade.is_empty());
221 assert!(!sc.contract_compliance.is_empty());
222 }
223 }
224
225 #[test]
226 fn test_deterministic() {
227 let vc = test_vendor_contracts();
228 let vendor_refs: Vec<(String, Vec<&ProcurementContract>)> = vc
229 .iter()
230 .map(|(v, cs)| (v.clone(), cs.iter().collect()))
231 .collect();
232 let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
233 let end = NaiveDate::from_ymd_opt(2024, 3, 31).unwrap();
234
235 let mut gen1 = ScorecardGenerator::new(42);
236 let mut gen2 = ScorecardGenerator::new(42);
237
238 let r1 = gen1.generate("C001", &vendor_refs, start, end, "REV-01");
239 let r2 = gen2.generate("C001", &vendor_refs, start, end, "REV-01");
240
241 assert_eq!(r1.len(), r2.len());
242 for (a, b) in r1.iter().zip(r2.iter()) {
243 assert_eq!(a.scorecard_id, b.scorecard_id);
244 assert_eq!(a.vendor_id, b.vendor_id);
245 assert_eq!(a.overall_score, b.overall_score);
246 assert_eq!(a.grade, b.grade);
247 assert_eq!(a.on_time_delivery_rate, b.on_time_delivery_rate);
248 }
249 }
250
251 #[test]
252 fn test_field_constraints() {
253 let mut gen = ScorecardGenerator::new(99);
254 let vc = test_vendor_contracts();
255 let vendor_refs: Vec<(String, Vec<&ProcurementContract>)> = vc
256 .iter()
257 .map(|(v, cs)| (v.clone(), cs.iter().collect()))
258 .collect();
259 let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
260 let end = NaiveDate::from_ymd_opt(2024, 3, 31).unwrap();
261
262 let scorecards = gen.generate("C001", &vendor_refs, start, end, "REV-01");
263
264 for sc in &scorecards {
265 assert!(sc.on_time_delivery_rate >= 0.75 && sc.on_time_delivery_rate <= 0.99);
267 assert!(sc.quality_rate >= 0.85 && sc.quality_rate <= 0.99);
268 assert!(sc.price_score >= 60.0 && sc.price_score <= 100.0);
269 assert!(sc.responsiveness_score >= 50.0 && sc.responsiveness_score <= 100.0);
270
271 assert!(["A", "B", "C", "D"].contains(&sc.grade.as_str()));
273
274 match sc.grade.as_str() {
276 "A" => assert!(matches!(sc.recommendation, ScorecardRecommendation::Expand)),
277 "B" => assert!(matches!(
278 sc.recommendation,
279 ScorecardRecommendation::Maintain
280 )),
281 "C" => assert!(matches!(
282 sc.recommendation,
283 ScorecardRecommendation::Probation
284 )),
285 "D" => assert!(matches!(
286 sc.recommendation,
287 ScorecardRecommendation::Replace
288 )),
289 _ => panic!("Unexpected grade"),
290 }
291
292 for cc in &sc.contract_compliance {
294 assert!(!cc.contract_id.is_empty());
295 assert!(cc.price_compliance_pct >= 0.85 && cc.price_compliance_pct <= 1.0);
296 assert!(cc.utilization_pct >= 0.0);
297 }
298 }
299 }
300}