feat(fusion_accounting_ai): 5 new bank-rec AI tools wrapping engine
Adds fusion_suggest_matches, fusion_accept_suggestion, fusion_reconcile_bank_line, fusion_unreconcile, and fusion_get_pending_suggestions. All route through the BankRecAdapter (or direct engine for ones the adapter doesn't expose), giving the AI chat the same reconciliation surface a human operator gets in the OWL UI. Made-with: Cursor
This commit is contained in:
@@ -946,6 +946,171 @@ def _format_aml_candidates(amls):
|
||||
} for aml in amls]
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Phase 1 Bank Reconciliation: engine-backed tools
|
||||
#
|
||||
# These five tools wrap the fusion.reconcile.engine 6-method API via the
|
||||
# bank_rec data adapter (or the engine directly when the adapter does not
|
||||
# expose a wrapper). They give the AI chat the same reconciliation surface
|
||||
# a human gets in the OWL bank-rec UI.
|
||||
# ============================================================
|
||||
|
||||
|
||||
def fusion_suggest_matches(env, params):
|
||||
"""Compute and persist AI suggestions for one or more bank statement lines.
|
||||
|
||||
Wraps ``BankRecAdapter.suggest_matches`` -> ``fusion.reconcile.engine``.
|
||||
"""
|
||||
raw_ids = params.get('statement_line_ids')
|
||||
if not raw_ids:
|
||||
return {'error': 'statement_line_ids is required'}
|
||||
statement_line_ids = [int(x) for x in raw_ids]
|
||||
limit_per_line = int(params.get('limit_per_line', 3))
|
||||
|
||||
from ..data_adapters import get_adapter
|
||||
adapter = get_adapter(env, 'bank_rec')
|
||||
raw = adapter.suggest_matches(
|
||||
statement_line_ids=statement_line_ids,
|
||||
limit_per_line=limit_per_line,
|
||||
company_id=env.company.id,
|
||||
) or {}
|
||||
|
||||
suggestions = {}
|
||||
total = 0
|
||||
for line_id, sug_list in raw.items():
|
||||
out = []
|
||||
for s in sug_list:
|
||||
out.append({
|
||||
'suggestion_id': s.get('id'),
|
||||
'candidate_id': s.get('candidate_id'),
|
||||
'confidence': s.get('confidence'),
|
||||
'reasoning': s.get('reasoning') or '',
|
||||
'rank': s.get('rank'),
|
||||
})
|
||||
total += 1
|
||||
suggestions[line_id] = out
|
||||
return {'suggestions': suggestions, 'count': total}
|
||||
|
||||
|
||||
def fusion_accept_suggestion(env, params):
|
||||
"""Accept a fusion.reconcile.suggestion: reconciles the bank line against
|
||||
the suggestion's proposed move lines and marks the suggestion accepted.
|
||||
|
||||
Wraps ``BankRecAdapter.accept_suggestion``.
|
||||
"""
|
||||
if not params.get('suggestion_id'):
|
||||
return {'error': 'suggestion_id is required'}
|
||||
suggestion_id = int(params['suggestion_id'])
|
||||
suggestion = env['fusion.reconcile.suggestion'].browse(suggestion_id)
|
||||
if not suggestion.exists():
|
||||
return {'error': 'Suggestion not found'}
|
||||
|
||||
from ..data_adapters import get_adapter
|
||||
adapter = get_adapter(env, 'bank_rec')
|
||||
result = adapter.accept_suggestion(suggestion_id) or {}
|
||||
statement_line = suggestion.statement_line_id
|
||||
return {
|
||||
'status': 'accepted',
|
||||
'suggestion_id': suggestion_id,
|
||||
'partial_ids': list(result.get('partial_ids') or []),
|
||||
'is_reconciled': bool(statement_line.is_reconciled),
|
||||
}
|
||||
|
||||
|
||||
def fusion_reconcile_bank_line(env, params):
|
||||
"""Manually reconcile a bank statement line against a set of journal items.
|
||||
|
||||
Routes through ``fusion.reconcile.engine.reconcile_one`` so behaviour
|
||||
matches the OWL widget and ``fusion_accept_suggestion``. Use this for
|
||||
direct AI-initiated matches that did not come from an AI suggestion.
|
||||
"""
|
||||
if not params.get('statement_line_id'):
|
||||
return {'error': 'statement_line_id is required'}
|
||||
raw_against = params.get('against_move_line_ids')
|
||||
if not raw_against:
|
||||
return {'error': 'against_move_line_ids is required'}
|
||||
|
||||
st_line_id = int(params['statement_line_id'])
|
||||
aml_ids = [int(x) for x in raw_against]
|
||||
statement_line = env['account.bank.statement.line'].browse(st_line_id)
|
||||
if not statement_line.exists():
|
||||
return {'error': 'Statement line not found'}
|
||||
against_lines = env['account.move.line'].browse(aml_ids).exists()
|
||||
if not against_lines:
|
||||
return {'error': 'No valid against_move_line_ids'}
|
||||
|
||||
result = env['fusion.reconcile.engine'].reconcile_one(
|
||||
statement_line, against_lines=against_lines)
|
||||
return {
|
||||
'status': 'reconciled',
|
||||
'statement_line_id': st_line_id,
|
||||
'partial_ids': list(result.get('partial_ids') or []),
|
||||
'is_reconciled': bool(statement_line.is_reconciled),
|
||||
}
|
||||
|
||||
|
||||
def fusion_unreconcile(env, params):
|
||||
"""Reverse a reconciliation by partial_reconcile_ids.
|
||||
|
||||
Wraps ``BankRecAdapter.unreconcile``. Works in fusion, Enterprise, and
|
||||
Community installs (the adapter falls back to a standalone path when
|
||||
fusion_accounting_bank_rec is not loaded).
|
||||
"""
|
||||
raw_ids = params.get('partial_reconcile_ids')
|
||||
if not raw_ids:
|
||||
return {'error': 'partial_reconcile_ids is required'}
|
||||
partial_ids = [int(x) for x in raw_ids]
|
||||
|
||||
from ..data_adapters import get_adapter
|
||||
adapter = get_adapter(env, 'bank_rec')
|
||||
result = adapter.unreconcile(partial_ids) or {}
|
||||
unreconciled_line_ids = list(result.get('unreconciled_line_ids') or [])
|
||||
return {
|
||||
'status': 'unreconciled',
|
||||
'unreconciled_line_ids': unreconciled_line_ids,
|
||||
'count': len(unreconciled_line_ids),
|
||||
}
|
||||
|
||||
|
||||
def fusion_get_pending_suggestions(env, params):
|
||||
"""List pending fusion.reconcile.suggestion rows.
|
||||
|
||||
Optional filters: ``statement_line_id``, ``min_confidence`` (default 0.0),
|
||||
``limit`` (default 50). Only returns suggestions in the ``pending`` state
|
||||
for the current company.
|
||||
"""
|
||||
domain = [
|
||||
('company_id', '=', env.company.id),
|
||||
('state', '=', 'pending'),
|
||||
]
|
||||
if params.get('statement_line_id'):
|
||||
domain.append(
|
||||
('statement_line_id', '=', int(params['statement_line_id'])))
|
||||
min_confidence = float(params.get('min_confidence') or 0.0)
|
||||
if min_confidence > 0.0:
|
||||
domain.append(('confidence', '>=', min_confidence))
|
||||
limit = int(params.get('limit', 50))
|
||||
|
||||
Suggestion = env['fusion.reconcile.suggestion'].sudo()
|
||||
records = Suggestion.search(
|
||||
domain, limit=limit, order='confidence desc, id desc')
|
||||
rows = []
|
||||
for s in records:
|
||||
st_line = s.statement_line_id
|
||||
rows.append({
|
||||
'id': s.id,
|
||||
'statement_line_id': st_line.id if st_line else None,
|
||||
'statement_line_ref': (
|
||||
st_line.payment_ref or '' if st_line else ''),
|
||||
'candidate_ids': s.proposed_move_line_ids.ids,
|
||||
'confidence': s.confidence,
|
||||
'rank': s.rank,
|
||||
'reasoning': s.reasoning or '',
|
||||
'state': s.state,
|
||||
})
|
||||
return {'count': len(rows), 'suggestions': rows}
|
||||
|
||||
|
||||
TOOLS = {
|
||||
'get_unreconciled_bank_lines': get_unreconciled_bank_lines,
|
||||
'get_unreconciled_receipts': get_unreconciled_receipts,
|
||||
@@ -962,4 +1127,10 @@ TOOLS = {
|
||||
'reconcile_payroll_cheques': reconcile_payroll_cheques,
|
||||
'suggest_bank_line_matches': suggest_bank_line_matches,
|
||||
'search_matching_entries': search_matching_entries,
|
||||
# Phase 1 engine-backed tools
|
||||
'fusion_suggest_matches': fusion_suggest_matches,
|
||||
'fusion_accept_suggestion': fusion_accept_suggestion,
|
||||
'fusion_reconcile_bank_line': fusion_reconcile_bank_line,
|
||||
'fusion_unreconcile': fusion_unreconcile,
|
||||
'fusion_get_pending_suggestions': fusion_get_pending_suggestions,
|
||||
}
|
||||
|
||||
@@ -11,3 +11,4 @@ from . import test_factories
|
||||
from . import test_reconcile_engine_integration
|
||||
from . import test_bank_rec_prompt
|
||||
from . import test_bank_rec_adapter
|
||||
from . import test_bank_rec_tools
|
||||
|
||||
84
fusion_accounting_bank_rec/tests/test_bank_rec_tools.py
Normal file
84
fusion_accounting_bank_rec/tests/test_bank_rec_tools.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""Smoke tests for the 5 new fusion bank-rec AI tools."""
|
||||
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
from odoo.addons.fusion_accounting_ai.services.tools import bank_reconciliation as tools
|
||||
from . import _factories as f
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestFusionBankRecTools(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.partner = self.env['res.partner'].create({'name': 'Tools Test Partner'})
|
||||
|
||||
def test_fusion_suggest_matches_returns_suggestions(self):
|
||||
invoice = f.make_invoice(self.env, partner=self.partner, amount=550.00)
|
||||
bank_line = f.make_bank_line(
|
||||
self.env, amount=550.00, partner=self.partner, memo='Tool test')
|
||||
result = tools.fusion_suggest_matches(self.env, {
|
||||
'statement_line_ids': [bank_line.id],
|
||||
'limit_per_line': 3,
|
||||
})
|
||||
self.assertIn('suggestions', result)
|
||||
self.assertIn('count', result)
|
||||
self.assertGreater(result['count'], 0)
|
||||
|
||||
def test_fusion_accept_suggestion_reconciles(self):
|
||||
invoice = f.make_invoice(self.env, partner=self.partner, amount=625.00)
|
||||
recv_lines = invoice.line_ids.filtered(
|
||||
lambda l: l.account_id.account_type == 'asset_receivable')
|
||||
bank_line = f.make_bank_line(self.env, amount=625.00, partner=self.partner)
|
||||
sug = f.make_suggestion(
|
||||
self.env, statement_line=bank_line,
|
||||
candidate_move_lines=recv_lines, confidence=0.94)
|
||||
result = tools.fusion_accept_suggestion(self.env, {'suggestion_id': sug.id})
|
||||
self.assertEqual(result['status'], 'accepted')
|
||||
self.assertGreater(len(result['partial_ids']), 0)
|
||||
|
||||
def test_fusion_reconcile_bank_line(self):
|
||||
bank_line, recv_lines = f.make_reconcileable_pair(
|
||||
self.env, amount=375.00, partner=self.partner)
|
||||
result = tools.fusion_reconcile_bank_line(self.env, {
|
||||
'statement_line_id': bank_line.id,
|
||||
'against_move_line_ids': recv_lines.ids,
|
||||
})
|
||||
self.assertEqual(result['status'], 'reconciled')
|
||||
self.assertTrue(result['is_reconciled'])
|
||||
|
||||
def test_fusion_unreconcile(self):
|
||||
bank_line, recv_lines = f.make_reconcileable_pair(
|
||||
self.env, amount=275.00, partner=self.partner)
|
||||
rec = self.env['fusion.reconcile.engine'].reconcile_one(
|
||||
bank_line, against_lines=recv_lines)
|
||||
partial_ids = rec['partial_ids']
|
||||
result = tools.fusion_unreconcile(self.env, {
|
||||
'partial_reconcile_ids': partial_ids,
|
||||
})
|
||||
self.assertEqual(result['status'], 'unreconciled')
|
||||
self.assertGreater(result['count'], 0)
|
||||
|
||||
def test_fusion_get_pending_suggestions(self):
|
||||
bank_line = f.make_bank_line(self.env, amount=100.00, partner=self.partner)
|
||||
sug = f.make_suggestion(
|
||||
self.env, statement_line=bank_line,
|
||||
candidate_move_lines=self.env['account.move.line'],
|
||||
confidence=0.88, state='pending')
|
||||
result = tools.fusion_get_pending_suggestions(self.env, {})
|
||||
self.assertIn('count', result)
|
||||
self.assertGreater(result['count'], 0)
|
||||
ids = [s['id'] for s in result['suggestions']]
|
||||
self.assertIn(sug.id, ids)
|
||||
|
||||
def test_fusion_get_pending_suggestions_filters_by_min_confidence(self):
|
||||
bank_line = f.make_bank_line(self.env, amount=100.00, partner=self.partner)
|
||||
# One low-confidence suggestion
|
||||
f.make_suggestion(self.env, statement_line=bank_line,
|
||||
confidence=0.30, state='pending')
|
||||
# One high-confidence
|
||||
high = f.make_suggestion(self.env, statement_line=bank_line,
|
||||
confidence=0.95, state='pending')
|
||||
result = tools.fusion_get_pending_suggestions(
|
||||
self.env, {'min_confidence': 0.80})
|
||||
ids = [s['id'] for s in result['suggestions']]
|
||||
self.assertIn(high.id, ids)
|
||||
Reference in New Issue
Block a user