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
This commit is contained in:
@@ -5,3 +5,4 @@ from . import test_ai_suggestion_lifecycle
|
||||
from . import test_precedent_lookup
|
||||
from . import test_pattern_extraction
|
||||
from . import test_confidence_scoring
|
||||
from . import test_reconcile_engine_unit
|
||||
|
||||
348
fusion_accounting_bank_rec/tests/test_reconcile_engine_unit.py
Normal file
348
fusion_accounting_bank_rec/tests/test_reconcile_engine_unit.py
Normal file
@@ -0,0 +1,348 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user