Enhances list_unreconciled_via_fusion to include fusion fields (top_suggestion_id, confidence_band, attachment_count). Adds 3 new adapter methods that proxy the engine: suggest_matches, accept_suggestion, unreconcile. AI tools (Task 22+) and OWL controller (Task 26) will call these adapter methods instead of touching the engine directly. Made-with: Cursor
230 lines
9.8 KiB
Python
230 lines
9.8 KiB
Python
"""Bank reconciliation data adapter.
|
|
|
|
Routes bank-rec data lookups across:
|
|
- FUSION: fusion.bank.rec.widget (added by fusion_accounting_bank_rec, Phase 1)
|
|
- ENTERPRISE: account_accountant's bank_rec_widget JS service
|
|
- COMMUNITY: pure search on account.bank.statement.line
|
|
|
|
In addition to ``list_unreconciled``, the adapter exposes thin wrappers
|
|
around the engine's public API: ``suggest_matches``, ``accept_suggestion``,
|
|
``unreconcile``. AI tools and the OWL controller go through these wrappers
|
|
instead of touching the engine directly so install-mode routing stays in
|
|
one place.
|
|
"""
|
|
|
|
from .base import DataAdapter
|
|
from ._registry import register_adapter
|
|
|
|
|
|
class BankRecAdapter(DataAdapter):
|
|
FUSION_MODEL = 'fusion.bank.rec.widget'
|
|
ENTERPRISE_MODULE = 'account_accountant'
|
|
|
|
# ------------------------------------------------------------
|
|
# list_unreconciled
|
|
# ------------------------------------------------------------
|
|
|
|
def list_unreconciled(self, journal_id=None, limit=100, date_from=None,
|
|
date_to=None, min_amount=None, company_id=None):
|
|
"""Return unreconciled bank statement lines.
|
|
|
|
All filter params are optional; pass company_id to restrict results to
|
|
a single company (the AI tools always do this).
|
|
"""
|
|
return self._dispatch(
|
|
'list_unreconciled',
|
|
journal_id=journal_id, limit=limit,
|
|
date_from=date_from, date_to=date_to,
|
|
min_amount=min_amount, company_id=company_id,
|
|
)
|
|
|
|
def list_unreconciled_via_fusion(self, journal_id=None, limit=100,
|
|
date_from=None, date_to=None,
|
|
min_amount=None, company_id=None):
|
|
"""Community shape + fusion AI fields (top suggestion, band, attachments)."""
|
|
base = self.list_unreconciled_via_community(
|
|
journal_id=journal_id, limit=limit,
|
|
date_from=date_from, date_to=date_to,
|
|
min_amount=min_amount, company_id=company_id,
|
|
)
|
|
if not base:
|
|
return base
|
|
Line = self.env['account.bank.statement.line'].sudo()
|
|
ids = [row['id'] for row in base]
|
|
lines_by_id = {line.id: line for line in Line.browse(ids)}
|
|
for row in base:
|
|
line = lines_by_id.get(row['id'])
|
|
if not line:
|
|
row['fusion_top_suggestion_id'] = None
|
|
row['fusion_confidence_band'] = 'none'
|
|
row['attachment_count'] = 0
|
|
continue
|
|
top = line.fusion_top_suggestion_id
|
|
row['fusion_top_suggestion_id'] = top.id if top else None
|
|
row['fusion_confidence_band'] = line.fusion_confidence_band or 'none'
|
|
row['attachment_count'] = len(line.bank_statement_attachment_ids)
|
|
return base
|
|
|
|
def list_unreconciled_via_enterprise(self, journal_id=None, limit=100,
|
|
date_from=None, date_to=None,
|
|
min_amount=None, company_id=None):
|
|
# Enterprise's bank rec uses a JS-side service; from Python the cleanest
|
|
# backend access is the same Community search (the data lives in
|
|
# account.bank.statement.line either way). This adapter's purpose is
|
|
# to expose a stable shape to AI tools regardless of which UI the user has.
|
|
return self.list_unreconciled_via_community(
|
|
journal_id=journal_id, limit=limit,
|
|
date_from=date_from, date_to=date_to,
|
|
min_amount=min_amount, company_id=company_id,
|
|
)
|
|
|
|
def list_unreconciled_via_community(self, journal_id=None, limit=100,
|
|
date_from=None, date_to=None,
|
|
min_amount=None, company_id=None):
|
|
Line = self.env['account.bank.statement.line'].sudo()
|
|
domain = [('is_reconciled', '=', False)]
|
|
if journal_id is not None:
|
|
domain.append(('journal_id', '=', journal_id))
|
|
if company_id is not None:
|
|
domain.append(('company_id', '=', company_id))
|
|
if date_from:
|
|
domain.append(('date', '>=', date_from))
|
|
if date_to:
|
|
domain.append(('date', '<=', date_to))
|
|
if min_amount is not None:
|
|
domain.append(('amount', '>=', min_amount))
|
|
records = Line.search(domain, limit=limit, order='date desc, id desc')
|
|
return [
|
|
{
|
|
'id': r.id,
|
|
'date': r.date,
|
|
'payment_ref': r.payment_ref,
|
|
'amount': r.amount,
|
|
'partner_id': r.partner_id.id if r.partner_id else None,
|
|
'partner_name': r.partner_name or (r.partner_id.name if r.partner_id else None),
|
|
'currency_id': r.currency_id.id if r.currency_id else None,
|
|
'journal_id': r.journal_id.id,
|
|
'journal_name': r.journal_id.name,
|
|
}
|
|
for r in records
|
|
]
|
|
|
|
# ------------------------------------------------------------
|
|
# suggest_matches
|
|
# ------------------------------------------------------------
|
|
|
|
def suggest_matches(self, statement_line_ids, *, limit_per_line=3,
|
|
company_id=None):
|
|
"""Return AI suggestions per bank line.
|
|
|
|
Shape: ``{line_id: [{'id', 'rank', 'confidence', 'reasoning',
|
|
'candidate_id'}, ...]}``. Empty dict when AI suggestions are not
|
|
available (Enterprise / Community).
|
|
"""
|
|
return self._dispatch(
|
|
'suggest_matches',
|
|
statement_line_ids=statement_line_ids,
|
|
limit_per_line=limit_per_line,
|
|
company_id=company_id,
|
|
)
|
|
|
|
def suggest_matches_via_fusion(self, statement_line_ids, *,
|
|
limit_per_line=3, company_id=None):
|
|
Line = self.env['account.bank.statement.line'].sudo()
|
|
lines = Line.browse(list(statement_line_ids or [])).exists()
|
|
if not lines:
|
|
return {}
|
|
return self.env['fusion.reconcile.engine'].suggest_matches(
|
|
lines, limit_per_line=limit_per_line)
|
|
|
|
def suggest_matches_via_enterprise(self, statement_line_ids, *,
|
|
limit_per_line=3, company_id=None):
|
|
# Enterprise has its own suggest mechanism inside bank_rec_widget;
|
|
# we don't proxy it from Python.
|
|
return {}
|
|
|
|
def suggest_matches_via_community(self, statement_line_ids, *,
|
|
limit_per_line=3, company_id=None):
|
|
return {}
|
|
|
|
# ------------------------------------------------------------
|
|
# accept_suggestion
|
|
# ------------------------------------------------------------
|
|
|
|
def accept_suggestion(self, suggestion_id):
|
|
"""Accept a fusion AI suggestion and reconcile against its proposal.
|
|
|
|
Returns ``{'partial_ids': [...], 'exchange_diff_move_id': int|None,
|
|
'write_off_move_id': int|None}``. Fusion-only.
|
|
"""
|
|
return self._dispatch(
|
|
'accept_suggestion', suggestion_id=suggestion_id)
|
|
|
|
def accept_suggestion_via_fusion(self, suggestion_id):
|
|
return self.env['fusion.reconcile.engine'].accept_suggestion(
|
|
int(suggestion_id))
|
|
|
|
def accept_suggestion_via_enterprise(self, suggestion_id):
|
|
raise NotImplementedError("accept_suggestion is fusion-only")
|
|
|
|
def accept_suggestion_via_community(self, suggestion_id):
|
|
raise NotImplementedError("accept_suggestion is fusion-only")
|
|
|
|
# ------------------------------------------------------------
|
|
# unreconcile
|
|
# ------------------------------------------------------------
|
|
|
|
def unreconcile(self, partial_reconcile_ids):
|
|
"""Reverse a reconciliation by partial IDs.
|
|
|
|
Returns ``{'unreconciled_line_ids': [...]}``. Available in all modes
|
|
(the engine delegates to V19's standard
|
|
``account.bank.statement.line.action_undo_reconciliation``).
|
|
"""
|
|
return self._dispatch(
|
|
'unreconcile', partial_reconcile_ids=partial_reconcile_ids)
|
|
|
|
def unreconcile_via_fusion(self, partial_reconcile_ids):
|
|
Partial = self.env['account.partial.reconcile'].sudo()
|
|
partials = Partial.browse(list(partial_reconcile_ids or [])).exists()
|
|
return self.env['fusion.reconcile.engine'].unreconcile(partials)
|
|
|
|
def unreconcile_via_enterprise(self, partial_reconcile_ids):
|
|
# Enterprise/community paths can't depend on fusion.reconcile.engine
|
|
# being loaded (fusion_accounting_ai does NOT depend on
|
|
# fusion_accounting_bank_rec). Mirror the engine's behaviour using
|
|
# only Community-available helpers.
|
|
return self._unreconcile_standalone(partial_reconcile_ids)
|
|
|
|
def unreconcile_via_community(self, partial_reconcile_ids):
|
|
return self._unreconcile_standalone(partial_reconcile_ids)
|
|
|
|
def _unreconcile_standalone(self, partial_reconcile_ids):
|
|
"""Engine-free unreconcile for installs without fusion_accounting_bank_rec.
|
|
|
|
Mirrors ``fusion.reconcile.engine.unreconcile``: finds bank lines whose
|
|
moves own any of the partials' journal items, runs the standard undo
|
|
on them, then unlinks any leftovers.
|
|
"""
|
|
Partial = self.env['account.partial.reconcile'].sudo()
|
|
partials = Partial.browse(list(partial_reconcile_ids or [])).exists()
|
|
if not partials:
|
|
return {'unreconciled_line_ids': []}
|
|
all_lines = (
|
|
partials.mapped('debit_move_id')
|
|
| partials.mapped('credit_move_id')
|
|
)
|
|
line_ids = all_lines.ids
|
|
affected = self.env['account.bank.statement.line'].sudo().search([
|
|
('move_id', 'in', all_lines.mapped('move_id').ids),
|
|
])
|
|
if affected:
|
|
affected.action_undo_reconciliation()
|
|
remaining = partials.exists()
|
|
if remaining:
|
|
remaining.unlink()
|
|
return {'unreconciled_line_ids': line_ids}
|
|
|
|
|
|
register_adapter('bank_rec', BankRecAdapter)
|