feat(fusion_accounting_assets): MV for per-asset book value snapshot

Made-with: Cursor
This commit is contained in:
gsinghpal
2026-04-19 17:25:14 -04:00
parent c939b83812
commit fec1c12246
8 changed files with 141 additions and 1 deletions

View File

@@ -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': """

View File

@@ -11,6 +11,16 @@
<field name="active" eval="True"/>
</record>
<record id="cron_fusion_assets_refresh_book_values_mv" model="ir.cron">
<field name="name">Fusion Assets — Refresh Book Values MV</field>
<field name="model_id" ref="model_fusion_assets_cron"/>
<field name="state">code</field>
<field name="code">model._cron_refresh_book_values_mv()</field>
<field name="interval_number">1</field>
<field name="interval_type">hours</field>
<field name="active" eval="True"/>
</record>
<record id="cron_fusion_assets_anomaly_scan" model="ir.cron">
<field name="name">Fusion Assets — Monthly Anomaly Scan</field>
<field name="model_id" ref="model_fusion_assets_cron"/>

View File

@@ -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;

View File

@@ -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

View File

@@ -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"
)

View File

@@ -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):

View File

@@ -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

View File

@@ -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)