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
331 lines
14 KiB
Python
331 lines
14 KiB
Python
"""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)
|