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 = (
|
liquidity_lines, suspense_lines, other_lines = (
|
||||||
statement_line._seek_for_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 = []
|
new_counterpart_vals = []
|
||||||
for inv_line in against_lines:
|
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(
|
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
|
write_off_move_id = None
|
||||||
if write_off_vals:
|
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(
|
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
|
# Replace the bank move line_ids: keep liquidity, drop everything
|
||||||
# else, append new counterparts.
|
# else, append new counterparts.
|
||||||
@@ -101,10 +130,14 @@ class FusionReconcileEngine(models.AbstractModel):
|
|||||||
lambda line: line.id not in prior_line_ids)
|
lambda line: line.id not in prior_line_ids)
|
||||||
|
|
||||||
# Reconcile each new counterpart with its matched invoice line.
|
# 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']
|
Partial = self.env['account.partial.reconcile']
|
||||||
new_partial_ids = []
|
new_partial_ids = []
|
||||||
for new_line, inv_line in zip(
|
invoice_counterparts = new_lines[:min(len(new_lines),
|
||||||
new_lines[:len(against_lines)], against_lines):
|
len(against_lines))]
|
||||||
|
for new_line, inv_line in zip(invoice_counterparts, against_lines):
|
||||||
pair = new_line | inv_line
|
pair = new_line | inv_line
|
||||||
existing = set(Partial.search([
|
existing = set(Partial.search([
|
||||||
'|',
|
'|',
|
||||||
@@ -255,6 +288,14 @@ class FusionReconcileEngine(models.AbstractModel):
|
|||||||
def unreconcile(self, partial_reconciles):
|
def unreconcile(self, partial_reconciles):
|
||||||
"""Reverse a reconciliation. Handles full vs. partial chains.
|
"""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': [...]}``
|
Returns: ``{'unreconciled_line_ids': [...]}``
|
||||||
"""
|
"""
|
||||||
partial_reconciles = partial_reconciles.exists()
|
partial_reconciles = partial_reconciles.exists()
|
||||||
@@ -265,7 +306,19 @@ class FusionReconcileEngine(models.AbstractModel):
|
|||||||
| partial_reconciles.mapped('credit_move_id')
|
| partial_reconciles.mapped('credit_move_id')
|
||||||
)
|
)
|
||||||
line_ids = all_lines.ids
|
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}
|
return {'unreconciled_line_ids': line_ids}
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -287,44 +340,45 @@ class FusionReconcileEngine(models.AbstractModel):
|
|||||||
lock=lock_date,
|
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
|
"""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 {
|
return {
|
||||||
'name': inv_line.name or statement_line.payment_ref or '',
|
'name': inv_line.name or statement_line.payment_ref or '',
|
||||||
'account_id': inv_line.account_id.id,
|
'account_id': inv_line.account_id.id,
|
||||||
'partner_id': (inv_line.partner_id.id
|
'partner_id': (inv_line.partner_id.id
|
||||||
if inv_line.partner_id else False),
|
if inv_line.partner_id else False),
|
||||||
'currency_id': inv_line.currency_id.id,
|
'currency_id': inv_line.currency_id.id,
|
||||||
'amount_currency': -inv_line.amount_residual_currency,
|
'amount_currency': amount_currency,
|
||||||
'balance': -inv_line.amount_residual,
|
'balance': allocated_balance,
|
||||||
}
|
}
|
||||||
|
|
||||||
def _build_write_off_vals(self, statement_line, write_off_vals,
|
def _build_write_off_vals(self, statement_line, write_off_vals, *,
|
||||||
against_lines):
|
balance):
|
||||||
"""Build the vals for a write-off counterpart line on the bank move.
|
"""Build the vals for a write-off counterpart line on the bank move.
|
||||||
|
|
||||||
The write-off absorbs the (signed) residual not covered by
|
``balance`` is the signed company-currency balance the write-off
|
||||||
``against_lines``: ``residual = bank_amount - sum(against_lines.balance)``.
|
line must carry to keep the bank move balanced.
|
||||||
We post that residual to the write-off account, with the opposite
|
|
||||||
sign so the bank move stays 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 = {
|
vals = {
|
||||||
'name': write_off_vals.get('label') or _('Write-off'),
|
'name': write_off_vals.get('label') or _('Write-off'),
|
||||||
'account_id': write_off_vals['account_id'],
|
'account_id': write_off_vals['account_id'],
|
||||||
'partner_id': (statement_line.partner_id.id
|
'partner_id': (statement_line.partner_id.id
|
||||||
if statement_line.partner_id else False),
|
if statement_line.partner_id else False),
|
||||||
'balance': wo_balance,
|
'balance': balance,
|
||||||
}
|
}
|
||||||
if write_off_vals.get('tax_id'):
|
if write_off_vals.get('tax_id'):
|
||||||
vals['tax_ids'] = [(6, 0, [write_off_vals['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_confidence_scoring
|
||||||
from . import test_reconcile_engine_unit
|
from . import test_reconcile_engine_unit
|
||||||
from . import test_reconcile_engine_property
|
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