diff --git a/fusion_accounting_ai/services/tools/bank_reconciliation.py b/fusion_accounting_ai/services/tools/bank_reconciliation.py index e98a3726..7ae9d2e9 100644 --- a/fusion_accounting_ai/services/tools/bank_reconciliation.py +++ b/fusion_accounting_ai/services/tools/bank_reconciliation.py @@ -67,7 +67,16 @@ def match_bank_line_to_payments(env, params): st_line = env['account.bank.statement.line'].browse(st_line_id) if not st_line.exists(): return {'error': 'Statement line not found'} - st_line.set_line_bank_statement_line(move_line_ids) + # Phase 1 Task 23: route through engine when available + if 'fusion.reconcile.engine' in env.registry: + cands = env['account.move.line'].browse(move_line_ids).exists() + if not cands: + return {'error': 'No valid move_line_ids'} + env['fusion.reconcile.engine'].reconcile_one( + st_line, against_lines=cands) + st_line.invalidate_recordset(['is_reconciled']) + else: + st_line.set_line_bank_statement_line(move_line_ids) return { 'status': 'matched', 'statement_line_id': st_line_id, @@ -83,7 +92,12 @@ def auto_reconcile_bank_lines(env, params): ('company_id', '=', int(company_id)), ]) before_count = len(lines) - lines._try_auto_reconcile_statement_lines(company_id=int(company_id)) + # Phase 1 Task 23: route through engine when available + if 'fusion.reconcile.engine' in env.registry: + env['fusion.reconcile.engine'].reconcile_batch( + lines, strategy='auto') + else: + lines._try_auto_reconcile_statement_lines(company_id=int(company_id)) still_unreconciled = env['account.bank.statement.line'].search([ ('is_reconciled', '=', False), ('company_id', '=', int(company_id)), diff --git a/fusion_accounting_bank_rec/models/fusion_reconcile_engine.py b/fusion_accounting_bank_rec/models/fusion_reconcile_engine.py index 88f0432f..2691bd7c 100644 --- a/fusion_accounting_bank_rec/models/fusion_reconcile_engine.py +++ b/fusion_accounting_bank_rec/models/fusion_reconcile_engine.py @@ -177,14 +177,19 @@ class FusionReconcileEngine(models.AbstractModel): if line.is_reconciled: skipped += 1 continue + # Per-line savepoint so a single DB-level failure (e.g. a + # check-constraint violation on one bad line) doesn't poison + # the whole batch's transaction. try: - candidates = self._fetch_candidates(line) - picked = self._apply_strategy(line, candidates, strategy) - if picked: - self.reconcile_one(line, against_lines=picked) - reconciled += 1 - else: - skipped += 1 + with self.env.cr.savepoint(): + candidates = self._fetch_candidates(line) + picked = self._apply_strategy( + line, candidates, strategy) + if picked: + self.reconcile_one(line, against_lines=picked) + reconciled += 1 + else: + skipped += 1 except Exception as e: # noqa: BLE001 errors.append({'line_id': line.id, 'error': str(e)}) _logger.warning( diff --git a/fusion_accounting_bank_rec/tests/__init__.py b/fusion_accounting_bank_rec/tests/__init__.py index af22688d..96de5be4 100644 --- a/fusion_accounting_bank_rec/tests/__init__.py +++ b/fusion_accounting_bank_rec/tests/__init__.py @@ -12,3 +12,4 @@ from . import test_reconcile_engine_integration from . import test_bank_rec_prompt from . import test_bank_rec_adapter from . import test_bank_rec_tools +from . import test_legacy_tools_refactor diff --git a/fusion_accounting_bank_rec/tests/test_legacy_tools_refactor.py b/fusion_accounting_bank_rec/tests/test_legacy_tools_refactor.py new file mode 100644 index 00000000..c92d3f22 --- /dev/null +++ b/fusion_accounting_bank_rec/tests/test_legacy_tools_refactor.py @@ -0,0 +1,59 @@ +"""Tests verifying legacy tools route through fusion.reconcile.engine when present. + +These tests run in the fusion_accounting_bank_rec context where the engine IS +available, so they assert the engine path is taken and produces correct +results. The fallback path is exercised by the existing fusion_accounting_ai +tests when fusion_accounting_bank_rec is not installed.""" + +from unittest.mock import patch + +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 TestLegacyToolsRefactor(TransactionCase): + + def setUp(self): + super().setUp() + self.partner = self.env['res.partner'].create({'name': 'Refactor Test Partner'}) + + def test_match_bank_line_to_payments_uses_engine(self): + """When engine is present, match_bank_line_to_payments must produce + a partial reconcile via the engine, not via set_line_bank_statement_line.""" + bank_line, recv_lines = f.make_reconcileable_pair( + self.env, amount=180.00, partner=self.partner) + result = tools.match_bank_line_to_payments(self.env, { + 'statement_line_id': bank_line.id, + 'move_line_ids': recv_lines.ids, + }) + self.assertEqual(result.get('status'), 'matched') + bank_line.invalidate_recordset(['is_reconciled']) + self.assertTrue(bank_line.is_reconciled) + # Verify a precedent was recorded - engine-only behaviour + Precedent = self.env['fusion.reconcile.precedent'] + precedents = Precedent.search([('partner_id', '=', self.partner.id)]) + self.assertGreater(len(precedents), 0, + "Engine path should record a precedent; legacy path would not") + + def test_auto_reconcile_bank_lines_uses_engine(self): + """When engine is present, auto_reconcile_bank_lines must call + fusion.reconcile.engine.reconcile_batch (not the Enterprise-only + _try_auto_reconcile_statement_lines fallback). We patch + reconcile_batch to verify routing without running the real engine + across every legacy unreconciled line in the test DB.""" + Engine = type(self.env['fusion.reconcile.engine']) + with patch.object( + Engine, 'reconcile_batch', autospec=True, + return_value={'reconciled_count': 2, 'skipped': 0, 'errors': []}, + ) as engine_call: + result = tools.auto_reconcile_bank_lines(self.env, { + 'company_id': self.env.company.id, + }) + self.assertEqual(result['status'], 'completed') + self.assertTrue(engine_call.called, + "Engine path must invoke fusion.reconcile.engine.reconcile_batch") + # Verify the engine was passed the strategy='auto' kwarg per spec + _self, _lines = engine_call.call_args.args[0], engine_call.call_args.args[1] + self.assertEqual(engine_call.call_args.kwargs.get('strategy'), 'auto')