From 118f0d9d16dd885a6633146edc5b4beb97c1ef49 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 19 Apr 2026 15:41:10 -0400 Subject: [PATCH] feat(fusion_accounting_ai): 5 new financial reports AI tools 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 --- .../services/tools/__init__.py | 3 +- .../services/tools/financial_reports.py | 127 ++++++++++++++++++ fusion_accounting_reports/__manifest__.py | 2 +- fusion_accounting_reports/tests/__init__.py | 1 + .../tests/test_fusion_report_tools.py | 81 +++++++++++ 5 files changed, 212 insertions(+), 2 deletions(-) create mode 100644 fusion_accounting_ai/services/tools/financial_reports.py create mode 100644 fusion_accounting_reports/tests/test_fusion_report_tools.py diff --git a/fusion_accounting_ai/services/tools/__init__.py b/fusion_accounting_ai/services/tools/__init__.py index b97b6963..17a6e9b2 100644 --- a/fusion_accounting_ai/services/tools/__init__.py +++ b/fusion_accounting_ai/services/tools/__init__.py @@ -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) diff --git a/fusion_accounting_ai/services/tools/financial_reports.py b/fusion_accounting_ai/services/tools/financial_reports.py new file mode 100644 index 00000000..e07d6f2b --- /dev/null +++ b/fusion_accounting_ai/services/tools/financial_reports.py @@ -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, +} diff --git a/fusion_accounting_reports/__manifest__.py b/fusion_accounting_reports/__manifest__.py index af850876..0fd4c4aa 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.15', + 'version': '19.0.1.0.16', 'category': 'Accounting/Accounting', 'summary': 'AI-augmented financial reports (P&L, balance sheet, trial balance, GL).', 'description': """ diff --git a/fusion_accounting_reports/tests/__init__.py b/fusion_accounting_reports/tests/__init__.py index 5e75a417..38c9c8bb 100644 --- a/fusion_accounting_reports/tests/__init__.py +++ b/fusion_accounting_reports/tests/__init__.py @@ -12,3 +12,4 @@ 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 diff --git a/fusion_accounting_reports/tests/test_fusion_report_tools.py b/fusion_accounting_reports/tests/test_fusion_report_tools.py new file mode 100644 index 00000000..d671b366 --- /dev/null +++ b/fusion_accounting_reports/tests/test_fusion_report_tools.py @@ -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", + )