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:
@@ -1,2 +1,3 @@
|
||||
from . import services
|
||||
from . import models
|
||||
from . import controllers
|
||||
|
||||
@@ -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': """
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
from . import reports_controller
|
||||
|
||||
224
fusion_accounting_reports/controllers/reports_controller.py
Normal file
224
fusion_accounting_reports/controllers/reports_controller.py
Normal 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',
|
||||
}
|
||||
@@ -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
|
||||
|
||||
118
fusion_accounting_reports/tests/test_reports_controller.py
Normal file
118
fusion_accounting_reports/tests/test_reports_controller.py
Normal 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')
|
||||
Reference in New Issue
Block a user