From 9db7271bdf580347e6a22cb4ae970dbe7db490f6 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 19 Apr 2026 15:53:34 -0400 Subject: [PATCH] feat(fusion_accounting_reports): MV for per-account-per-month balances Made-with: Cursor --- fusion_accounting_reports/__manifest__.py | 2 +- .../data/sql/create_mv_account_balance.sql | 31 +++++++ fusion_accounting_reports/models/__init__.py | 1 + .../models/fusion_account_balance_mv.py | 80 +++++++++++++++++++ fusion_accounting_reports/tests/__init__.py | 1 + .../tests/test_account_balance_mv.py | 20 +++++ 6 files changed, 134 insertions(+), 1 deletion(-) create mode 100644 fusion_accounting_reports/data/sql/create_mv_account_balance.sql create mode 100644 fusion_accounting_reports/models/fusion_account_balance_mv.py create mode 100644 fusion_accounting_reports/tests/test_account_balance_mv.py diff --git a/fusion_accounting_reports/__manifest__.py b/fusion_accounting_reports/__manifest__.py index 7dd38056..0e3da9ef 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.19', + 'version': '19.0.1.0.20', 'category': 'Accounting/Accounting', 'summary': 'AI-augmented financial reports (P&L, balance sheet, trial balance, GL).', 'description': """ diff --git a/fusion_accounting_reports/data/sql/create_mv_account_balance.sql b/fusion_accounting_reports/data/sql/create_mv_account_balance.sql new file mode 100644 index 00000000..3ccdd800 --- /dev/null +++ b/fusion_accounting_reports/data/sql/create_mv_account_balance.sql @@ -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); diff --git a/fusion_accounting_reports/models/__init__.py b/fusion_accounting_reports/models/__init__.py index 2bd452d8..0af3a39b 100644 --- a/fusion_accounting_reports/models/__init__.py +++ b/fusion_accounting_reports/models/__init__.py @@ -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 diff --git a/fusion_accounting_reports/models/fusion_account_balance_mv.py b/fusion_accounting_reports/models/fusion_account_balance_mv.py new file mode 100644 index 00000000..3a2d7136 --- /dev/null +++ b/fusion_accounting_reports/models/fusion_account_balance_mv.py @@ -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 diff --git a/fusion_accounting_reports/tests/__init__.py b/fusion_accounting_reports/tests/__init__.py index 01398d5a..3885f257 100644 --- a/fusion_accounting_reports/tests/__init__.py +++ b/fusion_accounting_reports/tests/__init__.py @@ -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 diff --git a/fusion_accounting_reports/tests/test_account_balance_mv.py b/fusion_accounting_reports/tests/test_account_balance_mv.py new file mode 100644 index 00000000..8f324636 --- /dev/null +++ b/fusion_accounting_reports/tests/test_account_balance_mv.py @@ -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}")