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!(
207 "Under-delivery: received {} vs ordered {}",
208 gr_qty, po_qty
209 ),
210 });
211 }
212
213 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 let invoice_item = invoice.items.iter().find(|i| i.po_item == Some(po_line));
235
236 if let Some(inv_item) = invoice_item {
237 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 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 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 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 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 pub fn check_quantities(&self, po: &PurchaseOrder, grs: &[&GoodsReceipt]) -> bool {
348 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 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), );
414
415 let item2 = PurchaseOrderItem::new(
416 20,
417 "Material B",
418 Decimal::from(200),
419 Decimal::from(25), );
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 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 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 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 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 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}