diff --git a/fusion_accounting_reports/__init__.py b/fusion_accounting_reports/__init__.py index 5b1c5641..70f95eae 100644 --- a/fusion_accounting_reports/__init__.py +++ b/fusion_accounting_reports/__init__.py @@ -1,2 +1,3 @@ from . import services from . import models +from . import controllers diff --git a/fusion_accounting_reports/__manifest__.py b/fusion_accounting_reports/__manifest__.py index 83bd3381..deec9036 100644 --- a/fusion_accounting_reports/__manifest__.py +++ b/fusion_accounting_reports/__manifest__.py @@ -1,6 +1,6 @@ { 'name': 'Fusion Accounting Reports', - 'version': '19.0.1.0.13', + 'version': '19.0.1.0.14', 'category': 'Accounting/Accounting', 'summary': 'AI-augmented financial reports (P&L, balance sheet, trial balance, GL).', 'description': """ diff --git a/fusion_accounting_reports/controllers/__init__.py b/fusion_accounting_reports/controllers/__init__.py index e69de29b..60bf84cb 100644 --- a/fusion_accounting_reports/controllers/__init__.py +++ b/fusion_accounting_reports/controllers/__init__.py @@ -0,0 +1 @@ +from . import reports_controller diff --git a/fusion_accounting_reports/controllers/reports_controller.py b/fusion_accounting_reports/controllers/reports_controller.py new file mode 100644 index 00000000..0bd3d8e8 --- /dev/null +++ b/fusion_accounting_reports/controllers/reports_controller.py @@ -0,0 +1,224 @@ +"""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', + } diff --git a/fusion_accounting_reports/tests/__init__.py b/fusion_accounting_reports/tests/__init__.py index 62cb7f71..d3f3f770 100644 --- a/fusion_accounting_reports/tests/__init__.py +++ b/fusion_accounting_reports/tests/__init__.py @@ -10,3 +10,4 @@ from . import test_commentary_prompt from . import test_commentary_generator from . import test_fusion_report_commentary from . import test_fusion_report_anomaly +from . import test_reports_controller diff --git a/fusion_accounting_reports/tests/test_reports_controller.py b/fusion_accounting_reports/tests/test_reports_controller.py new file mode 100644 index 00000000..19f51bcd --- /dev/null +++ b/fusion_accounting_reports/tests/test_reports_controller.py @@ -0,0 +1,118 @@ +"""Controller tests using HttpCase for the 8 JSON-RPC endpoints.""" + +import json + +from odoo.tests.common import HttpCase, new_test_user, tagged + + +@tagged('post_install', '-at_install') +class TestReportsController(HttpCase): + + def setUp(self): + super().setUp() + self.user = new_test_user( + self.env, + login='reports_test_user', + groups='base.group_user,account.group_account_invoice', + ) + + def _jsonrpc(self, endpoint, params): + self.authenticate('reports_test_user', 'reports_test_user') + url = f'/fusion/reports/{endpoint}' + body = { + 'jsonrpc': '2.0', + 'method': 'call', + 'params': params, + 'id': 1, + } + response = self.url_open( + url, + data=json.dumps(body), + headers={'Content-Type': 'application/json'}, + ) + self.assertEqual( + response.status_code, 200, + f"{endpoint} returned {response.status_code}: {response.text[:300]}", + ) + result = response.json() + if 'error' in result: + self.fail(f"{endpoint} errored: {result['error']}") + return result.get('result', {}) + + def test_list_available(self): + result = self._jsonrpc('list_available', { + 'company_id': self.env.company.id, + }) + self.assertIn('reports', result) + codes = [r['code'] for r in result['reports']] + self.assertIn('pnl', codes) + + def test_run_pnl(self): + result = self._jsonrpc('run', { + 'report_type': 'pnl', + 'date_from': '2026-01-01', + 'date_to': '2026-12-31', + 'company_id': self.env.company.id, + }) + self.assertEqual(result.get('report_type'), 'pnl') + self.assertIn('rows', result) + + def test_run_balance_sheet(self): + result = self._jsonrpc('run', { + 'report_type': 'balance_sheet', + 'date_from': '2026-01-01', + 'date_to': '2026-12-31', + 'company_id': self.env.company.id, + }) + self.assertEqual(result.get('report_type'), 'balance_sheet') + + def test_drill_down_returns_list(self): + line = self.env['account.move.line'].search( + [('parent_state', '=', 'posted')], limit=1, + ) + if not line: + self.skipTest("No posted lines in DB") + result = self._jsonrpc('drill_down', { + 'account_id': line.account_id.id, + 'date_from': str(line.date), + 'date_to': str(line.date), + 'company_id': line.company_id.id, + }) + self.assertIn('rows', result) + + def test_get_anomalies_returns_list(self): + result = self._jsonrpc('get_anomalies', { + 'report_type': 'pnl', + 'date_from': '2026-01-01', + 'date_to': '2026-12-31', + 'comparison': 'previous_year', + 'company_id': self.env.company.id, + }) + self.assertIn('anomalies', result) + + def test_get_commentary_returns_dict(self): + result = self._jsonrpc('get_commentary', { + 'report_type': 'pnl', + 'date_from': '2026-01-01', + 'date_to': '2026-12-31', + 'company_id': self.env.company.id, + }) + self.assertIn('summary', result) + self.assertIn('highlights', result) + self.assertIn('concerns', result) + + def test_export_pdf_placeholder(self): + result = self._jsonrpc('export_pdf', { + 'report_type': 'pnl', + 'date_from': '2026-01-01', + 'date_to': '2026-12-31', + }) + self.assertEqual(result.get('status'), 'not_implemented') + + def test_export_xlsx_placeholder(self): + result = self._jsonrpc('export_xlsx', { + 'report_type': 'pnl', + 'date_from': '2026-01-01', + 'date_to': '2026-12-31', + }) + self.assertEqual(result.get('status'), 'not_implemented')