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 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')
|
||||
Reference in New Issue
Block a user