From 8eee64f0531239e548c25e7cf7c6b2dea6c2d738 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 19 Apr 2026 11:25:41 -0400 Subject: [PATCH] feat(fusion_accounting_ai): wire BankRecAdapter fusion paths to engine Enhances list_unreconciled_via_fusion to include fusion fields (top_suggestion_id, confidence_band, attachment_count). Adds 3 new adapter methods that proxy the engine: suggest_matches, accept_suggestion, unreconcile. AI tools (Task 22+) and OWL controller (Task 26) will call these adapter methods instead of touching the engine directly. Made-with: Cursor --- .../services/data_adapters/bank_rec.py | 148 +++++++++++++++++- fusion_accounting_bank_rec/tests/__init__.py | 1 + .../tests/test_bank_rec_adapter.py | 81 ++++++++++ 3 files changed, 227 insertions(+), 3 deletions(-) create mode 100644 fusion_accounting_bank_rec/tests/test_bank_rec_adapter.py diff --git a/fusion_accounting_ai/services/data_adapters/bank_rec.py b/fusion_accounting_ai/services/data_adapters/bank_rec.py index 2f48be80..8e956d8a 100644 --- a/fusion_accounting_ai/services/data_adapters/bank_rec.py +++ b/fusion_accounting_ai/services/data_adapters/bank_rec.py @@ -4,6 +4,12 @@ Routes bank-rec data lookups across: - FUSION: fusion.bank.rec.widget (added by fusion_accounting_bank_rec, Phase 1) - ENTERPRISE: account_accountant's bank_rec_widget JS service - COMMUNITY: pure search on account.bank.statement.line + +In addition to ``list_unreconciled``, the adapter exposes thin wrappers +around the engine's public API: ``suggest_matches``, ``accept_suggestion``, +``unreconcile``. AI tools and the OWL controller go through these wrappers +instead of touching the engine directly so install-mode routing stays in +one place. """ from .base import DataAdapter @@ -14,6 +20,10 @@ class BankRecAdapter(DataAdapter): FUSION_MODEL = 'fusion.bank.rec.widget' ENTERPRISE_MODULE = 'account_accountant' + # ------------------------------------------------------------ + # list_unreconciled + # ------------------------------------------------------------ + def list_unreconciled(self, journal_id=None, limit=100, date_from=None, date_to=None, min_amount=None, company_id=None): """Return unreconciled bank statement lines. @@ -31,13 +41,29 @@ class BankRecAdapter(DataAdapter): def list_unreconciled_via_fusion(self, journal_id=None, limit=100, date_from=None, date_to=None, min_amount=None, company_id=None): - # Phase 1 will add fusion.bank.rec.widget; this method becomes the primary path. - # For now: even when the model exists, delegate to community read shape. - return self.list_unreconciled_via_community( + """Community shape + fusion AI fields (top suggestion, band, attachments).""" + base = self.list_unreconciled_via_community( journal_id=journal_id, limit=limit, date_from=date_from, date_to=date_to, min_amount=min_amount, company_id=company_id, ) + if not base: + return base + Line = self.env['account.bank.statement.line'].sudo() + ids = [row['id'] for row in base] + lines_by_id = {line.id: line for line in Line.browse(ids)} + for row in base: + line = lines_by_id.get(row['id']) + if not line: + row['fusion_top_suggestion_id'] = None + row['fusion_confidence_band'] = 'none' + row['attachment_count'] = 0 + continue + top = line.fusion_top_suggestion_id + row['fusion_top_suggestion_id'] = top.id if top else None + row['fusion_confidence_band'] = line.fusion_confidence_band or 'none' + row['attachment_count'] = len(line.bank_statement_attachment_ids) + return base def list_unreconciled_via_enterprise(self, journal_id=None, limit=100, date_from=None, date_to=None, @@ -83,5 +109,121 @@ class BankRecAdapter(DataAdapter): for r in records ] + # ------------------------------------------------------------ + # suggest_matches + # ------------------------------------------------------------ + + def suggest_matches(self, statement_line_ids, *, limit_per_line=3, + company_id=None): + """Return AI suggestions per bank line. + + Shape: ``{line_id: [{'id', 'rank', 'confidence', 'reasoning', + 'candidate_id'}, ...]}``. Empty dict when AI suggestions are not + available (Enterprise / Community). + """ + return self._dispatch( + 'suggest_matches', + statement_line_ids=statement_line_ids, + limit_per_line=limit_per_line, + company_id=company_id, + ) + + def suggest_matches_via_fusion(self, statement_line_ids, *, + limit_per_line=3, company_id=None): + Line = self.env['account.bank.statement.line'].sudo() + lines = Line.browse(list(statement_line_ids or [])).exists() + if not lines: + return {} + return self.env['fusion.reconcile.engine'].suggest_matches( + lines, limit_per_line=limit_per_line) + + def suggest_matches_via_enterprise(self, statement_line_ids, *, + limit_per_line=3, company_id=None): + # Enterprise has its own suggest mechanism inside bank_rec_widget; + # we don't proxy it from Python. + return {} + + def suggest_matches_via_community(self, statement_line_ids, *, + limit_per_line=3, company_id=None): + return {} + + # ------------------------------------------------------------ + # accept_suggestion + # ------------------------------------------------------------ + + def accept_suggestion(self, suggestion_id): + """Accept a fusion AI suggestion and reconcile against its proposal. + + Returns ``{'partial_ids': [...], 'exchange_diff_move_id': int|None, + 'write_off_move_id': int|None}``. Fusion-only. + """ + return self._dispatch( + 'accept_suggestion', suggestion_id=suggestion_id) + + def accept_suggestion_via_fusion(self, suggestion_id): + return self.env['fusion.reconcile.engine'].accept_suggestion( + int(suggestion_id)) + + def accept_suggestion_via_enterprise(self, suggestion_id): + raise NotImplementedError("accept_suggestion is fusion-only") + + def accept_suggestion_via_community(self, suggestion_id): + raise NotImplementedError("accept_suggestion is fusion-only") + + # ------------------------------------------------------------ + # unreconcile + # ------------------------------------------------------------ + + def unreconcile(self, partial_reconcile_ids): + """Reverse a reconciliation by partial IDs. + + Returns ``{'unreconciled_line_ids': [...]}``. Available in all modes + (the engine delegates to V19's standard + ``account.bank.statement.line.action_undo_reconciliation``). + """ + return self._dispatch( + 'unreconcile', partial_reconcile_ids=partial_reconcile_ids) + + def unreconcile_via_fusion(self, partial_reconcile_ids): + Partial = self.env['account.partial.reconcile'].sudo() + partials = Partial.browse(list(partial_reconcile_ids or [])).exists() + return self.env['fusion.reconcile.engine'].unreconcile(partials) + + def unreconcile_via_enterprise(self, partial_reconcile_ids): + # Enterprise/community paths can't depend on fusion.reconcile.engine + # being loaded (fusion_accounting_ai does NOT depend on + # fusion_accounting_bank_rec). Mirror the engine's behaviour using + # only Community-available helpers. + return self._unreconcile_standalone(partial_reconcile_ids) + + def unreconcile_via_community(self, partial_reconcile_ids): + return self._unreconcile_standalone(partial_reconcile_ids) + + def _unreconcile_standalone(self, partial_reconcile_ids): + """Engine-free unreconcile for installs without fusion_accounting_bank_rec. + + Mirrors ``fusion.reconcile.engine.unreconcile``: finds bank lines whose + moves own any of the partials' journal items, runs the standard undo + on them, then unlinks any leftovers. + """ + Partial = self.env['account.partial.reconcile'].sudo() + partials = Partial.browse(list(partial_reconcile_ids or [])).exists() + if not partials: + return {'unreconciled_line_ids': []} + all_lines = ( + partials.mapped('debit_move_id') + | partials.mapped('credit_move_id') + ) + line_ids = all_lines.ids + affected = self.env['account.bank.statement.line'].sudo().search([ + ('move_id', 'in', all_lines.mapped('move_id').ids), + ]) + if affected: + affected.action_undo_reconciliation() + remaining = partials.exists() + if remaining: + remaining.unlink() + return {'unreconciled_line_ids': line_ids} + register_adapter('bank_rec', BankRecAdapter) diff --git a/fusion_accounting_bank_rec/tests/__init__.py b/fusion_accounting_bank_rec/tests/__init__.py index 26b2e51d..40eff00b 100644 --- a/fusion_accounting_bank_rec/tests/__init__.py +++ b/fusion_accounting_bank_rec/tests/__init__.py @@ -10,3 +10,4 @@ from . import test_reconcile_engine_property from . import test_factories from . import test_reconcile_engine_integration from . import test_bank_rec_prompt +from . import test_bank_rec_adapter diff --git a/fusion_accounting_bank_rec/tests/test_bank_rec_adapter.py b/fusion_accounting_bank_rec/tests/test_bank_rec_adapter.py new file mode 100644 index 00000000..86406554 --- /dev/null +++ b/fusion_accounting_bank_rec/tests/test_bank_rec_adapter.py @@ -0,0 +1,81 @@ +"""Tests for BankRecAdapter's fusion paths.""" + +from odoo.tests.common import TransactionCase, tagged +from odoo.addons.fusion_accounting_ai.services.data_adapters.bank_rec import BankRecAdapter +from . import _factories as f + + +@tagged('post_install', '-at_install') +class TestBankRecAdapter(TransactionCase): + + def setUp(self): + super().setUp() + self.partner = self.env['res.partner'].create({'name': 'Adapter Test Partner'}) + self.adapter = BankRecAdapter(self.env) + + def test_list_unreconciled_via_fusion_returns_base_fields(self): + bank_line = f.make_bank_line( + self.env, amount=100.00, partner=self.partner, memo='Adapter base test') + result = self.adapter.list_unreconciled_via_fusion( + company_id=self.env.company.id, limit=50) + ours = [r for r in result if r['id'] == bank_line.id] + self.assertEqual(len(ours), 1) + row = ours[0] + for f_name in ['id', 'date', 'payment_ref', 'amount', 'partner_id', 'journal_id']: + self.assertIn(f_name, row) + self.assertIn('fusion_top_suggestion_id', row) + self.assertIn('fusion_confidence_band', row) + self.assertIn('attachment_count', row) + + def test_list_unreconciled_via_community_omits_fusion_fields(self): + bank_line = f.make_bank_line(self.env, amount=200.00, partner=self.partner) + result = self.adapter.list_unreconciled_via_community( + company_id=self.env.company.id, limit=50) + ours = [r for r in result if r['id'] == bank_line.id] + self.assertEqual(len(ours), 1) + self.assertNotIn('fusion_top_suggestion_id', ours[0]) + + def test_suggest_matches_via_fusion_returns_dict(self): + partner = self.env['res.partner'].create({'name': 'Suggest Adapter'}) + invoice = f.make_invoice(self.env, partner=partner, amount=350.00) + bank_line = f.make_bank_line(self.env, amount=350.00, partner=partner) + result = self.adapter.suggest_matches_via_fusion( + statement_line_ids=[bank_line.id], limit_per_line=3) + self.assertIsInstance(result, dict) + self.assertIn(bank_line.id, result) + self.assertGreater(len(result[bank_line.id]), 0) + + def test_suggest_matches_via_community_returns_empty(self): + bank_line = f.make_bank_line(self.env, amount=100.00, partner=self.partner) + result = self.adapter.suggest_matches_via_community( + statement_line_ids=[bank_line.id]) + self.assertEqual(result, {}) + + def test_accept_suggestion_via_fusion(self): + partner = self.env['res.partner'].create({'name': 'Accept Adapter'}) + invoice = f.make_invoice(self.env, partner=partner, amount=425.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=425.00, partner=partner) + sug = f.make_suggestion( + self.env, statement_line=bank_line, + candidate_move_lines=recv_lines, confidence=0.95) + result = self.adapter.accept_suggestion_via_fusion(suggestion_id=sug.id) + self.assertIn('partial_ids', result) + self.assertGreater(len(result['partial_ids']), 0) + + def test_accept_suggestion_via_community_raises(self): + with self.assertRaises(NotImplementedError): + self.adapter.accept_suggestion_via_community(suggestion_id=1) + + def test_unreconcile_via_fusion(self): + partner = self.env['res.partner'].create({'name': 'Unrec Adapter'}) + bank_line, recv_lines = f.make_reconcileable_pair( + self.env, amount=275.00, partner=partner) + rec_result = self.env['fusion.reconcile.engine'].reconcile_one( + bank_line, against_lines=recv_lines) + partial_ids = rec_result['partial_ids'] + result = self.adapter.unreconcile_via_fusion( + partial_reconcile_ids=partial_ids) + self.assertIn('unreconciled_line_ids', result) + self.assertGreater(len(result['unreconciled_line_ids']), 0)