Files
Odoo-Modules/fusion_accounting_bank_rec/controllers/bank_rec_controller.py
gsinghpal 6ecb1bbbee feat(fusion_accounting_bank_rec): 10 JSON-RPC endpoints for OWL widget
All endpoints route through fusion.reconcile.engine via BankRecAdapter
(or directly for engine methods adapter doesn't expose). Uses V19's
type='jsonrpc' (replacement for deprecated type='json'). Auth=user.

Endpoints:
- get_state, list_unreconciled, get_line_detail (read)
- suggest_matches, accept_suggestion (AI surface)
- reconcile_manual, unreconcile, write_off, bulk_reconcile (write)
- get_partner_history (precedent + pattern read)

Tests use HttpCase to exercise the real Werkzeug stack as a Fusion
Accounting administrator. Includes a smoke test for the deferred
write-off path (Task 12) and a negative test confirming auth='user'
rejects anonymous requests. Helper _make_pair shares one bank journal
across pairs to avoid the (code, company) unique-constraint collision
that the default factory would hit on repeat calls.

Verified: 11/11 controller tests pass, 134/134 module tests pass.
Made-with: Cursor
2026-04-19 12:15:40 -04:00

326 lines
14 KiB
Python

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