use crate::error::EvalResult;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SalesQuoteThresholds {
pub min_line_amount_accuracy: f64,
pub min_total_accuracy: f64,
pub min_status_consistency: f64,
pub tolerance: f64,
}
impl Default for SalesQuoteThresholds {
fn default() -> Self {
Self {
min_line_amount_accuracy: 0.999,
min_total_accuracy: 0.999,
min_status_consistency: 0.95,
tolerance: 0.001,
}
}
}
#[derive(Debug, Clone)]
pub struct QuoteLineData {
pub item_number: u32,
pub quantity: f64,
pub unit_price: f64,
pub line_amount: f64,
}
#[derive(Debug, Clone)]
pub struct SalesQuoteData {
pub quote_id: String,
pub status: String,
pub line_items: Vec<QuoteLineData>,
pub total_amount: f64,
pub has_sales_order_id: bool,
pub has_lost_reason: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SalesQuoteEvaluation {
pub line_amount_accuracy: f64,
pub total_accuracy: f64,
pub status_consistency: f64,
pub total_quotes: usize,
pub total_line_items: usize,
pub passes: bool,
pub issues: Vec<String>,
}
pub struct SalesQuoteEvaluator {
thresholds: SalesQuoteThresholds,
}
impl SalesQuoteEvaluator {
pub fn new() -> Self {
Self {
thresholds: SalesQuoteThresholds::default(),
}
}
pub fn with_thresholds(thresholds: SalesQuoteThresholds) -> Self {
Self { thresholds }
}
pub fn evaluate(&self, quotes: &[SalesQuoteData]) -> EvalResult<SalesQuoteEvaluation> {
let mut issues = Vec::new();
let tolerance = self.thresholds.tolerance;
let all_lines: Vec<&QuoteLineData> =
quotes.iter().flat_map(|q| q.line_items.iter()).collect();
let line_ok = all_lines
.iter()
.filter(|l| {
let expected = l.quantity * l.unit_price;
(l.line_amount - expected).abs() <= tolerance * expected.abs().max(1.0)
})
.count();
let line_amount_accuracy = if all_lines.is_empty() {
1.0
} else {
line_ok as f64 / all_lines.len() as f64
};
let total_ok = quotes
.iter()
.filter(|q| {
if q.line_items.is_empty() {
return true;
}
let sum: f64 = q.line_items.iter().map(|l| l.line_amount).sum();
(q.total_amount - sum).abs() <= tolerance * sum.abs().max(1.0)
})
.count();
let total_accuracy = if quotes.is_empty() {
1.0
} else {
total_ok as f64 / quotes.len() as f64
};
let status_relevant: Vec<_> = quotes
.iter()
.filter(|q| q.status == "Won" || q.status == "Lost")
.collect();
let status_ok = status_relevant
.iter()
.filter(|q| {
if q.status == "Won" {
q.has_sales_order_id
} else {
q.has_lost_reason
}
})
.count();
let status_consistency = if status_relevant.is_empty() {
1.0
} else {
status_ok as f64 / status_relevant.len() as f64
};
if line_amount_accuracy < self.thresholds.min_line_amount_accuracy {
issues.push(format!(
"Line amount accuracy {:.4} < {:.4}",
line_amount_accuracy, self.thresholds.min_line_amount_accuracy
));
}
if total_accuracy < self.thresholds.min_total_accuracy {
issues.push(format!(
"Quote total accuracy {:.4} < {:.4}",
total_accuracy, self.thresholds.min_total_accuracy
));
}
if status_consistency < self.thresholds.min_status_consistency {
issues.push(format!(
"Status consistency {:.4} < {:.4}",
status_consistency, self.thresholds.min_status_consistency
));
}
let passes = issues.is_empty();
Ok(SalesQuoteEvaluation {
line_amount_accuracy,
total_accuracy,
status_consistency,
total_quotes: quotes.len(),
total_line_items: all_lines.len(),
passes,
issues,
})
}
}
impl Default for SalesQuoteEvaluator {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn test_valid_sales_quotes() {
let evaluator = SalesQuoteEvaluator::new();
let quotes = vec![
SalesQuoteData {
quote_id: "SQ001".to_string(),
status: "Won".to_string(),
line_items: vec![
QuoteLineData {
item_number: 1,
quantity: 10.0,
unit_price: 100.0,
line_amount: 1000.0,
},
QuoteLineData {
item_number: 2,
quantity: 5.0,
unit_price: 200.0,
line_amount: 1000.0,
},
],
total_amount: 2000.0,
has_sales_order_id: true,
has_lost_reason: false,
},
SalesQuoteData {
quote_id: "SQ002".to_string(),
status: "Lost".to_string(),
line_items: vec![QuoteLineData {
item_number: 1,
quantity: 3.0,
unit_price: 500.0,
line_amount: 1500.0,
}],
total_amount: 1500.0,
has_sales_order_id: false,
has_lost_reason: true,
},
];
let result = evaluator.evaluate("es).unwrap();
assert!(result.passes);
assert_eq!(result.total_quotes, 2);
assert_eq!(result.total_line_items, 3);
}
#[test]
fn test_wrong_line_amount() {
let evaluator = SalesQuoteEvaluator::new();
let quotes = vec![SalesQuoteData {
quote_id: "SQ001".to_string(),
status: "Draft".to_string(),
line_items: vec![QuoteLineData {
item_number: 1,
quantity: 10.0,
unit_price: 100.0,
line_amount: 500.0, }],
total_amount: 500.0,
has_sales_order_id: false,
has_lost_reason: false,
}];
let result = evaluator.evaluate("es).unwrap();
assert!(!result.passes);
assert!(result.issues.iter().any(|i| i.contains("Line amount")));
}
#[test]
fn test_wrong_total() {
let evaluator = SalesQuoteEvaluator::new();
let quotes = vec![SalesQuoteData {
quote_id: "SQ001".to_string(),
status: "Draft".to_string(),
line_items: vec![
QuoteLineData {
item_number: 1,
quantity: 10.0,
unit_price: 100.0,
line_amount: 1000.0,
},
QuoteLineData {
item_number: 2,
quantity: 5.0,
unit_price: 200.0,
line_amount: 1000.0,
},
],
total_amount: 3000.0, has_sales_order_id: false,
has_lost_reason: false,
}];
let result = evaluator.evaluate("es).unwrap();
assert!(!result.passes);
assert!(result.issues.iter().any(|i| i.contains("Quote total")));
}
#[test]
fn test_won_without_order() {
let evaluator = SalesQuoteEvaluator::new();
let quotes = vec![SalesQuoteData {
quote_id: "SQ001".to_string(),
status: "Won".to_string(),
line_items: vec![QuoteLineData {
item_number: 1,
quantity: 1.0,
unit_price: 100.0,
line_amount: 100.0,
}],
total_amount: 100.0,
has_sales_order_id: false, has_lost_reason: false,
}];
let result = evaluator.evaluate("es).unwrap();
assert!(!result.passes);
assert!(result
.issues
.iter()
.any(|i| i.contains("Status consistency")));
}
#[test]
fn test_empty_data() {
let evaluator = SalesQuoteEvaluator::new();
let result = evaluator.evaluate(&[]).unwrap();
assert!(result.passes);
}
}