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::utils::seeded_rng;
10use datasynth_core::uuid_factory::{DeterministicUuidFactory, GeneratorType};
11use rand::prelude::*;
12use rand_chacha::ChaCha8Rng;
13
14/// Generates supplier scorecards from P2P performance data.
15pub struct ScorecardGenerator {
16    rng: ChaCha8Rng,
17    uuid_factory: DeterministicUuidFactory,
18    config: ScorecardConfig,
19}
20
21impl ScorecardGenerator {
22    /// Create a new scorecard generator.
23    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    /// Create with custom configuration.
32    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    /// Generate scorecards for vendors with active contracts.
41    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.gen_range(0.75..=0.99);
53            let quality_rate = self.rng.gen_range(0.85..=0.99);
54            let price_score = self.rng.gen_range(60.0..=100.0);
55            let responsiveness = self.rng.gen_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.gen_bool(0.5) {
73                ScoreboardTrend::Stable
74            } else if self.rng.gen_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.gen_range(0..=3),
102                        price_compliance_pct: self.rng.gen_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            // Rates should be in valid range
265            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            // Grade should be valid
271            assert!(["A", "B", "C", "D"].contains(&sc.grade.as_str()));
272
273            // Recommendation should match grade
274            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            // Contract compliance metrics
292            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}