Skip to main content

datasynth_generators/sourcing/
scorecard_generator.rs

1//! Supplier scorecard generator.
2
3use 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
13/// Generates supplier scorecards from P2P performance data.
14pub struct ScorecardGenerator {
15    rng: ChaCha8Rng,
16    uuid_factory: DeterministicUuidFactory,
17    config: ScorecardConfig,
18}
19
20impl ScorecardGenerator {
21    /// Create a new scorecard generator.
22    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    /// Create with custom configuration.
31    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    /// Generate scorecards for vendors with active contracts.
40    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            // Rates should be in valid range
264            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            // Grade should be valid
270            assert!(["A", "B", "C", "D"].contains(&sc.grade.as_str()));
271
272            // Recommendation should match grade
273            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            // Contract compliance metrics
291            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}