feat(fusion_accounting_reports): 2 cron jobs (anomaly scan + MV refresh)

Made-with: Cursor
This commit is contained in:
gsinghpal
2026-04-19 15:54:50 -04:00
parent 9db7271bdf
commit 97640a5ac8
6 changed files with 165 additions and 1 deletions

View File

@@ -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': [

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="cron_fusion_reports_anomaly_scan" model="ir.cron">
<field name="name">Fusion Reports - Daily Anomaly Scan</field>
<field name="model_id" ref="model_fusion_reports_cron"/>
<field name="state">code</field>
<field name="code">model._cron_anomaly_scan()</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="active" eval="True"/>
</record>
<record id="cron_fusion_reports_mv_refresh" model="ir.cron">
<field name="name">Fusion Reports - MV Refresh</field>
<field name="model_id" ref="model_fusion_reports_cron"/>
<field name="state">code</field>
<field name="code">model._cron_mv_refresh()</field>
<field name="interval_number">15</field>
<field name="interval_type">minutes</field>
<field name="active" eval="True"/>
</record>
</odoo>

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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()