feat(fusion_accounting_bank_rec): pattern_extractor for per-partner aggregates
Made-with: Cursor
This commit is contained in:
74
fusion_accounting_bank_rec/services/pattern_extractor.py
Normal file
74
fusion_accounting_bank_rec/services/pattern_extractor.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""Aggregate per-partner reconciliation patterns from precedent rows.
|
||||
|
||||
Computes typical amount range, cadence, preferred strategy, common memo
|
||||
tokens. Output is a dict suitable for create/write on fusion.reconcile.pattern.
|
||||
"""
|
||||
|
||||
from collections import Counter
|
||||
from statistics import median
|
||||
|
||||
|
||||
def extract_pattern_for_partner(env, *, company_id, partner_id) -> dict:
|
||||
"""Compute the pattern aggregate for one (company, partner) pair.
|
||||
|
||||
Returns vals dict suitable for env['fusion.reconcile.pattern'].create()."""
|
||||
Precedent = env['fusion.reconcile.precedent'].sudo()
|
||||
precedents = Precedent.search([
|
||||
('company_id', '=', company_id),
|
||||
('partner_id', '=', partner_id),
|
||||
], order='reconciled_at desc', limit=200)
|
||||
|
||||
if not precedents:
|
||||
return {
|
||||
'company_id': company_id,
|
||||
'partner_id': partner_id,
|
||||
'reconcile_count': 0,
|
||||
}
|
||||
|
||||
amounts = sorted(precedents.mapped('amount'))
|
||||
counts = precedents.mapped('matched_move_line_count')
|
||||
|
||||
single_count = sum(1 for c in counts if c == 1)
|
||||
multi_count = sum(1 for c in counts if c > 1)
|
||||
if multi_count > single_count:
|
||||
pref_strategy = 'multi_invoice'
|
||||
elif _amounts_concentrated(amounts):
|
||||
pref_strategy = 'exact_amount'
|
||||
else:
|
||||
pref_strategy = 'fifo'
|
||||
|
||||
reconcile_dates = sorted([p.reconciled_at for p in precedents if p.reconciled_at])
|
||||
if len(reconcile_dates) >= 2:
|
||||
deltas = [(reconcile_dates[i+1] - reconcile_dates[i]).days
|
||||
for i in range(len(reconcile_dates) - 1)]
|
||||
cadence = sum(deltas) / len(deltas) if deltas else 0.0
|
||||
else:
|
||||
cadence = 0.0
|
||||
|
||||
token_counter = Counter()
|
||||
for p in precedents:
|
||||
if p.memo_tokens:
|
||||
for tok in p.memo_tokens.split(','):
|
||||
token_counter[tok.strip()] += 1
|
||||
# Keep tokens appearing in >=30% of precedents (min floor of 2 occurrences)
|
||||
threshold = max(2, len(precedents) * 0.3)
|
||||
common_tokens = ','.join(t for t, c in token_counter.most_common() if c >= threshold)
|
||||
|
||||
return {
|
||||
'company_id': company_id,
|
||||
'partner_id': partner_id,
|
||||
'reconcile_count': len(precedents),
|
||||
'typical_amount_range': f"${min(amounts):,.2f} – ${max(amounts):,.2f} (median ${median(amounts):,.2f})",
|
||||
'typical_cadence_days': round(cadence, 1),
|
||||
'pref_strategy': pref_strategy,
|
||||
'common_memo_tokens': common_tokens,
|
||||
}
|
||||
|
||||
|
||||
def _amounts_concentrated(amounts: list[float]) -> bool:
|
||||
"""True if amounts cluster around a few values (suggests exact-amount strategy)."""
|
||||
if len(amounts) < 3:
|
||||
return True
|
||||
med = median(amounts)
|
||||
within_5pct = sum(1 for a in amounts if abs(a - med) / max(med, 1) < 0.05)
|
||||
return within_5pct / len(amounts) >= 0.6
|
||||
Reference in New Issue
Block a user