"""Reports data adapter. Routes report-data lookups across: - FUSION: fusion.account.report (added by fusion_accounting_reports, Phase 2) - ENTERPRISE: account.report from account_reports - COMMUNITY: raw aggregations on account.move.line """ import base64 import logging from .base import DataAdapter from ._registry import register_adapter _logger = logging.getLogger(__name__) class ReportsAdapter(DataAdapter): # 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' # ------------------------------------------------------------------ # trial_balance (Community-computable from account.move.line) # ------------------------------------------------------------------ def trial_balance(self, date_to=None, company_ids=None): return self._dispatch('trial_balance', date_to=date_to, company_ids=company_ids) def trial_balance_via_fusion(self, date_to=None, company_ids=None): # Phase 2 will implement; for now defer to community. return self.trial_balance_via_community(date_to=date_to, company_ids=company_ids) def trial_balance_via_enterprise(self, date_to=None, company_ids=None): # Enterprise account_reports has rich filters; for AI-tool consumption, # the community shape suffices and avoids brittle coupling to Odoo's # report-line internals. return self.trial_balance_via_community(date_to=date_to, company_ids=company_ids) def trial_balance_via_community(self, date_to=None, company_ids=None): domain = [('parent_state', '=', 'posted')] if date_to: domain.append(('date', '<=', date_to)) if company_ids: domain.append(('company_id', 'in', list(company_ids))) Line = self.env['account.move.line'].sudo() groups = Line._read_group( domain=domain, groupby=['account_id'], aggregates=['debit:sum', 'credit:sum'], ) return [ { 'account_id': account.id, 'account_code': account.code, 'account_name': account.name, 'debit': debit_sum, 'credit': credit_sum, 'balance': debit_sum - credit_sum, } for account, debit_sum, credit_sum in groups ] # ------------------------------------------------------------------ # run_report — generic Enterprise account.report wrapper # # Returns either {'report_name', 'lines'} or {'error': ...}. # Used by profit_loss / balance_sheet / cash_flow / trial_balance_lines # tool wrappers that want Enterprise's hierarchical report shape when # available. # ------------------------------------------------------------------ def run_report(self, ref_id, date_from=None, date_to=None, limit=100): return self._dispatch( 'run_report', ref_id=ref_id, date_from=date_from, date_to=date_to, limit=limit, ) def run_report_via_fusion(self, ref_id, date_from=None, date_to=None, limit=100): # Phase 2: fusion.account.report will implement equivalent rendering. return self.run_report_via_community( ref_id=ref_id, date_from=date_from, date_to=date_to, limit=limit, ) def run_report_via_enterprise(self, ref_id, date_from=None, date_to=None, limit=100): try: report = self.env.ref(ref_id, raise_if_not_found=False) except Exception: report = None if not report: return {'error': f'Report {ref_id} not found'} date_opts = {} if date_from: date_opts['date_from'] = date_from if date_to: date_opts['date_to'] = date_to options = report.get_options({'date': date_opts} if date_opts else {}) lines = report._get_lines(options) return { 'report_name': report.name, 'lines': [{ 'name': line.get('name', ''), 'level': line.get('level', 0), 'columns': [c.get('no_format', c.get('name', '')) for c in line.get('columns', [])], } for line in lines[:limit]], } def run_report_via_community(self, ref_id, date_from=None, date_to=None, limit=100): return { 'error': ( f'Report {ref_id!r} is only available when account_reports (Enterprise) ' 'or a fusion reports module is installed. For pure Community installs, ' 'use the raw trial_balance() adapter method or the tools that aggregate ' 'account.move.line directly.' ), } # ------------------------------------------------------------------ # export_report — Enterprise-only PDF/XLSX export # ------------------------------------------------------------------ def export_report(self, ref_id, fmt='pdf', date_from=None, date_to=None): return self._dispatch( 'export_report', ref_id=ref_id, fmt=fmt, date_from=date_from, date_to=date_to, ) def export_report_via_fusion(self, ref_id, fmt='pdf', date_from=None, date_to=None): return self.export_report_via_community( ref_id=ref_id, fmt=fmt, date_from=date_from, date_to=date_to, ) def export_report_via_enterprise(self, ref_id, fmt='pdf', date_from=None, date_to=None): try: report = self.env.ref(ref_id, raise_if_not_found=False) except Exception: report = None if not report: return {'error': f'Report {ref_id} not found'} date_opts = {} if date_from: date_opts['date_from'] = date_from if date_to: date_opts['date_to'] = date_to options = report.get_options({'date': date_opts} if date_opts else {}) try: if fmt == 'xlsx': result = report.dispatch_report_action(options, 'export_to_xlsx') else: result = report.dispatch_report_action(options, 'export_to_pdf') if isinstance(result, dict) and result.get('file_content'): return { 'file_name': result.get('file_name', f'report.{fmt}'), 'file_type': result.get('file_type', fmt), 'file_content_b64': base64.b64encode(result['file_content']).decode(), } return { 'status': 'generated', 'message': f'Report exported as {fmt}. Use the Odoo UI to download.', } except Exception as e: return {'error': f'Export failed: {str(e)}'} def export_report_via_community(self, ref_id, fmt='pdf', date_from=None, date_to=None): return { 'error': ( f'Exporting report {ref_id!r} is only available with Enterprise ' 'account_reports installed.' ), } # ================================================================== # 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)