"""Integration tests for the reconcile engine. These tests use the test factories (_factories.py) to set up realistic bank-line + invoice scenarios, then call engine methods and assert the account.partial.reconcile rows produced have the right shape. Tests cover: - Simple 1:1 match (bank line == one invoice) - Partial chain (one bank line < invoice amount) - Multi-invoice consolidation (one bank line == sum of N invoices) - Auto-strategy batch (mix of matchable and unmatchable lines) - Suggest-then-accept flow - Unreconcile (reverse a reconciliation) """ from datetime import date, timedelta from odoo.tests.common import TransactionCase, tagged from . import _factories as f @tagged('post_install', '-at_install', 'integration') class TestReconcileSimpleMatch(TransactionCase): """The most common scenario: 1 bank line matched against 1 invoice exact.""" def test_simple_match_creates_partial_reconcile(self): bank_line, recv_lines = f.make_reconcileable_pair(self.env, amount=100.00) result = self.env['fusion.reconcile.engine'].reconcile_one( bank_line, against_lines=recv_lines) self.assertGreater(len(result['partial_ids']), 0) partial = self.env['account.partial.reconcile'].browse(result['partial_ids']) self.assertAlmostEqual(sum(partial.mapped('amount')), 100.00, places=2) def test_simple_match_marks_line_reconciled(self): bank_line, recv_lines = f.make_reconcileable_pair(self.env, amount=250.00) self.env['fusion.reconcile.engine'].reconcile_one( bank_line, against_lines=recv_lines) bank_line.invalidate_recordset(['is_reconciled']) self.assertTrue(bank_line.is_reconciled) def test_simple_match_records_precedent(self): bank_line, recv_lines = f.make_reconcileable_pair(self.env, amount=500.00) partner = bank_line.partner_id Precedent = self.env['fusion.reconcile.precedent'] before = Precedent.search_count([('partner_id', '=', partner.id)]) self.env['fusion.reconcile.engine'].reconcile_one( bank_line, against_lines=recv_lines) after = Precedent.search_count([('partner_id', '=', partner.id)]) self.assertEqual(after, before + 1, "Engine should record one precedent per reconcile") @tagged('post_install', '-at_install', 'integration') class TestReconcilePartialChain(TransactionCase): """Bank line amount < invoice amount -> partial reconcile, residual remains.""" def test_partial_reconcile_leaves_residual(self): partner = self.env['res.partner'].create({'name': 'Partial Partner'}) invoice = f.make_invoice(self.env, partner=partner, amount=300.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=100.00, partner=partner) result = self.env['fusion.reconcile.engine'].reconcile_one( bank_line, against_lines=recv_lines) self.assertGreater(len(result['partial_ids']), 0) invoice.invalidate_recordset(['payment_state', 'amount_residual']) self.assertAlmostEqual(invoice.amount_residual, 200.00, places=2) @tagged('post_install', '-at_install', 'integration') class TestReconcileBatch(TransactionCase): """Bulk reconcile: mix of matchable and unmatchable lines.""" def test_batch_reconciles_matchable_lines_only(self): partner = self.env['res.partner'].create({'name': 'Batch Partner'}) # Share one journal/statement to avoid duplicate-code conflicts # when creating multiple bank lines in the same test transaction. shared_journal = f.make_bank_journal(self.env, name='Batch Bank', code='BBNK') shared_statement = f.make_bank_statement(self.env, journal=shared_journal) pairs = [] for amount in [100.00, 200.00, 300.00]: invoice = f.make_invoice(self.env, partner=partner, amount=amount) recv_lines = invoice.line_ids.filtered( lambda l: l.account_id.account_type == 'asset_receivable') bank_line = f.make_bank_line( self.env, statement=shared_statement, amount=amount, partner=partner) pairs.append((bank_line, recv_lines)) orphan_line = f.make_bank_line( self.env, statement=shared_statement, amount=999.99, partner=partner) all_lines = self.env['account.bank.statement.line'].browse( [p[0].id for p in pairs] + [orphan_line.id]) result = self.env['fusion.reconcile.engine'].reconcile_batch( all_lines, strategy='auto') self.assertEqual(result['reconciled_count'], 3) self.assertGreaterEqual(result['skipped'], 1) self.assertEqual(len(result['errors']), 0) def test_batch_handles_empty_recordset(self): empty = self.env['account.bank.statement.line'] result = self.env['fusion.reconcile.engine'].reconcile_batch(empty) self.assertEqual(result['reconciled_count'], 0) @tagged('post_install', '-at_install', 'integration') class TestSuggestThenAccept(TransactionCase): """Full flow: suggest_matches creates suggestions; accept_suggestion reconciles.""" def test_suggest_then_accept(self): partner = self.env['res.partner'].create({'name': 'Suggest Then Accept'}) invoice = f.make_invoice(self.env, partner=partner, amount=750.00) bank_line = f.make_bank_line(self.env, amount=750.00, partner=partner, memo='Test suggest accept') suggestions = self.env['fusion.reconcile.engine'].suggest_matches( bank_line, limit_per_line=3) self.assertIn(bank_line.id, suggestions) self.assertGreater(len(suggestions[bank_line.id]), 0, "Engine should suggest at least one candidate for matching invoice") top_suggestion_id = suggestions[bank_line.id][0]['id'] sug = self.env['fusion.reconcile.suggestion'].browse(top_suggestion_id) result = self.env['fusion.reconcile.engine'].accept_suggestion(sug) self.assertGreater(len(result['partial_ids']), 0) sug.invalidate_recordset(['state', 'accepted_at', 'accepted_by']) self.assertEqual(sug.state, 'accepted') self.assertTrue(sug.accepted_at) bank_line.invalidate_recordset(['is_reconciled']) self.assertTrue(bank_line.is_reconciled) def test_suggest_supersedes_prior_pending(self): partner = self.env['res.partner'].create({'name': 'Supersede Test'}) bank_line = f.make_bank_line(self.env, amount=100.00, partner=partner) invoice = f.make_invoice(self.env, partner=partner, amount=100.00) self.env['fusion.reconcile.engine'].suggest_matches(bank_line) first_pending = self.env['fusion.reconcile.suggestion'].search([ ('statement_line_id', '=', bank_line.id), ('state', '=', 'pending'), ]) self.assertGreater(len(first_pending), 0) self.env['fusion.reconcile.engine'].suggest_matches(bank_line) first_pending.invalidate_recordset(['state']) for s in first_pending: self.assertEqual(s.state, 'superseded') @tagged('post_install', '-at_install', 'integration') class TestUnreconcile(TransactionCase): """Reverse a reconciliation.""" def test_unreconcile_removes_partial(self): bank_line, recv_lines = f.make_reconcileable_pair(self.env, amount=100.00) result = self.env['fusion.reconcile.engine'].reconcile_one( bank_line, against_lines=recv_lines) partials = self.env['account.partial.reconcile'].browse(result['partial_ids']) self.assertGreater(len(partials), 0) unrec_result = self.env['fusion.reconcile.engine'].unreconcile(partials) self.assertGreater(len(unrec_result['unreconciled_line_ids']), 0) self.assertFalse(partials.exists()) bank_line.invalidate_recordset(['is_reconciled']) self.assertFalse(bank_line.is_reconciled) @tagged('post_install', '-at_install', 'integration') class TestEngineEdgeCases(TransactionCase): """Edge cases that came up during engine implementation.""" def test_reconcile_validates_line_exists(self): from odoo.exceptions import ValidationError with self.assertRaises(ValidationError): self.env['fusion.reconcile.engine'].reconcile_one( self.env['account.bank.statement.line'], against_lines=self.env['account.move.line']) def test_already_reconciled_line_skipped_in_batch(self): partner = self.env['res.partner'].create({'name': 'Already Reconciled'}) bank_line, recv_lines = f.make_reconcileable_pair( self.env, amount=50.00, partner=partner) self.env['fusion.reconcile.engine'].reconcile_one( bank_line, against_lines=recv_lines) bank_line.invalidate_recordset(['is_reconciled']) self.assertTrue(bank_line.is_reconciled) result = self.env['fusion.reconcile.engine'].reconcile_batch(bank_line) self.assertGreater(result['skipped'], 0)