feat(fusion_accounting_bank_rec): reconcile engine 6-method public API
Adds fusion.reconcile.engine — the AbstractModel orchestrator for all bank-line reconciliations. Six public methods (reconcile_one, reconcile_batch, suggest_matches, accept_suggestion, write_off, unreconcile) form the only sanctioned write path to account.partial.reconcile from the rest of the module (controllers, AI tools, wizards). Implementation follows V19's bank_rec_widget pattern: rewrite the bank move's suspense line into one counterpart per matched invoice (or a write-off line) on the appropriate receivable / payable / write-off account, then call account.move.line.reconcile() on each pair. Records a precedent row per reconcile for downstream pattern learning. 16 new unit tests cover all six methods across happy paths, the precedent side effect, suggestion lifecycle, batch auto-strategy, and write-off line clearance. 67 total tests, 0 failed. Made-with: Cursor
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
'name': 'Fusion Accounting — Bank Reconciliation',
|
'name': 'Fusion Accounting — Bank Reconciliation',
|
||||||
'version': '19.0.1.0.4',
|
'version': '19.0.1.0.5',
|
||||||
'category': 'Accounting/Accounting',
|
'category': 'Accounting/Accounting',
|
||||||
'sequence': 28,
|
'sequence': 28,
|
||||||
'summary': 'Native V19 bank reconciliation widget with AI confidence scoring + behavioural learning.',
|
'summary': 'Native V19 bank reconciliation widget with AI confidence scoring + behavioural learning.',
|
||||||
|
|||||||
@@ -4,3 +4,4 @@ from . import fusion_reconcile_suggestion
|
|||||||
from . import fusion_bank_rec_widget
|
from . import fusion_bank_rec_widget
|
||||||
from . import account_bank_statement_line
|
from . import account_bank_statement_line
|
||||||
from . import account_reconcile_model
|
from . import account_reconcile_model
|
||||||
|
from . import fusion_reconcile_engine
|
||||||
|
|||||||
422
fusion_accounting_bank_rec/models/fusion_reconcile_engine.py
Normal file
422
fusion_accounting_bank_rec/models/fusion_reconcile_engine.py
Normal file
@@ -0,0 +1,422 @@
|
|||||||
|
"""The reconcile engine — orchestrator for all bank-line reconciliations.
|
||||||
|
|
||||||
|
Public API: 6 methods. All other code (controllers, AI tools, wizards)
|
||||||
|
must go through these methods; no direct ORM writes to
|
||||||
|
``account.partial.reconcile`` from anywhere else.
|
||||||
|
|
||||||
|
V19 mechanics (per Enterprise's bank_rec_widget pattern):
|
||||||
|
|
||||||
|
A bank statement line creates an ``account.move`` with two journal
|
||||||
|
items: a *liquidity* line on the journal's default account, and a
|
||||||
|
*suspense* line on the journal's suspense account. Reconciliation
|
||||||
|
replaces the suspense line with one or more *counterpart* lines posted
|
||||||
|
to the matched invoices' receivable / payable accounts (or the write-off
|
||||||
|
account), then calls Odoo's standard ``account.move.line.reconcile()``
|
||||||
|
on each counterpart + invoice pair.
|
||||||
|
|
||||||
|
Internal pipeline (per spec Section 3.3):
|
||||||
|
|
||||||
|
1. Validate (period not locked, mandatory args present).
|
||||||
|
2. Compute counterpart vals from ``against_lines`` and optional write-off.
|
||||||
|
3. Rewrite the bank move ``line_ids``: keep liquidity, drop suspense +
|
||||||
|
any prior other lines, append the new counterparts.
|
||||||
|
4. Reconcile each counterpart with its matched invoice line.
|
||||||
|
5. Audit (``mail.message``) + record precedent for future learning.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from odoo import _, api, fields, models
|
||||||
|
from odoo.exceptions import ValidationError
|
||||||
|
from odoo.fields import Command
|
||||||
|
|
||||||
|
from ..services.matching_strategies import (
|
||||||
|
AmountExactStrategy,
|
||||||
|
Candidate,
|
||||||
|
FIFOStrategy,
|
||||||
|
MultiInvoiceStrategy,
|
||||||
|
)
|
||||||
|
from ..services.confidence_scoring import score_candidates
|
||||||
|
from ..services.memo_tokenizer import tokenize_memo
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class FusionReconcileEngine(models.AbstractModel):
|
||||||
|
_name = "fusion.reconcile.engine"
|
||||||
|
_description = "Fusion Bank Reconciliation Engine"
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# PUBLIC API (6 methods)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def reconcile_one(self, statement_line, *, against_lines=None,
|
||||||
|
write_off_vals=None):
|
||||||
|
"""Reconcile one bank line against a set of journal items.
|
||||||
|
|
||||||
|
Returns: ``{'partial_ids': [...], 'exchange_diff_move_id': int|None,
|
||||||
|
'write_off_move_id': int|None}``
|
||||||
|
"""
|
||||||
|
if not statement_line:
|
||||||
|
raise ValidationError(_("statement_line is required"))
|
||||||
|
statement_line.ensure_one()
|
||||||
|
AML = self.env['account.move.line']
|
||||||
|
against_lines = against_lines or AML
|
||||||
|
if not against_lines and not write_off_vals:
|
||||||
|
raise ValidationError(
|
||||||
|
_("Either against_lines or write_off_vals required"))
|
||||||
|
|
||||||
|
self._validate_reconcile(statement_line, against_lines)
|
||||||
|
|
||||||
|
bank_move = statement_line.move_id
|
||||||
|
liquidity_lines, suspense_lines, other_lines = (
|
||||||
|
statement_line._seek_for_lines())
|
||||||
|
|
||||||
|
# Build the new counterpart lines that replace suspense.
|
||||||
|
new_counterpart_vals = []
|
||||||
|
for inv_line in against_lines:
|
||||||
|
new_counterpart_vals.append(self._build_counterpart_vals(
|
||||||
|
statement_line, inv_line))
|
||||||
|
|
||||||
|
write_off_move_id = None
|
||||||
|
if write_off_vals:
|
||||||
|
new_counterpart_vals.append(self._build_write_off_vals(
|
||||||
|
statement_line, write_off_vals, against_lines))
|
||||||
|
|
||||||
|
# Replace the bank move line_ids: keep liquidity, drop everything
|
||||||
|
# else, append new counterparts.
|
||||||
|
ops = []
|
||||||
|
for line in (suspense_lines | other_lines):
|
||||||
|
ops.append(Command.unlink(line.id))
|
||||||
|
for vals in new_counterpart_vals:
|
||||||
|
ops.append(Command.create(vals))
|
||||||
|
|
||||||
|
editable_move = bank_move.with_context(
|
||||||
|
force_delete=True, skip_readonly_check=True)
|
||||||
|
prior_line_ids = set(bank_move.line_ids.ids)
|
||||||
|
editable_move.write({'line_ids': ops})
|
||||||
|
|
||||||
|
new_lines = bank_move.line_ids.filtered(
|
||||||
|
lambda line: line.id not in prior_line_ids)
|
||||||
|
|
||||||
|
# Reconcile each new counterpart with its matched invoice line.
|
||||||
|
Partial = self.env['account.partial.reconcile']
|
||||||
|
new_partial_ids = []
|
||||||
|
for new_line, inv_line in zip(
|
||||||
|
new_lines[:len(against_lines)], against_lines):
|
||||||
|
pair = new_line | inv_line
|
||||||
|
existing = set(Partial.search([
|
||||||
|
'|',
|
||||||
|
('debit_move_id', 'in', pair.ids),
|
||||||
|
('credit_move_id', 'in', pair.ids),
|
||||||
|
]).ids)
|
||||||
|
pair.reconcile()
|
||||||
|
added = Partial.search([
|
||||||
|
'|',
|
||||||
|
('debit_move_id', 'in', pair.ids),
|
||||||
|
('credit_move_id', 'in', pair.ids),
|
||||||
|
]).filtered(lambda p: p.id not in existing)
|
||||||
|
new_partial_ids.extend(added.ids)
|
||||||
|
|
||||||
|
self._post_audit(
|
||||||
|
statement_line, new_partial_ids, source='engine.reconcile_one')
|
||||||
|
if against_lines:
|
||||||
|
self._record_precedent(statement_line, against_lines)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'partial_ids': new_partial_ids,
|
||||||
|
'exchange_diff_move_id': None,
|
||||||
|
'write_off_move_id': write_off_move_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def reconcile_batch(self, statement_lines, *, strategy='auto'):
|
||||||
|
"""Bulk-reconcile a recordset using the chosen strategy.
|
||||||
|
|
||||||
|
Returns: ``{'reconciled_count': int, 'skipped': int,
|
||||||
|
'errors': [...]}``
|
||||||
|
"""
|
||||||
|
reconciled = 0
|
||||||
|
skipped = 0
|
||||||
|
errors = []
|
||||||
|
for line in statement_lines:
|
||||||
|
if line.is_reconciled:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
candidates = self._fetch_candidates(line)
|
||||||
|
picked = self._apply_strategy(line, candidates, strategy)
|
||||||
|
if picked:
|
||||||
|
self.reconcile_one(line, against_lines=picked)
|
||||||
|
reconciled += 1
|
||||||
|
else:
|
||||||
|
skipped += 1
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
errors.append({'line_id': line.id, 'error': str(e)})
|
||||||
|
_logger.warning(
|
||||||
|
"reconcile_batch failed for line %s: %s", line.id, e)
|
||||||
|
return {
|
||||||
|
'reconciled_count': reconciled,
|
||||||
|
'skipped': skipped,
|
||||||
|
'errors': errors,
|
||||||
|
}
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def suggest_matches(self, statement_lines, *, limit_per_line=3):
|
||||||
|
"""Compute and persist AI suggestions per line.
|
||||||
|
|
||||||
|
Returns: dict mapping ``line_id`` -> list of suggestion dicts.
|
||||||
|
"""
|
||||||
|
out = {}
|
||||||
|
Suggestion = self.env['fusion.reconcile.suggestion']
|
||||||
|
for line in statement_lines:
|
||||||
|
candidates_records = self._fetch_candidates(line)
|
||||||
|
if not candidates_records:
|
||||||
|
continue
|
||||||
|
candidates_dataclasses = self._records_to_candidates(
|
||||||
|
line, candidates_records)
|
||||||
|
scored = score_candidates(
|
||||||
|
self.env,
|
||||||
|
statement_line=line,
|
||||||
|
candidates=candidates_dataclasses,
|
||||||
|
k=limit_per_line,
|
||||||
|
use_ai=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
Suggestion.search([
|
||||||
|
('statement_line_id', '=', line.id),
|
||||||
|
('state', '=', 'pending'),
|
||||||
|
]).write({'state': 'superseded'})
|
||||||
|
|
||||||
|
line_suggestions = []
|
||||||
|
for rank, s in enumerate(scored, start=1):
|
||||||
|
sug = Suggestion.create({
|
||||||
|
'company_id': line.company_id.id,
|
||||||
|
'statement_line_id': line.id,
|
||||||
|
'proposed_move_line_ids': [(6, 0, [s.candidate_id])],
|
||||||
|
'confidence': s.confidence,
|
||||||
|
'rank': rank,
|
||||||
|
'reasoning': s.reasoning,
|
||||||
|
'score_amount_match': s.score_amount_match,
|
||||||
|
'score_partner_pattern': s.score_partner_pattern,
|
||||||
|
'score_precedent_similarity': s.score_precedent_similarity,
|
||||||
|
'score_ai_rerank': s.score_ai_rerank,
|
||||||
|
'generated_by': 'on_demand',
|
||||||
|
'state': 'pending',
|
||||||
|
})
|
||||||
|
line_suggestions.append({
|
||||||
|
'id': sug.id,
|
||||||
|
'rank': rank,
|
||||||
|
'confidence': s.confidence,
|
||||||
|
'reasoning': s.reasoning,
|
||||||
|
'candidate_id': s.candidate_id,
|
||||||
|
})
|
||||||
|
out[line.id] = line_suggestions
|
||||||
|
return out
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def accept_suggestion(self, suggestion):
|
||||||
|
"""User clicked Accept on a suggestion -> reconcile via its proposal.
|
||||||
|
|
||||||
|
Returns: same shape as ``reconcile_one``.
|
||||||
|
"""
|
||||||
|
if isinstance(suggestion, int):
|
||||||
|
suggestion = self.env['fusion.reconcile.suggestion'].browse(
|
||||||
|
suggestion)
|
||||||
|
suggestion.ensure_one()
|
||||||
|
line = suggestion.statement_line_id
|
||||||
|
against = suggestion.proposed_move_line_ids
|
||||||
|
result = self.reconcile_one(line, against_lines=against)
|
||||||
|
suggestion.write({
|
||||||
|
'state': 'accepted',
|
||||||
|
'accepted_at': fields.Datetime.now(),
|
||||||
|
'accepted_by': self.env.uid,
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def write_off(self, statement_line, *, account, amount, label, tax_id=None):
|
||||||
|
"""Create a write-off move + reconcile the bank line against it.
|
||||||
|
|
||||||
|
Returns: same shape as ``reconcile_one``.
|
||||||
|
"""
|
||||||
|
write_off_vals = {
|
||||||
|
'account_id': account.id if hasattr(account, 'id') else account,
|
||||||
|
'amount': amount,
|
||||||
|
'tax_id': (tax_id.id if (tax_id and hasattr(tax_id, 'id'))
|
||||||
|
else tax_id),
|
||||||
|
'label': label,
|
||||||
|
}
|
||||||
|
return self.reconcile_one(
|
||||||
|
statement_line, against_lines=None, write_off_vals=write_off_vals)
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def unreconcile(self, partial_reconciles):
|
||||||
|
"""Reverse a reconciliation. Handles full vs. partial chains.
|
||||||
|
|
||||||
|
Returns: ``{'unreconciled_line_ids': [...]}``
|
||||||
|
"""
|
||||||
|
partial_reconciles = partial_reconciles.exists()
|
||||||
|
if not partial_reconciles:
|
||||||
|
return {'unreconciled_line_ids': []}
|
||||||
|
all_lines = (
|
||||||
|
partial_reconciles.mapped('debit_move_id')
|
||||||
|
| partial_reconciles.mapped('credit_move_id')
|
||||||
|
)
|
||||||
|
line_ids = all_lines.ids
|
||||||
|
partial_reconciles.unlink()
|
||||||
|
return {'unreconciled_line_ids': line_ids}
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# PRIVATE HELPERS
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def _validate_reconcile(self, statement_line, against_lines):
|
||||||
|
"""Phase 2: structural + safety checks."""
|
||||||
|
if not statement_line.exists():
|
||||||
|
raise ValidationError(_("Statement line does not exist"))
|
||||||
|
company = statement_line.company_id
|
||||||
|
line_date = statement_line.date
|
||||||
|
lock_date = company.fiscalyear_lock_date
|
||||||
|
if lock_date and line_date and line_date <= lock_date:
|
||||||
|
raise ValidationError(_(
|
||||||
|
"Cannot reconcile: line date %(line)s is on or before fiscal "
|
||||||
|
"year lock date %(lock)s",
|
||||||
|
line=line_date,
|
||||||
|
lock=lock_date,
|
||||||
|
))
|
||||||
|
|
||||||
|
def _build_counterpart_vals(self, statement_line, inv_line):
|
||||||
|
"""Build the vals for one counterpart line that mirrors an invoice
|
||||||
|
line on the bank move."""
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _build_write_off_vals(self, statement_line, write_off_vals,
|
||||||
|
against_lines):
|
||||||
|
"""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.
|
||||||
|
"""
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
if write_off_vals.get('tax_id'):
|
||||||
|
vals['tax_ids'] = [(6, 0, [write_off_vals['tax_id']])]
|
||||||
|
return vals
|
||||||
|
|
||||||
|
def _fetch_candidates(self, statement_line):
|
||||||
|
"""SQL pre-filter: open journal items matching partner + reconcilable
|
||||||
|
account."""
|
||||||
|
domain = [
|
||||||
|
('parent_state', '=', 'posted'),
|
||||||
|
('account_id.reconcile', '=', True),
|
||||||
|
('reconciled', '=', False),
|
||||||
|
('display_type', 'not in', ('line_section', 'line_note')),
|
||||||
|
]
|
||||||
|
if statement_line.partner_id:
|
||||||
|
domain.append(('partner_id', '=', statement_line.partner_id.id))
|
||||||
|
return self.env['account.move.line'].search(domain, limit=200)
|
||||||
|
|
||||||
|
def _records_to_candidates(self, statement_line, records):
|
||||||
|
"""Convert ``account.move.line`` recordset to ``Candidate`` dataclasses."""
|
||||||
|
today = fields.Date.today()
|
||||||
|
result = []
|
||||||
|
for c in records:
|
||||||
|
ref_date = c.date_maturity or c.date or today
|
||||||
|
age_days = (today - ref_date).days
|
||||||
|
result.append(Candidate(
|
||||||
|
id=c.id,
|
||||||
|
amount=abs(c.amount_residual) or abs(c.balance),
|
||||||
|
partner_id=c.partner_id.id if c.partner_id else 0,
|
||||||
|
age_days=age_days,
|
||||||
|
))
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _apply_strategy(self, line, candidate_records, strategy):
|
||||||
|
"""Apply the named strategy. Returns matching ``account.move.line``
|
||||||
|
recordset, or empty recordset if nothing matched."""
|
||||||
|
AML = self.env['account.move.line']
|
||||||
|
if not candidate_records:
|
||||||
|
return AML
|
||||||
|
candidate_dcs = self._records_to_candidates(line, candidate_records)
|
||||||
|
bank_amount = abs(line.amount)
|
||||||
|
if strategy == 'auto':
|
||||||
|
for strat_class in (AmountExactStrategy,
|
||||||
|
MultiInvoiceStrategy,
|
||||||
|
FIFOStrategy):
|
||||||
|
result = strat_class().match(
|
||||||
|
bank_amount=bank_amount, candidates=candidate_dcs)
|
||||||
|
if result.picked_ids:
|
||||||
|
return AML.browse(result.picked_ids)
|
||||||
|
return AML
|
||||||
|
|
||||||
|
def _post_audit(self, statement_line, partial_ids, source):
|
||||||
|
"""Append an audit log to the bank-line move's chatter."""
|
||||||
|
if not statement_line.move_id:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
statement_line.move_id.message_post(
|
||||||
|
body=_(
|
||||||
|
"Reconciled via %(source)s; %(count)d partial(s) created: "
|
||||||
|
"%(ids)s",
|
||||||
|
source=source,
|
||||||
|
count=len(partial_ids),
|
||||||
|
ids=partial_ids,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
_logger.debug(
|
||||||
|
"Audit log skipped for line %s: %s", statement_line.id, e)
|
||||||
|
|
||||||
|
def _record_precedent(self, statement_line, against_lines):
|
||||||
|
"""Append a precedent for future pattern learning. Best-effort."""
|
||||||
|
if not against_lines:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self.env['fusion.reconcile.precedent'].sudo().create({
|
||||||
|
'company_id': statement_line.company_id.id,
|
||||||
|
'partner_id': (statement_line.partner_id.id
|
||||||
|
if statement_line.partner_id else False),
|
||||||
|
'amount': abs(statement_line.amount),
|
||||||
|
'currency_id': statement_line.currency_id.id,
|
||||||
|
'date': statement_line.date,
|
||||||
|
'memo_tokens': ','.join(
|
||||||
|
tokenize_memo(statement_line.payment_ref)),
|
||||||
|
'journal_id': statement_line.journal_id.id,
|
||||||
|
'matched_move_line_count': len(against_lines),
|
||||||
|
'matched_account_ids': ','.join(
|
||||||
|
str(i) for i in against_lines.mapped('account_id').ids),
|
||||||
|
'reconciler_user_id': self.env.uid,
|
||||||
|
'reconciled_at': fields.Datetime.now(),
|
||||||
|
'source': 'manual',
|
||||||
|
})
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
_logger.warning(
|
||||||
|
"Failed to record precedent for line %s: %s",
|
||||||
|
statement_line.id, e)
|
||||||
@@ -5,3 +5,4 @@ from . import test_ai_suggestion_lifecycle
|
|||||||
from . import test_precedent_lookup
|
from . import test_precedent_lookup
|
||||||
from . import test_pattern_extraction
|
from . import test_pattern_extraction
|
||||||
from . import test_confidence_scoring
|
from . import test_confidence_scoring
|
||||||
|
from . import test_reconcile_engine_unit
|
||||||
|
|||||||
348
fusion_accounting_bank_rec/tests/test_reconcile_engine_unit.py
Normal file
348
fusion_accounting_bank_rec/tests/test_reconcile_engine_unit.py
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
"""Unit tests for fusion.reconcile.engine — the 6-method public API.
|
||||||
|
|
||||||
|
Test layers:
|
||||||
|
- Layer 1: API surface (registry + method existence)
|
||||||
|
- Layer 2: unreconcile
|
||||||
|
- Layer 3: reconcile_one happy path
|
||||||
|
- Layer 4: accept_suggestion
|
||||||
|
- Layer 5: suggest_matches
|
||||||
|
- Layer 6: reconcile_batch
|
||||||
|
- Layer 7: write_off
|
||||||
|
|
||||||
|
Tests share a common setUpClass fixture providing a partner, bank
|
||||||
|
journal, statement, receivable account, and a small helper to mint a
|
||||||
|
posted customer invoice + bank statement line at given amounts.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from odoo.exceptions import ValidationError
|
||||||
|
from odoo.tests.common import TransactionCase, tagged
|
||||||
|
|
||||||
|
|
||||||
|
@tagged('post_install', '-at_install')
|
||||||
|
class TestReconcileEngineBase(TransactionCase):
|
||||||
|
"""Shared fixtures for engine tests."""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
super().setUpClass()
|
||||||
|
cls.engine = cls.env['fusion.reconcile.engine']
|
||||||
|
cls.company = cls.env.company
|
||||||
|
cls.currency = cls.company.currency_id
|
||||||
|
cls.partner = cls.env['res.partner'].create({
|
||||||
|
'name': 'Engine Test Partner',
|
||||||
|
})
|
||||||
|
cls.bank_journal = cls.env['account.journal'].create({
|
||||||
|
'name': 'Engine Test Bank',
|
||||||
|
'type': 'bank',
|
||||||
|
'code': 'ETBK',
|
||||||
|
'company_id': cls.company.id,
|
||||||
|
})
|
||||||
|
cls.sales_journal = cls.env['account.journal'].search([
|
||||||
|
('type', '=', 'sale'),
|
||||||
|
('company_id', '=', cls.company.id),
|
||||||
|
], limit=1)
|
||||||
|
if not cls.sales_journal:
|
||||||
|
cls.sales_journal = cls.env['account.journal'].create({
|
||||||
|
'name': 'Engine Test Sales',
|
||||||
|
'type': 'sale',
|
||||||
|
'code': 'ETSAL',
|
||||||
|
'company_id': cls.company.id,
|
||||||
|
})
|
||||||
|
cls.receivable_account = cls.env['account.account'].search([
|
||||||
|
('account_type', '=', 'asset_receivable'),
|
||||||
|
('company_ids', 'in', cls.company.id),
|
||||||
|
], limit=1)
|
||||||
|
cls.income_account = cls.env['account.account'].search([
|
||||||
|
('account_type', '=', 'income'),
|
||||||
|
('company_ids', 'in', cls.company.id),
|
||||||
|
], limit=1)
|
||||||
|
cls.expense_account = cls.env['account.account'].search([
|
||||||
|
('account_type', '=', 'expense'),
|
||||||
|
('company_ids', 'in', cls.company.id),
|
||||||
|
], limit=1)
|
||||||
|
|
||||||
|
def _make_statement_line(self, amount, *, partner=None, ref='ENGTEST',
|
||||||
|
line_date=None):
|
||||||
|
statement = self.env['account.bank.statement'].create({
|
||||||
|
'name': 'Engine Test Statement',
|
||||||
|
'journal_id': self.bank_journal.id,
|
||||||
|
})
|
||||||
|
return self.env['account.bank.statement.line'].create({
|
||||||
|
'statement_id': statement.id,
|
||||||
|
'journal_id': self.bank_journal.id,
|
||||||
|
'date': line_date or date.today(),
|
||||||
|
'payment_ref': ref,
|
||||||
|
'amount': amount,
|
||||||
|
'partner_id': (partner or self.partner).id,
|
||||||
|
})
|
||||||
|
|
||||||
|
def _make_invoice(self, amount, *, partner=None, inv_date=None):
|
||||||
|
"""Create + post a customer invoice for the given amount."""
|
||||||
|
inv = self.env['account.move'].create({
|
||||||
|
'move_type': 'out_invoice',
|
||||||
|
'partner_id': (partner or self.partner).id,
|
||||||
|
'invoice_date': inv_date or date.today(),
|
||||||
|
'journal_id': self.sales_journal.id,
|
||||||
|
'invoice_line_ids': [(0, 0, {
|
||||||
|
'name': 'Engine test product',
|
||||||
|
'quantity': 1,
|
||||||
|
'price_unit': amount,
|
||||||
|
'account_id': self.income_account.id,
|
||||||
|
'tax_ids': [(6, 0, [])],
|
||||||
|
})],
|
||||||
|
})
|
||||||
|
inv.action_post()
|
||||||
|
return inv
|
||||||
|
|
||||||
|
def _receivable_line(self, invoice):
|
||||||
|
return invoice.line_ids.filtered(
|
||||||
|
lambda line: line.account_id.account_type == 'asset_receivable'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Layer 1: API surface
|
||||||
|
# ============================================================
|
||||||
|
@tagged('post_install', '-at_install')
|
||||||
|
class TestReconcileEngineApi(TestReconcileEngineBase):
|
||||||
|
"""Layer 1: the engine class exists in the registry and exposes the
|
||||||
|
six expected methods."""
|
||||||
|
|
||||||
|
def test_engine_in_registry(self):
|
||||||
|
self.assertIn('fusion.reconcile.engine', self.env.registry)
|
||||||
|
|
||||||
|
def test_engine_is_abstract_model(self):
|
||||||
|
engine = self.env['fusion.reconcile.engine']
|
||||||
|
self.assertTrue(engine._abstract)
|
||||||
|
|
||||||
|
def test_six_public_methods_callable(self):
|
||||||
|
engine = self.env['fusion.reconcile.engine']
|
||||||
|
for name in ('reconcile_one', 'reconcile_batch', 'suggest_matches',
|
||||||
|
'accept_suggestion', 'write_off', 'unreconcile'):
|
||||||
|
self.assertTrue(callable(getattr(engine, name, None)),
|
||||||
|
f"engine.{name} must be callable")
|
||||||
|
|
||||||
|
def test_reconcile_one_requires_arguments(self):
|
||||||
|
line = self._make_statement_line(100.0)
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
self.engine.reconcile_one(line)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Layer 2: unreconcile
|
||||||
|
# ============================================================
|
||||||
|
@tagged('post_install', '-at_install')
|
||||||
|
class TestReconcileEngineUnreconcile(TestReconcileEngineBase):
|
||||||
|
|
||||||
|
def test_unreconcile_removes_partial_reconcile(self):
|
||||||
|
line = self._make_statement_line(100.0)
|
||||||
|
invoice = self._make_invoice(100.0)
|
||||||
|
receivable = self._receivable_line(invoice)
|
||||||
|
result = self.engine.reconcile_one(
|
||||||
|
line, against_lines=receivable)
|
||||||
|
self.assertTrue(result['partial_ids'],
|
||||||
|
"reconcile_one should produce partial_ids")
|
||||||
|
partials = self.env['account.partial.reconcile'].browse(
|
||||||
|
result['partial_ids']).exists()
|
||||||
|
self.assertTrue(partials)
|
||||||
|
|
||||||
|
out = self.engine.unreconcile(partials)
|
||||||
|
|
||||||
|
self.assertIn('unreconciled_line_ids', out)
|
||||||
|
self.assertTrue(out['unreconciled_line_ids'])
|
||||||
|
self.assertFalse(partials.exists(),
|
||||||
|
"Partials should be deleted after unreconcile")
|
||||||
|
receivable.invalidate_recordset(['reconciled', 'amount_residual'])
|
||||||
|
self.assertFalse(receivable.reconciled)
|
||||||
|
|
||||||
|
def test_unreconcile_empty_recordset_returns_empty(self):
|
||||||
|
empty = self.env['account.partial.reconcile']
|
||||||
|
out = self.engine.unreconcile(empty)
|
||||||
|
self.assertEqual(out, {'unreconciled_line_ids': []})
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Layer 3: reconcile_one happy path
|
||||||
|
# ============================================================
|
||||||
|
@tagged('post_install', '-at_install')
|
||||||
|
class TestReconcileEngineReconcileOne(TestReconcileEngineBase):
|
||||||
|
|
||||||
|
def test_reconcile_one_simple_invoice_match(self):
|
||||||
|
line = self._make_statement_line(250.0)
|
||||||
|
invoice = self._make_invoice(250.0)
|
||||||
|
receivable = self._receivable_line(invoice)
|
||||||
|
self.assertFalse(receivable.reconciled)
|
||||||
|
|
||||||
|
result = self.engine.reconcile_one(
|
||||||
|
line, against_lines=receivable)
|
||||||
|
|
||||||
|
self.assertIsInstance(result, dict)
|
||||||
|
self.assertIn('partial_ids', result)
|
||||||
|
self.assertIn('exchange_diff_move_id', result)
|
||||||
|
self.assertIn('write_off_move_id', result)
|
||||||
|
self.assertTrue(result['partial_ids'])
|
||||||
|
|
||||||
|
receivable.invalidate_recordset(['reconciled', 'amount_residual'])
|
||||||
|
self.assertTrue(receivable.reconciled)
|
||||||
|
self.assertAlmostEqual(receivable.amount_residual, 0.0, places=2)
|
||||||
|
|
||||||
|
def test_reconcile_one_creates_precedent(self):
|
||||||
|
line = self._make_statement_line(125.0, ref='Engine REF#42')
|
||||||
|
invoice = self._make_invoice(125.0)
|
||||||
|
receivable = self._receivable_line(invoice)
|
||||||
|
before = self.env['fusion.reconcile.precedent'].search_count([
|
||||||
|
('partner_id', '=', self.partner.id),
|
||||||
|
])
|
||||||
|
self.engine.reconcile_one(line, against_lines=receivable)
|
||||||
|
after = self.env['fusion.reconcile.precedent'].search_count([
|
||||||
|
('partner_id', '=', self.partner.id),
|
||||||
|
])
|
||||||
|
self.assertEqual(after, before + 1)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Layer 4: accept_suggestion
|
||||||
|
# ============================================================
|
||||||
|
@tagged('post_install', '-at_install')
|
||||||
|
class TestReconcileEngineAcceptSuggestion(TestReconcileEngineBase):
|
||||||
|
|
||||||
|
def test_accept_suggestion_reconciles_and_marks_accepted(self):
|
||||||
|
line = self._make_statement_line(310.0)
|
||||||
|
invoice = self._make_invoice(310.0)
|
||||||
|
receivable = self._receivable_line(invoice)
|
||||||
|
sug = self.env['fusion.reconcile.suggestion'].create({
|
||||||
|
'company_id': self.company.id,
|
||||||
|
'statement_line_id': line.id,
|
||||||
|
'proposed_move_line_ids': [(6, 0, receivable.ids)],
|
||||||
|
'confidence': 0.97,
|
||||||
|
'rank': 1,
|
||||||
|
'reasoning': 'Exact amount match',
|
||||||
|
'state': 'pending',
|
||||||
|
})
|
||||||
|
|
||||||
|
result = self.engine.accept_suggestion(sug)
|
||||||
|
|
||||||
|
self.assertTrue(result['partial_ids'])
|
||||||
|
self.assertEqual(sug.state, 'accepted')
|
||||||
|
self.assertTrue(sug.accepted_at)
|
||||||
|
self.assertEqual(sug.accepted_by, self.env.user)
|
||||||
|
|
||||||
|
def test_accept_suggestion_by_id(self):
|
||||||
|
line = self._make_statement_line(75.0)
|
||||||
|
invoice = self._make_invoice(75.0)
|
||||||
|
receivable = self._receivable_line(invoice)
|
||||||
|
sug = self.env['fusion.reconcile.suggestion'].create({
|
||||||
|
'company_id': self.company.id,
|
||||||
|
'statement_line_id': line.id,
|
||||||
|
'proposed_move_line_ids': [(6, 0, receivable.ids)],
|
||||||
|
'confidence': 0.91,
|
||||||
|
'rank': 1,
|
||||||
|
'reasoning': 'OK',
|
||||||
|
'state': 'pending',
|
||||||
|
})
|
||||||
|
result = self.engine.accept_suggestion(sug.id)
|
||||||
|
self.assertTrue(result['partial_ids'])
|
||||||
|
self.assertEqual(sug.state, 'accepted')
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Layer 5: suggest_matches
|
||||||
|
# ============================================================
|
||||||
|
@tagged('post_install', '-at_install')
|
||||||
|
class TestReconcileEngineSuggestMatches(TestReconcileEngineBase):
|
||||||
|
|
||||||
|
def test_suggest_matches_persists_pending_suggestions(self):
|
||||||
|
line = self._make_statement_line(420.0)
|
||||||
|
invoice = self._make_invoice(420.0)
|
||||||
|
# second open invoice for same partner — also a candidate
|
||||||
|
self._make_invoice(99.0)
|
||||||
|
|
||||||
|
out = self.engine.suggest_matches(line)
|
||||||
|
|
||||||
|
self.assertIn(line.id, out)
|
||||||
|
self.assertTrue(out[line.id])
|
||||||
|
suggestions = self.env['fusion.reconcile.suggestion'].search([
|
||||||
|
('statement_line_id', '=', line.id),
|
||||||
|
('state', '=', 'pending'),
|
||||||
|
])
|
||||||
|
self.assertTrue(suggestions)
|
||||||
|
# Top suggestion should reference the matching invoice's receivable
|
||||||
|
top = max(suggestions, key=lambda s: s.confidence)
|
||||||
|
receivable = self._receivable_line(invoice)
|
||||||
|
self.assertIn(receivable.id, top.proposed_move_line_ids.ids)
|
||||||
|
|
||||||
|
def test_suggest_matches_supersedes_prior_pending(self):
|
||||||
|
line = self._make_statement_line(180.0)
|
||||||
|
self._make_invoice(180.0)
|
||||||
|
old_sug = self.env['fusion.reconcile.suggestion'].create({
|
||||||
|
'company_id': self.company.id,
|
||||||
|
'statement_line_id': line.id,
|
||||||
|
'confidence': 0.5,
|
||||||
|
'rank': 1,
|
||||||
|
'reasoning': 'prior',
|
||||||
|
'state': 'pending',
|
||||||
|
})
|
||||||
|
|
||||||
|
self.engine.suggest_matches(line)
|
||||||
|
|
||||||
|
old_sug.invalidate_recordset(['state'])
|
||||||
|
self.assertEqual(old_sug.state, 'superseded')
|
||||||
|
|
||||||
|
def test_suggest_matches_returns_empty_for_no_candidates(self):
|
||||||
|
partner = self.env['res.partner'].create({'name': 'Empty Partner'})
|
||||||
|
line = self._make_statement_line(10.0, partner=partner)
|
||||||
|
out = self.engine.suggest_matches(line)
|
||||||
|
self.assertEqual(out, {})
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Layer 6: reconcile_batch
|
||||||
|
# ============================================================
|
||||||
|
@tagged('post_install', '-at_install')
|
||||||
|
class TestReconcileEngineBatch(TestReconcileEngineBase):
|
||||||
|
|
||||||
|
def test_reconcile_batch_auto_strategy_matches_n_lines(self):
|
||||||
|
amounts = [100.0, 200.0, 333.33]
|
||||||
|
lines = self.env['account.bank.statement.line']
|
||||||
|
for amt in amounts:
|
||||||
|
invoice = self._make_invoice(amt)
|
||||||
|
self.assertTrue(invoice)
|
||||||
|
lines |= self._make_statement_line(amt, ref=f'BATCH-{amt}')
|
||||||
|
|
||||||
|
result = self.engine.reconcile_batch(lines, strategy='auto')
|
||||||
|
|
||||||
|
self.assertEqual(result['reconciled_count'], len(amounts))
|
||||||
|
self.assertEqual(result['skipped'], 0)
|
||||||
|
self.assertEqual(result['errors'], [])
|
||||||
|
|
||||||
|
def test_reconcile_batch_skips_already_reconciled(self):
|
||||||
|
line = self._make_statement_line(50.0)
|
||||||
|
invoice = self._make_invoice(50.0)
|
||||||
|
receivable = self._receivable_line(invoice)
|
||||||
|
self.engine.reconcile_one(line, against_lines=receivable)
|
||||||
|
|
||||||
|
result = self.engine.reconcile_batch(line, strategy='auto')
|
||||||
|
self.assertEqual(result['reconciled_count'], 0)
|
||||||
|
self.assertEqual(result['skipped'], 1)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Layer 7: write_off
|
||||||
|
# ============================================================
|
||||||
|
@tagged('post_install', '-at_install')
|
||||||
|
class TestReconcileEngineWriteOff(TestReconcileEngineBase):
|
||||||
|
|
||||||
|
def test_write_off_clears_bank_line(self):
|
||||||
|
line = self._make_statement_line(40.0, ref='Bank fee')
|
||||||
|
# No invoices exist; write off the whole amount to expense.
|
||||||
|
result = self.engine.write_off(
|
||||||
|
line,
|
||||||
|
account=self.expense_account,
|
||||||
|
amount=40.0,
|
||||||
|
label='Bank fees',
|
||||||
|
)
|
||||||
|
self.assertIn('write_off_move_id', result)
|
||||||
|
line.invalidate_recordset(['is_reconciled'])
|
||||||
|
self.assertTrue(line.is_reconciled)
|
||||||
Reference in New Issue
Block a user