Compare commits

...

3 Commits

Author SHA1 Message Date
gsinghpal
8be0caa474 fix(fusion_accounting_bank_rec): partial-reconcile balance + unreconcile suspense restore
Two engine bugs caught by Task 19's integration tests:

1. Partial reconcile (bank_amount < invoice_residual) was creating an
   unbalanced bank move. Counterpart balance now clamped to
   min(remaining_bank_amount, abs(invoice_residual)) so the move stays
   balanced; Odoo's reconcile() handles the resulting partial. The
   counterpart's amount_currency is scaled proportionally so multi-
   currency lines stay consistent.

2. Unreconcile only removed account.partial.reconcile rows but didn't
   restore the suspense line on the bank move, leaving is_reconciled=True
   after unreconcile. Now delegates to V19's standard
   account.bank.statement.line.action_undo_reconciliation for any
   affected bank line, which both deletes partials and restores the
   suspense state in one shot.

Made-with: Cursor
2026-04-19 11:14:43 -04:00
gsinghpal
fce748b89c 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
2026-04-19 11:11:30 -04:00
gsinghpal
fcecf9d925 test(fusion_accounting_bank_rec): test data factories for bank-rec testing
Provides make_bank_journal, make_bank_statement, make_bank_line,
make_invoice, make_vendor_bill, make_suggestion, make_pattern,
make_precedent, make_reconcileable_pair helpers used across the
bank-rec test suite. Replaces the original plan's SQL-fixture capture
with programmatic factories — same testing intent, simpler maintenance,
no real Westin data baked into the repo.

Note: the original plan called for 5 SQL fixtures captured from the local
DB (westin_simple_match.sql, westin_partial_chain.sql, etc.). Those are
replaced by factory-driven test creation in Task 19 — eliminates fragile
hand-curated SQL while testing the same code paths.

Made-with: Cursor
2026-04-19 11:05:06 -04:00
5 changed files with 543 additions and 27 deletions

View File

@@ -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']])]

View File

@@ -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

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

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

View File

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