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)]
200mod tests {
201    use super::*;
202
203    #[test]
204    fn test_valid_sales_quotes() {
205        let evaluator = SalesQuoteEvaluator::new();
206        let quotes = vec![
207            SalesQuoteData {
208                quote_id: "SQ001".to_string(),
209                status: "Won".to_string(),
210                line_items: vec![
211                    QuoteLineData {
212                        item_number: 1,
213                        quantity: 10.0,
214                        unit_price: 100.0,
215                        line_amount: 1000.0,
216                    },
217                    QuoteLineData {
218                        item_number: 2,
219                        quantity: 5.0,
220                        unit_price: 200.0,
221                        line_amount: 1000.0,
222                    },
223                ],
224                total_amount: 2000.0,
225                has_sales_order_id: true,
226                has_lost_reason: false,
227            },
228            SalesQuoteData {
229                quote_id: "SQ002".to_string(),
230                status: "Lost".to_string(),
231                line_items: vec![QuoteLineData {
232                    item_number: 1,
233                    quantity: 3.0,
234                    unit_price: 500.0,
235                    line_amount: 1500.0,
236                }],
237                total_amount: 1500.0,
238                has_sales_order_id: false,
239                has_lost_reason: true,
240            },
241        ];
242
243        let result = evaluator.evaluate(&quotes).unwrap();
244        assert!(result.passes);
245        assert_eq!(result.total_quotes, 2);
246        assert_eq!(result.total_line_items, 3);
247    }
248
249    #[test]
250    fn test_wrong_line_amount() {
251        let evaluator = SalesQuoteEvaluator::new();
252        let quotes = vec![SalesQuoteData {
253            quote_id: "SQ001".to_string(),
254            status: "Draft".to_string(),
255            line_items: vec![QuoteLineData {
256                item_number: 1,
257                quantity: 10.0,
258                unit_price: 100.0,
259                line_amount: 500.0, // Wrong: should be 1000
260            }],
261            total_amount: 500.0,
262            has_sales_order_id: false,
263            has_lost_reason: false,
264        }];
265
266        let result = evaluator.evaluate(&quotes).unwrap();
267        assert!(!result.passes);
268        assert!(result.issues.iter().any(|i| i.contains("Line amount")));
269    }
270
271    #[test]
272    fn test_wrong_total() {
273        let evaluator = SalesQuoteEvaluator::new();
274        let quotes = vec![SalesQuoteData {
275            quote_id: "SQ001".to_string(),
276            status: "Draft".to_string(),
277            line_items: vec![
278                QuoteLineData {
279                    item_number: 1,
280                    quantity: 10.0,
281                    unit_price: 100.0,
282                    line_amount: 1000.0,
283                },
284                QuoteLineData {
285                    item_number: 2,
286                    quantity: 5.0,
287                    unit_price: 200.0,
288                    line_amount: 1000.0,
289                },
290            ],
291            total_amount: 3000.0, // Wrong: should be 2000
292            has_sales_order_id: false,
293            has_lost_reason: false,
294        }];
295
296        let result = evaluator.evaluate(&quotes).unwrap();
297        assert!(!result.passes);
298        assert!(result.issues.iter().any(|i| i.contains("Quote total")));
299    }
300
301    #[test]
302    fn test_won_without_order() {
303        let evaluator = SalesQuoteEvaluator::new();
304        let quotes = vec![SalesQuoteData {
305            quote_id: "SQ001".to_string(),
306            status: "Won".to_string(),
307            line_items: vec![QuoteLineData {
308                item_number: 1,
309                quantity: 1.0,
310                unit_price: 100.0,
311                line_amount: 100.0,
312            }],
313            total_amount: 100.0,
314            has_sales_order_id: false, // Missing for Won status
315            has_lost_reason: false,
316        }];
317
318        let result = evaluator.evaluate(&quotes).unwrap();
319        assert!(!result.passes);
320        assert!(result
321            .issues
322            .iter()
323            .any(|i| i.contains("Status consistency")));
324    }
325
326    #[test]
327    fn test_empty_data() {
328        let evaluator = SalesQuoteEvaluator::new();
329        let result = evaluator.evaluate(&[]).unwrap();
330        assert!(result.passes);
331    }
332}