refactor(fusion_accounting): move AI module code into fusion_accounting_ai sub-module
git mv preserves history. fusion_accounting/ retains only __manifest__.py, __init__.py, CLAUDE.md, and docs/ — the meta-module shell. All Python, data, views, security, services, static, tests, wizards, report move to fusion_accounting_ai/. Manifest data list updated; security.xml move to _core deferred to Task 12. Made-with: Cursor
This commit is contained in:
243
fusion_accounting_ai/controllers/chat_controller.py
Normal file
243
fusion_accounting_ai/controllers/chat_controller.py
Normal file
@@ -0,0 +1,243 @@
|
||||
import json
|
||||
import logging
|
||||
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FusionAccountingChatController(http.Controller):
|
||||
|
||||
def _check_session_ownership(self, session):
|
||||
"""S1-S3: Verify the current user owns the session."""
|
||||
if session.user_id.id != request.env.user.id:
|
||||
# Allow managers to access any session
|
||||
if not request.env.user.has_group('fusion_accounting.group_fusion_accounting_manager'):
|
||||
return {'error': 'Access denied: you do not own this session'}
|
||||
return None
|
||||
|
||||
@http.route('/fusion_accounting/session/create', type='jsonrpc', auth='user')
|
||||
def create_session(self, context_domain=None, **kwargs):
|
||||
session = request.env['fusion.accounting.session'].create({
|
||||
'user_id': request.env.user.id,
|
||||
'company_id': request.env.company.id,
|
||||
'context_domain': context_domain,
|
||||
})
|
||||
return {'session_id': session.id, 'name': session.name}
|
||||
|
||||
@http.route('/fusion_accounting/session/close', type='jsonrpc', auth='user')
|
||||
def close_session(self, session_id, **kwargs):
|
||||
session = request.env['fusion.accounting.session'].browse(int(session_id))
|
||||
if not session.exists():
|
||||
return {'status': 'closed'}
|
||||
# S2: Ownership check
|
||||
error = self._check_session_ownership(session)
|
||||
if error:
|
||||
return error
|
||||
if session.state == 'active':
|
||||
session.action_close_session()
|
||||
return {'status': 'closed'}
|
||||
|
||||
@http.route('/fusion_accounting/chat', type='jsonrpc', auth='user')
|
||||
def chat(self, session_id, message, context=None, image=None, **kwargs):
|
||||
if not message and not image:
|
||||
return {'error': 'Message or image is required'}
|
||||
# S3: Ownership check
|
||||
session = request.env['fusion.accounting.session'].browse(int(session_id))
|
||||
if session.exists():
|
||||
error = self._check_session_ownership(session)
|
||||
if error:
|
||||
return error
|
||||
agent = request.env['fusion.accounting.agent']
|
||||
result = agent.chat(int(session_id), message or '', context=context, image=image)
|
||||
return result
|
||||
|
||||
@http.route('/fusion_accounting/approve', type='jsonrpc', auth='user')
|
||||
def approve_action(self, match_history_id, **kwargs):
|
||||
if not request.env.user.has_group('fusion_accounting.group_fusion_accounting_manager'):
|
||||
return {'error': 'Insufficient permissions to approve actions'}
|
||||
agent = request.env['fusion.accounting.agent']
|
||||
result = agent.approve_action(int(match_history_id))
|
||||
return result
|
||||
|
||||
@http.route('/fusion_accounting/reject', type='jsonrpc', auth='user')
|
||||
def reject_action(self, match_history_id, reason='', **kwargs):
|
||||
if not request.env.user.has_group('fusion_accounting.group_fusion_accounting_manager'):
|
||||
return {'error': 'Insufficient permissions to reject actions'}
|
||||
agent = request.env['fusion.accounting.agent']
|
||||
result = agent.reject_action(int(match_history_id), reason)
|
||||
return result
|
||||
|
||||
@http.route('/fusion_accounting/dashboard/data', type='jsonrpc', auth='user')
|
||||
def dashboard_data(self, **kwargs):
|
||||
# E2: Wrap in try/except so dashboard doesn't return 500
|
||||
try:
|
||||
dashboard = request.env['fusion.accounting.dashboard'].new({
|
||||
'company_id': request.env.company.id,
|
||||
})
|
||||
return {
|
||||
'bank_recon': {'count': dashboard.bank_recon_count, 'amount': dashboard.bank_recon_amount},
|
||||
'ar': {'total': dashboard.ar_total, 'overdue_count': dashboard.ar_overdue_count},
|
||||
'ap': {'total': dashboard.ap_total, 'due_this_week': dashboard.ap_due_this_week},
|
||||
'hst': {'balance': dashboard.hst_balance},
|
||||
'audit': {'score': dashboard.audit_score, 'flags': dashboard.audit_flag_count},
|
||||
'month_end': {'status': dashboard.month_end_status, 'open_items': dashboard.month_end_open_items},
|
||||
# E1: Include needs_attention and recent_activity
|
||||
'needs_attention': json.loads(dashboard.needs_attention_json or '[]'),
|
||||
'recent_activity': json.loads(dashboard.recent_activity_json or '[]'),
|
||||
}
|
||||
except Exception as e:
|
||||
_logger.exception("Dashboard data computation failed")
|
||||
return {
|
||||
'error': 'Dashboard data could not be computed',
|
||||
'bank_recon': {'count': 0, 'amount': 0},
|
||||
'ar': {'total': 0, 'overdue_count': 0},
|
||||
'ap': {'total': 0, 'due_this_week': 0},
|
||||
'hst': {'balance': 0},
|
||||
'audit': {'score': 0, 'flags': 0},
|
||||
'month_end': {'status': 'Unknown', 'open_items': 0},
|
||||
'needs_attention': [],
|
||||
'recent_activity': [],
|
||||
}
|
||||
|
||||
@http.route('/fusion_accounting/approve_all', type='jsonrpc', auth='user')
|
||||
def approve_all(self, match_history_ids, **kwargs):
|
||||
if not request.env.user.has_group('fusion_accounting.group_fusion_accounting_manager'):
|
||||
return {'error': 'Insufficient permissions to approve actions'}
|
||||
agent = request.env['fusion.accounting.agent']
|
||||
results = []
|
||||
for mid in match_history_ids:
|
||||
try:
|
||||
result = agent.approve_action(int(mid))
|
||||
results.append({'id': mid, 'status': 'approved', 'result': result})
|
||||
except Exception as e:
|
||||
# S4: Sanitize exception — log full error, return generic message
|
||||
_logger.exception("Error approving match history %s", mid)
|
||||
results.append({'id': mid, 'status': 'error', 'error': 'Action could not be approved. Check server logs for details.'})
|
||||
return {'results': results}
|
||||
|
||||
@http.route('/fusion_accounting/reject_all', type='jsonrpc', auth='user')
|
||||
def reject_all(self, match_history_ids, reason='', **kwargs):
|
||||
if not request.env.user.has_group('fusion_accounting.group_fusion_accounting_manager'):
|
||||
return {'error': 'Insufficient permissions to reject actions'}
|
||||
agent = request.env['fusion.accounting.agent']
|
||||
results = []
|
||||
for mid in match_history_ids:
|
||||
try:
|
||||
result = agent.reject_action(int(mid), reason)
|
||||
# E3: Consistent return shape with approve_all
|
||||
results.append({'id': mid, 'status': 'rejected', 'result': result})
|
||||
except Exception as e:
|
||||
# S4: Sanitize exception
|
||||
_logger.exception("Error rejecting match history %s", mid)
|
||||
results.append({'id': mid, 'status': 'error', 'error': 'Action could not be rejected. Check server logs for details.'})
|
||||
return {'results': results}
|
||||
|
||||
@http.route('/fusion_accounting/chat/status', type='jsonrpc', auth='user')
|
||||
def chat_status(self, session_id, **kwargs):
|
||||
"""Poll the live execution state of a running chat — returns thinking text,
|
||||
tool calls in progress, and current status. Called every 500ms by the frontend
|
||||
while a chat request is in flight."""
|
||||
from ..services.agent import get_execution_state
|
||||
state = get_execution_state(int(session_id))
|
||||
return state
|
||||
|
||||
@http.route('/fusion_accounting/search_matches', type='jsonrpc', auth='user')
|
||||
def search_matches(self, statement_line_id, query='', **kwargs):
|
||||
"""Live search for matching journal items — called directly by the
|
||||
reconciliation table search bar (no AI round-trip)."""
|
||||
from ..services.tools.bank_reconciliation import search_matching_entries
|
||||
try:
|
||||
result = search_matching_entries(request.env, {
|
||||
'statement_line_id': int(statement_line_id),
|
||||
'query': query,
|
||||
})
|
||||
return result
|
||||
except Exception as e:
|
||||
_logger.exception("Search matches failed")
|
||||
return {'candidates': [], 'error': str(e)}
|
||||
|
||||
@http.route('/fusion_accounting/session/list', type='jsonrpc', auth='user')
|
||||
def session_list(self, limit=20, **kwargs):
|
||||
"""List recent sessions for the session picker dropdown."""
|
||||
sessions = request.env['fusion.accounting.session'].search([
|
||||
('user_id', '=', request.env.user.id),
|
||||
], order='write_date desc', limit=int(limit))
|
||||
return {
|
||||
'sessions': [{
|
||||
'id': s.id,
|
||||
'name': s.name,
|
||||
'state': s.state,
|
||||
'date': s.write_date.isoformat() if s.write_date else '',
|
||||
'message_count': len(json.loads(s.message_ids_json or '[]')),
|
||||
'ai_model': s.ai_model or '',
|
||||
} for s in sessions],
|
||||
}
|
||||
|
||||
@http.route('/fusion_accounting/session/latest', type='jsonrpc', auth='user')
|
||||
def session_latest(self, **kwargs):
|
||||
# Find the most recent active session that has messages first,
|
||||
# fall back to any active session (including empty ones)
|
||||
sessions = request.env['fusion.accounting.session'].search([
|
||||
('user_id', '=', request.env.user.id),
|
||||
('state', '=', 'active'),
|
||||
], order='write_date desc', limit=10)
|
||||
if not sessions:
|
||||
return {'session_id': None, 'messages': [], 'name': None}
|
||||
|
||||
# Prefer a session with actual messages
|
||||
session = None
|
||||
for s in sessions:
|
||||
msg_json = s.message_ids_json or '[]'
|
||||
if msg_json != '[]' and len(msg_json) > 5:
|
||||
session = s
|
||||
break
|
||||
# If no session has messages, use the newest one
|
||||
if not session:
|
||||
session = sessions[0]
|
||||
|
||||
# Clean up empty stale sessions (created but never used)
|
||||
for s in sessions:
|
||||
if s.id != session.id and (s.message_ids_json or '[]') == '[]':
|
||||
s.write({'state': 'closed'})
|
||||
|
||||
messages = json.loads(session.message_ids_json or '[]')
|
||||
display_messages = []
|
||||
for msg in messages:
|
||||
if isinstance(msg.get('content'), str) and msg['content'].strip():
|
||||
display_messages.append(msg)
|
||||
elif isinstance(msg.get('content'), list):
|
||||
for block in msg['content']:
|
||||
if isinstance(block, dict) and block.get('type') == 'text' and block['text'].strip():
|
||||
display_messages.append({'role': msg['role'], 'content': block['text']})
|
||||
|
||||
# Include any pending approvals so they show on page load
|
||||
agent = request.env['fusion.accounting.agent']
|
||||
pending = request.env['fusion.accounting.match.history'].search([
|
||||
('session_id', '=', session.id),
|
||||
('decision', '=', 'pending'),
|
||||
])
|
||||
pending_approvals = [agent._format_pending_approval(p) for p in pending]
|
||||
|
||||
return {
|
||||
'session_id': session.id,
|
||||
'messages': display_messages,
|
||||
'name': session.name,
|
||||
'pending_approvals': pending_approvals,
|
||||
}
|
||||
|
||||
@http.route('/fusion_accounting/session/history', type='jsonrpc', auth='user')
|
||||
def session_history(self, session_id, **kwargs):
|
||||
session = request.env['fusion.accounting.session'].browse(int(session_id))
|
||||
if not session.exists():
|
||||
return {'error': 'Session not found'}
|
||||
# S1: Ownership check
|
||||
error = self._check_session_ownership(session)
|
||||
if error:
|
||||
return error
|
||||
return {
|
||||
'messages': json.loads(session.message_ids_json or '[]'),
|
||||
'session_id': session.id,
|
||||
'state': session.state,
|
||||
}
|
||||
Reference in New Issue
Block a user