Skip to main content

datasynth_eval/coherence/
sales_quotes.rs

1//! Sales quote coherence evaluator.
2//!
3//! Validates line amount arithmetic, total consistency,
4//! and status-dependent field requirements (Won → sales_order_id, Lost → lost_reason).
5
6use crate::error::EvalResult;
7use serde::{Deserialize, Serialize};
8
9/// Thresholds for sales quote evaluation.
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct SalesQuoteThresholds {
12    /// Minimum accuracy for line_amount = qty * unit_price.
13    pub min_line_amount_accuracy: f64,
14    /// Minimum accuracy for total = sum(line_amounts).
15    pub min_total_accuracy: f64,
16    /// Minimum status consistency (Won has order, Lost has reason).
17    pub min_status_consistency: f64,
18    /// Tolerance for amount comparisons.
19    pub tolerance: f64,
20}
21
22impl Default for SalesQuoteThresholds {
23    fn default() -> Self {
24        Self {
25            min_line_amount_accuracy: 0.999,
26            min_total_accuracy: 0.999,
27            min_status_consistency: 0.95,
28            tolerance: 0.001,
29        }
30    }
31}
32
33/// Quote line item data.
34#[derive(Debug, Clone)]
35pub struct QuoteLineData {
36    /// Line item number.
37    pub item_number: u32,
38    /// Quantity.
39    pub quantity: f64,
40    /// Unit price.
41    pub unit_price: f64,
42    /// Line amount (should be qty * unit_price).
43    pub line_amount: f64,
44}
45
46/// Sales quote data for validation.
47#[derive(Debug, Clone)]
48pub struct SalesQuoteData {
49    /// Quote identifier.
50    pub quote_id: String,
51    /// Quote status (e.g., "Draft", "Won", "Lost", "Expired").
52    pub status: String,
53    /// Line items.
54    pub line_items: Vec<QuoteLineData>,
55    /// Total quote amount.
56    pub total_amount: f64,
57    /// Whether a sales order reference exists.
58    pub has_sales_order_id: bool,
59    /// Whether a lost reason is provided.
60    pub has_lost_reason: bool,
61}
62
63/// Results of sales quote coherence evaluation.
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct SalesQuoteEvaluation {
66    /// Fraction of line items with correct line_amount.
67    pub line_amount_accuracy: f64,
68    /// Fraction of quotes with correct total_amount.
69    pub total_accuracy: f64,
70    /// Fraction of Won/Lost quotes with required fields.
71    pub status_consistency: f64,
72    /// Total quotes evaluated.
73    pub total_quotes: usize,
74    /// Total line items evaluated.
75    pub total_line_items: usize,
76    /// Overall pass/fail.
77    pub passes: bool,
78    /// Issues found.
79    pub issues: Vec<String>,
80}
81
82/// Evaluator for sales quote coherence.
83pub struct SalesQuoteEvaluator {
84    thresholds: SalesQuoteThresholds,
85}
86
87impl SalesQuoteEvaluator {
88    /// Create a new evaluator with default thresholds.
89    pub fn new() -> Self {
90        Self {
91            thresholds: SalesQuoteThresholds::default(),
92        }
93    }
94
95    /// Create with custom thresholds.
96    pub fn with_thresholds(thresholds: SalesQuoteThresholds) -> Self {
97        Self { thresholds }
98    }
99
100    /// Evaluate sales quote data coherence.
101    pub fn evaluate(&self, quotes: &[SalesQuoteData]) -> EvalResult<SalesQuoteEvaluation> {
102        let mut issues = Vec::new();
103        let tolerance = self.thresholds.tolerance;
104
105        // 1. Line amount accuracy: line_amount ≈ quantity * unit_price
106        let all_lines: Vec<&QuoteLineData> =
107            quotes.iter().flat_map(|q| q.line_items.iter()).collect();
108        let line_ok = all_lines
109            .iter()
110            .filter(|l| {
111                let expected = l.quantity * l.unit_price;
112                (l.line_amount - expected).abs() <= tolerance * expected.abs().max(1.0)
113            })
114            .count();
115        let line_amount_accuracy = if all_lines.is_empty() {
116            1.0
117        } else {
118            line_ok as f64 / all_lines.len() as f64
119        };
120
121        // 2. Total accuracy: total_amount ≈ sum(line_amounts)
122        let total_ok = quotes
123            .iter()
124            .filter(|q| {
125                if q.line_items.is_empty() {
126                    return true;
127                }
128                let sum: f64 = q.line_items.iter().map(|l| l.line_amount).sum();
129                (q.total_amount - sum).abs() <= tolerance * sum.abs().max(1.0)
130            })
131            .count();
132        let total_accuracy = if quotes.is_empty() {
133            1.0
134        } else {
135            total_ok as f64 / quotes.len() as f64
136        };
137
138        // 3. Status consistency: Won → has_sales_order_id, Lost → has_lost_reason
139        let status_relevant: Vec<_> = quotes
140            .iter()
141            .filter(|q| q.status == "Won" || q.status == "Lost")
142            .collect();
143        let status_ok = status_relevant
144            .iter()
145            .filter(|q| {
146                if q.status == "Won" {
147                    q.has_sales_order_id
148                } else {
149                    q.has_lost_reason
150                }
151            })
152            .count();
153        let status_consistency = if status_relevant.is_empty() {
154            1.0
155        } else {
156            status_ok as f64 / status_relevant.len() as f64
157        };
158
159        // Check thresholds
160        if line_amount_accuracy < self.thresholds.min_line_amount_accuracy {
161            issues.push(format!(
162                "Line amount accuracy {:.4} < {:.4}",
163                line_amount_accuracy, self.thresholds.min_line_amount_accuracy
164            ));
165        }
166        if total_accuracy < self.thresholds.min_total_accuracy {
167            issues.push(format!(
168                "Quote total accuracy {:.4} < {:.4}",
169                total_accuracy, self.thresholds.min_total_accuracy
170            ));
171        }
172        if status_consistency < self.thresholds.min_status_consistency {
173            issues.push(format!(
174                "Status consistency {:.4} < {:.4}",
175                status_consistency, self.thresholds.min_status_consistency
176            ));
177        }
178
179        let passes = issues.is_empty();
180
181        Ok(SalesQuoteEvaluation {
182            line_amount_accuracy,
183            total_accuracy,
184            status_consistency,
185            total_quotes: quotes.len(),
186            total_line_items: all_lines.len(),
187            passes,
188            issues,
189        })
190    }
191}
192
193impl Default for SalesQuoteEvaluator {
194    fn default() -> Self {
195        Self::new()
196    }
197}
198
199#[cfg(test)]
200#[allow(clippy::unwrap_used)]
201mod tests {
202    use super::*;
203
204    #[test]
205    fn test_valid_sales_quotes() {
206        let evaluator = SalesQuoteEvaluator::new();
207        let quotes = vec![
208            SalesQuoteData {
209                quote_id: "SQ001".to_string(),
210                status: "Won".to_string(),
211                line_items: vec![
212                    QuoteLineData {
213                        item_number: 1,
214                        quantity: 10.0,
215                        unit_price: 100.0,
216                        line_amount: 1000.0,
217                    },
218                    QuoteLineData {
219                        item_number: 2,
220                        quantity: 5.0,
221                        unit_price: 200.0,
222                        line_amount: 1000.0,
223                    },
224                ],
225                total_amount: 2000.0,
226                has_sales_order_id: true,
227                has_lost_reason: false,
228            },
229            SalesQuoteData {
230                quote_id: "SQ002".to_string(),
231                status: "Lost".to_string(),
232                line_items: vec![QuoteLineData {
233                    item_number: 1,
234                    quantity: 3.0,
235                    unit_price: 500.0,
236                    line_amount: 1500.0,
237                }],
238                total_amount: 1500.0,
239                has_sales_order_id: false,
240                has_lost_reason: true,
241            },
242        ];
243
244        let result = evaluator.evaluate(&quotes).unwrap();
245        assert!(result.passes);
246        assert_eq!(result.total_quotes, 2);
247        assert_eq!(result.total_line_items, 3);
248    }
249
250    #[test]
251    fn test_wrong_line_amount() {
252        let evaluator = SalesQuoteEvaluator::new();
253        let quotes = vec![SalesQuoteData {
254            quote_id: "SQ001".to_string(),
255            status: "Draft".to_string(),
256            line_items: vec![QuoteLineData {
257                item_number: 1,
258                quantity: 10.0,
259                unit_price: 100.0,
260                line_amount: 500.0, // Wrong: should be 1000
261            }],
262            total_amount: 500.0,
263            has_sales_order_id: false,
264            has_lost_reason: false,
265        }];
266
267        let result = evaluator.evaluate(&quotes).unwrap();
268        assert!(!result.passes);
269        assert!(result.issues.iter().any(|i| i.contains("Line amount")));
270    }
271
272    #[test]
273    fn test_wrong_total() {
274        let evaluator = SalesQuoteEvaluator::new();
275        let quotes = vec![SalesQuoteData {
276            quote_id: "SQ001".to_string(),
277            status: "Draft".to_string(),
278            line_items: vec![
279                QuoteLineData {
280                    item_number: 1,
281                    quantity: 10.0,
282                    unit_price: 100.0,
283                    line_amount: 1000.0,
284                },
285                QuoteLineData {
286                    item_number: 2,
287                    quantity: 5.0,
288                    unit_price: 200.0,
289                    line_amount: 1000.0,
290                },
291            ],
292            total_amount: 3000.0, // Wrong: should be 2000
293            has_sales_order_id: false,
294            has_lost_reason: false,
295        }];
296
297        let result = evaluator.evaluate(&quotes).unwrap();
298        assert!(!result.passes);
299        assert!(result.issues.iter().any(|i| i.contains("Quote total")));
300    }
301
302    #[test]
303    fn test_won_without_order() {
304        let evaluator = SalesQuoteEvaluator::new();
305        let quotes = vec![SalesQuoteData {
306            quote_id: "SQ001".to_string(),
307            status: "Won".to_string(),
308            line_items: vec![QuoteLineData {
309                item_number: 1,
310                quantity: 1.0,
311                unit_price: 100.0,
312                line_amount: 100.0,
313            }],
314            total_amount: 100.0,
315            has_sales_order_id: false, // Missing for Won status
316            has_lost_reason: false,
317        }];
318
319        let result = evaluator.evaluate(&quotes).unwrap();
320        assert!(!result.passes);
321        assert!(result
322            .issues
323            .iter()
324            .any(|i| i.contains("Status consistency")));
325    }
326
327    #[test]
328    fn test_empty_data() {
329        let evaluator = SalesQuoteEvaluator::new();
330        let result = evaluator.evaluate(&[]).unwrap();
331        assert!(result.passes);
332    }
333}