When fusion_accounting_bank_rec is installed, match_bank_line_to_payments and auto_reconcile_bank_lines now use fusion.reconcile.engine via the BankRecAdapter, gaining precedent recording, AI suggestion superseding, and shared validation. Legacy paths preserved for Enterprise/Community- only installs (engine model absent -> fall back to set_line_bank_statement_line and _try_auto_reconcile_statement_lines). Also wraps engine.reconcile_batch's per-line loop in a savepoint so a single bad line's DB error (e.g. check-constraint violation) no longer poisons the whole batch transaction; the existing per-line try/except now isolates failures as originally intended. Made-with: Cursor
482 lines
20 KiB
Python
482 lines
20 KiB
Python
"""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())
|
|
|
|
# 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,
|
|
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, balance=wo_balance,
|
|
))
|
|
remaining = 0
|
|
|
|
# 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.
|
|
# 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 = []
|
|
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([
|
|
'|',
|
|
('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
|
|
# Per-line savepoint so a single DB-level failure (e.g. a
|
|
# check-constraint violation on one bad line) doesn't poison
|
|
# the whole batch's transaction.
|
|
try:
|
|
with self.env.cr.savepoint():
|
|
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.
|
|
|
|
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()
|
|
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
|
|
# 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}
|
|
|
|
# ============================================================
|
|
# 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, *,
|
|
allocated_balance):
|
|
"""Build the vals for one counterpart line that mirrors an invoice
|
|
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': amount_currency,
|
|
'balance': allocated_balance,
|
|
}
|
|
|
|
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.
|
|
|
|
``balance`` is the signed company-currency balance the write-off
|
|
line must carry to keep the bank move balanced.
|
|
"""
|
|
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': 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)
|