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!(
207                        "Under-delivery: received {} vs ordered {}",
208                        gr_qty, po_qty
209                    ),
210                });
211            }
212
213            // Check over-delivery
214            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            // Find matching invoice line
234            let invoice_item = invoice.items.iter().find(|i| i.po_item == Some(po_line));
235
236            if let Some(inv_item) = invoice_item {
237                // Check price variance
238                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                // Check quantity on invoice vs GR
264                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                // Missing invoice line for this PO line
291                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        // Check total amounts
305        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        // Update result status
333        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    /// Quick check if quantities match between PO and GRs.
347    pub fn check_quantities(&self, po: &PurchaseOrder, grs: &[&GoodsReceipt]) -> bool {
348        // Aggregate GR quantities by PO line
349        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        // Check each PO line
360        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), // $50/unit
412        );
413
414        let item2 = PurchaseOrderItem::new(
415            20,
416            "Material B",
417            Decimal::from(200),
418            Decimal::from(25), // $25/unit
419        );
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        // Match PO quantities exactly
441        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        // Match PO/GR exactly
471        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        // Increase invoice price by 10%
509        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        // Reduce GR quantity by 20%
534        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        // Increase invoice price by 1% (within 5% tolerance)
554        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}