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)