"""Unit tests for fusion.reconcile.engine — the 6-method public API. Test layers: - Layer 1: API surface (registry + method existence) - Layer 2: unreconcile - Layer 3: reconcile_one happy path - Layer 4: accept_suggestion - Layer 5: suggest_matches - Layer 6: reconcile_batch - Layer 7: write_off Tests share a common setUpClass fixture providing a partner, bank journal, statement, receivable account, and a small helper to mint a posted customer invoice + bank statement line at given amounts. """ from datetime import date from odoo.exceptions import ValidationError from odoo.tests.common import TransactionCase, tagged @tagged('post_install', '-at_install') class TestReconcileEngineBase(TransactionCase): """Shared fixtures for engine tests.""" @classmethod def setUpClass(cls): super().setUpClass() cls.engine = cls.env['fusion.reconcile.engine'] cls.company = cls.env.company cls.currency = cls.company.currency_id cls.partner = cls.env['res.partner'].create({ 'name': 'Engine Test Partner', }) cls.bank_journal = cls.env['account.journal'].create({ 'name': 'Engine Test Bank', 'type': 'bank', 'code': 'ETBK', 'company_id': cls.company.id, }) cls.sales_journal = cls.env['account.journal'].search([ ('type', '=', 'sale'), ('company_id', '=', cls.company.id), ], limit=1) if not cls.sales_journal: cls.sales_journal = cls.env['account.journal'].create({ 'name': 'Engine Test Sales', 'type': 'sale', 'code': 'ETSAL', 'company_id': cls.company.id, }) cls.receivable_account = cls.env['account.account'].search([ ('account_type', '=', 'asset_receivable'), ('company_ids', 'in', cls.company.id), ], limit=1) cls.income_account = cls.env['account.account'].search([ ('account_type', '=', 'income'), ('company_ids', 'in', cls.company.id), ], limit=1) cls.expense_account = cls.env['account.account'].search([ ('account_type', '=', 'expense'), ('company_ids', 'in', cls.company.id), ], limit=1) def _make_statement_line(self, amount, *, partner=None, ref='ENGTEST', line_date=None): statement = self.env['account.bank.statement'].create({ 'name': 'Engine Test Statement', 'journal_id': self.bank_journal.id, }) return self.env['account.bank.statement.line'].create({ 'statement_id': statement.id, 'journal_id': self.bank_journal.id, 'date': line_date or date.today(), 'payment_ref': ref, 'amount': amount, 'partner_id': (partner or self.partner).id, }) def _make_invoice(self, amount, *, partner=None, inv_date=None): """Create + post a customer invoice for the given amount.""" inv = self.env['account.move'].create({ 'move_type': 'out_invoice', 'partner_id': (partner or self.partner).id, 'invoice_date': inv_date or date.today(), 'journal_id': self.sales_journal.id, 'invoice_line_ids': [(0, 0, { 'name': 'Engine test product', 'quantity': 1, 'price_unit': amount, 'account_id': self.income_account.id, 'tax_ids': [(6, 0, [])], })], }) inv.action_post() return inv def _receivable_line(self, invoice): return invoice.line_ids.filtered( lambda line: line.account_id.account_type == 'asset_receivable' ) # ============================================================ # Layer 1: API surface # ============================================================ @tagged('post_install', '-at_install') class TestReconcileEngineApi(TestReconcileEngineBase): """Layer 1: the engine class exists in the registry and exposes the six expected methods.""" def test_engine_in_registry(self): self.assertIn('fusion.reconcile.engine', self.env.registry) def test_engine_is_abstract_model(self): engine = self.env['fusion.reconcile.engine'] self.assertTrue(engine._abstract) def test_six_public_methods_callable(self): engine = self.env['fusion.reconcile.engine'] for name in ('reconcile_one', 'reconcile_batch', 'suggest_matches', 'accept_suggestion', 'write_off', 'unreconcile'): self.assertTrue(callable(getattr(engine, name, None)), f"engine.{name} must be callable") def test_reconcile_one_requires_arguments(self): line = self._make_statement_line(100.0) with self.assertRaises(ValidationError): self.engine.reconcile_one(line) # ============================================================ # Layer 2: unreconcile # ============================================================ @tagged('post_install', '-at_install') class TestReconcileEngineUnreconcile(TestReconcileEngineBase): def test_unreconcile_removes_partial_reconcile(self): line = self._make_statement_line(100.0) invoice = self._make_invoice(100.0) receivable = self._receivable_line(invoice) result = self.engine.reconcile_one( line, against_lines=receivable) self.assertTrue(result['partial_ids'], "reconcile_one should produce partial_ids") partials = self.env['account.partial.reconcile'].browse( result['partial_ids']).exists() self.assertTrue(partials) out = self.engine.unreconcile(partials) self.assertIn('unreconciled_line_ids', out) self.assertTrue(out['unreconciled_line_ids']) self.assertFalse(partials.exists(), "Partials should be deleted after unreconcile") receivable.invalidate_recordset(['reconciled', 'amount_residual']) self.assertFalse(receivable.reconciled) def test_unreconcile_empty_recordset_returns_empty(self): empty = self.env['account.partial.reconcile'] out = self.engine.unreconcile(empty) self.assertEqual(out, {'unreconciled_line_ids': []}) # ============================================================ # Layer 3: reconcile_one happy path # ============================================================ @tagged('post_install', '-at_install') class TestReconcileEngineReconcileOne(TestReconcileEngineBase): def test_reconcile_one_simple_invoice_match(self): line = self._make_statement_line(250.0) invoice = self._make_invoice(250.0) receivable = self._receivable_line(invoice) self.assertFalse(receivable.reconciled) result = self.engine.reconcile_one( line, against_lines=receivable) self.assertIsInstance(result, dict) self.assertIn('partial_ids', result) self.assertIn('exchange_diff_move_id', result) self.assertIn('write_off_move_id', result) self.assertTrue(result['partial_ids']) receivable.invalidate_recordset(['reconciled', 'amount_residual']) self.assertTrue(receivable.reconciled) self.assertAlmostEqual(receivable.amount_residual, 0.0, places=2) def test_reconcile_one_creates_precedent(self): line = self._make_statement_line(125.0, ref='Engine REF#42') invoice = self._make_invoice(125.0) receivable = self._receivable_line(invoice) before = self.env['fusion.reconcile.precedent'].search_count([ ('partner_id', '=', self.partner.id), ]) self.engine.reconcile_one(line, against_lines=receivable) after = self.env['fusion.reconcile.precedent'].search_count([ ('partner_id', '=', self.partner.id), ]) self.assertEqual(after, before + 1) # ============================================================ # Layer 4: accept_suggestion # ============================================================ @tagged('post_install', '-at_install') class TestReconcileEngineAcceptSuggestion(TestReconcileEngineBase): def test_accept_suggestion_reconciles_and_marks_accepted(self): line = self._make_statement_line(310.0) invoice = self._make_invoice(310.0) receivable = self._receivable_line(invoice) sug = self.env['fusion.reconcile.suggestion'].create({ 'company_id': self.company.id, 'statement_line_id': line.id, 'proposed_move_line_ids': [(6, 0, receivable.ids)], 'confidence': 0.97, 'rank': 1, 'reasoning': 'Exact amount match', 'state': 'pending', }) result = self.engine.accept_suggestion(sug) self.assertTrue(result['partial_ids']) self.assertEqual(sug.state, 'accepted') self.assertTrue(sug.accepted_at) self.assertEqual(sug.accepted_by, self.env.user) def test_accept_suggestion_by_id(self): line = self._make_statement_line(75.0) invoice = self._make_invoice(75.0) receivable = self._receivable_line(invoice) sug = self.env['fusion.reconcile.suggestion'].create({ 'company_id': self.company.id, 'statement_line_id': line.id, 'proposed_move_line_ids': [(6, 0, receivable.ids)], 'confidence': 0.91, 'rank': 1, 'reasoning': 'OK', 'state': 'pending', }) result = self.engine.accept_suggestion(sug.id) self.assertTrue(result['partial_ids']) self.assertEqual(sug.state, 'accepted') # ============================================================ # Layer 5: suggest_matches # ============================================================ @tagged('post_install', '-at_install') class TestReconcileEngineSuggestMatches(TestReconcileEngineBase): def test_suggest_matches_persists_pending_suggestions(self): line = self._make_statement_line(420.0) invoice = self._make_invoice(420.0) # second open invoice for same partner — also a candidate self._make_invoice(99.0) out = self.engine.suggest_matches(line) self.assertIn(line.id, out) self.assertTrue(out[line.id]) suggestions = self.env['fusion.reconcile.suggestion'].search([ ('statement_line_id', '=', line.id), ('state', '=', 'pending'), ]) self.assertTrue(suggestions) # Top suggestion should reference the matching invoice's receivable top = max(suggestions, key=lambda s: s.confidence) receivable = self._receivable_line(invoice) self.assertIn(receivable.id, top.proposed_move_line_ids.ids) def test_suggest_matches_supersedes_prior_pending(self): line = self._make_statement_line(180.0) self._make_invoice(180.0) old_sug = self.env['fusion.reconcile.suggestion'].create({ 'company_id': self.company.id, 'statement_line_id': line.id, 'confidence': 0.5, 'rank': 1, 'reasoning': 'prior', 'state': 'pending', }) self.engine.suggest_matches(line) old_sug.invalidate_recordset(['state']) self.assertEqual(old_sug.state, 'superseded') def test_suggest_matches_returns_empty_for_no_candidates(self): partner = self.env['res.partner'].create({'name': 'Empty Partner'}) line = self._make_statement_line(10.0, partner=partner) out = self.engine.suggest_matches(line) self.assertEqual(out, {}) # ============================================================ # Layer 6: reconcile_batch # ============================================================ @tagged('post_install', '-at_install') class TestReconcileEngineBatch(TestReconcileEngineBase): def test_reconcile_batch_auto_strategy_matches_n_lines(self): amounts = [100.0, 200.0, 333.33] lines = self.env['account.bank.statement.line'] for amt in amounts: invoice = self._make_invoice(amt) self.assertTrue(invoice) lines |= self._make_statement_line(amt, ref=f'BATCH-{amt}') result = self.engine.reconcile_batch(lines, strategy='auto') self.assertEqual(result['reconciled_count'], len(amounts)) self.assertEqual(result['skipped'], 0) self.assertEqual(result['errors'], []) def test_reconcile_batch_skips_already_reconciled(self): line = self._make_statement_line(50.0) invoice = self._make_invoice(50.0) receivable = self._receivable_line(invoice) self.engine.reconcile_one(line, against_lines=receivable) result = self.engine.reconcile_batch(line, strategy='auto') self.assertEqual(result['reconciled_count'], 0) self.assertEqual(result['skipped'], 1) # ============================================================ # Layer 7: write_off # ============================================================ @tagged('post_install', '-at_install') class TestReconcileEngineWriteOff(TestReconcileEngineBase): def test_write_off_clears_bank_line(self): line = self._make_statement_line(40.0, ref='Bank fee') # No invoices exist; write off the whole amount to expense. result = self.engine.write_off( line, account=self.expense_account, amount=40.0, label='Bank fees', ) self.assertIn('write_off_move_id', result) line.invalidate_recordset(['is_reconciled']) self.assertTrue(line.is_reconciled)