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