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)]
389#[allow(clippy::unwrap_used)]
390mod tests {
391    use super::*;
392    use chrono::NaiveDate;
393    use datasynth_core::models::documents::{
394        GoodsReceiptItem, MovementType, PurchaseOrderItem, VendorInvoiceItem,
395    };
396
397    fn create_test_po() -> PurchaseOrder {
398        let mut po = PurchaseOrder::new(
399            "PO-001".to_string(),
400            "1000",
401            "V-001",
402            2024,
403            1,
404            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
405            "JSMITH",
406        );
407
408        let item1 = PurchaseOrderItem::new(
409            10,
410            "Material A",
411            Decimal::from(100),
412            Decimal::from(50), // $50/unit
413        );
414
415        let item2 = PurchaseOrderItem::new(
416            20,
417            "Material B",
418            Decimal::from(200),
419            Decimal::from(25), // $25/unit
420        );
421
422        po.add_item(item1);
423        po.add_item(item2);
424        po
425    }
426
427    fn create_matching_gr(po: &PurchaseOrder) -> GoodsReceipt {
428        let mut gr = GoodsReceipt::from_purchase_order(
429            "GR-001".to_string(),
430            "1000",
431            &po.header.document_id,
432            "V-001",
433            "P1000",
434            "0001",
435            2024,
436            1,
437            NaiveDate::from_ymd_opt(2024, 1, 20).unwrap(),
438            "JSMITH",
439        );
440
441        // Match PO quantities exactly
442        for po_item in &po.items {
443            let item = GoodsReceiptItem::from_po(
444                po_item.base.line_number,
445                &po_item.base.description,
446                po_item.base.quantity,
447                po_item.base.unit_price,
448                &po.header.document_id,
449                po_item.base.line_number,
450            )
451            .with_movement_type(MovementType::GrForPo);
452
453            gr.add_item(item);
454        }
455
456        gr
457    }
458
459    fn create_matching_invoice(po: &PurchaseOrder, gr: &GoodsReceipt) -> VendorInvoice {
460        let mut invoice = VendorInvoice::new(
461            "VI-001".to_string(),
462            "1000",
463            "V-001",
464            "INV-001".to_string(),
465            2024,
466            1,
467            NaiveDate::from_ymd_opt(2024, 1, 25).unwrap(),
468            "JSMITH",
469        );
470
471        // Match PO/GR exactly
472        for po_item in &po.items {
473            let item = VendorInvoiceItem::from_po_gr(
474                po_item.base.line_number,
475                &po_item.base.description,
476                po_item.base.quantity,
477                po_item.base.unit_price,
478                &po.header.document_id,
479                po_item.base.line_number,
480                Some(gr.header.document_id.clone()),
481                Some(po_item.base.line_number),
482            );
483
484            invoice.add_item(item);
485        }
486
487        invoice
488    }
489
490    #[test]
491    fn test_perfect_match() {
492        let po = create_test_po();
493        let gr = create_matching_gr(&po);
494        let invoice = create_matching_invoice(&po, &gr);
495
496        let matcher = ThreeWayMatcher::new();
497        let result = matcher.validate(&po, &[&gr], &invoice);
498
499        assert!(result.passed, "Perfect match should pass");
500        assert!(result.variances.is_empty(), "Should have no variances");
501    }
502
503    #[test]
504    fn test_price_variance() {
505        let po = create_test_po();
506        let gr = create_matching_gr(&po);
507        let mut invoice = create_matching_invoice(&po, &gr);
508
509        // Increase invoice price by 10%
510        for item in &mut invoice.items {
511            item.base.unit_price *= dec!(1.10);
512        }
513        invoice.recalculate_totals();
514
515        let matcher = ThreeWayMatcher::new();
516        let result = matcher.validate(&po, &[&gr], &invoice);
517
518        assert!(!result.passed, "Price variance should fail");
519        assert!(!result.price_matched, "Price should not match");
520        assert!(
521            result
522                .variances
523                .iter()
524                .any(|v| v.variance_type == VarianceType::PricePoInvoice),
525            "Should have price variance"
526        );
527    }
528
529    #[test]
530    fn test_quantity_under_delivery() {
531        let po = create_test_po();
532        let mut gr = create_matching_gr(&po);
533
534        // Reduce GR quantity by 20%
535        for item in &mut gr.items {
536            item.base.quantity *= dec!(0.80);
537        }
538
539        let invoice = create_matching_invoice(&po, &gr);
540
541        let matcher = ThreeWayMatcher::new();
542        let result = matcher.validate(&po, &[&gr], &invoice);
543
544        assert!(!result.passed, "Under-delivery should fail");
545        assert!(!result.quantity_matched, "Quantity should not match");
546    }
547
548    #[test]
549    fn test_small_variance_within_tolerance() {
550        let po = create_test_po();
551        let gr = create_matching_gr(&po);
552        let mut invoice = create_matching_invoice(&po, &gr);
553
554        // Increase invoice price by 1% (within 5% tolerance)
555        for item in &mut invoice.items {
556            item.base.unit_price *= dec!(1.01);
557        }
558        invoice.recalculate_totals();
559
560        let matcher = ThreeWayMatcher::new();
561        let result = matcher.validate(&po, &[&gr], &invoice);
562
563        assert!(result.passed, "Small variance within tolerance should pass");
564    }
565}