From fec1c12246c2144c1baf7639b1c6df8f68b33afc Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 19 Apr 2026 17:25:14 -0400 Subject: [PATCH] feat(fusion_accounting_assets): MV for per-asset book value snapshot Made-with: Cursor --- fusion_accounting_assets/__manifest__.py | 2 +- fusion_accounting_assets/data/cron.xml | 10 ++++ .../data/sql/create_mv_asset_book_values.sql | 29 +++++++++ fusion_accounting_assets/models/__init__.py | 1 + .../models/fusion_asset_book_values_mv.py | 59 +++++++++++++++++++ .../models/fusion_assets_cron.py | 11 ++++ fusion_accounting_assets/tests/__init__.py | 1 + .../tests/test_asset_book_values_mv.py | 29 +++++++++ 8 files changed, 141 insertions(+), 1 deletion(-) create mode 100644 fusion_accounting_assets/data/sql/create_mv_asset_book_values.sql create mode 100644 fusion_accounting_assets/models/fusion_asset_book_values_mv.py create mode 100644 fusion_accounting_assets/tests/test_asset_book_values_mv.py diff --git a/fusion_accounting_assets/__manifest__.py b/fusion_accounting_assets/__manifest__.py index f13587c1..032e8ab7 100644 --- a/fusion_accounting_assets/__manifest__.py +++ b/fusion_accounting_assets/__manifest__.py @@ -1,6 +1,6 @@ { 'name': 'Fusion Accounting Assets', - 'version': '19.0.1.0.17', + 'version': '19.0.1.0.18', 'category': 'Accounting/Accounting', 'summary': 'AI-augmented asset management with depreciation schedules.', 'description': """ diff --git a/fusion_accounting_assets/data/cron.xml b/fusion_accounting_assets/data/cron.xml index 7363bd6e..6687d958 100644 --- a/fusion_accounting_assets/data/cron.xml +++ b/fusion_accounting_assets/data/cron.xml @@ -11,6 +11,16 @@ + + Fusion Assets — Refresh Book Values MV + + code + model._cron_refresh_book_values_mv() + 1 + hours + + + Fusion Assets — Monthly Anomaly Scan diff --git a/fusion_accounting_assets/data/sql/create_mv_asset_book_values.sql b/fusion_accounting_assets/data/sql/create_mv_asset_book_values.sql new file mode 100644 index 00000000..0f70db3d --- /dev/null +++ b/fusion_accounting_assets/data/sql/create_mv_asset_book_values.sql @@ -0,0 +1,29 @@ +-- Materialized view: per-asset book value snapshot. +-- Refreshed via cron. Used by the OWL dashboard for portfolio summaries. + +CREATE MATERIALIZED VIEW IF NOT EXISTS fusion_asset_book_values_mv AS +SELECT + a.id AS id, + a.id AS asset_id, + a.company_id, + a.category_id, + a.state, + a.cost, + a.salvage_value, + COALESCE(SUM(CASE WHEN l.is_posted THEN l.amount ELSE 0 END), 0) AS total_depreciated, + a.cost - COALESCE(SUM(CASE WHEN l.is_posted THEN l.amount ELSE 0 END), 0) AS book_value, + COUNT(l.id) FILTER (WHERE l.is_posted) AS posted_periods, + COUNT(l.id) FILTER (WHERE NOT l.is_posted) AS pending_periods, + a.acquisition_date, + a.in_service_date +FROM fusion_asset a +LEFT JOIN fusion_asset_depreciation_line l ON l.asset_id = a.id +GROUP BY a.id, a.company_id, a.category_id, a.state, a.cost, a.salvage_value, + a.acquisition_date, a.in_service_date; + +CREATE UNIQUE INDEX IF NOT EXISTS fusion_asset_book_values_mv_pkey + ON fusion_asset_book_values_mv (id); +CREATE INDEX IF NOT EXISTS fusion_asset_book_values_mv_company_state + ON fusion_asset_book_values_mv (company_id, state); +CREATE INDEX IF NOT EXISTS fusion_asset_book_values_mv_category + ON fusion_asset_book_values_mv (category_id) WHERE category_id IS NOT NULL; diff --git a/fusion_accounting_assets/models/__init__.py b/fusion_accounting_assets/models/__init__.py index ef38f3a4..d5183ee1 100644 --- a/fusion_accounting_assets/models/__init__.py +++ b/fusion_accounting_assets/models/__init__.py @@ -6,3 +6,4 @@ from . import fusion_asset_anomaly from . import account_move from . import fusion_asset_engine from . import fusion_assets_cron +from . import fusion_asset_book_values_mv diff --git a/fusion_accounting_assets/models/fusion_asset_book_values_mv.py b/fusion_accounting_assets/models/fusion_asset_book_values_mv.py new file mode 100644 index 00000000..8471ff38 --- /dev/null +++ b/fusion_accounting_assets/models/fusion_asset_book_values_mv.py @@ -0,0 +1,59 @@ +"""MV of per-asset book value snapshot. Refresh via cron or model._refresh().""" + +import logging +import os + +from odoo import api, fields, models + +_logger = logging.getLogger(__name__) + + +class FusionAssetBookValuesMV(models.Model): + _name = "fusion.asset.book.values.mv" + _description = "MV of asset book value snapshot" + _auto = False + _table = "fusion_asset_book_values_mv" + _order = "book_value desc" + + asset_id = fields.Many2one('fusion.asset', readonly=True) + company_id = fields.Many2one('res.company', readonly=True) + category_id = fields.Many2one('fusion.asset.category', readonly=True) + state = fields.Char(readonly=True) + cost = fields.Float(readonly=True) + salvage_value = fields.Float(readonly=True) + total_depreciated = fields.Float(readonly=True) + book_value = fields.Float(readonly=True) + posted_periods = fields.Integer(readonly=True) + pending_periods = fields.Integer(readonly=True) + acquisition_date = fields.Date(readonly=True) + in_service_date = fields.Date(readonly=True) + + def init(self): + sql_path = os.path.join( + os.path.dirname(__file__), '..', 'data', 'sql', + 'create_mv_asset_book_values.sql', + ) + with open(sql_path, 'r') as f: + self.env.cr.execute(f.read()) + _logger.info("fusion_asset_book_values_mv: created/verified MV") + + @api.model + def _refresh(self, *, concurrently=True): + # CONCURRENTLY requires a unique index (we have one) and that the MV + # has been populated at least once. Wrap the concurrent attempt in a + # savepoint so a failure (e.g. first-ever refresh before the MV is + # populated) does NOT poison the surrounding transaction; we then + # fall back to a plain REFRESH. + if concurrently: + try: + with self.env.cr.savepoint(): + self.env.cr.execute( + "REFRESH MATERIALIZED VIEW CONCURRENTLY " + "fusion_asset_book_values_mv" + ) + return + except Exception as e: # noqa: BLE001 + _logger.warning("Concurrent MV refresh failed (%s); fallback", e) + self.env.cr.execute( + "REFRESH MATERIALIZED VIEW fusion_asset_book_values_mv" + ) diff --git a/fusion_accounting_assets/models/fusion_assets_cron.py b/fusion_accounting_assets/models/fusion_assets_cron.py index 4bf16429..57735afb 100644 --- a/fusion_accounting_assets/models/fusion_assets_cron.py +++ b/fusion_accounting_assets/models/fusion_assets_cron.py @@ -36,6 +36,17 @@ class FusionAssetsCron(models.AbstractModel): "Cron: posted depreciation on %d lines across %d running assets", posted_total, len(running_assets), ) + # Keep the book-value MV in sync after posting so the dashboard + # reflects today's numbers without waiting for the dedicated MV cron. + try: + self.env['fusion.asset.book.values.mv']._refresh() + except Exception as e: # noqa: BLE001 + _logger.warning("Post-cron MV refresh failed: %s", e) + + @api.model + def _cron_refresh_book_values_mv(self): + """Refresh the per-asset book value MV (hourly).""" + self.env['fusion.asset.book.values.mv']._refresh() @api.model def _cron_anomaly_scan(self): diff --git a/fusion_accounting_assets/tests/__init__.py b/fusion_accounting_assets/tests/__init__.py index e516a69d..f21110c5 100644 --- a/fusion_accounting_assets/tests/__init__.py +++ b/fusion_accounting_assets/tests/__init__.py @@ -17,3 +17,4 @@ from . import test_asset_tools from . import test_assets_cron from . import test_engine_property from . import test_method_integration +from . import test_asset_book_values_mv diff --git a/fusion_accounting_assets/tests/test_asset_book_values_mv.py b/fusion_accounting_assets/tests/test_asset_book_values_mv.py new file mode 100644 index 00000000..e21fe161 --- /dev/null +++ b/fusion_accounting_assets/tests/test_asset_book_values_mv.py @@ -0,0 +1,29 @@ +"""Tests for the per-asset book value MV.""" + +from datetime import date + +from odoo.tests.common import TransactionCase +from odoo.tests import tagged + + +@tagged('post_install', '-at_install') +class TestAssetBookValuesMV(TransactionCase): + + def test_mv_exists_and_is_queryable(self): + self.env['fusion.asset.book.values.mv']._refresh(concurrently=False) + rows = self.env['fusion.asset.book.values.mv'].search([], limit=10) + self.assertIsNotNone(rows) + + def test_mv_includes_new_asset_after_refresh(self): + asset = self.env['fusion.asset'].create({ + 'name': 'MV Test', 'cost': 5000, 'salvage_value': 500, + 'acquisition_date': date(2026, 1, 1), + 'method': 'straight_line', 'useful_life_years': 5, + }) + self.env.flush_all() + self.env['fusion.asset.book.values.mv']._refresh(concurrently=False) + mv_row = self.env['fusion.asset.book.values.mv'].search([ + ('asset_id', '=', asset.id), + ], limit=1) + self.assertTrue(mv_row) + self.assertAlmostEqual(mv_row.book_value, 5000, places=2)