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
326 lines
14 KiB
Python
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),
|
|
}
|