feat(fusion_accounting_reports): MV for per-account-per-month balances

Made-with: Cursor
This commit is contained in:
gsinghpal
2026-04-19 15:53:34 -04:00
parent 0f575dd523
commit 9db7271bdf
6 changed files with 134 additions and 1 deletions

View File

@@ -1,6 +1,6 @@
{
'name': 'Fusion Accounting Reports',
'version': '19.0.1.0.19',
'version': '19.0.1.0.20',
'category': 'Accounting/Accounting',
'summary': 'AI-augmented financial reports (P&L, balance sheet, trial balance, GL).',
'description': """

View File

@@ -0,0 +1,31 @@
-- Materialized view: per-account aggregated balances by year-month.
-- Used by GL drill-down + trial balance for large DBs.
-- Refresh strategy: cron every 15 minutes (Task 25); CONCURRENTLY-capable
-- thanks to the unique index.
CREATE MATERIALIZED VIEW IF NOT EXISTS fusion_account_balance_mv AS
SELECT
ROW_NUMBER() OVER (
ORDER BY account_id, company_id, DATE_TRUNC('month', date)
)::INTEGER AS id,
account_id,
company_id,
DATE_TRUNC('month', date)::date AS period_month,
SUM(debit) AS debit,
SUM(credit) AS credit,
SUM(balance) AS balance,
COUNT(*) AS line_count
FROM account_move_line
WHERE parent_state = 'posted'
GROUP BY account_id, company_id, DATE_TRUNC('month', date);
-- The (account_id, company_id, period_month) tuple is the natural key.
-- We mark it UNIQUE so REFRESH MATERIALIZED VIEW CONCURRENTLY is allowed.
CREATE UNIQUE INDEX IF NOT EXISTS fusion_account_balance_mv_pkey
ON fusion_account_balance_mv (account_id, company_id, period_month);
-- A separate index on the synthetic id is required by Odoo's ORM, which
-- expects every model row to be addressable by `id`.
CREATE UNIQUE INDEX IF NOT EXISTS fusion_account_balance_mv_id_idx
ON fusion_account_balance_mv (id);
CREATE INDEX IF NOT EXISTS fusion_account_balance_mv_company_month
ON fusion_account_balance_mv (company_id, period_month);

View File

@@ -2,3 +2,4 @@ from . import fusion_report
from . import fusion_report_engine
from . import fusion_report_commentary
from . import fusion_report_anomaly
from . import fusion_account_balance_mv

View File

@@ -0,0 +1,80 @@
"""Materialized view of per-account-per-month balances.
Created lazily by init() (called by Odoo on install/upgrade). Refresh
via the model's _refresh() method or via cron (Task 25)."""
import logging
import os
from odoo import api, fields, models
_logger = logging.getLogger(__name__)
class FusionAccountBalanceMV(models.Model):
_name = "fusion.account.balance.mv"
_description = "MV of per-account per-month aggregated balances"
_auto = False
_table = "fusion_account_balance_mv"
_order = "period_month desc, account_id"
account_id = fields.Many2one('account.account', readonly=True)
company_id = fields.Many2one('res.company', readonly=True)
period_month = fields.Date(readonly=True)
debit = fields.Float(readonly=True)
credit = fields.Float(readonly=True)
balance = fields.Float(readonly=True)
line_count = fields.Integer(readonly=True)
def init(self):
# If the MV exists but is missing the synthetic `id` column (e.g. from
# an earlier dev install), drop it so the new schema applies cleanly.
self.env.cr.execute(
"""
SELECT 1
FROM pg_matviews mv
JOIN pg_attribute a
ON a.attrelid = (mv.schemaname || '.' || mv.matviewname)::regclass
AND a.attname = 'id'
WHERE mv.matviewname = 'fusion_account_balance_mv'
"""
)
if not self.env.cr.fetchone():
self.env.cr.execute(
"DROP MATERIALIZED VIEW IF EXISTS fusion_account_balance_mv"
)
sql_path = os.path.join(
os.path.dirname(__file__), '..', 'data', 'sql',
'create_mv_account_balance.sql',
)
with open(sql_path, 'r') as f:
self.env.cr.execute(f.read())
_logger.info(
"fusion_account_balance_mv: created/verified MV + indexes")
@api.model
def _refresh(self, *, concurrently=True):
"""Refresh the MV. Falls back to non-concurrent if CONCURRENTLY fails.
REFRESH MATERIALIZED VIEW CONCURRENTLY requires the MV to be already
populated and an autocommit-capable cursor; the cron path in Task 25
opens a dedicated cursor for that. This helper keeps callers safe by
retrying without CONCURRENTLY on failure."""
keyword = "CONCURRENTLY" if concurrently else ""
try:
self.env.cr.execute(
f"REFRESH MATERIALIZED VIEW {keyword} fusion_account_balance_mv"
)
_logger.debug(
"fusion_account_balance_mv refreshed (%s)",
'concurrent' if concurrently else 'blocking',
)
except Exception as e:
if concurrently:
_logger.warning(
"Concurrent MV refresh failed (%s); falling back", e)
self.env.cr.execute(
"REFRESH MATERIALIZED VIEW fusion_account_balance_mv"
)
else:
raise

View File

@@ -16,3 +16,4 @@ from . import test_fusion_report_tools
from . import test_engine_property
from . import test_pnl_integration
from . import test_bs_tb_integration
from . import test_account_balance_mv

View File

@@ -0,0 +1,20 @@
"""Tests for fusion_account_balance MV."""
from odoo.tests.common import TransactionCase, tagged
@tagged('post_install', '-at_install')
class TestAccountBalanceMV(TransactionCase):
def test_mv_exists_and_is_queryable(self):
# Force initial refresh, then make sure the model can read it.
self.env['fusion.account.balance.mv']._refresh(concurrently=False)
rows = self.env['fusion.account.balance.mv'].search([], limit=5)
self.assertIsNotNone(rows)
def test_mv_refresh_concurrent(self):
# Try concurrent refresh; should either succeed or fall back gracefully.
try:
self.env['fusion.account.balance.mv']._refresh(concurrently=True)
except Exception as e:
self.fail(f"MV refresh raised: {e}")