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
349 lines
13 KiB
Python
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)
|