1use rust_decimal::Decimal;
7use rust_decimal_macros::dec;
8use serde::{Deserialize, Serialize};
9
10use datasynth_core::models::documents::{GoodsReceipt, PurchaseOrder, VendorInvoice};
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct ThreeWayMatchConfig {
15 pub price_tolerance: Decimal,
17 pub quantity_tolerance: Decimal,
19 pub absolute_amount_tolerance: Decimal,
21 pub allow_over_delivery: bool,
23 pub max_over_delivery_pct: Decimal,
25}
26
27impl Default for ThreeWayMatchConfig {
28 fn default() -> Self {
29 Self {
30 price_tolerance: dec!(0.05), quantity_tolerance: dec!(0.02), absolute_amount_tolerance: dec!(0.01), allow_over_delivery: true,
34 max_over_delivery_pct: dec!(0.10), }
36 }
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct ThreeWayMatchResult {
42 pub passed: bool,
44 pub quantity_matched: bool,
46 pub price_matched: bool,
48 pub amount_matched: bool,
50 pub variances: Vec<MatchVariance>,
52 pub message: String,
54}
55
56impl ThreeWayMatchResult {
57 pub fn success() -> Self {
59 Self {
60 passed: true,
61 quantity_matched: true,
62 price_matched: true,
63 amount_matched: true,
64 variances: Vec::new(),
65 message: "Three-way match passed".to_string(),
66 }
67 }
68
69 pub fn failure(message: impl Into<String>) -> Self {
71 Self {
72 passed: false,
73 quantity_matched: false,
74 price_matched: false,
75 amount_matched: false,
76 variances: Vec::new(),
77 message: message.into(),
78 }
79 }
80
81 pub fn with_variance(mut self, variance: MatchVariance) -> Self {
83 self.variances.push(variance);
84 self
85 }
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct MatchVariance {
91 pub line_number: u16,
93 pub variance_type: VarianceType,
95 pub expected: Decimal,
97 pub actual: Decimal,
99 pub variance: Decimal,
101 pub variance_pct: Decimal,
103 pub description: String,
105}
106
107#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
109#[serde(rename_all = "snake_case")]
110pub enum VarianceType {
111 QuantityPoGr,
113 QuantityGrInvoice,
115 PricePoInvoice,
117 TotalAmount,
119 MissingLine,
121 ExtraLine,
123}
124
125pub struct ThreeWayMatcher {
127 config: ThreeWayMatchConfig,
128}
129
130impl ThreeWayMatcher {
131 pub fn new() -> Self {
133 Self {
134 config: ThreeWayMatchConfig::default(),
135 }
136 }
137
138 pub fn with_config(config: ThreeWayMatchConfig) -> Self {
140 Self { config }
141 }
142
143 pub fn validate(
155 &self,
156 po: &PurchaseOrder,
157 grs: &[&GoodsReceipt],
158 invoice: &VendorInvoice,
159 ) -> ThreeWayMatchResult {
160 let mut result = ThreeWayMatchResult::success();
161 let mut all_quantity_matched = true;
162 let mut all_price_matched = true;
163 let mut all_amount_matched = true;
164
165 let mut gr_quantities: std::collections::HashMap<u16, Decimal> =
167 std::collections::HashMap::new();
168 for gr in grs {
169 for item in &gr.items {
170 if let Some(po_line) = item.po_item {
171 *gr_quantities.entry(po_line).or_insert(Decimal::ZERO) += item.base.quantity;
172 }
173 }
174 }
175
176 for po_item in &po.items {
178 let po_line = po_item.base.line_number;
179 let po_qty = po_item.base.quantity;
180 let po_price = po_item.base.unit_price;
181
182 let gr_qty = gr_quantities
184 .get(&po_line)
185 .copied()
186 .unwrap_or(Decimal::ZERO);
187 let qty_variance = gr_qty - po_qty;
188 let qty_variance_pct = if po_qty > Decimal::ZERO {
189 (qty_variance.abs() / po_qty) * dec!(100)
190 } else {
191 Decimal::ZERO
192 };
193
194 if qty_variance < Decimal::ZERO
196 && qty_variance_pct > self.config.quantity_tolerance * dec!(100)
197 {
198 all_quantity_matched = false;
199 result = result.with_variance(MatchVariance {
200 line_number: po_line,
201 variance_type: VarianceType::QuantityPoGr,
202 expected: po_qty,
203 actual: gr_qty,
204 variance: qty_variance,
205 variance_pct: qty_variance_pct,
206 description: format!(
207 "Under-delivery: received {} vs ordered {}",
208 gr_qty, po_qty
209 ),
210 });
211 }
212
213 if qty_variance > Decimal::ZERO
215 && (!self.config.allow_over_delivery
216 || qty_variance_pct > self.config.max_over_delivery_pct * dec!(100))
217 {
218 all_quantity_matched = false;
219 result = result.with_variance(MatchVariance {
220 line_number: po_line,
221 variance_type: VarianceType::QuantityPoGr,
222 expected: po_qty,
223 actual: gr_qty,
224 variance: qty_variance,
225 variance_pct: qty_variance_pct,
226 description: format!(
227 "Over-delivery: received {} vs ordered {}",
228 gr_qty, po_qty
229 ),
230 });
231 }
232
233 let invoice_item = invoice.items.iter().find(|i| i.po_item == Some(po_line));
235
236 if let Some(inv_item) = invoice_item {
237 let price_variance = inv_item.base.unit_price - po_price;
239 let price_variance_pct = if po_price > Decimal::ZERO {
240 (price_variance.abs() / po_price) * dec!(100)
241 } else {
242 Decimal::ZERO
243 };
244
245 if price_variance_pct > self.config.price_tolerance * dec!(100)
246 && price_variance.abs() > self.config.absolute_amount_tolerance
247 {
248 all_price_matched = false;
249 result = result.with_variance(MatchVariance {
250 line_number: po_line,
251 variance_type: VarianceType::PricePoInvoice,
252 expected: po_price,
253 actual: inv_item.base.unit_price,
254 variance: price_variance,
255 variance_pct: price_variance_pct,
256 description: format!(
257 "Price variance: invoiced {} vs PO price {}",
258 inv_item.base.unit_price, po_price
259 ),
260 });
261 }
262
263 let inv_qty = inv_item.invoiced_quantity;
265 let inv_gr_variance = inv_qty - gr_qty;
266 let inv_gr_variance_pct = if gr_qty > Decimal::ZERO {
267 (inv_gr_variance.abs() / gr_qty) * dec!(100)
268 } else {
269 Decimal::ZERO
270 };
271
272 if inv_gr_variance_pct > self.config.quantity_tolerance * dec!(100)
273 && inv_gr_variance.abs() > self.config.absolute_amount_tolerance
274 {
275 all_quantity_matched = false;
276 result = result.with_variance(MatchVariance {
277 line_number: po_line,
278 variance_type: VarianceType::QuantityGrInvoice,
279 expected: gr_qty,
280 actual: inv_qty,
281 variance: inv_gr_variance,
282 variance_pct: inv_gr_variance_pct,
283 description: format!(
284 "Invoice qty {} doesn't match GR qty {}",
285 inv_qty, gr_qty
286 ),
287 });
288 }
289 } else {
290 result = result.with_variance(MatchVariance {
292 line_number: po_line,
293 variance_type: VarianceType::MissingLine,
294 expected: po_qty,
295 actual: Decimal::ZERO,
296 variance: po_qty,
297 variance_pct: dec!(100),
298 description: format!("PO line {} not found on invoice", po_line),
299 });
300 all_amount_matched = false;
301 }
302 }
303
304 let po_total = po.total_net_amount;
306 let invoice_total = invoice.net_amount;
307 let total_variance = invoice_total - po_total;
308 let total_variance_pct = if po_total > Decimal::ZERO {
309 (total_variance.abs() / po_total) * dec!(100)
310 } else {
311 Decimal::ZERO
312 };
313
314 if total_variance.abs() > self.config.absolute_amount_tolerance
315 && total_variance_pct > self.config.price_tolerance * dec!(100)
316 {
317 all_amount_matched = false;
318 result = result.with_variance(MatchVariance {
319 line_number: 0,
320 variance_type: VarianceType::TotalAmount,
321 expected: po_total,
322 actual: invoice_total,
323 variance: total_variance,
324 variance_pct: total_variance_pct,
325 description: format!(
326 "Total amount variance: invoice {} vs PO {}",
327 invoice_total, po_total
328 ),
329 });
330 }
331
332 result.quantity_matched = all_quantity_matched;
334 result.price_matched = all_price_matched;
335 result.amount_matched = all_amount_matched;
336 result.passed = all_quantity_matched && all_price_matched && all_amount_matched;
337
338 if !result.passed {
339 let issues = result.variances.len();
340 result.message = format!("Three-way match failed with {} variance(s)", issues);
341 }
342
343 result
344 }
345
346 pub fn check_quantities(&self, po: &PurchaseOrder, grs: &[&GoodsReceipt]) -> bool {
348 let mut gr_quantities: std::collections::HashMap<u16, Decimal> =
350 std::collections::HashMap::new();
351 for gr in grs {
352 for item in &gr.items {
353 if let Some(po_line) = item.po_item {
354 *gr_quantities.entry(po_line).or_insert(Decimal::ZERO) += item.base.quantity;
355 }
356 }
357 }
358
359 for po_item in &po.items {
361 let po_qty = po_item.base.quantity;
362 let gr_qty = gr_quantities
363 .get(&po_item.base.line_number)
364 .copied()
365 .unwrap_or(Decimal::ZERO);
366
367 let variance_pct = if po_qty > Decimal::ZERO {
368 ((gr_qty - po_qty).abs() / po_qty) * dec!(100)
369 } else {
370 Decimal::ZERO
371 };
372
373 if variance_pct > self.config.quantity_tolerance * dec!(100) {
374 return false;
375 }
376 }
377
378 true
379 }
380}
381
382impl Default for ThreeWayMatcher {
383 fn default() -> Self {
384 Self::new()
385 }
386}
387
388#[cfg(test)]
389mod tests {
390 use super::*;
391 use chrono::NaiveDate;
392 use datasynth_core::models::documents::{
393 GoodsReceiptItem, MovementType, PurchaseOrderItem, VendorInvoiceItem,
394 };
395
396 fn create_test_po() -> PurchaseOrder {
397 let mut po = PurchaseOrder::new(
398 "PO-001".to_string(),
399 "1000",
400 "V-001",
401 2024,
402 1,
403 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
404 "JSMITH",
405 );
406
407 let item1 = PurchaseOrderItem::new(
408 10,
409 "Material A",
410 Decimal::from(100),
411 Decimal::from(50), );
413
414 let item2 = PurchaseOrderItem::new(
415 20,
416 "Material B",
417 Decimal::from(200),
418 Decimal::from(25), );
420
421 po.add_item(item1);
422 po.add_item(item2);
423 po
424 }
425
426 fn create_matching_gr(po: &PurchaseOrder) -> GoodsReceipt {
427 let mut gr = GoodsReceipt::from_purchase_order(
428 "GR-001".to_string(),
429 "1000",
430 &po.header.document_id,
431 "V-001",
432 "P1000",
433 "0001",
434 2024,
435 1,
436 NaiveDate::from_ymd_opt(2024, 1, 20).unwrap(),
437 "JSMITH",
438 );
439
440 for po_item in &po.items {
442 let item = GoodsReceiptItem::from_po(
443 po_item.base.line_number,
444 &po_item.base.description,
445 po_item.base.quantity,
446 po_item.base.unit_price,
447 &po.header.document_id,
448 po_item.base.line_number,
449 )
450 .with_movement_type(MovementType::GrForPo);
451
452 gr.add_item(item);
453 }
454
455 gr
456 }
457
458 fn create_matching_invoice(po: &PurchaseOrder, gr: &GoodsReceipt) -> VendorInvoice {
459 let mut invoice = VendorInvoice::new(
460 "VI-001".to_string(),
461 "1000",
462 "V-001",
463 "INV-001".to_string(),
464 2024,
465 1,
466 NaiveDate::from_ymd_opt(2024, 1, 25).unwrap(),
467 "JSMITH",
468 );
469
470 for po_item in &po.items {
472 let item = VendorInvoiceItem::from_po_gr(
473 po_item.base.line_number,
474 &po_item.base.description,
475 po_item.base.quantity,
476 po_item.base.unit_price,
477 &po.header.document_id,
478 po_item.base.line_number,
479 Some(gr.header.document_id.clone()),
480 Some(po_item.base.line_number),
481 );
482
483 invoice.add_item(item);
484 }
485
486 invoice
487 }
488
489 #[test]
490 fn test_perfect_match() {
491 let po = create_test_po();
492 let gr = create_matching_gr(&po);
493 let invoice = create_matching_invoice(&po, &gr);
494
495 let matcher = ThreeWayMatcher::new();
496 let result = matcher.validate(&po, &[&gr], &invoice);
497
498 assert!(result.passed, "Perfect match should pass");
499 assert!(result.variances.is_empty(), "Should have no variances");
500 }
501
502 #[test]
503 fn test_price_variance() {
504 let po = create_test_po();
505 let gr = create_matching_gr(&po);
506 let mut invoice = create_matching_invoice(&po, &gr);
507
508 for item in &mut invoice.items {
510 item.base.unit_price *= dec!(1.10);
511 }
512 invoice.recalculate_totals();
513
514 let matcher = ThreeWayMatcher::new();
515 let result = matcher.validate(&po, &[&gr], &invoice);
516
517 assert!(!result.passed, "Price variance should fail");
518 assert!(!result.price_matched, "Price should not match");
519 assert!(
520 result
521 .variances
522 .iter()
523 .any(|v| v.variance_type == VarianceType::PricePoInvoice),
524 "Should have price variance"
525 );
526 }
527
528 #[test]
529 fn test_quantity_under_delivery() {
530 let po = create_test_po();
531 let mut gr = create_matching_gr(&po);
532
533 for item in &mut gr.items {
535 item.base.quantity *= dec!(0.80);
536 }
537
538 let invoice = create_matching_invoice(&po, &gr);
539
540 let matcher = ThreeWayMatcher::new();
541 let result = matcher.validate(&po, &[&gr], &invoice);
542
543 assert!(!result.passed, "Under-delivery should fail");
544 assert!(!result.quantity_matched, "Quantity should not match");
545 }
546
547 #[test]
548 fn test_small_variance_within_tolerance() {
549 let po = create_test_po();
550 let gr = create_matching_gr(&po);
551 let mut invoice = create_matching_invoice(&po, &gr);
552
553 for item in &mut invoice.items {
555 item.base.unit_price *= dec!(1.01);
556 }
557 invoice.recalculate_totals();
558
559 let matcher = ThreeWayMatcher::new();
560 let result = matcher.validate(&po, &[&gr], &invoice);
561
562 assert!(result.passed, "Small variance within tolerance should pass");
563 }
564}