92 lines
3.1 KiB
Python
92 lines
3.1 KiB
Python
"""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')
|