"""HTTP controller: 10 JSON-RPC endpoints for the OWL bank-rec widget. All endpoints route through ``BankRecAdapter`` (which lives in ``fusion_accounting_ai`` and already encapsulates fusion / enterprise / community routing) or directly through ``fusion.reconcile.engine`` for methods the adapter does not yet expose. The controller never touches ``account.partial.reconcile`` directly. V19: uses ``@route(type='jsonrpc')``, the V19-blessed replacement for the deprecated ``type='json'`` (Odoo 19 logs a deprecation warning if you still use ``json``). """ import logging from odoo import _, http from odoo.exceptions import ValidationError from odoo.http import request _logger = logging.getLogger(__name__) def _adapter(): """Resolve the bank-rec data adapter from fusion_accounting_ai.""" from odoo.addons.fusion_accounting_ai.services.data_adapters import ( get_adapter, ) return get_adapter(request.env, 'bank_rec') class FusionBankRecController(http.Controller): """JSON-RPC surface consumed by the OWL bank-reconciliation widget. All routes are ``auth='user'`` -- anonymous traffic is rejected by Odoo's HTTP layer before reaching the handler. """ # ------------------------------------------------------------------ # 1. get_state -- initial widget bootstrap # ------------------------------------------------------------------ @http.route('/fusion/bank_rec/get_state', type='jsonrpc', auth='user') def get_state(self, journal_id, company_id): """Return the journal summary that seeds the kanban widget.""" Journal = request.env['account.journal'] Line = request.env['account.bank.statement.line'] journal = Journal.browse(int(journal_id)) if not journal.exists(): raise ValidationError(_("Journal %s not found") % journal_id) company_id = int(company_id) if company_id else request.env.company.id unreconciled_lines = Line.search([ ('journal_id', '=', journal.id), ('is_reconciled', '=', False), ('company_id', '=', company_id), ]) total_amount = sum(abs(l.amount) for l in unreconciled_lines) last_stmt = request.env['account.bank.statement'].search( [('journal_id', '=', journal.id)], order='date desc', limit=1) currency = journal.currency_id or journal.company_id.currency_id return { 'journal': { 'id': journal.id, 'name': journal.name, 'currency_code': currency.name, }, 'unreconciled_count': len(unreconciled_lines), 'total_pending_amount': total_amount, 'last_statement_date': str(last_stmt.date) if last_stmt and last_stmt.date else None, } # ------------------------------------------------------------------ # 2. list_unreconciled -- paginated, fusion-enriched # ------------------------------------------------------------------ @http.route('/fusion/bank_rec/list_unreconciled', type='jsonrpc', auth='user') def list_unreconciled(self, journal_id, limit=50, offset=0, company_id=None, date_from=None, date_to=None, min_amount=None): """Return enriched, paginated unreconciled bank lines.""" limit = int(limit) offset = int(offset) company_id = (int(company_id) if company_id else request.env.company.id) # The adapter doesn't take an offset; over-fetch and slice. rows = _adapter().list_unreconciled( journal_id=int(journal_id), limit=limit + offset, company_id=company_id, date_from=date_from, date_to=date_to, min_amount=min_amount, ) sliced = rows[offset:offset + limit] Line = request.env['account.bank.statement.line'] domain = [ ('journal_id', '=', int(journal_id)), ('is_reconciled', '=', False), ('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', '>=', float(min_amount))) total = Line.search_count(domain) return { 'count': len(sliced), 'total': total, 'lines': sliced, } # ------------------------------------------------------------------ # 3. get_line_detail -- one line + suggestions + attachments # ------------------------------------------------------------------ @http.route('/fusion/bank_rec/get_line_detail', type='jsonrpc', auth='user') def get_line_detail(self, statement_line_id): """Return full detail for one line including pending suggestions.""" Line = request.env['account.bank.statement.line'] line = Line.browse(int(statement_line_id)) if not line.exists(): raise ValidationError( _("Statement line %s not found") % statement_line_id) Sug = request.env['fusion.reconcile.suggestion'] suggestions = Sug.search([ ('statement_line_id', '=', line.id), ('state', '=', 'pending'), ], order='confidence desc, rank asc') Att = request.env['ir.attachment'] attachments = Att.search([ ('res_model', '=', 'account.move'), ('res_id', '=', line.move_id.id), ]) if line.move_id else Att.browse() currency = line.currency_id or line.company_id.currency_id return { 'line': { 'id': line.id, 'date': str(line.date) if line.date else None, 'payment_ref': line.payment_ref or '', 'amount': line.amount, 'partner_id': line.partner_id.id if line.partner_id else None, 'partner_name': line.partner_id.name if line.partner_id else None, 'currency_id': currency.id, 'currency_code': currency.name, 'journal_id': line.journal_id.id, 'journal_name': line.journal_id.name, 'is_reconciled': line.is_reconciled, }, 'suggestions': [{ 'id': s.id, 'candidate_ids': s.proposed_move_line_ids.ids, 'confidence': s.confidence, 'rank': s.rank, 'reasoning': s.reasoning or '', 'scores': { 'amount_match': s.score_amount_match, 'partner_pattern': s.score_partner_pattern, 'precedent_similarity': s.score_precedent_similarity, 'ai_rerank': s.score_ai_rerank, }, } for s in suggestions], 'attachments': [{ 'id': a.id, 'name': a.name, 'mimetype': a.mimetype, } for a in attachments], } # ------------------------------------------------------------------ # 4. suggest_matches -- lazy AI suggest for a line # ------------------------------------------------------------------ @http.route('/fusion/bank_rec/suggest_matches', type='jsonrpc', auth='user') def suggest_matches(self, statement_line_ids, limit_per_line=3): """Trigger AI suggest for one or more statement lines.""" ids = [int(i) for i in (statement_line_ids or [])] result = _adapter().suggest_matches( statement_line_ids=ids, limit_per_line=int(limit_per_line), ) return {'suggestions': result} # ------------------------------------------------------------------ # 5. accept_suggestion -- promote AI suggestion to real reconcile # ------------------------------------------------------------------ @http.route('/fusion/bank_rec/accept_suggestion', type='jsonrpc', auth='user') def accept_suggestion(self, suggestion_id): """Accept a fusion suggestion. Returns the partial IDs created.""" sug = request.env['fusion.reconcile.suggestion'].browse( int(suggestion_id)) if not sug.exists(): raise ValidationError( _("Suggestion %s not found") % suggestion_id) # Capture the journal/company before reconcile (the sug may go stale). journal_id = sug.statement_line_id.journal_id.id company_id = sug.company_id.id result = _adapter().accept_suggestion(suggestion_id=int(suggestion_id)) unreconciled_count_after = request.env[ 'account.bank.statement.line'].search_count([ ('journal_id', '=', journal_id), ('is_reconciled', '=', False), ('company_id', '=', company_id), ]) return { 'status': 'accepted', 'partial_ids': result.get('partial_ids', []), 'unreconciled_count_after': unreconciled_count_after, } # ------------------------------------------------------------------ # 6. reconcile_manual -- user picked candidates manually # ------------------------------------------------------------------ @http.route('/fusion/bank_rec/reconcile_manual', type='jsonrpc', auth='user') def reconcile_manual(self, statement_line_id, against_move_line_ids): """Reconcile a line against an explicit set of journal items.""" line = request.env['account.bank.statement.line'].browse( int(statement_line_id)) if not line.exists(): raise ValidationError( _("Statement line %s not found") % statement_line_id) cands = request.env['account.move.line'].browse( [int(i) for i in (against_move_line_ids or [])]) result = request.env['fusion.reconcile.engine'].reconcile_one( line, against_lines=cands) return { 'status': 'reconciled', 'partial_ids': result.get('partial_ids', []), } # ------------------------------------------------------------------ # 7. unreconcile -- reverse a prior reconcile # ------------------------------------------------------------------ @http.route('/fusion/bank_rec/unreconcile', type='jsonrpc', auth='user') def unreconcile(self, partial_reconcile_ids): """Reverse one or more partial reconciles.""" ids = [int(i) for i in (partial_reconcile_ids or [])] result = _adapter().unreconcile(partial_reconcile_ids=ids) return { 'status': 'unreconciled', 'unreconciled_line_ids': result.get('unreconciled_line_ids', []), } # ------------------------------------------------------------------ # 8. write_off -- absorb residual into a write-off account # ------------------------------------------------------------------ @http.route('/fusion/bank_rec/write_off', type='jsonrpc', auth='user') def write_off(self, statement_line_id, account_id, amount, label, tax_id=None): """Apply a write-off against a bank statement line.""" line = request.env['account.bank.statement.line'].browse( int(statement_line_id)) if not line.exists(): raise ValidationError( _("Statement line %s not found") % statement_line_id) account = request.env['account.account'].browse(int(account_id)) tax = (request.env['account.tax'].browse(int(tax_id)) if tax_id else None) result = request.env['fusion.reconcile.engine'].write_off( line, account=account, amount=float(amount), tax_id=tax, label=label) return { 'status': 'written_off', 'partial_ids': result.get('partial_ids', []), 'write_off_move_id': result.get('write_off_move_id'), } # ------------------------------------------------------------------ # 9. bulk_reconcile -- batch auto-reconcile a recordset # ------------------------------------------------------------------ @http.route('/fusion/bank_rec/bulk_reconcile', type='jsonrpc', auth='user') def bulk_reconcile(self, statement_line_ids, strategy='auto'): """Batch auto-reconcile. Returns counts + per-line errors.""" ids = [int(i) for i in (statement_line_ids or [])] lines = request.env['account.bank.statement.line'].browse(ids) result = request.env['fusion.reconcile.engine'].reconcile_batch( lines, strategy=strategy) return result # ------------------------------------------------------------------ # 10. get_partner_history -- partner reconcile history panel # ------------------------------------------------------------------ @http.route('/fusion/bank_rec/get_partner_history', type='jsonrpc', auth='user') def get_partner_history(self, partner_id, limit=20): """Return a partner's reconcile history + learned pattern.""" Partner = request.env['res.partner'] partner = Partner.browse(int(partner_id)) if not partner.exists(): raise ValidationError(_("Partner %s not found") % partner_id) Precedent = request.env['fusion.reconcile.precedent'] recent = Precedent.search( [('partner_id', '=', partner.id)], order='reconciled_at desc, id desc', limit=int(limit), ) Pattern = request.env['fusion.reconcile.pattern'] pattern = Pattern.search( [('partner_id', '=', partner.id)], limit=1) return { 'partner': { 'id': partner.id, 'name': partner.name, }, 'recent_reconciles': [{ 'precedent_id': p.id, 'date': str(p.date) if p.date else None, 'amount': p.amount, 'memo_tokens': p.memo_tokens or '', 'matched_count': p.matched_move_line_count, 'source': p.source, } for p in recent], 'pattern': ({ 'reconcile_count': pattern.reconcile_count, 'pref_strategy': pattern.pref_strategy or None, 'common_memo_tokens': pattern.common_memo_tokens or None, 'typical_cadence_days': pattern.typical_cadence_days, } if pattern else None), }