"""HTTP controller: 8 JSON-RPC endpoints for the OWL reports widget. All endpoints route through fusion.report.engine - no direct ORM aggregation from the controller. Uses V19's type='jsonrpc'. """ import logging from datetime import date, datetime from odoo import _, http from odoo.exceptions import ValidationError from odoo.http import request from ..services.anomaly_detection import detect as detect_anomalies from ..services.commentary_generator import generate_commentary from ..services.date_periods import Period _logger = logging.getLogger(__name__) REPORT_TYPES = {'pnl', 'balance_sheet', 'trial_balance', 'general_ledger'} def _parse_date(value): if isinstance(value, date): return value return datetime.strptime(value, '%Y-%m-%d').date() def _build_period(date_from, date_to, label=None): df = _parse_date(date_from) dt = _parse_date(date_to) return Period(date_from=df, date_to=dt, label=label or f"{df} - {dt}") class FusionReportsController(http.Controller): @http.route('/fusion/reports/list_available', type='jsonrpc', auth='user') def list_available(self, company_id=None): company_id = int(company_id) if company_id else request.env.company.id Report = request.env['fusion.report'].sudo() reports = Report.search([ ('active', '=', True), '|', ('company_id', '=', company_id), ('company_id', '=', False), ], order='sequence, name') return { 'reports': [{ 'id': r.id, 'name': r.name, 'code': r.code, 'report_type': r.report_type, 'description': r.description or '', 'default_comparison_mode': r.default_comparison_mode, } for r in reports], } @http.route('/fusion/reports/run', type='jsonrpc', auth='user') def run(self, report_type, date_from=None, date_to=None, comparison='none', company_id=None): if report_type not in REPORT_TYPES: raise ValidationError(_("Unknown report type: %s") % report_type) company_id = int(company_id) if company_id else request.env.company.id engine = request.env['fusion.report.engine'] if report_type == 'pnl': period = _build_period(date_from, date_to) return engine.compute_pnl( period, comparison=comparison, company_id=company_id, ) if report_type == 'balance_sheet': return engine.compute_balance_sheet( _parse_date(date_to), comparison=comparison, company_id=company_id, ) if report_type == 'trial_balance': period = _build_period(date_from, date_to) return engine.compute_trial_balance(period, company_id=company_id) # general_ledger period = _build_period(date_from, date_to) return engine.compute_gl(period, company_id=company_id) @http.route('/fusion/reports/drill_down', type='jsonrpc', auth='user') def drill_down(self, account_id, date_from, date_to, company_id=None): company_id = int(company_id) if company_id else request.env.company.id engine = request.env['fusion.report.engine'] period = _build_period(date_from, date_to) rows = engine.drill_down( account_id=int(account_id), period=period, company_id=company_id, ) return {'rows': rows, 'count': len(rows)} @http.route('/fusion/reports/get_anomalies', type='jsonrpc', auth='user') def get_anomalies(self, report_type, date_from, date_to, comparison='previous_year', persist=False, company_id=None): company_id = int(company_id) if company_id else request.env.company.id report_result = self.run( report_type=report_type, date_from=date_from, date_to=date_to, comparison=comparison, company_id=company_id, ) anomalies = detect_anomalies(report_result) if persist and anomalies: Report = request.env['fusion.report'] report_def = Report.search([('report_type', '=', report_type)], limit=1) if report_def: self._persist_anomalies( report_def, _parse_date(date_from), _parse_date(date_to), anomalies, ) return {'anomalies': anomalies, 'count': len(anomalies)} def _persist_anomalies(self, report, period_from, period_to, anomalies): Anomaly = request.env['fusion.report.anomaly'] for a in anomalies: existing = Anomaly.search([ ('report_id', '=', report.id), ('period_from', '=', period_from), ('period_to', '=', period_to), ('row_id', '=', a['row_id']), ], limit=1) vals = { 'report_id': report.id, 'period_from': period_from, 'period_to': period_to, 'row_id': a['row_id'], 'label': a['label'], 'current_amount': a['current_amount'], 'comparison_amount': a['comparison_amount'], 'variance_amount': a['variance_amount'], 'variance_pct': a['variance_pct'], 'severity': a['severity'], 'direction': a['direction'], } if existing: existing.write(vals) else: Anomaly.create(vals) @http.route('/fusion/reports/get_commentary', type='jsonrpc', auth='user') def get_commentary(self, report_type, date_from, date_to, comparison='none', force_regenerate=False, company_id=None): company_id = int(company_id) if company_id else request.env.company.id Report = request.env['fusion.report'] Commentary = request.env['fusion.report.commentary'] report_def = Report.search([('report_type', '=', report_type)], limit=1) if not report_def: raise ValidationError(_("No report definition for %s") % report_type) period_from = _parse_date(date_from) period_to = _parse_date(date_to) cached = Commentary.search([ ('report_id', '=', report_def.id), ('company_id', '=', company_id), ('period_from', '=', period_from), ('period_to', '=', period_to), ('comparison_mode', '=', comparison), ], limit=1) if cached and not force_regenerate: return { 'cached': True, 'summary': cached.summary or '', 'highlights': cached.highlights or [], 'concerns': cached.concerns or [], 'next_actions': cached.next_actions or [], 'generated_at': str(cached.generated_at), } report_result = self.run( report_type=report_type, date_from=date_from, date_to=date_to, comparison=comparison, company_id=company_id, ) anomalies = detect_anomalies(report_result) commentary = generate_commentary( request.env, report_result=report_result, anomalies=anomalies, ) vals = { 'report_id': report_def.id, 'company_id': company_id, 'period_from': period_from, 'period_to': period_to, 'comparison_mode': comparison, 'summary': commentary.get('summary', ''), 'highlights': commentary.get('highlights', []), 'concerns': commentary.get('concerns', []), 'next_actions': commentary.get('next_actions', []), } if cached: cached.write(vals) else: Commentary.create(vals) return {'cached': False, **commentary} @http.route('/fusion/reports/compare_periods', type='jsonrpc', auth='user') def compare_periods(self, report_type, date_from, date_to, comparison='previous_year', company_id=None): return self.run( report_type=report_type, date_from=date_from, date_to=date_to, comparison=comparison, company_id=company_id, ) @http.route('/fusion/reports/export_pdf', type='jsonrpc', auth='user') def export_pdf(self, report_type, date_from, date_to, comparison='none', company_id=None): return { 'status': 'not_implemented', 'message': 'PDF export shipping in Task 34', } @http.route('/fusion/reports/export_xlsx', type='jsonrpc', auth='user') def export_xlsx(self, report_type, date_from, date_to, comparison='none', company_id=None): return { 'status': 'not_implemented', 'message': 'XLSX export shipping in Task 35', }