feat(fusion_accounting_reports): MV for per-account-per-month balances
Made-with: Cursor
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
'name': 'Fusion Accounting Reports',
|
'name': 'Fusion Accounting Reports',
|
||||||
'version': '19.0.1.0.19',
|
'version': '19.0.1.0.20',
|
||||||
'category': 'Accounting/Accounting',
|
'category': 'Accounting/Accounting',
|
||||||
'summary': 'AI-augmented financial reports (P&L, balance sheet, trial balance, GL).',
|
'summary': 'AI-augmented financial reports (P&L, balance sheet, trial balance, GL).',
|
||||||
'description': """
|
'description': """
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -2,3 +2,4 @@ from . import fusion_report
|
|||||||
from . import fusion_report_engine
|
from . import fusion_report_engine
|
||||||
from . import fusion_report_commentary
|
from . import fusion_report_commentary
|
||||||
from . import fusion_report_anomaly
|
from . import fusion_report_anomaly
|
||||||
|
from . import fusion_account_balance_mv
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -16,3 +16,4 @@ from . import test_fusion_report_tools
|
|||||||
from . import test_engine_property
|
from . import test_engine_property
|
||||||
from . import test_pnl_integration
|
from . import test_pnl_integration
|
||||||
from . import test_bs_tb_integration
|
from . import test_bs_tb_integration
|
||||||
|
from . import test_account_balance_mv
|
||||||
|
|||||||
20
fusion_accounting_reports/tests/test_account_balance_mv.py
Normal file
20
fusion_accounting_reports/tests/test_account_balance_mv.py
Normal 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}")
|
||||||
Reference in New Issue
Block a user