From 8659f519359ff9e75945e209a473a1dbe02e5571 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 19 Apr 2026 16:58:56 -0400 Subject: [PATCH] feat(fusion_accounting_assets): asset anomaly persisted model - 3 anomaly types: behind_schedule, ahead_of_schedule, low_utilization - 3 severity levels: low, medium, high - expected / actual / variance_pct (mirrors anomaly_detection service output) - 4-state lifecycle: new -> acknowledged -> resolved (or dismissed) - action_acknowledge / action_dismiss / action_resolve transitions - ondelete='cascade' on asset_id (anomalies follow the asset) - 4 new tests (63 total) Made-with: Cursor --- fusion_accounting_assets/__manifest__.py | 2 +- fusion_accounting_assets/models/__init__.py | 1 + .../models/fusion_asset_anomaly.py | 42 ++++++++++++++++ .../security/ir.model.access.csv | 2 + fusion_accounting_assets/tests/__init__.py | 1 + .../tests/test_fusion_asset_anomaly.py | 49 +++++++++++++++++++ 6 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 fusion_accounting_assets/models/fusion_asset_anomaly.py create mode 100644 fusion_accounting_assets/tests/test_fusion_asset_anomaly.py diff --git a/fusion_accounting_assets/__manifest__.py b/fusion_accounting_assets/__manifest__.py index 1af012e4..c056e6c3 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.9', + 'version': '19.0.1.0.10', 'category': 'Accounting/Accounting', 'summary': 'AI-augmented asset management with depreciation schedules.', 'description': """ diff --git a/fusion_accounting_assets/models/__init__.py b/fusion_accounting_assets/models/__init__.py index ac027f8f..f2533127 100644 --- a/fusion_accounting_assets/models/__init__.py +++ b/fusion_accounting_assets/models/__init__.py @@ -2,3 +2,4 @@ from . import fusion_asset_category from . import fusion_asset from . import fusion_asset_depreciation_line from . import fusion_asset_disposal +from . import fusion_asset_anomaly diff --git a/fusion_accounting_assets/models/fusion_asset_anomaly.py b/fusion_accounting_assets/models/fusion_asset_anomaly.py new file mode 100644 index 00000000..693ee6b9 --- /dev/null +++ b/fusion_accounting_assets/models/fusion_asset_anomaly.py @@ -0,0 +1,42 @@ +"""Persisted asset anomaly flags from the engine's variance detection.""" + +from odoo import fields, models + + +SEVERITY = [('low', 'Low'), ('medium', 'Medium'), ('high', 'High')] +ANOMALY_TYPES = [ + ('behind_schedule', 'Behind Schedule'), + ('ahead_of_schedule', 'Ahead of Schedule'), + ('low_utilization', 'Low Utilization'), +] + + +class FusionAssetAnomaly(models.Model): + _name = "fusion.asset.anomaly" + _description = "Flagged Asset Anomaly" + _order = "detected_at desc, severity desc" + + asset_id = fields.Many2one('fusion.asset', required=True, ondelete='cascade') + company_id = fields.Many2one(related='asset_id.company_id', store=True) + anomaly_type = fields.Selection(ANOMALY_TYPES, required=True) + severity = fields.Selection(SEVERITY, required=True) + expected = fields.Float() + actual = fields.Float() + variance_pct = fields.Float() + detail = fields.Text() + detected_at = fields.Datetime(default=fields.Datetime.now, required=True) + state = fields.Selection([ + ('new', 'New'), + ('acknowledged', 'Acknowledged'), + ('resolved', 'Resolved'), + ('dismissed', 'Dismissed'), + ], default='new', required=True) + + def action_acknowledge(self): + self.write({'state': 'acknowledged'}) + + def action_dismiss(self): + self.write({'state': 'dismissed'}) + + def action_resolve(self): + self.write({'state': 'resolved'}) diff --git a/fusion_accounting_assets/security/ir.model.access.csv b/fusion_accounting_assets/security/ir.model.access.csv index b4fd5897..aae79f2e 100644 --- a/fusion_accounting_assets/security/ir.model.access.csv +++ b/fusion_accounting_assets/security/ir.model.access.csv @@ -7,3 +7,5 @@ access_fusion_asset_category_user,fusion.asset.category.user,model_fusion_asset_ access_fusion_asset_category_admin,fusion.asset.category.admin,model_fusion_asset_category,fusion_accounting_core.group_fusion_accounting_admin,1,1,1,1 access_fusion_asset_disposal_user,fusion.asset.disposal.user,model_fusion_asset_disposal,base.group_user,1,0,0,0 access_fusion_asset_disposal_admin,fusion.asset.disposal.admin,model_fusion_asset_disposal,fusion_accounting_core.group_fusion_accounting_admin,1,1,1,1 +access_fusion_asset_anomaly_user,fusion.asset.anomaly.user,model_fusion_asset_anomaly,base.group_user,1,0,0,0 +access_fusion_asset_anomaly_admin,fusion.asset.anomaly.admin,model_fusion_asset_anomaly,fusion_accounting_core.group_fusion_accounting_admin,1,1,1,1 diff --git a/fusion_accounting_assets/tests/__init__.py b/fusion_accounting_assets/tests/__init__.py index 18a546bd..bd3a87a9 100644 --- a/fusion_accounting_assets/tests/__init__.py +++ b/fusion_accounting_assets/tests/__init__.py @@ -7,3 +7,4 @@ from . import test_fusion_asset from . import test_fusion_asset_depreciation_line from . import test_fusion_asset_category from . import test_fusion_asset_disposal +from . import test_fusion_asset_anomaly diff --git a/fusion_accounting_assets/tests/test_fusion_asset_anomaly.py b/fusion_accounting_assets/tests/test_fusion_asset_anomaly.py new file mode 100644 index 00000000..b4d928eb --- /dev/null +++ b/fusion_accounting_assets/tests/test_fusion_asset_anomaly.py @@ -0,0 +1,49 @@ +from datetime import date + +from odoo.tests.common import TransactionCase +from odoo.tests import tagged + + +@tagged('post_install', '-at_install') +class TestFusionAssetAnomaly(TransactionCase): + + def setUp(self): + super().setUp() + self.asset = self.env['fusion.asset'].create({ + 'name': 'Watched Asset', + 'cost': 5000, + 'acquisition_date': date(2026, 1, 1), + }) + + def _make_anomaly(self, **kw): + vals = { + 'asset_id': self.asset.id, + 'anomaly_type': 'behind_schedule', + 'severity': 'medium', + 'expected': 1000.0, + 'actual': 700.0, + 'variance_pct': -30.0, + } + vals.update(kw) + return self.env['fusion.asset.anomaly'].create(vals) + + def test_create_defaults_state_new(self): + a = self._make_anomaly() + self.assertEqual(a.state, 'new') + self.assertTrue(a.detected_at) + self.assertEqual(a.company_id, self.asset.company_id) + + def test_acknowledge_transitions(self): + a = self._make_anomaly() + a.action_acknowledge() + self.assertEqual(a.state, 'acknowledged') + + def test_dismiss_transitions(self): + a = self._make_anomaly() + a.action_dismiss() + self.assertEqual(a.state, 'dismissed') + + def test_resolve_transitions(self): + a = self._make_anomaly(anomaly_type='low_utilization', severity='high') + a.action_resolve() + self.assertEqual(a.state, 'resolved')