feat(fusion_accounting_reports): 8 JSON-RPC endpoints for OWL widget

Adds FusionReportsController exposing:
- list_available, run, drill_down
- get_anomalies (with optional persistence to fusion.report.anomaly)
- get_commentary (LLM cache via fusion.report.commentary, force_regenerate flag)
- compare_periods (delegates to run with comparison flag)
- export_pdf / export_xlsx (Phase 2 placeholders for Tasks 34/35)

All endpoints use V19's type='jsonrpc' and route through
fusion.report.engine - no direct ORM aggregation in the controller.

8 new HttpCase tests cover each endpoint. Total: 78 logical tests.

Made-with: Cursor
This commit is contained in:
gsinghpal
2026-04-19 15:37:58 -04:00
parent c20e0888e1
commit 5cdd3e756d
6 changed files with 346 additions and 1 deletions

View File

@@ -1,2 +1,3 @@
from . import services
from . import models
from . import controllers

View File

@@ -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': """

View File

@@ -0,0 +1 @@
from . import reports_controller

View File

@@ -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',
}

View File

@@ -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

View File

@@ -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')