Compare commits
3 Commits
da269a6207
...
8be0caa474
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8be0caa474 | ||
|
|
fce748b89c | ||
|
|
fcecf9d925 |
@@ -73,16 +73,45 @@ class FusionReconcileEngine(models.AbstractModel):
|
||||
liquidity_lines, suspense_lines, other_lines = (
|
||||
statement_line._seek_for_lines())
|
||||
|
||||
# Build the new counterpart lines that replace suspense.
|
||||
# The bank move must stay balanced after we rewrite line_ids.
|
||||
# Liquidity sums to +bank_amount (or -bank_amount for outbound), so
|
||||
# the new counterparts must sum to the inverse. We allocate the
|
||||
# available bank amount across against_lines, clamped to each
|
||||
# invoice's residual; any leftover goes to the write-off line (or
|
||||
# raises if no write-off was requested).
|
||||
liq_balance = sum(liquidity_lines.mapped('balance'))
|
||||
# Available counterpart balance (positive magnitude) = abs(liq_balance)
|
||||
remaining = abs(liq_balance)
|
||||
# Counterparts mirror liquidity: opposite sign of liq_balance.
|
||||
cp_sign = -1 if liq_balance >= 0 else 1
|
||||
|
||||
new_counterpart_vals = []
|
||||
for inv_line in against_lines:
|
||||
inv_residual = inv_line.amount_residual
|
||||
# Clamp so we never write more than the invoice residual nor more
|
||||
# than what the bank line can pay.
|
||||
allocate = min(remaining, abs(inv_residual))
|
||||
new_counterpart_vals.append(self._build_counterpart_vals(
|
||||
statement_line, inv_line))
|
||||
statement_line, inv_line,
|
||||
allocated_balance=cp_sign * allocate,
|
||||
))
|
||||
remaining -= allocate
|
||||
if remaining <= 0:
|
||||
break
|
||||
|
||||
write_off_move_id = None
|
||||
if write_off_vals:
|
||||
# Write-off absorbs whatever the against_lines didn't cover.
|
||||
wo_balance = cp_sign * remaining
|
||||
# If user passed an explicit amount and there are no against_lines,
|
||||
# honour the explicit amount (covers the pure write-off case).
|
||||
if (write_off_vals.get('amount') is not None
|
||||
and not against_lines):
|
||||
wo_balance = -write_off_vals['amount']
|
||||
new_counterpart_vals.append(self._build_write_off_vals(
|
||||
statement_line, write_off_vals, against_lines))
|
||||
statement_line, write_off_vals, balance=wo_balance,
|
||||
))
|
||||
remaining = 0
|
||||
|
||||
# Replace the bank move line_ids: keep liquidity, drop everything
|
||||
# else, append new counterparts.
|
||||
@@ -101,10 +130,14 @@ class FusionReconcileEngine(models.AbstractModel):
|
||||
lambda line: line.id not in prior_line_ids)
|
||||
|
||||
# Reconcile each new counterpart with its matched invoice line.
|
||||
# The first N new lines correspond to the first N against_lines
|
||||
# (where N may be < len(against_lines) if the bank amount ran out).
|
||||
# Any trailing new line is a write-off and has no invoice pair.
|
||||
Partial = self.env['account.partial.reconcile']
|
||||
new_partial_ids = []
|
||||
for new_line, inv_line in zip(
|
||||
new_lines[:len(against_lines)], against_lines):
|
||||
invoice_counterparts = new_lines[:min(len(new_lines),
|
||||
len(against_lines))]
|
||||
for new_line, inv_line in zip(invoice_counterparts, against_lines):
|
||||
pair = new_line | inv_line
|
||||
existing = set(Partial.search([
|
||||
'|',
|
||||
@@ -255,6 +288,14 @@ class FusionReconcileEngine(models.AbstractModel):
|
||||
def unreconcile(self, partial_reconciles):
|
||||
"""Reverse a reconciliation. Handles full vs. partial chains.
|
||||
|
||||
Because ``reconcile_one`` rewrites the bank move's suspense line into
|
||||
one or more counterpart lines, simply deleting the
|
||||
``account.partial.reconcile`` rows is not enough — the bank move
|
||||
would still look reconciled (no suspense line, no residual). We
|
||||
delegate to V19's standard ``account.bank.statement.line.
|
||||
action_undo_reconciliation`` for any affected bank line, which
|
||||
clears the partials AND restores the original suspense state.
|
||||
|
||||
Returns: ``{'unreconciled_line_ids': [...]}``
|
||||
"""
|
||||
partial_reconciles = partial_reconciles.exists()
|
||||
@@ -265,7 +306,19 @@ class FusionReconcileEngine(models.AbstractModel):
|
||||
| partial_reconciles.mapped('credit_move_id')
|
||||
)
|
||||
line_ids = all_lines.ids
|
||||
partial_reconciles.unlink()
|
||||
# Find any bank statement lines whose move owns one of these journal
|
||||
# items; route them through the standard undo flow which both
|
||||
# deletes the partials and restores the suspense line.
|
||||
affected_bank_lines = self.env['account.bank.statement.line'].search([
|
||||
('move_id', 'in', all_lines.mapped('move_id').ids),
|
||||
])
|
||||
if affected_bank_lines:
|
||||
affected_bank_lines.action_undo_reconciliation()
|
||||
# Anything still hanging around (rare — non-bank-line reconciles)
|
||||
# gets a direct unlink as a fallback.
|
||||
remaining = partial_reconciles.exists()
|
||||
if remaining:
|
||||
remaining.unlink()
|
||||
return {'unreconciled_line_ids': line_ids}
|
||||
|
||||
# ============================================================
|
||||
@@ -287,44 +340,45 @@ class FusionReconcileEngine(models.AbstractModel):
|
||||
lock=lock_date,
|
||||
))
|
||||
|
||||
def _build_counterpart_vals(self, statement_line, inv_line):
|
||||
def _build_counterpart_vals(self, statement_line, inv_line, *,
|
||||
allocated_balance):
|
||||
"""Build the vals for one counterpart line that mirrors an invoice
|
||||
line on the bank move."""
|
||||
line on the bank move.
|
||||
|
||||
``allocated_balance`` is the signed company-currency balance to write
|
||||
on the counterpart. It is clamped (by the caller) so that the bank
|
||||
move stays balanced and no invoice gets over-paid. We scale
|
||||
``amount_currency`` proportionally for multi-currency lines.
|
||||
"""
|
||||
inv_residual = inv_line.amount_residual
|
||||
if inv_residual:
|
||||
scale = abs(allocated_balance) / abs(inv_residual)
|
||||
else:
|
||||
scale = 1.0
|
||||
amount_currency = -inv_line.amount_residual_currency * scale
|
||||
return {
|
||||
'name': inv_line.name or statement_line.payment_ref or '',
|
||||
'account_id': inv_line.account_id.id,
|
||||
'partner_id': (inv_line.partner_id.id
|
||||
if inv_line.partner_id else False),
|
||||
'currency_id': inv_line.currency_id.id,
|
||||
'amount_currency': -inv_line.amount_residual_currency,
|
||||
'balance': -inv_line.amount_residual,
|
||||
'amount_currency': amount_currency,
|
||||
'balance': allocated_balance,
|
||||
}
|
||||
|
||||
def _build_write_off_vals(self, statement_line, write_off_vals,
|
||||
against_lines):
|
||||
def _build_write_off_vals(self, statement_line, write_off_vals, *,
|
||||
balance):
|
||||
"""Build the vals for a write-off counterpart line on the bank move.
|
||||
|
||||
The write-off absorbs the (signed) residual not covered by
|
||||
``against_lines``: ``residual = bank_amount - sum(against_lines.balance)``.
|
||||
We post that residual to the write-off account, with the opposite
|
||||
sign so the bank move stays balanced.
|
||||
``balance`` is the signed company-currency balance the write-off
|
||||
line must carry to keep the bank move balanced.
|
||||
"""
|
||||
bank_amount = statement_line.amount
|
||||
already_covered = sum(
|
||||
-line.amount_residual for line in against_lines)
|
||||
residual = bank_amount - already_covered
|
||||
# The counterpart on the bank move must offset the liquidity line,
|
||||
# so its balance is -residual.
|
||||
wo_balance = -residual
|
||||
# If the user explicitly passed an amount, prefer it (overrides).
|
||||
if write_off_vals.get('amount') is not None and not against_lines:
|
||||
wo_balance = -write_off_vals['amount']
|
||||
vals = {
|
||||
'name': write_off_vals.get('label') or _('Write-off'),
|
||||
'account_id': write_off_vals['account_id'],
|
||||
'partner_id': (statement_line.partner_id.id
|
||||
if statement_line.partner_id else False),
|
||||
'balance': wo_balance,
|
||||
'balance': balance,
|
||||
}
|
||||
if write_off_vals.get('tax_id'):
|
||||
vals['tax_ids'] = [(6, 0, [write_off_vals['tax_id']])]
|
||||
|
||||
@@ -7,3 +7,5 @@ from . import test_pattern_extraction
|
||||
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
|
||||
|
||||
185
fusion_accounting_bank_rec/tests/_factories.py
Normal file
185
fusion_accounting_bank_rec/tests/_factories.py
Normal file
@@ -0,0 +1,185 @@
|
||||
"""Test data factories for fusion_accounting_bank_rec.
|
||||
|
||||
Provides recordset builders for use across all test files. Sane defaults
|
||||
let tests be readable: `make_bank_line(env, amount=100, partner=p)` instead
|
||||
of 30 lines of recordset setup.
|
||||
|
||||
These factories work against the real Odoo registry — they exercise the
|
||||
same code paths as production. Each factory is idempotent in the sense
|
||||
that calling it multiple times returns separate records.
|
||||
"""
|
||||
|
||||
from datetime import date, timedelta
|
||||
|
||||
from odoo import fields
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Bank journal + statements
|
||||
# ============================================================
|
||||
|
||||
def make_bank_journal(env, *, name='Test Bank', code=None):
|
||||
"""Create a bank journal. `code` defaults to first 5 chars of `name`."""
|
||||
code = code or name[:5].upper().replace(' ', '')
|
||||
return env['account.journal'].create({
|
||||
'name': name,
|
||||
'type': 'bank',
|
||||
'code': code,
|
||||
})
|
||||
|
||||
|
||||
def make_bank_statement(env, *, journal=None, name='Test Statement', date_=None):
|
||||
"""Create a bank statement. Auto-creates a bank journal if not provided."""
|
||||
journal = journal or make_bank_journal(env)
|
||||
return env['account.bank.statement'].create({
|
||||
'name': name,
|
||||
'journal_id': journal.id,
|
||||
'date': date_ or date.today(),
|
||||
})
|
||||
|
||||
|
||||
def make_bank_line(env, *, journal=None, statement=None, amount=100.00,
|
||||
partner=None, memo='Test line', date_=None):
|
||||
"""Create a bank statement line. Creates statement if not provided.
|
||||
|
||||
Most-common factory in tests. Defaults give a $100 line with no partner."""
|
||||
if not statement:
|
||||
statement = make_bank_statement(env, journal=journal, date_=date_)
|
||||
return env['account.bank.statement.line'].create({
|
||||
'statement_id': statement.id,
|
||||
'journal_id': statement.journal_id.id,
|
||||
'date': date_ or date.today(),
|
||||
'payment_ref': memo,
|
||||
'amount': amount,
|
||||
'partner_id': partner.id if partner else False,
|
||||
})
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Invoices + journal items
|
||||
# ============================================================
|
||||
|
||||
def _ensure_test_product(env):
|
||||
"""Get or create a service product suitable for invoice lines."""
|
||||
product = env['product.product'].search([('type', '=', 'service')], limit=1)
|
||||
if not product:
|
||||
product = env['product.product'].create({
|
||||
'name': 'Fusion Test Service',
|
||||
'type': 'service',
|
||||
})
|
||||
return product
|
||||
|
||||
|
||||
def make_invoice(env, *, partner, amount=100.00, date_=None, currency=None,
|
||||
product=None, posted=True):
|
||||
"""Create a customer invoice (out_invoice). Posted by default."""
|
||||
product = product or _ensure_test_product(env)
|
||||
vals = {
|
||||
'move_type': 'out_invoice',
|
||||
'partner_id': partner.id,
|
||||
'invoice_date': date_ or date.today(),
|
||||
'invoice_line_ids': [(0, 0, {
|
||||
'product_id': product.id,
|
||||
'name': 'Test invoice line',
|
||||
'quantity': 1,
|
||||
'price_unit': amount,
|
||||
})],
|
||||
}
|
||||
if currency:
|
||||
vals['currency_id'] = currency.id
|
||||
move = env['account.move'].create(vals)
|
||||
if posted:
|
||||
move.action_post()
|
||||
return move
|
||||
|
||||
|
||||
def make_vendor_bill(env, *, partner, amount=100.00, date_=None, currency=None,
|
||||
product=None, posted=True):
|
||||
"""Create a vendor bill (in_invoice). Posted by default."""
|
||||
product = product or _ensure_test_product(env)
|
||||
vals = {
|
||||
'move_type': 'in_invoice',
|
||||
'partner_id': partner.id,
|
||||
'invoice_date': date_ or date.today(),
|
||||
'invoice_line_ids': [(0, 0, {
|
||||
'product_id': product.id,
|
||||
'name': 'Test bill line',
|
||||
'quantity': 1,
|
||||
'price_unit': amount,
|
||||
})],
|
||||
}
|
||||
if currency:
|
||||
vals['currency_id'] = currency.id
|
||||
move = env['account.move'].create(vals)
|
||||
if posted:
|
||||
move.action_post()
|
||||
return move
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Suggestions + patterns + precedents (fusion-specific)
|
||||
# ============================================================
|
||||
|
||||
def make_suggestion(env, *, statement_line, candidate_move_lines=None,
|
||||
confidence=0.92, rank=1, reasoning='Test suggestion',
|
||||
state='pending'):
|
||||
"""Create a fusion.reconcile.suggestion against a bank line."""
|
||||
candidate_ids = candidate_move_lines.ids if candidate_move_lines else []
|
||||
return env['fusion.reconcile.suggestion'].create({
|
||||
'company_id': env.company.id,
|
||||
'statement_line_id': statement_line.id,
|
||||
'proposed_move_line_ids': [(6, 0, candidate_ids)],
|
||||
'confidence': confidence,
|
||||
'rank': rank,
|
||||
'reasoning': reasoning,
|
||||
'state': state,
|
||||
})
|
||||
|
||||
|
||||
def make_pattern(env, *, partner, reconcile_count=10, pref_strategy='exact_amount',
|
||||
typical_cadence_days=14.0, common_memo_tokens='RBC,ETF'):
|
||||
"""Create a fusion.reconcile.pattern for a partner."""
|
||||
return env['fusion.reconcile.pattern'].create({
|
||||
'company_id': env.company.id,
|
||||
'partner_id': partner.id,
|
||||
'reconcile_count': reconcile_count,
|
||||
'pref_strategy': pref_strategy,
|
||||
'typical_cadence_days': typical_cadence_days,
|
||||
'common_memo_tokens': common_memo_tokens,
|
||||
})
|
||||
|
||||
|
||||
def make_precedent(env, *, partner, amount=1847.50, days_ago=14,
|
||||
memo_tokens='RBC,ETF,REF', count=1, source='manual'):
|
||||
"""Create a fusion.reconcile.precedent."""
|
||||
return env['fusion.reconcile.precedent'].create({
|
||||
'company_id': env.company.id,
|
||||
'partner_id': partner.id,
|
||||
'amount': amount,
|
||||
'currency_id': env.company.currency_id.id,
|
||||
'date': date.today() - timedelta(days=days_ago),
|
||||
'memo_tokens': memo_tokens,
|
||||
'matched_move_line_count': count,
|
||||
'reconciled_at': fields.Datetime.now(),
|
||||
'source': source,
|
||||
})
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Convenience composite — bank line + matching invoice ready to reconcile
|
||||
# ============================================================
|
||||
|
||||
def make_reconcileable_pair(env, *, amount=100.00, partner=None, date_=None):
|
||||
"""Create a bank line + a customer invoice with the same partner+amount.
|
||||
Returns (bank_line, invoice_recv_lines) ready to pass to engine.reconcile_one().
|
||||
|
||||
Returns:
|
||||
(bank_line, invoice_receivable_lines) tuple
|
||||
"""
|
||||
if not partner:
|
||||
partner = env['res.partner'].create({'name': 'Reconcile Test Partner'})
|
||||
invoice = make_invoice(env, partner=partner, amount=amount, date_=date_)
|
||||
bank_line = make_bank_line(env, amount=amount, partner=partner, date_=date_)
|
||||
recv_lines = invoice.line_ids.filtered(
|
||||
lambda l: l.account_id.account_type == 'asset_receivable')
|
||||
return (bank_line, recv_lines)
|
||||
74
fusion_accounting_bank_rec/tests/test_factories.py
Normal file
74
fusion_accounting_bank_rec/tests/test_factories.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""Smoke tests verifying the factories produce usable records.
|
||||
|
||||
Not testing factory correctness exhaustively — just that each helper
|
||||
returns a record of the expected type with the expected basic state."""
|
||||
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
|
||||
from . import _factories as f
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestFactories(TransactionCase):
|
||||
|
||||
def test_make_bank_journal(self):
|
||||
journal = f.make_bank_journal(self.env)
|
||||
self.assertEqual(journal._name, 'account.journal')
|
||||
self.assertEqual(journal.type, 'bank')
|
||||
|
||||
def test_make_bank_statement(self):
|
||||
statement = f.make_bank_statement(self.env)
|
||||
self.assertEqual(statement._name, 'account.bank.statement')
|
||||
self.assertTrue(statement.journal_id)
|
||||
|
||||
def test_make_bank_line(self):
|
||||
line = f.make_bank_line(self.env, amount=250.00, memo='Smoke memo')
|
||||
self.assertEqual(line._name, 'account.bank.statement.line')
|
||||
self.assertEqual(line.amount, 250.00)
|
||||
self.assertEqual(line.payment_ref, 'Smoke memo')
|
||||
self.assertFalse(line.is_reconciled)
|
||||
|
||||
def test_make_bank_line_with_partner(self):
|
||||
partner = self.env['res.partner'].create({'name': 'Factory Partner'})
|
||||
line = f.make_bank_line(self.env, partner=partner, amount=500)
|
||||
self.assertEqual(line.partner_id, partner)
|
||||
|
||||
def test_make_invoice_posted(self):
|
||||
partner = self.env['res.partner'].create({'name': 'Invoice Partner'})
|
||||
invoice = f.make_invoice(self.env, partner=partner, amount=300)
|
||||
self.assertEqual(invoice._name, 'account.move')
|
||||
self.assertEqual(invoice.move_type, 'out_invoice')
|
||||
self.assertEqual(invoice.state, 'posted')
|
||||
self.assertAlmostEqual(invoice.amount_total, 300, places=2)
|
||||
|
||||
def test_make_vendor_bill_posted(self):
|
||||
partner = self.env['res.partner'].create({'name': 'Vendor Partner'})
|
||||
bill = f.make_vendor_bill(self.env, partner=partner, amount=400)
|
||||
self.assertEqual(bill.move_type, 'in_invoice')
|
||||
self.assertEqual(bill.state, 'posted')
|
||||
|
||||
def test_make_suggestion(self):
|
||||
line = f.make_bank_line(self.env, amount=100)
|
||||
sug = f.make_suggestion(self.env, statement_line=line, confidence=0.85)
|
||||
self.assertEqual(sug._name, 'fusion.reconcile.suggestion')
|
||||
self.assertEqual(sug.confidence, 0.85)
|
||||
self.assertEqual(sug.state, 'pending')
|
||||
|
||||
def test_make_pattern(self):
|
||||
partner = self.env['res.partner'].create({'name': 'Pattern Partner'})
|
||||
pattern = f.make_pattern(self.env, partner=partner, reconcile_count=20)
|
||||
self.assertEqual(pattern._name, 'fusion.reconcile.pattern')
|
||||
self.assertEqual(pattern.reconcile_count, 20)
|
||||
|
||||
def test_make_precedent(self):
|
||||
partner = self.env['res.partner'].create({'name': 'Precedent Partner'})
|
||||
precedent = f.make_precedent(self.env, partner=partner, amount=999.99)
|
||||
self.assertEqual(precedent._name, 'fusion.reconcile.precedent')
|
||||
self.assertEqual(precedent.amount, 999.99)
|
||||
self.assertEqual(precedent.source, 'manual')
|
||||
|
||||
def test_make_reconcileable_pair(self):
|
||||
bank_line, recv_lines = f.make_reconcileable_pair(self.env, amount=750)
|
||||
self.assertEqual(bank_line.amount, 750.00)
|
||||
self.assertGreater(len(recv_lines), 0)
|
||||
self.assertAlmostEqual(sum(recv_lines.mapped('amount_residual')), 750, places=2)
|
||||
@@ -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