From 3993f5891052c6740a4f51e095d3ca2c47079c34 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 19 Apr 2026 11:31:40 -0400 Subject: [PATCH] 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 --- .../services/tools/bank_reconciliation.py | 171 ++++++++++++++++++ fusion_accounting_bank_rec/tests/__init__.py | 1 + .../tests/test_bank_rec_tools.py | 84 +++++++++ 3 files changed, 256 insertions(+) create mode 100644 fusion_accounting_bank_rec/tests/test_bank_rec_tools.py diff --git a/fusion_accounting_ai/services/tools/bank_reconciliation.py b/fusion_accounting_ai/services/tools/bank_reconciliation.py index 7c1b0a5b..e98a3726 100644 --- a/fusion_accounting_ai/services/tools/bank_reconciliation.py +++ b/fusion_accounting_ai/services/tools/bank_reconciliation.py @@ -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, } diff --git a/fusion_accounting_bank_rec/tests/__init__.py b/fusion_accounting_bank_rec/tests/__init__.py index 40eff00b..af22688d 100644 --- a/fusion_accounting_bank_rec/tests/__init__.py +++ b/fusion_accounting_bank_rec/tests/__init__.py @@ -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 diff --git a/fusion_accounting_bank_rec/tests/test_bank_rec_tools.py b/fusion_accounting_bank_rec/tests/test_bank_rec_tools.py new file mode 100644 index 00000000..3c4bb6ce --- /dev/null +++ b/fusion_accounting_bank_rec/tests/test_bank_rec_tools.py @@ -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)