3 Commits

Author SHA1 Message Date
gsinghpal
118f0d9d16 feat(fusion_accounting_ai): 5 new financial reports AI tools
Some checks failed
fusion_accounting CI / test (fusion_accounting_ai) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_core) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_migration) (push) Has been cancelled
Adds financial_reports.py tools module with 5 fusion-engine-routed
tools registered in TOOL_DISPATCH:

- fusion_run_report
- fusion_get_anomalies
- fusion_generate_commentary
- fusion_drill_down_report_line
- fusion_compare_periods

Each tool guards on 'fusion.report.engine' being in the registry and
otherwise returns a structured error so the chat agent can surface a
clear "module not installed" message.

6 new TransactionCase tests (including a TOOL_DISPATCH registration
sanity check).

Made-with: Cursor
2026-04-19 15:41:10 -04:00
gsinghpal
15cf4e129f feat(fusion_accounting_ai): wire ReportsAdapter fusion paths to engine
Adds three new method families on ReportsAdapter that route through
fusion.report.engine when fusion_accounting_reports is installed:

- run_fusion_report (pnl/balance_sheet/trial_balance/general_ledger)
- get_anomalies (variance detection on engine output)
- get_commentary (LLM narrative; falls back to templated)

These coexist with the legacy ref_id-shaped run_report / export_report
API so existing reporting tools (profit_loss, balance_sheet, etc.) keep
working unchanged. FUSION_MODEL is updated to fusion.report.engine so
mode detection picks FUSION when the new engine is installed.

4 new TransactionCase tests cover the fusion + community paths.

Made-with: Cursor
2026-04-19 15:39:54 -04:00
gsinghpal
5cdd3e756d 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
2026-04-19 15:37:58 -04:00
11 changed files with 775 additions and 3 deletions

View File

@@ -16,7 +16,12 @@ _logger = logging.getLogger(__name__)
class ReportsAdapter(DataAdapter):
FUSION_MODEL = 'fusion.account.report'
# Phase 2 wires fusion.report.engine as the FUSION-mode backend for
# the new report_type-shaped methods (run_fusion_report, get_anomalies,
# get_commentary). The legacy ref_id-shaped run_report / export_report
# methods continue to defer to community when in FUSION mode (their
# original behavior), so this rename does not change their results.
FUSION_MODEL = 'fusion.report.engine'
ENTERPRISE_MODULE = 'account_reports'
# ------------------------------------------------------------------
@@ -167,4 +172,159 @@ class ReportsAdapter(DataAdapter):
}
# ==================================================================
# Phase 2 (Task 19): fusion.report.engine-routed report methods
#
# These coexist with the legacy ref_id-shaped run_report/export_report
# API. New callers (financial_reports AI tools, OWL widget) use the
# *_fusion_report methods below; those route through the engine when
# fusion_accounting_reports is installed.
# ==================================================================
# ------------------ run_fusion_report --------------------------
def run_fusion_report(self, report_type, date_from, date_to,
comparison='none', company_id=None):
return self._dispatch(
'run_fusion_report',
report_type=report_type,
date_from=date_from, date_to=date_to,
comparison=comparison, company_id=company_id,
)
def run_fusion_report_via_fusion(self, report_type, date_from, date_to,
comparison='none', company_id=None):
if 'fusion.report.engine' not in self.env.registry:
return {'rows': [], 'error': 'fusion.report.engine not installed'}
from datetime import datetime
from odoo.addons.fusion_accounting_reports.services.date_periods import (
Period,
)
df = (datetime.strptime(date_from, '%Y-%m-%d').date()
if isinstance(date_from, str) else date_from)
dt = (datetime.strptime(date_to, '%Y-%m-%d').date()
if isinstance(date_to, str) else date_to)
period = Period(date_from=df, date_to=dt, label=f"{df} - {dt}")
engine = self.env['fusion.report.engine']
company_id = company_id or self.env.company.id
if report_type == 'pnl':
return engine.compute_pnl(
period, comparison=comparison, company_id=company_id,
)
if report_type == 'balance_sheet':
return engine.compute_balance_sheet(
dt, comparison=comparison, company_id=company_id,
)
if report_type == 'trial_balance':
return engine.compute_trial_balance(
period, company_id=company_id,
)
if report_type == 'general_ledger':
return engine.compute_gl(period, company_id=company_id)
return {'rows': [], 'error': f'unknown report_type {report_type}'}
def run_fusion_report_via_enterprise(self, report_type, date_from, date_to,
comparison='none', company_id=None):
# Enterprise's account_reports has its own UI; we don't proxy from
# Python. Callers should use the Enterprise menus or the legacy
# run_report(ref_id=...) method instead.
return {
'rows': [],
'error': 'Enterprise reports must be run from the Enterprise UI',
}
def run_fusion_report_via_community(self, report_type, date_from, date_to,
comparison='none', company_id=None):
return {
'rows': [],
'error': 'No fusion reports engine available in pure Community',
}
# ------------------ get_anomalies ------------------------------
def get_anomalies(self, report_type, date_from, date_to,
comparison='previous_year', company_id=None):
return self._dispatch(
'get_anomalies',
report_type=report_type,
date_from=date_from, date_to=date_to,
comparison=comparison, company_id=company_id,
)
def get_anomalies_via_fusion(self, report_type, date_from, date_to,
comparison='previous_year', company_id=None):
if 'fusion.report.engine' not in self.env.registry:
return {'anomalies': []}
from odoo.addons.fusion_accounting_reports.services.anomaly_detection import (
detect,
)
report = self.run_fusion_report_via_fusion(
report_type=report_type,
date_from=date_from, date_to=date_to,
comparison=comparison, company_id=company_id,
)
if 'error' in report:
return {'anomalies': []}
return {'anomalies': detect(report)}
def get_anomalies_via_enterprise(self, report_type, date_from, date_to,
comparison='previous_year', company_id=None):
return {'anomalies': []}
def get_anomalies_via_community(self, report_type, date_from, date_to,
comparison='previous_year', company_id=None):
return {'anomalies': []}
# ------------------ get_commentary -----------------------------
def get_commentary(self, report_type, date_from, date_to,
comparison='none', company_id=None):
return self._dispatch(
'get_commentary',
report_type=report_type,
date_from=date_from, date_to=date_to,
comparison=comparison, company_id=company_id,
)
def get_commentary_via_fusion(self, report_type, date_from, date_to,
comparison='none', company_id=None):
empty = {
'summary': '', 'highlights': [],
'concerns': [], 'next_actions': [],
}
if 'fusion.report.engine' not in self.env.registry:
return empty
from odoo.addons.fusion_accounting_reports.services.anomaly_detection import (
detect,
)
from odoo.addons.fusion_accounting_reports.services.commentary_generator import (
generate_commentary,
)
report = self.run_fusion_report_via_fusion(
report_type=report_type,
date_from=date_from, date_to=date_to,
comparison=comparison, company_id=company_id,
)
if 'error' in report:
return empty
anomalies = detect(report)
return generate_commentary(
self.env, report_result=report, anomalies=anomalies,
)
def get_commentary_via_enterprise(self, report_type, date_from, date_to,
comparison='none', company_id=None):
return {
'summary': '', 'highlights': [],
'concerns': [], 'next_actions': [],
}
def get_commentary_via_community(self, report_type, date_from, date_to,
comparison='none', company_id=None):
return {
'summary': '', 'highlights': [],
'concerns': [], 'next_actions': [],
}
register_adapter('reports', ReportsAdapter)

View File

@@ -9,11 +9,12 @@ from .inventory import TOOLS as INVENTORY_TOOLS
from .adp import TOOLS as ADP_TOOLS
from .reporting import TOOLS as REPORTING_TOOLS
from .audit import TOOLS as AUDIT_TOOLS
from .financial_reports import TOOLS as FINANCIAL_REPORTS_TOOLS
TOOL_DISPATCH = {}
for tools_dict in [
BANK_RECON_TOOLS, HST_TOOLS, AR_TOOLS, AP_TOOLS, JOURNAL_TOOLS,
MONTH_END_TOOLS, PAYROLL_TOOLS, INVENTORY_TOOLS, ADP_TOOLS,
REPORTING_TOOLS, AUDIT_TOOLS,
REPORTING_TOOLS, AUDIT_TOOLS, FINANCIAL_REPORTS_TOOLS,
]:
TOOL_DISPATCH.update(tools_dict)

View File

@@ -0,0 +1,127 @@
"""Fusion-engine-routed AI tools for financial reports.
These 5 tools route through ReportsAdapter's Phase-2 methods
(run_fusion_report / get_anomalies / get_commentary), which in turn
call fusion.report.engine when fusion_accounting_reports is installed.
"""
import logging
_logger = logging.getLogger(__name__)
def _company_id(env, params):
raw = params.get('company_id')
return int(raw) if raw else env.company.id
def fusion_run_report(env, params):
"""Run a fusion financial report.
Params: report_type (pnl|balance_sheet|trial_balance|general_ledger),
date_from, date_to, comparison (none|previous_period|previous_year),
optional company_id.
"""
if 'fusion.report.engine' not in env.registry:
return {'error': 'fusion_accounting_reports not installed'}
from ..data_adapters import get_adapter
adapter = get_adapter(env, 'reports')
result = adapter.run_fusion_report(
report_type=params.get('report_type'),
date_from=params.get('date_from'),
date_to=params.get('date_to'),
comparison=params.get('comparison', 'none'),
company_id=_company_id(env, params),
)
rows = result.get('rows', [])
return {
'report_type': params.get('report_type'),
'period': result.get('period'),
'comparison_period': result.get('comparison_period'),
'row_count': len(rows),
'rows': rows,
}
def fusion_get_anomalies(env, params):
"""Detect variance anomalies in a report."""
if 'fusion.report.engine' not in env.registry:
return {'error': 'fusion_accounting_reports not installed'}
from ..data_adapters import get_adapter
adapter = get_adapter(env, 'reports')
result = adapter.get_anomalies(
report_type=params.get('report_type'),
date_from=params.get('date_from'),
date_to=params.get('date_to'),
comparison=params.get('comparison', 'previous_year'),
company_id=_company_id(env, params),
)
anomalies = result.get('anomalies', [])
return {'count': len(anomalies), 'anomalies': anomalies}
def fusion_generate_commentary(env, params):
"""Generate AI commentary for a report."""
if 'fusion.report.engine' not in env.registry:
return {'error': 'fusion_accounting_reports not installed'}
from ..data_adapters import get_adapter
adapter = get_adapter(env, 'reports')
result = adapter.get_commentary(
report_type=params.get('report_type'),
date_from=params.get('date_from'),
date_to=params.get('date_to'),
comparison=params.get('comparison', 'none'),
company_id=_company_id(env, params),
)
return {
'summary': result.get('summary', ''),
'highlights': result.get('highlights', []),
'concerns': result.get('concerns', []),
'next_actions': result.get('next_actions', []),
}
def fusion_drill_down_report_line(env, params):
"""Drill from a report line into the underlying journal items."""
if 'fusion.report.engine' not in env.registry:
return {'error': 'fusion_accounting_reports not installed'}
from datetime import datetime
from odoo.addons.fusion_accounting_reports.services.date_periods import (
Period,
)
date_from = params['date_from']
date_to = params['date_to']
if isinstance(date_from, str):
date_from = datetime.strptime(date_from, '%Y-%m-%d').date()
if isinstance(date_to, str):
date_to = datetime.strptime(date_to, '%Y-%m-%d').date()
period = Period(date_from=date_from, date_to=date_to, label='drill')
engine = env['fusion.report.engine']
rows = engine.drill_down(
account_id=int(params['account_id']),
period=period,
company_id=_company_id(env, params),
)
return {'count': len(rows), 'rows': rows}
def fusion_compare_periods(env, params):
"""Run a report with period comparison side-by-side.
Defaults comparison to 'previous_year' so callers get a comparison
column without specifying it explicitly.
"""
return fusion_run_report(env, {
**params,
'comparison': params.get('comparison', 'previous_year'),
})
TOOLS = {
'fusion_run_report': fusion_run_report,
'fusion_get_anomalies': fusion_get_anomalies,
'fusion_generate_commentary': fusion_generate_commentary,
'fusion_drill_down_report_line': fusion_drill_down_report_line,
'fusion_compare_periods': fusion_compare_periods,
}

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.16',
'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,6 @@ 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
from . import test_reports_adapter
from . import test_fusion_report_tools

View File

@@ -0,0 +1,81 @@
"""Tests for the 5 fusion AI tools registered in TOOL_DISPATCH."""
from odoo.tests.common import TransactionCase, tagged
from odoo.addons.fusion_accounting_ai.services.tools import financial_reports as tools
@tagged('post_install', '-at_install')
class TestFusionReportTools(TransactionCase):
def test_fusion_run_report_pnl(self):
result = tools.fusion_run_report(self.env, {
'report_type': 'pnl',
'date_from': '2026-01-01',
'date_to': '2026-12-31',
'company_id': self.env.company.id,
})
self.assertEqual(result['report_type'], 'pnl')
self.assertIn('rows', result)
self.assertIn('row_count', result)
def test_fusion_get_anomalies(self):
result = tools.fusion_get_anomalies(self.env, {
'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)
self.assertIn('count', result)
def test_fusion_generate_commentary(self):
result = tools.fusion_generate_commentary(self.env, {
'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)
self.assertIn('next_actions', result)
def test_fusion_drill_down(self):
line = self.env['account.move.line'].search(
[('parent_state', '=', 'posted')], limit=1,
)
if not line:
self.skipTest("No posted move lines")
result = tools.fusion_drill_down_report_line(self.env, {
'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)
self.assertIn('count', result)
def test_fusion_compare_periods(self):
result = tools.fusion_compare_periods(self.env, {
'report_type': 'pnl',
'date_from': '2026-01-01',
'date_to': '2026-12-31',
'company_id': self.env.company.id,
})
self.assertEqual(result['report_type'], 'pnl')
def test_tools_registered_in_dispatch(self):
from odoo.addons.fusion_accounting_ai.services.tools import TOOL_DISPATCH
for tool_name in [
'fusion_run_report',
'fusion_get_anomalies',
'fusion_generate_commentary',
'fusion_drill_down_report_line',
'fusion_compare_periods',
]:
self.assertIn(
tool_name, TOOL_DISPATCH,
f"{tool_name} not registered in TOOL_DISPATCH",
)

View File

@@ -0,0 +1,56 @@
"""Tests for ReportsAdapter Phase-2 (engine-routed) methods."""
from odoo.tests.common import TransactionCase, tagged
from odoo.addons.fusion_accounting_ai.services.data_adapters.reports import (
ReportsAdapter,
)
@tagged('post_install', '-at_install')
class TestReportsAdapter(TransactionCase):
def setUp(self):
super().setUp()
self.adapter = ReportsAdapter(self.env)
def test_run_fusion_report_via_fusion_pnl(self):
result = self.adapter.run_fusion_report_via_fusion(
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_fusion_report_via_community_returns_error(self):
result = self.adapter.run_fusion_report_via_community(
report_type='pnl',
date_from='2026-01-01',
date_to='2026-12-31',
)
self.assertIn('error', result)
def test_get_anomalies_via_fusion(self):
result = self.adapter.get_anomalies_via_fusion(
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)
self.assertIsInstance(result['anomalies'], list)
def test_get_commentary_via_fusion(self):
result = self.adapter.get_commentary_via_fusion(
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)
self.assertIn('next_actions', result)

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