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!("Under-delivery: received {gr_qty} vs ordered {po_qty}"),
207 });
208 }
209
210 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 let invoice_item = invoice.items.iter().find(|i| i.po_item == Some(po_line));
229
230 if let Some(inv_item) = invoice_item {
231 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 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 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 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 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 pub fn check_quantities(&self, po: &PurchaseOrder, grs: &[&GoodsReceipt]) -> bool {
338 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 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), );
404
405 let item2 = PurchaseOrderItem::new(
406 20,
407 "Material B",
408 Decimal::from(200),
409 Decimal::from(25), );
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 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 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 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 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 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}