Files
Odoo-Modules/fusion_accounting_bank_rec/tests/test_reconcile_engine_unit.py
gsinghpal 80b8100232 feat(fusion_accounting_bank_rec): reconcile engine 6-method public API
Adds fusion.reconcile.engine — the AbstractModel orchestrator for all
bank-line reconciliations. Six public methods (reconcile_one,
reconcile_batch, suggest_matches, accept_suggestion, write_off,
unreconcile) form the only sanctioned write path to
account.partial.reconcile from the rest of the module (controllers, AI
tools, wizards).

Implementation follows V19's bank_rec_widget pattern: rewrite the bank
move's suspense line into one counterpart per matched invoice (or a
write-off line) on the appropriate receivable / payable / write-off
account, then call account.move.line.reconcile() on each pair. Records
a precedent row per reconcile for downstream pattern learning.

16 new unit tests cover all six methods across happy paths, the
precedent side effect, suggestion lifecycle, batch auto-strategy, and
write-off line clearance. 67 total tests, 0 failed.

Made-with: Cursor
2026-04-19 10:50:46 -04:00

349 lines
13 KiB
Python

"""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)