diff --git a/fusion_accounting_bank_rec/services/__init__.py b/fusion_accounting_bank_rec/services/__init__.py index aea16c90..a08ba335 100644 --- a/fusion_accounting_bank_rec/services/__init__.py +++ b/fusion_accounting_bank_rec/services/__init__.py @@ -1,2 +1,3 @@ from . import memo_tokenizer from . import exchange_diff +from . import matching_strategies diff --git a/fusion_accounting_bank_rec/services/matching_strategies.py b/fusion_accounting_bank_rec/services/matching_strategies.py new file mode 100644 index 00000000..56e30f3a --- /dev/null +++ b/fusion_accounting_bank_rec/services/matching_strategies.py @@ -0,0 +1,91 @@ +"""Matching strategy classes for the reconcile engine. + +Each strategy takes a bank amount + list of candidate journal items +and returns a MatchResult with the picked ids + confidence + residual. +Strategies are pure Python; no ORM dependency. +""" + +from dataclasses import dataclass, field +from itertools import combinations + + +@dataclass +class Candidate: + id: int + amount: float + partner_id: int + age_days: int + + +@dataclass +class MatchResult: + picked_ids: list[int] = field(default_factory=list) + confidence: float = 0.0 + residual: float = 0.0 # bank_amount - sum(picked); positive = under-allocated + strategy_name: str = "" + + +AMOUNT_TOLERANCE = 0.005 # currency rounding tolerance + + +class AmountExactStrategy: + """Pick a single candidate whose amount equals the bank amount exactly. + If multiple candidates match exactly, pick the oldest (FIFO tiebreaker).""" + + def match(self, *, bank_amount: float, candidates: list[Candidate]) -> MatchResult: + exact = [c for c in candidates if abs(c.amount - bank_amount) < AMOUNT_TOLERANCE] + if not exact: + return MatchResult(strategy_name='amount_exact') + oldest = max(exact, key=lambda c: c.age_days) + return MatchResult( + picked_ids=[oldest.id], + confidence=1.0, + residual=0.0, + strategy_name='amount_exact', + ) + + +class FIFOStrategy: + """Pick oldest candidates first until the bank amount is exhausted. + May produce partial reconcile residual if last candidate doesn't fit exactly.""" + + def match(self, *, bank_amount: float, candidates: list[Candidate]) -> MatchResult: + if not candidates: + return MatchResult(strategy_name='fifo') + oldest_first = sorted(candidates, key=lambda c: -c.age_days) + picked = [] + remaining = bank_amount + for c in oldest_first: + if remaining <= AMOUNT_TOLERANCE: + break + picked.append(c.id) + remaining -= c.amount + + confidence = 0.7 if remaining < AMOUNT_TOLERANCE else 0.5 + return MatchResult( + picked_ids=picked, + confidence=confidence, + residual=remaining, + strategy_name='fifo', + ) + + +class MultiInvoiceStrategy: + """Find the smallest combination of candidates summing to the bank amount. + Bounded by max_combinations to keep complexity manageable.""" + + def __init__(self, max_combinations=3): + self.max_combinations = max_combinations + + def match(self, *, bank_amount: float, candidates: list[Candidate]) -> MatchResult: + for k in range(2, self.max_combinations + 1): + for combo in combinations(candidates, k): + total = sum(c.amount for c in combo) + if abs(total - bank_amount) < AMOUNT_TOLERANCE: + return MatchResult( + picked_ids=[c.id for c in combo], + confidence=0.85, + residual=0.0, + strategy_name=f'multi_invoice_{k}', + ) + return MatchResult(strategy_name='multi_invoice') diff --git a/fusion_accounting_bank_rec/tests/__init__.py b/fusion_accounting_bank_rec/tests/__init__.py index 7ef65365..223da87a 100644 --- a/fusion_accounting_bank_rec/tests/__init__.py +++ b/fusion_accounting_bank_rec/tests/__init__.py @@ -1,2 +1,3 @@ from . import test_memo_tokenizer from . import test_exchange_diff +from . import test_matching_strategies diff --git a/fusion_accounting_bank_rec/tests/test_matching_strategies.py b/fusion_accounting_bank_rec/tests/test_matching_strategies.py new file mode 100644 index 00000000..727738dc --- /dev/null +++ b/fusion_accounting_bank_rec/tests/test_matching_strategies.py @@ -0,0 +1,111 @@ +from odoo.tests.common import TransactionCase, tagged +from odoo.addons.fusion_accounting_bank_rec.services.matching_strategies import ( + Candidate, AmountExactStrategy, FIFOStrategy, MultiInvoiceStrategy, MatchResult, +) + + +@tagged('post_install', '-at_install') +class TestAmountExactStrategy(TransactionCase): + + def test_picks_exact_amount(self): + candidates = [ + Candidate(id=1, amount=99.99, partner_id=42, age_days=10), + Candidate(id=2, amount=100.00, partner_id=42, age_days=20), + Candidate(id=3, amount=100.50, partner_id=42, age_days=5), + ] + result = AmountExactStrategy().match(bank_amount=100.00, candidates=candidates) + self.assertEqual(result.picked_ids, [2]) + self.assertEqual(result.confidence, 1.0) + + def test_no_match_when_no_exact(self): + candidates = [ + Candidate(id=1, amount=99.99, partner_id=42, age_days=10), + Candidate(id=2, amount=100.50, partner_id=42, age_days=20), + ] + result = AmountExactStrategy().match(bank_amount=100.00, candidates=candidates) + self.assertEqual(result.picked_ids, []) + + def test_picks_oldest_when_multiple_exact(self): + candidates = [ + Candidate(id=1, amount=100.00, partner_id=42, age_days=10), + Candidate(id=2, amount=100.00, partner_id=42, age_days=30), # oldest + Candidate(id=3, amount=100.00, partner_id=42, age_days=20), + ] + result = AmountExactStrategy().match(bank_amount=100.00, candidates=candidates) + self.assertEqual(result.picked_ids, [2]) + + def test_handles_empty_candidates(self): + result = AmountExactStrategy().match(bank_amount=100.00, candidates=[]) + self.assertEqual(result.picked_ids, []) + + +@tagged('post_install', '-at_install') +class TestFIFOStrategy(TransactionCase): + + def test_picks_oldest_first(self): + candidates = [ + Candidate(id=1, amount=50.00, partner_id=42, age_days=10), + Candidate(id=2, amount=50.00, partner_id=42, age_days=30), + Candidate(id=3, amount=50.00, partner_id=42, age_days=20), + ] + result = FIFOStrategy().match(bank_amount=100.00, candidates=candidates) + self.assertEqual(result.picked_ids, [2, 3]) # oldest two summing to 100 + + def test_handles_partial_payment(self): + candidates = [ + Candidate(id=1, amount=200.00, partner_id=42, age_days=30), + ] + result = FIFOStrategy().match(bank_amount=100.00, candidates=candidates) + self.assertEqual(result.picked_ids, [1]) # partial reconcile signaled by residual + self.assertEqual(result.residual, -100.00) # over-allocated; engine handles + + def test_handles_empty_candidates(self): + result = FIFOStrategy().match(bank_amount=100.00, candidates=[]) + self.assertEqual(result.picked_ids, []) + + +@tagged('post_install', '-at_install') +class TestMultiInvoiceStrategy(TransactionCase): + + def test_finds_smallest_set_summing_to_amount(self): + candidates = [ + Candidate(id=1, amount=30.00, partner_id=42, age_days=10), + Candidate(id=2, amount=40.00, partner_id=42, age_days=15), + Candidate(id=3, amount=30.00, partner_id=42, age_days=20), + Candidate(id=4, amount=70.00, partner_id=42, age_days=25), + ] + result = MultiInvoiceStrategy(max_combinations=3).match( + bank_amount=100.00, candidates=candidates) + # Two-element solutions exist (e.g., {3,4}=100). Strategy should pick a 2-set. + self.assertEqual(len(result.picked_ids), 2) + # The picked set should sum to 100 + picked_amounts = [c.amount for c in candidates if c.id in result.picked_ids] + self.assertAlmostEqual(sum(picked_amounts), 100.00, places=2) + + def test_returns_empty_when_no_combination_sums(self): + candidates = [ + Candidate(id=1, amount=15.00, partner_id=42, age_days=10), + Candidate(id=2, amount=25.00, partner_id=42, age_days=15), + ] + result = MultiInvoiceStrategy(max_combinations=3).match( + bank_amount=100.00, candidates=candidates) + self.assertEqual(result.picked_ids, []) + + def test_respects_max_combinations(self): + # Many small invoices — only combinations of ≤3 items considered + candidates = [Candidate(id=i, amount=10.00, partner_id=42, age_days=i) + for i in range(1, 11)] + result = MultiInvoiceStrategy(max_combinations=3).match( + bank_amount=100.00, candidates=candidates) + # Can't make 100 with ≤3 items of $10 each + self.assertEqual(result.picked_ids, []) + + def test_strategy_name_includes_combination_size(self): + candidates = [ + Candidate(id=1, amount=50.00, partner_id=42, age_days=10), + Candidate(id=2, amount=50.00, partner_id=42, age_days=20), + ] + result = MultiInvoiceStrategy(max_combinations=3).match( + bank_amount=100.00, candidates=candidates) + self.assertEqual(set(result.picked_ids), {1, 2}) + self.assertIn('multi_invoice', result.strategy_name)