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