Skip to main content

datasynth_generators/document_flow/
three_way_match.rs

1//! Three-way match validation for P2P document flows.
2//!
3//! This module implements proper validation of Purchase Order, Goods Receipt,
4//! and Vendor Invoice matching according to standard AP practices.
5
6use rust_decimal::Decimal;
7use rust_decimal_macros::dec;
8use serde::{Deserialize, Serialize};
9
10use datasynth_core::models::documents::{GoodsReceipt, PurchaseOrder, VendorInvoice};
11
12/// Configuration for three-way match validation.
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct ThreeWayMatchConfig {
15    /// Tolerance for price variance (as decimal percentage, e.g., 0.05 = 5%)
16    pub price_tolerance: Decimal,
17    /// Tolerance for quantity variance (as decimal percentage, e.g., 0.02 = 2%)
18    pub quantity_tolerance: Decimal,
19    /// Absolute tolerance for small amounts (to handle rounding)
20    pub absolute_amount_tolerance: Decimal,
21    /// Whether to allow over-delivery (GR quantity > PO quantity)
22    pub allow_over_delivery: bool,
23    /// Maximum over-delivery percentage allowed
24    pub max_over_delivery_pct: Decimal,
25}
26
27impl Default for ThreeWayMatchConfig {
28    fn default() -> Self {
29        Self {
30            price_tolerance: dec!(0.05),           // 5% price variance allowed
31            quantity_tolerance: dec!(0.02),        // 2% quantity variance allowed
32            absolute_amount_tolerance: dec!(0.01), // $0.01 absolute tolerance
33            allow_over_delivery: true,
34            max_over_delivery_pct: dec!(0.10), // 10% over-delivery allowed
35        }
36    }
37}
38
39/// Result of three-way match validation.
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct ThreeWayMatchResult {
42    /// Overall match status
43    pub passed: bool,
44    /// Quantity match status
45    pub quantity_matched: bool,
46    /// Price match status
47    pub price_matched: bool,
48    /// Total amount match status
49    pub amount_matched: bool,
50    /// List of variances found
51    pub variances: Vec<MatchVariance>,
52    /// Summary message
53    pub message: String,
54}
55
56impl ThreeWayMatchResult {
57    /// Create a successful match result.
58    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    /// Create a failed match result with message.
70    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    /// Add a variance to the result.
82    pub fn with_variance(mut self, variance: MatchVariance) -> Self {
83        self.variances.push(variance);
84        self
85    }
86}
87
88/// A specific variance found during three-way match.
89#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct MatchVariance {
91    /// Line number/item affected
92    pub line_number: u16,
93    /// Type of variance
94    pub variance_type: VarianceType,
95    /// Expected value
96    pub expected: Decimal,
97    /// Actual value
98    pub actual: Decimal,
99    /// Variance amount
100    pub variance: Decimal,
101    /// Variance percentage
102    pub variance_pct: Decimal,
103    /// Description
104    pub description: String,
105}
106
107/// Type of variance in three-way match.
108#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
109#[serde(rename_all = "snake_case")]
110pub enum VarianceType {
111    /// Quantity variance between PO and GR
112    QuantityPoGr,
113    /// Quantity variance between GR and Invoice
114    QuantityGrInvoice,
115    /// Price variance between PO and Invoice
116    PricePoInvoice,
117    /// Total amount variance
118    TotalAmount,
119    /// Missing line item
120    MissingLine,
121    /// Extra line item
122    ExtraLine,
123}
124
125/// Three-way match validator.
126pub struct ThreeWayMatcher {
127    config: ThreeWayMatchConfig,
128}
129
130impl ThreeWayMatcher {
131    /// Create a new three-way matcher with default configuration.
132    pub fn new() -> Self {
133        Self {
134            config: ThreeWayMatchConfig::default(),
135        }
136    }
137
138    /// Create a three-way matcher with custom configuration.
139    pub fn with_config(config: ThreeWayMatchConfig) -> Self {
140        Self { config }
141    }
142
143    /// Validate three-way match between PO, GR, and Invoice.
144    ///
145    /// # Arguments
146    ///
147    /// * `po` - The purchase order
148    /// * `grs` - The goods receipts (may be multiple for partial deliveries)
149    /// * `invoice` - The vendor invoice
150    ///
151    /// # Returns
152    ///
153    /// `ThreeWayMatchResult` indicating whether the match passed and any variances found.
154    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        // Aggregate GR quantities by PO line
166        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        // Validate each PO line
177        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            // Check GR quantity vs PO quantity
183            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            // Check under-delivery
195            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!("Under-delivery: received {gr_qty} vs ordered {po_qty}"),
207                });
208            }
209
210            // Check over-delivery
211            if qty_variance > Decimal::ZERO
212                && (!self.config.allow_over_delivery
213                    || qty_variance_pct > self.config.max_over_delivery_pct * dec!(100))
214            {
215                all_quantity_matched = false;
216                result = result.with_variance(MatchVariance {
217                    line_number: po_line,
218                    variance_type: VarianceType::QuantityPoGr,
219                    expected: po_qty,
220                    actual: gr_qty,
221                    variance: qty_variance,
222                    variance_pct: qty_variance_pct,
223                    description: format!("Over-delivery: received {gr_qty} vs ordered {po_qty}"),
224                });
225            }
226
227            // Find matching invoice line
228            let invoice_item = invoice.items.iter().find(|i| i.po_item == Some(po_line));
229
230            if let Some(inv_item) = invoice_item {
231                // Check price variance
232                let price_variance = inv_item.base.unit_price - po_price;
233                let price_variance_pct = if po_price > Decimal::ZERO {
234                    (price_variance.abs() / po_price) * dec!(100)
235                } else {
236                    Decimal::ZERO
237                };
238
239                if price_variance_pct > self.config.price_tolerance * dec!(100)
240                    && price_variance.abs() > self.config.absolute_amount_tolerance
241                {
242                    all_price_matched = false;
243                    result = result.with_variance(MatchVariance {
244                        line_number: po_line,
245                        variance_type: VarianceType::PricePoInvoice,
246                        expected: po_price,
247                        actual: inv_item.base.unit_price,
248                        variance: price_variance,
249                        variance_pct: price_variance_pct,
250                        description: format!(
251                            "Price variance: invoiced {} vs PO price {}",
252                            inv_item.base.unit_price, po_price
253                        ),
254                    });
255                }
256
257                // Check quantity on invoice vs GR
258                let inv_qty = inv_item.invoiced_quantity;
259                let inv_gr_variance = inv_qty - gr_qty;
260                let inv_gr_variance_pct = if gr_qty > Decimal::ZERO {
261                    (inv_gr_variance.abs() / gr_qty) * dec!(100)
262                } else {
263                    Decimal::ZERO
264                };
265
266                if inv_gr_variance_pct > self.config.quantity_tolerance * dec!(100)
267                    && inv_gr_variance.abs() > self.config.absolute_amount_tolerance
268                {
269                    all_quantity_matched = false;
270                    result = result.with_variance(MatchVariance {
271                        line_number: po_line,
272                        variance_type: VarianceType::QuantityGrInvoice,
273                        expected: gr_qty,
274                        actual: inv_qty,
275                        variance: inv_gr_variance,
276                        variance_pct: inv_gr_variance_pct,
277                        description: format!("Invoice qty {inv_qty} doesn't match GR qty {gr_qty}"),
278                    });
279                }
280            } else {
281                // Missing invoice line for this PO line
282                result = result.with_variance(MatchVariance {
283                    line_number: po_line,
284                    variance_type: VarianceType::MissingLine,
285                    expected: po_qty,
286                    actual: Decimal::ZERO,
287                    variance: po_qty,
288                    variance_pct: dec!(100),
289                    description: format!("PO line {po_line} not found on invoice"),
290                });
291                all_amount_matched = false;
292            }
293        }
294
295        // Check total amounts
296        let po_total = po.total_net_amount;
297        let invoice_total = invoice.net_amount;
298        let total_variance = invoice_total - po_total;
299        let total_variance_pct = if po_total > Decimal::ZERO {
300            (total_variance.abs() / po_total) * dec!(100)
301        } else {
302            Decimal::ZERO
303        };
304
305        if total_variance.abs() > self.config.absolute_amount_tolerance
306            && total_variance_pct > self.config.price_tolerance * dec!(100)
307        {
308            all_amount_matched = false;
309            result = result.with_variance(MatchVariance {
310                line_number: 0,
311                variance_type: VarianceType::TotalAmount,
312                expected: po_total,
313                actual: invoice_total,
314                variance: total_variance,
315                variance_pct: total_variance_pct,
316                description: format!(
317                    "Total amount variance: invoice {invoice_total} vs PO {po_total}"
318                ),
319            });
320        }
321
322        // Update result status
323        result.quantity_matched = all_quantity_matched;
324        result.price_matched = all_price_matched;
325        result.amount_matched = all_amount_matched;
326        result.passed = all_quantity_matched && all_price_matched && all_amount_matched;
327
328        if !result.passed {
329            let issues = result.variances.len();
330            result.message = format!("Three-way match failed with {issues} variance(s)");
331        }
332
333        result
334    }
335
336    /// Quick check if quantities match between PO and GRs.
337    pub fn check_quantities(&self, po: &PurchaseOrder, grs: &[&GoodsReceipt]) -> bool {
338        // Aggregate GR quantities by PO line
339        let mut gr_quantities: std::collections::HashMap<u16, Decimal> =
340            std::collections::HashMap::new();
341        for gr in grs {
342            for item in &gr.items {
343                if let Some(po_line) = item.po_item {
344                    *gr_quantities.entry(po_line).or_insert(Decimal::ZERO) += item.base.quantity;
345                }
346            }
347        }
348
349        // Check each PO line
350        for po_item in &po.items {
351            let po_qty = po_item.base.quantity;
352            let gr_qty = gr_quantities
353                .get(&po_item.base.line_number)
354                .copied()
355                .unwrap_or(Decimal::ZERO);
356
357            let variance_pct = if po_qty > Decimal::ZERO {
358                ((gr_qty - po_qty).abs() / po_qty) * dec!(100)
359            } else {
360                Decimal::ZERO
361            };
362
363            if variance_pct > self.config.quantity_tolerance * dec!(100) {
364                return false;
365            }
366        }
367
368        true
369    }
370}
371
372impl Default for ThreeWayMatcher {
373    fn default() -> Self {
374        Self::new()
375    }
376}
377
378#[cfg(test)]
379mod tests {
380    use super::*;
381    use chrono::NaiveDate;
382    use datasynth_core::models::documents::{
383        GoodsReceiptItem, MovementType, PurchaseOrderItem, VendorInvoiceItem,
384    };
385
386    fn create_test_po() -> PurchaseOrder {
387        let mut po = PurchaseOrder::new(
388            "PO-001".to_string(),
389            "1000",
390            "V-001",
391            2024,
392            1,
393            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
394            "JSMITH",
395        );
396
397        let item1 = PurchaseOrderItem::new(
398            10,
399            "Material A",
400            Decimal::from(100),
401            Decimal::from(50), // $50/unit
402        );
403
404        let item2 = PurchaseOrderItem::new(
405            20,
406            "Material B",
407            Decimal::from(200),
408            Decimal::from(25), // $25/unit
409        );
410
411        po.add_item(item1);
412        po.add_item(item2);
413        po
414    }
415
416    fn create_matching_gr(po: &PurchaseOrder) -> GoodsReceipt {
417        let mut gr = GoodsReceipt::from_purchase_order(
418            "GR-001".to_string(),
419            "1000",
420            &po.header.document_id,
421            "V-001",
422            "P1000",
423            "0001",
424            2024,
425            1,
426            NaiveDate::from_ymd_opt(2024, 1, 20).unwrap(),
427            "JSMITH",
428        );
429
430        // Match PO quantities exactly
431        for po_item in &po.items {
432            let item = GoodsReceiptItem::from_po(
433                po_item.base.line_number,
434                &po_item.base.description,
435                po_item.base.quantity,
436                po_item.base.unit_price,
437                &po.header.document_id,
438                po_item.base.line_number,
439            )
440            .with_movement_type(MovementType::GrForPo);
441
442            gr.add_item(item);
443        }
444
445        gr
446    }
447
448    fn create_matching_invoice(po: &PurchaseOrder, gr: &GoodsReceipt) -> VendorInvoice {
449        let mut invoice = VendorInvoice::new(
450            "VI-001".to_string(),
451            "1000",
452            "V-001",
453            "INV-001".to_string(),
454            2024,
455            1,
456            NaiveDate::from_ymd_opt(2024, 1, 25).unwrap(),
457            "JSMITH",
458        );
459
460        // Match PO/GR exactly
461        for po_item in &po.items {
462            let item = VendorInvoiceItem::from_po_gr(
463                po_item.base.line_number,
464                &po_item.base.description,
465                po_item.base.quantity,
466                po_item.base.unit_price,
467                &po.header.document_id,
468                po_item.base.line_number,
469                Some(gr.header.document_id.clone()),
470                Some(po_item.base.line_number),
471            );
472
473            invoice.add_item(item);
474        }
475
476        invoice
477    }
478
479    #[test]
480    fn test_perfect_match() {
481        let po = create_test_po();
482        let gr = create_matching_gr(&po);
483        let invoice = create_matching_invoice(&po, &gr);
484
485        let matcher = ThreeWayMatcher::new();
486        let result = matcher.validate(&po, &[&gr], &invoice);
487
488        assert!(result.passed, "Perfect match should pass");
489        assert!(result.variances.is_empty(), "Should have no variances");
490    }
491
492    #[test]
493    fn test_price_variance() {
494        let po = create_test_po();
495        let gr = create_matching_gr(&po);
496        let mut invoice = create_matching_invoice(&po, &gr);
497
498        // Increase invoice price by 10%
499        for item in &mut invoice.items {
500            item.base.unit_price *= dec!(1.10);
501        }
502        invoice.recalculate_totals();
503
504        let matcher = ThreeWayMatcher::new();
505        let result = matcher.validate(&po, &[&gr], &invoice);
506
507        assert!(!result.passed, "Price variance should fail");
508        assert!(!result.price_matched, "Price should not match");
509        assert!(
510            result
511                .variances
512                .iter()
513                .any(|v| v.variance_type == VarianceType::PricePoInvoice),
514            "Should have price variance"
515        );
516    }
517
518    #[test]
519    fn test_quantity_under_delivery() {
520        let po = create_test_po();
521        let mut gr = create_matching_gr(&po);
522
523        // Reduce GR quantity by 20%
524        for item in &mut gr.items {
525            item.base.quantity *= dec!(0.80);
526        }
527
528        let invoice = create_matching_invoice(&po, &gr);
529
530        let matcher = ThreeWayMatcher::new();
531        let result = matcher.validate(&po, &[&gr], &invoice);
532
533        assert!(!result.passed, "Under-delivery should fail");
534        assert!(!result.quantity_matched, "Quantity should not match");
535    }
536
537    #[test]
538    fn test_small_variance_within_tolerance() {
539        let po = create_test_po();
540        let gr = create_matching_gr(&po);
541        let mut invoice = create_matching_invoice(&po, &gr);
542
543        // Increase invoice price by 1% (within 5% tolerance)
544        for item in &mut invoice.items {
545            item.base.unit_price *= dec!(1.01);
546        }
547        invoice.recalculate_totals();
548
549        let matcher = ThreeWayMatcher::new();
550        let result = matcher.validate(&po, &[&gr], &invoice);
551
552        assert!(result.passed, "Small variance within tolerance should pass");
553    }
554}