datasynth_eval/coherence/
sales_quotes.rs1use crate::error::EvalResult;
7use serde::{Deserialize, Serialize};
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct SalesQuoteThresholds {
12 pub min_line_amount_accuracy: f64,
14 pub min_total_accuracy: f64,
16 pub min_status_consistency: f64,
18 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#[derive(Debug, Clone)]
35pub struct QuoteLineData {
36 pub item_number: u32,
38 pub quantity: f64,
40 pub unit_price: f64,
42 pub line_amount: f64,
44}
45
46#[derive(Debug, Clone)]
48pub struct SalesQuoteData {
49 pub quote_id: String,
51 pub status: String,
53 pub line_items: Vec<QuoteLineData>,
55 pub total_amount: f64,
57 pub has_sales_order_id: bool,
59 pub has_lost_reason: bool,
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct SalesQuoteEvaluation {
66 pub line_amount_accuracy: f64,
68 pub total_accuracy: f64,
70 pub status_consistency: f64,
72 pub total_quotes: usize,
74 pub total_line_items: usize,
76 pub passes: bool,
78 pub issues: Vec<String>,
80}
81
82pub struct SalesQuoteEvaluator {
84 thresholds: SalesQuoteThresholds,
85}
86
87impl SalesQuoteEvaluator {
88 pub fn new() -> Self {
90 Self {
91 thresholds: SalesQuoteThresholds::default(),
92 }
93 }
94
95 pub fn with_thresholds(thresholds: SalesQuoteThresholds) -> Self {
97 Self { thresholds }
98 }
99
100 pub fn evaluate(&self, quotes: &[SalesQuoteData]) -> EvalResult<SalesQuoteEvaluation> {
102 let mut issues = Vec::new();
103 let tolerance = self.thresholds.tolerance;
104
105 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 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 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 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("es).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, }],
262 total_amount: 500.0,
263 has_sales_order_id: false,
264 has_lost_reason: false,
265 }];
266
267 let result = evaluator.evaluate("es).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, has_sales_order_id: false,
294 has_lost_reason: false,
295 }];
296
297 let result = evaluator.evaluate("es).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, has_lost_reason: false,
317 }];
318
319 let result = evaluator.evaluate("es).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}