diff --git a/fusion_accounting_reports/__manifest__.py b/fusion_accounting_reports/__manifest__.py index 0e3da9ef..ae0e2772 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.20', + 'version': '19.0.1.0.21', 'category': 'Accounting/Accounting', 'summary': 'AI-augmented financial reports (P&L, balance sheet, trial balance, GL).', 'description': """ @@ -35,6 +35,7 @@ menu hides; the engine and AI tools remain available for the chat. 'data/report_balance_sheet.xml', 'data/report_trial_balance.xml', 'data/report_general_ledger.xml', + 'data/cron.xml', ], 'assets': { 'web.assets_backend': [ diff --git a/fusion_accounting_reports/data/cron.xml b/fusion_accounting_reports/data/cron.xml new file mode 100644 index 00000000..4b602f90 --- /dev/null +++ b/fusion_accounting_reports/data/cron.xml @@ -0,0 +1,24 @@ + + + + + Fusion Reports - Daily Anomaly Scan + + code + model._cron_anomaly_scan() + 1 + days + + + + + Fusion Reports - MV Refresh + + code + model._cron_mv_refresh() + 15 + minutes + + + + diff --git a/fusion_accounting_reports/models/__init__.py b/fusion_accounting_reports/models/__init__.py index 0af3a39b..9beab560 100644 --- a/fusion_accounting_reports/models/__init__.py +++ b/fusion_accounting_reports/models/__init__.py @@ -3,3 +3,4 @@ from . import fusion_report_engine from . import fusion_report_commentary from . import fusion_report_anomaly from . import fusion_account_balance_mv +from . import fusion_reports_cron diff --git a/fusion_accounting_reports/models/fusion_reports_cron.py b/fusion_accounting_reports/models/fusion_reports_cron.py new file mode 100644 index 00000000..2b973a0f --- /dev/null +++ b/fusion_accounting_reports/models/fusion_reports_cron.py @@ -0,0 +1,117 @@ +"""Cron handlers for fusion_accounting_reports. + +Two scheduled jobs: +- _cron_anomaly_scan: daily P&L variance scan -> persist anomalies +- _cron_mv_refresh: every 15 min CONCURRENTLY refresh the MV""" + +import logging +from datetime import timedelta + +import odoo +from odoo import api, fields, models + +from ..services.anomaly_detection import detect +from ..services.date_periods import month_bounds + +_logger = logging.getLogger(__name__) + + +class FusionReportsCron(models.AbstractModel): + _name = "fusion.reports.cron" + _description = "Fusion Reports Cron Handlers" + + @api.model + def _cron_anomaly_scan(self): + """Run last-month P&L vs prior-year-same-month and persist anomalies.""" + today = fields.Date.today() + # Walk back into the previous full calendar month. + last_month = today.replace(day=1) - timedelta(days=1) + period = month_bounds(last_month) + + Report = self.env['fusion.report'].sudo() + Anomaly = self.env['fusion.report.anomaly'].sudo() + engine = self.env['fusion.report.engine'] + + for company in self.env['res.company'].search([]): + try: + pnl_def = Report.search( + [ + ('report_type', '=', 'pnl'), + '|', ('company_id', '=', company.id), + ('company_id', '=', False), + ], + limit=1, + ) + if not pnl_def: + continue + result = engine.compute_pnl( + period, + comparison='previous_year', + company_id=company.id, + ) + anomalies = detect(result) + for a in anomalies: + existing = Anomaly.search( + [ + ('report_id', '=', pnl_def.id), + ('company_id', '=', company.id), + ('period_from', '=', period.date_from), + ('period_to', '=', period.date_to), + ('row_id', '=', a['row_id']), + ], + limit=1, + ) + vals = { + 'report_id': pnl_def.id, + 'company_id': company.id, + 'period_from': period.date_from, + 'period_to': period.date_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) + _logger.info( + "Anomaly scan for company %s: %d flagged", + company.id, len(anomalies), + ) + except Exception as e: + _logger.exception( + "Anomaly scan failed for company %s: %s", company.id, e, + ) + + @api.model + def _cron_mv_refresh(self): + """REFRESH CONCURRENTLY via dedicated autocommit cursor. + + REFRESH MATERIALIZED VIEW CONCURRENTLY cannot run inside a + transaction block, so we open a separate connection with autocommit + enabled. The blocking REFRESH is used as a fallback if the + concurrent path fails (e.g. on a cold MV with no rows yet).""" + try: + db_name = self.env.cr.dbname + db = odoo.sql_db.db_connect(db_name) + with db.cursor() as cron_cr: + cron_cr._cnx.set_session(autocommit=True) + cron_cr.execute( + "REFRESH MATERIALIZED VIEW CONCURRENTLY " + "fusion_account_balance_mv" + ) + _logger.debug("MV refresh CONCURRENTLY succeeded") + except Exception as e: + _logger.warning( + "CONCURRENTLY refresh failed (%s); blocking fallback", e) + try: + self.env['fusion.account.balance.mv']._refresh( + concurrently=False) + except Exception as e2: + _logger.exception( + "Blocking MV refresh also failed: %s", e2) diff --git a/fusion_accounting_reports/tests/__init__.py b/fusion_accounting_reports/tests/__init__.py index 3885f257..86b1c345 100644 --- a/fusion_accounting_reports/tests/__init__.py +++ b/fusion_accounting_reports/tests/__init__.py @@ -17,3 +17,4 @@ from . import test_engine_property from . import test_pnl_integration from . import test_bs_tb_integration from . import test_account_balance_mv +from . import test_cron diff --git a/fusion_accounting_reports/tests/test_cron.py b/fusion_accounting_reports/tests/test_cron.py new file mode 100644 index 00000000..ca4095a0 --- /dev/null +++ b/fusion_accounting_reports/tests/test_cron.py @@ -0,0 +1,20 @@ +"""Tests for cron handlers.""" + +from odoo.tests.common import TransactionCase, tagged + + +@tagged('post_install', '-at_install') +class TestFusionReportsCron(TransactionCase): + + def setUp(self): + super().setUp() + self.cron = self.env['fusion.reports.cron'] + + def test_cron_mv_refresh_does_not_raise(self): + # Smoke test: the cron must complete without raising even if the + # CONCURRENTLY path fails on a cold MV (the handler falls back). + self.cron._cron_mv_refresh() + + def test_cron_anomaly_scan_does_not_raise(self): + # Smoke test: scan all companies, persist anomalies, no exceptions. + self.cron._cron_anomaly_scan()