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)]
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("es).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, }],
261 total_amount: 500.0,
262 has_sales_order_id: false,
263 has_lost_reason: false,
264 }];
265
266 let result = evaluator.evaluate("es).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, has_sales_order_id: false,
293 has_lost_reason: false,
294 }];
295
296 let result = evaluator.evaluate("es).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, has_lost_reason: false,
316 }];
317
318 let result = evaluator.evaluate("es).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}