feat(fusion_accounting_bank_rec): matching strategies (AmountExact, FIFO, MultiInvoice)
Made-with: Cursor
This commit is contained in:
@@ -1,2 +1,3 @@
|
|||||||
from . import memo_tokenizer
|
from . import memo_tokenizer
|
||||||
from . import exchange_diff
|
from . import exchange_diff
|
||||||
|
from . import matching_strategies
|
||||||
|
|||||||
91
fusion_accounting_bank_rec/services/matching_strategies.py
Normal file
91
fusion_accounting_bank_rec/services/matching_strategies.py
Normal file
@@ -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')
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
from . import test_memo_tokenizer
|
from . import test_memo_tokenizer
|
||||||
from . import test_exchange_diff
|
from . import test_exchange_diff
|
||||||
|
from . import test_matching_strategies
|
||||||
|
|||||||
111
fusion_accounting_bank_rec/tests/test_matching_strategies.py
Normal file
111
fusion_accounting_bank_rec/tests/test_matching_strategies.py
Normal file
@@ -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)
|
||||||
Reference in New Issue
Block a user