use crate::error::EvalResult;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SourcingThresholds {
pub min_rfx_completion: f64,
pub min_bid_receipt: f64,
pub min_ranking_consistency: f64,
pub min_evaluation_completion: f64,
}
impl Default for SourcingThresholds {
fn default() -> Self {
Self {
min_rfx_completion: 0.90,
min_bid_receipt: 0.80,
min_ranking_consistency: 0.95,
min_evaluation_completion: 0.85,
}
}
}
#[derive(Debug, Clone)]
pub struct SourcingProjectData {
pub project_id: String,
pub has_rfx: bool,
pub has_bids: bool,
pub has_evaluation: bool,
pub has_contract: bool,
}
#[derive(Debug, Clone)]
pub struct BidEvaluationData {
pub evaluation_id: String,
pub criteria_weights: Vec<f64>,
pub bid_scores: Vec<f64>,
pub bid_rankings: Vec<u32>,
pub recommended_vendor_idx: Option<usize>,
}
#[derive(Debug, Clone)]
pub struct SpendAnalysisData {
pub vendor_spends: Vec<f64>,
}
#[derive(Debug, Clone)]
pub struct ScorecardCoverageData {
pub total_active_vendors: usize,
pub vendors_with_scorecards: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SourcingEvaluation {
pub rfx_completion_rate: f64,
pub bid_receipt_rate: f64,
pub evaluation_completion_rate: f64,
pub contract_award_rate: f64,
pub criteria_weight_compliance: f64,
pub ranking_consistency: f64,
pub recommendation_match_rate: f64,
pub spend_hhi: f64,
pub scorecard_coverage: f64,
pub total_projects: usize,
pub passes: bool,
pub issues: Vec<String>,
}
pub struct SourcingEvaluator {
thresholds: SourcingThresholds,
}
impl SourcingEvaluator {
pub fn new() -> Self {
Self {
thresholds: SourcingThresholds::default(),
}
}
pub fn with_thresholds(thresholds: SourcingThresholds) -> Self {
Self { thresholds }
}
pub fn evaluate(
&self,
projects: &[SourcingProjectData],
evaluations: &[BidEvaluationData],
spend: &Option<SpendAnalysisData>,
scorecard: &Option<ScorecardCoverageData>,
) -> EvalResult<SourcingEvaluation> {
let mut issues = Vec::new();
let total_projects = projects.len();
let rfx_count = projects.iter().filter(|p| p.has_rfx).count();
let bid_count = projects.iter().filter(|p| p.has_bids).count();
let eval_count = projects.iter().filter(|p| p.has_evaluation).count();
let contract_count = projects.iter().filter(|p| p.has_contract).count();
let rfx_completion_rate = if total_projects > 0 {
rfx_count as f64 / total_projects as f64
} else {
1.0
};
let bid_receipt_rate = if rfx_count > 0 {
bid_count as f64 / rfx_count as f64
} else {
1.0
};
let evaluation_completion_rate = if bid_count > 0 {
eval_count as f64 / bid_count as f64
} else {
1.0
};
let contract_award_rate = if eval_count > 0 {
contract_count as f64 / eval_count as f64
} else {
1.0
};
let weight_ok = evaluations
.iter()
.filter(|e| {
if e.criteria_weights.is_empty() {
return true;
}
let sum: f64 = e.criteria_weights.iter().sum();
(sum - 1.0).abs() <= 0.01
})
.count();
let criteria_weight_compliance = if evaluations.is_empty() {
1.0
} else {
weight_ok as f64 / evaluations.len() as f64
};
let ranking_ok = evaluations
.iter()
.filter(|e| {
if e.bid_scores.len() != e.bid_rankings.len() || e.bid_scores.is_empty() {
return true;
}
let mut pairs: Vec<(f64, u32)> = e
.bid_scores
.iter()
.zip(e.bid_rankings.iter())
.map(|(&s, &r)| (s, r))
.collect();
pairs.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
pairs.windows(2).all(|w| w[0].1 <= w[1].1)
})
.count();
let ranking_consistency = if evaluations.is_empty() {
1.0
} else {
ranking_ok as f64 / evaluations.len() as f64
};
let rec_ok = evaluations
.iter()
.filter(|e| {
if let Some(rec_idx) = e.recommended_vendor_idx {
if rec_idx < e.bid_rankings.len() {
return e.bid_rankings[rec_idx] == 1;
}
}
true })
.count();
let recommendation_match_rate = if evaluations.is_empty() {
1.0
} else {
rec_ok as f64 / evaluations.len() as f64
};
let spend_hhi = if let Some(ref sp) = spend {
let total_spend: f64 = sp.vendor_spends.iter().sum();
if total_spend > 0.0 {
sp.vendor_spends
.iter()
.map(|s| (s / total_spend).powi(2))
.sum::<f64>()
} else {
0.0
}
} else {
0.0
};
let scorecard_coverage = if let Some(ref sc) = scorecard {
if sc.total_active_vendors > 0 {
sc.vendors_with_scorecards as f64 / sc.total_active_vendors as f64
} else {
1.0
}
} else {
1.0
};
if rfx_completion_rate < self.thresholds.min_rfx_completion {
issues.push(format!(
"RFx completion rate {:.3} < {:.3}",
rfx_completion_rate, self.thresholds.min_rfx_completion
));
}
if bid_receipt_rate < self.thresholds.min_bid_receipt {
issues.push(format!(
"Bid receipt rate {:.3} < {:.3}",
bid_receipt_rate, self.thresholds.min_bid_receipt
));
}
if ranking_consistency < self.thresholds.min_ranking_consistency {
issues.push(format!(
"Ranking consistency {:.3} < {:.3}",
ranking_consistency, self.thresholds.min_ranking_consistency
));
}
let passes = issues.is_empty();
Ok(SourcingEvaluation {
rfx_completion_rate,
bid_receipt_rate,
evaluation_completion_rate,
contract_award_rate,
criteria_weight_compliance,
ranking_consistency,
recommendation_match_rate,
spend_hhi,
scorecard_coverage,
total_projects,
passes,
issues,
})
}
}
impl Default for SourcingEvaluator {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn test_valid_sourcing_chain() {
let evaluator = SourcingEvaluator::new();
let projects = vec![SourcingProjectData {
project_id: "SP001".to_string(),
has_rfx: true,
has_bids: true,
has_evaluation: true,
has_contract: true,
}];
let evals = vec![BidEvaluationData {
evaluation_id: "EV001".to_string(),
criteria_weights: vec![0.4, 0.3, 0.3],
bid_scores: vec![90.0, 80.0, 70.0],
bid_rankings: vec![1, 2, 3],
recommended_vendor_idx: Some(0),
}];
let result = evaluator.evaluate(&projects, &evals, &None, &None).unwrap();
assert!(result.passes);
assert_eq!(result.rfx_completion_rate, 1.0);
assert_eq!(result.ranking_consistency, 1.0);
}
#[test]
fn test_inconsistent_rankings() {
let evaluator = SourcingEvaluator::new();
let evals = vec![BidEvaluationData {
evaluation_id: "EV001".to_string(),
criteria_weights: vec![0.5, 0.5],
bid_scores: vec![90.0, 80.0],
bid_rankings: vec![2, 1], recommended_vendor_idx: None,
}];
let result = evaluator.evaluate(&[], &evals, &None, &None).unwrap();
assert_eq!(result.ranking_consistency, 0.0);
}
#[test]
fn test_empty_data() {
let evaluator = SourcingEvaluator::new();
let result = evaluator.evaluate(&[], &[], &None, &None).unwrap();
assert!(result.passes);
}
#[test]
fn test_spend_hhi() {
let evaluator = SourcingEvaluator::new();
let spend = Some(SpendAnalysisData {
vendor_spends: vec![50.0, 50.0],
});
let result = evaluator.evaluate(&[], &[], &spend, &None).unwrap();
assert!((result.spend_hhi - 0.5).abs() < 0.001); }
}