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
225 lines
8.8 KiB
Python
225 lines
8.8 KiB
Python
"""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',
|
|
}
|