test(fusion_accounting_bank_rec): integration tests for engine end-to-end flows
Tests engine behavior using factories (Task 18) instead of SQL fixtures. Covers simple match, partial chain, multi-invoice batch, suggest-then- accept flow, unreconcile reversal, and edge cases. Two tests are intentionally failing — they expose real engine bugs that should be fixed in a follow-up: - TestReconcilePartialChain.test_partial_reconcile_leaves_residual: reconcile_one() builds counterpart vals using the full invoice residual, which leaves the bank move unbalanced when bank amount is smaller than the invoice (UserError: entry not balanced). - TestUnreconcile.test_unreconcile_removes_partial: unreconcile() unlinks partial.reconcile rows but does not restore the suspense line on the bank move, so account.bank.statement.line.is_reconciled remains True after reversal. Made-with: Cursor
This commit is contained in:
@@ -8,3 +8,4 @@ from . import test_confidence_scoring
|
||||
from . import test_reconcile_engine_unit
|
||||
from . import test_reconcile_engine_property
|
||||
from . import test_factories
|
||||
from . import test_reconcile_engine_integration
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user